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;
31const PAYLOAD_FIXED_RESERVE: usize = 1_024;
33
34type Configs = HashMap<String, Option<TagsConfiguration>>;
35
36pub async fn handle_tool_call(
37 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
38 args: FindReferences,
39) -> Result<(String, serde_json::Value)> {
40 handle_tool_call_with_budget(
41 bash_state_arc,
42 args,
43 crate::tools::outline::CODE_MAP_PAYLOAD_MAX_BYTES,
44 )
45 .await
46}
47
48pub(crate) async fn handle_tool_call_with_budget(
49 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
50 args: FindReferences,
51 payload_max_bytes: usize,
52) -> Result<(String, serde_json::Value)> {
53 let (cwd, workspace_root) = {
54 let guard = bash_state_arc.lock().await;
55 let bash_state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
56 (bash_state.cwd.clone(), bash_state.workspace_root.clone())
57 };
58
59 tokio::task::spawn_blocking(move || {
60 references_with_paths(&args, &cwd, workspace_root, payload_max_bytes)
61 })
62 .await
63 .map_err(|error| {
64 WinxError::CommandExecutionError(format!("CodeMap references worker failed: {error}"))
65 })?
66}
67
68fn references_with_paths(
69 args: &FindReferences,
70 cwd: &Path,
71 workspace_root: PathBuf,
72 payload_max_bytes: usize,
73) -> Result<(String, serde_json::Value)> {
74 let workspace_root = workspace_root.canonicalize().unwrap_or(workspace_root);
75
76 if args.name.trim().is_empty() {
77 return Err(WinxError::ArgumentParseError("Symbol name must not be empty.".to_string()));
78 }
79
80 let root = resolve_in_workspace(&args.path, cwd, &workspace_root).map_err(|e| {
81 WinxError::PathSecurityError { path: PathBuf::from(&args.path), message: e.to_string() }
82 })?;
83 let max = if args.max_results == 0 { DEFAULT_MAX_HITS } else { args.max_results };
84
85 let candidates: Vec<PathBuf> = if root.is_file() {
88 vec![root.clone()]
89 } else if root.is_dir() {
90 walk_workspace_files(&root)
91 } else {
92 return Err(WinxError::FileAccessError {
93 path: root.clone(),
94 message: "path not found (or not a regular file/directory)".to_string(),
95 });
96 };
97
98 let mut context = TagsContext::new();
99 let mut configs: Configs = HashMap::new();
100 let mut defs: Vec<ReferenceHit> = Vec::new();
105 let mut refs: Vec<ReferenceHit> = Vec::new();
106 let mut scanned = 0usize;
107 let mut truncated = false;
108
109 for abs in candidates {
110 if scanned >= MAX_FILES_SCANNED {
111 truncated = true;
112 break;
113 }
114 let ext = ext_of(&abs);
115 if !symbols::supports(&ext) {
116 continue;
117 }
118 let Ok(text) = read_file_to_string(&abs, MAX_FILE_SIZE) else { continue };
119 scanned += 1;
120 let rel = rel_of(&abs, &workspace_root);
121 let syms = match config_for(&mut configs, &ext) {
122 Some(config) => symbols::extract_all(&mut context, config, &text),
123 None => continue,
124 };
125 for s in syms {
126 if s.name != args.name {
127 continue;
128 }
129 let hit = ReferenceHit {
130 file: rel.clone(),
131 line: s.line,
132 kind: s.kind,
133 is_definition: s.is_definition,
134 };
135 if s.is_definition {
136 defs.push(hit);
137 } else if refs.len() < max {
138 refs.push(hit);
139 } else {
140 truncated = true; }
142 }
143 }
144
145 let by_loc =
146 |a: &ReferenceHit, b: &ReferenceHit| a.file.cmp(&b.file).then_with(|| a.line.cmp(&b.line));
147 defs.sort_by(by_loc);
148 refs.sort_by(by_loc);
149 let mut hits = defs;
150 hits.extend(refs); bounded_output(&args.name, &hits, truncated, &root, payload_max_bytes)
153}
154
155fn bounded_output(
156 name: &str,
157 hits: &[ReferenceHit],
158 already_truncated: bool,
159 root: &Path,
160 payload_max_bytes: usize,
161) -> Result<(String, serde_json::Value)> {
162 let budget = payload_max_bytes.saturating_sub(PAYLOAD_FIXED_RESERVE);
163 let mut low = 0usize;
164 let mut high = hits.len();
165 while low < high {
166 let middle = low + (high - low).div_ceil(2);
167 let selected = &hits[..middle];
168 let candidate = reference_output(name, selected, already_truncated || middle < hits.len());
169 let text = render(
170 name,
171 selected,
172 candidate.definitions,
173 candidate.references,
174 candidate.truncated,
175 root,
176 !hits.is_empty(),
177 );
178 let bytes = text.len().saturating_add(
179 serde_json::to_vec(&candidate).map_or(usize::MAX, |serialized| serialized.len()),
180 );
181 if bytes <= budget {
182 low = middle;
183 } else {
184 high = middle - 1;
185 }
186 }
187
188 let selected = &hits[..low];
189 let mut output =
190 reference_output(name, selected, already_truncated || selected.len() < hits.len());
191 let text = render(
192 name,
193 selected,
194 output.definitions,
195 output.references,
196 output.truncated,
197 root,
198 !hits.is_empty(),
199 );
200 for _ in 0..3 {
201 let serialized = serde_json::to_vec(&output)
202 .map_err(|error| WinxError::SerializationError(error.to_string()))?;
203 let measured = text.len().saturating_add(serialized.len());
204 if measured == output.payload_bytes {
205 break;
206 }
207 output.payload_bytes = measured;
208 }
209 Ok((text, crate::tools::structured_json(&output)?))
210}
211
212fn reference_output(name: &str, hits: &[ReferenceHit], truncated: bool) -> ReferencesOutput {
213 ReferencesOutput {
214 name: name.to_string(),
215 definitions: hits.iter().filter(|hit| hit.is_definition).count(),
216 references: hits.iter().filter(|hit| !hit.is_definition).count(),
217 truncated,
218 payload_bytes: 0,
219 hits: hits.to_vec(),
220 }
221}
222
223fn render(
224 name: &str,
225 hits: &[ReferenceHit],
226 definitions: usize,
227 references: usize,
228 truncated: bool,
229 root: &Path,
230 had_hits: bool,
231) -> String {
232 if hits.is_empty() {
233 if had_hits {
234 return format!(
235 "Occurrences of `{name}` were found under {}, but the first result exceeds the \
236 CodeMap response budget. Narrow `path` or use ReadFiles for exact source.",
237 root.display()
238 );
239 }
240 return format!("No occurrences of `{name}` found under {}.", root.display());
241 }
242 let mut out = format!("`{name}` — {definitions} definition(s), {references} reference(s):\n");
243 for h in hits {
244 let tag = if h.is_definition { "def" } else { "ref" };
245 let loc = format!("{}:{}", h.file, h.line);
246 let _ = writeln!(out, " {tag} {loc:<40} {:<9} {name}", h.kind);
247 }
248 if truncated {
249 let _ = write!(out, "(...capped; narrow `path` or raise max_results)");
250 }
251 out
252}
253
254fn ext_of(path: &Path) -> String {
256 path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_lowercase()
257}
258
259fn rel_of(path: &Path, workspace_root: &Path) -> String {
261 path.strip_prefix(workspace_root).unwrap_or(path).to_string_lossy().to_string()
262}
263
264fn config_for<'a>(configs: &'a mut Configs, ext: &str) -> Option<&'a TagsConfiguration> {
266 configs.entry(ext.to_string()).or_insert_with(|| symbols::config_for(ext)).as_ref()
267}
268
269#[cfg(test)]
270mod tests {
271 #![allow(clippy::unwrap_used)]
272 use super::*;
273 use tempfile::TempDir;
274
275 fn state_in(dir: &TempDir) -> Arc<Mutex<Option<BashState>>> {
276 let mut bs = BashState::new();
277 let root = dir.path().canonicalize().unwrap();
278 bs.cwd = root.clone();
279 bs.workspace_root = root;
280 Arc::new(Mutex::new(Some(bs)))
281 }
282
283 fn args(name: &str) -> FindReferences {
284 FindReferences {
285 name: name.to_string(),
286 path: String::new(),
287 max_results: 0,
288 thread_id: String::new(),
289 }
290 }
291
292 #[tokio::test]
293 async fn finds_definition_and_references() {
294 let dir = TempDir::new().unwrap();
295 std::fs::write(
296 dir.path().join("a.rs"),
297 "fn target() {}\nfn caller() { target(); target(); }\n",
298 )
299 .unwrap();
300 let st = state_in(&dir);
301 let (out, structured) = handle_tool_call(&st, args("target")).await.unwrap();
302 assert!(out.contains("target"));
303 assert_eq!(structured["definitions"], 1);
304 assert!(structured["references"].as_u64().unwrap() >= 1);
305 assert_eq!(structured["hits"][0]["is_definition"], true);
307 }
308
309 #[tokio::test]
310 async fn finds_python_and_elixir_definitions_and_calls() {
311 let dir = TempDir::new().unwrap();
312 std::fs::write(
313 dir.path().join("service.py"),
314 "def python_target():\n return 1\n\npython_target()\n",
315 )
316 .unwrap();
317 std::fs::write(
318 dir.path().join("worker.ex"),
319 "defmodule Worker do\n def elixir_target(), do: :ok\n def run(), do: elixir_target()\nend\n",
320 )
321 .unwrap();
322 let st = state_in(&dir);
323
324 let (_, python) = handle_tool_call(&st, args("python_target")).await.unwrap();
325 assert_eq!(python["definitions"], 1);
326 assert!(python["references"].as_u64().unwrap() >= 1, "{python}");
327
328 let (_, elixir) = handle_tool_call(&st, args("elixir_target")).await.unwrap();
329 assert_eq!(elixir["definitions"], 1);
330 assert!(elixir["references"].as_u64().unwrap() >= 1, "{elixir}");
331 }
332
333 #[tokio::test]
334 async fn empty_name_errors() {
335 let dir = TempDir::new().unwrap();
336 let st = state_in(&dir);
337 assert!(handle_tool_call(&st, args(" ")).await.is_err());
338 }
339
340 #[tokio::test]
341 async fn reports_no_occurrences() {
342 let dir = TempDir::new().unwrap();
343 std::fs::write(dir.path().join("a.rs"), "fn something() {}\n").unwrap();
344 let st = state_in(&dir);
345 let (out, _) = handle_tool_call(&st, args("nonexistent_symbol")).await.unwrap();
346 assert!(out.to_lowercase().contains("no occurrences"));
347 }
348
349 #[tokio::test]
350 async fn definition_survives_when_references_exceed_cap() {
351 let dir = TempDir::new().unwrap();
355 for i in 0..20 {
356 std::fs::write(
357 dir.path().join(format!("ref_{i:02}.rs")),
358 "fn caller() { target(); }\n",
359 )
360 .unwrap();
361 }
362 std::fs::write(dir.path().join("zzz_def.rs"), "fn target() {}\n").unwrap();
363 let st = state_in(&dir);
364 let mut a = args("target");
365 a.max_results = 3; let (_, structured) = handle_tool_call(&st, a).await.unwrap();
367 assert_eq!(
368 structured["definitions"], 1,
369 "definition must never be dropped by the reference cap"
370 );
371 assert!(structured["references"].as_u64().unwrap() <= 3);
372 assert_eq!(structured["truncated"], true);
373 assert_eq!(structured["hits"][0]["is_definition"], true); }
375
376 #[tokio::test]
377 async fn bare_name_matches_same_name_across_types() {
378 let dir = TempDir::new().unwrap();
381 std::fs::write(
382 dir.path().join("a.rs"),
383 "struct A;\nstruct B;\nimpl A { fn run(&self) {} }\nimpl B { fn run(&self) {} }\n",
384 )
385 .unwrap();
386 let st = state_in(&dir);
387 let (_, structured) = handle_tool_call(&st, args("run")).await.unwrap();
388 assert_eq!(structured["definitions"], 2);
389 }
390
391 #[tokio::test]
392 async fn dense_references_respect_the_combined_payload_budget() {
393 let dir = TempDir::new().unwrap();
394 let mut source = "fn target() {}\nfn caller() {\n".to_string();
395 for _ in 0..800 {
396 source.push_str(" target();\n");
397 }
398 source.push_str("}\n");
399 std::fs::write(dir.path().join("dense.rs"), source).unwrap();
400 let st = state_in(&dir);
401 let mut request = args("target");
402 request.max_results = 1_000;
403
404 let (text, structured) =
405 handle_tool_call_with_budget(&st, request, 8 * 1024).await.unwrap();
406 let bytes = text.len() + serde_json::to_vec(&structured).unwrap().len();
407 assert!(bytes <= 8 * 1024, "payload used {bytes} bytes");
408 assert_eq!(structured["truncated"], true);
409 assert_eq!(structured["hits"][0]["is_definition"], true);
410 }
411
412 #[tokio::test]
413 async fn nonexistent_path_errors() {
414 let dir = TempDir::new().unwrap();
416 std::fs::write(dir.path().join("real.rs"), "fn x() {}\n").unwrap();
417 let st = state_in(&dir);
418 let mut a = args("x");
419 a.path = "nope_typo.rs".to_string();
420 assert!(handle_tool_call(&st, a).await.is_err());
421 }
422}