hash: add subtree Merkle hashing
Diff phase 1 anchors on subtrees whose hash occurs exactly once in
each tree, so each node hashes (kind_id, label, child hashes) with
spans excluded: identical code must collide across positions and
across trees. Hashes are computed once at parse time in a reverse
pre-order walk and stored alongside the arena.

Assisted-by: Claude Fable 5 via Claude Code
change poxprkxxynvnomqrpvnyplkyyoxmoknk
commit 336c5c8c358268b910a7833d39198f6d35d2736c
author Alpha Chen <alpha@kejadlen.dev>
date
parent vkrpnoms
diff --git a/src/hash.rs b/src/hash.rs
new file mode 100644
index 0000000..6d93631
--- /dev/null
+++ b/src/hash.rs
@@ -0,0 +1,87 @@
+use std::hash::{DefaultHasher, Hash, Hasher};
+
+use crate::tree::Tree;
+
+/// Computes a Merkle hash for every node: (kind_id, label, child hashes
+/// in order), indexed like the tree's arena.
+///
+/// Spans are deliberately excluded so the hash is position-independent —
+/// identical code at different places (or in different trees) collides,
+/// which is exactly what anchoring the diff on unique equal subtrees
+/// needs.
+pub(crate) fn compute(tree: &Tree) -> Vec<u64> {
+    let mut hashes = vec![0u64; tree.nodes().count()];
+    // Arena order is pre-order, so every child's index is greater than
+    // its parent's; walking indices in reverse hashes children first.
+    let ids: Vec<_> = tree.nodes().collect();
+    for &id in ids.iter().rev() {
+        let mut hasher = DefaultHasher::new();
+        tree.kind_id(id).hash(&mut hasher);
+        tree.label(id).hash(&mut hasher);
+        for &child in tree.children(id) {
+            // In-bounds by construction: children share the arena the
+            // hashes vec was sized from, and the reverse walk computed
+            // their hashes already.
+            #[allow(clippy::indexing_slicing)]
+            hashes[child.index()].hash(&mut hasher);
+        }
+        #[allow(clippy::indexing_slicing)]
+        {
+            hashes[id.index()] = hasher.finish();
+        }
+    }
+    hashes
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::error::Error;
+    use crate::lang::Lang;
+    use crate::tree::Tree;
+
+    fn parse_rust(src: &str) -> Result<Tree, Error> {
+        let lang = Lang::by_name("rust").ok_or(Error::UnknownLanguage {
+            path: "rust".into(),
+        })?;
+        Tree::parse(src, lang)
+    }
+
+    #[test]
+    fn identical_subtrees_hash_equal_across_positions() -> Result<(), Error> {
+        let t = parse_rust("fn a() { x(); }\nfn b() { x(); }")?;
+        let calls: Vec<_> = t
+            .nodes()
+            .filter(|&n| t.kind(n) == "call_expression")
+            .collect();
+        assert_eq!(calls.len(), 2);
+        assert_eq!(
+            calls.first().map(|&n| t.hash(n)),
+            calls.last().map(|&n| t.hash(n))
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn equal_sources_hash_equal_across_trees() -> Result<(), Error> {
+        let a = parse_rust("fn main() { x(); }")?;
+        let b = parse_rust("fn main() { x(); }")?;
+        assert_eq!(a.hash(a.root()), b.hash(b.root()));
+        Ok(())
+    }
+
+    #[test]
+    fn different_labels_hash_differently() -> Result<(), Error> {
+        let a = parse_rust("fn main() { x(); }")?;
+        let b = parse_rust("fn main() { y(); }")?;
+        assert_ne!(a.hash(a.root()), b.hash(b.root()));
+        Ok(())
+    }
+
+    #[test]
+    fn different_structure_hashes_differently() -> Result<(), Error> {
+        let a = parse_rust("fn main() { x(); }")?;
+        let b = parse_rust("fn main() { x(); x(); }")?;
+        assert_ne!(a.hash(a.root()), b.hash(b.root()));
+        Ok(())
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index a83a8b2..e419dba 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,5 @@
 pub mod error;
+pub mod hash;
 pub mod lang;
 pub mod tree;
 
diff --git a/src/tree.rs b/src/tree.rs
index 28a5de7..de8d236 100644
--- a/src/tree.rs
+++ b/src/tree.rs
@@ -12,6 +12,14 @@ use crate::lang::Lang;
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 pub struct NodeId(usize);
 
+impl NodeId {
+    /// The arena index, for dense per-node side tables (subtree hashes,
+    /// matching maps).
+    pub(crate) fn index(self) -> usize {
+        self.0
+    }
+}
+
 /// An owned syntax tree lifted from a tree-sitter CST.
 ///
 /// Nodes live in an arena in pre-order (the root is index 0). The lift
@@ -23,6 +31,7 @@ pub struct NodeId(usize);
 pub struct Tree {
     source: String,
     nodes: Vec<NodeData>,
+    hashes: Vec<u64>,
 }
 
 #[derive(Debug)]
@@ -69,10 +78,13 @@ impl Tree {
         }
 
         let nodes = lift(root);
-        Ok(Self {
+        let mut tree = Self {
             source: source.into(),
             nodes,
-        })
+            hashes: Vec::new(),
+        };
+        tree.hashes = crate::hash::compute(&tree);
+        Ok(tree)
     }
 
     /// The root node (arena index 0).
@@ -132,6 +144,15 @@ impl Tree {
         self.node(id).field_id
     }
 
+    /// The node's subtree Merkle hash: (kind_id, label, child hashes),
+    /// position-independent. Equal hashes mean structurally identical
+    /// subtrees, within or across trees.
+    pub fn hash(&self, id: NodeId) -> u64 {
+        // Same invariant as node(): ids index this arena.
+        #[allow(clippy::indexing_slicing)]
+        self.hashes[id.0]
+    }
+
     fn node(&self, id: NodeId) -> &NodeData {
         // NodeIds only come from this arena's own accessors, so an
         // out-of-range index means ids were mixed across trees — a