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, FileAnalysisOutput, FocusedAnalysisConfig, FocusedAnalysisOutput,
7    MAX_FILE_SIZE_BYTES,
8};
9use crate::formatter::{format_focused_internal, format_focused_summary_internal};
10use crate::graph::store::GraphDiskStore;
11use crate::graph::structural::StructuralGraph;
12use crate::graph::{CallGraph, InternalCallChain};
13use crate::lang::language_for_extension;
14use crate::parser::SemanticExtractor;
15use crate::test_detection::is_test_file;
16use crate::traversal::{WalkEntry, walk_directory};
17use crate::types::{ImplTraitInfo, ImportInfo, SemanticAnalysis, SymbolMatchMode};
18use rayon::prelude::*;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::sync::atomic::{AtomicUsize, Ordering};
22use std::time::SystemTime;
23use tokio_util::sync::CancellationToken;
24use tracing::instrument;
25
26/// Internal parameters for focused analysis phases.
27#[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
39/// Type alias for analysis results: (`file_path`, `semantic_analysis`) pairs and impl-trait info.
40type FileAnalysisBatch = (Vec<(PathBuf, SemanticAnalysis)>, Vec<ImplTraitInfo>);
41
42/// Phase 1: Collect semantic analysis for all files in parallel.
43fn collect_file_analysis(
44    entries: &[WalkEntry],
45    progress: &Arc<AtomicUsize>,
46    ct: &CancellationToken,
47    ast_recursion_limit: Option<usize>,
48    parse_timeout_micros: Option<u64>,
49) -> Result<FileAnalysisBatch, AnalyzeError> {
50    // Check if already cancelled
51    if ct.is_cancelled() {
52        return Err(AnalyzeError::Cancelled);
53    }
54
55    // Use pre-walked entries (passed by caller)
56    // Collect semantic analysis for all files in parallel
57    let file_entries: Vec<&WalkEntry> = entries
58        .iter()
59        .filter(|e| !e.is_dir && !e.is_symlink)
60        .collect();
61
62    // Collect per-file timeout events so they can be surfaced as AnalyzeError::ParseTimeout.
63    let timed_out: std::sync::Mutex<Vec<(PathBuf, u64)>> = std::sync::Mutex::new(Vec::new());
64
65    let analysis_results: Vec<(PathBuf, SemanticAnalysis)> = file_entries
66        .par_iter()
67        .filter_map(|entry| {
68            // Check cancellation per file
69            if ct.is_cancelled() {
70                return None;
71            }
72
73            let ext = entry.path.extension().and_then(|e| e.to_str());
74
75            // Check file size before reading
76            if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
77                tracing::debug!("skipping large file: {}", entry.path.display());
78                progress.fetch_add(1, Ordering::Relaxed);
79                return None;
80            }
81
82            // Try to read file content
83            let Ok(source) = std::fs::read_to_string(&entry.path) else {
84                progress.fetch_add(1, Ordering::Relaxed);
85                return None;
86            };
87
88            // Detect language and extract semantic information
89            let language = if let Some(ext_str) = ext {
90                language_for_extension(ext_str)
91                    .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string)
92            } else {
93                "unknown".to_string()
94            };
95
96            match SemanticExtractor::extract(
97                &source,
98                &language,
99                ast_recursion_limit,
100                parse_timeout_micros,
101            ) {
102                Ok(mut semantic) => {
103                    // Populate file path on references
104                    for r in &mut semantic.references {
105                        r.location = entry.path.display().to_string();
106                    }
107                    // Populate file path on impl_traits (already extracted during SemanticExtractor::extract)
108                    for trait_info in &mut semantic.impl_traits {
109                        trait_info.path.clone_from(&entry.path);
110                    }
111                    progress.fetch_add(1, Ordering::Relaxed);
112                    Some((entry.path.clone(), semantic))
113                }
114                Err(crate::parser::ParserError::Timeout(micros)) => {
115                    tracing::warn!(
116                        "parse timeout exceeded for {}: {} microseconds",
117                        entry.path.display(),
118                        micros
119                    );
120                    if let Ok(mut v) = timed_out.lock() {
121                        v.push((entry.path.clone(), micros));
122                    }
123                    progress.fetch_add(1, Ordering::Relaxed);
124                    None
125                }
126                Err(_) => {
127                    progress.fetch_add(1, Ordering::Relaxed);
128                    None
129                }
130            }
131        })
132        .collect();
133
134    // Check if cancelled after parallel processing
135    if ct.is_cancelled() {
136        return Err(AnalyzeError::Cancelled);
137    }
138
139    // Surface the first timeout as AnalyzeError::ParseTimeout so callers can detect it.
140    if let Ok(mut v) = timed_out.lock()
141        && let Some((path, micros)) = v.drain(..).next()
142    {
143        return Err(AnalyzeError::ParseTimeout { path, micros });
144    }
145
146    // Collect all impl-trait info from analysis results
147    let all_impl_traits: Vec<ImplTraitInfo> = analysis_results
148        .iter()
149        .flat_map(|(_, sem)| sem.impl_traits.iter().cloned())
150        .collect();
151
152    Ok((analysis_results, all_impl_traits))
153}
154
155/// Phase 2: Build call graph from analysis results.
156fn build_call_graph(
157    analysis_results: Vec<(PathBuf, SemanticAnalysis)>,
158    all_impl_traits: &[ImplTraitInfo],
159) -> Result<CallGraph, AnalyzeError> {
160    // Build call graph. Always build without impl_only filter first so we can
161    // record the unfiltered caller count before discarding those edges.
162    CallGraph::build_from_results(
163        analysis_results,
164        all_impl_traits,
165        false, // filter applied below after counting
166    )
167    .map_err(std::convert::Into::into)
168}
169
170/// Cache key from file mtimes. Returns None when no mtime data is available.
171fn compute_cache_key(root: &Path, entries: &[WalkEntry]) -> Option<String> {
172    let mut mtimes = Vec::new();
173    for e in entries {
174        if !e.is_dir && !e.is_symlink {
175            let m = e
176                .mtime?
177                .duration_since(SystemTime::UNIX_EPOCH)
178                .ok()?
179                .as_millis() as u64;
180            mtimes.push((e.path.clone(), m));
181        }
182    }
183    Some(GraphDiskStore::cache_key(root, &mtimes))
184}
185
186/// Create a GraphDiskStore from env var or XDG data home default.
187fn create_graph_store() -> GraphDiskStore {
188    let data_home = std::env::var_os("XDG_DATA_HOME")
189        .map(PathBuf::from)
190        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
191        .unwrap_or_default();
192    let base = std::env::var("APTU_CODER_DISK_CACHE_DIR")
193        .map(PathBuf::from)
194        .unwrap_or_else(|_| data_home.join("aptu-coder").join("analysis-cache"));
195    GraphDiskStore::new(base)
196}
197
198/// Phase 3: Resolve symbol and apply `impl_only` filter.
199/// Returns (`resolved_focus`, `unfiltered_caller_count`, `impl_trait_caller_count`).
200/// CRITICAL: Must capture `unfiltered_caller_count` BEFORE `retain()`, then apply `retain()`,
201/// then compute `impl_trait_caller_count`.
202fn resolve_symbol(
203    graph: &mut CallGraph,
204    params: &InternalFocusedParams,
205) -> Result<(String, usize, usize), AnalyzeError> {
206    // Resolve symbol name using the requested match mode.
207    let resolved_focus = if params.match_mode == SymbolMatchMode::Exact {
208        let exists = graph.definitions.contains_key(&params.focus)
209            || graph.callers.contains_key(&params.focus)
210            || graph.callees.contains_key(&params.focus);
211        if exists {
212            params.focus.clone()
213        } else {
214            return Err(crate::graph::GraphError::SymbolNotFound {
215                symbol: params.focus.clone(),
216                hint: "Try match_mode=insensitive for a case-insensitive search, or match_mode=prefix to list symbols starting with this name.".to_string(),
217            }
218            .into());
219        }
220    } else {
221        graph.resolve_symbol_indexed(&params.focus, &params.match_mode)?
222    };
223
224    // Count unique callers for the focus symbol before applying impl_only filter.
225    let unfiltered_caller_count = graph.callers.get(&resolved_focus).map_or(0, |edges| {
226        edges
227            .iter()
228            .map(|e| &e.neighbor_name)
229            .collect::<std::collections::HashSet<_>>()
230            .len()
231    });
232
233    // Apply impl_only filter now if requested, then count filtered callers.
234    // Filter all caller adjacency lists so traversal and formatting are consistently
235    // restricted to impl-trait edges regardless of follow_depth.
236    let impl_trait_caller_count = if params.impl_only.unwrap_or(false) {
237        for edges in graph.callers.values_mut() {
238            edges.retain(|e| e.is_impl_trait);
239        }
240        graph.callers.get(&resolved_focus).map_or(0, |edges| {
241            edges
242                .iter()
243                .map(|e| &e.neighbor_name)
244                .collect::<std::collections::HashSet<_>>()
245                .len()
246        })
247    } else {
248        unfiltered_caller_count
249    };
250
251    Ok((
252        resolved_focus,
253        unfiltered_caller_count,
254        impl_trait_caller_count,
255    ))
256}
257
258/// Type alias for `compute_chains` return type: (`formatted_output`, `prod_chains`, `test_chains`, `outgoing_chains`, `def_count`).
259type ChainComputeResult = (
260    String,
261    Vec<InternalCallChain>,
262    Vec<InternalCallChain>,
263    Vec<InternalCallChain>,
264    usize,
265);
266
267/// Helper function to convert InternalCallChain data to CallChainEntry vec.
268/// Takes the first (depth-1) element of each chain and converts it to a CallChainEntry.
269/// Returns None if chains is empty, otherwise returns a vec of up to 10 entries.
270pub(crate) fn chains_to_entries(
271    chains: &[InternalCallChain],
272    root: Option<&std::path::Path>,
273) -> Option<Vec<CallChainEntry>> {
274    if chains.is_empty() {
275        return None;
276    }
277    let entries: Vec<CallChainEntry> = chains
278        .iter()
279        .take(10)
280        .filter_map(|chain| {
281            let (symbol, path, line) = chain.chain.first()?;
282            let file = match root {
283                Some(root) => path
284                    .strip_prefix(root)
285                    .unwrap_or(path.as_path())
286                    .to_string_lossy()
287                    .into_owned(),
288                None => path.to_string_lossy().into_owned(),
289            };
290            Some(CallChainEntry {
291                symbol: symbol.clone(),
292                file,
293                line: *line,
294            })
295        })
296        .collect();
297    if entries.is_empty() {
298        None
299    } else {
300        Some(entries)
301    }
302}
303
304/// Phase 4: Compute chains and format output.
305fn compute_chains(
306    graph: &CallGraph,
307    resolved_focus: &str,
308    root: &Path,
309    params: &InternalFocusedParams,
310    unfiltered_caller_count: usize,
311    impl_trait_caller_count: usize,
312    def_use_sites: &[crate::types::DefUseSite],
313) -> Result<ChainComputeResult, AnalyzeError> {
314    // Compute chain data for pagination (always, regardless of summary mode)
315    let def_count = graph.definitions.get(resolved_focus).map_or(0, Vec::len);
316    let incoming_chains = graph.find_incoming_chains(resolved_focus, params.follow_depth)?;
317    let outgoing_chains = graph.find_outgoing_chains(resolved_focus, params.follow_depth)?;
318
319    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
320        incoming_chains.iter().cloned().partition(|chain| {
321            chain
322                .chain
323                .first()
324                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
325        });
326
327    // Format output with pre-computed chains
328    let mut formatted = if params.use_summary {
329        format_focused_summary_internal(
330            graph,
331            resolved_focus,
332            params.follow_depth,
333            Some(root),
334            Some(&incoming_chains),
335            Some(&outgoing_chains),
336            def_use_sites,
337        )?
338    } else {
339        format_focused_internal(
340            graph,
341            resolved_focus,
342            params.follow_depth,
343            Some(root),
344            Some(&incoming_chains),
345            Some(&outgoing_chains),
346            def_use_sites,
347        )?
348    };
349
350    // Add FILTER header if impl_only filter was applied
351    if params.impl_only.unwrap_or(false) {
352        let filter_header = format!(
353            "FILTER: impl_only=true ({impl_trait_caller_count} of {unfiltered_caller_count} callers shown)\n",
354        );
355        formatted = format!("{filter_header}{formatted}");
356    }
357
358    Ok((
359        formatted,
360        prod_chains,
361        test_chains,
362        outgoing_chains,
363        def_count,
364    ))
365}
366
367/// Analyze a symbol's call graph across a directory with progress tracking.
368// public API; callers expect owned semantics
369#[allow(clippy::needless_pass_by_value)]
370pub fn analyze_focused_with_progress(
371    root: &Path,
372    params: &FocusedAnalysisConfig,
373    progress: Arc<AtomicUsize>,
374    ct: CancellationToken,
375) -> Result<FocusedAnalysisOutput, AnalyzeError> {
376    let entries = walk_directory(root, params.max_depth)?;
377    let internal_params = InternalFocusedParams {
378        focus: params.focus.clone(),
379        match_mode: params.match_mode.clone(),
380        follow_depth: params.follow_depth,
381        ast_recursion_limit: params.ast_recursion_limit,
382        use_summary: params.use_summary,
383        impl_only: params.impl_only,
384        def_use: params.def_use,
385        parse_timeout_micros: params.parse_timeout_micros,
386    };
387    analyze_focused_with_progress_with_entries_internal(
388        root,
389        params.max_depth,
390        &progress,
391        &ct,
392        &internal_params,
393        &entries,
394    )
395}
396
397/// Internal implementation of focused analysis using pre-walked entries and params struct.
398#[instrument(skip_all, fields(path = %root.display(), symbol = %params.focus))]
399fn analyze_focused_with_progress_with_entries_internal(
400    root: &Path,
401    _max_depth: Option<u32>,
402    progress: &Arc<AtomicUsize>,
403    ct: &CancellationToken,
404    params: &InternalFocusedParams,
405    entries: &[WalkEntry],
406) -> Result<FocusedAnalysisOutput, AnalyzeError> {
407    // Check if already cancelled
408    if ct.is_cancelled() {
409        return Err(AnalyzeError::Cancelled);
410    }
411
412    // Check if path is a file (hint to use directory)
413    if root.is_file() {
414        let formatted =
415            "Single-file focus not supported. Please provide a directory path for cross-file call graph analysis.\n"
416                .to_string();
417        return Ok(FocusedAnalysisOutput {
418            formatted,
419            next_cursor: None,
420            prod_chains: vec![],
421            test_chains: vec![],
422            outgoing_chains: vec![],
423            def_count: 0,
424            unfiltered_caller_count: 0,
425            impl_trait_caller_count: 0,
426            callers: None,
427            test_callers: None,
428            callees: None,
429            def_use_sites: vec![],
430            cache_tier: None,
431        });
432    }
433
434    // Phase 1: Collect file analysis
435    let (analysis_results, all_impl_traits) = collect_file_analysis(
436        entries,
437        progress,
438        ct,
439        params.ast_recursion_limit,
440        params.parse_timeout_micros,
441    )?;
442
443    // Compute cache key for structural graph store (best-effort, degrades silently)
444    let cache_key = compute_cache_key(root, entries);
445
446    // Check for cancellation before building the call graph (phase 2)
447    if ct.is_cancelled() {
448        return Err(AnalyzeError::Cancelled);
449    }
450
451    // Phase 2: Build call graph (clone analysis_results for structural graph storage)
452    let mut graph = build_call_graph(analysis_results.clone(), &all_impl_traits)?;
453
454    // Best-effort: warm-cache reload or cold-cache build+persist of structural graph.
455    // On warm hit: skip rebuild. On cold miss: build from analysis results and persist.
456    // I/O errors degrade silently; the focused call-graph result is unaffected.
457    if let Some(key) = &cache_key {
458        let store = create_graph_store();
459        if store.get(key).is_none() {
460            let sg_entries: Vec<FileAnalysisOutput> = analysis_results
461                .into_iter()
462                .map(|(p, s)| FileAnalysisOutput::new(p.to_string_lossy().into_owned(), s, 0, None))
463                .collect();
464            store.put(key, &StructuralGraph::build_from_analysis(&sg_entries));
465        } else {
466            tracing::debug!(key, "structural graph cache hit (warm)");
467        }
468    }
469
470    // Check for cancellation before resolving the symbol (phase 3)
471    if ct.is_cancelled() {
472        return Err(AnalyzeError::Cancelled);
473    }
474
475    // Phase 3: Resolve symbol and apply impl_only filter.
476    // When def_use=true and the symbol is not in the call graph (e.g. a variable),
477    // fall through to def-use extraction instead of returning SymbolNotFound.
478    let resolve_result = resolve_symbol(&mut graph, params);
479    if let Err(AnalyzeError::Graph(crate::graph::GraphError::SymbolNotFound { .. })) =
480        &resolve_result
481    {
482        // Deliberately not collapsed: resolve_result must stay alive past this block
483        // so that the `?` below can propagate non-SymbolNotFound errors.
484        if params.def_use {
485            let def_use_sites =
486                collect_def_use_sites(entries, &params.focus, params.ast_recursion_limit, root, ct);
487            if def_use_sites.is_empty() {
488                // Symbol not found anywhere (neither in call graph nor as def/use site).
489                // Propagate the original SymbolNotFound error instead of returning an
490                // empty success response.
491                if let Err(e) = resolve_result {
492                    return Err(e);
493                }
494                unreachable!("resolve_result is Ok only when symbol was found");
495            }
496            use std::fmt::Write as _;
497            let mut formatted = String::new();
498            let _ = writeln!(
499                formatted,
500                "FOCUS: {} (0 defs, 0 callers, 0 callees)",
501                params.focus
502            );
503            {
504                let writes = def_use_sites
505                    .iter()
506                    .filter(|s| {
507                        matches!(
508                            s.kind,
509                            crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
510                        )
511                    })
512                    .count();
513                let reads = def_use_sites
514                    .iter()
515                    .filter(|s| s.kind == crate::types::DefUseKind::Read)
516                    .count();
517                let _ = writeln!(
518                    formatted,
519                    "DEF-USE SITES  {}  ({} total: {} writes, {} reads)",
520                    params.focus,
521                    def_use_sites.len(),
522                    writes,
523                    reads
524                );
525            }
526            return Ok(FocusedAnalysisOutput {
527                formatted,
528                next_cursor: None,
529                callers: None,
530                test_callers: None,
531                callees: None,
532                prod_chains: vec![],
533                test_chains: vec![],
534                outgoing_chains: vec![],
535                def_count: 0,
536                unfiltered_caller_count: 0,
537                impl_trait_caller_count: 0,
538                def_use_sites,
539                cache_tier: None,
540            });
541        }
542    }
543    let (resolved_focus, unfiltered_caller_count, impl_trait_caller_count) = resolve_result?;
544
545    // Check for cancellation before computing chains (phase 4)
546    if ct.is_cancelled() {
547        return Err(AnalyzeError::Cancelled);
548    }
549
550    // Phase 5 (optional, before formatting): Def-use site extraction.
551    // Use params.focus (the raw user-supplied string) rather than resolved_focus
552    // so that variable/field names that are not in the call graph still work.
553    let def_use_sites = if params.def_use {
554        collect_def_use_sites(entries, &params.focus, params.ast_recursion_limit, root, ct)
555    } else {
556        Vec::new()
557    };
558
559    // Phase 4: Compute chains and format output (includes def_use_sites in one pass)
560    let (formatted, prod_chains, test_chains, outgoing_chains, def_count) = compute_chains(
561        &graph,
562        &resolved_focus,
563        root,
564        params,
565        unfiltered_caller_count,
566        impl_trait_caller_count,
567        &def_use_sites,
568    )?;
569
570    // Compute depth-1 chains for structured output fields (always direct relationships only,
571    // regardless of `follow_depth` used for the text-formatted output).
572    let (depth1_callers, depth1_test_callers, depth1_callees) = if params.follow_depth <= 1 {
573        // Chains already at depth 1; reuse the partitioned vecs.
574        let callers = chains_to_entries(&prod_chains, Some(root));
575        let test_callers = chains_to_entries(&test_chains, Some(root));
576        let callees = chains_to_entries(&outgoing_chains, Some(root));
577        (callers, test_callers, callees)
578    } else {
579        // follow_depth > 1: re-query at depth 1 to get only direct edges.
580        let incoming1 = graph
581            .find_incoming_chains(&resolved_focus, 1)
582            .unwrap_or_default();
583        let outgoing1 = graph
584            .find_outgoing_chains(&resolved_focus, 1)
585            .unwrap_or_default();
586        let (prod1, test1): (Vec<_>, Vec<_>) = incoming1.into_iter().partition(|chain| {
587            chain
588                .chain
589                .first()
590                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
591        });
592        let callers = chains_to_entries(&prod1, Some(root));
593        let test_callers = chains_to_entries(&test1, Some(root));
594        let callees = chains_to_entries(&outgoing1, Some(root));
595        (callers, test_callers, callees)
596    };
597
598    Ok(FocusedAnalysisOutput {
599        formatted,
600        next_cursor: None,
601        callers: depth1_callers,
602        test_callers: depth1_test_callers,
603        callees: depth1_callees,
604        prod_chains,
605        test_chains,
606        outgoing_chains,
607        def_count,
608        unfiltered_caller_count,
609        impl_trait_caller_count,
610        def_use_sites,
611        cache_tier: None,
612    })
613}
614
615/// Phase 5: Extract def-use sites for `symbol` across all entries.
616/// Writes go before reads; within each kind ordered by file, line, then column.
617fn collect_def_use_sites(
618    entries: &[WalkEntry],
619    symbol: &str,
620    ast_recursion_limit: Option<usize>,
621    root: &std::path::Path,
622    ct: &CancellationToken,
623) -> Vec<crate::types::DefUseSite> {
624    use crate::parser::SemanticExtractor;
625
626    let file_entries: Vec<&WalkEntry> = entries
627        .iter()
628        .filter(|e| !e.is_dir && !e.is_symlink)
629        .collect();
630
631    let mut sites: Vec<crate::types::DefUseSite> = file_entries
632        .par_iter()
633        .filter_map(|entry| {
634            if ct.is_cancelled() {
635                return None;
636            }
637
638            // Check file size before reading
639            if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
640                tracing::debug!("skipping large file: {}", entry.path.display());
641                return None;
642            }
643
644            let Ok(source) = std::fs::read_to_string(&entry.path) else {
645                return None;
646            };
647            let ext = entry
648                .path
649                .extension()
650                .and_then(|e| e.to_str())
651                .unwrap_or("");
652            let lang = crate::lang::language_for_extension(ext)?;
653            let file_path = entry
654                .path
655                .strip_prefix(root)
656                .unwrap_or(&entry.path)
657                .display()
658                .to_string();
659            let sites = SemanticExtractor::extract_def_use_for_file(
660                &source,
661                lang,
662                symbol,
663                &file_path,
664                ast_recursion_limit,
665            );
666            if sites.is_empty() { None } else { Some(sites) }
667        })
668        .flatten()
669        .collect();
670
671    // Writes before reads; within each kind: file, line, then column for deterministic order
672    sites.sort_by(|a, b| {
673        use crate::types::DefUseKind;
674        let kind_ord = |k: &DefUseKind| match k {
675            DefUseKind::Write | DefUseKind::WriteRead => 0,
676            DefUseKind::Read => 1,
677        };
678        kind_ord(&a.kind)
679            .cmp(&kind_ord(&b.kind))
680            .then_with(|| a.file.cmp(&b.file))
681            .then_with(|| a.line.cmp(&b.line))
682            .then_with(|| a.column.cmp(&b.column))
683    });
684
685    sites
686}
687
688/// Analyze a symbol's call graph using pre-walked directory entries.
689pub fn analyze_focused_with_progress_with_entries(
690    root: &Path,
691    params: &FocusedAnalysisConfig,
692    progress: &Arc<AtomicUsize>,
693    ct: &CancellationToken,
694    entries: &[WalkEntry],
695) -> Result<FocusedAnalysisOutput, AnalyzeError> {
696    let internal_params = InternalFocusedParams {
697        focus: params.focus.clone(),
698        match_mode: params.match_mode.clone(),
699        follow_depth: params.follow_depth,
700        ast_recursion_limit: params.ast_recursion_limit,
701        use_summary: params.use_summary,
702        impl_only: params.impl_only,
703        def_use: params.def_use,
704        parse_timeout_micros: params.parse_timeout_micros,
705    };
706    analyze_focused_with_progress_with_entries_internal(
707        root,
708        params.max_depth,
709        progress,
710        ct,
711        &internal_params,
712        entries,
713    )
714}
715
716#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
717pub fn analyze_focused(
718    root: &Path,
719    focus: &str,
720    follow_depth: u32,
721    max_depth: Option<u32>,
722    ast_recursion_limit: Option<usize>,
723) -> Result<FocusedAnalysisOutput, AnalyzeError> {
724    let entries = walk_directory(root, max_depth)?;
725    let counter = Arc::new(AtomicUsize::new(0));
726    let ct = CancellationToken::new();
727    let params = FocusedAnalysisConfig {
728        focus: focus.to_string(),
729        match_mode: SymbolMatchMode::Exact,
730        follow_depth,
731        max_depth,
732        ast_recursion_limit,
733        use_summary: false,
734        impl_only: None,
735        def_use: false,
736        parse_timeout_micros: None,
737    };
738    analyze_focused_with_progress_with_entries(root, &params, &counter, &ct, &entries)
739}
740
741/// Analyze a single file and return a minimal fixed schema (name, line count, language,
742/// functions, imports) for lightweight code understanding.
743#[instrument(skip_all, fields(path))]
744pub fn analyze_module_file(path: &str) -> Result<crate::types::ModuleInfo, AnalyzeError> {
745    // Check file size before reading
746    if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
747        tracing::debug!("skipping large file: {}", path);
748        return Err(AnalyzeError::Parser(
749            crate::parser::ParserError::ParseError("file too large".to_string()),
750        ));
751    }
752
753    let source = std::fs::read_to_string(path)
754        .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
755
756    let file_path = Path::new(path);
757    let name = file_path
758        .file_name()
759        .and_then(|s| s.to_str())
760        .unwrap_or("unknown")
761        .to_string();
762
763    let line_count = source.lines().count();
764
765    let language = file_path
766        .extension()
767        .and_then(|e| e.to_str())
768        .and_then(language_for_extension)
769        .ok_or_else(|| {
770            AnalyzeError::Parser(crate::parser::ParserError::UnsupportedLanguage(
771                file_path
772                    .extension()
773                    .and_then(|e| e.to_str())
774                    .unwrap_or("(no extension)")
775                    .to_string(),
776            ))
777        })?;
778
779    let mut module_info = SemanticExtractor::extract_module_info(&source, language, None)?;
780    module_info.name = name;
781    module_info.line_count = line_count;
782
783    Ok(module_info)
784}
785
786/// Scan a directory for files that import a given module path.
787///
788/// For each non-directory walk entry, extracts imports via [`SemanticExtractor`] and
789/// checks whether `module` matches `ImportInfo.module` or appears in `ImportInfo.items`.
790/// Returns a [`FocusedAnalysisOutput`] whose `formatted` field lists matching files.
791pub fn analyze_import_lookup(
792    root: &Path,
793    module: &str,
794    entries: &[WalkEntry],
795    ast_recursion_limit: Option<usize>,
796) -> Result<FocusedAnalysisOutput, AnalyzeError> {
797    let matches: Vec<(PathBuf, usize)> = entries
798        .par_iter()
799        .filter_map(|entry| {
800            if entry.is_dir || entry.is_symlink {
801                tracing::debug!("skipping symlink: {}", entry.path.display());
802                return None;
803            }
804            let ext = entry
805                .path
806                .extension()
807                .and_then(|e| e.to_str())
808                .and_then(crate::lang::language_for_extension)?;
809            let source = std::fs::read_to_string(&entry.path).ok()?;
810            let semantic =
811                SemanticExtractor::extract(&source, ext, ast_recursion_limit, None).ok()?;
812            for import in &semantic.imports {
813                if import.module == module || import.items.iter().any(|item| item == module) {
814                    return Some((entry.path.clone(), import.line));
815                }
816            }
817            None
818        })
819        .collect();
820
821    let mut text = format!("IMPORT_LOOKUP: {module}\n");
822    text.push_str(&format!("ROOT: {}\n", root.display()));
823    text.push_str(&format!("MATCHES: {}\n", matches.len()));
824    for (path, line) in &matches {
825        let rel = path.strip_prefix(root).unwrap_or(path);
826        text.push_str(&format!("  {}:{line}\n", rel.display()));
827    }
828
829    Ok(FocusedAnalysisOutput {
830        formatted: text,
831        next_cursor: None,
832        prod_chains: vec![],
833        test_chains: vec![],
834        outgoing_chains: vec![],
835        def_count: 0,
836        unfiltered_caller_count: 0,
837        impl_trait_caller_count: 0,
838        callers: None,
839        test_callers: None,
840        callees: None,
841        def_use_sites: vec![],
842        cache_tier: None,
843    })
844}
845
846/// Resolve Python wildcard imports to actual symbol names.
847///
848/// For each import with items=`["*"]`, this function:
849/// 1. Parses the relative dots (if any) and climbs the directory tree
850/// 2. Finds the target .py file or __init__.py
851/// 3. Extracts symbols (functions and classes) from the target
852/// 4. Honors __all__ if defined, otherwise uses function+class names
853///
854/// All resolution failures are non-fatal: debug-logged and the wildcard is preserved.
855pub(crate) fn resolve_wildcard_imports(file_path: &Path, imports: &mut [ImportInfo]) {
856    use std::collections::HashMap;
857
858    let mut resolved_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
859    let Ok(file_path_canonical) = file_path.canonicalize() else {
860        tracing::debug!(file = ?file_path, "unable to canonicalize current file path");
861        return;
862    };
863
864    for import in imports.iter_mut() {
865        if import.items != ["*"] {
866            continue;
867        }
868        resolve_single_wildcard(import, file_path, &file_path_canonical, &mut resolved_cache);
869    }
870}
871
872/// Validate and canonicalize a wildcard target path, checking for self-references.
873/// Returns the canonical path if valid, or None if validation fails.
874fn validate_wildcard_target(
875    target_to_read: &Path,
876    file_path_canonical: &Path,
877    module: &str,
878) -> Option<PathBuf> {
879    let Ok(canonical) = target_to_read.canonicalize() else {
880        tracing::debug!(target = ?target_to_read, import = %module, "unable to canonicalize path");
881        return None;
882    };
883
884    if canonical == file_path_canonical {
885        tracing::debug!(target = ?canonical, import = %module, "cannot import from self");
886        return None;
887    }
888
889    Some(canonical)
890}
891
892/// Resolve one wildcard import in place. On any failure the import is left unchanged.
893fn resolve_single_wildcard(
894    import: &mut ImportInfo,
895    file_path: &Path,
896    file_path_canonical: &Path,
897    resolved_cache: &mut std::collections::HashMap<PathBuf, Vec<String>>,
898) {
899    let module = import.module.clone();
900    let dot_count = module.chars().take_while(|c| *c == '.').count();
901    if dot_count == 0 {
902        return;
903    }
904    let module_path = module.trim_start_matches('.');
905
906    let Some(target_to_read) = locate_target_file(file_path, dot_count, module_path, &module)
907    else {
908        return;
909    };
910
911    let Some(canonical) = validate_wildcard_target(&target_to_read, file_path_canonical, &module)
912    else {
913        return;
914    };
915
916    if let Some(cached) = resolved_cache.get(&canonical) {
917        tracing::debug!(import = %module, symbols_count = cached.len(), "using cached symbols");
918        import.items.clone_from(cached);
919        return;
920    }
921
922    if let Some(symbols) = parse_target_symbols(&target_to_read, &module) {
923        tracing::debug!(import = %module, resolved_count = symbols.len(), "wildcard import resolved");
924        import.items.clone_from(&symbols);
925        resolved_cache.insert(canonical, symbols);
926    }
927}
928
929/// Locate the .py file that a wildcard import refers to. Returns None if not found.
930fn locate_target_file(
931    file_path: &Path,
932    dot_count: usize,
933    module_path: &str,
934    module: &str,
935) -> Option<PathBuf> {
936    let mut target_dir = file_path.parent()?.to_path_buf();
937
938    for _ in 1..dot_count {
939        if !target_dir.pop() {
940            tracing::debug!(import = %module, "unable to climb {} levels", dot_count.saturating_sub(1));
941            return None;
942        }
943    }
944
945    let target_file = if module_path.is_empty() {
946        target_dir.join("__init__.py")
947    } else {
948        let rel_path = module_path.replace('.', "/");
949        target_dir.join(format!("{rel_path}.py"))
950    };
951
952    if target_file.exists() {
953        Some(target_file)
954    } else if target_file.with_extension("").is_dir() {
955        let init = target_file.with_extension("").join("__init__.py");
956        if init.exists() { Some(init) } else { None }
957    } else {
958        tracing::debug!(target = ?target_file, import = %module, "target file not found");
959        None
960    }
961}
962
963/// Build a tree-sitter parser for Python and parse the source code.
964fn build_parser_for_file(source: &str) -> Option<tree_sitter::Tree> {
965    use tree_sitter::Parser;
966
967    let lang_info = crate::languages::get_language_info("python")?;
968    let mut parser = Parser::new();
969    if parser.set_language(&lang_info.language).is_err() {
970        return None;
971    }
972    parser.parse(source, None)
973}
974
975/// Extract all public symbols from a parsed tree (functions and classes).
976fn extract_all_symbols(tree: &tree_sitter::Tree, source: &str) -> Vec<String> {
977    let mut symbols = Vec::new();
978    let root = tree.root_node();
979    let mut cursor = root.walk();
980    for child in root.children(&mut cursor) {
981        if matches!(child.kind(), "function_definition" | "class_definition")
982            && let Some(name_node) = child.child_by_field_name("name")
983        {
984            let name = source[name_node.start_byte()..name_node.end_byte()].to_string();
985            if !name.starts_with('_') {
986                symbols.push(name);
987            }
988        }
989    }
990    symbols
991}
992
993/// Try to resolve symbols from __all__ or fallback to function/class extraction.
994fn resolve_symbols_from_tree(tree: &tree_sitter::Tree, source: &str, module: &str) -> Vec<String> {
995    let mut symbols = Vec::new();
996    extract_all_from_tree(tree, source, &mut symbols);
997    if !symbols.is_empty() {
998        tracing::debug!(import = %module, symbols = ?symbols, "using __all__ symbols");
999        return symbols;
1000    }
1001
1002    // Fallback: extract functions/classes from the tree
1003    let symbols = extract_all_symbols(tree, source);
1004    tracing::debug!(import = %module, fallback_symbols = ?symbols, "using fallback function/class names");
1005    symbols
1006}
1007
1008/// Read and parse a target .py file, returning its exported symbols.
1009fn parse_target_symbols(target_path: &Path, module: &str) -> Option<Vec<String>> {
1010    // Check file size before reading
1011    if target_path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1012        tracing::debug!("skipping large file: {}", target_path.display());
1013        return None;
1014    }
1015
1016    let source = match std::fs::read_to_string(target_path) {
1017        Ok(s) => s,
1018        Err(e) => {
1019            tracing::debug!(target = ?target_path, import = %module, error = %e, "unable to read target file");
1020            return None;
1021        }
1022    };
1023
1024    // Parse once with tree-sitter
1025    let tree = build_parser_for_file(&source)?;
1026
1027    // Try to extract __all__ or fallback to function/class extraction
1028    let symbols = resolve_symbols_from_tree(&tree, &source, module);
1029    Some(symbols)
1030}
1031
1032/// Extract __all__ from a tree-sitter tree.
1033fn extract_all_from_tree(tree: &tree_sitter::Tree, source: &str, result: &mut Vec<String>) {
1034    let root = tree.root_node();
1035    let mut cursor = root.walk();
1036    for child in root.children(&mut cursor) {
1037        if child.kind() == "simple_statement" {
1038            // simple_statement contains assignment and other statement types
1039            let mut simple_cursor = child.walk();
1040            for simple_child in child.children(&mut simple_cursor) {
1041                if simple_child.kind() == "assignment"
1042                    && let Some(left) = simple_child.child_by_field_name("left")
1043                {
1044                    let target_text = source[left.start_byte()..left.end_byte()].trim();
1045                    if target_text == "__all__"
1046                        && let Some(right) = simple_child.child_by_field_name("right")
1047                    {
1048                        extract_string_list_from_list_node(&right, source, result);
1049                    }
1050                }
1051            }
1052        } else if child.kind() == "expression_statement" {
1053            // Fallback for older Python AST structures
1054            let mut stmt_cursor = child.walk();
1055            for stmt_child in child.children(&mut stmt_cursor) {
1056                if stmt_child.kind() == "assignment"
1057                    && let Some(left) = stmt_child.child_by_field_name("left")
1058                {
1059                    let target_text = source[left.start_byte()..left.end_byte()].trim();
1060                    if target_text == "__all__"
1061                        && let Some(right) = stmt_child.child_by_field_name("right")
1062                    {
1063                        extract_string_list_from_list_node(&right, source, result);
1064                    }
1065                }
1066            }
1067        }
1068    }
1069}
1070
1071/// Extract string literals from a Python list node.
1072fn extract_string_list_from_list_node(
1073    list_node: &tree_sitter::Node,
1074    source: &str,
1075    result: &mut Vec<String>,
1076) {
1077    let mut cursor = list_node.walk();
1078    for child in list_node.named_children(&mut cursor) {
1079        if child.kind() == "string" {
1080            let raw = source[child.start_byte()..child.end_byte()].trim();
1081            // Strip quotes: "name" -> name
1082            let unquoted = raw.trim_matches('"').trim_matches('\'').to_string();
1083            if !unquoted.is_empty() {
1084                result.push(unquoted);
1085            }
1086        }
1087    }
1088}