返回 CodeWhale
lsp.rs
根目录 / crates / tui / src / tools / lsp.rs
1 //! Model-facing LSP code-intelligence tool.
2 //!
3 //! Extends the existing [`crate::lsp::LspManager`] lifecycle — never spawns a
4 //! competing server pool. Operations: diagnostics, symbols, definition,
5 //! references.
6
7 use async_trait::async_trait;
8 use serde_json::{Value, json};
9
10 use super::spec::{
11 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
12 optional_str, required_str,
13 };
14
15 /// Model-callable LSP intelligence surface.
16 pub struct LspTool;
17
18 #[async_trait]
19 impl ToolSpec for LspTool {
20 fn name(&self) -> &'static str {
21 "lsp"
22 }
23
24 fn description(&self) -> &'static str {
25 "Query language-server intelligence for a file: diagnostics, document \
26 or workspace symbols, go-to-definition, and find-references. Reuses \
27 the session LSP manager (no separate server lifecycle). Requires \
28 `[lsp] enabled = true` and a configured server for the file language."
29 }
30
31 fn input_schema(&self) -> Value {
32 json!({
33 "type": "object",
34 "properties": {
35 "operation": {
36 "type": "string",
37 "enum": ["diagnostics", "symbols", "definition", "references"],
38 "description": "Intelligence operation to run."
39 },
40 "path": {
41 "type": "string",
42 "description": "Workspace-relative or absolute path to the source file."
43 },
44 "line": {
45 "type": "integer",
46 "minimum": 1,
47 "description": "1-based line for definition/references."
48 },
49 "character": {
50 "type": "integer",
51 "minimum": 1,
52 "description": "1-based column for definition/references (default 1)."
53 },
54 "query": {
55 "type": "string",
56 "description": "Optional workspace symbol query when operation=symbols."
57 }
58 },
59 "required": ["operation", "path"]
60 })
61 }
62
63 fn capabilities(&self) -> Vec<ToolCapability> {
64 vec![ToolCapability::ReadOnly]
65 }
66
67 fn approval_requirement(&self) -> ApprovalRequirement {
68 ApprovalRequirement::Auto
69 }
70
71 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
72 let operation = required_str(&input, "operation")?;
73 let path_raw = required_str(&input, "path")?;
74 let line = input.get("line").and_then(|v| v.as_u64()).map(|n| n as u32);
75 let character = input
76 .get("character")
77 .and_then(|v| v.as_u64())
78 .map(|n| n as u32);
79 let query = optional_str(&input, "query")?;
80
81 let manager = context.lsp_manager.as_ref().ok_or_else(|| {
82 ToolError::execution_failed(
83 "LSP manager is not attached to this tool context (LSP unavailable for this session)",
84 )
85 })?;
86
87 let path = resolve_workspace_path(&context.workspace, path_raw);
88 let payload = manager
89 .intelligence(operation, &path, line, character, query)
90 .await
91 .map_err(ToolError::execution_failed)?;
92
93 Ok(ToolResult::success(
94 serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()),
95 ))
96 }
97 }
98
99 fn resolve_workspace_path(workspace: &std::path::Path, raw: &str) -> std::path::PathBuf {
100 let candidate = std::path::PathBuf::from(raw);
101 if candidate.is_absolute() {
102 candidate
103 } else {
104 workspace.join(candidate)
105 }
106 }
107
108 #[cfg(test)]
109 mod tests {
110 use super::*;
111 use crate::lsp::{Diagnostic, Language, LspConfig, LspManager, Severity};
112 use crate::tools::spec::ToolContext;
113 use async_trait::async_trait;
114 use std::path::Path;
115 use std::sync::Arc;
116 use std::sync::atomic::{AtomicUsize, Ordering};
117 use std::time::Duration;
118 use tempfile::tempdir;
119
120 struct CountingTransport {
121 calls: AtomicUsize,
122 request_calls: AtomicUsize,
123 }
124
125 #[async_trait]
126 impl crate::lsp::LspTransport for CountingTransport {
127 async fn diagnostics_for(
128 &self,
129 _path: &Path,
130 _text: &str,
131 _wait: Duration,
132 ) -> anyhow::Result<Vec<Diagnostic>> {
133 self.calls.fetch_add(1, Ordering::Relaxed);
134 Ok(vec![Diagnostic {
135 line: 1,
136 column: 1,
137 severity: Severity::Error,
138 message: "boom".into(),
139 }])
140 }
141
142 async fn request(
143 &self,
144 method: &str,
145 _params: Value,
146 _wait: Duration,
147 ) -> anyhow::Result<Value> {
148 self.request_calls.fetch_add(1, Ordering::Relaxed);
149 Ok(json!({ "method": method, "locations": [] }))
150 }
151
152 async fn shutdown(&self) {}
153 }
154
155 #[tokio::test]
156 async fn tool_reuses_single_manager_transport_for_definition() {
157 let dir = tempdir().unwrap();
158 let path = dir.path().join("lib.rs");
159 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
160
161 let mgr = Arc::new(LspManager::new(
162 LspConfig::default(),
163 dir.path().to_path_buf(),
164 ));
165 let transport = Arc::new(CountingTransport {
166 calls: AtomicUsize::new(0),
167 request_calls: AtomicUsize::new(0),
168 });
169 mgr.install_test_transport(Language::Rust, transport.clone())
170 .await;
171
172 let mut ctx = ToolContext::new(dir.path());
173 ctx = ctx.with_lsp_manager(mgr);
174
175 let tool = LspTool;
176 for _ in 0..2 {
177 let result = tool
178 .execute(
179 json!({
180 "operation": "definition",
181 "path": "lib.rs",
182 "line": 1,
183 "character": 4
184 }),
185 &ctx,
186 )
187 .await
188 .expect("definition succeeds");
189 assert!(result.success, "{}", result.content);
190 assert!(result.content.contains("definition"));
191 }
192 assert_eq!(
193 transport.request_calls.load(Ordering::Relaxed),
194 2,
195 "two definition calls"
196 );
197 }
198
199 #[tokio::test]
200 async fn diagnostics_operation_returns_items() {
201 let dir = tempdir().unwrap();
202 let path = dir.path().join("lib.rs");
203 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
204
205 let mgr = Arc::new(LspManager::new(
206 LspConfig::default(),
207 dir.path().to_path_buf(),
208 ));
209 let transport = Arc::new(CountingTransport {
210 calls: AtomicUsize::new(0),
211 request_calls: AtomicUsize::new(0),
212 });
213 mgr.install_test_transport(Language::Rust, transport.clone())
214 .await;
215
216 let mut ctx = ToolContext::new(dir.path());
217 ctx = ctx.with_lsp_manager(mgr);
218
219 let result = LspTool
220 .execute(
221 json!({ "operation": "diagnostics", "path": "lib.rs" }),
222 &ctx,
223 )
224 .await
225 .expect("diagnostics");
226 assert!(result.success);
227 assert!(result.content.contains("boom"));
228 assert_eq!(transport.calls.load(Ordering::Relaxed), 1);
229 }
230
231 #[tokio::test]
232 async fn disabled_lsp_hard_blocks_tool() {
233 let dir = tempdir().unwrap();
234 let path = dir.path().join("lib.rs");
235 tokio::fs::write(&path, b"fn main() {}").await.unwrap();
236 let mgr = Arc::new(LspManager::new(
237 LspConfig {
238 enabled: false,
239 ..LspConfig::default()
240 },
241 dir.path().to_path_buf(),
242 ));
243 let mut ctx = ToolContext::new(dir.path());
244 ctx = ctx.with_lsp_manager(mgr);
245 let err = LspTool
246 .execute(
247 json!({ "operation": "diagnostics", "path": "lib.rs" }),
248 &ctx,
249 )
250 .await
251 .expect_err("disabled must fail");
252 assert!(
253 err.to_string().contains("disabled"),
254 "unexpected error: {err}"
255 );
256 }
257 }
258
258 lines RUST