From 165682adf376fd04010e936bb9e21e1f1da98955 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Fri, 29 May 2026 14:55:35 -0600 Subject: [PATCH 1/4] =?UTF-8?q?feat(parser):=20pattern=20parser=20?= =?UTF-8?q?=E2=80=94=20NodePattern,=20RelPattern,=20PathPattern=20(#651)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements crates/gf-cypher/src/parser/patterns.rs with the full openCypher pattern grammar: - NodePattern: anonymous, variable, label(s), properties - RelPattern: all 6 syntactic forms (-->, <--, --, -[r]->, etc.) with optional variable, type list (pipe-separated), variable-length range (*1..3), and inline properties - PathPattern: chained node+rel elements, optional named path (p = ...) - parse_pattern_list: comma-separated patterns 24 unit tests, all passing. Delegates property parsing to parse_expr. Closes #651 Co-Authored-By: Claude Sonnet 4.6 --- crates/gf-cypher/src/parser/mod.rs | 2 + crates/gf-cypher/src/parser/patterns.rs | 634 ++++++++++++++++++++++++ 2 files changed, 636 insertions(+) create mode 100644 crates/gf-cypher/src/parser/patterns.rs diff --git a/crates/gf-cypher/src/parser/mod.rs b/crates/gf-cypher/src/parser/mod.rs index cadba14..563cb6f 100644 --- a/crates/gf-cypher/src/parser/mod.rs +++ b/crates/gf-cypher/src/parser/mod.rs @@ -1,6 +1,8 @@ pub mod expr; +pub mod patterns; pub use expr::parse_expr; +pub use patterns::{parse_node_pattern, parse_pattern, parse_pattern_list}; use crate::lexer::{Lexer, Tok}; use gf_ast::{AstQuery, ParseError, ParseErrorKind}; diff --git a/crates/gf-cypher/src/parser/patterns.rs b/crates/gf-cypher/src/parser/patterns.rs new file mode 100644 index 0000000..38f7a63 --- /dev/null +++ b/crates/gf-cypher/src/parser/patterns.rs @@ -0,0 +1,634 @@ +use crate::lexer::Tok; +use gf_ast::{Direction, Expr, NodePattern, ParseError, PathElement, PathPattern, RelPattern}; +use gf_core::Span; + +use super::TokenStream; +use super::expr::parse_expr; + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/// Parse a single path pattern (optionally named: `p = (...)`). +pub fn parse_pattern(ts: &mut TokenStream) -> Result { + let start = ts.current_pos(); + + // Named path: `p = (...)` + let var = if matches!(ts.peek(), Some(Tok::Ident(_))) && ts.peek_n(1) == Some(&Tok::Eq) { + let name = eat_ident(ts)?; + ts.eat(&Tok::Eq)?; + Some(name) + } else { + None + }; + + let first = parse_node_pattern(ts)?; + let mut elements: Vec = vec![PathElement::Node(first)]; + + loop { + // Try to parse a relationship pattern + match ts.peek() { + Some(Tok::RelOpen) | Some(Tok::Minus) | Some(Tok::LeftArrow) => { + let rel = parse_rel_pattern(ts)?; + elements.push(PathElement::Rel(rel)); + let node = parse_node_pattern(ts)?; + elements.push(PathElement::Node(node)); + } + _ => break, + } + } + + Ok(PathPattern { + var, + elements, + span: ts.span_from(start), + }) +} + +/// Parse a comma-separated list of path patterns. +pub fn parse_pattern_list(ts: &mut TokenStream) -> Result, ParseError> { + let mut patterns = vec![parse_pattern(ts)?]; + while ts.eat_if(&Tok::Comma) { + patterns.push(parse_pattern(ts)?); + } + Ok(patterns) +} + +/// Parse a node pattern: `( [var] [:Label]* [{props}] )`. +pub fn parse_node_pattern(ts: &mut TokenStream) -> Result { + let start = ts.current_pos(); + ts.eat(&Tok::LParen)?; + + // Optional variable + let var = match ts.peek() { + Some(Tok::Ident(_)) => Some(eat_ident(ts)?), + _ => None, + }; + + // Zero or more labels: `:Label` + let mut labels = Vec::new(); + while ts.eat_if(&Tok::Colon) { + labels.push(eat_label_name(ts)?); + } + + // Optional property map + let properties = if ts.peek() == Some(&Tok::LBrace) { + Some(parse_expr(ts, 0)?) + } else { + None + }; + + let (_, r) = ts.eat(&Tok::RParen)?; + + Ok(NodePattern { + var, + labels, + properties, + span: Span::new(start, r), + }) +} + +// --------------------------------------------------------------------------- +// Relationship pattern +// --------------------------------------------------------------------------- + +fn parse_rel_pattern(ts: &mut TokenStream) -> Result { + let start = ts.current_pos(); + + // Determine leading token to decide direction and whether bracket is present. + // Possible openings: + // RelOpen (-[) → Out or Undirected (need to check closing) + // LeftArrow (<-) → In or Both + // Minus (-) → Out (-->) or Undirected (--) — short forms without bracket + + match ts.peek().cloned() { + Some(Tok::RelOpen) => { + // -[ ... ]-> or -[ ... ]- + ts.advance(); // consume RelOpen (-[) + let (var, types, min_hops, max_hops, properties) = parse_rel_detail(ts)?; + let (_, r_brace) = ts.eat(&Tok::RBracket)?; + let direction = if ts.eat_if(&Tok::RightArrow) { + Direction::Out + } else if ts.eat_if(&Tok::Minus) { + Direction::Undirected + } else { + return Err(ts.err("expected `->` or `-` after `]`")); + }; + Ok(RelPattern { + var, + types, + direction, + min_hops, + max_hops, + properties, + span: Span::new(start, r_brace), + }) + } + Some(Tok::LeftArrow) => { + // <-[...]- or <-[...]-> or <-- + ts.advance(); // consume LeftArrow (<-) + if ts.eat_if(&Tok::LBracket) { + // <-[ ... ]-> or <-[ ... ]- + let (var, types, min_hops, max_hops, properties) = parse_rel_detail(ts)?; + let (_, _r_brace) = ts.eat(&Tok::RBracket)?; + let direction = if ts.eat_if(&Tok::RightArrow) { + // <-[r]-> means both directions + Direction::Undirected + } else if ts.eat_if(&Tok::Minus) { + Direction::In + } else { + return Err(ts.err("expected `->` or `-` after `]`")); + }; + Ok(RelPattern { + var, + types, + direction, + min_hops, + max_hops, + properties, + span: Span::new(start, ts.current_pos()), + }) + } else { + // <-- (anonymous incoming, no bracket) + ts.eat(&Tok::Minus)?; + Ok(RelPattern { + var: None, + types: vec![], + direction: Direction::In, + min_hops: None, + max_hops: None, + properties: None, + span: Span::new(start, ts.current_pos()), + }) + } + } + Some(Tok::Minus) => { + // --> or -- + ts.advance(); // consume Minus + if ts.eat_if(&Tok::RightArrow) { + // --> + Ok(RelPattern { + var: None, + types: vec![], + direction: Direction::Out, + min_hops: None, + max_hops: None, + properties: None, + span: Span::new(start, ts.current_pos()), + }) + } else if ts.eat_if(&Tok::Minus) { + // -- + Ok(RelPattern { + var: None, + types: vec![], + direction: Direction::Undirected, + min_hops: None, + max_hops: None, + properties: None, + span: Span::new(start, ts.current_pos()), + }) + } else { + Err(ts.err("expected `->` or `-` after `-`")) + } + } + _ => Err(ts.err("expected relationship pattern (`-`, `<-`, or `-[`)")), + } +} + +/// Parse the interior of a bracketed relationship pattern: +/// `[var :TYPE|OTHER *min..max {props}]` — everything between `[` and `]`. +fn parse_rel_detail( + ts: &mut TokenStream, +) -> Result< + ( + Option, + Vec, + Option, + Option, + Option, + ), + ParseError, +> { + // Optional variable + let var = match ts.peek() { + Some(Tok::Ident(_)) => Some(eat_ident(ts)?), + _ => None, + }; + + // Optional type list: `:TYPE1|TYPE2` + let mut types = Vec::new(); + if ts.eat_if(&Tok::Colon) { + types.push(eat_label_name(ts)?); + while ts.eat_if(&Tok::Pipe) { + types.push(eat_label_name(ts)?); + } + } + + // Optional variable-length: `*`, `*2`, `*1..3`, `*..5`, `*3..` + let (min_hops, max_hops) = if ts.eat_if(&Tok::Star) { + parse_hop_range(ts)? + } else { + (None, None) + }; + + // Optional property map + let properties = if ts.peek() == Some(&Tok::LBrace) { + Some(parse_expr(ts, 0)?) + } else { + None + }; + + Ok((var, types, min_hops, max_hops, properties)) +} + +/// Parse optional hop-range after `*`: ``, `2`, `1..3`, `..5`, `3..` +/// Returns `(min_hops, max_hops)`. Both `None` means bare `*` (any hops). +fn parse_hop_range(ts: &mut TokenStream) -> Result<(Option, Option), ParseError> { + match ts.peek().cloned() { + // `*..N` — no lower bound + Some(Tok::DotDot) => { + ts.advance(); + let max = parse_u32_lit(ts)?; + Ok((None, Some(max))) + } + // `*N` or `*N..` or `*N..M` + Some(Tok::IntLit(n)) => { + ts.advance(); + let min = n as u32; + if ts.eat_if(&Tok::DotDot) { + match ts.peek() { + Some(Tok::IntLit(_)) => { + let max = parse_u32_lit(ts)?; + Ok((Some(min), Some(max))) + } + _ => Ok((Some(min), None)), // `*N..` — no upper bound + } + } else { + // bare `*N` — exactly N hops + Ok((Some(min), Some(min))) + } + } + // bare `*` — no bounds + _ => Ok((None, None)), + } +} + +fn parse_u32_lit(ts: &mut TokenStream) -> Result { + match ts.peek().cloned() { + Some(Tok::IntLit(n)) => { + ts.advance(); + Ok(n as u32) + } + _ => Err(ts.err("expected integer for hop count")), + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Consume an identifier or keyword-as-identifier. +fn eat_ident(ts: &mut TokenStream) -> Result { + match ts.peek().cloned() { + Some(Tok::Ident(name)) => { + ts.advance(); + Ok(name) + } + _ => Err(ts.err("expected identifier")), + } +} + +/// Consume a label or type name: identifiers or keyword-as-label. +/// In Cypher, `:Match`, `:Return`, etc. are valid label/type names. +fn eat_label_name(ts: &mut TokenStream) -> Result { + match ts.peek().cloned() { + Some(Tok::Ident(name)) => { + ts.advance(); + Ok(name) + } + Some(tok) => { + if let Some(kw) = tok_as_label_str(&tok) { + ts.advance(); + Ok(kw.to_owned()) + } else { + Err(ts.err("expected label or type name")) + } + } + None => Err(ts.err("expected label or type name, found end of input")), + } +} + +/// Map keyword tokens to their string form for use as label/type names. +fn tok_as_label_str(tok: &Tok) -> Option<&'static str> { + match tok { + Tok::Match => Some("Match"), + Tok::Return => Some("Return"), + Tok::Where => Some("Where"), + Tok::With => Some("With"), + Tok::As => Some("As"), + Tok::Distinct => Some("Distinct"), + Tok::Union => Some("Union"), + Tok::All => Some("All"), + Tok::Create => Some("Create"), + Tok::Merge => Some("Merge"), + Tok::On => Some("On"), + Tok::Set => Some("Set"), + Tok::Remove => Some("Remove"), + Tok::Delete => Some("Delete"), + Tok::Detach => Some("Detach"), + Tok::Call => Some("Call"), + Tok::Yield => Some("Yield"), + Tok::Unwind => Some("Unwind"), + Tok::Order => Some("Order"), + Tok::By => Some("By"), + Tok::Skip => Some("Skip"), + Tok::Limit => Some("Limit"), + Tok::Is => Some("Is"), + Tok::In => Some("In"), + Tok::Starts => Some("Starts"), + Tok::Ends => Some("Ends"), + Tok::Contains => Some("Contains"), + Tok::Case => Some("Case"), + Tok::When => Some("When"), + Tok::Then => Some("Then"), + Tok::Else => Some("Else"), + Tok::End => Some("End"), + Tok::True => Some("True"), + Tok::False => Some("False"), + Tok::Null => Some("Null"), + Tok::Count => Some("Count"), + Tok::Exists => Some("Exists"), + Tok::Not => Some("Not"), + Tok::And => Some("And"), + Tok::Or => Some("Or"), + Tok::Xor => Some("Xor"), + Tok::Optional => Some("Optional"), + Tok::Any => Some("Any"), + Tok::None => Some("None"), + Tok::Single => Some("Single"), + Tok::Filter => Some("Filter"), + Tok::Extract => Some("Extract"), + Tok::Reduce => Some("Reduce"), + Tok::ShortestPath => Some("ShortestPath"), + Tok::AllShortestPaths => Some("AllShortestPaths"), + Tok::Asc => Some("Asc"), + Tok::Desc => Some("Desc"), + Tok::Ascending => Some("Ascending"), + Tok::Descending => Some("Descending"), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::TokenStream; + use gf_ast::Expr; + + fn ts(input: &str) -> TokenStream<'_> { + TokenStream::new(input).expect("lex failed") + } + + fn node(input: &str) -> NodePattern { + parse_node_pattern(&mut ts(input)).expect("parse_node_pattern failed") + } + + fn pattern(input: &str) -> PathPattern { + parse_pattern(&mut ts(input)).expect("parse_pattern failed") + } + + fn pattern_list(input: &str) -> Vec { + parse_pattern_list(&mut ts(input)).expect("parse_pattern_list failed") + } + + // --- NodePattern tests --- + + #[test] + fn anonymous_node() { + let n = node("()"); + assert_eq!(n.var, None); + assert!(n.labels.is_empty()); + assert!(n.properties.is_none()); + } + + #[test] + fn variable_only() { + let n = node("(n)"); + assert_eq!(n.var.as_deref(), Some("n")); + assert!(n.labels.is_empty()); + } + + #[test] + fn label_only() { + let n = node("(:Person)"); + assert_eq!(n.var, None); + assert_eq!(n.labels, vec!["Person"]); + } + + #[test] + fn variable_and_label() { + let n = node("(n:Person)"); + assert_eq!(n.var.as_deref(), Some("n")); + assert_eq!(n.labels, vec!["Person"]); + } + + #[test] + fn multi_label() { + let n = node("(n:Person:Employee)"); + assert_eq!(n.labels, vec!["Person", "Employee"]); + } + + #[test] + fn node_with_properties() { + let n = node("(n {age: 30})"); + assert!(n.properties.is_some()); + } + + #[test] + fn full_node_pattern() { + let n = node("(n:Person {name: $p})"); + assert_eq!(n.var.as_deref(), Some("n")); + assert_eq!(n.labels, vec!["Person"]); + assert!(n.properties.is_some()); + } + + #[test] + fn properties_delegate_to_parse_expr() { + let n = node("(n {age: 1 + 2})"); + match n.properties { + Some(Expr::Map(_)) => {} + other => panic!("expected MapLiteral, got {other:?}"), + } + } + + // --- RelPattern (via parse_pattern) tests --- + + #[test] + fn anon_out() { + let p = pattern("(a)-->(b)"); + assert_eq!(p.elements.len(), 3); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.direction, Direction::Out); + assert!(r.var.is_none()); + assert!(r.types.is_empty()); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn anon_in() { + let p = pattern("(a)<--(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.direction, Direction::In); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn anon_undirected() { + let p = pattern("(a)--(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.direction, Direction::Undirected); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn bracketed_out_with_type() { + let p = pattern("(a)-[:KNOWS]->(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.direction, Direction::Out); + assert_eq!(r.types, vec!["KNOWS"]); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn bracketed_in_with_type() { + let p = pattern("(a)<-[:KNOWS]-(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.direction, Direction::In); + assert_eq!(r.types, vec!["KNOWS"]); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn bracketed_undirected() { + let p = pattern("(a)-[r]-(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.direction, Direction::Undirected); + assert_eq!(r.var.as_deref(), Some("r")); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn type_list() { + let p = pattern("(a)-[:KNOWS|LIKES]->(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.types, vec!["KNOWS", "LIKES"]); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn variable_length_star() { + let p = pattern("(a)-[*]->(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.min_hops, None); + assert_eq!(r.max_hops, None); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn variable_length_exact() { + let p = pattern("(a)-[*2]->(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.min_hops, Some(2)); + assert_eq!(r.max_hops, Some(2)); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn variable_length_range() { + let p = pattern("(a)-[*1..3]->(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.min_hops, Some(1)); + assert_eq!(r.max_hops, Some(3)); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn variable_length_upper_only() { + let p = pattern("(a)-[*..5]->(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.min_hops, None); + assert_eq!(r.max_hops, Some(5)); + } else { + panic!("expected Rel"); + } + } + + #[test] + fn variable_length_lower_only() { + let p = pattern("(a)-[*3..]->(b)"); + if let PathElement::Rel(r) = &p.elements[1] { + assert_eq!(r.min_hops, Some(3)); + assert_eq!(r.max_hops, None); + } else { + panic!("expected Rel"); + } + } + + // --- PathPattern tests --- + + #[test] + fn chain_three_nodes() { + let p = pattern("(a)-[:K]->(b)-[:L]->(c)"); + assert_eq!(p.elements.len(), 5); // N R N R N + } + + #[test] + fn named_path() { + let p = pattern("p = (a)-[:KNOWS]->(b)"); + assert_eq!(p.var.as_deref(), Some("p")); + assert_eq!(p.elements.len(), 3); + } + + // --- parse_pattern_list --- + + #[test] + fn pattern_list_multiple() { + let patterns = pattern_list("(a), (b)-[:K]->(c)"); + assert_eq!(patterns.len(), 2); + assert_eq!(patterns[0].elements.len(), 1); + assert_eq!(patterns[1].elements.len(), 3); + } + + // --- Span tests --- + + #[test] + fn node_span_covers_parens() { + let n = node("(n:Person)"); + assert_eq!(n.span.start, 0); + assert!(n.span.end > n.span.start); + } +} From 8665cd5cbc841c5ca8022101ac94522df1004623 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Fri, 29 May 2026 15:02:46 -0600 Subject: [PATCH 2/4] fix(parser): address CodeRabbit/Macroscope review findings on patterns.rs - RelOpen span now extends past direction token (end at current_pos) - Reject <-[r]-> with a clear error (not a valid Cypher form) - Bare * now encodes as (min=Some(1), max=None) per openCypher semantics instead of (None, None) which is indistinguishable from non-variable-length - Hop count literals use u32::try_from to reject out-of-range values Co-Authored-By: Claude Sonnet 4.6 --- crates/gf-cypher/src/parser/patterns.rs | 33 +++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/gf-cypher/src/parser/patterns.rs b/crates/gf-cypher/src/parser/patterns.rs index 38f7a63..eabe190 100644 --- a/crates/gf-cypher/src/parser/patterns.rs +++ b/crates/gf-cypher/src/parser/patterns.rs @@ -106,7 +106,7 @@ fn parse_rel_pattern(ts: &mut TokenStream) -> Result { // -[ ... ]-> or -[ ... ]- ts.advance(); // consume RelOpen (-[) let (var, types, min_hops, max_hops, properties) = parse_rel_detail(ts)?; - let (_, r_brace) = ts.eat(&Tok::RBracket)?; + ts.eat(&Tok::RBracket)?; let direction = if ts.eat_if(&Tok::RightArrow) { Direction::Out } else if ts.eat_if(&Tok::Minus) { @@ -121,7 +121,7 @@ fn parse_rel_pattern(ts: &mut TokenStream) -> Result { min_hops, max_hops, properties, - span: Span::new(start, r_brace), + span: Span::new(start, ts.current_pos()), }) } Some(Tok::LeftArrow) => { @@ -131,13 +131,14 @@ fn parse_rel_pattern(ts: &mut TokenStream) -> Result { // <-[ ... ]-> or <-[ ... ]- let (var, types, min_hops, max_hops, properties) = parse_rel_detail(ts)?; let (_, _r_brace) = ts.eat(&Tok::RBracket)?; - let direction = if ts.eat_if(&Tok::RightArrow) { - // <-[r]-> means both directions - Direction::Undirected - } else if ts.eat_if(&Tok::Minus) { + let direction = if ts.eat_if(&Tok::Minus) { Direction::In + } else if ts.peek() == Some(&Tok::RightArrow) { + return Err(ts.err( + "`<-[r]->` is not a valid Cypher relationship pattern; use `-[r]-` for undirected", + )); } else { - return Err(ts.err("expected `->` or `-` after `]`")); + return Err(ts.err("expected `-` after `]` in incoming relationship pattern")); }; Ok(RelPattern { var, @@ -242,7 +243,8 @@ fn parse_rel_detail( } /// Parse optional hop-range after `*`: ``, `2`, `1..3`, `..5`, `3..` -/// Returns `(min_hops, max_hops)`. Both `None` means bare `*` (any hops). +/// Returns `(min_hops, max_hops)`. +/// Bare `*` → `(Some(1), None)` per openCypher semantics (min 1 hop, unbounded). fn parse_hop_range(ts: &mut TokenStream) -> Result<(Option, Option), ParseError> { match ts.peek().cloned() { // `*..N` — no lower bound @@ -254,7 +256,7 @@ fn parse_hop_range(ts: &mut TokenStream) -> Result<(Option, Option), P // `*N` or `*N..` or `*N..M` Some(Tok::IntLit(n)) => { ts.advance(); - let min = n as u32; + let min = to_u32(ts, n)?; if ts.eat_if(&Tok::DotDot) { match ts.peek() { Some(Tok::IntLit(_)) => { @@ -268,8 +270,8 @@ fn parse_hop_range(ts: &mut TokenStream) -> Result<(Option, Option), P Ok((Some(min), Some(min))) } } - // bare `*` — no bounds - _ => Ok((None, None)), + // bare `*` — min 1 hop, unbounded + _ => Ok((Some(1), None)), } } @@ -277,12 +279,16 @@ fn parse_u32_lit(ts: &mut TokenStream) -> Result { match ts.peek().cloned() { Some(Tok::IntLit(n)) => { ts.advance(); - Ok(n as u32) + to_u32(ts, n) } _ => Err(ts.err("expected integer for hop count")), } } +fn to_u32(ts: &TokenStream, n: i64) -> Result { + u32::try_from(n).map_err(|_| ts.err(format!("hop count {n} out of range for u32"))) +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -545,9 +551,10 @@ mod tests { #[test] fn variable_length_star() { + // bare * → min 1 hop, unbounded (openCypher semantics) let p = pattern("(a)-[*]->(b)"); if let PathElement::Rel(r) = &p.elements[1] { - assert_eq!(r.min_hops, None); + assert_eq!(r.min_hops, Some(1)); assert_eq!(r.max_hops, None); } else { panic!("expected Rel"); From 6d595247b0143e0b9bb21eb00f1e9f5e48a883de Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Fri, 29 May 2026 15:08:30 -0600 Subject: [PATCH 3/4] fix(parser): error span in to_u32 points at the integer token `ts.err()` uses `current_pos()` which is past the already-consumed integer literal. Capture the span before advancing and pass it to `ParseError::new` with `ParseErrorKind::InvalidNumericLiteral`. Co-Authored-By: Claude Sonnet 4.6 --- crates/gf-cypher/src/parser/patterns.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/gf-cypher/src/parser/patterns.rs b/crates/gf-cypher/src/parser/patterns.rs index eabe190..ad259c3 100644 --- a/crates/gf-cypher/src/parser/patterns.rs +++ b/crates/gf-cypher/src/parser/patterns.rs @@ -1,5 +1,5 @@ use crate::lexer::Tok; -use gf_ast::{Direction, Expr, NodePattern, ParseError, PathElement, PathPattern, RelPattern}; +use gf_ast::{Direction, Expr, NodePattern, ParseError, ParseErrorKind, PathElement, PathPattern, RelPattern}; use gf_core::Span; use super::TokenStream; @@ -255,8 +255,8 @@ fn parse_hop_range(ts: &mut TokenStream) -> Result<(Option, Option), P } // `*N` or `*N..` or `*N..M` Some(Tok::IntLit(n)) => { - ts.advance(); - let min = to_u32(ts, n)?; + let (l, _, r) = ts.advance().unwrap(); + let min = to_u32(n, Span::new(l, r))?; if ts.eat_if(&Tok::DotDot) { match ts.peek() { Some(Tok::IntLit(_)) => { @@ -278,15 +278,21 @@ fn parse_hop_range(ts: &mut TokenStream) -> Result<(Option, Option), P fn parse_u32_lit(ts: &mut TokenStream) -> Result { match ts.peek().cloned() { Some(Tok::IntLit(n)) => { - ts.advance(); - to_u32(ts, n) + let (l, _, r) = ts.advance().unwrap(); + to_u32(n, Span::new(l, r)) } _ => Err(ts.err("expected integer for hop count")), } } -fn to_u32(ts: &TokenStream, n: i64) -> Result { - u32::try_from(n).map_err(|_| ts.err(format!("hop count {n} out of range for u32"))) +fn to_u32(n: i64, span: Span) -> Result { + u32::try_from(n).map_err(|_| { + ParseError::new( + ParseErrorKind::InvalidNumericLiteral, + span, + format!("hop count {n} out of range for u32"), + ) + }) } // --------------------------------------------------------------------------- From 34b9a3bbc2e31a051ee04e71a963c20c89f32676 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Fri, 29 May 2026 15:11:18 -0600 Subject: [PATCH 4/4] =?UTF-8?q?fix(parser):=20rustfmt=20=E2=80=94=20wrap?= =?UTF-8?q?=20long=20import=20in=20patterns.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- crates/gf-cypher/src/parser/patterns.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/gf-cypher/src/parser/patterns.rs b/crates/gf-cypher/src/parser/patterns.rs index ad259c3..9fdeccd 100644 --- a/crates/gf-cypher/src/parser/patterns.rs +++ b/crates/gf-cypher/src/parser/patterns.rs @@ -1,5 +1,7 @@ use crate::lexer::Tok; -use gf_ast::{Direction, Expr, NodePattern, ParseError, ParseErrorKind, PathElement, PathPattern, RelPattern}; +use gf_ast::{ + Direction, Expr, NodePattern, ParseError, ParseErrorKind, PathElement, PathPattern, RelPattern, +}; use gf_core::Span; use super::TokenStream;