Skip to main content

core_query/cypher/
parser.rs

1//! Recursive-descent parser for the Cypher subset. Never panics on any token sequence.
2
3use super::ast::{
4    AggArg, AggFunc, ArithOp, CreateEdge, CreateNode, CreateStmt, EdgeDelete, Expr, HopRange,
5    LimitSkip, MatchDeleteNodeStmt, MatchDeleteStmt, MatchSetStmt, MergeStmt, NodePat, Operand,
6    OptionalClause, OrderItem, OrderTarget, Pattern, Query, RelDir, RelPat, RetItem, RetVal,
7    SetClause, UnwindClause, UnwindExpr, WithStage, WriteStatement,
8};
9use super::Tok;
10use crate::filter::CmpOp;
11use core_storage::{Value, NS_PROP};
12
13/// Max parenthesized-expression nesting. Deeper input is `Err`, not a stack overflow.
14const MAX_PAREN_DEPTH: usize = 64;
15
16/// Parse a tokenized Cypher subset query. Every failure is `Err(String)`; this
17/// function never panics on a well-formed `&[Tok]` (including empty / garbage).
18pub fn parse(tokens: &[Tok]) -> Result<Query, String> {
19    let mut p = Parser {
20        toks: tokens,
21        pos: 0,
22    };
23    let q = p.query()?;
24    if p.pos < p.toks.len() {
25        // A single-query parse: a trailing `UNION` (or anything else) is an
26        // error here. Use `parse_read` to accept a `UNION` chain.
27        return Err(p.unsupported_or_unexpected("unexpected tokens after query"));
28    }
29    Ok(q)
30}
31
32/// A read query that may be a single query or a `UNION` / `UNION ALL` chain.
33/// `all_flags[i]` is `true` when the boundary before `parts[i + 1]` was
34/// `UNION ALL` (bag union); `false` for `UNION` (distinct). Length is
35/// `parts.len() - 1`.
36#[derive(Debug, Clone, PartialEq)]
37pub struct UnionQuery {
38    pub parts: Vec<Query>,
39    pub all_flags: Vec<bool>,
40}
41
42/// Parse a read query, accepting a `UNION` / `UNION ALL` chain.
43pub fn parse_read(tokens: &[Tok]) -> Result<UnionQuery, String> {
44    let mut p = Parser {
45        toks: tokens,
46        pos: 0,
47    };
48    let mut parts = vec![p.query()?];
49    let mut all_flags = Vec::new();
50    while p.peek_is_union() {
51        p.pos += 1; // consume UNION
52        let all = matches!(p.peek(), Some(Tok::Ident(s)) if s.eq_ignore_ascii_case("all"));
53        if all {
54            p.pos += 1; // consume ALL
55        }
56        all_flags.push(all);
57        parts.push(p.query()?);
58    }
59    if p.pos < p.toks.len() {
60        return Err(p.err("unexpected tokens after UNION query"));
61    }
62    Ok(UnionQuery { parts, all_flags })
63}
64
65/// Parse a tokenized write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
66/// Returns `Err` for read queries or malformed write statements.
67pub fn parse_write(tokens: &[Tok]) -> Result<WriteStatement, String> {
68    let mut p = Parser {
69        toks: tokens,
70        pos: 0,
71    };
72    p.write_statement()
73}
74
75/// Return true if the token stream starts with a write keyword, or is a MATCH
76/// statement followed by SET or DELETE.  Used for fast server-side dispatch
77/// without a full parse.
78pub fn is_write_tokens(tokens: &[Tok]) -> bool {
79    match tokens.first() {
80        Some(Tok::Create) | Some(Tok::Merge) => true,
81        Some(Tok::Match) => tokens.iter().any(|t| matches!(t, Tok::Set | Tok::Delete)),
82        _ => false,
83    }
84}
85
86struct Parser<'a> {
87    toks: &'a [Tok],
88    pos: usize,
89}
90
91impl<'a> Parser<'a> {
92    fn peek(&self) -> Option<&'a Tok> {
93        self.toks.get(self.pos)
94    }
95
96    fn eat(&mut self, want: &Tok) -> bool {
97        if self.peek() == Some(want) {
98            self.pos += 1;
99            true
100        } else {
101            false
102        }
103    }
104
105    fn err(&self, msg: &str) -> String {
106        match self.peek() {
107            Some(tok) => format!("parse error at token {}: {msg} (found {tok:?})", self.pos),
108            None => format!(
109                "parse error at token {}: {msg} (found end of input)",
110                self.pos
111            ),
112        }
113    }
114
115    fn expect(&mut self, want: &Tok, what: &str) -> Result<(), String> {
116        if self.eat(want) {
117            Ok(())
118        } else {
119            Err(self.err(what))
120        }
121    }
122
123    fn ident(&mut self, what: &str) -> Result<String, String> {
124        match self.peek() {
125            Some(Tok::Ident(s)) => {
126                let s = s.clone();
127                self.pos += 1;
128                Ok(s)
129            }
130            _ => Err(self.err(what)),
131        }
132    }
133
134    fn query(&mut self) -> Result<Query, String> {
135        let mut matches = Vec::new();
136        while self.peek() == Some(&Tok::Match) {
137            matches.extend(self.match_clause()?);
138        }
139        if matches.is_empty() {
140            return Err(self.err("expected MATCH"));
141        }
142        // Optional WHERE clause that follows the required MATCH(es) — may come
143        // before or after OPTIONAL MATCH.  In standard Cypher, WHERE applies to
144        // the preceding MATCH, so `MATCH (a) WHERE … OPTIONAL MATCH … RETURN`
145        // is legal.  Parse it here, then parse OPTIONAL MATCHes, then check
146        // again in case WHERE follows the OPTIONAL MATCHes instead.
147        let where_expr_pre = if self.eat(&Tok::Where) {
148            Some(self.expr(0)?)
149        } else {
150            None
151        };
152        // Optional MATCH clauses (zero or more, after required MATCHes + WHERE).
153        let mut optional_clauses = Vec::new();
154        while self.peek() == Some(&Tok::Optional) {
155            optional_clauses.push(self.optional_match_clause()?);
156        }
157        // WHERE may also appear *after* OPTIONAL MATCHes (if not already seen).
158        let where_expr = if where_expr_pre.is_some() {
159            where_expr_pre
160        } else if self.eat(&Tok::Where) {
161            Some(self.expr(0)?)
162        } else {
163            None
164        };
165        // Optional top-level UNWIND clauses (after WHERE, before WITH).
166        let mut unwinds = Vec::new();
167        while self.peek() == Some(&Tok::Unwind) {
168            unwinds.push(self.unwind_clause()?);
169        }
170        // Optional WHERE that follows UNWIND (references UNWIND aliases).
171        let post_unwind_where = if !unwinds.is_empty() && self.eat(&Tok::Where) {
172            Some(self.expr(0)?)
173        } else {
174            None
175        };
176        // WITH pipeline stages (zero or more).
177        let mut stages = Vec::new();
178        while self.peek() == Some(&Tok::With) {
179            stages.push(self.with_stage()?);
180        }
181        let (distinct, returns) = self.return_clause()?;
182        let aliases: Vec<&str> = returns.iter().filter_map(|r| r.alias.as_deref()).collect();
183        let order_by = if self.peek() == Some(&Tok::Order) {
184            self.order_clause(&aliases)?
185        } else {
186            Vec::new()
187        };
188        let skip = if self.eat(&Tok::Skip) {
189            Some(self.uint("SKIP")?)
190        } else {
191            None
192        };
193        let limit = if self.eat(&Tok::Limit) {
194            Some(self.uint("LIMIT")?)
195        } else {
196            None
197        };
198        // NOTE: the leftover-token check lives in `parse` / `parse_read`, not
199        // here, so `query()` can stop cleanly at a `UNION` boundary.
200        Ok(Query {
201            matches,
202            optional_clauses,
203            where_expr,
204            unwinds,
205            post_unwind_where,
206            stages,
207            returns,
208            distinct,
209            order_by,
210            skip,
211            limit,
212        })
213    }
214
215    /// True if the next token is the `UNION` keyword (an ordinary identifier).
216    fn peek_is_union(&self) -> bool {
217        matches!(self.peek(), Some(Tok::Ident(s)) if s.eq_ignore_ascii_case("union"))
218    }
219
220    /// Named errors for still-unsupported Cypher forms (`UNION`, `CASE`).
221    fn unsupported_or_unexpected(&self, msg: &str) -> String {
222        match self.peek() {
223            Some(Tok::Ident(s)) if s.eq_ignore_ascii_case("union") => {
224                "UNION is not supported".to_string()
225            }
226            Some(Tok::Ident(s)) if s.eq_ignore_ascii_case("case") => {
227                "CASE is not supported".to_string()
228            }
229            _ => self.err(msg),
230        }
231    }
232
233    /// Consume an identifier keyword (case-insensitive). Keywords that are
234    /// not lexer tokens (`IN`, `DISTINCT`, `ON`) stay `Ident` so they can
235    /// still be used as variable names in other positions.
236    fn eat_ident_kw(&mut self, kw: &str) -> bool {
237        if let Some(Tok::Ident(s)) = self.peek() {
238            if s.eq_ignore_ascii_case(kw) {
239                self.pos += 1;
240                return true;
241            }
242        }
243        false
244    }
245
246    /// Parse one `OPTIONAL MATCH pattern [WHERE expr]` clause.
247    ///
248    /// Standard openCypher allows exactly one MATCH per OPTIONAL MATCH.
249    fn optional_match_clause(&mut self) -> Result<OptionalClause, String> {
250        self.expect(&Tok::Optional, "expected OPTIONAL")?;
251        self.expect(&Tok::Match, "expected MATCH after OPTIONAL")?;
252        let patterns = vec![self.pattern()?];
253        // Optional WHERE inside the optional scope.
254        let where_expr = if self.eat(&Tok::Where) {
255            Some(self.expr(0)?)
256        } else {
257            None
258        };
259        Ok(OptionalClause {
260            patterns,
261            where_expr,
262        })
263    }
264
265    /// Parse one `WITH <items> [WHERE] [ORDER BY] [SKIP] [LIMIT] [MATCH]* [UNWIND]* [WHERE]`
266    /// stage and return a `WithStage`.
267    fn with_stage(&mut self) -> Result<WithStage, String> {
268        self.expect(&Tok::With, "expected WITH")?;
269        let mut items = vec![self.ret_item()?];
270        while self.eat(&Tok::Comma) {
271            items.push(self.ret_item()?);
272        }
273        // Optional WHERE / HAVING immediately after WITH items.
274        let where_expr = if self.eat(&Tok::Where) {
275            Some(self.expr(0)?)
276        } else {
277            None
278        };
279        // Optional ORDER BY inside WITH.
280        let aliases: Vec<&str> = items.iter().filter_map(|r| r.alias.as_deref()).collect();
281        let order_by = if self.peek() == Some(&Tok::Order) {
282            self.order_clause(&aliases)?
283        } else {
284            Vec::new()
285        };
286        let skip = if self.eat(&Tok::Skip) {
287            Some(self.uint("SKIP")?)
288        } else {
289            None
290        };
291        let limit = if self.eat(&Tok::Limit) {
292            Some(self.uint("LIMIT")?)
293        } else {
294            None
295        };
296        // Optional MATCH clauses that follow this WITH.
297        let mut matches = Vec::new();
298        while self.peek() == Some(&Tok::Match) {
299            matches.extend(self.match_clause()?);
300        }
301        // Optional OPTIONAL MATCH clauses that follow those MATCHes.
302        let mut optional_clauses = Vec::new();
303        while self.peek() == Some(&Tok::Optional) {
304            optional_clauses.push(self.optional_match_clause()?);
305        }
306        // Optional UNWIND clauses that follow those MATCHes.
307        let mut stage_unwinds = Vec::new();
308        while self.peek() == Some(&Tok::Unwind) {
309            stage_unwinds.push(self.unwind_clause()?);
310        }
311        // Optional WHERE that follows those MATCHes / UNWINDs.
312        let post_where = if self.peek() == Some(&Tok::Where)
313            && (!matches.is_empty() || !optional_clauses.is_empty() || !stage_unwinds.is_empty())
314        {
315            self.pos += 1; // consume WHERE
316            Some(self.expr(0)?)
317        } else {
318            None
319        };
320        Ok(WithStage {
321            items,
322            where_expr,
323            order_by,
324            skip,
325            limit,
326            matches,
327            optional_clauses,
328            unwinds: stage_unwinds,
329            post_where,
330        })
331    }
332
333    /// Parse `UNWIND <expr> AS <alias>`.
334    fn unwind_clause(&mut self) -> Result<UnwindClause, String> {
335        self.expect(&Tok::Unwind, "expected UNWIND")?;
336        let list = self.unwind_expr()?;
337        self.expect(&Tok::As, "expected AS after UNWIND expression")?;
338        let alias = self.ident("expected alias identifier after AS")?;
339        Ok(UnwindClause { list, alias })
340    }
341
342    /// Parse the list expression in an UNWIND clause:
343    /// - `[v1, v2, …]` — literal list.
344    /// - `var.field`   — property reference.
345    /// - `var`         — bare variable (alias from a prior WITH).
346    fn unwind_expr(&mut self) -> Result<UnwindExpr, String> {
347        match self.peek() {
348            Some(Tok::LBracket) => {
349                self.pos += 1; // consume '['
350                let mut vals = Vec::new();
351                if !self.eat(&Tok::RBracket) {
352                    loop {
353                        vals.push(self.literal_value("UNWIND list element")?);
354                        if self.eat(&Tok::Comma) {
355                            continue;
356                        }
357                        self.expect(&Tok::RBracket, "expected ']' to close UNWIND list")?;
358                        break;
359                    }
360                }
361                Ok(UnwindExpr::Lit(vals))
362            }
363            Some(Tok::Ident(_)) => {
364                let name = self.ident("expected variable or property in UNWIND")?;
365                if self.eat(&Tok::Dot) {
366                    let field = self.ident("expected field name after '.' in UNWIND")?;
367                    Ok(UnwindExpr::Prop { var: name, field })
368                } else {
369                    Ok(UnwindExpr::Var(name))
370                }
371            }
372            _ => Err(self.err("expected list literal, property, or variable in UNWIND")),
373        }
374    }
375
376    /// Parse one `MATCH p1 [, p2, …]` clause.
377    ///
378    /// Comma-separated patterns in a single MATCH are the openCypher way to
379    /// write an intersection — `MATCH (t)-[:A]->(c), (t)-[:B]->(c)` binds the
380    /// *same* `t` and `c` across both patterns — and every multihop question
381    /// an assistant asks is that shape. The returned patterns are appended to
382    /// the same `Vec<Pattern>` a run of separate MATCH clauses produces, so
383    /// the two spellings plan and execute identically.
384    fn match_clause(&mut self) -> Result<Vec<Pattern>, String> {
385        self.expect(&Tok::Match, "expected MATCH")?;
386        let mut out = vec![self.match_pattern()?];
387        while self.eat(&Tok::Comma) {
388            out.push(self.match_pattern()?);
389        }
390        Ok(out)
391    }
392
393    /// One pattern inside a MATCH clause: `shortestPath(…)` or a plain chain.
394    fn match_pattern(&mut self) -> Result<Pattern, String> {
395        // Detect `shortestPath(...)`.
396        if let Some(Tok::Ident(s)) = self.peek() {
397            if s.eq_ignore_ascii_case("shortestpath") {
398                return self.shortest_path_clause();
399            }
400        }
401        self.pattern()
402    }
403
404    fn shortest_path_clause(&mut self) -> Result<Pattern, String> {
405        self.pos += 1; // consume "shortestPath" identifier
406        self.expect(&Tok::LParen, "expected '(' after shortestPath")?;
407        let start = self.node()?;
408        let rel = self.rel()?;
409        if rel.hops.is_none() {
410            return Err(
411                self.err("shortestPath requires a variable-length relationship (e.g. [*..5])")
412            );
413        }
414        let dest = self.node()?;
415        self.expect(&Tok::RParen, "expected ')' to close shortestPath")?;
416        Ok(Pattern {
417            start,
418            chain: vec![(rel, dest)],
419            shortest: true,
420        })
421    }
422
423    fn pattern(&mut self) -> Result<Pattern, String> {
424        let start = self.node()?;
425        let mut chain = Vec::new();
426        while matches!(self.peek(), Some(Tok::Dash) | Some(Tok::Lt)) {
427            let rel = self.rel()?;
428            let dest = self.node()?;
429            chain.push((rel, dest));
430        }
431        Ok(Pattern {
432            start,
433            chain,
434            shortest: false,
435        })
436    }
437
438    fn node(&mut self) -> Result<NodePat, String> {
439        self.expect(&Tok::LParen, "expected '(' to start a node pattern")?;
440        let var = match self.peek() {
441            Some(Tok::Ident(s)) => {
442                let s = s.clone();
443                self.pos += 1;
444                Some(s)
445            }
446            _ => None,
447        };
448        let label = if self.eat(&Tok::Colon) {
449            Some(self.ident("expected label identifier after ':'")?)
450        } else {
451            None
452        };
453        let props = if self.peek() == Some(&Tok::LBrace) {
454            self.props()?
455        } else {
456            Vec::new()
457        };
458        self.expect(&Tok::RParen, "expected ')' to close a node pattern")?;
459        Ok(NodePat { var, label, props })
460    }
461
462    fn props(&mut self) -> Result<Vec<(String, Operand)>, String> {
463        self.expect(&Tok::LBrace, "expected '{'")?;
464        let mut out = Vec::new();
465        loop {
466            let key = self.ident("expected property key")?;
467            self.expect(&Tok::Colon, "expected ':' after property key")?;
468            let val = self.operand()?;
469            out.push((key, val));
470            if self.eat(&Tok::Comma) {
471                continue;
472            }
473            break;
474        }
475        self.expect(&Tok::RBrace, "expected '}' to close property map")?;
476        Ok(out)
477    }
478
479    fn rel(&mut self) -> Result<RelPat, String> {
480        if self.eat(&Tok::Lt) {
481            self.expect(
482                &Tok::Dash,
483                "expected '-' after '<' in a left-directed relationship",
484            )?;
485            let (var, etypes, hops) = self.rel_body()?;
486            self.expect(
487                &Tok::Dash,
488                "expected '-' to close a left-directed relationship",
489            )?;
490            return Ok(RelPat {
491                var,
492                etypes,
493                dir: RelDir::Left,
494                hops,
495            });
496        }
497        self.expect(&Tok::Dash, "expected '-' to start a relationship")?;
498        let (var, etypes, hops) = self.rel_body()?;
499        self.expect(&Tok::Dash, "expected '-' after ']'")?;
500        let dir = if self.eat(&Tok::Gt) {
501            RelDir::Right
502        } else {
503            RelDir::Undirected
504        };
505        Ok(RelPat {
506            var,
507            etypes,
508            dir,
509            hops,
510        })
511    }
512
513    #[allow(clippy::type_complexity)]
514    fn rel_body(&mut self) -> Result<(Option<String>, Vec<String>, Option<HopRange>), String> {
515        self.expect(&Tok::LBracket, "expected '[' in a relationship pattern")?;
516        let var = match self.peek() {
517            Some(Tok::Ident(s)) => {
518                let s = s.clone();
519                self.pos += 1;
520                Some(s)
521            }
522            _ => None,
523        };
524        // `:A`, or `:A|:B|:C` alternation. Empty = any type.
525        let mut etypes = Vec::new();
526        if self.eat(&Tok::Colon) {
527            etypes.push(self.ident("expected relationship type identifier after ':'")?);
528            while self.eat(&Tok::Pipe) {
529                // Both `|:B` (openCypher) and the lenient `|B` are accepted.
530                self.eat(&Tok::Colon);
531                etypes.push(self.ident("expected relationship type identifier after '|'")?);
532            }
533        }
534        let hops = if self.eat(&Tok::Star) {
535            Some(self.parse_hop_range()?)
536        } else {
537            None
538        };
539        self.expect(
540            &Tok::RBracket,
541            "expected ']' to close a relationship pattern",
542        )?;
543        Ok((var, etypes, hops))
544    }
545
546    /// Parse the hop-count range that follows `*` inside a relationship bracket.
547    ///
548    /// Recognised forms (after `*` is already consumed):
549    /// - `]`        → bare `*`, treated as `1..10`
550    /// - `n`        → exactly n hops (`n..n`)
551    /// - `n..m`     → n..m hops
552    /// - `..m`      → 1..m hops
553    /// - `n..`      → unbounded → hard-cap error
554    ///
555    /// Hard cap: max > 10 or unbounded → Err("variable-length paths are capped at 10 hops").
556    fn parse_hop_range(&mut self) -> Result<HopRange, String> {
557        const CAP_ERR: &str = "variable-length paths are capped at 10 hops";
558
559        match self.peek() {
560            // bare `*`  →  min=1, max=10
561            Some(Tok::RBracket) => Ok(HopRange { min: 1, max: 10 }),
562
563            // `*n`  or  `*n..`  or  `*n..m`
564            Some(Tok::Int(n)) => {
565                let n = *n;
566                self.pos += 1;
567                if self.eat(&Tok::Dot) {
568                    self.expect(&Tok::Dot, "expected '..' separator in hop range")?;
569                    match self.peek() {
570                        Some(Tok::Int(m)) => {
571                            let m = *m;
572                            self.pos += 1;
573                            // `*n..m`
574                            self.validate_hop_range(n, m, CAP_ERR)
575                        }
576                        _ => {
577                            // `*n..` — unbounded
578                            Err(CAP_ERR.to_string())
579                        }
580                    }
581                } else {
582                    // `*n` — exact hops
583                    self.validate_hop_range(n, n, CAP_ERR)
584                }
585            }
586
587            // `*..m`
588            Some(Tok::Dot) => {
589                self.pos += 1; // consume first '.'
590                self.expect(&Tok::Dot, "expected '..' range separator after '*'")?;
591                match self.peek() {
592                    Some(Tok::Int(m)) => {
593                        let m = *m;
594                        self.pos += 1;
595                        self.validate_hop_range(1, m, CAP_ERR)
596                    }
597                    _ => Err(self.err("expected max-hop integer after '*..'")),
598                }
599            }
600
601            // Anything else after `*` — treat as bare `*`
602            _ => Ok(HopRange { min: 1, max: 10 }),
603        }
604    }
605
606    fn validate_hop_range(
607        &self,
608        min_n: i64,
609        max_n: i64,
610        cap_err: &str,
611    ) -> Result<HopRange, String> {
612        if min_n < 0 || max_n < 0 {
613            return Err(self.err("hop counts must be non-negative"));
614        }
615        if min_n == 0 {
616            return Err(self.err(
617                "zero-length variable-length paths are not supported; minimum hop count is 1",
618            ));
619        }
620        if max_n > 10 {
621            return Err(cap_err.to_string());
622        }
623        let min = min_n as u8;
624        let max = max_n as u8;
625        if min > max {
626            return Err(self.err(&format!(
627                "variable-length path min ({min}) must not exceed max ({max})"
628            )));
629        }
630        Ok(HopRange { min, max })
631    }
632
633    fn expr(&mut self, paren_depth: usize) -> Result<Expr, String> {
634        let mut left = self.term(paren_depth)?;
635        while self.eat(&Tok::Or) {
636            let right = self.term(paren_depth)?;
637            left = Expr::Or(Box::new(left), Box::new(right));
638        }
639        Ok(left)
640    }
641
642    fn term(&mut self, paren_depth: usize) -> Result<Expr, String> {
643        let mut left = self.factor(paren_depth)?;
644        while self.eat(&Tok::And) {
645            let right = self.factor(paren_depth)?;
646            left = Expr::And(Box::new(left), Box::new(right));
647        }
648        Ok(left)
649    }
650
651    fn factor(&mut self, paren_depth: usize) -> Result<Expr, String> {
652        let negated = self.eat(&Tok::Not);
653        let inner = if self.eat(&Tok::LParen) {
654            if paren_depth >= MAX_PAREN_DEPTH {
655                return Err(self.err("expression nesting too deep"));
656            }
657            let e = self.expr(paren_depth + 1)?;
658            self.expect(
659                &Tok::RParen,
660                "expected ')' to close parenthesized expression",
661            )?;
662            e
663        } else {
664            self.cmp()?
665        };
666        if negated {
667            Ok(Expr::Not(Box::new(inner)))
668        } else {
669            Ok(inner)
670        }
671    }
672
673    fn cmp(&mut self) -> Result<Expr, String> {
674        let lhs = self.arith_expr()?;
675        // Check for IS NULL / IS NOT NULL postfix.
676        if let Some(Tok::Ident(s)) = self.peek() {
677            if s.eq_ignore_ascii_case("is") {
678                self.pos += 1; // consume "IS"
679                               // Optional NOT.
680                let negated = self.eat(&Tok::Not);
681                // Expect NULL identifier.
682                match self.peek() {
683                    Some(Tok::Ident(n)) if n.eq_ignore_ascii_case("null") => {
684                        self.pos += 1; // consume "NULL"
685                        return Ok(if negated {
686                            Expr::IsNotNull(lhs)
687                        } else {
688                            Expr::IsNull(lhs)
689                        });
690                    }
691                    _ => {
692                        return Err(self.err(if negated {
693                            "expected NULL after IS NOT"
694                        } else {
695                            "expected NULL after IS"
696                        }));
697                    }
698                }
699            }
700        }
701        // `IN [a, b, $p]` or `IN $list`.
702        if self.eat_ident_kw("in") {
703            let list = self.in_list()?;
704            return Ok(Expr::In { expr: lhs, list });
705        }
706        // Infix string predicates: `STARTS WITH`, `ENDS WITH`, `CONTAINS`.
707        // These desugar to the scalar functions of the same name that the
708        // executor already implements, so null- and non-string handling is
709        // shared with the `startsWith(a, b)` call spelling. Only the infix
710        // form is what an assistant writes, and it used to be a parse error.
711        if let Some(name) = self.eat_infix_string_op()? {
712            let rhs = self.arith_expr()?;
713            return Ok(Expr::Truthy(Operand::FuncCall {
714                name: name.to_string(),
715                args: vec![lhs, rhs],
716            }));
717        }
718        // If no comparison operator follows, treat the operand as a standalone
719        // boolean predicate (Expr::Truthy).  This enables:
720        //   WHERE textMatches(n.bio, 'query')
721        // without requiring an explicit `= true` or similar.
722        match self.cmp_op() {
723            Ok(op) => {
724                let rhs = self.arith_expr()?;
725                Ok(Expr::Cmp { lhs, op, rhs })
726            }
727            Err(_) => Ok(Expr::Truthy(lhs)),
728        }
729    }
730
731    /// Consume `STARTS WITH` / `ENDS WITH` / `CONTAINS` if one follows, and
732    /// return the scalar function it desugars to.
733    ///
734    /// `STARTS` and `ENDS` are ordinary identifiers to the lexer, so a bare
735    /// `STARTS` not followed by `WITH` is a named error rather than a silent
736    /// reinterpretation of the token as a variable.
737    fn eat_infix_string_op(&mut self) -> Result<Option<&'static str>, String> {
738        let Some(Tok::Ident(s)) = self.peek() else {
739            return Ok(None);
740        };
741        let word = s.to_ascii_lowercase();
742        match word.as_str() {
743            "contains" => {
744                self.pos += 1;
745                Ok(Some("contains"))
746            }
747            "starts" | "ends" => {
748                self.pos += 1;
749                // `WITH` is a lexer keyword token here, not an identifier.
750                if !self.eat(&Tok::With) {
751                    let kw = if word == "starts" { "STARTS" } else { "ENDS" };
752                    return Err(self.err(&format!("expected WITH after {kw}")));
753                }
754                Ok(Some(if word == "starts" {
755                    "startsWith"
756                } else {
757                    "endsWith"
758                }))
759            }
760            _ => Ok(None),
761        }
762    }
763
764    /// Parse the list operand of `IN`: `[a, b, $p]` or a single operand (`$cities`).
765    fn in_list(&mut self) -> Result<Vec<Operand>, String> {
766        if self.eat(&Tok::LBracket) {
767            let mut items = Vec::new();
768            if !self.eat(&Tok::RBracket) {
769                loop {
770                    items.push(self.arith_expr()?);
771                    if self.eat(&Tok::Comma) {
772                        continue;
773                    }
774                    self.expect(&Tok::RBracket, "expected ']' to close IN list")?;
775                    break;
776                }
777            }
778            Ok(items)
779        } else {
780            Ok(vec![self.arith_expr()?])
781        }
782    }
783
784    // ── Arithmetic expression parsing (precedence: * / > + -) ──────────────
785    //
786    // Grammar:
787    //   arith_expr  = arith_add
788    //   arith_add   = arith_mul ((+ | -) arith_mul)*
789    //   arith_mul   = arith_unary ((* | /) arith_unary)*
790    //   arith_unary = - arith_atom | arith_atom
791    //   arith_atom  = literal | param | ident | ident.field | ident(args…) | (arith_add)
792
793    /// Parse a full arithmetic expression (additive level).
794    fn arith_expr(&mut self) -> Result<Operand, String> {
795        let mut left = self.arith_mul()?;
796        loop {
797            let op = if self.eat(&Tok::Plus) {
798                ArithOp::Add
799            } else if self.eat(&Tok::Dash) {
800                // Dash is `-`; but we must not consume a Dash that starts a
801                // relationship pattern (those appear at the top-level pattern
802                // parser, not inside an expression). Inside expressions `-`
803                // is always subtraction.
804                ArithOp::Sub
805            } else {
806                break;
807            };
808            let right = self.arith_mul()?;
809            left = Operand::BinArith {
810                op,
811                left: Box::new(left),
812                right: Box::new(right),
813            };
814        }
815        Ok(left)
816    }
817
818    /// Parse a multiplicative-level arithmetic expression.
819    fn arith_mul(&mut self) -> Result<Operand, String> {
820        let mut left = self.arith_unary()?;
821        loop {
822            let op = if self.eat(&Tok::Star) {
823                ArithOp::Mul
824            } else if self.eat(&Tok::Slash) {
825                ArithOp::Div
826            } else {
827                break;
828            };
829            let right = self.arith_unary()?;
830            left = Operand::BinArith {
831                op,
832                left: Box::new(left),
833                right: Box::new(right),
834            };
835        }
836        Ok(left)
837    }
838
839    /// Parse a unary-level arithmetic expression (handles unary `-`).
840    fn arith_unary(&mut self) -> Result<Operand, String> {
841        if self.eat(&Tok::Dash) {
842            // Unary minus: fold into the next atom.
843            return match self.peek() {
844                Some(Tok::Int(n)) => {
845                    let n = *n;
846                    self.pos += 1;
847                    let neg = n
848                        .checked_neg()
849                        .ok_or_else(|| self.err("integer negation overflow"))?;
850                    Ok(Operand::Lit(Value::Int(neg)))
851                }
852                Some(Tok::Float(x)) => {
853                    let x = *x;
854                    self.pos += 1;
855                    Ok(Operand::Lit(Value::Float(-x)))
856                }
857                _ => Err(self.err("unary minus only applies to numeric literals")),
858            };
859        }
860        self.arith_atom()
861    }
862
863    /// Parse an atomic operand (leaf of the arithmetic expression tree).
864    fn arith_atom(&mut self) -> Result<Operand, String> {
865        // Parenthesized arithmetic expression.
866        if self.eat(&Tok::LParen) {
867            let inner = self.arith_expr()?;
868            self.expect(&Tok::RParen, "expected ')' to close arithmetic expression")?;
869            return Ok(inner);
870        }
871        // Delegate to the existing atom parser (no unary minus here — handled above).
872        let atom = self.operand_atom()?;
873        self.subscripts(atom)
874    }
875
876    /// Apply any postfix `[index]` subscripts to an already-parsed operand.
877    ///
878    /// `n.location[0]` is how an assistant reads one end of a `[lat, lon]`
879    /// pair, and it used to stop the parse dead at the `[`. Chained
880    /// subscripts (`n.grid[0][1]`) parse; only the first level is ever
881    /// non-null for the list shapes the graph stores.
882    fn subscripts(&mut self, mut base: Operand) -> Result<Operand, String> {
883        while self.eat(&Tok::LBracket) {
884            let index = self.arith_expr()?;
885            self.expect(&Tok::RBracket, "expected ']' to close a list subscript")?;
886            base = Operand::Index {
887                base: Box::new(base),
888                index: Box::new(index),
889            };
890        }
891        Ok(base)
892    }
893
894    fn cmp_op(&mut self) -> Result<CmpOp, String> {
895        let op = match self.peek() {
896            Some(Tok::Eq) => CmpOp::Eq,
897            Some(Tok::Ne) => CmpOp::Ne,
898            Some(Tok::Lt) => CmpOp::Lt,
899            Some(Tok::Le) => CmpOp::Le,
900            Some(Tok::Gt) => CmpOp::Gt,
901            Some(Tok::Ge) => CmpOp::Ge,
902            _ => return Err(self.err("expected comparison operator")),
903        };
904        self.pos += 1;
905        Ok(op)
906    }
907
908    /// Parse a single atomic operand (no arithmetic wrapping — use `arith_expr`
909    /// for full expression support).  This is the leaf parser for literals,
910    /// parameters, property references, variable references, and function calls.
911    fn operand_atom(&mut self) -> Result<Operand, String> {
912        match self.peek() {
913            Some(Tok::Int(n)) => {
914                let n = *n;
915                self.pos += 1;
916                Ok(Operand::Lit(Value::Int(n)))
917            }
918            Some(Tok::Float(x)) => {
919                let x = *x;
920                self.pos += 1;
921                Ok(Operand::Lit(Value::Float(x)))
922            }
923            Some(Tok::Str(s)) => {
924                let s = s.clone();
925                self.pos += 1;
926                Ok(Operand::Lit(Value::Str(s)))
927            }
928            Some(Tok::Param(s)) => {
929                let s = s.clone();
930                self.pos += 1;
931                Ok(Operand::Param(s))
932            }
933            Some(Tok::Ident(_)) => {
934                let name = self.ident("expected identifier")?;
935                if name.eq_ignore_ascii_case("case") {
936                    return self.parse_case();
937                }
938                if name.eq_ignore_ascii_case("collect") && self.peek() == Some(&Tok::LParen) {
939                    // collect() is a top-level RETURN/WITH aggregate (handled in
940                    // ret_item); it is not valid inside a larger expression.
941                    return Err(
942                        "collect() is only supported as a top-level RETURN/WITH aggregate"
943                            .to_string(),
944                    );
945                }
946                if self.peek() == Some(&Tok::LParen) {
947                    // Scalar function call: name(arg, ...)
948                    // Function arguments may be arbitrary arithmetic expressions.
949                    self.pos += 1; // consume '('
950                    let mut args = Vec::new();
951                    if self.peek() != Some(&Tok::RParen) {
952                        args.push(self.arith_expr()?);
953                        while self.eat(&Tok::Comma) {
954                            args.push(self.arith_expr()?);
955                        }
956                    }
957                    self.expect(&Tok::RParen, "expected ')' to close function call")?;
958                    Ok(Operand::FuncCall { name, args })
959                } else if self.eat(&Tok::Dot) {
960                    let field = self.ident("expected field name after '.'")?;
961                    Ok(Operand::Prop { var: name, field })
962                } else {
963                    // Bare variable reference (e.g. alias name in WITH … WHERE c > 2).
964                    Ok(Operand::Var(name))
965                }
966            }
967            _ => Err(self.err("expected operand (property, literal, or parameter)")),
968        }
969    }
970
971    /// Parse a generic `CASE WHEN <cond> THEN <value> … [ELSE <value>] END`.
972    /// The `CASE` keyword has already been consumed. `WHEN`/`THEN`/`ELSE`/`END`
973    /// are ordinary identifiers to the lexer, matched case-insensitively.
974    fn parse_case(&mut self) -> Result<Operand, String> {
975        let is_kw = |tok: Option<&Tok>, kw: &str| matches!(tok, Some(Tok::Ident(s)) if s.eq_ignore_ascii_case(kw));
976        let mut branches = Vec::new();
977        while is_kw(self.peek(), "when") {
978            self.pos += 1; // consume WHEN
979            let cond = self.expr(0)?;
980            if !is_kw(self.peek(), "then") {
981                return Err(self.err("expected THEN in a CASE branch"));
982            }
983            self.pos += 1; // consume THEN
984            let value = self.arith_expr()?;
985            branches.push((cond, value));
986        }
987        if branches.is_empty() {
988            return Err(self.err("CASE requires at least one WHEN ... THEN branch"));
989        }
990        let default = if is_kw(self.peek(), "else") {
991            self.pos += 1; // consume ELSE
992            Some(Box::new(self.arith_expr()?))
993        } else {
994            None
995        };
996        if !is_kw(self.peek(), "end") {
997            return Err(self.err("expected END to close CASE"));
998        }
999        self.pos += 1; // consume END
1000        Ok(Operand::Case { branches, default })
1001    }
1002
1003    /// Parse a full arithmetic expression (additive + multiplicative + unary +
1004    /// atom).  This is the primary operand entry point for WHERE, RETURN, SET,
1005    /// and function arguments.  Use `operand_atom` for contexts that truly
1006    /// require a single atom (e.g., MATCH property map values where arithmetic
1007    /// would be syntactically ambiguous with the `}` delimiter).
1008    fn operand(&mut self) -> Result<Operand, String> {
1009        // In contexts where `operand` is called for MATCH props, the parser
1010        // never sees arithmetic operators (`+`/`/`) because they can't appear
1011        // inside `{key: val}` maps.  Delegating to `arith_expr` is safe here.
1012        self.arith_expr()
1013    }
1014
1015    fn return_clause(&mut self) -> Result<(bool, Vec<RetItem>), String> {
1016        if !self.eat(&Tok::Return) {
1017            return Err(self.unsupported_or_unexpected("expected RETURN"));
1018        }
1019        let distinct = self.eat_ident_kw("distinct");
1020        let mut items = vec![self.ret_item()?];
1021        while self.eat(&Tok::Comma) {
1022            items.push(self.ret_item()?);
1023        }
1024        Ok((distinct, items))
1025    }
1026
1027    fn return_items(&mut self) -> Result<Vec<RetItem>, String> {
1028        let mut items = vec![self.ret_item()?];
1029        while self.eat(&Tok::Comma) {
1030            items.push(self.ret_item()?);
1031        }
1032        Ok(items)
1033    }
1034
1035    fn ret_item(&mut self) -> Result<RetItem, String> {
1036        // Check for an aggregate function name (COUNT/SUM/AVG/MIN/MAX).
1037        // These are ordinary identifiers in the lexer, so we peek and check
1038        // the lowercased string before deciding the parse branch.
1039        if let Some(Tok::Ident(s)) = self.peek() {
1040            let func = match s.to_ascii_lowercase().as_str() {
1041                "count" => Some(AggFunc::Count),
1042                "sum" => Some(AggFunc::Sum),
1043                "avg" => Some(AggFunc::Avg),
1044                "min" => Some(AggFunc::Min),
1045                "max" => Some(AggFunc::Max),
1046                "collect" => Some(AggFunc::Collect),
1047                _ => None,
1048            };
1049            if let Some(func) = func {
1050                self.pos += 1; // consume the function name
1051                self.expect(&Tok::LParen, "expected '(' after aggregate function name")?;
1052                // `count(DISTINCT t)` — DISTINCT is an ordinary identifier to
1053                // the lexer, so it is only a modifier here when something
1054                // follows it inside the parens.
1055                let distinct = matches!(self.peek(), Some(Tok::Ident(s)) if s.eq_ignore_ascii_case("distinct"))
1056                    && !matches!(self.toks.get(self.pos + 1), Some(Tok::RParen));
1057                if distinct {
1058                    self.pos += 1;
1059                    if self.peek() == Some(&Tok::Star) {
1060                        return Err(self.err("DISTINCT * is not a valid aggregate argument"));
1061                    }
1062                }
1063                let arg = if self.eat(&Tok::Star) {
1064                    AggArg::Star
1065                } else {
1066                    let var = self.ident("expected variable or '*' in aggregate argument")?;
1067                    if self.eat(&Tok::Dot) {
1068                        let field =
1069                            self.ident("expected field name after '.' in aggregate argument")?;
1070                        AggArg::Prop { var, field }
1071                    } else {
1072                        AggArg::Var(var)
1073                    }
1074                };
1075                let arg = if distinct {
1076                    AggArg::Distinct(Box::new(arg))
1077                } else {
1078                    arg
1079                };
1080                self.expect(&Tok::RParen, "expected ')' to close aggregate function")?;
1081                let alias = if self.eat(&Tok::As) {
1082                    Some(self.ident("expected alias identifier after AS")?)
1083                } else {
1084                    None
1085                };
1086                return Ok(RetItem {
1087                    value: RetVal::Agg { func, arg },
1088                    alias,
1089                });
1090            }
1091        }
1092
1093        // Non-aggregate RETURN item: parse as a full arithmetic expression,
1094        // then convert the resulting Operand to the appropriate RetVal variant.
1095        // This unified path handles:
1096        //   n          → RetVal::Var
1097        //   n.prop     → RetVal::Prop
1098        //   f(...)     → RetVal::FuncCall
1099        //   n.age + 1  → RetVal::ScalarExpr(BinArith)
1100        //   42         → RetVal::ScalarExpr(Lit)
1101        let op = self.arith_expr()?;
1102        let value = match op {
1103            Operand::Var(name) => RetVal::Var(name),
1104            Operand::Prop { var, field } => RetVal::Prop { var, field },
1105            Operand::FuncCall { name, args } => RetVal::FuncCall { name, args },
1106            other => RetVal::ScalarExpr(other),
1107        };
1108        let alias = if self.eat(&Tok::As) {
1109            Some(self.ident("expected alias identifier after AS")?)
1110        } else {
1111            None
1112        };
1113        Ok(RetItem { value, alias })
1114    }
1115
1116    fn order_clause(&mut self, aliases: &[&str]) -> Result<Vec<OrderItem>, String> {
1117        self.expect(&Tok::Order, "expected ORDER")?;
1118        self.expect(&Tok::By, "expected BY after ORDER")?;
1119        let mut items = vec![self.order_item(aliases)?];
1120        while self.eat(&Tok::Comma) {
1121            items.push(self.order_item(aliases)?);
1122        }
1123        Ok(items)
1124    }
1125
1126    fn order_item(&mut self, aliases: &[&str]) -> Result<OrderItem, String> {
1127        let name = self.ident("expected ORDER BY target")?;
1128        let target = if self.eat(&Tok::Dot) {
1129            let field = self.ident("expected field name after '.'")?;
1130            OrderTarget::Prop { var: name, field }
1131        } else if aliases.contains(&name.as_str()) {
1132            OrderTarget::Alias(name)
1133        } else {
1134            OrderTarget::Var(name)
1135        };
1136        let descending = if self.eat(&Tok::Desc) {
1137            true
1138        } else {
1139            let _ = self.eat(&Tok::Asc);
1140            false
1141        };
1142        Ok(OrderItem { target, descending })
1143    }
1144
1145    fn uint(&mut self, what: &str) -> Result<LimitSkip, String> {
1146        match self.peek() {
1147            Some(Tok::Int(n)) if *n >= 0 => {
1148                let n = *n as u64;
1149                self.pos += 1;
1150                Ok(LimitSkip::Exact(n))
1151            }
1152            Some(Tok::Int(_)) => Err(self.err(&format!("{what} must be a non-negative integer"))),
1153            Some(Tok::Param(_)) => {
1154                let name = match self.toks.get(self.pos) {
1155                    Some(Tok::Param(s)) => s.clone(),
1156                    _ => unreachable!(),
1157                };
1158                self.pos += 1;
1159                Ok(LimitSkip::Param(name))
1160            }
1161            _ => Err(self.err(&format!("expected integer or $parameter after {what}"))),
1162        }
1163    }
1164
1165    // ── Write statement parsing ───────────────────────────────────────────────
1166
1167    fn write_statement(&mut self) -> Result<WriteStatement, String> {
1168        match self.peek() {
1169            Some(Tok::Create) => self.create_stmt(),
1170            Some(Tok::Merge) => self.merge_stmt(),
1171            Some(Tok::Match) => self.match_write_stmt(),
1172            _ => Err(self
1173                .err("expected CREATE, MERGE, or MATCH … SET/DELETE (write statement required)")),
1174        }
1175    }
1176
1177    // ── CREATE ────────────────────────────────────────────────────────────────
1178
1179    fn create_stmt(&mut self) -> Result<WriteStatement, String> {
1180        self.expect(&Tok::Create, "expected CREATE")?;
1181        let mut stmt = self.create_pattern()?;
1182        // Optional RETURN clause: `CREATE (n:L {…}) RETURN n` or `RETURN n.id AS id`.
1183        if self.eat(&Tok::Return) {
1184            stmt.returns = Some(self.return_items()?);
1185        }
1186        if self.pos < self.toks.len() {
1187            return Err(self.err("unexpected tokens after CREATE"));
1188        }
1189        Ok(WriteStatement::Create(stmt))
1190    }
1191
1192    fn create_pattern(&mut self) -> Result<CreateStmt, String> {
1193        // Parse the first (possibly only) node.
1194        let first = self.create_node(0)?;
1195        let first_var = first.var.clone().unwrap_or_else(|| format!("_cn{}", 0));
1196        let mut nodes: Vec<CreateNode> = vec![first];
1197        let mut edges: Vec<CreateEdge> = Vec::new();
1198
1199        // Chain: (-[:T]-> | <-[:T]-) followed by another node.
1200        while matches!(self.peek(), Some(Tok::Dash) | Some(Tok::Lt)) {
1201            let (etype, src_is_left) = self.create_rel()?;
1202            let idx = nodes.len();
1203            let next = self.create_node(idx)?;
1204            let next_var = next.var.clone().unwrap_or_else(|| format!("_cn{idx}"));
1205            let prev_var = nodes.last().unwrap().var.clone().unwrap_or_else(|| {
1206                if nodes.len() == 1 {
1207                    first_var.clone()
1208                } else {
1209                    format!("_cn{}", nodes.len() - 1)
1210                }
1211            });
1212            let (src_var, dst_var) = if src_is_left {
1213                // <-[:T]- means next→prev i.e. next is src
1214                (next_var.clone(), prev_var)
1215            } else {
1216                // -[:T]-> means prev→next
1217                (prev_var, next_var.clone())
1218            };
1219            edges.push(CreateEdge {
1220                src_var,
1221                etype,
1222                dst_var,
1223            });
1224            nodes.push(next);
1225        }
1226        Ok(CreateStmt {
1227            nodes,
1228            edges,
1229            returns: None,
1230        })
1231    }
1232
1233    fn create_node(&mut self, idx: usize) -> Result<CreateNode, String> {
1234        self.expect(&Tok::LParen, "expected '(' in CREATE node pattern")?;
1235        let var = match self.peek() {
1236            Some(Tok::Ident(s)) => {
1237                let s = s.clone();
1238                self.pos += 1;
1239                Some(s)
1240            }
1241            _ => None,
1242        };
1243        if !self.eat(&Tok::Colon) {
1244            return Err(self.err("CREATE node requires a label (e.g., (n:Label {…}))"));
1245        }
1246        let label = self.ident("expected label identifier after ':'")?;
1247        let props = if self.peek() == Some(&Tok::LBrace) {
1248            self.literal_props()?
1249        } else {
1250            Vec::new()
1251        };
1252        self.expect(&Tok::RParen, "expected ')' to close CREATE node pattern")?;
1253        let var = Some(var.unwrap_or_else(|| format!("_cn{idx}")));
1254        Ok(CreateNode { var, label, props })
1255    }
1256
1257    /// Parse `{key: literal, …}` where all values must be literals (no params, no props).
1258    fn literal_props(&mut self) -> Result<Vec<(String, Value)>, String> {
1259        self.expect(&Tok::LBrace, "expected '{'")?;
1260        let mut out = Vec::new();
1261        if self.eat(&Tok::RBrace) {
1262            return Ok(out);
1263        }
1264        loop {
1265            let key = self.ident("expected property key")?;
1266            self.expect(&Tok::Colon, "expected ':' after property key")?;
1267            let val = self.literal_value("property value")?;
1268            out.push((key, val));
1269            if self.eat(&Tok::Comma) {
1270                continue;
1271            }
1272            break;
1273        }
1274        self.expect(&Tok::RBrace, "expected '}' to close property map")?;
1275        Ok(out)
1276    }
1277
1278    /// Parse a literal value (int, float, or string). Parameters and property
1279    /// references are not accepted in write statements (v1 limitation).
1280    fn literal_value(&mut self, what: &str) -> Result<Value, String> {
1281        if self.eat(&Tok::Dash) {
1282            return match self.peek() {
1283                Some(Tok::Int(n)) => {
1284                    let n = *n;
1285                    self.pos += 1;
1286                    Ok(Value::Int(-n))
1287                }
1288                Some(Tok::Float(x)) => {
1289                    let x = *x;
1290                    self.pos += 1;
1291                    Ok(Value::Float(-x))
1292                }
1293                _ => Err(self.err("unary minus only applies to numeric literals")),
1294            };
1295        }
1296        match self.peek() {
1297            Some(Tok::Int(n)) => {
1298                let n = *n;
1299                self.pos += 1;
1300                Ok(Value::Int(n))
1301            }
1302            Some(Tok::Float(x)) => {
1303                let x = *x;
1304                self.pos += 1;
1305                Ok(Value::Float(x))
1306            }
1307            Some(Tok::Str(s)) => {
1308                let s = s.clone();
1309                self.pos += 1;
1310                Ok(Value::Str(s))
1311            }
1312            // List literal: `[]`, `[v1, v2, …]`, nesting allowed. Elements are
1313            // themselves literal values (recursion), matching the UNWIND list
1314            // form and downstream `Value::List` support in the store.
1315            Some(Tok::LBracket) => {
1316                self.pos += 1; // consume '['
1317                let mut items = Vec::new();
1318                if !self.eat(&Tok::RBracket) {
1319                    loop {
1320                        items.push(self.literal_value(what)?);
1321                        if self.eat(&Tok::Comma) {
1322                            continue;
1323                        }
1324                        self.expect(&Tok::RBracket, "expected ']' to close list literal")?;
1325                        break;
1326                    }
1327                }
1328                Ok(Value::List(items))
1329            }
1330            Some(Tok::Param(_)) => Err(self.err(&format!(
1331                "parameter references are not supported in {what} (v1 limitation: use literals only)"
1332            ))),
1333            Some(Tok::Ident(_)) => Err(self.err(&format!(
1334                "expression RHS not supported in {what} (v1 limitation: use literals only)"
1335            ))),
1336            _ => Err(self.err(&format!("expected literal value for {what}"))),
1337        }
1338    }
1339
1340    /// Parse `-[:TYPE]->` or `<-[:TYPE]-`.  Returns `(etype, src_is_left)` where
1341    /// `src_is_left = true` means left node is dst (i.e., next node is src).
1342    fn create_rel(&mut self) -> Result<(String, bool), String> {
1343        if self.eat(&Tok::Lt) {
1344            // <-[:TYPE]-
1345            self.expect(&Tok::Dash, "expected '-' after '<' in relationship")?;
1346            self.expect(&Tok::LBracket, "expected '[' in relationship pattern")?;
1347            self.expect(&Tok::Colon, "expected ':TYPE' in CREATE relationship")?;
1348            let etype = self.ident("expected relationship type")?;
1349            self.expect(&Tok::RBracket, "expected ']'")?;
1350            self.expect(&Tok::Dash, "expected '-'")?;
1351            return Ok((etype, true));
1352        }
1353        // -[:TYPE]->
1354        self.expect(&Tok::Dash, "expected '-' to start relationship")?;
1355        self.expect(&Tok::LBracket, "expected '[' in relationship pattern")?;
1356        self.expect(&Tok::Colon, "expected ':TYPE' in CREATE relationship")?;
1357        let etype = self.ident("expected relationship type")?;
1358        self.expect(&Tok::RBracket, "expected ']'")?;
1359        self.expect(&Tok::Dash, "expected '-'")?;
1360        self.expect(
1361            &Tok::Gt,
1362            "expected '>' — CREATE requires directed relationships",
1363        )?;
1364        Ok((etype, false))
1365    }
1366
1367    // ── MERGE ─────────────────────────────────────────────────────────────────
1368
1369    fn merge_stmt(&mut self) -> Result<WriteStatement, String> {
1370        self.expect(&Tok::Merge, "expected MERGE")?;
1371        self.expect(&Tok::LParen, "expected '(' after MERGE")?;
1372        // Optional var: `MERGE (n:Label {…})` — capture n for RETURN projection.
1373        let var = match self.peek() {
1374            Some(Tok::Ident(_)) => {
1375                let s = match self.toks.get(self.pos) {
1376                    Some(Tok::Ident(s)) => s.clone(),
1377                    _ => unreachable!(),
1378                };
1379                self.pos += 1; // consume var name
1380                Some(s)
1381            }
1382            _ => None,
1383        };
1384        if !self.eat(&Tok::Colon) {
1385            return Err(self.err("MERGE requires a label (e.g., MERGE (n:Label {key: 'x'}))"));
1386        }
1387        let label = self.ident("expected label identifier after ':'")?;
1388        if self.peek() != Some(&Tok::LBrace) {
1389            return Err(self.err(
1390                "MERGE requires a property map with exactly one key (e.g., MERGE (n:Label {id: 'x'}))",
1391            ));
1392        }
1393        let props = self.literal_props()?;
1394        let mut ns = None;
1395        let mut rest = Vec::new();
1396        for (k, v) in props {
1397            if k == NS_PROP {
1398                if ns.is_some() {
1399                    return Err(
1400                        self.err("ns is given more than once; a node has exactly one namespace")
1401                    );
1402                }
1403                ns = Some(v);
1404            } else {
1405                rest.push((k, v));
1406            }
1407        }
1408        if rest.len() != 1 {
1409            // A lone `ns` is the identifying key, not a namespace stamp.
1410            if rest.is_empty() {
1411                if let Some(v) = ns.take() {
1412                    rest.push((NS_PROP.to_string(), v));
1413                }
1414            }
1415            if rest.len() != 1 {
1416                return Err(format!(
1417                    "MERGE supports exactly one key property (got {}); use CREATE for multi-prop nodes",
1418                    rest.len() + usize::from(ns.is_some()),
1419                ));
1420            }
1421        }
1422        self.expect(&Tok::RParen, "expected ')' to close MERGE pattern")?;
1423        let mut on_create = Vec::new();
1424        let mut on_match = Vec::new();
1425        while self.eat_ident_kw("on") {
1426            if self.eat(&Tok::Create) {
1427                self.expect(&Tok::Set, "expected SET after ON CREATE")?;
1428                on_create.extend(self.set_clauses()?);
1429            } else if self.eat(&Tok::Match) {
1430                self.expect(&Tok::Set, "expected SET after ON MATCH")?;
1431                on_match.extend(self.set_clauses()?);
1432            } else {
1433                return Err(self.err("expected CREATE or MATCH after ON"));
1434            }
1435        }
1436        // Optional RETURN clause.
1437        let returns = if self.eat(&Tok::Return) {
1438            Some(self.return_items()?)
1439        } else {
1440            None
1441        };
1442        if self.pos < self.toks.len() {
1443            return Err(self.unsupported_or_unexpected("unexpected tokens after MERGE"));
1444        }
1445        let (key_field, key_value) = rest.remove(0);
1446        Ok(WriteStatement::Merge(MergeStmt {
1447            label,
1448            key_field,
1449            key_value,
1450            ns,
1451            var,
1452            on_create,
1453            on_match,
1454            returns,
1455        }))
1456    }
1457
1458    // ── MATCH … SET / MATCH … DELETE ─────────────────────────────────────────
1459
1460    fn match_write_stmt(&mut self) -> Result<WriteStatement, String> {
1461        // Parse MATCH clauses (same as read query).
1462        let mut matches = Vec::new();
1463        while self.peek() == Some(&Tok::Match) {
1464            matches.extend(self.match_clause()?);
1465        }
1466        if matches.is_empty() {
1467            return Err(self.err("expected MATCH"));
1468        }
1469        // Optional WHERE.
1470        let where_expr = if self.eat(&Tok::Where) {
1471            Some(self.expr(0)?)
1472        } else {
1473            None
1474        };
1475        // Dispatch on SET, DETACH DELETE, or DELETE.
1476        match self.peek() {
1477            Some(Tok::Set) => {
1478                self.pos += 1; // consume SET
1479                let sets = self.set_clauses()?;
1480                let returns = if self.eat(&Tok::Return) {
1481                    Some(self.return_items()?)
1482                } else {
1483                    None
1484                };
1485                if self.pos < self.toks.len() {
1486                    return Err(self.unsupported_or_unexpected("unexpected tokens after SET"));
1487                }
1488                Ok(WriteStatement::MatchSet(MatchSetStmt {
1489                    matches,
1490                    where_expr,
1491                    sets,
1492                    returns,
1493                }))
1494            }
1495            Some(Tok::Detach) => {
1496                // DETACH DELETE <node_var> [, …]
1497                self.pos += 1; // consume DETACH
1498                self.expect(&Tok::Delete, "expected DELETE after DETACH")?;
1499                let node_vars = self.node_delete_targets(&matches)?;
1500                if self.pos < self.toks.len() {
1501                    return Err(self.err("unexpected tokens after DETACH DELETE"));
1502                }
1503                Ok(WriteStatement::MatchDeleteNode(MatchDeleteNodeStmt {
1504                    matches,
1505                    where_expr,
1506                    node_vars,
1507                    detach: true,
1508                }))
1509            }
1510            Some(Tok::Delete) => {
1511                self.pos += 1; // consume DELETE
1512                               // Try to resolve all targets as edge vars first. If the first
1513                               // target is a node var (not an edge var), fall through to node delete.
1514                match self.delete_targets_or_node(&matches)? {
1515                    DeleteTargetResult::Edges(deletes) => {
1516                        if self.pos < self.toks.len() {
1517                            return Err(self.err("unexpected tokens after DELETE"));
1518                        }
1519                        Ok(WriteStatement::MatchDelete(MatchDeleteStmt {
1520                            matches,
1521                            where_expr,
1522                            deletes,
1523                        }))
1524                    }
1525                    DeleteTargetResult::Nodes(node_vars) => {
1526                        if self.pos < self.toks.len() {
1527                            return Err(self.err("unexpected tokens after DELETE"));
1528                        }
1529                        Ok(WriteStatement::MatchDeleteNode(MatchDeleteNodeStmt {
1530                            matches,
1531                            where_expr,
1532                            node_vars,
1533                            detach: false,
1534                        }))
1535                    }
1536                }
1537            }
1538            _ => Err(self.err(
1539                "expected SET or DELETE after MATCH [WHERE]; \
1540                 combined MATCH…RETURN is a read query, not a write statement",
1541            )),
1542        }
1543    }
1544
1545    fn set_clauses(&mut self) -> Result<Vec<SetClause>, String> {
1546        let mut sets = vec![self.set_clause()?];
1547        while self.eat(&Tok::Comma) {
1548            sets.push(self.set_clause()?);
1549        }
1550        Ok(sets)
1551    }
1552
1553    fn set_clause(&mut self) -> Result<SetClause, String> {
1554        let var = self.ident("expected variable in SET clause")?;
1555        self.expect(&Tok::Dot, "expected '.' after variable in SET")?;
1556        let field = self.ident("expected field name after '.'")?;
1557        self.expect(&Tok::Eq, "expected '=' in SET clause")?;
1558        // Accept Lit, Param, BinArith (arithmetic expression), and FuncCall on
1559        // the RHS. Bare Prop/Var (e.g. `SET n.x = m.y`) remain a named error
1560        // because that form requires join semantics not supported in v1; use
1561        // an arithmetic expression like `m.y + 0` if needed.
1562        let value = match self.peek() {
1563            Some(
1564                Tok::Int(_)
1565                | Tok::Float(_)
1566                | Tok::Str(_)
1567                | Tok::Param(_)
1568                | Tok::Dash
1569                | Tok::Ident(_),
1570            ) => {
1571                let op = self.arith_expr()?;
1572                match &op {
1573                    Operand::Lit(_)
1574                    | Operand::Param(_)
1575                    | Operand::BinArith { .. }
1576                    | Operand::FuncCall { .. }
1577                    | Operand::Index { .. }
1578                    | Operand::Case { .. } => op,
1579                    Operand::Prop { .. } | Operand::Var(_) => {
1580                        return Err(self.err(
1581                            "SET RHS: bare property/variable reference is not supported; \
1582                             use a literal, $parameter, or arithmetic expression (e.g. n.x + 1)",
1583                        ));
1584                    }
1585                }
1586            }
1587            // List-literal RHS: `SET n.tags = ['a', 'b']`. Lists are pure
1588            // literals (no arithmetic), so parse directly into `Operand::Lit`.
1589            Some(Tok::LBracket) => Operand::Lit(self.literal_value("SET value")?),
1590            _ => {
1591                return Err(
1592                    self.err("expected literal, $parameter, or arithmetic expression as SET value")
1593                )
1594            }
1595        };
1596        Ok(SetClause { var, field, value })
1597    }
1598
1599    /// Find the rel-var `var` in `patterns` and return its etype, src node var,
1600    /// and dst node var.  Returns Err if the var is not a rel var or has no type.
1601    fn resolve_edge_var(&self, var: &str, patterns: &[Pattern]) -> Result<EdgeDelete, String> {
1602        for pat in patterns {
1603            let start_var = pat.start.var.as_deref().unwrap_or("_unknown");
1604            let mut from_var = start_var;
1605            for (rel, dest) in &pat.chain {
1606                let to_var = dest.var.as_deref().unwrap_or("_unknown");
1607                if rel.var.as_deref() == Some(var) {
1608                    let etype = match rel.etypes.as_slice() {
1609                        [t] => t.clone(),
1610                        [] => {
1611                            return Err(format!(
1612                                "DELETE `{var}`: relationship has no type; \
1613                                 DELETE requires an explicit edge type (e.g., [r:TYPE])"
1614                            ))
1615                        }
1616                        _ => {
1617                            return Err(format!(
1618                                "DELETE `{var}`: relationship has multiple types; \
1619                                 DELETE requires a single explicit edge type (e.g., [r:TYPE])"
1620                            ))
1621                        }
1622                    };
1623                    let (src_var, dst_var) = match rel.dir {
1624                        RelDir::Right => (from_var.to_string(), to_var.to_string()),
1625                        RelDir::Left => (to_var.to_string(), from_var.to_string()),
1626                        RelDir::Undirected => {
1627                            return Err(format!(
1628                                "DELETE `{var}`: undirected relationship DELETE is not supported; \
1629                                 use a directed pattern (e.g., -[r:TYPE]->)"
1630                            ))
1631                        }
1632                    };
1633                    return Ok(EdgeDelete {
1634                        rel_var: var.to_string(),
1635                        etype,
1636                        src_var,
1637                        dst_var,
1638                    });
1639                }
1640                from_var = to_var;
1641            }
1642        }
1643        Err(format!(
1644            "DELETE `{var}`: variable is not bound as a relationship in any MATCH pattern; \
1645             only relationship variables can be deleted (DELETE edge vars, not node vars)"
1646        ))
1647    }
1648
1649    /// Return `true` if `var` is bound as a node variable in `patterns`.
1650    fn is_node_var(&self, var: &str, patterns: &[Pattern]) -> bool {
1651        for pat in patterns {
1652            if pat.start.var.as_deref() == Some(var) {
1653                return true;
1654            }
1655            for (_, dest) in &pat.chain {
1656                if dest.var.as_deref() == Some(var) {
1657                    return true;
1658                }
1659            }
1660        }
1661        false
1662    }
1663
1664    /// Parse a comma-separated list of node-variable targets for
1665    /// `[DETACH] DELETE`.  All targets must be node variables bound in `patterns`.
1666    fn node_delete_targets(&mut self, patterns: &[Pattern]) -> Result<Vec<String>, String> {
1667        let mut vars = Vec::new();
1668        loop {
1669            let var = self.ident("expected node variable to DELETE")?;
1670            if !self.is_node_var(&var, patterns) {
1671                return Err(format!(
1672                    "DELETE `{var}`: variable is not bound as a node in any MATCH pattern"
1673                ));
1674            }
1675            vars.push(var);
1676            if !self.eat(&Tok::Comma) {
1677                break;
1678            }
1679        }
1680        Ok(vars)
1681    }
1682
1683    /// Try to parse DELETE targets as edge vars; if the first target is a node
1684    /// var, fall back to parsing all targets as node vars.
1685    fn delete_targets_or_node(
1686        &mut self,
1687        patterns: &[Pattern],
1688    ) -> Result<DeleteTargetResult, String> {
1689        // Peek at the identifier to decide which path to take.
1690        let var = self.ident("expected variable to DELETE")?;
1691        // Try edge var first.
1692        match self.resolve_edge_var(&var, patterns) {
1693            Ok(edge_del) => {
1694                // At least the first target is an edge var; parse the rest as
1695                // edge vars too.
1696                let mut targets = vec![edge_del];
1697                while self.eat(&Tok::Comma) {
1698                    let v = self.ident("expected variable to DELETE")?;
1699                    targets.push(self.resolve_edge_var(&v, patterns)?);
1700                }
1701                Ok(DeleteTargetResult::Edges(targets))
1702            }
1703            Err(_) => {
1704                // Not an edge var; try as node var.
1705                if self.is_node_var(&var, patterns) {
1706                    let mut node_vars = vec![var];
1707                    while self.eat(&Tok::Comma) {
1708                        let v = self.ident("expected variable to DELETE")?;
1709                        if !self.is_node_var(&v, patterns) {
1710                            return Err(format!(
1711                                "DELETE `{v}`: variable is not bound as a node in any MATCH pattern"
1712                            ));
1713                        }
1714                        node_vars.push(v);
1715                    }
1716                    Ok(DeleteTargetResult::Nodes(node_vars))
1717                } else {
1718                    Err(format!(
1719                        "DELETE `{var}`: variable is not bound as a relationship or node \
1720                         in any MATCH pattern"
1721                    ))
1722                }
1723            }
1724        }
1725    }
1726}
1727
1728/// Used internally by `delete_targets_or_node` to signal whether the targets
1729/// resolved to edge variables or node variables.
1730enum DeleteTargetResult {
1731    Edges(Vec<EdgeDelete>),
1732    Nodes(Vec<String>),
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737    use super::parse;
1738    use crate::cypher::ast::{
1739        AggFunc, Expr, HopRange, LimitSkip, NodePat, Operand, OrderItem, OrderTarget, Pattern,
1740        Query, RelDir, RelPat, RetItem, RetVal,
1741    };
1742    use crate::cypher::{lex, Tok};
1743    use crate::filter::CmpOp;
1744    use core_storage::Value;
1745
1746    fn parse_src(src: &str) -> Result<Query, String> {
1747        parse(&lex(src)?)
1748    }
1749
1750    fn prop(var: &str, field: &str) -> Operand {
1751        Operand::Prop {
1752            var: var.into(),
1753            field: field.into(),
1754        }
1755    }
1756
1757    fn cmp(lhs: Operand, op: CmpOp, rhs: Operand) -> Expr {
1758        Expr::Cmp { lhs, op, rhs }
1759    }
1760
1761    fn node(var: Option<&str>, label: Option<&str>, props: Vec<(String, Operand)>) -> NodePat {
1762        NodePat {
1763            var: var.map(str::to_string),
1764            label: label.map(str::to_string),
1765            props,
1766        }
1767    }
1768
1769    #[test]
1770    fn full_feature_query_exact_ast() {
1771        let src = "\
1772MATCH (a:Person {name: $n, age: 30})-[r:KNOWS]->(b)-[u:TEAM]-(c)<-[s:LIKES]-(d) \
1773WHERE NOT a.age < 18 AND b.name = 'x' OR c.score >= 2.5 \
1774RETURN a, r.since AS since, b.name \
1775ORDER BY since DESC, b.name ASC \
1776SKIP 1 LIMIT 5";
1777        let got = parse_src(src).expect("full-feature query must parse");
1778        let expected = Query {
1779            matches: vec![Pattern {
1780                start: node(
1781                    Some("a"),
1782                    Some("Person"),
1783                    vec![
1784                        ("name".into(), Operand::Param("n".into())),
1785                        ("age".into(), Operand::Lit(Value::Int(30))),
1786                    ],
1787                ),
1788                chain: vec![
1789                    (
1790                        RelPat {
1791                            var: Some("r".into()),
1792                            etypes: vec!["KNOWS".into()],
1793                            dir: RelDir::Right,
1794                            hops: None,
1795                        },
1796                        node(Some("b"), None, vec![]),
1797                    ),
1798                    (
1799                        RelPat {
1800                            var: Some("u".into()),
1801                            etypes: vec!["TEAM".into()],
1802                            dir: RelDir::Undirected,
1803                            hops: None,
1804                        },
1805                        node(Some("c"), None, vec![]),
1806                    ),
1807                    (
1808                        RelPat {
1809                            var: Some("s".into()),
1810                            etypes: vec!["LIKES".into()],
1811                            dir: RelDir::Left,
1812                            hops: None,
1813                        },
1814                        node(Some("d"), None, vec![]),
1815                    ),
1816                ],
1817                shortest: false,
1818            }],
1819            optional_clauses: vec![],
1820            unwinds: vec![],
1821            post_unwind_where: None,
1822            where_expr: Some(Expr::Or(
1823                Box::new(Expr::And(
1824                    Box::new(Expr::Not(Box::new(cmp(
1825                        prop("a", "age"),
1826                        CmpOp::Lt,
1827                        Operand::Lit(Value::Int(18)),
1828                    )))),
1829                    Box::new(cmp(
1830                        prop("b", "name"),
1831                        CmpOp::Eq,
1832                        Operand::Lit(Value::Str("x".into())),
1833                    )),
1834                )),
1835                Box::new(cmp(
1836                    prop("c", "score"),
1837                    CmpOp::Ge,
1838                    Operand::Lit(Value::Float(2.5)),
1839                )),
1840            )),
1841            stages: vec![],
1842            returns: vec![
1843                RetItem {
1844                    value: RetVal::Var("a".into()),
1845                    alias: None,
1846                },
1847                RetItem {
1848                    value: RetVal::Prop {
1849                        var: "r".into(),
1850                        field: "since".into(),
1851                    },
1852                    alias: Some("since".into()),
1853                },
1854                RetItem {
1855                    value: RetVal::Prop {
1856                        var: "b".into(),
1857                        field: "name".into(),
1858                    },
1859                    alias: None,
1860                },
1861            ],
1862            order_by: vec![
1863                OrderItem {
1864                    target: OrderTarget::Alias("since".into()),
1865                    descending: true,
1866                },
1867                OrderItem {
1868                    target: OrderTarget::Prop {
1869                        var: "b".into(),
1870                        field: "name".into(),
1871                    },
1872                    descending: false,
1873                },
1874            ],
1875            distinct: false,
1876            skip: Some(LimitSkip::Exact(1)),
1877            limit: Some(LimitSkip::Exact(5)),
1878        };
1879        assert_eq!(got, expected);
1880    }
1881
1882    #[test]
1883    fn rel_direction_right() {
1884        let q = parse_src("MATCH (a)-[r:T]->(b) RETURN a").unwrap();
1885        assert_eq!(q.matches[0].chain[0].0.dir, RelDir::Right);
1886        assert_eq!(q.matches[0].chain[0].0.var.as_deref(), Some("r"));
1887        assert_eq!(q.matches[0].chain[0].0.etypes, vec!["T".to_string()]);
1888    }
1889
1890    #[test]
1891    fn rel_direction_left() {
1892        let q = parse_src("MATCH (a)<-[r:T]-(b) RETURN a").unwrap();
1893        assert_eq!(q.matches[0].chain[0].0.dir, RelDir::Left);
1894    }
1895
1896    #[test]
1897    fn rel_direction_undirected() {
1898        let q = parse_src("MATCH (a)-[r:T]-(b) RETURN a").unwrap();
1899        assert_eq!(q.matches[0].chain[0].0.dir, RelDir::Undirected);
1900    }
1901
1902    #[test]
1903    fn node_props_map_with_param() {
1904        let q = parse_src("MATCH (t:Talent {id: $tid, n: 1, s: 'x'}) RETURN t").unwrap();
1905        assert_eq!(
1906            q.matches[0].start.props,
1907            vec![
1908                ("id".into(), Operand::Param("tid".into())),
1909                ("n".into(), Operand::Lit(Value::Int(1))),
1910                ("s".into(), Operand::Lit(Value::Str("x".into()))),
1911            ]
1912        );
1913        assert_eq!(q.matches[0].start.var.as_deref(), Some("t"));
1914        assert_eq!(q.matches[0].start.label.as_deref(), Some("Talent"));
1915    }
1916
1917    #[test]
1918    fn operator_precedence_or_and_not() {
1919        let q = parse_src("MATCH (a) WHERE a.x = 1 OR b.y = 2 AND NOT c.z = 3 RETURN a").unwrap();
1920        let expected = Expr::Or(
1921            Box::new(cmp(prop("a", "x"), CmpOp::Eq, Operand::Lit(Value::Int(1)))),
1922            Box::new(Expr::And(
1923                Box::new(cmp(prop("b", "y"), CmpOp::Eq, Operand::Lit(Value::Int(2)))),
1924                Box::new(Expr::Not(Box::new(cmp(
1925                    prop("c", "z"),
1926                    CmpOp::Eq,
1927                    Operand::Lit(Value::Int(3)),
1928                )))),
1929            )),
1930        );
1931        assert_eq!(q.where_expr, Some(expected));
1932    }
1933
1934    #[test]
1935    fn unary_minus_folds_numeric_literals() {
1936        let q = parse_src("MATCH (a) WHERE a.x > -5 AND a.y < -1.5 RETURN a").unwrap();
1937        let expected = Expr::And(
1938            Box::new(cmp(prop("a", "x"), CmpOp::Gt, Operand::Lit(Value::Int(-5)))),
1939            Box::new(cmp(
1940                prop("a", "y"),
1941                CmpOp::Lt,
1942                Operand::Lit(Value::Float(-1.5)),
1943            )),
1944        );
1945        assert_eq!(q.where_expr, Some(expected));
1946    }
1947
1948    fn assert_parse_err(src: &str) {
1949        let result = std::panic::catch_unwind(|| parse_src(src));
1950        assert!(result.is_ok(), "parse({src:?}) panicked");
1951        let err = result
1952            .unwrap()
1953            .expect_err(&format!("parse({src:?}) must be Err"));
1954        // Parse errors say "token"; lex errors say "position". Either is a valid reject.
1955        assert!(
1956            err.contains("token") || err.contains("position"),
1957            "error must include token or lex position, got: {err}"
1958        );
1959    }
1960
1961    #[test]
1962    fn malformed_match_alone_is_err() {
1963        assert_parse_err("MATCH");
1964    }
1965
1966    #[test]
1967    fn malformed_missing_return_is_err() {
1968        assert_parse_err("MATCH (n)");
1969    }
1970
1971    #[test]
1972    fn malformed_rel_colon_without_type_is_err() {
1973        assert_parse_err("MATCH (a)-[x:]->(b) RETURN a");
1974    }
1975
1976    #[test]
1977    fn malformed_dangling_comma_in_return_is_err() {
1978        assert_parse_err("MATCH (n) RETURN n,");
1979    }
1980
1981    #[test]
1982    fn malformed_unclosed_paren_is_err() {
1983        assert_parse_err("MATCH (n RETURN n");
1984        assert_parse_err("MATCH (n) WHERE (a.x = 1 RETURN n");
1985    }
1986
1987    #[test]
1988    fn malformed_order_by_before_return_is_err() {
1989        assert_parse_err("MATCH (n) ORDER BY n RETURN n");
1990    }
1991
1992    #[test]
1993    fn malformed_garbage_after_limit_is_err() {
1994        assert_parse_err("MATCH (n) RETURN n LIMIT 1 extra");
1995    }
1996
1997    #[test]
1998    fn dogfood_query_exact_ast() {
1999        let src = "\
2000MATCH (t:Talent {id: $tid}) \
2001MATCH (c:Company)-[i:INDUSTRY_ALIGNMENT]->(t) \
2002MATCH (c)-[s:SPECIALTY_MATCH]->(t) \
2003WHERE i.score >= 0.5 AND s.score >= 0.5 \
2004RETURN c, i.score AS industry, s.score AS specialty \
2005ORDER BY industry DESC, specialty DESC \
2006LIMIT 10";
2007        let got = parse_src(src).expect("dogfood query must parse");
2008        let expected = Query {
2009            matches: vec![
2010                Pattern {
2011                    start: node(
2012                        Some("t"),
2013                        Some("Talent"),
2014                        vec![("id".into(), Operand::Param("tid".into()))],
2015                    ),
2016                    chain: vec![],
2017                    shortest: false,
2018                },
2019                Pattern {
2020                    start: node(Some("c"), Some("Company"), vec![]),
2021                    chain: vec![(
2022                        RelPat {
2023                            var: Some("i".into()),
2024                            etypes: vec!["INDUSTRY_ALIGNMENT".into()],
2025                            dir: RelDir::Right,
2026                            hops: None,
2027                        },
2028                        node(Some("t"), None, vec![]),
2029                    )],
2030                    shortest: false,
2031                },
2032                Pattern {
2033                    start: node(Some("c"), None, vec![]),
2034                    chain: vec![(
2035                        RelPat {
2036                            var: Some("s".into()),
2037                            etypes: vec!["SPECIALTY_MATCH".into()],
2038                            dir: RelDir::Right,
2039                            hops: None,
2040                        },
2041                        node(Some("t"), None, vec![]),
2042                    )],
2043                    shortest: false,
2044                },
2045            ],
2046            optional_clauses: vec![],
2047            unwinds: vec![],
2048            post_unwind_where: None,
2049            where_expr: Some(Expr::And(
2050                Box::new(cmp(
2051                    prop("i", "score"),
2052                    CmpOp::Ge,
2053                    Operand::Lit(Value::Float(0.5)),
2054                )),
2055                Box::new(cmp(
2056                    prop("s", "score"),
2057                    CmpOp::Ge,
2058                    Operand::Lit(Value::Float(0.5)),
2059                )),
2060            )),
2061            stages: vec![],
2062            returns: vec![
2063                RetItem {
2064                    value: RetVal::Var("c".into()),
2065                    alias: None,
2066                },
2067                RetItem {
2068                    value: RetVal::Prop {
2069                        var: "i".into(),
2070                        field: "score".into(),
2071                    },
2072                    alias: Some("industry".into()),
2073                },
2074                RetItem {
2075                    value: RetVal::Prop {
2076                        var: "s".into(),
2077                        field: "score".into(),
2078                    },
2079                    alias: Some("specialty".into()),
2080                },
2081            ],
2082            order_by: vec![
2083                OrderItem {
2084                    target: OrderTarget::Alias("industry".into()),
2085                    descending: true,
2086                },
2087                OrderItem {
2088                    target: OrderTarget::Alias("specialty".into()),
2089                    descending: true,
2090                },
2091            ],
2092            distinct: false,
2093            skip: None,
2094            limit: Some(LimitSkip::Exact(10)),
2095        };
2096        assert_eq!(got, expected);
2097    }
2098
2099    #[test]
2100    fn unary_minus_in_props_and_dash_elsewhere_is_err() {
2101        let q = parse_src("MATCH (a {x: -5, y: -1.5}) RETURN a").unwrap();
2102        assert_eq!(
2103            q.matches[0].start.props,
2104            vec![
2105                ("x".into(), Operand::Lit(Value::Int(-5))),
2106                ("y".into(), Operand::Lit(Value::Float(-1.5))),
2107            ]
2108        );
2109        // `1 - 2` is now valid arithmetic — no longer a parse error.
2110        let q2 = parse_src("MATCH (a) WHERE a.x = 1 - 2 RETURN a").unwrap();
2111        assert!(q2.where_expr.is_some());
2112        // Unary minus on non-literal remains an error.
2113        assert_parse_err("MATCH (a) WHERE a.x > -b.y RETURN a");
2114        assert_parse_err("MATCH (a) RETURN a SKIP -1");
2115    }
2116
2117    // ── IS NULL / IS NOT NULL ──────────────────────────────────────────────────
2118
2119    #[test]
2120    fn is_null_parses_on_prop() {
2121        let q = parse_src("MATCH (a) WHERE a.x IS NULL RETURN a").unwrap();
2122        assert_eq!(q.where_expr, Some(Expr::IsNull(prop("a", "x"))),);
2123    }
2124
2125    #[test]
2126    fn is_not_null_parses_on_prop() {
2127        let q = parse_src("MATCH (a) WHERE a.x IS NOT NULL RETURN a").unwrap();
2128        assert_eq!(q.where_expr, Some(Expr::IsNotNull(prop("a", "x"))),);
2129    }
2130
2131    #[test]
2132    fn is_null_on_var() {
2133        let q =
2134            parse_src("MATCH (a) OPTIONAL MATCH (a)-[:T]->(b) WITH a, b WHERE b IS NULL RETURN a")
2135                .unwrap();
2136        // The IS NULL filter lives on the first WITH stage's where_expr.
2137        let stage = &q.stages[0];
2138        assert_eq!(
2139            stage.where_expr,
2140            Some(Expr::IsNull(Operand::Var("b".into()))),
2141        );
2142    }
2143
2144    #[test]
2145    fn is_null_case_insensitive() {
2146        let q = parse_src("MATCH (a) WHERE a.x is null RETURN a").unwrap();
2147        assert_eq!(q.where_expr, Some(Expr::IsNull(prop("a", "x"))));
2148        let q2 = parse_src("MATCH (a) WHERE a.x IS NOT NULL RETURN a").unwrap();
2149        assert_eq!(q2.where_expr, Some(Expr::IsNotNull(prop("a", "x"))));
2150    }
2151
2152    #[test]
2153    fn is_null_combined_with_and() {
2154        let q = parse_src("MATCH (a) WHERE a.x IS NULL AND a.y > 5 RETURN a").unwrap();
2155        assert!(matches!(q.where_expr, Some(Expr::And(_, _))));
2156    }
2157
2158    // ── Arithmetic expression parsing ──────────────────────────────────────────
2159
2160    #[test]
2161    fn arith_add_in_where() {
2162        use crate::cypher::ast::ArithOp;
2163        let q = parse_src("MATCH (n) WHERE n.age + 1 > 5 RETURN n").unwrap();
2164        let expected_lhs = Operand::BinArith {
2165            op: ArithOp::Add,
2166            left: Box::new(prop("n", "age")),
2167            right: Box::new(Operand::Lit(Value::Int(1))),
2168        };
2169        assert_eq!(
2170            q.where_expr,
2171            Some(Expr::Cmp {
2172                lhs: expected_lhs,
2173                op: CmpOp::Gt,
2174                rhs: Operand::Lit(Value::Int(5)),
2175            })
2176        );
2177    }
2178
2179    #[test]
2180    fn arith_precedence_mul_over_add() {
2181        use crate::cypher::ast::ArithOp;
2182        // 1 + 2 * 3  should parse as  1 + (2 * 3)
2183        let q = parse_src("MATCH (n) WHERE n.x = 1 + 2 * 3 RETURN n").unwrap();
2184        let expected_rhs = Operand::BinArith {
2185            op: ArithOp::Add,
2186            left: Box::new(Operand::Lit(Value::Int(1))),
2187            right: Box::new(Operand::BinArith {
2188                op: ArithOp::Mul,
2189                left: Box::new(Operand::Lit(Value::Int(2))),
2190                right: Box::new(Operand::Lit(Value::Int(3))),
2191            }),
2192        };
2193        assert_eq!(
2194            q.where_expr,
2195            Some(Expr::Cmp {
2196                lhs: prop("n", "x"),
2197                op: CmpOp::Eq,
2198                rhs: expected_rhs,
2199            })
2200        );
2201    }
2202
2203    #[test]
2204    fn arith_parens_override_precedence() {
2205        use crate::cypher::ast::ArithOp;
2206        // In RETURN position: (1+2)*3 should parse as (1+2)*3
2207        let q = parse_src("MATCH (n) RETURN (1 + 2) * 3 AS r").unwrap();
2208        let expected = RetVal::ScalarExpr(Operand::BinArith {
2209            op: ArithOp::Mul,
2210            left: Box::new(Operand::BinArith {
2211                op: ArithOp::Add,
2212                left: Box::new(Operand::Lit(Value::Int(1))),
2213                right: Box::new(Operand::Lit(Value::Int(2))),
2214            }),
2215            right: Box::new(Operand::Lit(Value::Int(3))),
2216        });
2217        assert_eq!(q.returns[0].value, expected);
2218        assert_eq!(q.returns[0].alias, Some("r".into()));
2219    }
2220
2221    #[test]
2222    fn arith_scalar_expr_in_return() {
2223        use crate::cypher::ast::ArithOp;
2224        let q = parse_src("MATCH (n) RETURN n.age + 1 AS adjusted").unwrap();
2225        let expected = RetVal::ScalarExpr(Operand::BinArith {
2226            op: ArithOp::Add,
2227            left: Box::new(prop("n", "age")),
2228            right: Box::new(Operand::Lit(Value::Int(1))),
2229        });
2230        assert_eq!(q.returns[0].value, expected);
2231        assert_eq!(q.returns[0].alias, Some("adjusted".into()));
2232    }
2233
2234    #[test]
2235    fn arith_div_in_where() {
2236        use crate::cypher::ast::ArithOp;
2237        let q = parse_src("MATCH (n) WHERE n.x / 2 > 3 RETURN n").unwrap();
2238        assert!(matches!(
2239            q.where_expr,
2240            Some(Expr::Cmp {
2241                lhs: Operand::BinArith {
2242                    op: ArithOp::Div,
2243                    ..
2244                },
2245                ..
2246            })
2247        ));
2248    }
2249
2250    // ── CREATE...RETURN and MERGE...RETURN parser tests ────────────────────────
2251
2252    #[test]
2253    fn create_return_parses_node_var() {
2254        use super::parse_write;
2255        use crate::cypher::ast::{RetVal, WriteStatement};
2256
2257        let toks = crate::cypher::lex("CREATE (n:Thing {id: 'x'}) RETURN n").unwrap();
2258        let stmt = parse_write(&toks).unwrap();
2259        match stmt {
2260            WriteStatement::Create(s) => {
2261                assert_eq!(s.nodes.len(), 1);
2262                let returns = s.returns.expect("expected RETURN clause");
2263                assert_eq!(returns.len(), 1);
2264                assert_eq!(returns[0].value, RetVal::Var("n".into()));
2265            }
2266            _ => panic!("expected Create"),
2267        }
2268    }
2269
2270    #[test]
2271    fn create_return_prop_with_alias() {
2272        use super::parse_write;
2273        use crate::cypher::ast::{RetVal, WriteStatement};
2274
2275        let toks = crate::cypher::lex("CREATE (n:Thing {id: 'x'}) RETURN n.id AS node_id").unwrap();
2276        let stmt = parse_write(&toks).unwrap();
2277        match stmt {
2278            WriteStatement::Create(s) => {
2279                let returns = s.returns.expect("RETURN required");
2280                assert_eq!(
2281                    returns[0].value,
2282                    RetVal::Prop {
2283                        var: "n".into(),
2284                        field: "id".into()
2285                    }
2286                );
2287                assert_eq!(returns[0].alias, Some("node_id".into()));
2288            }
2289            _ => panic!("expected Create"),
2290        }
2291    }
2292
2293    #[test]
2294    fn merge_return_parses_node_var() {
2295        use super::parse_write;
2296        use crate::cypher::ast::{RetVal, WriteStatement};
2297
2298        let toks = crate::cypher::lex("MERGE (n:Thing {id: 'x'}) RETURN n").unwrap();
2299        let stmt = parse_write(&toks).unwrap();
2300        match stmt {
2301            WriteStatement::Merge(s) => {
2302                assert_eq!(s.var, Some("n".into()));
2303                let returns = s.returns.expect("RETURN required");
2304                assert_eq!(returns[0].value, RetVal::Var("n".into()));
2305            }
2306            _ => panic!("expected Merge"),
2307        }
2308    }
2309
2310    #[test]
2311    fn is_write_tokens_still_true_for_create_return() {
2312        use super::is_write_tokens;
2313        use crate::cypher::lex;
2314
2315        let toks = lex("CREATE (n:T {id: 'x'}) RETURN n").unwrap();
2316        assert!(
2317            is_write_tokens(&toks),
2318            "CREATE...RETURN must still be classified as write"
2319        );
2320    }
2321
2322    #[test]
2323    fn where_in_list_and_param_parses() {
2324        let q = parse_src("MATCH (n) WHERE n.city IN ['Austin', $c] RETURN n").unwrap();
2325        match q.where_expr {
2326            Some(Expr::In { expr, list }) => {
2327                assert_eq!(
2328                    expr,
2329                    Operand::Prop {
2330                        var: "n".into(),
2331                        field: "city".into()
2332                    }
2333                );
2334                assert_eq!(list.len(), 2);
2335                assert_eq!(list[0], Operand::Lit(Value::Str("Austin".into())));
2336                assert_eq!(list[1], Operand::Param("c".into()));
2337            }
2338            other => panic!("expected Expr::In, got {other:?}"),
2339        }
2340        let q2 = parse_src("MATCH (n) WHERE n.city IN $cities RETURN n").unwrap();
2341        match q2.where_expr {
2342            Some(Expr::In { list, .. }) => {
2343                assert_eq!(list, vec![Operand::Param("cities".into())]);
2344            }
2345            other => panic!("expected Expr::In, got {other:?}"),
2346        }
2347    }
2348
2349    #[test]
2350    fn return_distinct_parses() {
2351        let q = parse_src("MATCH (n) RETURN DISTINCT n.city").unwrap();
2352        assert!(q.distinct);
2353        assert_eq!(q.returns.len(), 1);
2354    }
2355
2356    #[test]
2357    fn union_query_parses_into_parts() {
2358        use super::parse_read;
2359        let u = parse_read(&lex("MATCH (n) RETURN n UNION ALL MATCH (m) RETURN m").unwrap())
2360            .expect("UNION parses");
2361        assert_eq!(u.parts.len(), 2);
2362        assert_eq!(u.all_flags, vec![true]);
2363        // A single query yields one part, no boundaries.
2364        let single = parse_read(&lex("MATCH (n) RETURN n").unwrap()).unwrap();
2365        assert_eq!(single.parts.len(), 1);
2366        assert!(single.all_flags.is_empty());
2367    }
2368
2369    #[test]
2370    fn case_when_expression_parses() {
2371        let q = parse_src("MATCH (n) RETURN CASE WHEN n.x = 1 THEN 2 ELSE 3 END AS c")
2372            .expect("CASE parses");
2373        assert!(matches!(
2374            q.returns[0].value,
2375            RetVal::ScalarExpr(Operand::Case { .. })
2376        ));
2377    }
2378
2379    #[test]
2380    fn collect_is_a_supported_aggregate() {
2381        let q = parse_src("MATCH (n) RETURN collect(n.name) AS names").expect("collect parses");
2382        assert!(matches!(
2383            q.returns[0].value,
2384            RetVal::Agg {
2385                func: AggFunc::Collect,
2386                ..
2387            }
2388        ));
2389    }
2390
2391    #[test]
2392    fn match_set_return_parses() {
2393        use super::parse_write;
2394        use crate::cypher::ast::{RetVal, WriteStatement};
2395
2396        let toks = crate::cypher::lex("MATCH (n {id:'a'}) SET n.x = 2 RETURN n.x").unwrap();
2397        let stmt = parse_write(&toks).unwrap();
2398        match stmt {
2399            WriteStatement::MatchSet(s) => {
2400                let returns = s.returns.expect("RETURN required");
2401                assert_eq!(
2402                    returns[0].value,
2403                    RetVal::Prop {
2404                        var: "n".into(),
2405                        field: "x".into()
2406                    }
2407                );
2408            }
2409            other => panic!("expected MatchSet, got {other:?}"),
2410        }
2411    }
2412
2413    #[test]
2414    fn merge_on_create_and_on_match_parse() {
2415        use super::parse_write;
2416        use crate::cypher::ast::WriteStatement;
2417
2418        let toks = crate::cypher::lex(
2419            "MERGE (n:L {id:'new'}) ON CREATE SET n.born = 1 ON MATCH SET n.hit = 1 RETURN n",
2420        )
2421        .unwrap();
2422        let stmt = parse_write(&toks).unwrap();
2423        match stmt {
2424            WriteStatement::Merge(s) => {
2425                assert_eq!(s.on_create.len(), 1);
2426                assert_eq!(s.on_create[0].field, "born");
2427                assert_eq!(s.on_match.len(), 1);
2428                assert_eq!(s.on_match[0].field, "hit");
2429                assert!(s.returns.is_some());
2430            }
2431            other => panic!("expected Merge, got {other:?}"),
2432        }
2433    }
2434
2435    #[test]
2436    fn merge_ns_property_parses_beside_the_key() {
2437        use super::parse_write;
2438        use crate::cypher::ast::WriteStatement;
2439
2440        let toks = crate::cypher::lex("MERGE (n:Doc {id: 'x', ns: 'other'})").unwrap();
2441        let stmt = parse_write(&toks).expect("MERGE may name ns beside the identifying key");
2442        match stmt {
2443            WriteStatement::Merge(s) => {
2444                assert_eq!(s.key_field, "id");
2445                assert_eq!(s.key_value, Value::Str("x".into()));
2446                assert_eq!(s.ns, Some(Value::Str("other".into())));
2447            }
2448            other => panic!("expected Merge, got {other:?}"),
2449        }
2450
2451        let toks = crate::cypher::lex("MERGE (n:Doc {id: 'x', name: 'Alice'})").unwrap();
2452        let err = parse_write(&toks).expect_err("a non-ns extra property is still refused");
2453        assert!(
2454            err.contains("one key property") || err.contains("exactly one"),
2455            "error must mention the single-key constraint, got: {err}"
2456        );
2457    }
2458
2459    #[test]
2460    fn paren_grouping_and_and_left_assoc() {
2461        let q = parse_src("MATCH (a) WHERE (a.x = 1 OR a.y = 2) AND a.z = 3 RETURN a").unwrap();
2462        let expected = Expr::And(
2463            Box::new(Expr::Or(
2464                Box::new(cmp(prop("a", "x"), CmpOp::Eq, Operand::Lit(Value::Int(1)))),
2465                Box::new(cmp(prop("a", "y"), CmpOp::Eq, Operand::Lit(Value::Int(2)))),
2466            )),
2467            Box::new(cmp(prop("a", "z"), CmpOp::Eq, Operand::Lit(Value::Int(3)))),
2468        );
2469        assert_eq!(q.where_expr, Some(expected));
2470
2471        let q = parse_src("MATCH (a) WHERE a.x = 1 AND a.y = 2 AND a.z = 3 RETURN a").unwrap();
2472        let expected = Expr::And(
2473            Box::new(Expr::And(
2474                Box::new(cmp(prop("a", "x"), CmpOp::Eq, Operand::Lit(Value::Int(1)))),
2475                Box::new(cmp(prop("a", "y"), CmpOp::Eq, Operand::Lit(Value::Int(2)))),
2476            )),
2477            Box::new(cmp(prop("a", "z"), CmpOp::Eq, Operand::Lit(Value::Int(3)))),
2478        );
2479        assert_eq!(q.where_expr, Some(expected));
2480    }
2481
2482    #[test]
2483    fn order_by_bare_ident_is_var_when_not_an_alias() {
2484        let q = parse_src("MATCH (a) RETURN a, b.name ORDER BY a, b.name").unwrap();
2485        assert_eq!(
2486            q.order_by,
2487            vec![
2488                OrderItem {
2489                    target: OrderTarget::Var("a".into()),
2490                    descending: false,
2491                },
2492                OrderItem {
2493                    target: OrderTarget::Prop {
2494                        var: "b".into(),
2495                        field: "name".into(),
2496                    },
2497                    descending: false,
2498                },
2499            ]
2500        );
2501    }
2502
2503    #[test]
2504    fn parse_never_panics_on_token_sequences() {
2505        let sequences: Vec<Vec<Tok>> = vec![
2506            vec![],
2507            vec![Tok::Match],
2508            vec![Tok::Return],
2509            vec![Tok::Dash, Tok::Dash, Tok::Dash],
2510            vec![Tok::Lt, Tok::Gt, Tok::Eq],
2511            vec![Tok::LParen, Tok::RParen, Tok::RParen],
2512            vec![Tok::Int(1), Tok::Float(2.0), Tok::Str("x".into())],
2513            vec![Tok::Where, Tok::Not, Tok::And, Tok::Or],
2514            vec![Tok::Order, Tok::By, Tok::Asc, Tok::Desc],
2515            vec![Tok::Skip, Tok::Limit, Tok::As],
2516            vec![Tok::Ident("n".into()), Tok::Dot, Tok::Ident("x".into())],
2517            vec![Tok::Param("p".into()), Tok::Colon, Tok::Comma],
2518            vec![Tok::LBracket, Tok::RBracket, Tok::LBrace, Tok::RBrace],
2519            lex("MATCH (a)-[x:]->(b) RETURN a ORDER BY a LIMIT 1 extra").unwrap(),
2520            // Aggregate tokens: COUNT(*), SUM/AVG/MIN/MAX in various positions.
2521            vec![
2522                Tok::Ident("COUNT".into()),
2523                Tok::LParen,
2524                Tok::Star,
2525                Tok::RParen,
2526            ],
2527            vec![
2528                Tok::Ident("sum".into()),
2529                Tok::LParen,
2530                Tok::Ident("n".into()),
2531                Tok::Dot,
2532                Tok::Ident("x".into()),
2533                Tok::RParen,
2534            ],
2535            vec![Tok::Star],
2536            vec![Tok::Star, Tok::LParen, Tok::RParen, Tok::Star],
2537            vec![
2538                Tok::Ident("avg".into()),
2539                Tok::LParen,
2540                Tok::Star,
2541                Tok::RParen,
2542            ],
2543            vec![Tok::Ident("min".into()), Tok::LParen, Tok::RParen],
2544            vec![
2545                Tok::Ident("max".into()),
2546                Tok::LParen,
2547                Tok::Star,
2548                Tok::RParen,
2549            ],
2550        ];
2551        for toks in sequences {
2552            let result = std::panic::catch_unwind(|| parse(&toks));
2553            assert!(result.is_ok(), "parse panicked on token sequence {toks:?}");
2554            let parsed = result.unwrap();
2555            if let Err(err) = parsed {
2556                assert!(
2557                    err.contains("token"),
2558                    "error must include token position, got: {err}"
2559                );
2560            }
2561        }
2562    }
2563
2564    #[test]
2565    fn aggregate_functions_parse_to_agg_retval() {
2566        use crate::cypher::ast::{AggArg, AggFunc, RetVal};
2567
2568        // COUNT(*) → AggFunc::Count, AggArg::Star
2569        let q = parse_src("MATCH (n) RETURN COUNT(*)").unwrap();
2570        assert_eq!(q.returns.len(), 1);
2571        assert_eq!(
2572            q.returns[0].value,
2573            RetVal::Agg {
2574                func: AggFunc::Count,
2575                arg: AggArg::Star,
2576            }
2577        );
2578        assert_eq!(q.returns[0].alias, None);
2579
2580        // COUNT(n) → AggFunc::Count, AggArg::Var
2581        let q = parse_src("MATCH (n) RETURN COUNT(n)").unwrap();
2582        assert_eq!(
2583            q.returns[0].value,
2584            RetVal::Agg {
2585                func: AggFunc::Count,
2586                arg: AggArg::Var("n".into()),
2587            }
2588        );
2589
2590        // SUM(n.age) → AggFunc::Sum, AggArg::Prop
2591        let q = parse_src("MATCH (n) RETURN SUM(n.age) AS total").unwrap();
2592        assert_eq!(
2593            q.returns[0].value,
2594            RetVal::Agg {
2595                func: AggFunc::Sum,
2596                arg: AggArg::Prop {
2597                    var: "n".into(),
2598                    field: "age".into()
2599                },
2600            }
2601        );
2602        assert_eq!(q.returns[0].alias, Some("total".into()));
2603
2604        // AVG, MIN, MAX case-insensitive
2605        let q = parse_src("MATCH (n) RETURN avg(n.score)").unwrap();
2606        assert!(matches!(
2607            q.returns[0].value,
2608            RetVal::Agg {
2609                func: AggFunc::Avg,
2610                ..
2611            }
2612        ));
2613        let q = parse_src("MATCH (n) RETURN Min(n.x)").unwrap();
2614        assert!(matches!(
2615            q.returns[0].value,
2616            RetVal::Agg {
2617                func: AggFunc::Min,
2618                ..
2619            }
2620        ));
2621        let q = parse_src("MATCH (n) RETURN MAX(n.x)").unwrap();
2622        assert!(matches!(
2623            q.returns[0].value,
2624            RetVal::Agg {
2625                func: AggFunc::Max,
2626                ..
2627            }
2628        ));
2629    }
2630
2631    #[test]
2632    fn nested_parens_beyond_limit_is_err_not_panic() {
2633        let mut src = String::from("MATCH (a) WHERE ");
2634        for _ in 0..80 {
2635            src.push('(');
2636        }
2637        src.push_str("a.x = 1");
2638        for _ in 0..80 {
2639            src.push(')');
2640        }
2641        src.push_str(" RETURN a");
2642        assert_parse_err(&src);
2643    }
2644
2645    // ── Variable-length path parser tests ─────────────────────────────────────
2646
2647    fn hop_range_of(src: &str) -> HopRange {
2648        let q = parse_src(src).expect(src);
2649        let (rel, _) = &q.matches[0].chain[0];
2650        rel.hops.expect("expected hop range")
2651    }
2652
2653    fn assert_hop_err(src: &str, needle: &str) {
2654        let result = std::panic::catch_unwind(|| parse_src(src));
2655        assert!(result.is_ok(), "parse panicked on {src:?}");
2656        let err = result
2657            .unwrap()
2658            .expect_err(&format!("parse({src:?}) must Err"));
2659        assert!(
2660            err.contains(needle),
2661            "error must contain {needle:?}, got: {err}"
2662        );
2663    }
2664
2665    #[test]
2666    fn var_length_bare_star_is_one_to_ten() {
2667        let r = hop_range_of("MATCH (a)-[r:T*]->(b) RETURN a");
2668        assert_eq!(r, HopRange { min: 1, max: 10 });
2669    }
2670
2671    #[test]
2672    fn var_length_exact_n_hops() {
2673        let r = hop_range_of("MATCH (a)-[r:T*3]->(b) RETURN a");
2674        assert_eq!(r, HopRange { min: 3, max: 3 });
2675    }
2676
2677    #[test]
2678    fn var_length_min_max_range() {
2679        let r = hop_range_of("MATCH (a)-[r:T*2..5]->(b) RETURN a");
2680        assert_eq!(r, HopRange { min: 2, max: 5 });
2681    }
2682
2683    #[test]
2684    fn var_length_dotdot_max() {
2685        let r = hop_range_of("MATCH (a)-[r:T*..4]->(b) RETURN a");
2686        assert_eq!(r, HopRange { min: 1, max: 4 });
2687    }
2688
2689    #[test]
2690    fn var_length_cap_at_ten_is_ok() {
2691        let r = hop_range_of("MATCH (a)-[r:T*10]->(b) RETURN a");
2692        assert_eq!(r, HopRange { min: 10, max: 10 });
2693        let r2 = hop_range_of("MATCH (a)-[r:T*1..10]->(b) RETURN a");
2694        assert_eq!(r2, HopRange { min: 1, max: 10 });
2695    }
2696
2697    #[test]
2698    fn var_length_cap_exceeded_is_err() {
2699        assert_hop_err(
2700            "MATCH (a)-[r:T*11]->(b) RETURN a",
2701            "variable-length paths are capped at 10 hops",
2702        );
2703        assert_hop_err(
2704            "MATCH (a)-[r:T*1..11]->(b) RETURN a",
2705            "variable-length paths are capped at 10 hops",
2706        );
2707    }
2708
2709    #[test]
2710    fn var_length_unbounded_min_dot_dot_is_err() {
2711        assert_hop_err(
2712            "MATCH (a)-[r:T*2..]->(b) RETURN a",
2713            "variable-length paths are capped at 10 hops",
2714        );
2715    }
2716
2717    #[test]
2718    fn var_length_shortest_path_parses() {
2719        let q =
2720            parse_src("MATCH (a:N) MATCH (b:N) MATCH shortestPath((a)-[r:T*..5]->(b)) RETURN a")
2721                .expect("shortestPath must parse");
2722        assert!(q.matches[2].shortest, "third match must be shortest=true");
2723        let (rel, _) = &q.matches[2].chain[0];
2724        assert_eq!(rel.hops, Some(HopRange { min: 1, max: 5 }));
2725        assert_eq!(rel.etypes, vec!["T".to_string()]);
2726    }
2727
2728    #[test]
2729    fn var_length_no_type_is_ok() {
2730        // Bare `*` with no type filter
2731        let r = hop_range_of("MATCH (a)-[r*1..3]->(b) RETURN a");
2732        assert_eq!(r, HopRange { min: 1, max: 3 });
2733    }
2734
2735    #[test]
2736    fn var_length_rel_appears_in_chain() {
2737        let q = parse_src("MATCH (a)-[r:T*2..4]->(b) RETURN a").unwrap();
2738        let (rel, dest) = &q.matches[0].chain[0];
2739        assert_eq!(rel.var.as_deref(), Some("r"));
2740        assert_eq!(rel.etypes, vec!["T".to_string()]);
2741        assert_eq!(rel.dir, RelDir::Right);
2742        assert_eq!(rel.hops, Some(HopRange { min: 2, max: 4 }));
2743        assert_eq!(dest.var.as_deref(), Some("b"));
2744    }
2745
2746    #[test]
2747    fn var_length_zero_hop_minimum_is_err() {
2748        // `*0` — exact form with min=0
2749        assert_hop_err(
2750            "MATCH (a)-[r:T*0]->(b) RETURN a",
2751            "zero-length variable-length paths are not supported",
2752        );
2753        // `*0..3` — range form with min=0
2754        assert_hop_err(
2755            "MATCH (a)-[r:T*0..3]->(b) RETURN a",
2756            "zero-length variable-length paths are not supported",
2757        );
2758    }
2759
2760    #[test]
2761    fn create_accepts_list_literal_property() {
2762        use super::parse_write;
2763        use crate::cypher::ast::WriteStatement;
2764        let src = "CREATE (n:Person {id: 'p1', tags: ['a', 'b']})";
2765        let stmt = parse_write(&lex(src).unwrap())
2766            .expect("CREATE with a list-literal property must parse");
2767        let WriteStatement::Create(c) = stmt else {
2768            panic!("expected a Create statement");
2769        };
2770        let tags = &c.nodes[0]
2771            .props
2772            .iter()
2773            .find(|(k, _)| k == "tags")
2774            .expect("tags property present")
2775            .1;
2776        assert_eq!(
2777            *tags,
2778            Value::List(vec![Value::Str("a".into()), Value::Str("b".into())])
2779        );
2780    }
2781
2782    #[test]
2783    fn create_accepts_empty_and_nested_list_literals() {
2784        use super::parse_write;
2785        use crate::cypher::ast::WriteStatement;
2786        let src = "CREATE (n:L {id: 'p1', empty: [], nested: [[1, 2], [3]]})";
2787        let stmt = parse_write(&lex(src).unwrap()).expect("empty and nested lists must parse");
2788        let WriteStatement::Create(c) = stmt else {
2789            panic!("expected a Create statement");
2790        };
2791        let get = |k: &str| {
2792            c.nodes[0]
2793                .props
2794                .iter()
2795                .find(|(name, _)| name == k)
2796                .expect("property present")
2797                .1
2798                .clone()
2799        };
2800        assert_eq!(get("empty"), Value::List(vec![]));
2801        assert_eq!(
2802            get("nested"),
2803            Value::List(vec![
2804                Value::List(vec![Value::Int(1), Value::Int(2)]),
2805                Value::List(vec![Value::Int(3)]),
2806            ])
2807        );
2808    }
2809
2810    #[test]
2811    fn set_accepts_list_literal_rhs() {
2812        use super::parse_write;
2813        use crate::cypher::ast::{Operand, WriteStatement};
2814        let src = "MATCH (n:Person {id: 'p1'}) SET n.tags = ['x', 'y']";
2815        let stmt = parse_write(&lex(src).unwrap()).expect("SET with a list-literal RHS must parse");
2816        let WriteStatement::MatchSet(m) = stmt else {
2817            panic!("expected a MatchSet statement");
2818        };
2819        let set = &m.sets[0];
2820        assert_eq!(set.field, "tags");
2821        assert_eq!(
2822            set.value,
2823            Operand::Lit(Value::List(vec![
2824                Value::Str("x".into()),
2825                Value::Str("y".into())
2826            ]))
2827        );
2828    }
2829}