WHITESPACE = _{ " " | "\t" | "\r" | "\n" }
query = { SOI ~ statement ~ ";"? ~ EOI }
// A `;`-separated batch of statements — `parse_many` uses this, `parse`
// (single-statement) still uses `query` above unchanged. `;` only ever
// separates `statement`s here; a `;` inside a `string_literal` is
// consumed as part of that atomic rule and never reaches this level, so
// no special-casing is needed to avoid splitting inside a string.
queries = { SOI ~ statement ~ (";" ~ statement)* ~ ";"? ~ EOI }
statement = { create_stmt | match_stmt }
create_stmt = { ^"CREATE" ~ pattern ~ ("," ~ pattern)* }
// tail_clause is optional -- a bare `MERGE (...)` with nothing after it
// is valid, same as standalone CREATE (see parser.rs::parse_match_stmt
// for the "tail required unless a MERGE clause is present" check this
// still enforces for MATCH/UNWIND-only statements).
match_stmt = { clause+ ~ tail_clause? ~ order_by_clause? ~ limit_clause? }
clause = { unwind_clause | merge_clause | match_part }
match_part = { match_keyword ~ path_pattern ~ ("," ~ pattern)* ~ where_clause? ~ with_clause? }
match_keyword = { (^"OPTIONAL" ~ ^"MATCH") | ^"MATCH" }
// `p = (a)-->(b)` (named-path capture) or `p = shortestPath((a)-[:T*..N]-(b))`
// -- only the match_part's *first* pattern token can carry a name; any
// comma-continuation after it (spliced by parser::splice_patterns) is a
// plain `pattern`, same as today, since naming/shortestPath only make
// sense for the whole matched shape, not a fragment of it.
path_pattern = { (identifier ~ "=")? ~ (shortest_path_wrapper | pattern) }
shortest_path_wrapper = { ^"shortestPath" ~ "(" ~ pattern ~ ")" }
with_clause = { ^"WITH" ~ return_item ~ ("," ~ return_item)* ~ with_where_clause? ~ order_by_clause? ~ limit_clause? }
// Not a general `Literal::List` usable anywhere a `literal` is (a prop
// value, a comparison RHS, ...) — deliberately scoped to only appear right
// after UNWIND, same reasoning as `with_comparison`'s deliberately-separate
// mirror of `comparison` elsewhere in this grammar.
// Its filter reuses `with_where_clause` (a `with_expr`, LHS is a
// `return_expr`), not the pattern-level `where_clause`/`expr`
// (`comparison`'s LHS is always a `prop_access`) -- an UNWIND-bound
// variable is very often a bare scalar (`UNWIND [1,2,3] AS x WHERE x >
// 2`), which `comparison` structurally cannot express at all (only
// `x.prop > 2` is representable there).
unwind_clause = { ^"UNWIND" ~ unwind_source ~ ^"AS" ~ identifier ~ with_where_clause? ~ with_clause? }
unwind_source = { list_literal | null_literal | identifier }
list_literal = { "[" ~ (literal ~ ("," ~ literal)*)? ~ "]" }
// No WHERE here -- real Cypher's MERGE has none either; the pattern's own
// labels/props are the only match criteria. `pattern`'s hop count is
// capped at 1 in parser.rs (whole-pattern atomicity across multiple
// simultaneously-unbound hops isn't attempted in v1 -- see
// executor::eval_merge's docs).
merge_clause = { ^"MERGE" ~ pattern ~ on_create_clause? ~ on_match_clause? ~ with_clause? }
on_create_clause = { ^"ON" ~ ^"CREATE" ~ ^"SET" ~ set_item ~ ("," ~ set_item)* }
on_match_clause = { ^"ON" ~ ^"MATCH" ~ ^"SET" ~ set_item ~ ("," ~ set_item)* }
// Reuses `create_stmt` itself (not a duplicate rule) — a `MATCH ... CREATE`
// tail has the exact same pattern syntax as a standalone `CREATE`
// statement; only the AST/executor treat a node token whose variable is
// already bound differently (reuse the existing node instead of making a
// new one — see `Tail::Create`'s docs).
tail_clause = { return_clause | detach_delete_clause | delete_clause | remove_clause | set_clause | create_stmt }
return_clause = { ^"RETURN" ~ distinct_kw? ~ return_item ~ ("," ~ return_item)* }
return_item = { return_expr ~ (^"AS" ~ identifier)? }
// Order matters: case_expr/function_call/prop_access need their extra
// syntax tried before the bare `identifier` fallback, and `literal` must
// come before `identifier` too — otherwise the keywords true/false/null
// would parse as plain variable references instead of literals.
return_expr = { case_expr | function_call | prop_access | literal | identifier }
case_expr = { ^"CASE" ~ return_expr ~ case_when+ ~ (^"ELSE" ~ return_expr)? ~ ^"END" }
case_when = { ^"WHEN" ~ return_expr ~ ^"THEN" ~ return_expr }
function_call = { identifier ~ "(" ~ call_args ~ ")" }
// `"*"` (bare, only meaningful for `count(*)`) is tried first — no
// `return_expr` production can ever start with a bare `*`, so there's no
// backtracking ambiguity with the general-args branch. Which functions
// accept `"*"` or `DISTINCT` is a semantic check in parser.rs, not a
// grammar-level restriction — see cypher.pest's other permissive-grammar
// precedents (comma-pattern splicing, WITH-boundary counting).
call_args = { "*" | (distinct_kw? ~ (return_expr ~ ("," ~ return_expr)*)?) }
distinct_kw = { ^"DISTINCT" }
// WITH's HAVING-equivalent: filters on the already-projected/aggregated
// row, so its comparison LHS is a `return_expr` (a WITH alias or raw
// expression), not `prop_access` like pattern-level `comparison` below —
// structurally a separate mirror of expr/and_expr/or_expr, not a reuse of
// them, since widening pattern-`Expr`'s LHS type would ripple into the
// planner's pre-projection filter-pushdown logic for a WHERE that
// fundamentally belongs post-projection instead.
with_where_clause = { ^"WHERE" ~ with_expr }
with_expr = { with_or_expr }
with_or_expr = { with_and_expr ~ (^"OR" ~ with_and_expr)* }
with_and_expr = { with_unary_expr ~ (^"AND" ~ with_unary_expr)* }
with_unary_expr = { (^"NOT" ~ with_unary_expr) | with_comparison | ("(" ~ with_expr ~ ")") }
with_comparison = { return_expr ~ compare_op ~ literal }
detach_delete_clause = { ^"DETACH" ~ ^"DELETE" ~ identifier ~ ("," ~ identifier)* }
delete_clause = { ^"DELETE" ~ identifier ~ ("," ~ identifier)* }
// Order matters: `prop_access` (`n.prop`) is tried first -- both start with
// an `identifier`, and only backtrack to `set_label_item` (`n:Label`) if
// the "." doesn't match.
set_clause = { ^"SET" ~ set_item ~ ("," ~ set_item)* }
set_item = { (prop_access ~ "=" ~ literal) | set_label_item }
set_label_item = { identifier ~ (":" ~ identifier)+ }
remove_clause = { ^"REMOVE" ~ remove_item ~ ("," ~ remove_item)* }
remove_item = { prop_access | set_label_item }
where_clause = { ^"WHERE" ~ expr }
limit_clause = { ^"LIMIT" ~ int_literal }
order_by_clause = { ^"ORDER" ~ ^"BY" ~ sort_item ~ ("," ~ sort_item)* }
sort_item = { return_expr ~ sort_dir? }
sort_dir = { ^"ASC" | ^"DESC" }
pattern = { node_pattern ~ (rel_pattern ~ node_pattern)* }
node_pattern = { "(" ~ node_var? ~ node_label* ~ prop_map? ~ ")" }
node_var = { identifier }
node_label = { ":" ~ identifier }
// Order matters: rel_right's trailing "->" must be tried before rel_either's
// bare "-", or "-[:KNOWS]->" would parse as rel_either and leave a dangling
// ">" that fails the rest of the pattern instead of matching rel_right.
rel_pattern = { rel_right | rel_left | rel_either }
rel_right = { "-[" ~ rel_var? ~ rel_type? ~ rel_range? ~ prop_map? ~ "]->" }
rel_left = { "<-[" ~ rel_var? ~ rel_type? ~ rel_range? ~ prop_map? ~ "]-" }
rel_either = { "-[" ~ rel_var? ~ rel_type? ~ rel_range? ~ prop_map? ~ "]-" }
rel_var = { identifier }
rel_type = { ":" ~ identifier }
// Variable-length hop count: `*`, `*N`, `*N..`, `*N..M`, `*..M`. Captured
// as raw text and parsed directly in parser.rs rather than broken into
// sub-rules — simpler than distinguishing "*N" (exact) from "*N.." (N or
// more) structurally, since the ".." literal itself produces no Pair.
rel_range = @{ "*" ~ ASCII_DIGIT* ~ (".." ~ ASCII_DIGIT*)? }
prop_map = { "{" ~ (prop_kv ~ ("," ~ prop_kv)*)? ~ "}" }
prop_kv = { identifier ~ ":" ~ literal }
expr = { or_expr }
or_expr = { and_expr ~ (^"OR" ~ and_expr)* }
and_expr = { unary_expr ~ (^"AND" ~ unary_expr)* }
unary_expr = { (^"NOT" ~ unary_expr) | comparison | ("(" ~ expr ~ ")") }
comparison = { prop_access ~ compare_op ~ literal }
compare_op = { "<>" | "<=" | ">=" | "=" | "<" | ">" | (^"STARTS" ~ ^"WITH") | (^"ENDS" ~ ^"WITH") | ^"CONTAINS" }
prop_access = { identifier ~ "." ~ identifier }
literal = { float_literal | int_literal | string_literal | bool_literal | null_literal | param }
int_literal = @{ "-"? ~ ASCII_DIGIT+ }
float_literal = @{ "-"? ~ ASCII_DIGIT+ ~ "." ~ ASCII_DIGIT+ }
// `\` escapes the next character (so `\'` doesn't terminate the string
// early -- without this, `(!"'" ~ ANY)*` has no concept of escaping and
// `'it\'s'` silently mis-terminates at the escaped quote instead of
// erroring, leaving a dangling `s'` that breaks whatever follows with a
// confusing, seemingly-unrelated parse error). Which escapes are
// recognized (`\\ \' \" \n \r \t \b \f`) is checked in parser.rs, not
// here -- this rule accepts any `\`-prefixed char permissively, same
// precedent as this grammar's other permissive/semantic-check-in-Rust
// rules (see call_args's DISTINCT-validity comment).
string_literal = @{ "'" ~ (escaped_char | (!("'" | "\\") ~ ANY))* ~ "'" }
escaped_char = { "\\" ~ ANY }
bool_literal = @{ ^"true" | ^"false" }
null_literal = @{ ^"null" }
param = { "$" ~ identifier }
identifier = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* }