Skip to main content

gruel_lsp/
diagnostics.rs

1//! Convert Gruel `JsonDiagnostic` values to LSP `Diagnostic` values
2//! (ADR-0091).
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use gruel_compiler::{JsonDiagnostic, JsonSpan, JsonSuggestion};
8use lsp_types::{
9    Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, Location, NumberOrString,
10    Position, Range, Url,
11};
12
13use crate::position::PositionEncoding;
14
15/// Group of diagnostics keyed by file path string.
16pub type DiagnosticsByFile = HashMap<PathBuf, Vec<Diagnostic>>;
17
18fn make_position(line: u32, column: u32) -> Position {
19    // JsonSpan uses 1-indexed line/column. LSP uses 0-indexed.
20    Position {
21        line: line.saturating_sub(1),
22        character: column.saturating_sub(1),
23    }
24}
25
26fn range_from_span(span: &JsonSpan, _encoding: PositionEncoding) -> Range {
27    // We do not know the source text here to compute UTF-16 columns; for
28    // best fidelity callers should remap spans through `position::byte_to_position`
29    // when source is available. The JsonSpan's `line` / `column` are byte-based
30    // 1-indexed; we use them as a best-effort UTF-8 mapping (LSP clients that
31    // negotiate UTF-8 see correct positions; UTF-16 clients see byte-position
32    // approximation, which is upgraded by the worker via `remap_diagnostic_range`).
33    Range {
34        start: make_position(span.line, span.column),
35        end: make_position(span.line, span.column),
36    }
37}
38
39/// Convert one Gruel `JsonDiagnostic` to an LSP `Diagnostic` and the file
40/// path it belongs to. Returns `None` if the diagnostic has no primary span.
41pub fn to_lsp_diagnostic(
42    diag: &JsonDiagnostic,
43    workspace_root: Option<&Path>,
44) -> Option<(PathBuf, Diagnostic)> {
45    let primary = diag.spans.iter().find(|s| s.primary)?;
46    let range = range_from_span(primary, PositionEncoding::Utf8);
47
48    let severity = match diag.severity {
49        "error" => Some(DiagnosticSeverity::ERROR),
50        "warning" => Some(DiagnosticSeverity::WARNING),
51        _ => None,
52    };
53
54    let mut message = diag.message.clone();
55    for note in &diag.notes {
56        message.push_str("\nnote: ");
57        message.push_str(note);
58    }
59    for help in &diag.helps {
60        message.push_str("\nhelp: ");
61        message.push_str(help);
62    }
63
64    let related = diag
65        .spans
66        .iter()
67        .filter(|s| !s.primary)
68        .filter_map(|s| {
69            let path = resolve_path(&s.file, workspace_root)?;
70            let uri = Url::from_file_path(&path).ok()?;
71            Some(DiagnosticRelatedInformation {
72                location: Location {
73                    uri,
74                    range: range_from_span(s, PositionEncoding::Utf8),
75                },
76                message: s.label.clone().unwrap_or_default(),
77            })
78        })
79        .collect::<Vec<_>>();
80    let related = if related.is_empty() {
81        None
82    } else {
83        Some(related)
84    };
85
86    let path = resolve_path(&primary.file, workspace_root)?;
87
88    let code = if diag.code.is_empty() {
89        None
90    } else {
91        Some(NumberOrString::String(diag.code.clone()))
92    };
93
94    let suggestions_data = serde_json::to_value(&diag.suggestions).ok();
95
96    Some((
97        path,
98        Diagnostic {
99            range,
100            severity,
101            code,
102            code_description: None,
103            source: Some("gruel".to_string()),
104            message,
105            related_information: related,
106            tags: None,
107            data: suggestions_data,
108        },
109    ))
110}
111
112fn resolve_path(raw: &str, workspace_root: Option<&Path>) -> Option<PathBuf> {
113    let p = PathBuf::from(raw);
114    if p.is_absolute() {
115        return Some(p);
116    }
117    if let Some(root) = workspace_root {
118        return Some(root.join(p));
119    }
120    std::env::current_dir().ok().map(|cwd| cwd.join(p))
121}
122
123/// Group LSP diagnostics by file path.
124pub fn group_by_file(
125    diagnostics: impl IntoIterator<Item = JsonDiagnostic>,
126    workspace_root: Option<&Path>,
127) -> DiagnosticsByFile {
128    let mut out: DiagnosticsByFile = HashMap::new();
129    for d in diagnostics {
130        if let Some((path, diag)) = to_lsp_diagnostic(&d, workspace_root) {
131            out.entry(path).or_default().push(diag);
132        }
133    }
134    out
135}
136
137/// Deserialize the `JsonSuggestion[]` from a Diagnostic.data field
138/// (Phase 2 carries them so codeAction can read them back).
139pub fn suggestions_from_diagnostic_data(value: &serde_json::Value) -> Vec<JsonSuggestion> {
140    serde_json::from_value(value.clone()).unwrap_or_default()
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use gruel_compiler::JsonSpan;
147
148    fn make_diag() -> JsonDiagnostic {
149        JsonDiagnostic {
150            code: "E0001".to_string(),
151            message: "type mismatch".to_string(),
152            severity: "error",
153            spans: vec![JsonSpan {
154                file: "main.gruel".to_string(),
155                start: 10,
156                end: 12,
157                line: 2,
158                column: 5,
159                label: None,
160                primary: true,
161            }],
162            suggestions: vec![],
163            notes: vec!["expected i32".to_string()],
164            helps: vec![],
165        }
166    }
167
168    #[test]
169    fn basic_mapping() {
170        let d = make_diag();
171        let (path, lsp) = to_lsp_diagnostic(&d, Some(Path::new("/work"))).unwrap();
172        assert_eq!(path, PathBuf::from("/work/main.gruel"));
173        assert_eq!(lsp.severity, Some(DiagnosticSeverity::ERROR));
174        assert!(lsp.message.contains("type mismatch"));
175        assert!(lsp.message.contains("note: expected i32"));
176        assert_eq!(
177            lsp.range.start,
178            Position {
179                line: 1,
180                character: 4
181            }
182        );
183    }
184}