lattice_snippet/active.rs
1//! Active-snippet state machine. The host instantiates one
2//! [`ActiveSnippet`] when a snippet expands; while it's the
3//! "active" snippet, `<Tab>` / `<S-Tab>` step through
4//! placeholders and edits to one tabstop ripple to the others
5//! that share its index.
6//!
7//! Buffer-position tracking is the host's job. This module
8//! owns the *intent* (which placeholder is focused, what the
9//! mirror groups are, exit semantics); the host translates
10//! that into rope edits + cursor moves.
11//!
12//! Lifecycle:
13//!
14//! 1. Host renders a `RenderedSnippet`, splices its `text`
15//! into the buffer, and constructs an `ActiveSnippet`
16//! keyed by the snippet's start position + the
17//! `RenderedSnippet`'s tabstops.
18//! 2. Host moves the cursor to the first tabstop's range.
19//! If it has a default, the host can mark the range
20//! selected (vim Visual) so a typed character replaces
21//! the default.
22//! 3. `<Tab>` -> [`ActiveSnippet::next`] returns the next
23//! tabstop group; host moves cursor there.
24//! 4. Edit inside one mirror -> host updates the rope, asks
25//! [`ActiveSnippet::shift_ranges_after`] to ripple the change to the
26//! other mirrors of the same tabstop group, then re-renders.
27//! 5. Reaching `$0` exits; the host drops the `ActiveSnippet`
28//! and lets normal Insert-mode resume.
29
30use std::ops::Range;
31
32use crate::render::{RenderedSnippet, TabstopRange};
33
34/// One group of mirror ranges sharing the same tabstop index.
35/// `<Tab>` cycles between groups; edits within one group
36/// ripple to every range in the same group.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct TabstopGroup {
39 pub index: u32,
40 /// Live byte ranges in the buffer (host re-bases these
41 /// after every edit). Initially copied from the
42 /// `RenderedSnippet`'s tabstop ranges + an offset that
43 /// places them at the snippet's insertion site.
44 pub ranges: Vec<Range<usize>>,
45 pub has_default: bool,
46 pub is_choice: bool,
47}
48
49/// State of an in-flight snippet expansion.
50#[derive(Debug, Clone)]
51pub struct ActiveSnippet {
52 /// Where in the buffer this snippet began. The host uses
53 /// this as the reference origin for offset arithmetic on
54 /// the tabstop ranges.
55 pub origin_offset: usize,
56 /// Tabstop groups in display order (`$1`, `$2`, ..., `$0`
57 /// last when present). Cycle order matches the LSP /
58 /// TextMate convention: 1, 2, 3, ..., then 0 to exit.
59 pub groups: Vec<TabstopGroup>,
60 /// Index into `groups` of the currently-focused group.
61 /// `groups.len()` means "no focus yet" (just expanded;
62 /// next `<Tab>` focuses index 0).
63 pub current: usize,
64}
65
66impl ActiveSnippet {
67 /// Build from a freshly-rendered snippet placed at the
68 /// given byte offset in the buffer. Groups are built in
69 /// `$1 -> $2 -> ... -> $0` order so navigation matches
70 /// the LSP convention.
71 pub fn from_render(rendered: &RenderedSnippet, origin_offset: usize) -> Self {
72 // Group by index. BTreeMap keeps `$0` at the start of
73 // the iteration, so we reorder: positive indices first
74 // (in numeric order), `$0` last.
75 let groups_map = rendered.grouped_by_index();
76 let mut numbered: Vec<(u32, &Vec<&TabstopRange>)> = groups_map
77 .iter()
78 .filter(|(idx, _)| **idx != 0)
79 .map(|(idx, ranges)| (*idx, ranges))
80 .collect();
81 numbered.sort_by_key(|(idx, _)| *idx);
82 let mut groups: Vec<TabstopGroup> = numbered
83 .into_iter()
84 .map(|(idx, ranges)| TabstopGroup {
85 index: idx,
86 ranges: ranges
87 .iter()
88 .map(|r| (origin_offset + r.range.start)..(origin_offset + r.range.end))
89 .collect(),
90 has_default: ranges.iter().any(|r| r.has_default),
91 is_choice: ranges.iter().any(|r| r.is_choice),
92 })
93 .collect();
94 // Append `$0` last when it exists -- the snippet's
95 // exit position.
96 if let Some(zero_ranges) = groups_map.get(&0) {
97 groups.push(TabstopGroup {
98 index: 0,
99 ranges: zero_ranges
100 .iter()
101 .map(|r| (origin_offset + r.range.start)..(origin_offset + r.range.end))
102 .collect(),
103 has_default: false,
104 is_choice: false,
105 });
106 }
107 Self {
108 origin_offset,
109 groups,
110 current: usize::MAX, // sentinel for "no focus yet"
111 }
112 }
113
114 /// True when the snippet has at least one tabstop group
115 /// to navigate. False when the body was pure literal text
116 /// (in which case the host doesn't need an `ActiveSnippet`
117 /// at all).
118 pub fn has_tabstops(&self) -> bool {
119 !self.groups.is_empty()
120 }
121
122 /// Focus the first tabstop group. Called by the host
123 /// right after inserting the rendered text. Returns the
124 /// group, or `None` when the snippet has no tabstops.
125 pub fn focus_first(&mut self) -> Option<&TabstopGroup> {
126 if self.groups.is_empty() {
127 return None;
128 }
129 self.current = 0;
130 self.groups.first()
131 }
132
133 /// Step to the next tabstop group. Returns `None` when
134 /// the snippet has been exited (`$0` consumed or no
135 /// further groups). The host drops the `ActiveSnippet`
136 /// when this returns `None`.
137 pub fn next(&mut self) -> Option<&TabstopGroup> {
138 // Special case: no focus yet -> focus first.
139 if self.current == usize::MAX {
140 return self.focus_first();
141 }
142 // Are we at `$0` already? Then exiting.
143 if let Some(g) = self.groups.get(self.current)
144 && g.index == 0
145 {
146 return None;
147 }
148 let next = self.current + 1;
149 if next >= self.groups.len() {
150 return None;
151 }
152 self.current = next;
153 self.groups.get(self.current)
154 }
155
156 /// Step backward. Returns `None` when already at the
157 /// first group.
158 pub fn prev(&mut self) -> Option<&TabstopGroup> {
159 if self.current == usize::MAX || self.current == 0 {
160 return None;
161 }
162 self.current -= 1;
163 self.groups.get(self.current)
164 }
165
166 /// Currently-focused group, when one is.
167 pub fn current_group(&self) -> Option<&TabstopGroup> {
168 if self.current == usize::MAX {
169 return None;
170 }
171 self.groups.get(self.current)
172 }
173
174 /// Currently-focused group's index (`$N`).
175 pub fn current_index(&self) -> Option<u32> {
176 self.current_group().map(|g| g.index)
177 }
178
179 /// Shift downstream ranges after a buffer edit at byte
180 /// offset `at`. The host calls this AFTER manually
181 /// expanding the active tabstop's range to include the
182 /// edit; this method only handles ranges *strictly past*
183 /// the edit point. `delta` is signed: positive on insert,
184 /// negative on delete.
185 ///
186 /// The split (host expands active tabstop, this expands
187 /// downstream) keeps the contract small. Snippet engines
188 /// that ripple mirror edits typically own the per-mirror
189 /// expansion logic; v1 does that host-side too. A future
190 /// `ripple_edit` helper can encapsulate both halves once
191 /// the host's edit-pipeline integration lands.
192 pub fn shift_ranges_after(&mut self, at: usize, delta: isize) {
193 for g in &mut self.groups {
194 for r in &mut g.ranges {
195 if r.start > at {
196 r.start = (r.start as isize + delta).max(0) as usize;
197 r.end = (r.end as isize + delta).max(r.start as isize) as usize;
198 } else if r.end > at {
199 // Range contains the edit point; only the
200 // end shifts.
201 r.end = (r.end as isize + delta).max(r.start as isize) as usize;
202 }
203 }
204 }
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::parse;
212 use crate::render::render;
213 use crate::variables::VariableContext;
214
215 fn snippet(s: &str) -> RenderedSnippet {
216 let body = parse::parse(s).unwrap();
217 render(&body, &VariableContext::default())
218 }
219
220 #[test]
221 fn no_tabstops_yields_empty_groups() {
222 let r = snippet("hello world");
223 let a = ActiveSnippet::from_render(&r, 100);
224 assert!(!a.has_tabstops());
225 }
226
227 #[test]
228 fn groups_ordered_by_index_with_zero_last() {
229 let r = snippet("for ${1:i} in ${2:iter} { $0 }");
230 let a = ActiveSnippet::from_render(&r, 0);
231 let indices: Vec<u32> = a.groups.iter().map(|g| g.index).collect();
232 assert_eq!(indices, vec![1, 2, 0]);
233 }
234
235 #[test]
236 fn focus_first_yields_index_one() {
237 let r = snippet("for ${1:i} in ${2:iter}");
238 let mut a = ActiveSnippet::from_render(&r, 0);
239 let g = a.focus_first().expect("first focused");
240 assert_eq!(g.index, 1);
241 }
242
243 #[test]
244 fn next_walks_through_groups_and_returns_none_at_exit() {
245 let r = snippet("for ${1:i} in ${2:iter} { $0 }");
246 let mut a = ActiveSnippet::from_render(&r, 0);
247 assert_eq!(a.next().map(|g| g.index), Some(1));
248 assert_eq!(a.next().map(|g| g.index), Some(2));
249 assert_eq!(a.next().map(|g| g.index), Some(0));
250 assert_eq!(a.next(), None);
251 }
252
253 #[test]
254 fn next_without_zero_terminates_after_last_numbered_group() {
255 let r = snippet("${1:a} ${2:b}");
256 let mut a = ActiveSnippet::from_render(&r, 0);
257 a.next();
258 a.next();
259 // Already at last numbered group; next returns None.
260 assert!(a.next().is_none());
261 }
262
263 #[test]
264 fn prev_walks_back_through_groups() {
265 let r = snippet("${1:a} ${2:b} ${3:c}");
266 let mut a = ActiveSnippet::from_render(&r, 0);
267 a.next();
268 a.next();
269 a.next();
270 assert_eq!(a.current_index(), Some(3));
271 assert_eq!(a.prev().map(|g| g.index), Some(2));
272 assert_eq!(a.prev().map(|g| g.index), Some(1));
273 assert_eq!(a.prev(), None);
274 }
275
276 #[test]
277 fn mirror_groups_share_one_tabstop_group() {
278 let r = snippet("$1 + $1 = ${1:two-x}");
279 let a = ActiveSnippet::from_render(&r, 0);
280 // One group of index 1 with three ranges.
281 assert_eq!(a.groups.len(), 1);
282 assert_eq!(a.groups[0].ranges.len(), 3);
283 }
284
285 #[test]
286 fn from_render_offsets_ranges_by_origin() {
287 let r = snippet("foo$1bar");
288 let a = ActiveSnippet::from_render(&r, 100);
289 // The tabstop range was 3..3 in the rendered text;
290 // origin 100 -> 103..103 in the buffer.
291 assert_eq!(a.groups[0].ranges[0], 103..103);
292 }
293
294 #[test]
295 fn shift_ranges_after_advances_downstream_tabstops() {
296 let r = snippet("$1 ${2:foo}");
297 // Rendered text is " foo": $1 -> 0..0, then literal
298 // " " (1 byte), then $2 -> 1..4 covering "foo".
299 let mut a = ActiveSnippet::from_render(&r, 0);
300 let group_two_before = a
301 .groups
302 .iter()
303 .find(|g| g.index == 2)
304 .expect("group 2")
305 .ranges[0]
306 .clone();
307 assert_eq!(group_two_before, 1..4);
308 // Simulate the host inserting 3 chars into $1's
309 // range (which the host expanded itself before
310 // calling shift). Downstream $2 shifts by +3.
311 a.shift_ranges_after(0, 3);
312 let group_two_after = a
313 .groups
314 .iter()
315 .find(|g| g.index == 2)
316 .expect("group 2")
317 .ranges[0]
318 .clone();
319 assert_eq!(group_two_after, 4..7);
320 }
321}