use objects::object::{
ContentHash, SymbolEntry, SymbolKindTag, compute_file_scaffold_hash,
compute_symbol_semantic_hash,
};
use crate::{
parser::{Language, ParsedFile, walk_non_comment_leaves},
symbol_resolver::{DefinitionKind, visit_definitions},
};
pub const EXTRACTOR_VERSION: u32 = 3;
pub fn language_name(language: Language) -> &'static str {
match language {
Language::Rust => "rust",
Language::Python => "python",
Language::JavaScript => "javascript",
Language::TypeScript => "typescript",
Language::Go => "go",
Language::C => "c",
Language::Cpp => "cpp",
Language::Java => "java",
Language::Zig => "zig",
Language::Unknown => "unknown",
}
}
pub fn grammar_version(language: Language) -> &'static str {
match language {
Language::Rust => "tree-sitter-rust@0.24",
Language::Python => "tree-sitter-python@0.25",
Language::JavaScript => "tree-sitter-javascript@0.25",
Language::TypeScript => "tree-sitter-typescript@0.23",
Language::Go => "tree-sitter-go@0.25",
Language::C => "tree-sitter-c@0.24",
Language::Cpp => "tree-sitter-cpp@0.23",
Language::Java => "tree-sitter-java@0.23",
Language::Zig => "tree-sitter-zig@1.1",
Language::Unknown => "none",
}
}
pub fn grammar_version_by_name(name: &str) -> Option<&'static str> {
let language = match name {
"rust" => Language::Rust,
"python" => Language::Python,
"javascript" => Language::JavaScript,
"typescript" => Language::TypeScript,
"go" => Language::Go,
"c" => Language::C,
"cpp" => Language::Cpp,
"java" => Language::Java,
"zig" => Language::Zig,
_ => return None,
};
Some(grammar_version(language))
}
fn map_kind(kind: DefinitionKind) -> SymbolKindTag {
match kind {
DefinitionKind::Function => SymbolKindTag::Function,
DefinitionKind::Type => SymbolKindTag::Type,
DefinitionKind::Trait => SymbolKindTag::Trait,
DefinitionKind::Class => SymbolKindTag::Class,
DefinitionKind::Interface => SymbolKindTag::Interface,
DefinitionKind::TypeAlias => SymbolKindTag::TypeAlias,
DefinitionKind::EnumDef => SymbolKindTag::Enum,
DefinitionKind::ConstDecl => SymbolKindTag::Const,
DefinitionKind::Module => SymbolKindTag::Module,
DefinitionKind::Other => SymbolKindTag::Other,
}
}
pub struct ExtractedFile {
pub language: Language,
pub scaffold_hash: ContentHash,
pub symbols: Vec<SymbolEntry>,
}
pub fn extract_semantic_file(source: &[u8], language: Language) -> Option<ExtractedFile> {
language.parser_handle()?;
let source_text = std::str::from_utf8(source).ok()?;
let parsed = ParsedFile::parse(source_text, language)?;
let mut symbols = Vec::new();
let mut covered: Vec<(usize, usize)> = Vec::new();
visit_definitions(parsed.root_node(), source, &mut |site| {
let kind = map_kind(site.kind);
let semantic_hash = symbol_semantic_hash(site.node, source, kind);
let container_path = site.parent_name.map(|p| vec![p]).unwrap_or_default();
let range = site.node.byte_range();
covered.push((range.start, range.end));
symbols.push(SymbolEntry {
name: site.name,
kind,
container_path,
semantic_hash,
span: (site.start_line, site.end_line),
});
});
let scaffold_hash = compute_scaffold(parsed.root_node(), source, covered);
Some(ExtractedFile {
language,
scaffold_hash,
symbols,
})
}
fn compute_scaffold(
root: tree_sitter::Node<'_>,
source: &[u8],
mut covered: Vec<(usize, usize)>,
) -> ContentHash {
covered.sort_by_key(|&(start, _)| start);
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(covered.len());
for (start, end) in covered {
match merged.last_mut() {
Some(last) if start <= last.1 => last.1 = last.1.max(end),
_ => merged.push((start, end)),
}
}
let mut stream: Vec<u8> = Vec::new();
walk_non_comment_leaves(root, |leaf| {
let range = leaf.byte_range();
if is_covered(&merged, range.start, range.end) {
return;
}
let bytes = &source[range];
stream.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
stream.extend_from_slice(bytes);
});
compute_file_scaffold_hash(&stream)
}
fn is_covered(merged: &[(usize, usize)], start: usize, end: usize) -> bool {
match merged.binary_search_by(|&(interval_start, _)| interval_start.cmp(&start)) {
Ok(i) => merged[i].1 >= end,
Err(0) => false,
Err(i) => merged[i - 1].1 >= end,
}
}
fn symbol_semantic_hash(
node: tree_sitter::Node<'_>,
source: &[u8],
kind: SymbolKindTag,
) -> ContentHash {
let mut token_stream: Vec<u8> = Vec::new();
walk_non_comment_leaves(node, |leaf| {
let bytes = &source[leaf.byte_range()];
token_stream.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
token_stream.extend_from_slice(bytes);
});
compute_symbol_semantic_hash(kind, &token_stream)
}
#[cfg(test)]
mod tests {
use super::*;
fn extract(src: &str) -> Vec<SymbolEntry> {
extract_semantic_file(src.as_bytes(), Language::Rust)
.expect("rust parse")
.symbols
}
fn scaffold(src: &str) -> ContentHash {
extract_semantic_file(src.as_bytes(), Language::Rust)
.expect("rust parse")
.scaffold_hash
}
#[test]
fn scaffold_binds_non_definition_content() {
assert_ne!(
scaffold("use a::x;\nfn f() { g(); }\n"),
scaffold("use b::x;\nfn f() { g(); }\n"),
"use-decl swap"
);
assert_ne!(
scaffold("struct S;\nimpl Display for S { fn fmt(&self) {} }\n"),
scaffold("struct S;\nimpl Debug for S { fn fmt(&self) {} }\n"),
"impl trait change"
);
assert_ne!(
scaffold("fn f() { g(); }\n"),
scaffold("#[inline]\nfn f() { g(); }\n"),
"attribute add"
);
assert_ne!(
scaffold("macro_rules! m { () => { 1 }; }\n"),
scaffold("macro_rules! m { () => { 2 }; }\n"),
"macro_rules body edit"
);
assert_ne!(
scaffold("pub use crate::a::Foo;\n"),
scaffold("pub use crate::b::Bar;\n"),
"definition-free re-export files"
);
}
#[test]
fn scaffold_is_reformat_and_comment_stable() {
assert_eq!(
scaffold("use a::x;\nfn f() { g(); }\n"),
scaffold("use a::x;\n\n// note\nfn f() {\n g();\n}\n"),
);
}
#[test]
fn reformat_leaves_symbol_hash_stable() {
let a = "fn add(a: i32, b: i32) -> i32 { a + b }\n";
let b = "fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n";
let sa = extract(a);
let sb = extract(b);
assert_eq!(sa.len(), 1);
assert_eq!(sb.len(), 1);
assert_eq!(
sa[0].semantic_hash, sb[0].semantic_hash,
"reformatting must not change the symbol semantic_hash"
);
}
#[test]
fn comment_edit_leaves_symbol_hash_stable() {
let a = "fn f() {\n // old comment\n g();\n}\n";
let b = "fn f() {\n // a completely different comment\n g();\n}\n";
assert_eq!(extract(a)[0].semantic_hash, extract(b)[0].semantic_hash);
}
#[test]
fn one_token_change_perturbs_only_that_symbol() {
let a = "fn f() -> i32 { 1 }\nfn g() -> i32 { 2 }\n";
let b = "fn f() -> i32 { 1 }\nfn g() -> i32 { 3 }\n";
let sa = extract(a);
let sb = extract(b);
let f_a = sa.iter().find(|s| s.name == "f").unwrap();
let f_b = sb.iter().find(|s| s.name == "f").unwrap();
let g_a = sa.iter().find(|s| s.name == "g").unwrap();
let g_b = sb.iter().find(|s| s.name == "g").unwrap();
assert_eq!(f_a.semantic_hash, f_b.semantic_hash, "untouched symbol stable");
assert_ne!(g_a.semantic_hash, g_b.semantic_hash, "edited symbol changes");
}
#[test]
fn string_literal_contents_included() {
let a = "fn f() { let s = \"hello\"; }\n";
let b = "fn f() { let s = \"world\"; }\n";
assert_ne!(
extract(a)[0].semantic_hash,
extract(b)[0].semantic_hash,
"string literal contents are part of the fingerprint"
);
}
#[test]
fn types_are_first_class() {
let src = "struct S { x: u32 }\nenum E { A, B }\ntrait T { fn m(&self); }\n";
let names: Vec<_> = extract(src).into_iter().map(|s| (s.name, s.kind)).collect();
assert!(names.contains(&("S".to_string(), SymbolKindTag::Type)));
assert!(names.contains(&("E".to_string(), SymbolKindTag::Enum)));
assert!(names.contains(&("T".to_string(), SymbolKindTag::Trait)));
}
#[test]
fn unsupported_language_is_none() {
assert!(extract_semantic_file(b"whatever", Language::Unknown).is_none());
}
#[cfg(feature = "lang-zig")]
#[test]
fn zig_blob_extracts_real_symbols_with_hashes() {
let src = "pub const Point = struct {\n x: f64,\n pub fn dist(self: Point) f64 { return self.x; }\n};\n\ntest \"works\" { _ = 1; }\n";
let extracted =
extract_semantic_file(src.as_bytes(), Language::Zig).expect("zig parses to a real node");
assert_eq!(language_name(extracted.language), "zig");
let by_name = |n: &str| extracted.symbols.iter().find(|s| s.name == n);
let point = by_name("Point").expect("Point type extracted");
assert_eq!(point.kind, SymbolKindTag::Type);
let dist = by_name("dist").expect("method extracted");
assert_eq!(dist.kind, SymbolKindTag::Function);
assert_eq!(dist.container_path, vec!["Point".to_string()]);
let test = by_name("test:\"works\"").expect("test block extracted");
assert_eq!(test.kind, SymbolKindTag::Function);
assert!(
extracted
.symbols
.iter()
.all(|s| s.semantic_hash != ContentHash::compute(b"")),
"symbols must carry real semantic hashes"
);
}
#[cfg(feature = "lang-zig")]
#[test]
fn zig_reformat_leaves_semantic_digest_stable() {
use objects::object::SemanticFileNode;
let tight = "const std = @import(\"std\");\npub fn add(a: i32, b: i32) i32 { return a + b; }\npub const Point = struct { x: f64, pub fn dist(self: Point) f64 { return self.x; } };\n";
let loose = "const std = @import(\"std\");\n\n// a comment\npub fn add(a: i32, b: i32) i32 {\n return a + b;\n}\n\npub const Point = struct {\n // fields\n x: f64,\n pub fn dist(self: Point) f64 {\n return self.x;\n }\n};\n";
let ea = extract_semantic_file(tight.as_bytes(), Language::Zig).expect("tight parses");
let eb = extract_semantic_file(loose.as_bytes(), Language::Zig).expect("loose parses");
assert_eq!(
ea.scaffold_hash, eb.scaffold_hash,
"scaffold must be reformat/comment stable"
);
let node = |e: &ExtractedFile, src: &str| {
SemanticFileNode::new(
language_name(e.language),
grammar_version(e.language),
EXTRACTOR_VERSION,
ContentHash::compute(src.as_bytes()),
e.scaffold_hash,
e.symbols.clone(),
)
};
assert_eq!(
node(&ea, tight).semantic_digest,
node(&eb, loose).semantic_digest,
"reformatting must not perturb the file semantic_digest"
);
}
}