Skip to main content

cosh_tools/lsp/
mod.rs

1//! Language-server tools built on the `cosh-sdk` LSP engine.
2//!
3//! - [`diagnostics::run_diagnostics`]: settle and render diagnostics. Not
4//!   exposed to the agent as a tool; the fs wrapper consumes it directly to
5//!   attach passive findings to write/edit/rollback results.
6//! - [`definitions::run_definitions`]: go to definition (hybrid addressing).
7//! - [`references::run_references`]: find references grouped by file.
8//! - [`symbols::run_symbols`]: document symbols with hierarchy.
9//! - [`restart::run_restart`]: targeted or workspace-wide server restart.
10//! - [`rename::run_rename`]: two-phase rename (plan → confirm) applying
11//!   WorkspaceEdit to disk with negotiated-encoding offsets.
12//! - [`hover::run_hover`]: type/signature documentation at a position.
13//! - [`workspace_symbols::run_workspace_symbols`]: project-wide symbol search.
14//! - [`call_hierarchy::run_call_hierarchy`]: incoming/outgoing calls.
15//! - [`code_actions::run_code_actions`]: quickfixes at a diagnostic position.
16//!
17//! [`Lsp`] bundles the [`Manager`] + [`DiagnosticsEngine`] pair every engine
18//! shares, plus the path guard applied to model-supplied paths. Server
19//! binaries must be on `PATH`; discovery is lazy — the first tool call that
20//! touches a file starts its server.
21//!
22//! # Example
23//!
24//! ```ignore
25//! use cosh_tools::lsp::Lsp;
26//!
27//! let lsp = Lsp::new("/project");
28//! lsp.diagnostics(&DiagnosticsInput {
29//!     file_path: Some("src/main.rs".into()),
30//!     ..Default::default()
31//! }).await;
32//! ```
33
34pub 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
60/// Shared-state wrapper for language-server tool operations.
61///
62/// The manager and diagnostics store live behind `Arc`s; session layers that
63/// need the same state elsewhere (e.g. an event pump) clone those handles
64/// directly via [`Lsp::manager`] / [`Lsp::diagnostics_engine`].
65pub struct Lsp {
66    manager: Arc<Manager>,
67    diagnostics: Arc<DiagnosticsEngine>,
68    guard: PathGuard,
69    request_timeout: Duration,
70    settle_cap: Duration,
71
72    /// MCP Tool description for `lsp_definitions`.
73    pub description_definitions: ToolDescription,
74    /// MCP Tool description for `lsp_references`.
75    pub description_references: ToolDescription,
76    /// MCP Tool description for `lsp_symbols`.
77    pub description_symbols: ToolDescription,
78    /// MCP Tool description for `lsp_restart`.
79    pub description_restart: ToolDescription,
80    /// MCP Tool description for `lsp_rename`.
81    pub description_rename: ToolDescription,
82    /// MCP Tool description for `lsp_hover`.
83    pub description_hover: ToolDescription,
84    /// MCP Tool description for `lsp_workspace_symbols`.
85    pub description_workspace_symbols: ToolDescription,
86    /// MCP Tool description for `lsp_call_hierarchy`.
87    pub description_call_hierarchy: ToolDescription,
88    /// MCP Tool description for `lsp_code_actions`.
89    pub description_code_actions: ToolDescription,
90}
91
92impl Lsp {
93    /// Wrapper bound to a workspace root with default budgets (10 s per
94    /// query, 5 s settle cap).
95    #[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    /// Wrapper over an existing manager/engine pair — the wiring used by
104    /// session layers that already pump events into the diagnostics store.
105    #[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    /// Workspace root this wrapper serves.
290    #[must_use]
291    pub fn root(&self) -> &Path {
292        self.guard.root()
293    }
294
295    /// The shared diagnostics store (for event pumps feeding it).
296    #[must_use]
297    pub fn diagnostics_engine(&self) -> &Arc<DiagnosticsEngine> {
298        &self.diagnostics
299    }
300
301    /// The underlying manager (session layers start/stop through it too).
302    #[must_use]
303    pub fn manager(&self) -> &Arc<Manager> {
304        &self.manager
305    }
306
307    /// Per-request deadline used by the query engines.
308    #[must_use]
309    pub fn request_timeout(&self) -> Duration {
310        self.request_timeout
311    }
312
313    /// Settle budget handed to the diagnostics engine after touches.
314    #[must_use]
315    pub fn settle_cap(&self) -> Duration {
316        self.settle_cap
317    }
318
319    /// Override the per-request query deadline.
320    #[must_use]
321    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
322        self.request_timeout = timeout;
323        self
324    }
325
326    /// Override the settle budget.
327    #[must_use]
328    pub fn with_settle_cap(mut self, cap: Duration) -> Self {
329        self.settle_cap = cap;
330        self
331    }
332
333    /// Narrow the path guard's allowlist (same semantics as other tools).
334    #[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    /// `lsp_diagnostics` entry point.
355    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    /// `lsp_definitions` entry point.
367    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    /// `lsp_references` entry point.
380    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    /// `lsp_symbols` entry point.
393    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    /// `lsp_restart` entry point.
403    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    /// `lsp_rename` entry point.
415    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    /// `lsp_hover` entry point.
422    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    /// `lsp_workspace_symbols` entry point.
429    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    /// `lsp_call_hierarchy` entry point.
437    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    /// `lsp_code_actions` entry point.
447    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    /// Key lookup helper for session layers driving `stop_client` directly.
457    #[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}