Skip to main content

aft/lsp/
diagnostics.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::lsp::registry::ServerKind;
5
6/// A single diagnostic from an LSP server.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct StoredDiagnostic {
9    pub file: PathBuf,
10    pub line: u32,
11    pub column: u32,
12    pub end_line: u32,
13    pub end_column: u32,
14    pub severity: DiagnosticSeverity,
15    pub message: String,
16    pub code: Option<String>,
17    pub source: Option<String>,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DiagnosticSeverity {
22    Error,
23    Warning,
24    Information,
25    Hint,
26}
27
28impl DiagnosticSeverity {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::Error => "error",
32            Self::Warning => "warning",
33            Self::Information => "information",
34            Self::Hint => "hint",
35        }
36    }
37}
38
39/// Stores diagnostics from all LSP servers.
40///
41/// Uses replacement semantics: each publishDiagnostics notification replaces
42/// all diagnostics for that (server, file) pair. An empty diagnostics array
43/// clears the entry.
44pub struct DiagnosticsStore {
45    /// Key: (ServerKind, canonical file path)
46    /// Value: list of diagnostics for that file from that server
47    store: HashMap<(ServerKind, PathBuf), Vec<StoredDiagnostic>>,
48}
49
50impl DiagnosticsStore {
51    pub fn new() -> Self {
52        Self {
53            store: HashMap::new(),
54        }
55    }
56
57    /// Replace diagnostics for a (server, file) pair.
58    pub fn publish(
59        &mut self,
60        server: ServerKind,
61        file: PathBuf,
62        diagnostics: Vec<StoredDiagnostic>,
63    ) {
64        if diagnostics.is_empty() {
65            self.store.remove(&(server, file));
66        } else {
67            self.store.insert((server, file), diagnostics);
68        }
69    }
70
71    /// Get all diagnostics for a specific file (from all servers).
72    pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
73        self.store
74            .iter()
75            .filter(|((_, stored_file), _)| stored_file == file)
76            .flat_map(|(_, diagnostics)| diagnostics.iter())
77            .collect()
78    }
79
80    /// Get all diagnostics for a directory (all files under it).
81    pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
82        self.store
83            .iter()
84            .filter(|((_, stored_file), _)| stored_file.starts_with(dir))
85            .flat_map(|(_, diagnostics)| diagnostics.iter())
86            .collect()
87    }
88
89    /// Get all stored diagnostics.
90    pub fn all(&self) -> Vec<&StoredDiagnostic> {
91        self.store.values().flat_map(|value| value.iter()).collect()
92    }
93
94    /// Clear all diagnostics for a server (e.g. on server restart).
95    pub fn clear_server(&mut self, server: ServerKind) {
96        self.store
97            .retain(|(stored_server, _), _| *stored_server != server);
98    }
99}
100
101impl Default for DiagnosticsStore {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107/// Convert LSP diagnostics to our stored format.
108/// LSP uses 0-based line/character; we convert to 1-based.
109pub fn from_lsp_diagnostics(
110    file: PathBuf,
111    lsp_diagnostics: Vec<lsp_types::Diagnostic>,
112) -> Vec<StoredDiagnostic> {
113    lsp_diagnostics
114        .into_iter()
115        .map(|diagnostic| StoredDiagnostic {
116            file: file.clone(),
117            line: diagnostic.range.start.line + 1,
118            column: diagnostic.range.start.character + 1,
119            end_line: diagnostic.range.end.line + 1,
120            end_column: diagnostic.range.end.character + 1,
121            severity: match diagnostic.severity {
122                Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
123                Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
124                Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
125                Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
126                _ => DiagnosticSeverity::Warning,
127            },
128            message: diagnostic.message,
129            code: diagnostic.code.map(|code| match code {
130                lsp_types::NumberOrString::Number(value) => value.to_string(),
131                lsp_types::NumberOrString::String(value) => value,
132            }),
133            source: diagnostic.source,
134        })
135        .collect()
136}
137
138#[cfg(test)]
139mod tests {
140    use std::path::PathBuf;
141
142    use lsp_types::{
143        Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
144    };
145
146    use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
147    use crate::lsp::registry::ServerKind;
148
149    #[test]
150    fn converts_lsp_positions_to_one_based() {
151        let file = PathBuf::from("/tmp/demo.rs");
152        let diagnostics = from_lsp_diagnostics(
153            file.clone(),
154            vec![Diagnostic {
155                range: Range::new(Position::new(0, 0), Position::new(1, 4)),
156                severity: Some(LspDiagnosticSeverity::ERROR),
157                code: Some(NumberOrString::String("E1".into())),
158                code_description: None,
159                source: Some("fake".into()),
160                message: "boom".into(),
161                related_information: None,
162                tags: None,
163                data: None,
164            }],
165        );
166
167        assert_eq!(diagnostics.len(), 1);
168        assert_eq!(diagnostics[0].file, file);
169        assert_eq!(diagnostics[0].line, 1);
170        assert_eq!(diagnostics[0].column, 1);
171        assert_eq!(diagnostics[0].end_line, 2);
172        assert_eq!(diagnostics[0].end_column, 5);
173        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
174        assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
175    }
176
177    #[test]
178    fn publish_replaces_existing_file_diagnostics() {
179        let file = PathBuf::from("/tmp/demo.rs");
180        let mut store = DiagnosticsStore::new();
181
182        store.publish(
183            ServerKind::Rust,
184            file.clone(),
185            vec![StoredDiagnostic {
186                file: file.clone(),
187                line: 1,
188                column: 1,
189                end_line: 1,
190                end_column: 2,
191                severity: DiagnosticSeverity::Warning,
192                message: "first".into(),
193                code: None,
194                source: None,
195            }],
196        );
197        store.publish(
198            ServerKind::Rust,
199            file.clone(),
200            vec![StoredDiagnostic {
201                file: file.clone(),
202                line: 2,
203                column: 1,
204                end_line: 2,
205                end_column: 2,
206                severity: DiagnosticSeverity::Error,
207                message: "second".into(),
208                code: None,
209                source: None,
210            }],
211        );
212
213        let stored = store.for_file(&file);
214        assert_eq!(stored.len(), 1);
215        assert_eq!(stored[0].message, "second");
216    }
217}