Skip to main content

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}