Skip to main content

cyrs_db/
queries.rs

1//! Derived Salsa query chain (spec 0001 §11.3).
2//!
3//! Implements the full parse→HIR→sema→plan pipeline as individually
4//! memoised Salsa `#[tracked]` queries.  Each layer re-evaluates only when
5//! its direct inputs change; Salsa's dependency tracking provides surgical
6//! cache invalidation at per-file granularity (spec §11.4 v1 scope).
7//!
8//! ## Query chain
9//!
10//! ```text
11//! SourceFile (input)
12//!   └─ parse_cst        → ParseOutput           (CST + syntax errors)
13//!       └─ parse_ast    → AstOutput             (CST wrapper, pointer-eq)
14//! FileOptions (input)
15//!   └─ options_digest   → u64                   (stable options hash)
16//! WorkspaceInputs (input)
17//!
18//! sema_diagnostics(file, file_opts, ws) → DiagnosticsOutput
19//!   (reads parse_cst → hir_lower [inline] → analyse)
20//! resolved_names(file, file_opts) → ResolvedNamesOutput
21//!   (reads parse_cst → hir_lower [inline] → resolve)
22//! plan_of(file) → PlanOutput
23//!   (reads parse_cst → hir_lower [inline] → plan_lower)
24//! all_diagnostics(file, file_opts, ws) → DiagnosticsOutput
25//!   (union of parse + sema diagnostics, sorted + deduped)
26//! ```
27//!
28//! ## HIR and the Send+Sync constraint
29//!
30//! Salsa requires all tracked-function outputs to be `Send + Sync`.
31//! `cyrs_hir::Statement` contains a `node_map: IndexMap<HirId, SyntaxNode>`
32//! whose value type (`SyntaxNode`) is `!Sync` (it wraps `NonNull`).  Rather
33//! than return `Statement` from a `#[salsa::tracked]` function, the HIR
34//! lowering is performed inline inside each downstream tracked query that
35//! needs it (`sema_diagnostics`, `plan_of`).  Those queries are already
36//! individually memoised by Salsa, so the HIR is effectively cached at the
37//! appropriate granularity (per-file, invalidated by source change).
38//!
39//! ## Caching semantics
40//!
41//! - Granularity: per `SourceFile` (v1, spec §11.4).
42//! - Changing `source` or `dialect` → re-executes `parse_cst` and all
43//!   downstream queries.
44//! - Changing `options` (via `FileOptions`) → re-executes `sema_diagnostics`
45//!   but leaves `parse_cst` cached.
46//! - Changing workspace `schema` → re-executes `sema_diagnostics` (schema-
47//!   aware pass) but leaves `parse_cst` cached.
48//!
49//! ## Arc-based sharing (spec §8 determinism)
50//!
51//! All output types implement `Eq` via pointer equality on their inner
52//! `Arc`, so Salsa can short-circuit downstream propagation when a
53//! re-executed query produces the same logical value.
54
55use std::sync::Arc;
56
57use cyrs_diag::{DiagCode, Diagnostic, DiagnosticsSink};
58use cyrs_hir::desugar::desugar_statement;
59use cyrs_hir::lower::lower_statement as hir_lower;
60use cyrs_plan::lower::{PlanStatement, lower_statement as plan_lower};
61use cyrs_sema::SemaOptions;
62use cyrs_sema::resolve::ResolveResult;
63use cyrs_syntax::TextRange;
64use smol_str::SmolStr;
65
66use crate::inputs::{FileOptions, WorkspaceInputs};
67use crate::{CypherDb, DialectMode, ParseOutput, SourceFile, parse_cst};
68
69// ---------------------------------------------------------------------------
70// Output newtypes
71//
72// All outputs are Arc-wrapped structs.  Equality is pointer-based so Salsa
73// can detect unchanged outputs and avoid unnecessary downstream propagation.
74// All output types are Send + Sync (required by Salsa).
75// ---------------------------------------------------------------------------
76
77/// AST projection of a `SourceFile`'s CST.
78///
79/// Wraps `Arc<ParseOutput>` with pointer-equality `Eq` so Salsa can detect
80/// when the CST has not changed between revisions.
81#[derive(Debug, Clone)]
82pub struct AstOutput(Arc<ParseOutput>);
83
84impl AstOutput {
85    fn new(p: ParseOutput) -> Self {
86        Self(Arc::new(p))
87    }
88
89    /// Access the underlying [`ParseOutput`].
90    #[must_use]
91    pub fn parse_output(&self) -> &ParseOutput {
92        &self.0
93    }
94}
95
96impl PartialEq for AstOutput {
97    fn eq(&self, other: &Self) -> bool {
98        Arc::ptr_eq(&self.0, &other.0)
99    }
100}
101impl Eq for AstOutput {}
102
103// ParseOutput is Send + Sync (verified in lib.rs), so Arc<ParseOutput> is too.
104const _: fn() = || {
105    fn check_send_sync<T: Send + Sync>() {}
106    check_send_sync::<AstOutput>();
107};
108
109/// Name-resolution result for one `SourceFile`.
110///
111/// Wraps `Arc<ResolveResult>` with pointer-equality `Eq`.
112/// `ResolveResult` is `Send + Sync` (contains only `SmolStr` / `VarId` /
113/// `TextRange` values; no raw pointers).
114#[derive(Debug, Clone)]
115pub struct ResolvedNamesOutput(Arc<ResolveResult>);
116
117impl ResolvedNamesOutput {
118    fn new(r: ResolveResult) -> Self {
119        Self(Arc::new(r))
120    }
121
122    /// Access the underlying [`ResolveResult`].
123    #[must_use]
124    pub fn result(&self) -> &ResolveResult {
125        &self.0
126    }
127}
128
129impl PartialEq for ResolvedNamesOutput {
130    fn eq(&self, other: &Self) -> bool {
131        Arc::ptr_eq(&self.0, &other.0)
132    }
133}
134impl Eq for ResolvedNamesOutput {}
135
136const _: fn() = || {
137    fn check_send_sync<T: Send + Sync>() {}
138    check_send_sync::<ResolvedNamesOutput>();
139};
140
141/// Logical plan for one `SourceFile`.
142///
143/// Wraps `Arc<PlanStatement>` with pointer-equality `Eq`.
144/// `PlanStatement` is `Send + Sync` (contains only `SmolStr` / index types).
145#[derive(Debug, Clone)]
146pub struct PlanOutput(Arc<PlanStatement>);
147
148impl PlanOutput {
149    fn new(p: PlanStatement) -> Self {
150        Self(Arc::new(p))
151    }
152
153    /// Access the underlying [`PlanStatement`].
154    #[must_use]
155    pub fn plan(&self) -> &PlanStatement {
156        &self.0
157    }
158}
159
160impl PartialEq for PlanOutput {
161    fn eq(&self, other: &Self) -> bool {
162        Arc::ptr_eq(&self.0, &other.0)
163    }
164}
165impl Eq for PlanOutput {}
166
167const _: fn() = || {
168    fn check_send_sync<T: Send + Sync>() {}
169    check_send_sync::<PlanOutput>();
170};
171
172/// A stable, de-duplicated list of diagnostics, wrapped in `Arc`.
173///
174/// Equality is by pointer identity so Salsa can avoid re-evaluating
175/// downstream queries when the diagnostic list is unchanged.
176#[derive(Debug, Clone)]
177pub struct DiagnosticsOutput(Arc<Vec<Diagnostic>>);
178
179impl DiagnosticsOutput {
180    fn new(v: Vec<Diagnostic>) -> Self {
181        Self(Arc::new(v))
182    }
183
184    /// Access the inner diagnostics slice.
185    #[must_use]
186    pub fn diagnostics(&self) -> &[Diagnostic] {
187        &self.0
188    }
189
190    /// Clone the inner `Arc` cheaply.
191    #[must_use]
192    pub fn arc_clone(&self) -> Arc<Vec<Diagnostic>> {
193        self.0.clone()
194    }
195}
196
197impl PartialEq for DiagnosticsOutput {
198    fn eq(&self, other: &Self) -> bool {
199        Arc::ptr_eq(&self.0, &other.0)
200    }
201}
202impl Eq for DiagnosticsOutput {}
203
204const _: fn() = || {
205    fn check_send_sync<T: Send + Sync>() {}
206    check_send_sync::<DiagnosticsOutput>();
207};
208
209// ---------------------------------------------------------------------------
210// Derived queries
211// ---------------------------------------------------------------------------
212
213/// AST projection of a [`SourceFile`]'s CST (spec §11.3 `ast(FileId)`).
214///
215/// Cache key: the `ParseOutput` returned by [`parse_cst`].  Changing
216/// `source` or `dialect` re-executes this query; changing only options or
217/// schema does not.
218///
219/// In v1 the typed AST wrappers from `cyrs-ast` are zero-cost views over
220/// the CST `SyntaxNode`.  This query returns the cached `ParseOutput` wrapped
221/// in an `Arc` so that downstream consumers can cheaply obtain the syntax root
222/// without re-parsing.
223#[salsa::tracked]
224pub fn parse_ast(db: &dyn CypherDb, file: SourceFile) -> AstOutput {
225    let cst = parse_cst(db, file);
226    AstOutput::new(cst)
227}
228
229/// Run name resolution on a [`SourceFile`] (spec §6.2 / §11.3 `sema`).
230///
231/// Cache key: `parse_cst` result (source changes trigger re-execution) +
232/// `warn_shadowing` option via `file_opts`.  Changing only the workspace
233/// schema does not re-execute name resolution.
234///
235/// HIR lowering is performed inline here because `HirStatement` contains
236/// `SyntaxNode` (which is `!Sync`) and therefore cannot be returned from a
237/// `#[salsa::tracked]` function.  The HIR lowering cost is dominated by the
238/// upstream `parse_cst`, which is cached.
239#[salsa::tracked(lru = 256)]
240pub fn resolved_names(
241    db: &dyn CypherDb,
242    file: SourceFile,
243    file_opts: FileOptions,
244) -> ResolvedNamesOutput {
245    // Establish Salsa dependency on the parsed CST.
246    let _cst = parse_cst(db, file);
247    let src = file.source(db);
248    let opts = file_opts.options(db);
249
250    // Lower and desugar HIR inline (not stored in Salsa — see module doc).
251    let stmt = hir_lower(src.as_str());
252    let stmt = desugar_statement(stmt);
253
254    let mut sink = DiagnosticsSink::new();
255    let result = cyrs_sema::resolve::resolve(&stmt, opts.warn_shadowing, &mut sink);
256    ResolvedNamesOutput::new(result)
257}
258
259/// Run all sema passes on a [`SourceFile`] (spec §7 / §11.3 `sema`).
260///
261/// Cache key: `parse_cst` + `options_digest` + workspace `schema`.  Changing
262/// source re-executes this query (cascading from `parse_cst`). Changing only
263/// options or schema re-executes this query but leaves `parse_cst` cached.
264///
265/// HIR lowering is performed inline (see module doc for rationale).
266#[salsa::tracked(lru = 256)]
267pub fn sema_diagnostics(
268    db: &dyn CypherDb,
269    file: SourceFile,
270    file_opts: FileOptions,
271    ws: WorkspaceInputs,
272) -> DiagnosticsOutput {
273    // Establish Salsa dependency on the parsed CST.
274    let _cst = parse_cst(db, file);
275    let src = file.source(db);
276    let opts = file_opts.options(db);
277    let schema = ws.schema(db);
278    let schema_ref = schema.as_deref();
279
280    // Lower and desugar HIR inline.
281    let stmt = hir_lower(src.as_str());
282    let stmt = desugar_statement(stmt);
283
284    let sema_opts = SemaOptions {
285        parameter_hints: opts
286            .parameter_hints
287            .iter()
288            .map(|(k, v)| (k.clone(), v.clone()))
289            .collect(),
290        warn_shadowing: opts.warn_shadowing,
291        dialect: match opts.dialect {
292            DialectMode::GqlAligned => cyrs_sema::DialectMode::GqlAligned,
293            DialectMode::OpenCypherV9 => cyrs_sema::DialectMode::OpenCypherV9,
294        },
295    };
296
297    let mut sink = DiagnosticsSink::new();
298    cyrs_sema::analyse(&stmt, schema_ref, &sema_opts, &mut sink);
299    DiagnosticsOutput::new(sink.into_sorted())
300}
301
302/// Lower a [`SourceFile`] to a logical plan (spec §12 / §11.3 `plan`).
303///
304/// Cache key: `parse_cst` (plan lowering is a pure function of source +
305/// dialect; options and schema do not affect the plan shape in v1).
306///
307/// HIR lowering is performed inline (see module doc for rationale).
308#[salsa::tracked(lru = 256)]
309pub fn plan_of(db: &dyn CypherDb, file: SourceFile) -> PlanOutput {
310    // Establish Salsa dependency on the parsed CST.
311    let _cst = parse_cst(db, file);
312    let src = file.source(db);
313
314    // Lower and desugar HIR inline.
315    let stmt = hir_lower(src.as_str());
316    let stmt = desugar_statement(stmt);
317
318    // cy-wlr: `plan_lower` is fallible now. When the HIR still contains
319    // `Expr::Unresolved` or un-desugared nodes (e.g. because the source
320    // is malformed and name resolution did not wire every reference) we
321    // surface an empty plan rather than propagating the error — the
322    // diagnostics surface of the DB layer already reports the underlying
323    // issue through `sema_diagnostics` / `all_diagnostics`, and the plan
324    // view is best-effort for malformed inputs.
325    let plan = plan_lower(&stmt).unwrap_or_else(|_| PlanStatement::empty());
326    PlanOutput::new(plan)
327}
328
329/// Union of parse diagnostics + sema diagnostics (spec §11.3 `diagnostics`).
330///
331/// Sorted by `(span_start, code)` and de-duplicated for determinism
332/// (spec §8 / §17.14).
333///
334/// Cache key: `parse_cst` + `sema_diagnostics`. Changing source invalidates
335/// both parse and sema layers; changing only options/schema invalidates only
336/// the sema layer (parse cache survives).
337#[salsa::tracked]
338pub fn all_diagnostics(
339    db: &dyn CypherDb,
340    file: SourceFile,
341    file_opts: FileOptions,
342    ws: WorkspaceInputs,
343) -> DiagnosticsOutput {
344    // Parse diagnostics (SyntaxError → Diagnostic).
345    let cst = parse_cst(db, file);
346    let parse_diags: Vec<Diagnostic> = cst
347        .parse()
348        .errors()
349        .iter()
350        .map(syntax_error_to_diagnostic)
351        .collect();
352
353    // Sema diagnostics (cached independently).
354    let sema = sema_diagnostics(db, file, file_opts, ws);
355
356    // Union, sort by (span_start, code), deduplicate.
357    let mut combined: Vec<Diagnostic> = parse_diags;
358    combined.extend_from_slice(sema.diagnostics());
359    combined.sort_by_key(|d| (d.primary.range.start(), d.code));
360    combined
361        .dedup_by(|a, b| a.primary.range.start() == b.primary.range.start() && a.code == b.code);
362
363    DiagnosticsOutput::new(combined)
364}
365
366// ---------------------------------------------------------------------------
367// Public convenience API — `analyse_file`
368// ---------------------------------------------------------------------------
369
370/// The result of a full analysis of a single [`SourceFile`].
371///
372/// Returned by [`analyse_file`] as a convenient summary that bundles the
373/// logical plan with the complete diagnostic list.
374#[derive(Debug, Clone)]
375pub struct Analysis {
376    /// Logical plan for the query.
377    pub plan: PlanOutput,
378    /// All diagnostics (parse + semantic), sorted and de-duplicated.
379    pub diagnostics: DiagnosticsOutput,
380}
381
382/// Convenience entry point: run the full analysis pipeline for `file` and
383/// return an [`Analysis`] bundling the plan and all diagnostics.
384///
385/// `file_opts` carries the per-file [`crate::inputs::AnalysisOptions`];
386/// `ws` carries the workspace-scoped schema. Callers that do not need
387/// options or a schema may pass freshly-constructed defaults:
388///
389/// ```rust,ignore
390/// let opts = db.new_file_options(AnalysisOptions::default());
391/// let ws   = db.new_workspace_inputs(None);
392/// let a    = analyse_file(&db, file, opts, ws);
393/// ```
394///
395/// All layers are individually memoised; subsequent calls with unchanged
396/// inputs are O(1).
397pub fn analyse_file(
398    db: &dyn CypherDb,
399    file: SourceFile,
400    file_opts: FileOptions,
401    ws: WorkspaceInputs,
402) -> Analysis {
403    let plan = plan_of(db, file);
404    let diagnostics = all_diagnostics(db, file, file_opts, ws);
405    Analysis { plan, diagnostics }
406}
407
408// ---------------------------------------------------------------------------
409// Internal helpers
410// ---------------------------------------------------------------------------
411
412/// Convert a [`cyrs_syntax::SyntaxError`] into a [`Diagnostic`].
413///
414/// `SyntaxError.code` carries the numeric discriminant of the `DiagCode`
415/// variant (e.g. `3` for `E0003`). The lookup is delegated to
416/// [`DiagCode::from`] for a [`cyrs_syntax::SyntaxError`] reference
417/// (cy-emb3) — unknown codes fall back to `E0001`.
418fn syntax_error_to_diagnostic(e: &cyrs_syntax::SyntaxError) -> Diagnostic {
419    let code = DiagCode::from(e);
420    let range = TextRange::new(e.offset, e.offset);
421    // `Diagnostic` is `#[non_exhaustive]` (cy-2i9.1).  Use the `error`
422    // constructor rather than a struct literal so downstream / cross-crate
423    // construction remains SemVer-stable.
424    Diagnostic::error(code, range, SmolStr::new(&e.message))
425}
426
427// ---------------------------------------------------------------------------
428// LRU capacity helpers (spec §11.X, bead cy-31b)
429//
430// Salsa 0.26 generates `set_lru_capacity` as an associated function on the
431// tracked-fn module, but it is private to the declaring module.  These thin
432// wrappers re-export the capability so that `workspace::Database::with_options`
433// can apply runtime LRU caps from [`crate::options::DatabaseOptions`].
434// ---------------------------------------------------------------------------
435
436/// Adjust the LRU capacity of [`resolved_names`] at runtime.
437///
438/// Must be called before any queries are issued.  Corresponds to the
439/// `sema_lru` field of [`crate::options::DatabaseOptions`].
440pub fn set_resolved_names_lru(db: &mut impl crate::CypherDb, cap: usize) {
441    resolved_names::set_lru_capacity(db, cap);
442}
443
444/// Adjust the LRU capacity of [`sema_diagnostics`] at runtime.
445///
446/// Must be called before any queries are issued.  Corresponds to the
447/// `sema_lru` field of [`crate::options::DatabaseOptions`].
448pub fn set_sema_diagnostics_lru(db: &mut impl crate::CypherDb, cap: usize) {
449    sema_diagnostics::set_lru_capacity(db, cap);
450}
451
452/// Adjust the LRU capacity of [`plan_of`] at runtime.
453///
454/// Must be called before any queries are issued.  Corresponds to the
455/// `plan_lru` field of [`crate::options::DatabaseOptions`].
456pub fn set_plan_of_lru(db: &mut impl crate::CypherDb, cap: usize) {
457    plan_of::set_lru_capacity(db, cap);
458}
459
460// ---------------------------------------------------------------------------
461// Tests
462// ---------------------------------------------------------------------------
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::CypherDatabase;
468    use crate::inputs::AnalysisOptions;
469    use std::sync::Arc;
470
471    // Helper: build a db with one file + default options + no schema.
472    fn setup(src: &str) -> (CypherDatabase, SourceFile, FileOptions, WorkspaceInputs) {
473        let mut db = CypherDatabase::new();
474        let file = db.new_source_file(src);
475        let file_opts = db.new_file_options(AnalysisOptions::default());
476        let ws = db.new_workspace_inputs(None);
477        (db, file, file_opts, ws)
478    }
479
480    // ── Cache hit: pointer-equality ─────────────────────────────────────────
481
482    /// Second call on unchanged inputs returns the same `Arc` (cached).
483    #[test]
484    fn parse_ast_cached() {
485        let (db, file, _, _) = setup("RETURN 1");
486        let a1 = parse_ast(&db, file);
487        let a2 = parse_ast(&db, file);
488        assert!(
489            Arc::ptr_eq(&a1.0, &a2.0),
490            "parse_ast should return a cached Arc on second call"
491        );
492    }
493
494    /// `plan_of` returns the same Arc on second call (no recomputation).
495    #[test]
496    fn plan_of_cached() {
497        let (db, file, _, _) = setup("MATCH (n) RETURN n");
498        let p1 = plan_of(&db, file);
499        let p2 = plan_of(&db, file);
500        assert!(
501            Arc::ptr_eq(&p1.0, &p2.0),
502            "plan_of should return a cached Arc on second call"
503        );
504    }
505
506    /// `sema_diagnostics` returns the same Arc on second call.
507    #[test]
508    fn sema_diagnostics_cached() {
509        let (db, file, file_opts, ws) = setup("RETURN 1");
510        let d1 = sema_diagnostics(&db, file, file_opts, ws);
511        let d2 = sema_diagnostics(&db, file, file_opts, ws);
512        assert!(
513            Arc::ptr_eq(&d1.0, &d2.0),
514            "sema_diagnostics should return a cached Arc on second call"
515        );
516    }
517
518    /// `all_diagnostics` returns the same Arc on second call.
519    #[test]
520    fn all_diagnostics_cached() {
521        let (db, file, file_opts, ws) = setup("RETURN 1");
522        let d1 = all_diagnostics(&db, file, file_opts, ws);
523        let d2 = all_diagnostics(&db, file, file_opts, ws);
524        assert!(
525            Arc::ptr_eq(&d1.0, &d2.0),
526            "all_diagnostics should return a cached Arc on second call"
527        );
528    }
529
530    // ── Source change: full pipeline re-evaluates ────────────────────────────
531
532    /// Changing `source` produces a new `ParseOutput` Arc and a new
533    /// `PlanOutput` Arc (cache invalidated).
534    #[test]
535    fn source_change_invalidates_pipeline() {
536        let (mut db, file, _, _) = setup("MATCH (n) RETURN n");
537
538        let p1 = plan_of(&db, file);
539        let d1 = parse_ast(&db, file);
540
541        db.set_source(file, "RETURN 42");
542
543        let p2 = plan_of(&db, file);
544        let d2 = parse_ast(&db, file);
545
546        assert!(
547            !Arc::ptr_eq(&p1.0, &p2.0),
548            "plan_of should re-execute after source change"
549        );
550        assert!(
551            !Arc::ptr_eq(&d1.0, &d2.0),
552            "parse_ast should re-execute after source change"
553        );
554    }
555
556    // ── Options-only change: sema re-evaluates, parse cache survives ─────────
557
558    /// Toggling `warn_shadowing` re-evaluates `sema_diagnostics` but does
559    /// NOT re-evaluate `parse_cst`.
560    #[test]
561    fn options_change_reruns_sema_only() {
562        let (mut db, file, file_opts, ws) = setup("MATCH (n) RETURN n");
563
564        // Parse the CST first to warm the cache.
565        let cst1 = parse_cst(&db, file);
566        let _ = sema_diagnostics(&db, file, file_opts, ws);
567
568        // Toggle warn_shadowing — options digest changes.
569        db.set_options(
570            file_opts,
571            AnalysisOptions {
572                warn_shadowing: true,
573                ..Default::default()
574            },
575        );
576
577        // parse_cst cache should survive (same Arc).
578        let cst2 = parse_cst(&db, file);
579        assert!(
580            Arc::ptr_eq(&cst1.0, &cst2.0),
581            "parse_cst Arc should be unchanged after options-only change"
582        );
583
584        // sema_diagnostics should re-execute (new Arc).
585        // We can't directly assert the Arc changed since the output might
586        // happen to be equal; we simply verify it runs without error.
587        let _d2 = sema_diagnostics(&db, file, file_opts, ws);
588    }
589
590    // ── Schema change: schema-aware sema re-evaluates, parse cache survives ──
591
592    /// Changing the workspace schema re-evaluates `sema_diagnostics` but
593    /// leaves `parse_cst` cached.
594    #[test]
595    fn schema_change_reruns_sema_only() {
596        use cyrs_schema::EmptySchema;
597        let (mut db, file, file_opts, ws) = setup("MATCH (n:Person) RETURN n");
598
599        let cst1 = parse_cst(&db, file);
600
601        // Set a (still empty) schema — triggers a revision bump on `ws`.
602        let schema: Arc<dyn cyrs_schema::SchemaProvider> = Arc::new(EmptySchema);
603        db.set_schema(ws, Some(schema));
604
605        let cst2 = parse_cst(&db, file);
606        assert!(
607            Arc::ptr_eq(&cst1.0, &cst2.0),
608            "parse_cst Arc should be unchanged after schema change"
609        );
610
611        // sema_diagnostics runs without error (schema-aware pass with EmptySchema).
612        let _d = sema_diagnostics(&db, file, file_opts, ws);
613    }
614
615    // ── analyse_file convenience wrapper ────────────────────────────────────
616
617    /// `analyse_file` returns both a plan and a diagnostic list.
618    #[test]
619    fn analyse_file_returns_plan_and_diags() {
620        let (db, file, file_opts, ws) = setup("MATCH (n) RETURN n");
621        let analysis = analyse_file(&db, file, file_opts, ws);
622        // A valid query produces at least a Source + Project plan op.
623        assert!(
624            !analysis.plan.plan().ops.is_empty(),
625            "expected at least one plan op"
626        );
627    }
628
629    /// `analyse_file` on an empty source returns an empty plan and no parse errors.
630    #[test]
631    fn analyse_file_empty_source() {
632        let (db, file, file_opts, ws) = setup("");
633        let analysis = analyse_file(&db, file, file_opts, ws);
634        // Empty source → no sema diagnostics from the sema passes (parse may
635        // or may not produce syntax errors depending on the parser; either is
636        // acceptable).
637        let _ = analysis.diagnostics.diagnostics();
638    }
639
640    // ── all_diagnostics ordering + dedup ────────────────────────────────────
641
642    /// Diagnostics in `all_diagnostics` are sorted by (`span_start`, code).
643    #[test]
644    fn all_diagnostics_are_sorted() {
645        let (db, file, file_opts, ws) = setup("MATCH (n) RETURN n");
646        let diags = all_diagnostics(&db, file, file_opts, ws);
647        let d = diags.diagnostics();
648        for w in d.windows(2) {
649            let a_key = (w[0].primary.range.start(), w[0].code);
650            let b_key = (w[1].primary.range.start(), w[1].code);
651            assert!(
652                a_key <= b_key,
653                "diagnostics must be sorted: {a_key:?} > {b_key:?}"
654            );
655        }
656    }
657
658    // ── parse_ast basic correctness ─────────────────────────────────────────
659
660    /// `parse_ast` round-trips the source text.
661    #[test]
662    fn parse_ast_roundtrips_source() {
663        let src = "MATCH (n:Person) RETURN n.name";
664        let (db, file, _, _) = setup(src);
665        let ast = parse_ast(&db, file);
666        assert_eq!(
667            ast.parse_output().parse().syntax().to_string(),
668            src,
669            "AST output must round-trip the source"
670        );
671    }
672
673    // ── resolved_names basic correctness ────────────────────────────────────
674
675    /// `resolved_names` runs without panic on a simple query.
676    #[test]
677    fn resolved_names_basic() {
678        let (db, file, file_opts, _) = setup("MATCH (n) RETURN n");
679        let rn = resolved_names(&db, file, file_opts);
680        // The scope graph must have at least a Root scope.
681        let result = rn.result();
682        // Scope graph is non-trivial — just ensure it didn't panic.
683        let _ = &result.scope_graph;
684        let _ = &result.resolved_names;
685    }
686}