//! Mini-parser for the inside of `{{ … }}`. use std::fmt; use crate::filters::FilterArg; /// Parsed expression: a path plus a list of filter calls to apply in order. #[derive(Debug, PartialEq)] pub(crate) struct Expr<'a> { pub path: &'a str, pub filters: Vec>, } #[derive(Debug, PartialEq)] pub(crate) struct FilterCall<'a> { pub name: &'a str, pub args: Vec, } #[derive(Debug, PartialEq)] pub(crate) enum ParseError { EmptyExpression, InvalidPath(String), MissingFilterName, InvalidArg(String), UnclosedParen, } impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::EmptyExpression => write!(f, "empty expression inside {{{{ }}}}"), Self::InvalidPath(s) => write!(f, "invalid path: {s:?}"), Self::MissingFilterName => write!(f, "missing filter name after `|`"), Self::InvalidArg(s) => write!(f, "invalid filter argument: {s:?}"), Self::UnclosedParen => write!(f, "unclosed `(`"), } } } /// One `{{ … }}` marker located in a document. #[derive(Debug, PartialEq, Eq)] pub(crate) struct Marker<'a> { /// Byte offset of the opening `{{`. pub start: usize, /// Byte offset just past the closing `}}`. pub end: usize, /// What sits between them, braces excluded. pub body: &'a str, } /// Find every marker in `text`, respecting quoted strings. /// /// This lives beside the grammar above rather than in `lib.rs` because it has /// to agree with it. A scanner that knows nothing about quoting, while /// [`split_commas`] and [`parse_arg`] do, disagrees about where a marker ends, /// and a `}}` inside a string argument ends it early: /// /// ```text /// {{ price.basic | money("}}") }} /// ``` /// /// Stopping inside the string literal hands `parse_expr` the fragment /// ` price.basic | money("`, which reports an unclosed paren: a true statement /// about the fragment and a misleading one about the input. The tail after the /// false ending is then copied through as literal text, so substituting a /// creator template can emit live template syntax. /// /// Two rules, and both are deliberate: /// /// - **Quotes are tracked exactly as [`split_commas`] tracks them**: `"` and /// `'` both open, the same character closes, and there is no escape /// sequence. Sharing the rule is the point. Two scanners with their own /// notion of "quoted" is a source of divergence. /// - **A marker does not span a line.** This bounds how far a stray `{{` can /// reach. Without it an unclosed marker in one paragraph would swallow prose /// until a `}}` turned up further down the document. /// /// An unterminated marker is not a marker. It is left in the output verbatim, /// which is what documentation showing template syntax depends on. pub(crate) fn markers(text: &str) -> Vec> { let bytes = text.as_bytes(); let mut out = Vec::new(); let mut i = 0; while i + 1 < bytes.len() { if !(bytes[i] == b'{' && bytes[i + 1] == b'{') { i += 1; continue; } let body_start = i + 2; let mut j = body_start; let mut in_string: Option = None; let mut end = None; while j < bytes.len() { let b = bytes[j]; match in_string { Some(q) if b == q => in_string = None, Some(_) => {} None => match b { b'"' | b'\'' => in_string = Some(b), b'\n' => break, b'}' if j + 1 < bytes.len() && bytes[j + 1] == b'}' => { end = Some(j); break; } _ => {} }, } j += 1; } match end { Some(close) => { out.push(Marker { start: i, end: close + 2, // Both ends are ASCII brace boundaries, so this cannot // split a character. body: &text[body_start..close], }); i = close + 2; } // No close on this line. Resume scanning after the `{{` rather than // past it, so `{{{{ x }}` still finds the inner marker. None => i = body_start, } } out } /// Parse the body of a `{{ … }}` marker. /// /// Grammar: /// ```text /// expr := path ( '|' filter )* /// path := IDENT ( '.' IDENT )* /// filter := IDENT ( '(' arg ( ',' arg )* ')' )? /// arg := NUMBER | STRING /// STRING := '"' [^"]* '"' | "'" [^']* "'" /// NUMBER := -?[0-9]+ ( '.' [0-9]+ )? /// ``` pub(crate) fn parse_expr(input: &str) -> Result, ParseError> { let input = input.trim(); if input.is_empty() { return Err(ParseError::EmptyExpression); } let mut parts = split_pipes(input); let path_raw = parts.next().ok_or(ParseError::EmptyExpression)?.trim(); if path_raw.is_empty() { return Err(ParseError::EmptyExpression); } if !is_valid_path(path_raw) { return Err(ParseError::InvalidPath(path_raw.to_string())); } let mut filters = Vec::new(); for raw in parts { let call = parse_filter_call(raw.trim())?; filters.push(call); } Ok(Expr { path: path_raw, filters, }) } /// Split on top-level `|` (not inside parens or quotes). fn split_pipes(input: &str) -> impl Iterator { let mut parts = Vec::new(); let bytes = input.as_bytes(); let mut depth = 0i32; let mut in_string: Option = None; let mut start = 0; for (i, &b) in bytes.iter().enumerate() { match in_string { Some(q) if b == q => in_string = None, Some(_) => {} None => match b { b'(' => depth += 1, b')' => depth -= 1, b'"' | b'\'' => in_string = Some(b), b'|' if depth == 0 => { parts.push(&input[start..i]); start = i + 1; } _ => {} }, } } parts.push(&input[start..]); parts.into_iter() } fn is_valid_path(s: &str) -> bool { !s.is_empty() && s.chars() .next() .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && s.chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') && !s.starts_with('.') && !s.ends_with('.') && !s.contains("..") } fn is_valid_ident(s: &str) -> bool { !s.is_empty() && s.chars() .next() .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') } fn parse_filter_call(input: &str) -> Result, ParseError> { if input.is_empty() { return Err(ParseError::MissingFilterName); } let (name, args_raw) = match input.find('(') { Some(open) => { if !input.ends_with(')') { return Err(ParseError::UnclosedParen); } let name = input[..open].trim_end(); let inner = &input[open + 1..input.len() - 1]; (name, Some(inner)) } None => (input.trim_end(), None), }; if !is_valid_ident(name) { return Err(ParseError::MissingFilterName); } let args = match args_raw { None => Vec::new(), Some(s) if s.trim().is_empty() => Vec::new(), Some(s) => parse_args(s)?, }; Ok(FilterCall { name, args }) } fn parse_args(input: &str) -> Result, ParseError> { let mut args = Vec::new(); for piece in split_commas(input) { let p = piece.trim(); if p.is_empty() { return Err(ParseError::InvalidArg(piece.to_string())); } args.push(parse_arg(p)?); } Ok(args) } fn split_commas(input: &str) -> Vec<&str> { let bytes = input.as_bytes(); let mut parts = Vec::new(); let mut in_string: Option = None; let mut start = 0; for (i, &b) in bytes.iter().enumerate() { match in_string { Some(q) if b == q => in_string = None, Some(_) => {} None => match b { b'"' | b'\'' => in_string = Some(b), b',' => { parts.push(&input[start..i]); start = i + 1; } _ => {} }, } } parts.push(&input[start..]); parts } fn parse_arg(input: &str) -> Result { if let Some(rest) = input.strip_prefix('"').and_then(|s| s.strip_suffix('"')) { return Ok(FilterArg::String(rest.to_string())); } if let Some(rest) = input.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) { return Ok(FilterArg::String(rest.to_string())); } if let Ok(n) = input.parse::() { return Ok(FilterArg::Int(n)); } if let Ok(x) = input.parse::() { return Ok(FilterArg::Float(x)); } Err(ParseError::InvalidArg(input.to_string())) } #[cfg(test)] mod tests { use super::*; #[test] fn marker_ends_after_a_close_brace_inside_a_string_argument() { // The bug, minimally. The old regex stopped at the `}}` inside the // string and handed the parser ` price.basic | money("`. let m = markers(r#"{{ price.basic | money("}}") }}"#); assert_eq!(m.len(), 1); assert_eq!(m[0].body, r#" price.basic | money("}}") "#); assert_eq!(m[0].start, 0); } #[test] fn single_quotes_hide_a_close_brace_too() { let m = markers("{{ x | money('}}') }}"); assert_eq!(m.len(), 1); assert_eq!(m[0].body, " x | money('}}') "); } /// The crash input from the soak tier, verbatim. One marker, not two. #[test] fn nested_marker_text_inside_a_string_is_one_marker() { let text = r#"{{ price.basic | money("{{ price.basic | money("$") }}$") }}"#; let m = markers(text); assert_eq!(m.len(), 1); assert_eq!(m[0].start, 0); assert_eq!(m[0].end, text.len()); } /// The regex could not span a newline, because `.` does not match one. That /// bound is kept deliberately: without it a stray `{{` swallows prose until /// a `}}` turns up further down the document. #[test] fn a_marker_does_not_span_a_line() { assert!(markers("{{ foo\nbar }}").is_empty()); // And the scanner recovers on the next line rather than giving up. let m = markers("{{ unclosed\n{{ ok }}"); assert_eq!(m.len(), 1); assert_eq!(m[0].body, " ok "); } #[test] fn an_unterminated_marker_is_not_a_marker() { assert!(markers("{{ ").is_empty()); assert!(markers(r#"{{ x | money("unclosed }}"#).is_empty()); } /// Matching the old regex exactly: it opened at the first `{{` and closed /// at the first `}}`, so the extra braces land inside the body and the /// expression fails to parse there rather than here. Locating a marker and /// judging its contents are separate jobs. #[test] fn doubled_braces_open_at_the_first_pair() { let m = markers("{{{{ x }}"); assert_eq!(m.len(), 1); assert_eq!(m[0].body, "{{ x "); } /// The resume path: a `{{` that never closes must not hide a later marker /// on the same line. Scanning resumes just after the failed open rather /// than past it. #[test] fn an_unclosed_open_does_not_swallow_a_later_marker() { let m = markers(r#"{{ a | money(" and then {{ b }}"#); assert_eq!(m.len(), 1); assert_eq!(m[0].body, " b "); } #[test] fn adjacent_markers_are_separate() { let m = markers("{{ a }}{{ b }}"); assert_eq!(m.len(), 2); assert_eq!(m[0].body, " a "); assert_eq!(m[1].body, " b "); assert_eq!(m[1].start, 7); } #[test] fn multibyte_text_yields_slicable_spans() { let text = "ü {{ x }} £"; let m = markers(text); assert_eq!(m.len(), 1); // The spans are what `substitute` indexes with. assert_eq!(&text[m[0].start..m[0].end], "{{ x }}"); } #[test] fn parses_bare_path() { let e = parse_expr("foo.bar").unwrap(); assert_eq!(e.path, "foo.bar"); assert!(e.filters.is_empty()); } #[test] fn parses_path_with_filter_no_args() { let e = parse_expr("x | ceil").unwrap(); assert_eq!(e.path, "x"); assert_eq!(e.filters.len(), 1); assert_eq!(e.filters[0].name, "ceil"); assert!(e.filters[0].args.is_empty()); } #[test] fn parses_filter_with_int_arg() { let e = parse_expr("x | round(2)").unwrap(); assert_eq!(e.filters[0].args, vec![FilterArg::Int(2)]); } #[test] fn parses_filter_with_string_arg() { let e = parse_expr("x | money(\"€\")").unwrap(); assert_eq!(e.filters[0].args, vec![FilterArg::String("€".to_string())]); } #[test] fn parses_chained_filters() { let e = parse_expr("x | round(2) | money").unwrap(); assert_eq!(e.filters.len(), 2); assert_eq!(e.filters[0].name, "round"); assert_eq!(e.filters[1].name, "money"); } #[test] fn rejects_invalid_path() { assert!(matches!( parse_expr(".bad"), Err(ParseError::InvalidPath(_)) )); assert!(matches!( parse_expr("a..b"), Err(ParseError::InvalidPath(_)) )); } #[test] fn rejects_unclosed_paren() { assert!(matches!( parse_expr("x | round(2"), Err(ParseError::UnclosedParen) )); } #[test] fn rejects_missing_filter_name() { assert!(matches!( parse_expr("x | "), Err(ParseError::MissingFilterName) )); } #[test] fn pipe_inside_string_is_not_a_separator() { // The pipe between quotes belongs to the string arg, not a filter split. let e = parse_expr("x | money(\"a|b\")").unwrap(); assert_eq!(e.filters.len(), 1); assert_eq!( e.filters[0].args, vec![FilterArg::String("a|b".to_string())] ); } }