Skip to main content

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}