1use crate::formatter::{
9 format_file_details, format_focused_internal, format_focused_summary_internal, format_structure,
10};
11use crate::graph::{CallGraph, InternalCallChain};
12use crate::lang::{language_for_extension, supported_languages};
13use crate::parser::{ElementExtractor, SemanticExtractor};
14use crate::test_detection::is_test_file;
15use crate::traversal::{WalkEntry, walk_directory};
16use crate::types::{
17 AnalysisMode, FileInfo, ImplTraitInfo, ImportInfo, SemanticAnalysis, SymbolMatchMode,
18};
19use rayon::prelude::*;
20#[cfg(feature = "schemars")]
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25use std::sync::atomic::{AtomicUsize, Ordering};
26use std::time::Instant;
27use thiserror::Error;
28use tokio_util::sync::CancellationToken;
29use tracing::instrument;
30
31pub const MAX_FILE_SIZE_BYTES: u64 = 10_000_000;
32
33#[derive(Debug, Error)]
34#[non_exhaustive]
35pub enum AnalyzeError {
36 #[error("Traversal error: {0}")]
37 Traversal(#[from] crate::traversal::TraversalError),
38 #[error("Parser error: {0}")]
39 Parser(#[from] crate::parser::ParserError),
40 #[error("Graph error: {0}")]
41 Graph(#[from] crate::graph::GraphError),
42 #[error("Formatter error: {0}")]
43 Formatter(#[from] crate::formatter::FormatterError),
44 #[error("Analysis cancelled")]
45 Cancelled,
46 #[error("unsupported language: {0}")]
47 UnsupportedLanguage(String),
48 #[error("I/O error: {0}")]
49 Io(#[from] std::io::Error),
50 #[error("invalid range: start ({start}) > end ({end}); file has {total} lines")]
51 InvalidRange {
52 start: usize,
53 end: usize,
54 total: usize,
55 },
56 #[error("path is a directory, not a file: {0}")]
57 NotAFile(PathBuf),
58 #[error(
59 "file has {total_lines} lines; provide start_line and end_line, or call analyze_module first to locate the range"
60 )]
61 RangelessLargeFile { total_lines: usize },
62 #[error("parse timeout exceeded for {path}: {micros} microseconds")]
63 ParseTimeout { path: PathBuf, micros: u64 },
64}
65
66#[derive(Debug, Clone, Serialize)]
68#[cfg_attr(feature = "schemars", derive(JsonSchema))]
69#[non_exhaustive]
70pub struct AnalysisOutput {
71 #[cfg_attr(
72 feature = "schemars",
73 schemars(description = "Formatted text representation of the analysis")
74 )]
75 pub formatted: String,
76 #[cfg_attr(
77 feature = "schemars",
78 schemars(description = "List of files analyzed in the directory")
79 )]
80 pub files: Vec<FileInfo>,
81 #[serde(skip)]
83 #[cfg_attr(feature = "schemars", schemars(skip))]
84 pub entries: Vec<WalkEntry>,
85 #[serde(skip)]
87 #[cfg_attr(feature = "schemars", schemars(skip))]
88 pub subtree_counts: Option<Vec<(std::path::PathBuf, usize)>>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 #[cfg_attr(
91 feature = "schemars",
92 schemars(
93 description = "Opaque cursor token for the next page of results (absent when no more results)"
94 )
95 )]
96 pub next_cursor: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize)]
101#[cfg_attr(feature = "schemars", derive(JsonSchema))]
102#[non_exhaustive]
103pub struct FileAnalysisOutput {
104 #[cfg_attr(
105 feature = "schemars",
106 schemars(description = "Formatted text representation of the analysis")
107 )]
108 pub formatted: String,
109 #[cfg_attr(
110 feature = "schemars",
111 schemars(description = "Semantic analysis data including functions, classes, and imports")
112 )]
113 pub semantic: SemanticAnalysis,
114 #[cfg_attr(
115 feature = "schemars",
116 schemars(description = "Total line count of the analyzed file")
117 )]
118 #[cfg_attr(
119 feature = "schemars",
120 schemars(schema_with = "crate::schema_helpers::integer_schema")
121 )]
122 pub line_count: usize,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 #[cfg_attr(
125 feature = "schemars",
126 schemars(
127 description = "Opaque cursor token for the next page of results (absent when no more results)"
128 )
129 )]
130 pub next_cursor: Option<String>,
131}
132
133impl FileAnalysisOutput {
134 #[must_use]
136 pub fn new(
137 formatted: String,
138 semantic: SemanticAnalysis,
139 line_count: usize,
140 next_cursor: Option<String>,
141 ) -> Self {
142 Self {
143 formatted,
144 semantic,
145 line_count,
146 next_cursor,
147 }
148 }
149}
150#[instrument(skip_all, fields(path = %root.display()))]
151#[allow(clippy::needless_pass_by_value)]
153pub fn analyze_directory_with_progress(
154 root: &Path,
155 entries: Vec<WalkEntry>,
156 progress: Arc<AtomicUsize>,
157 ct: CancellationToken,
158) -> Result<AnalysisOutput, AnalyzeError> {
159 if ct.is_cancelled() {
161 return Err(AnalyzeError::Cancelled);
162 }
163
164 let file_entries: Vec<&WalkEntry> = entries
166 .iter()
167 .filter(|e| !e.is_dir && !e.is_symlink)
168 .collect();
169
170 let start = Instant::now();
171 tracing::debug!(file_count = file_entries.len(), root = %root.display(), "analysis start");
172
173 let analysis_results: Vec<FileInfo> = file_entries
175 .par_iter()
176 .filter_map(|entry| {
177 if ct.is_cancelled() {
179 return None;
180 }
181
182 let path_str = entry.path.display().to_string();
183
184 let ext = entry.path.extension().and_then(|e| e.to_str());
186
187 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
189 tracing::debug!("skipping large file: {}", entry.path.display());
190 progress.fetch_add(1, Ordering::Relaxed);
191 return None;
192 }
193
194 let Ok(source) = std::fs::read_to_string(&entry.path) else {
196 progress.fetch_add(1, Ordering::Relaxed);
197 return None;
198 };
199
200 let line_count = source.lines().count();
202
203 let (language, function_count, class_count) = if let Some(ext_str) = ext {
205 if let Some(lang) = language_for_extension(ext_str) {
206 let lang_str = lang.to_string();
207 match ElementExtractor::extract_with_depth(&source, &lang_str) {
208 Ok((func_count, class_count)) => (lang_str, func_count, class_count),
209 Err(_) => (lang_str, 0, 0),
210 }
211 } else {
212 ("unknown".to_string(), 0, 0)
213 }
214 } else {
215 ("unknown".to_string(), 0, 0)
216 };
217
218 progress.fetch_add(1, Ordering::Relaxed);
219
220 let is_test = is_test_file(&entry.path);
221
222 Some(FileInfo {
223 path: path_str,
224 line_count,
225 function_count,
226 class_count,
227 language,
228 is_test,
229 })
230 })
231 .collect();
232
233 if ct.is_cancelled() {
235 return Err(AnalyzeError::Cancelled);
236 }
237
238 tracing::debug!(
239 file_count = file_entries.len(),
240 duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
241 "analysis complete"
242 );
243
244 let formatted = format_structure(&entries, &analysis_results, None);
246
247 Ok(AnalysisOutput {
248 formatted,
249 files: analysis_results,
250 entries,
251 next_cursor: None,
252 subtree_counts: None,
253 })
254}
255
256#[instrument(skip_all, fields(path = %root.display()))]
258pub fn analyze_directory(
259 root: &Path,
260 max_depth: Option<u32>,
261) -> Result<AnalysisOutput, AnalyzeError> {
262 let entries = walk_directory(root, max_depth)?;
263 let counter = Arc::new(AtomicUsize::new(0));
264 let ct = CancellationToken::new();
265 analyze_directory_with_progress(root, entries, counter, ct)
266}
267
268#[must_use]
270pub fn determine_mode(path: &str, focus: Option<&str>) -> AnalysisMode {
271 if focus.is_some() {
272 return AnalysisMode::SymbolFocus;
273 }
274
275 let path_obj = Path::new(path);
276 if path_obj.is_dir() {
277 AnalysisMode::Overview
278 } else {
279 AnalysisMode::FileDetails
280 }
281}
282
283#[instrument(skip_all, fields(path))]
285pub fn analyze_file(
286 path: &str,
287 ast_recursion_limit: Option<usize>,
288) -> Result<FileAnalysisOutput, AnalyzeError> {
289 let start = Instant::now();
290
291 if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
293 tracing::debug!("skipping large file: {}", path);
294 return Err(AnalyzeError::Parser(
295 crate::parser::ParserError::ParseError("file too large".to_string()),
296 ));
297 }
298
299 let source = std::fs::read_to_string(path)
300 .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
301
302 let line_count = source.lines().count();
303
304 let ext = Path::new(path)
306 .extension()
307 .and_then(|e| e.to_str())
308 .and_then(language_for_extension)
309 .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string);
310
311 let mut semantic = SemanticExtractor::extract(&source, &ext, ast_recursion_limit, None)?;
313
314 for r in &mut semantic.references {
316 r.location = path.to_string();
317 }
318
319 if ext == "python" {
321 resolve_wildcard_imports(Path::new(path), &mut semantic.imports);
322 }
323
324 let is_test = is_test_file(Path::new(path));
326
327 let parent_dir = Path::new(path).parent();
329
330 let formatted = format_file_details(path, &semantic, line_count, is_test, parent_dir);
332
333 tracing::debug!(path = %path, language = %ext, functions = semantic.functions.len(), classes = semantic.classes.len(), imports = semantic.imports.len(), duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), "file analysis complete");
334
335 Ok(FileAnalysisOutput::new(
336 formatted, semantic, line_count, None,
337 ))
338}
339
340#[inline]
367pub fn analyze_str(
368 source: &str,
369 language: &str,
370 ast_recursion_limit: Option<usize>,
371) -> Result<FileAnalysisOutput, AnalyzeError> {
372 let lang = language_for_extension(language).or_else(|| {
375 let lower = language.to_ascii_lowercase();
376 supported_languages()
377 .iter()
378 .find(|&&name| name == lower)
379 .copied()
380 });
381 let lang = lang.ok_or_else(|| AnalyzeError::UnsupportedLanguage(language.to_string()))?;
382
383 let mut semantic = SemanticExtractor::extract(source, lang, ast_recursion_limit, None)?;
385
386 for r in &mut semantic.references {
388 r.location = "<memory>".to_string();
389 }
390
391 let line_count = source.lines().count();
393
394 let formatted = format_file_details("", &semantic, line_count, false, None);
396
397 Ok(FileAnalysisOutput::new(
398 formatted, semantic, line_count, None,
399 ))
400}
401
402#[non_exhaustive]
404#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
405#[cfg_attr(feature = "schemars", derive(JsonSchema))]
406pub struct CallChainEntry {
407 #[cfg_attr(
408 feature = "schemars",
409 schemars(description = "Symbol name of the caller or callee")
410 )]
411 pub symbol: String,
412 #[cfg_attr(
413 feature = "schemars",
414 schemars(description = "File path relative to the repository root")
415 )]
416 pub file: String,
417 #[cfg_attr(
418 feature = "schemars",
419 schemars(
420 description = "Line number of the definition or call site (1-indexed)",
421 schema_with = "crate::schema_helpers::integer_schema"
422 )
423 )]
424 pub line: usize,
425}
426
427#[derive(Debug, Serialize)]
429#[cfg_attr(feature = "schemars", derive(JsonSchema))]
430#[non_exhaustive]
431pub struct FocusedAnalysisOutput {
432 #[cfg_attr(
433 feature = "schemars",
434 schemars(description = "Formatted text representation of the call graph analysis")
435 )]
436 pub formatted: String,
437 #[serde(skip_serializing_if = "Option::is_none")]
438 #[cfg_attr(
439 feature = "schemars",
440 schemars(
441 description = "Opaque cursor token for the next page of results (absent when no more results)"
442 )
443 )]
444 pub next_cursor: Option<String>,
445 #[serde(skip)]
448 #[cfg_attr(feature = "schemars", schemars(skip))]
449 pub prod_chains: Vec<InternalCallChain>,
450 #[serde(skip)]
452 #[cfg_attr(feature = "schemars", schemars(skip))]
453 pub test_chains: Vec<InternalCallChain>,
454 #[serde(skip)]
456 #[cfg_attr(feature = "schemars", schemars(skip))]
457 pub outgoing_chains: Vec<InternalCallChain>,
458 #[serde(skip)]
460 #[cfg_attr(feature = "schemars", schemars(skip))]
461 pub def_count: usize,
462 #[serde(skip)]
464 #[cfg_attr(feature = "schemars", schemars(skip))]
465 pub unfiltered_caller_count: usize,
466 #[serde(skip)]
468 #[cfg_attr(feature = "schemars", schemars(skip))]
469 pub impl_trait_caller_count: usize,
470 #[serde(skip_serializing_if = "Option::is_none")]
472 pub callers: Option<Vec<CallChainEntry>>,
473 #[serde(skip_serializing_if = "Option::is_none")]
475 pub test_callers: Option<Vec<CallChainEntry>>,
476 #[serde(skip_serializing_if = "Option::is_none")]
478 pub callees: Option<Vec<CallChainEntry>>,
479 #[serde(default)]
481 pub def_use_sites: Vec<crate::types::DefUseSite>,
482}
483
484#[derive(Clone)]
487pub struct FocusedAnalysisConfig {
488 pub focus: String,
489 pub match_mode: SymbolMatchMode,
490 pub follow_depth: u32,
491 pub max_depth: Option<u32>,
492 pub ast_recursion_limit: Option<usize>,
493 pub use_summary: bool,
494 pub impl_only: Option<bool>,
495 pub def_use: bool,
496 pub parse_timeout_micros: Option<u64>,
497}
498
499#[derive(Clone)]
501struct InternalFocusedParams {
502 focus: String,
503 match_mode: SymbolMatchMode,
504 follow_depth: u32,
505 ast_recursion_limit: Option<usize>,
506 use_summary: bool,
507 impl_only: Option<bool>,
508 def_use: bool,
509 parse_timeout_micros: Option<u64>,
510}
511
512type FileAnalysisBatch = (Vec<(PathBuf, SemanticAnalysis)>, Vec<ImplTraitInfo>);
514
515fn collect_file_analysis(
517 entries: &[WalkEntry],
518 progress: &Arc<AtomicUsize>,
519 ct: &CancellationToken,
520 ast_recursion_limit: Option<usize>,
521 parse_timeout_micros: Option<u64>,
522) -> Result<FileAnalysisBatch, AnalyzeError> {
523 if ct.is_cancelled() {
525 return Err(AnalyzeError::Cancelled);
526 }
527
528 let file_entries: Vec<&WalkEntry> = entries
531 .iter()
532 .filter(|e| !e.is_dir && !e.is_symlink)
533 .collect();
534
535 let timed_out: std::sync::Mutex<Vec<(PathBuf, u64)>> = std::sync::Mutex::new(Vec::new());
537
538 let analysis_results: Vec<(PathBuf, SemanticAnalysis)> = file_entries
539 .par_iter()
540 .filter_map(|entry| {
541 if ct.is_cancelled() {
543 return None;
544 }
545
546 let ext = entry.path.extension().and_then(|e| e.to_str());
547
548 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
550 tracing::debug!("skipping large file: {}", entry.path.display());
551 progress.fetch_add(1, Ordering::Relaxed);
552 return None;
553 }
554
555 let Ok(source) = std::fs::read_to_string(&entry.path) else {
557 progress.fetch_add(1, Ordering::Relaxed);
558 return None;
559 };
560
561 let language = if let Some(ext_str) = ext {
563 language_for_extension(ext_str)
564 .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string)
565 } else {
566 "unknown".to_string()
567 };
568
569 match SemanticExtractor::extract(
570 &source,
571 &language,
572 ast_recursion_limit,
573 parse_timeout_micros,
574 ) {
575 Ok(mut semantic) => {
576 for r in &mut semantic.references {
578 r.location = entry.path.display().to_string();
579 }
580 for trait_info in &mut semantic.impl_traits {
582 trait_info.path.clone_from(&entry.path);
583 }
584 progress.fetch_add(1, Ordering::Relaxed);
585 Some((entry.path.clone(), semantic))
586 }
587 Err(crate::parser::ParserError::Timeout(micros)) => {
588 tracing::warn!(
589 "parse timeout exceeded for {}: {} microseconds",
590 entry.path.display(),
591 micros
592 );
593 if let Ok(mut v) = timed_out.lock() {
594 v.push((entry.path.clone(), micros));
595 }
596 progress.fetch_add(1, Ordering::Relaxed);
597 None
598 }
599 Err(_) => {
600 progress.fetch_add(1, Ordering::Relaxed);
601 None
602 }
603 }
604 })
605 .collect();
606
607 if ct.is_cancelled() {
609 return Err(AnalyzeError::Cancelled);
610 }
611
612 if let Ok(mut v) = timed_out.lock()
614 && let Some((path, micros)) = v.drain(..).next()
615 {
616 return Err(AnalyzeError::ParseTimeout { path, micros });
617 }
618
619 let all_impl_traits: Vec<ImplTraitInfo> = analysis_results
621 .iter()
622 .flat_map(|(_, sem)| sem.impl_traits.iter().cloned())
623 .collect();
624
625 Ok((analysis_results, all_impl_traits))
626}
627
628fn build_call_graph(
630 analysis_results: Vec<(PathBuf, SemanticAnalysis)>,
631 all_impl_traits: &[ImplTraitInfo],
632) -> Result<CallGraph, AnalyzeError> {
633 CallGraph::build_from_results(
636 analysis_results,
637 all_impl_traits,
638 false, )
640 .map_err(std::convert::Into::into)
641}
642
643fn resolve_symbol(
648 graph: &mut CallGraph,
649 params: &InternalFocusedParams,
650) -> Result<(String, usize, usize), AnalyzeError> {
651 let resolved_focus = if params.match_mode == SymbolMatchMode::Exact {
653 let exists = graph.definitions.contains_key(¶ms.focus)
654 || graph.callers.contains_key(¶ms.focus)
655 || graph.callees.contains_key(¶ms.focus);
656 if exists {
657 params.focus.clone()
658 } else {
659 return Err(crate::graph::GraphError::SymbolNotFound {
660 symbol: params.focus.clone(),
661 hint: "Try match_mode=insensitive for a case-insensitive search, or match_mode=prefix to list symbols starting with this name.".to_string(),
662 }
663 .into());
664 }
665 } else {
666 graph.resolve_symbol_indexed(¶ms.focus, ¶ms.match_mode)?
667 };
668
669 let unfiltered_caller_count = graph.callers.get(&resolved_focus).map_or(0, |edges| {
671 edges
672 .iter()
673 .map(|e| &e.neighbor_name)
674 .collect::<std::collections::HashSet<_>>()
675 .len()
676 });
677
678 let impl_trait_caller_count = if params.impl_only.unwrap_or(false) {
682 for edges in graph.callers.values_mut() {
683 edges.retain(|e| e.is_impl_trait);
684 }
685 graph.callers.get(&resolved_focus).map_or(0, |edges| {
686 edges
687 .iter()
688 .map(|e| &e.neighbor_name)
689 .collect::<std::collections::HashSet<_>>()
690 .len()
691 })
692 } else {
693 unfiltered_caller_count
694 };
695
696 Ok((
697 resolved_focus,
698 unfiltered_caller_count,
699 impl_trait_caller_count,
700 ))
701}
702
703type ChainComputeResult = (
705 String,
706 Vec<InternalCallChain>,
707 Vec<InternalCallChain>,
708 Vec<InternalCallChain>,
709 usize,
710);
711
712fn chains_to_entries(
716 chains: &[InternalCallChain],
717 root: Option<&std::path::Path>,
718) -> Option<Vec<CallChainEntry>> {
719 if chains.is_empty() {
720 return None;
721 }
722 let entries: Vec<CallChainEntry> = chains
723 .iter()
724 .take(10)
725 .filter_map(|chain| {
726 let (symbol, path, line) = chain.chain.first()?;
727 let file = match root {
728 Some(root) => path
729 .strip_prefix(root)
730 .unwrap_or(path.as_path())
731 .to_string_lossy()
732 .into_owned(),
733 None => path.to_string_lossy().into_owned(),
734 };
735 Some(CallChainEntry {
736 symbol: symbol.clone(),
737 file,
738 line: *line,
739 })
740 })
741 .collect();
742 if entries.is_empty() {
743 None
744 } else {
745 Some(entries)
746 }
747}
748
749fn compute_chains(
751 graph: &CallGraph,
752 resolved_focus: &str,
753 root: &Path,
754 params: &InternalFocusedParams,
755 unfiltered_caller_count: usize,
756 impl_trait_caller_count: usize,
757 def_use_sites: &[crate::types::DefUseSite],
758) -> Result<ChainComputeResult, AnalyzeError> {
759 let def_count = graph.definitions.get(resolved_focus).map_or(0, Vec::len);
761 let incoming_chains = graph.find_incoming_chains(resolved_focus, params.follow_depth)?;
762 let outgoing_chains = graph.find_outgoing_chains(resolved_focus, params.follow_depth)?;
763
764 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
765 incoming_chains.iter().cloned().partition(|chain| {
766 chain
767 .chain
768 .first()
769 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
770 });
771
772 let mut formatted = if params.use_summary {
774 format_focused_summary_internal(
775 graph,
776 resolved_focus,
777 params.follow_depth,
778 Some(root),
779 Some(&incoming_chains),
780 Some(&outgoing_chains),
781 def_use_sites,
782 )?
783 } else {
784 format_focused_internal(
785 graph,
786 resolved_focus,
787 params.follow_depth,
788 Some(root),
789 Some(&incoming_chains),
790 Some(&outgoing_chains),
791 def_use_sites,
792 )?
793 };
794
795 if params.impl_only.unwrap_or(false) {
797 let filter_header = format!(
798 "FILTER: impl_only=true ({impl_trait_caller_count} of {unfiltered_caller_count} callers shown)\n",
799 );
800 formatted = format!("{filter_header}{formatted}");
801 }
802
803 Ok((
804 formatted,
805 prod_chains,
806 test_chains,
807 outgoing_chains,
808 def_count,
809 ))
810}
811
812#[allow(clippy::needless_pass_by_value)]
815pub fn analyze_focused_with_progress(
816 root: &Path,
817 params: &FocusedAnalysisConfig,
818 progress: Arc<AtomicUsize>,
819 ct: CancellationToken,
820) -> Result<FocusedAnalysisOutput, AnalyzeError> {
821 let entries = walk_directory(root, params.max_depth)?;
822 let internal_params = InternalFocusedParams {
823 focus: params.focus.clone(),
824 match_mode: params.match_mode.clone(),
825 follow_depth: params.follow_depth,
826 ast_recursion_limit: params.ast_recursion_limit,
827 use_summary: params.use_summary,
828 impl_only: params.impl_only,
829 def_use: params.def_use,
830 parse_timeout_micros: params.parse_timeout_micros,
831 };
832 analyze_focused_with_progress_with_entries_internal(
833 root,
834 params.max_depth,
835 &progress,
836 &ct,
837 &internal_params,
838 &entries,
839 )
840}
841
842#[instrument(skip_all, fields(path = %root.display(), symbol = %params.focus))]
844fn analyze_focused_with_progress_with_entries_internal(
845 root: &Path,
846 _max_depth: Option<u32>,
847 progress: &Arc<AtomicUsize>,
848 ct: &CancellationToken,
849 params: &InternalFocusedParams,
850 entries: &[WalkEntry],
851) -> Result<FocusedAnalysisOutput, AnalyzeError> {
852 if ct.is_cancelled() {
854 return Err(AnalyzeError::Cancelled);
855 }
856
857 if root.is_file() {
859 let formatted =
860 "Single-file focus not supported. Please provide a directory path for cross-file call graph analysis.\n"
861 .to_string();
862 return Ok(FocusedAnalysisOutput {
863 formatted,
864 next_cursor: None,
865 prod_chains: vec![],
866 test_chains: vec![],
867 outgoing_chains: vec![],
868 def_count: 0,
869 unfiltered_caller_count: 0,
870 impl_trait_caller_count: 0,
871 callers: None,
872 test_callers: None,
873 callees: None,
874 def_use_sites: vec![],
875 });
876 }
877
878 let (analysis_results, all_impl_traits) = collect_file_analysis(
880 entries,
881 progress,
882 ct,
883 params.ast_recursion_limit,
884 params.parse_timeout_micros,
885 )?;
886
887 if ct.is_cancelled() {
889 return Err(AnalyzeError::Cancelled);
890 }
891
892 let mut graph = build_call_graph(analysis_results, &all_impl_traits)?;
894
895 if ct.is_cancelled() {
897 return Err(AnalyzeError::Cancelled);
898 }
899
900 let resolve_result = resolve_symbol(&mut graph, params);
904 if let Err(AnalyzeError::Graph(crate::graph::GraphError::SymbolNotFound { .. })) =
905 &resolve_result
906 {
907 if params.def_use {
910 let def_use_sites =
911 collect_def_use_sites(entries, ¶ms.focus, params.ast_recursion_limit, root, ct);
912 if def_use_sites.is_empty() {
913 return Err(resolve_result.unwrap_err());
917 }
918 use std::fmt::Write as _;
919 let mut formatted = String::new();
920 let _ = writeln!(
921 formatted,
922 "FOCUS: {} (0 defs, 0 callers, 0 callees)",
923 params.focus
924 );
925 {
926 let writes = def_use_sites
927 .iter()
928 .filter(|s| {
929 matches!(
930 s.kind,
931 crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
932 )
933 })
934 .count();
935 let reads = def_use_sites
936 .iter()
937 .filter(|s| s.kind == crate::types::DefUseKind::Read)
938 .count();
939 let _ = writeln!(
940 formatted,
941 "DEF-USE SITES {} ({} total: {} writes, {} reads)",
942 params.focus,
943 def_use_sites.len(),
944 writes,
945 reads
946 );
947 }
948 return Ok(FocusedAnalysisOutput {
949 formatted,
950 next_cursor: None,
951 callers: None,
952 test_callers: None,
953 callees: None,
954 prod_chains: vec![],
955 test_chains: vec![],
956 outgoing_chains: vec![],
957 def_count: 0,
958 unfiltered_caller_count: 0,
959 impl_trait_caller_count: 0,
960 def_use_sites,
961 });
962 }
963 }
964 let (resolved_focus, unfiltered_caller_count, impl_trait_caller_count) = resolve_result?;
965
966 if ct.is_cancelled() {
968 return Err(AnalyzeError::Cancelled);
969 }
970
971 let def_use_sites = if params.def_use {
975 collect_def_use_sites(entries, ¶ms.focus, params.ast_recursion_limit, root, ct)
976 } else {
977 Vec::new()
978 };
979
980 let (formatted, prod_chains, test_chains, outgoing_chains, def_count) = compute_chains(
982 &graph,
983 &resolved_focus,
984 root,
985 params,
986 unfiltered_caller_count,
987 impl_trait_caller_count,
988 &def_use_sites,
989 )?;
990
991 let (depth1_callers, depth1_test_callers, depth1_callees) = if params.follow_depth <= 1 {
994 let callers = chains_to_entries(&prod_chains, Some(root));
996 let test_callers = chains_to_entries(&test_chains, Some(root));
997 let callees = chains_to_entries(&outgoing_chains, Some(root));
998 (callers, test_callers, callees)
999 } else {
1000 let incoming1 = graph
1002 .find_incoming_chains(&resolved_focus, 1)
1003 .unwrap_or_default();
1004 let outgoing1 = graph
1005 .find_outgoing_chains(&resolved_focus, 1)
1006 .unwrap_or_default();
1007 let (prod1, test1): (Vec<_>, Vec<_>) = incoming1.into_iter().partition(|chain| {
1008 chain
1009 .chain
1010 .first()
1011 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
1012 });
1013 let callers = chains_to_entries(&prod1, Some(root));
1014 let test_callers = chains_to_entries(&test1, Some(root));
1015 let callees = chains_to_entries(&outgoing1, Some(root));
1016 (callers, test_callers, callees)
1017 };
1018
1019 Ok(FocusedAnalysisOutput {
1020 formatted,
1021 next_cursor: None,
1022 callers: depth1_callers,
1023 test_callers: depth1_test_callers,
1024 callees: depth1_callees,
1025 prod_chains,
1026 test_chains,
1027 outgoing_chains,
1028 def_count,
1029 unfiltered_caller_count,
1030 impl_trait_caller_count,
1031 def_use_sites,
1032 })
1033}
1034
1035fn collect_def_use_sites(
1038 entries: &[WalkEntry],
1039 symbol: &str,
1040 ast_recursion_limit: Option<usize>,
1041 root: &std::path::Path,
1042 ct: &CancellationToken,
1043) -> Vec<crate::types::DefUseSite> {
1044 use crate::parser::SemanticExtractor;
1045
1046 let file_entries: Vec<&WalkEntry> = entries
1047 .iter()
1048 .filter(|e| !e.is_dir && !e.is_symlink)
1049 .collect();
1050
1051 let mut sites: Vec<crate::types::DefUseSite> = file_entries
1052 .par_iter()
1053 .filter_map(|entry| {
1054 if ct.is_cancelled() {
1055 return None;
1056 }
1057
1058 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1060 tracing::debug!("skipping large file: {}", entry.path.display());
1061 return None;
1062 }
1063
1064 let Ok(source) = std::fs::read_to_string(&entry.path) else {
1065 return None;
1066 };
1067 let ext = entry
1068 .path
1069 .extension()
1070 .and_then(|e| e.to_str())
1071 .unwrap_or("");
1072 let lang = crate::lang::language_for_extension(ext)?;
1073 let file_path = entry
1074 .path
1075 .strip_prefix(root)
1076 .unwrap_or(&entry.path)
1077 .display()
1078 .to_string();
1079 let sites = SemanticExtractor::extract_def_use_for_file(
1080 &source,
1081 lang,
1082 symbol,
1083 &file_path,
1084 ast_recursion_limit,
1085 );
1086 if sites.is_empty() { None } else { Some(sites) }
1087 })
1088 .flatten()
1089 .collect();
1090
1091 sites.sort_by(|a, b| {
1093 use crate::types::DefUseKind;
1094 let kind_ord = |k: &DefUseKind| match k {
1095 DefUseKind::Write | DefUseKind::WriteRead => 0,
1096 DefUseKind::Read => 1,
1097 };
1098 kind_ord(&a.kind)
1099 .cmp(&kind_ord(&b.kind))
1100 .then_with(|| a.file.cmp(&b.file))
1101 .then_with(|| a.line.cmp(&b.line))
1102 .then_with(|| a.column.cmp(&b.column))
1103 });
1104
1105 sites
1106}
1107
1108pub fn analyze_focused_with_progress_with_entries(
1110 root: &Path,
1111 params: &FocusedAnalysisConfig,
1112 progress: &Arc<AtomicUsize>,
1113 ct: &CancellationToken,
1114 entries: &[WalkEntry],
1115) -> Result<FocusedAnalysisOutput, AnalyzeError> {
1116 let internal_params = InternalFocusedParams {
1117 focus: params.focus.clone(),
1118 match_mode: params.match_mode.clone(),
1119 follow_depth: params.follow_depth,
1120 ast_recursion_limit: params.ast_recursion_limit,
1121 use_summary: params.use_summary,
1122 impl_only: params.impl_only,
1123 def_use: params.def_use,
1124 parse_timeout_micros: params.parse_timeout_micros,
1125 };
1126 analyze_focused_with_progress_with_entries_internal(
1127 root,
1128 params.max_depth,
1129 progress,
1130 ct,
1131 &internal_params,
1132 entries,
1133 )
1134}
1135
1136#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
1137pub fn analyze_focused(
1138 root: &Path,
1139 focus: &str,
1140 follow_depth: u32,
1141 max_depth: Option<u32>,
1142 ast_recursion_limit: Option<usize>,
1143) -> Result<FocusedAnalysisOutput, AnalyzeError> {
1144 let entries = walk_directory(root, max_depth)?;
1145 let counter = Arc::new(AtomicUsize::new(0));
1146 let ct = CancellationToken::new();
1147 let params = FocusedAnalysisConfig {
1148 focus: focus.to_string(),
1149 match_mode: SymbolMatchMode::Exact,
1150 follow_depth,
1151 max_depth,
1152 ast_recursion_limit,
1153 use_summary: false,
1154 impl_only: None,
1155 def_use: false,
1156 parse_timeout_micros: None,
1157 };
1158 analyze_focused_with_progress_with_entries(root, ¶ms, &counter, &ct, &entries)
1159}
1160
1161#[instrument(skip_all, fields(path))]
1164pub fn analyze_module_file(path: &str) -> Result<crate::types::ModuleInfo, AnalyzeError> {
1165 if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1167 tracing::debug!("skipping large file: {}", path);
1168 return Err(AnalyzeError::Parser(
1169 crate::parser::ParserError::ParseError("file too large".to_string()),
1170 ));
1171 }
1172
1173 let source = std::fs::read_to_string(path)
1174 .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
1175
1176 let file_path = Path::new(path);
1177 let name = file_path
1178 .file_name()
1179 .and_then(|s| s.to_str())
1180 .unwrap_or("unknown")
1181 .to_string();
1182
1183 let line_count = source.lines().count();
1184
1185 let language = file_path
1186 .extension()
1187 .and_then(|e| e.to_str())
1188 .and_then(language_for_extension)
1189 .ok_or_else(|| {
1190 AnalyzeError::Parser(crate::parser::ParserError::ParseError(
1191 "unsupported or missing file extension".to_string(),
1192 ))
1193 })?;
1194
1195 let semantic = SemanticExtractor::extract(&source, language, None, None)?;
1196
1197 let functions = semantic
1198 .functions
1199 .into_iter()
1200 .map(|f| crate::types::ModuleFunctionInfo {
1201 name: f.name,
1202 line: f.line,
1203 })
1204 .collect();
1205
1206 let imports = semantic
1207 .imports
1208 .into_iter()
1209 .map(|i| crate::types::ModuleImportInfo {
1210 module: i.module,
1211 items: i.items,
1212 })
1213 .collect();
1214
1215 Ok(crate::types::ModuleInfo {
1216 name,
1217 line_count,
1218 language: language.to_string(),
1219 functions,
1220 imports,
1221 })
1222}
1223
1224pub fn analyze_import_lookup(
1230 root: &Path,
1231 module: &str,
1232 entries: &[WalkEntry],
1233 ast_recursion_limit: Option<usize>,
1234) -> Result<FocusedAnalysisOutput, AnalyzeError> {
1235 let matches: Vec<(PathBuf, usize)> = entries
1236 .par_iter()
1237 .filter_map(|entry| {
1238 if entry.is_dir || entry.is_symlink {
1239 tracing::debug!("skipping symlink: {}", entry.path.display());
1240 return None;
1241 }
1242 let ext = entry
1243 .path
1244 .extension()
1245 .and_then(|e| e.to_str())
1246 .and_then(crate::lang::language_for_extension)?;
1247 let source = std::fs::read_to_string(&entry.path).ok()?;
1248 let semantic =
1249 SemanticExtractor::extract(&source, ext, ast_recursion_limit, None).ok()?;
1250 for import in &semantic.imports {
1251 if import.module == module || import.items.iter().any(|item| item == module) {
1252 return Some((entry.path.clone(), import.line));
1253 }
1254 }
1255 None
1256 })
1257 .collect();
1258
1259 let mut text = format!("IMPORT_LOOKUP: {module}\n");
1260 text.push_str(&format!("ROOT: {}\n", root.display()));
1261 text.push_str(&format!("MATCHES: {}\n", matches.len()));
1262 for (path, line) in &matches {
1263 let rel = path.strip_prefix(root).unwrap_or(path);
1264 text.push_str(&format!(" {}:{line}\n", rel.display()));
1265 }
1266
1267 Ok(FocusedAnalysisOutput {
1268 formatted: text,
1269 next_cursor: None,
1270 prod_chains: vec![],
1271 test_chains: vec![],
1272 outgoing_chains: vec![],
1273 def_count: 0,
1274 unfiltered_caller_count: 0,
1275 impl_trait_caller_count: 0,
1276 callers: None,
1277 test_callers: None,
1278 callees: None,
1279 def_use_sites: vec![],
1280 })
1281}
1282
1283fn resolve_wildcard_imports(file_path: &Path, imports: &mut [ImportInfo]) {
1293 use std::collections::HashMap;
1294
1295 let mut resolved_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
1296 let Ok(file_path_canonical) = file_path.canonicalize() else {
1297 tracing::debug!(file = ?file_path, "unable to canonicalize current file path");
1298 return;
1299 };
1300
1301 for import in imports.iter_mut() {
1302 if import.items != ["*"] {
1303 continue;
1304 }
1305 resolve_single_wildcard(import, file_path, &file_path_canonical, &mut resolved_cache);
1306 }
1307}
1308
1309fn resolve_single_wildcard(
1311 import: &mut ImportInfo,
1312 file_path: &Path,
1313 file_path_canonical: &Path,
1314 resolved_cache: &mut std::collections::HashMap<PathBuf, Vec<String>>,
1315) {
1316 let module = import.module.clone();
1317 let dot_count = module.chars().take_while(|c| *c == '.').count();
1318 if dot_count == 0 {
1319 return;
1320 }
1321 let module_path = module.trim_start_matches('.');
1322
1323 let Some(target_to_read) = locate_target_file(file_path, dot_count, module_path, &module)
1324 else {
1325 return;
1326 };
1327
1328 let Ok(canonical) = target_to_read.canonicalize() else {
1329 tracing::debug!(target = ?target_to_read, import = %module, "unable to canonicalize path");
1330 return;
1331 };
1332
1333 if canonical == file_path_canonical {
1334 tracing::debug!(target = ?canonical, import = %module, "cannot import from self");
1335 return;
1336 }
1337
1338 if let Some(cached) = resolved_cache.get(&canonical) {
1339 tracing::debug!(import = %module, symbols_count = cached.len(), "using cached symbols");
1340 import.items.clone_from(cached);
1341 return;
1342 }
1343
1344 if let Some(symbols) = parse_target_symbols(&target_to_read, &module) {
1345 tracing::debug!(import = %module, resolved_count = symbols.len(), "wildcard import resolved");
1346 import.items.clone_from(&symbols);
1347 resolved_cache.insert(canonical, symbols);
1348 }
1349}
1350
1351fn locate_target_file(
1353 file_path: &Path,
1354 dot_count: usize,
1355 module_path: &str,
1356 module: &str,
1357) -> Option<PathBuf> {
1358 let mut target_dir = file_path.parent()?.to_path_buf();
1359
1360 for _ in 1..dot_count {
1361 if !target_dir.pop() {
1362 tracing::debug!(import = %module, "unable to climb {} levels", dot_count.saturating_sub(1));
1363 return None;
1364 }
1365 }
1366
1367 let target_file = if module_path.is_empty() {
1368 target_dir.join("__init__.py")
1369 } else {
1370 let rel_path = module_path.replace('.', "/");
1371 target_dir.join(format!("{rel_path}.py"))
1372 };
1373
1374 if target_file.exists() {
1375 Some(target_file)
1376 } else if target_file.with_extension("").is_dir() {
1377 let init = target_file.with_extension("").join("__init__.py");
1378 if init.exists() { Some(init) } else { None }
1379 } else {
1380 tracing::debug!(target = ?target_file, import = %module, "target file not found");
1381 None
1382 }
1383}
1384
1385fn parse_target_symbols(target_path: &Path, module: &str) -> Option<Vec<String>> {
1387 use tree_sitter::Parser;
1388
1389 if target_path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1391 tracing::debug!("skipping large file: {}", target_path.display());
1392 return None;
1393 }
1394
1395 let source = match std::fs::read_to_string(target_path) {
1396 Ok(s) => s,
1397 Err(e) => {
1398 tracing::debug!(target = ?target_path, import = %module, error = %e, "unable to read target file");
1399 return None;
1400 }
1401 };
1402
1403 let lang_info = crate::languages::get_language_info("python")?;
1405 let mut parser = Parser::new();
1406 if parser.set_language(&lang_info.language).is_err() {
1407 return None;
1408 }
1409 let tree = parser.parse(&source, None)?;
1410
1411 let mut symbols = Vec::new();
1413 extract_all_from_tree(&tree, &source, &mut symbols);
1414 if !symbols.is_empty() {
1415 tracing::debug!(import = %module, symbols = ?symbols, "using __all__ symbols");
1416 return Some(symbols);
1417 }
1418
1419 let root = tree.root_node();
1421 let mut cursor = root.walk();
1422 for child in root.children(&mut cursor) {
1423 if matches!(child.kind(), "function_definition" | "class_definition")
1424 && let Some(name_node) = child.child_by_field_name("name")
1425 {
1426 let name = source[name_node.start_byte()..name_node.end_byte()].to_string();
1427 if !name.starts_with('_') {
1428 symbols.push(name);
1429 }
1430 }
1431 }
1432 tracing::debug!(import = %module, fallback_symbols = ?symbols, "using fallback function/class names");
1433 Some(symbols)
1434}
1435
1436fn extract_all_from_tree(tree: &tree_sitter::Tree, source: &str, result: &mut Vec<String>) {
1438 let root = tree.root_node();
1439 let mut cursor = root.walk();
1440 for child in root.children(&mut cursor) {
1441 if child.kind() == "simple_statement" {
1442 let mut simple_cursor = child.walk();
1444 for simple_child in child.children(&mut simple_cursor) {
1445 if simple_child.kind() == "assignment"
1446 && let Some(left) = simple_child.child_by_field_name("left")
1447 {
1448 let target_text = source[left.start_byte()..left.end_byte()].trim();
1449 if target_text == "__all__"
1450 && let Some(right) = simple_child.child_by_field_name("right")
1451 {
1452 extract_string_list_from_list_node(&right, source, result);
1453 }
1454 }
1455 }
1456 } else if child.kind() == "expression_statement" {
1457 let mut stmt_cursor = child.walk();
1459 for stmt_child in child.children(&mut stmt_cursor) {
1460 if stmt_child.kind() == "assignment"
1461 && let Some(left) = stmt_child.child_by_field_name("left")
1462 {
1463 let target_text = source[left.start_byte()..left.end_byte()].trim();
1464 if target_text == "__all__"
1465 && let Some(right) = stmt_child.child_by_field_name("right")
1466 {
1467 extract_string_list_from_list_node(&right, source, result);
1468 }
1469 }
1470 }
1471 }
1472 }
1473}
1474
1475fn extract_string_list_from_list_node(
1477 list_node: &tree_sitter::Node,
1478 source: &str,
1479 result: &mut Vec<String>,
1480) {
1481 let mut cursor = list_node.walk();
1482 for child in list_node.named_children(&mut cursor) {
1483 if child.kind() == "string" {
1484 let raw = source[child.start_byte()..child.end_byte()].trim();
1485 let unquoted = raw.trim_matches('"').trim_matches('\'').to_string();
1487 if !unquoted.is_empty() {
1488 result.push(unquoted);
1489 }
1490 }
1491 }
1492}
1493
1494#[cfg(test)]
1496mod tests {
1497 use super::*;
1498 use crate::formatter::format_focused_paginated;
1499 use crate::graph::InternalCallChain;
1500 use crate::pagination::{PaginationMode, decode_cursor, paginate_slice};
1501 use std::fs;
1502 use std::path::PathBuf;
1503 use tempfile::TempDir;
1504
1505 #[cfg(feature = "lang-rust")]
1506 #[test]
1507 fn analyze_str_rust_happy_path() {
1508 let source = "fn hello() -> i32 { 42 }";
1509 let result = analyze_str(source, "rs", None);
1510 assert!(result.is_ok());
1511 }
1512
1513 #[cfg(feature = "lang-python")]
1514 #[test]
1515 fn analyze_str_python_happy_path() {
1516 let source = "def greet(name):\n return f'Hello {name}'";
1517 let result = analyze_str(source, "py", None);
1518 assert!(result.is_ok());
1519 }
1520
1521 #[cfg(feature = "lang-rust")]
1522 #[test]
1523 fn analyze_str_rust_by_language_name() {
1524 let source = "fn hello() -> i32 { 42 }";
1525 let result = analyze_str(source, "rust", None);
1526 assert!(result.is_ok());
1527 }
1528
1529 #[cfg(feature = "lang-python")]
1530 #[test]
1531 fn analyze_str_python_by_language_name() {
1532 let source = "def greet(name):\n return f'Hello {name}'";
1533 let result = analyze_str(source, "python", None);
1534 assert!(result.is_ok());
1535 }
1536
1537 #[cfg(feature = "lang-rust")]
1538 #[test]
1539 fn analyze_str_rust_mixed_case() {
1540 let source = "fn hello() -> i32 { 42 }";
1541 let result = analyze_str(source, "RuSt", None);
1542 assert!(result.is_ok());
1543 }
1544
1545 #[cfg(feature = "lang-python")]
1546 #[test]
1547 fn analyze_str_python_mixed_case() {
1548 let source = "def greet(name):\n return f'Hello {name}'";
1549 let result = analyze_str(source, "PyThOn", None);
1550 assert!(result.is_ok());
1551 }
1552
1553 #[test]
1554 fn analyze_str_unsupported_language() {
1555 let result = analyze_str("code", "brainfuck", None);
1556 assert!(
1557 matches!(result, Err(AnalyzeError::UnsupportedLanguage(lang)) if lang == "brainfuck")
1558 );
1559 }
1560
1561 #[cfg(feature = "lang-rust")]
1562 #[test]
1563 fn test_symbol_focus_callers_pagination_first_page() {
1564 let temp_dir = TempDir::new().unwrap();
1565
1566 let mut code = String::from("fn target() {}\n");
1568 for i in 0..15 {
1569 code.push_str(&format!("fn caller_{:02}() {{ target(); }}\n", i));
1570 }
1571 fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
1572
1573 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1575
1576 let paginated = paginate_slice(&output.prod_chains, 0, 5, PaginationMode::Callers)
1578 .expect("paginate failed");
1579 assert!(
1580 paginated.total >= 5,
1581 "should have enough callers to paginate"
1582 );
1583 assert!(
1584 paginated.next_cursor.is_some(),
1585 "should have next_cursor for page 1"
1586 );
1587
1588 assert_eq!(paginated.items.len(), 5);
1590 }
1591
1592 #[test]
1593 fn test_symbol_focus_callers_pagination_second_page() {
1594 let temp_dir = TempDir::new().unwrap();
1595
1596 let mut code = String::from("fn target() {}\n");
1597 for i in 0..12 {
1598 code.push_str(&format!("fn caller_{:02}() {{ target(); }}\n", i));
1599 }
1600 fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
1601
1602 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1603 let total_prod = output.prod_chains.len();
1604
1605 if total_prod > 5 {
1606 let p1 = paginate_slice(&output.prod_chains, 0, 5, PaginationMode::Callers)
1608 .expect("paginate failed");
1609 assert!(p1.next_cursor.is_some());
1610
1611 let cursor_str = p1.next_cursor.unwrap();
1612 let cursor_data = decode_cursor(&cursor_str).expect("decode failed");
1613
1614 let p2 = paginate_slice(
1616 &output.prod_chains,
1617 cursor_data.offset,
1618 5,
1619 PaginationMode::Callers,
1620 )
1621 .expect("paginate failed");
1622
1623 let formatted = format_focused_paginated(
1625 &p2.items,
1626 total_prod,
1627 PaginationMode::Callers,
1628 "target",
1629 &output.prod_chains,
1630 &output.test_chains,
1631 &output.outgoing_chains,
1632 output.def_count,
1633 cursor_data.offset,
1634 Some(temp_dir.path()),
1635 true,
1636 );
1637
1638 let expected_start = cursor_data.offset + 1;
1640 assert!(
1641 formatted.contains(&format!("CALLERS ({}", expected_start)),
1642 "header should show page 2 range, got: {}",
1643 formatted
1644 );
1645 }
1646 }
1647
1648 #[test]
1649 fn test_chains_to_entries_empty_returns_none() {
1650 let chains: Vec<InternalCallChain> = vec![];
1652
1653 let result = chains_to_entries(&chains, None);
1655
1656 assert!(result.is_none());
1658 }
1659
1660 #[test]
1661 fn test_chains_to_entries_with_data_returns_entries() {
1662 let chains = vec![
1664 InternalCallChain {
1665 chain: vec![("caller1".to_string(), PathBuf::from("/root/lib.rs"), 10)],
1666 },
1667 InternalCallChain {
1668 chain: vec![("caller2".to_string(), PathBuf::from("/root/other.rs"), 20)],
1669 },
1670 ];
1671 let root = PathBuf::from("/root");
1672
1673 let result = chains_to_entries(&chains, Some(root.as_path()));
1675
1676 assert!(result.is_some());
1678 let entries = result.unwrap();
1679 assert_eq!(entries.len(), 2);
1680 assert_eq!(entries[0].symbol, "caller1");
1681 assert_eq!(entries[0].file, "lib.rs");
1682 assert_eq!(entries[0].line, 10);
1683 assert_eq!(entries[1].symbol, "caller2");
1684 assert_eq!(entries[1].file, "other.rs");
1685 assert_eq!(entries[1].line, 20);
1686 }
1687
1688 #[test]
1689 fn test_symbol_focus_callees_pagination() {
1690 let temp_dir = TempDir::new().unwrap();
1691
1692 let mut code = String::from("fn target() {\n");
1694 for i in 0..10 {
1695 code.push_str(&format!(" callee_{:02}();\n", i));
1696 }
1697 code.push_str("}\n");
1698 for i in 0..10 {
1699 code.push_str(&format!("fn callee_{:02}() {{}}\n", i));
1700 }
1701 fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
1702
1703 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1704 let total_callees = output.outgoing_chains.len();
1705
1706 if total_callees > 3 {
1707 let paginated = paginate_slice(&output.outgoing_chains, 0, 3, PaginationMode::Callees)
1708 .expect("paginate failed");
1709
1710 let formatted = format_focused_paginated(
1711 &paginated.items,
1712 total_callees,
1713 PaginationMode::Callees,
1714 "target",
1715 &output.prod_chains,
1716 &output.test_chains,
1717 &output.outgoing_chains,
1718 output.def_count,
1719 0,
1720 Some(temp_dir.path()),
1721 true,
1722 );
1723
1724 assert!(
1725 formatted.contains(&format!(
1726 "CALLEES (1-{} of {})",
1727 paginated.items.len(),
1728 total_callees
1729 )),
1730 "header should show callees range, got: {}",
1731 formatted
1732 );
1733 }
1734 }
1735
1736 #[test]
1737 fn test_symbol_focus_empty_prod_callers() {
1738 let temp_dir = TempDir::new().unwrap();
1739
1740 let code = r#"
1742fn target() {}
1743
1744#[cfg(test)]
1745mod tests {
1746 use super::*;
1747 #[test]
1748 fn test_something() { target(); }
1749}
1750"#;
1751 fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
1752
1753 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1754
1755 let paginated = paginate_slice(&output.prod_chains, 0, 100, PaginationMode::Callers)
1757 .expect("paginate failed");
1758 assert_eq!(paginated.items.len(), output.prod_chains.len());
1759 assert!(
1760 paginated.next_cursor.is_none(),
1761 "no next_cursor for empty or single-page prod_chains"
1762 );
1763 }
1764
1765 #[test]
1766 fn test_impl_only_filter_header_correct_counts() {
1767 let temp_dir = TempDir::new().unwrap();
1768
1769 let code = r#"
1774trait MyTrait {
1775 fn focus_symbol();
1776}
1777
1778struct SomeType;
1779
1780impl MyTrait for SomeType {
1781 fn focus_symbol() {}
1782}
1783
1784fn impl_caller() {
1785 SomeType::focus_symbol();
1786}
1787
1788fn regular_caller() {
1789 SomeType::focus_symbol();
1790}
1791"#;
1792 fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
1793
1794 let params = FocusedAnalysisConfig {
1796 focus: "focus_symbol".to_string(),
1797 match_mode: SymbolMatchMode::Insensitive,
1798 follow_depth: 1,
1799 max_depth: None,
1800 ast_recursion_limit: None,
1801 use_summary: false,
1802 impl_only: Some(true),
1803 def_use: false,
1804 parse_timeout_micros: None,
1805 };
1806 let output = analyze_focused_with_progress(
1807 temp_dir.path(),
1808 ¶ms,
1809 Arc::new(AtomicUsize::new(0)),
1810 CancellationToken::new(),
1811 )
1812 .unwrap();
1813
1814 assert!(
1816 output.formatted.contains("FILTER: impl_only=true"),
1817 "formatted output should contain FILTER header for impl_only=true, got: {}",
1818 output.formatted
1819 );
1820
1821 assert!(
1823 output.impl_trait_caller_count < output.unfiltered_caller_count,
1824 "impl_trait_caller_count ({}) should be less than unfiltered_caller_count ({})",
1825 output.impl_trait_caller_count,
1826 output.unfiltered_caller_count
1827 );
1828
1829 let filter_line = output
1831 .formatted
1832 .lines()
1833 .find(|line| line.contains("FILTER: impl_only=true"))
1834 .expect("should find FILTER line");
1835 assert!(
1836 filter_line.contains(&format!(
1837 "({} of {} callers shown)",
1838 output.impl_trait_caller_count, output.unfiltered_caller_count
1839 )),
1840 "FILTER line should show correct N of M counts, got: {}",
1841 filter_line
1842 );
1843 }
1844
1845 #[test]
1846 fn test_callers_count_matches_formatted_output() {
1847 let temp_dir = TempDir::new().unwrap();
1848
1849 let code = r#"
1851fn target() {}
1852fn caller_a() { target(); }
1853fn caller_b() { target(); }
1854fn caller_c() { target(); }
1855"#;
1856 fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
1857
1858 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1860
1861 let formatted = &output.formatted;
1863 let callers_count_from_output = formatted
1864 .lines()
1865 .find(|line| line.contains("FOCUS:"))
1866 .and_then(|line| {
1867 line.split(',')
1868 .find(|part| part.contains("callers"))
1869 .and_then(|part| {
1870 part.trim()
1871 .split_whitespace()
1872 .next()
1873 .and_then(|s| s.parse::<usize>().ok())
1874 })
1875 })
1876 .expect("should find CALLERS count in formatted output");
1877
1878 let expected_callers_count = output
1880 .prod_chains
1881 .iter()
1882 .filter_map(|chain| chain.chain.first().map(|(name, _, _)| name))
1883 .collect::<std::collections::HashSet<_>>()
1884 .len();
1885
1886 assert_eq!(
1887 callers_count_from_output, expected_callers_count,
1888 "CALLERS count in formatted output should match unique-first-caller count in prod_chains"
1889 );
1890 }
1891
1892 #[cfg(feature = "lang-rust")]
1893 #[test]
1894 fn test_def_use_focused_analysis() {
1895 let temp_dir = TempDir::new().unwrap();
1896 fs::write(
1897 temp_dir.path().join("lib.rs"),
1898 "fn example() {\n let x = 10;\n x += 1;\n println!(\"{}\", x);\n let y = x + 1;\n}\n",
1899 )
1900 .unwrap();
1901
1902 let entries = walk_directory(temp_dir.path(), None).unwrap();
1903 let counter = Arc::new(AtomicUsize::new(0));
1904 let ct = CancellationToken::new();
1905 let params = FocusedAnalysisConfig {
1906 focus: "x".to_string(),
1907 match_mode: SymbolMatchMode::Exact,
1908 follow_depth: 1,
1909 max_depth: None,
1910 ast_recursion_limit: None,
1911 use_summary: false,
1912 impl_only: None,
1913 def_use: true,
1914 parse_timeout_micros: None,
1915 };
1916
1917 let output = analyze_focused_with_progress_with_entries(
1918 temp_dir.path(),
1919 ¶ms,
1920 &counter,
1921 &ct,
1922 &entries,
1923 )
1924 .expect("def_use analysis should succeed");
1925
1926 assert!(
1927 !output.def_use_sites.is_empty(),
1928 "should find def-use sites for x"
1929 );
1930 assert!(
1931 output
1932 .def_use_sites
1933 .iter()
1934 .any(|s| s.kind == crate::types::DefUseKind::Write),
1935 "should have at least one Write site",
1936 );
1937 let write_locs: std::collections::HashSet<_> = output
1939 .def_use_sites
1940 .iter()
1941 .filter(|s| {
1942 matches!(
1943 s.kind,
1944 crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
1945 )
1946 })
1947 .map(|s| (&s.file, s.line, s.column))
1948 .collect();
1949 assert!(
1950 output
1951 .def_use_sites
1952 .iter()
1953 .filter(|s| s.kind == crate::types::DefUseKind::Read)
1954 .all(|s| !write_locs.contains(&(&s.file, s.line, s.column))),
1955 "no location should appear as both write and read",
1956 );
1957 assert!(
1958 output.formatted.contains("DEF-USE SITES"),
1959 "formatted output should contain DEF-USE SITES"
1960 );
1961 }
1962
1963 fn make_temp_file(content: &str) -> tempfile::NamedTempFile {
1964 let mut f = tempfile::NamedTempFile::new().unwrap();
1965 use std::io::Write;
1966 f.write_all(content.as_bytes()).unwrap();
1967 f.flush().unwrap();
1968 f
1969 }
1970}