Skip to main content

scrive_core/intel/
snippet.rs

1//! The snippet parser: the LSP placeholder subset `spec.rs` emits, parsed
2//! into a body with defaults expanded plus an ordered list of tab stops. Pure
3//! text — no document, no transaction; the session state machine that drives the
4//! decoration store consumes this. Items stay inert data until accept time, so
5//! parsing happens then, never earlier.
6//!
7//! Grammar: `$0` / `${0}` = the final stop (≤1; implicit at end-of-body when
8//! absent); `$N` = an empty placeholder; `${N:default}` = a placeholder with
9//! literal default text (nesting a placeholder inside a default is an error);
10//! `${N|a,b,c|}` = a choice (the first is the inserted default); `\$ \} \\`
11//! escape their characters. A duplicate index (mirrored placeholders) is an
12//! error — no `spec.rs` snippet generates one.
13
14use core::ops::Range;
15
16use crate::decorations::{DecorationId, DecorationKind, DecorationStore, Stickiness};
17
18/// A parsed snippet: the body with every placeholder's default expanded, plus
19/// the tab stops to visit.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct Snippet {
22    /// The body with placeholders replaced by their default text. LF-normal
23    /// already (the caller's expander normalizes EOL and adapts `\t` indent at
24    /// insert time); this parser leaves `\t` literal.
25    pub text: String,
26    /// Stops in visit order: ascending placeholder index, the final stop last.
27    pub stops: Vec<TabStop>,
28}
29
30/// One tab stop within a [`Snippet`].
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct TabStop {
33    /// 1-based placeholder index; [`u16::MAX`] marks the final stop (`$0`).
34    pub index: u16,
35    /// Byte range within [`Snippet::text`] covering the inserted default (empty
36    /// for `$N` / `$0`).
37    pub range: Range<u32>,
38    /// Non-empty for `${N|a,b|}`; `choices[0]` is the inserted default.
39    pub choices: Vec<String>,
40}
41
42impl TabStop {
43    /// Whether this is the final stop (`$0`), where the caret lands and the
44    /// session ends.
45    #[must_use]
46    pub fn is_final(&self) -> bool {
47        self.index == u16::MAX
48    }
49}
50
51/// Why a snippet body failed to parse. Real `spec.rs` snippets never trigger
52/// these (its own suite pins validity); the insertion path treats a failure as
53/// "insert the raw body verbatim" behind a `debug_assert`, so this stays a
54/// clean `Result` here.
55#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
56pub enum SnippetError {
57    /// The same placeholder index appeared twice (mirrored placeholders are
58    /// unsupported). `0` reports the final stop (`$0`).
59    #[error("duplicate placeholder index {0}")]
60    DuplicateIndex(u16),
61    /// A placeholder appeared inside a default (defaults are literal text).
62    #[error("nested placeholder inside a default")]
63    Nesting,
64    /// A malformed placeholder token.
65    #[error("malformed placeholder: {0}")]
66    Malformed(&'static str),
67}
68
69impl Snippet {
70    /// Adapt a parsed snippet for insertion at a line whose leading whitespace is
71    /// `indent` (captured pre-edit), expanding tabs to `indent_size` spaces — the
72    /// insertion transform (pure text; no transaction machinery):
73    /// 1. EOL-normalize (`\r\n` / `\r` → `\n`);
74    /// 2. every `\n` → `\n` + `indent`, so continuation lines align with the
75    ///    insertion column (spec.rs bodies are written flush-left);
76    /// 3. every `\t` → `indent_size` spaces (one indent unit);
77    /// 4. stop ranges remap through the same rewrite.
78    ///
79    /// The caret lands at the final stop; the returned stop ranges are relative
80    /// to the returned `text`, which the caller inserts as one transaction.
81    #[must_use]
82    pub fn for_insertion(&self, indent: &str, indent_size: usize) -> Snippet {
83        use std::collections::BTreeMap;
84        let tab = " ".repeat(indent_size);
85        let mut out = String::with_capacity(self.text.len());
86        // input byte offset (char boundary) → output byte offset.
87        let mut map: BTreeMap<u32, u32> = BTreeMap::new();
88        let mut it = self.text.char_indices().peekable();
89        while let Some((byte_i, c)) = it.next() {
90            map.insert(byte_i as u32, out.len() as u32);
91            match c {
92                '\r' => {
93                    if it.peek().map(|&(_, c)| c) == Some('\n') {
94                        it.next(); // swallow the LF of a CRLF
95                    }
96                    out.push('\n');
97                    out.push_str(indent);
98                }
99                '\n' => {
100                    out.push('\n');
101                    out.push_str(indent);
102                }
103                '\t' => out.push_str(&tab),
104                _ => out.push(c),
105            }
106        }
107        map.insert(self.text.len() as u32, out.len() as u32);
108
109        let remap = |off: u32| map.get(&off).copied().unwrap_or(off);
110        let stops = self
111            .stops
112            .iter()
113            .map(|s| TabStop {
114                index: s.index,
115                range: remap(s.range.start)..remap(s.range.end),
116                choices: s.choices.clone(),
117            })
118            .collect();
119        Snippet { text: out, stops }
120    }
121
122    /// Parse an LSP placeholder body (see the module grammar).
123    ///
124    /// # Errors
125    /// [`SnippetError`] on a duplicate index, a nested placeholder in a default,
126    /// or a malformed `${…}` token.
127    pub fn parse(body: &str) -> Result<Self, SnippetError> {
128        let chars: Vec<char> = body.chars().collect();
129        let mut text = String::new();
130        let mut stops: Vec<TabStop> = Vec::new();
131        let mut seen: Vec<u16> = Vec::new();
132        let mut i = 0;
133
134        while i < chars.len() {
135            match chars[i] {
136                // Escape: \$ \} \\ → the bare character.
137                '\\' if matches!(chars.get(i + 1), Some('$' | '}' | '\\')) => {
138                    text.push(chars[i + 1]);
139                    i += 2;
140                }
141                '$' if matches!(chars.get(i + 1), Some('{')) => {
142                    i += 2; // past "${"
143                    let index = read_index(&chars, &mut i)?;
144                    match chars.get(i) {
145                        Some('}') => {
146                            i += 1;
147                            let pos = text.len() as u32;
148                            push_stop(&mut stops, &mut seen, index, pos..pos, Vec::new())?;
149                        }
150                        Some(':') => {
151                            i += 1;
152                            let start = text.len() as u32;
153                            read_default(&chars, &mut i, &mut text)?;
154                            let end = text.len() as u32;
155                            push_stop(&mut stops, &mut seen, index, start..end, Vec::new())?;
156                        }
157                        Some('|') => {
158                            i += 1;
159                            let choices = read_choices(&chars, &mut i)?;
160                            let start = text.len() as u32;
161                            text.push_str(choices.first().map_or("", String::as_str));
162                            let end = text.len() as u32;
163                            push_stop(&mut stops, &mut seen, index, start..end, choices)?;
164                        }
165                        _ => return Err(SnippetError::Malformed("expected } : or | after ${N")),
166                    }
167                }
168                '$' if matches!(chars.get(i + 1), Some(c) if c.is_ascii_digit()) => {
169                    i += 1; // past '$'
170                    let index = read_index(&chars, &mut i)?;
171                    let pos = text.len() as u32;
172                    push_stop(&mut stops, &mut seen, index, pos..pos, Vec::new())?;
173                }
174                // A bare `$` (not a placeholder) is literal.
175                c => {
176                    text.push(c);
177                    i += 1;
178                }
179            }
180        }
181
182        // An absent final stop is implicit at end-of-body.
183        if !seen.contains(&u16::MAX) {
184            let pos = text.len() as u32;
185            stops.push(TabStop { index: u16::MAX, range: pos..pos, choices: Vec::new() });
186        }
187        // Visit order: ascending index; u16::MAX (final) sorts last.
188        stops.sort_by_key(|s| s.index);
189        Ok(Snippet { text, stops })
190    }
191}
192
193/// Read a run of ASCII digits into a placeholder index (`0` → the final stop's
194/// `u16::MAX`). Advances `i` past the digits.
195fn read_index(chars: &[char], i: &mut usize) -> Result<u16, SnippetError> {
196    let start = *i;
197    while matches!(chars.get(*i), Some(c) if c.is_ascii_digit()) {
198        *i += 1;
199    }
200    if *i == start {
201        return Err(SnippetError::Malformed("placeholder without an index"));
202    }
203    let n: u32 = chars[start..*i]
204        .iter()
205        .collect::<String>()
206        .parse()
207        .map_err(|_| SnippetError::Malformed("placeholder index out of range"))?;
208    if n == 0 {
209        Ok(u16::MAX)
210    } else {
211        u16::try_from(n).map_err(|_| SnippetError::Malformed("placeholder index out of range"))
212    }
213}
214
215/// Read `${N:` default text into `text` until the unescaped closing `}`. A
216/// placeholder (`$`) inside is nesting (an error).
217fn read_default(chars: &[char], i: &mut usize, text: &mut String) -> Result<(), SnippetError> {
218    loop {
219        match chars.get(*i) {
220            None => return Err(SnippetError::Malformed("unterminated ${N:default}")),
221            Some('\\') if matches!(chars.get(*i + 1), Some('$' | '}' | '\\')) => {
222                text.push(chars[*i + 1]);
223                *i += 2;
224            }
225            Some('}') => {
226                *i += 1;
227                return Ok(());
228            }
229            Some('$') => return Err(SnippetError::Nesting),
230            Some(&c) => {
231                text.push(c);
232                *i += 1;
233            }
234        }
235    }
236}
237
238/// Read `${N|a,b,c|}` choices (comma-separated, `|}`-terminated). Advances `i`
239/// past the closing `|}`.
240fn read_choices(chars: &[char], i: &mut usize) -> Result<Vec<String>, SnippetError> {
241    let mut choices = Vec::new();
242    let mut cur = String::new();
243    loop {
244        match chars.get(*i) {
245            None => return Err(SnippetError::Malformed("unterminated ${N|choices|}")),
246            Some('\\') if matches!(chars.get(*i + 1), Some('$' | '}' | '\\')) => {
247                cur.push(chars[*i + 1]);
248                *i += 2;
249            }
250            Some(',') => {
251                choices.push(core::mem::take(&mut cur));
252                *i += 1;
253            }
254            Some('|') => {
255                *i += 1;
256                if chars.get(*i) != Some(&'}') {
257                    return Err(SnippetError::Malformed("choice not closed with |}"));
258                }
259                *i += 1;
260                choices.push(cur);
261                return Ok(choices);
262            }
263            Some(&c) => {
264                cur.push(c);
265                *i += 1;
266            }
267        }
268    }
269}
270
271/// What a Tab / Shift+Tab did to a live session.
272#[derive(Clone, Debug, PartialEq, Eq)]
273pub enum TabOutcome {
274    /// Moved to a stop; the caller selects this range.
275    Move(Range<u32>),
276    /// Reached the final stop — the session has ended (ranges unregistered);
277    /// the caller collapses the caret at this offset.
278    Finish(u32),
279    /// No-op (Shift+Tab at the first stop).
280    Stay,
281}
282
283/// What a caret move (click) did to a live session.
284#[derive(Clone, Debug, PartialEq, Eq)]
285pub enum CaretOutcome {
286    /// The caret entered a different stop; the caller selects this range.
287    Move(Range<u32>),
288    /// The caret stayed within the active stop.
289    Stay,
290    /// The caret left every stop; the caller cancels the session.
291    Escaped,
292}
293
294/// A live snippet session: one tracked range per stop, the active stop
295/// `AlwaysGrows` and the rest `NeverGrows`. It never touches the document or
296/// history — the caller inserts the snippet text (one sealed transaction),
297/// selects the range this reports, and the session only registers / swaps /
298/// unregisters ranges. Cancellation is never a transaction: text stays, ranges
299/// unregister, nothing enters history.
300pub struct SnippetSession {
301    /// Decoration handles for every stop in visit order; the final stop is last.
302    stops: Vec<DecorationId>,
303    /// Index into `stops` of the active stop — always a non-final stop
304    /// (`0..stops.len() - 1`).
305    active: usize,
306}
307
308impl SnippetSession {
309    /// Start a session from a just-inserted snippet whose stop ranges are offset
310    /// by `base` (the insert position). Registers one `SnippetStop` per stop
311    /// (the first `AlwaysGrows`, the rest `NeverGrows`) and returns the session
312    /// plus the first stop's content for the caller to select. `None` — no
313    /// session — when the snippet has no stop besides the final.
314    pub fn start(snippet: &Snippet, base: u32, store: &mut DecorationStore) -> Option<(Self, Range<u32>)> {
315        if snippet.stops.len() < 2 {
316            return None; // only the (implicit or explicit) final stop
317        }
318        let stops: Vec<DecorationId> = snippet
319            .stops
320            .iter()
321            .enumerate()
322            .map(|(i, s)| {
323                let range = (base + s.range.start)..(base + s.range.end);
324                let stickiness = if i == 0 { Stickiness::AlwaysGrows } else { Stickiness::NeverGrows };
325                store.add_decoration(range, DecorationKind::SnippetStop { index: i as u8 }, stickiness)
326            })
327            .collect();
328        let session = Self { stops, active: 0 };
329        let first = store.decoration_range(session.stops[0]).expect("just registered");
330        Some((session, first))
331    }
332
333    /// The ordinal (visit-order) index of the active stop.
334    #[must_use]
335    pub fn active_index(&self) -> usize {
336        self.active
337    }
338
339    /// The active stop's current content range (post-edit), or `None` if it was
340    /// dropped.
341    #[must_use]
342    pub fn active_range(&self, store: &DecorationStore) -> Option<Range<u32>> {
343        store.decoration_range(self.stops[self.active])
344    }
345
346    /// Tab (`forward`) / Shift+Tab (`!forward`) between stops. Swaps stickiness
347    /// on a move; Tab past the last non-final stop ends the session at the final
348    /// stop ([`TabOutcome::Finish`], ranges already unregistered).
349    pub fn tab(&mut self, forward: bool, store: &mut DecorationStore) -> TabOutcome {
350        let last = self.stops.len() - 1; // the final stop's index in `stops`
351        if forward {
352            let next = self.active + 1;
353            if next == last {
354                let pos = store.decoration_range(self.stops[last]).map_or(0, |r| r.start);
355                self.cancel(store);
356                return TabOutcome::Finish(pos);
357            }
358            self.activate(next, store);
359            TabOutcome::Move(self.active_range(store).expect("active stop live"))
360        } else if self.active == 0 {
361            TabOutcome::Stay
362        } else {
363            self.activate(self.active - 1, store);
364            TabOutcome::Move(self.active_range(store).expect("active stop live"))
365        }
366    }
367
368    /// A caret landed at `offset` (a click): re-activate a different stop it lands
369    /// in, stay if it's the active stop, or report that it left every stop (the
370    /// caller then cancels).
371    pub fn on_caret(&mut self, offset: u32, store: &mut DecorationStore) -> CaretOutcome {
372        let last = self.stops.len() - 1;
373        for i in 0..last {
374            if let Some(r) = store.decoration_range(self.stops[i]) {
375                if offset >= r.start && offset <= r.end {
376                    if i == self.active {
377                        return CaretOutcome::Stay;
378                    }
379                    self.activate(i, store);
380                    return CaretOutcome::Move(self.active_range(store).expect("active stop live"));
381                }
382            }
383        }
384        CaretOutcome::Escaped
385    }
386
387    /// Whether an edit spanning `range` lands wholly outside every stop (→ the
388    /// caller cancels). An edit touching any stop keeps the session.
389    #[must_use]
390    pub fn edit_escapes(&self, range: &Range<u32>, store: &DecorationStore) -> bool {
391        !self.stops.iter().any(|&id| {
392            store.decoration_range(id).is_some_and(|r| range.start <= r.end && r.start <= range.end)
393        })
394    }
395
396    /// Unregister every stop range. Cancellation is never a transaction.
397    pub fn cancel(&mut self, store: &mut DecorationStore) {
398        for id in self.stops.drain(..) {
399            store.take_decoration(id);
400        }
401    }
402
403    /// Swap the active stop to ordinal `i`, moving `AlwaysGrows` with it.
404    fn activate(&mut self, i: usize, store: &mut DecorationStore) {
405        store.set_decoration_stickiness(self.stops[self.active], Stickiness::NeverGrows);
406        store.set_decoration_stickiness(self.stops[i], Stickiness::AlwaysGrows);
407        self.active = i;
408    }
409}
410
411/// Record a stop, rejecting a duplicate index (mirrored placeholders).
412fn push_stop(
413    stops: &mut Vec<TabStop>,
414    seen: &mut Vec<u16>,
415    index: u16,
416    range: Range<u32>,
417    choices: Vec<String>,
418) -> Result<(), SnippetError> {
419    if seen.contains(&index) {
420        return Err(SnippetError::DuplicateIndex(if index == u16::MAX { 0 } else { index }));
421    }
422    seen.push(index);
423    stops.push(TabStop { index, range, choices });
424    Ok(())
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    fn parse(body: &str) -> Snippet {
432        Snippet::parse(body).expect("valid snippet")
433    }
434
435    fn stop(index: u16, range: Range<u32>, choices: &[&str]) -> TabStop {
436        TabStop { index, range, choices: choices.iter().map(|s| s.to_string()).collect() }
437    }
438
439    #[test]
440    fn plain_body_has_only_an_implicit_final_stop_at_the_end() {
441        let s = parse("hello world");
442        assert_eq!(s.text, "hello world");
443        assert_eq!(s.stops, vec![stop(u16::MAX, 11..11, &[])]);
444    }
445
446    #[test]
447    fn default_placeholder_expands_and_ranges_cover_it() {
448        let s = parse("${1:name}");
449        assert_eq!(s.text, "name");
450        assert_eq!(s.stops, vec![stop(1, 0..4, &[]), stop(u16::MAX, 4..4, &[])]);
451    }
452
453    #[test]
454    fn empty_and_multiple_placeholders_track_positions() {
455        let s = parse("$1 and $2");
456        assert_eq!(s.text, " and ");
457        assert_eq!(s.stops, vec![stop(1, 0..0, &[]), stop(2, 5..5, &[]), stop(u16::MAX, 5..5, &[])]);
458    }
459
460    #[test]
461    fn explicit_final_stop_is_placed_and_not_duplicated_at_end() {
462        let s = parse("foo($0)bar");
463        assert_eq!(s.text, "foo()bar");
464        assert_eq!(s.stops, vec![stop(u16::MAX, 4..4, &[])]);
465    }
466
467    #[test]
468    fn choice_inserts_the_first_and_keeps_all() {
469        let s = parse("${1|hs_can,ms_can,kline|}");
470        assert_eq!(s.text, "hs_can");
471        assert_eq!(s.stops, vec![stop(1, 0..6, &["hs_can", "ms_can", "kline"]), stop(u16::MAX, 6..6, &[])]);
472    }
473
474    #[test]
475    fn escapes_are_literal_and_do_not_start_placeholders() {
476        let s = parse(r"\${1\} and \\");
477        assert_eq!(s.text, r"${1} and \");
478        assert_eq!(s.stops, vec![stop(u16::MAX, 10..10, &[])]);
479    }
480
481    #[test]
482    fn stops_sort_by_index_regardless_of_textual_order() {
483        let s = parse("${2:b}${1:a}");
484        assert_eq!(s.text, "ba");
485        // index 1 (range 1..2, the second textually) visits before index 2.
486        assert_eq!(
487            s.stops,
488            vec![stop(1, 1..2, &[]), stop(2, 0..1, &[]), stop(u16::MAX, 2..2, &[])]
489        );
490    }
491
492    #[test]
493    fn duplicate_index_and_nesting_and_malformed_are_errors() {
494        assert_eq!(Snippet::parse("$1 $1"), Err(SnippetError::DuplicateIndex(1)));
495        assert_eq!(Snippet::parse("$0$0"), Err(SnippetError::DuplicateIndex(0)));
496        assert_eq!(Snippet::parse("${1:${2:x}}"), Err(SnippetError::Nesting));
497        assert!(matches!(Snippet::parse("${x}"), Err(SnippetError::Malformed(_))));
498        assert!(matches!(Snippet::parse("${1:oops"), Err(SnippetError::Malformed(_))));
499    }
500
501    #[test]
502    fn for_insertion_is_identity_on_a_flat_single_line() {
503        let s = parse("${1:name}").for_insertion("    ", 4);
504        assert_eq!(s.text, "name");
505        assert_eq!(s.stops, vec![stop(1, 0..4, &[]), stop(u16::MAX, 4..4, &[])]);
506    }
507
508    #[test]
509    fn for_insertion_reindents_continuation_lines_and_expands_tabs() {
510        // A two-level body: line 1 flush, line 2 one tab in, line 3 flush.
511        let s = parse("fn ${1:id} {\n\t$0\n}").for_insertion("    ", 4);
512        // \n → \n + "    " (insertion indent); the body \t → 4 spaces.
513        assert_eq!(s.text, "fn id {\n        \n    }");
514        // stop 1 = "id" at 3..5; final = the empty $0 on line 2.
515        assert_eq!(s.stops[0], stop(1, 3..5, &[]));
516        assert!(s.stops[1].is_final());
517        // $0 sits after "fn id {\n" (8) + "    " indent (4) + "\t"→4 = 16.
518        assert_eq!(s.stops[1].range, 16..16);
519    }
520
521    #[test]
522    fn for_insertion_remaps_stops_across_the_rewrite() {
523        let s = parse("${1:a}\n${2:b}").for_insertion("  ", 4);
524        assert_eq!(s.text, "a\n  b");
525        assert_eq!(
526            s.stops,
527            vec![stop(1, 0..1, &[]), stop(2, 4..5, &[]), stop(u16::MAX, 5..5, &[])]
528        );
529    }
530
531    #[test]
532    fn for_insertion_normalizes_crlf() {
533        let s = parse("a\r\nb").for_insertion("", 4);
534        assert_eq!(s.text, "a\nb");
535    }
536
537    #[test]
538    fn a_bare_dollar_not_starting_a_placeholder_is_literal() {
539        // `$` followed by a non-digit / non-`{` is literal (a digit would make it
540        // a placeholder — `$5` is tab stop 5).
541        let s = parse("a $ b and c$d");
542        assert_eq!(s.text, "a $ b and c$d");
543        assert_eq!(s.stops, vec![stop(u16::MAX, 13..13, &[])]);
544    }
545
546    // --- session state machine ---
547
548    /// The SnippetStop ranges + stickiness in the store, sorted by start.
549    fn live_stops(store: &DecorationStore) -> Vec<(Range<u32>, Stickiness)> {
550        let mut v: Vec<_> = store
551            .iter()
552            .filter(|r| matches!(r.kind, DecorationKind::SnippetStop { .. }))
553            .map(|r| (r.range.clone(), r.stickiness))
554            .collect();
555        v.sort_by_key(|(r, _)| r.start);
556        v
557    }
558
559    #[test]
560    fn session_needs_a_stop_besides_the_final() {
561        let mut store = DecorationStore::new();
562        // Only a final stop → no session.
563        assert!(SnippetSession::start(&parse("$0"), 0, &mut store).is_none());
564        assert!(SnippetSession::start(&parse("plain"), 0, &mut store).is_none());
565        // One real stop → a session, first content selected.
566        let (_s, range) = SnippetSession::start(&parse("${1:x}"), 0, &mut store).expect("session");
567        assert_eq!(range, 0..1);
568    }
569
570    #[test]
571    fn start_registers_stops_with_only_the_first_active() {
572        let mut store = DecorationStore::new();
573        let (_s, first) = SnippetSession::start(&parse("${1:a}${2:b}"), 0, &mut store).expect("session");
574        assert_eq!(first, 0..1);
575        assert_eq!(
576            live_stops(&store),
577            vec![
578                (0..1, Stickiness::AlwaysGrows), // stop 1, active
579                (1..2, Stickiness::NeverGrows),  // stop 2
580                (2..2, Stickiness::NeverGrows),  // final
581            ]
582        );
583    }
584
585    #[test]
586    fn tab_moves_active_swaps_stickiness_then_finishes_at_the_final() {
587        let mut store = DecorationStore::new();
588        let (mut s, _) = SnippetSession::start(&parse("${1:a}${2:b}"), 0, &mut store).expect("session");
589        // Tab → stop 2 active; AlwaysGrows moves with it.
590        assert_eq!(s.tab(true, &mut store), TabOutcome::Move(1..2));
591        assert_eq!(
592            live_stops(&store),
593            vec![(0..1, Stickiness::NeverGrows), (1..2, Stickiness::AlwaysGrows), (2..2, Stickiness::NeverGrows)]
594        );
595        // Tab again → the next stop is the final → finish; ranges unregistered.
596        assert_eq!(s.tab(true, &mut store), TabOutcome::Finish(2));
597        assert!(live_stops(&store).is_empty());
598    }
599
600    #[test]
601    fn shift_tab_at_the_first_stop_is_a_no_op() {
602        let mut store = DecorationStore::new();
603        let (mut s, _) = SnippetSession::start(&parse("${1:a}${2:b}"), 0, &mut store).expect("session");
604        assert_eq!(s.tab(false, &mut store), TabOutcome::Stay);
605        assert_eq!(s.active_index(), 0);
606        // Forward then back returns to the first.
607        assert_eq!(s.tab(true, &mut store), TabOutcome::Move(1..2));
608        assert_eq!(s.tab(false, &mut store), TabOutcome::Move(0..1));
609        assert_eq!(s.active_index(), 0);
610    }
611
612    #[test]
613    fn on_caret_reactivates_inside_a_stop_and_escapes_outside() {
614        let mut store = DecorationStore::new();
615        let (mut s, _) = SnippetSession::start(&parse("${1:aa} ${2:bb}"), 0, &mut store).expect("session");
616        // stops: 1 @ 0..2, 2 @ 3..5, final @ 5..5. Click into stop 2.
617        assert_eq!(s.on_caret(4, &mut store), CaretOutcome::Move(3..5));
618        assert_eq!(s.active_index(), 1);
619        // Click back into stop 1.
620        assert_eq!(s.on_caret(0, &mut store), CaretOutcome::Move(0..2));
621        // Click within the active stop is a stay; far outside escapes.
622        assert_eq!(s.on_caret(1, &mut store), CaretOutcome::Stay);
623        assert_eq!(s.on_caret(99, &mut store), CaretOutcome::Escaped);
624    }
625
626    #[test]
627    fn edit_escapes_only_when_wholly_outside_every_stop() {
628        let mut store = DecorationStore::new();
629        let (s, _) = SnippetSession::start(&parse("${1:aa} ${2:bb}"), 0, &mut store).expect("session");
630        assert!(!s.edit_escapes(&(1..2), &store), "inside stop 1");
631        assert!(!s.edit_escapes(&(4..4), &store), "inside stop 2");
632        assert!(s.edit_escapes(&(10..12), &store), "past every stop");
633    }
634
635    #[test]
636    fn cancel_unregisters_all_ranges() {
637        let mut store = DecorationStore::new();
638        let (mut s, _) = SnippetSession::start(&parse("${1:a}${2:b}"), 0, &mut store).expect("session");
639        assert_eq!(live_stops(&store).len(), 3);
640        s.cancel(&mut store);
641        assert!(live_stops(&store).is_empty());
642    }
643}