1pub mod go;
7pub mod kotlin;
8pub mod python;
9pub mod rust;
10pub mod types;
11pub mod typescript;
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::{Path, PathBuf};
15
16use async_trait::async_trait;
17use serde_json::json;
18
19use super::{truncate_head, truncate_line, Tool, ToolContext, ToolOutput, TruncationResult};
20use crate::error::{Error, Result};
21use types::*;
22
23const MAX_OUTPUT_LINES: usize = 2000;
24const MAX_OUTPUT_BYTES: usize = 50 * 1024;
25const MAX_LINE_CHARS: usize = 500;
26
27const BLOCK_KINDS: &[&str] = &[
29 "function_item",
31 "impl_item",
32 "struct_item",
33 "enum_item",
34 "trait_item",
35 "mod_item",
36 "const_item",
37 "static_item",
38 "type_item",
39 "macro_definition",
40 "function_declaration",
42 "method_definition",
43 "class_declaration",
44 "interface_declaration",
45 "type_alias_declaration",
46 "enum_declaration",
47 "export_statement",
48 "lexical_declaration",
49 "variable_declaration",
50 "arrow_function",
51 "function_definition",
53 "class_definition",
54 "decorated_definition",
55 "class_declaration",
57 "object_declaration",
58 "function_declaration",
59 "property_declaration",
60 "function_declaration",
62 "method_declaration",
63 "type_declaration",
64 "type_spec",
65];
66
67pub struct ScanTool;
68
69#[async_trait]
70impl Tool for ScanTool {
71 fn name(&self) -> &str {
72 "scan"
73 }
74
75 fn label(&self) -> &str {
76 "Scan Code Structure"
77 }
78
79 fn description(&self) -> &str {
80 "Code structure search/extraction with tree-sitter."
81 }
82
83 fn parameters(&self) -> serde_json::Value {
84 json!({
85 "type": "object",
86 "properties": {
87 "action": {
88 "type": "string",
89 "enum": ["directory", "files", "extract", "search", "tests", "related", "references", "impact"],
90 "description": "Scan operation"
91 },
92 "directory": {
93 "type": "string",
94 "description": "Directory; defaults to cwd"
95 },
96 "files": {
97 "type": "array",
98 "items": { "type": "string" },
99 "description": "Files for action=files"
100 },
101 "targets": {
102 "type": "array",
103 "items": { "type": "string" },
104 "description": "Targets: file#symbol, file:start-end, or file:line"
105 },
106 "query": {
107 "type": "string",
108 "description": "Search query"
109 },
110 "mode": {
111 "type": "string",
112 "enum": ["symbol", "text", "concept"],
113 "description": "Search mode; default concept"
114 },
115 "preset": {
116 "type": "string",
117 "enum": ["definition", "edit_context", "module_context", "test_context"],
118 "description": "Extraction preset"
119 },
120 "target": {
121 "type": "string",
122 "description": "Single target"
123 },
124 "max_results": {
125 "type": "integer",
126 "description": "Max results"
127 }
128 },
129 "required": ["action"]
130 })
131 }
132
133 fn is_readonly(&self) -> bool {
134 true
135 }
136
137 async fn execute(
138 &self,
139 _call_id: &str,
140 params: serde_json::Value,
141 ctx: ToolContext,
142 ) -> Result<ToolOutput> {
143 let action = match params["action"].as_str() {
144 Some(a) => a,
145 None => return Ok(ToolOutput::error("missing 'action' parameter")),
146 };
147
148 let mut files = match action {
149 "extract" => {
150 let target_values = params
151 .get("targets")
152 .or_else(|| params.get("files"))
153 .and_then(|value| value.as_array());
154 let targets = match parse_string_array(target_values, "targets") {
155 Ok(targets) if !targets.is_empty() => targets,
156 Ok(_) => return Ok(ToolOutput::error("scan extract requires targets")),
157 Err(message) => return Ok(ToolOutput::error(message)),
158 };
159 let preset = params["preset"].as_str();
160 return Ok(execute_extract_with_preset(&targets, preset, &ctx));
161 }
162 "search" => {
163 let query = match params["query"]
164 .as_str()
165 .map(str::trim)
166 .filter(|q| !q.is_empty())
167 {
168 Some(query) => query,
169 None => return Ok(ToolOutput::error("scan search requires query")),
170 };
171 let mode = params["mode"].as_str().unwrap_or("concept");
172 let max_results = params["max_results"].as_u64().unwrap_or(10) as usize;
173 let files = files_from_params_or_directory(¶ms, &ctx)?;
174 return Ok(execute_search(
175 files,
176 &ctx.cwd,
177 query,
178 mode,
179 max_results.max(1),
180 ));
181 }
182 "tests" => {
183 let targets =
184 parse_string_array(params["targets"].as_array(), "targets").unwrap_or_default();
185 let target = params["target"]
186 .as_str()
187 .map(str::to_string)
188 .or_else(|| targets.first().cloned())
189 .or_else(|| params["query"].as_str().map(str::to_string));
190 let Some(target) = target else {
191 return Ok(ToolOutput::error(
192 "scan tests requires target, targets, or query",
193 ));
194 };
195 let max_results = params["max_results"].as_u64().unwrap_or(10) as usize;
196 let files = files_from_params_or_directory(¶ms, &ctx)?;
197 return Ok(execute_tests(files, &ctx.cwd, &target, max_results.max(1)));
198 }
199 "related" => {
200 let targets =
201 parse_string_array(params["targets"].as_array(), "targets").unwrap_or_default();
202 let target = params["target"]
203 .as_str()
204 .map(str::to_string)
205 .or_else(|| targets.first().cloned());
206 let Some(target) = target else {
207 return Ok(ToolOutput::error("scan related requires target or targets"));
208 };
209 let files = files_from_params_or_directory(¶ms, &ctx)?;
210 return Ok(execute_related(files, &ctx.cwd, &target));
211 }
212 "references" | "impact" => {
213 let target = params["target"]
214 .as_str()
215 .map(str::to_string)
216 .or_else(|| params["query"].as_str().map(str::to_string));
217 let Some(target) = target else {
218 return Ok(ToolOutput::error(format!(
219 "scan {action} requires target or query"
220 )));
221 };
222 let max_results = params["max_results"].as_u64().unwrap_or(25) as usize;
223 let files = files_from_params_or_directory(¶ms, &ctx)?;
224 if action == "references" {
225 return Ok(execute_references(
226 files,
227 &ctx.cwd,
228 &target,
229 max_results.max(1),
230 ));
231 }
232 return Ok(execute_impact(files, &ctx.cwd, &target, max_results.max(1)));
233 }
234 "files" | "build" => {
235 let files = match parse_string_array(params["files"].as_array(), "files") {
236 Ok(files) if !files.is_empty() => files,
237 Ok(_) => return Ok(ToolOutput::error("scan files requires files")),
238 Err(message) => return Ok(ToolOutput::error(message)),
239 };
240 files
241 .into_iter()
242 .map(|file| crate::tools::resolve_path(&ctx.cwd, &file))
243 .collect()
244 }
245 "directory" | "scan" => {
246 let dir = params["directory"]
247 .as_str()
248 .map(|d| crate::tools::resolve_path(&ctx.cwd, d))
249 .unwrap_or_else(|| ctx.cwd.clone());
250 collect_source_files(&dir)?
251 }
252 _ => return Ok(ToolOutput::error(format!("unknown action: {action}"))),
253 };
254
255 files.sort();
256 files.dedup();
257
258 if files.is_empty() {
259 return Ok(ToolOutput::text("No supported source files found."));
260 }
261
262 let result = extract_files(&files, &ctx.cwd);
263 let action_name = canonical_action(action);
264 let output = format_result(&result, &files, &ctx.cwd, action_name, None);
265
266 Ok(ToolOutput {
267 content: vec![imp_llm::ContentBlock::Text {
268 text: truncate_output(output),
269 }],
270 details: json!({
271 "action": action_name,
272 "files_analyzed": files.len(),
273 "supported_languages": ["rust", "typescript", "javascript", "python", "go", "kotlin"],
274 "types_count": result.types.len(),
275 "functions_count": result.functions.len(),
276 }),
277 is_error: false,
278 })
279 }
280}
281
282fn canonical_action(action: &str) -> &str {
285 match action {
286 "scan" => "directory",
287 "build" => "files",
288 other => other,
289 }
290}
291
292fn parse_string_array(
293 values: Option<&Vec<serde_json::Value>>,
294 field: &str,
295) -> std::result::Result<Vec<String>, String> {
296 let Some(values) = values else {
297 return Ok(Vec::new());
298 };
299 let mut strings = Vec::with_capacity(values.len());
300 for (index, value) in values.iter().enumerate() {
301 let Some(text) = value
302 .as_str()
303 .map(str::trim)
304 .filter(|text| !text.is_empty())
305 else {
306 return Err(format!("{field}[{index}] must be a non-empty string"));
307 };
308 strings.push(text.to_string());
309 }
310 Ok(strings)
311}
312
313fn files_from_params_or_directory(
314 params: &serde_json::Value,
315 ctx: &ToolContext,
316) -> Result<Vec<PathBuf>> {
317 let explicit_files =
318 parse_string_array(params["files"].as_array(), "files").map_err(Error::Tool)?;
319 if !explicit_files.is_empty() {
320 return Ok(explicit_files
321 .into_iter()
322 .map(|file| crate::tools::resolve_path(&ctx.cwd, &file))
323 .collect());
324 }
325
326 let dir = params["directory"]
327 .as_str()
328 .map(|d| crate::tools::resolve_path(&ctx.cwd, d))
329 .unwrap_or_else(|| ctx.cwd.clone());
330 collect_source_files(&dir)
331}
332
333fn extract_files(files: &[PathBuf], cwd: &Path) -> ScanResult {
336 let mut result = ScanResult::default();
337
338 for file in files {
339 let source = match std::fs::read_to_string(file) {
340 Ok(s) => s,
341 Err(_) => continue,
342 };
343
344 if source.as_bytes().contains(&0) {
346 continue;
347 }
348
349 let rel = file
350 .strip_prefix(cwd)
351 .unwrap_or(file)
352 .to_string_lossy()
353 .to_string();
354
355 let ext = file
356 .extension()
357 .and_then(|e| e.to_str())
358 .unwrap_or_default();
359
360 match ext {
361 "rs" => rust::parse(&source, &rel, &mut result),
362 "ts" => {
363 if !rel.ends_with(".d.ts") {
364 typescript::parse(&source, &rel, false, &mut result);
365 }
366 }
367 "tsx" => typescript::parse(&source, &rel, true, &mut result),
368 "py" => python::parse(&source, &rel, &mut result),
369 "go" => go::parse(&source, &rel, &mut result),
370 "kt" | "kts" => kotlin::parse(&source, &rel, &mut result),
371 "js" | "jsx" => typescript::parse(&source, &rel, ext == "jsx", &mut result),
372 _ => {}
374 }
375 }
376
377 result
378}
379
380fn collect_source_files(root: &Path) -> Result<Vec<PathBuf>> {
383 if root.is_file() {
384 return Ok(if is_supported(root) {
385 vec![root.to_path_buf()]
386 } else {
387 Vec::new()
388 });
389 }
390
391 if !root.exists() {
392 return Err(Error::Tool(format!(
393 "scan path not found: {}",
394 root.display()
395 )));
396 }
397
398 let mut files = Vec::new();
399 for entry in walkdir::WalkDir::new(root)
400 .follow_links(false)
401 .into_iter()
402 .filter_map(|e| e.ok())
403 .filter(|e| e.file_type().is_file())
404 .filter(|e| !is_skip_dir(e.path()))
405 {
406 if is_supported(entry.path()) {
407 files.push(entry.path().to_path_buf());
408 }
409 }
410
411 Ok(files)
412}
413
414fn is_supported(path: &Path) -> bool {
415 matches!(
416 path.extension().and_then(|e| e.to_str()),
417 Some("rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "kt" | "kts")
418 )
419}
420
421fn is_skip_dir(path: &Path) -> bool {
422 const SKIP: &[&str] = &[
423 "target",
424 "node_modules",
425 ".git",
426 "__pycache__",
427 ".venv",
428 "venv",
429 "vendor",
430 "dist",
431 "build",
432 ".next",
433 "coverage",
434 ];
435 path.components().any(|c| {
436 if let std::path::Component::Normal(name) = c {
437 SKIP.contains(&name.to_string_lossy().as_ref())
438 } else {
439 false
440 }
441 })
442}
443
444#[derive(Debug, Clone)]
447struct IndexedSymbol {
448 file: String,
449 name: String,
450 kind: String,
451 line: usize,
452 text: String,
453 is_test: bool,
454}
455
456#[derive(Debug, Clone)]
457struct SearchHit {
458 file: String,
459 symbol: Option<String>,
460 kind: String,
461 line: usize,
462 score: i32,
463 why: Vec<String>,
464}
465
466fn execute_search(
467 mut files: Vec<PathBuf>,
468 cwd: &Path,
469 query: &str,
470 mode: &str,
471 max_results: usize,
472) -> ToolOutput {
473 files.sort();
474 files.dedup();
475 let index = build_symbol_index(&files, cwd);
476 let hits = search_index(&index, query, mode, max_results);
477 let mut lines = vec![
478 format!("Action: search"),
479 format!("Query: {query}"),
480 format!("Mode: {mode}"),
481 format!("Files analyzed: {}", files.len()),
482 ];
483 if hits.is_empty() {
484 lines.push("No matching symbols found.".to_string());
485 } else {
486 lines.push("Results:".to_string());
487 for hit in &hits {
488 let symbol = hit.symbol.as_deref().unwrap_or("<file>");
489 lines.push(format!(
490 "- {}:{} [{}] {} score={} — {}",
491 hit.file,
492 hit.line,
493 hit.kind,
494 symbol,
495 hit.score,
496 hit.why.join(", ")
497 ));
498 }
499 }
500
501 ToolOutput {
502 content: vec![imp_llm::ContentBlock::Text {
503 text: truncate_output(lines.join("\n")),
504 }],
505 details: json!({
506 "action": "search",
507 "query": query,
508 "mode": mode,
509 "files_analyzed": files.len(),
510 "results": hits.iter().map(|hit| json!({
511 "file": hit.file,
512 "symbol": hit.symbol,
513 "kind": hit.kind,
514 "line": hit.line,
515 "score": hit.score,
516 "why": hit.why,
517 })).collect::<Vec<_>>(),
518 }),
519 is_error: false,
520 }
521}
522
523fn execute_tests(
524 mut files: Vec<PathBuf>,
525 cwd: &Path,
526 target: &str,
527 max_results: usize,
528) -> ToolOutput {
529 files.sort();
530 files.dedup();
531 let index = build_symbol_index(&files, cwd);
532 let tests = discover_tests(&index, target, cwd, max_results);
533 let mut lines = vec![
534 format!("Action: tests"),
535 format!("Target: {target}"),
536 format!("Files analyzed: {}", files.len()),
537 ];
538 if tests.is_empty() {
539 lines.push("No likely tests found.".to_string());
540 } else {
541 lines.push("Likely tests:".to_string());
542 for test in &tests {
543 lines.push(format!(
544 "- {}:{} {} — {}",
545 test.file,
546 test.line,
547 test.name,
548 test.command.as_deref().unwrap_or("no command inferred")
549 ));
550 }
551 }
552
553 ToolOutput {
554 content: vec![imp_llm::ContentBlock::Text {
555 text: truncate_output(lines.join("\n")),
556 }],
557 details: json!({
558 "action": "tests",
559 "target": target,
560 "files_analyzed": files.len(),
561 "tests": tests.iter().map(|test| json!({
562 "file": test.file,
563 "symbol": test.name,
564 "line": test.line,
565 "command": test.command,
566 "why": test.why,
567 })).collect::<Vec<_>>(),
568 }),
569 is_error: false,
570 }
571}
572
573fn build_symbol_index(files: &[PathBuf], cwd: &Path) -> Vec<IndexedSymbol> {
574 let result = extract_files(files, cwd);
575 let mut symbols = Vec::new();
576 for t in result.types.values() {
577 symbols.push(IndexedSymbol {
578 file: source_file(&t.source).to_string(),
579 name: t.name.clone(),
580 kind: format!("{:?}", t.kind).to_lowercase(),
581 line: source_line(&t.source),
582 text: format!(
583 "{} {:?} {:?} {:?}",
584 t.name, t.fields, t.variants, t.implements
585 ),
586 is_test: false,
587 });
588 }
589 for f in result.functions.values() {
590 symbols.push(IndexedSymbol {
591 file: source_file(&f.source).to_string(),
592 name: f.name.clone(),
593 kind: "function".to_string(),
594 line: source_line(&f.source),
595 text: f.signature.clone(),
596 is_test: f.is_test,
597 });
598 }
599 symbols
600}
601
602fn search_index(
603 index: &[IndexedSymbol],
604 query: &str,
605 mode: &str,
606 max_results: usize,
607) -> Vec<SearchHit> {
608 let terms = query_terms(query);
609 if terms.is_empty() {
610 return Vec::new();
611 }
612 let mut hits = Vec::new();
613 for symbol in index {
614 let mut score = 0;
615 let mut why = Vec::new();
616 let path = symbol.file.to_lowercase();
617 let name = symbol.name.to_lowercase();
618 let text = symbol.text.to_lowercase();
619 for term in &terms {
620 if name == *term {
621 score += 100;
622 why.push(format!("symbol exactly matches {term}"));
623 } else if name.contains(term) {
624 score += 60;
625 why.push(format!("symbol contains {term}"));
626 }
627 if mode != "symbol" {
628 if path.contains(term) {
629 score += 25;
630 why.push(format!("path contains {term}"));
631 }
632 if text.contains(term) {
633 score += if mode == "concept" { 20 } else { 15 };
634 why.push(format!("signature/metadata contains {term}"));
635 }
636 }
637 }
638 if score > 0 {
639 hits.push(SearchHit {
640 file: symbol.file.clone(),
641 symbol: Some(symbol.name.clone()),
642 kind: symbol.kind.clone(),
643 line: symbol.line,
644 score,
645 why,
646 });
647 }
648 }
649 hits.sort_by(|a, b| {
650 b.score
651 .cmp(&a.score)
652 .then_with(|| a.file.cmp(&b.file))
653 .then_with(|| a.line.cmp(&b.line))
654 });
655 hits.truncate(max_results);
656 hits
657}
658
659#[derive(Debug, Clone)]
660struct TestHit {
661 file: String,
662 name: String,
663 line: usize,
664 command: Option<String>,
665 why: String,
666}
667
668fn discover_tests(
669 index: &[IndexedSymbol],
670 target: &str,
671 cwd: &Path,
672 max_results: usize,
673) -> Vec<TestHit> {
674 let (target_file, target_symbol) = split_target(target);
675 let target_terms = query_terms(target_symbol.as_deref().unwrap_or(target));
676 let cargo = cwd.join("Cargo.toml").exists();
677 let mut hits = Vec::new();
678 for symbol in index
679 .iter()
680 .filter(|symbol| symbol.is_test || looks_like_test_file(&symbol.file))
681 {
682 let mut score = 0;
683 if let Some(file) = &target_file {
684 if symbol.file == *file {
685 score += 50;
686 } else if same_stem_or_test_neighbor(&symbol.file, file) {
687 score += 35;
688 }
689 }
690 for term in &target_terms {
691 if symbol.name.to_lowercase().contains(term) {
692 score += 20;
693 }
694 }
695 if score > 0 || target_file.is_none() {
696 let test_name = symbol.name.rsplit("::").next().unwrap_or(&symbol.name);
697 hits.push((
698 score,
699 TestHit {
700 file: symbol.file.clone(),
701 name: symbol.name.clone(),
702 line: symbol.line,
703 command: if cargo && symbol.file.ends_with(".rs") {
704 Some(format!("cargo test {test_name}"))
705 } else {
706 None
707 },
708 why: if symbol.is_test {
709 "indexed test symbol"
710 } else {
711 "test file naming"
712 }
713 .to_string(),
714 },
715 ));
716 }
717 }
718 hits.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.file.cmp(&b.1.file)));
719 hits.into_iter()
720 .take(max_results)
721 .map(|(_, hit)| hit)
722 .collect()
723}
724
725fn query_terms(query: &str) -> Vec<String> {
726 query
727 .split(|c: char| !c.is_alphanumeric() && c != '_')
728 .map(str::trim)
729 .filter(|term| !term.is_empty())
730 .map(|term| term.to_lowercase())
731 .collect()
732}
733
734fn source_line(source: &str) -> usize {
735 source
736 .rsplit_once(':')
737 .and_then(|(_, line)| line.parse().ok())
738 .unwrap_or(1)
739}
740
741fn split_target(target: &str) -> (Option<String>, Option<String>) {
742 if let Some((file, symbol)) = target.split_once('#') {
743 return (Some(file.to_string()), Some(symbol.to_string()));
744 }
745 if let Some((file, _line)) = target.rsplit_once(':') {
746 return (Some(file.to_string()), None);
747 }
748 if target.contains('/') || target.contains('\\') {
749 return (Some(target.to_string()), None);
750 }
751 (None, Some(target.to_string()))
752}
753
754fn looks_like_test_file(file: &str) -> bool {
755 let name = Path::new(file)
756 .file_name()
757 .and_then(|name| name.to_str())
758 .unwrap_or(file);
759 name.contains("test")
760 || name.ends_with(".spec.ts")
761 || name.ends_with(".spec.tsx")
762 || name.ends_with("_test.go")
763 || name.ends_with("_test.exs")
764}
765
766fn same_stem_or_test_neighbor(test_file: &str, target_file: &str) -> bool {
767 let test_stem = Path::new(test_file)
768 .file_stem()
769 .and_then(|s| s.to_str())
770 .unwrap_or("")
771 .replace("_test", "")
772 .replace(".test", "")
773 .replace(".spec", "");
774 let target_stem = Path::new(target_file)
775 .file_stem()
776 .and_then(|s| s.to_str())
777 .unwrap_or("");
778 !target_stem.is_empty() && test_stem.contains(target_stem)
779}
780
781fn related_symbols<'a>(
782 index: &'a [IndexedSymbol],
783 target_file: Option<&str>,
784 target_symbol: Option<&str>,
785 max_results: usize,
786) -> Vec<&'a IndexedSymbol> {
787 let target_terms = target_symbol.map(query_terms).unwrap_or_default();
788 let mut scored = Vec::new();
789 for symbol in index {
790 if target_symbol == Some(symbol.name.as_str()) {
791 continue;
792 }
793 let mut score = 0;
794 if target_file == Some(symbol.file.as_str()) {
795 score += 50;
796 }
797 for term in &target_terms {
798 if symbol.name.to_lowercase().contains(term)
799 || symbol.text.to_lowercase().contains(term)
800 {
801 score += 15;
802 }
803 }
804 if score > 0 {
805 scored.push((score, symbol));
806 }
807 }
808 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.line.cmp(&b.1.line)));
809 scored
810 .into_iter()
811 .take(max_results)
812 .map(|(_, symbol)| symbol)
813 .collect()
814}
815
816fn collect_target_files(targets: &[String], cwd: &Path) -> Vec<PathBuf> {
817 let mut files = Vec::new();
818 for target in targets {
819 if let Some((file, _locator)) = parse_extract_target(target) {
820 files.push(crate::tools::resolve_path(cwd, &file));
821 }
822 }
823 files.sort();
824 files.dedup();
825 files
826}
827
828fn execute_related(mut files: Vec<PathBuf>, cwd: &Path, target: &str) -> ToolOutput {
829 files.sort();
830 files.dedup();
831 let index = build_symbol_index(&files, cwd);
832 let (target_file, target_symbol) = split_target(target);
833 let related = related_symbols(&index, target_file.as_deref(), target_symbol.as_deref(), 12);
834 let tests = discover_tests(&index, target, cwd, 5);
835 let definition = target_symbol.as_ref().and_then(|name| {
836 index.iter().find(|symbol| {
837 symbol.name == *name && target_file.as_ref().is_none_or(|file| symbol.file == *file)
838 })
839 });
840
841 let mut lines = vec![
842 format!("Action: related"),
843 format!("Target: {target}"),
844 format!("Files analyzed: {}", files.len()),
845 ];
846 if let Some(symbol) = definition {
847 lines.push(format!(
848 "Definition: {}:{} [{}] {} (extract: {}#{})",
849 symbol.file, symbol.line, symbol.kind, symbol.name, symbol.file, symbol.name
850 ));
851 }
852 if !related.is_empty() {
853 lines.push("Related symbols:".to_string());
854 for symbol in &related {
855 lines.push(format!(
856 "- {}:{} [{}] {} (extract: {}#{})",
857 symbol.file, symbol.line, symbol.kind, symbol.name, symbol.file, symbol.name
858 ));
859 }
860 }
861 if !tests.is_empty() {
862 lines.push("Likely tests:".to_string());
863 for test in &tests {
864 lines.push(format!(
865 "- {}:{} {} — {}",
866 test.file,
867 test.line,
868 test.name,
869 test.command.as_deref().unwrap_or("no command inferred")
870 ));
871 }
872 }
873 if definition.is_none() && related.is_empty() && tests.is_empty() {
874 lines.push("No related context found.".to_string());
875 }
876
877 ToolOutput {
878 content: vec![imp_llm::ContentBlock::Text {
879 text: truncate_output(lines.join("\n")),
880 }],
881 details: json!({
882 "action": "related",
883 "target": target,
884 "files_analyzed": files.len(),
885 "definition": definition.map(|symbol| json!({
886 "file": symbol.file,
887 "symbol": symbol.name,
888 "kind": symbol.kind,
889 "line": symbol.line,
890 "extract_target": format!("{}#{}", symbol.file, symbol.name),
891 })),
892 "related": related.iter().map(|symbol| json!({
893 "file": symbol.file,
894 "symbol": symbol.name,
895 "kind": symbol.kind,
896 "line": symbol.line,
897 "extract_target": format!("{}#{}", symbol.file, symbol.name),
898 })).collect::<Vec<_>>(),
899 "tests": tests.iter().map(|test| json!({
900 "file": test.file,
901 "symbol": test.name,
902 "line": test.line,
903 "command": test.command,
904 "why": test.why,
905 })).collect::<Vec<_>>(),
906 }),
907 is_error: false,
908 }
909}
910
911#[derive(Debug, Clone)]
912struct ReferenceHit {
913 file: String,
914 line: usize,
915 kind: String,
916 snippet: String,
917 confidence: &'static str,
918 why: String,
919}
920
921fn execute_references(
922 mut files: Vec<PathBuf>,
923 cwd: &Path,
924 target: &str,
925 max_results: usize,
926) -> ToolOutput {
927 files.sort();
928 files.dedup();
929 let index = build_symbol_index(&files, cwd);
930 let hits = find_references(&files, cwd, &index, target, max_results);
931 let mut lines = vec![
932 "Action: references".to_string(),
933 format!("Target: {target}"),
934 "Accuracy: lexical/structural reference search, not a complete LSP call graph.".to_string(),
935 format!("Files analyzed: {}", files.len()),
936 ];
937 if hits.is_empty() {
938 lines.push("No references found.".to_string());
939 } else {
940 lines.push("References:".to_string());
941 for hit in &hits {
942 lines.push(format!(
943 "- {}:{} [{} {}] {} — {}",
944 hit.file, hit.line, hit.kind, hit.confidence, hit.snippet, hit.why
945 ));
946 }
947 }
948
949 ToolOutput {
950 content: vec![imp_llm::ContentBlock::Text {
951 text: truncate_output(lines.join("\n")),
952 }],
953 details: json!({
954 "action": "references",
955 "target": target,
956 "accuracy": "lexical_structural_not_lsp_complete",
957 "files_analyzed": files.len(),
958 "references": hits.iter().map(|hit| json!({
959 "file": hit.file,
960 "line": hit.line,
961 "kind": hit.kind,
962 "snippet": hit.snippet,
963 "confidence": hit.confidence,
964 "why": hit.why,
965 })).collect::<Vec<_>>(),
966 }),
967 is_error: false,
968 }
969}
970
971fn execute_impact(
972 mut files: Vec<PathBuf>,
973 cwd: &Path,
974 target: &str,
975 max_results: usize,
976) -> ToolOutput {
977 files.sort();
978 files.dedup();
979 let index = build_symbol_index(&files, cwd);
980 let references = find_references(&files, cwd, &index, target, max_results);
981 let tests = discover_tests(&index, target, cwd, 10);
982 let (_target_file, target_symbol) = split_target(target);
983 let public_status = target_symbol.as_ref().and_then(|name| {
984 index
985 .iter()
986 .find(|symbol| symbol.name == *name)
987 .map(|symbol| {
988 if symbol.kind == "function" {
989 "unknown"
990 } else {
991 "indexed symbol"
992 }
993 })
994 });
995 let affected_files: BTreeSet<String> = references.iter().map(|hit| hit.file.clone()).collect();
996 let verify_commands: Vec<String> = tests
997 .iter()
998 .filter_map(|test| test.command.clone())
999 .collect();
1000 let mut lines = vec![
1001 "Action: impact".to_string(),
1002 format!("Target: {target}"),
1003 "Accuracy: lexical/structural impact analysis, not a complete LSP call graph.".to_string(),
1004 format!("Files analyzed: {}", files.len()),
1005 format!("References found: {}", references.len()),
1006 ];
1007 if !affected_files.is_empty() {
1008 lines.push("Likely affected files:".to_string());
1009 for file in &affected_files {
1010 lines.push(format!("- {file}"));
1011 }
1012 }
1013 if !tests.is_empty() {
1014 lines.push("Relevant tests:".to_string());
1015 for test in &tests {
1016 lines.push(format!(
1017 "- {}:{} {} — {}",
1018 test.file,
1019 test.line,
1020 test.name,
1021 test.command.as_deref().unwrap_or("no command inferred")
1022 ));
1023 }
1024 }
1025 if !verify_commands.is_empty() {
1026 lines.push("Suggested verification:".to_string());
1027 for command in &verify_commands {
1028 lines.push(format!("- {command}"));
1029 }
1030 }
1031
1032 ToolOutput {
1033 content: vec![imp_llm::ContentBlock::Text {
1034 text: truncate_output(lines.join("\n")),
1035 }],
1036 details: json!({
1037 "action": "impact",
1038 "target": target,
1039 "accuracy": "lexical_structural_not_lsp_complete",
1040 "files_analyzed": files.len(),
1041 "public_status": public_status,
1042 "affected_files": affected_files.into_iter().collect::<Vec<_>>(),
1043 "references": references.iter().map(|hit| json!({
1044 "file": hit.file,
1045 "line": hit.line,
1046 "kind": hit.kind,
1047 "snippet": hit.snippet,
1048 "confidence": hit.confidence,
1049 "why": hit.why,
1050 })).collect::<Vec<_>>(),
1051 "tests": tests.iter().map(|test| json!({
1052 "file": test.file,
1053 "symbol": test.name,
1054 "line": test.line,
1055 "command": test.command,
1056 "why": test.why,
1057 })).collect::<Vec<_>>(),
1058 "verify_commands": verify_commands,
1059 }),
1060 is_error: false,
1061 }
1062}
1063
1064fn find_references(
1065 files: &[PathBuf],
1066 cwd: &Path,
1067 index: &[IndexedSymbol],
1068 target: &str,
1069 max_results: usize,
1070) -> Vec<ReferenceHit> {
1071 let (_target_file, target_symbol) = split_target(target);
1072 let needle = target_symbol.unwrap_or_else(|| target.to_string());
1073 let needle = needle.trim();
1074 if needle.is_empty() {
1075 return Vec::new();
1076 }
1077 let pattern = format!(r"\b{}\b", regex::escape(needle));
1078 let Ok(symbol_regex) = regex::Regex::new(&pattern) else {
1079 return Vec::new();
1080 };
1081 let mut hits = Vec::new();
1082 for file in files {
1083 let Some(content) = read_text_file(file) else {
1084 continue;
1085 };
1086 let rel = file
1087 .strip_prefix(cwd)
1088 .unwrap_or(file)
1089 .to_string_lossy()
1090 .to_string();
1091 for (idx, line) in content.lines().enumerate() {
1092 if !symbol_regex.is_match(line) {
1093 continue;
1094 }
1095 let line_no = idx + 1;
1096 let (kind, confidence, why) = classify_reference(index, &rel, line_no, line, needle);
1097 hits.push(ReferenceHit {
1098 file: rel.clone(),
1099 line: line_no,
1100 kind,
1101 snippet: truncate_line(line.trim(), 180),
1102 confidence,
1103 why,
1104 });
1105 }
1106 }
1107 hits.sort_by(|a, b| {
1108 reference_kind_rank(&a.kind)
1109 .cmp(&reference_kind_rank(&b.kind))
1110 .then_with(|| a.file.cmp(&b.file))
1111 .then_with(|| a.line.cmp(&b.line))
1112 });
1113 hits.truncate(max_results);
1114 hits
1115}
1116
1117fn classify_reference(
1118 index: &[IndexedSymbol],
1119 file: &str,
1120 line_no: usize,
1121 line: &str,
1122 needle: &str,
1123) -> (String, &'static str, String) {
1124 if index
1125 .iter()
1126 .any(|symbol| symbol.file == file && symbol.line == line_no && symbol.name == needle)
1127 {
1128 return (
1129 "definition".to_string(),
1130 "high",
1131 "matches indexed symbol definition".to_string(),
1132 );
1133 }
1134 let trimmed = line.trim_start();
1135 if trimmed.starts_with("use ")
1136 || trimmed.starts_with("import ")
1137 || trimmed.starts_with("from ")
1138 || trimmed.contains("require(")
1139 {
1140 return (
1141 "import".to_string(),
1142 "medium",
1143 "line looks like import/use".to_string(),
1144 );
1145 }
1146 if looks_like_test_file(file)
1147 || index
1148 .iter()
1149 .any(|symbol| symbol.file == file && symbol.is_test && line_no >= symbol.line)
1150 {
1151 return (
1152 "test".to_string(),
1153 "medium",
1154 "reference appears in test context".to_string(),
1155 );
1156 }
1157 if line.contains(&format!("{needle}(")) || line.contains(&format!(".{needle}")) {
1158 return (
1159 "call".to_string(),
1160 "medium",
1161 "line looks like call/member access".to_string(),
1162 );
1163 }
1164 ("reference".to_string(), "low", "lexical match".to_string())
1165}
1166
1167fn reference_kind_rank(kind: &str) -> usize {
1168 match kind {
1169 "definition" => 0,
1170 "call" => 1,
1171 "reference" => 2,
1172 "import" => 3,
1173 "test" => 4,
1174 _ => 5,
1175 }
1176}
1177
1178fn format_result(
1181 result: &ScanResult,
1182 files: &[PathBuf],
1183 cwd: &Path,
1184 action: &str,
1185 task: Option<&str>,
1186) -> String {
1187 let mut sections = Vec::new();
1188 sections.push(format!("Action: {action}"));
1189 if let Some(task) = task {
1190 sections.push(format!("Task: {task}"));
1191 }
1192 sections.push(format!("Files analyzed: {}", files.len()));
1193 sections.push("Output: compact code skeleton with symbol kind and line ranges. Use `scan extract` targets like file#symbol, file:start-end, or file:line for exact code.".to_string());
1194
1195 let mut file_types: BTreeMap<&str, Vec<&TypeInfo>> = BTreeMap::new();
1197 let mut file_functions: BTreeMap<&str, Vec<&FunctionInfo>> = BTreeMap::new();
1198
1199 for t in result.types.values() {
1200 let file = source_file(&t.source);
1201 file_types.entry(file).or_default().push(t);
1202 }
1203
1204 for f in result.functions.values() {
1205 let file = source_file(&f.source);
1206 file_functions.entry(file).or_default().push(f);
1207 }
1208
1209 let all_files: BTreeSet<&str> = file_types
1210 .keys()
1211 .chain(file_functions.keys())
1212 .copied()
1213 .collect();
1214
1215 for file in &all_files {
1216 let rel = display_path(file, cwd);
1217 let mut lines = vec![rel];
1218
1219 if let Some(types) = file_types.get(file) {
1220 lines.push(format!(" Types ({}):", types.len()));
1221 for t in types {
1222 lines.push(format!(" - {}", format_type(t)));
1223 }
1224 }
1225
1226 if let Some(funcs) = file_functions.get(file) {
1227 let standalone: Vec<_> = funcs
1229 .iter()
1230 .filter(|f| !f.name.contains("::") && !is_qualified_name(&f.name))
1231 .filter(|f| !f.is_test)
1232 .collect();
1233 if !standalone.is_empty() {
1234 lines.push(format!(" Functions ({}):", standalone.len()));
1235 for f in standalone {
1236 lines.push(format!(" - {}", format_function(f)));
1237 }
1238 }
1239 }
1240
1241 if lines.len() > 1 {
1242 sections.push(lines.join("\n"));
1243 }
1244 }
1245
1246 sections.join("\n\n")
1247}
1248
1249fn format_type(t: &TypeInfo) -> String {
1250 let vis = format_visibility(&t.visibility);
1251 let kind = match t.kind {
1252 TypeKind::Struct => "struct",
1253 TypeKind::Enum => "enum",
1254 TypeKind::Trait => "trait",
1255 TypeKind::Interface => "interface",
1256 TypeKind::Class => "class",
1257 TypeKind::TypeAlias => "type",
1258 TypeKind::Union => "union",
1259 TypeKind::Protocol => "protocol",
1260 };
1261
1262 let mut out = format!("{vis}{kind} {}", t.name);
1263
1264 match t.kind {
1265 TypeKind::Struct | TypeKind::Class => {
1266 if !t.fields.is_empty() {
1267 let names: Vec<&str> = t.fields.iter().map(|f| f.name.as_str()).collect();
1268 if names.len() <= 6 {
1269 out.push_str(&format!(" {{ {} }}", names.join(", ")));
1270 } else {
1271 let shown = &names[..5];
1272 out.push_str(&format!(
1273 " {{ {}, ... +{} }}",
1274 shown.join(", "),
1275 names.len() - 5
1276 ));
1277 }
1278 }
1279 }
1280 TypeKind::Enum => {
1281 if !t.variants.is_empty() {
1282 if t.variants.len() <= 6 {
1283 out.push_str(&format!(" {{ {} }}", t.variants.join(", ")));
1284 } else {
1285 let shown: Vec<&str> = t.variants[..5].iter().map(|s| s.as_str()).collect();
1286 out.push_str(&format!(
1287 " {{ {}, ... +{} }}",
1288 shown.join(", "),
1289 t.variants.len() - 5
1290 ));
1291 }
1292 }
1293 }
1294 TypeKind::Trait | TypeKind::Interface | TypeKind::Protocol => {
1295 if !t.methods.is_empty() {
1296 if t.methods.len() <= 6 {
1297 out.push_str(&format!(" {{ {} }}", t.methods.join(", ")));
1298 } else {
1299 let shown: Vec<&str> = t.methods[..5].iter().map(|s| s.as_str()).collect();
1300 out.push_str(&format!(
1301 " {{ {}, ... +{} }}",
1302 shown.join(", "),
1303 t.methods.len() - 5
1304 ));
1305 }
1306 }
1307 }
1308 _ => {}
1309 }
1310
1311 if !t.implements.is_empty() {
1312 out.push_str(&format!(" [{}]", t.implements.join(", ")));
1313 }
1314
1315 out.push_str(&format!(" @ {}", t.source));
1316
1317 out
1318}
1319
1320fn format_function(f: &FunctionInfo) -> String {
1321 let vis = format_visibility(&f.visibility);
1322 let mut out = if !f.signature.is_empty() {
1323 format!("{vis}{}", f.signature)
1324 } else {
1325 format!("{vis}fn {}", f.name)
1326 };
1327 out.push_str(&format!(" @ {}", f.source));
1328 out
1329}
1330
1331fn format_visibility(vis: &Visibility) -> &'static str {
1332 match vis {
1333 Visibility::Public => "pub ",
1334 Visibility::Internal => "pub(crate) ",
1335 Visibility::Private => "",
1336 }
1337}
1338
1339fn source_file(source: &str) -> &str {
1340 source.rsplit_once(':').map(|(f, _)| f).unwrap_or(source)
1342}
1343
1344fn display_path(path: &str, cwd: &Path) -> String {
1345 let cwd_str = cwd.to_string_lossy();
1346 path.strip_prefix(cwd_str.as_ref())
1347 .map(|p| p.strip_prefix('/').unwrap_or(p))
1348 .unwrap_or(path)
1349 .to_string()
1350}
1351
1352fn is_qualified_name(name: &str) -> bool {
1353 name.contains("::")
1355}
1356
1357fn truncate_output(text: String) -> String {
1358 if text.is_empty() {
1359 return text;
1360 }
1361
1362 let truncated_lines = text
1363 .lines()
1364 .map(|line| truncate_line(line, MAX_LINE_CHARS))
1365 .collect::<Vec<_>>()
1366 .join("\n");
1367
1368 let TruncationResult {
1369 content,
1370 truncated,
1371 output_lines,
1372 total_lines,
1373 temp_file,
1374 ..
1375 } = truncate_head(&truncated_lines, MAX_OUTPUT_LINES, MAX_OUTPUT_BYTES);
1376
1377 if !truncated {
1378 return content;
1379 }
1380
1381 let mut result = content;
1382 result.push_str(&format!(
1383 "\n[Output truncated: showing first {output_lines} of {total_lines} lines{}]",
1384 temp_file
1385 .as_ref()
1386 .map(|p| format!(". Full output saved to {}", p.display()))
1387 .unwrap_or_default()
1388 ));
1389 result
1390}
1391
1392struct CodeBlock {
1393 file: PathBuf,
1394 start_line: usize,
1395 end_line: usize,
1396 kind: Option<String>,
1397 symbol: Option<String>,
1398 language: Option<String>,
1399 truncated: bool,
1400 code: String,
1401}
1402
1403enum Locator {
1404 Line(usize),
1405 Range(usize, usize),
1406 Symbol(String),
1407}
1408
1409fn execute_extract_with_preset(
1410 targets: &[String],
1411 preset: Option<&str>,
1412 ctx: &ToolContext,
1413) -> ToolOutput {
1414 let Some(preset) = preset else {
1415 return execute_extract(targets, ctx);
1416 };
1417 match preset {
1418 "definition" | "module_context" => execute_extract(targets, ctx),
1419 "edit_context" | "test_context" => execute_context_preset(targets, preset, ctx),
1420 other => ToolOutput::error(format!(
1421 "unknown extract preset: {other}. Use definition, edit_context, module_context, or test_context."
1422 )),
1423 }
1424}
1425
1426fn execute_context_preset(targets: &[String], preset: &str, ctx: &ToolContext) -> ToolOutput {
1427 let mut output = execute_extract(targets, ctx);
1428 if output.is_error {
1429 return output;
1430 }
1431 let files = collect_target_files(targets, &ctx.cwd);
1432 let index = build_symbol_index(&files, &ctx.cwd);
1433 let mut extras = Vec::new();
1434 for target in targets {
1435 let tests = discover_tests(&index, target, &ctx.cwd, 5);
1436 if preset == "test_context" && !tests.is_empty() {
1437 extras.push(format!("Related tests for {target}:"));
1438 for test in tests {
1439 extras.push(format!(
1440 "- {}:{} {} — {}",
1441 test.file,
1442 test.line,
1443 test.name,
1444 test.command
1445 .unwrap_or_else(|| "no command inferred".to_string())
1446 ));
1447 }
1448 } else if preset == "edit_context" {
1449 let (target_file, target_symbol) = split_target(target);
1450 let related =
1451 related_symbols(&index, target_file.as_deref(), target_symbol.as_deref(), 6);
1452 if !related.is_empty() {
1453 extras.push(format!("Related symbols for {target}:"));
1454 for symbol in related {
1455 extras.push(format!(
1456 "- {}:{} [{}] {} (extract: {}#{})",
1457 symbol.file,
1458 symbol.line,
1459 symbol.kind,
1460 symbol.name,
1461 symbol.file,
1462 symbol.name
1463 ));
1464 }
1465 }
1466 }
1467 }
1468 if !extras.is_empty() {
1469 if let Some(text) = output.content.iter_mut().find_map(|block| match block {
1470 imp_llm::ContentBlock::Text { text } => Some(text),
1471 _ => None,
1472 }) {
1473 text.push_str("\n\n");
1474 text.push_str(&extras.join("\n"));
1475 *text = truncate_output(text.clone());
1476 }
1477 output.details["preset"] = json!(preset);
1478 output.details["context"] = json!(extras);
1479 }
1480 output
1481}
1482
1483fn execute_extract(targets: &[String], ctx: &ToolContext) -> ToolOutput {
1484 let mut blocks = Vec::new();
1485 let mut errors = Vec::new();
1486
1487 for target in targets {
1488 let Some((file, locator)) = parse_extract_target(target) else {
1489 errors.push(format!(
1490 "Invalid target `{target}`. Use file#symbol, file:start-end, or file:line."
1491 ));
1492 continue;
1493 };
1494
1495 let path = crate::tools::resolve_path(&ctx.cwd, &file);
1496 let Some(content) = read_text_file(&path) else {
1497 blocks.push(CodeBlock {
1498 file: PathBuf::from(&file),
1499 start_line: 0,
1500 end_line: 0,
1501 kind: None,
1502 symbol: None,
1503 language: language_for_path(Path::new(&file)).map(str::to_string),
1504 truncated: false,
1505 code: format!("Error: could not read {file}"),
1506 });
1507 continue;
1508 };
1509
1510 let rel_path = path.strip_prefix(&ctx.cwd).unwrap_or(&path).to_path_buf();
1511
1512 match locator {
1513 Locator::Line(line) => {
1514 let line_idx = line.saturating_sub(1);
1515 if let Some(extracted) = extract_blocks_at_lines(&content, &path, &[line_idx]) {
1516 for mut block in extracted {
1517 block.file = rel_path.clone();
1518 blocks.push(block);
1519 }
1520 } else {
1521 let lines: Vec<&str> = content.lines().collect();
1522 let start = line_idx.saturating_sub(5);
1523 let end = (line_idx + 6).min(lines.len());
1524 blocks.push(CodeBlock {
1525 file: rel_path.clone(),
1526 start_line: start + 1,
1527 end_line: end,
1528 kind: None,
1529 symbol: None,
1530 language: language_for_path(&path).map(str::to_string),
1531 truncated: false,
1532 code: lines[start..end].join("\n"),
1533 });
1534 }
1535 }
1536 Locator::Range(start, end) => {
1537 let lines: Vec<&str> = content.lines().collect();
1538 let s = start.saturating_sub(1).min(lines.len());
1539 let e = end.min(lines.len());
1540 blocks.push(CodeBlock {
1541 file: rel_path.clone(),
1542 start_line: s + 1,
1543 end_line: e,
1544 kind: None,
1545 symbol: None,
1546 language: language_for_path(&path).map(str::to_string),
1547 truncated: false,
1548 code: lines[s..e].join("\n"),
1549 });
1550 }
1551 Locator::Symbol(name) => {
1552 if let Some(found) = extract_symbol(&content, &path, &name) {
1553 blocks.push(CodeBlock {
1554 file: rel_path.clone(),
1555 ..found
1556 });
1557 } else {
1558 blocks.push(CodeBlock {
1559 file: rel_path.clone(),
1560 start_line: 0,
1561 end_line: 0,
1562 kind: None,
1563 symbol: Some(name.clone()),
1564 language: language_for_path(&path).map(str::to_string),
1565 truncated: false,
1566 code: format!("Symbol '{name}' not found in {file}"),
1567 });
1568 }
1569 }
1570 }
1571 }
1572
1573 if blocks.is_empty() && errors.is_empty() {
1574 return ToolOutput::text("No code blocks found.");
1575 }
1576
1577 let mut output = String::new();
1578 if !blocks.is_empty() {
1579 output.push_str(&format_blocks(&blocks));
1580 }
1581 if !errors.is_empty() {
1582 if !output.is_empty() {
1583 output.push_str("\n\n");
1584 }
1585 output.push_str("Errors:\n");
1586 for error in &errors {
1587 output.push_str(&format!("- {error}\n"));
1588 }
1589 }
1590
1591 ToolOutput {
1592 content: vec![imp_llm::ContentBlock::Text {
1593 text: truncate_output(output),
1594 }],
1595 details: json!({
1596 "action": "extract",
1597 "targets_count": targets.len(),
1598 "blocks_count": blocks.len(),
1599 "errors": errors,
1600 "blocks": blocks.iter().map(block_details).collect::<Vec<_>>(),
1601 }),
1602 is_error: blocks.is_empty(),
1603 }
1604}
1605
1606fn parse_extract_target(target: &str) -> Option<(String, Locator)> {
1607 if let Some(hash_pos) = target.rfind('#') {
1608 let file = target[..hash_pos].to_string();
1609 let symbol = target[hash_pos + 1..].to_string();
1610 if !file.is_empty() && !symbol.is_empty() {
1611 return Some((file, Locator::Symbol(symbol)));
1612 }
1613 }
1614
1615 if let Some(colon_pos) = target.rfind(':') {
1616 let file = target[..colon_pos].to_string();
1617 let suffix = &target[colon_pos + 1..];
1618 if !file.is_empty() && !suffix.is_empty() {
1619 if let Some(dash_pos) = suffix.find('-') {
1620 let start = suffix[..dash_pos].parse::<usize>().ok()?;
1621 let end = suffix[dash_pos + 1..].parse::<usize>().ok()?;
1622 if start == 0 || end == 0 || start > end {
1623 return None;
1624 }
1625 return Some((file, Locator::Range(start, end)));
1626 } else if let Ok(line) = suffix.parse::<usize>() {
1627 if line == 0 {
1628 return None;
1629 }
1630 return Some((file, Locator::Line(line)));
1631 }
1632 }
1633 }
1634
1635 None
1636}
1637
1638fn block_details(block: &CodeBlock) -> serde_json::Value {
1639 json!({
1640 "path": block.file.to_string_lossy(),
1641 "symbol": block.symbol,
1642 "kind": block.kind,
1643 "language": block.language,
1644 "start_line": block.start_line,
1645 "end_line": block.end_line,
1646 "truncated": block.truncated,
1647 })
1648}
1649
1650fn read_text_file(path: &Path) -> Option<String> {
1651 let bytes = std::fs::read(path).ok()?;
1652 if bytes.contains(&0) {
1653 return None;
1654 }
1655 Some(String::from_utf8_lossy(&bytes).into_owned())
1656}
1657
1658fn get_parser(path: &Path) -> Option<tree_sitter::Parser> {
1659 let ext = path.extension()?.to_str()?;
1660 let language = match ext {
1661 "rs" => tree_sitter_rust::LANGUAGE.into(),
1662 "ts" | "tsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1663 "js" | "jsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1664 "py" => tree_sitter_python::LANGUAGE.into(),
1665 "go" => tree_sitter_go::LANGUAGE.into(),
1666 "kt" | "kts" => tree_sitter_kotlin_ng::LANGUAGE.into(),
1667 _ => return None,
1668 };
1669 let mut parser = tree_sitter::Parser::new();
1670 parser.set_language(&language).ok()?;
1671 Some(parser)
1672}
1673
1674fn extract_blocks_at_lines(
1675 source: &str,
1676 path: &Path,
1677 match_lines: &[usize],
1678) -> Option<Vec<CodeBlock>> {
1679 let mut parser = get_parser(path)?;
1680 let tree = parser.parse(source, None)?;
1681 let root = tree.root_node();
1682 let lines: Vec<&str> = source.lines().collect();
1683
1684 let mut blocks = Vec::new();
1685 let mut seen_ranges = std::collections::HashSet::new();
1686
1687 for &line_idx in match_lines {
1688 if let Some(node) = find_enclosing_block(root, line_idx) {
1689 let start = node.start_position().row;
1690 let end = node.end_position().row;
1691 let range = (start, end);
1692 if seen_ranges.insert(range) {
1693 let s = start.min(lines.len());
1694 let e = (end + 1).min(lines.len());
1695 blocks.push(CodeBlock {
1696 file: PathBuf::new(),
1697 start_line: start + 1,
1698 end_line: end + 1,
1699 kind: Some(node.kind().to_string()),
1700 symbol: None,
1701 language: language_for_path(path).map(str::to_string),
1702 truncated: false,
1703 code: lines[s..e].join("\n"),
1704 });
1705 }
1706 }
1707 }
1708
1709 Some(blocks)
1710}
1711
1712fn find_enclosing_block(root: tree_sitter::Node, target_line: usize) -> Option<tree_sitter::Node> {
1713 let mut best: Option<tree_sitter::Node> = None;
1714 find_enclosing_block_recursive(root, target_line, &mut best);
1715 best
1716}
1717
1718fn find_enclosing_block_recursive<'a>(
1719 node: tree_sitter::Node<'a>,
1720 target_line: usize,
1721 best: &mut Option<tree_sitter::Node<'a>>,
1722) {
1723 let start = node.start_position().row;
1724 let end = node.end_position().row;
1725
1726 if target_line < start || target_line > end {
1727 return;
1728 }
1729
1730 if BLOCK_KINDS.contains(&node.kind()) {
1731 *best = Some(node);
1732 }
1733
1734 let mut cursor = node.walk();
1735 let children: Vec<_> = node.children(&mut cursor).collect();
1736 for child in children {
1737 find_enclosing_block_recursive(child, target_line, best);
1738 }
1739}
1740
1741fn extract_symbol(source: &str, path: &Path, name: &str) -> Option<CodeBlock> {
1742 let mut parser = get_parser(path)?;
1743 let tree = parser.parse(source, None)?;
1744 let root = tree.root_node();
1745 let lines: Vec<&str> = source.lines().collect();
1746
1747 let node = find_symbol_node(root, source, name)?;
1748 let start = node.start_position().row;
1749 let end = node.end_position().row;
1750 let s = start.min(lines.len());
1751 let e = (end + 1).min(lines.len());
1752
1753 Some(CodeBlock {
1754 file: PathBuf::new(),
1755 start_line: start + 1,
1756 end_line: end + 1,
1757 kind: Some(node.kind().to_string()),
1758 symbol: Some(name.to_string()),
1759 language: language_for_path(path).map(str::to_string),
1760 truncated: false,
1761 code: lines[s..e].join("\n"),
1762 })
1763}
1764
1765fn find_symbol_node<'a>(
1766 node: tree_sitter::Node<'a>,
1767 source: &str,
1768 name: &str,
1769) -> Option<tree_sitter::Node<'a>> {
1770 if BLOCK_KINDS.contains(&node.kind()) && node_has_name(node, source, name) {
1771 return Some(node);
1772 }
1773
1774 let mut cursor = node.walk();
1775 let children: Vec<_> = node.children(&mut cursor).collect();
1776 for child in children {
1777 if let Some(found) = find_symbol_node(child, source, name) {
1778 return Some(found);
1779 }
1780 }
1781
1782 None
1783}
1784
1785fn node_has_name(node: tree_sitter::Node, source: &str, name: &str) -> bool {
1786 let mut cursor = node.walk();
1787 let children: Vec<_> = node.children(&mut cursor).collect();
1788 for child in children {
1789 let kind = child.kind();
1790 if kind == "identifier"
1791 || kind == "type_identifier"
1792 || kind == "name"
1793 || kind == "property_identifier"
1794 || kind == "simple_identifier"
1795 || kind == "variable_identifier"
1796 {
1797 let text = &source[child.byte_range()];
1798 if text == name {
1799 return true;
1800 }
1801 }
1802 if BLOCK_KINDS.contains(&kind) {
1803 continue;
1804 }
1805 let mut inner_cursor = child.walk();
1806 let inner_children: Vec<_> = child.children(&mut inner_cursor).collect();
1807 for inner in inner_children {
1808 let ik = inner.kind();
1809 if ik == "identifier"
1810 || ik == "type_identifier"
1811 || ik == "name"
1812 || ik == "simple_identifier"
1813 || ik == "variable_identifier"
1814 {
1815 let text = &source[inner.byte_range()];
1816 if text == name {
1817 return true;
1818 }
1819 }
1820 }
1821 }
1822 false
1823}
1824
1825fn language_for_path(path: &Path) -> Option<&'static str> {
1826 match path.extension().and_then(|e| e.to_str())? {
1827 "rs" => Some("rust"),
1828 "ts" | "tsx" => Some("typescript"),
1829 "js" | "jsx" => Some("javascript"),
1830 "py" => Some("python"),
1831 "go" => Some("go"),
1832 "kt" | "kts" => Some("kotlin"),
1833 _ => None,
1834 }
1835}
1836
1837fn format_blocks(blocks: &[CodeBlock]) -> String {
1838 let mut sections = Vec::with_capacity(blocks.len());
1839
1840 for block in blocks {
1841 let mut header = format!(
1842 "{}:{}-{}",
1843 block.file.display(),
1844 block.start_line,
1845 block.end_line
1846 );
1847 if let Some(kind) = &block.kind {
1848 header.push_str(&format!(" ({kind})"));
1849 }
1850 let details = block_details(block);
1851
1852 let fence = match block.file.extension().and_then(|e| e.to_str()) {
1853 Some("rs") => "rust",
1854 Some("ts") | Some("tsx") => "typescript",
1855 Some("js") | Some("jsx") => "javascript",
1856 Some("py") => "python",
1857 Some("go") => "go",
1858 _ => "text",
1859 };
1860 sections.push(format!(
1861 "{header}\nDetails: {details}\n```{fence}\n{}\n```",
1862 block.code
1863 ));
1864 }
1865
1866 sections.join("\n\n")
1867}
1868
1869#[cfg(test)]
1870mod tests {
1871 use super::*;
1872
1873 #[test]
1874 fn schema_uses_directory_files_extract_and_targets() {
1875 let schema = ScanTool.parameters();
1876 let properties = schema["properties"].as_object().unwrap();
1877 let actions = properties["action"]["enum"].as_array().unwrap();
1878 assert!(actions.iter().any(|value| value == "directory"));
1879 assert!(actions.iter().any(|value| value == "files"));
1880 assert!(actions.iter().any(|value| value == "extract"));
1881 assert!(actions.iter().any(|value| value == "search"));
1882 assert!(actions.iter().any(|value| value == "tests"));
1883 assert!(actions.iter().any(|value| value == "related"));
1884 assert!(actions.iter().any(|value| value == "references"));
1885 assert!(actions.iter().any(|value| value == "impact"));
1886 assert!(properties.contains_key("targets"));
1887 assert!(properties.contains_key("query"));
1888 assert!(properties.contains_key("mode"));
1889 assert!(properties.contains_key("max_results"));
1890 assert!(properties.contains_key("preset"));
1891 assert!(properties.contains_key("target"));
1892 assert!(!properties.contains_key("task"));
1893 }
1894
1895 #[test]
1896 fn search_index_returns_ranked_symbol_hits() {
1897 let index = vec![
1898 IndexedSymbol {
1899 file: "src/auth/session.rs".to_string(),
1900 name: "resolve_auth_fallback".to_string(),
1901 kind: "function".to_string(),
1902 line: 12,
1903 text: "fn resolve_auth_fallback()".to_string(),
1904 is_test: false,
1905 },
1906 IndexedSymbol {
1907 file: "src/cache.rs".to_string(),
1908 name: "load_cache".to_string(),
1909 kind: "function".to_string(),
1910 line: 4,
1911 text: "fn load_cache()".to_string(),
1912 is_test: false,
1913 },
1914 ];
1915
1916 let hits = search_index(&index, "auth fallback", "concept", 5);
1917
1918 assert_eq!(hits.len(), 1);
1919 assert_eq!(hits[0].symbol.as_deref(), Some("resolve_auth_fallback"));
1920 assert!(hits[0]
1921 .why
1922 .iter()
1923 .any(|why| why.contains("symbol contains")));
1924 }
1925
1926 #[test]
1927 fn discover_tests_suggests_cargo_test_for_rust_tests() {
1928 let tmp = tempfile::tempdir().unwrap();
1929 std::fs::write(
1930 tmp.path().join("Cargo.toml"),
1931 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
1932 )
1933 .unwrap();
1934 let index = vec![IndexedSymbol {
1935 file: "src/session.rs".to_string(),
1936 name: "falls_back_to_env_token".to_string(),
1937 kind: "function".to_string(),
1938 line: 42,
1939 text: "fn falls_back_to_env_token()".to_string(),
1940 is_test: true,
1941 }];
1942
1943 let tests = discover_tests(
1944 &index,
1945 "src/session.rs#resolve_auth_fallback",
1946 tmp.path(),
1947 5,
1948 );
1949
1950 assert_eq!(tests.len(), 1);
1951 assert_eq!(
1952 tests[0].command.as_deref(),
1953 Some("cargo test falls_back_to_env_token")
1954 );
1955 }
1956
1957 #[test]
1958 fn related_symbols_returns_same_file_context() {
1959 let index = vec![
1960 IndexedSymbol {
1961 file: "src/session.rs".to_string(),
1962 name: "resolve_auth_fallback".to_string(),
1963 kind: "function".to_string(),
1964 line: 10,
1965 text: "fn resolve_auth_fallback()".to_string(),
1966 is_test: false,
1967 },
1968 IndexedSymbol {
1969 file: "src/session.rs".to_string(),
1970 name: "SessionConfig".to_string(),
1971 kind: "struct".to_string(),
1972 line: 2,
1973 text: "SessionConfig".to_string(),
1974 is_test: false,
1975 },
1976 ];
1977
1978 let related = related_symbols(
1979 &index,
1980 Some("src/session.rs"),
1981 Some("resolve_auth_fallback"),
1982 5,
1983 );
1984
1985 assert_eq!(related.len(), 1);
1986 assert_eq!(related[0].name, "SessionConfig");
1987 }
1988
1989 #[test]
1990 fn collect_target_files_extracts_paths_from_targets() {
1991 let cwd = Path::new("/tmp/example");
1992 let files = collect_target_files(&["src/lib.rs#run".to_string()], cwd);
1993
1994 assert_eq!(files, vec![PathBuf::from("/tmp/example/src/lib.rs")]);
1995 }
1996
1997 #[test]
1998 fn references_classify_definitions_and_calls() {
1999 let tmp = tempfile::tempdir().unwrap();
2000 let file = tmp.path().join("lib.rs");
2001 std::fs::write(
2002 &file,
2003 "fn resolve_auth_fallback() {}\nfn caller() { resolve_auth_fallback(); }\n",
2004 )
2005 .unwrap();
2006 let files = vec![file];
2007 let index = build_symbol_index(&files, tmp.path());
2008
2009 let references = find_references(&files, tmp.path(), &index, "resolve_auth_fallback", 10);
2010
2011 assert!(references.iter().any(|hit| hit.kind == "definition"));
2012 assert!(references.iter().any(|hit| hit.kind == "call"));
2013 }
2014
2015 #[test]
2016 fn impact_uses_tests_for_verification_commands() {
2017 let tmp = tempfile::tempdir().unwrap();
2018 std::fs::write(
2019 tmp.path().join("Cargo.toml"),
2020 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
2021 )
2022 .unwrap();
2023 let file = tmp.path().join("session.rs");
2024 std::fs::write(
2025 &file,
2026 "fn resolve_auth_fallback() {}\n#[test]\nfn resolve_auth_fallback_uses_env() { resolve_auth_fallback(); }\n",
2027 )
2028 .unwrap();
2029
2030 let output = execute_impact(
2031 vec![file],
2032 tmp.path(),
2033 "session.rs#resolve_auth_fallback",
2034 10,
2035 );
2036
2037 assert_eq!(output.details["action"], "impact");
2038 assert!(output.details["verify_commands"]
2039 .as_array()
2040 .unwrap()
2041 .iter()
2042 .any(|value| value.as_str() == Some("cargo test resolve_auth_fallback_uses_env")));
2043 }
2044
2045 #[test]
2046 fn parse_extract_target_rejects_invalid_lines() {
2047 assert!(parse_extract_target("src/lib.rs:0").is_none());
2048 assert!(parse_extract_target("src/lib.rs:10-2").is_none());
2049 assert!(parse_extract_target("src/lib.rs:1-2").is_some());
2050 }
2051
2052 #[test]
2053 fn execute_extract_reports_invalid_target_errors() {
2054 let tmp = tempfile::tempdir().unwrap();
2055 let (tx, _rx) = tokio::sync::mpsc::channel(1);
2056 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
2057 let ctx = ToolContext {
2058 cwd: tmp.path().to_path_buf(),
2059 cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2060 update_tx: tx,
2061 command_tx: cmd_tx,
2062 ui: std::sync::Arc::new(crate::ui::NullInterface),
2063 file_cache: std::sync::Arc::new(crate::tools::FileCache::new()),
2064 checkpoint_state: std::sync::Arc::new(crate::tools::CheckpointState::new()),
2065 file_tracker: std::sync::Arc::new(std::sync::Mutex::new(
2066 crate::tools::FileTracker::new(),
2067 )),
2068 anchor_store: std::sync::Arc::new(crate::tools::AnchorStore::new()),
2069 lua_tool_loader: None,
2070 mode: crate::config::AgentMode::Full,
2071 read_max_lines: 500,
2072 turn_mana_review: std::sync::Arc::new(std::sync::Mutex::new(
2073 crate::mana_review::TurnManaReviewAccumulator::default(),
2074 )),
2075 run_policy: Default::default(),
2076 config: std::sync::Arc::new(crate::config::Config::default()),
2077 supporting_provenance: Vec::new(),
2078 };
2079
2080 let output = execute_extract(&["not-a-target".to_string()], &ctx);
2081
2082 assert!(output.is_error);
2083 assert_eq!(output.details["action"], "extract");
2084 assert_eq!(output.details["errors"].as_array().unwrap().len(), 1);
2085 }
2086
2087 #[test]
2088 fn extract_rust_file() {
2089 let tmp = tempfile::tempdir().unwrap();
2090 let file = tmp.path().join("sample.rs");
2091 std::fs::write(
2092 &file,
2093 r#"
2094pub struct User {
2095 pub name: String,
2096 pub age: u32,
2097}
2098
2099pub enum Status { Active, Inactive }
2100
2101pub trait Validate {
2102 fn validate(&self) -> bool;
2103}
2104
2105impl Validate for User {
2106 fn validate(&self) -> bool { true }
2107}
2108
2109pub async fn load_user(id: &str) -> Result<User> { todo!() }
2110fn internal_helper() {}
2111"#,
2112 )
2113 .unwrap();
2114
2115 let result = extract_files(&[file], tmp.path());
2116
2117 assert!(result.types.contains_key("User"));
2119 assert!(result.types.contains_key("Status"));
2120 assert!(result.types.contains_key("Validate"));
2121
2122 let user = &result.types["User"];
2124 assert_eq!(user.fields.len(), 2);
2125 assert_eq!(user.visibility, Visibility::Public);
2126
2127 let status = &result.types["Status"];
2129 assert_eq!(status.variants, vec!["Active", "Inactive"]);
2130
2131 let validate = &result.types["Validate"];
2133 assert!(validate.methods.contains(&"validate".to_string()));
2134
2135 assert!(user.implements.contains(&"Validate".to_string()));
2137
2138 let load = &result.functions["load_user"];
2140 assert!(load.is_async);
2141 assert!(load.signature.contains("-> Result<User>"));
2142 assert_eq!(load.visibility, Visibility::Public);
2143
2144 let helper = &result.functions["internal_helper"];
2145 assert_eq!(helper.visibility, Visibility::Private);
2146 }
2147
2148 #[test]
2149 fn extract_typescript_file() {
2150 let tmp = tempfile::tempdir().unwrap();
2151 let file = tmp.path().join("models.ts");
2152 std::fs::write(
2153 &file,
2154 r#"
2155export interface User {
2156 name: string;
2157 email: string;
2158}
2159
2160export enum Status {
2161 Active = "active",
2162 Inactive = "inactive",
2163}
2164
2165export async function fetchUser(id: string): Promise<User> {
2166 return {} as User;
2167}
2168
2169function internalHelper(): void {}
2170"#,
2171 )
2172 .unwrap();
2173
2174 let result = extract_files(&[file], tmp.path());
2175 assert!(result.types.contains_key("User"));
2176 assert!(result.types.contains_key("Status"));
2177 assert_eq!(result.types["User"].visibility, Visibility::Public);
2178 assert_eq!(result.types["Status"].variants, vec!["Active", "Inactive"]);
2179 assert!(result.functions["fetchUser"].is_async);
2180 assert_eq!(
2181 result.functions["internalHelper"].visibility,
2182 Visibility::Private
2183 );
2184 }
2185
2186 #[test]
2187 fn format_output_shows_rich_info() {
2188 let tmp = tempfile::tempdir().unwrap();
2189 let file = tmp.path().join("lib.rs");
2190 std::fs::write(
2191 &file,
2192 r#"
2193pub struct Config { pub host: String, pub port: u16 }
2194pub enum Mode { Debug, Release }
2195pub fn start(config: &Config) -> Result<()> { todo!() }
2196"#,
2197 )
2198 .unwrap();
2199
2200 let result = extract_files(std::slice::from_ref(&file), tmp.path());
2201 let output = format_result(&result, &[file], tmp.path(), "extract", None);
2202
2203 assert!(output.contains("pub struct Config { host, port }"));
2204 assert!(output.contains("pub enum Mode { Debug, Release }"));
2205 assert!(output.contains("pub fn start"));
2206 assert!(output.contains("-> Result<()>"));
2207 }
2208
2209 #[test]
2210 fn skeleton_output_includes_line_ranges_and_target_hint() {
2211 let tmp = tempfile::tempdir().unwrap();
2212 let file = tmp.path().join("lib.rs");
2213 std::fs::write(
2214 &file,
2215 r#"
2216pub struct Config { pub host: String, pub port: u16 }
2217pub fn start(config: &Config) -> Result<()> { todo!() }
2218"#,
2219 )
2220 .unwrap();
2221
2222 let result = extract_files(std::slice::from_ref(&file), tmp.path());
2223 let output = format_result(&result, &[file], tmp.path(), "build", None);
2224
2225 assert!(output.contains("compact code skeleton"));
2226 assert!(output.contains("file#symbol"));
2227 assert!(output.contains("pub struct Config"));
2228 assert!(output.contains(" @ lib.rs:2"));
2229 assert!(output.contains("pub fn start"));
2230 assert!(output.contains(" @ lib.rs:3"));
2231 }
2232
2233 #[test]
2234 fn symbol_extract_includes_structured_details() {
2235 let tmp = tempfile::tempdir().unwrap();
2236 let file = tmp.path().join("lib.rs");
2237 std::fs::write(
2238 &file,
2239 r#"
2240pub struct Config {
2241 pub host: String,
2242}
2243
2244pub fn start(config: &Config) -> Result<()> { todo!() }
2245"#,
2246 )
2247 .unwrap();
2248
2249 let found =
2250 extract_symbol(&std::fs::read_to_string(&file).unwrap(), &file, "Config").unwrap();
2251 let output = format_blocks(&[CodeBlock {
2252 file: PathBuf::from("lib.rs"),
2253 ..found
2254 }]);
2255
2256 assert!(output.contains("Details:"));
2257 assert!(output.contains("\"symbol\":\"Config\""));
2258 assert!(output.contains("\"language\":\"rust\""));
2259 assert!(output.contains("\"start_line\":2"));
2260 assert!(output.contains("pub struct Config"));
2261 }
2262
2263 #[test]
2264 fn typescript_skeleton_output_includes_line_ranges() {
2265 let tmp = tempfile::tempdir().unwrap();
2266 let file = tmp.path().join("models.ts");
2267 std::fs::write(
2268 &file,
2269 r#"
2270export interface User { name: string; }
2271export async function fetchUser(id: string): Promise<User> { return {} as User; }
2272"#,
2273 )
2274 .unwrap();
2275
2276 let result = extract_files(std::slice::from_ref(&file), tmp.path());
2277 let output = format_result(&result, &[file], tmp.path(), "build", None);
2278
2279 assert!(output.contains("pub interface User @ models.ts:2"));
2280 assert!(output.contains("pub async function fetchUser"));
2281 assert!(output.contains(" @ models.ts:3"));
2282 }
2283}