Skip to main content

max / makenotwork

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