Skip to main content

adam_lsp/
diagnostics.rs

1//! Computes LSP diagnostics for adam-lang source text. No LSP transport knowledge lives here —
2//! see `crate::dispatch` for that — so this module's tests exercise the diagnostic logic
3//! directly, per the design doc's testing strategy (handler-level unit tests over full protocol
4//! round-trips where possible).
5
6use adam_lang::{AdamAstParser, TypeRegistry, check_sheet};
7use cel_parser::{CELError, SourceSpan};
8use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
9
10/// Parses and type-checks adam-lang `source`, returning every recovered syntax and type error as
11/// an LSP diagnostic.
12///
13/// - Postcondition: returns one [`Diagnostic`] per [`cel_parser::ParseError`] recovered by
14///   [`AdamAstParser::parse_str`] (syntax errors, including the single error produced when the
15///   whole parse aborts instead of recovering one item) or returned by [`check_sheet`] (type
16///   errors); returns an empty `Vec` for a syntactically and semantically clean sheet.
17///
18/// The environment explicitly includes the standard library and optional Euclid geometry,
19/// matching the executing web/book hosts. Unresolved call-result shapes remain best-effort.
20///
21/// - Complexity: O(n + s²) in source length and installed library signatures, with no caching.
22///
23/// # Examples
24///
25/// ```
26/// use adam_lsp::diagnostics::diagnostics_for_source;
27///
28/// assert!(diagnostics_for_source("sheet s { cell x: i32 = 1; }").is_empty());
29/// assert_eq!(diagnostics_for_source("sheet s { cell x: i32 = 1.0; }").len(), 1);
30/// ```
31pub fn diagnostics_for_source(source: &str) -> Vec<Diagnostic> {
32    let mut parser = AdamAstParser::new();
33    let sheet = match parser.parse_str(source) {
34        Ok(sheet) => sheet,
35        Err(e) => return vec![to_diagnostic(e.into())],
36    };
37    let mut types = TypeRegistry::new();
38    cel_euclid::register_adam_types(&mut types);
39    let mut lookup = cel_parser::OpLookup::new();
40    cel_std::install(&mut lookup);
41    cel_euclid::install(&mut lookup);
42    let type_errors = check_sheet(&sheet, &types, &lookup);
43    sheet
44        .errors
45        .into_iter()
46        .chain(type_errors)
47        .map(|e| to_diagnostic(e.into()))
48        .collect()
49}
50
51/// Converts a [`CELError`] into an LSP [`Diagnostic`] at error severity.
52fn to_diagnostic(error: CELError) -> Diagnostic {
53    Diagnostic {
54        range: to_range(error.span()),
55        severity: Some(DiagnosticSeverity::ERROR),
56        message: error.message().to_string(),
57        ..Default::default()
58    }
59}
60
61/// Converts a [`SourceSpan`] (1-based lines, character-offset columns) into an LSP [`Range`]
62/// (0-based lines, UTF-16-code-unit columns).
63///
64/// - Precondition: no character on `span`'s line(s) before `span` lies outside the Basic
65///   Multilingual Plane — adam-lang source is overwhelmingly ASCII, so a character-offset ≈
66///   UTF-16-code-unit-offset approximation is accepted here rather than computed exactly.
67fn to_range(span: SourceSpan) -> Range {
68    Range {
69        start: Position {
70            line: (span.start.line - 1) as u32,
71            character: span.start.column as u32,
72        },
73        end: Position {
74            line: (span.end.line - 1) as u32,
75            character: span.end.column as u32,
76        },
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    /// The LSP uses the same standard and Euclid metadata as executing hosts.
85    #[test]
86    fn lsp_geometry_fields_and_members_have_matching_diagnostics() {
87        let valid = "sheet s { out x := (|p: Point2D| p.x)(Point2D { y: 2.0, x: 1.0 }); out n := min(3.0, 4.0); }";
88        assert!(
89            diagnostics_for_source(valid).is_empty(),
90            "{:?}",
91            diagnostics_for_source(valid)
92        );
93        let invalid = "sheet s { out p := Point2D { x: true, y: 1.0 }; }";
94        let diagnostics = diagnostics_for_source(invalid);
95        assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
96        let invalid = "sheet s { out p := Point2D { x: 1.0, y: 1.0 }.translate(1.0); }";
97        assert!(!diagnostics_for_source(invalid).is_empty());
98        for actual in [
99            include_str!("../../cel-euclid/examples/crop_straighten.adm2"),
100            include_str!("../../cel-euclid/examples/pan_zoom.adm2"),
101        ] {
102            let diagnostics = diagnostics_for_source(actual);
103            assert!(diagnostics.is_empty(), "{diagnostics:?}");
104        }
105    }
106
107    /// Reports missing host schemas and still checks field expressions through the LSP path.
108    #[test]
109    fn unknown_record_and_child_errors_reach_lsp_diagnostics() {
110        let diagnostics =
111            diagnostics_for_source("sheet s {\n    out value := Missing { x: true + 1 }.x;\n}");
112        assert_eq!(diagnostics.len(), 2);
113        let missing = diagnostics
114            .iter()
115            .find(|d| d.message.contains("Missing"))
116            .unwrap();
117        assert_eq!(missing.range.start, Position::new(1, 17));
118        assert_eq!(missing.range.end, Position::new(1, 24));
119        assert!(diagnostics.iter().any(|d| d.message.contains("operand")));
120    }
121
122    #[test]
123    fn clean_sheet_has_no_diagnostics() {
124        assert!(diagnostics_for_source("sheet s { cell x: i32 = 1; }").is_empty());
125    }
126
127    #[test]
128    fn type_mismatched_cell_initializer_is_a_diagnostic() {
129        // Unsuffixed float literal defaults to f64, not the declared i32 — same fixture used by
130        // adam-lang's own `check_sheet` test suite (adam-lang/src/typecheck.rs).
131        let diags = diagnostics_for_source("sheet s { cell x: i32 = 1.0; }");
132        assert_eq!(diags.len(), 1);
133        assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
134    }
135
136    #[test]
137    fn recovered_syntax_error_is_a_diagnostic() {
138        // Malformed cell declaration; AdamAstParser recovers it as one `Sheet.errors` entry
139        // (adam-lang/src/ast_parser.rs's `parse_malformed_cell_is_recorded_as_an_error_item`).
140        assert_eq!(
141            diagnostics_for_source("sheet s { cell x unknown_syntax }").len(),
142            1
143        );
144    }
145
146    #[test]
147    fn unrecoverable_parse_failure_is_a_single_diagnostic() {
148        // Missing the `sheet` keyword entirely is a structural error outside any item —
149        // AdamAstParser::parse_str's documented `Err` case, not a `Sheet.errors` recovery.
150        assert_eq!(diagnostics_for_source("not a sheet at all").len(), 1);
151    }
152
153    #[test]
154    fn filter_clause_with_matching_types_has_no_diagnostics() {
155        assert!(diagnostics_for_source("sheet s { cell a: i32 = 1 filter _; }").is_empty());
156    }
157
158    #[test]
159    fn filter_clause_without_underscore_is_a_diagnostic() {
160        let diags = diagnostics_for_source("sheet s { cell a: i32 = 1 filter 1; }");
161        assert_eq!(diags.len(), 1);
162    }
163}