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. Recovery does not cross unclosed delimiters or repair expressions.
88//! Ambiguous UNION and multi-statement inputs, and inputs with no recoverable
89//! clauses, fall back to a whole-input error statement. The full token stream
90//! remains available. At most 32 diagnostics are reported per parse.
91//!
92//! ```
93//! use open_cypher::parse_recovering;
94//!
95//! let outcome = parse_recovering("MATCH (person RETURN person");
96//! let parsed = outcome.value.expect("recovery always yields a program");
97//!
98//! assert!(!outcome.diagnostics.is_empty());
99//! assert_eq!(parsed.program.statements.len(), 1);
100//! ```
101//!
102//! # Tokens
103//!
104//! [`lex`] tokenizes without parsing. The token stream is lossless: it keeps
105//! whitespace, comments, and invalid regions, and the spans tile the input.
106//! [`parse`] returns the same tokens in [`ParsedProgram::tokens`].
107//!
108//! ```
109//! use open_cypher::{lex, TokenKind};
110//!
111//! let source = "RETURN 1 // answer";
112//! let lexed = lex(source);
113//!
114//! assert!(lexed.diagnostics.is_empty());
115//! let kinds: Vec<_> = lexed.tokens.iter().map(|token| token.kind).collect();
116//! assert!(matches!(kinds.last(), Some(TokenKind::LineComment)));
117//!
118//! let spelled: String = lexed
119//!     .tokens
120//!     .iter()
121//!     .filter_map(|token| token.text(source))
122//!     .collect();
123//! assert_eq!(spelled, source);
124//! ```
125//!
126//! # What is supported
127//!
128//! The parser accepts the full openCypher 2024.3 grammar. Every one of the
129//! 377 BNF productions is mapped to lexer and parser targets with executable
130//! witnesses, and every query in the pinned Technology Compatibility Kit
131//! (TCK) parses or is rejected as the syntax projection expects. That is
132//! evidence about syntax only; it is not official TCK certification.
133//!
134//! A handful of deliberate extensions over the 2024.3 BNF are accepted and
135//! recorded in the repository's deviation ledger. They include:
136//!
137//! - `COUNT { ... }` and `COLLECT { ... }` subquery expressions beside
138//!   `EXISTS { ... }`;
139//! - `!=` as an alias for `<>` and `||` as a concatenation operator;
140//! - the GQL path modes `WALK`, `TRAIL`, `SIMPLE`, and `ACYCLIC` before a
141//!   graph pattern, and `?` as the optional path quantifier;
142//! - boolean and `IS` label expressions in `SET` and `REMOVE`;
143//! - more than one `ON MATCH` / `ON CREATE` action after `MERGE`;
144//! - empty property maps and bare parameters as property maps anywhere in a
145//!   pattern, leaving update restrictions to a later semantic pass;
146//! - empty or comment-only input, one optional trailing semicolon, and
147//!   identifiers that start with an underscore.
148//!
149//! The following are out of scope for the `0.3` series:
150//!
151//! - **Semantics.** Names are not resolved, types are not checked, and no
152//!   query runs. Some inputs that a database would reject (for example a
153//!   parameter used as a `MATCH` property map) parse successfully so that a
154//!   semantic pass can report them with a better message.
155//! - **Multi-statement scripts.** A program contains zero or one query. A
156//!   second statement after the semicolon is a syntax error.
157//! - **Expression-level recovery.** Malformed expressions are not locally repaired.
158//!
159//! Some accepted inputs look surprising but follow the 2024.3 grammar and are
160//! not parser bugs:
161//!
162//! - `a[1:b]` parses as an index whose subscript is `1:b`: a subscript is any
163//!   value expression, and a comparison predicand may carry a `:Label` test.
164//!
165//! # Feature flags
166//!
167//! - `serde`: derives `Serialize` and `Deserialize` for the token, syntax
168//!   tree, and diagnostic types. Off by default.
169//!
170//! # Versioning of the grammar
171//!
172//! The language snapshot is pinned independently of the crate version. See
173//! [`OPEN_CYPHER_GRAMMAR_VERSION`] and [`OPEN_CYPHER_GRAMMAR_REVISION`].
174//!
175//! # How the crate is tested
176//!
177//! The parser is checked against the pinned openCypher TCK as a syntax
178//! projection, against per-production witnesses, and with property tests,
179//! fuzzing, a coverage floor, and weekly mutation testing. The repository's
180//! `docs/testing.md` describes each layer and how to run it locally.
181
182#![forbid(unsafe_code)]
183
184pub mod ast;
185pub mod diagnostic;
186pub mod lexer;
187pub mod parser;
188pub mod span;
189pub mod token;
190
191pub use diagnostic::{Diagnostic, DiagnosticCode, Label, ParseErrors, Severity};
192pub use lexer::{LexOutcome, lex};
193pub use parser::{ParseOutcome, ParsedProgram, parse, parse_recovering};
194pub use span::Span;
195pub use token::{Keyword, Token, TokenKind};
196
197/// The upstream openCypher language release used as this crate's pinned baseline.
198pub const OPEN_CYPHER_GRAMMAR_VERSION: &str = "2024.3";
199
200/// The immutable upstream Git revision containing the language snapshot.
201pub const OPEN_CYPHER_GRAMMAR_REVISION: &str = "677cbafabb8c3c5eed458fd3b1ec0daec8d67d23";