use std::sync::Arc;
use cyrs_diag::{DiagCode, Diagnostic, DiagnosticsSink};
use cyrs_hir::desugar::desugar_statement;
use cyrs_hir::lower::lower_statement as hir_lower;
use cyrs_plan::lower::{PlanStatement, lower_statement as plan_lower};
use cyrs_sema::SemaOptions;
use cyrs_sema::resolve::ResolveResult;
use cyrs_syntax::TextRange;
use smol_str::SmolStr;
use crate::inputs::{FileOptions, WorkspaceInputs};
use crate::{CypherDb, DialectMode, ParseOutput, SourceFile, parse_cst};
#[derive(Debug, Clone)]
pub struct AstOutput(Arc<ParseOutput>);
impl AstOutput {
fn new(p: ParseOutput) -> Self {
Self(Arc::new(p))
}
#[must_use]
pub fn parse_output(&self) -> &ParseOutput {
&self.0
}
}
impl PartialEq for AstOutput {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
impl Eq for AstOutput {}
const _: fn() = || {
fn check_send_sync<T: Send + Sync>() {}
check_send_sync::<AstOutput>();
};
#[derive(Debug, Clone)]
pub struct ResolvedNamesOutput(Arc<ResolveResult>);
impl ResolvedNamesOutput {
fn new(r: ResolveResult) -> Self {
Self(Arc::new(r))
}
#[must_use]
pub fn result(&self) -> &ResolveResult {
&self.0
}
}
impl PartialEq for ResolvedNamesOutput {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
impl Eq for ResolvedNamesOutput {}
const _: fn() = || {
fn check_send_sync<T: Send + Sync>() {}
check_send_sync::<ResolvedNamesOutput>();
};
#[derive(Debug, Clone)]
pub struct PlanOutput(Arc<PlanStatement>);
impl PlanOutput {
fn new(p: PlanStatement) -> Self {
Self(Arc::new(p))
}
#[must_use]
pub fn plan(&self) -> &PlanStatement {
&self.0
}
}
impl PartialEq for PlanOutput {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
impl Eq for PlanOutput {}
const _: fn() = || {
fn check_send_sync<T: Send + Sync>() {}
check_send_sync::<PlanOutput>();
};
#[derive(Debug, Clone)]
pub struct DiagnosticsOutput(Arc<Vec<Diagnostic>>);
impl DiagnosticsOutput {
fn new(v: Vec<Diagnostic>) -> Self {
Self(Arc::new(v))
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.0
}
#[must_use]
pub fn arc_clone(&self) -> Arc<Vec<Diagnostic>> {
self.0.clone()
}
}
impl PartialEq for DiagnosticsOutput {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
impl Eq for DiagnosticsOutput {}
const _: fn() = || {
fn check_send_sync<T: Send + Sync>() {}
check_send_sync::<DiagnosticsOutput>();
};
#[salsa::tracked]
pub fn parse_ast(db: &dyn CypherDb, file: SourceFile) -> AstOutput {
let cst = parse_cst(db, file);
AstOutput::new(cst)
}
#[salsa::tracked(lru = 256)]
pub fn resolved_names(
db: &dyn CypherDb,
file: SourceFile,
file_opts: FileOptions,
) -> ResolvedNamesOutput {
let _cst = parse_cst(db, file);
let src = file.source(db);
let opts = file_opts.options(db);
let stmt = hir_lower(src.as_str());
let stmt = desugar_statement(stmt);
let mut sink = DiagnosticsSink::new();
let result = cyrs_sema::resolve::resolve(&stmt, opts.warn_shadowing, &mut sink);
ResolvedNamesOutput::new(result)
}
#[salsa::tracked(lru = 256)]
pub fn sema_diagnostics(
db: &dyn CypherDb,
file: SourceFile,
file_opts: FileOptions,
ws: WorkspaceInputs,
) -> DiagnosticsOutput {
let _cst = parse_cst(db, file);
let src = file.source(db);
let opts = file_opts.options(db);
let schema = ws.schema(db);
let schema_ref = schema.as_deref();
let stmt = hir_lower(src.as_str());
let stmt = desugar_statement(stmt);
let sema_opts = SemaOptions {
parameter_hints: opts
.parameter_hints
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
warn_shadowing: opts.warn_shadowing,
dialect: match opts.dialect {
DialectMode::GqlAligned => cyrs_sema::DialectMode::GqlAligned,
DialectMode::OpenCypherV9 => cyrs_sema::DialectMode::OpenCypherV9,
},
};
let mut sink = DiagnosticsSink::new();
cyrs_sema::analyse(&stmt, schema_ref, &sema_opts, &mut sink);
DiagnosticsOutput::new(sink.into_sorted())
}
#[salsa::tracked(lru = 256)]
pub fn plan_of(db: &dyn CypherDb, file: SourceFile) -> PlanOutput {
let _cst = parse_cst(db, file);
let src = file.source(db);
let stmt = hir_lower(src.as_str());
let stmt = desugar_statement(stmt);
let plan = plan_lower(&stmt).unwrap_or_else(|_| PlanStatement::empty());
PlanOutput::new(plan)
}
#[salsa::tracked]
pub fn all_diagnostics(
db: &dyn CypherDb,
file: SourceFile,
file_opts: FileOptions,
ws: WorkspaceInputs,
) -> DiagnosticsOutput {
let cst = parse_cst(db, file);
let parse_diags: Vec<Diagnostic> = cst
.parse()
.errors()
.iter()
.map(syntax_error_to_diagnostic)
.collect();
let sema = sema_diagnostics(db, file, file_opts, ws);
let mut combined: Vec<Diagnostic> = parse_diags;
combined.extend_from_slice(sema.diagnostics());
combined.sort_by_key(|d| (d.primary.range.start(), d.code));
combined
.dedup_by(|a, b| a.primary.range.start() == b.primary.range.start() && a.code == b.code);
DiagnosticsOutput::new(combined)
}
#[derive(Debug, Clone)]
pub struct Analysis {
pub plan: PlanOutput,
pub diagnostics: DiagnosticsOutput,
}
pub fn analyse_file(
db: &dyn CypherDb,
file: SourceFile,
file_opts: FileOptions,
ws: WorkspaceInputs,
) -> Analysis {
let plan = plan_of(db, file);
let diagnostics = all_diagnostics(db, file, file_opts, ws);
Analysis { plan, diagnostics }
}
fn syntax_error_to_diagnostic(e: &cyrs_syntax::SyntaxError) -> Diagnostic {
let code = DiagCode::from(e);
let range = TextRange::new(e.offset, e.offset);
Diagnostic::error(code, range, SmolStr::new(&e.message))
}
pub fn set_resolved_names_lru(db: &mut impl crate::CypherDb, cap: usize) {
resolved_names::set_lru_capacity(db, cap);
}
pub fn set_sema_diagnostics_lru(db: &mut impl crate::CypherDb, cap: usize) {
sema_diagnostics::set_lru_capacity(db, cap);
}
pub fn set_plan_of_lru(db: &mut impl crate::CypherDb, cap: usize) {
plan_of::set_lru_capacity(db, cap);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CypherDatabase;
use crate::inputs::AnalysisOptions;
use std::sync::Arc;
fn setup(src: &str) -> (CypherDatabase, SourceFile, FileOptions, WorkspaceInputs) {
let mut db = CypherDatabase::new();
let file = db.new_source_file(src);
let file_opts = db.new_file_options(AnalysisOptions::default());
let ws = db.new_workspace_inputs(None);
(db, file, file_opts, ws)
}
#[test]
fn parse_ast_cached() {
let (db, file, _, _) = setup("RETURN 1");
let a1 = parse_ast(&db, file);
let a2 = parse_ast(&db, file);
assert!(
Arc::ptr_eq(&a1.0, &a2.0),
"parse_ast should return a cached Arc on second call"
);
}
#[test]
fn plan_of_cached() {
let (db, file, _, _) = setup("MATCH (n) RETURN n");
let p1 = plan_of(&db, file);
let p2 = plan_of(&db, file);
assert!(
Arc::ptr_eq(&p1.0, &p2.0),
"plan_of should return a cached Arc on second call"
);
}
#[test]
fn sema_diagnostics_cached() {
let (db, file, file_opts, ws) = setup("RETURN 1");
let d1 = sema_diagnostics(&db, file, file_opts, ws);
let d2 = sema_diagnostics(&db, file, file_opts, ws);
assert!(
Arc::ptr_eq(&d1.0, &d2.0),
"sema_diagnostics should return a cached Arc on second call"
);
}
#[test]
fn all_diagnostics_cached() {
let (db, file, file_opts, ws) = setup("RETURN 1");
let d1 = all_diagnostics(&db, file, file_opts, ws);
let d2 = all_diagnostics(&db, file, file_opts, ws);
assert!(
Arc::ptr_eq(&d1.0, &d2.0),
"all_diagnostics should return a cached Arc on second call"
);
}
#[test]
fn source_change_invalidates_pipeline() {
let (mut db, file, _, _) = setup("MATCH (n) RETURN n");
let p1 = plan_of(&db, file);
let d1 = parse_ast(&db, file);
db.set_source(file, "RETURN 42");
let p2 = plan_of(&db, file);
let d2 = parse_ast(&db, file);
assert!(
!Arc::ptr_eq(&p1.0, &p2.0),
"plan_of should re-execute after source change"
);
assert!(
!Arc::ptr_eq(&d1.0, &d2.0),
"parse_ast should re-execute after source change"
);
}
#[test]
fn options_change_reruns_sema_only() {
let (mut db, file, file_opts, ws) = setup("MATCH (n) RETURN n");
let cst1 = parse_cst(&db, file);
let _ = sema_diagnostics(&db, file, file_opts, ws);
db.set_options(
file_opts,
AnalysisOptions {
warn_shadowing: true,
..Default::default()
},
);
let cst2 = parse_cst(&db, file);
assert!(
Arc::ptr_eq(&cst1.0, &cst2.0),
"parse_cst Arc should be unchanged after options-only change"
);
let _d2 = sema_diagnostics(&db, file, file_opts, ws);
}
#[test]
fn schema_change_reruns_sema_only() {
use cyrs_schema::EmptySchema;
let (mut db, file, file_opts, ws) = setup("MATCH (n:Person) RETURN n");
let cst1 = parse_cst(&db, file);
let schema: Arc<dyn cyrs_schema::SchemaProvider> = Arc::new(EmptySchema);
db.set_schema(ws, Some(schema));
let cst2 = parse_cst(&db, file);
assert!(
Arc::ptr_eq(&cst1.0, &cst2.0),
"parse_cst Arc should be unchanged after schema change"
);
let _d = sema_diagnostics(&db, file, file_opts, ws);
}
#[test]
fn analyse_file_returns_plan_and_diags() {
let (db, file, file_opts, ws) = setup("MATCH (n) RETURN n");
let analysis = analyse_file(&db, file, file_opts, ws);
assert!(
!analysis.plan.plan().ops.is_empty(),
"expected at least one plan op"
);
}
#[test]
fn analyse_file_empty_source() {
let (db, file, file_opts, ws) = setup("");
let analysis = analyse_file(&db, file, file_opts, ws);
let _ = analysis.diagnostics.diagnostics();
}
#[test]
fn all_diagnostics_are_sorted() {
let (db, file, file_opts, ws) = setup("MATCH (n) RETURN n");
let diags = all_diagnostics(&db, file, file_opts, ws);
let d = diags.diagnostics();
for w in d.windows(2) {
let a_key = (w[0].primary.range.start(), w[0].code);
let b_key = (w[1].primary.range.start(), w[1].code);
assert!(
a_key <= b_key,
"diagnostics must be sorted: {a_key:?} > {b_key:?}"
);
}
}
#[test]
fn parse_ast_roundtrips_source() {
let src = "MATCH (n:Person) RETURN n.name";
let (db, file, _, _) = setup(src);
let ast = parse_ast(&db, file);
assert_eq!(
ast.parse_output().parse().syntax().to_string(),
src,
"AST output must round-trip the source"
);
}
#[test]
fn resolved_names_basic() {
let (db, file, file_opts, _) = setup("MATCH (n) RETURN n");
let rn = resolved_names(&db, file, file_opts);
let result = rn.result();
let _ = &result.scope_graph;
let _ = &result.resolved_names;
}
}