Skip to main content

rustlavel_view/
expr.rs

1//! The small expression language inside `{{ }}` and `@if(...)`.
2//!
3//! It is deliberately tiny: paths, literals, comparisons, and boolean logic.
4//! There is no arithmetic, no method calls, and no assignment — a view that
5//! needs any of those is doing work that belongs in Rust, where the compiler
6//! can check it and a test can cover it.
7
8use crate::context::Scope;
9use crate::source::Span;
10use crate::value::{compare, truthy};
11use rustlavel_core::{Json, Result};
12use std::cmp::Ordering;
13
14/// A parsed expression.
15#[derive(Debug, Clone, PartialEq)]
16pub enum Expr {
17    /// A dotted lookup into the render data: `user.name`, `items.0.title`.
18    Path(String),
19    Str(String),
20    Number(f64),
21    Bool(bool),
22    Null,
23    Not(Box<Expr>),
24    Binary { op: BinaryOp, left: Box<Expr>, right: Box<Expr> },
25}
26
27/// The operators, loosest binding first: `||`, `&&`, equality, comparison.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BinaryOp {
30    Or,
31    And,
32    Eq,
33    Ne,
34    Lt,
35    Le,
36    Gt,
37    Ge,
38}
39
40impl Expr {
41    /// Evaluate against the current scope.
42    ///
43    /// A path that resolves to nothing yields `Json::Null` rather than an
44    /// error. Rendering is the last step of a request: a typo in a view should
45    /// leave a hole in the page, not turn a working page into a 500.
46    pub fn eval(&self, scope: &Scope) -> Json {
47        match self {
48            Expr::Path(path) => scope.lookup(path).cloned().unwrap_or(Json::Null),
49            Expr::Str(text) => Json::String(text.clone()),
50            Expr::Number(number) => Json::Number(*number),
51            Expr::Bool(flag) => Json::Bool(*flag),
52            Expr::Null => Json::Null,
53            Expr::Not(inner) => Json::Bool(!truthy(&inner.eval(scope))),
54            Expr::Binary { op, left, right } => match op {
55                // Short-circuiting matters: `@if(user && user.admin)` must not
56                // walk into a null.
57                BinaryOp::And => {
58                    Json::Bool(truthy(&left.eval(scope)) && truthy(&right.eval(scope)))
59                }
60                BinaryOp::Or => {
61                    Json::Bool(truthy(&left.eval(scope)) || truthy(&right.eval(scope)))
62                }
63                _ => {
64                    let (left, right) = (left.eval(scope), right.eval(scope));
65                    Json::Bool(match op {
66                        // Equality is strict: `"3" == 3` is false, because a
67                        // coercion table is one more thing to memorise.
68                        BinaryOp::Eq => left == right,
69                        BinaryOp::Ne => left != right,
70                        BinaryOp::Lt => compare(&left, &right) == Some(Ordering::Less),
71                        BinaryOp::Le => {
72                            matches!(compare(&left, &right), Some(Ordering::Less | Ordering::Equal))
73                        }
74                        BinaryOp::Gt => compare(&left, &right) == Some(Ordering::Greater),
75                        _ => matches!(
76                            compare(&left, &right),
77                            Some(Ordering::Greater | Ordering::Equal)
78                        ),
79                    })
80                }
81            },
82        }
83    }
84}
85
86/// Parse exactly one expression, rejecting anything left over.
87pub fn parse(span: Span<'_>) -> Result<Expr> {
88    let mut cursor = Cursor::new(span)?;
89    let expr = cursor.expression()?;
90    cursor.expect_end()?;
91    Ok(expr)
92}
93
94/// Parse a comma-separated argument list, as `@section("title", "Home")` and
95/// `@yield("body", "nothing here yet")` need.
96pub fn parse_arguments(span: Span<'_>) -> Result<Vec<Expr>> {
97    let mut cursor = Cursor::new(span)?;
98    let mut arguments = Vec::new();
99    if cursor.peek().is_some() {
100        loop {
101            arguments.push(cursor.expression()?);
102            if cursor.peek() == Some(&Token::Comma) {
103                cursor.pos += 1;
104                continue;
105            }
106            break;
107        }
108    }
109    cursor.expect_end()?;
110    Ok(arguments)
111}
112
113/// Parse the head of `@foreach(items as item)` into its subject and binding.
114pub fn parse_foreach(span: Span<'_>) -> Result<(Expr, String)> {
115    let mut cursor = Cursor::new(span)?;
116    let subject = cursor.expression()?;
117
118    // `as` is not an operator, so the expression parser stops in front of it.
119    match cursor.peek() {
120        Some(Token::Path(word)) if word == "as" => cursor.pos += 1,
121        _ => {
122            return Err(cursor
123                .error_here("expected `as`, like `@foreach(users as user)`"));
124        }
125    }
126
127    let binding = match cursor.peek().cloned() {
128        Some(Token::Path(name)) if !name.contains('.') => {
129            cursor.pos += 1;
130            name
131        }
132        _ => return Err(cursor.error_here("expected a variable name after `as`")),
133    };
134
135    cursor.expect_end()?;
136    Ok((subject, binding))
137}
138
139/// One lexical unit of an expression.
140#[derive(Debug, Clone, PartialEq)]
141enum Token {
142    Path(String),
143    Str(String),
144    Number(f64),
145    True,
146    False,
147    Null,
148    Not,
149    And,
150    Or,
151    Eq,
152    Ne,
153    Lt,
154    Le,
155    Gt,
156    Ge,
157    Open,
158    Close,
159    Comma,
160}
161
162impl Token {
163    /// How the token is named in an error message.
164    fn describe(&self) -> String {
165        match self {
166            Token::Path(name) => format!("`{name}`"),
167            Token::Str(_) => "a string".to_string(),
168            Token::Number(_) => "a number".to_string(),
169            Token::True => "`true`".to_string(),
170            Token::False => "`false`".to_string(),
171            Token::Null => "`null`".to_string(),
172            Token::Not => "`!`".to_string(),
173            Token::And => "`&&`".to_string(),
174            Token::Or => "`||`".to_string(),
175            Token::Eq => "`==`".to_string(),
176            Token::Ne => "`!=`".to_string(),
177            Token::Lt => "`<`".to_string(),
178            Token::Le => "`<=`".to_string(),
179            Token::Gt => "`>`".to_string(),
180            Token::Ge => "`>=`".to_string(),
181            Token::Open => "`(`".to_string(),
182            Token::Close => "`)`".to_string(),
183            Token::Comma => "`,`".to_string(),
184        }
185    }
186}
187
188/// Recursive-descent parser over a tokenised expression.
189struct Cursor<'a> {
190    span: Span<'a>,
191    tokens: Vec<(Token, usize)>,
192    pos: usize,
193}
194
195impl<'a> Cursor<'a> {
196    fn new(span: Span<'a>) -> Result<Self> {
197        Ok(Cursor { tokens: tokenize(span)?, span, pos: 0 })
198    }
199
200    fn peek(&self) -> Option<&Token> {
201        self.tokens.get(self.pos).map(|(token, _)| token)
202    }
203
204    /// The offset of the current token, or the end of the fragment.
205    fn offset(&self) -> usize {
206        self.tokens.get(self.pos).map_or(self.span.text.len(), |(_, offset)| *offset)
207    }
208
209    fn error_here(&self, message: impl Into<String>) -> rustlavel_core::Error {
210        self.span.error(self.offset(), message)
211    }
212
213    /// Consume `token` if it is next.
214    fn eat(&mut self, token: &Token) -> bool {
215        if self.peek() == Some(token) {
216            self.pos += 1;
217            return true;
218        }
219        false
220    }
221
222    fn expect_end(&self) -> Result<()> {
223        match self.peek() {
224            None => Ok(()),
225            Some(token) => Err(self.error_here(format!("unexpected {}", token.describe()))),
226        }
227    }
228
229    /// The precedence ladder, loosest first: `||`, `&&`, equality,
230    /// comparison, `!`, primary. Four rungs is the whole language.
231    fn expression(&mut self) -> Result<Expr> {
232        self.any_of(&[(Token::Or, BinaryOp::Or)], Cursor::conjunction)
233    }
234
235    fn conjunction(&mut self) -> Result<Expr> {
236        self.any_of(&[(Token::And, BinaryOp::And)], Cursor::equality)
237    }
238
239    fn equality(&mut self) -> Result<Expr> {
240        self.any_of(&[(Token::Eq, BinaryOp::Eq), (Token::Ne, BinaryOp::Ne)], Cursor::comparison)
241    }
242
243    fn comparison(&mut self) -> Result<Expr> {
244        // Two-character operators are listed first only for readability; the
245        // tokenizer already decided which one it saw.
246        self.any_of(
247            &[
248                (Token::Le, BinaryOp::Le),
249                (Token::Ge, BinaryOp::Ge),
250                (Token::Lt, BinaryOp::Lt),
251                (Token::Gt, BinaryOp::Gt),
252            ],
253            Cursor::unary,
254        )
255    }
256
257    /// Left-associative chain of `operators` over the next tighter rung.
258    fn any_of(
259        &mut self,
260        operators: &[(Token, BinaryOp)],
261        tighter: fn(&mut Self) -> Result<Expr>,
262    ) -> Result<Expr> {
263        let mut left = tighter(self)?;
264        'chain: loop {
265            for (token, op) in operators {
266                if self.eat(token) {
267                    let right = tighter(self)?;
268                    left = Expr::Binary { op: *op, left: Box::new(left), right: Box::new(right) };
269                    continue 'chain;
270                }
271            }
272            return Ok(left);
273        }
274    }
275
276    fn unary(&mut self) -> Result<Expr> {
277        if self.eat(&Token::Not) {
278            return Ok(Expr::Not(Box::new(self.unary()?)));
279        }
280        self.primary()
281    }
282
283    fn primary(&mut self) -> Result<Expr> {
284        let Some(token) = self.peek().cloned() else {
285            return Err(self.error_here("unexpected end of expression"));
286        };
287        self.pos += 1;
288
289        match token {
290            Token::Open => {
291                let inner = self.expression()?;
292                if !self.eat(&Token::Close) {
293                    return Err(self.error_here("expected `)`"));
294                }
295                Ok(inner)
296            }
297            Token::Path(name) => Ok(Expr::Path(name)),
298            Token::Str(text) => Ok(Expr::Str(text)),
299            Token::Number(number) => Ok(Expr::Number(number)),
300            Token::True => Ok(Expr::Bool(true)),
301            Token::False => Ok(Expr::Bool(false)),
302            Token::Null => Ok(Expr::Null),
303            other => {
304                self.pos -= 1;
305                Err(self.error_here(format!("unexpected {}", other.describe())))
306            }
307        }
308    }
309}
310
311/// Split an expression fragment into tokens, remembering each one's offset.
312fn tokenize(span: Span<'_>) -> Result<Vec<(Token, usize)>> {
313    let text = span.text;
314    let bytes = text.as_bytes();
315    let mut tokens = Vec::new();
316    let mut i = 0;
317
318    while i < bytes.len() {
319        let start = i;
320        let token = match bytes[i] {
321            b' ' | b'\t' | b'\n' | b'\r' => {
322                i += 1;
323                continue;
324            }
325            b'"' | b'\'' => {
326                let (value, next) = string_literal(span, i)?;
327                i = next;
328                Token::Str(value)
329            }
330            b'0'..=b'9' => {
331                let (value, next) = number_literal(span, i)?;
332                i = next;
333                Token::Number(value)
334            }
335            // A `-` is only ever the sign of a literal; there is no arithmetic.
336            b'-' if matches!(bytes.get(i + 1), Some(b'0'..=b'9')) => {
337                let (value, next) = number_literal(span, i)?;
338                i = next;
339                Token::Number(value)
340            }
341            b'A'..=b'Z' | b'a'..=b'z' | b'_' => {
342                while matches!(
343                    bytes.get(i),
344                    Some(b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.')
345                ) {
346                    i += 1;
347                }
348                let word = &text[start..i];
349                if word.ends_with('.') || word.contains("..") {
350                    return Err(span.error(start, "a path segment is missing after `.`"));
351                }
352                match word {
353                    "true" => Token::True,
354                    "false" => Token::False,
355                    "null" | "nil" => Token::Null,
356                    _ => Token::Path(word.to_string()),
357                }
358            }
359            b'(' => {
360                i += 1;
361                Token::Open
362            }
363            b')' => {
364                i += 1;
365                Token::Close
366            }
367            b',' => {
368                i += 1;
369                Token::Comma
370            }
371            b'&' | b'|' => {
372                let word = if bytes[i] == b'&' { "&&" } else { "||" };
373                if !bytes[i..].starts_with(word.as_bytes()) {
374                    return Err(span.error(start, format!("expected `{word}`")));
375                }
376                i += 2;
377                if word == "&&" { Token::And } else { Token::Or }
378            }
379            b'=' | b'!' | b'<' | b'>' => {
380                let two = bytes.get(i + 1) == Some(&b'=');
381                i += if two { 2 } else { 1 };
382                match (bytes[start], two) {
383                    (b'=', true) => Token::Eq,
384                    (b'=', false) => {
385                        return Err(span.error(start, "use `==` to compare; there is no assignment in a template"));
386                    }
387                    (b'!', true) => Token::Ne,
388                    (b'!', false) => Token::Not,
389                    (b'<', true) => Token::Le,
390                    (b'<', false) => Token::Lt,
391                    (b'>', true) => Token::Ge,
392                    _ => Token::Gt,
393                }
394            }
395            byte => {
396                return Err(span.error(start, format!("unexpected character `{}`", byte as char)));
397            }
398        };
399        tokens.push((token, start));
400    }
401
402    Ok(tokens)
403}
404
405/// Read a quoted string, honouring the escapes an HTML author actually types.
406fn string_literal(span: Span<'_>, start: usize) -> Result<(String, usize)> {
407    let bytes = span.text.as_bytes();
408    let quote = bytes[start];
409    let mut value = String::new();
410    let mut i = start + 1;
411
412    while i < bytes.len() {
413        match bytes[i] {
414            b'\\' => {
415                let escape = *bytes
416                    .get(i + 1)
417                    .ok_or_else(|| span.error(start, "unterminated string"))?;
418                value.push(match escape {
419                    b'n' => '\n',
420                    b't' => '\t',
421                    b'r' => '\r',
422                    other => other as char,
423                });
424                i += 2;
425            }
426            byte if byte == quote => {
427                return Ok((value, i + 1));
428            }
429            _ => {
430                // Copy whole UTF-8 sequences: the delimiters are all ASCII, so
431                // anything else belongs to the string as-is.
432                let from = i;
433                while i < bytes.len() && bytes[i] != quote && bytes[i] != b'\\' {
434                    i += 1;
435                }
436                value.push_str(&span.text[from..i]);
437            }
438        }
439    }
440
441    Err(span.error(start, "unterminated string"))
442}
443
444fn number_literal(span: Span<'_>, start: usize) -> Result<(f64, usize)> {
445    let bytes = span.text.as_bytes();
446    let mut i = start + usize::from(bytes[start] == b'-');
447    while matches!(bytes.get(i), Some(b'0'..=b'9' | b'.')) {
448        i += 1;
449    }
450    span.text[start..i]
451        .parse::<f64>()
452        .map(|number| (number, i))
453        .map_err(|_| span.error(start, "invalid number"))
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::context::Context;
460    use rustlavel_core::Error;
461
462    fn expr(text: &str) -> Expr {
463        parse(Span { file: "test", source: text, offset: 0, text }).unwrap()
464    }
465
466    fn eval(text: &str, context: &Context) -> Json {
467        expr(text).eval(&Scope::new(context))
468    }
469
470    #[test]
471    fn parses_paths_and_literals() {
472        assert_eq!(expr("user.name"), Expr::Path("user.name".to_string()));
473        assert_eq!(expr("'hi there'"), Expr::Str("hi there".to_string()));
474        assert_eq!(expr(r#""hi""#), Expr::Str("hi".to_string()));
475        assert_eq!(expr("42"), Expr::Number(42.0));
476        assert_eq!(expr("-1.5"), Expr::Number(-1.5));
477        assert_eq!(expr("true"), Expr::Bool(true));
478        assert_eq!(expr("null"), Expr::Null);
479    }
480
481    #[test]
482    fn and_binds_tighter_than_or() {
483        let context = Context::new();
484
485        // Parsed as `a || (b && c)`: with `a` false and `b` false the whole
486        // thing is false, which only holds if `&&` won.
487        assert_eq!(eval("false || false && true", &context), Json::Bool(false));
488        assert_eq!(eval("(false || false) && true", &context), Json::Bool(false));
489        assert_eq!(eval("true || false && false", &context), Json::Bool(true));
490    }
491
492    #[test]
493    fn comparisons_bind_tighter_than_boolean_operators() {
494        let context = Context::new().with("age", 30);
495
496        assert_eq!(eval("age > 18 && age < 65", &context), Json::Bool(true));
497        assert_eq!(eval("age == 30", &context), Json::Bool(true));
498        assert_eq!(eval("age != 30", &context), Json::Bool(false));
499        assert_eq!(eval("age >= 30 && age <= 30", &context), Json::Bool(true));
500    }
501
502    #[test]
503    fn negation_applies_to_truthiness() {
504        let context = Context::new().with("name", "");
505
506        assert_eq!(eval("!name", &context), Json::Bool(true));
507        assert_eq!(eval("!missing", &context), Json::Bool(true));
508        assert_eq!(eval("!!name", &context), Json::Bool(false));
509    }
510
511    #[test]
512    fn an_unknown_path_evaluates_to_null_instead_of_failing() {
513        assert_eq!(eval("user.address.city", &Context::new()), Json::Null);
514    }
515
516    #[test]
517    fn parses_an_argument_list() {
518        let text = "\"title\", 'Home'";
519        let span = Span { file: "test", source: text, offset: 0, text };
520        let arguments = parse_arguments(span).unwrap();
521
522        assert_eq!(arguments, vec![Expr::Str("title".into()), Expr::Str("Home".into())]);
523    }
524
525    #[test]
526    fn parses_a_foreach_head() {
527        let text = "user.posts as post";
528        let span = Span { file: "test", source: text, offset: 0, text };
529        let (subject, binding) = parse_foreach(span).unwrap();
530
531        assert_eq!(subject, Expr::Path("user.posts".into()));
532        assert_eq!(binding, "post");
533    }
534
535    #[test]
536    fn reports_the_column_of_a_broken_expression() {
537        let source = "\n@if(a == )\n";
538        let span = Span::new("home", source, 5, 5);
539
540        match parse(span).unwrap_err() {
541            Error::Template { line, column, message, .. } => {
542                assert_eq!((line, column), (2, 10));
543                assert!(message.contains("unexpected end"), "{message}");
544            }
545            other => panic!("expected a template error, got {other:?}"),
546        }
547    }
548
549    #[test]
550    fn rejects_assignment_and_stray_operators() {
551        let cases = ["a = 1", "a & b", "a +"];
552        for case in cases {
553            let span = Span { file: "test", source: case, offset: 0, text: case };
554            assert!(parse(span).is_err(), "`{case}` should not parse");
555        }
556    }
557}