Skip to main content

repo/
repository_semantic_query.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Read-only, never-compute query primitives over the merkle semantic index
3//! (heddle#1067 / heddle#1078).
4//!
5//! This module is **always compiled** — it is deliberately free of any
6//! dependency on `heddle-semantic`/tree-sitter. Every primitive here is a pure
7//! store walk over already-stored, content-addressed semantic nodes; NONE of
8//! them ever parse a source blob or recompute an index. A consumer that never
9//! parses (e.g. `weft`, which must never carry a parser) reads the persisted
10//! index through exactly these entry points.
11//!
12//! The get-or-compute / self-heal / backfill / builder machinery — everything
13//! that can *produce* index nodes — lives in `repository_semantic_index`, gated
14//! behind the `tree-sitter-symbols` feature.
15
16use std::collections::{BTreeMap, HashMap};
17
18use objects::{
19    object::{
20        ContentHash, SemanticEntryKind, SemanticFileNode, SemanticIndexRoot, SemanticTreeEntry,
21        SemanticTreeNode, StateId, SymbolAnchor, SymbolEntry, SymbolKindTag,
22    },
23    store::ObjectStore,
24};
25
26use crate::{HeddleError, Repository, Result, StateAttachmentKind};
27
28/// Recursion ceiling for the merkle tree/dir walkers. Legitimate directory
29/// nesting is far below this; a crafted, pushed `SemanticTreeNode` chain deeper
30/// than this is treated as pathological rather than overflowing the stack
31/// (same hardening class as the AST walkers, heddle#876). Shared by the
32/// (gated) builder in `repository_semantic_index`.
33pub(crate) const MAX_SEMANTIC_TREE_DEPTH: usize = 1024;
34
35/// A single-symbol delta between two states, produced by
36/// [`Repository::semantic_diff_symbols`].
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct SymbolDelta {
39    /// File path + canonical symbol address (`container::name`).
40    pub anchor: SymbolAnchor,
41    pub kind: SymbolKindTag,
42    /// Fingerprint on the `a` side, `None` if the symbol did not exist there.
43    pub old_hash: Option<ContentHash>,
44    /// Fingerprint on the `b` side, `None` if the symbol does not exist there.
45    pub new_hash: Option<ContentHash>,
46}
47
48impl Repository {
49    /// Load a state's attached semantic index root, if present AND intact —
50    /// **never recomputing**. A missing attachment, a body of another kind, or
51    /// a missing/corrupt root blob (e.g. a partially-replicated push, or GC that
52    /// pruned the sidecar) is reported as ABSENT — `Ok(None)`.
53    ///
54    /// This is the parse-free read entry point: it only ever loads an
55    /// already-stored root. The get-or-compute behaviour (build forward from
56    /// the nearest indexed ancestor) lives in the tree-sitter-gated
57    /// [`Repository::semantic_index`](crate::Repository) and is NOT reachable
58    /// from here.
59    pub fn attached_semantic_index(&self, state_id: &StateId) -> Result<Option<SemanticIndexRoot>> {
60        let Some(attachment) =
61            self.latest_state_attachment(state_id, StateAttachmentKind::SemanticIndex)?
62        else {
63            return Ok(None);
64        };
65        let objects::object::StateAttachmentBody::SemanticIndex(root_hash) = attachment.body else {
66            return Ok(None);
67        };
68        let Some(blob) = self.store().get_blob(&root_hash)? else {
69            return Ok(None);
70        };
71        Ok(SemanticIndexRoot::decode(blob.content()).ok())
72    }
73
74    /// Load the per-file semantic node for `path` in a state's attached index —
75    /// a pure store walk to the file node, then the node blob. `Ok(None)` when
76    /// the state has no attached index, or the path is absent / resolves to a
77    /// dir or opaque entry. NEVER parses.
78    pub fn semantic_file_node(
79        &self,
80        state_id: &StateId,
81        path: &str,
82    ) -> Result<Option<SemanticFileNode>> {
83        let Some(root) = self.attached_semantic_index(state_id)? else {
84            return Ok(None);
85        };
86        let Some(node_hash) = self.resolve_file_node(&root, path)? else {
87            return Ok(None);
88        };
89        Ok(Some(self.load_semantic_file(&node_hash)?))
90    }
91
92    /// Whether the symbol at `anchor` changed between `since` and `at`.
93    /// Resolves each side through whichever `symbol_hash` is compiled (the
94    /// never-compute reader without the parser, the get-or-compute reader with
95    /// it).
96    pub fn changed_since(
97        &self,
98        anchor: &SymbolAnchor,
99        since: &StateId,
100        at: &StateId,
101    ) -> Result<bool> {
102        let old = self.symbol_hash(since, anchor)?.map(|s| s.semantic_hash);
103        let new = self.symbol_hash(at, anchor)?.map(|s| s.semantic_hash);
104        Ok(old != new)
105    }
106
107    // ----- never-compute inner walks (shared) -------------------------------
108
109    /// Never-compute `symbol_hash`: resolve an anchor against the state's
110    /// ATTACHED index only. Backs the public `symbol_hash` in the parse-free
111    /// build; the tree-sitter build's `symbol_hash` is get-or-compute instead.
112    #[cfg(not(feature = "tree-sitter-symbols"))]
113    pub(crate) fn symbol_hash_readonly(
114        &self,
115        state_id: &StateId,
116        anchor: &SymbolAnchor,
117    ) -> Result<Option<SymbolEntry>> {
118        let Some(root) = self.attached_semantic_index(state_id)? else {
119            return Ok(None);
120        };
121        let Some(file_node_hash) = self.resolve_file_node(&root, &anchor.file)? else {
122            return Ok(None);
123        };
124        let file = self.load_semantic_file(&file_node_hash)?;
125        Ok(file.symbol_by_address(&anchor.symbol).cloned())
126    }
127
128    /// Never-compute `semantic_changed`: compare the reformat-stable digest at
129    /// `path_prefix` between the two states' ATTACHED indexes.
130    #[cfg(not(feature = "tree-sitter-symbols"))]
131    pub(crate) fn semantic_changed_readonly(
132        &self,
133        a: &StateId,
134        b: &StateId,
135        path_prefix: &str,
136    ) -> Result<bool> {
137        match (
138            self.attached_semantic_index(a)?,
139            self.attached_semantic_index(b)?,
140        ) {
141            (Some(root_a), Some(root_b)) => {
142                let da = self.digest_at_path(&root_a, path_prefix)?;
143                let db = self.digest_at_path(&root_b, path_prefix)?;
144                Ok(da != db)
145            }
146            // A missing index on either side is a difference iff the other exists.
147            (a_opt, b_opt) => Ok(a_opt.is_some() != b_opt.is_some()),
148        }
149    }
150
151    /// Never-compute `semantic_diff_symbols`: merkle-walk the two states'
152    /// ATTACHED indexes, descending only into differing digests.
153    #[cfg(not(feature = "tree-sitter-symbols"))]
154    pub(crate) fn semantic_diff_symbols_readonly(
155        &self,
156        a: &StateId,
157        b: &StateId,
158    ) -> Result<Vec<SymbolDelta>> {
159        let (root_a, root_b) = match (
160            self.attached_semantic_index(a)?,
161            self.attached_semantic_index(b)?,
162        ) {
163            (Some(ra), Some(rb)) => (ra, rb),
164            _ => return Ok(Vec::new()),
165        };
166        if root_a.semantic_digest == root_b.semantic_digest {
167            return Ok(Vec::new());
168        }
169        let node_a = self.load_semantic_tree(&root_a.tree)?;
170        let node_b = self.load_semantic_tree(&root_b.tree)?;
171        let mut out = Vec::new();
172        self.diff_tree_nodes(&node_a, &node_b, "", 0, &mut out)?;
173        Ok(out)
174    }
175
176    // ----- shared node loaders + walkers ------------------------------------
177
178    /// Load and decode an index root blob by hash. Used only by the (gated)
179    /// compute paths that just persisted a root and want it back as a struct;
180    /// the read paths go through [`Repository::attached_semantic_index`].
181    #[cfg(feature = "tree-sitter-symbols")]
182    pub(crate) fn load_index_root(&self, root_hash: &ContentHash) -> Result<SemanticIndexRoot> {
183        let blob = self.store().get_blob(root_hash)?.ok_or_else(|| {
184            crate::HeddleError::NotFound(format!("semantic index root {root_hash}"))
185        })?;
186        SemanticIndexRoot::decode(blob.content())
187            .map_err(|err| crate::HeddleError::InvalidObject(err.to_string()))
188    }
189
190    pub(crate) fn load_semantic_tree(&self, node_hash: &ContentHash) -> Result<SemanticTreeNode> {
191        let blob = self.store().get_blob(node_hash)?.ok_or_else(|| {
192            crate::HeddleError::NotFound(format!("semantic tree node {node_hash}"))
193        })?;
194        SemanticTreeNode::decode(blob.content())
195            .map_err(|err| crate::HeddleError::InvalidObject(err.to_string()))
196    }
197
198    pub(crate) fn load_semantic_file(&self, node_hash: &ContentHash) -> Result<SemanticFileNode> {
199        let blob = self.store().get_blob(node_hash)?.ok_or_else(|| {
200            crate::HeddleError::NotFound(format!("semantic file node {node_hash}"))
201        })?;
202        SemanticFileNode::decode(blob.content())
203            .map_err(|err| crate::HeddleError::InvalidObject(err.to_string()))
204    }
205
206    /// Walk the semantic tree to the `File` node for `path`, returning its
207    /// storage hash. `None` if the path is absent or resolves to a dir/opaque.
208    pub(crate) fn resolve_file_node(
209        &self,
210        root: &SemanticIndexRoot,
211        path: &str,
212    ) -> Result<Option<ContentHash>> {
213        let components: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
214        if components.is_empty() {
215            return Ok(None);
216        }
217        let mut node = self.load_semantic_tree(&root.tree)?;
218        for (i, comp) in components.iter().enumerate() {
219            let Some(entry) = node.get(comp) else {
220                return Ok(None);
221            };
222            let last = i + 1 == components.len();
223            match (last, entry.kind) {
224                (true, SemanticEntryKind::File) => return Ok(Some(entry.node)),
225                (false, SemanticEntryKind::Dir) => {
226                    node = self.load_semantic_tree(&entry.node)?;
227                }
228                _ => return Ok(None),
229            }
230        }
231        Ok(None)
232    }
233
234    /// The reformat-stable digest at `path_prefix` within an index. Empty prefix
235    /// yields the whole-tree digest. Loads only the tree nodes along the path.
236    pub(crate) fn digest_at_path(
237        &self,
238        root: &SemanticIndexRoot,
239        path_prefix: &str,
240    ) -> Result<Option<ContentHash>> {
241        let components: Vec<&str> = path_prefix.split('/').filter(|c| !c.is_empty()).collect();
242        if components.is_empty() {
243            return Ok(Some(root.semantic_digest));
244        }
245        let mut node = self.load_semantic_tree(&root.tree)?;
246        for (i, comp) in components.iter().enumerate() {
247            let Some(entry) = node.get(comp) else {
248                return Ok(None);
249            };
250            let last = i + 1 == components.len();
251            if last {
252                return Ok(Some(entry.semantic_digest));
253            }
254            if entry.kind != SemanticEntryKind::Dir {
255                return Ok(None);
256            }
257            node = self.load_semantic_tree(&entry.node)?;
258        }
259        Ok(None)
260    }
261
262    pub(crate) fn diff_tree_nodes(
263        &self,
264        a: &SemanticTreeNode,
265        b: &SemanticTreeNode,
266        prefix: &str,
267        depth: usize,
268        out: &mut Vec<SymbolDelta>,
269    ) -> Result<()> {
270        if depth > MAX_SEMANTIC_TREE_DEPTH {
271            return Err(HeddleError::InvalidObject(format!(
272                "semantic diff exceeds max depth {MAX_SEMANTIC_TREE_DEPTH}"
273            )));
274        }
275        let a_by_name: HashMap<&str, &SemanticTreeEntry> =
276            a.entries.iter().map(|e| (e.name.as_str(), e)).collect();
277        let b_by_name: HashMap<&str, &SemanticTreeEntry> =
278            b.entries.iter().map(|e| (e.name.as_str(), e)).collect();
279
280        for entry_a in &a.entries {
281            let path = join_path(prefix, &entry_a.name);
282            match b_by_name.get(entry_a.name.as_str()) {
283                None => self.emit_side(entry_a, &path, Side::Removed, depth, out)?,
284                Some(entry_b) => {
285                    if entry_a.semantic_digest == entry_b.semantic_digest {
286                        continue; // pruned — identical subtree/file.
287                    }
288                    match (entry_a.kind, entry_b.kind) {
289                        (SemanticEntryKind::Dir, SemanticEntryKind::Dir) => {
290                            let child_a = self.load_semantic_tree(&entry_a.node)?;
291                            let child_b = self.load_semantic_tree(&entry_b.node)?;
292                            self.diff_tree_nodes(&child_a, &child_b, &path, depth + 1, out)?;
293                        }
294                        (SemanticEntryKind::File, SemanticEntryKind::File) => {
295                            let file_a = self.load_semantic_file(&entry_a.node)?;
296                            let file_b = self.load_semantic_file(&entry_b.node)?;
297                            diff_file_symbols(&file_a, &file_b, &path, out);
298                        }
299                        // Kind flipped (file↔dir↔opaque): a full replace.
300                        _ => {
301                            self.emit_side(entry_a, &path, Side::Removed, depth, out)?;
302                            self.emit_side(entry_b, &path, Side::Added, depth, out)?;
303                        }
304                    }
305                }
306            }
307        }
308        // Names only on the b side are additions.
309        for entry_b in &b.entries {
310            if !a_by_name.contains_key(entry_b.name.as_str()) {
311                let path = join_path(prefix, &entry_b.name);
312                self.emit_side(entry_b, &path, Side::Added, depth, out)?;
313            }
314        }
315        Ok(())
316    }
317
318    /// Emit every symbol of a single-sided entry (whole file/subtree added or
319    /// removed). Opaque entries carry no symbols.
320    pub(crate) fn emit_side(
321        &self,
322        entry: &SemanticTreeEntry,
323        path: &str,
324        side: Side,
325        depth: usize,
326        out: &mut Vec<SymbolDelta>,
327    ) -> Result<()> {
328        if depth > MAX_SEMANTIC_TREE_DEPTH {
329            return Err(HeddleError::InvalidObject(format!(
330                "semantic diff exceeds max depth {MAX_SEMANTIC_TREE_DEPTH}"
331            )));
332        }
333        match entry.kind {
334            SemanticEntryKind::File => {
335                let file = self.load_semantic_file(&entry.node)?;
336                for sym in &file.symbols {
337                    out.push(side.delta(path, sym));
338                }
339            }
340            SemanticEntryKind::Dir => {
341                let node = self.load_semantic_tree(&entry.node)?;
342                for child in &node.entries {
343                    let child_path = join_path(path, &child.name);
344                    self.emit_side(child, &child_path, side, depth + 1, out)?;
345                }
346            }
347            SemanticEntryKind::Opaque => {}
348        }
349        Ok(())
350    }
351}
352
353/// Never-compute public read methods, compiled when the parser is absent — the
354/// parse-free consumer's (weft's) surface. When `tree-sitter-symbols` is on,
355/// `repository_semantic_index` supplies get-or-compute + self-heal variants of
356/// these same names instead.
357#[cfg(not(feature = "tree-sitter-symbols"))]
358impl Repository {
359    /// Resolve a symbol anchor to its entry in a state's ATTACHED index.
360    pub fn symbol_hash(
361        &self,
362        state_id: &StateId,
363        anchor: &SymbolAnchor,
364    ) -> Result<Option<SymbolEntry>> {
365        self.symbol_hash_readonly(state_id, anchor)
366    }
367
368    /// Whether the semantic content under `path_prefix` differs between two
369    /// states, compared top-down by digest over their ATTACHED indexes.
370    pub fn semantic_changed(&self, a: &StateId, b: &StateId, path_prefix: &str) -> Result<bool> {
371        self.semantic_changed_readonly(a, b, path_prefix)
372    }
373
374    /// Symbol-level delta between two states' ATTACHED indexes.
375    pub fn semantic_diff_symbols(&self, a: &StateId, b: &StateId) -> Result<Vec<SymbolDelta>> {
376        self.semantic_diff_symbols_readonly(a, b)
377    }
378}
379
380#[derive(Clone, Copy)]
381pub(crate) enum Side {
382    Added,
383    Removed,
384}
385
386impl Side {
387    fn delta(self, path: &str, sym: &SymbolEntry) -> SymbolDelta {
388        let anchor = SymbolAnchor::new(path, sym.address());
389        match self {
390            Side::Added => SymbolDelta {
391                anchor,
392                kind: sym.kind,
393                old_hash: None,
394                new_hash: Some(sym.semantic_hash),
395            },
396            Side::Removed => SymbolDelta {
397                anchor,
398                kind: sym.kind,
399                old_hash: Some(sym.semantic_hash),
400                new_hash: None,
401            },
402        }
403    }
404}
405
406/// The identity a symbol is matched on across two file versions:
407/// `(container_path, name, kind)`. Distinguishes `fn f` in `mod a` from `mod b`,
408/// `fn X` from `struct X`, so the diff never last-wins-collides distinct
409/// symbols into a mislabeled Modified or a silent skip.
410type SymbolKey = (Vec<String>, String, SymbolKindTag);
411
412fn symbol_key(sym: &SymbolEntry) -> SymbolKey {
413    (sym.container_path.clone(), sym.name.clone(), sym.kind)
414}
415
416/// Diff two file nodes by symbol identity (a `(container, name, kind)`
417/// MULTISET), emitting a delta per added/removed/changed symbol. Same-key
418/// entries (e.g. C++ overloads the extractor can't tell apart) are paired
419/// positionally in canonical order; unmatched extras become add/remove.
420/// Unchanged symbols (equal `semantic_hash`) are skipped.
421fn diff_file_symbols(
422    a: &SemanticFileNode,
423    b: &SemanticFileNode,
424    path: &str,
425    out: &mut Vec<SymbolDelta>,
426) {
427    // BTreeMap, not HashMap: the loops below emit into `out`, so key order must
428    // be deterministic — this is a public, determinism-centric API. (The
429    // pre-refactor code iterated `a.symbols` in canonical order; a HashMap here
430    // reintroduced nondeterministic delta ordering.)
431    let mut a_by_key: BTreeMap<SymbolKey, Vec<&SymbolEntry>> = BTreeMap::new();
432    for sym in &a.symbols {
433        a_by_key.entry(symbol_key(sym)).or_default().push(sym);
434    }
435    let mut b_by_key: BTreeMap<SymbolKey, Vec<&SymbolEntry>> = BTreeMap::new();
436    for sym in &b.symbols {
437        b_by_key.entry(symbol_key(sym)).or_default().push(sym);
438    }
439
440    for (key, a_syms) in &a_by_key {
441        let b_syms = b_by_key.get(key).map(Vec::as_slice).unwrap_or(&[]);
442        // Symbols arrive in the file node already sorted by
443        // (container, name, kind, span.0), so same-key lists are in a stable
444        // order; pair them positionally.
445        for pair in a_syms.iter().zip(b_syms.iter()) {
446            let (sym_a, sym_b) = pair;
447            if sym_a.semantic_hash != sym_b.semantic_hash {
448                out.push(SymbolDelta {
449                    anchor: SymbolAnchor::new(path, sym_b.address()),
450                    kind: sym_b.kind,
451                    old_hash: Some(sym_a.semantic_hash),
452                    new_hash: Some(sym_b.semantic_hash),
453                });
454            }
455        }
456        // Extra `a` occurrences with no `b` counterpart are removals.
457        for sym_a in a_syms.iter().skip(b_syms.len()) {
458            out.push(Side::Removed.delta(path, sym_a));
459        }
460    }
461    // Keys (or extra occurrences of a key) present only in `b` are additions.
462    for (key, b_syms) in &b_by_key {
463        let a_len = a_by_key.get(key).map(Vec::len).unwrap_or(0);
464        for sym_b in b_syms.iter().skip(a_len) {
465            out.push(Side::Added.delta(path, sym_b));
466        }
467    }
468}
469
470fn join_path(prefix: &str, name: &str) -> String {
471    if prefix.is_empty() {
472        name.to_string()
473    } else {
474        format!("{prefix}/{name}")
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use std::collections::BTreeMap;
481
482    use chrono::Utc;
483    use objects::object::{
484        Attribution, Blob, Principal, State, StateAttachment, StateAttachmentBody,
485    };
486    use tempfile::TempDir;
487
488    use super::*;
489
490    fn repo() -> (TempDir, Repository) {
491        let temp = TempDir::new().unwrap();
492        let repo = Repository::init_default(temp.path()).unwrap();
493        (temp, repo)
494    }
495
496    fn author() -> Attribution {
497        Attribution::human(Principal::new("Test", "test@example.com"))
498    }
499
500    fn put_encoded(repo: &Repository, bytes: Vec<u8>) -> ContentHash {
501        repo.store().put_blob(&Blob::new(bytes)).unwrap()
502    }
503
504    /// Hand-assemble a one-file semantic index (no parser involved) and attach
505    /// its root to a freshly-put state, returning the state id and the root's
506    /// tree hash.
507    fn attach_handbuilt_index(repo: &Repository) -> (StateId, ContentHash) {
508        let sym = SymbolEntry {
509            name: "foo".to_string(),
510            kind: SymbolKindTag::Function,
511            container_path: vec![],
512            semantic_hash: ContentHash::compute(b"foo-body"),
513            span: (1, 1),
514        };
515        let file_node = SemanticFileNode::new(
516            "rust",
517            "0.24",
518            1,
519            ContentHash::compute(b"src-blob"),
520            ContentHash::compute(b"scaffold"),
521            vec![sym],
522        );
523        let file_hash = put_encoded(repo, file_node.encode().unwrap());
524        let (tree_node, tree_digest) = SemanticTreeNode::new(vec![SemanticTreeEntry {
525            name: "foo.rs".to_string(),
526            kind: SemanticEntryKind::File,
527            node: file_hash,
528            semantic_digest: file_node.semantic_digest,
529        }]);
530        let tree_hash = put_encoded(repo, tree_node.encode().unwrap());
531        let root = SemanticIndexRoot::new(1, BTreeMap::new(), tree_hash, tree_digest);
532        let root_hash = put_encoded(repo, root.encode().unwrap());
533
534        let state = State::new(ContentHash::compute(b"state-tree"), vec![], author());
535        repo.store().put_state(&state).unwrap();
536        let state_id = state.id();
537        repo.put_state_attachment(&StateAttachment {
538            state_id,
539            body: StateAttachmentBody::SemanticIndex(root_hash),
540            attribution: author(),
541            created_at: Utc::now(),
542            supersedes: None,
543        })
544        .unwrap();
545        (state_id, tree_hash)
546    }
547
548    /// `attached_semantic_index` returns `None` for a state with no index
549    /// attachment — and NEVER computes one (there is no parser in this build
550    /// path, and the state's tree hash is dangling).
551    #[test]
552    fn attached_semantic_index_none_without_attachment() {
553        let (_temp, repo) = repo();
554        let state = State::new(ContentHash::compute(b"no-index-tree"), vec![], author());
555        repo.store().put_state(&state).unwrap();
556        assert!(
557            repo.attached_semantic_index(&state.id()).unwrap().is_none(),
558            "a state with no attachment must read as no index, never recompute"
559        );
560    }
561
562    /// `attached_semantic_index` returns the stored root when one is attached,
563    /// and `semantic_file_node` loads a file's symbols by path — both pure
564    /// store walks, no parser.
565    #[test]
566    fn attached_index_and_file_node_load_without_parser() {
567        let (_temp, repo) = repo();
568        let (state_id, tree_hash) = attach_handbuilt_index(&repo);
569
570        let root = repo
571            .attached_semantic_index(&state_id)
572            .unwrap()
573            .expect("attached root must load");
574        assert_eq!(root.tree, tree_hash);
575
576        let file = repo
577            .semantic_file_node(&state_id, "foo.rs")
578            .unwrap()
579            .expect("file node must resolve by path");
580        assert_eq!(file.language, "rust");
581        assert!(
582            file.symbols.iter().any(|s| s.name == "foo"),
583            "hand-built symbol must be present: {:?}",
584            file.symbols
585        );
586
587        // An absent path resolves to None, never an error.
588        assert!(
589            repo.semantic_file_node(&state_id, "missing.rs")
590                .unwrap()
591                .is_none()
592        );
593    }
594}