Skip to main content

lattice_snippet/
render.rs

1//! Render a parsed [`SnippetBody`] to literal text plus
2//! per-tabstop byte ranges. Output feeds the editor's edit
3//! pipeline (insert the text, then track the ranges as the
4//! user types into placeholders).
5//!
6//! Variables are resolved against the supplied
7//! [`VariableContext`] -- unknown variables fall back to their
8//! `default` body when present, else the empty string. Choice
9//! placeholders render their first option as the initial
10//! placeholder text; the host's choice-picker UI lets the
11//! user swap.
12//!
13//! Transformations parse but render as the bound text un-
14//! transformed in v1; full regex support lands as polish.
15//! Snippet bodies in the wild rarely use transformations.
16
17use std::collections::BTreeMap;
18
19use crate::token::{SnippetBody, SnippetToken, TransformTarget};
20use crate::variables::VariableContext;
21
22/// Byte range a tabstop covers in the rendered text. Multiple
23/// occurrences of the same tabstop index produce one
24/// [`TabstopRange`] per occurrence -- they're "mirrors"; edits
25/// in one ripple to the others.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct TabstopRange {
28    /// Tabstop index. `0` is the snippet's exit position.
29    pub index: u32,
30    /// Byte range in the rendered text.
31    pub range: std::ops::Range<usize>,
32    /// Whether this range was a `Placeholder` with default
33    /// text vs a bare `Tabstop`. Affects the host's "select
34    /// placeholder text on focus" UX (placeholders auto-
35    /// select their default for easy overwrite; bare tabstops
36    /// just place the cursor).
37    pub has_default: bool,
38    /// Whether this range came from a `Choice` placeholder.
39    /// The host's choice-picker UI fires on these.
40    pub is_choice: bool,
41}
42
43/// Output of [`render`]. Contains the literal text the editor
44/// inserts plus the tabstop ranges grouped by index for easy
45/// navigation.
46#[derive(Debug, Clone)]
47pub struct RenderedSnippet {
48    /// Text the editor splices into the buffer.
49    pub text: String,
50    /// All tabstop ranges in render order (`$1`'s ranges
51    /// before `$2`'s, etc.).
52    pub tabstops: Vec<TabstopRange>,
53    /// Index of the `$0` (exit) tabstop in `tabstops`, when
54    /// the body included one.
55    pub exit_index: Option<usize>,
56}
57
58impl RenderedSnippet {
59    /// Group tabstop ranges by index. Returns a sorted map so
60    /// the host can iterate `$1` -> `$2` -> ... -> `$0` in
61    /// order, with each group containing all mirrors for that
62    /// index.
63    pub fn grouped_by_index(&self) -> BTreeMap<u32, Vec<&TabstopRange>> {
64        let mut out: BTreeMap<u32, Vec<&TabstopRange>> = BTreeMap::new();
65        for r in &self.tabstops {
66            out.entry(r.index).or_default().push(r);
67        }
68        out
69    }
70}
71
72/// Render the parsed body to text + tabstop ranges.
73pub fn render(body: &SnippetBody, vars: &VariableContext) -> RenderedSnippet {
74    let mut out = String::new();
75    let mut tabstops: Vec<TabstopRange> = Vec::new();
76    walk(&body.tokens, vars, &mut out, &mut tabstops);
77    let exit_index = tabstops.iter().position(|t| t.index == 0);
78    RenderedSnippet {
79        text: out,
80        tabstops,
81        exit_index,
82    }
83}
84
85fn walk(
86    tokens: &[SnippetToken],
87    vars: &VariableContext,
88    out: &mut String,
89    tabstops: &mut Vec<TabstopRange>,
90) {
91    for token in tokens {
92        match token {
93            SnippetToken::Literal(s) => {
94                out.push_str(s);
95            }
96            SnippetToken::Tabstop(idx) => {
97                let start = out.len();
98                tabstops.push(TabstopRange {
99                    index: *idx,
100                    range: start..start,
101                    has_default: false,
102                    is_choice: false,
103                });
104            }
105            SnippetToken::Placeholder { idx, default } => {
106                let start = out.len();
107                walk(&default.tokens, vars, out, tabstops);
108                let end = out.len();
109                tabstops.push(TabstopRange {
110                    index: *idx,
111                    range: start..end,
112                    has_default: !default.is_empty(),
113                    is_choice: false,
114                });
115            }
116            SnippetToken::Choice { idx, options } => {
117                let start = out.len();
118                if let Some(first) = options.first() {
119                    out.push_str(&first.text);
120                }
121                let end = out.len();
122                tabstops.push(TabstopRange {
123                    index: *idx,
124                    range: start..end,
125                    has_default: !options.is_empty(),
126                    is_choice: true,
127                });
128            }
129            SnippetToken::Variable { name, default } => {
130                if let Some(value) = vars.resolve(name) {
131                    out.push_str(&value);
132                } else if let Some(default) = default {
133                    walk(&default.tokens, vars, out, tabstops);
134                }
135                // else: variable unknown + no default -> emit
136                // nothing. Matches VS Code behaviour.
137            }
138            SnippetToken::Transform { target, .. } => {
139                // v1: render as the bound text un-transformed.
140                // Tabstop targets emit nothing yet (they'll
141                // re-render once the user types into the bound
142                // tabstop in 4.2.g.7); variable targets emit
143                // the variable's value.
144                match target {
145                    TransformTarget::Variable(name) => {
146                        if let Some(v) = vars.resolve(name) {
147                            out.push_str(&v);
148                        }
149                    }
150                    TransformTarget::Tabstop(_) => {
151                        // No bound text yet at render time;
152                        // emit nothing. Future polish: re-
153                        // render after each placeholder edit.
154                    }
155                }
156            }
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::parse;
165
166    fn render_str(s: &str) -> RenderedSnippet {
167        let body = parse::parse(s).unwrap();
168        render(&body, &VariableContext::default())
169    }
170
171    #[test]
172    fn pure_literal_renders_verbatim() {
173        let r = render_str("hello world");
174        assert_eq!(r.text, "hello world");
175        assert!(r.tabstops.is_empty());
176    }
177
178    #[test]
179    fn tabstop_emits_zero_width_range() {
180        let r = render_str("foo$1bar");
181        assert_eq!(r.text, "foobar");
182        assert_eq!(r.tabstops.len(), 1);
183        assert_eq!(r.tabstops[0].index, 1);
184        assert_eq!(r.tabstops[0].range, 3..3);
185        assert!(!r.tabstops[0].has_default);
186    }
187
188    #[test]
189    fn placeholder_emits_default_text_in_range() {
190        let r = render_str("for ${1:i} in ${2:iter}");
191        assert_eq!(r.text, "for i in iter");
192        assert_eq!(r.tabstops.len(), 2);
193        assert_eq!(r.tabstops[0].index, 1);
194        assert_eq!(r.tabstops[0].range, 4..5); // "i"
195        assert!(r.tabstops[0].has_default);
196        assert_eq!(r.tabstops[1].index, 2);
197        assert_eq!(r.tabstops[1].range, 9..13); // "iter"
198    }
199
200    #[test]
201    fn final_tabstop_is_marked_in_exit_index() {
202        let r = render_str("foo$0bar");
203        assert_eq!(r.text, "foobar");
204        assert_eq!(r.exit_index, Some(0));
205        assert_eq!(r.tabstops[0].index, 0);
206    }
207
208    #[test]
209    fn choice_renders_first_option_initially() {
210        let r = render_str("Hello ${1|world,Earth,planet|}!");
211        assert_eq!(r.text, "Hello world!");
212        assert_eq!(r.tabstops[0].range, 6..11); // "world"
213        assert!(r.tabstops[0].is_choice);
214    }
215
216    #[test]
217    fn variable_resolves_via_context() {
218        let body = parse::parse("$TM_FILENAME").unwrap();
219        let ctx = VariableContext {
220            filename: Some("foo.rs".into()),
221            ..Default::default()
222        };
223        let r = render(&body, &ctx);
224        assert_eq!(r.text, "foo.rs");
225    }
226
227    #[test]
228    fn variable_falls_back_to_default_when_unset() {
229        let body = parse::parse("${TM_FILENAME:fallback.txt}").unwrap();
230        let r = render(&body, &VariableContext::default());
231        assert_eq!(r.text, "fallback.txt");
232    }
233
234    #[test]
235    fn unknown_variable_with_no_default_emits_nothing() {
236        let body = parse::parse("foo$NOPE bar").unwrap();
237        let r = render(&body, &VariableContext::default());
238        assert_eq!(r.text, "foo bar");
239    }
240
241    #[test]
242    fn nested_placeholder_default_renders_inner_text() {
243        let r = render_str("${1:outer ${2:inner} more}");
244        assert_eq!(r.text, "outer inner more");
245        // Two tabstop ranges: inner $2 emitted before outer
246        // $1 because we walk default body first.
247        assert_eq!(r.tabstops.len(), 2);
248        assert_eq!(r.tabstops[0].index, 2); // inner first (walked first)
249        assert_eq!(r.tabstops[0].range, 6..11); // "inner"
250        assert_eq!(r.tabstops[1].index, 1); // outer wraps
251        assert_eq!(r.tabstops[1].range, 0..16);
252    }
253
254    #[test]
255    fn grouped_by_index_collects_mirrors() {
256        // Two `$1` mirrors -- common in for-loops where the
257        // counter appears in both header and body.
258        let r = render_str("for $1 = 0; $1 < n; $1++");
259        let groups = r.grouped_by_index();
260        let group_one = groups.get(&1).expect("$1 group");
261        assert_eq!(group_one.len(), 3);
262    }
263
264    #[test]
265    fn render_round_trips_friendly_snippets_for_loop_shape() {
266        let body = parse::parse("for ${1:i} in ${2:iter} {\n\t$0\n}").unwrap();
267        let r = render(&body, &VariableContext::default());
268        assert_eq!(r.text, "for i in iter {\n\t\n}");
269        assert_eq!(r.tabstops.len(), 3);
270        assert_eq!(r.exit_index, Some(2));
271    }
272}