harn-lsp 0.9.0

Language Server Protocol implementation for Harn
Documentation
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::rules::{RuleDiagnostic, RuleWorkspace};
use crate::symbols::{build_symbol_table, SymbolInfo};

pub(crate) struct DocumentState {
    pub(crate) source: String,
    pub(crate) language_id: 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) rule_diagnostics: Vec<RuleDiagnostic>,
    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 state = Self::new_unparsed(source, "harn");
        state.reparse_if_dirty();
        state
    }

    pub(crate) fn new_for_language_with_rules(
        source: String,
        language_id: impl Into<String>,
        uri: &Url,
        rule_workspace: &RuleWorkspace,
    ) -> Self {
        let mut state = Self::new_unparsed(source, language_id);
        state.reparse_if_dirty_with_rules(Some(uri), Some(rule_workspace));
        state
    }

    fn new_unparsed(source: String, language_id: impl Into<String>) -> Self {
        let language_id = language_id.into();
        let mut analysis = AnalysisDatabase::new();
        let source_id = SourceId::new("document");
        analysis.set_source(source_id.clone(), source.clone(), SourceVersion(1));
        Self {
            source,
            language_id,
            analysis,
            source_id,
            version: SourceVersion(1),
            cached_ast: None,
            symbols: Vec::new(),
            diagnostics: Vec::new(),
            lint_diagnostics: Vec::new(),
            type_diagnostics: Vec::new(),
            rule_diagnostics: Vec::new(),
            invariant_diagnostics: Vec::new(),
            inlay_hints: Vec::new(),
            dirty: true,
        }
    }

    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) {
        self.reparse_if_dirty_with_rules(None, None);
    }

    pub(crate) fn reparse_if_dirty_with_rules(
        &mut self,
        uri: Option<&Url>,
        rule_workspace: Option<&RuleWorkspace>,
    ) {
        if !self.dirty {
            return;
        }

        self.diagnostics.clear();
        self.lint_diagnostics.clear();
        self.type_diagnostics.clear();
        self.rule_diagnostics.clear();
        self.invariant_diagnostics.clear();
        self.inlay_hints.clear();
        self.symbols.clear();
        self.cached_ast = None;

        if self.language_id != "harn" {
            self.append_rule_diagnostics(uri, rule_workspace);
            self.dirty = false;
            return;
        }

        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.append_rule_diagnostics(uri, rule_workspace);
                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.append_rule_diagnostics(uri, rule_workspace);
        self.dirty = false;
    }

    fn append_rule_diagnostics(
        &mut self,
        uri: Option<&Url>,
        rule_workspace: Option<&RuleWorkspace>,
    ) {
        let (Some(uri), Some(rule_workspace)) = (uri, rule_workspace) else {
            return;
        };
        self.rule_diagnostics =
            rule_workspace.diagnostics_for_document(uri, &self.language_id, &self.source);
        self.diagnostics.extend(
            self.rule_diagnostics
                .iter()
                .map(|item| item.diagnostic.clone()),
        );
    }
}

#[cfg(test)]
mod tests {
    use super::DocumentState;
    use crate::rules::RuleWorkspace;
    use std::path::Path;
    use tower_lsp::lsp_types::Url;

    fn write(path: &Path, content: &str) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, content).unwrap();
    }

    #[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 non_harn_documents_run_rules_without_harn_parse_diagnostics() {
        let temp = tempfile::tempdir().unwrap();
        write(
            &temp.path().join("harn.toml"),
            "[rules]\nruleDirs = [\"rules\"]\n",
        );
        write(
            &temp.path().join("rules/no-debugger.toml"),
            r#"
id = "no-debugger"
language = "typescript"
message = "remove debugger statements"
severity = "warning"
safety = "behavior-preserving"
fix = ""

[rule]
regex = "debugger;"
"#,
        );

        let workspace = RuleWorkspace::from_root(temp.path());
        let uri = Url::from_file_path(temp.path().join("src/main.ts")).unwrap();
        let state = DocumentState::new_for_language_with_rules(
            "function f() { debugger; }\n".to_string(),
            "typescript",
            &uri,
            &workspace,
        );

        assert!(
            state.cached_ast.is_none(),
            "TypeScript should not parse as Harn"
        );
        assert_eq!(state.rule_diagnostics.len(), 1);
        assert_eq!(state.diagnostics.len(), 1);
        assert_eq!(state.diagnostics[0].source.as_deref(), Some("harn-rules"));
    }

    #[test]
    fn typecheck_diagnostics_carry_repair_data_envelope() {
        // A `let x = 1; x = 2` reassigns an immutable binding —
        // HARN-OWN-001 with a `bindings/make-mutable` repair. The
        // LSP-side code-action provider reads the safety class from
        // `Diagnostic.data` to decide whether to auto-apply.
        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")
        );
    }
}