Skip to main content

pocopine_expr/
lib.rs

1//! Template expression parser + AST. Per RFC-012.
2//!
3//! This crate owns the **pure** half of pocopine's expression
4//! pipeline — lexer, parser, AST. The runtime evaluator
5//! (`pocopine_core::expr::evaluate`) and the proc-macro
6//! validator (`pocopine_macros::for_plan`, RFC 054) both depend
7//! on this crate.
8//!
9//! Splitting parser from evaluator lets the macro crate validate
10//! template expressions at `cargo check` time without pulling in
11//! `wasm-bindgen` / `web-sys` (host-incompatible).
12//!
13//! Hand-rolled recursive descent so:
14//!
15//! * the wasm bundle doesn't pay for a parser-combinator crate,
16//! * errors carry structured spans a future `.poco` language server
17//!   can consume directly (no second parse),
18//! * the AST is trivially re-evaluable — the runtime caches it once
19//!   per directive setup and walks it on every change.
20//!
21//! Grammar (operator precedence, lowest → highest):
22//!
23//! ```text
24//! expr       := stmt_seq
25//! stmt_seq   := stmt ( ';' stmt )*       // RFC-024
26//! stmt       := assign | ternary
27//! assign     := path '=' ternary         // RFC-024
28//! ternary    := logic_or ( '?' expr ':' expr )?
29//! logic_or   := logic_and ( '||' logic_and )*
30//! logic_and  := equality  ( '&&' equality  )*
31//! equality   := relation  ( ( '==' | '!=' ) relation )*
32//! relation   := additive  ( ( '<=' | '<' | '>=' | '>' ) additive )*
33//! additive   := unary     ( '+' unary )*
34//! unary      := '!' unary | primary
35//! primary    := literal | path | call | '(' expr ')'
36//! call       := ident '(' ( ternary ( ',' ternary )* )? ')'   // RFC-024
37//! literal    := string | number | 'true' | 'false' | 'null'
38//! string     := '"' [^"]* '"' | "'" [^']* "'"
39//! number     := /-?\d+(\.\d+)?/
40//! path       := ident ( '.' ident )*
41//! ident      := /[A-Za-z_$][A-Za-z0-9_$]*/
42//! ```
43
44use std::ops::Range;
45
46/// Byte-range span into the original source string.
47pub type Span = Range<usize>;
48
49#[derive(Debug, Clone)]
50pub struct Spanned<T> {
51    pub value: T,
52    pub span: Span,
53}
54
55#[derive(Debug, Clone)]
56pub enum Expr {
57    Literal(Literal),
58    /// Dotted path segments, already split: `"foo.bar.baz"` →
59    /// `vec!["foo", "bar", "baz"]`.
60    Path(Vec<String>),
61    Not(Box<Spanned<Expr>>),
62    BinOp(BinOp, Box<Spanned<Expr>>, Box<Spanned<Expr>>),
63    Ternary(Box<Spanned<Expr>>, Box<Spanned<Expr>>, Box<Spanned<Expr>>),
64    /// `ident(arg, arg, ...)` — invokes a handler on the scope.
65    /// RFC-024. Handlers resolve via `invoke_handler`; the name is
66    /// a single identifier, not a path.
67    Call(String, Vec<Spanned<Expr>>),
68    /// `path = expr` — writes `expr` through the scope proxy's
69    /// set trap at `path`. RFC-024. `path` is one or more dotted
70    /// identifiers.
71    Assign(Vec<String>, Box<Spanned<Expr>>),
72    /// `a; b; c` — evaluated left-to-right; result is the last
73    /// statement's value. RFC-024. Top-level form for `pp-on`
74    /// values containing multiple statements.
75    Seq(Vec<Spanned<Expr>>),
76}
77
78#[derive(Debug, Clone)]
79pub enum Literal {
80    Null,
81    Bool(bool),
82    Number(f64),
83    String(String),
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum BinOp {
88    And,
89    Or,
90    Eq,
91    Ne,
92    Lt,
93    Le,
94    Gt,
95    Ge,
96    /// `+` — string concatenation when either operand is a string;
97    /// numeric addition when both coerce to `f64`; empty string
98    /// otherwise. Matches JS coercion closely enough for templates
99    /// that compose IDs like `$id + '-title'`.
100    Plus,
101}
102
103#[derive(Debug, Clone)]
104pub struct ParseError {
105    pub message: String,
106    pub span: Span,
107    pub hint: Option<String>,
108}
109
110// ─── lexer ────────────────────────────────────────────────────────
111
112#[derive(Debug, Clone, PartialEq)]
113enum Tok {
114    LParen,
115    RParen,
116    Bang,
117    AndAnd,
118    OrOr,
119    EqEq,
120    BangEq,
121    Lt,
122    Le,
123    Gt,
124    Ge,
125    Plus,
126    Question,
127    Colon,
128    Dot,
129    /// `,` — separates call arguments.
130    Comma,
131    /// `;` — separates statements in a `pp-on` value.
132    Semi,
133    /// Single `=` — assignment, per RFC-024. Distinct from
134    /// `EqEq` (the equality check).
135    Eq,
136    Ident(String),
137    StringLit(String),
138    NumberLit(f64),
139    True,
140    False,
141    Null,
142    Eof,
143}
144
145struct Lexer<'a> {
146    src: &'a [u8],
147    pos: usize,
148}
149
150impl<'a> Lexer<'a> {
151    fn new(src: &'a str) -> Self {
152        Self {
153            src: src.as_bytes(),
154            pos: 0,
155        }
156    }
157
158    fn peek(&self, offset: usize) -> Option<u8> {
159        self.src.get(self.pos + offset).copied()
160    }
161
162    fn skip_whitespace(&mut self) {
163        while let Some(c) = self.peek(0) {
164            if c.is_ascii_whitespace() {
165                self.pos += 1;
166            } else {
167                break;
168            }
169        }
170    }
171
172    fn next(&mut self) -> Result<(Tok, Span), ParseError> {
173        self.skip_whitespace();
174        let start = self.pos;
175        let Some(c) = self.peek(0) else {
176            return Ok((Tok::Eof, start..start));
177        };
178        let tok = match c {
179            b'(' => {
180                self.pos += 1;
181                Tok::LParen
182            }
183            b')' => {
184                self.pos += 1;
185                Tok::RParen
186            }
187            b',' => {
188                self.pos += 1;
189                Tok::Comma
190            }
191            b';' => {
192                self.pos += 1;
193                Tok::Semi
194            }
195            b'+' => {
196                self.pos += 1;
197                Tok::Plus
198            }
199            // Arithmetic beyond `+` is not supported. Compute Rust-side
200            // as a `#[computed]` field and bind by name. See
201            // docs/guides/poco/04-expressions.md.
202            b'*' | b'/' | b'%' => {
203                return Err(self.err(
204                    start..start + 1,
205                    &format!(
206                        "arithmetic operator `{}` is not supported in pine-expr",
207                        c as char
208                    ),
209                    Some(
210                        "compute Rust-side as a `#[computed]` field and bind by name — see docs/guides/poco/04-expressions.md",
211                    ),
212                ));
213            }
214            // Bare `-` (not part of a number literal) is unsupported
215            // arithmetic. The number-literal case is the guarded arm
216            // below this one; the match-first-wins rule means we
217            // need the same guard inverted here so `-42` still
218            // lexes as a number.
219            b'-' if !matches!(self.peek(1), Some(b'0'..=b'9')) => {
220                return Err(self.err(
221                    start..start + 1,
222                    "arithmetic subtraction is not supported in pine-expr",
223                    Some(
224                        "compute Rust-side as a `#[computed]` field and bind by name — see docs/guides/poco/04-expressions.md",
225                    ),
226                ));
227            }
228            b'?' => {
229                self.pos += 1;
230                if self.peek(0) == Some(b'?') {
231                    return Err(self.err(
232                        start..self.pos + 1,
233                        "nullish coalescing `??` is not supported in pine-expr",
234                        Some("use a ternary (`x == null ? fallback : x`) or compute Rust-side"),
235                    ));
236                }
237                if self.peek(0) == Some(b'.') {
238                    return Err(self.err(
239                        start..self.pos + 1,
240                        "optional chaining `?.` is not supported in pine-expr",
241                        Some(
242                            "compute Rust-side as a `#[computed]` field that handles the null case",
243                        ),
244                    ));
245                }
246                Tok::Question
247            }
248            b':' => {
249                self.pos += 1;
250                Tok::Colon
251            }
252            b'.' => {
253                self.pos += 1;
254                if self.peek(0) == Some(b'.') {
255                    return Err(self.err(
256                        start..self.pos + 1,
257                        "spread `...` is not supported in pine-expr",
258                        Some("pass arguments explicitly or compute Rust-side"),
259                    ));
260                }
261                Tok::Dot
262            }
263            b'!' => {
264                self.pos += 1;
265                if self.peek(0) == Some(b'=') {
266                    self.pos += 1;
267                    if self.peek(0) == Some(b'=') {
268                        return Err(self.err(
269                            start..self.pos + 1,
270                            "`!==` is not supported in pine-expr",
271                            Some("use `!=` (pine-expr uses Rust-style equality)"),
272                        ));
273                    }
274                    Tok::BangEq
275                } else {
276                    Tok::Bang
277                }
278            }
279            b'=' => {
280                // `==` wins over `=` — peek the next byte before
281                // committing. Single `=` is assignment (RFC-024);
282                // `==` is equality (RFC-012). `===`, `!==`, and `=>`
283                // are rejected with directive messages so JS muscle
284                // memory fails loud at compile time.
285                if self.peek(1) == Some(b'=') {
286                    if self.peek(2) == Some(b'=') {
287                        return Err(self.err(
288                            start..self.pos + 3,
289                            "`===` is not supported in pine-expr",
290                            Some("use `==` (pine-expr uses Rust-style equality)"),
291                        ));
292                    }
293                    self.pos += 2;
294                    Tok::EqEq
295                } else if self.peek(1) == Some(b'>') {
296                    return Err(self.err(
297                        start..self.pos + 2,
298                        "arrow functions are not supported in pine-expr",
299                        Some(
300                            "define a handler method on the component — see docs/guides/poco/04-expressions.md",
301                        ),
302                    ));
303                } else {
304                    self.pos += 1;
305                    Tok::Eq
306                }
307            }
308            b'&' => {
309                if self.peek(1) != Some(b'&') {
310                    return Err(self.err(start..start + 1, "expected `&&`", None));
311                }
312                self.pos += 2;
313                Tok::AndAnd
314            }
315            b'|' => {
316                if self.peek(1) != Some(b'|') {
317                    return Err(self.err(start..start + 1, "expected `||`", None));
318                }
319                self.pos += 2;
320                Tok::OrOr
321            }
322            b'<' => {
323                self.pos += 1;
324                if self.peek(0) == Some(b'=') {
325                    self.pos += 1;
326                    Tok::Le
327                } else {
328                    Tok::Lt
329                }
330            }
331            b'>' => {
332                self.pos += 1;
333                if self.peek(0) == Some(b'=') {
334                    self.pos += 1;
335                    Tok::Ge
336                } else {
337                    Tok::Gt
338                }
339            }
340            b'"' | b'\'' => self.string(c, start)?,
341            b'0'..=b'9' => self.number(start)?,
342            b'-' if matches!(self.peek(1), Some(b'0'..=b'9')) => self.number(start)?,
343            c if is_ident_start(c) => self.ident(start),
344            _ => {
345                return Err(self.err(
346                    start..start + 1,
347                    &format!("unexpected character {:?}", c as char),
348                    None,
349                ));
350            }
351        };
352        Ok((tok, start..self.pos))
353    }
354
355    fn string(&mut self, quote: u8, start: usize) -> Result<Tok, ParseError> {
356        self.pos += 1; // opening quote
357        let content_start = self.pos;
358        while let Some(c) = self.peek(0) {
359            if c == quote {
360                let s = std::str::from_utf8(&self.src[content_start..self.pos])
361                    .unwrap_or_default()
362                    .to_string();
363                self.pos += 1; // closing quote
364                return Ok(Tok::StringLit(s));
365            }
366            self.pos += 1;
367        }
368        Err(self.err(start..self.pos, "unterminated string literal", None))
369    }
370
371    fn number(&mut self, start: usize) -> Result<Tok, ParseError> {
372        if self.peek(0) == Some(b'-') {
373            self.pos += 1;
374        }
375        while matches!(self.peek(0), Some(b'0'..=b'9')) {
376            self.pos += 1;
377        }
378        if self.peek(0) == Some(b'.') && matches!(self.peek(1), Some(b'0'..=b'9')) {
379            self.pos += 1; // dot
380            while matches!(self.peek(0), Some(b'0'..=b'9')) {
381                self.pos += 1;
382            }
383        }
384        let s = std::str::from_utf8(&self.src[start..self.pos]).unwrap_or_default();
385        let n = s.parse::<f64>().map_err(|_| ParseError {
386            message: format!("invalid number literal {s:?}"),
387            span: start..self.pos,
388            hint: None,
389        })?;
390        Ok(Tok::NumberLit(n))
391    }
392
393    fn ident(&mut self, start: usize) -> Tok {
394        while matches!(self.peek(0), Some(c) if is_ident_continue(c)) {
395            self.pos += 1;
396        }
397        let s = std::str::from_utf8(&self.src[start..self.pos]).unwrap_or_default();
398        match s {
399            "true" => Tok::True,
400            "false" => Tok::False,
401            "null" => Tok::Null,
402            _ => Tok::Ident(s.to_string()),
403        }
404    }
405
406    fn err(&self, span: Span, msg: &str, hint: Option<&str>) -> ParseError {
407        ParseError {
408            message: msg.to_string(),
409            span,
410            hint: hint.map(|s| s.to_string()),
411        }
412    }
413}
414
415fn is_ident_start(c: u8) -> bool {
416    c.is_ascii_alphabetic() || c == b'_' || c == b'$'
417}
418fn is_ident_continue(c: u8) -> bool {
419    c.is_ascii_alphanumeric() || c == b'_' || c == b'$'
420}
421
422// ─── parser ───────────────────────────────────────────────────────
423
424struct Parser {
425    toks: Vec<(Tok, Span)>,
426    pos: usize,
427}
428
429impl Parser {
430    fn new(src: &str) -> Result<Self, ParseError> {
431        let mut lex = Lexer::new(src);
432        let mut toks = Vec::new();
433        loop {
434            let (tok, span) = lex.next()?;
435            let is_eof = tok == Tok::Eof;
436            toks.push((tok, span));
437            if is_eof {
438                break;
439            }
440        }
441        Ok(Self { toks, pos: 0 })
442    }
443
444    fn peek(&self) -> &(Tok, Span) {
445        &self.toks[self.pos]
446    }
447
448    fn eat(&mut self, want: &Tok) -> bool {
449        if std::mem::discriminant(&self.peek().0) == std::mem::discriminant(want) {
450            self.pos += 1;
451            true
452        } else {
453            false
454        }
455    }
456
457    fn expect(&mut self, want: &Tok, msg: &str) -> Result<Span, ParseError> {
458        let (tok, span) = self.peek().clone();
459        if std::mem::discriminant(&tok) == std::mem::discriminant(want) {
460            self.pos += 1;
461            Ok(span)
462        } else {
463            Err(ParseError {
464                message: msg.to_string(),
465                span,
466                hint: None,
467            })
468        }
469    }
470
471    fn parse_expr(&mut self) -> Result<Spanned<Expr>, ParseError> {
472        let e = self.parse_stmt_seq()?;
473        if self.peek().0 != Tok::Eof {
474            let span = self.peek().1.clone();
475            return Err(ParseError {
476                message: "unexpected trailing tokens".to_string(),
477                span,
478                hint: None,
479            });
480        }
481        Ok(e)
482    }
483
484    /// One or more statements separated by `;`. A single-statement
485    /// value (the overwhelmingly common case) returns the inner
486    /// AST directly; two or more fold into `Expr::Seq`. Trailing
487    /// `;` is tolerated.
488    fn parse_stmt_seq(&mut self) -> Result<Spanned<Expr>, ParseError> {
489        let first = self.parse_stmt()?;
490        if !matches!(self.peek().0, Tok::Semi) {
491            return Ok(first);
492        }
493        let mut stmts = vec![first];
494        while self.eat(&Tok::Semi) {
495            if matches!(self.peek().0, Tok::Eof) {
496                break; // trailing `;`
497            }
498            stmts.push(self.parse_stmt()?);
499        }
500        let start = stmts.first().map(|s| s.span.start).unwrap_or(0);
501        let end = stmts.last().map(|s| s.span.end).unwrap_or(0);
502        Ok(Spanned {
503            value: Expr::Seq(stmts),
504            span: start..end,
505        })
506    }
507
508    /// Either `path = expr` (assignment, RFC-024) or a plain
509    /// expression. Assignment is recognised by a look-ahead: if the
510    /// token after a `Path` primary is `=` (single `=`, not `==`),
511    /// treat the path as an l-value and consume the RHS.
512    fn parse_stmt(&mut self) -> Result<Spanned<Expr>, ParseError> {
513        let lhs = self.parse_expr_top()?;
514        if !matches!(self.peek().0, Tok::Eq) {
515            return Ok(lhs);
516        }
517        // Assignment. LHS must be a plain path.
518        let Expr::Path(segments) = lhs.value else {
519            let span = self.peek().1.clone();
520            return Err(ParseError {
521                message: "left side of `=` must be a path".to_string(),
522                span,
523                hint: Some(
524                    "only dotted identifiers like `foo` or `foo.bar` are assignable".to_string(),
525                ),
526            });
527        };
528        self.pos += 1; // consume `=`
529        let rhs = self.parse_expr_top()?;
530        let span = lhs.span.start..rhs.span.end;
531        Ok(Spanned {
532            value: Expr::Assign(segments, Box::new(rhs)),
533            span,
534        })
535    }
536
537    fn parse_expr_top(&mut self) -> Result<Spanned<Expr>, ParseError> {
538        self.parse_ternary()
539    }
540
541    fn parse_ternary(&mut self) -> Result<Spanned<Expr>, ParseError> {
542        let cond = self.parse_or()?;
543        if self.eat(&Tok::Question) {
544            let then_e = self.parse_expr_top()?;
545            self.expect(&Tok::Colon, "expected `:` in ternary expression")?;
546            let else_e = self.parse_expr_top()?;
547            let span = cond.span.start..else_e.span.end;
548            return Ok(Spanned {
549                value: Expr::Ternary(Box::new(cond), Box::new(then_e), Box::new(else_e)),
550                span,
551            });
552        }
553        Ok(cond)
554    }
555
556    fn parse_or(&mut self) -> Result<Spanned<Expr>, ParseError> {
557        let mut lhs = self.parse_and()?;
558        while self.eat(&Tok::OrOr) {
559            let rhs = self.parse_and()?;
560            let span = lhs.span.start..rhs.span.end;
561            lhs = Spanned {
562                value: Expr::BinOp(BinOp::Or, Box::new(lhs), Box::new(rhs)),
563                span,
564            };
565        }
566        Ok(lhs)
567    }
568
569    fn parse_and(&mut self) -> Result<Spanned<Expr>, ParseError> {
570        let mut lhs = self.parse_equality()?;
571        while self.eat(&Tok::AndAnd) {
572            let rhs = self.parse_equality()?;
573            let span = lhs.span.start..rhs.span.end;
574            lhs = Spanned {
575                value: Expr::BinOp(BinOp::And, Box::new(lhs), Box::new(rhs)),
576                span,
577            };
578        }
579        Ok(lhs)
580    }
581
582    fn parse_equality(&mut self) -> Result<Spanned<Expr>, ParseError> {
583        let mut lhs = self.parse_relation()?;
584        loop {
585            let op = match self.peek().0 {
586                Tok::EqEq => BinOp::Eq,
587                Tok::BangEq => BinOp::Ne,
588                _ => break,
589            };
590            self.pos += 1;
591            let rhs = self.parse_relation()?;
592            let span = lhs.span.start..rhs.span.end;
593            lhs = Spanned {
594                value: Expr::BinOp(op, Box::new(lhs), Box::new(rhs)),
595                span,
596            };
597        }
598        Ok(lhs)
599    }
600
601    fn parse_relation(&mut self) -> Result<Spanned<Expr>, ParseError> {
602        let mut lhs = self.parse_additive()?;
603        loop {
604            let op = match self.peek().0 {
605                Tok::Le => BinOp::Le,
606                Tok::Lt => BinOp::Lt,
607                Tok::Ge => BinOp::Ge,
608                Tok::Gt => BinOp::Gt,
609                _ => break,
610            };
611            self.pos += 1;
612            let rhs = self.parse_additive()?;
613            let span = lhs.span.start..rhs.span.end;
614            lhs = Spanned {
615                value: Expr::BinOp(op, Box::new(lhs), Box::new(rhs)),
616                span,
617            };
618        }
619        Ok(lhs)
620    }
621
622    fn parse_additive(&mut self) -> Result<Spanned<Expr>, ParseError> {
623        let mut lhs = self.parse_unary()?;
624        while self.eat(&Tok::Plus) {
625            let rhs = self.parse_unary()?;
626            let span = lhs.span.start..rhs.span.end;
627            lhs = Spanned {
628                value: Expr::BinOp(BinOp::Plus, Box::new(lhs), Box::new(rhs)),
629                span,
630            };
631        }
632        Ok(lhs)
633    }
634
635    fn parse_unary(&mut self) -> Result<Spanned<Expr>, ParseError> {
636        let (tok, span) = self.peek().clone();
637        if tok == Tok::Bang {
638            self.pos += 1;
639            let inner = self.parse_unary()?;
640            let outer = span.start..inner.span.end;
641            return Ok(Spanned {
642                value: Expr::Not(Box::new(inner)),
643                span: outer,
644            });
645        }
646        self.parse_primary()
647    }
648
649    fn parse_primary(&mut self) -> Result<Spanned<Expr>, ParseError> {
650        let (tok, span) = self.peek().clone();
651        match tok {
652            Tok::LParen => {
653                self.pos += 1;
654                let e = self.parse_expr_top()?;
655                self.expect(&Tok::RParen, "expected `)`")?;
656                Ok(e)
657            }
658            Tok::StringLit(s) => {
659                self.pos += 1;
660                Ok(Spanned {
661                    value: Expr::Literal(Literal::String(s)),
662                    span,
663                })
664            }
665            Tok::NumberLit(n) => {
666                self.pos += 1;
667                Ok(Spanned {
668                    value: Expr::Literal(Literal::Number(n)),
669                    span,
670                })
671            }
672            Tok::True => {
673                self.pos += 1;
674                Ok(Spanned {
675                    value: Expr::Literal(Literal::Bool(true)),
676                    span,
677                })
678            }
679            Tok::False => {
680                self.pos += 1;
681                Ok(Spanned {
682                    value: Expr::Literal(Literal::Bool(false)),
683                    span,
684                })
685            }
686            Tok::Null => {
687                self.pos += 1;
688                Ok(Spanned {
689                    value: Expr::Literal(Literal::Null),
690                    span,
691                })
692            }
693            Tok::Ident(first) => {
694                self.pos += 1;
695                let start = span.start;
696                // `ident(` → call. Only plain identifiers can be
697                // called; `foo.bar(…)` is not supported in v0
698                // (handlers are scope methods, not properties on
699                // sub-objects).
700                if matches!(self.peek().0, Tok::LParen) {
701                    self.pos += 1; // consume `(`
702                    let mut args = Vec::new();
703                    if !matches!(self.peek().0, Tok::RParen) {
704                        loop {
705                            args.push(self.parse_expr_top()?);
706                            if self.eat(&Tok::Comma) {
707                                continue;
708                            }
709                            break;
710                        }
711                    }
712                    let end = self.peek().1.end;
713                    self.expect(&Tok::RParen, "expected `)` closing call arguments")?;
714                    return Ok(Spanned {
715                        value: Expr::Call(first, args),
716                        span: start..end,
717                    });
718                }
719                let mut segments = vec![first];
720                let mut end = span.end;
721                while self.eat(&Tok::Dot) {
722                    let (tok, s) = self.peek().clone();
723                    match tok {
724                        Tok::Ident(seg) => {
725                            self.pos += 1;
726                            end = s.end;
727                            segments.push(seg);
728                        }
729                        _ => {
730                            return Err(ParseError {
731                                message: "expected identifier after `.`".to_string(),
732                                span: s,
733                                hint: None,
734                            });
735                        }
736                    }
737                }
738                // `obj.method(…)` — only plain identifiers can be
739                // called. A path followed by `(` is the JS muscle
740                // memory we want to teach against: define a plain
741                // identifier handler that takes the object as an
742                // argument instead.
743                if matches!(self.peek().0, Tok::LParen) && segments.len() > 1 {
744                    return Err(ParseError {
745                        message: "method calls on objects are not supported in pine-expr"
746                            .to_string(),
747                        span: self.peek().1.clone(),
748                        hint: Some(
749                            "define a plain identifier handler, or a `#[computed]` field, that takes the object as an argument — see docs/guides/poco/04-expressions.md"
750                                .to_string(),
751                        ),
752                    });
753                }
754                Ok(Spanned {
755                    value: Expr::Path(segments),
756                    span: start..end,
757                })
758            }
759            _ => Err(ParseError {
760                message: "expected expression".to_string(),
761                span,
762                hint: None,
763            }),
764        }
765    }
766}
767
768/// Parse `src` into a reusable AST. Returns structured errors with
769/// byte spans so a future `.poco` LSP can surface them without a
770/// second parse.
771pub fn parse(src: &str) -> Result<Spanned<Expr>, ParseError> {
772    if src.trim().is_empty() {
773        return Err(ParseError {
774            message: "empty expression".to_string(),
775            span: 0..src.len(),
776            hint: None,
777        });
778    }
779    let mut p = Parser::new(src)?;
780    p.parse_expr()
781}
782
783// ─── tests ────────────────────────────────────────────────────────
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788
789    fn parse_ok(src: &str) -> Spanned<Expr> {
790        parse(src).unwrap_or_else(|e| panic!("parse failed for {src:?}: {:?}", e))
791    }
792
793    fn parse_err(src: &str) {
794        assert!(parse(src).is_err(), "expected parse error for {src:?}");
795    }
796
797    #[test]
798    fn literals() {
799        matches!(parse_ok("true").value, Expr::Literal(Literal::Bool(true)));
800        matches!(parse_ok("false").value, Expr::Literal(Literal::Bool(false)));
801        matches!(parse_ok("null").value, Expr::Literal(Literal::Null));
802        matches!(parse_ok("42").value, Expr::Literal(Literal::Number(_)));
803        matches!(parse_ok("\"hi\"").value, Expr::Literal(Literal::String(_)));
804    }
805
806    #[test]
807    fn path_segments() {
808        let e = parse_ok("foo.bar.baz");
809        match e.value {
810            Expr::Path(segs) => assert_eq!(segs, vec!["foo", "bar", "baz"]),
811            other => panic!("expected Path, got {other:?}"),
812        }
813    }
814
815    #[test]
816    fn precedence() {
817        // `a && b || c` → `(a && b) || c`
818        let e = parse_ok("a && b || c");
819        match e.value {
820            Expr::BinOp(BinOp::Or, lhs, rhs) => {
821                assert!(matches!(lhs.value, Expr::BinOp(BinOp::And, _, _)));
822                assert!(matches!(rhs.value, Expr::Path(_)));
823            }
824            other => panic!("expected OR at top, got {other:?}"),
825        }
826    }
827
828    #[test]
829    fn not_prefix_and_comparison() {
830        let e = parse_ok("!(a == b)");
831        assert!(matches!(e.value, Expr::Not(_)));
832    }
833
834    #[test]
835    fn ternary_right_associative() {
836        // a ? b : c ? d : e  ==  a ? b : (c ? d : e)
837        let e = parse_ok("a ? b : c ? d : e");
838        match e.value {
839            Expr::Ternary(_, _, else_e) => {
840                assert!(matches!(else_e.value, Expr::Ternary(_, _, _)));
841            }
842            other => panic!("expected Ternary, got {other:?}"),
843        }
844    }
845
846    #[test]
847    fn string_literals_both_quotes() {
848        matches!(parse_ok("'hello'").value, Expr::Literal(Literal::String(_)));
849        matches!(
850            parse_ok("\"world\"").value,
851            Expr::Literal(Literal::String(_))
852        );
853    }
854
855    #[test]
856    fn errors_carry_spans() {
857        match parse("a ||") {
858            Err(ParseError { span, .. }) => assert!(span.start >= 3),
859            Ok(_) => panic!("expected error"),
860        }
861    }
862
863    // RFC-024: `a = b` is now a valid assignment statement.
864    #[test]
865    fn parses_assignment() {
866        let e = parse_ok("open = true");
867        match e.value {
868            Expr::Assign(ref path, ref rhs) => {
869                assert_eq!(path, &vec!["open".to_string()]);
870                assert!(matches!(rhs.value, Expr::Literal(Literal::Bool(true))));
871            }
872            other => panic!("expected Assign, got {other:?}"),
873        }
874    }
875
876    #[test]
877    fn parses_call_zero_args() {
878        let e = parse_ok("close()");
879        match e.value {
880            Expr::Call(name, args) => {
881                assert_eq!(name, "close");
882                assert!(args.is_empty());
883            }
884            other => panic!("expected Call, got {other:?}"),
885        }
886    }
887
888    #[test]
889    fn parses_call_with_args() {
890        let e = parse_ok("select(item.value, 42)");
891        match e.value {
892            Expr::Call(name, args) => {
893                assert_eq!(name, "select");
894                assert_eq!(args.len(), 2);
895                assert!(matches!(args[0].value, Expr::Path(_)));
896                assert!(matches!(args[1].value, Expr::Literal(Literal::Number(_))));
897            }
898            other => panic!("expected Call, got {other:?}"),
899        }
900    }
901
902    #[test]
903    fn parses_statement_sequence() {
904        let e = parse_ok("copy($event); close()");
905        match e.value {
906            Expr::Seq(ref stmts) => {
907                assert_eq!(stmts.len(), 2);
908                assert!(matches!(stmts[0].value, Expr::Call(_, _)));
909                assert!(matches!(stmts[1].value, Expr::Call(_, _)));
910            }
911            other => panic!("expected Seq, got {other:?}"),
912        }
913    }
914
915    #[test]
916    fn assignment_rhs_can_be_expression() {
917        // `open = !open` parses with `!open` as the RHS.
918        let e = parse_ok("open = !open");
919        match e.value {
920            Expr::Assign(path, rhs) => {
921                assert_eq!(path, vec!["open".to_string()]);
922                assert!(matches!(rhs.value, Expr::Not(_)));
923            }
924            other => panic!("expected Assign, got {other:?}"),
925        }
926    }
927
928    #[test]
929    fn rejects_trailing_garbage() {
930        parse_err("a b");
931    }
932
933    // Associativity + nesting coverage — the grammar commits to
934    // left-associative binops and right-associative ternary, matching
935    // JS / Vue / React semantics.
936
937    #[test]
938    fn and_is_left_associative() {
939        // `a && b && c` → `(a && b) && c`
940        let e = parse_ok("a && b && c");
941        match e.value {
942            Expr::BinOp(BinOp::And, lhs, rhs) => {
943                assert!(
944                    matches!(lhs.value, Expr::BinOp(BinOp::And, _, _)),
945                    "left arm should be another AND",
946                );
947                assert!(
948                    matches!(rhs.value, Expr::Path(_)),
949                    "right arm should be a leaf path",
950                );
951            }
952            other => panic!("expected top-level AND, got {other:?}"),
953        }
954    }
955
956    #[test]
957    fn or_is_left_associative() {
958        let e = parse_ok("a || b || c");
959        match e.value {
960            Expr::BinOp(BinOp::Or, lhs, rhs) => {
961                assert!(matches!(lhs.value, Expr::BinOp(BinOp::Or, _, _)));
962                assert!(matches!(rhs.value, Expr::Path(_)));
963            }
964            other => panic!("expected top-level OR, got {other:?}"),
965        }
966    }
967
968    #[test]
969    fn equality_is_left_associative() {
970        // `a == b == c` → `(a == b) == c`. Unusual but legal.
971        let e = parse_ok("a == b == c");
972        match e.value {
973            Expr::BinOp(BinOp::Eq, lhs, rhs) => {
974                assert!(matches!(lhs.value, Expr::BinOp(BinOp::Eq, _, _)));
975                assert!(matches!(rhs.value, Expr::Path(_)));
976            }
977            other => panic!("expected EQ at top, got {other:?}"),
978        }
979    }
980
981    #[test]
982    fn not_or_and_mixed_precedence() {
983        // `!a || b && c` → `(!a) || (b && c)` —
984        // `!` binds tightest, `&&` tighter than `||`.
985        let e = parse_ok("!a || b && c");
986        match e.value {
987            Expr::BinOp(BinOp::Or, lhs, rhs) => {
988                assert!(matches!(lhs.value, Expr::Not(_)), "left is `!a`");
989                assert!(
990                    matches!(rhs.value, Expr::BinOp(BinOp::And, _, _)),
991                    "right is `b && c`",
992                );
993            }
994            other => panic!("expected OR at top, got {other:?}"),
995        }
996    }
997
998    #[test]
999    fn relation_tighter_than_equality_tighter_than_and() {
1000        // `a < b == c && d` → `((a < b) == c) && d`
1001        let e = parse_ok("a < b == c && d");
1002        match e.value {
1003            Expr::BinOp(BinOp::And, lhs, rhs) => {
1004                match lhs.value {
1005                    Expr::BinOp(BinOp::Eq, eq_l, _) => {
1006                        assert!(matches!(eq_l.value, Expr::BinOp(BinOp::Lt, _, _)));
1007                    }
1008                    other => panic!("expected EQ inside AND's left, got {other:?}"),
1009                }
1010                assert!(matches!(rhs.value, Expr::Path(_)));
1011            }
1012            other => panic!("expected AND at top, got {other:?}"),
1013        }
1014    }
1015
1016    #[test]
1017    fn parens_override_precedence() {
1018        // `a && (b || c)` → top is AND, not OR as it would be without parens.
1019        let e = parse_ok("a && (b || c)");
1020        match e.value {
1021            Expr::BinOp(BinOp::And, _, rhs) => {
1022                assert!(matches!(rhs.value, Expr::BinOp(BinOp::Or, _, _)));
1023            }
1024            other => panic!("expected AND at top after parens, got {other:?}"),
1025        }
1026    }
1027
1028    #[test]
1029    fn deeply_nested_parens() {
1030        // `((a || b) && (c || d))` — two-level paren nest resolves
1031        // correctly; top is AND.
1032        let e = parse_ok("((a || b) && (c || d))");
1033        match e.value {
1034            Expr::BinOp(BinOp::And, lhs, rhs) => {
1035                assert!(matches!(lhs.value, Expr::BinOp(BinOp::Or, _, _)));
1036                assert!(matches!(rhs.value, Expr::BinOp(BinOp::Or, _, _)));
1037            }
1038            other => panic!("expected AND between two OR groups, got {other:?}"),
1039        }
1040    }
1041
1042    #[test]
1043    fn nested_path_many_segments() {
1044        let e = parse_ok("a.b.c.d.e");
1045        match e.value {
1046            Expr::Path(segs) => assert_eq!(segs, vec!["a", "b", "c", "d", "e"]),
1047            other => panic!("expected Path, got {other:?}"),
1048        }
1049    }
1050
1051    #[test]
1052    fn ternary_with_complex_condition() {
1053        // `a && b ? c : d` — `&&` binds tighter than `?:`, so
1054        // condition is `(a && b)`.
1055        let e = parse_ok("a && b ? c : d");
1056        match e.value {
1057            Expr::Ternary(cond, _, _) => {
1058                assert!(matches!(cond.value, Expr::BinOp(BinOp::And, _, _)));
1059            }
1060            other => panic!("expected Ternary, got {other:?}"),
1061        }
1062    }
1063
1064    #[test]
1065    fn ternary_in_ternary_branches() {
1066        // `a ? (b ? c : d) : (e ? f : g)` — nested ternary in both arms.
1067        let e = parse_ok("a ? (b ? c : d) : (e ? f : g)");
1068        match e.value {
1069            Expr::Ternary(_, then_e, else_e) => {
1070                assert!(matches!(then_e.value, Expr::Ternary(_, _, _)));
1071                assert!(matches!(else_e.value, Expr::Ternary(_, _, _)));
1072            }
1073            other => panic!("expected Ternary, got {other:?}"),
1074        }
1075    }
1076
1077    #[test]
1078    fn unary_not_stacks() {
1079        // `!!a` → Not(Not(a)). No special-case collapse; the
1080        // evaluator computes `true` for truthy `a`.
1081        let e = parse_ok("!!a");
1082        match e.value {
1083            Expr::Not(inner) => assert!(matches!(inner.value, Expr::Not(_))),
1084            other => panic!("expected Not(Not), got {other:?}"),
1085        }
1086    }
1087
1088    #[test]
1089    fn comparison_with_string_literal() {
1090        let e = parse_ok("role == 'admin' || role == \"editor\"");
1091        assert!(matches!(e.value, Expr::BinOp(BinOp::Or, _, _)));
1092    }
1093
1094    // ─── RFC-018 — `+` operator ─────────────────────────────────
1095
1096    #[test]
1097    fn plus_chains_left_to_right() {
1098        let e = parse_ok("a + b + c");
1099        // (a + b) + c
1100        match e.value {
1101            Expr::BinOp(BinOp::Plus, lhs, rhs) => {
1102                assert!(matches!(lhs.value, Expr::BinOp(BinOp::Plus, _, _)));
1103                assert!(matches!(rhs.value, Expr::Path(_)));
1104            }
1105            other => panic!("expected BinOp Plus, got {other:?}"),
1106        }
1107    }
1108
1109    #[test]
1110    fn plus_binds_tighter_than_comparison() {
1111        let e = parse_ok("a + b == 'foo-bar'");
1112        match e.value {
1113            Expr::BinOp(BinOp::Eq, lhs, _) => {
1114                assert!(matches!(lhs.value, Expr::BinOp(BinOp::Plus, _, _)));
1115            }
1116            other => panic!("expected equality, got {other:?}"),
1117        }
1118    }
1119
1120    #[test]
1121    fn plus_binds_looser_than_unary_not() {
1122        // `!a + !b` should parse as `(!a) + (!b)` — i.e. `+` is
1123        // outside, `!` is inside.
1124        let e = parse_ok("!a + !b");
1125        match e.value {
1126            Expr::BinOp(BinOp::Plus, lhs, rhs) => {
1127                assert!(matches!(lhs.value, Expr::Not(_)));
1128                assert!(matches!(rhs.value, Expr::Not(_)));
1129            }
1130            other => panic!("expected plus at root, got {other:?}"),
1131        }
1132    }
1133
1134    #[test]
1135    fn plus_with_string_literal_parses() {
1136        let e = parse_ok("$id + '-title'");
1137        assert!(matches!(e.value, Expr::BinOp(BinOp::Plus, _, _)));
1138    }
1139
1140    // ── Teaching errors: JS muscle memory rejected at compile time ──
1141    //
1142    // Every case below was already a parse error with a less helpful
1143    // message. These tests pin the directive wording so future edits
1144    // don't regress the diagnostic.
1145
1146    fn assert_err_says(src: &str, needle: &str) {
1147        let err = parse(src).expect_err(&format!("expected parse error for {src:?}"));
1148        assert!(
1149            err.message.contains(needle) || err.hint.as_deref().unwrap_or("").contains(needle),
1150            "error for {src:?} should mention {needle:?}; got message={:?} hint={:?}",
1151            err.message,
1152            err.hint,
1153        );
1154    }
1155
1156    #[test]
1157    fn reject_arithmetic_star() {
1158        assert_err_says("progress * 100", "arithmetic operator");
1159        assert_err_says("progress * 100", "#[computed]");
1160    }
1161
1162    #[test]
1163    fn reject_arithmetic_slash() {
1164        assert_err_says("total / count", "arithmetic operator");
1165    }
1166
1167    #[test]
1168    fn reject_arithmetic_percent() {
1169        assert_err_says("idx % 2", "arithmetic operator");
1170    }
1171
1172    #[test]
1173    fn reject_arithmetic_minus() {
1174        assert_err_says("count - 1", "subtraction");
1175        assert_err_says("count - 1", "#[computed]");
1176    }
1177
1178    #[test]
1179    fn reject_triple_equals() {
1180        assert_err_says("status === 'queued'", "`===`");
1181        assert_err_says("status === 'queued'", "use `==`");
1182    }
1183
1184    #[test]
1185    fn reject_strict_inequality() {
1186        assert_err_says("status !== 'queued'", "`!==`");
1187        assert_err_says("status !== 'queued'", "use `!=`");
1188    }
1189
1190    #[test]
1191    fn reject_arrow_function() {
1192        assert_err_says("x => x", "arrow functions");
1193        assert_err_says("x => x", "handler method");
1194    }
1195
1196    #[test]
1197    fn reject_nullish_coalescing() {
1198        assert_err_says("a ?? b", "nullish coalescing");
1199    }
1200
1201    #[test]
1202    fn reject_optional_chaining() {
1203        assert_err_says("user?.name", "optional chaining");
1204    }
1205
1206    #[test]
1207    fn reject_spread() {
1208        assert_err_says("...rest", "spread");
1209    }
1210
1211    #[test]
1212    fn reject_method_call_on_path() {
1213        // `files.filter(...)` — a path of length >= 2 followed by `(`
1214        // is the JS muscle-memory case we want to flag.
1215        assert_err_says("files.filter(f)", "method calls on objects");
1216        assert_err_says("files.filter(f)", "#[computed]");
1217    }
1218
1219    #[test]
1220    fn negative_number_literal_still_parses() {
1221        // The `-` rejection must not break `-42` as a number literal.
1222        let e = parse_ok("-42");
1223        assert!(matches!(
1224            e.value,
1225            Expr::Literal(Literal::Number(n)) if n == -42.0
1226        ));
1227    }
1228
1229    #[test]
1230    fn plain_identifier_call_still_parses() {
1231        // `obj.method(...)` is rejected, but `method(obj)` is the
1232        // canonical replacement and must still parse.
1233        let e = parse_ok("filter_done(files)");
1234        assert!(matches!(e.value, Expr::Call(_, _)));
1235    }
1236}