Skip to main content

max / makenotwork

14.5 KB · 466 lines History Blame Raw
1 //! Mini-parser for the inside of `{{ … }}`.
2
3 use std::fmt;
4
5 use crate::filters::FilterArg;
6
7 /// Parsed expression: a path plus a list of filter calls to apply in order.
8 #[derive(Debug, PartialEq)]
9 pub(crate) struct Expr<'a> {
10 pub path: &'a str,
11 pub filters: Vec<FilterCall<'a>>,
12 }
13
14 #[derive(Debug, PartialEq)]
15 pub(crate) struct FilterCall<'a> {
16 pub name: &'a str,
17 pub args: Vec<FilterArg>,
18 }
19
20 #[derive(Debug, PartialEq)]
21 pub(crate) enum ParseError {
22 EmptyExpression,
23 InvalidPath(String),
24 MissingFilterName,
25 InvalidArg(String),
26 UnclosedParen,
27 }
28
29 impl fmt::Display for ParseError {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 match self {
32 Self::EmptyExpression => write!(f, "empty expression inside {{{{ }}}}"),
33 Self::InvalidPath(s) => write!(f, "invalid path: {s:?}"),
34 Self::MissingFilterName => write!(f, "missing filter name after `|`"),
35 Self::InvalidArg(s) => write!(f, "invalid filter argument: {s:?}"),
36 Self::UnclosedParen => write!(f, "unclosed `(`"),
37 }
38 }
39 }
40
41 /// One `{{ … }}` marker located in a document.
42 #[derive(Debug, PartialEq, Eq)]
43 pub(crate) struct Marker<'a> {
44 /// Byte offset of the opening `{{`.
45 pub start: usize,
46 /// Byte offset just past the closing `}}`.
47 pub end: usize,
48 /// What sits between them, braces excluded.
49 pub body: &'a str,
50 }
51
52 /// Find every marker in `text`, respecting quoted strings.
53 ///
54 /// This lives beside the grammar above rather than in `lib.rs` because it has
55 /// to agree with it. It previously did not: markers were found with the regex
56 /// `\{\{(.*?)\}\}`, which knows nothing about quoting, while [`split_commas`]
57 /// and [`parse_arg`] do. The two disagreed about where a marker ends, and a
58 /// `}}` inside a string argument ended it early:
59 ///
60 /// ```text
61 /// {{ price.basic | money("}}") }}
62 /// ```
63 ///
64 /// The regex stopped inside the string literal and handed `parse_expr` the
65 /// fragment ` price.basic | money("`, which then reported an unclosed paren --
66 /// a true statement about the fragment and a misleading one about the input.
67 /// Worse, the tail after the false ending was copied through as literal text,
68 /// so substituting a creator template could emit live template syntax. Found by
69 /// the soak tier 2026-08-11, GoingsOn `subst-substitute:4def8fda6141`.
70 ///
71 /// Two rules, and both are deliberate:
72 ///
73 /// - **Quotes are tracked exactly as [`split_commas`] tracks them**: `"` and
74 /// `'` both open, the same character closes, and there is no escape
75 /// sequence. Sharing the rule is the point. Two scanners with their own
76 /// notion of "quoted" is what the bug was.
77 /// - **A marker does not span a line.** The regex could not, because `.` does
78 /// not match `\n`, and that is worth keeping rather than an accident to fix:
79 /// it bounds how far a stray `{{` can reach. Without it an unclosed marker in
80 /// one paragraph would swallow prose until a `}}` turned up further down the
81 /// document.
82 ///
83 /// An unterminated marker is not a marker. It is left in the output verbatim,
84 /// which is what the regex did and what documentation showing template syntax
85 /// depends on.
86 pub(crate) fn markers(text: &str) -> Vec<Marker<'_>> {
87 let bytes = text.as_bytes();
88 let mut out = Vec::new();
89 let mut i = 0;
90
91 while i + 1 < bytes.len() {
92 if !(bytes[i] == b'{' && bytes[i + 1] == b'{') {
93 i += 1;
94 continue;
95 }
96 let body_start = i + 2;
97 let mut j = body_start;
98 let mut in_string: Option<u8> = None;
99 let mut end = None;
100
101 while j < bytes.len() {
102 let b = bytes[j];
103 match in_string {
104 Some(q) if b == q => in_string = None,
105 Some(_) => {}
106 None => match b {
107 b'"' | b'\'' => in_string = Some(b),
108 b'\n' => break,
109 b'}' if j + 1 < bytes.len() && bytes[j + 1] == b'}' => {
110 end = Some(j);
111 break;
112 }
113 _ => {}
114 },
115 }
116 j += 1;
117 }
118
119 match end {
120 Some(close) => {
121 out.push(Marker {
122 start: i,
123 end: close + 2,
124 // Both ends are ASCII brace boundaries, so this cannot
125 // split a character.
126 body: &text[body_start..close],
127 });
128 i = close + 2;
129 }
130 // No close on this line. Resume scanning after the `{{` rather than
131 // past it, so `{{{{ x }}` still finds the inner marker.
132 None => i = body_start,
133 }
134 }
135 out
136 }
137
138 /// Parse the body of a `{{ … }}` marker.
139 ///
140 /// Grammar:
141 /// ```text
142 /// expr := path ( '|' filter )*
143 /// path := IDENT ( '.' IDENT )*
144 /// filter := IDENT ( '(' arg ( ',' arg )* ')' )?
145 /// arg := NUMBER | STRING
146 /// STRING := '"' [^"]* '"' | "'" [^']* "'"
147 /// NUMBER := -?[0-9]+ ( '.' [0-9]+ )?
148 /// ```
149 pub(crate) fn parse_expr(input: &str) -> Result<Expr<'_>, ParseError> {
150 let input = input.trim();
151 if input.is_empty() {
152 return Err(ParseError::EmptyExpression);
153 }
154
155 let mut parts = split_pipes(input);
156 let path_raw = parts.next().ok_or(ParseError::EmptyExpression)?.trim();
157 if path_raw.is_empty() {
158 return Err(ParseError::EmptyExpression);
159 }
160 if !is_valid_path(path_raw) {
161 return Err(ParseError::InvalidPath(path_raw.to_string()));
162 }
163
164 let mut filters = Vec::new();
165 for raw in parts {
166 let call = parse_filter_call(raw.trim())?;
167 filters.push(call);
168 }
169
170 Ok(Expr {
171 path: path_raw,
172 filters,
173 })
174 }
175
176 /// Split on top-level `|` (not inside parens or quotes).
177 fn split_pipes(input: &str) -> impl Iterator<Item = &str> {
178 let mut parts = Vec::new();
179 let bytes = input.as_bytes();
180 let mut depth = 0i32;
181 let mut in_string: Option<u8> = None;
182 let mut start = 0;
183 for (i, &b) in bytes.iter().enumerate() {
184 match in_string {
185 Some(q) if b == q => in_string = None,
186 Some(_) => {}
187 None => match b {
188 b'(' => depth += 1,
189 b')' => depth -= 1,
190 b'"' | b'\'' => in_string = Some(b),
191 b'|' if depth == 0 => {
192 parts.push(&input[start..i]);
193 start = i + 1;
194 }
195 _ => {}
196 },
197 }
198 }
199 parts.push(&input[start..]);
200 parts.into_iter()
201 }
202
203 fn is_valid_path(s: &str) -> bool {
204 !s.is_empty()
205 && s.chars()
206 .next()
207 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
208 && s.chars()
209 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
210 && !s.starts_with('.')
211 && !s.ends_with('.')
212 && !s.contains("..")
213 }
214
215 fn is_valid_ident(s: &str) -> bool {
216 !s.is_empty()
217 && s.chars()
218 .next()
219 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
220 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
221 }
222
223 fn parse_filter_call(input: &str) -> Result<FilterCall<'_>, ParseError> {
224 if input.is_empty() {
225 return Err(ParseError::MissingFilterName);
226 }
227 let (name, args_raw) = match input.find('(') {
228 Some(open) => {
229 if !input.ends_with(')') {
230 return Err(ParseError::UnclosedParen);
231 }
232 let name = input[..open].trim_end();
233 let inner = &input[open + 1..input.len() - 1];
234 (name, Some(inner))
235 }
236 None => (input.trim_end(), None),
237 };
238
239 if !is_valid_ident(name) {
240 return Err(ParseError::MissingFilterName);
241 }
242
243 let args = match args_raw {
244 None => Vec::new(),
245 Some(s) if s.trim().is_empty() => Vec::new(),
246 Some(s) => parse_args(s)?,
247 };
248
249 Ok(FilterCall { name, args })
250 }
251
252 fn parse_args(input: &str) -> Result<Vec<FilterArg>, ParseError> {
253 let mut args = Vec::new();
254 for piece in split_commas(input) {
255 let p = piece.trim();
256 if p.is_empty() {
257 return Err(ParseError::InvalidArg(piece.to_string()));
258 }
259 args.push(parse_arg(p)?);
260 }
261 Ok(args)
262 }
263
264 fn split_commas(input: &str) -> Vec<&str> {
265 let bytes = input.as_bytes();
266 let mut parts = Vec::new();
267 let mut in_string: Option<u8> = None;
268 let mut start = 0;
269 for (i, &b) in bytes.iter().enumerate() {
270 match in_string {
271 Some(q) if b == q => in_string = None,
272 Some(_) => {}
273 None => match b {
274 b'"' | b'\'' => in_string = Some(b),
275 b',' => {
276 parts.push(&input[start..i]);
277 start = i + 1;
278 }
279 _ => {}
280 },
281 }
282 }
283 parts.push(&input[start..]);
284 parts
285 }
286
287 fn parse_arg(input: &str) -> Result<FilterArg, ParseError> {
288 if let Some(rest) = input.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
289 return Ok(FilterArg::String(rest.to_string()));
290 }
291 if let Some(rest) = input.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
292 return Ok(FilterArg::String(rest.to_string()));
293 }
294 if let Ok(n) = input.parse::<i64>() {
295 return Ok(FilterArg::Int(n));
296 }
297 if let Ok(x) = input.parse::<f64>() {
298 return Ok(FilterArg::Float(x));
299 }
300 Err(ParseError::InvalidArg(input.to_string()))
301 }
302
303 #[cfg(test)]
304 mod tests {
305 use super::*;
306
307 #[test]
308 fn marker_ends_after_a_close_brace_inside_a_string_argument() {
309 // The bug, minimally. The old regex stopped at the `}}` inside the
310 // string and handed the parser ` price.basic | money("`.
311 let m = markers(r#"{{ price.basic | money("}}") }}"#);
312 assert_eq!(m.len(), 1);
313 assert_eq!(m[0].body, r#" price.basic | money("}}") "#);
314 assert_eq!(m[0].start, 0);
315 }
316
317 #[test]
318 fn single_quotes_hide_a_close_brace_too() {
319 let m = markers("{{ x | money('}}') }}");
320 assert_eq!(m.len(), 1);
321 assert_eq!(m[0].body, " x | money('}}') ");
322 }
323
324 /// The crash input from the soak tier, verbatim. One marker, not two.
325 #[test]
326 fn nested_marker_text_inside_a_string_is_one_marker() {
327 let text = r#"{{ price.basic | money("{{ price.basic | money("$") }}$") }}"#;
328 let m = markers(text);
329 assert_eq!(m.len(), 1);
330 assert_eq!(m[0].start, 0);
331 assert_eq!(m[0].end, text.len());
332 }
333
334 /// The regex could not span a newline, because `.` does not match one. That
335 /// bound is kept deliberately: without it a stray `{{` swallows prose until
336 /// a `}}` turns up further down the document.
337 #[test]
338 fn a_marker_does_not_span_a_line() {
339 assert!(markers("{{ foo\nbar }}").is_empty());
340 // And the scanner recovers on the next line rather than giving up.
341 let m = markers("{{ unclosed\n{{ ok }}");
342 assert_eq!(m.len(), 1);
343 assert_eq!(m[0].body, " ok ");
344 }
345
346 #[test]
347 fn an_unterminated_marker_is_not_a_marker() {
348 assert!(markers("{{ ").is_empty());
349 assert!(markers(r#"{{ x | money("unclosed }}"#).is_empty());
350 }
351
352 /// Matching the old regex exactly: it opened at the first `{{` and closed
353 /// at the first `}}`, so the extra braces land inside the body and the
354 /// expression fails to parse there rather than here. Locating a marker and
355 /// judging its contents are separate jobs.
356 #[test]
357 fn doubled_braces_open_at_the_first_pair() {
358 let m = markers("{{{{ x }}");
359 assert_eq!(m.len(), 1);
360 assert_eq!(m[0].body, "{{ x ");
361 }
362
363 /// The resume path: a `{{` that never closes must not hide a later marker
364 /// on the same line. Scanning resumes just after the failed open rather
365 /// than past it.
366 #[test]
367 fn an_unclosed_open_does_not_swallow_a_later_marker() {
368 let m = markers(r#"{{ a | money(" and then {{ b }}"#);
369 assert_eq!(m.len(), 1);
370 assert_eq!(m[0].body, " b ");
371 }
372
373 #[test]
374 fn adjacent_markers_are_separate() {
375 let m = markers("{{ a }}{{ b }}");
376 assert_eq!(m.len(), 2);
377 assert_eq!(m[0].body, " a ");
378 assert_eq!(m[1].body, " b ");
379 assert_eq!(m[1].start, 7);
380 }
381
382 #[test]
383 fn multibyte_text_yields_slicable_spans() {
384 let text = "ü {{ x }} £";
385 let m = markers(text);
386 assert_eq!(m.len(), 1);
387 // The spans are what `substitute` indexes with.
388 assert_eq!(&text[m[0].start..m[0].end], "{{ x }}");
389 }
390
391 #[test]
392 fn parses_bare_path() {
393 let e = parse_expr("foo.bar").unwrap();
394 assert_eq!(e.path, "foo.bar");
395 assert!(e.filters.is_empty());
396 }
397
398 #[test]
399 fn parses_path_with_filter_no_args() {
400 let e = parse_expr("x | ceil").unwrap();
401 assert_eq!(e.path, "x");
402 assert_eq!(e.filters.len(), 1);
403 assert_eq!(e.filters[0].name, "ceil");
404 assert!(e.filters[0].args.is_empty());
405 }
406
407 #[test]
408 fn parses_filter_with_int_arg() {
409 let e = parse_expr("x | round(2)").unwrap();
410 assert_eq!(e.filters[0].args, vec![FilterArg::Int(2)]);
411 }
412
413 #[test]
414 fn parses_filter_with_string_arg() {
415 let e = parse_expr("x | money(\"\")").unwrap();
416 assert_eq!(e.filters[0].args, vec![FilterArg::String("".to_string())]);
417 }
418
419 #[test]
420 fn parses_chained_filters() {
421 let e = parse_expr("x | round(2) | money").unwrap();
422 assert_eq!(e.filters.len(), 2);
423 assert_eq!(e.filters[0].name, "round");
424 assert_eq!(e.filters[1].name, "money");
425 }
426
427 #[test]
428 fn rejects_invalid_path() {
429 assert!(matches!(
430 parse_expr(".bad"),
431 Err(ParseError::InvalidPath(_))
432 ));
433 assert!(matches!(
434 parse_expr("a..b"),
435 Err(ParseError::InvalidPath(_))
436 ));
437 }
438
439 #[test]
440 fn rejects_unclosed_paren() {
441 assert!(matches!(
442 parse_expr("x | round(2"),
443 Err(ParseError::UnclosedParen)
444 ));
445 }
446
447 #[test]
448 fn rejects_missing_filter_name() {
449 assert!(matches!(
450 parse_expr("x | "),
451 Err(ParseError::MissingFilterName)
452 ));
453 }
454
455 #[test]
456 fn pipe_inside_string_is_not_a_separator() {
457 // The pipe between quotes belongs to the string arg, not a filter split.
458 let e = parse_expr("x | money(\"a|b\")").unwrap();
459 assert_eq!(e.filters.len(), 1);
460 assert_eq!(
461 e.filters[0].args,
462 vec![FilterArg::String("a|b".to_string())]
463 );
464 }
465 }
466