Skip to main content

editor_core/
snippets.rs

1//! Snippet parsing + placeholder navigation primitives.
2//!
3//! This module implements a **pragmatic subset** of TextMate / VS Code snippet syntax that is
4//! sufficient for kernel features like:
5//!
6//! - LSP completion items with `insertTextFormat == 2` (snippets)
7//! - placeholder (`$1`, `${1:default}`) navigation (tab / shift-tab)
8//! - best-effort handling of choices (`${1|a,b,c|}`) and variables (`${TM_FILENAME:foo.rs}`)
9//!
10//! Goals:
11//! - Keep the kernel UI-agnostic: no rendering, no popup UI for choices.
12//! - Prefer "do something reasonable" over rejecting malformed snippet text.
13//! - Express all ranges in **character offsets** (Rust `char` indices), consistent with the rest
14//!   of `editor-core`.
15
16use std::collections::{BTreeMap, HashMap};
17
18use crate::{AnchorBias, TextAnchor, TextDelta};
19
20/// A half-open character range (start..end) within a snippet's expanded text.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub struct SnippetRange {
23    /// Inclusive start character offset.
24    pub start: usize,
25    /// Exclusive end character offset.
26    pub end: usize,
27}
28
29impl SnippetRange {
30    fn offset_by(&self, base: usize) -> Self {
31        Self {
32            start: self.start.saturating_add(base),
33            end: self.end.saturating_add(base),
34        }
35    }
36}
37
38/// A numbered tabstop within a snippet.
39///
40/// Notes:
41/// - Tabstop indices start at `1`. `$0` is the "final caret position" and is represented as
42///   [`SnippetTemplate::final_offset`], not as a tabstop.
43/// - A tabstop may have multiple ranges (mirrored placeholders).
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SnippetTabstop {
46    /// Tabstop index (1..).
47    pub index: u32,
48    /// All placeholder ranges for this tabstop, in character offsets within the expanded text.
49    pub ranges: Vec<SnippetRange>,
50}
51
52/// An expanded snippet template.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct SnippetTemplate {
55    /// Expanded text to insert into the document.
56    pub text: String,
57    /// Ordered tabstops (ascending index, excluding `$0`).
58    pub tabstops: Vec<SnippetTabstop>,
59    /// Final caret position (character offset) within `text`.
60    pub final_offset: usize,
61}
62
63#[derive(Debug, Default, Clone)]
64struct SnippetParts {
65    text: String,
66    text_char_len: usize,
67    tabstops: BTreeMap<u32, Vec<SnippetRange>>,
68    /// `Some(offset)` only when `$0` / `${0}` appears. We intentionally keep this optional so we
69    /// don't accidentally treat "default to end" as an explicit `$0` when nesting.
70    final_offset: Option<usize>,
71}
72
73impl SnippetParts {
74    fn push_char(&mut self, ch: char) {
75        self.text.push(ch);
76        self.text_char_len = self.text_char_len.saturating_add(1);
77    }
78
79    fn push_str(&mut self, s: &str) {
80        self.text.push_str(s);
81        self.text_char_len = self.text_char_len.saturating_add(s.chars().count());
82    }
83
84    fn record_tabstop_range(&mut self, idx: u32, range: SnippetRange) {
85        if idx == 0 {
86            return;
87        }
88        self.tabstops.entry(idx).or_default().push(range);
89    }
90
91    fn merge_with_offset(&mut self, other: SnippetParts, base: usize) {
92        self.push_str(&other.text);
93
94        for (idx, mut ranges) in other.tabstops {
95            for r in &mut ranges {
96                *r = r.offset_by(base);
97            }
98            self.tabstops.entry(idx).or_default().extend(ranges);
99        }
100
101        if self.final_offset.is_none()
102            && let Some(off) = other.final_offset
103        {
104            self.final_offset = Some(base.saturating_add(off));
105        }
106    }
107
108    fn finish(mut self) -> SnippetTemplate {
109        let mut tabstops: Vec<SnippetTabstop> = self
110            .tabstops
111            .into_iter()
112            .filter(|(idx, _)| *idx != 0)
113            .map(|(index, mut ranges)| {
114                ranges.sort_by_key(|r| (r.start, r.end));
115                SnippetTabstop { index, ranges }
116            })
117            .collect();
118        tabstops.sort_by_key(|t| t.index);
119
120        let final_offset = self.final_offset.unwrap_or(self.text_char_len);
121        if final_offset > self.text_char_len {
122            self.final_offset = Some(self.text_char_len);
123        }
124
125        SnippetTemplate {
126            text: self.text,
127            tabstops,
128            final_offset: final_offset.min(self.text_char_len),
129        }
130    }
131}
132
133const MAX_SNIPPET_PARSE_DEPTH: usize = 32;
134
135/// Parse a TextMate/VS Code-style snippet string into an expanded template.
136///
137/// Supported constructs (best-effort):
138/// - `$1`, `$2`, ... tabstops
139/// - `${1}`, `${1:default}`
140/// - `${1|a,b,c|}` (inserts the first option; selects as placeholder)
141/// - `$0` / `${0}` final caret position
142/// - `${VAR}` / `${VAR:default}` variables (unresolved; default is used when provided)
143///
144/// Unsupported constructs are ignored or treated as literal text where reasonable.
145pub fn parse_snippet(snippet: &str) -> SnippetTemplate {
146    let mut chars = snippet.chars().peekable();
147    let mut ctx = ParseCtx::default();
148    parse_until(&mut chars, None, 0, &mut ctx).finish()
149}
150
151fn parse_until<I>(
152    chars: &mut std::iter::Peekable<I>,
153    terminator: Option<char>,
154    depth: usize,
155    ctx: &mut ParseCtx,
156) -> SnippetParts
157where
158    I: Iterator<Item = char>,
159{
160    if depth > MAX_SNIPPET_PARSE_DEPTH {
161        // Best-effort: treat remaining content as literal text.
162        let mut parts = SnippetParts::default();
163        for ch in chars.by_ref() {
164            if terminator == Some(ch) {
165                break;
166            }
167            parts.push_char(ch);
168        }
169        return parts;
170    }
171
172    let mut parts = SnippetParts::default();
173
174    while let Some(ch) = chars.next() {
175        if terminator == Some(ch) {
176            break;
177        }
178
179        match ch {
180            '\\' => {
181                // Escape next character (if any).
182                if let Some(next) = chars.next() {
183                    parts.push_char(next);
184                }
185            }
186            '$' => match chars.peek().copied() {
187                Some('{') => {
188                    chars.next(); // consume '{'
189                    parse_braced_expression(&mut parts, chars, depth + 1, ctx);
190                }
191                Some(d) if d.is_ascii_digit() => {
192                    let idx = parse_number(chars);
193                    insert_tabstop_reference(&mut parts, ctx, idx);
194                }
195                Some(c) if c == '_' || c.is_ascii_alphabetic() => {
196                    // `$VAR` - unresolved variable; consume its name and insert nothing.
197                    parse_identifier(chars);
198                }
199                _ => parts.push_char('$'),
200            },
201            other => parts.push_char(other),
202        }
203    }
204
205    parts
206}
207
208#[derive(Debug, Default)]
209struct ParseCtx {
210    tabstop_defaults: HashMap<u32, String>,
211}
212
213fn parse_number<I>(chars: &mut std::iter::Peekable<I>) -> u32
214where
215    I: Iterator<Item = char>,
216{
217    let mut num: u32 = 0;
218    let mut saw_any = false;
219    while let Some(ch) = chars.peek().copied() {
220        if !ch.is_ascii_digit() {
221            break;
222        }
223        saw_any = true;
224        chars.next();
225        num = num
226            .saturating_mul(10)
227            .saturating_add((ch as u8 - b'0') as u32);
228    }
229    if saw_any { num } else { 0 }
230}
231
232fn parse_identifier<I>(chars: &mut std::iter::Peekable<I>) -> String
233where
234    I: Iterator<Item = char>,
235{
236    let mut out = String::new();
237    while let Some(ch) = chars.peek().copied() {
238        if ch == '_' || ch.is_ascii_alphanumeric() {
239            chars.next();
240            out.push(ch);
241        } else {
242            break;
243        }
244    }
245    out
246}
247
248fn parse_braced_expression<I>(
249    parts: &mut SnippetParts,
250    chars: &mut std::iter::Peekable<I>,
251    depth: usize,
252    ctx: &mut ParseCtx,
253) where
254    I: Iterator<Item = char>,
255{
256    // `${...}` form: either a numeric tabstop (`${1:foo}`) or a variable (`${TM_FILENAME:foo.rs}`).
257    let ident_first = chars.peek().copied();
258    let is_number = matches!(ident_first, Some(c) if c.is_ascii_digit());
259
260    if is_number {
261        let idx = parse_number(chars);
262        match chars.peek().copied() {
263            Some('}') => {
264                chars.next();
265                insert_tabstop_reference(parts, ctx, idx);
266            }
267            Some(':') => {
268                chars.next();
269                parse_tabstop_default(parts, chars, depth, idx, ctx);
270            }
271            Some('|') => {
272                chars.next();
273                parse_tabstop_choice(parts, chars, idx, ctx);
274            }
275            _ => {
276                // Unknown / malformed; consume until matching `}`.
277                consume_until_closing_brace(chars);
278            }
279        }
280        return;
281    }
282
283    let name = parse_identifier(chars);
284    match chars.peek().copied() {
285        Some('}') => {
286            chars.next();
287            // Unresolved variable: insert nothing.
288        }
289        Some(':') => {
290            chars.next();
291            let start = parts.text_char_len;
292            let inner = parse_until(chars, Some('}'), depth, ctx);
293            parts.merge_with_offset(inner, start);
294        }
295        _ => {
296            // Unhandled variable transform `${VAR/...}` or malformed: consume.
297            consume_until_closing_brace(chars);
298        }
299    }
300
301    let _ = name; // keep `name` for potential future variable resolution hooks.
302}
303
304fn parse_tabstop_default<I>(
305    parts: &mut SnippetParts,
306    chars: &mut std::iter::Peekable<I>,
307    depth: usize,
308    idx: u32,
309    ctx: &mut ParseCtx,
310) where
311    I: Iterator<Item = char>,
312{
313    // `${1:default}`: insert default (snippet-expanded) and mark it as a placeholder range.
314    // `$0` / `${0:...}`: we treat as final caret only (no textual output), consistent with the
315    // previous downgrade behavior.
316    let start = parts.text_char_len;
317    let inner = parse_until(chars, Some('}'), depth, ctx);
318
319    if idx == 0 {
320        // Final cursor position, no output.
321        if parts.final_offset.is_none() {
322            parts.final_offset = Some(start);
323        }
324        return;
325    }
326
327    let placeholder_start = parts.text_char_len;
328    if let std::collections::hash_map::Entry::Vacant(e) = ctx.tabstop_defaults.entry(idx) {
329        e.insert(inner.text.clone());
330    }
331    parts.merge_with_offset(inner, placeholder_start);
332    let placeholder_end = parts.text_char_len;
333    parts.record_tabstop_range(
334        idx,
335        SnippetRange {
336            start: placeholder_start,
337            end: placeholder_end,
338        },
339    );
340}
341
342fn parse_tabstop_choice<I>(
343    parts: &mut SnippetParts,
344    chars: &mut std::iter::Peekable<I>,
345    idx: u32,
346    ctx: &mut ParseCtx,
347) where
348    I: Iterator<Item = char>,
349{
350    // `${1|a,b,c|}`: pick the first option as inserted text.
351    let mut options: Vec<String> = Vec::new();
352    let mut current = String::new();
353
354    while let Some(ch) = chars.next() {
355        match ch {
356            '\\' => {
357                if let Some(next) = chars.next() {
358                    current.push(next);
359                }
360            }
361            ',' => {
362                options.push(current);
363                current = String::new();
364            }
365            '|' => {
366                if chars.peek().copied() == Some('}') {
367                    chars.next(); // consume '}'
368                    options.push(current);
369                    break;
370                }
371                current.push('|');
372            }
373            other => current.push(other),
374        }
375    }
376
377    if idx == 0 {
378        // `$0` is not a placeholder; don't insert choice text.
379        if parts.final_offset.is_none() {
380            parts.final_offset = Some(parts.text_char_len);
381        }
382        return;
383    }
384
385    let insert_text = options.first().map(String::as_str).unwrap_or("");
386    ctx.tabstop_defaults
387        .entry(idx)
388        .or_insert_with(|| insert_text.to_string());
389    let start = parts.text_char_len;
390    parts.push_str(insert_text);
391    let end = parts.text_char_len;
392    parts.record_tabstop_range(idx, SnippetRange { start, end });
393}
394
395fn insert_tabstop_reference(parts: &mut SnippetParts, ctx: &mut ParseCtx, idx: u32) {
396    if idx == 0 {
397        if parts.final_offset.is_none() {
398            parts.final_offset = Some(parts.text_char_len);
399        }
400        return;
401    }
402
403    if let Some(default) = ctx.tabstop_defaults.get(&idx) {
404        let start = parts.text_char_len;
405        parts.push_str(default);
406        let end = parts.text_char_len;
407        parts.record_tabstop_range(idx, SnippetRange { start, end });
408    } else {
409        let at = parts.text_char_len;
410        parts.record_tabstop_range(idx, SnippetRange { start: at, end: at });
411    }
412}
413
414fn consume_until_closing_brace<I>(chars: &mut std::iter::Peekable<I>)
415where
416    I: Iterator<Item = char>,
417{
418    while let Some(ch) = chars.next() {
419        match ch {
420            '\\' => {
421                let _ = chars.next();
422            }
423            '}' => break,
424            _ => {}
425        }
426    }
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
430struct AnchoredRange {
431    start: TextAnchor,
432    end: TextAnchor,
433}
434
435impl AnchoredRange {
436    fn from_offsets(start: usize, end: usize) -> Self {
437        Self {
438            start: TextAnchor::new(start, AnchorBias::Left),
439            end: TextAnchor::new(end, AnchorBias::Right),
440        }
441    }
442
443    fn offsets(&self) -> (usize, usize) {
444        let a = self.start.offset;
445        let b = self.end.offset;
446        (a.min(b), a.max(b))
447    }
448
449    fn apply_delta(&mut self, delta: &TextDelta) {
450        self.start.apply_delta(delta);
451        self.end.apply_delta(delta);
452    }
453}
454
455#[derive(Debug, Clone, PartialEq, Eq)]
456struct AnchoredTabstop {
457    index: u32,
458    ranges: Vec<AnchoredRange>,
459}
460
461/// An active snippet session tracked in document coordinates.
462///
463/// This is a **view-local** editing state: it is expected to be stored alongside cursor/selection
464/// state (e.g. per `ViewId` in a `Workspace`).
465#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct SnippetSession {
467    tabstops: Vec<AnchoredTabstop>,
468    final_anchor: TextAnchor,
469    active_tabstop_index: usize,
470}
471
472/// Result of a snippet navigation operation.
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub(crate) enum SnippetNavigation {
475    /// Select the given ranges (in document char offsets) for the next/prev tabstop.
476    SelectRanges(Vec<(usize, usize)>),
477    /// Finish the snippet session by moving the caret to the final offset.
478    Finish(usize),
479    /// No active snippet / no movement possible.
480    Noop,
481}
482
483impl SnippetSession {
484    /// Create a snippet session for an inserted template.
485    ///
486    /// - `insert_start` is the character offset where `template.text` begins in the document.
487    /// - Returns `None` when `template` contains no tabstops (nothing to navigate).
488    pub fn new(insert_start: usize, template: &SnippetTemplate) -> Option<Self> {
489        if template.tabstops.is_empty() {
490            return None;
491        }
492
493        let mut tabstops: Vec<AnchoredTabstop> = Vec::with_capacity(template.tabstops.len());
494        for t in &template.tabstops {
495            let mut ranges: Vec<AnchoredRange> = Vec::with_capacity(t.ranges.len());
496            for r in &t.ranges {
497                let start = insert_start.saturating_add(r.start);
498                let end = insert_start.saturating_add(r.end);
499                ranges.push(AnchoredRange::from_offsets(start, end));
500            }
501
502            tabstops.push(AnchoredTabstop {
503                index: t.index,
504                ranges,
505            });
506        }
507
508        Some(Self {
509            tabstops,
510            final_anchor: TextAnchor::new(
511                insert_start.saturating_add(template.final_offset),
512                AnchorBias::Right,
513            ),
514            active_tabstop_index: 0,
515        })
516    }
517
518    /// Return `true` if this session has at least one tabstop.
519    pub fn is_active(&self) -> bool {
520        !self.tabstops.is_empty()
521    }
522
523    /// Shift all tracked placeholder/final offsets through a document text delta.
524    pub fn apply_delta(&mut self, delta: &TextDelta) {
525        for t in &mut self.tabstops {
526            for r in &mut t.ranges {
527                r.apply_delta(delta);
528            }
529        }
530        self.final_anchor.apply_delta(delta);
531    }
532
533    pub(crate) fn current_ranges(&self) -> Vec<(usize, usize)> {
534        self.tabstops
535            .get(self.active_tabstop_index)
536            .map(|t| {
537                let mut ranges: Vec<(usize, usize)> =
538                    t.ranges.iter().map(|r| r.offsets()).collect();
539                ranges.sort_by_key(|r| (r.0, r.1));
540                ranges
541            })
542            .unwrap_or_default()
543    }
544
545    pub(crate) fn next(&mut self) -> SnippetNavigation {
546        if self.tabstops.is_empty() {
547            return SnippetNavigation::Noop;
548        }
549
550        let next_index = self.active_tabstop_index.saturating_add(1);
551        if next_index >= self.tabstops.len() {
552            return SnippetNavigation::Finish(self.final_anchor.offset);
553        }
554
555        self.active_tabstop_index = next_index;
556        SnippetNavigation::SelectRanges(self.current_ranges())
557    }
558
559    pub(crate) fn prev(&mut self) -> SnippetNavigation {
560        if self.tabstops.is_empty() {
561            return SnippetNavigation::Noop;
562        }
563
564        if self.active_tabstop_index == 0 {
565            return SnippetNavigation::SelectRanges(self.current_ranges());
566        }
567
568        self.active_tabstop_index = self.active_tabstop_index.saturating_sub(1);
569        SnippetNavigation::SelectRanges(self.current_ranges())
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn parses_basic_placeholders_and_final_offset() {
579        let t = parse_snippet("println!(${1:msg})$0");
580        assert_eq!(t.text, "println!(msg)");
581        assert_eq!(t.final_offset, "println!(msg)".chars().count());
582        assert_eq!(t.tabstops.len(), 1);
583        assert_eq!(t.tabstops[0].index, 1);
584        assert_eq!(
585            t.tabstops[0].ranges,
586            vec![SnippetRange {
587                start: "println!(".chars().count(),
588                end: "println!(msg".chars().count()
589            }]
590        );
591    }
592
593    #[test]
594    fn parses_choice_placeholder_picks_first() {
595        let t = parse_snippet("${1|a,b,c|} $0");
596        assert_eq!(t.text, "a ");
597        assert_eq!(t.tabstops.len(), 1);
598        assert_eq!(t.tabstops[0].ranges[0], SnippetRange { start: 0, end: 1 });
599        assert_eq!(t.final_offset, 2);
600    }
601
602    #[test]
603    fn parses_variable_with_default_inserts_default() {
604        let t = parse_snippet("${TM_FILENAME:main.rs} $0");
605        assert_eq!(t.text, "main.rs ");
606        assert!(t.tabstops.is_empty());
607        assert_eq!(t.final_offset, "main.rs ".chars().count());
608    }
609
610    #[test]
611    fn mirrored_tabstops_insert_default_text() {
612        let t = parse_snippet("${1:foo} = $1; $0");
613        assert_eq!(t.text, "foo = foo; ");
614        assert_eq!(t.tabstops.len(), 1);
615        assert_eq!(t.tabstops[0].index, 1);
616        assert_eq!(t.tabstops[0].ranges.len(), 2);
617    }
618}