use sha2::{Digest, Sha256};
use std::collections::HashSet;
use tree_sitter::{Node, Parser, TreeCursor};
use crate::clones::language::CloneLanguage;
use crate::{CodeLoreError, Result};
#[derive(Debug, Clone)]
pub struct Fingerprint {
pub digest: [u8; 32],
pub node_count: u32,
}
impl Fingerprint {
#[must_use]
pub fn hex(&self) -> String {
hex::encode(self.digest)
}
#[must_use]
pub fn from_sequence(sequence: &[(&str, u16)]) -> Self {
let mut hasher = Sha256::new();
for (kind, arity) in sequence {
hasher.update(kind.as_bytes());
hasher.update(b"\x00");
hasher.update(arity.to_le_bytes());
}
let mut digest = [0u8; 32];
digest.copy_from_slice(&hasher.finalize());
let node_count = u32::try_from(sequence.len()).unwrap_or(u32::MAX);
Self { digest, node_count }
}
}
pub fn fingerprint_source(code: &[u8], lang: CloneLanguage) -> Result<Fingerprint> {
let mut parser = Parser::new();
parser
.set_language(&lang.language())
.map_err(|e| CodeLoreError::Analysis(format!("clone-fingerprint: set_language: {e}")))?;
let tree = parser
.parse(code, None)
.ok_or_else(|| CodeLoreError::Analysis("clone-fingerprint: parse returned None".into()))?;
let skip: HashSet<&'static str> = lang.skip_kinds().iter().copied().collect();
let comment: HashSet<&'static str> = lang.comment_kinds().iter().copied().collect();
let mut sequence: Vec<(&str, u16)> = Vec::new();
let root = tree.root_node();
walk_preorder_internal(root, &skip, &comment, &mut sequence);
Ok(Fingerprint::from_sequence(&sequence))
}
pub(crate) fn walk_preorder_internal(
node: Node,
skip: &HashSet<&'static str>,
comment: &HashSet<&'static str>,
out: &mut Vec<(&'static str, u16)>,
) {
let root = node;
let mut cursor: TreeCursor<'_> = root.walk();
let mut child_cursor: TreeCursor<'_> = root.walk();
loop {
let current = cursor.node();
let kind = current.kind();
let is_comment = comment.contains(kind);
if !skip.contains(kind) {
let arity = effective_arity(current, comment, &mut child_cursor);
out.push((kind, arity));
}
if !is_comment && cursor.goto_first_child() {
continue;
}
loop {
if cursor.goto_next_sibling() {
break;
}
if !cursor.goto_parent() || cursor.node().id() == root.id() {
return;
}
}
}
}
fn effective_arity<'tree>(
node: Node<'tree>,
comment: &HashSet<&'static str>,
cursor: &mut TreeCursor<'tree>,
) -> u16 {
if node.child_count() == 0 {
return 0;
}
let mut count: usize = 0;
cursor.reset(node);
if cursor.goto_first_child() {
loop {
if !comment.contains(cursor.node().kind()) {
count += 1;
}
if !cursor.goto_next_sibling() {
break;
}
}
}
u16::try_from(count).unwrap_or(u16::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(lang: CloneLanguage, code: &str) -> Fingerprint {
fingerprint_source(code.as_bytes(), lang).expect("fingerprint")
}
#[test]
fn identical_rust_functions_share_fingerprint() {
let a = fp(
CloneLanguage::Rust,
"fn add(a: i32, b: i32) -> i32 { a + b }",
);
let b = fp(
CloneLanguage::Rust,
"fn add(a: i32, b: i32) -> i32 { a + b }",
);
assert_eq!(a.digest, b.digest);
}
#[test]
fn type2_renamed_rust_functions_share_fingerprint() {
let a = fp(
CloneLanguage::Rust,
"fn add(a: i32, b: i32) -> i32 { a + b }",
);
let b = fp(
CloneLanguage::Rust,
"fn mul(x: u64, y: u64) -> u64 { x + y }",
);
assert_eq!(a.digest, b.digest, "Type 2 clones should share fingerprint");
}
#[test]
fn structurally_different_rust_functions_diverge() {
let a = fp(CloneLanguage::Rust, "fn id(x: i32) -> i32 { x }");
let b = fp(CloneLanguage::Rust, "fn id(x: i32) -> i32 { x + 1 }");
assert_ne!(
a.digest, b.digest,
"different shape ⇒ different fingerprint"
);
}
#[test]
fn identical_python_functions_share_fingerprint() {
let a = fp(CloneLanguage::Python, "def add(a, b):\n return a + b\n");
let b = fp(CloneLanguage::Python, "def mul(x, y):\n return x + y\n");
assert_eq!(a.digest, b.digest);
}
#[test]
fn fingerprint_carries_node_count_and_hex_digest() {
let f = fp(
CloneLanguage::Rust,
"fn add(a: i32, b: i32) -> i32 { a + b }",
);
assert!(f.node_count > 0, "node_count should be positive");
assert_eq!(f.hex().len(), 64, "hex digest is 64 chars");
}
#[test]
fn line_comment_does_not_change_fingerprint_rust() {
let plain = fp(CloneLanguage::Rust, "fn f() -> i32 { 1 + 2 }");
let with_comment = fp(CloneLanguage::Rust, "fn f() -> i32 { // TODO\n1 + 2 }");
assert_eq!(plain.digest, with_comment.digest);
assert_eq!(plain.node_count, with_comment.node_count);
}
#[test]
fn doc_comment_children_are_skipped_rust() {
let plain = fp(CloneLanguage::Rust, "fn f() -> i32 { 1 + 2 }");
let with_doc = fp(CloneLanguage::Rust, "/// docs\nfn f() -> i32 { 1 + 2 }");
assert_eq!(plain.digest, with_doc.digest);
}
#[test]
fn comment_does_not_change_fingerprint_python() {
let plain = fp(CloneLanguage::Python, "def f():\n return 1 + 2\n");
let with_comment = fp(
CloneLanguage::Python,
"def f():\n # note\n return 1 + 2\n",
);
assert_eq!(plain.digest, with_comment.digest);
}
#[test]
fn identical_function_matches_across_ts_and_tsx() {
let src = "function add(a: number, b: number): number { return a + b; }";
let ts = fp(CloneLanguage::TypeScript, src);
let tsx = fp(CloneLanguage::Tsx, src);
assert_eq!(ts.digest, tsx.digest);
}
#[test]
fn parameterless_function_matches_across_js_and_ts() {
let src = "function f() { return 1 + 2; }";
let js = fp(CloneLanguage::JavaScript, src);
let ts = fp(CloneLanguage::TypeScript, src);
assert_eq!(js.digest, ts.digest);
}
#[test]
fn jsx_component_does_not_match_plain_ts() {
let jsx = fp(CloneLanguage::Tsx, "const C = () => <div>{x}</div>;");
let plain = fp(CloneLanguage::TypeScript, "const C = () => y;");
assert_ne!(jsx.digest, plain.digest);
}
}