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