use std::path::{Path, PathBuf};
use std::sync::Arc;
use indexmap::IndexMap;
use cyrs_schema::SchemaProvider;
use cyrs_syntax::{TextEdit, incremental_reparse};
use crate::inputs::{AnalysisOptions, FileOptions, WorkspaceInputs};
use crate::options::DatabaseOptions;
use crate::queries::{
AstOutput, DiagnosticsOutput, PlanOutput, ResolvedNamesOutput, all_diagnostics, analyse_file,
parse_ast, plan_of, resolved_names, sema_diagnostics,
};
use crate::{Analysis, CypherDatabase, DialectMode, ParseOutput, SourceFile, parse_cst};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FileId(pub u32);
impl std::fmt::Display for FileId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FileId({})", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UnknownFileId(pub FileId);
impl std::fmt::Display for UnknownFileId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "unknown FileId: {}", self.0)
}
}
impl std::error::Error for UnknownFileId {}
struct FileRecord {
source_file: SourceFile,
file_opts: FileOptions,
path: PathBuf,
}
struct FreeSlot {
source_file: SourceFile,
file_opts: FileOptions,
}
pub struct DatabaseSnapshot {
inner: CypherDatabase,
files: Arc<IndexMap<FileId, SourceFile>>,
workspace: Option<WorkspaceInputs>,
file_opts: Arc<IndexMap<FileId, FileOptions>>,
}
impl std::fmt::Debug for DatabaseSnapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DatabaseSnapshot")
.field("num_files", &self.files.len())
.finish_non_exhaustive()
}
}
const _: fn() = || {
fn check_send<T: Send>() {}
check_send::<DatabaseSnapshot>();
};
impl DatabaseSnapshot {
pub fn parse_cst(&self, id: FileId) -> Result<ParseOutput, UnknownFileId> {
let sf = self.files.get(&id).copied().ok_or(UnknownFileId(id))?;
Ok(parse_cst(&self.inner, sf))
}
pub fn parse_ast(&self, id: FileId) -> Result<AstOutput, UnknownFileId> {
let sf = self.files.get(&id).copied().ok_or(UnknownFileId(id))?;
Ok(parse_ast(&self.inner, sf))
}
pub fn plan_of(&self, id: FileId) -> Result<PlanOutput, UnknownFileId> {
let sf = self.files.get(&id).copied().ok_or(UnknownFileId(id))?;
Ok(plan_of(&self.inner, sf))
}
pub fn sema_diagnostics(&self, id: FileId) -> Result<DiagnosticsOutput, UnknownFileId> {
let sf = self.files.get(&id).copied().ok_or(UnknownFileId(id))?;
let fo = self.file_opts.get(&id).copied().ok_or(UnknownFileId(id))?;
let ws = self.workspace.ok_or(UnknownFileId(id))?;
Ok(sema_diagnostics(&self.inner, sf, fo, ws))
}
pub fn resolved_names(&self, id: FileId) -> Result<ResolvedNamesOutput, UnknownFileId> {
let sf = self.files.get(&id).copied().ok_or(UnknownFileId(id))?;
let fo = self.file_opts.get(&id).copied().ok_or(UnknownFileId(id))?;
Ok(resolved_names(&self.inner, sf, fo))
}
pub fn all_diagnostics(&self, id: FileId) -> Result<DiagnosticsOutput, UnknownFileId> {
let sf = self.files.get(&id).copied().ok_or(UnknownFileId(id))?;
let fo = self.file_opts.get(&id).copied().ok_or(UnknownFileId(id))?;
let ws = self.workspace.ok_or(UnknownFileId(id))?;
Ok(all_diagnostics(&self.inner, sf, fo, ws))
}
pub fn analyse_file(&self, id: FileId) -> Result<Analysis, UnknownFileId> {
let sf = self.files.get(&id).copied().ok_or(UnknownFileId(id))?;
let fo = self.file_opts.get(&id).copied().ok_or(UnknownFileId(id))?;
let ws = self.workspace.ok_or(UnknownFileId(id))?;
Ok(analyse_file(&self.inner, sf, fo, ws))
}
}
pub struct Database {
inner: CypherDatabase,
files: IndexMap<FileId, FileRecord>,
free_slots: Vec<FreeSlot>,
workspace: Option<WorkspaceInputs>,
next_id: u32,
#[allow(dead_code)]
options: DatabaseOptions,
}
impl std::fmt::Debug for Database {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Database")
.field("num_files", &self.files.len())
.finish_non_exhaustive()
}
}
impl Default for Database {
fn default() -> Self {
Self::new()
}
}
impl Database {
#[must_use]
pub fn new() -> Self {
Self::with_options(DatabaseOptions::default())
}
#[must_use]
pub fn with_options(opts: DatabaseOptions) -> Self {
let mut inner = CypherDatabase::new();
crate::set_parse_cst_lru(&mut inner, opts.parse_lru);
crate::queries::set_resolved_names_lru(&mut inner, opts.sema_lru);
crate::queries::set_sema_diagnostics_lru(&mut inner, opts.sema_lru);
crate::queries::set_plan_of_lru(&mut inner, opts.plan_lru);
let workspace = Some(inner.new_workspace_inputs(None));
Self {
inner,
files: IndexMap::new(),
free_slots: Vec::new(),
workspace,
next_id: 0,
options: opts,
}
}
pub fn open_file(&mut self, path: &Path, source: String, dialect: DialectMode) -> FileId {
let id = FileId(self.next_id);
self.next_id += 1;
let options = AnalysisOptions {
dialect,
..Default::default()
};
let (source_file, file_opts) = if let Some(slot) = self.free_slots.pop() {
self.inner.set_source(slot.source_file, source);
self.inner.set_dialect(slot.source_file, dialect);
self.inner.set_options(slot.file_opts, options);
(slot.source_file, slot.file_opts)
} else {
let source_file = self.inner.new_source_file_with(source, dialect, 0);
let file_opts = self.inner.new_file_options(options);
(source_file, file_opts)
};
self.files.insert(
id,
FileRecord {
source_file,
file_opts,
path: path.to_owned(),
},
);
id
}
pub fn update_file(&mut self, id: FileId, new_source: String) -> Result<(), UnknownFileId> {
let record = self.files.get(&id).ok_or(UnknownFileId(id))?;
let sf = record.source_file;
self.inner.set_source(sf, new_source);
Ok(())
}
pub fn edit_file(&mut self, id: FileId, edit: &TextEdit) -> Result<(), UnknownFileId> {
let record = self.files.get(&id).ok_or(UnknownFileId(id))?;
let sf = record.source_file;
let parse_out = parse_cst(&self.inner, sf);
let old_tree = parse_out.parse().syntax();
let new_parse = incremental_reparse(&old_tree, edit);
let new_source = new_parse.syntax().to_string();
let new_parse_out = crate::ParseOutput::new(new_parse);
self.inner
.set_source_with_parse(sf, new_source, new_parse_out);
Ok(())
}
pub fn remove_file(&mut self, id: FileId) -> Result<(), UnknownFileId> {
let record = self.files.swap_remove(&id).ok_or(UnknownFileId(id))?;
self.inner.set_source(record.source_file, String::new());
self.free_slots.push(FreeSlot {
source_file: record.source_file,
file_opts: record.file_opts,
});
Ok(())
}
pub fn set_schema(&mut self, schema: Option<Arc<dyn SchemaProvider>>) {
let ws = self.workspace_inputs_mut();
self.inner.set_schema(ws, schema);
}
#[must_use]
pub fn schema(&self) -> Option<Arc<dyn SchemaProvider>> {
self.workspace.as_ref()?.schema(&self.inner)
}
#[must_use]
pub fn snapshot(&self) -> DatabaseSnapshot {
let files: IndexMap<FileId, SourceFile> = self
.files
.iter()
.map(|(&id, rec)| (id, rec.source_file))
.collect();
let file_opts: IndexMap<FileId, FileOptions> = self
.files
.iter()
.map(|(&id, rec)| (id, rec.file_opts))
.collect();
DatabaseSnapshot {
inner: self.inner.clone(),
files: Arc::new(files),
workspace: self.workspace,
file_opts: Arc::new(file_opts),
}
}
pub fn parse_cst(&self, id: FileId) -> Result<ParseOutput, UnknownFileId> {
let sf = self.source_file(id)?;
Ok(parse_cst(&self.inner, sf))
}
pub fn parse_ast(&self, id: FileId) -> Result<AstOutput, UnknownFileId> {
let sf = self.source_file(id)?;
Ok(parse_ast(&self.inner, sf))
}
pub fn plan_of(&self, id: FileId) -> Result<PlanOutput, UnknownFileId> {
let sf = self.source_file(id)?;
Ok(plan_of(&self.inner, sf))
}
pub fn sema_diagnostics(&self, id: FileId) -> Result<DiagnosticsOutput, UnknownFileId> {
let sf = self.source_file(id)?;
let fo = self.file_options(id)?;
let ws = self.workspace_inputs()?;
Ok(sema_diagnostics(&self.inner, sf, fo, ws))
}
pub fn resolved_names(&self, id: FileId) -> Result<ResolvedNamesOutput, UnknownFileId> {
let sf = self.source_file(id)?;
let fo = self.file_options(id)?;
Ok(resolved_names(&self.inner, sf, fo))
}
pub fn all_diagnostics(&self, id: FileId) -> Result<DiagnosticsOutput, UnknownFileId> {
let sf = self.source_file(id)?;
let fo = self.file_options(id)?;
let ws = self.workspace_inputs()?;
Ok(all_diagnostics(&self.inner, sf, fo, ws))
}
pub fn analyse_file(&self, id: FileId) -> Result<Analysis, UnknownFileId> {
let sf = self.source_file(id)?;
let fo = self.file_options(id)?;
let ws = self.workspace_inputs()?;
Ok(analyse_file(&self.inner, sf, fo, ws))
}
pub fn source_of(&self, id: FileId) -> Result<String, UnknownFileId> {
let sf = self.source_file(id)?;
Ok(sf.source(&self.inner).clone())
}
pub fn path_of(&self, id: FileId) -> Result<&Path, UnknownFileId> {
self.files
.get(&id)
.map(|r| r.path.as_path())
.ok_or(UnknownFileId(id))
}
#[must_use]
pub fn is_open(&self, id: FileId) -> bool {
self.files.contains_key(&id)
}
pub fn file_ids(&self) -> impl Iterator<Item = FileId> + '_ {
self.files.keys().copied()
}
fn source_file(&self, id: FileId) -> Result<SourceFile, UnknownFileId> {
self.files
.get(&id)
.map(|r| r.source_file)
.ok_or(UnknownFileId(id))
}
fn file_options(&self, id: FileId) -> Result<FileOptions, UnknownFileId> {
self.files
.get(&id)
.map(|r| r.file_opts)
.ok_or(UnknownFileId(id))
}
fn workspace_inputs(&self) -> Result<WorkspaceInputs, UnknownFileId> {
self.workspace
.ok_or_else(|| unreachable!("WorkspaceInputs always initialised in Database::new"))
}
fn workspace_inputs_mut(&mut self) -> WorkspaceInputs {
self.workspace
.expect("WorkspaceInputs always initialised in Database::new")
}
}
const _: fn() = || {
fn check_send<T: Send>() {}
check_send::<Database>();
};
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn open_and_query_single_file() {
let mut db = Database::new();
let id = db.open_file(
Path::new("a.cyp"),
"MATCH (n) RETURN n".into(),
DialectMode::GqlAligned,
);
let out = db.parse_cst(id).expect("file must be open");
assert_eq!(out.parse().syntax().to_string(), "MATCH (n) RETURN n");
}
#[test]
fn update_file_invalidates_cache() {
let mut db = Database::new();
let id = db.open_file(
Path::new("a.cyp"),
"RETURN 1".into(),
DialectMode::GqlAligned,
);
let out1 = db.parse_cst(id).unwrap();
assert_eq!(out1.parse().syntax().to_string(), "RETURN 1");
db.update_file(id, "RETURN 2".into()).unwrap();
let out2 = db.parse_cst(id).unwrap();
assert_eq!(out2.parse().syntax().to_string(), "RETURN 2");
}
#[test]
fn edit_file_applies_edit_and_invalidates_cache() {
use cyrs_syntax::{TextEdit, TextRange, TextSize};
let mut db = Database::new();
let id = db.open_file(
Path::new("e.cyp"),
"RETURN 1".into(),
DialectMode::GqlAligned,
);
let before = db.parse_cst(id).unwrap();
assert_eq!(before.parse().syntax().to_string(), "RETURN 1");
let edit = TextEdit::replace(TextRange::new(TextSize::new(7), TextSize::new(8)), "42");
db.edit_file(id, &edit).unwrap();
let after = db.parse_cst(id).unwrap();
assert_eq!(after.parse().syntax().to_string(), "RETURN 42");
assert_eq!(db.source_of(id).unwrap(), "RETURN 42");
assert!(
!Arc::ptr_eq(&before.0, &after.0),
"edit_file must bump the Salsa revision → new Arc"
);
}
#[test]
fn edit_file_publishes_precomputed_parse_to_salsa() {
use cyrs_syntax::{TextEdit, TextRange, TextSize};
let mut db = Database::new();
let id = db.open_file(
Path::new("e.cyp"),
"RETURN 1".into(),
DialectMode::GqlAligned,
);
let _ = db.parse_cst(id).unwrap();
let edit = TextEdit::replace(TextRange::new(TextSize::new(7), TextSize::new(8)), "42");
db.edit_file(id, &edit).unwrap();
let sf = db.source_file(id).unwrap();
let hint = sf
.precomputed_parse(&db.inner)
.as_ref()
.expect("edit_file must publish a precomputed Parse to the SourceFile input")
.clone();
let after = db.parse_cst(id).unwrap();
assert!(
Arc::ptr_eq(&hint.0, &after.0),
"parse_cst after edit_file must return the precomputed Parse Arc, \
not a freshly re-parsed one"
);
let after2 = db.parse_cst(id).unwrap();
assert!(
Arc::ptr_eq(&after.0, &after2.0),
"parse_cst memo must be stable across subsequent queries"
);
assert_eq!(after.parse().syntax().to_string(), "RETURN 42");
}
#[test]
fn update_file_clears_precomputed_parse_hint() {
use cyrs_syntax::{TextEdit, TextRange, TextSize};
let mut db = Database::new();
let id = db.open_file(
Path::new("e.cyp"),
"RETURN 1".into(),
DialectMode::GqlAligned,
);
let _ = db.parse_cst(id).unwrap();
let edit = TextEdit::replace(TextRange::new(TextSize::new(7), TextSize::new(8)), "42");
db.edit_file(id, &edit).unwrap();
let sf = db.source_file(id).unwrap();
assert!(
sf.precomputed_parse(&db.inner).is_some(),
"edit_file must seed the precomputed_parse hint"
);
db.update_file(id, "RETURN 99".into()).unwrap();
assert!(
sf.precomputed_parse(&db.inner).is_none(),
"update_file must clear the stale precomputed_parse hint"
);
let after = db.parse_cst(id).unwrap();
assert_eq!(after.parse().syntax().to_string(), "RETURN 99");
}
#[test]
fn edit_file_unknown_fileid() {
use cyrs_syntax::{TextEdit, TextSize};
let mut db = Database::new();
let ghost = FileId(999);
let edit = TextEdit::insert(TextSize::new(0), "x");
assert_eq!(db.edit_file(ghost, &edit), Err(UnknownFileId(ghost)));
}
#[test]
fn edit_file_preserves_other_files_cache() {
use cyrs_syntax::{TextEdit, TextRange, TextSize};
let mut db = Database::new();
let a = db.open_file(
Path::new("a.cyp"),
"RETURN 1".into(),
DialectMode::GqlAligned,
);
let b = db.open_file(
Path::new("b.cyp"),
"RETURN 2".into(),
DialectMode::GqlAligned,
);
let oa = db.parse_cst(a).unwrap();
let ob = db.parse_cst(b).unwrap();
let edit = TextEdit::replace(TextRange::new(TextSize::new(7), TextSize::new(8)), "99");
db.edit_file(a, &edit).unwrap();
let ob2 = db.parse_cst(b).unwrap();
assert!(
Arc::ptr_eq(&ob.0, &ob2.0),
"file b cache must survive edit to file a"
);
let oa2 = db.parse_cst(a).unwrap();
assert!(!Arc::ptr_eq(&oa.0, &oa2.0));
assert_eq!(oa2.parse().syntax().to_string(), "RETURN 99");
}
#[test]
fn remove_file_stale_returns_error() {
let mut db = Database::new();
let id = db.open_file(
Path::new("a.cyp"),
"RETURN 1".into(),
DialectMode::GqlAligned,
);
assert!(db.is_open(id));
db.remove_file(id).expect("remove should succeed");
assert!(!db.is_open(id));
assert_eq!(db.parse_cst(id), Err(UnknownFileId(id)));
assert_eq!(
db.update_file(id, "RETURN 2".into()),
Err(UnknownFileId(id))
);
assert_eq!(db.remove_file(id), Err(UnknownFileId(id)));
}
#[test]
fn three_files_independent_caching() {
let mut db = Database::new();
let a = db.open_file(
Path::new("a.cyp"),
"RETURN 1".into(),
DialectMode::GqlAligned,
);
let b = db.open_file(
Path::new("b.cyp"),
"RETURN 2".into(),
DialectMode::GqlAligned,
);
let c = db.open_file(
Path::new("c.cyp"),
"RETURN 3".into(),
DialectMode::GqlAligned,
);
let oa = db.parse_cst(a).unwrap();
let ob = db.parse_cst(b).unwrap();
let oc = db.parse_cst(c).unwrap();
assert_eq!(oa.parse().syntax().to_string(), "RETURN 1");
assert_eq!(ob.parse().syntax().to_string(), "RETURN 2");
assert_eq!(oc.parse().syntax().to_string(), "RETURN 3");
db.update_file(b, "RETURN 99".into()).unwrap();
let oa2 = db.parse_cst(a).unwrap();
let oc2 = db.parse_cst(c).unwrap();
assert!(
Arc::ptr_eq(&oa.0, &oa2.0),
"file a cache must survive update to file b"
);
assert!(
Arc::ptr_eq(&oc.0, &oc2.0),
"file c cache must survive update to file b"
);
let ob2 = db.parse_cst(b).unwrap();
assert_eq!(ob2.parse().syntax().to_string(), "RETURN 99");
assert!(
!Arc::ptr_eq(&ob.0, &ob2.0),
"file b cache must be invalidated"
);
}
#[test]
fn snapshot_send_concurrent_query() {
let mut db = Database::new();
let id = db.open_file(
Path::new("q.cyp"),
"MATCH (n) RETURN n".into(),
DialectMode::GqlAligned,
);
let main_out = db.parse_cst(id).unwrap();
let main_text = main_out.parse().syntax().to_string();
let snap = db.snapshot();
let worker_text = std::thread::spawn(move || {
snap.parse_cst(id)
.expect("snapshot must contain the file")
.parse()
.syntax()
.to_string()
})
.join()
.expect("worker thread panicked");
assert_eq!(
worker_text, main_text,
"snapshot result must match main-thread result"
);
}
#[test]
fn database_snapshot_is_send() {
fn require_send<T: Send>(_: T) {}
let mut db = Database::new();
let _id = db.open_file(
Path::new("a.cyp"),
"RETURN 1".into(),
DialectMode::GqlAligned,
);
let snap = db.snapshot();
require_send(snap);
}
#[test]
fn schema_change_invalidates_sema_not_parse() {
use cyrs_schema::EmptySchema;
let mut db = Database::new();
let id = db.open_file(
Path::new("s.cyp"),
"MATCH (n:Person) RETURN n".into(),
DialectMode::GqlAligned,
);
let cst1 = db.parse_cst(id).unwrap();
let _sema1 = db.sema_diagnostics(id).unwrap();
let schema: Arc<dyn SchemaProvider> = Arc::new(EmptySchema);
db.set_schema(Some(schema));
let cst2 = db.parse_cst(id).unwrap();
assert!(
Arc::ptr_eq(&cst1.0, &cst2.0),
"parse_cst Arc must survive schema change"
);
let _sema2 = db.sema_diagnostics(id).unwrap();
}
#[test]
fn source_and_path_accessors() {
let mut db = Database::new();
let p = Path::new("myfile.cyp");
let id = db.open_file(p, "RETURN 42".into(), DialectMode::GqlAligned);
assert_eq!(db.source_of(id).unwrap(), "RETURN 42");
assert_eq!(db.path_of(id).unwrap(), p);
}
#[test]
fn with_options_parse_lru_2() {
use crate::DatabaseOptions;
let db = Database::with_options(DatabaseOptions {
parse_lru: 2,
sema_lru: 2,
plan_lru: 2,
formatted_lru: 2,
});
assert_eq!(db.files.len(), 0);
}
#[test]
fn with_options_lru_2_three_files() {
use crate::DatabaseOptions;
let mut db = Database::with_options(DatabaseOptions {
parse_lru: 2,
sema_lru: 2,
plan_lru: 2,
..DatabaseOptions::default()
});
let a = db.open_file(
Path::new("a.cyp"),
"RETURN 1".into(),
DialectMode::GqlAligned,
);
let b = db.open_file(
Path::new("b.cyp"),
"RETURN 2".into(),
DialectMode::GqlAligned,
);
let c = db.open_file(
Path::new("c.cyp"),
"RETURN 3".into(),
DialectMode::GqlAligned,
);
assert_eq!(
db.parse_cst(a).unwrap().parse().syntax().to_string(),
"RETURN 1"
);
assert_eq!(
db.parse_cst(b).unwrap().parse().syntax().to_string(),
"RETURN 2"
);
assert_eq!(
db.parse_cst(c).unwrap().parse().syntax().to_string(),
"RETURN 3"
);
assert_eq!(
db.parse_cst(a).unwrap().parse().syntax().to_string(),
"RETURN 1"
);
assert_eq!(
db.parse_cst(b).unwrap().parse().syntax().to_string(),
"RETURN 2"
);
assert_eq!(
db.parse_cst(c).unwrap().parse().syntax().to_string(),
"RETURN 3"
);
}
#[test]
fn database_new_uses_default_options() {
use crate::DatabaseOptions;
let default_opts = DatabaseOptions::default();
assert_eq!(default_opts.parse_lru, 256);
assert_eq!(default_opts.sema_lru, 256);
assert_eq!(default_opts.plan_lru, 256);
assert_eq!(default_opts.formatted_lru, 256);
let db = Database::new();
assert_eq!(db.files.len(), 0);
}
#[test]
fn unknown_fileid_source_of() {
let db = Database::new();
let ghost = FileId(999);
assert_eq!(db.source_of(ghost), Err(UnknownFileId(ghost)));
assert_eq!(db.path_of(ghost), Err(UnknownFileId(ghost)));
}
}