use harn_parser::analysis::{
AnalysisDatabase, AnalysisError, SourceId, SourceVersion, TypeCheckConfig,
};
use harn_parser::SNode;
use tower_lsp::lsp_types::*;
use crate::helpers::{
diagnostic_data_value, lexer_error_to_diagnostic, parser_error_to_diagnostic, span_to_range,
};
use crate::symbols::{build_symbol_table, SymbolInfo};
pub(crate) struct DocumentState {
pub(crate) source: String,
analysis: AnalysisDatabase,
source_id: SourceId,
version: SourceVersion,
pub(crate) cached_ast: Option<Vec<SNode>>,
pub(crate) symbols: Vec<SymbolInfo>,
pub(crate) diagnostics: Vec<Diagnostic>,
pub(crate) lint_diagnostics: Vec<harn_lint::LintDiagnostic>,
pub(crate) type_diagnostics: Vec<harn_parser::TypeDiagnostic>,
pub(crate) invariant_diagnostics: Vec<harn_ir::InvariantDiagnostic>,
pub(crate) inlay_hints: Vec<harn_parser::InlayHintInfo>,
pub(crate) dirty: bool,
}
impl DocumentState {
pub(crate) fn new(source: String) -> Self {
let mut analysis = AnalysisDatabase::new();
let source_id = SourceId::new("document");
analysis.set_source(source_id.clone(), source.clone(), SourceVersion(1));
let mut state = Self {
source,
analysis,
source_id,
version: SourceVersion(1),
cached_ast: None,
symbols: Vec::new(),
diagnostics: Vec::new(),
lint_diagnostics: Vec::new(),
type_diagnostics: Vec::new(),
invariant_diagnostics: Vec::new(),
inlay_hints: Vec::new(),
dirty: true,
};
state.reparse_if_dirty();
state
}
pub(crate) fn update_source(&mut self, source: String) {
self.source = source;
self.version = SourceVersion(self.version.0 + 1);
self.analysis
.set_source(self.source_id.clone(), self.source.clone(), self.version);
self.dirty = true;
}
pub(crate) fn reparse_if_dirty(&mut self) {
if !self.dirty {
return;
}
self.diagnostics.clear();
self.lint_diagnostics.clear();
self.type_diagnostics.clear();
self.invariant_diagnostics.clear();
self.inlay_hints.clear();
self.symbols.clear();
self.cached_ast = None;
let analysis = match self
.analysis
.typecheck(&self.source_id, TypeCheckConfig::new())
{
Ok(analysis) => analysis,
Err(error) => {
match error {
AnalysisError::Lex { error, .. } => {
self.diagnostics.push(lexer_error_to_diagnostic(&error));
}
AnalysisError::Parse { errors, .. } => {
for error in &errors {
self.diagnostics.push(parser_error_to_diagnostic(error));
}
}
AnalysisError::MissingSource(_) => {}
}
self.dirty = false;
return;
}
};
let program = analysis.program;
let type_diags = analysis.diagnostics;
self.inlay_hints = analysis.inlay_hints;
for diag in &type_diags {
let severity = match diag.severity {
harn_parser::DiagnosticSeverity::Error => DiagnosticSeverity::ERROR,
harn_parser::DiagnosticSeverity::Warning => DiagnosticSeverity::WARNING,
};
let range = if let Some(span) = &diag.span {
span_to_range(span)
} else {
Range {
start: Position::new(0, 0),
end: Position::new(0, 1),
}
};
self.diagnostics.push(Diagnostic {
range,
severity: Some(severity),
source: Some("harn-typecheck".to_string()),
code: Some(NumberOrString::String(diag.code.to_string())),
message: diag.message.clone(),
data: Some(diagnostic_data_value(
diag.code.to_string(),
diag.repair.as_ref(),
)),
..Default::default()
});
}
self.type_diagnostics = type_diags;
let invariant_report = harn_ir::analyze_program(&program);
for diag in &invariant_report.diagnostics {
let range = span_to_range(&diag.span);
self.diagnostics.push(Diagnostic {
range,
severity: Some(DiagnosticSeverity::ERROR),
source: Some("harn-invariant".to_string()),
message: format!("[{}] {}", diag.invariant, diag.message),
..Default::default()
});
}
self.invariant_diagnostics = invariant_report.diagnostics;
let lint_diags = harn_lint::lint_with_source(&program, &self.source);
for ld in &lint_diags {
let severity = match ld.severity {
harn_lint::LintSeverity::Info => DiagnosticSeverity::INFORMATION,
harn_lint::LintSeverity::Warning => DiagnosticSeverity::WARNING,
harn_lint::LintSeverity::Error => DiagnosticSeverity::ERROR,
};
let range = span_to_range(&ld.span);
let lint_repair = ld.repair();
self.diagnostics.push(Diagnostic {
range,
severity: Some(severity),
source: Some("harn-lint".to_string()),
code: Some(NumberOrString::String(ld.code.to_string())),
message: format!("[{}] {}", ld.rule, ld.message),
data: Some(diagnostic_data_value(
ld.code.to_string(),
lint_repair.as_ref(),
)),
..Default::default()
});
}
self.lint_diagnostics = lint_diags;
self.symbols = build_symbol_table(&program, &self.source);
self.cached_ast = Some(program);
self.dirty = false;
}
}
#[cfg(test)]
mod tests {
use super::DocumentState;
#[test]
fn update_source_marks_document_dirty_until_reparse() {
let mut state = DocumentState::new("pipeline default(task) { log(1) }\n".to_string());
assert!(!state.dirty, "fresh parse should clear dirty flag");
assert!(
state.cached_ast.is_some(),
"fresh parse should cache the AST"
);
state.update_source("pipeline default(task) { let = }\n".to_string());
assert!(state.dirty, "source update should mark the document dirty");
assert!(
state.cached_ast.is_some(),
"cached AST should remain available until debounce reparses"
);
state.reparse_if_dirty();
assert!(!state.dirty, "reparse should clear dirty flag");
assert!(
!state.diagnostics.is_empty(),
"invalid source should produce diagnostics after reparse"
);
}
#[test]
fn unchanged_document_reuses_analysis_cache() {
let mut state = DocumentState::new("pipeline default(task) { log(1) }\n".to_string());
let initial = state.analysis.stats();
state.update_source("pipeline default(task) { log(1) }\n".to_string());
state.reparse_if_dirty();
let after = state.analysis.stats();
assert_eq!(after.lex_runs, initial.lex_runs);
assert_eq!(after.parse_runs, initial.parse_runs);
assert_eq!(after.typecheck_runs, initial.typecheck_runs);
}
#[test]
fn invariant_violations_surface_as_lsp_diagnostics() {
let state = DocumentState::new(
r#"
@invariant("approval.reachability")
fn handler() {
write_file("src/main.rs", "unsafe")
}
"#
.to_string(),
);
assert!(
state
.diagnostics
.iter()
.any(|diag| diag.source.as_deref() == Some("harn-invariant")),
"expected invariant diagnostics, got {:?}",
state
.diagnostics
.iter()
.map(|diag| (&diag.source, &diag.message))
.collect::<Vec<_>>()
);
}
#[test]
fn typecheck_diagnostics_carry_repair_data_envelope() {
let state = DocumentState::new("pipeline main() {\n let x = 1\n x = 2\n}\n".to_string());
let diag = state
.diagnostics
.iter()
.find(|d| {
matches!(
d.code.as_ref(),
Some(tower_lsp::lsp_types::NumberOrString::String(code)) if code == "HARN-OWN-001"
)
})
.expect("expected ImmutableAssignment diagnostic");
let data = diag.data.as_ref().expect("repair data should be attached");
assert_eq!(
data.get("code").and_then(|v| v.as_str()),
Some("HARN-OWN-001")
);
assert_eq!(
data.get("repair_id").and_then(|v| v.as_str()),
Some("bindings/make-mutable")
);
let repair = data.get("repair").expect("data.repair should be present");
assert_eq!(
repair.get("id").and_then(|v| v.as_str()),
Some("bindings/make-mutable")
);
assert_eq!(
repair.get("safety").and_then(|v| v.as_str()),
Some("scope-local")
);
}
}