dr_strange_parser/lib.rs
1//! dr-strange's query language: an openCypher subset that parses into core's
2//! `LogicalPlan` (arch/00 §5 — "a Cypher/GQL-subset parser lands in v2 on top
3//! of the same logical plan layer"). The `nom` grammar and the plan compiler
4//! live here so the parser-combinator dependency never reaches the lean core;
5//! the crate depends on core only for the plan types it targets.
6//!
7//! ```text
8//! MATCH (p:Paper)-[:CITES]->(q:Paper)
9//! WHERE p.year >= 2020
10//! RETURN q
11//! ORDER BY q.year DESC
12//! LIMIT 10
13//! ```
14//!
15//! # Supported
16//! Every source below binds one node pattern and may continue with a
17//! relationship tail, so a typed hop chains off a retrieval seed exactly as it
18//! does off a `MATCH` node (ROADMAP §7).
19//! - `MATCH` one linear path: `(a:Label)`, `-[:TYPE]->` / `<-[:TYPE]-` / `-[:T]-`
20//! (in/out/both), bare `-->`/`--`, and bounded variable-length `-[:T*1..3]->`.
21//! - **`SEARCH (v:Label) [ON prop] NEAR "text"|[..] [METRIC m] [TOPK k]`** — an
22//! indexed vector seed (`Source::VectorTopK`). `"text"` is embedded server-side
23//! via [`parse_with_embedder`]; `[..]` is a literal escape hatch. `ON` may be
24//! omitted: every `NEAR` defaults to the `embedding` property, which is what
25//! the digest pipeline writes.
26//! - **`SEARCH (v:Label) ON prop MATCHING "text" [TOPK k]`** — a BM25 keyword
27//! seed (`Source::KeywordTopK`) over a declared keyword index. Same verb as
28//! the vector seed, different operator: `NEAR` compares meaning, `MATCHING`
29//! compares words.
30//! - **`HYBRID (v:Label) [VECTOR [ON p] NEAR q [METRIC m] [WEIGHT w]]
31//! [KEYWORD ON p MATCHING "text" [WEIGHT w]]
32//! [GRAPH HOPS h [DECAY d] [SEEDS n] [WEIGHT w]] [CANDIDATES n] [TOPK k]`** —
33//! fused retrieval (`Source::Hybrid`); channels in any order, at least one of
34//! VECTOR/KEYWORD.
35//! - **`CALL <pagerank|components|shortest_path|louvain>(arg: v, …) ON (v[:Label])`**
36//! — a graph algorithm as a source (`Source::Algo`). `ON` both scopes the
37//! algorithm and binds the variable; the per-node result rides `score()`.
38//! - **`BEAM (result[:Label]) <OUT|IN|BOTH> [:TYPE] ON prop NEAR "text"|[..]
39//! [METRIC m] WIDTH w DEPTH d`** — similarity-guided beam traversal
40//! (`Step::ExpandBeam`) from the current frontier; chains after any source.
41//! - `WHERE` over `=,<>,!=,<,<=,>,>=`, `AND`/`OR`/`NOT`, `+ - * /`, `IS [NOT] NULL`,
42//! `x IN [a, b]`, property access `a.key`, the label predicate `a:Label`, the
43//! external key `key(a)`, and the scoring terms `score()`, `hops()`,
44//! `similarity(a.prop, "text"|[..][, metric])`, `distance(...)` (usable in
45//! `ORDER BY` too — a brute-force rank).
46//! - `RETURN [DISTINCT] <var|*>` — node records — or a **projection**:
47//! `RETURN a.name, count(*) AS n, …`, each item a column named by its alias
48//! or its own text, over any variable the pattern bound (an earlier one
49//! compiles to `Expr::At`). Folds: `count`/`sum`/`avg`/`min`/`max`/`collect`,
50//! each optionally `DISTINCT`, grouped by every column that isn't one.
51//! - `ORDER BY expr [ASC|DESC], …`, `SKIP n`, `LIMIT n` — steps over node rows;
52//! on the projection tail otherwise, where `ORDER BY` names a returned column
53//! (by alias, by the expression it returned, or by the aggregate it folds:
54//! `ORDER BY count(*)`).
55//! - **`AS OF <seq|"RFC-3339"|TIME <ms>>`** — last clause; reads a past
56//! snapshot (native backend). Not a plan node: it rides on [`ReadQuery`] for
57//! the surface to apply with `PlaneHandle::as_of`.
58//!
59//! `key(n) = "…"` (or `key(n) IN […]`) on a scanned source compiles to a
60//! `SeekKeys` seek rather than a scan-and-filter — an index lookup, so an LLM
61//! can anchor on an entity it knows by key.
62//!
63//! # Writes (via [`parse_statement`], applied with [`WriteStatement::apply`])
64//! - `CREATE (n:L {k: v, …}), (a)-[:T {…}]->(b), …` — a string `key:` sets the
65//! external key; edges are directed (`->`/`<-`).
66//! - `MERGE (n:L {key: "…", …}) [ON CREATE SET …] [ON MATCH SET …]` — upsert a
67//! node by its external key; a path `MERGE (a {key})-[:T]->(b {key})` upserts
68//! each keyed node and ensures the edge (idempotent, element-wise — also
69//! against what earlier clauses of the same statement created).
70//! - `MATCH pattern [WHERE …] SET n.p = v, n:Label, n += {…}` /
71//! `REMOVE n.p, n:Label` / `[DETACH] DELETE n` — find-then-mutate on the
72//! pattern's terminal variable, once per distinct matched node however many
73//! paths reach it (plain `DELETE` refuses a connected node).
74//! - `MATCH pattern [WHERE …] CREATE (n)-[:T]->(x {…})` /
75//! `MERGE (n)-[:T]->(x {key})` — once per matched row, with the terminal
76//! variable pre-bound so `(n)` anchors to the matched node.
77//!
78//! # Parameters
79//! `$name` placeholders in value positions (WHERE/ORDER literals, SET/CREATE/
80//! MERGE props, and a node's `key:`) are resolved from a caller-supplied
81//! [`Params`] map via [`parse_statement_full`] — the SDK-safe way to pass
82//! values (no string interpolation).
83//!
84//! # Null
85//! Core's evaluator is two-valued: a missing property makes a predicate
86//! false, so left alone `n.p <> 1`, `NOT n.p = 1` and `NOT n.p IN [..]` would
87//! be *true* for a node with no `p`. The compiler guards those — `<>`, `NOT`,
88//! and `=` with no constant side — with `IS NOT NULL` on every property (or
89//! channel, or literal `null`) they read, so for a predicate over one
90//! property the rows match openCypher: a missing value satisfies neither the
91//! test nor its negation, `n.p = null` keeps nothing, and `IS [NOT] NULL` is
92//! how absence is asked about. Where this is stricter than openCypher: under
93//! `NOT`, a compound predicate whose false branch would have absorbed a null
94//! (`NOT (n.a = 1 AND n.b = 2)` keeps a node with `b = 3` and no `a` there,
95//! not here); and `x IN [1, null]` is null here even when `x = 1`. Ordered
96//! comparisons, `IN` and the string predicates are already false on a
97//! missing value and stay unguarded.
98//!
99//! # Lexical rules
100//! Keywords and function names are case-insensitive. Identifiers are Unicode
101//! words, or anything between backticks. Strings take either quote with the
102//! escapes `\'` `\"` `\\` `\n` `\t` `\r` `\b` `\f` `\uXXXX`; numbers are
103//! ints or floats (`1.5`, `1e9`). Expressions nest at most
104//! `parse::MAX_NESTING` levels, an operator of an `AND`/`OR`/arithmetic chain
105//! counting one; deeper is a syntax error, not a stack overflow.
106//!
107//! # Not yet (each a clear error naming the rewrite, never a silent miscompile)
108//! - cross-variable predicates (`p.year < q.year`);
109//! - returning the *rows* of a non-terminal variable (`RETURN p` after a hop);
110//! its values project (`RETURN p.name`);
111//! - `WITH` pipelining: a projection is a tail, so nothing follows it;
112//! - unbounded variable-length (`*`, `*n..`);
113//! - a second `MATCH`, `OPTIONAL MATCH`, `UNION`, `UNWIND`, and a pattern with
114//! several paths (`(a)-->(b), (a)-->(c)`): a query holds one linear path —
115//! run one query per pattern or branch;
116//! - relationship variables (`-[r:T]->`): an edge can't be bound, returned or
117//! filtered on — drop the variable;
118//! - inline property predicates in a `MATCH` node (`(n {name: "x"})`): write
119//! them in `WHERE`;
120//! - list and map literals as values (`RETURN [1, 2]`, `n.x = {a: 1}`); a
121//! list is only sugar after `IN` (and a vector after `NEAR`), a map only a
122//! `CREATE`/`SET` property map;
123//! - the `%` and `^` operators.
124//!
125//! Errors point at the fault: a mistyped clause is reported from the reading
126//! of the statement that got furthest, not from the first token.
127//!
128//! # Completing a prefix
129//! [`complete`] answers the other question an editor asks: not "is this a
130//! statement" but "what could this become". It reads a half-typed query
131//! against a plane's [`Vocab`] — its labels, edge types and properties, with
132//! the counts — so the guess after `MATCH ` is the label that plane holds most
133//! of, and the guess after a node is the edge that most often leaves it. It is
134//! a second, smaller reading of this same grammar; see [`complete`] for why a
135//! `nom` parser cannot do it.
136
137mod ast;
138mod compile;
139mod complete;
140mod hint;
141mod parse;
142mod write;
143
144use ahash::AHashMap;
145
146use dr_strange_core::{LogicalPlan, PropValue};
147
148pub use ast::AsOfSpec;
149pub use complete::{
150 Completion, Connection, EdgeInfo, Expect, Kind, LabelInfo, Suggestion, Vocab, complete,
151};
152pub use hint::grammar_hint;
153pub use write::{WriteStatement, WriteSummary};
154
155/// Values for `$name` placeholders in a query, supplied by the caller and
156/// resolved at parse time (so no string interpolation — the SDK-safe way to
157/// pass values). Keyed by the bare name (no `$`).
158pub type Params = AHashMap<String, PropValue>;
159
160/// Resolve a `$name` placeholder against the caller's params.
161pub(crate) fn resolve_param(params: &Params, name: &str) -> Result<PropValue, String> {
162 params
163 .get(name)
164 .cloned()
165 .ok_or_else(|| format!("unbound parameter `${name}`"))
166}
167
168/// A parsed statement: a read (a runnable plan) or a write (applied with
169/// [`WriteStatement::apply`]). Surfaces branch on this to run a query.
170#[derive(Debug)]
171pub enum Statement {
172 Read(ReadQuery),
173 Write(WriteStatement),
174}
175
176/// A compiled read: the plan to run, and the snapshot to run it against.
177///
178/// `AS OF` is not a plan node — it addresses the *plane handle* — so it rides
179/// alongside. A surface applies it with `PlaneHandle::as_of` before running
180/// the plan; `None` reads the latest commit.
181#[derive(Debug, Clone, PartialEq)]
182pub struct ReadQuery {
183 pub plan: LogicalPlan,
184 pub as_of: Option<AsOfSpec>,
185}
186
187/// Resolves the text in a `SEARCH … NEAR "text"` (or `similarity(p, "text")`)
188/// into a query vector. The pure parser owns no embedding machinery — a
189/// surface (web/MCP/CLI) supplies this, backed by the server's configured
190/// provider (API key from the environment, never the client). Keeping it a
191/// one-method trait keeps `dr-strange-parser` dependency-free.
192pub trait Embedder {
193 fn embed(&self, text: &str) -> Result<Vec<f32>, String>;
194}
195
196/// Why a query didn't turn into a plan.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum ParseError {
199 /// The text didn't match the grammar.
200 Syntax(String),
201 /// The text is understood, but the pattern/clauses can't map onto a
202 /// `LogicalPlan` (e.g. a cross-variable predicate, an unbounded `*`, a
203 /// text `NEAR` with no embedder configured) — or it uses an openCypher
204 /// clause this cut recognises and refuses (`WITH`, `UNION`, `OPTIONAL
205 /// MATCH`, …). The message names the rewrite that works.
206 Compile(String),
207}
208
209impl std::fmt::Display for ParseError {
210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211 match self {
212 ParseError::Syntax(m) => write!(f, "syntax error: {m}"),
213 ParseError::Compile(m) => write!(f, "unsupported query: {m}"),
214 }
215 }
216}
217
218impl std::error::Error for ParseError {}
219
220/// Parse a *read* query into a runnable [`ReadQuery`]. A write statement
221/// (`CREATE`, …) is an error here — use [`parse_statement`]. A text
222/// `SEARCH … NEAR "…"` also errors (no embedder); use [`parse_with_embedder`]
223/// or a literal vector.
224pub fn parse(input: &str) -> Result<ReadQuery, ParseError> {
225 read_only(parse_statement_inner(input, None, &Params::new())?)
226}
227
228/// Like [`parse`], but resolves text `NEAR "…"` terms into query vectors via
229/// `embedder` — the semantic-search entry point the surfaces use for reads.
230pub fn parse_with_embedder(input: &str, embedder: &dyn Embedder) -> Result<ReadQuery, ParseError> {
231 read_only(parse_statement_inner(
232 input,
233 Some(embedder),
234 &Params::new(),
235 )?)
236}
237
238/// Parse a statement — either a read query or a write (`CREATE`, …). This is the
239/// entry point a surface uses when it wants to run *either*.
240pub fn parse_statement(input: &str) -> Result<Statement, ParseError> {
241 parse_statement_inner(input, None, &Params::new())
242}
243
244/// Like [`parse_statement`], but resolves a text `SEARCH … NEAR "…"` via `embedder`.
245pub fn parse_statement_with_embedder(
246 input: &str,
247 embedder: &dyn Embedder,
248) -> Result<Statement, ParseError> {
249 parse_statement_inner(input, Some(embedder), &Params::new())
250}
251
252/// The general entry point: parse a statement, resolving text `SEARCH` via an
253/// optional `embedder` and `$name` placeholders via `params`. The surfaces that
254/// accept parameters (CLI/MCP/plane.cypher) use this.
255pub fn parse_statement_full(
256 input: &str,
257 embedder: Option<&dyn Embedder>,
258 params: &Params,
259) -> Result<Statement, ParseError> {
260 parse_statement_inner(input, embedder, params)
261}
262
263fn read_only(stmt: Statement) -> Result<ReadQuery, ParseError> {
264 match stmt {
265 Statement::Read(read) => Ok(read),
266 Statement::Write(_) => Err(ParseError::Compile(
267 "this is a write statement; run it with parse_statement, not a read query".to_string(),
268 )),
269 }
270}
271
272fn parse_statement_inner(
273 input: &str,
274 embedder: Option<&dyn Embedder>,
275 params: &Params,
276) -> Result<Statement, ParseError> {
277 let (rest, stmt) = parse::statement(input).map_err(diagnose)?;
278 // Everything must be consumed — trailing tokens mean a mistyped clause.
279 let rest = rest.trim_start();
280 if !rest.trim_end().is_empty() {
281 return Err(ParseError::Syntax(format!(
282 "unexpected trailing input near `{}`",
283 snippet(rest.trim_end())
284 )));
285 }
286 Ok(match stmt {
287 ast::StmtAst::Read(query) => {
288 let as_of = query.as_of;
289 let plan = compile::compile(*query, embedder, params).map_err(ParseError::Compile)?;
290 Statement::Read(ReadQuery { plan, as_of })
291 }
292 ast::StmtAst::Write(w) => {
293 Statement::Write(write::compile(w, params.clone()).map_err(ParseError::Compile)?)
294 }
295 })
296}
297
298/// Turn the grammar's failure into the error a caller sees. A recognised but
299/// unsupported shape (`parse::unsupported`) is a `Compile` error carrying its
300/// rewrite hint; anything else is a `Syntax` error located by `describe`.
301fn diagnose(e: nom::Err<nom::error::Error<&str>>) -> ParseError {
302 if let nom::Err::Failure(er) = &e
303 && er.code == nom::error::ErrorKind::Fail
304 && let Some(msg) = parse::take_unsupported()
305 {
306 return ParseError::Compile(msg);
307 }
308 ParseError::Syntax(describe(e))
309}
310
311fn describe(e: nom::Err<nom::error::Error<&str>>) -> String {
312 match e {
313 // The one failure that is about the query's shape, not its text.
314 nom::Err::Failure(er) if er.code == nom::error::ErrorKind::TooLarge => format!(
315 "expression nested deeper than {} levels near `{}`",
316 parse::MAX_NESTING,
317 snippet(er.input.trim())
318 ),
319 nom::Err::Error(er) | nom::Err::Failure(er) => {
320 let at = er.input.trim();
321 if at.is_empty() {
322 "unexpected end of query".to_string()
323 } else {
324 format!("near `{}`", snippet(at))
325 }
326 }
327 nom::Err::Incomplete(_) => "incomplete query".to_string(),
328 }
329}
330
331fn snippet(s: &str) -> &str {
332 let end = s.char_indices().nth(40).map_or(s.len(), |(i, _)| i);
333 &s[..end]
334}