Skip to main content

cypherlite_query/parser/
mod.rs

1// Parser module: recursive descent parser producing AST
2/// Abstract Syntax Tree node type definitions.
3pub mod ast;
4/// Clause-level parsers (MATCH, RETURN, CREATE, etc.).
5pub mod clause;
6/// Expression parser (Pratt/precedence-climbing).
7pub mod expression;
8/// Graph pattern parser (nodes and relationships).
9pub mod pattern;
10
11use crate::lexer::{lex, LexError, Span, Token};
12pub use ast::*;
13
14// @MX:ANCHOR: [AUTO] Main entry point for the query pipeline — called by SemanticAnalyzer, Planner, and API layer
15// @MX:REASON: fan_in >= 3; all query processing starts here
16/// Parse a Cypher query string into a `Query` AST (TASK-034).
17///
18/// This is the main public entry point for the parser. It lexes the input,
19/// then dispatches to the appropriate clause parsers based on the leading
20/// keyword token.
21pub fn parse_query(input: &str) -> Result<Query, ParseError> {
22    let tokens = lex(input).map_err(|e: LexError| {
23        // Convert byte offset to line/col for consistent error reporting
24        let mut line = 1;
25        let mut col = 1;
26        for (i, ch) in input.char_indices() {
27            if i >= e.position {
28                break;
29            }
30            if ch == '\n' {
31                line += 1;
32                col = 1;
33            } else {
34                col += 1;
35            }
36        }
37        ParseError {
38            line,
39            column: col,
40            message: e.to_string(),
41        }
42    })?;
43
44    let mut parser = Parser::new(&tokens, input);
45    let mut clauses = Vec::new();
46
47    while !parser.at_end() {
48        let clause = match parser.peek() {
49            Some(Token::Optional) => {
50                parser.advance(); // consume OPTIONAL
51                if !parser.check(&Token::Match) {
52                    return Err(parser.error("expected MATCH after OPTIONAL"));
53                }
54                Clause::Match(parser.parse_match_clause(true)?)
55            }
56            Some(Token::Match) => {
57                // Peek at next token to distinguish MATCH HYPEREDGE from MATCH (pattern)
58                let next1 = parser.tokens.get(parser.pos + 1).map(|(t, _)| t);
59                #[cfg(feature = "hypergraph")]
60                {
61                    if next1 == Some(&Token::Hyperedge) {
62                        parser.advance(); // consume MATCH
63                        Clause::MatchHyperedge(parser.parse_match_hyperedge_clause()?)
64                    } else {
65                        Clause::Match(parser.parse_match_clause(false)?)
66                    }
67                }
68                #[cfg(not(feature = "hypergraph"))]
69                {
70                    let _ = next1;
71                    Clause::Match(parser.parse_match_clause(false)?)
72                }
73            }
74            Some(Token::Return) => Clause::Return(parser.parse_return_clause()?),
75            Some(Token::Create) => {
76                // Peek at next tokens to distinguish CREATE INDEX / CREATE EDGE INDEX / CREATE SNAPSHOT / CREATE HYPEREDGE from CREATE (pattern)
77                let next1 = parser.tokens.get(parser.pos + 1).map(|(t, _)| t);
78                let next2 = parser.tokens.get(parser.pos + 2).map(|(t, _)| t);
79                if next1 == Some(&Token::Index) {
80                    parser.advance(); // consume CREATE
81                    Clause::CreateIndex(parser.parse_create_index_clause()?)
82                } else if next1 == Some(&Token::Edge) && next2 == Some(&Token::Index) {
83                    parser.advance(); // consume CREATE
84                    Clause::CreateIndex(parser.parse_create_edge_index_clause()?)
85                } else {
86                    // Feature-gated CREATE dispatch: HYPEREDGE and SNAPSHOT
87                    // Use labeled block to produce Clause value with cfg-gated early breaks.
88                    #[allow(unused_labels)]
89                    'create_dispatch: {
90                        #[cfg(feature = "hypergraph")]
91                        if next1 == Some(&Token::Hyperedge) {
92                            parser.advance(); // consume CREATE
93                            break 'create_dispatch Clause::CreateHyperedge(
94                                parser.parse_create_hyperedge_clause()?,
95                            );
96                        }
97                        #[cfg(feature = "subgraph")]
98                        if next1 == Some(&Token::Snapshot) {
99                            parser.advance(); // consume CREATE
100                            break 'create_dispatch Clause::CreateSnapshot(
101                                parser.parse_create_snapshot_clause()?,
102                            );
103                        }
104                        Clause::Create(parser.parse_create_clause()?)
105                    }
106                }
107            }
108            Some(Token::Set) => Clause::Set(parser.parse_set_clause()?),
109            Some(Token::Remove) => Clause::Remove(parser.parse_remove_clause()?),
110            Some(Token::Delete) => Clause::Delete(parser.parse_delete_clause(false)?),
111            Some(Token::Detach) => {
112                parser.advance(); // consume DETACH
113                if !parser.check(&Token::Delete) {
114                    return Err(parser.error("expected DELETE after DETACH"));
115                }
116                Clause::Delete(parser.parse_delete_clause(true)?)
117            }
118            Some(Token::With) => Clause::With(parser.parse_with_clause()?),
119            Some(Token::Merge) => Clause::Merge(parser.parse_merge_clause()?),
120            Some(Token::Unwind) => Clause::Unwind(parser.parse_unwind_clause()?),
121            Some(Token::Drop) => Clause::DropIndex(parser.parse_drop_index_clause()?),
122            Some(Token::Where) => {
123                return Err(parser.error("WHERE clause must follow a MATCH clause"));
124            }
125            Some(Token::Order | Token::Limit | Token::Skip) => {
126                return Err(parser.error("ORDER BY / SKIP / LIMIT must be part of a RETURN clause"));
127            }
128            Some(tok) => {
129                return Err(parser.error(format!("unexpected token at top level: {:?}", tok)));
130            }
131            None => break,
132        };
133        clauses.push(clause);
134    }
135
136    if clauses.is_empty() {
137        return Err(ParseError {
138            line: 1,
139            column: 1,
140            message: "empty query".to_string(),
141        });
142    }
143
144    Ok(Query { clauses })
145}
146
147/// Error produced during parsing, with source location.
148#[derive(Debug, Clone, PartialEq)]
149pub struct ParseError {
150    /// 1-based line number.
151    pub line: usize,
152    /// 1-based column number.
153    pub column: usize,
154    /// Human-readable error description.
155    pub message: String,
156}
157
158impl std::fmt::Display for ParseError {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        write!(
161            f,
162            "Parse error at {}:{}: {}",
163            self.line, self.column, self.message
164        )
165    }
166}
167
168impl std::error::Error for ParseError {}
169
170/// Recursive descent parser for the openCypher subset.
171pub struct Parser<'a> {
172    tokens: &'a [(Token, Span)],
173    pos: usize,
174    input: &'a str,
175}
176
177impl<'a> Parser<'a> {
178    /// Create a new parser over the given token slice and source text.
179    pub fn new(tokens: &'a [(Token, Span)], input: &'a str) -> Self {
180        Self {
181            tokens,
182            pos: 0,
183            input,
184        }
185    }
186
187    /// Peek at the current token without consuming it.
188    pub fn peek(&self) -> Option<&Token> {
189        self.tokens.get(self.pos).map(|(t, _)| t)
190    }
191
192    /// Advance the parser by one token, returning the consumed token and span.
193    pub fn advance(&mut self) -> Option<&(Token, Span)> {
194        let tok = self.tokens.get(self.pos);
195        if tok.is_some() {
196            self.pos += 1;
197        }
198        tok
199    }
200
201    /// Expect a specific token, consuming it. Returns the span on success.
202    pub fn expect(&mut self, expected: &Token) -> Result<Span, ParseError> {
203        match self.tokens.get(self.pos) {
204            Some((tok, span)) if tok == expected => {
205                let s = *span;
206                self.pos += 1;
207                Ok(s)
208            }
209            Some((tok, span)) => {
210                let (line, col) = self.offset_to_line_col(span.start);
211                Err(ParseError {
212                    line,
213                    column: col,
214                    message: format!("expected {:?}, found {:?}", expected, tok),
215                })
216            }
217            None => {
218                let offset = self.tokens.last().map(|(_, s)| s.end).unwrap_or(0);
219                let (line, col) = self.offset_to_line_col(offset);
220                Err(ParseError {
221                    line,
222                    column: col,
223                    message: format!("expected {:?}, found end of input", expected),
224                })
225            }
226        }
227    }
228
229    /// Check if the current token matches without consuming it.
230    pub fn check(&self, expected: &Token) -> bool {
231        self.peek() == Some(expected)
232    }
233
234    /// Consume the current token if it matches, returning true.
235    pub fn eat(&mut self, expected: &Token) -> bool {
236        if self.check(expected) {
237            self.pos += 1;
238            true
239        } else {
240            false
241        }
242    }
243
244    /// Return the span of the current token, or a zero-length span at EOF.
245    pub fn current_span(&self) -> Span {
246        self.tokens
247            .get(self.pos)
248            .map(|(_, s)| *s)
249            .unwrap_or(Span { start: 0, end: 0 })
250    }
251
252    /// Convert byte offset to (line, column) using the input string.
253    pub fn offset_to_line_col(&self, offset: usize) -> (usize, usize) {
254        let mut line = 1;
255        let mut col = 1;
256        for (i, ch) in self.input.char_indices() {
257            if i >= offset {
258                break;
259            }
260            if ch == '\n' {
261                line += 1;
262                col = 1;
263            } else {
264                col += 1;
265            }
266        }
267        (line, col)
268    }
269
270    /// Return a `ParseError` at the current position with the given message.
271    pub fn error(&self, message: impl Into<String>) -> ParseError {
272        let span = self.current_span();
273        let (line, col) = self.offset_to_line_col(span.start);
274        ParseError {
275            line,
276            column: col,
277            message: message.into(),
278        }
279    }
280
281    /// Return true when there are no more tokens.
282    pub fn at_end(&self) -> bool {
283        self.pos >= self.tokens.len()
284    }
285
286    /// Expect and consume an identifier, returning its name.
287    pub fn expect_ident(&mut self) -> Result<String, ParseError> {
288        match self.tokens.get(self.pos) {
289            Some((Token::Ident(name), _)) => {
290                let name = name.clone();
291                self.pos += 1;
292                Ok(name)
293            }
294            Some((Token::BacktickIdent(name), _)) => {
295                let name = name.clone();
296                self.pos += 1;
297                Ok(name)
298            }
299            Some((tok, span)) => {
300                let (line, col) = self.offset_to_line_col(span.start);
301                Err(ParseError {
302                    line,
303                    column: col,
304                    message: format!("expected identifier, found {:?}", tok),
305                })
306            }
307            None => {
308                let offset = self.tokens.last().map(|(_, s)| s.end).unwrap_or(0);
309                let (line, col) = self.offset_to_line_col(offset);
310                Err(ParseError {
311                    line,
312                    column: col,
313                    message: "expected identifier, found end of input".to_string(),
314                })
315            }
316        }
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::lexer::Span;
324
325    #[test]
326    fn parser_peek_and_advance() {
327        let tokens = lex("MATCH").expect("should lex");
328        let mut p = Parser::new(&tokens, "MATCH");
329        assert_eq!(p.peek(), Some(&Token::Match));
330        let tok = p.advance();
331        assert!(tok.is_some());
332        assert_eq!(p.peek(), None);
333    }
334
335    #[test]
336    fn parser_expect_success() {
337        let tokens = lex("(").expect("should lex");
338        let mut p = Parser::new(&tokens, "(");
339        let span = p.expect(&Token::LParen);
340        assert!(span.is_ok());
341        assert_eq!(span.expect("checked above"), Span { start: 0, end: 1 });
342    }
343
344    #[test]
345    fn parser_expect_failure() {
346        let tokens = lex("(").expect("should lex");
347        let mut p = Parser::new(&tokens, "(");
348        let result = p.expect(&Token::RParen);
349        assert!(result.is_err());
350    }
351
352    #[test]
353    fn parser_offset_to_line_col() {
354        let input = "line1\nline2\nline3";
355        let tokens = lex(input).expect("should lex");
356        let p = Parser::new(&tokens, input);
357        assert_eq!(p.offset_to_line_col(0), (1, 1));
358        assert_eq!(p.offset_to_line_col(6), (2, 1));
359        assert_eq!(p.offset_to_line_col(12), (3, 1));
360    }
361
362    #[test]
363    fn parser_eat_and_check() {
364        let tokens = lex("( )").expect("should lex");
365        let mut p = Parser::new(&tokens, "( )");
366        assert!(p.check(&Token::LParen));
367        assert!(!p.check(&Token::RParen));
368        assert!(p.eat(&Token::LParen));
369        assert!(!p.eat(&Token::LParen));
370        assert!(p.eat(&Token::RParen));
371        assert!(p.at_end());
372    }
373
374    #[test]
375    fn parser_expect_ident() {
376        let tokens = lex("foo").expect("should lex");
377        let mut p = Parser::new(&tokens, "foo");
378        let name = p.expect_ident();
379        assert_eq!(name.expect("should be ident"), "foo");
380    }
381
382    #[test]
383    fn parser_expect_ident_backtick() {
384        let tokens = lex("`my var`").expect("should lex");
385        let mut p = Parser::new(&tokens, "`my var`");
386        let name = p.expect_ident();
387        assert_eq!(name.expect("should be ident"), "my var");
388    }
389
390    #[test]
391    fn parse_error_display() {
392        let err = ParseError {
393            line: 1,
394            column: 5,
395            message: "unexpected token".to_string(),
396        };
397        assert_eq!(err.to_string(), "Parse error at 1:5: unexpected token");
398    }
399
400    // ======================================================================
401    // TASK-035: Integration tests -- full query round-trip
402    // ======================================================================
403
404    #[test]
405    fn query_match_return() {
406        let q = parse_query("MATCH (n:Person) RETURN n").expect("should parse");
407        assert_eq!(q.clauses.len(), 2);
408        assert!(matches!(&q.clauses[0], Clause::Match(_)));
409        assert!(matches!(&q.clauses[1], Clause::Return(_)));
410
411        if let Clause::Match(mc) = &q.clauses[0] {
412            assert!(!mc.optional);
413            let node = match &mc.pattern.chains[0].elements[0] {
414                PatternElement::Node(n) => n,
415                _ => panic!("expected node"),
416            };
417            assert_eq!(node.labels, vec!["Person".to_string()]);
418        }
419    }
420
421    #[test]
422    fn query_create_with_properties() {
423        let q = parse_query("CREATE (n:Person {name: 'Alice'})").expect("should parse");
424        assert_eq!(q.clauses.len(), 1);
425        assert!(matches!(&q.clauses[0], Clause::Create(_)));
426
427        if let Clause::Create(cc) = &q.clauses[0] {
428            let node = match &cc.pattern.chains[0].elements[0] {
429                PatternElement::Node(n) => n,
430                _ => panic!("expected node"),
431            };
432            assert_eq!(node.labels, vec!["Person".to_string()]);
433            assert!(node.properties.is_some());
434        }
435    }
436
437    #[test]
438    fn query_match_relationship_return() {
439        let q = parse_query("MATCH (a)-[:KNOWS]->(b) RETURN b.name").expect("should parse");
440        assert_eq!(q.clauses.len(), 2);
441
442        if let Clause::Match(mc) = &q.clauses[0] {
443            assert_eq!(mc.pattern.chains[0].elements.len(), 3);
444        } else {
445            panic!("expected MATCH clause");
446        }
447
448        if let Clause::Return(rc) = &q.clauses[1] {
449            assert_eq!(
450                rc.items[0].expr,
451                Expression::Property(
452                    Box::new(Expression::Variable("b".to_string())),
453                    "name".to_string(),
454                )
455            );
456        } else {
457            panic!("expected RETURN clause");
458        }
459    }
460
461    #[test]
462    fn query_match_where_return() {
463        let q =
464            parse_query("MATCH (n:Person) WHERE n.age > 30 RETURN n.name").expect("should parse");
465        assert_eq!(q.clauses.len(), 2);
466
467        if let Clause::Match(mc) = &q.clauses[0] {
468            assert!(mc.where_clause.is_some());
469        } else {
470            panic!("expected MATCH clause");
471        }
472    }
473
474    #[test]
475    fn query_return_order_limit() {
476        let q = parse_query("MATCH (n:Person) RETURN n.name ORDER BY n.name ASC LIMIT 10")
477            .expect("should parse");
478        assert_eq!(q.clauses.len(), 2);
479
480        if let Clause::Return(rc) = &q.clauses[1] {
481            let order = rc.order_by.as_ref().expect("should have ORDER BY");
482            assert_eq!(order.len(), 1);
483            assert!(order[0].ascending);
484            assert_eq!(
485                rc.limit.as_ref().expect("should have LIMIT"),
486                &Expression::Literal(Literal::Integer(10))
487            );
488        } else {
489            panic!("expected RETURN clause");
490        }
491    }
492
493    #[test]
494    fn query_match_set() {
495        let q = parse_query("MATCH (n:Person) SET n.age = 30").expect("should parse");
496        assert_eq!(q.clauses.len(), 2);
497        assert!(matches!(&q.clauses[0], Clause::Match(_)));
498        assert!(matches!(&q.clauses[1], Clause::Set(_)));
499    }
500
501    #[test]
502    fn query_detach_delete() {
503        let q = parse_query("DETACH DELETE n").expect("should parse");
504        assert_eq!(q.clauses.len(), 1);
505
506        if let Clause::Delete(dc) = &q.clauses[0] {
507            assert!(dc.detach);
508            assert_eq!(dc.exprs[0], Expression::Variable("n".to_string()));
509        } else {
510            panic!("expected DELETE clause");
511        }
512    }
513
514    #[test]
515    fn query_optional_match() {
516        let q = parse_query("MATCH (n:Person) OPTIONAL MATCH (n)-[:KNOWS]->(m) RETURN n, m")
517            .expect("should parse");
518        assert_eq!(q.clauses.len(), 3);
519
520        if let Clause::Match(mc) = &q.clauses[0] {
521            assert!(!mc.optional);
522        } else {
523            panic!("expected MATCH clause");
524        }
525
526        if let Clause::Match(mc) = &q.clauses[1] {
527            assert!(mc.optional);
528        } else {
529            panic!("expected OPTIONAL MATCH clause");
530        }
531    }
532
533    #[test]
534    fn query_count_star() {
535        let q = parse_query("MATCH (n) RETURN count(*)").expect("should parse");
536        assert_eq!(q.clauses.len(), 2);
537
538        if let Clause::Return(rc) = &q.clauses[1] {
539            assert_eq!(rc.items[0].expr, Expression::CountStar);
540        } else {
541            panic!("expected RETURN clause");
542        }
543    }
544
545    #[test]
546    fn query_count_distinct() {
547        let q = parse_query("MATCH (n) RETURN count(DISTINCT n.name)").expect("should parse");
548
549        if let Clause::Return(rc) = &q.clauses[1] {
550            assert_eq!(
551                rc.items[0].expr,
552                Expression::FunctionCall {
553                    name: "count".to_string(),
554                    distinct: true,
555                    args: vec![Expression::Property(
556                        Box::new(Expression::Variable("n".to_string())),
557                        "name".to_string(),
558                    )],
559                }
560            );
561        } else {
562            panic!("expected RETURN clause");
563        }
564    }
565
566    #[test]
567    fn query_create_relationship() {
568        let q = parse_query("CREATE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'})")
569            .expect("should parse");
570        assert_eq!(q.clauses.len(), 1);
571
572        if let Clause::Create(cc) = &q.clauses[0] {
573            assert_eq!(cc.pattern.chains[0].elements.len(), 3);
574            // Verify node a
575            if let PatternElement::Node(n) = &cc.pattern.chains[0].elements[0] {
576                assert_eq!(n.variable, Some("a".to_string()));
577                assert_eq!(n.labels, vec!["Person".to_string()]);
578            }
579            // Verify relationship
580            if let PatternElement::Relationship(r) = &cc.pattern.chains[0].elements[1] {
581                assert_eq!(r.rel_types, vec!["KNOWS".to_string()]);
582                assert_eq!(r.direction, RelDirection::Outgoing);
583            }
584            // Verify node b
585            if let PatternElement::Node(n) = &cc.pattern.chains[0].elements[2] {
586                assert_eq!(n.variable, Some("b".to_string()));
587            }
588        } else {
589            panic!("expected CREATE clause");
590        }
591    }
592
593    // -- Error cases --
594
595    #[test]
596    fn query_error_empty() {
597        let result = parse_query("");
598        assert!(result.is_err());
599        assert!(result
600            .expect_err("should fail")
601            .message
602            .contains("empty query"));
603    }
604
605    #[test]
606    fn query_error_where_without_match() {
607        let result = parse_query("WHERE n.age > 30");
608        assert!(result.is_err());
609        assert!(result.expect_err("should fail").message.contains("WHERE"));
610    }
611
612    #[test]
613    fn query_error_order_without_return() {
614        let result = parse_query("ORDER BY n.name");
615        assert!(result.is_err());
616    }
617
618    #[test]
619    fn query_error_unexpected_token() {
620        let result = parse_query("42");
621        assert!(result.is_err());
622    }
623
624    #[test]
625    fn query_error_lex_error() {
626        let result = parse_query("MATCH @");
627        assert!(result.is_err());
628    }
629
630    #[test]
631    fn query_with_clause() {
632        let q = parse_query("MATCH (n) WITH n WHERE n.age > 30 RETURN n").expect("should parse");
633        assert_eq!(q.clauses.len(), 3);
634        assert!(matches!(&q.clauses[0], Clause::Match(_)));
635        assert!(matches!(&q.clauses[1], Clause::With(_)));
636        assert!(matches!(&q.clauses[2], Clause::Return(_)));
637
638        if let Clause::With(wc) = &q.clauses[1] {
639            assert!(wc.where_clause.is_some());
640        }
641    }
642
643    #[test]
644    fn query_merge() {
645        let q = parse_query("MERGE (n:Person {name: 'Alice'})").expect("should parse");
646        assert_eq!(q.clauses.len(), 1);
647        assert!(matches!(&q.clauses[0], Clause::Merge(_)));
648    }
649
650    #[test]
651    fn query_match_remove() {
652        let q = parse_query("MATCH (n:Person) REMOVE n.email, n:Temp").expect("should parse");
653        assert_eq!(q.clauses.len(), 2);
654        assert!(matches!(&q.clauses[1], Clause::Remove(_)));
655    }
656
657    #[test]
658    fn query_match_delete() {
659        let q = parse_query("MATCH (n:Person) DELETE n").expect("should parse");
660        assert_eq!(q.clauses.len(), 2);
661
662        if let Clause::Delete(dc) = &q.clauses[1] {
663            assert!(!dc.detach);
664        }
665    }
666
667    #[test]
668    fn query_return_skip_limit() {
669        let q = parse_query("MATCH (n) RETURN n SKIP 5 LIMIT 10").expect("should parse");
670
671        if let Clause::Return(rc) = &q.clauses[1] {
672            assert_eq!(
673                rc.skip.as_ref().expect("should have SKIP"),
674                &Expression::Literal(Literal::Integer(5))
675            );
676            assert_eq!(
677                rc.limit.as_ref().expect("should have LIMIT"),
678                &Expression::Literal(Literal::Integer(10))
679            );
680        }
681    }
682
683    #[test]
684    fn query_case_insensitive() {
685        let q = parse_query("match (n:Person) return n").expect("should parse");
686        assert_eq!(q.clauses.len(), 2);
687    }
688
689    // ======================================================================
690    // TASK-098: CREATE INDEX / DROP INDEX parsing
691    // ======================================================================
692
693    #[test]
694    fn query_create_index_with_name() {
695        let q = parse_query("CREATE INDEX idx_person_name ON :Person(name)").expect("should parse");
696        assert_eq!(q.clauses.len(), 1);
697
698        if let Clause::CreateIndex(ci) = &q.clauses[0] {
699            assert_eq!(ci.name, Some("idx_person_name".to_string()));
700            assert_eq!(ci.target, IndexTarget::NodeLabel("Person".to_string()));
701            assert_eq!(ci.property, "name");
702        } else {
703            panic!("expected CreateIndex clause");
704        }
705    }
706
707    #[test]
708    fn query_create_index_without_name() {
709        let q = parse_query("CREATE INDEX ON :Person(name)").expect("should parse");
710        assert_eq!(q.clauses.len(), 1);
711
712        if let Clause::CreateIndex(ci) = &q.clauses[0] {
713            assert_eq!(ci.name, None);
714            assert_eq!(ci.target, IndexTarget::NodeLabel("Person".to_string()));
715            assert_eq!(ci.property, "name");
716        } else {
717            panic!("expected CreateIndex clause");
718        }
719    }
720
721    #[test]
722    fn query_drop_index() {
723        let q = parse_query("DROP INDEX idx_person_name").expect("should parse");
724        assert_eq!(q.clauses.len(), 1);
725
726        if let Clause::DropIndex(di) = &q.clauses[0] {
727            assert_eq!(di.name, "idx_person_name");
728        } else {
729            panic!("expected DropIndex clause");
730        }
731    }
732
733    // CC-T3: CREATE EDGE INDEX parsing
734    #[test]
735    fn query_create_edge_index_with_name() {
736        let q = parse_query("CREATE EDGE INDEX eidx_knows_since ON :KNOWS(since)")
737            .expect("should parse");
738        assert_eq!(q.clauses.len(), 1);
739
740        if let Clause::CreateIndex(ci) = &q.clauses[0] {
741            assert_eq!(ci.name, Some("eidx_knows_since".to_string()));
742            assert_eq!(
743                ci.target,
744                IndexTarget::RelationshipType("KNOWS".to_string())
745            );
746            assert_eq!(ci.property, "since");
747        } else {
748            panic!("expected CreateIndex clause with RelationshipType target");
749        }
750    }
751
752    #[test]
753    fn query_create_edge_index_without_name() {
754        let q = parse_query("CREATE EDGE INDEX ON :LIKES(weight)").expect("should parse");
755        assert_eq!(q.clauses.len(), 1);
756
757        if let Clause::CreateIndex(ci) = &q.clauses[0] {
758            assert_eq!(ci.name, None);
759            assert_eq!(
760                ci.target,
761                IndexTarget::RelationshipType("LIKES".to_string())
762            );
763            assert_eq!(ci.property, "weight");
764        } else {
765            panic!("expected CreateIndex clause with RelationshipType target");
766        }
767    }
768
769    // ======================================================================
770    // TASK-068: UNWIND clause integration tests
771    // ======================================================================
772
773    #[test]
774    fn query_unwind_list_return() {
775        let q = parse_query("UNWIND [1, 2, 3] AS x RETURN x").expect("should parse");
776        assert_eq!(q.clauses.len(), 2);
777        assert!(matches!(&q.clauses[0], Clause::Unwind(_)));
778        assert!(matches!(&q.clauses[1], Clause::Return(_)));
779
780        if let Clause::Unwind(uc) = &q.clauses[0] {
781            assert_eq!(uc.variable, "x");
782            assert!(matches!(&uc.expr, Expression::ListLiteral(_)));
783        }
784    }
785
786    #[test]
787    fn query_match_unwind_return() {
788        let q =
789            parse_query("MATCH (n:Person) UNWIND n.hobbies AS h RETURN h").expect("should parse");
790        assert_eq!(q.clauses.len(), 3);
791        assert!(matches!(&q.clauses[0], Clause::Match(_)));
792        assert!(matches!(&q.clauses[1], Clause::Unwind(_)));
793        assert!(matches!(&q.clauses[2], Clause::Return(_)));
794    }
795
796    // ======================================================================
797    // HH-001: CREATE SNAPSHOT full query integration tests (cfg-gated)
798    // ======================================================================
799
800    #[cfg(feature = "subgraph")]
801    mod snapshot_integration_tests {
802        use super::*;
803
804        // HH-001: Full CREATE SNAPSHOT query
805        #[test]
806        fn query_create_snapshot_basic() {
807            let q = parse_query("CREATE SNAPSHOT (s:Snap) FROM MATCH (n:Person) RETURN n")
808                .expect("should parse");
809            assert_eq!(q.clauses.len(), 1);
810            assert!(matches!(&q.clauses[0], Clause::CreateSnapshot(_)));
811
812            if let Clause::CreateSnapshot(sc) = &q.clauses[0] {
813                assert_eq!(sc.variable, Some("s".to_string()));
814                assert_eq!(sc.labels, vec!["Snap".to_string()]);
815                assert!(sc.properties.is_none());
816                assert!(sc.temporal_anchor.is_none());
817            } else {
818                panic!("expected CreateSnapshot clause");
819            }
820        }
821
822        // HH-001: CREATE SNAPSHOT with AT TIME
823        #[test]
824        fn query_create_snapshot_with_at_time() {
825            let q =
826                parse_query("CREATE SNAPSHOT (s:Snap) AT TIME 1000 FROM MATCH (n:Person) RETURN n")
827                    .expect("should parse");
828            assert_eq!(q.clauses.len(), 1);
829
830            if let Clause::CreateSnapshot(sc) = &q.clauses[0] {
831                assert!(sc.temporal_anchor.is_some());
832            } else {
833                panic!("expected CreateSnapshot clause");
834            }
835        }
836
837        // HH-001: CREATE SNAPSHOT with WHERE in FROM MATCH
838        #[test]
839        fn query_create_snapshot_with_where() {
840            let q = parse_query(
841                "CREATE SNAPSHOT (s:Snap) FROM MATCH (n:Person) WHERE n.age > 30 RETURN n",
842            )
843            .expect("should parse");
844
845            if let Clause::CreateSnapshot(sc) = &q.clauses[0] {
846                assert!(sc.from_match.where_clause.is_some());
847            } else {
848                panic!("expected CreateSnapshot clause");
849            }
850        }
851
852        // HH-001: CREATE SNAPSHOT with properties and complex FROM
853        #[test]
854        fn query_create_snapshot_with_props_and_rel() {
855            let q = parse_query(
856                "CREATE SNAPSHOT (s:Snap {name: 'team'}) FROM MATCH (n:Person)-[:KNOWS]->(m) RETURN n, m",
857            )
858            .expect("should parse");
859
860            if let Clause::CreateSnapshot(sc) = &q.clauses[0] {
861                assert!(sc.properties.is_some());
862                assert_eq!(sc.from_return.len(), 2);
863                // Verify the match pattern has a relationship
864                assert_eq!(sc.from_match.pattern.chains[0].elements.len(), 3);
865            } else {
866                panic!("expected CreateSnapshot clause");
867            }
868        }
869    }
870}