1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use regex::Regex;
5use serde::Serialize;
6
7#[derive(Clone)]
8pub struct LspManager {
9 workspace_root: Arc<PathBuf>,
10}
11
12#[derive(Debug, Clone, Serialize)]
13pub struct LspDiagnostic {
14 pub severity: String,
15 pub message: String,
16 pub path: String,
17 pub line: usize,
18 pub column: usize,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct LspLocation {
23 pub path: String,
24 pub line: usize,
25 pub column: usize,
26 pub preview: String,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct LspSymbol {
31 pub name: String,
32 pub kind: String,
33 pub path: String,
34 pub line: usize,
35}
36
37impl LspManager {
38 pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
39 Self {
40 workspace_root: Arc::new(workspace_root.into()),
41 }
42 }
43
44 pub fn diagnostics(&self, rel_path: &str) -> Vec<LspDiagnostic> {
45 let path = self.absolute_path(rel_path);
46 let Ok(content) = std::fs::read_to_string(&path) else {
47 return vec![LspDiagnostic {
48 severity: "error".to_string(),
49 message: "File not found".to_string(),
50 path: rel_path.to_string(),
51 line: 1,
52 column: 1,
53 }];
54 };
55
56 let mut diagnostics = Vec::new();
57 let mut brace_balance = 0i64;
58 for (idx, line) in content.lines().enumerate() {
59 for ch in line.chars() {
60 if ch == '{' {
61 brace_balance += 1;
62 } else if ch == '}' {
63 brace_balance -= 1;
64 }
65 }
66 if line.contains("TODO") {
67 diagnostics.push(LspDiagnostic {
68 severity: "hint".to_string(),
69 message: "TODO marker".to_string(),
70 path: rel_path.to_string(),
71 line: idx + 1,
72 column: line.find("TODO").unwrap_or(0) + 1,
73 });
74 }
75 }
76 if brace_balance != 0 {
77 diagnostics.push(LspDiagnostic {
78 severity: "warning".to_string(),
79 message: "Unbalanced braces detected".to_string(),
80 path: rel_path.to_string(),
81 line: 1,
82 column: 1,
83 });
84 }
85 diagnostics
86 }
87
88 pub fn symbols(&self, q: Option<&str>) -> Vec<LspSymbol> {
89 let mut out = Vec::new();
90 let rust_fn = Regex::new(r"^\s*(pub\s+)?(async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)").ok();
91 let rust_struct = Regex::new(r"^\s*(struct|enum|trait)\s+([A-Za-z_][A-Za-z0-9_]*)").ok();
92 let ts_fn =
93 Regex::new(r"^\s*(export\s+)?(async\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)").ok();
94
95 for entry in ignore::WalkBuilder::new(self.workspace_root.as_path())
96 .build()
97 .flatten()
98 {
99 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
100 continue;
101 }
102 let path = entry.path();
103 let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
104 if !matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "py") {
105 continue;
106 }
107 let Ok(content) = std::fs::read_to_string(path) else {
108 continue;
109 };
110 for (idx, line) in content.lines().enumerate() {
111 if let Some(re) = &rust_fn {
112 if let Some(c) = re.captures(line) {
113 let name = c[3].to_string();
114 if symbol_matches(&name, q) {
115 out.push(LspSymbol {
116 name,
117 kind: "function".to_string(),
118 path: relativize(self.workspace_root.as_path(), path),
119 line: idx + 1,
120 });
121 }
122 }
123 }
124 if let Some(re) = &rust_struct {
125 if let Some(c) = re.captures(line) {
126 let kind = c[1].to_string();
127 let name = c[2].to_string();
128 if symbol_matches(&name, q) {
129 out.push(LspSymbol {
130 name,
131 kind,
132 path: relativize(self.workspace_root.as_path(), path),
133 line: idx + 1,
134 });
135 }
136 }
137 }
138 if let Some(re) = &ts_fn {
139 if let Some(c) = re.captures(line) {
140 let name = c[3].to_string();
141 if symbol_matches(&name, q) {
142 out.push(LspSymbol {
143 name,
144 kind: "function".to_string(),
145 path: relativize(self.workspace_root.as_path(), path),
146 line: idx + 1,
147 });
148 }
149 }
150 }
151 }
152 if out.len() >= 500 {
153 break;
154 }
155 }
156 out
157 }
158
159 pub fn goto_definition(&self, symbol: &str) -> Option<LspLocation> {
160 self.symbols(Some(symbol))
161 .into_iter()
162 .find(|s| s.name == symbol)
163 .map(|s| LspLocation {
164 path: s.path,
165 line: s.line,
166 column: 1,
167 preview: format!("{} {}", s.kind, s.name),
168 })
169 }
170
171 pub fn references(&self, symbol: &str) -> Vec<LspLocation> {
172 let escaped = regex::escape(symbol);
173 let re = Regex::new(&format!(r"\b{}\b", escaped)).ok();
174 let Some(re) = re else {
175 return Vec::new();
176 };
177 let mut refs = Vec::new();
178 for entry in ignore::WalkBuilder::new(self.workspace_root.as_path())
179 .build()
180 .flatten()
181 {
182 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
183 continue;
184 }
185 let path = entry.path();
186 let Ok(content) = std::fs::read_to_string(path) else {
187 continue;
188 };
189 for (idx, line) in content.lines().enumerate() {
190 if let Some(m) = re.find(line) {
191 refs.push(LspLocation {
192 path: relativize(self.workspace_root.as_path(), path),
193 line: idx + 1,
194 column: m.start() + 1,
195 preview: line.trim().to_string(),
196 });
197 if refs.len() >= 200 {
198 return refs;
199 }
200 }
201 }
202 }
203 refs
204 }
205
206 pub fn hover(&self, symbol: &str) -> Option<String> {
207 let def = self.goto_definition(symbol)?;
208 Some(format!(
209 "{}:{}:{} => {}",
210 def.path, def.line, def.column, def.preview
211 ))
212 }
213
214 pub fn call_hierarchy(&self, symbol: &str) -> serde_json::Value {
215 let definition = self.goto_definition(symbol);
216 let references = self.references(symbol);
217 serde_json::json!({
218 "symbol": symbol,
219 "definition": definition,
220 "incomingCalls": references.into_iter().take(50).collect::<Vec<_>>(),
221 "outgoingCalls": []
222 })
223 }
224
225 fn absolute_path(&self, rel_path: &str) -> PathBuf {
226 let p = PathBuf::from(rel_path);
227 if p.is_absolute() {
228 p
229 } else {
230 self.workspace_root.join(p)
231 }
232 }
233}
234
235fn relativize(root: &Path, path: &Path) -> String {
236 path.strip_prefix(root)
237 .map(|p| p.to_string_lossy().to_string())
238 .unwrap_or_else(|_| path.to_string_lossy().to_string())
239}
240
241fn symbol_matches(name: &str, q: Option<&str>) -> bool {
242 match q {
243 None => true,
244 Some(q) => name.to_lowercase().contains(&q.to_lowercase()),
245 }
246}