1use 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
28pub(crate) const MAX_SEMANTIC_TREE_DEPTH: usize = 1024;
34
35#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct SymbolDelta {
39 pub anchor: SymbolAnchor,
41 pub kind: SymbolKindTag,
42 pub old_hash: Option<ContentHash>,
44 pub new_hash: Option<ContentHash>,
46}
47
48impl Repository {
49 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 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 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 #[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 #[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_opt, b_opt) => Ok(a_opt.is_some() != b_opt.is_some()),
151 }
152 }
153
154 #[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 #[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 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 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; }
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 _ => {
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 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 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#[cfg(not(feature = "tree-sitter-symbols"))]
364impl Repository {
365 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 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 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
412type SymbolKey = (Vec<String>, String, SymbolKindTag);
417
418fn symbol_key(sym: &SymbolEntry) -> SymbolKey {
419 (sym.container_path.clone(), sym.name.clone(), sym.kind)
420}
421
422fn diff_file_symbols(
428 a: &SemanticFileNode,
429 b: &SemanticFileNode,
430 path: &str,
431 out: &mut Vec<SymbolDelta>,
432) {
433 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 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 for sym_a in a_syms.iter().skip(b_syms.len()) {
464 out.push(Side::Removed.delta(path, sym_a));
465 }
466 }
467 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 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 #[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 #[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 assert!(
595 repo.semantic_file_node(&state_id, "missing.rs")
596 .unwrap()
597 .is_none()
598 );
599 }
600}