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