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