Skip to main content

meta_ast/extractor/
mod.rs

1//! Parallel file extraction orchestration.
2//!
3//! Uses rayon `par_iter` to read, parse, and extract symbols/imports/
4//! references across files concurrently. Each file is processed
5//! independently; errors are accumulated as diagnostics per file.
6//!
7//! Set `ExtractOptions::skip_imports_and_refs` to `true` when only
8//! symbol listing is needed (e.g. inspect mode); skips the import and
9//! reference query passes, roughly halving per-file extraction time.
10
11use rayon::prelude::*;
12
13use crate::error::{Diagnostic, Severity};
14use crate::language::LangId;
15use crate::model::{IdGenerator, Symbol, SymbolId};
16use crate::parser;
17
18pub use crate::model::FileExtraction;
19
20/// Controls what the extraction pass produces.
21#[derive(Debug, Clone, Copy, Default)]
22pub struct ExtractOptions {
23    /// Skip import and reference extraction entirely; only extract symbols
24    /// and AST node counts. Halves per-file extraction cost for pure
25    /// symbol-inspection workflows.
26    pub skip_imports_and_refs: bool,
27}
28
29pub struct ExtractionResult {
30    pub files: Vec<FileExtraction>,
31}
32
33pub fn extract(files: &[(std::path::PathBuf, LangId)]) -> ExtractionResult {
34    extract_with_options(files, &ExtractOptions::default())
35}
36
37pub fn extract_with_options(
38    files: &[(std::path::PathBuf, LangId)],
39    opts: &ExtractOptions,
40) -> ExtractionResult {
41    let id_gen = IdGenerator::<SymbolId>::new();
42
43    let mut file_extractions: Vec<_> = files
44        .par_iter()
45        .map(|(path, lang)| extract_single_file(path, lang, &id_gen, opts))
46        .collect();
47
48    file_extractions.sort_by(|a, b| a.path.cmp(&b.path));
49
50    ExtractionResult {
51        files: file_extractions,
52    }
53}
54
55fn extract_single_file(
56    path: &std::path::Path,
57    lang: &LangId,
58    id_gen: &IdGenerator<SymbolId>,
59    opts: &ExtractOptions,
60) -> FileExtraction {
61    let source = match std::fs::read(path) {
62        Ok(s) => s,
63        Err(e) => {
64            return FileExtraction {
65                path: path.to_path_buf(),
66                lang: *lang,
67                symbols: Vec::new(),
68                imports: Vec::new(),
69                references: Vec::new(),
70                diagnostics: vec![Diagnostic {
71                    path: path.to_path_buf(),
72                    severity: Severity::Error,
73                    message: format!("failed to read file: {e}"),
74                    source_range: None,
75                }],
76                ast_node_count: 0,
77            };
78        }
79    };
80
81    let tree = match crate::parser::parse_tree(*lang, &source) {
82        Ok(t) => t,
83        Err(e) => {
84            return FileExtraction {
85                path: path.to_path_buf(),
86                lang: *lang,
87                symbols: Vec::new(),
88                imports: Vec::new(),
89                references: Vec::new(),
90                diagnostics: vec![Diagnostic {
91                    path: path.to_path_buf(),
92                    severity: Severity::Error,
93                    message: e.to_string(),
94                    source_range: None,
95                }],
96                ast_node_count: 0,
97            };
98        }
99    };
100
101    let ratio = parser::error_ratio(&tree, &source);
102    let node_count = parser::ast_node_count(&tree);
103    let mut diags = Vec::new();
104
105    if ratio > 0.5 {
106        diags.push(Diagnostic {
107            path: path.to_path_buf(),
108            severity: Severity::Warning,
109            message: format!(
110                "file has {:.0}% parse errors, results may be incomplete",
111                ratio * 100.0
112            ),
113            source_range: None,
114        });
115    }
116
117    let raw_symbols = crate::language::extract_symbols_for(*lang, &tree, &source);
118    let symbols = raw_symbols
119        .into_iter()
120        .map(|raw| Symbol {
121            id: id_gen.next(),
122            name: raw.name.into_owned(),
123            kind: raw.kind,
124            language: *lang,
125            file_path: path.to_path_buf(),
126            source_range: raw.source_range,
127            visibility: raw.visibility,
128            signature: raw.signature.map(|s| s.into_owned()),
129            docstring: raw.docstring.map(|s| s.into_owned()),
130            is_async: raw.is_async,
131        })
132        .collect();
133
134    let (imports, references) = if opts.skip_imports_and_refs {
135        (Vec::new(), Vec::new())
136    } else {
137        crate::language::extract_imports_and_references_for(*lang, &tree, &source, path)
138    };
139
140    FileExtraction {
141        path: path.to_path_buf(),
142        lang: *lang,
143        symbols,
144        imports,
145        references,
146        diagnostics: diags,
147        ast_node_count: node_count,
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::path::PathBuf;
155
156    fn test_dir() -> PathBuf {
157        let dir = std::env::temp_dir().join("meta_ast_test_extractor");
158        let _ = std::fs::create_dir_all(&dir);
159        dir
160    }
161
162    fn write_temp(name: &str, content: &[u8]) -> PathBuf {
163        let path = test_dir().join(name);
164        std::fs::write(&path, content).unwrap();
165        path
166    }
167
168    #[test]
169    fn extract_single_python_file() {
170        let path = write_temp("single.py", b"def hello(): pass\n");
171        let result = extract(&[(path.clone(), LangId::Python)]);
172        assert_eq!(result.files.len(), 1);
173        assert!(!result.files[0].symbols.is_empty());
174        assert!(result.files[0].diagnostics.is_empty());
175        let names: Vec<&str> = result.files[0]
176            .symbols
177            .iter()
178            .map(|s| s.name.as_str())
179            .collect();
180        assert!(names.contains(&"hello"));
181    }
182
183    #[test]
184    fn extract_multiple_files_parallel() {
185        let p1 = write_temp("file_a.py", b"def alpha(): pass\n");
186        let p2 = write_temp("file_b.py", b"def beta(): pass\ndef gamma(): pass\n");
187        let p3 = write_temp("file_c.py", b"class Delta: pass\n");
188
189        let files = vec![
190            (p1.clone(), LangId::Python),
191            (p2.clone(), LangId::Python),
192            (p3.clone(), LangId::Python),
193        ];
194        let result = extract(&files);
195        let all_names: Vec<&str> = result
196            .files
197            .iter()
198            .flat_map(|f| f.symbols.iter().map(|s| s.name.as_str()))
199            .collect();
200        assert!(all_names.contains(&"alpha"), "missing alpha: {all_names:?}");
201        assert!(all_names.contains(&"beta"), "missing beta: {all_names:?}");
202        assert!(all_names.contains(&"gamma"), "missing gamma: {all_names:?}");
203        assert!(all_names.contains(&"Delta"), "missing Delta: {all_names:?}");
204    }
205
206    #[test]
207    fn accumulate_diagnostics_on_malformed() {
208        let path = test_dir().join("nonexistent_broken.py");
209        let _ = std::fs::remove_file(&path);
210        let result = extract(&[(path, LangId::Python)]);
211        assert!(!result.files[0].diagnostics.is_empty());
212    }
213
214    #[test]
215    fn partial_extraction_on_errors() {
216        let valid = write_temp("valid_partial.py", b"def works(): pass\n");
217        let broken = write_temp(
218            "broken_partial.py",
219            b"def broken(\n   # missing close paren and colon\n",
220        );
221        let result = extract(&[(valid.clone(), LangId::Python), (broken, LangId::Python)]);
222        let names: Vec<&str> = result
223            .files
224            .iter()
225            .flat_map(|f| f.symbols.iter().map(|s| s.name.as_str()))
226            .collect();
227        assert!(
228            names.contains(&"works"),
229            "valid file symbols should be present: {names:?}"
230        );
231    }
232
233    #[test]
234    fn output_deterministic() {
235        let path = write_temp("deterministic.py", b"def foo(): pass\ndef bar(): pass\n");
236        let files = vec![(path.clone(), LangId::Python)];
237
238        let r1 = extract(&files);
239        let r2 = extract(&files);
240
241        let names1: Vec<String> = r1
242            .files
243            .iter()
244            .flat_map(|f| f.symbols.iter().map(|s| s.name.clone()))
245            .collect();
246        let names2: Vec<String> = r2
247            .files
248            .iter()
249            .flat_map(|f| f.symbols.iter().map(|s| s.name.clone()))
250            .collect();
251        assert_eq!(names1, names2);
252    }
253
254    #[test]
255    fn symbols_assigned_ids() {
256        let path = write_temp("ids.py", b"def a(): pass\ndef b(): pass\ndef c(): pass\n");
257        let result = extract(&[(path, LangId::Python)]);
258        let ids: Vec<u32> = result.files[0]
259            .symbols
260            .iter()
261            .map(|s| s.id.to_raw())
262            .collect();
263        let mut sorted_ids = ids.clone();
264        sorted_ids.sort();
265        assert_eq!(ids, sorted_ids, "IDs should be sequential");
266
267        for window in sorted_ids.windows(2) {
268            assert_eq!(window[1] - window[0], 1, "IDs should be consecutive");
269        }
270
271        let unique: std::collections::HashSet<u32> = ids.iter().copied().collect();
272        assert_eq!(
273            unique.len(),
274            result.files[0].symbols.len(),
275            "all IDs must be unique"
276        );
277    }
278}