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//!
11//! Set `ExtractOptions::keep_text` to `true` to retain the analyzed source text
12//! on each extraction. An editor or language server needs it to convert ranges
13//! against the exact bytes the analysis read, instead of re-reading a file that
14//! may have changed. Off by default: the CLI does not need it and the text is
15//! held for the lifetime of every extraction.
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20use rayon::prelude::*;
21
22use crate::error::{Diagnostic, Severity};
23use crate::language::LangId;
24use crate::model::{IdGenerator, Symbol, SymbolId};
25use crate::parser;
26
27pub use crate::model::FileExtraction;
28
29/// Largest source file the extractor reads, in bytes.
30///
31/// A generated file above this size is reported and skipped: parsing it would
32/// spike memory for a result no consumer can use, and the diagnostic keeps the
33/// skip visible instead of silently dropping the file.
34pub const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
35
36/// Controls what the extraction pass produces.
37#[derive(Debug, Clone, Copy, Default)]
38pub struct ExtractOptions {
39    /// Skip import and reference extraction entirely; only extract symbols
40    /// and AST node counts. Halves per-file extraction cost for pure
41    /// symbol-inspection workflows.
42    pub skip_imports_and_refs: bool,
43    /// Retain the analyzed source text on each extraction. Off by default:
44    /// the CLI does not need it, and an editor or language server uses it to
45    /// convert ranges against the exact text the analysis read.
46    pub keep_text: bool,
47}
48
49pub struct ExtractionResult {
50    pub files: Vec<FileExtraction>,
51}
52
53/// ID allocation state shared by disk and in-memory extraction.
54#[derive(Debug, Default)]
55pub struct ExtractionIdGenerators {
56    symbols: IdGenerator<SymbolId>,
57    #[cfg(feature = "dataflow")]
58    data_nodes: IdGenerator<crate::model::DataNodeId>,
59}
60
61impl ExtractionIdGenerators {
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    pub fn with_symbol_start(symbol_start: u32) -> Self {
67        Self {
68            symbols: IdGenerator::with_start(symbol_start),
69            #[cfg(feature = "dataflow")]
70            data_nodes: IdGenerator::new(),
71        }
72    }
73
74    #[cfg(feature = "dataflow")]
75    pub fn with_starts(symbol_start: u32, data_node_start: u32) -> Self {
76        Self {
77            symbols: IdGenerator::with_start(symbol_start),
78            data_nodes: IdGenerator::with_start(data_node_start),
79        }
80    }
81
82    pub fn symbols(&self) -> &IdGenerator<SymbolId> {
83        &self.symbols
84    }
85
86    #[cfg(feature = "dataflow")]
87    pub fn data_nodes(&self) -> &IdGenerator<crate::model::DataNodeId> {
88        &self.data_nodes
89    }
90}
91
92/// Source text supplied by an editor buffer.
93#[derive(Debug, Clone, Copy)]
94pub struct InMemorySource<'a> {
95    pub uri: &'a str,
96    pub text: &'a str,
97    pub version: i32,
98    pub language: LangId,
99}
100
101/// Extraction result tied to the editor document version that produced it.
102#[derive(Debug, Clone)]
103pub struct VersionedExtraction {
104    pub uri: String,
105    pub version: i32,
106    pub file: FileExtraction,
107}
108
109pub fn extract(files: &[(std::path::PathBuf, LangId)]) -> ExtractionResult {
110    extract_with_options(files, &ExtractOptions::default())
111}
112
113pub fn extract_with_options(
114    files: &[(std::path::PathBuf, LangId)],
115    opts: &ExtractOptions,
116) -> ExtractionResult {
117    let id_generators = ExtractionIdGenerators::new();
118    extract_with_id_gen(files, opts, &id_generators)
119}
120
121pub fn extract_with_id_gen(
122    files: &[(PathBuf, LangId)],
123    opts: &ExtractOptions,
124    id_generators: &ExtractionIdGenerators,
125) -> ExtractionResult {
126    // Phase one parses in parallel with file-local numbering, so no atomic
127    // ordering reaches the output. Phase two numbers the files in path order.
128    let mut file_extractions: Vec<FileExtraction> = files
129        .par_iter()
130        .map(|(path, lang)| {
131            let local = ExtractionIdGenerators::new();
132            extract_single_file(path, lang, &local, opts)
133        })
134        .collect();
135
136    file_extractions.sort_by(|a, b| a.path.cmp(&b.path));
137
138    for file in &mut file_extractions {
139        renumber(file, id_generators);
140    }
141
142    ExtractionResult {
143        files: file_extractions,
144    }
145}
146
147/// Map the file-local identifiers onto the shared generator.
148///
149/// The new numbering follows the symbol and data node order inside the file,
150/// which is deterministic for identical input.
151fn renumber(file: &mut FileExtraction, id_generators: &ExtractionIdGenerators) {
152    let symbol_map = if file.symbols.is_empty() {
153        HashMap::new()
154    } else {
155        let counter = IdGenerator::<SymbolId>::with_start(
156            id_generators.symbols.reserve(file.symbols.len() as u32),
157        );
158        let mut map = HashMap::with_capacity(file.symbols.len());
159        for symbol in &mut file.symbols {
160            let next = counter.next();
161            map.insert(symbol.id.to_raw(), next.to_raw());
162            symbol.id = next;
163        }
164        map
165    };
166
167    #[cfg(feature = "dataflow")]
168    renumber_data_nodes(file, id_generators, &symbol_map);
169
170    #[cfg(not(feature = "dataflow"))]
171    drop(symbol_map);
172}
173
174#[cfg(feature = "dataflow")]
175fn renumber_data_nodes(
176    file: &mut FileExtraction,
177    id_generators: &ExtractionIdGenerators,
178    symbol_map: &HashMap<u32, u32>,
179) {
180    use crate::model::DataNodeId;
181
182    if file.data_nodes.is_empty() {
183        file.flow_edges.clear();
184        return;
185    }
186
187    let counter = IdGenerator::<DataNodeId>::with_start(
188        id_generators
189            .data_nodes
190            .reserve(file.data_nodes.len() as u32),
191    );
192    let mut node_map = HashMap::with_capacity(file.data_nodes.len());
193    for node in &mut file.data_nodes {
194        let next = counter.next();
195        node_map.insert(node.id.to_raw(), next.to_raw());
196        node.id = next;
197        node.symbol_id = node
198            .symbol_id
199            .and_then(|id| symbol_map.get(&id.to_raw()).copied())
200            .and_then(SymbolId::new);
201    }
202
203    let node_ids: HashMap<u32, DataNodeId> = node_map
204        .iter()
205        .filter_map(|(&old, &new)| Some((old, DataNodeId::new(new)?)))
206        .collect();
207    file.flow_edges.retain_mut(|edge| {
208        let (Some(source), Some(target)) = (
209            node_ids.get(&edge.source.to_raw()).copied(),
210            node_ids.get(&edge.target.to_raw()).copied(),
211        ) else {
212            return false;
213        };
214        edge.source = source;
215        edge.target = target;
216        true
217    });
218}
219
220fn extract_single_file(
221    path: &Path,
222    lang: &LangId,
223    id_generators: &ExtractionIdGenerators,
224    opts: &ExtractOptions,
225) -> FileExtraction {
226    match std::fs::metadata(path) {
227        Ok(metadata) if metadata.len() > MAX_SOURCE_BYTES => {
228            return failed_extraction(
229                path,
230                *lang,
231                format!(
232                    "file is {} bytes, over the {MAX_SOURCE_BYTES} byte limit for a single source",
233                    metadata.len()
234                ),
235            );
236        }
237        Ok(_) => {}
238        Err(error) => {
239            return failed_extraction(path, *lang, format!("failed to read file: {error}"));
240        }
241    }
242
243    let source = match std::fs::read(path) {
244        Ok(source) => source,
245        Err(error) => {
246            return failed_extraction(path, *lang, format!("failed to read file: {error}"));
247        }
248    };
249
250    extract_source(path, *lang, &source, id_generators, opts)
251}
252
253/// Extract an open editor buffer without reading its backing file.
254pub fn extract_text_with_id_gen(
255    source: InMemorySource<'_>,
256    opts: &ExtractOptions,
257    id_generators: &ExtractionIdGenerators,
258) -> Result<VersionedExtraction, crate::Error> {
259    let parsed_uri =
260        url::Url::parse(source.uri).map_err(|error| crate::Error::InvalidSourceUri {
261            uri: source.uri.to_string(),
262            message: error.to_string(),
263        })?;
264    let path = parsed_uri
265        .to_file_path()
266        .map_err(|()| crate::Error::InvalidSourceUri {
267            uri: source.uri.to_string(),
268            message: "URI must use the file scheme and contain an absolute path".to_string(),
269        })?;
270    let path = crate::input::simplified_path(&path).to_path_buf();
271    let file = extract_source(
272        &path,
273        source.language,
274        source.text.as_bytes(),
275        id_generators,
276        opts,
277    );
278
279    Ok(VersionedExtraction {
280        uri: source.uri.to_string(),
281        version: source.version,
282        file,
283    })
284}
285
286fn extract_source(
287    path: &Path,
288    lang: LangId,
289    source: &[u8],
290    id_generators: &ExtractionIdGenerators,
291    opts: &ExtractOptions,
292) -> FileExtraction {
293    let tree = match crate::parser::parse_tree(lang, source) {
294        Ok(t) => t,
295        Err(e) => {
296            return failed_extraction(path, lang, e.to_string());
297        }
298    };
299
300    let metrics = parser::tree_metrics(&tree, source);
301    let mut diags = Vec::new();
302
303    if tree.root_node().has_error() {
304        diags.push(Diagnostic {
305            path: path.to_path_buf(),
306            severity: Severity::Warning,
307            message: format!(
308                "file has {:.0}% parse errors, results may be incomplete",
309                metrics.error_ratio * 100.0
310            ),
311            source_range: None,
312        });
313    }
314
315    let raw_symbols = match crate::language::extract_symbols_for_checked(lang, &tree, source) {
316        Ok(symbols) => symbols,
317        Err(error) => {
318            diags.push(Diagnostic {
319                path: path.to_path_buf(),
320                severity: Severity::Error,
321                message: format!("symbol extraction failed: {error}"),
322                source_range: None,
323            });
324            Vec::new()
325        }
326    };
327    let symbols = raw_symbols
328        .into_iter()
329        .map(|raw| Symbol {
330            id: id_generators.symbols.next(),
331            name: raw.name.into_owned(),
332            kind: raw.kind,
333            language: lang,
334            file_path: path.to_path_buf(),
335            source_range: raw.source_range,
336            name_range: raw.name_range,
337            visibility: raw.visibility,
338            signature: raw.signature.map(|s| s.into_owned()),
339            docstring: raw.docstring.map(|s| s.into_owned()),
340            is_async: raw.is_async,
341        })
342        .collect();
343
344    let (imports, references, text_diagnostics) = if opts.skip_imports_and_refs {
345        (Vec::new(), Vec::new(), Vec::new())
346    } else {
347        match crate::language::extract_imports_and_references_for_checked(lang, &tree, source, path)
348        {
349            Ok(extracted) => extracted,
350            Err(error) => {
351                diags.push(Diagnostic {
352                    path: path.to_path_buf(),
353                    severity: Severity::Error,
354                    message: format!("import and reference extraction failed: {error}"),
355                    source_range: None,
356                });
357                (Vec::new(), Vec::new(), Vec::new())
358            }
359        }
360    };
361
362    #[cfg(feature = "metacall-deploy")]
363    let call_sites = match crate::deploy::scanner::scan_file(lang, &tree, source, path) {
364        Ok(sites) => sites,
365        Err(error) => {
366            diags.push(Diagnostic {
367                path: path.to_path_buf(),
368                severity: Severity::Error,
369                message: format!("deploy call site scan failed: {error}"),
370                source_range: None,
371            });
372            Vec::new()
373        }
374    };
375
376    #[cfg(feature = "dataflow")]
377    let (data_nodes, flow_edges) =
378        crate::language::dataflow::extract_dataflow(lang, &tree, source, &id_generators.data_nodes);
379
380    let mut out = FileExtraction::empty(path.to_path_buf(), lang);
381    out.symbols = symbols;
382    out.imports = imports;
383    out.references = references;
384    out.diagnostics = diags;
385    out.diagnostics.extend(text_diagnostics);
386    out.ast_node_count = metrics.node_count;
387    // Ranges index the analyzed bytes, so a lossy copy would shift every
388    // offset. A file that is not valid UTF-8 keeps no text and the consumer
389    // falls back to its own source lookup.
390    if opts.keep_text {
391        out.text = std::str::from_utf8(source)
392            .ok()
393            .map(std::sync::Arc::<str>::from);
394    }
395    #[cfg(feature = "metacall-deploy")]
396    {
397        out.call_sites = call_sites;
398    }
399    #[cfg(feature = "dataflow")]
400    {
401        out.data_nodes = data_nodes;
402        out.flow_edges = flow_edges;
403    }
404    out
405}
406
407fn failed_extraction(path: &Path, lang: LangId, message: String) -> FileExtraction {
408    FileExtraction::failed(path.to_path_buf(), lang, message)
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use std::path::PathBuf;
415
416    fn test_dir() -> PathBuf {
417        let dir = std::env::temp_dir().join("meta_ast_test_extractor");
418        let _ = std::fs::create_dir_all(&dir);
419        dir
420    }
421
422    fn write_temp(name: &str, content: &[u8]) -> PathBuf {
423        let path = test_dir().join(name);
424        std::fs::write(&path, content).unwrap();
425        path
426    }
427
428    #[test]
429    fn symbol_ids_are_contiguous_in_path_order() {
430        let dir = test_dir().join("numbering");
431        let _ = std::fs::remove_dir_all(&dir);
432        std::fs::create_dir_all(&dir).unwrap();
433
434        let mut files = Vec::new();
435        for index in (0..24).rev() {
436            let path = dir.join(format!("f{index:02}.py"));
437            let body: String = (0..5)
438                .map(|n| format!("def g{index}_{n}(): pass\n"))
439                .collect();
440            std::fs::write(&path, body).unwrap();
441            files.push((path, LangId::Python));
442        }
443
444        let result = extract(&files);
445        let mut per_file: Vec<(PathBuf, Vec<u32>)> = result
446            .files
447            .iter()
448            .map(|file| {
449                (
450                    file.path.clone(),
451                    file.symbols.iter().map(|s| s.id.to_raw()).collect(),
452                )
453            })
454            .collect();
455        per_file.sort_by(|a, b| a.0.cmp(&b.0));
456
457        let mut next = 1u32;
458        for (path, ids) in &per_file {
459            assert_eq!(ids.len(), 5, "{}", path.display());
460            assert_eq!(
461                ids.first().copied(),
462                Some(next),
463                "ids of {} must start at {next} in path order",
464                path.display()
465            );
466            let last = ids.last().copied().unwrap();
467            assert_eq!(
468                last,
469                next + ids.len() as u32 - 1,
470                "ids of {} must be contiguous",
471                path.display()
472            );
473            next = last + 1;
474        }
475    }
476
477    #[test]
478    fn symbol_ids_are_stable_across_runs() {
479        let dir = test_dir().join("stable_numbering");
480        let _ = std::fs::remove_dir_all(&dir);
481        std::fs::create_dir_all(&dir).unwrap();
482
483        let mut files = Vec::new();
484        for index in 0..8 {
485            let path = dir.join(format!("s{index}.py"));
486            std::fs::write(
487                &path,
488                format!("def h{index}(): pass\ndef k{index}(): pass\n"),
489            )
490            .unwrap();
491            files.push((path, LangId::Python));
492        }
493
494        let map = |result: ExtractionResult| {
495            let mut entries: Vec<(String, u32)> = result
496                .files
497                .iter()
498                .flat_map(|file| file.symbols.iter().map(|s| (s.name.clone(), s.id.to_raw())))
499                .collect();
500            entries.sort();
501            entries
502        };
503
504        assert_eq!(map(extract(&files)), map(extract(&files)));
505    }
506
507    #[test]
508    fn extract_single_python_file() {
509        let path = write_temp("single.py", b"def hello(): pass\n");
510        let result = extract(&[(path.clone(), LangId::Python)]);
511        assert_eq!(result.files.len(), 1);
512        assert!(!result.files[0].symbols.is_empty());
513        assert!(result.files[0].diagnostics.is_empty());
514        let names: Vec<&str> = result.files[0]
515            .symbols
516            .iter()
517            .map(|s| s.name.as_str())
518            .collect();
519        assert!(names.contains(&"hello"));
520    }
521
522    #[test]
523    fn extract_multiple_files_parallel() {
524        let p1 = write_temp("file_a.py", b"def alpha(): pass\n");
525        let p2 = write_temp("file_b.py", b"def beta(): pass\ndef gamma(): pass\n");
526        let p3 = write_temp("file_c.py", b"class Delta: pass\n");
527
528        let files = vec![
529            (p1.clone(), LangId::Python),
530            (p2.clone(), LangId::Python),
531            (p3.clone(), LangId::Python),
532        ];
533        let result = extract(&files);
534        let all_names: Vec<&str> = result
535            .files
536            .iter()
537            .flat_map(|f| f.symbols.iter().map(|s| s.name.as_str()))
538            .collect();
539        assert!(all_names.contains(&"alpha"), "missing alpha: {all_names:?}");
540        assert!(all_names.contains(&"beta"), "missing beta: {all_names:?}");
541        assert!(all_names.contains(&"gamma"), "missing gamma: {all_names:?}");
542        assert!(all_names.contains(&"Delta"), "missing Delta: {all_names:?}");
543    }
544
545    #[test]
546    fn accumulate_diagnostics_on_malformed() {
547        let path = test_dir().join("nonexistent_broken.py");
548        let _ = std::fs::remove_file(&path);
549        let result = extract(&[(path, LangId::Python)]);
550        assert!(!result.files[0].diagnostics.is_empty());
551    }
552
553    #[test]
554    fn partial_extraction_on_errors() {
555        let valid = write_temp("valid_partial.py", b"def works(): pass\n");
556        let broken = write_temp(
557            "broken_partial.py",
558            b"def broken(\n   # missing close paren and colon\n",
559        );
560        let result = extract(&[(valid.clone(), LangId::Python), (broken, LangId::Python)]);
561        let names: Vec<&str> = result
562            .files
563            .iter()
564            .flat_map(|f| f.symbols.iter().map(|s| s.name.as_str()))
565            .collect();
566        assert!(
567            names.contains(&"works"),
568            "valid file symbols should be present: {names:?}"
569        );
570    }
571
572    #[test]
573    fn output_deterministic() {
574        let path = write_temp("deterministic.py", b"def foo(): pass\ndef bar(): pass\n");
575        let files = vec![(path.clone(), LangId::Python)];
576
577        let r1 = extract(&files);
578        let r2 = extract(&files);
579
580        let names1: Vec<String> = r1
581            .files
582            .iter()
583            .flat_map(|f| f.symbols.iter().map(|s| s.name.clone()))
584            .collect();
585        let names2: Vec<String> = r2
586            .files
587            .iter()
588            .flat_map(|f| f.symbols.iter().map(|s| s.name.clone()))
589            .collect();
590        assert_eq!(names1, names2);
591    }
592
593    #[test]
594    fn in_memory_extraction_uses_unsaved_text_and_preserves_version() {
595        let path = test_dir().join("buffer.py");
596        let _ = std::fs::remove_file(&path);
597        let uri = url::Url::from_file_path(&path).unwrap().to_string();
598        let id_generators = ExtractionIdGenerators::with_symbol_start(40);
599
600        let result = extract_text_with_id_gen(
601            InMemorySource {
602                uri: &uri,
603                text: "def unsaved(): pass\n",
604                version: 7,
605                language: LangId::Python,
606            },
607            &ExtractOptions::default(),
608            &id_generators,
609        )
610        .unwrap();
611
612        assert_eq!(result.version, 7);
613        assert_eq!(result.uri, uri);
614        assert_eq!(result.file.path, path);
615        assert_eq!(result.file.symbols[0].name, "unsaved");
616        assert_eq!(result.file.symbols[0].id, SymbolId::new(40).unwrap());
617    }
618
619    #[test]
620    fn text_retention_is_opt_in_for_both_entry_points() {
621        let path = test_dir().join("retained.py");
622        let source = "def retained(): pass\n";
623        std::fs::write(&path, source).unwrap();
624        let id_generators = ExtractionIdGenerators::new();
625        let files = [(path.clone(), LangId::Python)];
626
627        let without = extract_with_id_gen(&files, &ExtractOptions::default(), &id_generators);
628        assert_eq!(without.files[0].text, None);
629
630        let opts = ExtractOptions {
631            keep_text: true,
632            ..ExtractOptions::default()
633        };
634        let from_disk = extract_with_id_gen(&files, &opts, &id_generators);
635        assert_eq!(from_disk.files[0].text.as_deref(), Some(source));
636
637        let uri = url::Url::from_file_path(&path).unwrap().to_string();
638        let from_buffer = extract_text_with_id_gen(
639            InMemorySource {
640                uri: &uri,
641                text: source,
642                version: 1,
643                language: LangId::Python,
644            },
645            &opts,
646            &id_generators,
647        )
648        .unwrap();
649        assert_eq!(from_buffer.file.text.as_deref(), Some(source));
650    }
651
652    #[test]
653    fn in_memory_extraction_rejects_non_file_uri() {
654        let id_generators = ExtractionIdGenerators::new();
655        let error = extract_text_with_id_gen(
656            InMemorySource {
657                uri: "untitled:buffer.py",
658                text: "def value(): pass\n",
659                version: 1,
660                language: LangId::Python,
661            },
662            &ExtractOptions::default(),
663            &id_generators,
664        )
665        .unwrap_err();
666
667        assert!(matches!(error, crate::Error::InvalidSourceUri { .. }));
668    }
669
670    #[test]
671    fn extraction_id_generators_accessors() {
672        let id_generators = ExtractionIdGenerators::with_symbol_start(100);
673        assert_eq!(id_generators.symbols().next(), SymbolId::new(100).unwrap());
674        #[cfg(feature = "dataflow")]
675        {
676            let dual = ExtractionIdGenerators::with_starts(200, 300);
677            assert_eq!(dual.symbols().next(), SymbolId::new(200).unwrap());
678            assert_eq!(
679                dual.data_nodes().next(),
680                crate::model::DataNodeId::new(300).unwrap()
681            );
682        }
683    }
684
685    #[test]
686    fn symbols_assigned_ids() {
687        let path = write_temp("ids.py", b"def a(): pass\ndef b(): pass\ndef c(): pass\n");
688        let result = extract(&[(path, LangId::Python)]);
689        let ids: Vec<u32> = result.files[0]
690            .symbols
691            .iter()
692            .map(|s| s.id.to_raw())
693            .collect();
694        let mut sorted_ids = ids.clone();
695        sorted_ids.sort();
696        assert_eq!(ids, sorted_ids, "IDs should be sequential");
697
698        for window in sorted_ids.windows(2) {
699            assert_eq!(window[1] - window[0], 1, "IDs should be consecutive");
700        }
701
702        let unique: std::collections::HashSet<u32> = ids.iter().copied().collect();
703        assert_eq!(
704            unique.len(),
705            result.files[0].symbols.len(),
706            "all IDs must be unique"
707        );
708    }
709
710    #[test]
711    fn parse_errors_are_reported_for_any_broken_tree() {
712        let clean = write_temp("parse_clean.py", b"def ok(): pass\n");
713        let slightly_broken = write_temp("parse_broken.py", b"def broken(\n   # no close paren\n");
714        let heavily_broken = write_temp("parse_heavily_broken.py", b"@@@ ??? (((\n");
715        let result = extract(&[
716            (clean.clone(), LangId::Python),
717            (slightly_broken.clone(), LangId::Python),
718            (heavily_broken.clone(), LangId::Python),
719        ]);
720
721        let parse_diagnostics = |path: &PathBuf| -> usize {
722            result
723                .files
724                .iter()
725                .find(|f| &f.path == path)
726                .unwrap()
727                .diagnostics
728                .iter()
729                .filter(|d| d.message.contains("parse errors"))
730                .count()
731        };
732
733        assert_eq!(parse_diagnostics(&clean), 0);
734        assert!(parse_diagnostics(&slightly_broken) > 0);
735        assert!(parse_diagnostics(&heavily_broken) > 0);
736    }
737    #[test]
738    fn a_source_over_the_size_cap_is_reported_once() {
739        use std::io::Write;
740
741        let dir = test_dir().join("oversized");
742        let _ = std::fs::remove_dir_all(&dir);
743        std::fs::create_dir_all(&dir).unwrap();
744        let path = dir.join("huge.py");
745
746        let mut file = std::fs::File::create(&path).unwrap();
747        let line = b"# filler line\n";
748        let cap = 8 * 1024 * 1024;
749        let mut written = 0usize;
750        while written <= cap {
751            file.write_all(line).unwrap();
752            written += line.len();
753        }
754        drop(file);
755        assert!(written > cap, "the file is over the cap");
756
757        let result = extract_with_options(
758            &[(path.clone(), LangId::Python)],
759            &ExtractOptions::default(),
760        );
761        let extraction = &result.files[0];
762
763        assert!(
764            extraction.symbols.is_empty(),
765            "an oversized file contributes no symbols"
766        );
767        let errors: Vec<&Diagnostic> = extraction
768            .diagnostics
769            .iter()
770            .filter(|diagnostic| diagnostic.severity == Severity::Error)
771            .collect();
772        assert_eq!(
773            errors.len(),
774            1,
775            "an oversized file reports exactly one error"
776        );
777        assert!(
778            errors[0].message.contains("8388608"),
779            "the error names the cap: {}",
780            errors[0].message
781        );
782
783        let _ = std::fs::remove_dir_all(&dir);
784    }
785
786    #[test]
787    fn two_passes_over_a_tree_agree() {
788        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/multi");
789        let files = crate::input::discover_files(&root, None).unwrap();
790        assert!(!files.is_empty(), "the fixture tree has files");
791
792        let describe = |result: &ExtractionResult| -> Vec<String> {
793            let mut described = Vec::new();
794            for file in &result.files {
795                described.push(format!("file {} {:?}", file.path.display(), file.lang));
796                for symbol in &file.symbols {
797                    described.push(format!(
798                        "symbol {} {} {:?} {}",
799                        symbol.id.to_raw(),
800                        symbol.name,
801                        symbol.kind,
802                        symbol.source_range.byte_start
803                    ));
804                }
805                for import in &file.imports {
806                    described.push(format!(
807                        "import {} {}",
808                        import.import_specifier, import.range.byte_start
809                    ));
810                }
811                for reference in &file.references {
812                    described.push(format!(
813                        "reference {} {}",
814                        reference.name, reference.range.byte_start
815                    ));
816                }
817            }
818            described
819        };
820
821        let first = extract_with_options(&files, &ExtractOptions::default());
822        let second = extract_with_options(&files, &ExtractOptions::default());
823
824        assert_eq!(
825            describe(&first),
826            describe(&second),
827            "two passes over the same tree produce the same extraction"
828        );
829    }
830}