1use crate::context::Scope;
9use crate::source::Span;
10use crate::value::{compare, truthy};
11use rustlavel_core::{Json, Result};
12use std::cmp::Ordering;
13
14#[derive(Debug, Clone, PartialEq)]
16pub enum Expr {
17 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#[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 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 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 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
86pub 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
94pub 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
113pub fn parse_foreach(span: Span<'_>) -> Result<(Expr, String)> {
115 let mut cursor = Cursor::new(span)?;
116 let subject = cursor.expression()?;
117
118 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#[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 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
188struct 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 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 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 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 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 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
311fn 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 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
405fn 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 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 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}