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
18mod 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
30/// Version of the extraction logic itself. Bump when the taxonomy, source-fact
31/// extraction, container resolution, or token-stream framing changes the
32/// durable file artifact for unchanged source. It participates in file-node
33/// identity so a bump forces a clean recompute via the supersedes chain.
34///
35/// v2: `hd-sem-file-v2`/`hd-sem-dir-v2` framed layouts, the `scaffold_hash`
36/// (non-definition file content), and mod-qualified `container_path`. Bumped so
37/// any same-version-but-old-layout nodes written by a pre-fix branch checkout
38/// are treated as stale and recomputed rather than digest-compared.
39///
40/// v3: Zig extraction added. `parent_is_reusable` only validates grammars
41/// *present* in the parent's map, so a pre-Zig index (no `"zig"` entry) would
42/// otherwise carry `.zig` files forward as `Opaque` indefinitely. The bump
43/// forces those to recompute so imported Zig repos gain granularity.
44///
45/// v4: Persist deterministic source-local scopes, imports, and symbol
46/// occurrences alongside definitions in each semantic file node.
47///
48/// v5: JavaScript and TypeScript object-literal function properties are
49/// extracted from `pair` nodes. The added symbols and covered ranges alter
50/// both the symbol list and scaffold hash for unchanged source, so v4 nodes
51/// must not be reused.
52pub const EXTRACTOR_VERSION: u32 = 5;
53
54/// Stable lowercase language name recorded in file nodes and the root's
55/// grammar map.
56pub 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
71/// Grammar version string for a language — the tree-sitter grammar crate
72/// version. Participates in node identity so a grammar bump recomputes cleanly.
73pub 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
88/// The current grammar version for a language *name* (as recorded in a
89/// [`SemanticIndexRoot`](objects::object::SemanticIndexRoot)'s grammar map).
90/// Used by the builder to detect a grammar bump and refuse stale node reuse.
91pub 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
107/// The symbols extracted from one source file, plus the file scaffold hash,
108/// ready to be assembled into a
109/// [`SemanticFileNode`](objects::object::SemanticFileNode).
110pub struct ExtractedFile {
111    pub language: Language,
112    /// Hash of the residual non-definition top-level token stream — binds
113    /// use-decls, impl/attribute/macro tokens and definition-free files into
114    /// the file digest. See [`compute_file_scaffold_hash`].
115    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
122/// Parse `source` (as `language`) and extract its symbols with per-symbol
123/// normalization-stable hashes, plus the file scaffold hash.
124///
125/// Returns `None` when the language is unsupported or the file fails to parse —
126/// the caller records those as `Opaque` in the index (fingerprint = raw source
127/// blob hash).
128pub fn extract_semantic_file(source: &[u8], language: Language) -> Option<ExtractedFile> {
129    // Unsupported language → no grammar → Opaque.
130    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
137/// Bounded extraction reports expansion failures separately from unsupported or
138/// syntactically invalid files; callers must not silently turn a limit into
139/// an opaque successful analysis.
140pub 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    // Byte ranges of every extracted definition node — used to carve the
176    // residual scaffold (everything NOT covered by a symbol).
177    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
206/// Hash the file scaffold: every non-comment leaf under the root NOT covered by
207/// an extracted symbol's byte range, length-prefixed in document order. This is
208/// what carries use-decl swaps, `impl Trait` headers, attribute edits,
209/// `macro_rules!` bodies and definition-free files into the file digest.
210fn compute_scaffold(
211    root: tree_sitter::Node<'_>,
212    source: &[u8],
213    mut covered: Vec<(usize, usize)>,
214) -> ContentHash {
215    // Merge symbol ranges into disjoint sorted intervals (they nest — a module
216    // covers its children).
217    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
239/// Whether `[start, end)` lies fully within one of the disjoint sorted
240/// intervals in `merged`.
241fn 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
249/// Build the canonical `hd-sem-sym-v1` token stream for a definition node and
250/// hash it. Length-prefixed leaves in document order, comment subtrees skipped.
251fn 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    /// DEFECT 1: content that lives OUTSIDE any extracted symbol must still
337    /// perturb the file scaffold (and therefore the file digest). Covers all
338    /// five classes Fable flagged as digest false-negatives.
339    #[test]
340    fn scaffold_binds_non_definition_content() {
341        // 1. use-decl swap (same fn body)
342        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        // 2. impl-trait change, identical method body
348        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        // 3. attribute add/remove (attribute_item is a sibling of function_item)
354        assert_ne!(
355            scaffold("fn f() { g(); }\n"),
356            scaffold("#[inline]\nfn f() { g(); }\n"),
357            "attribute add"
358        );
359        // 4. macro_rules! body edit
360        assert_ne!(
361            scaffold("macro_rules! m { () => { 1 }; }\n"),
362            scaffold("macro_rules! m { () => { 2 }; }\n"),
363            "macro_rules body edit"
364        );
365        // 5. definition-free files (re-export only) — two different such files
366        //    must not share a digest.
367        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    /// The scaffold must stay reformat- and comment-stable, like symbol hashes.
375    #[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    // ── Zig (heddle#1068) ────────────────────────────────────────────────
552
553    /// A `.zig` blob must extract a real file node — symbols with per-symbol
554    /// `semantic_hash`es — not the `Opaque` fallback that `Language::Unknown`
555    /// produced before Zig support.
556    #[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        // Every symbol carries a non-degenerate per-symbol semantic_hash.
574        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    /// Reformatting a Zig file — whitespace + comments only — must leave the
584    /// file node's `semantic_digest` (and every symbol hash + the scaffold)
585    /// byte-identical.
586    #[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        // The source blobs differ, but the reformat-stable digest must not.
622        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}