1use crate::analyze::{
6 AnalyzeError, CallChainEntry, FileAnalysisOutput, FocusedAnalysisConfig, FocusedAnalysisOutput,
7 MAX_FILE_SIZE_BYTES,
8};
9use crate::cache::StructuralGraphCache;
10use crate::formatter::{format_focused_internal, format_focused_summary_internal};
11use crate::graph::store::GraphDiskStore;
12use crate::graph::structural::StructuralGraph;
13use crate::graph::{CallGraph, InternalCallChain};
14use crate::lang::language_for_extension;
15use crate::parser::SemanticExtractor;
16use crate::test_detection::is_test_file;
17use crate::traversal::{WalkEntry, walk_directory};
18use crate::types::{ImplTraitInfo, ImportInfo, SemanticAnalysis, SymbolMatchMode};
19use rayon::prelude::*;
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::time::SystemTime;
24use tokio_util::sync::CancellationToken;
25use tracing::instrument;
26
27#[derive(Clone)]
29pub(crate) struct InternalFocusedParams {
30 pub(crate) focus: String,
31 pub(crate) match_mode: SymbolMatchMode,
32 pub(crate) follow_depth: u32,
33 pub(crate) ast_recursion_limit: Option<usize>,
34 pub(crate) use_summary: bool,
35 pub(crate) impl_only: Option<bool>,
36 pub(crate) def_use: bool,
37 pub(crate) parse_timeout_micros: Option<u64>,
38}
39
40type FileAnalysisBatch = (Vec<(PathBuf, SemanticAnalysis)>, Vec<ImplTraitInfo>);
42
43fn collect_file_analysis(
45 entries: &[WalkEntry],
46 progress: &Arc<AtomicUsize>,
47 ct: &CancellationToken,
48 ast_recursion_limit: Option<usize>,
49 parse_timeout_micros: Option<u64>,
50) -> Result<FileAnalysisBatch, AnalyzeError> {
51 if ct.is_cancelled() {
53 return Err(AnalyzeError::Cancelled);
54 }
55
56 let file_entries: Vec<&WalkEntry> = entries
59 .iter()
60 .filter(|e| !e.is_dir && !e.is_symlink)
61 .collect();
62
63 let timed_out: std::sync::Mutex<Vec<(PathBuf, u64)>> = std::sync::Mutex::new(Vec::new());
65
66 let analysis_results: Vec<(PathBuf, SemanticAnalysis)> = file_entries
67 .par_iter()
68 .filter_map(|entry| {
69 if ct.is_cancelled() {
71 return None;
72 }
73
74 let ext = entry.path.extension().and_then(|e| e.to_str());
75
76 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
78 tracing::debug!("skipping large file: {}", entry.path.display());
79 progress.fetch_add(1, Ordering::Relaxed);
80 return None;
81 }
82
83 let Ok(source) = std::fs::read_to_string(&entry.path) else {
85 progress.fetch_add(1, Ordering::Relaxed);
86 return None;
87 };
88
89 let language = if let Some(ext_str) = ext {
91 language_for_extension(ext_str)
92 .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string)
93 } else {
94 "unknown".to_string()
95 };
96
97 match SemanticExtractor::extract(
98 &source,
99 &language,
100 ast_recursion_limit,
101 parse_timeout_micros,
102 ) {
103 Ok(mut semantic) => {
104 for r in &mut semantic.references {
106 r.location = entry.path.display().to_string();
107 }
108 for trait_info in &mut semantic.impl_traits {
110 trait_info.path.clone_from(&entry.path);
111 }
112 progress.fetch_add(1, Ordering::Relaxed);
113 Some((entry.path.clone(), semantic))
114 }
115 Err(crate::parser::ParserError::Timeout(micros)) => {
116 tracing::warn!(
117 "parse timeout exceeded for {}: {} microseconds",
118 entry.path.display(),
119 micros
120 );
121 if let Ok(mut v) = timed_out.lock() {
122 v.push((entry.path.clone(), micros));
123 }
124 progress.fetch_add(1, Ordering::Relaxed);
125 None
126 }
127 Err(_) => {
128 progress.fetch_add(1, Ordering::Relaxed);
129 None
130 }
131 }
132 })
133 .collect();
134
135 if ct.is_cancelled() {
137 return Err(AnalyzeError::Cancelled);
138 }
139
140 if let Ok(mut v) = timed_out.lock()
142 && let Some((path, micros)) = v.drain(..).next()
143 {
144 return Err(AnalyzeError::ParseTimeout { path, micros });
145 }
146
147 let all_impl_traits: Vec<ImplTraitInfo> = analysis_results
149 .iter()
150 .flat_map(|(_, sem)| sem.impl_traits.iter().cloned())
151 .collect();
152
153 Ok((analysis_results, all_impl_traits))
154}
155
156fn build_call_graph(
158 analysis_results: Vec<(PathBuf, SemanticAnalysis)>,
159 all_impl_traits: &[ImplTraitInfo],
160) -> Result<CallGraph, AnalyzeError> {
161 CallGraph::build_from_results(
164 analysis_results,
165 all_impl_traits,
166 false, )
168 .map_err(std::convert::Into::into)
169}
170
171fn compute_cache_key(root: &Path, entries: &[WalkEntry]) -> Option<String> {
173 let mut mtimes = Vec::new();
174 for e in entries {
175 if !e.is_dir && !e.is_symlink {
176 let m = e
177 .mtime?
178 .duration_since(SystemTime::UNIX_EPOCH)
179 .ok()?
180 .as_millis() as u64;
181 mtimes.push((e.path.clone(), m));
182 }
183 }
184 Some(GraphDiskStore::cache_key(root, &mtimes))
185}
186
187fn create_graph_store() -> GraphDiskStore {
189 let data_home = std::env::var_os("XDG_DATA_HOME")
190 .map(PathBuf::from)
191 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
192 .unwrap_or_default();
193 let base = std::env::var("APTU_CODER_DISK_CACHE_DIR")
194 .map(PathBuf::from)
195 .unwrap_or_else(|_| data_home.join("aptu-coder").join("analysis-cache"));
196 GraphDiskStore::new(base)
197}
198
199fn resolve_symbol(
204 graph: &mut CallGraph,
205 params: &InternalFocusedParams,
206) -> Result<(String, usize, usize), AnalyzeError> {
207 let resolved_focus = if params.match_mode == SymbolMatchMode::Exact {
209 let exists = graph.definitions.contains_key(¶ms.focus)
210 || graph.callers.contains_key(¶ms.focus)
211 || graph.callees.contains_key(¶ms.focus);
212 if exists {
213 params.focus.clone()
214 } else {
215 return Err(crate::graph::GraphError::SymbolNotFound {
216 symbol: params.focus.clone(),
217 hint: "Try match_mode=insensitive for a case-insensitive search, or match_mode=prefix to list symbols starting with this name.".to_string(),
218 }
219 .into());
220 }
221 } else {
222 graph.resolve_symbol_indexed(¶ms.focus, ¶ms.match_mode)?
223 };
224
225 let unfiltered_caller_count = graph.callers.get(&resolved_focus).map_or(0, |edges| {
227 edges
228 .iter()
229 .map(|e| &e.neighbor_name)
230 .collect::<std::collections::HashSet<_>>()
231 .len()
232 });
233
234 let impl_trait_caller_count = if params.impl_only.unwrap_or(false) {
238 for edges in graph.callers.values_mut() {
239 edges.retain(|e| e.is_impl_trait);
240 }
241 graph.callers.get(&resolved_focus).map_or(0, |edges| {
242 edges
243 .iter()
244 .map(|e| &e.neighbor_name)
245 .collect::<std::collections::HashSet<_>>()
246 .len()
247 })
248 } else {
249 unfiltered_caller_count
250 };
251
252 Ok((
253 resolved_focus,
254 unfiltered_caller_count,
255 impl_trait_caller_count,
256 ))
257}
258
259type ChainComputeResult = (
261 String,
262 Vec<InternalCallChain>,
263 Vec<InternalCallChain>,
264 Vec<InternalCallChain>,
265 usize,
266);
267
268pub(crate) fn chains_to_entries(
272 chains: &[InternalCallChain],
273 root: Option<&std::path::Path>,
274) -> Option<Vec<CallChainEntry>> {
275 if chains.is_empty() {
276 return None;
277 }
278 let entries: Vec<CallChainEntry> = chains
279 .iter()
280 .take(10)
281 .filter_map(|chain| {
282 let (symbol, path, line) = chain.chain.first()?;
283 let file = match root {
284 Some(root) => path
285 .strip_prefix(root)
286 .unwrap_or(path.as_path())
287 .to_string_lossy()
288 .into_owned(),
289 None => path.to_string_lossy().into_owned(),
290 };
291 Some(CallChainEntry {
292 symbol: symbol.clone(),
293 file,
294 line: *line,
295 })
296 })
297 .collect();
298 if entries.is_empty() {
299 None
300 } else {
301 Some(entries)
302 }
303}
304
305fn compute_chains(
307 graph: &CallGraph,
308 resolved_focus: &str,
309 root: &Path,
310 params: &InternalFocusedParams,
311 unfiltered_caller_count: usize,
312 impl_trait_caller_count: usize,
313 def_use_sites: &[crate::types::DefUseSite],
314) -> Result<ChainComputeResult, AnalyzeError> {
315 let def_count = graph.definitions.get(resolved_focus).map_or(0, Vec::len);
317 let incoming_chains = graph.find_incoming_chains(resolved_focus, params.follow_depth)?;
318 let outgoing_chains = graph.find_outgoing_chains(resolved_focus, params.follow_depth)?;
319
320 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
321 incoming_chains.iter().cloned().partition(|chain| {
322 chain
323 .chain
324 .first()
325 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
326 });
327
328 let mut formatted = if params.use_summary {
330 format_focused_summary_internal(
331 graph,
332 resolved_focus,
333 params.follow_depth,
334 Some(root),
335 Some(&incoming_chains),
336 Some(&outgoing_chains),
337 def_use_sites,
338 )?
339 } else {
340 format_focused_internal(
341 graph,
342 resolved_focus,
343 params.follow_depth,
344 Some(root),
345 Some(&incoming_chains),
346 Some(&outgoing_chains),
347 def_use_sites,
348 )?
349 };
350
351 if params.impl_only.unwrap_or(false) {
353 let filter_header = format!(
354 "FILTER: impl_only=true ({impl_trait_caller_count} of {unfiltered_caller_count} callers shown)\n",
355 );
356 formatted = format!("{filter_header}{formatted}");
357 }
358
359 Ok((
360 formatted,
361 prod_chains,
362 test_chains,
363 outgoing_chains,
364 def_count,
365 ))
366}
367
368#[allow(clippy::needless_pass_by_value)]
371pub fn analyze_focused_with_progress(
372 root: &Path,
373 params: &FocusedAnalysisConfig,
374 progress: Arc<AtomicUsize>,
375 ct: CancellationToken,
376) -> Result<FocusedAnalysisOutput, AnalyzeError> {
377 let entries = walk_directory(root, params.max_depth)?;
378 let internal_params = InternalFocusedParams {
379 focus: params.focus.clone(),
380 match_mode: params.match_mode.clone(),
381 follow_depth: params.follow_depth,
382 ast_recursion_limit: params.ast_recursion_limit,
383 use_summary: params.use_summary,
384 impl_only: params.impl_only,
385 def_use: params.def_use,
386 parse_timeout_micros: params.parse_timeout_micros,
387 };
388 analyze_focused_with_progress_with_entries_internal(
389 root,
390 params.max_depth,
391 &progress,
392 &ct,
393 &internal_params,
394 &entries,
395 None,
396 )
397}
398
399#[instrument(skip_all, fields(path = %root.display(), symbol = %params.focus))]
401fn analyze_focused_with_progress_with_entries_internal(
402 root: &Path,
403 _max_depth: Option<u32>,
404 progress: &Arc<AtomicUsize>,
405 ct: &CancellationToken,
406 params: &InternalFocusedParams,
407 entries: &[WalkEntry],
408 structural_graph_cache: Option<&StructuralGraphCache>,
409) -> Result<FocusedAnalysisOutput, AnalyzeError> {
410 if ct.is_cancelled() {
412 return Err(AnalyzeError::Cancelled);
413 }
414
415 if root.is_file() {
417 let formatted =
418 "Single-file focus not supported. Please provide a directory path for cross-file call graph analysis.\n"
419 .to_string();
420 return Ok(FocusedAnalysisOutput {
421 formatted,
422 next_cursor: None,
423 prod_chains: vec![],
424 test_chains: vec![],
425 outgoing_chains: vec![],
426 def_count: 0,
427 unfiltered_caller_count: 0,
428 impl_trait_caller_count: 0,
429 callers: None,
430 test_callers: None,
431 callees: None,
432 def_use_sites: vec![],
433 cache_tier: None,
434 });
435 }
436
437 let (analysis_results, all_impl_traits) = collect_file_analysis(
439 entries,
440 progress,
441 ct,
442 params.ast_recursion_limit,
443 params.parse_timeout_micros,
444 )?;
445
446 let cache_key = compute_cache_key(root, entries);
448
449 if ct.is_cancelled() {
451 return Err(AnalyzeError::Cancelled);
452 }
453
454 let mut graph = build_call_graph(analysis_results.clone(), &all_impl_traits)?;
456
457 if let Some(key) = &cache_key {
461 if let Some(_graph) = structural_graph_cache.and_then(|c| c.get(key)) {
462 tracing::debug!(key, "structural graph cache hit (warm L1)");
463 } else {
464 let store = create_graph_store();
465 if let Some(graph) = store.get(key) {
466 tracing::debug!(key, "structural graph cache hit (warm L2)");
467 if let Some(cache) = structural_graph_cache {
468 cache.put(key.clone(), Arc::new(graph));
469 }
470 } else {
471 let sg_entries: Vec<FileAnalysisOutput> = analysis_results
472 .into_iter()
473 .map(|(p, s)| {
474 FileAnalysisOutput::new(p.to_string_lossy().into_owned(), s, 0, None)
475 })
476 .collect();
477 let graph = Arc::new(StructuralGraph::build_from_analysis(&sg_entries));
478 store.put(key, &graph);
479 if let Some(cache) = structural_graph_cache {
480 cache.put(key.clone(), graph);
481 }
482 }
483 }
484 }
485
486 if ct.is_cancelled() {
488 return Err(AnalyzeError::Cancelled);
489 }
490
491 let resolve_result = resolve_symbol(&mut graph, params);
495 if let Err(AnalyzeError::Graph(crate::graph::GraphError::SymbolNotFound { .. })) =
496 &resolve_result
497 {
498 if params.def_use {
501 let def_use_sites =
502 collect_def_use_sites(entries, ¶ms.focus, params.ast_recursion_limit, root, ct);
503 if def_use_sites.is_empty() {
504 if let Err(e) = resolve_result {
508 return Err(e);
509 }
510 unreachable!("resolve_result is Ok only when symbol was found");
511 }
512 use std::fmt::Write as _;
513 let mut formatted = String::new();
514 let _ = writeln!(
515 formatted,
516 "FOCUS: {} (0 defs, 0 callers, 0 callees)",
517 params.focus
518 );
519 {
520 let writes = def_use_sites
521 .iter()
522 .filter(|s| {
523 matches!(
524 s.kind,
525 crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
526 )
527 })
528 .count();
529 let reads = def_use_sites
530 .iter()
531 .filter(|s| s.kind == crate::types::DefUseKind::Read)
532 .count();
533 let _ = writeln!(
534 formatted,
535 "DEF-USE SITES {} ({} total: {} writes, {} reads)",
536 params.focus,
537 def_use_sites.len(),
538 writes,
539 reads
540 );
541 }
542 return Ok(FocusedAnalysisOutput {
543 formatted,
544 next_cursor: None,
545 callers: None,
546 test_callers: None,
547 callees: None,
548 prod_chains: vec![],
549 test_chains: vec![],
550 outgoing_chains: vec![],
551 def_count: 0,
552 unfiltered_caller_count: 0,
553 impl_trait_caller_count: 0,
554 def_use_sites,
555 cache_tier: None,
556 });
557 }
558 }
559 let (resolved_focus, unfiltered_caller_count, impl_trait_caller_count) = resolve_result?;
560
561 if ct.is_cancelled() {
563 return Err(AnalyzeError::Cancelled);
564 }
565
566 let def_use_sites = if params.def_use {
570 collect_def_use_sites(entries, ¶ms.focus, params.ast_recursion_limit, root, ct)
571 } else {
572 Vec::new()
573 };
574
575 let (formatted, prod_chains, test_chains, outgoing_chains, def_count) = compute_chains(
577 &graph,
578 &resolved_focus,
579 root,
580 params,
581 unfiltered_caller_count,
582 impl_trait_caller_count,
583 &def_use_sites,
584 )?;
585
586 let (depth1_callers, depth1_test_callers, depth1_callees) = if params.follow_depth <= 1 {
589 let callers = chains_to_entries(&prod_chains, Some(root));
591 let test_callers = chains_to_entries(&test_chains, Some(root));
592 let callees = chains_to_entries(&outgoing_chains, Some(root));
593 (callers, test_callers, callees)
594 } else {
595 let incoming1 = graph
597 .find_incoming_chains(&resolved_focus, 1)
598 .unwrap_or_default();
599 let outgoing1 = graph
600 .find_outgoing_chains(&resolved_focus, 1)
601 .unwrap_or_default();
602 let (prod1, test1): (Vec<_>, Vec<_>) = incoming1.into_iter().partition(|chain| {
603 chain
604 .chain
605 .first()
606 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
607 });
608 let callers = chains_to_entries(&prod1, Some(root));
609 let test_callers = chains_to_entries(&test1, Some(root));
610 let callees = chains_to_entries(&outgoing1, Some(root));
611 (callers, test_callers, callees)
612 };
613
614 Ok(FocusedAnalysisOutput {
615 formatted,
616 next_cursor: None,
617 callers: depth1_callers,
618 test_callers: depth1_test_callers,
619 callees: depth1_callees,
620 prod_chains,
621 test_chains,
622 outgoing_chains,
623 def_count,
624 unfiltered_caller_count,
625 impl_trait_caller_count,
626 def_use_sites,
627 cache_tier: None,
628 })
629}
630
631fn collect_def_use_sites(
634 entries: &[WalkEntry],
635 symbol: &str,
636 ast_recursion_limit: Option<usize>,
637 root: &std::path::Path,
638 ct: &CancellationToken,
639) -> Vec<crate::types::DefUseSite> {
640 use crate::parser::SemanticExtractor;
641
642 let file_entries: Vec<&WalkEntry> = entries
643 .iter()
644 .filter(|e| !e.is_dir && !e.is_symlink)
645 .collect();
646
647 let mut sites: Vec<crate::types::DefUseSite> = file_entries
648 .par_iter()
649 .filter_map(|entry| {
650 if ct.is_cancelled() {
651 return None;
652 }
653
654 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
656 tracing::debug!("skipping large file: {}", entry.path.display());
657 return None;
658 }
659
660 let Ok(source) = std::fs::read_to_string(&entry.path) else {
661 return None;
662 };
663 let ext = entry
664 .path
665 .extension()
666 .and_then(|e| e.to_str())
667 .unwrap_or("");
668 let lang = crate::lang::language_for_extension(ext)?;
669 let file_path = entry
670 .path
671 .strip_prefix(root)
672 .unwrap_or(&entry.path)
673 .display()
674 .to_string();
675 let sites = SemanticExtractor::extract_def_use_for_file(
676 &source,
677 lang,
678 symbol,
679 &file_path,
680 ast_recursion_limit,
681 );
682 if sites.is_empty() { None } else { Some(sites) }
683 })
684 .flatten()
685 .collect();
686
687 sites.sort_by(|a, b| {
689 use crate::types::DefUseKind;
690 let kind_ord = |k: &DefUseKind| match k {
691 DefUseKind::Write | DefUseKind::WriteRead => 0,
692 DefUseKind::Read => 1,
693 };
694 kind_ord(&a.kind)
695 .cmp(&kind_ord(&b.kind))
696 .then_with(|| a.file.cmp(&b.file))
697 .then_with(|| a.line.cmp(&b.line))
698 .then_with(|| a.column.cmp(&b.column))
699 });
700
701 sites
702}
703
704pub fn analyze_focused_with_progress_with_entries(
706 root: &Path,
707 params: &FocusedAnalysisConfig,
708 progress: &Arc<AtomicUsize>,
709 ct: &CancellationToken,
710 entries: &[WalkEntry],
711 structural_graph_cache: Option<&StructuralGraphCache>,
712) -> Result<FocusedAnalysisOutput, AnalyzeError> {
713 let internal_params = InternalFocusedParams {
714 focus: params.focus.clone(),
715 match_mode: params.match_mode.clone(),
716 follow_depth: params.follow_depth,
717 ast_recursion_limit: params.ast_recursion_limit,
718 use_summary: params.use_summary,
719 impl_only: params.impl_only,
720 def_use: params.def_use,
721 parse_timeout_micros: params.parse_timeout_micros,
722 };
723 analyze_focused_with_progress_with_entries_internal(
724 root,
725 params.max_depth,
726 progress,
727 ct,
728 &internal_params,
729 entries,
730 structural_graph_cache,
731 )
732}
733
734#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
735pub fn analyze_focused(
736 root: &Path,
737 focus: &str,
738 follow_depth: u32,
739 max_depth: Option<u32>,
740 ast_recursion_limit: Option<usize>,
741) -> Result<FocusedAnalysisOutput, AnalyzeError> {
742 let entries = walk_directory(root, max_depth)?;
743 let counter = Arc::new(AtomicUsize::new(0));
744 let ct = CancellationToken::new();
745 let params = FocusedAnalysisConfig {
746 focus: focus.to_string(),
747 match_mode: SymbolMatchMode::Exact,
748 follow_depth,
749 max_depth,
750 ast_recursion_limit,
751 use_summary: false,
752 impl_only: None,
753 def_use: false,
754 parse_timeout_micros: None,
755 };
756 analyze_focused_with_progress_with_entries(root, ¶ms, &counter, &ct, &entries, None)
757}
758
759#[instrument(skip_all, fields(path))]
762pub fn analyze_module_file(path: &str) -> Result<crate::types::ModuleInfo, AnalyzeError> {
763 if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
765 tracing::debug!("skipping large file: {}", path);
766 return Err(AnalyzeError::Parser(
767 crate::parser::ParserError::ParseError("file too large".to_string()),
768 ));
769 }
770
771 let source = std::fs::read_to_string(path)
772 .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
773
774 let file_path = Path::new(path);
775 let name = file_path
776 .file_name()
777 .and_then(|s| s.to_str())
778 .unwrap_or("unknown")
779 .to_string();
780
781 let line_count = source.lines().count();
782
783 let language = file_path
784 .extension()
785 .and_then(|e| e.to_str())
786 .and_then(language_for_extension)
787 .ok_or_else(|| {
788 AnalyzeError::Parser(crate::parser::ParserError::UnsupportedLanguage(
789 file_path
790 .extension()
791 .and_then(|e| e.to_str())
792 .unwrap_or("(no extension)")
793 .to_string(),
794 ))
795 })?;
796
797 let mut module_info = SemanticExtractor::extract_module_info(&source, language, None)?;
798 module_info.name = name;
799 module_info.line_count = line_count;
800
801 Ok(module_info)
802}
803
804pub fn analyze_import_lookup(
810 root: &Path,
811 module: &str,
812 entries: &[WalkEntry],
813 ast_recursion_limit: Option<usize>,
814) -> Result<FocusedAnalysisOutput, AnalyzeError> {
815 let matches: Vec<(PathBuf, usize)> = entries
816 .par_iter()
817 .filter_map(|entry| {
818 if entry.is_dir || entry.is_symlink {
819 tracing::debug!("skipping symlink: {}", entry.path.display());
820 return None;
821 }
822 let ext = entry
823 .path
824 .extension()
825 .and_then(|e| e.to_str())
826 .and_then(crate::lang::language_for_extension)?;
827 let source = std::fs::read_to_string(&entry.path).ok()?;
828 let semantic =
829 SemanticExtractor::extract(&source, ext, ast_recursion_limit, None).ok()?;
830 for import in &semantic.imports {
831 if import.module == module || import.items.iter().any(|item| item == module) {
832 return Some((entry.path.clone(), import.line));
833 }
834 }
835 None
836 })
837 .collect();
838
839 let mut text = format!("IMPORT_LOOKUP: {module}\n");
840 text.push_str(&format!("ROOT: {}\n", root.display()));
841 text.push_str(&format!("MATCHES: {}\n", matches.len()));
842 for (path, line) in &matches {
843 let rel = path.strip_prefix(root).unwrap_or(path);
844 text.push_str(&format!(" {}:{line}\n", rel.display()));
845 }
846
847 Ok(FocusedAnalysisOutput {
848 formatted: text,
849 next_cursor: None,
850 prod_chains: vec![],
851 test_chains: vec![],
852 outgoing_chains: vec![],
853 def_count: 0,
854 unfiltered_caller_count: 0,
855 impl_trait_caller_count: 0,
856 callers: None,
857 test_callers: None,
858 callees: None,
859 def_use_sites: vec![],
860 cache_tier: None,
861 })
862}
863
864pub(crate) fn resolve_wildcard_imports(file_path: &Path, imports: &mut [ImportInfo]) {
874 use std::collections::HashMap;
875
876 let mut resolved_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
877 let Ok(file_path_canonical) = file_path.canonicalize() else {
878 tracing::debug!(file = ?file_path, "unable to canonicalize current file path");
879 return;
880 };
881
882 for import in imports.iter_mut() {
883 if import.items != ["*"] {
884 continue;
885 }
886 resolve_single_wildcard(import, file_path, &file_path_canonical, &mut resolved_cache);
887 }
888}
889
890fn validate_wildcard_target(
893 target_to_read: &Path,
894 file_path_canonical: &Path,
895 module: &str,
896) -> Option<PathBuf> {
897 let Ok(canonical) = target_to_read.canonicalize() else {
898 tracing::debug!(target = ?target_to_read, import = %module, "unable to canonicalize path");
899 return None;
900 };
901
902 if canonical == file_path_canonical {
903 tracing::debug!(target = ?canonical, import = %module, "cannot import from self");
904 return None;
905 }
906
907 Some(canonical)
908}
909
910fn resolve_single_wildcard(
912 import: &mut ImportInfo,
913 file_path: &Path,
914 file_path_canonical: &Path,
915 resolved_cache: &mut std::collections::HashMap<PathBuf, Vec<String>>,
916) {
917 let module = import.module.clone();
918 let dot_count = module.chars().take_while(|c| *c == '.').count();
919 if dot_count == 0 {
920 return;
921 }
922 let module_path = module.trim_start_matches('.');
923
924 let Some(target_to_read) = locate_target_file(file_path, dot_count, module_path, &module)
925 else {
926 return;
927 };
928
929 let Some(canonical) = validate_wildcard_target(&target_to_read, file_path_canonical, &module)
930 else {
931 return;
932 };
933
934 if let Some(cached) = resolved_cache.get(&canonical) {
935 tracing::debug!(import = %module, symbols_count = cached.len(), "using cached symbols");
936 import.items.clone_from(cached);
937 return;
938 }
939
940 if let Some(symbols) = parse_target_symbols(&target_to_read, &module) {
941 tracing::debug!(import = %module, resolved_count = symbols.len(), "wildcard import resolved");
942 import.items.clone_from(&symbols);
943 resolved_cache.insert(canonical, symbols);
944 }
945}
946
947fn locate_target_file(
949 file_path: &Path,
950 dot_count: usize,
951 module_path: &str,
952 module: &str,
953) -> Option<PathBuf> {
954 let mut target_dir = file_path.parent()?.to_path_buf();
955
956 for _ in 1..dot_count {
957 if !target_dir.pop() {
958 tracing::debug!(import = %module, "unable to climb {} levels", dot_count.saturating_sub(1));
959 return None;
960 }
961 }
962
963 let target_file = if module_path.is_empty() {
964 target_dir.join("__init__.py")
965 } else {
966 let rel_path = module_path.replace('.', "/");
967 target_dir.join(format!("{rel_path}.py"))
968 };
969
970 if target_file.exists() {
971 Some(target_file)
972 } else if target_file.with_extension("").is_dir() {
973 let init = target_file.with_extension("").join("__init__.py");
974 if init.exists() { Some(init) } else { None }
975 } else {
976 tracing::debug!(target = ?target_file, import = %module, "target file not found");
977 None
978 }
979}
980
981fn build_parser_for_file(source: &str) -> Option<tree_sitter::Tree> {
983 use tree_sitter::Parser;
984
985 let lang_info = crate::languages::get_language_info("python")?;
986 let mut parser = Parser::new();
987 if parser.set_language(&lang_info.language).is_err() {
988 return None;
989 }
990 parser.parse(source, None)
991}
992
993fn extract_all_symbols(tree: &tree_sitter::Tree, source: &str) -> Vec<String> {
995 let mut symbols = Vec::new();
996 let root = tree.root_node();
997 let mut cursor = root.walk();
998 for child in root.children(&mut cursor) {
999 if matches!(child.kind(), "function_definition" | "class_definition")
1000 && let Some(name_node) = child.child_by_field_name("name")
1001 {
1002 let name = source[name_node.start_byte()..name_node.end_byte()].to_string();
1003 if !name.starts_with('_') {
1004 symbols.push(name);
1005 }
1006 }
1007 }
1008 symbols
1009}
1010
1011fn resolve_symbols_from_tree(tree: &tree_sitter::Tree, source: &str, module: &str) -> Vec<String> {
1013 let mut symbols = Vec::new();
1014 extract_all_from_tree(tree, source, &mut symbols);
1015 if !symbols.is_empty() {
1016 tracing::debug!(import = %module, symbols = ?symbols, "using __all__ symbols");
1017 return symbols;
1018 }
1019
1020 let symbols = extract_all_symbols(tree, source);
1022 tracing::debug!(import = %module, fallback_symbols = ?symbols, "using fallback function/class names");
1023 symbols
1024}
1025
1026fn parse_target_symbols(target_path: &Path, module: &str) -> Option<Vec<String>> {
1028 if target_path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1030 tracing::debug!("skipping large file: {}", target_path.display());
1031 return None;
1032 }
1033
1034 let source = match std::fs::read_to_string(target_path) {
1035 Ok(s) => s,
1036 Err(e) => {
1037 tracing::debug!(target = ?target_path, import = %module, error = %e, "unable to read target file");
1038 return None;
1039 }
1040 };
1041
1042 let tree = build_parser_for_file(&source)?;
1044
1045 let symbols = resolve_symbols_from_tree(&tree, &source, module);
1047 Some(symbols)
1048}
1049
1050fn extract_all_from_tree(tree: &tree_sitter::Tree, source: &str, result: &mut Vec<String>) {
1052 let root = tree.root_node();
1053 let mut cursor = root.walk();
1054 for child in root.children(&mut cursor) {
1055 if child.kind() == "simple_statement" {
1056 let mut simple_cursor = child.walk();
1058 for simple_child in child.children(&mut simple_cursor) {
1059 if simple_child.kind() == "assignment"
1060 && let Some(left) = simple_child.child_by_field_name("left")
1061 {
1062 let target_text = source[left.start_byte()..left.end_byte()].trim();
1063 if target_text == "__all__"
1064 && let Some(right) = simple_child.child_by_field_name("right")
1065 {
1066 extract_string_list_from_list_node(&right, source, result);
1067 }
1068 }
1069 }
1070 } else if child.kind() == "expression_statement" {
1071 let mut stmt_cursor = child.walk();
1073 for stmt_child in child.children(&mut stmt_cursor) {
1074 if stmt_child.kind() == "assignment"
1075 && let Some(left) = stmt_child.child_by_field_name("left")
1076 {
1077 let target_text = source[left.start_byte()..left.end_byte()].trim();
1078 if target_text == "__all__"
1079 && let Some(right) = stmt_child.child_by_field_name("right")
1080 {
1081 extract_string_list_from_list_node(&right, source, result);
1082 }
1083 }
1084 }
1085 }
1086 }
1087}
1088
1089fn extract_string_list_from_list_node(
1091 list_node: &tree_sitter::Node,
1092 source: &str,
1093 result: &mut Vec<String>,
1094) {
1095 let mut cursor = list_node.walk();
1096 for child in list_node.named_children(&mut cursor) {
1097 if child.kind() == "string" {
1098 let raw = source[child.start_byte()..child.end_byte()].trim();
1099 let unquoted = raw.trim_matches('"').trim_matches('\'').to_string();
1101 if !unquoted.is_empty() {
1102 result.push(unquoted);
1103 }
1104 }
1105 }
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111
1112 #[test]
1113 fn test_structural_graph_cache_warm_hit() {
1114 let temp_dir = tempfile::tempdir().expect("tempdir");
1116 let test_file = tempfile::NamedTempFile::new_in(temp_dir.path()).expect("tempfile");
1117 std::fs::write(&test_file.path(), "fn main() {}").expect("write");
1118
1119 let entries = walk_directory(temp_dir.path(), None).expect("walk");
1121
1122 let cache = StructuralGraphCache::new(10);
1124 let progress = Arc::new(AtomicUsize::new(0));
1125 let ct = CancellationToken::new();
1126 let config = FocusedAnalysisConfig {
1127 focus: "main".to_string(),
1128 match_mode: SymbolMatchMode::Exact,
1129 follow_depth: 2,
1130 max_depth: None,
1131 ast_recursion_limit: None,
1132 use_summary: false,
1133 impl_only: None,
1134 def_use: false,
1135 parse_timeout_micros: None,
1136 };
1137
1138 let _ = analyze_focused_with_progress_with_entries(
1140 temp_dir.path(),
1141 &config,
1142 &progress,
1143 &ct,
1144 &entries,
1145 Some(&cache),
1146 );
1147
1148 if let Some(key) = compute_cache_key(temp_dir.path(), &entries) {
1150 assert!(
1151 cache.get(&key).is_some(),
1152 "structural graph cache should be populated after first call"
1153 );
1154
1155 let progress2 = Arc::new(AtomicUsize::new(0));
1157 let _ = analyze_focused_with_progress_with_entries(
1158 temp_dir.path(),
1159 &config,
1160 &progress2,
1161 &ct,
1162 &entries,
1163 Some(&cache),
1164 );
1165
1166 assert!(
1168 cache.get(&key).is_some(),
1169 "structural graph cache hit should work on second call"
1170 );
1171 }
1172 }
1173}