Skip to main content

xcodeai/tools/
lsp_references.rs

1// src/tools/lsp_references.rs
2//
3// LSP Find-References tool — asks the language server for all usages of a
4// symbol at a given cursor position.
5//
6// Uses: textDocument/references request
7// Returns: "file:line:col" for each reference location found.
8//
9// Reuses `parse_locations` from lsp_goto_def and shared helpers from
10// lsp_diagnostics to avoid code duplication.
11
12use 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        // ── 1. Validate required parameters ──────────────────────────────────
60        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        // Whether to include the declaration site itself in the results.
88        // Defaults to true, matching the LSP spec default.
89        let include_declaration = args["include_declaration"].as_bool().unwrap_or(true);
90
91        // ── 2. Resolve path ───────────────────────────────────────────────────
92        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        // ── 3. Lazy LSP startup ───────────────────────────────────────────────
106        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        // ── 4. Open the document ──────────────────────────────────────────────
124        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        // ── 5. Send the references request ────────────────────────────────────
140        // The `context.includeDeclaration` flag controls whether the symbol's
141        // own definition site is included alongside its call sites.
142        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        // ── 6. Close the document ─────────────────────────────────────────────
154        let _ = client
155            .send_notification(
156                "textDocument/didClose",
157                json!({ "textDocument": { "uri": file_uri } }),
158            )
159            .await;
160
161        // ── 7. Format and return results ──────────────────────────────────────
162        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// ─── Unit tests ──────────────────────────────────────────────────────────────
190
191#[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        // include_declaration is optional
223        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        // With no include_declaration param, should default to true (not error out)
250        let tool = LspReferencesTool;
251        // /nonexistent won't get to the LSP call, but we can verify it accepted the params
252        let result = tool
253            .execute(
254                json!({ "path": "/nonexistent/file.rs", "line": 5, "character": 10 }),
255                &ctx(),
256            )
257            .await
258            .unwrap();
259        // Still an error (file not found), but NOT a missing-param error
260        assert!(result.is_error);
261        assert!(!result.output.contains("Missing required parameter"));
262    }
263}