Skip to main content

tandem_runtime/
lsp.rs

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}