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 std::path::{Path, PathBuf};
12
13use rayon::prelude::*;
14
15use crate::error::{Diagnostic, Severity};
16use crate::language::LangId;
17use crate::model::{IdGenerator, Symbol, SymbolId};
18use crate::parser;
19
20pub use crate::model::FileExtraction;
21
22/// Controls what the extraction pass produces.
23#[derive(Debug, Clone, Copy, Default)]
24pub struct ExtractOptions {
25    /// Skip import and reference extraction entirely; only extract symbols
26    /// and AST node counts. Halves per-file extraction cost for pure
27    /// symbol-inspection workflows.
28    pub skip_imports_and_refs: bool,
29}
30
31pub struct ExtractionResult {
32    pub files: Vec<FileExtraction>,
33}
34
35/// ID allocation state shared by disk and in-memory extraction.
36#[derive(Debug, Default)]
37pub struct ExtractionIdGenerators {
38    symbols: IdGenerator<SymbolId>,
39    #[cfg(feature = "dataflow")]
40    data_nodes: IdGenerator<crate::model::DataNodeId>,
41}
42
43impl ExtractionIdGenerators {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    pub fn with_symbol_start(symbol_start: u32) -> Self {
49        Self {
50            symbols: IdGenerator::with_start(symbol_start),
51            #[cfg(feature = "dataflow")]
52            data_nodes: IdGenerator::new(),
53        }
54    }
55
56    #[cfg(feature = "dataflow")]
57    pub fn with_starts(symbol_start: u32, data_node_start: u32) -> Self {
58        Self {
59            symbols: IdGenerator::with_start(symbol_start),
60            data_nodes: IdGenerator::with_start(data_node_start),
61        }
62    }
63
64    pub fn symbols(&self) -> &IdGenerator<SymbolId> {
65        &self.symbols
66    }
67
68    #[cfg(feature = "dataflow")]
69    pub fn data_nodes(&self) -> &IdGenerator<crate::model::DataNodeId> {
70        &self.data_nodes
71    }
72}
73
74/// Source text supplied by an editor buffer.
75#[derive(Debug, Clone, Copy)]
76pub struct InMemorySource<'a> {
77    pub uri: &'a str,
78    pub text: &'a str,
79    pub version: i32,
80    pub language: LangId,
81}
82
83/// Extraction result tied to the editor document version that produced it.
84#[derive(Debug, Clone)]
85pub struct VersionedExtraction {
86    pub uri: String,
87    pub version: i32,
88    pub file: FileExtraction,
89}
90
91pub fn extract(files: &[(std::path::PathBuf, LangId)]) -> ExtractionResult {
92    extract_with_options(files, &ExtractOptions::default())
93}
94
95pub fn extract_with_options(
96    files: &[(std::path::PathBuf, LangId)],
97    opts: &ExtractOptions,
98) -> ExtractionResult {
99    let id_generators = ExtractionIdGenerators::new();
100    extract_with_id_gen(files, opts, &id_generators)
101}
102
103pub fn extract_with_id_gen(
104    files: &[(PathBuf, LangId)],
105    opts: &ExtractOptions,
106    id_generators: &ExtractionIdGenerators,
107) -> ExtractionResult {
108    let mut file_extractions: Vec<_> = files
109        .par_iter()
110        .map(|(path, lang)| extract_single_file(path, lang, id_generators, opts))
111        .collect();
112
113    file_extractions.sort_by(|a, b| a.path.cmp(&b.path));
114
115    ExtractionResult {
116        files: file_extractions,
117    }
118}
119
120fn extract_single_file(
121    path: &Path,
122    lang: &LangId,
123    id_generators: &ExtractionIdGenerators,
124    opts: &ExtractOptions,
125) -> FileExtraction {
126    let source = match std::fs::read(path) {
127        Ok(source) => source,
128        Err(error) => {
129            return failed_extraction(path, *lang, format!("failed to read file: {error}"));
130        }
131    };
132
133    extract_source(path, *lang, &source, id_generators, opts)
134}
135
136/// Extract an open editor buffer without reading its backing file.
137pub fn extract_text_with_id_gen(
138    source: InMemorySource<'_>,
139    opts: &ExtractOptions,
140    id_generators: &ExtractionIdGenerators,
141) -> Result<VersionedExtraction, crate::Error> {
142    let parsed_uri =
143        url::Url::parse(source.uri).map_err(|error| crate::Error::InvalidSourceUri {
144            uri: source.uri.to_string(),
145            message: error.to_string(),
146        })?;
147    let path = parsed_uri
148        .to_file_path()
149        .map_err(|()| crate::Error::InvalidSourceUri {
150            uri: source.uri.to_string(),
151            message: "URI must use the file scheme and contain an absolute path".to_string(),
152        })?;
153    let path = crate::input::simplified_path(&path).to_path_buf();
154    let file = extract_source(
155        &path,
156        source.language,
157        source.text.as_bytes(),
158        id_generators,
159        opts,
160    );
161
162    Ok(VersionedExtraction {
163        uri: source.uri.to_string(),
164        version: source.version,
165        file,
166    })
167}
168
169fn extract_source(
170    path: &Path,
171    lang: LangId,
172    source: &[u8],
173    id_generators: &ExtractionIdGenerators,
174    opts: &ExtractOptions,
175) -> FileExtraction {
176    let tree = match crate::parser::parse_tree(lang, source) {
177        Ok(t) => t,
178        Err(e) => {
179            return failed_extraction(path, lang, e.to_string());
180        }
181    };
182
183    let metrics = parser::tree_metrics(&tree, source);
184    let mut diags = Vec::new();
185
186    if metrics.error_ratio > 0.5 {
187        diags.push(Diagnostic {
188            path: path.to_path_buf(),
189            severity: Severity::Warning,
190            message: format!(
191                "file has {:.0}% parse errors, results may be incomplete",
192                metrics.error_ratio * 100.0
193            ),
194            source_range: None,
195        });
196    }
197
198    let raw_symbols = crate::language::extract_symbols_for(lang, &tree, source);
199    let symbols = raw_symbols
200        .into_iter()
201        .map(|raw| Symbol {
202            id: id_generators.symbols.next(),
203            name: raw.name.into_owned(),
204            kind: raw.kind,
205            language: lang,
206            file_path: path.to_path_buf(),
207            source_range: raw.source_range,
208            visibility: raw.visibility,
209            signature: raw.signature.map(|s| s.into_owned()),
210            docstring: raw.docstring.map(|s| s.into_owned()),
211            is_async: raw.is_async,
212        })
213        .collect();
214
215    let (imports, references) = if opts.skip_imports_and_refs {
216        (Vec::new(), Vec::new())
217    } else {
218        crate::language::extract_imports_and_references_for(lang, &tree, source, path)
219    };
220
221    #[cfg(feature = "metacall-deploy")]
222    let call_sites = crate::deploy::scanner::scan_file(lang, &tree, source, path);
223
224    #[cfg(feature = "dataflow")]
225    let (data_nodes, flow_edges) =
226        crate::language::dataflow::extract_dataflow(lang, &tree, source, &id_generators.data_nodes);
227
228    let mut out = FileExtraction::empty(path.to_path_buf(), lang);
229    out.symbols = symbols;
230    out.imports = imports;
231    out.references = references;
232    out.diagnostics = diags;
233    out.ast_node_count = metrics.node_count;
234    #[cfg(feature = "metacall-deploy")]
235    {
236        out.call_sites = call_sites;
237    }
238    #[cfg(feature = "dataflow")]
239    {
240        out.data_nodes = data_nodes;
241        out.flow_edges = flow_edges;
242    }
243    out
244}
245
246fn failed_extraction(path: &Path, lang: LangId, message: String) -> FileExtraction {
247    FileExtraction::failed(path.to_path_buf(), lang, message)
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::path::PathBuf;
254
255    fn test_dir() -> PathBuf {
256        let dir = std::env::temp_dir().join("meta_ast_test_extractor");
257        let _ = std::fs::create_dir_all(&dir);
258        dir
259    }
260
261    fn write_temp(name: &str, content: &[u8]) -> PathBuf {
262        let path = test_dir().join(name);
263        std::fs::write(&path, content).unwrap();
264        path
265    }
266
267    #[test]
268    fn extract_single_python_file() {
269        let path = write_temp("single.py", b"def hello(): pass\n");
270        let result = extract(&[(path.clone(), LangId::Python)]);
271        assert_eq!(result.files.len(), 1);
272        assert!(!result.files[0].symbols.is_empty());
273        assert!(result.files[0].diagnostics.is_empty());
274        let names: Vec<&str> = result.files[0]
275            .symbols
276            .iter()
277            .map(|s| s.name.as_str())
278            .collect();
279        assert!(names.contains(&"hello"));
280    }
281
282    #[test]
283    fn extract_multiple_files_parallel() {
284        let p1 = write_temp("file_a.py", b"def alpha(): pass\n");
285        let p2 = write_temp("file_b.py", b"def beta(): pass\ndef gamma(): pass\n");
286        let p3 = write_temp("file_c.py", b"class Delta: pass\n");
287
288        let files = vec![
289            (p1.clone(), LangId::Python),
290            (p2.clone(), LangId::Python),
291            (p3.clone(), LangId::Python),
292        ];
293        let result = extract(&files);
294        let all_names: Vec<&str> = result
295            .files
296            .iter()
297            .flat_map(|f| f.symbols.iter().map(|s| s.name.as_str()))
298            .collect();
299        assert!(all_names.contains(&"alpha"), "missing alpha: {all_names:?}");
300        assert!(all_names.contains(&"beta"), "missing beta: {all_names:?}");
301        assert!(all_names.contains(&"gamma"), "missing gamma: {all_names:?}");
302        assert!(all_names.contains(&"Delta"), "missing Delta: {all_names:?}");
303    }
304
305    #[test]
306    fn accumulate_diagnostics_on_malformed() {
307        let path = test_dir().join("nonexistent_broken.py");
308        let _ = std::fs::remove_file(&path);
309        let result = extract(&[(path, LangId::Python)]);
310        assert!(!result.files[0].diagnostics.is_empty());
311    }
312
313    #[test]
314    fn partial_extraction_on_errors() {
315        let valid = write_temp("valid_partial.py", b"def works(): pass\n");
316        let broken = write_temp(
317            "broken_partial.py",
318            b"def broken(\n   # missing close paren and colon\n",
319        );
320        let result = extract(&[(valid.clone(), LangId::Python), (broken, LangId::Python)]);
321        let names: Vec<&str> = result
322            .files
323            .iter()
324            .flat_map(|f| f.symbols.iter().map(|s| s.name.as_str()))
325            .collect();
326        assert!(
327            names.contains(&"works"),
328            "valid file symbols should be present: {names:?}"
329        );
330    }
331
332    #[test]
333    fn output_deterministic() {
334        let path = write_temp("deterministic.py", b"def foo(): pass\ndef bar(): pass\n");
335        let files = vec![(path.clone(), LangId::Python)];
336
337        let r1 = extract(&files);
338        let r2 = extract(&files);
339
340        let names1: Vec<String> = r1
341            .files
342            .iter()
343            .flat_map(|f| f.symbols.iter().map(|s| s.name.clone()))
344            .collect();
345        let names2: Vec<String> = r2
346            .files
347            .iter()
348            .flat_map(|f| f.symbols.iter().map(|s| s.name.clone()))
349            .collect();
350        assert_eq!(names1, names2);
351    }
352
353    #[test]
354    fn in_memory_extraction_uses_unsaved_text_and_preserves_version() {
355        let path = test_dir().join("buffer.py");
356        let _ = std::fs::remove_file(&path);
357        let uri = url::Url::from_file_path(&path).unwrap().to_string();
358        let id_generators = ExtractionIdGenerators::with_symbol_start(40);
359
360        let result = extract_text_with_id_gen(
361            InMemorySource {
362                uri: &uri,
363                text: "def unsaved(): pass\n",
364                version: 7,
365                language: LangId::Python,
366            },
367            &ExtractOptions::default(),
368            &id_generators,
369        )
370        .unwrap();
371
372        assert_eq!(result.version, 7);
373        assert_eq!(result.uri, uri);
374        assert_eq!(result.file.path, path);
375        assert_eq!(result.file.symbols[0].name, "unsaved");
376        assert_eq!(result.file.symbols[0].id, SymbolId::new(40).unwrap());
377    }
378
379    #[test]
380    fn in_memory_extraction_rejects_non_file_uri() {
381        let id_generators = ExtractionIdGenerators::new();
382        let error = extract_text_with_id_gen(
383            InMemorySource {
384                uri: "untitled:buffer.py",
385                text: "def value(): pass\n",
386                version: 1,
387                language: LangId::Python,
388            },
389            &ExtractOptions::default(),
390            &id_generators,
391        )
392        .unwrap_err();
393
394        assert!(matches!(error, crate::Error::InvalidSourceUri { .. }));
395    }
396
397    #[test]
398    fn extraction_id_generators_accessors() {
399        let id_generators = ExtractionIdGenerators::with_symbol_start(100);
400        assert_eq!(id_generators.symbols().next(), SymbolId::new(100).unwrap());
401        #[cfg(feature = "dataflow")]
402        {
403            let dual = ExtractionIdGenerators::with_starts(200, 300);
404            assert_eq!(dual.symbols().next(), SymbolId::new(200).unwrap());
405            assert_eq!(
406                dual.data_nodes().next(),
407                crate::model::DataNodeId::new(300).unwrap()
408            );
409        }
410    }
411
412    #[test]
413    fn symbols_assigned_ids() {
414        let path = write_temp("ids.py", b"def a(): pass\ndef b(): pass\ndef c(): pass\n");
415        let result = extract(&[(path, LangId::Python)]);
416        let ids: Vec<u32> = result.files[0]
417            .symbols
418            .iter()
419            .map(|s| s.id.to_raw())
420            .collect();
421        let mut sorted_ids = ids.clone();
422        sorted_ids.sort();
423        assert_eq!(ids, sorted_ids, "IDs should be sequential");
424
425        for window in sorted_ids.windows(2) {
426            assert_eq!(window[1] - window[0], 1, "IDs should be consecutive");
427        }
428
429        let unique: std::collections::HashSet<u32> = ids.iter().copied().collect();
430        assert_eq!(
431            unique.len(),
432            result.files[0].symbols.len(),
433            "all IDs must be unique"
434        );
435    }
436}