1use crate::analyze::{
6 AnalyzeError, CallChainEntry, FocusedAnalysisConfig, FocusedAnalysisOutput, MAX_FILE_SIZE_BYTES,
7};
8use crate::formatter::{format_focused_internal, format_focused_summary_internal};
9use crate::graph::{CallGraph, InternalCallChain};
10use crate::lang::language_for_extension;
11use crate::parser::SemanticExtractor;
12use crate::test_detection::is_test_file;
13use crate::traversal::{WalkEntry, walk_directory};
14use crate::types::{ImplTraitInfo, ImportInfo, SemanticAnalysis, SymbolMatchMode};
15use rayon::prelude::*;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use tokio_util::sync::CancellationToken;
20use tracing::instrument;
21
22#[derive(Clone)]
24pub(crate) struct InternalFocusedParams {
25 pub(crate) focus: String,
26 pub(crate) match_mode: SymbolMatchMode,
27 pub(crate) follow_depth: u32,
28 pub(crate) ast_recursion_limit: Option<usize>,
29 pub(crate) use_summary: bool,
30 pub(crate) impl_only: Option<bool>,
31 pub(crate) def_use: bool,
32 pub(crate) parse_timeout_micros: Option<u64>,
33}
34
35type FileAnalysisBatch = (Vec<(PathBuf, SemanticAnalysis)>, Vec<ImplTraitInfo>);
37
38fn collect_file_analysis(
40 entries: &[WalkEntry],
41 progress: &Arc<AtomicUsize>,
42 ct: &CancellationToken,
43 ast_recursion_limit: Option<usize>,
44 parse_timeout_micros: Option<u64>,
45) -> Result<FileAnalysisBatch, AnalyzeError> {
46 if ct.is_cancelled() {
48 return Err(AnalyzeError::Cancelled);
49 }
50
51 let file_entries: Vec<&WalkEntry> = entries
54 .iter()
55 .filter(|e| !e.is_dir && !e.is_symlink)
56 .collect();
57
58 let timed_out: std::sync::Mutex<Vec<(PathBuf, u64)>> = std::sync::Mutex::new(Vec::new());
60
61 let analysis_results: Vec<(PathBuf, SemanticAnalysis)> = file_entries
62 .par_iter()
63 .filter_map(|entry| {
64 if ct.is_cancelled() {
66 return None;
67 }
68
69 let ext = entry.path.extension().and_then(|e| e.to_str());
70
71 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
73 tracing::debug!("skipping large file: {}", entry.path.display());
74 progress.fetch_add(1, Ordering::Relaxed);
75 return None;
76 }
77
78 let Ok(source) = std::fs::read_to_string(&entry.path) else {
80 progress.fetch_add(1, Ordering::Relaxed);
81 return None;
82 };
83
84 let language = if let Some(ext_str) = ext {
86 language_for_extension(ext_str)
87 .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string)
88 } else {
89 "unknown".to_string()
90 };
91
92 match SemanticExtractor::extract(
93 &source,
94 &language,
95 ast_recursion_limit,
96 parse_timeout_micros,
97 ) {
98 Ok(mut semantic) => {
99 for r in &mut semantic.references {
101 r.location = entry.path.display().to_string();
102 }
103 for trait_info in &mut semantic.impl_traits {
105 trait_info.path.clone_from(&entry.path);
106 }
107 progress.fetch_add(1, Ordering::Relaxed);
108 Some((entry.path.clone(), semantic))
109 }
110 Err(crate::parser::ParserError::Timeout(micros)) => {
111 tracing::warn!(
112 "parse timeout exceeded for {}: {} microseconds",
113 entry.path.display(),
114 micros
115 );
116 if let Ok(mut v) = timed_out.lock() {
117 v.push((entry.path.clone(), micros));
118 }
119 progress.fetch_add(1, Ordering::Relaxed);
120 None
121 }
122 Err(_) => {
123 progress.fetch_add(1, Ordering::Relaxed);
124 None
125 }
126 }
127 })
128 .collect();
129
130 if ct.is_cancelled() {
132 return Err(AnalyzeError::Cancelled);
133 }
134
135 if let Ok(mut v) = timed_out.lock()
137 && let Some((path, micros)) = v.drain(..).next()
138 {
139 return Err(AnalyzeError::ParseTimeout { path, micros });
140 }
141
142 let all_impl_traits: Vec<ImplTraitInfo> = analysis_results
144 .iter()
145 .flat_map(|(_, sem)| sem.impl_traits.iter().cloned())
146 .collect();
147
148 Ok((analysis_results, all_impl_traits))
149}
150
151fn build_call_graph(
153 analysis_results: Vec<(PathBuf, SemanticAnalysis)>,
154 all_impl_traits: &[ImplTraitInfo],
155) -> Result<CallGraph, AnalyzeError> {
156 CallGraph::build_from_results(
159 analysis_results,
160 all_impl_traits,
161 false, )
163 .map_err(std::convert::Into::into)
164}
165
166fn resolve_symbol(
171 graph: &mut CallGraph,
172 params: &InternalFocusedParams,
173) -> Result<(String, usize, usize), AnalyzeError> {
174 let resolved_focus = if params.match_mode == SymbolMatchMode::Exact {
176 let exists = graph.definitions.contains_key(¶ms.focus)
177 || graph.callers.contains_key(¶ms.focus)
178 || graph.callees.contains_key(¶ms.focus);
179 if exists {
180 params.focus.clone()
181 } else {
182 return Err(crate::graph::GraphError::SymbolNotFound {
183 symbol: params.focus.clone(),
184 hint: "Try match_mode=insensitive for a case-insensitive search, or match_mode=prefix to list symbols starting with this name.".to_string(),
185 }
186 .into());
187 }
188 } else {
189 graph.resolve_symbol_indexed(¶ms.focus, ¶ms.match_mode)?
190 };
191
192 let unfiltered_caller_count = graph.callers.get(&resolved_focus).map_or(0, |edges| {
194 edges
195 .iter()
196 .map(|e| &e.neighbor_name)
197 .collect::<std::collections::HashSet<_>>()
198 .len()
199 });
200
201 let impl_trait_caller_count = if params.impl_only.unwrap_or(false) {
205 for edges in graph.callers.values_mut() {
206 edges.retain(|e| e.is_impl_trait);
207 }
208 graph.callers.get(&resolved_focus).map_or(0, |edges| {
209 edges
210 .iter()
211 .map(|e| &e.neighbor_name)
212 .collect::<std::collections::HashSet<_>>()
213 .len()
214 })
215 } else {
216 unfiltered_caller_count
217 };
218
219 Ok((
220 resolved_focus,
221 unfiltered_caller_count,
222 impl_trait_caller_count,
223 ))
224}
225
226type ChainComputeResult = (
228 String,
229 Vec<InternalCallChain>,
230 Vec<InternalCallChain>,
231 Vec<InternalCallChain>,
232 usize,
233);
234
235pub(crate) fn chains_to_entries(
239 chains: &[InternalCallChain],
240 root: Option<&std::path::Path>,
241) -> Option<Vec<CallChainEntry>> {
242 if chains.is_empty() {
243 return None;
244 }
245 let entries: Vec<CallChainEntry> = chains
246 .iter()
247 .take(10)
248 .filter_map(|chain| {
249 let (symbol, path, line) = chain.chain.first()?;
250 let file = match root {
251 Some(root) => path
252 .strip_prefix(root)
253 .unwrap_or(path.as_path())
254 .to_string_lossy()
255 .into_owned(),
256 None => path.to_string_lossy().into_owned(),
257 };
258 Some(CallChainEntry {
259 symbol: symbol.clone(),
260 file,
261 line: *line,
262 })
263 })
264 .collect();
265 if entries.is_empty() {
266 None
267 } else {
268 Some(entries)
269 }
270}
271
272fn compute_chains(
274 graph: &CallGraph,
275 resolved_focus: &str,
276 root: &Path,
277 params: &InternalFocusedParams,
278 unfiltered_caller_count: usize,
279 impl_trait_caller_count: usize,
280 def_use_sites: &[crate::types::DefUseSite],
281) -> Result<ChainComputeResult, AnalyzeError> {
282 let def_count = graph.definitions.get(resolved_focus).map_or(0, Vec::len);
284 let incoming_chains = graph.find_incoming_chains(resolved_focus, params.follow_depth)?;
285 let outgoing_chains = graph.find_outgoing_chains(resolved_focus, params.follow_depth)?;
286
287 let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
288 incoming_chains.iter().cloned().partition(|chain| {
289 chain
290 .chain
291 .first()
292 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
293 });
294
295 let mut formatted = if params.use_summary {
297 format_focused_summary_internal(
298 graph,
299 resolved_focus,
300 params.follow_depth,
301 Some(root),
302 Some(&incoming_chains),
303 Some(&outgoing_chains),
304 def_use_sites,
305 )?
306 } else {
307 format_focused_internal(
308 graph,
309 resolved_focus,
310 params.follow_depth,
311 Some(root),
312 Some(&incoming_chains),
313 Some(&outgoing_chains),
314 def_use_sites,
315 )?
316 };
317
318 if params.impl_only.unwrap_or(false) {
320 let filter_header = format!(
321 "FILTER: impl_only=true ({impl_trait_caller_count} of {unfiltered_caller_count} callers shown)\n",
322 );
323 formatted = format!("{filter_header}{formatted}");
324 }
325
326 Ok((
327 formatted,
328 prod_chains,
329 test_chains,
330 outgoing_chains,
331 def_count,
332 ))
333}
334
335#[allow(clippy::needless_pass_by_value)]
338pub fn analyze_focused_with_progress(
339 root: &Path,
340 params: &FocusedAnalysisConfig,
341 progress: Arc<AtomicUsize>,
342 ct: CancellationToken,
343) -> Result<FocusedAnalysisOutput, AnalyzeError> {
344 let entries = walk_directory(root, params.max_depth)?;
345 let internal_params = InternalFocusedParams {
346 focus: params.focus.clone(),
347 match_mode: params.match_mode.clone(),
348 follow_depth: params.follow_depth,
349 ast_recursion_limit: params.ast_recursion_limit,
350 use_summary: params.use_summary,
351 impl_only: params.impl_only,
352 def_use: params.def_use,
353 parse_timeout_micros: params.parse_timeout_micros,
354 };
355 analyze_focused_with_progress_with_entries_internal(
356 root,
357 params.max_depth,
358 &progress,
359 &ct,
360 &internal_params,
361 &entries,
362 )
363}
364
365#[instrument(skip_all, fields(path = %root.display(), symbol = %params.focus))]
367fn analyze_focused_with_progress_with_entries_internal(
368 root: &Path,
369 _max_depth: Option<u32>,
370 progress: &Arc<AtomicUsize>,
371 ct: &CancellationToken,
372 params: &InternalFocusedParams,
373 entries: &[WalkEntry],
374) -> Result<FocusedAnalysisOutput, AnalyzeError> {
375 if ct.is_cancelled() {
377 return Err(AnalyzeError::Cancelled);
378 }
379
380 if root.is_file() {
382 let formatted =
383 "Single-file focus not supported. Please provide a directory path for cross-file call graph analysis.\n"
384 .to_string();
385 return Ok(FocusedAnalysisOutput {
386 formatted,
387 next_cursor: None,
388 prod_chains: vec![],
389 test_chains: vec![],
390 outgoing_chains: vec![],
391 def_count: 0,
392 unfiltered_caller_count: 0,
393 impl_trait_caller_count: 0,
394 callers: None,
395 test_callers: None,
396 callees: None,
397 def_use_sites: vec![],
398 cache_tier: None,
399 });
400 }
401
402 let (analysis_results, all_impl_traits) = collect_file_analysis(
404 entries,
405 progress,
406 ct,
407 params.ast_recursion_limit,
408 params.parse_timeout_micros,
409 )?;
410
411 if ct.is_cancelled() {
413 return Err(AnalyzeError::Cancelled);
414 }
415
416 let mut graph = build_call_graph(analysis_results, &all_impl_traits)?;
418
419 if ct.is_cancelled() {
421 return Err(AnalyzeError::Cancelled);
422 }
423
424 let resolve_result = resolve_symbol(&mut graph, params);
428 if let Err(AnalyzeError::Graph(crate::graph::GraphError::SymbolNotFound { .. })) =
429 &resolve_result
430 {
431 if params.def_use {
434 let def_use_sites =
435 collect_def_use_sites(entries, ¶ms.focus, params.ast_recursion_limit, root, ct);
436 if def_use_sites.is_empty() {
437 if let Err(e) = resolve_result {
441 return Err(e);
442 }
443 unreachable!("resolve_result is Ok only when symbol was found");
444 }
445 use std::fmt::Write as _;
446 let mut formatted = String::new();
447 let _ = writeln!(
448 formatted,
449 "FOCUS: {} (0 defs, 0 callers, 0 callees)",
450 params.focus
451 );
452 {
453 let writes = def_use_sites
454 .iter()
455 .filter(|s| {
456 matches!(
457 s.kind,
458 crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
459 )
460 })
461 .count();
462 let reads = def_use_sites
463 .iter()
464 .filter(|s| s.kind == crate::types::DefUseKind::Read)
465 .count();
466 let _ = writeln!(
467 formatted,
468 "DEF-USE SITES {} ({} total: {} writes, {} reads)",
469 params.focus,
470 def_use_sites.len(),
471 writes,
472 reads
473 );
474 }
475 return Ok(FocusedAnalysisOutput {
476 formatted,
477 next_cursor: None,
478 callers: None,
479 test_callers: None,
480 callees: None,
481 prod_chains: vec![],
482 test_chains: vec![],
483 outgoing_chains: vec![],
484 def_count: 0,
485 unfiltered_caller_count: 0,
486 impl_trait_caller_count: 0,
487 def_use_sites,
488 cache_tier: None,
489 });
490 }
491 }
492 let (resolved_focus, unfiltered_caller_count, impl_trait_caller_count) = resolve_result?;
493
494 if ct.is_cancelled() {
496 return Err(AnalyzeError::Cancelled);
497 }
498
499 let def_use_sites = if params.def_use {
503 collect_def_use_sites(entries, ¶ms.focus, params.ast_recursion_limit, root, ct)
504 } else {
505 Vec::new()
506 };
507
508 let (formatted, prod_chains, test_chains, outgoing_chains, def_count) = compute_chains(
510 &graph,
511 &resolved_focus,
512 root,
513 params,
514 unfiltered_caller_count,
515 impl_trait_caller_count,
516 &def_use_sites,
517 )?;
518
519 let (depth1_callers, depth1_test_callers, depth1_callees) = if params.follow_depth <= 1 {
522 let callers = chains_to_entries(&prod_chains, Some(root));
524 let test_callers = chains_to_entries(&test_chains, Some(root));
525 let callees = chains_to_entries(&outgoing_chains, Some(root));
526 (callers, test_callers, callees)
527 } else {
528 let incoming1 = graph
530 .find_incoming_chains(&resolved_focus, 1)
531 .unwrap_or_default();
532 let outgoing1 = graph
533 .find_outgoing_chains(&resolved_focus, 1)
534 .unwrap_or_default();
535 let (prod1, test1): (Vec<_>, Vec<_>) = incoming1.into_iter().partition(|chain| {
536 chain
537 .chain
538 .first()
539 .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
540 });
541 let callers = chains_to_entries(&prod1, Some(root));
542 let test_callers = chains_to_entries(&test1, Some(root));
543 let callees = chains_to_entries(&outgoing1, Some(root));
544 (callers, test_callers, callees)
545 };
546
547 Ok(FocusedAnalysisOutput {
548 formatted,
549 next_cursor: None,
550 callers: depth1_callers,
551 test_callers: depth1_test_callers,
552 callees: depth1_callees,
553 prod_chains,
554 test_chains,
555 outgoing_chains,
556 def_count,
557 unfiltered_caller_count,
558 impl_trait_caller_count,
559 def_use_sites,
560 cache_tier: None,
561 })
562}
563
564fn collect_def_use_sites(
567 entries: &[WalkEntry],
568 symbol: &str,
569 ast_recursion_limit: Option<usize>,
570 root: &std::path::Path,
571 ct: &CancellationToken,
572) -> Vec<crate::types::DefUseSite> {
573 use crate::parser::SemanticExtractor;
574
575 let file_entries: Vec<&WalkEntry> = entries
576 .iter()
577 .filter(|e| !e.is_dir && !e.is_symlink)
578 .collect();
579
580 let mut sites: Vec<crate::types::DefUseSite> = file_entries
581 .par_iter()
582 .filter_map(|entry| {
583 if ct.is_cancelled() {
584 return None;
585 }
586
587 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
589 tracing::debug!("skipping large file: {}", entry.path.display());
590 return None;
591 }
592
593 let Ok(source) = std::fs::read_to_string(&entry.path) else {
594 return None;
595 };
596 let ext = entry
597 .path
598 .extension()
599 .and_then(|e| e.to_str())
600 .unwrap_or("");
601 let lang = crate::lang::language_for_extension(ext)?;
602 let file_path = entry
603 .path
604 .strip_prefix(root)
605 .unwrap_or(&entry.path)
606 .display()
607 .to_string();
608 let sites = SemanticExtractor::extract_def_use_for_file(
609 &source,
610 lang,
611 symbol,
612 &file_path,
613 ast_recursion_limit,
614 );
615 if sites.is_empty() { None } else { Some(sites) }
616 })
617 .flatten()
618 .collect();
619
620 sites.sort_by(|a, b| {
622 use crate::types::DefUseKind;
623 let kind_ord = |k: &DefUseKind| match k {
624 DefUseKind::Write | DefUseKind::WriteRead => 0,
625 DefUseKind::Read => 1,
626 };
627 kind_ord(&a.kind)
628 .cmp(&kind_ord(&b.kind))
629 .then_with(|| a.file.cmp(&b.file))
630 .then_with(|| a.line.cmp(&b.line))
631 .then_with(|| a.column.cmp(&b.column))
632 });
633
634 sites
635}
636
637pub fn analyze_focused_with_progress_with_entries(
639 root: &Path,
640 params: &FocusedAnalysisConfig,
641 progress: &Arc<AtomicUsize>,
642 ct: &CancellationToken,
643 entries: &[WalkEntry],
644) -> Result<FocusedAnalysisOutput, AnalyzeError> {
645 let internal_params = InternalFocusedParams {
646 focus: params.focus.clone(),
647 match_mode: params.match_mode.clone(),
648 follow_depth: params.follow_depth,
649 ast_recursion_limit: params.ast_recursion_limit,
650 use_summary: params.use_summary,
651 impl_only: params.impl_only,
652 def_use: params.def_use,
653 parse_timeout_micros: params.parse_timeout_micros,
654 };
655 analyze_focused_with_progress_with_entries_internal(
656 root,
657 params.max_depth,
658 progress,
659 ct,
660 &internal_params,
661 entries,
662 )
663}
664
665#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
666pub fn analyze_focused(
667 root: &Path,
668 focus: &str,
669 follow_depth: u32,
670 max_depth: Option<u32>,
671 ast_recursion_limit: Option<usize>,
672) -> Result<FocusedAnalysisOutput, AnalyzeError> {
673 let entries = walk_directory(root, max_depth)?;
674 let counter = Arc::new(AtomicUsize::new(0));
675 let ct = CancellationToken::new();
676 let params = FocusedAnalysisConfig {
677 focus: focus.to_string(),
678 match_mode: SymbolMatchMode::Exact,
679 follow_depth,
680 max_depth,
681 ast_recursion_limit,
682 use_summary: false,
683 impl_only: None,
684 def_use: false,
685 parse_timeout_micros: None,
686 };
687 analyze_focused_with_progress_with_entries(root, ¶ms, &counter, &ct, &entries)
688}
689
690#[instrument(skip_all, fields(path))]
693pub fn analyze_module_file(path: &str) -> Result<crate::types::ModuleInfo, AnalyzeError> {
694 if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
696 tracing::debug!("skipping large file: {}", path);
697 return Err(AnalyzeError::Parser(
698 crate::parser::ParserError::ParseError("file too large".to_string()),
699 ));
700 }
701
702 let source = std::fs::read_to_string(path)
703 .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
704
705 let file_path = Path::new(path);
706 let name = file_path
707 .file_name()
708 .and_then(|s| s.to_str())
709 .unwrap_or("unknown")
710 .to_string();
711
712 let line_count = source.lines().count();
713
714 let language = file_path
715 .extension()
716 .and_then(|e| e.to_str())
717 .and_then(language_for_extension)
718 .ok_or_else(|| {
719 AnalyzeError::Parser(crate::parser::ParserError::UnsupportedLanguage(
720 file_path
721 .extension()
722 .and_then(|e| e.to_str())
723 .unwrap_or("(no extension)")
724 .to_string(),
725 ))
726 })?;
727
728 let mut module_info = SemanticExtractor::extract_module_info(&source, language, None)?;
729 module_info.name = name;
730 module_info.line_count = line_count;
731
732 Ok(module_info)
733}
734
735pub fn analyze_import_lookup(
741 root: &Path,
742 module: &str,
743 entries: &[WalkEntry],
744 ast_recursion_limit: Option<usize>,
745) -> Result<FocusedAnalysisOutput, AnalyzeError> {
746 let matches: Vec<(PathBuf, usize)> = entries
747 .par_iter()
748 .filter_map(|entry| {
749 if entry.is_dir || entry.is_symlink {
750 tracing::debug!("skipping symlink: {}", entry.path.display());
751 return None;
752 }
753 let ext = entry
754 .path
755 .extension()
756 .and_then(|e| e.to_str())
757 .and_then(crate::lang::language_for_extension)?;
758 let source = std::fs::read_to_string(&entry.path).ok()?;
759 let semantic =
760 SemanticExtractor::extract(&source, ext, ast_recursion_limit, None).ok()?;
761 for import in &semantic.imports {
762 if import.module == module || import.items.iter().any(|item| item == module) {
763 return Some((entry.path.clone(), import.line));
764 }
765 }
766 None
767 })
768 .collect();
769
770 let mut text = format!("IMPORT_LOOKUP: {module}\n");
771 text.push_str(&format!("ROOT: {}\n", root.display()));
772 text.push_str(&format!("MATCHES: {}\n", matches.len()));
773 for (path, line) in &matches {
774 let rel = path.strip_prefix(root).unwrap_or(path);
775 text.push_str(&format!(" {}:{line}\n", rel.display()));
776 }
777
778 Ok(FocusedAnalysisOutput {
779 formatted: text,
780 next_cursor: None,
781 prod_chains: vec![],
782 test_chains: vec![],
783 outgoing_chains: vec![],
784 def_count: 0,
785 unfiltered_caller_count: 0,
786 impl_trait_caller_count: 0,
787 callers: None,
788 test_callers: None,
789 callees: None,
790 def_use_sites: vec![],
791 cache_tier: None,
792 })
793}
794
795pub(crate) fn resolve_wildcard_imports(file_path: &Path, imports: &mut [ImportInfo]) {
805 use std::collections::HashMap;
806
807 let mut resolved_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
808 let Ok(file_path_canonical) = file_path.canonicalize() else {
809 tracing::debug!(file = ?file_path, "unable to canonicalize current file path");
810 return;
811 };
812
813 for import in imports.iter_mut() {
814 if import.items != ["*"] {
815 continue;
816 }
817 resolve_single_wildcard(import, file_path, &file_path_canonical, &mut resolved_cache);
818 }
819}
820
821fn validate_wildcard_target(
824 target_to_read: &Path,
825 file_path_canonical: &Path,
826 module: &str,
827) -> Option<PathBuf> {
828 let Ok(canonical) = target_to_read.canonicalize() else {
829 tracing::debug!(target = ?target_to_read, import = %module, "unable to canonicalize path");
830 return None;
831 };
832
833 if canonical == file_path_canonical {
834 tracing::debug!(target = ?canonical, import = %module, "cannot import from self");
835 return None;
836 }
837
838 Some(canonical)
839}
840
841fn resolve_single_wildcard(
843 import: &mut ImportInfo,
844 file_path: &Path,
845 file_path_canonical: &Path,
846 resolved_cache: &mut std::collections::HashMap<PathBuf, Vec<String>>,
847) {
848 let module = import.module.clone();
849 let dot_count = module.chars().take_while(|c| *c == '.').count();
850 if dot_count == 0 {
851 return;
852 }
853 let module_path = module.trim_start_matches('.');
854
855 let Some(target_to_read) = locate_target_file(file_path, dot_count, module_path, &module)
856 else {
857 return;
858 };
859
860 let Some(canonical) = validate_wildcard_target(&target_to_read, file_path_canonical, &module)
861 else {
862 return;
863 };
864
865 if let Some(cached) = resolved_cache.get(&canonical) {
866 tracing::debug!(import = %module, symbols_count = cached.len(), "using cached symbols");
867 import.items.clone_from(cached);
868 return;
869 }
870
871 if let Some(symbols) = parse_target_symbols(&target_to_read, &module) {
872 tracing::debug!(import = %module, resolved_count = symbols.len(), "wildcard import resolved");
873 import.items.clone_from(&symbols);
874 resolved_cache.insert(canonical, symbols);
875 }
876}
877
878fn locate_target_file(
880 file_path: &Path,
881 dot_count: usize,
882 module_path: &str,
883 module: &str,
884) -> Option<PathBuf> {
885 let mut target_dir = file_path.parent()?.to_path_buf();
886
887 for _ in 1..dot_count {
888 if !target_dir.pop() {
889 tracing::debug!(import = %module, "unable to climb {} levels", dot_count.saturating_sub(1));
890 return None;
891 }
892 }
893
894 let target_file = if module_path.is_empty() {
895 target_dir.join("__init__.py")
896 } else {
897 let rel_path = module_path.replace('.', "/");
898 target_dir.join(format!("{rel_path}.py"))
899 };
900
901 if target_file.exists() {
902 Some(target_file)
903 } else if target_file.with_extension("").is_dir() {
904 let init = target_file.with_extension("").join("__init__.py");
905 if init.exists() { Some(init) } else { None }
906 } else {
907 tracing::debug!(target = ?target_file, import = %module, "target file not found");
908 None
909 }
910}
911
912fn build_parser_for_file(source: &str) -> Option<tree_sitter::Tree> {
914 use tree_sitter::Parser;
915
916 let lang_info = crate::languages::get_language_info("python")?;
917 let mut parser = Parser::new();
918 if parser.set_language(&lang_info.language).is_err() {
919 return None;
920 }
921 parser.parse(source, None)
922}
923
924fn extract_all_symbols(tree: &tree_sitter::Tree, source: &str) -> Vec<String> {
926 let mut symbols = Vec::new();
927 let root = tree.root_node();
928 let mut cursor = root.walk();
929 for child in root.children(&mut cursor) {
930 if matches!(child.kind(), "function_definition" | "class_definition")
931 && let Some(name_node) = child.child_by_field_name("name")
932 {
933 let name = source[name_node.start_byte()..name_node.end_byte()].to_string();
934 if !name.starts_with('_') {
935 symbols.push(name);
936 }
937 }
938 }
939 symbols
940}
941
942fn resolve_symbols_from_tree(tree: &tree_sitter::Tree, source: &str, module: &str) -> Vec<String> {
944 let mut symbols = Vec::new();
945 extract_all_from_tree(tree, source, &mut symbols);
946 if !symbols.is_empty() {
947 tracing::debug!(import = %module, symbols = ?symbols, "using __all__ symbols");
948 return symbols;
949 }
950
951 let symbols = extract_all_symbols(tree, source);
953 tracing::debug!(import = %module, fallback_symbols = ?symbols, "using fallback function/class names");
954 symbols
955}
956
957fn parse_target_symbols(target_path: &Path, module: &str) -> Option<Vec<String>> {
959 if target_path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
961 tracing::debug!("skipping large file: {}", target_path.display());
962 return None;
963 }
964
965 let source = match std::fs::read_to_string(target_path) {
966 Ok(s) => s,
967 Err(e) => {
968 tracing::debug!(target = ?target_path, import = %module, error = %e, "unable to read target file");
969 return None;
970 }
971 };
972
973 let tree = build_parser_for_file(&source)?;
975
976 let symbols = resolve_symbols_from_tree(&tree, &source, module);
978 Some(symbols)
979}
980
981fn extract_all_from_tree(tree: &tree_sitter::Tree, source: &str, result: &mut Vec<String>) {
983 let root = tree.root_node();
984 let mut cursor = root.walk();
985 for child in root.children(&mut cursor) {
986 if child.kind() == "simple_statement" {
987 let mut simple_cursor = child.walk();
989 for simple_child in child.children(&mut simple_cursor) {
990 if simple_child.kind() == "assignment"
991 && let Some(left) = simple_child.child_by_field_name("left")
992 {
993 let target_text = source[left.start_byte()..left.end_byte()].trim();
994 if target_text == "__all__"
995 && let Some(right) = simple_child.child_by_field_name("right")
996 {
997 extract_string_list_from_list_node(&right, source, result);
998 }
999 }
1000 }
1001 } else if child.kind() == "expression_statement" {
1002 let mut stmt_cursor = child.walk();
1004 for stmt_child in child.children(&mut stmt_cursor) {
1005 if stmt_child.kind() == "assignment"
1006 && let Some(left) = stmt_child.child_by_field_name("left")
1007 {
1008 let target_text = source[left.start_byte()..left.end_byte()].trim();
1009 if target_text == "__all__"
1010 && let Some(right) = stmt_child.child_by_field_name("right")
1011 {
1012 extract_string_list_from_list_node(&right, source, result);
1013 }
1014 }
1015 }
1016 }
1017 }
1018}
1019
1020fn extract_string_list_from_list_node(
1022 list_node: &tree_sitter::Node,
1023 source: &str,
1024 result: &mut Vec<String>,
1025) {
1026 let mut cursor = list_node.walk();
1027 for child in list_node.named_children(&mut cursor) {
1028 if child.kind() == "string" {
1029 let raw = source[child.start_byte()..child.end_byte()].trim();
1030 let unquoted = raw.trim_matches('"').trim_matches('\'').to_string();
1032 if !unquoted.is_empty() {
1033 result.push(unquoted);
1034 }
1035 }
1036 }
1037}