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#[derive(Debug, Clone, PartialEq)]
86pub struct HoverResult {
87 pub contents: String,
89 pub range: Option<Range>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct CompletionItem {
96 pub label: String,
98 pub kind: Option<String>,
100 pub detail: Option<String>,
102 pub documentation: Option<String>,
104 pub insert_text: Option<String>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct DocumentSymbolInfo {
111 pub name: String,
113 pub kind: String,
115 pub location: SymbolLocation,
117}
118
119pub 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}