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}
59
60#[derive(Debug, Clone, Serialize)]
62#[cfg_attr(feature = "schemars", derive(JsonSchema))]
63#[non_exhaustive]
64pub struct AnalysisOutput {
65 #[cfg_attr(
66 feature = "schemars",
67 schemars(description = "Formatted text representation of the analysis")
68 )]
69 pub formatted: String,
70 #[cfg_attr(
71 feature = "schemars",
72 schemars(description = "List of files analyzed in the directory")
73 )]
74 pub files: Vec<FileInfo>,
75 #[serde(skip)]
77 #[cfg_attr(feature = "schemars", schemars(skip))]
78 pub entries: Vec<WalkEntry>,
79 #[serde(skip)]
81 #[cfg_attr(feature = "schemars", schemars(skip))]
82 pub subtree_counts: Option<Vec<(std::path::PathBuf, usize)>>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 #[cfg_attr(
85 feature = "schemars",
86 schemars(
87 description = "Opaque cursor token for the next page of results (absent when no more results)"
88 )
89 )]
90 pub next_cursor: Option<String>,
91}
92
93#[derive(Debug, Clone, Serialize)]
95#[cfg_attr(feature = "schemars", derive(JsonSchema))]
96#[non_exhaustive]
97pub struct FileAnalysisOutput {
98 #[cfg_attr(
99 feature = "schemars",
100 schemars(description = "Formatted text representation of the analysis")
101 )]
102 pub formatted: String,
103 #[cfg_attr(
104 feature = "schemars",
105 schemars(description = "Semantic analysis data including functions, classes, and imports")
106 )]
107 pub semantic: SemanticAnalysis,
108 #[cfg_attr(
109 feature = "schemars",
110 schemars(description = "Total line count of the analyzed file")
111 )]
112 #[cfg_attr(
113 feature = "schemars",
114 schemars(schema_with = "crate::schema_helpers::integer_schema")
115 )]
116 pub line_count: usize,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 #[cfg_attr(
119 feature = "schemars",
120 schemars(
121 description = "Opaque cursor token for the next page of results (absent when no more results)"
122 )
123 )]
124 pub next_cursor: Option<String>,
125}
126
127impl FileAnalysisOutput {
128 #[must_use]
130 pub fn new(
131 formatted: String,
132 semantic: SemanticAnalysis,
133 line_count: usize,
134 next_cursor: Option<String>,
135 ) -> Self {
136 Self {
137 formatted,
138 semantic,
139 line_count,
140 next_cursor,
141 }
142 }
143}
144#[instrument(skip_all, fields(path = %root.display()))]
145#[allow(clippy::needless_pass_by_value)]
147pub fn analyze_directory_with_progress(
148 root: &Path,
149 entries: Vec<WalkEntry>,
150 progress: Arc<AtomicUsize>,
151 ct: CancellationToken,
152) -> Result<AnalysisOutput, AnalyzeError> {
153 if ct.is_cancelled() {
155 return Err(AnalyzeError::Cancelled);
156 }
157
158 let file_entries: Vec<&WalkEntry> = entries
160 .iter()
161 .filter(|e| !e.is_dir && !e.is_symlink)
162 .collect();
163
164 let start = Instant::now();
165 tracing::debug!(file_count = file_entries.len(), root = %root.display(), "analysis start");
166
167 let analysis_results: Vec<FileInfo> = file_entries
169 .par_iter()
170 .filter_map(|entry| {
171 if ct.is_cancelled() {
173 return None;
174 }
175
176 let path_str = entry.path.display().to_string();
177
178 let ext = entry.path.extension().and_then(|e| e.to_str());
180
181 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
183 tracing::debug!("skipping large file: {}", entry.path.display());
184 progress.fetch_add(1, Ordering::Relaxed);
185 return None;
186 }
187
188 let Ok(source) = std::fs::read_to_string(&entry.path) else {
190 progress.fetch_add(1, Ordering::Relaxed);
191 return None;
192 };
193
194 let line_count = source.lines().count();
196
197 let (language, function_count, class_count) = if let Some(ext_str) = ext {
199 if let Some(lang) = language_for_extension(ext_str) {
200 let lang_str = lang.to_string();
201 match ElementExtractor::extract_with_depth(&source, &lang_str) {
202 Ok((func_count, class_count)) => (lang_str, func_count, class_count),
203 Err(_) => (lang_str, 0, 0),
204 }
205 } else {
206 ("unknown".to_string(), 0, 0)
207 }
208 } else {
209 ("unknown".to_string(), 0, 0)
210 };
211
212 progress.fetch_add(1, Ordering::Relaxed);
213
214 let is_test = is_test_file(&entry.path);
215
216 Some(FileInfo {
217 path: path_str,
218 line_count,
219 function_count,
220 class_count,
221 language,
222 is_test,
223 })
224 })
225 .collect();
226
227 if ct.is_cancelled() {
229 return Err(AnalyzeError::Cancelled);
230 }
231
232 tracing::debug!(
233 file_count = file_entries.len(),
234 duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
235 "analysis complete"
236 );
237
238 let formatted = format_structure(&entries, &analysis_results, None);
240
241 Ok(AnalysisOutput {
242 formatted,
243 files: analysis_results,
244 entries,
245 next_cursor: None,
246 subtree_counts: None,
247 })
248}
249
250#[instrument(skip_all, fields(path = %root.display()))]
252pub fn analyze_directory(
253 root: &Path,
254 max_depth: Option<u32>,
255) -> Result<AnalysisOutput, AnalyzeError> {
256 let entries = walk_directory(root, max_depth)?;
257 let counter = Arc::new(AtomicUsize::new(0));
258 let ct = CancellationToken::new();
259 analyze_directory_with_progress(root, entries, counter, ct)
260}
261
262#[must_use]
264pub fn determine_mode(path: &str, focus: Option<&str>) -> AnalysisMode {
265 if focus.is_some() {
266 return AnalysisMode::SymbolFocus;
267 }
268
269 let path_obj = Path::new(path);
270 if path_obj.is_dir() {
271 AnalysisMode::Overview
272 } else {
273 AnalysisMode::FileDetails
274 }
275}
276
277#[instrument(skip_all, fields(path))]
279pub fn analyze_file(
280 path: &str,
281 ast_recursion_limit: Option<usize>,
282) -> Result<FileAnalysisOutput, AnalyzeError> {
283 let start = Instant::now();
284
285 if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
287 tracing::debug!("skipping large file: {}", path);
288 return Err(AnalyzeError::Parser(
289 crate::parser::ParserError::ParseError("file too large".to_string()),
290 ));
291 }
292
293 let source = std::fs::read_to_string(path)
294 .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
295
296 let line_count = source.lines().count();
297
298 let ext = Path::new(path)
300 .extension()
301 .and_then(|e| e.to_str())
302 .and_then(language_for_extension)
303 .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string);
304
305 let mut semantic = SemanticExtractor::extract(&source, &ext, ast_recursion_limit)?;
307
308 for r in &mut semantic.references {
310 r.location = path.to_string();
311 }
312
313 if ext == "python" {
315 resolve_wildcard_imports(Path::new(path), &mut semantic.imports);
316 }
317
318 let is_test = is_test_file(Path::new(path));
320
321 let parent_dir = Path::new(path).parent();
323
324 let formatted = format_file_details(path, &semantic, line_count, is_test, parent_dir);
326
327 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");
328
329 Ok(FileAnalysisOutput::new(
330 formatted, semantic, line_count, None,
331 ))
332}
333
334#[inline]
361pub fn analyze_str(
362 source: &str,
363 language: &str,
364 ast_recursion_limit: Option<usize>,
365) -> Result<FileAnalysisOutput, AnalyzeError> {
366 let lang = language_for_extension(language).or_else(|| {
369 let lower = language.to_ascii_lowercase();
370 supported_languages()
371 .iter()
372 .find(|&&name| name == lower)
373 .copied()
374 });
375 let lang = lang.ok_or_else(|| AnalyzeError::UnsupportedLanguage(language.to_string()))?;
376
377 let mut semantic = SemanticExtractor::extract(source, lang, ast_recursion_limit)?;
379
380 for r in &mut semantic.references {
382 r.location = "<memory>".to_string();
383 }
384
385 let line_count = source.lines().count();
387
388 let formatted = format_file_details("", &semantic, line_count, false, None);
390
391 Ok(FileAnalysisOutput::new(
392 formatted, semantic, line_count, None,
393 ))
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
398#[cfg_attr(feature = "schemars", derive(JsonSchema))]
399pub struct CallChainEntry {
400 #[cfg_attr(
401 feature = "schemars",
402 schemars(description = "Symbol name of the caller or callee")
403 )]
404 pub symbol: String,
405 #[cfg_attr(
406 feature = "schemars",
407 schemars(description = "File path relative to the repository root")
408 )]
409 pub file: String,
410 #[cfg_attr(
411 feature = "schemars",
412 schemars(
413 description = "Line number of the definition or call site (1-indexed)",
414 schema_with = "crate::schema_helpers::integer_schema"
415 )
416 )]
417 pub line: usize,
418}
419
420#[derive(Debug, Serialize)]
422#[cfg_attr(feature = "schemars", derive(JsonSchema))]
423#[non_exhaustive]
424pub struct FocusedAnalysisOutput {
425 #[cfg_attr(
426 feature = "schemars",
427 schemars(description = "Formatted text representation of the call graph analysis")
428 )]
429 pub formatted: String,
430 #[serde(skip_serializing_if = "Option::is_none")]
431 #[cfg_attr(
432 feature = "schemars",
433 schemars(
434 description = "Opaque cursor token for the next page of results (absent when no more results)"
435 )
436 )]
437 pub next_cursor: Option<String>,
438 #[serde(skip)]
441 #[cfg_attr(feature = "schemars", schemars(skip))]
442 pub prod_chains: Vec<InternalCallChain>,
443 #[serde(skip)]
445 #[cfg_attr(feature = "schemars", schemars(skip))]
446 pub test_chains: Vec<InternalCallChain>,
447 #[serde(skip)]
449 #[cfg_attr(feature = "schemars", schemars(skip))]
450 pub outgoing_chains: Vec<InternalCallChain>,
451 #[serde(skip)]
453 #[cfg_attr(feature = "schemars", schemars(skip))]
454 pub def_count: usize,
455 #[serde(skip)]
457 #[cfg_attr(feature = "schemars", schemars(skip))]
458 pub unfiltered_caller_count: usize,
459 #[serde(skip)]
461 #[cfg_attr(feature = "schemars", schemars(skip))]
462 pub impl_trait_caller_count: usize,
463 #[serde(skip_serializing_if = "Option::is_none")]
465 pub callers: Option<Vec<CallChainEntry>>,
466 #[serde(skip_serializing_if = "Option::is_none")]
468 pub test_callers: Option<Vec<CallChainEntry>>,
469 #[serde(skip_serializing_if = "Option::is_none")]
471 pub callees: Option<Vec<CallChainEntry>>,
472 #[serde(default)]
474 pub def_use_sites: Vec<crate::types::DefUseSite>,
475}
476
477#[derive(Clone)]
480pub struct FocusedAnalysisConfig {
481 pub focus: String,
482 pub match_mode: SymbolMatchMode,
483 pub follow_depth: u32,
484 pub max_depth: Option<u32>,
485 pub ast_recursion_limit: Option<usize>,
486 pub use_summary: bool,
487 pub impl_only: Option<bool>,
488 pub def_use: bool,
489}
490
491#[derive(Clone)]
493struct InternalFocusedParams {
494 focus: String,
495 match_mode: SymbolMatchMode,
496 follow_depth: u32,
497 ast_recursion_limit: Option<usize>,
498 use_summary: bool,
499 impl_only: Option<bool>,
500 def_use: bool,
501}
502
503type FileAnalysisBatch = (Vec<(PathBuf, SemanticAnalysis)>, Vec<ImplTraitInfo>);
505
506fn collect_file_analysis(
508 entries: &[WalkEntry],
509 progress: &Arc<AtomicUsize>,
510 ct: &CancellationToken,
511 ast_recursion_limit: Option<usize>,
512) -> Result<FileAnalysisBatch, AnalyzeError> {
513 if ct.is_cancelled() {
515 return Err(AnalyzeError::Cancelled);
516 }
517
518 let file_entries: Vec<&WalkEntry> = entries
521 .iter()
522 .filter(|e| !e.is_dir && !e.is_symlink)
523 .collect();
524
525 let analysis_results: Vec<(PathBuf, SemanticAnalysis)> = file_entries
526 .par_iter()
527 .filter_map(|entry| {
528 if ct.is_cancelled() {
530 return None;
531 }
532
533 let ext = entry.path.extension().and_then(|e| e.to_str());
534
535 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
537 tracing::debug!("skipping large file: {}", entry.path.display());
538 progress.fetch_add(1, Ordering::Relaxed);
539 return None;
540 }
541
542 let Ok(source) = std::fs::read_to_string(&entry.path) else {
544 progress.fetch_add(1, Ordering::Relaxed);
545 return None;
546 };
547
548 let language = if let Some(ext_str) = ext {
550 language_for_extension(ext_str)
551 .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string)
552 } else {
553 "unknown".to_string()
554 };
555
556 if let Ok(mut semantic) =
557 SemanticExtractor::extract(&source, &language, ast_recursion_limit)
558 {
559 for r in &mut semantic.references {
561 r.location = entry.path.display().to_string();
562 }
563 for trait_info in &mut semantic.impl_traits {
565 trait_info.path.clone_from(&entry.path);
566 }
567 progress.fetch_add(1, Ordering::Relaxed);
568 Some((entry.path.clone(), semantic))
569 } else {
570 progress.fetch_add(1, Ordering::Relaxed);
571 None
572 }
573 })
574 .collect();
575
576 if ct.is_cancelled() {
578 return Err(AnalyzeError::Cancelled);
579 }
580
581 let all_impl_traits: Vec<ImplTraitInfo> = analysis_results
583 .iter()
584 .flat_map(|(_, sem)| sem.impl_traits.iter().cloned())
585 .collect();
586
587 Ok((analysis_results, all_impl_traits))
588}
589
590fn build_call_graph(
592 analysis_results: Vec<(PathBuf, SemanticAnalysis)>,
593 all_impl_traits: &[ImplTraitInfo],
594) -> Result<CallGraph, AnalyzeError> {
595 CallGraph::build_from_results(
598 analysis_results,
599 all_impl_traits,
600 false, )
602 .map_err(std::convert::Into::into)
603}
604
605fn resolve_symbol(
610 graph: &mut CallGraph,
611 params: &InternalFocusedParams,
612) -> Result<(String, usize, usize), AnalyzeError> {
613 let resolved_focus = if params.match_mode == SymbolMatchMode::Exact {
615 let exists = graph.definitions.contains_key(¶ms.focus)
616 || graph.callers.contains_key(¶ms.focus)
617 || graph.callees.contains_key(¶ms.focus);
618 if exists {
619 params.focus.clone()
620 } else {
621 return Err(crate::graph::GraphError::SymbolNotFound {
622 symbol: params.focus.clone(),
623 hint: "Try match_mode=insensitive for a case-insensitive search, or match_mode=prefix to list symbols starting with this name.".to_string(),
624 }
625 .into());
626 }
627 } else {
628 graph.resolve_symbol_indexed(¶ms.focus, ¶ms.match_mode)?
629 };
630
631 let unfiltered_caller_count = graph.callers.get(&resolved_focus).map_or(0, |edges| {
633 edges
634 .iter()
635 .map(|e| &e.neighbor_name)
636 .collect::<std::collections::HashSet<_>>()
637 .len()
638 });
639
640 let impl_trait_caller_count = if params.impl_only.unwrap_or(false) {
644 for edges in graph.callers.values_mut() {
645 edges.retain(|e| e.is_impl_trait);
646 }
647 graph.callers.get(&resolved_focus).map_or(0, |edges| {
648 edges
649 .iter()
650 .map(|e| &e.neighbor_name)
651 .collect::<std::collections::HashSet<_>>()
652 .len()
653 })
654 } else {
655 unfiltered_caller_count
656 };
657
658 Ok((
659 resolved_focus,
660 unfiltered_caller_count,
661 impl_trait_caller_count,
662 ))
663}
664
665type ChainComputeResult = (
667 String,
668 Vec<InternalCallChain>,
669 Vec<InternalCallChain>,
670 Vec<InternalCallChain>,
671 usize,
672);
673
674fn chains_to_entries(
678 chains: &[InternalCallChain],
679 root: Option<&std::path::Path>,
680) -> Option<Vec<CallChainEntry>> {
681 if chains.is_empty() {
682 return None;
683 }
684 let entries: Vec<CallChainEntry> = chains
685 .iter()
686 .take(10)
687 .filter_map(|chain| {
688 let (symbol, path, line) = chain.chain.first()?;
689 let file = match root {
690 Some(root) => path
691 .strip_prefix(root)
692 .unwrap_or(path.as_path())
693 .to_string_lossy()
694 .into_owned(),
695 None => path.to_string_lossy().into_owned(),
696 };
697 Some(CallChainEntry {
698 symbol: symbol.clone(),
699 file,
700 line: *line,
701 })
702 })
703 .collect();
704 if entries.is_empty() {
705 None
706 } else {
707 Some(entries)
708 }
709}
710
711fn compute_chains(
713 graph: &CallGraph,
714 resolved_focus: &str,
715 root: &Path,
716 params: &InternalFocusedParams,
717 unfiltered_caller_count: usize,
718 impl_trait_caller_count: usize,
719 def_use_sites: &[crate::types::DefUseSite],
720) -> Result<ChainComputeResult, AnalyzeError> {
721 let def_count = graph.definitions.get(resolved_focus).map_or(0, Vec::len);
723 let incoming_chains = graph.find_incoming_chains(resolved_focus, params.follow_depth)?;
724 let outgoing_chains = graph.find_outgoing_chains(resolved_focus, params.follow_depth)?;
725
726 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
727 incoming_chains.iter().cloned().partition(|chain| {
728 chain
729 .chain
730 .first()
731 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
732 });
733
734 let mut formatted = if params.use_summary {
736 format_focused_summary_internal(
737 graph,
738 resolved_focus,
739 params.follow_depth,
740 Some(root),
741 Some(&incoming_chains),
742 Some(&outgoing_chains),
743 def_use_sites,
744 )?
745 } else {
746 format_focused_internal(
747 graph,
748 resolved_focus,
749 params.follow_depth,
750 Some(root),
751 Some(&incoming_chains),
752 Some(&outgoing_chains),
753 def_use_sites,
754 )?
755 };
756
757 if params.impl_only.unwrap_or(false) {
759 let filter_header = format!(
760 "FILTER: impl_only=true ({impl_trait_caller_count} of {unfiltered_caller_count} callers shown)\n",
761 );
762 formatted = format!("{filter_header}{formatted}");
763 }
764
765 Ok((
766 formatted,
767 prod_chains,
768 test_chains,
769 outgoing_chains,
770 def_count,
771 ))
772}
773
774#[allow(clippy::needless_pass_by_value)]
777pub fn analyze_focused_with_progress(
778 root: &Path,
779 params: &FocusedAnalysisConfig,
780 progress: Arc<AtomicUsize>,
781 ct: CancellationToken,
782) -> Result<FocusedAnalysisOutput, AnalyzeError> {
783 let entries = walk_directory(root, params.max_depth)?;
784 let internal_params = InternalFocusedParams {
785 focus: params.focus.clone(),
786 match_mode: params.match_mode.clone(),
787 follow_depth: params.follow_depth,
788 ast_recursion_limit: params.ast_recursion_limit,
789 use_summary: params.use_summary,
790 impl_only: params.impl_only,
791 def_use: params.def_use,
792 };
793 analyze_focused_with_progress_with_entries_internal(
794 root,
795 params.max_depth,
796 &progress,
797 &ct,
798 &internal_params,
799 &entries,
800 )
801}
802
803#[instrument(skip_all, fields(path = %root.display(), symbol = %params.focus))]
805fn analyze_focused_with_progress_with_entries_internal(
806 root: &Path,
807 _max_depth: Option<u32>,
808 progress: &Arc<AtomicUsize>,
809 ct: &CancellationToken,
810 params: &InternalFocusedParams,
811 entries: &[WalkEntry],
812) -> Result<FocusedAnalysisOutput, AnalyzeError> {
813 if ct.is_cancelled() {
815 return Err(AnalyzeError::Cancelled);
816 }
817
818 if root.is_file() {
820 let formatted =
821 "Single-file focus not supported. Please provide a directory path for cross-file call graph analysis.\n"
822 .to_string();
823 return Ok(FocusedAnalysisOutput {
824 formatted,
825 next_cursor: None,
826 prod_chains: vec![],
827 test_chains: vec![],
828 outgoing_chains: vec![],
829 def_count: 0,
830 unfiltered_caller_count: 0,
831 impl_trait_caller_count: 0,
832 callers: None,
833 test_callers: None,
834 callees: None,
835 def_use_sites: vec![],
836 });
837 }
838
839 let (analysis_results, all_impl_traits) =
841 collect_file_analysis(entries, progress, ct, params.ast_recursion_limit)?;
842
843 if ct.is_cancelled() {
845 return Err(AnalyzeError::Cancelled);
846 }
847
848 let mut graph = build_call_graph(analysis_results, &all_impl_traits)?;
850
851 if ct.is_cancelled() {
853 return Err(AnalyzeError::Cancelled);
854 }
855
856 let resolve_result = resolve_symbol(&mut graph, params);
860 if let Err(AnalyzeError::Graph(crate::graph::GraphError::SymbolNotFound { .. })) =
861 &resolve_result
862 {
863 if params.def_use {
866 let def_use_sites =
867 collect_def_use_sites(entries, ¶ms.focus, params.ast_recursion_limit, root, ct);
868 if def_use_sites.is_empty() {
869 return Err(resolve_result.unwrap_err());
873 }
874 use std::fmt::Write as _;
875 let mut formatted = String::new();
876 let _ = writeln!(
877 formatted,
878 "FOCUS: {} (0 defs, 0 callers, 0 callees)",
879 params.focus
880 );
881 {
882 let writes = def_use_sites
883 .iter()
884 .filter(|s| {
885 matches!(
886 s.kind,
887 crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
888 )
889 })
890 .count();
891 let reads = def_use_sites
892 .iter()
893 .filter(|s| s.kind == crate::types::DefUseKind::Read)
894 .count();
895 let _ = writeln!(
896 formatted,
897 "DEF-USE SITES {} ({} total: {} writes, {} reads)",
898 params.focus,
899 def_use_sites.len(),
900 writes,
901 reads
902 );
903 }
904 return Ok(FocusedAnalysisOutput {
905 formatted,
906 next_cursor: None,
907 callers: None,
908 test_callers: None,
909 callees: None,
910 prod_chains: vec![],
911 test_chains: vec![],
912 outgoing_chains: vec![],
913 def_count: 0,
914 unfiltered_caller_count: 0,
915 impl_trait_caller_count: 0,
916 def_use_sites,
917 });
918 }
919 }
920 let (resolved_focus, unfiltered_caller_count, impl_trait_caller_count) = resolve_result?;
921
922 if ct.is_cancelled() {
924 return Err(AnalyzeError::Cancelled);
925 }
926
927 let def_use_sites = if params.def_use {
931 collect_def_use_sites(entries, ¶ms.focus, params.ast_recursion_limit, root, ct)
932 } else {
933 Vec::new()
934 };
935
936 let (formatted, prod_chains, test_chains, outgoing_chains, def_count) = compute_chains(
938 &graph,
939 &resolved_focus,
940 root,
941 params,
942 unfiltered_caller_count,
943 impl_trait_caller_count,
944 &def_use_sites,
945 )?;
946
947 let (depth1_callers, depth1_test_callers, depth1_callees) = if params.follow_depth <= 1 {
950 let callers = chains_to_entries(&prod_chains, Some(root));
952 let test_callers = chains_to_entries(&test_chains, Some(root));
953 let callees = chains_to_entries(&outgoing_chains, Some(root));
954 (callers, test_callers, callees)
955 } else {
956 let incoming1 = graph
958 .find_incoming_chains(&resolved_focus, 1)
959 .unwrap_or_default();
960 let outgoing1 = graph
961 .find_outgoing_chains(&resolved_focus, 1)
962 .unwrap_or_default();
963 let (prod1, test1): (Vec<_>, Vec<_>) = incoming1.into_iter().partition(|chain| {
964 chain
965 .chain
966 .first()
967 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
968 });
969 let callers = chains_to_entries(&prod1, Some(root));
970 let test_callers = chains_to_entries(&test1, Some(root));
971 let callees = chains_to_entries(&outgoing1, Some(root));
972 (callers, test_callers, callees)
973 };
974
975 Ok(FocusedAnalysisOutput {
976 formatted,
977 next_cursor: None,
978 callers: depth1_callers,
979 test_callers: depth1_test_callers,
980 callees: depth1_callees,
981 prod_chains,
982 test_chains,
983 outgoing_chains,
984 def_count,
985 unfiltered_caller_count,
986 impl_trait_caller_count,
987 def_use_sites,
988 })
989}
990
991fn collect_def_use_sites(
994 entries: &[WalkEntry],
995 symbol: &str,
996 ast_recursion_limit: Option<usize>,
997 root: &std::path::Path,
998 ct: &CancellationToken,
999) -> Vec<crate::types::DefUseSite> {
1000 use crate::parser::SemanticExtractor;
1001
1002 let file_entries: Vec<&WalkEntry> = entries
1003 .iter()
1004 .filter(|e| !e.is_dir && !e.is_symlink)
1005 .collect();
1006
1007 let mut sites: Vec<crate::types::DefUseSite> = file_entries
1008 .par_iter()
1009 .filter_map(|entry| {
1010 if ct.is_cancelled() {
1011 return None;
1012 }
1013
1014 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1016 tracing::debug!("skipping large file: {}", entry.path.display());
1017 return None;
1018 }
1019
1020 let Ok(source) = std::fs::read_to_string(&entry.path) else {
1021 return None;
1022 };
1023 let ext = entry
1024 .path
1025 .extension()
1026 .and_then(|e| e.to_str())
1027 .unwrap_or("");
1028 let lang = crate::lang::language_for_extension(ext)?;
1029 let file_path = entry
1030 .path
1031 .strip_prefix(root)
1032 .unwrap_or(&entry.path)
1033 .display()
1034 .to_string();
1035 let sites = SemanticExtractor::extract_def_use_for_file(
1036 &source,
1037 lang,
1038 symbol,
1039 &file_path,
1040 ast_recursion_limit,
1041 );
1042 if sites.is_empty() { None } else { Some(sites) }
1043 })
1044 .flatten()
1045 .collect();
1046
1047 sites.sort_by(|a, b| {
1049 use crate::types::DefUseKind;
1050 let kind_ord = |k: &DefUseKind| match k {
1051 DefUseKind::Write | DefUseKind::WriteRead => 0,
1052 DefUseKind::Read => 1,
1053 };
1054 kind_ord(&a.kind)
1055 .cmp(&kind_ord(&b.kind))
1056 .then_with(|| a.file.cmp(&b.file))
1057 .then_with(|| a.line.cmp(&b.line))
1058 .then_with(|| a.column.cmp(&b.column))
1059 });
1060
1061 sites
1062}
1063
1064pub fn analyze_focused_with_progress_with_entries(
1066 root: &Path,
1067 params: &FocusedAnalysisConfig,
1068 progress: &Arc<AtomicUsize>,
1069 ct: &CancellationToken,
1070 entries: &[WalkEntry],
1071) -> Result<FocusedAnalysisOutput, AnalyzeError> {
1072 let internal_params = InternalFocusedParams {
1073 focus: params.focus.clone(),
1074 match_mode: params.match_mode.clone(),
1075 follow_depth: params.follow_depth,
1076 ast_recursion_limit: params.ast_recursion_limit,
1077 use_summary: params.use_summary,
1078 impl_only: params.impl_only,
1079 def_use: params.def_use,
1080 };
1081 analyze_focused_with_progress_with_entries_internal(
1082 root,
1083 params.max_depth,
1084 progress,
1085 ct,
1086 &internal_params,
1087 entries,
1088 )
1089}
1090
1091#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
1092pub fn analyze_focused(
1093 root: &Path,
1094 focus: &str,
1095 follow_depth: u32,
1096 max_depth: Option<u32>,
1097 ast_recursion_limit: Option<usize>,
1098) -> Result<FocusedAnalysisOutput, AnalyzeError> {
1099 let entries = walk_directory(root, max_depth)?;
1100 let counter = Arc::new(AtomicUsize::new(0));
1101 let ct = CancellationToken::new();
1102 let params = FocusedAnalysisConfig {
1103 focus: focus.to_string(),
1104 match_mode: SymbolMatchMode::Exact,
1105 follow_depth,
1106 max_depth,
1107 ast_recursion_limit,
1108 use_summary: false,
1109 impl_only: None,
1110 def_use: false,
1111 };
1112 analyze_focused_with_progress_with_entries(root, ¶ms, &counter, &ct, &entries)
1113}
1114
1115#[instrument(skip_all, fields(path))]
1118pub fn analyze_module_file(path: &str) -> Result<crate::types::ModuleInfo, AnalyzeError> {
1119 if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1121 tracing::debug!("skipping large file: {}", path);
1122 return Err(AnalyzeError::Parser(
1123 crate::parser::ParserError::ParseError("file too large".to_string()),
1124 ));
1125 }
1126
1127 let source = std::fs::read_to_string(path)
1128 .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
1129
1130 let file_path = Path::new(path);
1131 let name = file_path
1132 .file_name()
1133 .and_then(|s| s.to_str())
1134 .unwrap_or("unknown")
1135 .to_string();
1136
1137 let line_count = source.lines().count();
1138
1139 let language = file_path
1140 .extension()
1141 .and_then(|e| e.to_str())
1142 .and_then(language_for_extension)
1143 .ok_or_else(|| {
1144 AnalyzeError::Parser(crate::parser::ParserError::ParseError(
1145 "unsupported or missing file extension".to_string(),
1146 ))
1147 })?;
1148
1149 let semantic = SemanticExtractor::extract(&source, language, None)?;
1150
1151 let functions = semantic
1152 .functions
1153 .into_iter()
1154 .map(|f| crate::types::ModuleFunctionInfo {
1155 name: f.name,
1156 line: f.line,
1157 })
1158 .collect();
1159
1160 let imports = semantic
1161 .imports
1162 .into_iter()
1163 .map(|i| crate::types::ModuleImportInfo {
1164 module: i.module,
1165 items: i.items,
1166 })
1167 .collect();
1168
1169 Ok(crate::types::ModuleInfo {
1170 name,
1171 line_count,
1172 language: language.to_string(),
1173 functions,
1174 imports,
1175 })
1176}
1177
1178pub fn analyze_import_lookup(
1184 root: &Path,
1185 module: &str,
1186 entries: &[WalkEntry],
1187 ast_recursion_limit: Option<usize>,
1188) -> Result<FocusedAnalysisOutput, AnalyzeError> {
1189 let matches: Vec<(PathBuf, usize)> = entries
1190 .par_iter()
1191 .filter_map(|entry| {
1192 if entry.is_dir || entry.is_symlink {
1193 tracing::debug!("skipping symlink: {}", entry.path.display());
1194 return None;
1195 }
1196 let ext = entry
1197 .path
1198 .extension()
1199 .and_then(|e| e.to_str())
1200 .and_then(crate::lang::language_for_extension)?;
1201 let source = std::fs::read_to_string(&entry.path).ok()?;
1202 let semantic = SemanticExtractor::extract(&source, ext, ast_recursion_limit).ok()?;
1203 for import in &semantic.imports {
1204 if import.module == module || import.items.iter().any(|item| item == module) {
1205 return Some((entry.path.clone(), import.line));
1206 }
1207 }
1208 None
1209 })
1210 .collect();
1211
1212 let mut text = format!("IMPORT_LOOKUP: {module}\n");
1213 text.push_str(&format!("ROOT: {}\n", root.display()));
1214 text.push_str(&format!("MATCHES: {}\n", matches.len()));
1215 for (path, line) in &matches {
1216 let rel = path.strip_prefix(root).unwrap_or(path);
1217 text.push_str(&format!(" {}:{line}\n", rel.display()));
1218 }
1219
1220 Ok(FocusedAnalysisOutput {
1221 formatted: text,
1222 next_cursor: None,
1223 prod_chains: vec![],
1224 test_chains: vec![],
1225 outgoing_chains: vec![],
1226 def_count: 0,
1227 unfiltered_caller_count: 0,
1228 impl_trait_caller_count: 0,
1229 callers: None,
1230 test_callers: None,
1231 callees: None,
1232 def_use_sites: vec![],
1233 })
1234}
1235
1236fn resolve_wildcard_imports(file_path: &Path, imports: &mut [ImportInfo]) {
1246 use std::collections::HashMap;
1247
1248 let mut resolved_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
1249 let Ok(file_path_canonical) = file_path.canonicalize() else {
1250 tracing::debug!(file = ?file_path, "unable to canonicalize current file path");
1251 return;
1252 };
1253
1254 for import in imports.iter_mut() {
1255 if import.items != ["*"] {
1256 continue;
1257 }
1258 resolve_single_wildcard(import, file_path, &file_path_canonical, &mut resolved_cache);
1259 }
1260}
1261
1262fn resolve_single_wildcard(
1264 import: &mut ImportInfo,
1265 file_path: &Path,
1266 file_path_canonical: &Path,
1267 resolved_cache: &mut std::collections::HashMap<PathBuf, Vec<String>>,
1268) {
1269 let module = import.module.clone();
1270 let dot_count = module.chars().take_while(|c| *c == '.').count();
1271 if dot_count == 0 {
1272 return;
1273 }
1274 let module_path = module.trim_start_matches('.');
1275
1276 let Some(target_to_read) = locate_target_file(file_path, dot_count, module_path, &module)
1277 else {
1278 return;
1279 };
1280
1281 let Ok(canonical) = target_to_read.canonicalize() else {
1282 tracing::debug!(target = ?target_to_read, import = %module, "unable to canonicalize path");
1283 return;
1284 };
1285
1286 if canonical == file_path_canonical {
1287 tracing::debug!(target = ?canonical, import = %module, "cannot import from self");
1288 return;
1289 }
1290
1291 if let Some(cached) = resolved_cache.get(&canonical) {
1292 tracing::debug!(import = %module, symbols_count = cached.len(), "using cached symbols");
1293 import.items.clone_from(cached);
1294 return;
1295 }
1296
1297 if let Some(symbols) = parse_target_symbols(&target_to_read, &module) {
1298 tracing::debug!(import = %module, resolved_count = symbols.len(), "wildcard import resolved");
1299 import.items.clone_from(&symbols);
1300 resolved_cache.insert(canonical, symbols);
1301 }
1302}
1303
1304fn locate_target_file(
1306 file_path: &Path,
1307 dot_count: usize,
1308 module_path: &str,
1309 module: &str,
1310) -> Option<PathBuf> {
1311 let mut target_dir = file_path.parent()?.to_path_buf();
1312
1313 for _ in 1..dot_count {
1314 if !target_dir.pop() {
1315 tracing::debug!(import = %module, "unable to climb {} levels", dot_count.saturating_sub(1));
1316 return None;
1317 }
1318 }
1319
1320 let target_file = if module_path.is_empty() {
1321 target_dir.join("__init__.py")
1322 } else {
1323 let rel_path = module_path.replace('.', "/");
1324 target_dir.join(format!("{rel_path}.py"))
1325 };
1326
1327 if target_file.exists() {
1328 Some(target_file)
1329 } else if target_file.with_extension("").is_dir() {
1330 let init = target_file.with_extension("").join("__init__.py");
1331 if init.exists() { Some(init) } else { None }
1332 } else {
1333 tracing::debug!(target = ?target_file, import = %module, "target file not found");
1334 None
1335 }
1336}
1337
1338fn parse_target_symbols(target_path: &Path, module: &str) -> Option<Vec<String>> {
1340 use tree_sitter::Parser;
1341
1342 if target_path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1344 tracing::debug!("skipping large file: {}", target_path.display());
1345 return None;
1346 }
1347
1348 let source = match std::fs::read_to_string(target_path) {
1349 Ok(s) => s,
1350 Err(e) => {
1351 tracing::debug!(target = ?target_path, import = %module, error = %e, "unable to read target file");
1352 return None;
1353 }
1354 };
1355
1356 let lang_info = crate::languages::get_language_info("python")?;
1358 let mut parser = Parser::new();
1359 if parser.set_language(&lang_info.language).is_err() {
1360 return None;
1361 }
1362 let tree = parser.parse(&source, None)?;
1363
1364 let mut symbols = Vec::new();
1366 extract_all_from_tree(&tree, &source, &mut symbols);
1367 if !symbols.is_empty() {
1368 tracing::debug!(import = %module, symbols = ?symbols, "using __all__ symbols");
1369 return Some(symbols);
1370 }
1371
1372 let root = tree.root_node();
1374 let mut cursor = root.walk();
1375 for child in root.children(&mut cursor) {
1376 if matches!(child.kind(), "function_definition" | "class_definition")
1377 && let Some(name_node) = child.child_by_field_name("name")
1378 {
1379 let name = source[name_node.start_byte()..name_node.end_byte()].to_string();
1380 if !name.starts_with('_') {
1381 symbols.push(name);
1382 }
1383 }
1384 }
1385 tracing::debug!(import = %module, fallback_symbols = ?symbols, "using fallback function/class names");
1386 Some(symbols)
1387}
1388
1389fn extract_all_from_tree(tree: &tree_sitter::Tree, source: &str, result: &mut Vec<String>) {
1391 let root = tree.root_node();
1392 let mut cursor = root.walk();
1393 for child in root.children(&mut cursor) {
1394 if child.kind() == "simple_statement" {
1395 let mut simple_cursor = child.walk();
1397 for simple_child in child.children(&mut simple_cursor) {
1398 if simple_child.kind() == "assignment"
1399 && let Some(left) = simple_child.child_by_field_name("left")
1400 {
1401 let target_text = source[left.start_byte()..left.end_byte()].trim();
1402 if target_text == "__all__"
1403 && let Some(right) = simple_child.child_by_field_name("right")
1404 {
1405 extract_string_list_from_list_node(&right, source, result);
1406 }
1407 }
1408 }
1409 } else if child.kind() == "expression_statement" {
1410 let mut stmt_cursor = child.walk();
1412 for stmt_child in child.children(&mut stmt_cursor) {
1413 if stmt_child.kind() == "assignment"
1414 && let Some(left) = stmt_child.child_by_field_name("left")
1415 {
1416 let target_text = source[left.start_byte()..left.end_byte()].trim();
1417 if target_text == "__all__"
1418 && let Some(right) = stmt_child.child_by_field_name("right")
1419 {
1420 extract_string_list_from_list_node(&right, source, result);
1421 }
1422 }
1423 }
1424 }
1425 }
1426}
1427
1428fn extract_string_list_from_list_node(
1430 list_node: &tree_sitter::Node,
1431 source: &str,
1432 result: &mut Vec<String>,
1433) {
1434 let mut cursor = list_node.walk();
1435 for child in list_node.named_children(&mut cursor) {
1436 if child.kind() == "string" {
1437 let raw = source[child.start_byte()..child.end_byte()].trim();
1438 let unquoted = raw.trim_matches('"').trim_matches('\'').to_string();
1440 if !unquoted.is_empty() {
1441 result.push(unquoted);
1442 }
1443 }
1444 }
1445}
1446
1447pub fn analyze_raw_range(
1460 path: &Path,
1461 start_line: Option<usize>,
1462 end_line: Option<usize>,
1463) -> Result<crate::types::AnalyzeRawOutput, AnalyzeError> {
1464 if path.is_dir() {
1465 return Err(AnalyzeError::NotAFile(path.to_path_buf()));
1466 }
1467
1468 if path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1470 tracing::debug!("skipping large file: {}", path.display());
1471 return Err(AnalyzeError::Parser(
1472 crate::parser::ParserError::ParseError("file too large".to_string()),
1473 ));
1474 }
1475
1476 let raw = std::fs::read_to_string(path)?;
1477 let lines: Vec<&str> = raw.lines().collect();
1478 let total = lines.len();
1479 if total == 0 {
1480 return Ok(crate::types::AnalyzeRawOutput {
1481 path: path.display().to_string(),
1482 total_lines: 0,
1483 start_line: 0,
1484 end_line: 0,
1485 content: String::new(),
1486 next_start_line: None,
1487 });
1488 }
1489 let start = start_line.unwrap_or(1).max(1).min(total.max(1));
1490 let end = end_line.unwrap_or(total).min(total).max(1);
1491 if start > end {
1492 return Err(AnalyzeError::InvalidRange { start, end, total });
1493 }
1494 let width = end.to_string().len();
1495 let content = lines[start - 1..end]
1496 .iter()
1497 .enumerate()
1498 .map(|(i, line)| format!("{:>width$}: {}", start + i, line, width = width))
1499 .collect::<Vec<_>>()
1500 .join("\n");
1501 Ok(crate::types::AnalyzeRawOutput {
1502 path: path.display().to_string(),
1503 total_lines: total,
1504 start_line: start,
1505 end_line: end,
1506 content,
1507 next_start_line: if end == total { None } else { Some(end + 1) },
1508 })
1509}
1510
1511#[cfg(test)]
1512mod tests {
1513 use super::*;
1514 use crate::formatter::format_focused_paginated;
1515 use crate::graph::InternalCallChain;
1516 use crate::pagination::{PaginationMode, decode_cursor, paginate_slice};
1517 use std::fs;
1518 use std::path::PathBuf;
1519 use tempfile::TempDir;
1520
1521 #[cfg(feature = "lang-rust")]
1522 #[test]
1523 fn analyze_str_rust_happy_path() {
1524 let source = "fn hello() -> i32 { 42 }";
1525 let result = analyze_str(source, "rs", None);
1526 assert!(result.is_ok());
1527 }
1528
1529 #[cfg(feature = "lang-python")]
1530 #[test]
1531 fn analyze_str_python_happy_path() {
1532 let source = "def greet(name):\n return f'Hello {name}'";
1533 let result = analyze_str(source, "py", None);
1534 assert!(result.is_ok());
1535 }
1536
1537 #[cfg(feature = "lang-rust")]
1538 #[test]
1539 fn analyze_str_rust_by_language_name() {
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_by_language_name() {
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 #[cfg(feature = "lang-rust")]
1554 #[test]
1555 fn analyze_str_rust_mixed_case() {
1556 let source = "fn hello() -> i32 { 42 }";
1557 let result = analyze_str(source, "RuSt", None);
1558 assert!(result.is_ok());
1559 }
1560
1561 #[cfg(feature = "lang-python")]
1562 #[test]
1563 fn analyze_str_python_mixed_case() {
1564 let source = "def greet(name):\n return f'Hello {name}'";
1565 let result = analyze_str(source, "PyThOn", None);
1566 assert!(result.is_ok());
1567 }
1568
1569 #[test]
1570 fn analyze_str_unsupported_language() {
1571 let result = analyze_str("code", "brainfuck", None);
1572 assert!(
1573 matches!(result, Err(AnalyzeError::UnsupportedLanguage(lang)) if lang == "brainfuck")
1574 );
1575 }
1576
1577 #[cfg(feature = "lang-rust")]
1578 #[test]
1579 fn test_symbol_focus_callers_pagination_first_page() {
1580 let temp_dir = TempDir::new().unwrap();
1581
1582 let mut code = String::from("fn target() {}\n");
1584 for i in 0..15 {
1585 code.push_str(&format!("fn caller_{:02}() {{ target(); }}\n", i));
1586 }
1587 fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
1588
1589 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1591
1592 let paginated = paginate_slice(&output.prod_chains, 0, 5, PaginationMode::Callers)
1594 .expect("paginate failed");
1595 assert!(
1596 paginated.total >= 5,
1597 "should have enough callers to paginate"
1598 );
1599 assert!(
1600 paginated.next_cursor.is_some(),
1601 "should have next_cursor for page 1"
1602 );
1603
1604 assert_eq!(paginated.items.len(), 5);
1606 }
1607
1608 #[test]
1609 fn test_symbol_focus_callers_pagination_second_page() {
1610 let temp_dir = TempDir::new().unwrap();
1611
1612 let mut code = String::from("fn target() {}\n");
1613 for i in 0..12 {
1614 code.push_str(&format!("fn caller_{:02}() {{ target(); }}\n", i));
1615 }
1616 fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
1617
1618 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1619 let total_prod = output.prod_chains.len();
1620
1621 if total_prod > 5 {
1622 let p1 = paginate_slice(&output.prod_chains, 0, 5, PaginationMode::Callers)
1624 .expect("paginate failed");
1625 assert!(p1.next_cursor.is_some());
1626
1627 let cursor_str = p1.next_cursor.unwrap();
1628 let cursor_data = decode_cursor(&cursor_str).expect("decode failed");
1629
1630 let p2 = paginate_slice(
1632 &output.prod_chains,
1633 cursor_data.offset,
1634 5,
1635 PaginationMode::Callers,
1636 )
1637 .expect("paginate failed");
1638
1639 let formatted = format_focused_paginated(
1641 &p2.items,
1642 total_prod,
1643 PaginationMode::Callers,
1644 "target",
1645 &output.prod_chains,
1646 &output.test_chains,
1647 &output.outgoing_chains,
1648 output.def_count,
1649 cursor_data.offset,
1650 Some(temp_dir.path()),
1651 true,
1652 );
1653
1654 let expected_start = cursor_data.offset + 1;
1656 assert!(
1657 formatted.contains(&format!("CALLERS ({}", expected_start)),
1658 "header should show page 2 range, got: {}",
1659 formatted
1660 );
1661 }
1662 }
1663
1664 #[test]
1665 fn test_chains_to_entries_empty_returns_none() {
1666 let chains: Vec<InternalCallChain> = vec![];
1668
1669 let result = chains_to_entries(&chains, None);
1671
1672 assert!(result.is_none());
1674 }
1675
1676 #[test]
1677 fn test_chains_to_entries_with_data_returns_entries() {
1678 let chains = vec![
1680 InternalCallChain {
1681 chain: vec![("caller1".to_string(), PathBuf::from("/root/lib.rs"), 10)],
1682 },
1683 InternalCallChain {
1684 chain: vec![("caller2".to_string(), PathBuf::from("/root/other.rs"), 20)],
1685 },
1686 ];
1687 let root = PathBuf::from("/root");
1688
1689 let result = chains_to_entries(&chains, Some(root.as_path()));
1691
1692 assert!(result.is_some());
1694 let entries = result.unwrap();
1695 assert_eq!(entries.len(), 2);
1696 assert_eq!(entries[0].symbol, "caller1");
1697 assert_eq!(entries[0].file, "lib.rs");
1698 assert_eq!(entries[0].line, 10);
1699 assert_eq!(entries[1].symbol, "caller2");
1700 assert_eq!(entries[1].file, "other.rs");
1701 assert_eq!(entries[1].line, 20);
1702 }
1703
1704 #[test]
1705 fn test_symbol_focus_callees_pagination() {
1706 let temp_dir = TempDir::new().unwrap();
1707
1708 let mut code = String::from("fn target() {\n");
1710 for i in 0..10 {
1711 code.push_str(&format!(" callee_{:02}();\n", i));
1712 }
1713 code.push_str("}\n");
1714 for i in 0..10 {
1715 code.push_str(&format!("fn callee_{:02}() {{}}\n", i));
1716 }
1717 fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
1718
1719 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1720 let total_callees = output.outgoing_chains.len();
1721
1722 if total_callees > 3 {
1723 let paginated = paginate_slice(&output.outgoing_chains, 0, 3, PaginationMode::Callees)
1724 .expect("paginate failed");
1725
1726 let formatted = format_focused_paginated(
1727 &paginated.items,
1728 total_callees,
1729 PaginationMode::Callees,
1730 "target",
1731 &output.prod_chains,
1732 &output.test_chains,
1733 &output.outgoing_chains,
1734 output.def_count,
1735 0,
1736 Some(temp_dir.path()),
1737 true,
1738 );
1739
1740 assert!(
1741 formatted.contains(&format!(
1742 "CALLEES (1-{} of {})",
1743 paginated.items.len(),
1744 total_callees
1745 )),
1746 "header should show callees range, got: {}",
1747 formatted
1748 );
1749 }
1750 }
1751
1752 #[test]
1753 fn test_symbol_focus_empty_prod_callers() {
1754 let temp_dir = TempDir::new().unwrap();
1755
1756 let code = r#"
1758fn target() {}
1759
1760#[cfg(test)]
1761mod tests {
1762 use super::*;
1763 #[test]
1764 fn test_something() { target(); }
1765}
1766"#;
1767 fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
1768
1769 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1770
1771 let paginated = paginate_slice(&output.prod_chains, 0, 100, PaginationMode::Callers)
1773 .expect("paginate failed");
1774 assert_eq!(paginated.items.len(), output.prod_chains.len());
1775 assert!(
1776 paginated.next_cursor.is_none(),
1777 "no next_cursor for empty or single-page prod_chains"
1778 );
1779 }
1780
1781 #[test]
1782 fn test_impl_only_filter_header_correct_counts() {
1783 let temp_dir = TempDir::new().unwrap();
1784
1785 let code = r#"
1790trait MyTrait {
1791 fn focus_symbol();
1792}
1793
1794struct SomeType;
1795
1796impl MyTrait for SomeType {
1797 fn focus_symbol() {}
1798}
1799
1800fn impl_caller() {
1801 SomeType::focus_symbol();
1802}
1803
1804fn regular_caller() {
1805 SomeType::focus_symbol();
1806}
1807"#;
1808 fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
1809
1810 let params = FocusedAnalysisConfig {
1812 focus: "focus_symbol".to_string(),
1813 match_mode: SymbolMatchMode::Insensitive,
1814 follow_depth: 1,
1815 max_depth: None,
1816 ast_recursion_limit: None,
1817 use_summary: false,
1818 impl_only: Some(true),
1819 def_use: false,
1820 };
1821 let output = analyze_focused_with_progress(
1822 temp_dir.path(),
1823 ¶ms,
1824 Arc::new(AtomicUsize::new(0)),
1825 CancellationToken::new(),
1826 )
1827 .unwrap();
1828
1829 assert!(
1831 output.formatted.contains("FILTER: impl_only=true"),
1832 "formatted output should contain FILTER header for impl_only=true, got: {}",
1833 output.formatted
1834 );
1835
1836 assert!(
1838 output.impl_trait_caller_count < output.unfiltered_caller_count,
1839 "impl_trait_caller_count ({}) should be less than unfiltered_caller_count ({})",
1840 output.impl_trait_caller_count,
1841 output.unfiltered_caller_count
1842 );
1843
1844 let filter_line = output
1846 .formatted
1847 .lines()
1848 .find(|line| line.contains("FILTER: impl_only=true"))
1849 .expect("should find FILTER line");
1850 assert!(
1851 filter_line.contains(&format!(
1852 "({} of {} callers shown)",
1853 output.impl_trait_caller_count, output.unfiltered_caller_count
1854 )),
1855 "FILTER line should show correct N of M counts, got: {}",
1856 filter_line
1857 );
1858 }
1859
1860 #[test]
1861 fn test_callers_count_matches_formatted_output() {
1862 let temp_dir = TempDir::new().unwrap();
1863
1864 let code = r#"
1866fn target() {}
1867fn caller_a() { target(); }
1868fn caller_b() { target(); }
1869fn caller_c() { target(); }
1870"#;
1871 fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
1872
1873 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1875
1876 let formatted = &output.formatted;
1878 let callers_count_from_output = formatted
1879 .lines()
1880 .find(|line| line.contains("FOCUS:"))
1881 .and_then(|line| {
1882 line.split(',')
1883 .find(|part| part.contains("callers"))
1884 .and_then(|part| {
1885 part.trim()
1886 .split_whitespace()
1887 .next()
1888 .and_then(|s| s.parse::<usize>().ok())
1889 })
1890 })
1891 .expect("should find CALLERS count in formatted output");
1892
1893 let expected_callers_count = output
1895 .prod_chains
1896 .iter()
1897 .filter_map(|chain| chain.chain.first().map(|(name, _, _)| name))
1898 .collect::<std::collections::HashSet<_>>()
1899 .len();
1900
1901 assert_eq!(
1902 callers_count_from_output, expected_callers_count,
1903 "CALLERS count in formatted output should match unique-first-caller count in prod_chains"
1904 );
1905 }
1906
1907 #[cfg(feature = "lang-rust")]
1908 #[test]
1909 fn test_def_use_focused_analysis() {
1910 let temp_dir = TempDir::new().unwrap();
1911 fs::write(
1912 temp_dir.path().join("lib.rs"),
1913 "fn example() {\n let x = 10;\n x += 1;\n println!(\"{}\", x);\n let y = x + 1;\n}\n",
1914 )
1915 .unwrap();
1916
1917 let entries = walk_directory(temp_dir.path(), None).unwrap();
1918 let counter = Arc::new(AtomicUsize::new(0));
1919 let ct = CancellationToken::new();
1920 let params = FocusedAnalysisConfig {
1921 focus: "x".to_string(),
1922 match_mode: SymbolMatchMode::Exact,
1923 follow_depth: 1,
1924 max_depth: None,
1925 ast_recursion_limit: None,
1926 use_summary: false,
1927 impl_only: None,
1928 def_use: true,
1929 };
1930
1931 let output = analyze_focused_with_progress_with_entries(
1932 temp_dir.path(),
1933 ¶ms,
1934 &counter,
1935 &ct,
1936 &entries,
1937 )
1938 .expect("def_use analysis should succeed");
1939
1940 assert!(
1941 !output.def_use_sites.is_empty(),
1942 "should find def-use sites for x"
1943 );
1944 assert!(
1945 output
1946 .def_use_sites
1947 .iter()
1948 .any(|s| s.kind == crate::types::DefUseKind::Write),
1949 "should have at least one Write site",
1950 );
1951 let write_locs: std::collections::HashSet<_> = output
1953 .def_use_sites
1954 .iter()
1955 .filter(|s| {
1956 matches!(
1957 s.kind,
1958 crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
1959 )
1960 })
1961 .map(|s| (&s.file, s.line, s.column))
1962 .collect();
1963 assert!(
1964 output
1965 .def_use_sites
1966 .iter()
1967 .filter(|s| s.kind == crate::types::DefUseKind::Read)
1968 .all(|s| !write_locs.contains(&(&s.file, s.line, s.column))),
1969 "no location should appear as both write and read",
1970 );
1971 assert!(
1972 output.formatted.contains("DEF-USE SITES"),
1973 "formatted output should contain DEF-USE SITES"
1974 );
1975 }
1976
1977 fn make_temp_file(content: &str) -> tempfile::NamedTempFile {
1978 let mut f = tempfile::NamedTempFile::new().unwrap();
1979 use std::io::Write;
1980 f.write_all(content.as_bytes()).unwrap();
1981 f.flush().unwrap();
1982 f
1983 }
1984
1985 #[test]
1986 fn test_analyze_raw_full_file() {
1987 let f = make_temp_file("line1\nline2\nline3\n");
1988 let out = analyze_raw_range(f.path(), None, None).unwrap();
1989 assert_eq!(out.total_lines, 3);
1990 assert_eq!(out.start_line, 1);
1991 assert_eq!(out.end_line, 3);
1992 assert_eq!(out.next_start_line, None);
1993 assert!(out.content.contains("line1"));
1994 assert!(out.content.contains("line3"));
1995 }
1996
1997 #[test]
1998 fn test_analyze_raw_partial_range() {
1999 let f = make_temp_file("a\nb\nc\nd\ne\n");
2000 let out = analyze_raw_range(f.path(), Some(2), Some(4)).unwrap();
2001 assert_eq!(out.start_line, 2);
2002 assert_eq!(out.end_line, 4);
2003 assert_eq!(out.next_start_line, Some(5));
2004 assert!(out.content.contains("b"));
2005 assert!(out.content.contains("d"));
2006 assert!(!out.content.contains("a"));
2007 assert!(!out.content.contains("e"));
2008 }
2009
2010 #[test]
2011 fn test_analyze_raw_invalid_range() {
2012 let f = make_temp_file("a\nb\nc\n");
2013 let err = analyze_raw_range(f.path(), Some(3), Some(1)).unwrap_err();
2014 assert!(matches!(err, AnalyzeError::InvalidRange { .. }));
2015 }
2016
2017 #[test]
2018 fn test_analyze_raw_clamped_range() {
2019 let f = make_temp_file("x\ny\nz\n");
2020 let out = analyze_raw_range(f.path(), Some(1), Some(999)).unwrap();
2022 assert_eq!(out.end_line, 3);
2023 assert_eq!(out.total_lines, 3);
2024 assert_eq!(out.next_start_line, None);
2025 }
2026
2027 #[test]
2028 fn test_analyze_raw_empty_file() {
2029 let f = make_temp_file("");
2030 let out = analyze_raw_range(f.path(), None, None).unwrap();
2031 assert_eq!(out.total_lines, 0);
2032 assert_eq!(out.content, "");
2033 assert_eq!(out.next_start_line, None);
2034 }
2035
2036 #[test]
2037 fn test_analyze_raw_pagination_loop() {
2038 let content = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n";
2040 let f = make_temp_file(content);
2041
2042 let mut all_collected = String::new();
2043 let mut start = 1;
2044 let mut iterations = 0;
2045 let max_iterations = 10; loop {
2048 iterations += 1;
2049 assert!(
2050 iterations <= max_iterations,
2051 "pagination loop exceeded max iterations"
2052 );
2053
2054 let out = analyze_raw_range(f.path(), Some(start), Some(start + 2)).unwrap();
2055 all_collected.push_str(&out.content);
2056 all_collected.push('\n');
2057
2058 match out.next_start_line {
2059 Some(next) => {
2060 start = next;
2061 }
2062 None => {
2063 break;
2064 }
2065 }
2066 }
2067
2068 assert!(all_collected.contains("line1"));
2070 assert!(all_collected.contains("line10"));
2071 assert!(
2072 iterations <= 5,
2073 "should take at most 5 iterations for 10 lines with page_size=3"
2074 );
2075 }
2076}