1use 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
29pub const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
35
36#[derive(Debug, Clone, Copy, Default)]
38pub struct ExtractOptions {
39 pub skip_imports_and_refs: bool,
43 pub keep_text: bool,
47}
48
49pub struct ExtractionResult {
50 pub files: Vec<FileExtraction>,
51}
52
53#[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#[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#[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 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
147fn 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
253pub 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 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}