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