1mod extraction_budget;
19pub use extraction_budget::ExtractionBudgetError;
20use objects::object::{
21 ContentHash, ImportEntry, OccurrenceEntry, ScopeEntry, SymbolEntry, SymbolKindTag,
22 compute_file_scaffold_hash, compute_symbol_semantic_hash,
23};
24
25use crate::{
26 parser::{Language, ParsedFile, walk_non_comment_leaves},
27 symbol_resolver::visit_definitions,
28};
29
30pub const EXTRACTOR_VERSION: u32 = 5;
53
54pub fn language_name(language: Language) -> &'static str {
57 match language {
58 Language::Rust => "rust",
59 Language::Python => "python",
60 Language::JavaScript => "javascript",
61 Language::TypeScript => "typescript",
62 Language::Go => "go",
63 Language::C => "c",
64 Language::Cpp => "cpp",
65 Language::Java => "java",
66 Language::Zig => "zig",
67 Language::Unknown => "unknown",
68 }
69}
70
71pub fn grammar_version(language: Language) -> &'static str {
74 match language {
75 Language::Rust => "tree-sitter-rust@0.24",
76 Language::Python => "tree-sitter-python@0.25",
77 Language::JavaScript => "tree-sitter-javascript@0.25",
78 Language::TypeScript => "tree-sitter-typescript@0.23",
79 Language::Go => "tree-sitter-go@0.25",
80 Language::C => "tree-sitter-c@0.24",
81 Language::Cpp => "tree-sitter-cpp@0.23",
82 Language::Java => "tree-sitter-java@0.23",
83 Language::Zig => "tree-sitter-zig@1.1",
84 Language::Unknown => "none",
85 }
86}
87
88pub fn grammar_version_by_name(name: &str) -> Option<&'static str> {
92 let language = match name {
93 "rust" => Language::Rust,
94 "python" => Language::Python,
95 "javascript" => Language::JavaScript,
96 "typescript" => Language::TypeScript,
97 "go" => Language::Go,
98 "c" => Language::C,
99 "cpp" => Language::Cpp,
100 "java" => Language::Java,
101 "zig" => Language::Zig,
102 _ => return None,
103 };
104 Some(grammar_version(language))
105}
106
107pub struct ExtractedFile {
111 pub language: Language,
112 pub scaffold_hash: ContentHash,
116 pub symbols: Vec<SymbolEntry>,
117 pub scopes: Vec<ScopeEntry>,
118 pub imports: Vec<ImportEntry>,
119 pub occurrences: Vec<OccurrenceEntry>,
120}
121
122pub fn extract_semantic_file(source: &[u8], language: Language) -> Option<ExtractedFile> {
129 language.parser_handle()?;
131 let source_text = std::str::from_utf8(source).ok()?;
132 let parsed = ParsedFile::parse(source_text, language)?;
133
134 extract_parsed_file(source, language, parsed)
135}
136
137pub fn extract_semantic_file_bounded(
141 source: &[u8],
142 language: Language,
143 budget: &crate::parser::ParseBudget,
144) -> Result<Option<ExtractedFile>, ExtractionBudgetError> {
145 if budget.interrupted() {
146 return Err(ExtractionBudgetError::Interrupted);
147 }
148 if source.len() > extraction_budget::MAX_SOURCE_BYTES {
149 return Err(ExtractionBudgetError::Exceeded("source bytes"));
150 }
151 let Some(source_text) = std::str::from_utf8(source).ok() else {
152 return Ok(None);
153 };
154 let parsed = ParsedFile::parse_bounded(source_text, language, budget);
155 if budget.interrupted() {
156 return Err(ExtractionBudgetError::Interrupted);
157 }
158 let Some(parsed) = parsed else {
159 return Ok(None);
160 };
161 extraction_budget::admit(parsed.root_node(), budget)?;
162 let value = extract_parsed_file(source, language, parsed);
163 if budget.interrupted() {
164 return Err(ExtractionBudgetError::Interrupted);
165 }
166 Ok(value)
167}
168
169fn extract_parsed_file(
170 source: &[u8],
171 language: Language,
172 parsed: ParsedFile,
173) -> Option<ExtractedFile> {
174 let mut symbols = Vec::new();
175 let mut covered: Vec<(usize, usize)> = Vec::new();
178 visit_definitions(parsed.root_node(), source, &mut |site| {
179 let kind = site.kind;
180 let semantic_hash = symbol_semantic_hash(site.node, source, kind);
181 let container_path = site.parent_name.map(|p| vec![p]).unwrap_or_default();
182 let range = site.node.byte_range();
183 covered.push((range.start, range.end));
184 symbols.push(SymbolEntry {
185 name: site.name,
186 kind,
187 container_path,
188 semantic_hash,
189 span: (site.start_line, site.end_line),
190 });
191 });
192
193 let scaffold_hash = compute_scaffold(parsed.root_node(), source, covered);
194 let syntax_index = parsed.syntax_index();
195
196 Some(ExtractedFile {
197 language,
198 scaffold_hash,
199 symbols,
200 scopes: syntax_index.semantic_scopes().to_vec(),
201 imports: syntax_index.semantic_imports().to_vec(),
202 occurrences: syntax_index.occurrences().to_vec(),
203 })
204}
205
206fn compute_scaffold(
211 root: tree_sitter::Node<'_>,
212 source: &[u8],
213 mut covered: Vec<(usize, usize)>,
214) -> ContentHash {
215 covered.sort_by_key(|&(start, _)| start);
218 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(covered.len());
219 for (start, end) in covered {
220 match merged.last_mut() {
221 Some(last) if start <= last.1 => last.1 = last.1.max(end),
222 _ => merged.push((start, end)),
223 }
224 }
225
226 let mut stream: Vec<u8> = Vec::new();
227 walk_non_comment_leaves(root, |leaf| {
228 let range = leaf.byte_range();
229 if is_covered(&merged, range.start, range.end) {
230 return;
231 }
232 let bytes = &source[range];
233 stream.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
234 stream.extend_from_slice(bytes);
235 });
236 compute_file_scaffold_hash(&stream)
237}
238
239fn is_covered(merged: &[(usize, usize)], start: usize, end: usize) -> bool {
242 match merged.binary_search_by(|&(interval_start, _)| interval_start.cmp(&start)) {
243 Ok(i) => merged[i].1 >= end,
244 Err(0) => false,
245 Err(i) => merged[i - 1].1 >= end,
246 }
247}
248
249fn symbol_semantic_hash(
252 node: tree_sitter::Node<'_>,
253 source: &[u8],
254 kind: SymbolKindTag,
255) -> ContentHash {
256 let mut token_stream: Vec<u8> = Vec::new();
257 walk_non_comment_leaves(node, |leaf| {
258 let bytes = &source[leaf.byte_range()];
259 token_stream.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
260 token_stream.extend_from_slice(bytes);
261 });
262 compute_symbol_semantic_hash(kind, &token_stream)
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 fn execution_budget() -> crate::parser::ParseBudget {
270 crate::parser::ParseBudget {
271 cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
272 deadline: std::time::Instant::now() + std::time::Duration::from_secs(30),
273 }
274 }
275
276 #[test]
277 fn bounded_extraction_rejects_dense_fact_expansion_before_owned_index() {
278 let source = format!("fn dense() {{ {} }}", "let value = item;".repeat(12_000));
279 assert!(source.len() < extraction_budget::MAX_SOURCE_BYTES);
280 let error =
281 extract_semantic_file_bounded(source.as_bytes(), Language::Rust, &execution_budget())
282 .err()
283 .unwrap_or_else(|| panic!("dense AST must fail before allocating owned facts"));
284 assert!(
285 matches!(error, ExtractionBudgetError::Exceeded("AST node count")),
286 "{error}"
287 );
288 }
289
290 #[test]
291 fn bounded_extraction_rejects_nested_expansion_without_opaque_success() {
292 let source = format!(
293 "{}fn leaf() {{}}{}",
294 "mod nested {".repeat(140),
295 "}".repeat(140)
296 );
297 assert!(source.len() < extraction_budget::MAX_SOURCE_BYTES);
298 let error =
299 extract_semantic_file_bounded(source.as_bytes(), Language::Rust, &execution_budget())
300 .err()
301 .unwrap_or_else(|| panic!("nested AST must fail before allocating owned facts"));
302 assert!(
303 matches!(error, ExtractionBudgetError::Exceeded("AST depth")),
304 "{error}"
305 );
306 }
307
308 #[test]
309 fn bounded_extraction_preserves_actual_semantic_facts() {
310 let source = b"use crate::api::Item; fn answer(item: Item) -> u32 { item.value() }";
311 let ordinary = extract_semantic_file(source, Language::Rust).expect("ordinary parse");
312 let bounded = extract_semantic_file_bounded(source, Language::Rust, &execution_budget())
313 .expect("within budget")
314 .expect("bounded parse");
315 assert!(!bounded.symbols.is_empty());
316 assert!(!bounded.occurrences.is_empty());
317 assert_eq!(bounded.symbols, ordinary.symbols);
318 assert_eq!(bounded.scopes, ordinary.scopes);
319 assert_eq!(bounded.imports, ordinary.imports);
320 assert_eq!(bounded.occurrences, ordinary.occurrences);
321 assert_eq!(bounded.scaffold_hash, ordinary.scaffold_hash);
322 }
323
324 fn extract(src: &str) -> Vec<SymbolEntry> {
325 extract_semantic_file(src.as_bytes(), Language::Rust)
326 .expect("rust parse")
327 .symbols
328 }
329
330 fn scaffold(src: &str) -> ContentHash {
331 extract_semantic_file(src.as_bytes(), Language::Rust)
332 .expect("rust parse")
333 .scaffold_hash
334 }
335
336 #[test]
340 fn scaffold_binds_non_definition_content() {
341 assert_ne!(
343 scaffold("use a::x;\nfn f() { g(); }\n"),
344 scaffold("use b::x;\nfn f() { g(); }\n"),
345 "use-decl swap"
346 );
347 assert_ne!(
349 scaffold("struct S;\nimpl Display for S { fn fmt(&self) {} }\n"),
350 scaffold("struct S;\nimpl Debug for S { fn fmt(&self) {} }\n"),
351 "impl trait change"
352 );
353 assert_ne!(
355 scaffold("fn f() { g(); }\n"),
356 scaffold("#[inline]\nfn f() { g(); }\n"),
357 "attribute add"
358 );
359 assert_ne!(
361 scaffold("macro_rules! m { () => { 1 }; }\n"),
362 scaffold("macro_rules! m { () => { 2 }; }\n"),
363 "macro_rules body edit"
364 );
365 assert_ne!(
368 scaffold("pub use crate::a::Foo;\n"),
369 scaffold("pub use crate::b::Bar;\n"),
370 "definition-free re-export files"
371 );
372 }
373
374 #[test]
376 fn scaffold_is_reformat_and_comment_stable() {
377 assert_eq!(
378 scaffold("use a::x;\nfn f() { g(); }\n"),
379 scaffold("use a::x;\n\n// note\nfn f() {\n g();\n}\n"),
380 );
381 }
382
383 #[test]
384 fn reformat_leaves_symbol_hash_stable() {
385 let a = "fn add(a: i32, b: i32) -> i32 { a + b }\n";
386 let b = "fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n";
387 let sa = extract(a);
388 let sb = extract(b);
389 assert_eq!(sa.len(), 1);
390 assert_eq!(sb.len(), 1);
391 assert_eq!(
392 sa[0].semantic_hash, sb[0].semantic_hash,
393 "reformatting must not change the symbol semantic_hash"
394 );
395 }
396
397 #[test]
398 fn comment_edit_leaves_symbol_hash_stable() {
399 let a = "fn f() {\n // old comment\n g();\n}\n";
400 let b = "fn f() {\n // a completely different comment\n g();\n}\n";
401 assert_eq!(extract(a)[0].semantic_hash, extract(b)[0].semantic_hash);
402 }
403
404 #[test]
405 fn one_token_change_perturbs_only_that_symbol() {
406 let a = "fn f() -> i32 { 1 }\nfn g() -> i32 { 2 }\n";
407 let b = "fn f() -> i32 { 1 }\nfn g() -> i32 { 3 }\n";
408 let sa = extract(a);
409 let sb = extract(b);
410 let f_a = sa.iter().find(|s| s.name == "f").unwrap();
411 let f_b = sb.iter().find(|s| s.name == "f").unwrap();
412 let g_a = sa.iter().find(|s| s.name == "g").unwrap();
413 let g_b = sb.iter().find(|s| s.name == "g").unwrap();
414 assert_eq!(
415 f_a.semantic_hash, f_b.semantic_hash,
416 "untouched symbol stable"
417 );
418 assert_ne!(
419 g_a.semantic_hash, g_b.semantic_hash,
420 "edited symbol changes"
421 );
422 }
423
424 #[test]
425 fn string_literal_contents_included() {
426 let a = "fn f() { let s = \"hello\"; }\n";
427 let b = "fn f() { let s = \"world\"; }\n";
428 assert_ne!(
429 extract(a)[0].semantic_hash,
430 extract(b)[0].semantic_hash,
431 "string literal contents are part of the fingerprint"
432 );
433 }
434
435 #[test]
436 fn types_are_first_class() {
437 let src = "struct S { x: u32 }\nenum E { A, B }\ntrait T { fn m(&self); }\n";
438 let names: Vec<_> = extract(src).into_iter().map(|s| (s.name, s.kind)).collect();
439 assert!(names.contains(&("S".to_string(), SymbolKindTag::Type)));
440 assert!(names.contains(&("E".to_string(), SymbolKindTag::Enum)));
441 assert!(names.contains(&("T".to_string(), SymbolKindTag::Trait)));
442 }
443
444 #[test]
445 fn unsupported_language_is_none() {
446 assert!(extract_semantic_file(b"whatever", Language::Unknown).is_none());
447 }
448
449 #[cfg(feature = "lang-javascript")]
450 #[test]
451 fn javascript_object_function_property_is_part_of_v5_index() {
452 let src = b"const handlers = { save: async (value) => value };\n";
453 let extracted = extract_semantic_file(src, Language::JavaScript).expect("javascript parse");
454 assert_eq!(EXTRACTOR_VERSION, 5);
455 assert!(
456 extracted
457 .symbols
458 .iter()
459 .any(|symbol| symbol.name == "save" && symbol.kind == SymbolKindTag::Function),
460 "v5 must index object-literal function properties"
461 );
462 }
463
464 #[test]
465 fn extracts_structured_imports_and_symbol_occurrences() {
466 use objects::object::{ImportKindTag, OccurrenceRole, SymbolNamespace};
467
468 let source = "pub use crate::api::{greet as hello, User};\nfn run(user: User) { crate::api::greet(); hello(); }\n";
469 let extracted = extract_semantic_file(source.as_bytes(), Language::Rust).unwrap();
470
471 assert_eq!(extracted.imports.len(), 1);
472 let import = &extracted.imports[0];
473 assert_eq!(import.kind, ImportKindTag::Reexport);
474 assert_eq!(import.module_specifier, "crate::api");
475 assert_eq!(
476 import
477 .bindings
478 .iter()
479 .map(|binding| (&*binding.imported, &*binding.local))
480 .collect::<Vec<_>>(),
481 vec![("greet", "hello"), ("User", "User")]
482 );
483
484 assert!(extracted.occurrences.iter().any(|occurrence| {
485 occurrence.role == OccurrenceRole::Call
486 && occurrence.name == "greet"
487 && occurrence.qualifier == ["crate", "api"]
488 }));
489 assert!(extracted.occurrences.iter().any(|occurrence| {
490 occurrence.role == OccurrenceRole::Call && occurrence.name == "hello"
491 }));
492 assert!(extracted.occurrences.iter().any(|occurrence| {
493 occurrence.role == OccurrenceRole::TypeReference
494 && occurrence.name == "User"
495 && occurrence.namespace == SymbolNamespace::Type
496 }));
497 assert!(extracted.occurrences.iter().any(|occurrence| {
498 occurrence.role == OccurrenceRole::Definition
499 && occurrence.name == "run"
500 && occurrence.scope == 0
501 }));
502 assert_eq!(extracted.scopes[0].local_id, 0);
503 }
504
505 #[cfg(feature = "lang-typescript")]
506 #[test]
507 fn typescript_import_bindings_reexports_and_calls_are_source_local() {
508 use objects::object::{ImportKindTag, OccurrenceRole, SymbolNamespace};
509
510 let source = "import type { Request as Req } from './types';\nimport client, { run as execute } from './client';\nexport { User } from './models';\nexport function handle(req: Req) { client.run(); execute(); }\n";
511 let extracted = extract_semantic_file(source.as_bytes(), Language::TypeScript).unwrap();
512
513 let types = extracted
514 .imports
515 .iter()
516 .find(|import| import.module_specifier == "./types")
517 .unwrap();
518 assert_eq!(types.bindings[0].imported, "Request");
519 assert_eq!(types.bindings[0].local, "Req");
520 assert_eq!(types.bindings[0].namespace, SymbolNamespace::Type);
521
522 let client = extracted
523 .imports
524 .iter()
525 .find(|import| import.module_specifier == "./client")
526 .unwrap();
527 assert_eq!(
528 client
529 .bindings
530 .iter()
531 .map(|binding| (&*binding.imported, &*binding.local))
532 .collect::<Vec<_>>(),
533 vec![("default", "client"), ("run", "execute")]
534 );
535 assert_eq!(
536 extracted
537 .imports
538 .iter()
539 .find(|import| import.module_specifier == "./models")
540 .unwrap()
541 .kind,
542 ImportKindTag::Reexport
543 );
544 assert!(extracted.occurrences.iter().any(|occurrence| {
545 occurrence.role == OccurrenceRole::Call
546 && occurrence.name == "run"
547 && occurrence.qualifier == ["client"]
548 }));
549 }
550
551 #[cfg(feature = "lang-zig")]
557 #[test]
558 fn zig_blob_extracts_real_symbols_with_hashes() {
559 let src = "pub const Point = struct {\n x: f64,\n pub fn dist(self: Point) f64 { return self.x; }\n};\n\ntest \"works\" { _ = 1; }\n";
560 let extracted = extract_semantic_file(src.as_bytes(), Language::Zig)
561 .expect("zig parses to a real node");
562 assert_eq!(language_name(extracted.language), "zig");
563
564 let by_name = |n: &str| extracted.symbols.iter().find(|s| s.name == n);
565 let point = by_name("Point").expect("Point type extracted");
566 assert_eq!(point.kind, SymbolKindTag::Type);
567 let dist = by_name("dist").expect("method extracted");
568 assert_eq!(dist.kind, SymbolKindTag::Function);
569 assert_eq!(dist.container_path, vec!["Point".to_string()]);
570 let test = by_name("test:\"works\"").expect("test block extracted");
571 assert_eq!(test.kind, SymbolKindTag::Function);
572
573 assert!(
575 extracted
576 .symbols
577 .iter()
578 .all(|s| s.semantic_hash != ContentHash::compute(b"")),
579 "symbols must carry real semantic hashes"
580 );
581 }
582
583 #[cfg(feature = "lang-zig")]
587 #[test]
588 fn zig_reformat_leaves_semantic_digest_stable() {
589 use objects::object::SemanticFileNode;
590
591 let tight = "const std = @import(\"std\");\npub fn add(a: i32, b: i32) i32 { return a + b; }\npub const Point = struct { x: f64, pub fn dist(self: Point) f64 { return self.x; } };\n";
592 let loose = "const std = @import(\"std\");\n\n// a comment\npub fn add(a: i32, b: i32) i32 {\n return a + b;\n}\n\npub const Point = struct {\n // fields\n x: f64,\n pub fn dist(self: Point) f64 {\n return self.x;\n }\n};\n";
593
594 let ea = extract_semantic_file(tight.as_bytes(), Language::Zig).expect("tight parses");
595 let eb = extract_semantic_file(loose.as_bytes(), Language::Zig).expect("loose parses");
596
597 assert_eq!(ea.imports.len(), 1);
598 assert_eq!(ea.imports[0].module_specifier, "std");
599 assert_eq!(ea.imports[0].kind, objects::object::ImportKindTag::Dynamic);
600
601 assert_eq!(
602 ea.scaffold_hash, eb.scaffold_hash,
603 "scaffold must be reformat/comment stable"
604 );
605
606 let node = |e: &ExtractedFile, src: &str| {
607 SemanticFileNode::new(
608 language_name(e.language),
609 grammar_version(e.language),
610 EXTRACTOR_VERSION,
611 ContentHash::compute(src.as_bytes()),
612 e.scaffold_hash,
613 objects::object::SemanticFileFacts {
614 symbols: e.symbols.clone(),
615 scopes: e.scopes.clone(),
616 imports: e.imports.clone(),
617 occurrences: e.occurrences.clone(),
618 },
619 )
620 };
621 assert_eq!(
623 node(&ea, tight).semantic_digest,
624 node(&eb, loose).semantic_digest,
625 "reformatting must not perturb the file semantic_digest"
626 );
627 }
628}