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