1use 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#[derive(Debug, Clone, Copy, Default)]
22pub struct ExtractOptions {
23 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}