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}