Skip to main content

docgen_bases/
parser.rs

1//! A Pratt (precedence-climbing) parser turning a token stream into an [`Expr`].
2//! Tolerant: on a syntax error it returns `Err(BaseError::Expr)`; callers render
3//! that as an inline error rather than failing the build.
4
5use crate::ast::{BinaryOp, Expr, UnaryOp};
6use crate::lexer::{tokenize, Token};
7
8/// Maximum expression nesting depth. Guards against a stack overflow from
9/// adversarial input (e.g. a `.base` filter of thousands of nested `(`/`!`/`-`):
10/// past this depth `parse` returns an error rather than recursing to a crash. The
11/// evaluator enforces a matching cap (see `eval::MAX_EVAL_DEPTH`).
12pub const MAX_PARSE_DEPTH: usize = 256;
13
14/// Maximum total AST nodes in one expression. Bounds a long *postfix* chain
15/// (`a.a.a…` / `f()()()…`), which is built iteratively and so escapes the depth
16/// guard — an unbounded chain would overflow both the evaluator and the recursive
17/// `Box<Expr>` drop. Real base expressions are tiny; this is a generous ceiling.
18pub const MAX_PARSE_NODES: usize = 4096;
19
20/// Parse a whole expression string. Trailing tokens after a complete expression
21/// are an error.
22pub fn parse(src: &str) -> Result<Expr, String> {
23    let tokens = tokenize(src);
24    let mut p = Parser {
25        tokens,
26        pos: 0,
27        depth: 0,
28        nodes: 0,
29    };
30    let expr = p.expr(0)?;
31    if !matches!(p.peek(), Token::Eof) {
32        return Err(format!("unexpected trailing token: {:?}", p.peek()));
33    }
34    Ok(expr)
35}
36
37struct Parser {
38    tokens: Vec<Token>,
39    pos: usize,
40    /// Current recursion depth, bounded by [`MAX_PARSE_DEPTH`].
41    depth: usize,
42    /// Total AST nodes produced, bounded by [`MAX_PARSE_NODES`].
43    nodes: usize,
44}
45
46/// Binding powers for binary operators (higher binds tighter). `||` < `&&` <
47/// comparison < additive < multiplicative.
48fn infix_bp(tok: &Token) -> Option<(BinaryOp, u8, u8)> {
49    Some(match tok {
50        Token::OrOr => (BinaryOp::Or, 1, 2),
51        Token::AndAnd => (BinaryOp::And, 3, 4),
52        Token::EqEq => (BinaryOp::Eq, 5, 6),
53        Token::NotEq => (BinaryOp::NotEq, 5, 6),
54        Token::Lt => (BinaryOp::Lt, 7, 8),
55        Token::Gt => (BinaryOp::Gt, 7, 8),
56        Token::LtEq => (BinaryOp::LtEq, 7, 8),
57        Token::GtEq => (BinaryOp::GtEq, 7, 8),
58        Token::Plus => (BinaryOp::Add, 9, 10),
59        Token::Minus => (BinaryOp::Sub, 9, 10),
60        Token::Star => (BinaryOp::Mul, 11, 12),
61        Token::Slash => (BinaryOp::Div, 11, 12),
62        Token::Percent => (BinaryOp::Mod, 11, 12),
63        _ => return None,
64    })
65}
66
67impl Parser {
68    fn peek(&self) -> &Token {
69        self.tokens.get(self.pos).unwrap_or(&Token::Eof)
70    }
71
72    fn next(&mut self) -> Token {
73        let t = self.tokens.get(self.pos).cloned().unwrap_or(Token::Eof);
74        self.pos += 1;
75        t
76    }
77
78    fn eat(&mut self, want: &Token) -> Result<(), String> {
79        if self.peek() == want {
80            self.pos += 1;
81            Ok(())
82        } else {
83            Err(format!("expected {:?}, found {:?}", want, self.peek()))
84        }
85    }
86
87    /// Parse an expression with binding power >= `min_bp`. Depth-guarded: past
88    /// [`MAX_PARSE_DEPTH`] nested calls it errors instead of overflowing the stack.
89    fn expr(&mut self, min_bp: u8) -> Result<Expr, String> {
90        self.depth += 1;
91        if self.depth > MAX_PARSE_DEPTH {
92            self.depth -= 1;
93            return Err("expression nesting too deep".to_string());
94        }
95        let result = self.expr_inner(min_bp);
96        self.depth -= 1;
97        result
98    }
99
100    /// Charge one AST node against the budget, erroring past [`MAX_PARSE_NODES`].
101    /// Bounds the iterative postfix chain (which the depth guard doesn't cover).
102    fn bump(&mut self) -> Result<(), String> {
103        self.nodes += 1;
104        if self.nodes > MAX_PARSE_NODES {
105            return Err("expression too large".to_string());
106        }
107        Ok(())
108    }
109
110    fn expr_inner(&mut self, min_bp: u8) -> Result<Expr, String> {
111        let mut lhs = self.prefix()?;
112        loop {
113            // Postfix: member `.name`, index `[...]`, call `(...)`.
114            match self.peek() {
115                Token::Dot => {
116                    self.bump()?;
117                    self.pos += 1;
118                    let name = match self.next() {
119                        Token::Ident(n) => n,
120                        // Allow keywords as member names too (rare but harmless).
121                        Token::True => "true".to_string(),
122                        Token::False => "false".to_string(),
123                        Token::Null => "null".to_string(),
124                        other => return Err(format!("expected member name, found {other:?}")),
125                    };
126                    lhs = Expr::Member(Box::new(lhs), name);
127                    continue;
128                }
129                Token::LBracket => {
130                    self.bump()?;
131                    self.pos += 1;
132                    let idx = self.expr(0)?;
133                    self.eat(&Token::RBracket)?;
134                    lhs = Expr::Index(Box::new(lhs), Box::new(idx));
135                    continue;
136                }
137                Token::LParen => {
138                    self.bump()?;
139                    self.pos += 1;
140                    let args = self.args()?;
141                    self.eat(&Token::RParen)?;
142                    lhs = Expr::Call(Box::new(lhs), args);
143                    continue;
144                }
145                _ => {}
146            }
147            // Infix binary operators.
148            let Some((op, lbp, rbp)) = infix_bp(self.peek()) else {
149                break;
150            };
151            if lbp < min_bp {
152                break;
153            }
154            self.bump()?;
155            self.pos += 1;
156            let rhs = self.expr(rbp)?;
157            lhs = Expr::Binary(op, Box::new(lhs), Box::new(rhs));
158        }
159        Ok(lhs)
160    }
161
162    fn args(&mut self) -> Result<Vec<Expr>, String> {
163        let mut out = Vec::new();
164        // An empty argument/element list (`f()` or `[]`).
165        if matches!(self.peek(), Token::RParen | Token::RBracket) {
166            return Ok(out);
167        }
168        loop {
169            out.push(self.expr(0)?);
170            match self.peek() {
171                Token::Comma => {
172                    self.pos += 1;
173                    // Tolerate a trailing comma before the closing delimiter.
174                    if matches!(self.peek(), Token::RParen | Token::RBracket) {
175                        break;
176                    }
177                }
178                _ => break,
179            }
180        }
181        Ok(out)
182    }
183
184    fn prefix(&mut self) -> Result<Expr, String> {
185        match self.next() {
186            Token::Number(n) => Ok(Expr::Number(n)),
187            Token::Str(s) => Ok(Expr::Str(s)),
188            Token::Regex(p, f) => Ok(Expr::Regex(p, f)),
189            Token::Ident(name) => Ok(Expr::Ident(name)),
190            Token::True => Ok(Expr::Bool(true)),
191            Token::False => Ok(Expr::Bool(false)),
192            Token::Null => Ok(Expr::Null),
193            Token::Bang => Ok(Expr::Unary(UnaryOp::Not, Box::new(self.expr(13)?))),
194            Token::Minus => Ok(Expr::Unary(UnaryOp::Neg, Box::new(self.expr(13)?))),
195            Token::LParen => {
196                let e = self.expr(0)?;
197                self.eat(&Token::RParen)?;
198                Ok(e)
199            }
200            Token::LBracket => {
201                // A list literal `[a, b, c]`.
202                let items = self.args()?;
203                self.eat(&Token::RBracket)?;
204                // Represent as a call to the built-in `list`, so eval reuses one path.
205                Ok(Expr::Call(Box::new(Expr::Ident("__list".into())), items))
206            }
207            other => Err(format!("unexpected token: {other:?}")),
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::ast::BinaryOp;
216
217    #[test]
218    fn precedence_mul_over_add() {
219        let e = parse("1 + 2 * 3").unwrap();
220        // 1 + (2 * 3)
221        assert_eq!(
222            e,
223            Expr::Binary(
224                BinaryOp::Add,
225                Box::new(Expr::Number(1.0)),
226                Box::new(Expr::Binary(
227                    BinaryOp::Mul,
228                    Box::new(Expr::Number(2.0)),
229                    Box::new(Expr::Number(3.0)),
230                )),
231            )
232        );
233    }
234
235    #[test]
236    fn comparison_below_arithmetic() {
237        let e = parse("a + 1 == b").unwrap();
238        // (a + 1) == b
239        assert!(matches!(e, Expr::Binary(BinaryOp::Eq, _, _)));
240    }
241
242    #[test]
243    fn and_below_or_binding() {
244        // a || b && c  ==>  a || (b && c)
245        let e = parse("a || b && c").unwrap();
246        match e {
247            Expr::Binary(BinaryOp::Or, l, r) => {
248                assert_eq!(*l, Expr::Ident("a".into()));
249                assert!(matches!(*r, Expr::Binary(BinaryOp::And, _, _)));
250            }
251            other => panic!("expected Or at top, got {other:?}"),
252        }
253    }
254
255    #[test]
256    fn member_call_chain() {
257        let e = parse("file.hasTag(\"x\")").unwrap();
258        match e {
259            Expr::Call(callee, args) => {
260                assert_eq!(args.len(), 1);
261                assert_eq!(
262                    *callee,
263                    Expr::Member(Box::new(Expr::Ident("file".into())), "hasTag".into())
264                );
265            }
266            other => panic!("expected call, got {other:?}"),
267        }
268    }
269
270    #[test]
271    fn method_on_call_result() {
272        // link("a").linksTo(x) — call on a member of a call.
273        let e = parse("link(\"a\").linksTo(x)").unwrap();
274        assert!(matches!(e, Expr::Call(_, _)));
275    }
276
277    #[test]
278    fn not_and_neg() {
279        assert!(matches!(parse("!a").unwrap(), Expr::Unary(UnaryOp::Not, _)));
280        assert!(matches!(parse("-5").unwrap(), Expr::Unary(UnaryOp::Neg, _)));
281    }
282
283    #[test]
284    fn index_access() {
285        let e = parse("categories[0]").unwrap();
286        assert!(matches!(e, Expr::Index(_, _)));
287    }
288
289    #[test]
290    fn list_literal_becomes_list_call() {
291        let e = parse("[1, 2, 3]").unwrap();
292        match e {
293            Expr::Call(callee, args) => {
294                assert_eq!(*callee, Expr::Ident("__list".into()));
295                assert_eq!(args.len(), 3);
296            }
297            other => panic!("expected list call, got {other:?}"),
298        }
299    }
300
301    #[test]
302    fn grouping() {
303        let e = parse("(1 + 2) * 3").unwrap();
304        assert!(matches!(e, Expr::Binary(BinaryOp::Mul, _, _)));
305    }
306
307    #[test]
308    fn trailing_token_is_error() {
309        assert!(parse("1 2").is_err());
310    }
311
312    #[test]
313    fn negation_of_call() {
314        // !file.inFolder("Misc")
315        let e = parse("!file.inFolder(\"Misc\")").unwrap();
316        assert!(matches!(e, Expr::Unary(UnaryOp::Not, _)));
317    }
318
319    #[test]
320    fn deeply_nested_input_errors_instead_of_overflowing() {
321        // Adversarial nesting must return an error, not crash the process.
322        let parens = "(".repeat(100_000);
323        assert!(parse(&parens).is_err());
324        let bangs = format!("{}x", "!".repeat(100_000));
325        assert!(parse(&bangs).is_err());
326        let negs = format!("{}x", "-".repeat(100_000));
327        assert!(parse(&negs).is_err());
328    }
329
330    #[test]
331    fn long_postfix_chain_errors_on_node_budget() {
332        // A long member chain is built iteratively; the node budget must bound it
333        // so neither eval nor the recursive Box<Expr> drop overflows.
334        let chain = format!("a{}", ".a".repeat(100_000));
335        assert!(parse(&chain).is_err());
336    }
337
338    #[test]
339    fn normal_expressions_stay_under_the_budget() {
340        // A realistic base expression parses fine.
341        assert!(parse(
342            r#"categories.contains(link("Categories/Books", "Books")) && !file.inFolder("Misc")"#
343        )
344        .is_ok());
345    }
346}