//! 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 `(`"), } } } /// 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 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())] ); } }