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