Skip to main content

max / makenotwork

7.8 KB · 285 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 /// Parse the body of a `{{ … }}` marker.
42 ///
43 /// Grammar:
44 /// ```text
45 /// expr := path ( '|' filter )*
46 /// path := IDENT ( '.' IDENT )*
47 /// filter := IDENT ( '(' arg ( ',' arg )* ')' )?
48 /// arg := NUMBER | STRING
49 /// STRING := '"' [^"]* '"' | "'" [^']* "'"
50 /// NUMBER := -?[0-9]+ ( '.' [0-9]+ )?
51 /// ```
52 pub(crate) fn parse_expr(input: &str) -> Result<Expr<'_>, ParseError> {
53 let input = input.trim();
54 if input.is_empty() {
55 return Err(ParseError::EmptyExpression);
56 }
57
58 let mut parts = split_pipes(input);
59 let path_raw = parts.next().ok_or(ParseError::EmptyExpression)?.trim();
60 if path_raw.is_empty() {
61 return Err(ParseError::EmptyExpression);
62 }
63 if !is_valid_path(path_raw) {
64 return Err(ParseError::InvalidPath(path_raw.to_string()));
65 }
66
67 let mut filters = Vec::new();
68 for raw in parts {
69 let call = parse_filter_call(raw.trim())?;
70 filters.push(call);
71 }
72
73 Ok(Expr {
74 path: path_raw,
75 filters,
76 })
77 }
78
79 /// Split on top-level `|` (not inside parens or quotes).
80 fn split_pipes(input: &str) -> impl Iterator<Item = &str> {
81 let mut parts = Vec::new();
82 let bytes = input.as_bytes();
83 let mut depth = 0i32;
84 let mut in_string: Option<u8> = None;
85 let mut start = 0;
86 for (i, &b) in bytes.iter().enumerate() {
87 match in_string {
88 Some(q) if b == q => in_string = None,
89 Some(_) => {}
90 None => match b {
91 b'(' => depth += 1,
92 b')' => depth -= 1,
93 b'"' | b'\'' => in_string = Some(b),
94 b'|' if depth == 0 => {
95 parts.push(&input[start..i]);
96 start = i + 1;
97 }
98 _ => {}
99 },
100 }
101 }
102 parts.push(&input[start..]);
103 parts.into_iter()
104 }
105
106 fn is_valid_path(s: &str) -> bool {
107 !s.is_empty()
108 && s.chars()
109 .next()
110 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
111 && s.chars()
112 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
113 && !s.starts_with('.')
114 && !s.ends_with('.')
115 && !s.contains("..")
116 }
117
118 fn is_valid_ident(s: &str) -> bool {
119 !s.is_empty()
120 && s.chars()
121 .next()
122 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
123 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
124 }
125
126 fn parse_filter_call(input: &str) -> Result<FilterCall<'_>, ParseError> {
127 if input.is_empty() {
128 return Err(ParseError::MissingFilterName);
129 }
130 let (name, args_raw) = match input.find('(') {
131 Some(open) => {
132 if !input.ends_with(')') {
133 return Err(ParseError::UnclosedParen);
134 }
135 let name = input[..open].trim_end();
136 let inner = &input[open + 1..input.len() - 1];
137 (name, Some(inner))
138 }
139 None => (input.trim_end(), None),
140 };
141
142 if !is_valid_ident(name) {
143 return Err(ParseError::MissingFilterName);
144 }
145
146 let args = match args_raw {
147 None => Vec::new(),
148 Some(s) if s.trim().is_empty() => Vec::new(),
149 Some(s) => parse_args(s)?,
150 };
151
152 Ok(FilterCall { name, args })
153 }
154
155 fn parse_args(input: &str) -> Result<Vec<FilterArg>, ParseError> {
156 let mut args = Vec::new();
157 for piece in split_commas(input) {
158 let p = piece.trim();
159 if p.is_empty() {
160 return Err(ParseError::InvalidArg(piece.to_string()));
161 }
162 args.push(parse_arg(p)?);
163 }
164 Ok(args)
165 }
166
167 fn split_commas(input: &str) -> Vec<&str> {
168 let bytes = input.as_bytes();
169 let mut parts = Vec::new();
170 let mut in_string: Option<u8> = None;
171 let mut start = 0;
172 for (i, &b) in bytes.iter().enumerate() {
173 match in_string {
174 Some(q) if b == q => in_string = None,
175 Some(_) => {}
176 None => match b {
177 b'"' | b'\'' => in_string = Some(b),
178 b',' => {
179 parts.push(&input[start..i]);
180 start = i + 1;
181 }
182 _ => {}
183 },
184 }
185 }
186 parts.push(&input[start..]);
187 parts
188 }
189
190 fn parse_arg(input: &str) -> Result<FilterArg, ParseError> {
191 if let Some(rest) = input.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
192 return Ok(FilterArg::String(rest.to_string()));
193 }
194 if let Some(rest) = input.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
195 return Ok(FilterArg::String(rest.to_string()));
196 }
197 if let Ok(n) = input.parse::<i64>() {
198 return Ok(FilterArg::Int(n));
199 }
200 if let Ok(x) = input.parse::<f64>() {
201 return Ok(FilterArg::Float(x));
202 }
203 Err(ParseError::InvalidArg(input.to_string()))
204 }
205
206 #[cfg(test)]
207 mod tests {
208 use super::*;
209
210 #[test]
211 fn parses_bare_path() {
212 let e = parse_expr("foo.bar").unwrap();
213 assert_eq!(e.path, "foo.bar");
214 assert!(e.filters.is_empty());
215 }
216
217 #[test]
218 fn parses_path_with_filter_no_args() {
219 let e = parse_expr("x | ceil").unwrap();
220 assert_eq!(e.path, "x");
221 assert_eq!(e.filters.len(), 1);
222 assert_eq!(e.filters[0].name, "ceil");
223 assert!(e.filters[0].args.is_empty());
224 }
225
226 #[test]
227 fn parses_filter_with_int_arg() {
228 let e = parse_expr("x | round(2)").unwrap();
229 assert_eq!(e.filters[0].args, vec![FilterArg::Int(2)]);
230 }
231
232 #[test]
233 fn parses_filter_with_string_arg() {
234 let e = parse_expr("x | money(\"\")").unwrap();
235 assert_eq!(e.filters[0].args, vec![FilterArg::String("".to_string())]);
236 }
237
238 #[test]
239 fn parses_chained_filters() {
240 let e = parse_expr("x | round(2) | money").unwrap();
241 assert_eq!(e.filters.len(), 2);
242 assert_eq!(e.filters[0].name, "round");
243 assert_eq!(e.filters[1].name, "money");
244 }
245
246 #[test]
247 fn rejects_invalid_path() {
248 assert!(matches!(
249 parse_expr(".bad"),
250 Err(ParseError::InvalidPath(_))
251 ));
252 assert!(matches!(
253 parse_expr("a..b"),
254 Err(ParseError::InvalidPath(_))
255 ));
256 }
257
258 #[test]
259 fn rejects_unclosed_paren() {
260 assert!(matches!(
261 parse_expr("x | round(2"),
262 Err(ParseError::UnclosedParen)
263 ));
264 }
265
266 #[test]
267 fn rejects_missing_filter_name() {
268 assert!(matches!(
269 parse_expr("x | "),
270 Err(ParseError::MissingFilterName)
271 ));
272 }
273
274 #[test]
275 fn pipe_inside_string_is_not_a_separator() {
276 // The pipe between quotes belongs to the string arg, not a filter split.
277 let e = parse_expr("x | money(\"a|b\")").unwrap();
278 assert_eq!(e.filters.len(), 1);
279 assert_eq!(
280 e.filters[0].args,
281 vec![FilterArg::String("a|b".to_string())]
282 );
283 }
284 }
285