1use objects::object::{
19 ContentHash, ImportEntry, OccurrenceEntry, ScopeEntry, SymbolEntry, SymbolKindTag,
20 compute_file_scaffold_hash, compute_symbol_semantic_hash,
21};
22
23use crate::{
24 parser::{Language, ParsedFile, walk_non_comment_leaves},
25 symbol_resolver::visit_definitions,
26};
27
28pub const EXTRACTOR_VERSION: u32 = 5;
51
52pub fn language_name(language: Language) -> &'static str {
55 match language {
56 Language::Rust => "rust",
57 Language::Python => "python",
58 Language::JavaScript => "javascript",
59 Language::TypeScript => "typescript",
60 Language::Go => "go",
61 Language::C => "c",
62 Language::Cpp => "cpp",
63 Language::Java => "java",
64 Language::Zig => "zig",
65 Language::Unknown => "unknown",
66 }
67}
68
69pub fn grammar_version(language: Language) -> &'static str {
72 match language {
73 Language::Rust => "tree-sitter-rust@0.24",
74 Language::Python => "tree-sitter-python@0.25",
75 Language::JavaScript => "tree-sitter-javascript@0.25",
76 Language::TypeScript => "tree-sitter-typescript@0.23",
77 Language::Go => "tree-sitter-go@0.25",
78 Language::C => "tree-sitter-c@0.24",
79 Language::Cpp => "tree-sitter-cpp@0.23",
80 Language::Java => "tree-sitter-java@0.23",
81 Language::Zig => "tree-sitter-zig@1.1",
82 Language::Unknown => "none",
83 }
84}
85
86pub fn grammar_version_by_name(name: &str) -> Option<&'static str> {
90 let language = match name {
91 "rust" => Language::Rust,
92 "python" => Language::Python,
93 "javascript" => Language::JavaScript,
94 "typescript" => Language::TypeScript,
95 "go" => Language::Go,
96 "c" => Language::C,
97 "cpp" => Language::Cpp,
98 "java" => Language::Java,
99 "zig" => Language::Zig,
100 _ => return None,
101 };
102 Some(grammar_version(language))
103}
104
105pub struct ExtractedFile {
109 pub language: Language,
110 pub scaffold_hash: ContentHash,
114 pub symbols: Vec<SymbolEntry>,
115 pub scopes: Vec<ScopeEntry>,
116 pub imports: Vec<ImportEntry>,
117 pub occurrences: Vec<OccurrenceEntry>,
118}
119
120pub fn extract_semantic_file(source: &[u8], language: Language) -> Option<ExtractedFile> {
127 language.parser_handle()?;
129 let source_text = std::str::from_utf8(source).ok()?;
130 let parsed = ParsedFile::parse(source_text, language)?;
131
132 let mut symbols = Vec::new();
133 let mut covered: Vec<(usize, usize)> = Vec::new();
136 visit_definitions(parsed.root_node(), source, &mut |site| {
137 let kind = site.kind;
138 let semantic_hash = symbol_semantic_hash(site.node, source, kind);
139 let container_path = site.parent_name.map(|p| vec![p]).unwrap_or_default();
140 let range = site.node.byte_range();
141 covered.push((range.start, range.end));
142 symbols.push(SymbolEntry {
143 name: site.name,
144 kind,
145 container_path,
146 semantic_hash,
147 span: (site.start_line, site.end_line),
148 });
149 });
150
151 let scaffold_hash = compute_scaffold(parsed.root_node(), source, covered);
152 let syntax_index = parsed.syntax_index();
153
154 Some(ExtractedFile {
155 language,
156 scaffold_hash,
157 symbols,
158 scopes: syntax_index.semantic_scopes().to_vec(),
159 imports: syntax_index.semantic_imports().to_vec(),
160 occurrences: syntax_index.occurrences().to_vec(),
161 })
162}
163
164fn compute_scaffold(
169 root: tree_sitter::Node<'_>,
170 source: &[u8],
171 mut covered: Vec<(usize, usize)>,
172) -> ContentHash {
173 covered.sort_by_key(|&(start, _)| start);
176 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(covered.len());
177 for (start, end) in covered {
178 match merged.last_mut() {
179 Some(last) if start <= last.1 => last.1 = last.1.max(end),
180 _ => merged.push((start, end)),
181 }
182 }
183
184 let mut stream: Vec<u8> = Vec::new();
185 walk_non_comment_leaves(root, |leaf| {
186 let range = leaf.byte_range();
187 if is_covered(&merged, range.start, range.end) {
188 return;
189 }
190 let bytes = &source[range];
191 stream.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
192 stream.extend_from_slice(bytes);
193 });
194 compute_file_scaffold_hash(&stream)
195}
196
197fn is_covered(merged: &[(usize, usize)], start: usize, end: usize) -> bool {
200 match merged.binary_search_by(|&(interval_start, _)| interval_start.cmp(&start)) {
201 Ok(i) => merged[i].1 >= end,
202 Err(0) => false,
203 Err(i) => merged[i - 1].1 >= end,
204 }
205}
206
207fn symbol_semantic_hash(
210 node: tree_sitter::Node<'_>,
211 source: &[u8],
212 kind: SymbolKindTag,
213) -> ContentHash {
214 let mut token_stream: Vec<u8> = Vec::new();
215 walk_non_comment_leaves(node, |leaf| {
216 let bytes = &source[leaf.byte_range()];
217 token_stream.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
218 token_stream.extend_from_slice(bytes);
219 });
220 compute_symbol_semantic_hash(kind, &token_stream)
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn extract(src: &str) -> Vec<SymbolEntry> {
228 extract_semantic_file(src.as_bytes(), Language::Rust)
229 .expect("rust parse")
230 .symbols
231 }
232
233 fn scaffold(src: &str) -> ContentHash {
234 extract_semantic_file(src.as_bytes(), Language::Rust)
235 .expect("rust parse")
236 .scaffold_hash
237 }
238
239 #[test]
243 fn scaffold_binds_non_definition_content() {
244 assert_ne!(
246 scaffold("use a::x;\nfn f() { g(); }\n"),
247 scaffold("use b::x;\nfn f() { g(); }\n"),
248 "use-decl swap"
249 );
250 assert_ne!(
252 scaffold("struct S;\nimpl Display for S { fn fmt(&self) {} }\n"),
253 scaffold("struct S;\nimpl Debug for S { fn fmt(&self) {} }\n"),
254 "impl trait change"
255 );
256 assert_ne!(
258 scaffold("fn f() { g(); }\n"),
259 scaffold("#[inline]\nfn f() { g(); }\n"),
260 "attribute add"
261 );
262 assert_ne!(
264 scaffold("macro_rules! m { () => { 1 }; }\n"),
265 scaffold("macro_rules! m { () => { 2 }; }\n"),
266 "macro_rules body edit"
267 );
268 assert_ne!(
271 scaffold("pub use crate::a::Foo;\n"),
272 scaffold("pub use crate::b::Bar;\n"),
273 "definition-free re-export files"
274 );
275 }
276
277 #[test]
279 fn scaffold_is_reformat_and_comment_stable() {
280 assert_eq!(
281 scaffold("use a::x;\nfn f() { g(); }\n"),
282 scaffold("use a::x;\n\n// note\nfn f() {\n g();\n}\n"),
283 );
284 }
285
286 #[test]
287 fn reformat_leaves_symbol_hash_stable() {
288 let a = "fn add(a: i32, b: i32) -> i32 { a + b }\n";
289 let b = "fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n";
290 let sa = extract(a);
291 let sb = extract(b);
292 assert_eq!(sa.len(), 1);
293 assert_eq!(sb.len(), 1);
294 assert_eq!(
295 sa[0].semantic_hash, sb[0].semantic_hash,
296 "reformatting must not change the symbol semantic_hash"
297 );
298 }
299
300 #[test]
301 fn comment_edit_leaves_symbol_hash_stable() {
302 let a = "fn f() {\n // old comment\n g();\n}\n";
303 let b = "fn f() {\n // a completely different comment\n g();\n}\n";
304 assert_eq!(extract(a)[0].semantic_hash, extract(b)[0].semantic_hash);
305 }
306
307 #[test]
308 fn one_token_change_perturbs_only_that_symbol() {
309 let a = "fn f() -> i32 { 1 }\nfn g() -> i32 { 2 }\n";
310 let b = "fn f() -> i32 { 1 }\nfn g() -> i32 { 3 }\n";
311 let sa = extract(a);
312 let sb = extract(b);
313 let f_a = sa.iter().find(|s| s.name == "f").unwrap();
314 let f_b = sb.iter().find(|s| s.name == "f").unwrap();
315 let g_a = sa.iter().find(|s| s.name == "g").unwrap();
316 let g_b = sb.iter().find(|s| s.name == "g").unwrap();
317 assert_eq!(
318 f_a.semantic_hash, f_b.semantic_hash,
319 "untouched symbol stable"
320 );
321 assert_ne!(
322 g_a.semantic_hash, g_b.semantic_hash,
323 "edited symbol changes"
324 );
325 }
326
327 #[test]
328 fn string_literal_contents_included() {
329 let a = "fn f() { let s = \"hello\"; }\n";
330 let b = "fn f() { let s = \"world\"; }\n";
331 assert_ne!(
332 extract(a)[0].semantic_hash,
333 extract(b)[0].semantic_hash,
334 "string literal contents are part of the fingerprint"
335 );
336 }
337
338 #[test]
339 fn types_are_first_class() {
340 let src = "struct S { x: u32 }\nenum E { A, B }\ntrait T { fn m(&self); }\n";
341 let names: Vec<_> = extract(src).into_iter().map(|s| (s.name, s.kind)).collect();
342 assert!(names.contains(&("S".to_string(), SymbolKindTag::Type)));
343 assert!(names.contains(&("E".to_string(), SymbolKindTag::Enum)));
344 assert!(names.contains(&("T".to_string(), SymbolKindTag::Trait)));
345 }
346
347 #[test]
348 fn unsupported_language_is_none() {
349 assert!(extract_semantic_file(b"whatever", Language::Unknown).is_none());
350 }
351
352 #[cfg(feature = "lang-javascript")]
353 #[test]
354 fn javascript_object_function_property_is_part_of_v5_index() {
355 let src = b"const handlers = { save: async (value) => value };\n";
356 let extracted = extract_semantic_file(src, Language::JavaScript).expect("javascript parse");
357 assert_eq!(EXTRACTOR_VERSION, 5);
358 assert!(
359 extracted
360 .symbols
361 .iter()
362 .any(|symbol| symbol.name == "save" && symbol.kind == SymbolKindTag::Function),
363 "v5 must index object-literal function properties"
364 );
365 }
366
367 #[test]
368 fn extracts_structured_imports_and_symbol_occurrences() {
369 use objects::object::{ImportKindTag, OccurrenceRole, SymbolNamespace};
370
371 let source = "pub use crate::api::{greet as hello, User};\nfn run(user: User) { crate::api::greet(); hello(); }\n";
372 let extracted = extract_semantic_file(source.as_bytes(), Language::Rust).unwrap();
373
374 assert_eq!(extracted.imports.len(), 1);
375 let import = &extracted.imports[0];
376 assert_eq!(import.kind, ImportKindTag::Reexport);
377 assert_eq!(import.module_specifier, "crate::api");
378 assert_eq!(
379 import
380 .bindings
381 .iter()
382 .map(|binding| (&*binding.imported, &*binding.local))
383 .collect::<Vec<_>>(),
384 vec![("greet", "hello"), ("User", "User")]
385 );
386
387 assert!(extracted.occurrences.iter().any(|occurrence| {
388 occurrence.role == OccurrenceRole::Call
389 && occurrence.name == "greet"
390 && occurrence.qualifier == ["crate", "api"]
391 }));
392 assert!(extracted.occurrences.iter().any(|occurrence| {
393 occurrence.role == OccurrenceRole::Call && occurrence.name == "hello"
394 }));
395 assert!(extracted.occurrences.iter().any(|occurrence| {
396 occurrence.role == OccurrenceRole::TypeReference
397 && occurrence.name == "User"
398 && occurrence.namespace == SymbolNamespace::Type
399 }));
400 assert!(extracted.occurrences.iter().any(|occurrence| {
401 occurrence.role == OccurrenceRole::Definition
402 && occurrence.name == "run"
403 && occurrence.scope == 0
404 }));
405 assert_eq!(extracted.scopes[0].local_id, 0);
406 }
407
408 #[cfg(feature = "lang-typescript")]
409 #[test]
410 fn typescript_import_bindings_reexports_and_calls_are_source_local() {
411 use objects::object::{ImportKindTag, OccurrenceRole, SymbolNamespace};
412
413 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";
414 let extracted = extract_semantic_file(source.as_bytes(), Language::TypeScript).unwrap();
415
416 let types = extracted
417 .imports
418 .iter()
419 .find(|import| import.module_specifier == "./types")
420 .unwrap();
421 assert_eq!(types.bindings[0].imported, "Request");
422 assert_eq!(types.bindings[0].local, "Req");
423 assert_eq!(types.bindings[0].namespace, SymbolNamespace::Type);
424
425 let client = extracted
426 .imports
427 .iter()
428 .find(|import| import.module_specifier == "./client")
429 .unwrap();
430 assert_eq!(
431 client
432 .bindings
433 .iter()
434 .map(|binding| (&*binding.imported, &*binding.local))
435 .collect::<Vec<_>>(),
436 vec![("default", "client"), ("run", "execute")]
437 );
438 assert_eq!(
439 extracted
440 .imports
441 .iter()
442 .find(|import| import.module_specifier == "./models")
443 .unwrap()
444 .kind,
445 ImportKindTag::Reexport
446 );
447 assert!(extracted.occurrences.iter().any(|occurrence| {
448 occurrence.role == OccurrenceRole::Call
449 && occurrence.name == "run"
450 && occurrence.qualifier == ["client"]
451 }));
452 }
453
454 #[cfg(feature = "lang-zig")]
460 #[test]
461 fn zig_blob_extracts_real_symbols_with_hashes() {
462 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";
463 let extracted = extract_semantic_file(src.as_bytes(), Language::Zig)
464 .expect("zig parses to a real node");
465 assert_eq!(language_name(extracted.language), "zig");
466
467 let by_name = |n: &str| extracted.symbols.iter().find(|s| s.name == n);
468 let point = by_name("Point").expect("Point type extracted");
469 assert_eq!(point.kind, SymbolKindTag::Type);
470 let dist = by_name("dist").expect("method extracted");
471 assert_eq!(dist.kind, SymbolKindTag::Function);
472 assert_eq!(dist.container_path, vec!["Point".to_string()]);
473 let test = by_name("test:\"works\"").expect("test block extracted");
474 assert_eq!(test.kind, SymbolKindTag::Function);
475
476 assert!(
478 extracted
479 .symbols
480 .iter()
481 .all(|s| s.semantic_hash != ContentHash::compute(b"")),
482 "symbols must carry real semantic hashes"
483 );
484 }
485
486 #[cfg(feature = "lang-zig")]
490 #[test]
491 fn zig_reformat_leaves_semantic_digest_stable() {
492 use objects::object::SemanticFileNode;
493
494 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";
495 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";
496
497 let ea = extract_semantic_file(tight.as_bytes(), Language::Zig).expect("tight parses");
498 let eb = extract_semantic_file(loose.as_bytes(), Language::Zig).expect("loose parses");
499
500 assert_eq!(ea.imports.len(), 1);
501 assert_eq!(ea.imports[0].module_specifier, "std");
502 assert_eq!(ea.imports[0].kind, objects::object::ImportKindTag::Dynamic);
503
504 assert_eq!(
505 ea.scaffold_hash, eb.scaffold_hash,
506 "scaffold must be reformat/comment stable"
507 );
508
509 let node = |e: &ExtractedFile, src: &str| {
510 SemanticFileNode::new(
511 language_name(e.language),
512 grammar_version(e.language),
513 EXTRACTOR_VERSION,
514 ContentHash::compute(src.as_bytes()),
515 e.scaffold_hash,
516 objects::object::SemanticFileFacts {
517 symbols: e.symbols.clone(),
518 scopes: e.scopes.clone(),
519 imports: e.imports.clone(),
520 occurrences: e.occurrences.clone(),
521 },
522 )
523 };
524 assert_eq!(
526 node(&ea, tight).semantic_digest,
527 node(&eb, loose).semantic_digest,
528 "reformatting must not perturb the file semantic_digest"
529 );
530 }
531}