1pub mod call_hierarchy;
35pub mod code_actions;
36pub mod definitions;
37pub mod diagnostics;
38pub mod hover;
39pub mod references;
40pub mod rename;
41pub mod restart;
42pub mod support;
43pub mod symbols;
44pub mod types;
45pub mod workspace_symbols;
46pub use types::{DefinitionsInput, DiagnosticsInput, ReferencesInput, RestartInput, SymbolsInput};
47
48#[cfg(test)]
49mod test;
50
51use std::{
52 path::{Path, PathBuf},
53 sync::Arc,
54 time::Duration,
55};
56
57use crate::{ToolDescription, util::path_guard::PathGuard};
58use cosh_sdk::lsp::{ClientKey, DiagnosticsEngine, Manager};
59
60pub struct Lsp {
66 manager: Arc<Manager>,
67 diagnostics: Arc<DiagnosticsEngine>,
68 guard: PathGuard,
69 request_timeout: Duration,
70 settle_cap: Duration,
71
72 pub description_definitions: ToolDescription,
74 pub description_references: ToolDescription,
76 pub description_symbols: ToolDescription,
78 pub description_restart: ToolDescription,
80 pub description_rename: ToolDescription,
82 pub description_hover: ToolDescription,
84 pub description_workspace_symbols: ToolDescription,
86 pub description_call_hierarchy: ToolDescription,
88 pub description_code_actions: ToolDescription,
90}
91
92impl Lsp {
93 #[must_use]
96 pub fn new(root: impl Into<PathBuf>) -> Self {
97 Self::with_manager(
98 Arc::new(Manager::new(root)),
99 Arc::new(DiagnosticsEngine::new()),
100 )
101 }
102
103 #[must_use]
106 pub fn with_manager(manager: Arc<Manager>, diagnostics: Arc<DiagnosticsEngine>) -> Self {
107 let root = manager.root().to_path_buf();
108 Self {
109 manager,
110 diagnostics,
111 guard: PathGuard::new(&root, None, None),
112 request_timeout: Duration::from_secs(10),
113 settle_cap: Duration::from_secs(5),
114 description_definitions: json_description(
115 "lsp_definitions",
116 concat!(
117 "Go to the definition of the symbol at a given position. ",
118 "Addressing is flexible: pass `position` (1-based line and ",
119 "character), OR just `symbol` and the first whole-word match ",
120 "in the file is used.\n\n",
121 "Returns absolute paths with 1-based positions plus the ",
122 "source line text, so you can jump straight to a read/edit ",
123 "call afterwards."
124 ),
125 &location_schema(),
126 ),
127 description_references: json_description(
128 "lsp_references",
129 concat!(
130 "Find all references to the symbol at a given position, ",
131 "grouped by file. Same hybrid addressing as definitions: ",
132 "`position` or bare `symbol`.\n\n",
133 "Use to gauge blast radius before changing a signature or to ",
134 "find every caller worth updating. Declaration inclusion can ",
135 "be toggled."
136 ),
137 &{
138 let mut schema = location_schema();
139 schema["properties"]["include_declaration"] = serde_json::json!({
140 "type": "boolean",
141 "description": "Include the declaration itself among references. Default true."
142 });
143 schema["properties"]["max_items"] = serde_json::json!({
144 "type": "integer",
145 "minimum": 1,
146 "description": "Maximum references returned. Default 100."
147 });
148 schema
149 },
150 ),
151 description_symbols: json_description(
152 "lsp_symbols",
153 concat!(
154 "List document symbols (functions, structs, methods…) with ",
155 "their kinds and positions, preserving hierarchy. Optional ",
156 "substring `query` filters names.\n\n",
157 "Cheaper than reading a whole file when you need its shape: ",
158 "call this first, then read only the ranges you care about."
159 ),
160 &serde_json::json!({
161 "type": "object",
162 "required": ["file_path"],
163 "properties": {
164 "file_path": { "type": "string", "description": "File whose symbols are listed." },
165 "query": { "type": "string", "description": "Case-insensitive substring filter on symbol names." },
166 "max_items": { "type": "integer", "minimum": 1, "description": "Maximum symbols returned. Default 200." }
167 }
168 }),
169 ),
170 description_restart: json_description(
171 "lsp_restart",
172 concat!(
173 "Restart one language server (scoped to a file) or all of ",
174 "them. Use when servers look wedged: diagnostics frozen, ",
175 "queries timing out, or stale completions after config ",
176 "changes.\n\n",
177 "Scoped restarts re-open the touched file immediately; ",
178 "workspace-wide restarts take effect the next time a file ",
179 "is touched."
180 ),
181 &serde_json::json!({
182 "type": "object",
183 "properties": {
184 "file_path": { "type": "string", "description": "Restart only the server(s) serving this file. Omitted: restart everything running." }
185 }
186 }),
187 ),
188 description_rename: json_description(
189 "lsp_rename",
190 concat!(
191 "Rename a symbol across the workspace via the language ",
192 "server. TWO-PHASE by design: the first call (confirm ",
193 "omitted/false) returns the PLAN — files, edit counts, ",
194 "preview lines — and changes nothing. Review it, then ",
195 "re-call with confirm=true to write.\n\n",
196 "Addressing matches definitions: position OR bare symbol ",
197 "name. LSP errors on the touched files are reported ",
198 "automatically after each fs write/edit, so rely on that ",
199 "passive feedback to catch fallout."
200 ),
201 &{
202 let mut schema = location_schema();
203 schema["properties"]["new_name"] = serde_json::json!({
204 "type": "string",
205 "description": "The new name for the symbol."
206 });
207 schema["properties"]["confirm"] = serde_json::json!({
208 "type": "boolean",
209 "description": "Apply the rename. Default false: returns the plan only."
210 });
211 schema
212 },
213 ),
214 description_hover: json_description(
215 "lsp_hover",
216 concat!(
217 "Show type signature and documentation for the symbol at a ",
218 "position. Same hybrid addressing as definitions (position ",
219 "OR symbol).\n\n",
220 "Use when you need the exact type or doc comment without ",
221 "navigating away."
222 ),
223 &location_schema(),
224 ),
225 description_workspace_symbols: json_description(
226 "lsp_workspace_symbols",
227 concat!(
228 "Search symbols across the whole workspace (functions, ",
229 "structs, methods…) using the language server's index. ",
230 "Substring query on names.\n\n",
231 "Requires at least one server already running — touch any ",
232 "project file first if servers have not started."
233 ),
234 &serde_json::json!({
235 "type": "object",
236 "properties": {
237 "query": { "type": "string", "description": "Substring filter on symbol names. Empty: return all." },
238 "max_items": { "type": "integer", "minimum": 1, "description": "Maximum symbols returned. Default 100." }
239 }
240 }),
241 ),
242 description_call_hierarchy: json_description(
243 "lsp_call_hierarchy",
244 concat!(
245 "Incoming or outgoing calls around a symbol: what it calls ",
246 "(outgoing) or what calls it (incoming). Same hybrid ",
247 "addressing as definitions.\n\n",
248 "Use to trace call chains before refactoring or to map a ",
249 "code path end-to-end."
250 ),
251 &{
252 let mut schema = location_schema();
253 schema["properties"]["direction"] = serde_json::json!({
254 "type": "string",
255 "enum": ["incoming", "outgoing"],
256 "description": "Direction of the hierarchy. Default outgoing."
257 });
258 schema["properties"]["max_items"] = serde_json::json!({
259 "type": "integer",
260 "minimum": 1,
261 "description": "Maximum calls returned. Default 50."
262 });
263 schema
264 },
265 ),
266 description_code_actions: json_description(
267 "lsp_code_actions",
268 concat!(
269 "Quickfixes and refactoring suggestions for the error at ",
270 "the given line. The language server knows how to fix its ",
271 "own diagnostics — missing imports, wrong types, unused ",
272 "variables.\n\n",
273 "Two-phase: first call lists available actions. Re-call ",
274 "with apply_index to apply one to disk."
275 ),
276 &serde_json::json!({
277 "type": "object",
278 "required": ["file_path", "line"],
279 "properties": {
280 "file_path": { "type": "string", "description": "File containing the error." },
281 "line": { "type": "integer", "minimum": 1, "description": "1-based line where the problem is." },
282 "apply_index": { "type": "integer", "minimum": 0, "description": "Apply the Nth action's edit instead of listing." }
283 }
284 }),
285 ),
286 }
287 }
288
289 #[must_use]
291 pub fn root(&self) -> &Path {
292 self.guard.root()
293 }
294
295 #[must_use]
297 pub fn diagnostics_engine(&self) -> &Arc<DiagnosticsEngine> {
298 &self.diagnostics
299 }
300
301 #[must_use]
303 pub fn manager(&self) -> &Arc<Manager> {
304 &self.manager
305 }
306
307 #[must_use]
309 pub fn request_timeout(&self) -> Duration {
310 self.request_timeout
311 }
312
313 #[must_use]
315 pub fn settle_cap(&self) -> Duration {
316 self.settle_cap
317 }
318
319 #[must_use]
321 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
322 self.request_timeout = timeout;
323 self
324 }
325
326 #[must_use]
328 pub fn with_settle_cap(mut self, cap: Duration) -> Self {
329 self.settle_cap = cap;
330 self
331 }
332
333 #[must_use]
335 pub fn allowlist(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
336 let list: Vec<PathBuf> = paths.into_iter().map(Into::into).collect();
337 self.guard = PathGuard::new(self.guard.root(), Some(&list), self.guard.blocklist());
338 self
339 }
340
341 fn deps(&self) -> support::Deps<'_> {
342 support::Deps {
343 manager: &self.manager,
344 diagnostics: &self.diagnostics,
345 request_timeout: self.request_timeout,
346 settle_cap: self.settle_cap,
347 }
348 }
349
350 fn resolve(&self, raw: &str) -> Result<PathBuf, String> {
351 self.guard.resolve(raw)
352 }
353
354 pub async fn diagnostics(
356 &self,
357 input: &types::DiagnosticsInput,
358 ) -> Result<types::DiagnosticsOutput, String> {
359 let mut scoped = input.clone();
360 if let Some(file_path) = &input.file_path {
361 scoped.file_path = Some(self.resolve(file_path)?.display().to_string());
362 }
363 diagnostics::run_diagnostics(&self.deps(), &scoped).await
364 }
365
366 pub async fn definitions(
368 &self,
369 input: &types::DefinitionsInput,
370 ) -> Result<types::DefinitionsOutput, String> {
371 let mut scoped = input.clone();
372 scoped.location.file_path = self
373 .resolve(&input.location.file_path)?
374 .display()
375 .to_string();
376 definitions::run_definitions(&self.deps(), &scoped).await
377 }
378
379 pub async fn references(
381 &self,
382 input: &types::ReferencesInput,
383 ) -> Result<types::ReferencesOutput, String> {
384 let mut scoped = input.clone();
385 scoped.location.file_path = self
386 .resolve(&input.location.file_path)?
387 .display()
388 .to_string();
389 references::run_references(&self.deps(), &scoped).await
390 }
391
392 pub async fn symbols(
394 &self,
395 input: &types::SymbolsInput,
396 ) -> Result<types::SymbolsOutput, String> {
397 let mut scoped = input.clone();
398 scoped.file_path = self.resolve(&input.file_path)?.display().to_string();
399 symbols::run_symbols(&self.deps(), &scoped).await
400 }
401
402 pub async fn restart(
404 &self,
405 input: &types::RestartInput,
406 ) -> Result<types::RestartOutput, String> {
407 let mut scoped = input.clone();
408 if let Some(file_path) = &input.file_path {
409 scoped.file_path = Some(self.resolve(file_path)?.display().to_string());
410 }
411 restart::run_restart(&self.deps(), &scoped).await
412 }
413
414 pub async fn rename(&self, input: &types::RenameInput) -> Result<types::RenameOutput, String> {
416 let mut scoped = input.clone();
417 scoped.file_path = self.resolve(&input.file_path)?.display().to_string();
418 rename::run_rename(&self.deps(), &scoped).await
419 }
420
421 pub async fn hover(&self, input: &types::HoverInput) -> Result<types::HoverOutput, String> {
423 let mut scoped = input.clone();
424 scoped.file_path = self.resolve(&input.file_path)?.display().to_string();
425 hover::run_hover(&self.deps(), &scoped).await
426 }
427
428 pub async fn workspace_symbols(
430 &self,
431 input: &types::WorkspaceSymbolsInput,
432 ) -> Result<types::WorkspaceSymbolsOutput, String> {
433 workspace_symbols::run_workspace_symbols(&self.deps(), input).await
434 }
435
436 pub async fn call_hierarchy(
438 &self,
439 input: &types::CallHierarchyInput,
440 ) -> Result<types::CallHierarchyOutput, String> {
441 let mut scoped = input.clone();
442 scoped.file_path = self.resolve(&input.file_path)?.display().to_string();
443 call_hierarchy::run_call_hierarchy(&self.deps(), &scoped).await
444 }
445
446 pub async fn code_actions(
448 &self,
449 input: &types::CodeActionsInput,
450 ) -> Result<types::CodeActionsOutput, String> {
451 let mut scoped = input.clone();
452 scoped.file_path = self.resolve(&input.file_path)?.display().to_string();
453 code_actions::run_code_actions(&self.deps(), &scoped).await
454 }
455
456 #[must_use]
458 pub fn keys_for_file(&self, raw: &str) -> Vec<ClientKey> {
459 match self.guard.resolve(raw) {
460 Ok(path) => self
461 .manager
462 .matches_for_file(&path)
463 .into_iter()
464 .map(|(key, _)| key)
465 .collect(),
466 Err(_) => Vec::new(),
467 }
468 }
469}
470
471fn location_schema() -> serde_json::Value {
472 serde_json::json!({
473 "type": "object",
474 "required": ["file_path"],
475 "properties": {
476 "file_path": { "type": "string", "description": "File to query." },
477 "position": {
478 "type": "object",
479 "description": "Target position (1-based). Provide this OR `symbol`.",
480 "required": ["line", "character"],
481 "properties": {
482 "line": { "type": "integer", "minimum": 1 },
483 "character": { "type": "integer", "minimum": 1 }
484 }
485 },
486 "symbol": {
487 "type": "string",
488 "description": "Symbol name; its first whole-word occurrence in the file is queried. Used when `position` is omitted."
489 }
490 }
491 })
492}
493
494fn json_description(
495 name: &'static str,
496 description: &'static str,
497 properties: &serde_json::Value,
498) -> ToolDescription {
499 let mut schema = serde_json::json!({
500 "type": "object",
501 "properties": properties["properties"].clone(),
502 });
503 if let Some(required) = properties.get("required") {
504 schema["required"] = required.clone();
505 }
506 serde_json::json!({
507 "name": name,
508 "description": description,
509 "inputSchema": schema,
510 })
511}