Skip to main content

semantic/
semantic_index.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Semantic-index *extraction*: turning a source blob into the per-file symbol
3//! list the merkle semantic index (heddle#1067) stores.
4//!
5//! The index node types and the canonical digest/hash byte layouts live in the
6//! `objects` crate ([`objects::object::semantic_index`]); this module owns the
7//! grammar-facing half — walking the AST to produce [`SymbolEntry`] values with
8//! their normalization-stable `semantic_hash`. Assembly of the tree and the
9//! store wiring live in the `repo` crate.
10//!
11//! A symbol's `semantic_hash` is a pure function of
12//! `(source bytes, grammar, extractor_version)`: a DFS in document order over
13//! the definition's node, comment subtrees skipped, each remaining leaf emitted
14//! as `u32-LE(byte_len) ‖ exact source bytes`. Whitespace and comments are not
15//! leaves, so reformatting and comment edits leave the hash untouched — while a
16//! one-token change perturbs exactly the symbols that contain it.
17
18use 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
28/// Version of the extraction logic itself. Bump when the taxonomy, source-fact
29/// extraction, container resolution, or token-stream framing changes the
30/// durable file artifact for unchanged source. It participates in file-node
31/// identity so a bump forces a clean recompute via the supersedes chain.
32///
33/// v2: `hd-sem-file-v2`/`hd-sem-dir-v2` framed layouts, the `scaffold_hash`
34/// (non-definition file content), and mod-qualified `container_path`. Bumped so
35/// any same-version-but-old-layout nodes written by a pre-fix branch checkout
36/// are treated as stale and recomputed rather than digest-compared.
37///
38/// v3: Zig extraction added. `parent_is_reusable` only validates grammars
39/// *present* in the parent's map, so a pre-Zig index (no `"zig"` entry) would
40/// otherwise carry `.zig` files forward as `Opaque` indefinitely. The bump
41/// forces those to recompute so imported Zig repos gain granularity.
42///
43/// v4: Persist deterministic source-local scopes, imports, and symbol
44/// occurrences alongside definitions in each semantic file node.
45///
46/// v5: JavaScript and TypeScript object-literal function properties are
47/// extracted from `pair` nodes. The added symbols and covered ranges alter
48/// both the symbol list and scaffold hash for unchanged source, so v4 nodes
49/// must not be reused.
50pub const EXTRACTOR_VERSION: u32 = 5;
51
52/// Stable lowercase language name recorded in file nodes and the root's
53/// grammar map.
54pub 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
69/// Grammar version string for a language — the tree-sitter grammar crate
70/// version. Participates in node identity so a grammar bump recomputes cleanly.
71pub 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
86/// The current grammar version for a language *name* (as recorded in a
87/// [`SemanticIndexRoot`](objects::object::SemanticIndexRoot)'s grammar map).
88/// Used by the builder to detect a grammar bump and refuse stale node reuse.
89pub 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
105/// The symbols extracted from one source file, plus the file scaffold hash,
106/// ready to be assembled into a
107/// [`SemanticFileNode`](objects::object::SemanticFileNode).
108pub struct ExtractedFile {
109    pub language: Language,
110    /// Hash of the residual non-definition top-level token stream — binds
111    /// use-decls, impl/attribute/macro tokens and definition-free files into
112    /// the file digest. See [`compute_file_scaffold_hash`].
113    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
120/// Parse `source` (as `language`) and extract its symbols with per-symbol
121/// normalization-stable hashes, plus the file scaffold hash.
122///
123/// Returns `None` when the language is unsupported or the file fails to parse —
124/// the caller records those as `Opaque` in the index (fingerprint = raw source
125/// blob hash).
126pub fn extract_semantic_file(source: &[u8], language: Language) -> Option<ExtractedFile> {
127    // Unsupported language → no grammar → Opaque.
128    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    // Byte ranges of every extracted definition node — used to carve the
134    // residual scaffold (everything NOT covered by a symbol).
135    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
164/// Hash the file scaffold: every non-comment leaf under the root NOT covered by
165/// an extracted symbol's byte range, length-prefixed in document order. This is
166/// what carries use-decl swaps, `impl Trait` headers, attribute edits,
167/// `macro_rules!` bodies and definition-free files into the file digest.
168fn compute_scaffold(
169    root: tree_sitter::Node<'_>,
170    source: &[u8],
171    mut covered: Vec<(usize, usize)>,
172) -> ContentHash {
173    // Merge symbol ranges into disjoint sorted intervals (they nest — a module
174    // covers its children).
175    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
197/// Whether `[start, end)` lies fully within one of the disjoint sorted
198/// intervals in `merged`.
199fn 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
207/// Build the canonical `hd-sem-sym-v1` token stream for a definition node and
208/// hash it. Length-prefixed leaves in document order, comment subtrees skipped.
209fn 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    /// DEFECT 1: content that lives OUTSIDE any extracted symbol must still
240    /// perturb the file scaffold (and therefore the file digest). Covers all
241    /// five classes Fable flagged as digest false-negatives.
242    #[test]
243    fn scaffold_binds_non_definition_content() {
244        // 1. use-decl swap (same fn body)
245        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        // 2. impl-trait change, identical method body
251        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        // 3. attribute add/remove (attribute_item is a sibling of function_item)
257        assert_ne!(
258            scaffold("fn f() { g(); }\n"),
259            scaffold("#[inline]\nfn f() { g(); }\n"),
260            "attribute add"
261        );
262        // 4. macro_rules! body edit
263        assert_ne!(
264            scaffold("macro_rules! m { () => { 1 }; }\n"),
265            scaffold("macro_rules! m { () => { 2 }; }\n"),
266            "macro_rules body edit"
267        );
268        // 5. definition-free files (re-export only) — two different such files
269        //    must not share a digest.
270        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    /// The scaffold must stay reformat- and comment-stable, like symbol hashes.
278    #[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    // ── Zig (heddle#1068) ────────────────────────────────────────────────
455
456    /// A `.zig` blob must extract a real file node — symbols with per-symbol
457    /// `semantic_hash`es — not the `Opaque` fallback that `Language::Unknown`
458    /// produced before Zig support.
459    #[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        // Every symbol carries a non-degenerate per-symbol semantic_hash.
477        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    /// Reformatting a Zig file — whitespace + comments only — must leave the
487    /// file node's `semantic_digest` (and every symbol hash + the scaffold)
488    /// byte-identical.
489    #[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        // The source blobs differ, but the reformat-stable digest must not.
525        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}