Skip to main content

code_analyze_mcp/
analyze.rs

1//! Main analysis engine for extracting code structure from files and directories.
2//!
3//! Implements the three analysis modes: Overview (directory structure), FileDetails (semantic extraction),
4//! and SymbolFocus (call graph analysis). Handles parallel processing and cancellation.
5
6use crate::formatter::{
7    format_file_details, format_focused, format_focused_summary, format_structure,
8};
9use crate::graph::{CallChain, CallGraph, resolve_symbol};
10use crate::lang::language_from_extension;
11use crate::parser::{ElementExtractor, SemanticExtractor};
12use crate::test_detection::is_test_file;
13use crate::traversal::{WalkEntry, walk_directory};
14use crate::types::{AnalysisMode, FileInfo, ImportInfo, SemanticAnalysis, SymbolMatchMode};
15use rayon::prelude::*;
16use schemars::JsonSchema;
17use serde::Serialize;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicUsize, Ordering};
21use std::time::Instant;
22use thiserror::Error;
23use tokio_util::sync::CancellationToken;
24use tracing::instrument;
25
26#[derive(Debug, Error)]
27pub enum AnalyzeError {
28    #[error("Traversal error: {0}")]
29    Traversal(#[from] crate::traversal::TraversalError),
30    #[error("Parser error: {0}")]
31    Parser(#[from] crate::parser::ParserError),
32    #[error("Graph error: {0}")]
33    Graph(#[from] crate::graph::GraphError),
34    #[error("Formatter error: {0}")]
35    Formatter(#[from] crate::formatter::FormatterError),
36    #[error("Analysis cancelled")]
37    Cancelled,
38}
39
40/// Result of directory analysis containing both formatted output and file data.
41#[derive(Debug, Serialize, JsonSchema)]
42pub struct AnalysisOutput {
43    #[schemars(description = "Formatted text representation of the analysis")]
44    pub formatted: String,
45    #[schemars(description = "List of files analyzed in the directory")]
46    pub files: Vec<FileInfo>,
47    /// Walk entries used internally for summary generation; not serialized.
48    #[serde(skip)]
49    #[schemars(skip)]
50    pub entries: Vec<WalkEntry>,
51    /// Subtree file counts computed from an unbounded walk; used by format_summary; not serialized.
52    #[serde(skip)]
53    #[schemars(skip)]
54    pub subtree_counts: Option<Vec<(std::path::PathBuf, usize)>>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    #[schemars(
57        description = "Opaque cursor token for the next page of results (absent when no more results)"
58    )]
59    pub next_cursor: Option<String>,
60}
61
62/// Result of file-level semantic analysis.
63#[derive(Debug, Clone, Serialize, JsonSchema)]
64pub struct FileAnalysisOutput {
65    #[schemars(description = "Formatted text representation of the analysis")]
66    pub formatted: String,
67    #[schemars(description = "Semantic analysis data including functions, classes, and imports")]
68    pub semantic: SemanticAnalysis,
69    #[schemars(description = "Total line count of the analyzed file")]
70    #[schemars(schema_with = "crate::schema_helpers::integer_schema")]
71    pub line_count: usize,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    #[schemars(
74        description = "Opaque cursor token for the next page of results (absent when no more results)"
75    )]
76    pub next_cursor: Option<String>,
77}
78
79/// Analyze a directory structure with progress tracking.
80#[instrument(skip_all, fields(path = %root.display()))]
81pub fn analyze_directory_with_progress(
82    root: &Path,
83    entries: Vec<WalkEntry>,
84    progress: Arc<AtomicUsize>,
85    ct: CancellationToken,
86) -> Result<AnalysisOutput, AnalyzeError> {
87    // Check if already cancelled
88    if ct.is_cancelled() {
89        return Err(AnalyzeError::Cancelled);
90    }
91
92    // Detect language from file extension
93    let file_entries: Vec<&WalkEntry> = entries.iter().filter(|e| !e.is_dir).collect();
94
95    let start = Instant::now();
96    tracing::debug!(file_count = file_entries.len(), root = %root.display(), "analysis start");
97
98    // Parallel analysis of files
99    let analysis_results: Vec<FileInfo> = file_entries
100        .par_iter()
101        .filter_map(|entry| {
102            // Check cancellation per file
103            if ct.is_cancelled() {
104                return None;
105            }
106
107            let path_str = entry.path.display().to_string();
108
109            // Detect language from extension
110            let ext = entry.path.extension().and_then(|e| e.to_str());
111
112            // Try to read file content
113            let source = match std::fs::read_to_string(&entry.path) {
114                Ok(content) => content,
115                Err(_) => {
116                    // Binary file or unreadable - exclude from output
117                    progress.fetch_add(1, Ordering::Relaxed);
118                    return None;
119                }
120            };
121
122            // Count lines
123            let line_count = source.lines().count();
124
125            // Detect language and extract counts
126            let (language, function_count, class_count) = if let Some(ext_str) = ext {
127                if let Some(lang) = language_from_extension(ext_str) {
128                    let lang_str = lang.to_string();
129                    match ElementExtractor::extract_with_depth(&source, &lang_str) {
130                        Ok((func_count, class_count)) => (lang_str, func_count, class_count),
131                        Err(_) => (lang_str, 0, 0),
132                    }
133                } else {
134                    ("unknown".to_string(), 0, 0)
135                }
136            } else {
137                ("unknown".to_string(), 0, 0)
138            };
139
140            progress.fetch_add(1, Ordering::Relaxed);
141
142            let is_test = is_test_file(&entry.path);
143
144            Some(FileInfo {
145                path: path_str,
146                line_count,
147                function_count,
148                class_count,
149                language,
150                is_test,
151            })
152        })
153        .collect();
154
155    // Check if cancelled after parallel processing
156    if ct.is_cancelled() {
157        return Err(AnalyzeError::Cancelled);
158    }
159
160    tracing::debug!(
161        file_count = file_entries.len(),
162        duration_ms = start.elapsed().as_millis() as u64,
163        "analysis complete"
164    );
165
166    // Format output
167    let formatted = format_structure(&entries, &analysis_results, None, Some(root));
168
169    Ok(AnalysisOutput {
170        formatted,
171        files: analysis_results,
172        entries,
173        next_cursor: None,
174        subtree_counts: None,
175    })
176}
177
178/// Analyze a directory structure and return formatted output and file data.
179#[instrument(skip_all, fields(path = %root.display()))]
180pub fn analyze_directory(
181    root: &Path,
182    max_depth: Option<u32>,
183) -> Result<AnalysisOutput, AnalyzeError> {
184    let entries = walk_directory(root, max_depth)?;
185    let counter = Arc::new(AtomicUsize::new(0));
186    let ct = CancellationToken::new();
187    analyze_directory_with_progress(root, entries, counter, ct)
188}
189
190/// Determine analysis mode based on parameters and path.
191pub fn determine_mode(path: &str, focus: Option<&str>) -> AnalysisMode {
192    if focus.is_some() {
193        return AnalysisMode::SymbolFocus;
194    }
195
196    let path_obj = Path::new(path);
197    if path_obj.is_dir() {
198        AnalysisMode::Overview
199    } else {
200        AnalysisMode::FileDetails
201    }
202}
203
204/// Analyze a single file and return semantic analysis with formatted output.
205#[instrument(skip_all, fields(path))]
206pub fn analyze_file(
207    path: &str,
208    ast_recursion_limit: Option<usize>,
209) -> Result<FileAnalysisOutput, AnalyzeError> {
210    let start = Instant::now();
211    let source = std::fs::read_to_string(path)
212        .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
213
214    let line_count = source.lines().count();
215
216    // Detect language from extension
217    let ext = Path::new(path)
218        .extension()
219        .and_then(|e| e.to_str())
220        .and_then(language_from_extension)
221        .map(|l| l.to_string())
222        .unwrap_or_else(|| "unknown".to_string());
223
224    // Extract semantic information
225    let mut semantic = SemanticExtractor::extract(&source, &ext, ast_recursion_limit)?;
226
227    // Populate the file path on references now that the path is known
228    for r in &mut semantic.references {
229        r.location = path.to_string();
230    }
231
232    // Resolve Python wildcard imports
233    if ext == "python" {
234        resolve_wildcard_imports(Path::new(path), &mut semantic.imports);
235    }
236
237    // Detect if this is a test file
238    let is_test = is_test_file(Path::new(path));
239
240    // Extract parent directory for relative path display
241    let parent_dir = Path::new(path).parent();
242
243    // Format output
244    let formatted = format_file_details(path, &semantic, line_count, is_test, parent_dir);
245
246    tracing::debug!(path = %path, language = %ext, functions = semantic.functions.len(), classes = semantic.classes.len(), imports = semantic.imports.len(), duration_ms = start.elapsed().as_millis() as u64, "file analysis complete");
247
248    Ok(FileAnalysisOutput {
249        formatted,
250        semantic,
251        line_count,
252        next_cursor: None,
253    })
254}
255
256/// Result of focused symbol analysis.
257#[derive(Debug, Serialize, JsonSchema)]
258pub struct FocusedAnalysisOutput {
259    #[schemars(description = "Formatted text representation of the call graph analysis")]
260    pub formatted: String,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    #[schemars(
263        description = "Opaque cursor token for the next page of results (absent when no more results)"
264    )]
265    pub next_cursor: Option<String>,
266    /// Production caller chains (partitioned from incoming chains, excluding test callers).
267    /// Not serialized; used for pagination in lib.rs.
268    #[serde(skip)]
269    #[schemars(skip)]
270    pub prod_chains: Vec<CallChain>,
271    /// Test caller chains. Not serialized; used for pagination summary in lib.rs.
272    #[serde(skip)]
273    #[schemars(skip)]
274    pub test_chains: Vec<CallChain>,
275    /// Outgoing (callee) chains. Not serialized; used for pagination in lib.rs.
276    #[serde(skip)]
277    #[schemars(skip)]
278    pub outgoing_chains: Vec<CallChain>,
279    /// Number of definitions for the symbol. Not serialized; used for pagination headers.
280    #[serde(skip)]
281    #[schemars(skip)]
282    pub def_count: usize,
283}
284
285/// Analyze a symbol's call graph across a directory with progress tracking.
286#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
287#[allow(clippy::too_many_arguments)]
288pub fn analyze_focused_with_progress(
289    root: &Path,
290    focus: &str,
291    match_mode: SymbolMatchMode,
292    follow_depth: u32,
293    max_depth: Option<u32>,
294    ast_recursion_limit: Option<usize>,
295    progress: Arc<AtomicUsize>,
296    ct: CancellationToken,
297    use_summary: bool,
298) -> Result<FocusedAnalysisOutput, AnalyzeError> {
299    #[allow(clippy::too_many_arguments)]
300    // Check if already cancelled
301    if ct.is_cancelled() {
302        return Err(AnalyzeError::Cancelled);
303    }
304
305    // Check if path is a file (hint to use directory)
306    if root.is_file() {
307        let formatted =
308            "Single-file focus not supported. Please provide a directory path for cross-file call graph analysis.\n"
309                .to_string();
310        return Ok(FocusedAnalysisOutput {
311            formatted,
312            next_cursor: None,
313            prod_chains: vec![],
314            test_chains: vec![],
315            outgoing_chains: vec![],
316            def_count: 0,
317        });
318    }
319
320    // Walk the directory
321    let entries = walk_directory(root, max_depth)?;
322
323    // Collect semantic analysis for all files in parallel
324    let file_entries: Vec<&WalkEntry> = entries.iter().filter(|e| !e.is_dir).collect();
325
326    let analysis_results: Vec<(PathBuf, SemanticAnalysis)> = file_entries
327        .par_iter()
328        .filter_map(|entry| {
329            // Check cancellation per file
330            if ct.is_cancelled() {
331                return None;
332            }
333
334            let ext = entry.path.extension().and_then(|e| e.to_str());
335
336            // Try to read file content
337            let source = match std::fs::read_to_string(&entry.path) {
338                Ok(content) => content,
339                Err(_) => {
340                    progress.fetch_add(1, Ordering::Relaxed);
341                    return None;
342                }
343            };
344
345            // Detect language and extract semantic information
346            let language = if let Some(ext_str) = ext {
347                language_from_extension(ext_str)
348                    .map(|l| l.to_string())
349                    .unwrap_or_else(|| "unknown".to_string())
350            } else {
351                "unknown".to_string()
352            };
353
354            match SemanticExtractor::extract(&source, &language, ast_recursion_limit) {
355                Ok(mut semantic) => {
356                    // Populate file path on references
357                    for r in &mut semantic.references {
358                        r.location = entry.path.display().to_string();
359                    }
360                    progress.fetch_add(1, Ordering::Relaxed);
361                    Some((entry.path.clone(), semantic))
362                }
363                Err(_) => {
364                    progress.fetch_add(1, Ordering::Relaxed);
365                    None
366                }
367            }
368        })
369        .collect();
370
371    // Check if cancelled after parallel processing
372    if ct.is_cancelled() {
373        return Err(AnalyzeError::Cancelled);
374    }
375
376    // Build call graph
377    let graph = CallGraph::build_from_results(analysis_results)?;
378
379    // Resolve symbol name using the requested match mode.
380    // Exact mode: check the graph directly without building a sorted set (O(1) lookups).
381    // Fuzzy modes: collect a sorted, deduplicated set of all known symbols for deterministic results.
382    let resolved_focus = if match_mode == SymbolMatchMode::Exact {
383        let exists = graph.definitions.contains_key(focus)
384            || graph.callers.contains_key(focus)
385            || graph.callees.contains_key(focus);
386        if exists {
387            focus.to_string()
388        } else {
389            return Err(crate::graph::GraphError::SymbolNotFound {
390                symbol: focus.to_string(),
391                hint: "Try match_mode=insensitive for a case-insensitive search.".to_string(),
392            }
393            .into());
394        }
395    } else {
396        let all_known: Vec<String> = graph
397            .definitions
398            .keys()
399            .chain(graph.callers.keys())
400            .chain(graph.callees.keys())
401            .cloned()
402            .collect::<std::collections::BTreeSet<_>>()
403            .into_iter()
404            .collect();
405        resolve_symbol(all_known.iter(), focus, &match_mode)?
406    };
407
408    // Compute chain data for pagination (always, regardless of summary mode)
409    let def_count = graph
410        .definitions
411        .get(&resolved_focus)
412        .map_or(0, |d| d.len());
413    let incoming_chains = graph.find_incoming_chains(&resolved_focus, follow_depth)?;
414    let outgoing_chains = graph.find_outgoing_chains(&resolved_focus, follow_depth)?;
415
416    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
417        incoming_chains.into_iter().partition(|chain| {
418            chain
419                .chain
420                .first()
421                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
422        });
423
424    // Format output
425    let formatted = if use_summary {
426        format_focused_summary(&graph, &resolved_focus, follow_depth, Some(root))?
427    } else {
428        format_focused(&graph, &resolved_focus, follow_depth, Some(root))?
429    };
430
431    Ok(FocusedAnalysisOutput {
432        formatted,
433        next_cursor: None,
434        prod_chains,
435        test_chains,
436        outgoing_chains,
437        def_count,
438    })
439}
440
441/// Analyze a symbol's call graph with use_summary parameter (internal).
442#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
443#[allow(clippy::too_many_arguments)]
444/// Analyze a symbol's call graph across a directory.
445#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
446pub fn analyze_focused(
447    root: &Path,
448    focus: &str,
449    follow_depth: u32,
450    max_depth: Option<u32>,
451    ast_recursion_limit: Option<usize>,
452) -> Result<FocusedAnalysisOutput, AnalyzeError> {
453    let counter = Arc::new(AtomicUsize::new(0));
454    let ct = CancellationToken::new();
455    analyze_focused_with_progress(
456        root,
457        focus,
458        SymbolMatchMode::Exact,
459        follow_depth,
460        max_depth,
461        ast_recursion_limit,
462        counter,
463        ct,
464        false,
465    )
466}
467
468/// Analyze a single file and return a minimal fixed schema (name, line count, language,
469/// functions, imports) for lightweight code understanding.
470#[instrument(skip_all, fields(path))]
471pub fn analyze_module_file(path: &str) -> Result<crate::types::ModuleInfo, AnalyzeError> {
472    let source = std::fs::read_to_string(path)
473        .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
474
475    let file_path = Path::new(path);
476    let name = file_path
477        .file_name()
478        .and_then(|s| s.to_str())
479        .unwrap_or("unknown")
480        .to_string();
481
482    let line_count = source.lines().count();
483
484    let language = file_path
485        .extension()
486        .and_then(|e| e.to_str())
487        .and_then(language_from_extension)
488        .ok_or_else(|| {
489            AnalyzeError::Parser(crate::parser::ParserError::ParseError(
490                "unsupported or missing file extension".to_string(),
491            ))
492        })?;
493
494    let semantic = SemanticExtractor::extract(&source, language, None)?;
495
496    let functions = semantic
497        .functions
498        .into_iter()
499        .map(|f| crate::types::ModuleFunctionInfo {
500            name: f.name,
501            line: f.line,
502        })
503        .collect();
504
505    let imports = semantic
506        .imports
507        .into_iter()
508        .map(|i| crate::types::ModuleImportInfo {
509            module: i.module,
510            items: i.items,
511        })
512        .collect();
513
514    Ok(crate::types::ModuleInfo {
515        name,
516        line_count,
517        language: language.to_string(),
518        functions,
519        imports,
520    })
521}
522
523/// Resolve Python wildcard imports to actual symbol names.
524///
525/// For each import with items=["*"], this function:
526/// 1. Parses the relative dots (if any) and climbs the directory tree
527/// 2. Finds the target .py file or __init__.py
528/// 3. Extracts symbols (functions and classes) from the target
529/// 4. Honors __all__ if defined, otherwise uses function+class names
530///
531/// All resolution failures are non-fatal: debug-logged and the wildcard is preserved.
532fn resolve_wildcard_imports(file_path: &Path, imports: &mut [ImportInfo]) {
533    use std::collections::HashMap;
534
535    let mut resolved_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
536    let file_path_canonical = match file_path.canonicalize() {
537        Ok(p) => p,
538        Err(_) => {
539            tracing::debug!(file = ?file_path, "unable to canonicalize current file path");
540            return;
541        }
542    };
543
544    for import in imports.iter_mut() {
545        if import.items != ["*"] {
546            continue;
547        }
548        resolve_single_wildcard(import, file_path, &file_path_canonical, &mut resolved_cache);
549    }
550}
551
552/// Resolve one wildcard import in place. On any failure the import is left unchanged.
553fn resolve_single_wildcard(
554    import: &mut ImportInfo,
555    file_path: &Path,
556    file_path_canonical: &Path,
557    resolved_cache: &mut std::collections::HashMap<PathBuf, Vec<String>>,
558) {
559    let module = import.module.clone();
560    let dot_count = module.chars().take_while(|c| *c == '.').count();
561    if dot_count == 0 {
562        return;
563    }
564    let module_path = module.trim_start_matches('.');
565
566    let target_to_read = match locate_target_file(file_path, dot_count, module_path, &module) {
567        Some(p) => p,
568        None => return,
569    };
570
571    let canonical = match target_to_read.canonicalize() {
572        Ok(p) => p,
573        Err(_) => {
574            tracing::debug!(target = ?target_to_read, import = %module, "unable to canonicalize path");
575            return;
576        }
577    };
578
579    if canonical == file_path_canonical {
580        tracing::debug!(target = ?canonical, import = %module, "cannot import from self");
581        return;
582    }
583
584    if let Some(cached) = resolved_cache.get(&canonical) {
585        tracing::debug!(import = %module, symbols_count = cached.len(), "using cached symbols");
586        import.items = cached.clone();
587        return;
588    }
589
590    if let Some(symbols) = parse_target_symbols(&target_to_read, &module) {
591        tracing::debug!(import = %module, resolved_count = symbols.len(), "wildcard import resolved");
592        import.items = symbols.clone();
593        resolved_cache.insert(canonical, symbols);
594    }
595}
596
597/// Locate the .py file that a wildcard import refers to. Returns None if not found.
598fn locate_target_file(
599    file_path: &Path,
600    dot_count: usize,
601    module_path: &str,
602    module: &str,
603) -> Option<PathBuf> {
604    let mut target_dir = file_path.parent()?.to_path_buf();
605
606    for _ in 1..dot_count {
607        if !target_dir.pop() {
608            tracing::debug!(import = %module, "unable to climb {} levels", dot_count.saturating_sub(1));
609            return None;
610        }
611    }
612
613    let target_file = if module_path.is_empty() {
614        target_dir.join("__init__.py")
615    } else {
616        let rel_path = module_path.replace('.', "/");
617        target_dir.join(format!("{rel_path}.py"))
618    };
619
620    if target_file.exists() {
621        Some(target_file)
622    } else if target_file.with_extension("").is_dir() {
623        let init = target_file.with_extension("").join("__init__.py");
624        if init.exists() { Some(init) } else { None }
625    } else {
626        tracing::debug!(target = ?target_file, import = %module, "target file not found");
627        None
628    }
629}
630
631/// Read and parse a target .py file, returning its exported symbols.
632fn parse_target_symbols(target_path: &Path, module: &str) -> Option<Vec<String>> {
633    let source = match std::fs::read_to_string(target_path) {
634        Ok(s) => s,
635        Err(e) => {
636            tracing::debug!(target = ?target_path, import = %module, error = %e, "unable to read target file");
637            return None;
638        }
639    };
640
641    // Parse once with tree-sitter
642    use tree_sitter::Parser;
643    let lang_info = crate::languages::get_language_info("python")?;
644    let mut parser = Parser::new();
645    if parser.set_language(&lang_info.language).is_err() {
646        return None;
647    }
648    let tree = parser.parse(&source, None)?;
649
650    // First, try to extract __all__ from the same tree
651    let mut symbols = Vec::new();
652    extract_all_from_tree(&tree, &source, &mut symbols);
653    if !symbols.is_empty() {
654        tracing::debug!(import = %module, symbols = ?symbols, "using __all__ symbols");
655        return Some(symbols);
656    }
657
658    // Fallback: extract functions/classes from the tree
659    let root = tree.root_node();
660    let mut cursor = root.walk();
661    for child in root.children(&mut cursor) {
662        match child.kind() {
663            "function_definition" => {
664                if let Some(name_node) = child.child_by_field_name("name") {
665                    let name = source[name_node.start_byte()..name_node.end_byte()].to_string();
666                    if !name.starts_with('_') {
667                        symbols.push(name);
668                    }
669                }
670            }
671            "class_definition" => {
672                if let Some(name_node) = child.child_by_field_name("name") {
673                    let name = source[name_node.start_byte()..name_node.end_byte()].to_string();
674                    if !name.starts_with('_') {
675                        symbols.push(name);
676                    }
677                }
678            }
679            _ => {}
680        }
681    }
682    tracing::debug!(import = %module, fallback_symbols = ?symbols, "using fallback function/class names");
683    Some(symbols)
684}
685
686/// Extract __all__ from a tree-sitter tree.
687fn extract_all_from_tree(tree: &tree_sitter::Tree, source: &str, result: &mut Vec<String>) {
688    let root = tree.root_node();
689    let mut cursor = root.walk();
690    for child in root.children(&mut cursor) {
691        if child.kind() == "simple_statement" {
692            // simple_statement contains assignment and other statement types
693            let mut simple_cursor = child.walk();
694            for simple_child in child.children(&mut simple_cursor) {
695                if simple_child.kind() == "assignment"
696                    && let Some(left) = simple_child.child_by_field_name("left")
697                {
698                    let target_text = source[left.start_byte()..left.end_byte()].trim();
699                    if target_text == "__all__"
700                        && let Some(right) = simple_child.child_by_field_name("right")
701                    {
702                        extract_string_list_from_list_node(&right, source, result);
703                    }
704                }
705            }
706        } else if child.kind() == "expression_statement" {
707            // Fallback for older Python AST structures
708            let mut stmt_cursor = child.walk();
709            for stmt_child in child.children(&mut stmt_cursor) {
710                if stmt_child.kind() == "assignment"
711                    && let Some(left) = stmt_child.child_by_field_name("left")
712                {
713                    let target_text = source[left.start_byte()..left.end_byte()].trim();
714                    if target_text == "__all__"
715                        && let Some(right) = stmt_child.child_by_field_name("right")
716                    {
717                        extract_string_list_from_list_node(&right, source, result);
718                    }
719                }
720            }
721        }
722    }
723}
724
725/// Extract string literals from a Python list node.
726fn extract_string_list_from_list_node(
727    list_node: &tree_sitter::Node,
728    source: &str,
729    result: &mut Vec<String>,
730) {
731    let mut cursor = list_node.walk();
732    for child in list_node.named_children(&mut cursor) {
733        if child.kind() == "string" {
734            let raw = source[child.start_byte()..child.end_byte()].trim();
735            // Strip quotes: "name" -> name
736            let unquoted = raw.trim_matches('"').trim_matches('\'').to_string();
737            if !unquoted.is_empty() {
738                result.push(unquoted);
739            }
740        }
741    }
742}