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