Skip to main content

aptu_coder_core/
analyze_focused.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Focused analysis: call-graph traversal, import lookup, and wildcard resolution.
4
5use 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/// Internal parameters for focused analysis phases.
23#[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
35/// Type alias for analysis results: (`file_path`, `semantic_analysis`) pairs and impl-trait info.
36type FileAnalysisBatch = (Vec<(PathBuf, SemanticAnalysis)>, Vec<ImplTraitInfo>);
37
38/// Phase 1: Collect semantic analysis for all files in parallel.
39fn 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    // Check if already cancelled
47    if ct.is_cancelled() {
48        return Err(AnalyzeError::Cancelled);
49    }
50
51    // Use pre-walked entries (passed by caller)
52    // Collect semantic analysis for all files in parallel
53    let file_entries: Vec<&WalkEntry> = entries
54        .iter()
55        .filter(|e| !e.is_dir && !e.is_symlink)
56        .collect();
57
58    // Collect per-file timeout events so they can be surfaced as AnalyzeError::ParseTimeout.
59    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            // Check cancellation per file
65            if ct.is_cancelled() {
66                return None;
67            }
68
69            let ext = entry.path.extension().and_then(|e| e.to_str());
70
71            // Check file size before reading
72            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            // Try to read file content
79            let Ok(source) = std::fs::read_to_string(&entry.path) else {
80                progress.fetch_add(1, Ordering::Relaxed);
81                return None;
82            };
83
84            // Detect language and extract semantic information
85            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                    // Populate file path on references
100                    for r in &mut semantic.references {
101                        r.location = entry.path.display().to_string();
102                    }
103                    // Populate file path on impl_traits (already extracted during SemanticExtractor::extract)
104                    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    // Check if cancelled after parallel processing
131    if ct.is_cancelled() {
132        return Err(AnalyzeError::Cancelled);
133    }
134
135    // Surface the first timeout as AnalyzeError::ParseTimeout so callers can detect it.
136    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    // Collect all impl-trait info from analysis results
143    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
151/// Phase 2: Build call graph from analysis results.
152fn build_call_graph(
153    analysis_results: Vec<(PathBuf, SemanticAnalysis)>,
154    all_impl_traits: &[ImplTraitInfo],
155) -> Result<CallGraph, AnalyzeError> {
156    // Build call graph. Always build without impl_only filter first so we can
157    // record the unfiltered caller count before discarding those edges.
158    CallGraph::build_from_results(
159        analysis_results,
160        all_impl_traits,
161        false, // filter applied below after counting
162    )
163    .map_err(std::convert::Into::into)
164}
165
166/// Phase 3: Resolve symbol and apply `impl_only` filter.
167/// Returns (`resolved_focus`, `unfiltered_caller_count`, `impl_trait_caller_count`).
168/// CRITICAL: Must capture `unfiltered_caller_count` BEFORE `retain()`, then apply `retain()`,
169/// then compute `impl_trait_caller_count`.
170fn resolve_symbol(
171    graph: &mut CallGraph,
172    params: &InternalFocusedParams,
173) -> Result<(String, usize, usize), AnalyzeError> {
174    // Resolve symbol name using the requested match mode.
175    let resolved_focus = if params.match_mode == SymbolMatchMode::Exact {
176        let exists = graph.definitions.contains_key(&params.focus)
177            || graph.callers.contains_key(&params.focus)
178            || graph.callees.contains_key(&params.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(&params.focus, &params.match_mode)?
190    };
191
192    // Count unique callers for the focus symbol before applying impl_only filter.
193    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    // Apply impl_only filter now if requested, then count filtered callers.
202    // Filter all caller adjacency lists so traversal and formatting are consistently
203    // restricted to impl-trait edges regardless of follow_depth.
204    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
226/// Type alias for `compute_chains` return type: (`formatted_output`, `prod_chains`, `test_chains`, `outgoing_chains`, `def_count`).
227type ChainComputeResult = (
228    String,
229    Vec<InternalCallChain>,
230    Vec<InternalCallChain>,
231    Vec<InternalCallChain>,
232    usize,
233);
234
235/// Helper function to convert InternalCallChain data to CallChainEntry vec.
236/// Takes the first (depth-1) element of each chain and converts it to a CallChainEntry.
237/// Returns None if chains is empty, otherwise returns a vec of up to 10 entries.
238pub(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
272/// Phase 4: Compute chains and format output.
273fn 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    // Compute chain data for pagination (always, regardless of summary mode)
283    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    // Format output with pre-computed chains
296    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    // Add FILTER header if impl_only filter was applied
319    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/// Analyze a symbol's call graph across a directory with progress tracking.
336// public API; callers expect owned semantics
337#[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/// Internal implementation of focused analysis using pre-walked entries and params struct.
366#[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    // Check if already cancelled
376    if ct.is_cancelled() {
377        return Err(AnalyzeError::Cancelled);
378    }
379
380    // Check if path is a file (hint to use directory)
381    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    // Phase 1: Collect file analysis
403    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    // Check for cancellation before building the call graph (phase 2)
412    if ct.is_cancelled() {
413        return Err(AnalyzeError::Cancelled);
414    }
415
416    // Phase 2: Build call graph
417    let mut graph = build_call_graph(analysis_results, &all_impl_traits)?;
418
419    // Check for cancellation before resolving the symbol (phase 3)
420    if ct.is_cancelled() {
421        return Err(AnalyzeError::Cancelled);
422    }
423
424    // Phase 3: Resolve symbol and apply impl_only filter.
425    // When def_use=true and the symbol is not in the call graph (e.g. a variable),
426    // fall through to def-use extraction instead of returning SymbolNotFound.
427    let resolve_result = resolve_symbol(&mut graph, params);
428    if let Err(AnalyzeError::Graph(crate::graph::GraphError::SymbolNotFound { .. })) =
429        &resolve_result
430    {
431        // Deliberately not collapsed: resolve_result must stay alive past this block
432        // so that the `?` below can propagate non-SymbolNotFound errors.
433        if params.def_use {
434            let def_use_sites =
435                collect_def_use_sites(entries, &params.focus, params.ast_recursion_limit, root, ct);
436            if def_use_sites.is_empty() {
437                // Symbol not found anywhere (neither in call graph nor as def/use site).
438                // Propagate the original SymbolNotFound error instead of returning an
439                // empty success response.
440                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    // Check for cancellation before computing chains (phase 4)
495    if ct.is_cancelled() {
496        return Err(AnalyzeError::Cancelled);
497    }
498
499    // Phase 5 (optional, before formatting): Def-use site extraction.
500    // Use params.focus (the raw user-supplied string) rather than resolved_focus
501    // so that variable/field names that are not in the call graph still work.
502    let def_use_sites = if params.def_use {
503        collect_def_use_sites(entries, &params.focus, params.ast_recursion_limit, root, ct)
504    } else {
505        Vec::new()
506    };
507
508    // Phase 4: Compute chains and format output (includes def_use_sites in one pass)
509    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    // Compute depth-1 chains for structured output fields (always direct relationships only,
520    // regardless of `follow_depth` used for the text-formatted output).
521    let (depth1_callers, depth1_test_callers, depth1_callees) = if params.follow_depth <= 1 {
522        // Chains already at depth 1; reuse the partitioned vecs.
523        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        // follow_depth > 1: re-query at depth 1 to get only direct edges.
529        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
564/// Phase 5: Extract def-use sites for `symbol` across all entries.
565/// Writes go before reads; within each kind ordered by file, line, then column.
566fn 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            // Check file size before reading
588            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    // Writes before reads; within each kind: file, line, then column for deterministic order
621    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
637/// Analyze a symbol's call graph using pre-walked directory entries.
638pub 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, &params, &counter, &ct, &entries)
688}
689
690/// Analyze a single file and return a minimal fixed schema (name, line count, language,
691/// functions, imports) for lightweight code understanding.
692#[instrument(skip_all, fields(path))]
693pub fn analyze_module_file(path: &str) -> Result<crate::types::ModuleInfo, AnalyzeError> {
694    // Check file size before reading
695    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
735/// Scan a directory for files that import a given module path.
736///
737/// For each non-directory walk entry, extracts imports via [`SemanticExtractor`] and
738/// checks whether `module` matches `ImportInfo.module` or appears in `ImportInfo.items`.
739/// Returns a [`FocusedAnalysisOutput`] whose `formatted` field lists matching files.
740pub 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
795/// Resolve Python wildcard imports to actual symbol names.
796///
797/// For each import with items=`["*"]`, this function:
798/// 1. Parses the relative dots (if any) and climbs the directory tree
799/// 2. Finds the target .py file or __init__.py
800/// 3. Extracts symbols (functions and classes) from the target
801/// 4. Honors __all__ if defined, otherwise uses function+class names
802///
803/// All resolution failures are non-fatal: debug-logged and the wildcard is preserved.
804pub(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
821/// Validate and canonicalize a wildcard target path, checking for self-references.
822/// Returns the canonical path if valid, or None if validation fails.
823fn 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
841/// Resolve one wildcard import in place. On any failure the import is left unchanged.
842fn 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
878/// Locate the .py file that a wildcard import refers to. Returns None if not found.
879fn 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
912/// Build a tree-sitter parser for Python and parse the source code.
913fn 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
924/// Extract all public symbols from a parsed tree (functions and classes).
925fn 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
942/// Try to resolve symbols from __all__ or fallback to function/class extraction.
943fn 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    // Fallback: extract functions/classes from the tree
952    let symbols = extract_all_symbols(tree, source);
953    tracing::debug!(import = %module, fallback_symbols = ?symbols, "using fallback function/class names");
954    symbols
955}
956
957/// Read and parse a target .py file, returning its exported symbols.
958fn parse_target_symbols(target_path: &Path, module: &str) -> Option<Vec<String>> {
959    // Check file size before reading
960    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    // Parse once with tree-sitter
974    let tree = build_parser_for_file(&source)?;
975
976    // Try to extract __all__ or fallback to function/class extraction
977    let symbols = resolve_symbols_from_tree(&tree, &source, module);
978    Some(symbols)
979}
980
981/// Extract __all__ from a tree-sitter tree.
982fn 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            // simple_statement contains assignment and other statement types
988            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            // Fallback for older Python AST structures
1003            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
1020/// Extract string literals from a Python list node.
1021fn 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            // Strip quotes: "name" -> name
1031            let unquoted = raw.trim_matches('"').trim_matches('\'').to_string();
1032            if !unquoted.is_empty() {
1033                result.push(unquoted);
1034            }
1035        }
1036    }
1037}