Skip to main content

codineer_lsp/
types.rs

1use std::collections::BTreeMap;
2use std::fmt::{Display, Formatter};
3use std::path::{Path, PathBuf};
4
5use lsp_types::{Diagnostic, Range};
6use serde_json::Value;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct LspServerConfig {
10    pub name: String,
11    pub command: String,
12    pub args: Vec<String>,
13    pub env: BTreeMap<String, String>,
14    pub workspace_root: PathBuf,
15    pub initialization_options: Option<Value>,
16    pub extension_to_language: BTreeMap<String, String>,
17}
18
19impl LspServerConfig {
20    #[must_use]
21    pub fn language_id_for(&self, path: &Path) -> Option<&str> {
22        let extension = normalize_extension(path.extension()?.to_string_lossy().as_ref());
23        self.extension_to_language
24            .get(&extension)
25            .map(String::as_str)
26    }
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct FileDiagnostics {
31    pub path: PathBuf,
32    pub uri: String,
33    pub diagnostics: Vec<Diagnostic>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq)]
37pub struct WorkspaceDiagnostics {
38    pub files: Vec<FileDiagnostics>,
39}
40
41impl WorkspaceDiagnostics {
42    #[must_use]
43    pub fn is_empty(&self) -> bool {
44        self.files.is_empty()
45    }
46
47    #[must_use]
48    pub fn total_diagnostics(&self) -> usize {
49        self.files.iter().map(|file| file.diagnostics.len()).sum()
50    }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct SymbolLocation {
55    pub path: PathBuf,
56    pub range: Range,
57}
58
59impl SymbolLocation {
60    #[must_use]
61    pub fn start_line(&self) -> u32 {
62        self.range.start.line + 1
63    }
64
65    #[must_use]
66    pub fn start_character(&self) -> u32 {
67        self.range.start.character + 1
68    }
69}
70
71impl Display for SymbolLocation {
72    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
73        write!(
74            f,
75            "{}:{}:{}",
76            self.path.display(),
77            self.start_line(),
78            self.start_character()
79        )
80    }
81}
82
83#[derive(Debug, Clone, Default, PartialEq)]
84pub struct LspContextEnrichment {
85    pub file_path: PathBuf,
86    pub diagnostics: WorkspaceDiagnostics,
87    pub definitions: Vec<SymbolLocation>,
88    pub references: Vec<SymbolLocation>,
89}
90
91impl LspContextEnrichment {
92    #[must_use]
93    pub fn is_empty(&self) -> bool {
94        self.diagnostics.is_empty() && self.definitions.is_empty() && self.references.is_empty()
95    }
96
97    #[must_use]
98    pub fn render_prompt_section(&self) -> String {
99        const MAX_RENDERED_DIAGNOSTICS: usize = 12;
100        const MAX_RENDERED_LOCATIONS: usize = 12;
101
102        let mut lines = vec!["# LSP context".to_string()];
103        lines.push(format!(" - Focus file: {}", self.file_path.display()));
104        lines.push(format!(
105            " - Workspace diagnostics: {} across {} file(s)",
106            self.diagnostics.total_diagnostics(),
107            self.diagnostics.files.len()
108        ));
109
110        if !self.diagnostics.files.is_empty() {
111            lines.push(String::new());
112            lines.push("Diagnostics:".to_string());
113            let mut rendered = 0usize;
114            for file in &self.diagnostics.files {
115                for diagnostic in &file.diagnostics {
116                    if rendered == MAX_RENDERED_DIAGNOSTICS {
117                        lines.push(" - Additional diagnostics omitted for brevity.".to_string());
118                        break;
119                    }
120                    let severity = diagnostic_severity_label(diagnostic.severity);
121                    lines.push(format!(
122                        " - {}:{}:{} [{}] {}",
123                        file.path.display(),
124                        diagnostic.range.start.line + 1,
125                        diagnostic.range.start.character + 1,
126                        severity,
127                        diagnostic.message.replace('\n', " ")
128                    ));
129                    rendered += 1;
130                }
131                if rendered == MAX_RENDERED_DIAGNOSTICS {
132                    break;
133                }
134            }
135        }
136
137        if !self.definitions.is_empty() {
138            lines.push(String::new());
139            lines.push("Definitions:".to_string());
140            lines.extend(
141                self.definitions
142                    .iter()
143                    .take(MAX_RENDERED_LOCATIONS)
144                    .map(|location| format!(" - {location}")),
145            );
146            if self.definitions.len() > MAX_RENDERED_LOCATIONS {
147                lines.push(" - Additional definitions omitted for brevity.".to_string());
148            }
149        }
150
151        if !self.references.is_empty() {
152            lines.push(String::new());
153            lines.push("References:".to_string());
154            lines.extend(
155                self.references
156                    .iter()
157                    .take(MAX_RENDERED_LOCATIONS)
158                    .map(|location| format!(" - {location}")),
159            );
160            if self.references.len() > MAX_RENDERED_LOCATIONS {
161                lines.push(" - Additional references omitted for brevity.".to_string());
162            }
163        }
164
165        lines.join("\n")
166    }
167}
168
169#[must_use]
170pub(crate) fn normalize_extension(extension: &str) -> String {
171    if extension.starts_with('.') {
172        extension.to_ascii_lowercase()
173    } else {
174        format!(".{}", extension.to_ascii_lowercase())
175    }
176}
177
178fn diagnostic_severity_label(severity: Option<lsp_types::DiagnosticSeverity>) -> &'static str {
179    match severity {
180        Some(lsp_types::DiagnosticSeverity::ERROR) => "error",
181        Some(lsp_types::DiagnosticSeverity::WARNING) => "warning",
182        Some(lsp_types::DiagnosticSeverity::INFORMATION) => "info",
183        Some(lsp_types::DiagnosticSeverity::HINT) => "hint",
184        _ => "unknown",
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use lsp_types::{DiagnosticSeverity, Position, Range};
192    use std::path::PathBuf;
193
194    #[test]
195    fn normalize_extension_handles_dot_prefix_and_casing() {
196        assert_eq!(normalize_extension("rs"), ".rs");
197        assert_eq!(normalize_extension(".rs"), ".rs");
198        assert_eq!(normalize_extension("RS"), ".rs");
199        assert_eq!(normalize_extension(".PY"), ".py");
200        assert_eq!(normalize_extension("ts"), ".ts");
201    }
202
203    #[test]
204    fn symbol_location_display_and_line_numbers() {
205        let location = SymbolLocation {
206            path: PathBuf::from("/src/main.rs"),
207            range: Range {
208                start: Position::new(9, 4),
209                end: Position::new(9, 10),
210            },
211        };
212        assert_eq!(location.start_line(), 10);
213        assert_eq!(location.start_character(), 5);
214        assert_eq!(format!("{location}"), "/src/main.rs:10:5");
215    }
216
217    #[test]
218    fn workspace_diagnostics_counts_total() {
219        let diag = WorkspaceDiagnostics {
220            files: vec![
221                FileDiagnostics {
222                    path: PathBuf::from("/a.rs"),
223                    uri: "file:///a.rs".to_string(),
224                    diagnostics: vec![Diagnostic::default(), Diagnostic::default()],
225                },
226                FileDiagnostics {
227                    path: PathBuf::from("/b.rs"),
228                    uri: "file:///b.rs".to_string(),
229                    diagnostics: vec![Diagnostic::default()],
230                },
231            ],
232        };
233        assert_eq!(diag.total_diagnostics(), 3);
234        assert!(!diag.is_empty());
235        assert!(WorkspaceDiagnostics::default().is_empty());
236    }
237
238    #[test]
239    fn context_enrichment_renders_prompt_section_with_diagnostics() {
240        let enrichment = LspContextEnrichment {
241            file_path: PathBuf::from("/src/lib.rs"),
242            diagnostics: WorkspaceDiagnostics {
243                files: vec![FileDiagnostics {
244                    path: PathBuf::from("/src/lib.rs"),
245                    uri: "file:///src/lib.rs".to_string(),
246                    diagnostics: vec![Diagnostic {
247                        message: "unused variable".to_string(),
248                        severity: Some(DiagnosticSeverity::WARNING),
249                        range: Range {
250                            start: Position::new(4, 0),
251                            end: Position::new(4, 5),
252                        },
253                        ..Diagnostic::default()
254                    }],
255                }],
256            },
257            definitions: vec![SymbolLocation {
258                path: PathBuf::from("/src/lib.rs"),
259                range: Range {
260                    start: Position::new(0, 0),
261                    end: Position::new(0, 5),
262                },
263            }],
264            references: vec![],
265        };
266        let rendered = enrichment.render_prompt_section();
267        assert!(rendered.contains("# LSP context"));
268        assert!(rendered.contains("[warning]"));
269        assert!(rendered.contains("unused variable"));
270        assert!(rendered.contains("Definitions:"));
271        assert!(!rendered.contains("References:"));
272    }
273
274    #[test]
275    fn empty_enrichment_reports_empty() {
276        let empty = LspContextEnrichment::default();
277        assert!(empty.is_empty());
278    }
279
280    #[test]
281    fn severity_labels_are_correct() {
282        assert_eq!(
283            diagnostic_severity_label(Some(DiagnosticSeverity::ERROR)),
284            "error"
285        );
286        assert_eq!(
287            diagnostic_severity_label(Some(DiagnosticSeverity::WARNING)),
288            "warning"
289        );
290        assert_eq!(
291            diagnostic_severity_label(Some(DiagnosticSeverity::INFORMATION)),
292            "info"
293        );
294        assert_eq!(
295            diagnostic_severity_label(Some(DiagnosticSeverity::HINT)),
296            "hint"
297        );
298        assert_eq!(diagnostic_severity_label(None), "unknown");
299    }
300
301    #[test]
302    fn language_id_for_maps_configured_extensions() {
303        let config = LspServerConfig {
304            name: "test-server".to_string(),
305            command: "echo".to_string(),
306            args: vec![],
307            env: BTreeMap::new(),
308            workspace_root: PathBuf::from("/workspace"),
309            initialization_options: None,
310            extension_to_language: BTreeMap::from([
311                (".rs".to_string(), "rust".to_string()),
312                (".py".to_string(), "python".to_string()),
313            ]),
314        };
315        assert_eq!(config.language_id_for(Path::new("main.rs")), Some("rust"));
316        assert_eq!(
317            config.language_id_for(Path::new("script.py")),
318            Some("python")
319        );
320        assert_eq!(config.language_id_for(Path::new("style.css")), None);
321        assert_eq!(config.language_id_for(Path::new("Makefile")), None);
322    }
323}