Skip to main content

harn_hostlib/code_index/
cypher.rs

1//! Minimal Cypher executor over [`super::SymbolGraph`].
2//!
3//! Supported subset (intentionally narrow — see issue #2434):
4//!
5//! ```text
6//! query     = MATCH pattern [, pattern]* [WHERE expr] RETURN proj [, proj]*
7//! pattern   = '(' var ':' label '{' kv (',' kv)* '}' ')'
8//!             [ ('-' | '<-') '[' ':' edge ('*' bounds)? ']' ('->' | '-') node ]*
9//! bounds    = INT? '..' INT?
10//! expr      = term [ ('AND' | 'OR') term ]*
11//! term      = path op literal | path op path | literal
12//! op        = '=' | '<>' | '!=' | '<' | '<=' | '>' | '>='
13//! proj      = path [AS alias]
14//! path      = ident '.' ident | ident
15//! literal   = STRING | INT
16//! ```
17//!
18//! Variable-length traversal up to depth 4 is supported via `*1..N` or
19//! the shorthand `*` (defaults to `1..3`). When the upper bound is
20//! omitted (e.g. `*1..` or `*2..`), it defaults to the executor depth
21//! cap (4) — matching the usual Cypher "open upper bound = capped by
22//! engine" semantics. The executor implements depth-first enumeration
23//! with a per-step visited set so cycles can't infinite-loop.
24//!
25//! Read-only; no MERGE/CREATE/SET. The result is a flat
26//! [`Vec<CypherRow>`] where each row maps the projected aliases to
27//! [`CypherValue`]s.
28//!
29//! Execution is bounded by two budgets enforced inside [`exec`] and
30//! [`parse`]:
31//! * [`MAX_ROWS`] — maximum number of intermediate bindings *and*
32//!   projected rows. The executor returns [`CypherError::ExecError`] as
33//!   soon as it tries to push past the cap, so a degenerate
34//!   `MATCH (a),(b),(c) RETURN ...` query cannot lock the symbol-graph
35//!   index for arbitrarily long.
36//! * [`MAX_PATTERNS`] — maximum number of comma-separated disjoint
37//!   patterns in one query. Beyond this, the parser raises
38//!   [`CypherError::ParseError`]; richer joins should use the edge
39//!   syntax (`-[:CALLS]->`) which is bounded by [`MAX_ROWS`] but does
40//!   not multiply pattern counts.
41
42use std::collections::{BTreeMap, HashMap, HashSet};
43use std::fmt;
44
45use harn_vm::VmValue;
46
47use super::symbol_graph::{EdgeKind, NodeId, NodeKind, SymbolGraph};
48
49/// Hard upper bound on the number of rows the executor will materialize
50/// before raising [`CypherError::ExecError`]. A query like
51/// `MATCH (a),(b),(c),(d) RETURN ...` over a workspace symbol graph can
52/// produce N⁴ rows in the worst case; without a cap a single Cypher call
53/// can lock the host's symbol-graph index for many seconds. This budget
54/// matches the schema description (`code_index/cypher.request.json`).
55pub const MAX_ROWS: usize = 10_000;
56
57/// Hard upper bound on the number of comma-separated `MATCH` patterns in
58/// one query. Each additional pattern multiplies the row count, so we
59/// refuse to even start enumerating beyond three disjoint patterns —
60/// scripts that need a richer join should chain patterns through the
61/// edge syntax (`-[:CALLS]->`) rather than relying on a cartesian
62/// product. Enforced at parse time so the executor never sees one.
63pub const MAX_PATTERNS: usize = 3;
64
65/// Maximum traversal depth for variable-length patterns (`*lo..hi`).
66/// Also used as the implicit upper bound when the high end is omitted
67/// (e.g. `*1..`), matching standard "open upper bound" Cypher semantics.
68const VAR_LENGTH_MAX_DEPTH: u32 = 4;
69
70/// Error variants the parser/executor raise. The host wraps these in
71/// [`crate::error::HostlibError`] before surfacing to scripts. Each
72/// variant carries a free-text message identifying the offending token
73/// or position.
74#[derive(Debug, Clone, Eq, PartialEq)]
75pub enum CypherError {
76    /// Tokenizer reached an unexpected character.
77    LexError(String),
78    /// Parser saw a token it didn't recognize at this position.
79    ParseError(String),
80    /// Executor saw something semantically invalid (unknown variable,
81    /// unknown edge label, var-length bound > 4, …).
82    ExecError(String),
83}
84
85impl fmt::Display for CypherError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            CypherError::LexError(s) => write!(f, "lex error: {s}"),
89            CypherError::ParseError(s) => write!(f, "parse error: {s}"),
90            CypherError::ExecError(s) => write!(f, "exec error: {s}"),
91        }
92    }
93}
94
95impl std::error::Error for CypherError {}
96
97/// One projected value in a row.
98#[derive(Debug, Clone, Eq, PartialEq)]
99pub enum CypherValue {
100    /// SQL/Cypher `NULL`. Falls back to `VmValue::Nil`.
101    Null,
102    /// UTF-8 string.
103    String(String),
104    /// 64-bit signed integer.
105    Int(i64),
106    /// Boolean.
107    Bool(bool),
108}
109
110impl CypherValue {
111    /// Render as the corresponding [`VmValue`].
112    pub fn to_vm(&self) -> VmValue {
113        match self {
114            CypherValue::Null => VmValue::Nil,
115            CypherValue::String(s) => VmValue::String(arcstr::ArcStr::from(s.as_str())),
116            CypherValue::Int(n) => VmValue::Int(*n),
117            CypherValue::Bool(b) => VmValue::Bool(*b),
118        }
119    }
120}
121
122/// One projected row.
123pub type CypherRow = BTreeMap<String, CypherValue>;
124
125/// Parse + execute `query` against `graph`. Returns one row per match.
126pub fn execute(query: &str, graph: &SymbolGraph) -> Result<Vec<CypherRow>, CypherError> {
127    let tokens = lex(query)?;
128    let ast = parse(&tokens)?;
129    exec(&ast, graph)
130}
131
132// ---------------------------------------------------------------------------
133// Lexer
134// ---------------------------------------------------------------------------
135
136#[derive(Debug, Clone, PartialEq)]
137enum Token {
138    /// Keyword (uppercase form, case-insensitive matching at lex time).
139    Keyword(String),
140    Ident(String),
141    Str(String),
142    Int(i64),
143    LParen,
144    RParen,
145    LBrace,
146    RBrace,
147    LBracket,
148    RBracket,
149    Colon,
150    Comma,
151    Dot,
152    Eq,
153    Neq,
154    Lt,
155    Le,
156    Gt,
157    Ge,
158    Star,
159    DotDot,
160    Dash,
161    Arrow,     // ->
162    LeftArrow, // <-
163}
164
165#[expect(
166    clippy::string_slice,
167    reason = "every slice bound is at or right after an ASCII byte, hence a char boundary"
168)]
169fn lex(input: &str) -> Result<Vec<Token>, CypherError> {
170    let mut out: Vec<Token> = Vec::new();
171    let bytes = input.as_bytes();
172    let mut i = 0;
173    while i < bytes.len() {
174        let b = bytes[i];
175        if b.is_ascii_whitespace() {
176            i += 1;
177            continue;
178        }
179        if b == b'(' {
180            out.push(Token::LParen);
181            i += 1;
182            continue;
183        }
184        if b == b')' {
185            out.push(Token::RParen);
186            i += 1;
187            continue;
188        }
189        if b == b'{' {
190            out.push(Token::LBrace);
191            i += 1;
192            continue;
193        }
194        if b == b'}' {
195            out.push(Token::RBrace);
196            i += 1;
197            continue;
198        }
199        if b == b'[' {
200            out.push(Token::LBracket);
201            i += 1;
202            continue;
203        }
204        if b == b']' {
205            out.push(Token::RBracket);
206            i += 1;
207            continue;
208        }
209        if b == b':' {
210            out.push(Token::Colon);
211            i += 1;
212            continue;
213        }
214        if b == b',' {
215            out.push(Token::Comma);
216            i += 1;
217            continue;
218        }
219        if b == b'*' {
220            out.push(Token::Star);
221            i += 1;
222            continue;
223        }
224        if b == b'=' {
225            out.push(Token::Eq);
226            i += 1;
227            continue;
228        }
229        if b == b'.' {
230            if i + 1 < bytes.len() && bytes[i + 1] == b'.' {
231                out.push(Token::DotDot);
232                i += 2;
233            } else {
234                out.push(Token::Dot);
235                i += 1;
236            }
237            continue;
238        }
239        if b == b'<' {
240            if i + 1 < bytes.len() && bytes[i + 1] == b'-' {
241                out.push(Token::LeftArrow);
242                i += 2;
243            } else if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
244                out.push(Token::Le);
245                i += 2;
246            } else if i + 1 < bytes.len() && bytes[i + 1] == b'>' {
247                out.push(Token::Neq);
248                i += 2;
249            } else {
250                out.push(Token::Lt);
251                i += 1;
252            }
253            continue;
254        }
255        if b == b'>' {
256            if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
257                out.push(Token::Ge);
258                i += 2;
259            } else {
260                out.push(Token::Gt);
261                i += 1;
262            }
263            continue;
264        }
265        if b == b'!' {
266            if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
267                out.push(Token::Neq);
268                i += 2;
269                continue;
270            }
271            return Err(CypherError::LexError(format!(
272                "unexpected '!' at byte {i} (expected '!=')"
273            )));
274        }
275        if b == b'-' {
276            if i + 1 < bytes.len() && bytes[i + 1] == b'>' {
277                out.push(Token::Arrow);
278                i += 2;
279            } else {
280                out.push(Token::Dash);
281                i += 1;
282            }
283            continue;
284        }
285        if b == b'\'' || b == b'"' {
286            let quote = b;
287            let start = i + 1;
288            i += 1;
289            while i < bytes.len() && bytes[i] != quote {
290                if bytes[i] == b'\\' && i + 1 < bytes.len() {
291                    i += 2;
292                } else {
293                    i += 1;
294                }
295            }
296            if i >= bytes.len() {
297                return Err(CypherError::LexError("unterminated string literal".into()));
298            }
299            let raw = &input[start..i];
300            out.push(Token::Str(unescape(raw)));
301            i += 1;
302            continue;
303        }
304        if b.is_ascii_digit() {
305            let start = i;
306            while i < bytes.len() && bytes[i].is_ascii_digit() {
307                i += 1;
308            }
309            let n: i64 = input[start..i].parse().map_err(|err| {
310                CypherError::LexError(format!("bad integer at byte {start}: {err}"))
311            })?;
312            out.push(Token::Int(n));
313            continue;
314        }
315        if b.is_ascii_alphabetic() || b == b'_' {
316            let start = i;
317            while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
318                i += 1;
319            }
320            let word = &input[start..i];
321            let upper = word.to_ascii_uppercase();
322            if is_keyword(&upper) {
323                out.push(Token::Keyword(upper));
324            } else {
325                out.push(Token::Ident(word.to_string()));
326            }
327            continue;
328        }
329        return Err(CypherError::LexError(format!(
330            "unexpected character `{}` at byte {i}",
331            char::from(b)
332        )));
333    }
334    Ok(out)
335}
336
337fn is_keyword(word: &str) -> bool {
338    matches!(
339        word,
340        "MATCH" | "WHERE" | "RETURN" | "AND" | "OR" | "AS" | "NOT" | "TRUE" | "FALSE"
341    )
342}
343
344fn unescape(raw: &str) -> String {
345    let mut out = String::with_capacity(raw.len());
346    let mut iter = raw.chars();
347    while let Some(c) = iter.next() {
348        if c == '\\' {
349            if let Some(next) = iter.next() {
350                out.push(match next {
351                    'n' => '\n',
352                    't' => '\t',
353                    'r' => '\r',
354                    '\\' => '\\',
355                    '\'' => '\'',
356                    '"' => '"',
357                    other => other,
358                });
359            }
360        } else {
361            out.push(c);
362        }
363    }
364    out
365}
366
367// ---------------------------------------------------------------------------
368// Parser AST
369// ---------------------------------------------------------------------------
370
371#[derive(Debug, Clone)]
372struct Query {
373    matches: Vec<Pattern>,
374    where_clause: Option<Expr>,
375    projections: Vec<Projection>,
376}
377
378#[derive(Debug, Clone)]
379struct Pattern {
380    head: NodePat,
381    steps: Vec<RelStep>,
382}
383
384#[derive(Debug, Clone)]
385struct NodePat {
386    var: String,
387    label: Option<String>,
388    props: BTreeMap<String, Literal>,
389}
390
391#[derive(Debug, Clone)]
392struct RelStep {
393    /// Direction the arrow points. true = forward (`-[]->`), false = reverse (`<-[]-`).
394    forward: bool,
395    edge_label: String,
396    var_length: Option<(u32, u32)>,
397    target: NodePat,
398}
399
400#[derive(Debug, Clone)]
401enum Expr {
402    And(Box<Expr>, Box<Expr>),
403    Or(Box<Expr>, Box<Expr>),
404    Not(Box<Expr>),
405    Compare(Operand, CmpOp, Operand),
406    Bool(bool),
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410enum CmpOp {
411    Eq,
412    Neq,
413    Lt,
414    Le,
415    Gt,
416    Ge,
417}
418
419#[derive(Debug, Clone)]
420enum Operand {
421    /// `var.property` or just `var` (no property).
422    Path {
423        var: String,
424        property: Option<String>,
425    },
426    Literal(Literal),
427}
428
429#[derive(Debug, Clone, PartialEq, Eq)]
430enum Literal {
431    Str(String),
432    Int(i64),
433    Bool(bool),
434}
435
436#[derive(Debug, Clone)]
437struct Projection {
438    operand: Operand,
439    /// Final alias used in the projected row. Empty during parsing
440    /// when no explicit `AS` clause was supplied; filled in after the
441    /// query is fully parsed by [`resolve_projection_aliases`].
442    alias: String,
443    /// `true` if the user supplied an `AS <name>` clause for this
444    /// projection. Used to disambiguate default-alias collisions
445    /// (e.g. `RETURN 1, 2` would otherwise project two `value` keys
446    /// into the same `BTreeMap`).
447    explicit_alias: bool,
448}
449
450// ---------------------------------------------------------------------------
451// Parser
452// ---------------------------------------------------------------------------
453
454struct Parser<'a> {
455    tokens: &'a [Token],
456    pos: usize,
457}
458
459fn parse(tokens: &[Token]) -> Result<Query, CypherError> {
460    let mut p = Parser { tokens, pos: 0 };
461    let q = p.parse_query()?;
462    if p.pos != tokens.len() {
463        return Err(CypherError::ParseError(format!(
464            "trailing tokens after RETURN at position {} ({:?})",
465            p.pos, tokens[p.pos]
466        )));
467    }
468    Ok(q)
469}
470
471impl<'a> Parser<'a> {
472    fn peek(&self) -> Option<&Token> {
473        self.tokens.get(self.pos)
474    }
475
476    fn advance(&mut self) -> Option<&Token> {
477        let t = self.tokens.get(self.pos);
478        self.pos += 1;
479        t
480    }
481
482    fn expect_keyword(&mut self, word: &str) -> Result<(), CypherError> {
483        match self.advance() {
484            Some(Token::Keyword(k)) if k == word => Ok(()),
485            other => Err(CypherError::ParseError(format!(
486                "expected `{word}`, got {other:?}"
487            ))),
488        }
489    }
490
491    fn match_keyword(&mut self, word: &str) -> bool {
492        if matches!(self.peek(), Some(Token::Keyword(k)) if k == word) {
493            self.pos += 1;
494            true
495        } else {
496            false
497        }
498    }
499
500    fn expect(&mut self, want: &Token) -> Result<(), CypherError> {
501        let next = self.advance();
502        if next == Some(want) {
503            Ok(())
504        } else {
505            Err(CypherError::ParseError(format!(
506                "expected {want:?}, got {next:?}"
507            )))
508        }
509    }
510
511    fn parse_query(&mut self) -> Result<Query, CypherError> {
512        self.expect_keyword("MATCH")?;
513        let mut matches = vec![self.parse_pattern()?];
514        while matches!(self.peek(), Some(Token::Comma)) {
515            self.advance();
516            matches.push(self.parse_pattern()?);
517            if matches.len() > MAX_PATTERNS {
518                return Err(CypherError::ParseError(format!(
519                    "too many disjoint MATCH patterns (got {}, cap is {}); \
520                     join through edge syntax (`-[:KIND]->`) instead of a cartesian product",
521                    matches.len(),
522                    MAX_PATTERNS
523                )));
524            }
525        }
526        let where_clause = if self.match_keyword("WHERE") {
527            Some(self.parse_expr()?)
528        } else {
529            None
530        };
531        self.expect_keyword("RETURN")?;
532        let mut projections = vec![self.parse_projection()?];
533        while matches!(self.peek(), Some(Token::Comma)) {
534            self.advance();
535            projections.push(self.parse_projection()?);
536        }
537        resolve_projection_aliases(&mut projections)?;
538        Ok(Query {
539            matches,
540            where_clause,
541            projections,
542        })
543    }
544
545    fn parse_pattern(&mut self) -> Result<Pattern, CypherError> {
546        let head = self.parse_node_pat()?;
547        let mut steps: Vec<RelStep> = Vec::new();
548        loop {
549            // Detect either `-[:X]->` or `<-[:X]-`.
550            let forward = match self.peek() {
551                Some(Token::Dash) => true,
552                Some(Token::LeftArrow) => false,
553                _ => break,
554            };
555            self.advance(); // consume - or <-
556            self.expect(&Token::LBracket)?;
557            self.expect(&Token::Colon)?;
558            let edge_label = match self.advance() {
559                Some(Token::Ident(s)) => s.clone(),
560                other => {
561                    return Err(CypherError::ParseError(format!(
562                        "expected edge label, got {other:?}"
563                    )))
564                }
565            };
566            let var_length = if matches!(self.peek(), Some(Token::Star)) {
567                self.advance();
568                let lo = if matches!(self.peek(), Some(Token::Int(_))) {
569                    if let Some(Token::Int(n)) = self.advance() {
570                        Some(*n)
571                    } else {
572                        None
573                    }
574                } else {
575                    None
576                };
577                let bounds = if matches!(self.peek(), Some(Token::DotDot)) {
578                    self.advance();
579                    let hi = if matches!(self.peek(), Some(Token::Int(_))) {
580                        if let Some(Token::Int(n)) = self.advance() {
581                            Some(*n)
582                        } else {
583                            None
584                        }
585                    } else {
586                        None
587                    };
588                    // `*lo..` with no upper bound: default to the executor
589                    // depth cap so users opt into the maximum traversal
590                    // length without naming it explicitly.
591                    let hi_v = hi.map(|n| n as u32).unwrap_or(VAR_LENGTH_MAX_DEPTH);
592                    (lo.unwrap_or(1) as u32, hi_v)
593                } else {
594                    // bare `*`: 1..3
595                    let lo_v = lo.unwrap_or(1) as u32;
596                    (lo_v, lo_v.max(3))
597                };
598                Some(bounds)
599            } else {
600                None
601            };
602            self.expect(&Token::RBracket)?;
603            if forward {
604                self.expect(&Token::Arrow)?;
605            } else {
606                self.expect(&Token::Dash)?;
607            }
608            let target = self.parse_node_pat()?;
609            steps.push(RelStep {
610                forward,
611                edge_label,
612                var_length,
613                target,
614            });
615        }
616        Ok(Pattern { head, steps })
617    }
618
619    fn parse_node_pat(&mut self) -> Result<NodePat, CypherError> {
620        self.expect(&Token::LParen)?;
621        let var = match self.advance() {
622            Some(Token::Ident(s)) => s.clone(),
623            other => {
624                return Err(CypherError::ParseError(format!(
625                    "expected node variable, got {other:?}"
626                )))
627            }
628        };
629        let label = if matches!(self.peek(), Some(Token::Colon)) {
630            self.advance();
631            match self.advance() {
632                Some(Token::Ident(s)) => Some(s.clone()),
633                other => {
634                    return Err(CypherError::ParseError(format!(
635                        "expected node label, got {other:?}"
636                    )))
637                }
638            }
639        } else {
640            None
641        };
642        let props = if matches!(self.peek(), Some(Token::LBrace)) {
643            self.parse_props()?
644        } else {
645            BTreeMap::new()
646        };
647        self.expect(&Token::RParen)?;
648        Ok(NodePat { var, label, props })
649    }
650
651    fn parse_props(&mut self) -> Result<BTreeMap<String, Literal>, CypherError> {
652        self.expect(&Token::LBrace)?;
653        let mut props: BTreeMap<String, Literal> = BTreeMap::new();
654        loop {
655            let key = match self.advance() {
656                Some(Token::Ident(s)) => s.clone(),
657                other => {
658                    return Err(CypherError::ParseError(format!(
659                        "expected property key, got {other:?}"
660                    )))
661                }
662            };
663            self.expect(&Token::Colon)?;
664            let value = self.parse_literal()?;
665            props.insert(key, value);
666            match self.peek() {
667                Some(Token::Comma) => {
668                    self.advance();
669                }
670                Some(Token::RBrace) => break,
671                other => {
672                    return Err(CypherError::ParseError(format!(
673                        "expected ',' or '}}', got {other:?}"
674                    )))
675                }
676            }
677        }
678        self.expect(&Token::RBrace)?;
679        Ok(props)
680    }
681
682    fn parse_literal(&mut self) -> Result<Literal, CypherError> {
683        match self.advance() {
684            Some(Token::Str(s)) => Ok(Literal::Str(s.clone())),
685            Some(Token::Int(n)) => Ok(Literal::Int(*n)),
686            Some(Token::Keyword(k)) if k == "TRUE" => Ok(Literal::Bool(true)),
687            Some(Token::Keyword(k)) if k == "FALSE" => Ok(Literal::Bool(false)),
688            other => Err(CypherError::ParseError(format!(
689                "expected literal, got {other:?}"
690            ))),
691        }
692    }
693
694    fn parse_expr(&mut self) -> Result<Expr, CypherError> {
695        let mut left = self.parse_and_expr()?;
696        while self.match_keyword("OR") {
697            let right = self.parse_and_expr()?;
698            left = Expr::Or(Box::new(left), Box::new(right));
699        }
700        Ok(left)
701    }
702
703    fn parse_and_expr(&mut self) -> Result<Expr, CypherError> {
704        let mut left = self.parse_unary_expr()?;
705        while self.match_keyword("AND") {
706            let right = self.parse_unary_expr()?;
707            left = Expr::And(Box::new(left), Box::new(right));
708        }
709        Ok(left)
710    }
711
712    fn parse_unary_expr(&mut self) -> Result<Expr, CypherError> {
713        if self.match_keyword("NOT") {
714            let inner = self.parse_unary_expr()?;
715            return Ok(Expr::Not(Box::new(inner)));
716        }
717        self.parse_compare()
718    }
719
720    fn parse_compare(&mut self) -> Result<Expr, CypherError> {
721        if matches!(
722            self.peek(),
723            Some(Token::Keyword(k)) if k == "TRUE" || k == "FALSE"
724        ) {
725            let lit = self.parse_literal()?;
726            return Ok(match lit {
727                Literal::Bool(b) => Expr::Bool(b),
728                _ => unreachable!(),
729            });
730        }
731        let left = self.parse_operand()?;
732        let op = match self.peek() {
733            Some(Token::Eq) => Some(CmpOp::Eq),
734            Some(Token::Neq) => Some(CmpOp::Neq),
735            Some(Token::Lt) => Some(CmpOp::Lt),
736            Some(Token::Le) => Some(CmpOp::Le),
737            Some(Token::Gt) => Some(CmpOp::Gt),
738            Some(Token::Ge) => Some(CmpOp::Ge),
739            _ => None,
740        };
741        let Some(op) = op else {
742            return Err(CypherError::ParseError(
743                "expected comparison operator in WHERE clause".into(),
744            ));
745        };
746        self.advance();
747        let right = self.parse_operand()?;
748        Ok(Expr::Compare(left, op, right))
749    }
750
751    fn parse_operand(&mut self) -> Result<Operand, CypherError> {
752        match self.peek() {
753            Some(Token::Str(_)) | Some(Token::Int(_)) => {
754                let lit = self.parse_literal()?;
755                Ok(Operand::Literal(lit))
756            }
757            Some(Token::Keyword(k)) if k == "TRUE" || k == "FALSE" => {
758                let lit = self.parse_literal()?;
759                Ok(Operand::Literal(lit))
760            }
761            Some(Token::Ident(_)) => {
762                let var = if let Some(Token::Ident(s)) = self.advance() {
763                    s.clone()
764                } else {
765                    unreachable!()
766                };
767                let property = if matches!(self.peek(), Some(Token::Dot)) {
768                    self.advance();
769                    match self.advance() {
770                        Some(Token::Ident(s)) => Some(s.clone()),
771                        other => {
772                            return Err(CypherError::ParseError(format!(
773                                "expected property name after '.', got {other:?}"
774                            )))
775                        }
776                    }
777                } else {
778                    None
779                };
780                Ok(Operand::Path { var, property })
781            }
782            other => Err(CypherError::ParseError(format!(
783                "expected operand, got {other:?}"
784            ))),
785        }
786    }
787
788    fn parse_projection(&mut self) -> Result<Projection, CypherError> {
789        let operand = self.parse_operand()?;
790        let alias = if self.match_keyword("AS") {
791            match self.advance() {
792                Some(Token::Ident(s)) => Some(s.clone()),
793                other => {
794                    return Err(CypherError::ParseError(format!(
795                        "expected alias after AS, got {other:?}"
796                    )))
797                }
798            }
799        } else {
800            None
801        };
802        let explicit_alias = alias.is_some();
803        Ok(Projection {
804            operand,
805            alias: alias.unwrap_or_default(),
806            explicit_alias,
807        })
808    }
809}
810
811fn default_alias(operand: &Operand) -> String {
812    match operand {
813        Operand::Path {
814            var,
815            property: None,
816        } => var.clone(),
817        Operand::Path {
818            var,
819            property: Some(p),
820        } => format!("{var}.{p}"),
821        Operand::Literal(_) => "value".to_string(),
822    }
823}
824
825/// Assign default aliases to every projection that didn't get an
826/// explicit `AS <name>`. Default aliases for literal operands all start
827/// as `"value"`, so without deduplication a query like `RETURN 1, 2`
828/// would silently overwrite the first column. We resolve the collision
829/// deterministically by suffixing the second, third, … occurrence with
830/// `_2`, `_3`, … For non-literal default aliases (`var` or
831/// `var.property`) we leave names unchanged because they are already
832/// disambiguated by the underlying path. If a default-derived alias
833/// collides with an explicit alias, the default is suffixed; an
834/// explicit alias colliding with another explicit alias raises a
835/// `ParseError::ParseError` because that is a user authoring mistake.
836fn resolve_projection_aliases(projections: &mut [Projection]) -> Result<(), CypherError> {
837    use std::collections::HashSet;
838
839    // First pass: collect explicit aliases and detect duplicates among them.
840    let mut seen: HashSet<String> = HashSet::new();
841    for proj in projections.iter() {
842        if proj.explicit_alias && !seen.insert(proj.alias.clone()) {
843            return Err(CypherError::ParseError(format!(
844                "duplicate alias `{}` in RETURN clause",
845                proj.alias
846            )));
847        }
848    }
849
850    // Second pass: fill in default aliases with collision-suffixing.
851    let mut literal_count: usize = 0;
852    for proj in projections.iter_mut() {
853        if proj.explicit_alias {
854            continue;
855        }
856        let base = default_alias(&proj.operand);
857        let is_literal_default = matches!(proj.operand, Operand::Literal(_));
858        let mut candidate = base.clone();
859        if is_literal_default {
860            literal_count += 1;
861            if literal_count >= 2 {
862                candidate = format!("{base}_{literal_count}");
863            }
864        }
865        // Guard against any other accidental collision (default path
866        // alias clashing with an explicit alias or another default).
867        let mut bump: usize = 2;
868        while seen.contains(&candidate) {
869            candidate = format!("{base}_{bump}");
870            bump += 1;
871        }
872        seen.insert(candidate.clone());
873        proj.alias = candidate;
874    }
875    Ok(())
876}
877
878// ---------------------------------------------------------------------------
879// Executor
880// ---------------------------------------------------------------------------
881
882fn exec(query: &Query, graph: &SymbolGraph) -> Result<Vec<CypherRow>, CypherError> {
883    let mut all_rows: Vec<HashMap<String, NodeId>> = vec![HashMap::new()];
884    for pattern in &query.matches {
885        let mut new_rows: Vec<HashMap<String, NodeId>> = Vec::new();
886        for binding in &all_rows {
887            let candidates = match_pattern(pattern, graph, binding)?;
888            for cand in candidates {
889                let mut merged = binding.clone();
890                for (k, v) in cand {
891                    merged.insert(k, v);
892                }
893                new_rows.push(merged);
894                // Pre-projection budget: a cartesian explosion can
895                // balloon `new_rows` long before we ever start
896                // building projected rows, so we cap intermediate
897                // bindings too.
898                if new_rows.len() > MAX_ROWS {
899                    return Err(CypherError::ExecError(format!(
900                        "row budget exceeded ({} > {}); narrow the MATCH or add a WHERE clause",
901                        new_rows.len(),
902                        MAX_ROWS
903                    )));
904                }
905            }
906        }
907        all_rows = new_rows;
908    }
909
910    let mut out: Vec<CypherRow> = Vec::new();
911    for binding in all_rows {
912        if let Some(expr) = &query.where_clause {
913            if !eval_bool(expr, &binding, graph)? {
914                continue;
915            }
916        }
917        let mut row: CypherRow = BTreeMap::new();
918        for proj in &query.projections {
919            let v = eval_operand(&proj.operand, &binding, graph)?;
920            row.insert(proj.alias.clone(), v);
921        }
922        out.push(row);
923        // Post-WHERE budget: even if intermediate bindings stayed
924        // under the cap, a permissive projection on a wide graph can
925        // still hand back too many rows.
926        if out.len() > MAX_ROWS {
927            return Err(CypherError::ExecError(format!(
928                "row budget exceeded ({} > {}); narrow the MATCH or add a WHERE clause",
929                out.len(),
930                MAX_ROWS
931            )));
932        }
933    }
934    Ok(out)
935}
936
937fn match_pattern(
938    pattern: &Pattern,
939    graph: &SymbolGraph,
940    seed: &HashMap<String, NodeId>,
941) -> Result<Vec<HashMap<String, NodeId>>, CypherError> {
942    let head_candidates: Vec<NodeId> = if let Some(existing) = seed.get(&pattern.head.var) {
943        // Already bound — verify it matches the constraints.
944        if node_matches(graph, *existing, &pattern.head) {
945            vec![*existing]
946        } else {
947            vec![]
948        }
949    } else {
950        candidate_nodes(graph, &pattern.head)
951    };
952
953    let mut out: Vec<HashMap<String, NodeId>> = Vec::new();
954    for head_id in head_candidates {
955        let mut binding: HashMap<String, NodeId> = HashMap::new();
956        binding.insert(pattern.head.var.clone(), head_id);
957        extend_steps(graph, &pattern.steps, 0, head_id, binding, &mut out)?;
958    }
959    Ok(out)
960}
961
962fn extend_steps(
963    graph: &SymbolGraph,
964    steps: &[RelStep],
965    idx: usize,
966    cursor: NodeId,
967    binding: HashMap<String, NodeId>,
968    out: &mut Vec<HashMap<String, NodeId>>,
969) -> Result<(), CypherError> {
970    if idx == steps.len() {
971        out.push(binding);
972        return Ok(());
973    }
974    let step = &steps[idx];
975    let (kind, label_reversed) =
976        EdgeKind::parse_with_direction(&step.edge_label).ok_or_else(|| {
977            CypherError::ExecError(format!("unknown edge label `{}`", step.edge_label))
978        })?;
979    // Pattern arrow direction XOR label inversion = effective direction.
980    let forward = step.forward ^ label_reversed;
981    let (lo, hi) = step.var_length.unwrap_or((1, 1));
982    if hi > VAR_LENGTH_MAX_DEPTH {
983        return Err(CypherError::ExecError(format!(
984            "variable-length traversal capped at depth {VAR_LENGTH_MAX_DEPTH} (got `*{lo}..{hi}`)"
985        )));
986    }
987    let lo = lo.max(1);
988    let hi = hi.max(lo);
989
990    // Depth-first enumerate every reachable node at depths in [lo, hi].
991    let mut visited: HashSet<NodeId> = HashSet::new();
992    visited.insert(cursor);
993    let mut stack: Vec<(NodeId, u32, HashSet<NodeId>)> = vec![(cursor, 0, visited)];
994    while let Some((node, depth, visited)) = stack.pop() {
995        if depth >= hi {
996            continue;
997        }
998        let edges = if forward {
999            graph.outgoing(node)
1000        } else {
1001            graph.incoming(node)
1002        };
1003        for edge in edges {
1004            if edge.kind != kind {
1005                continue;
1006            }
1007            let next = if forward { edge.to } else { edge.from };
1008            if visited.contains(&next) {
1009                continue;
1010            }
1011            let new_depth = depth + 1;
1012            if new_depth >= lo && node_matches(graph, next, &step.target) {
1013                let already_bound = binding.get(&step.target.var);
1014                if matches!(already_bound, Some(id) if *id != next) {
1015                    // Variable rebinds to a different id — skip.
1016                } else {
1017                    let mut next_binding = binding.clone();
1018                    next_binding.insert(step.target.var.clone(), next);
1019                    extend_steps(graph, steps, idx + 1, next, next_binding, out)?;
1020                }
1021            }
1022            if new_depth < hi {
1023                let mut next_visited = visited.clone();
1024                next_visited.insert(next);
1025                stack.push((next, new_depth, next_visited));
1026            }
1027        }
1028    }
1029    Ok(())
1030}
1031
1032fn candidate_nodes(graph: &SymbolGraph, pat: &NodePat) -> Vec<NodeId> {
1033    // Property-driven fast path: if `name` is constrained, scan by name.
1034    if let Some(Literal::Str(name)) = pat.props.get("name") {
1035        return graph
1036            .nodes_named(name)
1037            .iter()
1038            .copied()
1039            .filter(|nid| node_matches(graph, *nid, pat))
1040            .collect();
1041    }
1042    if let Some(label) = &pat.label {
1043        let kind = NodeKind::parse(label).unwrap_or(NodeKind::Module);
1044        return graph
1045            .nodes_of_kind(kind)
1046            .into_iter()
1047            .filter(|nid| node_matches(graph, *nid, pat))
1048            .collect();
1049    }
1050    graph
1051        .all_node_ids()
1052        .into_iter()
1053        .filter(|nid| node_matches(graph, *nid, pat))
1054        .collect()
1055}
1056
1057fn node_matches(graph: &SymbolGraph, id: NodeId, pat: &NodePat) -> bool {
1058    let Some(node) = graph.node(id) else {
1059        return false;
1060    };
1061    if let Some(label) = &pat.label {
1062        let Some(kind) = NodeKind::parse(label) else {
1063            return false;
1064        };
1065        if node.kind != kind {
1066            return false;
1067        }
1068    }
1069    for (key, expected) in &pat.props {
1070        let actual = property_value(node, key);
1071        if &actual != expected {
1072            return false;
1073        }
1074    }
1075    true
1076}
1077
1078fn property_value(node: &super::symbol_graph::Node, key: &str) -> Literal {
1079    match key {
1080        "name" => Literal::Str(node.name.clone()),
1081        "path" => Literal::Str(node.path.clone()),
1082        "language" => Literal::Str(node.language.clone()),
1083        "kind" => Literal::Str(node.kind.as_str().to_string()),
1084        "container" => Literal::Str(node.container.clone().unwrap_or_default()),
1085        "access_level" => Literal::Str(node.access_level.clone().unwrap_or_default()),
1086        "signature" => Literal::Str(node.signature.clone()),
1087        "line" => Literal::Int(node.line as i64),
1088        "file_id" => Literal::Int(node.file_id as i64),
1089        "id" => Literal::Int(node.id as i64),
1090        _ => Literal::Str(String::new()),
1091    }
1092}
1093
1094fn eval_bool(
1095    expr: &Expr,
1096    binding: &HashMap<String, NodeId>,
1097    graph: &SymbolGraph,
1098) -> Result<bool, CypherError> {
1099    match expr {
1100        Expr::Bool(b) => Ok(*b),
1101        Expr::And(a, b) => Ok(eval_bool(a, binding, graph)? && eval_bool(b, binding, graph)?),
1102        Expr::Or(a, b) => Ok(eval_bool(a, binding, graph)? || eval_bool(b, binding, graph)?),
1103        Expr::Not(inner) => Ok(!eval_bool(inner, binding, graph)?),
1104        Expr::Compare(l, op, r) => {
1105            let lv = lookup_operand(l, binding, graph)?;
1106            let rv = lookup_operand(r, binding, graph)?;
1107            Ok(apply_cmp(&lv, *op, &rv))
1108        }
1109    }
1110}
1111
1112fn apply_cmp(left: &Literal, op: CmpOp, right: &Literal) -> bool {
1113    match (left, right) {
1114        (Literal::Int(a), Literal::Int(b)) => match op {
1115            CmpOp::Eq => a == b,
1116            CmpOp::Neq => a != b,
1117            CmpOp::Lt => a < b,
1118            CmpOp::Le => a <= b,
1119            CmpOp::Gt => a > b,
1120            CmpOp::Ge => a >= b,
1121        },
1122        (Literal::Str(a), Literal::Str(b)) => match op {
1123            CmpOp::Eq => a == b,
1124            CmpOp::Neq => a != b,
1125            CmpOp::Lt => a < b,
1126            CmpOp::Le => a <= b,
1127            CmpOp::Gt => a > b,
1128            CmpOp::Ge => a >= b,
1129        },
1130        (Literal::Bool(a), Literal::Bool(b)) => match op {
1131            CmpOp::Eq => a == b,
1132            CmpOp::Neq => a != b,
1133            _ => false,
1134        },
1135        _ => matches!(op, CmpOp::Neq),
1136    }
1137}
1138
1139fn lookup_operand(
1140    op: &Operand,
1141    binding: &HashMap<String, NodeId>,
1142    graph: &SymbolGraph,
1143) -> Result<Literal, CypherError> {
1144    match op {
1145        Operand::Literal(lit) => Ok(lit.clone()),
1146        Operand::Path { var, property } => {
1147            let id = binding.get(var).copied().ok_or_else(|| {
1148                CypherError::ExecError(format!("unbound variable `{var}` in expression"))
1149            })?;
1150            let node = graph
1151                .node(id)
1152                .ok_or_else(|| CypherError::ExecError(format!("node id {id} not in graph")))?;
1153            match property {
1154                Some(p) => Ok(property_value(node, p)),
1155                None => Ok(Literal::Str(node.name.clone())),
1156            }
1157        }
1158    }
1159}
1160
1161fn eval_operand(
1162    op: &Operand,
1163    binding: &HashMap<String, NodeId>,
1164    graph: &SymbolGraph,
1165) -> Result<CypherValue, CypherError> {
1166    let lit = lookup_operand(op, binding, graph)?;
1167    Ok(match lit {
1168        Literal::Str(s) => {
1169            if s.is_empty() {
1170                CypherValue::Null
1171            } else {
1172                CypherValue::String(s)
1173            }
1174        }
1175        Literal::Int(n) => CypherValue::Int(n),
1176        Literal::Bool(b) => CypherValue::Bool(b),
1177    })
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183    use crate::ast::Language;
1184
1185    fn fixture() -> SymbolGraph {
1186        let mut g = SymbolGraph::new();
1187        g.rebuild_file(
1188            1,
1189            "src/a.rs",
1190            Language::Rust,
1191            "fn start() {}\nfn driver() { start(); }\n",
1192            &[],
1193        );
1194        g.rebuild_file(
1195            2,
1196            "src/b.rs",
1197            Language::Rust,
1198            "fn entry() { driver(); }\n",
1199            &[],
1200        );
1201        g
1202    }
1203
1204    #[test]
1205    fn lexer_recognises_arrows_and_keywords() {
1206        let toks = lex("MATCH (a)-[:CALLS]->(b) RETURN a.name").unwrap();
1207        assert!(toks.iter().any(|t| matches!(t, Token::Arrow)));
1208        assert!(toks
1209            .iter()
1210            .any(|t| matches!(t, Token::Keyword(k) if k == "MATCH")));
1211    }
1212
1213    #[test]
1214    fn returns_function_by_name() {
1215        let g = fixture();
1216        let rows = execute(
1217            "MATCH (f:Function {name: 'start'}) RETURN f.path AS path, f.line AS line",
1218            &g,
1219        )
1220        .unwrap();
1221        assert_eq!(rows.len(), 1);
1222        assert_eq!(
1223            rows[0].get("path"),
1224            Some(&CypherValue::String("src/a.rs".into()))
1225        );
1226    }
1227
1228    #[test]
1229    fn called_by_var_length_finds_indirect_callers() {
1230        let g = fixture();
1231        let rows = execute(
1232            "MATCH (f:Function {name: 'start'})<-[:CALLS*1..3]-(c:CallSite) RETURN c.path AS path",
1233            &g,
1234        )
1235        .unwrap();
1236        assert!(!rows.is_empty(), "expected at least one call-site caller");
1237    }
1238
1239    #[test]
1240    fn where_predicate_filters_results() {
1241        let g = fixture();
1242        let rows = execute(
1243            "MATCH (f:Function) WHERE f.name = 'driver' RETURN f.path AS path",
1244            &g,
1245        )
1246        .unwrap();
1247        assert_eq!(rows.len(), 1);
1248        assert_eq!(
1249            rows[0].get("path"),
1250            Some(&CypherValue::String("src/a.rs".into()))
1251        );
1252    }
1253
1254    #[test]
1255    fn literal_default_aliases_do_not_collide() {
1256        // `RETURN 1, 2, 3` previously projected all three literals under
1257        // the same `"value"` key, silently overwriting earlier columns.
1258        // The deduplicator suffixes the 2nd/3rd literals as `value_2`
1259        // / `value_3` so every literal makes it into the row.
1260        let g = fixture();
1261        let rows = execute("MATCH (f:Function {name: 'start'}) RETURN 1, 2, 3", &g).unwrap();
1262        assert_eq!(rows.len(), 1);
1263        let row = &rows[0];
1264        assert_eq!(row.get("value"), Some(&CypherValue::Int(1)));
1265        assert_eq!(row.get("value_2"), Some(&CypherValue::Int(2)));
1266        assert_eq!(row.get("value_3"), Some(&CypherValue::Int(3)));
1267    }
1268
1269    #[test]
1270    fn duplicate_explicit_aliases_are_rejected() {
1271        let g = fixture();
1272        let err = execute("MATCH (f:Function) RETURN f.name AS n, f.path AS n", &g).unwrap_err();
1273        assert!(
1274            matches!(err, CypherError::ParseError(_)),
1275            "expected ParseError for duplicate explicit alias, got {err:?}"
1276        );
1277    }
1278
1279    #[test]
1280    fn rejects_too_many_disjoint_patterns() {
1281        // Four disjoint patterns is one more than `MAX_PATTERNS`; the
1282        // parser refuses before the executor ever sees the cartesian
1283        // explosion.
1284        let g = fixture();
1285        let err = execute(
1286            "MATCH (a:Function),(b:Function),(c:Function),(d:Function) RETURN a.name",
1287            &g,
1288        )
1289        .unwrap_err();
1290        assert!(
1291            matches!(&err, CypherError::ParseError(msg) if msg.contains("too many disjoint MATCH patterns")),
1292            "expected ParseError for too many patterns, got {err:?}"
1293        );
1294    }
1295
1296    #[test]
1297    fn enforces_row_budget_on_cartesian_explosion() {
1298        // Build a synthetic graph with enough Function nodes that even
1299        // a three-pattern cartesian product blows the row budget.
1300        // 25 * 25 * 25 = 15,625 > MAX_ROWS (10,000).
1301        let mut g = SymbolGraph::new();
1302        let mut source = String::new();
1303        for i in 0..25 {
1304            source.push_str(&format!("fn f{i}() {{}}\n"));
1305        }
1306        g.rebuild_file(1, "src/big.rs", Language::Rust, &source, &[]);
1307
1308        let err = execute(
1309            "MATCH (a:Function),(b:Function),(c:Function) RETURN a.name AS n",
1310            &g,
1311        )
1312        .unwrap_err();
1313        assert!(
1314            matches!(&err, CypherError::ExecError(msg) if msg.contains("row budget exceeded")),
1315            "expected ExecError for row budget, got {err:?}"
1316        );
1317    }
1318
1319    #[test]
1320    fn open_upper_bound_defaults_to_depth_cap() {
1321        // `*1..` (no hi) should fall back to VAR_LENGTH_MAX_DEPTH, not 3.
1322        let toks = lex("MATCH (a:Function)-[:CALLS*1..]->(b:Function) RETURN a.name AS n").unwrap();
1323        let q = parse(&toks).unwrap();
1324        let step = &q.matches[0].steps[0];
1325        assert_eq!(step.var_length, Some((1, VAR_LENGTH_MAX_DEPTH)));
1326
1327        // `*2..` likewise.
1328        let toks = lex("MATCH (a:Function)-[:CALLS*2..]->(b:Function) RETURN a.name AS n").unwrap();
1329        let q = parse(&toks).unwrap();
1330        let step = &q.matches[0].steps[0];
1331        assert_eq!(step.var_length, Some((2, VAR_LENGTH_MAX_DEPTH)));
1332    }
1333
1334    #[test]
1335    fn rejects_depth_above_four() {
1336        let g = fixture();
1337        let err = execute(
1338            "MATCH (a:Function)-[:CALLS*1..6]->(b:Function) RETURN a.name AS n",
1339            &g,
1340        )
1341        .unwrap_err();
1342        assert!(
1343            matches!(err, CypherError::ExecError(_)),
1344            "expected ExecError, got {err:?}"
1345        );
1346    }
1347}