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