cyrs-db 0.1.0

Salsa-backed incremental analysis database for Cypher / GQL (spec 0001 §11).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
//! `cyrs-db` — incremental analysis database (spec 0001 §11).
//!
//! This crate builds on the Salsa skeleton from `cy-zx6` and adds the
//! complete input-query surface from spec §11.2 (`cy-nk7`), plus the
//! `FileId` model, snapshot API, and workspace-scoped `SchemaProvider`
//! wiring from spec §11.4–§11.5 (`cy-amr`).
//!
//! ## Primary API (spec §11.4–§11.5)
//!
//! See [`workspace`] for the high-level `Database` + `FileId` + snapshot API
//! intended for consumers (`cyrs-lsp`, `cyrs-agent`, `cyrs-cli`,
//! `cyrs-tck`).
//!
//! - [`workspace::Database`] — workspace-scoped database.  Owns a
//!   [`CypherDatabase`] plus a `FileId → SourceFile` registry.
//! - [`workspace::FileId`] — stable u32 handle, the unit of caching (§11.4).
//! - [`workspace::DatabaseSnapshot`] — `Send` read-only snapshot for
//!   cross-thread queries (§11.5).
//! - [`workspace::UnknownFileId`] — error returned for stale handles.
//!
//! ## Salsa internals (low-level, for crate authors)
//!
//! - [`SourceFile`] — Salsa `#[input]` for per-file `source` + `dialect`.
//! - [`inputs::FileOptions`] — Salsa `#[input]` for per-file [`inputs::AnalysisOptions`].
//! - [`inputs::WorkspaceInputs`] — Salsa `#[input]` for workspace-scoped schema (§11.4).
//! - [`inputs::options_digest`] — `#[salsa::tracked]` derived query: stable u64 hash
//!   of `AnalysisOptions`, used to gate all analysis-dependent derived queries.
//! - [`CypherDatabase`] — the concrete `salsa::Database` impl.
//! - [`CypherDb`] — the database trait that all concrete DBs implement.
//! - [`ParseOutput`] — memoised result of parsing a [`SourceFile`].
//! - [`parse_cst`] — first derived query: lossless CST, re-evaluated only
//!   when `source` changes.
//!
//! ## Input query surface (spec §11.2)
//!
//! | Query                             | Salsa kind        | Scope     |
//! |-----------------------------------|-------------------|-----------|
//! | `source_text(file) -> &str`       | `#[input]` field  | per-file  |
//! | `dialect(file) -> DialectMode`    | `#[input]` field  | per-file  |
//! | `options(file) -> AnalysisOptions`| `#[input]` field  | per-file  |
//! | `options_digest(file) -> u64`     | `#[tracked]`      | per-file  |
//! | `schema() -> Option<Arc<dyn …>>`  | `#[input]` field  | workspace |
//!
//! ## Legacy facade API (preserved for backward compat)
//!
//! The legacy [`LegacyDatabase`] / legacy `FileId` (u32 newtype) remain
//! exported under their old names for backward compatibility while binary
//! crates migrate to the new [`workspace::Database`] API.
//!
//! ## Send + Sync / snapshot semantics (spec §11.5)
//!
//! `CypherDatabase` is `Clone`.  Cloning shares the `Arc<Zalsa>` backing
//! store and creates a fresh `ZalsaLocal`, producing a snapshot.  The clone
//! can be sent to another thread for concurrent read queries (`Send`).
//! Writes require `&mut self` and block until all clones are dropped.

#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/cyrs-db/0.0.1")]

pub mod inputs;
pub mod options;
pub mod queries;
pub mod workspace;

pub use inputs::{AnalysisOptions, FileOptions, WorkspaceInputs, options_digest};
pub use options::DatabaseOptions;
pub use queries::{
    Analysis, AstOutput, DiagnosticsOutput, PlanOutput, ResolvedNamesOutput, all_diagnostics,
    analyse_file, parse_ast, plan_of, resolved_names, sema_diagnostics,
};
pub use workspace::{Database, DatabaseSnapshot, FileId, UnknownFileId};

use std::sync::Arc;

use cyrs_syntax::{Parse, parse};
use salsa::Setter as _;

// ---------------------------------------------------------------------------
// Dialect mode (spec §9)
// ---------------------------------------------------------------------------

/// Dialect mode selected at parse time.  Spec §9.
///
/// Marked `#[non_exhaustive]` (cy-2i9.1) so new dialects can land
/// without forcing a SemVer-major release.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DialectMode {
    /// GQL-aligned parsing (default).
    #[default]
    GqlAligned,
    /// openCypher 9 compatibility mode.
    OpenCypherV9,
}

// ---------------------------------------------------------------------------
// Compile-time Send / Sync assertion helpers (no unsafe)
// ---------------------------------------------------------------------------

macro_rules! assert_send {
    ($T:ty) => {
        const _: () = {
            fn _check()
            where
                $T: Send,
            {
            }
        };
    };
}

macro_rules! assert_sync {
    ($T:ty) => {
        const _: () = {
            fn _check()
            where
                $T: Sync,
            {
            }
        };
    };
}

// ---------------------------------------------------------------------------
// ParseOutput — newtype wrapping Arc<Parse> with pointer-equality Eq
//
// Salsa tracked functions require Output: Eq so that it can detect whether
// re-execution produced the same value (§11.3).  `Parse` itself does not
// implement `Eq`, so we wrap it in an `Arc` and compare by pointer identity.
// Two re-executions of an unchanged source return the cached Arc, so they
// compare equal.  A mutation produces a fresh `Arc`, so they compare unequal.
// ---------------------------------------------------------------------------

/// Memoised result of parsing a [`SourceFile`].
///
/// Wraps an `Arc<Parse>`; equality is by pointer identity so that Salsa
/// can detect when re-parsing produced the same tree.
#[derive(Debug, Clone)]
pub struct ParseOutput(Arc<Parse>);

impl ParseOutput {
    fn new(p: Parse) -> Self {
        Self(Arc::new(p))
    }

    /// Access the underlying [`Parse`].
    #[must_use]
    pub fn parse(&self) -> &Parse {
        &self.0
    }

    /// Strong-reference count of the wrapped `Arc<Parse>`.
    ///
    /// Exposed so tests and memory-diagnostics tooling can observe Salsa
    /// memo retention (§11.6): after a `FileId` is evicted and the next
    /// revision replaces the memo entry for its `SourceFile`, the old
    /// `ParseOutput` held by the memo is dropped and any caller-held clone
    /// of its `Arc<Parse>` sees its strong count drop to 1.
    #[must_use]
    pub fn strong_count(&self) -> usize {
        Arc::strong_count(&self.0)
    }
}

impl PartialEq for ParseOutput {
    fn eq(&self, other: &Self) -> bool {
        Arc::ptr_eq(&self.0, &other.0)
    }
}

impl Eq for ParseOutput {}

// Salsa requires Output: Send + Sync.
// Arc<Parse> is Send+Sync because GreenNode (Arc-backed) and Vec<SyntaxError>
// (all fields Send+Sync) are Send+Sync.
assert_send!(ParseOutput);
assert_sync!(ParseOutput);

// ---------------------------------------------------------------------------
// Salsa: input structs
// ---------------------------------------------------------------------------

mod source_file_input {
    // Wrapping the `#[salsa::input]` in a private module lets us put
    // `#![allow(missing_docs)]` at inner-module scope so the macro's
    // generated impl block inherits the exemption.  The struct
    // itself is re-exported below.  Outer-item `#[allow]` attributes
    // do not propagate into Salsa's expansion.
    #![allow(missing_docs)]

    use super::{DialectMode, ParseOutput};

    /// A single source file tracked by the incremental database.
    ///
    /// Fields:
    /// - `source` — raw UTF-8 source text.
    /// - `dialect` — parsing dialect (spec §9).
    /// - `options_digest` — hash of analysis options.  Full shape deferred to
    ///   bead cy-nk7; zero is a valid "no options" value.
    /// - `precomputed_parse` — optional pre-computed [`ParseOutput`] (cy-li6).
    ///   When `Some`, [`super::parse_cst`] returns this value directly instead
    ///   of re-parsing `source` from scratch.  Set by
    ///   [`super::workspace::Database::edit_file`] after the smart-path
    ///   sub-tree splice produces a fresh [`super::Parse`]; cleared on every
    ///   non-incremental source mutation (`set_source`, `update_file`).
    #[salsa::input]
    pub struct SourceFile {
        /// Raw UTF-8 source text for this file.
        #[returns(ref)]
        pub source: String,

        /// Dialect used when parsing this file.
        pub dialect: DialectMode,

        /// Hash of options that affect derived queries.
        /// Shape is stabilised in cy-nk7; zero is a valid "no options" value.
        pub options_digest: u64,

        /// Optional pre-computed parse, supplied by the incremental edit
        /// path (cy-li6).  `None` means "no hint, re-parse `source`".
        #[returns(ref)]
        pub precomputed_parse: Option<ParseOutput>,
    }
}
pub use source_file_input::SourceFile;

// ---------------------------------------------------------------------------
// Salsa: derived queries
// ---------------------------------------------------------------------------

/// Parse a [`SourceFile`] into a lossless CST.
///
/// The result is memoised; it is re-evaluated only when `source`, `dialect`,
/// or the cy-li6 `precomputed_parse` hint changes.  See [`ParseOutput`]
/// for equality semantics.
///
/// ## cy-li6 fast path
///
/// If [`SourceFile::precomputed_parse`] is `Some`, this query returns the
/// hint verbatim instead of calling [`parse`] on `source`.  This wires the
/// smart-path sub-tree splice produced by
/// [`workspace::Database::edit_file`] through Salsa as the published
/// [`ParseOutput`] for the next revision, so downstream tracked queries
/// (`parse_ast`, `sema_diagnostics`, `plan_of`, `analyse_file`) consume the
/// spliced tree without paying a whole-file reparse.
///
/// The hint is cleared on every non-incremental source mutation so a
/// follow-up `set_source` always produces a fresh full parse.
#[salsa::tracked(lru = 256)]
pub fn parse_cst(db: &dyn CypherDb, file: SourceFile) -> ParseOutput {
    if let Some(hint) = file.precomputed_parse(db) {
        return hint.clone();
    }
    let src = file.source(db);
    ParseOutput::new(parse(src))
}

/// Adjust the LRU capacity of [`parse_cst`] at runtime.
///
/// Called by [`workspace::Database::with_options`] to apply
/// [`options::DatabaseOptions::parse_lru`].  Must be called before any
/// queries are issued.
pub fn set_parse_cst_lru(db: &mut impl CypherDb, cap: usize) {
    parse_cst::set_lru_capacity(db, cap);
}

// ---------------------------------------------------------------------------
// Salsa: database trait + concrete struct
// ---------------------------------------------------------------------------

/// Trait that all concrete databases in this workspace implement.
///
/// Using a trait lets downstream crates write `&dyn CypherDb` functions
/// that are testable against mock databases.
#[salsa::db]
pub trait CypherDb: salsa::Database {}

/// The concrete incremental database (Salsa 2022-style, spec §11.1).
///
/// ## Send + Sync via snapshots (spec §11.5)
///
/// `CypherDatabase` is `Send` but not `Sync` — `ZalsaLocal` contains
/// per-thread `UnsafeCell` state by design.  Thread-safety is achieved via
/// snapshots: `clone()` shares the `Arc<Zalsa>` backing store and produces a
/// fresh `ZalsaLocal`, allowing the clone to be sent to another thread and
/// queried concurrently.  The LSP server clones once per request; the CLI
/// never needs to.
#[salsa::db]
#[derive(Clone, Default)]
pub struct CypherDatabase {
    storage: salsa::Storage<Self>,
}

impl std::fmt::Debug for CypherDatabase {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CypherDatabase").finish_non_exhaustive()
    }
}

impl CypherDatabase {
    /// Construct a new, empty database.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Create a new [`SourceFile`] input with the given source text,
    /// using the default dialect and a zero options digest.
    pub fn new_source_file(&mut self, source: impl Into<String>) -> SourceFile {
        SourceFile::new(self, source.into(), DialectMode::default(), 0, None)
    }

    /// Create a new [`SourceFile`] input with explicit dialect and digest.
    pub fn new_source_file_with(
        &mut self,
        source: impl Into<String>,
        dialect: DialectMode,
        options_digest: u64,
    ) -> SourceFile {
        SourceFile::new(self, source.into(), dialect, options_digest, None)
    }

    /// Update the source text of an existing [`SourceFile`], bumping the
    /// Salsa revision so that derived queries are invalidated.
    ///
    /// Always clears any cy-li6 [`precomputed_parse`](SourceFile::precomputed_parse)
    /// hint, because a fresh source string must produce a fresh parse —
    /// reusing a stale hint would silently desync the published CST from
    /// `source`.
    pub fn set_source(&mut self, file: SourceFile, source: impl Into<String>) {
        file.set_source(self).to(source.into());
        // Clear any stale incremental hint — see doc comment above.
        file.set_precomputed_parse(self).to(None);
    }

    /// Atomically replace `source` and publish a precomputed [`Parse`]
    /// (cy-li6).
    ///
    /// Used by [`workspace::Database::edit_file`] after
    /// [`cyrs_syntax::incremental_reparse`] has produced a spliced tree
    /// for `new_source`.  Bumps the Salsa revision once for both fields,
    /// so the next [`parse_cst`] query returns `parse` directly without
    /// re-parsing.
    pub fn set_source_with_parse(
        &mut self,
        file: SourceFile,
        source: impl Into<String>,
        parse: ParseOutput,
    ) {
        file.set_source(self).to(source.into());
        file.set_precomputed_parse(self).to(Some(parse));
    }

    /// Update the dialect of an existing [`SourceFile`].
    pub fn set_dialect(&mut self, file: SourceFile, dialect: DialectMode) {
        file.set_dialect(self).to(dialect);
    }

    /// Create a new [`FileOptions`] input with the given [`AnalysisOptions`].
    pub fn new_file_options(&mut self, options: AnalysisOptions) -> FileOptions {
        FileOptions::new(self, options)
    }

    /// Update the [`AnalysisOptions`] of an existing [`FileOptions`] input.
    ///
    /// Bumps the Salsa revision for `file_opts`, which cascades through
    /// `options_digest` and all derived queries that read it.
    pub fn set_options(&mut self, file_opts: FileOptions, options: AnalysisOptions) {
        file_opts.set_options(self).to(options);
    }

    /// Create a new [`WorkspaceInputs`] input.
    ///
    /// There should be exactly one `WorkspaceInputs` per database.
    /// Call this once at database initialisation; update it with
    /// [`set_schema`](Self::set_schema).
    pub fn new_workspace_inputs(
        &mut self,
        schema: Option<Arc<dyn cyrs_schema::SchemaProvider>>,
    ) -> WorkspaceInputs {
        WorkspaceInputs::new(self, schema)
    }

    /// Update the workspace-scoped schema.
    ///
    /// Invalidates all derived queries that depend on the schema.
    pub fn set_schema(
        &mut self,
        ws: WorkspaceInputs,
        schema: Option<Arc<dyn cyrs_schema::SchemaProvider>>,
    ) {
        ws.set_schema(self).to(schema);
    }
}

#[salsa::db]
impl salsa::Database for CypherDatabase {}

#[salsa::db]
impl CypherDb for CypherDatabase {}

// CypherDatabase is Send (salsa adds `unsafe impl Send` via the #[salsa::db]
// macro) but not Sync.
assert_send!(CypherDatabase);

// ---------------------------------------------------------------------------
// Legacy thin facade — preserved for backward compatibility.
//
// Binary crates (cyrs-cli, cyrs-agent) and cyrs-testkit depend on this
// API.  They will migrate to the new `workspace::Database` in subsequent beads.
// ---------------------------------------------------------------------------

use std::sync::Mutex as StdMutex;

use cyrs_diag::{Diagnostic, DiagnosticsSink};
use cyrs_fmt::{FormatOptions, format_with as fmt_format_with};
use cyrs_schema::{EmptySchema, SchemaProvider};
use cyrs_sema::SemaOptions;
use smol_str::SmolStr;

/// File identity within the legacy [`LegacyDatabase`].
///
/// Deprecated: use [`workspace::FileId`] with [`workspace::Database`] instead.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LegacyFileId(pub u32);

#[derive(Default)]
struct LegacyInner {
    sources: indexmap::IndexMap<LegacyFileId, Arc<str>>,
    dialects: indexmap::IndexMap<LegacyFileId, DialectMode>,
    #[allow(dead_code)] // consumed when sema passes are wired in
    sema_opts: SemaOptions,
    next_file_id: u32,
}

/// Legacy analysis database (non-incremental).  Preserved for backward
/// compatibility while binary crates migrate to [`workspace::Database`].
///
/// `Send + Sync` via `Mutex`-guarded interior.  The incremental replacement
/// is [`workspace::Database`] / [`CypherDatabase`].
pub struct LegacyDatabase {
    inner: StdMutex<LegacyInner>,
    schema: StdMutex<Arc<dyn SchemaProvider>>,
}

impl Default for LegacyDatabase {
    fn default() -> Self {
        Self::new()
    }
}

// `LegacyDatabase` is the pre-workspace u32-FileId façade kept for
// backward compatibility while binary crates migrate to the new
// `workspace::Database`.  Its methods are intentionally thin
// wrappers and document themselves; the module-level doc already
// explains the migration status.  `#[allow(missing_docs)]` on the
// whole impl avoids churning through 10+ trivial one-liners that
// will be deleted alongside the façade.
#[allow(missing_docs)]
impl LegacyDatabase {
    #[must_use]
    pub fn new() -> Self {
        Self {
            inner: StdMutex::new(LegacyInner::default()),
            schema: StdMutex::new(Arc::new(EmptySchema)),
        }
    }

    pub fn set_schema(&self, schema: Arc<dyn SchemaProvider>) {
        *self.schema.lock().expect("db mutex") = schema;
    }

    pub fn allocate_file(&self) -> LegacyFileId {
        let mut i = self.inner.lock().expect("db mutex");
        let id = LegacyFileId(i.next_file_id);
        i.next_file_id += 1;
        id
    }

    pub fn set_source(&self, file: LegacyFileId, src: impl Into<Arc<str>>) {
        let mut i = self.inner.lock().expect("db mutex");
        i.sources.insert(file, src.into());
    }

    pub fn set_dialect(&self, file: LegacyFileId, d: DialectMode) {
        let mut i = self.inner.lock().expect("db mutex");
        i.dialects.insert(file, d);
    }

    fn source_of_inner(&self, file: LegacyFileId) -> Arc<str> {
        let i = self.inner.lock().expect("db mutex");
        i.sources
            .get(&file)
            .cloned()
            .unwrap_or_else(|| Arc::from(""))
    }

    #[must_use]
    pub fn parse(&self, file: LegacyFileId) -> Parse {
        let src = self.source_of_inner(file);
        parse(&src)
    }

    #[must_use]
    pub fn diagnostics(&self, file: LegacyFileId) -> Vec<Diagnostic> {
        let _parse = self.parse(file);
        let sink = DiagnosticsSink::new();
        sink.into_sorted()
    }

    #[must_use]
    pub fn formatted(&self, file: LegacyFileId, opts: &FormatOptions) -> SmolStr {
        let src = self.source_of_inner(file);
        fmt_format_with(&src, opts)
            .expect("formatter is infallible")
            .into()
    }
}

impl std::fmt::Debug for LegacyDatabase {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("LegacyDatabase").finish_non_exhaustive()
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    // --- Salsa / CypherDatabase tests ---

    /// Basic construction: create a DB, create a [`SourceFile`], run the
    /// derived query, check the CST round-trips the source.
    #[test]
    fn parse_cst_basic() {
        let mut db = CypherDatabase::new();
        let file = db.new_source_file("MATCH (n) RETURN n");
        let out = parse_cst(&db, file);
        assert_eq!(
            out.parse().syntax().to_string(),
            "MATCH (n) RETURN n",
            "lossless CST round-trip"
        );
    }

    /// Calling `parse_cst` twice returns the same `Arc` (pointer equality),
    /// proving Salsa returned the cached result.
    #[test]
    fn parse_cst_cached() {
        let mut db = CypherDatabase::new();
        let file = db.new_source_file("RETURN 1");
        let out1 = parse_cst(&db, file);
        let out2 = parse_cst(&db, file);
        // Same Arc pointer → cached, not re-executed.
        assert!(
            Arc::ptr_eq(&out1.0, &out2.0),
            "second call should return cached ParseOutput"
        );
    }

    /// Modifying the source bumps the revision and causes `parse_cst` to
    /// re-execute, producing a new `Arc`.
    #[test]
    fn parse_cst_invalidates_on_source_change() {
        let mut db = CypherDatabase::new();
        let file = db.new_source_file("MATCH (n) RETURN n");

        let out1 = parse_cst(&db, file);
        assert_eq!(out1.parse().syntax().to_string(), "MATCH (n) RETURN n");

        // Mutate the source → revision bump.
        db.set_source(file, "RETURN 42");

        let out2 = parse_cst(&db, file);
        assert_eq!(out2.parse().syntax().to_string(), "RETURN 42");

        // Different Arc pointer → query was re-executed.
        assert!(
            !Arc::ptr_eq(&out1.0, &out2.0),
            "parse_cst should re-execute after source change"
        );
    }

    /// Cloning the DB creates a snapshot that can be sent to another thread.
    /// Both clones share `Arc<Zalsa>` so they see the same revision; each has
    /// its own `ZalsaLocal` allowing concurrent reads without `&mut`.
    #[test]
    fn snapshot_is_send_and_readable() {
        let mut db = CypherDatabase::new();
        let file = db.new_source_file("RETURN 1");

        // Parse once to populate the memo cache.
        let out1 = parse_cst(&db, file);
        assert_eq!(out1.parse().syntax().to_string(), "RETURN 1");

        // Clone = snapshot that can be sent to another thread.
        let snapshot = db.clone();

        // The snapshot can run queries (no &mut needed).
        let out_snap = parse_cst(&snapshot, file);
        assert_eq!(
            out_snap.parse().syntax().to_string(),
            "RETURN 1",
            "snapshot sees the same state"
        );

        // The snapshot can be sent across thread boundaries.
        let out_thread =
            std::thread::spawn(move || parse_cst(&snapshot, file).parse().syntax().to_string())
                .join()
                .expect("thread panicked");
        assert_eq!(out_thread, "RETURN 1");
    }

    /// `CypherDatabase` is `Send`; `ParseOutput` is `Send + Sync`.
    #[test]
    fn send_sync_properties() {
        fn require_send<T: Send>(_: T) {}
        fn require_send_sync<T: Send + Sync>(_: T) {}

        let db = CypherDatabase::new();
        require_send(db);

        let mut db2 = CypherDatabase::new();
        let file = db2.new_source_file("RETURN 1");
        let out = parse_cst(&db2, file);
        require_send_sync(out);
    }

    /// Empty source produces a valid (empty) CST and no parse errors.
    #[test]
    fn empty_source_ok() {
        let mut db = CypherDatabase::new();
        let file = db.new_source_file("");
        let out = parse_cst(&db, file);
        assert_eq!(out.parse().syntax().to_string(), "");
        assert!(out.parse().errors().is_empty());
    }

    // --- Legacy LegacyDatabase tests (backward compat) ---

    #[test]
    fn legacy_parse_through_db() {
        let db = LegacyDatabase::new();
        let f = db.allocate_file();
        db.set_source(f, "MATCH (n) RETURN n");
        let p = db.parse(f);
        assert_eq!(p.syntax().to_string(), "MATCH (n) RETURN n");
    }

    #[test]
    fn legacy_empty_source_is_ok() {
        let db = LegacyDatabase::new();
        let f = db.allocate_file();
        assert_eq!(db.parse(f).syntax().to_string(), "");
        assert!(db.diagnostics(f).is_empty());
    }
}