Skip to main content

open_cypher/
lib.rs

1//! An unofficial, syntax-only lexer and parser for the openCypher query
2//! language, targeting the openCypher 2024.3 grammar.
3//!
4//! The crate turns a query string into three things: a lossless token
5//! stream, an owned syntax tree in which every node carries a byte
6//! [`Span`], and structured [`Diagnostic`]s. It stops there. There is no name
7//! resolution, type checking, planning, or execution.
8//!
9//! # Getting started
10//!
11//! [`parse`] is the strict entry point. It returns the tree and the tokens on
12//! success and a [`ParseErrors`] collection otherwise.
13//!
14//! ```
15//! use open_cypher::ast::{ClauseKind, QueryKind, StatementKind};
16//! use open_cypher::parse;
17//!
18//! let source = "MATCH (person:Person) WHERE person.age > 30 RETURN person.name";
19//! let parsed = parse(source)?;
20//!
21//! // A program holds at most one statement, and every node records where it
22//! // came from, so spans can be used to slice the original source.
23//! assert_eq!(parsed.program.statements.len(), 1);
24//! assert_eq!(parsed.program.span.text(source), Some(source));
25//!
26//! let statement = &parsed.program.statements[0];
27//! if let StatementKind::Query(query) = &statement.kind
28//!     && let QueryKind::Regular(regular) = &query.query.kind
29//! {
30//!     for clause in &regular.head.kind.clauses {
31//!         let text = clause.span.text(source).unwrap_or_default();
32//!         match &clause.kind {
33//!             ClauseKind::Match(m) if m.where_clause.is_some() => {
34//!                 println!("MATCH with WHERE: {text}");
35//!             }
36//!             ClauseKind::Match(_) => println!("MATCH: {text}"),
37//!             ClauseKind::Return(_) => println!("RETURN: {text}"),
38//!             _ => println!("other clause: {text}"),
39//!         }
40//!     }
41//! }
42//! # Ok::<(), open_cypher::ParseErrors>(())
43//! ```
44//!
45//! The [`ast`] module documents the tree. Most enums there are
46//! `#[non_exhaustive]`, so matches need a wildcard arm.
47//!
48//! # Reporting errors
49//!
50//! A failed strict parse returns every diagnostic collected while lexing and
51//! parsing, in source order. [`ParseErrors::render`] produces a plain-text
52//! report with line and column numbers; [`Diagnostic`] exposes the same data
53//! as fields for tools that want to format it themselves.
54//!
55//! ```
56//! use open_cypher::{parse, DiagnosticCode};
57//!
58//! let source = "MATCH (person:Person RETURN person.name";
59//! let errors = parse(source).unwrap_err();
60//!
61//! assert_eq!(errors.diagnostics()[0].code, DiagnosticCode::UnclosedDelimiter);
62//! eprintln!("{}", errors.render(source));
63//! ```
64//!
65//! The rendered report looks like this:
66//!
67//! ```text
68//! error[OCY-P004]: unclosed delimiter `(`
69//!  --> 1:7
70//!   |
71//! 1 | MATCH (person:Person RETURN person.name
72//!   |       ^
73//!   = help: add the matching delimiter before byte 39
74//!
75//! error[OCY-P001]: unexpected token `RETURN`; expected `)`, `WHERE`, `parameter`, or `{`
76//!  --> 1:22
77//!   |
78//! 1 | MATCH (person:Person RETURN person.name
79//!   |                      ^^^^^^
80//! ```
81//!
82//! # Recovering from errors
83//!
84//! [`parse_recovering`] never fails. It returns a [`ParseOutcome`] carrying
85//! the diagnostics alongside a program root. Valid top-level clauses are retained,
86//! with [`ClauseKind::Error`](ast::ClauseKind::Error) placeholders for skipped
87//! regions. Missing operands become empty-span [`ExprKind::Error`](ast::ExprKind::Error)
88//! nodes, retaining surrounding operators and valid siblings. Recovery inserts
89//! at most four operands per candidate and shares the clause retry budget. It
90//! does not delete source tokens, invent identifiers, or close delimiters.
91//! UNION branches retain their operators and clause structure, with empty-span
92//! error clauses for missing branches. Valid clauses take precedence over
93//! splitting contextual names. A shared retry budget leaves an unsplit error
94//! suffix on exhaustion. Multi-statement inputs and inputs with no recoverable
95//! clauses fall back to a whole-input error statement. The full token stream
96//! remains available. At most 32 diagnostics are reported per parse.
97//!
98//! ```
99//! use open_cypher::parse_recovering;
100//!
101//! let outcome = parse_recovering("MATCH (person RETURN person");
102//! let parsed = outcome.value.expect("recovery always yields a program");
103//!
104//! assert!(!outcome.diagnostics.is_empty());
105//! assert_eq!(parsed.program.statements.len(), 1);
106//! ```
107//!
108//! # Tokens
109//!
110//! [`lex`] tokenizes without parsing. The token stream is lossless: it keeps
111//! whitespace, comments, and invalid regions, and the spans tile the input.
112//! [`parse`] returns the same tokens in [`ParsedProgram::tokens`].
113//!
114//! ```
115//! use open_cypher::{lex, TokenKind};
116//!
117//! let source = "RETURN 1 // answer";
118//! let lexed = lex(source);
119//!
120//! assert!(lexed.diagnostics.is_empty());
121//! let kinds: Vec<_> = lexed.tokens.iter().map(|token| token.kind).collect();
122//! assert!(matches!(kinds.last(), Some(TokenKind::LineComment)));
123//!
124//! let spelled: String = lexed
125//!     .tokens
126//!     .iter()
127//!     .filter_map(|token| token.text(source))
128//!     .collect();
129//! assert_eq!(spelled, source);
130//! ```
131//!
132//! # What is supported
133//!
134//! The parser accepts the full openCypher 2024.3 grammar. Every one of the
135//! 377 BNF productions is mapped to lexer and parser targets with executable
136//! witnesses, and every query in the pinned Technology Compatibility Kit
137//! (TCK) parses or is rejected as the syntax projection expects. That is
138//! evidence about syntax only; it is not official TCK certification.
139//!
140//! A handful of deliberate extensions over the 2024.3 BNF are accepted and
141//! recorded in the repository's deviation ledger. They include:
142//!
143//! - `COUNT { ... }` and `COLLECT { ... }` subquery expressions beside
144//!   `EXISTS { ... }`;
145//! - `!=` as an alias for `<>` and `||` as a concatenation operator;
146//! - the GQL path modes `WALK`, `TRAIL`, `SIMPLE`, and `ACYCLIC` before a
147//!   graph pattern, and `?` as the optional path quantifier;
148//! - boolean and `IS` label expressions in `SET` and `REMOVE`;
149//! - more than one `ON MATCH` / `ON CREATE` action after `MERGE`;
150//! - empty property maps and bare parameters as property maps anywhere in a
151//!   pattern, leaving update restrictions to a later semantic pass;
152//! - empty or comment-only input, one optional trailing semicolon, and
153//!   identifiers that start with an underscore.
154//!
155//! The following are out of scope for the `0.3` series:
156//!
157//! - **Semantics.** Names are not resolved, types are not checked, and no
158//!   query runs. Some inputs that a database would reject (for example a
159//!   parameter used as a `MATCH` property map) parse successfully so that a
160//!   semantic pass can report them with a better message.
161//! - **Multi-statement scripts.** A program contains zero or one query. A
162//!   second statement after the semicolon is a syntax error.
163//! - **Expression-level recovery.** Malformed expressions are not locally repaired.
164//!
165//! Some accepted inputs look surprising but follow the 2024.3 grammar and are
166//! not parser bugs:
167//!
168//! - `a[1:b]` parses as an index whose subscript is `1:b`: a subscript is any
169//!   value expression, and a comparison predicand may carry a `:Label` test.
170//!
171//! # Feature flags
172//!
173//! - `serde`: derives `Serialize` and `Deserialize` for the token, syntax
174//!   tree, and diagnostic types. Off by default.
175//!
176//! # Versioning of the grammar
177//!
178//! The language snapshot is pinned independently of the crate version. See
179//! [`OPEN_CYPHER_GRAMMAR_VERSION`] and [`OPEN_CYPHER_GRAMMAR_REVISION`].
180//!
181//! # How the crate is tested
182//!
183//! The parser is checked against the pinned openCypher TCK as a syntax
184//! projection, against per-production witnesses, and with property tests,
185//! fuzzing, a coverage floor, and weekly mutation testing. The repository's
186//! `docs/testing.md` describes each layer and how to run it locally.
187
188#![forbid(unsafe_code)]
189
190pub mod ast;
191pub mod diagnostic;
192pub mod lexer;
193pub mod parser;
194pub mod span;
195pub mod token;
196
197pub use diagnostic::{Diagnostic, DiagnosticCode, Label, ParseErrors, Severity};
198pub use lexer::{LexOutcome, lex};
199pub use parser::{ParseOutcome, ParsedProgram, parse, parse_recovering};
200pub use span::Span;
201pub use token::{Keyword, Token, TokenKind};
202
203/// The upstream openCypher language release used as this crate's pinned baseline.
204pub const OPEN_CYPHER_GRAMMAR_VERSION: &str = "2024.3";
205
206/// The immutable upstream Git revision containing the language snapshot.
207pub const OPEN_CYPHER_GRAMMAR_REVISION: &str = "677cbafabb8c3c5eed458fd3b1ec0daec8d67d23";