1use 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#[derive(Debug, Clone, Copy, Default)]
24pub struct ExtractOptions {
25 pub skip_imports_and_refs: bool,
29}
30
31pub struct ExtractionResult {
32 pub files: Vec<FileExtraction>,
33}
34
35#[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#[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#[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
136pub 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}