lattice_snippet/
render.rs1use std::collections::BTreeMap;
18
19use crate::token::{SnippetBody, SnippetToken, TransformTarget};
20use crate::variables::VariableContext;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct TabstopRange {
28 pub index: u32,
30 pub range: std::ops::Range<usize>,
32 pub has_default: bool,
38 pub is_choice: bool,
41}
42
43#[derive(Debug, Clone)]
47pub struct RenderedSnippet {
48 pub text: String,
50 pub tabstops: Vec<TabstopRange>,
53 pub exit_index: Option<usize>,
56}
57
58impl RenderedSnippet {
59 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
72pub 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 }
138 SnippetToken::Transform { target, .. } => {
139 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 }
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); assert!(r.tabstops[0].has_default);
196 assert_eq!(r.tabstops[1].index, 2);
197 assert_eq!(r.tabstops[1].range, 9..13); }
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); 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 assert_eq!(r.tabstops.len(), 2);
248 assert_eq!(r.tabstops[0].index, 2); assert_eq!(r.tabstops[0].range, 6..11); assert_eq!(r.tabstops[1].index, 1); assert_eq!(r.tabstops[1].range, 0..16);
252 }
253
254 #[test]
255 fn grouped_by_index_collects_mirrors() {
256 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}