1use 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
25const DEFAULT_MAX_HITS: usize = 200;
27const MAX_FILE_SIZE: u64 = 2_000_000;
29const 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 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 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; }
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); 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
164fn ext_of(path: &Path) -> String {
166 path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_lowercase()
167}
168
169fn rel_of(path: &Path, workspace_root: &Path) -> String {
171 path.strip_prefix(workspace_root).unwrap_or(path).to_string_lossy().to_string()
172}
173
174fn 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 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 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; 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); }
285
286 #[tokio::test]
287 async fn bare_name_matches_same_name_across_types() {
288 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 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}