xcodeai/tools/
lsp_references.rs1use crate::tools::lsp_diagnostics::{detect_language_id, ensure_lsp_started, path_to_uri};
13use crate::tools::lsp_goto_def::parse_locations;
14use crate::tools::{Tool, ToolContext, ToolResult};
15use anyhow::Result;
16use async_trait::async_trait;
17use serde_json::json;
18
19pub struct LspReferencesTool;
20
21#[async_trait]
22impl Tool for LspReferencesTool {
23 fn name(&self) -> &str {
24 "lsp_find_references"
25 }
26
27 fn description(&self) -> &str {
28 "Find all usages/references to a symbol using the Language Server Protocol. \
29 Provide the file path and cursor position (0-indexed). \
30 Returns file:line:col for each reference location."
31 }
32
33 fn parameters_schema(&self) -> serde_json::Value {
34 json!({
35 "type": "object",
36 "properties": {
37 "path": {
38 "type": "string",
39 "description": "Path to the source file"
40 },
41 "line": {
42 "type": "integer",
43 "description": "0-indexed line number of the cursor position"
44 },
45 "character": {
46 "type": "integer",
47 "description": "0-indexed character offset on the line"
48 },
49 "include_declaration": {
50 "type": "boolean",
51 "description": "Include the declaration site in results (default: true)"
52 }
53 },
54 "required": ["path", "line", "character"]
55 })
56 }
57
58 async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> Result<ToolResult> {
59 let path_str = match args["path"].as_str() {
61 Some(p) => p,
62 None => {
63 return Ok(ToolResult {
64 output: "Missing required parameter: path".into(),
65 is_error: true,
66 })
67 }
68 };
69 let line = match args["line"].as_u64() {
70 Some(l) => l,
71 None => {
72 return Ok(ToolResult {
73 output: "Missing required parameter: line".into(),
74 is_error: true,
75 })
76 }
77 };
78 let character = match args["character"].as_u64() {
79 Some(c) => c,
80 None => {
81 return Ok(ToolResult {
82 output: "Missing required parameter: character".into(),
83 is_error: true,
84 })
85 }
86 };
87 let include_declaration = args["include_declaration"].as_bool().unwrap_or(true);
90
91 let abs_path = if std::path::Path::new(path_str).is_absolute() {
93 std::path::PathBuf::from(path_str)
94 } else {
95 ctx.working_dir.join(path_str)
96 };
97
98 if !abs_path.exists() {
99 return Ok(ToolResult {
100 output: format!("File not found: {}", abs_path.display()),
101 is_error: true,
102 });
103 }
104
105 let mut guard = ctx.lsp_client.lock().await;
107 if guard.is_none() {
108 match ensure_lsp_started(&ctx.working_dir).await {
109 Ok(client) => *guard = Some(client),
110 Err(e) => {
111 return Ok(ToolResult {
112 output: format!("LSP server not available: {}", e),
113 is_error: true,
114 })
115 }
116 }
117 }
118 let client = guard.as_mut().unwrap();
119
120 let file_uri = path_to_uri(&abs_path);
121 let lang_id = detect_language_id(&abs_path);
122
123 let content = std::fs::read_to_string(&abs_path).unwrap_or_default();
125 let _ = client
126 .send_notification(
127 "textDocument/didOpen",
128 json!({
129 "textDocument": {
130 "uri": file_uri,
131 "languageId": lang_id,
132 "version": 1,
133 "text": content
134 }
135 }),
136 )
137 .await;
138
139 let result = client
143 .send_request(
144 "textDocument/references",
145 json!({
146 "textDocument": { "uri": file_uri },
147 "position": { "line": line, "character": character },
148 "context": { "includeDeclaration": include_declaration }
149 }),
150 )
151 .await;
152
153 let _ = client
155 .send_notification(
156 "textDocument/didClose",
157 json!({ "textDocument": { "uri": file_uri } }),
158 )
159 .await;
160
161 match result {
163 Err(e) => Ok(ToolResult {
164 output: format!("LSP references request failed: {}", e),
165 is_error: true,
166 }),
167 Ok(val) => {
168 let locations = parse_locations(&val);
169 if locations.is_empty() {
170 Ok(ToolResult {
171 output: "No references found.".into(),
172 is_error: false,
173 })
174 } else {
175 Ok(ToolResult {
176 output: format!(
177 "Found {} reference(s):\n{}",
178 locations.len(),
179 locations.join("\n")
180 ),
181 is_error: false,
182 })
183 }
184 }
185 }
186 }
187}
188
189#[cfg(test)]
192mod tests {
193 use super::*;
194 use crate::io::NullIO;
195 use std::sync::Arc;
196 use tokio::sync::Mutex;
197
198 fn ctx() -> ToolContext {
199 ToolContext {
200 working_dir: std::path::PathBuf::from("/tmp"),
201 sandbox_enabled: false,
202 io: Arc::new(NullIO),
203 compact_mode: false,
204 lsp_client: Arc::new(Mutex::new(None)),
205 mcp_client: None,
206 nesting_depth: 0,
207 llm: std::sync::Arc::new(crate::llm::NullLlmProvider),
208 tools: std::sync::Arc::new(crate::tools::ToolRegistry::new()),
209 }
210 }
211
212 #[test]
213 fn test_lsp_references_metadata() {
214 let tool = LspReferencesTool;
215 assert_eq!(tool.name(), "lsp_find_references");
216 assert!(!tool.description().is_empty());
217 let schema = tool.parameters_schema();
218 let required = schema["required"].as_array().unwrap();
219 assert!(required.contains(&json!("path")));
220 assert!(required.contains(&json!("line")));
221 assert!(required.contains(&json!("character")));
222 assert!(!required.contains(&json!("include_declaration")));
224 }
225
226 #[tokio::test]
227 async fn test_lsp_references_missing_params() {
228 let tool = LspReferencesTool;
229 let result = tool.execute(json!({}), &ctx()).await.unwrap();
230 assert!(result.is_error);
231 assert!(result.output.contains("Missing required parameter"));
232 }
233
234 #[tokio::test]
235 async fn test_lsp_references_nonexistent_file() {
236 let tool = LspReferencesTool;
237 let result = tool
238 .execute(
239 json!({ "path": "/nonexistent/file.rs", "line": 0, "character": 0 }),
240 &ctx(),
241 )
242 .await
243 .unwrap();
244 assert!(result.is_error);
245 }
246
247 #[tokio::test]
248 async fn test_lsp_references_include_declaration_default() {
249 let tool = LspReferencesTool;
251 let result = tool
253 .execute(
254 json!({ "path": "/nonexistent/file.rs", "line": 5, "character": 10 }),
255 &ctx(),
256 )
257 .await
258 .unwrap();
259 assert!(result.is_error);
261 assert!(!result.output.contains("Missing required parameter"));
262 }
263}