1use crate::formatter::{
7 format_file_details, format_focused, format_focused_summary, format_structure,
8};
9use crate::graph::{CallChain, CallGraph, resolve_symbol};
10use crate::lang::language_from_extension;
11use crate::parser::{ElementExtractor, SemanticExtractor};
12use crate::test_detection::is_test_file;
13use crate::traversal::{WalkEntry, walk_directory};
14use crate::types::{AnalysisMode, FileInfo, ImportInfo, SemanticAnalysis, SymbolMatchMode};
15use rayon::prelude::*;
16use schemars::JsonSchema;
17use serde::Serialize;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicUsize, Ordering};
21use std::time::Instant;
22use thiserror::Error;
23use tokio_util::sync::CancellationToken;
24use tracing::instrument;
25
26#[derive(Debug, Error)]
27pub enum AnalyzeError {
28 #[error("Traversal error: {0}")]
29 Traversal(#[from] crate::traversal::TraversalError),
30 #[error("Parser error: {0}")]
31 Parser(#[from] crate::parser::ParserError),
32 #[error("Graph error: {0}")]
33 Graph(#[from] crate::graph::GraphError),
34 #[error("Formatter error: {0}")]
35 Formatter(#[from] crate::formatter::FormatterError),
36 #[error("Analysis cancelled")]
37 Cancelled,
38}
39
40#[derive(Debug, Serialize, JsonSchema)]
42pub struct AnalysisOutput {
43 #[schemars(description = "Formatted text representation of the analysis")]
44 pub formatted: String,
45 #[schemars(description = "List of files analyzed in the directory")]
46 pub files: Vec<FileInfo>,
47 #[serde(skip)]
49 #[schemars(skip)]
50 pub entries: Vec<WalkEntry>,
51 #[serde(skip)]
53 #[schemars(skip)]
54 pub subtree_counts: Option<Vec<(std::path::PathBuf, usize)>>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 #[schemars(
57 description = "Opaque cursor token for the next page of results (absent when no more results)"
58 )]
59 pub next_cursor: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, JsonSchema)]
64pub struct FileAnalysisOutput {
65 #[schemars(description = "Formatted text representation of the analysis")]
66 pub formatted: String,
67 #[schemars(description = "Semantic analysis data including functions, classes, and imports")]
68 pub semantic: SemanticAnalysis,
69 #[schemars(description = "Total line count of the analyzed file")]
70 #[schemars(schema_with = "crate::schema_helpers::integer_schema")]
71 pub line_count: usize,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 #[schemars(
74 description = "Opaque cursor token for the next page of results (absent when no more results)"
75 )]
76 pub next_cursor: Option<String>,
77}
78
79#[instrument(skip_all, fields(path = %root.display()))]
81pub fn analyze_directory_with_progress(
82 root: &Path,
83 entries: Vec<WalkEntry>,
84 progress: Arc<AtomicUsize>,
85 ct: CancellationToken,
86) -> Result<AnalysisOutput, AnalyzeError> {
87 if ct.is_cancelled() {
89 return Err(AnalyzeError::Cancelled);
90 }
91
92 let file_entries: Vec<&WalkEntry> = entries.iter().filter(|e| !e.is_dir).collect();
94
95 let start = Instant::now();
96 tracing::debug!(file_count = file_entries.len(), root = %root.display(), "analysis start");
97
98 let analysis_results: Vec<FileInfo> = file_entries
100 .par_iter()
101 .filter_map(|entry| {
102 if ct.is_cancelled() {
104 return None;
105 }
106
107 let path_str = entry.path.display().to_string();
108
109 let ext = entry.path.extension().and_then(|e| e.to_str());
111
112 let source = match std::fs::read_to_string(&entry.path) {
114 Ok(content) => content,
115 Err(_) => {
116 progress.fetch_add(1, Ordering::Relaxed);
118 return None;
119 }
120 };
121
122 let line_count = source.lines().count();
124
125 let (language, function_count, class_count) = if let Some(ext_str) = ext {
127 if let Some(lang) = language_from_extension(ext_str) {
128 let lang_str = lang.to_string();
129 match ElementExtractor::extract_with_depth(&source, &lang_str) {
130 Ok((func_count, class_count)) => (lang_str, func_count, class_count),
131 Err(_) => (lang_str, 0, 0),
132 }
133 } else {
134 ("unknown".to_string(), 0, 0)
135 }
136 } else {
137 ("unknown".to_string(), 0, 0)
138 };
139
140 progress.fetch_add(1, Ordering::Relaxed);
141
142 let is_test = is_test_file(&entry.path);
143
144 Some(FileInfo {
145 path: path_str,
146 line_count,
147 function_count,
148 class_count,
149 language,
150 is_test,
151 })
152 })
153 .collect();
154
155 if ct.is_cancelled() {
157 return Err(AnalyzeError::Cancelled);
158 }
159
160 tracing::debug!(
161 file_count = file_entries.len(),
162 duration_ms = start.elapsed().as_millis() as u64,
163 "analysis complete"
164 );
165
166 let formatted = format_structure(&entries, &analysis_results, None, Some(root));
168
169 Ok(AnalysisOutput {
170 formatted,
171 files: analysis_results,
172 entries,
173 next_cursor: None,
174 subtree_counts: None,
175 })
176}
177
178#[instrument(skip_all, fields(path = %root.display()))]
180pub fn analyze_directory(
181 root: &Path,
182 max_depth: Option<u32>,
183) -> Result<AnalysisOutput, AnalyzeError> {
184 let entries = walk_directory(root, max_depth)?;
185 let counter = Arc::new(AtomicUsize::new(0));
186 let ct = CancellationToken::new();
187 analyze_directory_with_progress(root, entries, counter, ct)
188}
189
190pub fn determine_mode(path: &str, focus: Option<&str>) -> AnalysisMode {
192 if focus.is_some() {
193 return AnalysisMode::SymbolFocus;
194 }
195
196 let path_obj = Path::new(path);
197 if path_obj.is_dir() {
198 AnalysisMode::Overview
199 } else {
200 AnalysisMode::FileDetails
201 }
202}
203
204#[instrument(skip_all, fields(path))]
206pub fn analyze_file(
207 path: &str,
208 ast_recursion_limit: Option<usize>,
209) -> Result<FileAnalysisOutput, AnalyzeError> {
210 let start = Instant::now();
211 let source = std::fs::read_to_string(path)
212 .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
213
214 let line_count = source.lines().count();
215
216 let ext = Path::new(path)
218 .extension()
219 .and_then(|e| e.to_str())
220 .and_then(language_from_extension)
221 .map(|l| l.to_string())
222 .unwrap_or_else(|| "unknown".to_string());
223
224 let mut semantic = SemanticExtractor::extract(&source, &ext, ast_recursion_limit)?;
226
227 for r in &mut semantic.references {
229 r.location = path.to_string();
230 }
231
232 if ext == "python" {
234 resolve_wildcard_imports(Path::new(path), &mut semantic.imports);
235 }
236
237 let is_test = is_test_file(Path::new(path));
239
240 let parent_dir = Path::new(path).parent();
242
243 let formatted = format_file_details(path, &semantic, line_count, is_test, parent_dir);
245
246 tracing::debug!(path = %path, language = %ext, functions = semantic.functions.len(), classes = semantic.classes.len(), imports = semantic.imports.len(), duration_ms = start.elapsed().as_millis() as u64, "file analysis complete");
247
248 Ok(FileAnalysisOutput {
249 formatted,
250 semantic,
251 line_count,
252 next_cursor: None,
253 })
254}
255
256#[derive(Debug, Serialize, JsonSchema)]
258pub struct FocusedAnalysisOutput {
259 #[schemars(description = "Formatted text representation of the call graph analysis")]
260 pub formatted: String,
261 #[serde(skip_serializing_if = "Option::is_none")]
262 #[schemars(
263 description = "Opaque cursor token for the next page of results (absent when no more results)"
264 )]
265 pub next_cursor: Option<String>,
266 #[serde(skip)]
269 #[schemars(skip)]
270 pub prod_chains: Vec<CallChain>,
271 #[serde(skip)]
273 #[schemars(skip)]
274 pub test_chains: Vec<CallChain>,
275 #[serde(skip)]
277 #[schemars(skip)]
278 pub outgoing_chains: Vec<CallChain>,
279 #[serde(skip)]
281 #[schemars(skip)]
282 pub def_count: usize,
283}
284
285#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
287#[allow(clippy::too_many_arguments)]
288pub fn analyze_focused_with_progress(
289 root: &Path,
290 focus: &str,
291 match_mode: SymbolMatchMode,
292 follow_depth: u32,
293 max_depth: Option<u32>,
294 ast_recursion_limit: Option<usize>,
295 progress: Arc<AtomicUsize>,
296 ct: CancellationToken,
297 use_summary: bool,
298) -> Result<FocusedAnalysisOutput, AnalyzeError> {
299 #[allow(clippy::too_many_arguments)]
300 if ct.is_cancelled() {
302 return Err(AnalyzeError::Cancelled);
303 }
304
305 if root.is_file() {
307 let formatted =
308 "Single-file focus not supported. Please provide a directory path for cross-file call graph analysis.\n"
309 .to_string();
310 return Ok(FocusedAnalysisOutput {
311 formatted,
312 next_cursor: None,
313 prod_chains: vec![],
314 test_chains: vec![],
315 outgoing_chains: vec![],
316 def_count: 0,
317 });
318 }
319
320 let entries = walk_directory(root, max_depth)?;
322
323 let file_entries: Vec<&WalkEntry> = entries.iter().filter(|e| !e.is_dir).collect();
325
326 let analysis_results: Vec<(PathBuf, SemanticAnalysis)> = file_entries
327 .par_iter()
328 .filter_map(|entry| {
329 if ct.is_cancelled() {
331 return None;
332 }
333
334 let ext = entry.path.extension().and_then(|e| e.to_str());
335
336 let source = match std::fs::read_to_string(&entry.path) {
338 Ok(content) => content,
339 Err(_) => {
340 progress.fetch_add(1, Ordering::Relaxed);
341 return None;
342 }
343 };
344
345 let language = if let Some(ext_str) = ext {
347 language_from_extension(ext_str)
348 .map(|l| l.to_string())
349 .unwrap_or_else(|| "unknown".to_string())
350 } else {
351 "unknown".to_string()
352 };
353
354 match SemanticExtractor::extract(&source, &language, ast_recursion_limit) {
355 Ok(mut semantic) => {
356 for r in &mut semantic.references {
358 r.location = entry.path.display().to_string();
359 }
360 progress.fetch_add(1, Ordering::Relaxed);
361 Some((entry.path.clone(), semantic))
362 }
363 Err(_) => {
364 progress.fetch_add(1, Ordering::Relaxed);
365 None
366 }
367 }
368 })
369 .collect();
370
371 if ct.is_cancelled() {
373 return Err(AnalyzeError::Cancelled);
374 }
375
376 let graph = CallGraph::build_from_results(analysis_results)?;
378
379 let resolved_focus = if match_mode == SymbolMatchMode::Exact {
383 let exists = graph.definitions.contains_key(focus)
384 || graph.callers.contains_key(focus)
385 || graph.callees.contains_key(focus);
386 if exists {
387 focus.to_string()
388 } else {
389 return Err(crate::graph::GraphError::SymbolNotFound {
390 symbol: focus.to_string(),
391 hint: "Try match_mode=insensitive for a case-insensitive search.".to_string(),
392 }
393 .into());
394 }
395 } else {
396 let all_known: Vec<String> = graph
397 .definitions
398 .keys()
399 .chain(graph.callers.keys())
400 .chain(graph.callees.keys())
401 .cloned()
402 .collect::<std::collections::BTreeSet<_>>()
403 .into_iter()
404 .collect();
405 resolve_symbol(all_known.iter(), focus, &match_mode)?
406 };
407
408 let def_count = graph
410 .definitions
411 .get(&resolved_focus)
412 .map_or(0, |d| d.len());
413 let incoming_chains = graph.find_incoming_chains(&resolved_focus, follow_depth)?;
414 let outgoing_chains = graph.find_outgoing_chains(&resolved_focus, follow_depth)?;
415
416 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
417 incoming_chains.into_iter().partition(|chain| {
418 chain
419 .chain
420 .first()
421 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
422 });
423
424 let formatted = if use_summary {
426 format_focused_summary(&graph, &resolved_focus, follow_depth, Some(root))?
427 } else {
428 format_focused(&graph, &resolved_focus, follow_depth, Some(root))?
429 };
430
431 Ok(FocusedAnalysisOutput {
432 formatted,
433 next_cursor: None,
434 prod_chains,
435 test_chains,
436 outgoing_chains,
437 def_count,
438 })
439}
440
441#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
443#[allow(clippy::too_many_arguments)]
444#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
446pub fn analyze_focused(
447 root: &Path,
448 focus: &str,
449 follow_depth: u32,
450 max_depth: Option<u32>,
451 ast_recursion_limit: Option<usize>,
452) -> Result<FocusedAnalysisOutput, AnalyzeError> {
453 let counter = Arc::new(AtomicUsize::new(0));
454 let ct = CancellationToken::new();
455 analyze_focused_with_progress(
456 root,
457 focus,
458 SymbolMatchMode::Exact,
459 follow_depth,
460 max_depth,
461 ast_recursion_limit,
462 counter,
463 ct,
464 false,
465 )
466}
467
468#[instrument(skip_all, fields(path))]
471pub fn analyze_module_file(path: &str) -> Result<crate::types::ModuleInfo, AnalyzeError> {
472 let source = std::fs::read_to_string(path)
473 .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
474
475 let file_path = Path::new(path);
476 let name = file_path
477 .file_name()
478 .and_then(|s| s.to_str())
479 .unwrap_or("unknown")
480 .to_string();
481
482 let line_count = source.lines().count();
483
484 let language = file_path
485 .extension()
486 .and_then(|e| e.to_str())
487 .and_then(language_from_extension)
488 .ok_or_else(|| {
489 AnalyzeError::Parser(crate::parser::ParserError::ParseError(
490 "unsupported or missing file extension".to_string(),
491 ))
492 })?;
493
494 let semantic = SemanticExtractor::extract(&source, language, None)?;
495
496 let functions = semantic
497 .functions
498 .into_iter()
499 .map(|f| crate::types::ModuleFunctionInfo {
500 name: f.name,
501 line: f.line,
502 })
503 .collect();
504
505 let imports = semantic
506 .imports
507 .into_iter()
508 .map(|i| crate::types::ModuleImportInfo {
509 module: i.module,
510 items: i.items,
511 })
512 .collect();
513
514 Ok(crate::types::ModuleInfo {
515 name,
516 line_count,
517 language: language.to_string(),
518 functions,
519 imports,
520 })
521}
522
523fn resolve_wildcard_imports(file_path: &Path, imports: &mut [ImportInfo]) {
533 use std::collections::HashMap;
534
535 let mut resolved_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
536 let file_path_canonical = match file_path.canonicalize() {
537 Ok(p) => p,
538 Err(_) => {
539 tracing::debug!(file = ?file_path, "unable to canonicalize current file path");
540 return;
541 }
542 };
543
544 for import in imports.iter_mut() {
545 if import.items != ["*"] {
546 continue;
547 }
548 resolve_single_wildcard(import, file_path, &file_path_canonical, &mut resolved_cache);
549 }
550}
551
552fn resolve_single_wildcard(
554 import: &mut ImportInfo,
555 file_path: &Path,
556 file_path_canonical: &Path,
557 resolved_cache: &mut std::collections::HashMap<PathBuf, Vec<String>>,
558) {
559 let module = import.module.clone();
560 let dot_count = module.chars().take_while(|c| *c == '.').count();
561 if dot_count == 0 {
562 return;
563 }
564 let module_path = module.trim_start_matches('.');
565
566 let target_to_read = match locate_target_file(file_path, dot_count, module_path, &module) {
567 Some(p) => p,
568 None => return,
569 };
570
571 let canonical = match target_to_read.canonicalize() {
572 Ok(p) => p,
573 Err(_) => {
574 tracing::debug!(target = ?target_to_read, import = %module, "unable to canonicalize path");
575 return;
576 }
577 };
578
579 if canonical == file_path_canonical {
580 tracing::debug!(target = ?canonical, import = %module, "cannot import from self");
581 return;
582 }
583
584 if let Some(cached) = resolved_cache.get(&canonical) {
585 tracing::debug!(import = %module, symbols_count = cached.len(), "using cached symbols");
586 import.items = cached.clone();
587 return;
588 }
589
590 if let Some(symbols) = parse_target_symbols(&target_to_read, &module) {
591 tracing::debug!(import = %module, resolved_count = symbols.len(), "wildcard import resolved");
592 import.items = symbols.clone();
593 resolved_cache.insert(canonical, symbols);
594 }
595}
596
597fn locate_target_file(
599 file_path: &Path,
600 dot_count: usize,
601 module_path: &str,
602 module: &str,
603) -> Option<PathBuf> {
604 let mut target_dir = file_path.parent()?.to_path_buf();
605
606 for _ in 1..dot_count {
607 if !target_dir.pop() {
608 tracing::debug!(import = %module, "unable to climb {} levels", dot_count.saturating_sub(1));
609 return None;
610 }
611 }
612
613 let target_file = if module_path.is_empty() {
614 target_dir.join("__init__.py")
615 } else {
616 let rel_path = module_path.replace('.', "/");
617 target_dir.join(format!("{rel_path}.py"))
618 };
619
620 if target_file.exists() {
621 Some(target_file)
622 } else if target_file.with_extension("").is_dir() {
623 let init = target_file.with_extension("").join("__init__.py");
624 if init.exists() { Some(init) } else { None }
625 } else {
626 tracing::debug!(target = ?target_file, import = %module, "target file not found");
627 None
628 }
629}
630
631fn parse_target_symbols(target_path: &Path, module: &str) -> Option<Vec<String>> {
633 let source = match std::fs::read_to_string(target_path) {
634 Ok(s) => s,
635 Err(e) => {
636 tracing::debug!(target = ?target_path, import = %module, error = %e, "unable to read target file");
637 return None;
638 }
639 };
640
641 use tree_sitter::Parser;
643 let lang_info = crate::languages::get_language_info("python")?;
644 let mut parser = Parser::new();
645 if parser.set_language(&lang_info.language).is_err() {
646 return None;
647 }
648 let tree = parser.parse(&source, None)?;
649
650 let mut symbols = Vec::new();
652 extract_all_from_tree(&tree, &source, &mut symbols);
653 if !symbols.is_empty() {
654 tracing::debug!(import = %module, symbols = ?symbols, "using __all__ symbols");
655 return Some(symbols);
656 }
657
658 let root = tree.root_node();
660 let mut cursor = root.walk();
661 for child in root.children(&mut cursor) {
662 match child.kind() {
663 "function_definition" => {
664 if let Some(name_node) = child.child_by_field_name("name") {
665 let name = source[name_node.start_byte()..name_node.end_byte()].to_string();
666 if !name.starts_with('_') {
667 symbols.push(name);
668 }
669 }
670 }
671 "class_definition" => {
672 if let Some(name_node) = child.child_by_field_name("name") {
673 let name = source[name_node.start_byte()..name_node.end_byte()].to_string();
674 if !name.starts_with('_') {
675 symbols.push(name);
676 }
677 }
678 }
679 _ => {}
680 }
681 }
682 tracing::debug!(import = %module, fallback_symbols = ?symbols, "using fallback function/class names");
683 Some(symbols)
684}
685
686fn extract_all_from_tree(tree: &tree_sitter::Tree, source: &str, result: &mut Vec<String>) {
688 let root = tree.root_node();
689 let mut cursor = root.walk();
690 for child in root.children(&mut cursor) {
691 if child.kind() == "simple_statement" {
692 let mut simple_cursor = child.walk();
694 for simple_child in child.children(&mut simple_cursor) {
695 if simple_child.kind() == "assignment"
696 && let Some(left) = simple_child.child_by_field_name("left")
697 {
698 let target_text = source[left.start_byte()..left.end_byte()].trim();
699 if target_text == "__all__"
700 && let Some(right) = simple_child.child_by_field_name("right")
701 {
702 extract_string_list_from_list_node(&right, source, result);
703 }
704 }
705 }
706 } else if child.kind() == "expression_statement" {
707 let mut stmt_cursor = child.walk();
709 for stmt_child in child.children(&mut stmt_cursor) {
710 if stmt_child.kind() == "assignment"
711 && let Some(left) = stmt_child.child_by_field_name("left")
712 {
713 let target_text = source[left.start_byte()..left.end_byte()].trim();
714 if target_text == "__all__"
715 && let Some(right) = stmt_child.child_by_field_name("right")
716 {
717 extract_string_list_from_list_node(&right, source, result);
718 }
719 }
720 }
721 }
722 }
723}
724
725fn extract_string_list_from_list_node(
727 list_node: &tree_sitter::Node,
728 source: &str,
729 result: &mut Vec<String>,
730) {
731 let mut cursor = list_node.walk();
732 for child in list_node.named_children(&mut cursor) {
733 if child.kind() == "string" {
734 let raw = source[child.start_byte()..child.end_byte()].trim();
735 let unquoted = raw.trim_matches('"').trim_matches('\'').to_string();
737 if !unquoted.is_empty() {
738 result.push(unquoted);
739 }
740 }
741 }
742}