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(&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 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 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 #[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 #[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_opt, b_opt) => Ok(a_opt.is_some() != b_opt.is_some()),
148 }
149 }
150
151 #[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 #[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 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 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; }
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 _ => {
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 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 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#[cfg(not(feature = "tree-sitter-symbols"))]
358impl Repository {
359 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 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 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
406type SymbolKey = (Vec<String>, String, SymbolKindTag);
411
412fn symbol_key(sym: &SymbolEntry) -> SymbolKey {
413 (sym.container_path.clone(), sym.name.clone(), sym.kind)
414}
415
416fn diff_file_symbols(
422 a: &SemanticFileNode,
423 b: &SemanticFileNode,
424 path: &str,
425 out: &mut Vec<SymbolDelta>,
426) {
427 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 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 for sym_a in a_syms.iter().skip(b_syms.len()) {
458 out.push(Side::Removed.delta(path, sym_a));
459 }
460 }
461 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 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 #[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 #[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 assert!(
589 repo.semantic_file_node(&state_id, "missing.rs")
590 .unwrap()
591 .is_none()
592 );
593 }
594}