Skip to main content

winx_code_agent/tools/
references.rs

1//! Implementation of the `FindReferences` tool — tree-sitter symbol occurrences.
2//!
3//! Finds where an identifier is defined and referenced (called / used) across
4//! the workspace, by name. It is a *name-based* semantic lookup (it counts only
5//! real identifier occurrences, never matches inside strings or comments like a
6//! raw grep would), built on the same `tree-sitter-tags` engine as `Outline`.
7//! Read-only, gitignore-aware, workspace-confined; works in every mode.
8
9use std::collections::HashMap;
10use std::fmt::Write as FmtWrite;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use tokio::sync::Mutex;
15use tree_sitter_tags::{TagsConfiguration, TagsContext};
16
17use crate::errors::{Result, WinxError};
18use crate::state::bash_state::BashState;
19use crate::types::{FindReferences, ReferenceHit, ReferencesOutput};
20use crate::utils::mmap::read_file_to_string;
21use crate::utils::path::resolve_in_workspace;
22use crate::utils::repo::walk_workspace_files;
23use crate::utils::symbols;
24
25/// Cap on occurrences returned when the caller passes 0.
26const DEFAULT_MAX_HITS: usize = 200;
27/// Skip files larger than this when scanning.
28const MAX_FILE_SIZE: u64 = 2_000_000;
29/// Stop after reading+parsing this many files (a scan-budget cap).
30const MAX_FILES_SCANNED: usize = 20_000;
31
32type Configs = HashMap<String, Option<TagsConfiguration>>;
33
34pub async fn handle_tool_call(
35    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
36    args: FindReferences,
37) -> Result<(String, serde_json::Value)> {
38    let (cwd, workspace_root) = {
39        let guard = bash_state_arc.lock().await;
40        let bash_state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
41        (bash_state.cwd.clone(), bash_state.workspace_root.clone())
42    };
43
44    tokio::task::spawn_blocking(move || references_with_paths(args, &cwd, workspace_root))
45        .await
46        .map_err(|error| {
47            WinxError::CommandExecutionError(format!("CodeMap references worker failed: {error}"))
48        })?
49}
50
51fn references_with_paths(
52    args: FindReferences,
53    cwd: &Path,
54    workspace_root: PathBuf,
55) -> Result<(String, serde_json::Value)> {
56    let workspace_root = workspace_root.canonicalize().unwrap_or(workspace_root);
57
58    if args.name.trim().is_empty() {
59        return Err(WinxError::ArgumentParseError("Symbol name must not be empty.".to_string()));
60    }
61
62    let root = resolve_in_workspace(&args.path, cwd, &workspace_root).map_err(|e| {
63        WinxError::PathSecurityError { path: PathBuf::from(&args.path), message: e.to_string() }
64    })?;
65    let max = if args.max_results == 0 { DEFAULT_MAX_HITS } else { args.max_results };
66
67    // Don't silently degrade a typo'd file path into an empty whole-workspace
68    // scan (mirrors Outline).
69    let candidates: Vec<PathBuf> = if root.is_file() {
70        vec![root.clone()]
71    } else if root.is_dir() {
72        walk_workspace_files(&root)
73    } else {
74        return Err(WinxError::FileAccessError {
75            path: root.clone(),
76            message: "path not found (or not a regular file/directory)".to_string(),
77        });
78    };
79
80    let mut context = TagsContext::new();
81    let mut configs: Configs = HashMap::new();
82    // Two buckets: definitions are collected UNCAPPED (there are few, and losing
83    // one is the worst failure), only references are capped at `max`. The walk is
84    // stopped only by the file budget — never by the hit count — so a definition
85    // in a late-walked file is never missed (which the naive top-of-loop cap did).
86    let mut defs: Vec<ReferenceHit> = Vec::new();
87    let mut refs: Vec<ReferenceHit> = Vec::new();
88    let mut scanned = 0usize;
89    let mut truncated = false;
90
91    for abs in candidates {
92        if scanned >= MAX_FILES_SCANNED {
93            truncated = true;
94            break;
95        }
96        let ext = ext_of(&abs);
97        if !symbols::supports(&ext) {
98            continue;
99        }
100        let Ok(text) = read_file_to_string(&abs, MAX_FILE_SIZE) else { continue };
101        scanned += 1;
102        let rel = rel_of(&abs, &workspace_root);
103        let syms = match config_for(&mut configs, &ext) {
104            Some(config) => symbols::extract_all(&mut context, config, &text),
105            None => continue,
106        };
107        for s in syms {
108            if s.name != args.name {
109                continue;
110            }
111            let hit = ReferenceHit {
112                file: rel.clone(),
113                line: s.line,
114                kind: s.kind,
115                is_definition: s.is_definition,
116            };
117            if s.is_definition {
118                defs.push(hit);
119            } else if refs.len() < max {
120                refs.push(hit);
121            } else {
122                truncated = true; // more references than the cap allows
123            }
124        }
125    }
126
127    let by_loc =
128        |a: &ReferenceHit, b: &ReferenceHit| a.file.cmp(&b.file).then_with(|| a.line.cmp(&b.line));
129    defs.sort_by(by_loc);
130    refs.sort_by(by_loc);
131    let definitions = defs.len();
132    let references = refs.len();
133    let mut hits = defs;
134    hits.extend(refs); // definitions first, then references
135
136    let out = render(&args.name, &hits, definitions, references, truncated, &root);
137    let structured = ReferencesOutput { name: args.name, definitions, references, truncated, hits };
138    Ok((out, crate::tools::structured_json(&structured)?))
139}
140
141fn render(
142    name: &str,
143    hits: &[ReferenceHit],
144    definitions: usize,
145    references: usize,
146    truncated: bool,
147    root: &Path,
148) -> String {
149    if hits.is_empty() {
150        return format!("No occurrences of `{name}` found under {}.", root.display());
151    }
152    let mut out = format!("`{name}` — {definitions} definition(s), {references} reference(s):\n");
153    for h in hits {
154        let tag = if h.is_definition { "def" } else { "ref" };
155        let loc = format!("{}:{}", h.file, h.line);
156        let _ = writeln!(out, "  {tag}  {loc:<40} {:<9} {name}", h.kind);
157    }
158    if truncated {
159        let _ = write!(out, "(...capped; narrow `path` or raise max_results)");
160    }
161    out
162}
163
164/// Lowercase extension of `path` ("" if none).
165fn ext_of(path: &Path) -> String {
166    path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_lowercase()
167}
168
169/// Workspace-relative display path.
170fn rel_of(path: &Path, workspace_root: &Path) -> String {
171    path.strip_prefix(workspace_root).unwrap_or(path).to_string_lossy().to_string()
172}
173
174/// Compile-once-per-language cached config lookup.
175fn config_for<'a>(configs: &'a mut Configs, ext: &str) -> Option<&'a TagsConfiguration> {
176    configs.entry(ext.to_string()).or_insert_with(|| symbols::config_for(ext)).as_ref()
177}
178
179#[cfg(test)]
180mod tests {
181    #![allow(clippy::unwrap_used)]
182    use super::*;
183    use tempfile::TempDir;
184
185    fn state_in(dir: &TempDir) -> Arc<Mutex<Option<BashState>>> {
186        let mut bs = BashState::new();
187        let root = dir.path().canonicalize().unwrap();
188        bs.cwd = root.clone();
189        bs.workspace_root = root;
190        Arc::new(Mutex::new(Some(bs)))
191    }
192
193    fn args(name: &str) -> FindReferences {
194        FindReferences {
195            name: name.to_string(),
196            path: String::new(),
197            max_results: 0,
198            thread_id: String::new(),
199        }
200    }
201
202    #[tokio::test]
203    async fn finds_definition_and_references() {
204        let dir = TempDir::new().unwrap();
205        std::fs::write(
206            dir.path().join("a.rs"),
207            "fn target() {}\nfn caller() { target(); target(); }\n",
208        )
209        .unwrap();
210        let st = state_in(&dir);
211        let (out, structured) = handle_tool_call(&st, args("target")).await.unwrap();
212        assert!(out.contains("target"));
213        assert_eq!(structured["definitions"], 1);
214        assert!(structured["references"].as_u64().unwrap() >= 1);
215        // definition is listed first
216        assert_eq!(structured["hits"][0]["is_definition"], true);
217    }
218
219    #[tokio::test]
220    async fn finds_python_and_elixir_definitions_and_calls() {
221        let dir = TempDir::new().unwrap();
222        std::fs::write(
223            dir.path().join("service.py"),
224            "def python_target():\n    return 1\n\npython_target()\n",
225        )
226        .unwrap();
227        std::fs::write(
228            dir.path().join("worker.ex"),
229            "defmodule Worker do\n  def elixir_target(), do: :ok\n  def run(), do: elixir_target()\nend\n",
230        )
231        .unwrap();
232        let st = state_in(&dir);
233
234        let (_, python) = handle_tool_call(&st, args("python_target")).await.unwrap();
235        assert_eq!(python["definitions"], 1);
236        assert!(python["references"].as_u64().unwrap() >= 1, "{python}");
237
238        let (_, elixir) = handle_tool_call(&st, args("elixir_target")).await.unwrap();
239        assert_eq!(elixir["definitions"], 1);
240        assert!(elixir["references"].as_u64().unwrap() >= 1, "{elixir}");
241    }
242
243    #[tokio::test]
244    async fn empty_name_errors() {
245        let dir = TempDir::new().unwrap();
246        let st = state_in(&dir);
247        assert!(handle_tool_call(&st, args("  ")).await.is_err());
248    }
249
250    #[tokio::test]
251    async fn reports_no_occurrences() {
252        let dir = TempDir::new().unwrap();
253        std::fs::write(dir.path().join("a.rs"), "fn something() {}\n").unwrap();
254        let st = state_in(&dir);
255        let (out, _) = handle_tool_call(&st, args("nonexistent_symbol")).await.unwrap();
256        assert!(out.to_lowercase().contains("no occurrences"));
257    }
258
259    #[tokio::test]
260    async fn definition_survives_when_references_exceed_cap() {
261        // Regression (B1): definitions are uncapped, so the def is found and kept
262        // even when far more reference hits than `max` are collected, regardless
263        // of filesystem walk order.
264        let dir = TempDir::new().unwrap();
265        for i in 0..20 {
266            std::fs::write(
267                dir.path().join(format!("ref_{i:02}.rs")),
268                "fn caller() { target(); }\n",
269            )
270            .unwrap();
271        }
272        std::fs::write(dir.path().join("zzz_def.rs"), "fn target() {}\n").unwrap();
273        let st = state_in(&dir);
274        let mut a = args("target");
275        a.max_results = 3; // far fewer than the 20 reference hits
276        let (_, structured) = handle_tool_call(&st, a).await.unwrap();
277        assert_eq!(
278            structured["definitions"], 1,
279            "definition must never be dropped by the reference cap"
280        );
281        assert!(structured["references"].as_u64().unwrap() <= 3);
282        assert_eq!(structured["truncated"], true);
283        assert_eq!(structured["hits"][0]["is_definition"], true); // definitions listed first
284    }
285
286    #[tokio::test]
287    async fn bare_name_matches_same_name_across_types() {
288        // Documented limitation: a name-based lookup can't distinguish A::run from
289        // B::run — both count. Useful as a superset for rename/impact analysis.
290        let dir = TempDir::new().unwrap();
291        std::fs::write(
292            dir.path().join("a.rs"),
293            "struct A;\nstruct B;\nimpl A { fn run(&self) {} }\nimpl B { fn run(&self) {} }\n",
294        )
295        .unwrap();
296        let st = state_in(&dir);
297        let (_, structured) = handle_tool_call(&st, args("run")).await.unwrap();
298        assert_eq!(structured["definitions"], 2);
299    }
300
301    #[tokio::test]
302    async fn nonexistent_path_errors() {
303        // B3: a typo'd path must error, not silently scan the whole workspace.
304        let dir = TempDir::new().unwrap();
305        std::fs::write(dir.path().join("real.rs"), "fn x() {}\n").unwrap();
306        let st = state_in(&dir);
307        let mut a = args("x");
308        a.path = "nope_typo.rs".to_string();
309        assert!(handle_tool_call(&st, a).await.is_err());
310    }
311}