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}