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::{Deserialize, Serialize};
7use serde_json::Value;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct LspServerConfig {
11    pub name: String,
12    pub command: String,
13    pub args: Vec<String>,
14    pub env: BTreeMap<String, String>,
15    pub workspace_root: PathBuf,
16    pub initialization_options: Option<Value>,
17    pub extension_to_language: BTreeMap<String, String>,
18}
19
20impl LspServerConfig {
21    #[must_use]
22    pub fn language_id_for(&self, path: &Path) -> Option<&str> {
23        let extension = normalize_extension(path.extension()?.to_string_lossy().as_ref());
24        self.extension_to_language
25            .get(&extension)
26            .map(String::as_str)
27    }
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct FileDiagnostics {
32    pub path: PathBuf,
33    pub uri: String,
34    pub diagnostics: Vec<Diagnostic>,
35}
36
37#[derive(Debug, Clone, Default, PartialEq)]
38pub struct WorkspaceDiagnostics {
39    pub files: Vec<FileDiagnostics>,
40}
41
42impl WorkspaceDiagnostics {
43    #[must_use]
44    pub fn is_empty(&self) -> bool {
45        self.files.is_empty()
46    }
47
48    #[must_use]
49    pub fn total_diagnostics(&self) -> usize {
50        self.files.iter().map(|file| file.diagnostics.len()).sum()
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SymbolLocation {
56    pub path: PathBuf,
57    pub range: Range,
58}
59
60impl SymbolLocation {
61    #[must_use]
62    pub fn start_line(&self) -> u32 {
63        self.range.start.line + 1
64    }
65
66    #[must_use]
67    pub fn start_character(&self) -> u32 {
68        self.range.start.character + 1
69    }
70}
71
72impl Display for SymbolLocation {
73    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
74        write!(
75            f,
76            "{}:{}:{}",
77            self.path.display(),
78            self.start_line(),
79            self.start_character()
80        )
81    }
82}
83
84/// Hover result returned by `textDocument/hover`.
85#[derive(Debug, Clone, PartialEq)]
86pub struct HoverResult {
87    /// Markdown-formatted or plain-text hover contents.
88    pub contents: String,
89    /// Optional source range this hover applies to.
90    pub range: Option<Range>,
91}
92
93/// A single completion suggestion from `textDocument/completion`.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct CompletionItem {
96    /// Display label shown in the completion list.
97    pub label: String,
98    /// Human-readable kind string (e.g. `"Function"`, `"Variable"`).
99    pub kind: Option<String>,
100    /// Short detail string (e.g. return type or signature).
101    pub detail: Option<String>,
102    /// Markdown documentation for this item.
103    pub documentation: Option<String>,
104    /// Text to insert when accepted; defaults to `label` when absent.
105    pub insert_text: Option<String>,
106}
107
108/// A flat document symbol entry from `textDocument/documentSymbol`.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct DocumentSymbolInfo {
111    /// Symbol name.
112    pub name: String,
113    /// Human-readable symbol kind (e.g. `"Class"`, `"Method"`).
114    pub kind: String,
115    /// Location of this symbol in its source file.
116    pub location: SymbolLocation,
117}
118
119/// Re-export `TextEdit` so callers don't need a direct `lsp-types` dependency.
120pub use lsp_types::TextEdit as LspTextEdit;
121
122#[derive(Debug, Clone, Default, PartialEq)]
123pub struct LspContextEnrichment {
124    pub file_path: PathBuf,
125    pub diagnostics: WorkspaceDiagnostics,
126    pub definitions: Vec<SymbolLocation>,
127    pub references: Vec<SymbolLocation>,
128}
129
130impl LspContextEnrichment {
131    #[must_use]
132    pub fn is_empty(&self) -> bool {
133        self.diagnostics.is_empty() && self.definitions.is_empty() && self.references.is_empty()
134    }
135
136    #[must_use]
137    pub fn render_prompt_section(&self) -> String {
138        const MAX_RENDERED_DIAGNOSTICS: usize = 12;
139        const MAX_RENDERED_LOCATIONS: usize = 12;
140
141        let mut lines = vec!["# LSP context".to_string()];
142        lines.push(format!(" - Focus file: {}", self.file_path.display()));
143        lines.push(format!(
144            " - Workspace diagnostics: {} across {} file(s)",
145            self.diagnostics.total_diagnostics(),
146            self.diagnostics.files.len()
147        ));
148
149        if !self.diagnostics.files.is_empty() {
150            lines.push(String::new());
151            lines.push("Diagnostics:".to_string());
152            let mut rendered = 0usize;
153            for file in &self.diagnostics.files {
154                for diagnostic in &file.diagnostics {
155                    if rendered == MAX_RENDERED_DIAGNOSTICS {
156                        lines.push(" - Additional diagnostics omitted for brevity.".to_string());
157                        break;
158                    }
159                    let severity = diagnostic_severity_label(diagnostic.severity);
160                    lines.push(format!(
161                        " - {}:{}:{} [{}] {}",
162                        file.path.display(),
163                        diagnostic.range.start.line + 1,
164                        diagnostic.range.start.character + 1,
165                        severity,
166                        diagnostic.message.replace('\n', " ")
167                    ));
168                    rendered += 1;
169                }
170                if rendered == MAX_RENDERED_DIAGNOSTICS {
171                    break;
172                }
173            }
174        }
175
176        if !self.definitions.is_empty() {
177            lines.push(String::new());
178            lines.push("Definitions:".to_string());
179            lines.extend(
180                self.definitions
181                    .iter()
182                    .take(MAX_RENDERED_LOCATIONS)
183                    .map(|location| format!(" - {location}")),
184            );
185            if self.definitions.len() > MAX_RENDERED_LOCATIONS {
186                lines.push(" - Additional definitions omitted for brevity.".to_string());
187            }
188        }
189
190        if !self.references.is_empty() {
191            lines.push(String::new());
192            lines.push("References:".to_string());
193            lines.extend(
194                self.references
195                    .iter()
196                    .take(MAX_RENDERED_LOCATIONS)
197                    .map(|location| format!(" - {location}")),
198            );
199            if self.references.len() > MAX_RENDERED_LOCATIONS {
200                lines.push(" - Additional references omitted for brevity.".to_string());
201            }
202        }
203
204        lines.join("\n")
205    }
206}
207
208#[must_use]
209pub(crate) fn normalize_extension(extension: &str) -> String {
210    if extension.starts_with('.') {
211        extension.to_ascii_lowercase()
212    } else {
213        format!(".{}", extension.to_ascii_lowercase())
214    }
215}
216
217pub fn diagnostic_severity_label(severity: Option<lsp_types::DiagnosticSeverity>) -> &'static str {
218    match severity {
219        Some(lsp_types::DiagnosticSeverity::ERROR) => "error",
220        Some(lsp_types::DiagnosticSeverity::WARNING) => "warning",
221        Some(lsp_types::DiagnosticSeverity::INFORMATION) => "info",
222        Some(lsp_types::DiagnosticSeverity::HINT) => "hint",
223        _ => "unknown",
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use lsp_types::{DiagnosticSeverity, Position, Range};
231    use std::path::PathBuf;
232
233    #[test]
234    fn normalize_extension_handles_dot_prefix_and_casing() {
235        assert_eq!(normalize_extension("rs"), ".rs");
236        assert_eq!(normalize_extension(".rs"), ".rs");
237        assert_eq!(normalize_extension("RS"), ".rs");
238        assert_eq!(normalize_extension(".PY"), ".py");
239        assert_eq!(normalize_extension("ts"), ".ts");
240    }
241
242    #[test]
243    fn symbol_location_display_and_line_numbers() {
244        let location = SymbolLocation {
245            path: PathBuf::from("/src/main.rs"),
246            range: Range {
247                start: Position::new(9, 4),
248                end: Position::new(9, 10),
249            },
250        };
251        assert_eq!(location.start_line(), 10);
252        assert_eq!(location.start_character(), 5);
253        assert_eq!(format!("{location}"), "/src/main.rs:10:5");
254    }
255
256    #[test]
257    fn workspace_diagnostics_counts_total() {
258        let diag = WorkspaceDiagnostics {
259            files: vec![
260                FileDiagnostics {
261                    path: PathBuf::from("/a.rs"),
262                    uri: "file:///a.rs".to_string(),
263                    diagnostics: vec![Diagnostic::default(), Diagnostic::default()],
264                },
265                FileDiagnostics {
266                    path: PathBuf::from("/b.rs"),
267                    uri: "file:///b.rs".to_string(),
268                    diagnostics: vec![Diagnostic::default()],
269                },
270            ],
271        };
272        assert_eq!(diag.total_diagnostics(), 3);
273        assert!(!diag.is_empty());
274        assert!(WorkspaceDiagnostics::default().is_empty());
275    }
276
277    #[test]
278    fn context_enrichment_renders_prompt_section_with_diagnostics() {
279        let enrichment = LspContextEnrichment {
280            file_path: PathBuf::from("/src/lib.rs"),
281            diagnostics: WorkspaceDiagnostics {
282                files: vec![FileDiagnostics {
283                    path: PathBuf::from("/src/lib.rs"),
284                    uri: "file:///src/lib.rs".to_string(),
285                    diagnostics: vec![Diagnostic {
286                        message: "unused variable".to_string(),
287                        severity: Some(DiagnosticSeverity::WARNING),
288                        range: Range {
289                            start: Position::new(4, 0),
290                            end: Position::new(4, 5),
291                        },
292                        ..Diagnostic::default()
293                    }],
294                }],
295            },
296            definitions: vec![SymbolLocation {
297                path: PathBuf::from("/src/lib.rs"),
298                range: Range {
299                    start: Position::new(0, 0),
300                    end: Position::new(0, 5),
301                },
302            }],
303            references: vec![],
304        };
305        let rendered = enrichment.render_prompt_section();
306        assert!(rendered.contains("# LSP context"));
307        assert!(rendered.contains("[warning]"));
308        assert!(rendered.contains("unused variable"));
309        assert!(rendered.contains("Definitions:"));
310        assert!(!rendered.contains("References:"));
311    }
312
313    #[test]
314    fn empty_enrichment_reports_empty() {
315        let empty = LspContextEnrichment::default();
316        assert!(empty.is_empty());
317    }
318
319    #[test]
320    fn severity_labels_are_correct() {
321        assert_eq!(
322            diagnostic_severity_label(Some(DiagnosticSeverity::ERROR)),
323            "error"
324        );
325        assert_eq!(
326            diagnostic_severity_label(Some(DiagnosticSeverity::WARNING)),
327            "warning"
328        );
329        assert_eq!(
330            diagnostic_severity_label(Some(DiagnosticSeverity::INFORMATION)),
331            "info"
332        );
333        assert_eq!(
334            diagnostic_severity_label(Some(DiagnosticSeverity::HINT)),
335            "hint"
336        );
337        assert_eq!(diagnostic_severity_label(None), "unknown");
338    }
339
340    #[test]
341    fn language_id_for_maps_configured_extensions() {
342        let config = LspServerConfig {
343            name: "test-server".to_string(),
344            command: "echo".to_string(),
345            args: vec![],
346            env: BTreeMap::new(),
347            workspace_root: PathBuf::from("/workspace"),
348            initialization_options: None,
349            extension_to_language: BTreeMap::from([
350                (".rs".to_string(), "rust".to_string()),
351                (".py".to_string(), "python".to_string()),
352            ]),
353        };
354        assert_eq!(config.language_id_for(Path::new("main.rs")), Some("rust"));
355        assert_eq!(
356            config.language_id_for(Path::new("script.py")),
357            Some("python")
358        );
359        assert_eq!(config.language_id_for(Path::new("style.css")), None);
360        assert_eq!(config.language_id_for(Path::new("Makefile")), None);
361    }
362
363    #[test]
364    fn lsp_server_config_serializes_and_deserializes() {
365        let config = LspServerConfig {
366            name: "rust-analyzer".to_string(),
367            command: "rust-analyzer".to_string(),
368            args: vec!["--stdio".to_string()],
369            env: BTreeMap::from([("RUST_LOG".to_string(), "info".to_string())]),
370            workspace_root: PathBuf::from("/workspace"),
371            initialization_options: None,
372            extension_to_language: BTreeMap::from([(".rs".to_string(), "rust".to_string())]),
373        };
374        let json = serde_json::to_string(&config).expect("serialize should succeed");
375        let roundtripped: LspServerConfig =
376            serde_json::from_str(&json).expect("deserialize should succeed");
377        assert_eq!(config, roundtripped);
378    }
379
380    #[test]
381    fn lsp_text_edit_reexport_is_constructible() {
382        let _: LspTextEdit = LspTextEdit {
383            range: Range::default(),
384            new_text: String::new(),
385        };
386    }
387}