use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::ContentHash;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SymbolKindTag {
Function,
Type,
Enum,
Trait,
Class,
Interface,
TypeAlias,
Const,
Module,
Other,
}
impl SymbolKindTag {
pub fn tag_byte(self) -> u8 {
match self {
SymbolKindTag::Function => 1,
SymbolKindTag::Type => 2,
SymbolKindTag::Enum => 3,
SymbolKindTag::Trait => 4,
SymbolKindTag::Class => 5,
SymbolKindTag::Interface => 6,
SymbolKindTag::TypeAlias => 7,
SymbolKindTag::Const => 8,
SymbolKindTag::Module => 9,
SymbolKindTag::Other => 10,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SemanticEntryKind {
Dir,
File,
Opaque,
}
impl SemanticEntryKind {
pub fn tag_byte(self) -> u8 {
match self {
SemanticEntryKind::Dir => 1,
SemanticEntryKind::File => 2,
SemanticEntryKind::Opaque => 3,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolEntry {
pub name: String,
pub kind: SymbolKindTag,
pub container_path: Vec<String>,
pub semantic_hash: ContentHash,
pub span: (u32, u32),
}
impl SymbolEntry {
pub fn address(&self) -> String {
if self.container_path.is_empty() {
self.name.clone()
} else {
format!("{}::{}", self.container_path.join("::"), self.name)
}
}
fn sort_key(&self) -> (&[String], &str, u8, ContentHash) {
(
&self.container_path,
self.name.as_str(),
self.kind.tag_byte(),
self.semantic_hash,
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ByteSpan {
pub start: u32,
pub end: u32,
}
impl ByteSpan {
pub fn new(start: u32, end: u32) -> Self {
Self { start, end }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScopeKind {
Module,
Type,
Function,
Block,
}
impl ScopeKind {
fn tag_byte(self) -> u8 {
match self {
Self::Module => 1,
Self::Type => 2,
Self::Function => 3,
Self::Block => 4,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScopeEntry {
pub local_id: u32,
pub parent: Option<u32>,
pub kind: ScopeKind,
pub span: ByteSpan,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImportKindTag {
Use,
Import,
Reexport,
Dynamic,
}
impl ImportKindTag {
fn tag_byte(self) -> u8 {
match self {
Self::Use => 1,
Self::Import => 2,
Self::Reexport => 3,
Self::Dynamic => 4,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SymbolNamespace {
Value,
Type,
Both,
}
impl SymbolNamespace {
fn tag_byte(self) -> u8 {
match self {
Self::Value => 1,
Self::Type => 2,
Self::Both => 3,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ImportBinding {
pub imported: String,
pub local: String,
pub namespace: SymbolNamespace,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportEntry {
pub kind: ImportKindTag,
pub module_specifier: String,
pub bindings: Vec<ImportBinding>,
pub scope: u32,
pub span: ByteSpan,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OccurrenceRole {
Definition,
Reference,
Call,
TypeReference,
}
impl OccurrenceRole {
fn tag_byte(self) -> u8 {
match self {
Self::Definition => 1,
Self::Reference => 2,
Self::Call => 3,
Self::TypeReference => 4,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OccurrenceEntry {
pub local_id: u32,
pub role: OccurrenceRole,
pub name: String,
pub qualifier: Vec<String>,
pub namespace: SymbolNamespace,
pub scope: u32,
pub span: ByteSpan,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SemanticFileFacts {
pub symbols: Vec<SymbolEntry>,
pub scopes: Vec<ScopeEntry>,
pub imports: Vec<ImportEntry>,
pub occurrences: Vec<OccurrenceEntry>,
}
pub fn compute_symbol_semantic_hash(kind: SymbolKindTag, token_stream: &[u8]) -> ContentHash {
let mut buf = Vec::with_capacity(2 + token_stream.len());
buf.push(kind.tag_byte());
buf.push(0x00);
buf.extend_from_slice(token_stream);
ContentHash::compute_typed("hd-sem-sym-v1", &buf)
}
pub fn compute_file_scaffold_hash(token_stream: &[u8]) -> ContentHash {
ContentHash::compute_typed("hd-sem-scaffold-v1", token_stream)
}
pub fn compute_file_semantic_digest(
scaffold_hash: ContentHash,
symbols: &[SymbolEntry],
scopes: &[ScopeEntry],
imports: &[ImportEntry],
occurrences: &[OccurrenceEntry],
) -> ContentHash {
let mut buf = Vec::new();
buf.extend_from_slice(scaffold_hash.as_bytes());
buf.extend_from_slice(&(symbols.len() as u32).to_le_bytes());
for symbol in symbols {
buf.extend_from_slice(&(symbol.container_path.len() as u32).to_le_bytes());
for segment in &symbol.container_path {
buf.extend_from_slice(&(segment.len() as u32).to_le_bytes());
buf.extend_from_slice(segment.as_bytes());
}
buf.extend_from_slice(&(symbol.name.len() as u32).to_le_bytes());
buf.extend_from_slice(symbol.name.as_bytes());
buf.push(symbol.kind.tag_byte());
buf.extend_from_slice(symbol.semantic_hash.as_bytes());
}
buf.extend_from_slice(&(scopes.len() as u32).to_le_bytes());
for scope in scopes {
buf.extend_from_slice(&scope.local_id.to_le_bytes());
match scope.parent {
Some(parent) => {
buf.push(1);
buf.extend_from_slice(&parent.to_le_bytes());
}
None => buf.push(0),
}
buf.push(scope.kind.tag_byte());
}
buf.extend_from_slice(&(imports.len() as u32).to_le_bytes());
for import in imports {
buf.push(import.kind.tag_byte());
push_str(&mut buf, &import.module_specifier);
buf.extend_from_slice(&(import.bindings.len() as u32).to_le_bytes());
for binding in &import.bindings {
push_str(&mut buf, &binding.imported);
push_str(&mut buf, &binding.local);
buf.push(binding.namespace.tag_byte());
}
buf.extend_from_slice(&import.scope.to_le_bytes());
}
buf.extend_from_slice(&(occurrences.len() as u32).to_le_bytes());
for occurrence in occurrences {
buf.extend_from_slice(&occurrence.local_id.to_le_bytes());
buf.push(occurrence.role.tag_byte());
push_str(&mut buf, &occurrence.name);
buf.extend_from_slice(&(occurrence.qualifier.len() as u32).to_le_bytes());
for segment in &occurrence.qualifier {
push_str(&mut buf, segment);
}
buf.push(occurrence.namespace.tag_byte());
buf.extend_from_slice(&occurrence.scope.to_le_bytes());
}
ContentHash::compute_typed("hd-sem-file-v3", &buf)
}
fn push_str(buf: &mut Vec<u8>, value: &str) {
buf.extend_from_slice(&(value.len() as u32).to_le_bytes());
buf.extend_from_slice(value.as_bytes());
}
pub fn compute_dir_semantic_digest(entries: &[SemanticTreeEntry]) -> ContentHash {
let mut buf = Vec::new();
for entry in entries {
buf.extend_from_slice(&(entry.name.len() as u32).to_le_bytes());
buf.extend_from_slice(entry.name.as_bytes());
buf.push(entry.kind.tag_byte());
buf.extend_from_slice(entry.semantic_digest.as_bytes());
}
ContentHash::compute_typed("hd-sem-dir-v2", &buf)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticFileNode {
pub format_version: u8,
pub language: String,
pub grammar_version: String,
pub extractor_version: u32,
pub source_blob: ContentHash,
pub scaffold_hash: ContentHash,
pub symbols: Vec<SymbolEntry>,
pub scopes: Vec<ScopeEntry>,
pub imports: Vec<ImportEntry>,
pub occurrences: Vec<OccurrenceEntry>,
pub semantic_digest: ContentHash,
}
impl SemanticFileNode {
pub const FORMAT_VERSION: u8 = 2;
pub fn new(
language: impl Into<String>,
grammar_version: impl Into<String>,
extractor_version: u32,
source_blob: ContentHash,
scaffold_hash: ContentHash,
facts: SemanticFileFacts,
) -> Self {
let SemanticFileFacts {
mut symbols,
mut scopes,
mut imports,
mut occurrences,
} = facts;
symbols.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
scopes.sort_by_key(|scope| scope.local_id);
imports.sort_by(|a, b| {
(a.kind, &a.module_specifier, a.scope, &a.bindings).cmp(&(
b.kind,
&b.module_specifier,
b.scope,
&b.bindings,
))
});
occurrences.sort_by_key(|occurrence| occurrence.local_id);
let semantic_digest =
compute_file_semantic_digest(scaffold_hash, &symbols, &scopes, &imports, &occurrences);
Self {
format_version: Self::FORMAT_VERSION,
language: language.into(),
grammar_version: grammar_version.into(),
extractor_version,
source_blob,
scaffold_hash,
symbols,
scopes,
imports,
occurrences,
semantic_digest,
}
}
pub fn encode(&self) -> Result<Vec<u8>, SemanticIndexError> {
rmp_serde::to_vec_named(self).map_err(|err| SemanticIndexError::Encoding(err.to_string()))
}
pub fn decode(bytes: &[u8]) -> Result<Self, SemanticIndexError> {
let node: Self = rmp_serde::from_slice(bytes)
.map_err(|err| SemanticIndexError::Encoding(err.to_string()))?;
if node.format_version != Self::FORMAT_VERSION {
return Err(SemanticIndexError::UnsupportedVersion(node.format_version));
}
Ok(node)
}
pub fn symbol_by_address(&self, address: &str) -> Option<&SymbolEntry> {
self.symbols.iter().find(|s| s.address() == address)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticTreeEntry {
pub name: String,
pub kind: SemanticEntryKind,
pub node: ContentHash,
pub semantic_digest: ContentHash,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticTreeNode {
pub format_version: u8,
pub entries: Vec<SemanticTreeEntry>,
}
impl SemanticTreeNode {
pub const FORMAT_VERSION: u8 = 1;
pub fn new(mut entries: Vec<SemanticTreeEntry>) -> (Self, ContentHash) {
entries.sort_by(|a, b| a.name.cmp(&b.name));
let digest = compute_dir_semantic_digest(&entries);
(
Self {
format_version: Self::FORMAT_VERSION,
entries,
},
digest,
)
}
pub fn semantic_digest(&self) -> ContentHash {
compute_dir_semantic_digest(&self.entries)
}
pub fn encode(&self) -> Result<Vec<u8>, SemanticIndexError> {
rmp_serde::to_vec_named(self).map_err(|err| SemanticIndexError::Encoding(err.to_string()))
}
pub fn decode(bytes: &[u8]) -> Result<Self, SemanticIndexError> {
let node: Self = rmp_serde::from_slice(bytes)
.map_err(|err| SemanticIndexError::Encoding(err.to_string()))?;
if node.format_version != Self::FORMAT_VERSION {
return Err(SemanticIndexError::UnsupportedVersion(node.format_version));
}
Ok(node)
}
pub fn get(&self, name: &str) -> Option<&SemanticTreeEntry> {
self.entries
.binary_search_by(|e| e.name.as_str().cmp(name))
.ok()
.map(|i| &self.entries[i])
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticIndexRoot {
pub format_version: u8,
pub extractor_version: u32,
pub grammars: BTreeMap<String, String>,
pub tree: ContentHash,
pub semantic_digest: ContentHash,
#[serde(default)]
pub binding_delta: Option<ContentHash>,
#[serde(default)]
pub importer_index: Option<ContentHash>,
#[serde(default)]
pub resolver_version: u32,
}
impl SemanticIndexRoot {
pub const FORMAT_VERSION: u8 = 1;
pub fn new(
extractor_version: u32,
grammars: BTreeMap<String, String>,
tree: ContentHash,
semantic_digest: ContentHash,
) -> Self {
Self {
format_version: Self::FORMAT_VERSION,
extractor_version,
grammars,
tree,
semantic_digest,
binding_delta: None,
importer_index: None,
resolver_version: 0,
}
}
pub fn with_binding_delta(mut self, binding_delta: ContentHash, resolver_version: u32) -> Self {
self.binding_delta = Some(binding_delta);
self.resolver_version = resolver_version;
self
}
pub fn with_importer_index(mut self, importer_index: ContentHash) -> Self {
self.importer_index = Some(importer_index);
self
}
pub fn encode(&self) -> Result<Vec<u8>, SemanticIndexError> {
rmp_serde::to_vec_named(self).map_err(|err| SemanticIndexError::Encoding(err.to_string()))
}
pub fn decode(bytes: &[u8]) -> Result<Self, SemanticIndexError> {
let root: Self = rmp_serde::from_slice(bytes)
.map_err(|err| SemanticIndexError::Encoding(err.to_string()))?;
if root.format_version != Self::FORMAT_VERSION {
return Err(SemanticIndexError::UnsupportedVersion(root.format_version));
}
Ok(root)
}
}
#[derive(Debug, thiserror::Error)]
pub enum SemanticIndexError {
#[error("unsupported semantic index node version {0}")]
UnsupportedVersion(u8),
#[error("semantic index node encoding error: {0}")]
Encoding(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn h(seed: u8) -> ContentHash {
ContentHash::from_bytes([seed; 32])
}
fn sym(name: &str, container: &[&str], kind: SymbolKindTag, span: (u32, u32)) -> SymbolEntry {
SymbolEntry {
name: name.to_string(),
kind,
container_path: container.iter().map(|s| s.to_string()).collect(),
semantic_hash: ContentHash::compute(name.as_bytes()),
span,
}
}
#[test]
fn file_digest_excludes_span() {
let a = SemanticFileNode::new(
"rust",
"0.24",
1,
h(1),
h(0),
SemanticFileFacts {
symbols: vec![sym("foo", &[], SymbolKindTag::Function, (10, 20))],
..SemanticFileFacts::default()
},
);
let b = SemanticFileNode::new(
"rust",
"0.24",
1,
h(1),
h(0),
SemanticFileFacts {
symbols: vec![sym("foo", &[], SymbolKindTag::Function, (99, 120))],
..SemanticFileFacts::default()
},
);
assert_eq!(
a.semantic_digest, b.semantic_digest,
"span must not affect the file semantic_digest"
);
}
#[test]
fn semantic_content_hash_excludes_all_provenance_spans() {
let source_blob = ContentHash::compute(b"use crate::api::greet; greet();");
let scope = |span| ScopeEntry {
local_id: 0,
parent: None,
kind: ScopeKind::Module,
span,
};
let import = |module_specifier: &str, span| ImportEntry {
kind: ImportKindTag::Use,
module_specifier: module_specifier.to_string(),
bindings: vec![ImportBinding {
imported: "greet".to_string(),
local: "greet".to_string(),
namespace: SymbolNamespace::Both,
}],
scope: 0,
span,
};
let occurrence = |span| OccurrenceEntry {
local_id: 0,
role: OccurrenceRole::Call,
name: "greet".to_string(),
qualifier: Vec::new(),
namespace: SymbolNamespace::Value,
scope: 0,
span,
};
let node = |scope_span, import_spans: [ByteSpan; 2], occurrence_span| {
SemanticFileNode::new(
"rust",
"0.24",
4,
source_blob,
h(0),
SemanticFileFacts {
symbols: vec![],
scopes: vec![scope(scope_span)],
imports: vec![
import("crate::api", import_spans[0]),
import("crate::util", import_spans[1]),
],
occurrences: vec![occurrence(occurrence_span)],
},
)
};
let a = node(
ByteSpan::new(0, 38),
[ByteSpan::new(0, 22), ByteSpan::new(23, 32)],
ByteSpan::new(23, 30),
);
let b = node(
ByteSpan::new(10, 48),
[ByteSpan::new(33, 42), ByteSpan::new(10, 32)],
ByteSpan::new(33, 40),
);
assert_eq!(
a.semantic_digest, b.semantic_digest,
"span-only differences must not affect semantic content identity"
);
assert_ne!(
a.encode().unwrap(),
b.encode().unwrap(),
"encoded provenance still records the distinct spans"
);
}
#[test]
fn file_node_roundtrip_preserves_source_local_facts() {
let node = SemanticFileNode::new(
"typescript",
"0.23",
4,
h(1),
h(0),
SemanticFileFacts {
symbols: vec![sym("run", &[], SymbolKindTag::Function, (2, 4))],
scopes: vec![ScopeEntry {
local_id: 0,
parent: None,
kind: ScopeKind::Module,
span: ByteSpan::new(0, 64),
}],
imports: vec![ImportEntry {
kind: ImportKindTag::Import,
module_specifier: "./api".to_string(),
bindings: vec![ImportBinding {
imported: "greet".to_string(),
local: "hello".to_string(),
namespace: SymbolNamespace::Value,
}],
scope: 0,
span: ByteSpan::new(0, 39),
}],
occurrences: vec![OccurrenceEntry {
local_id: 0,
role: OccurrenceRole::Call,
name: "hello".to_string(),
qualifier: Vec::new(),
namespace: SymbolNamespace::Value,
scope: 0,
span: ByteSpan::new(50, 55),
}],
},
);
assert_eq!(
SemanticFileNode::decode(&node.encode().unwrap()).unwrap(),
node
);
}
#[test]
fn file_digest_changes_on_symbol_hash_change() {
let mut s = sym("foo", &[], SymbolKindTag::Function, (1, 2));
let d1 = compute_file_semantic_digest(h(0), std::slice::from_ref(&s), &[], &[], &[]);
s.semantic_hash = ContentHash::compute(b"different-body");
let d2 = compute_file_semantic_digest(h(0), std::slice::from_ref(&s), &[], &[], &[]);
assert_ne!(d1, d2);
}
#[test]
fn file_digest_changes_on_scaffold_change() {
let syms = [sym("foo", &[], SymbolKindTag::Function, (1, 2))];
let d1 = compute_file_semantic_digest(
compute_file_scaffold_hash(b"use a;"),
&syms,
&[],
&[],
&[],
);
let d2 = compute_file_semantic_digest(
compute_file_scaffold_hash(b"use b;"),
&syms,
&[],
&[],
&[],
);
assert_ne!(
d1, d2,
"scaffold (non-definition top-level tokens) must affect the file digest"
);
}
#[test]
fn file_digest_framing_is_unambiguous() {
let one = sym("f", &["a::b"], SymbolKindTag::Function, (0, 0));
let two = sym("f", &["a", "b"], SymbolKindTag::Function, (0, 0));
assert_ne!(
compute_file_semantic_digest(h(0), &[one], &[], &[], &[]),
compute_file_semantic_digest(h(0), &[two], &[], &[], &[]),
);
}
#[test]
fn symbol_hash_stable_and_kind_sensitive() {
let ts = b"some token stream";
let a = compute_symbol_semantic_hash(SymbolKindTag::Function, ts);
let b = compute_symbol_semantic_hash(SymbolKindTag::Function, ts);
assert_eq!(a, b);
let c = compute_symbol_semantic_hash(SymbolKindTag::Type, ts);
assert_ne!(a, c, "kind participates in the symbol hash");
}
#[test]
fn symbols_sorted_canonically() {
let node = SemanticFileNode::new(
"rust",
"0.24",
1,
h(1),
h(0),
SemanticFileFacts {
symbols: vec![
sym("zed", &[], SymbolKindTag::Function, (1, 1)),
sym("abe", &["Impl"], SymbolKindTag::Function, (2, 2)),
sym("abe", &[], SymbolKindTag::Function, (3, 3)),
],
..SemanticFileFacts::default()
},
);
let names: Vec<_> = node.symbols.iter().map(|s| s.address()).collect();
assert_eq!(names, vec!["abe", "zed", "Impl::abe"]);
}
#[test]
fn dir_digest_stable_and_roundtrip() {
let e = SemanticTreeEntry {
name: "a.rs".to_string(),
kind: SemanticEntryKind::File,
node: h(5),
semantic_digest: h(6),
};
let (node, digest) = SemanticTreeNode::new(vec![e.clone()]);
assert_eq!(node.semantic_digest(), digest);
let bytes = node.encode().unwrap();
assert_eq!(SemanticTreeNode::decode(&bytes).unwrap(), node);
}
#[test]
fn address_spelling() {
assert_eq!(
sym("foo", &[], SymbolKindTag::Function, (0, 0)).address(),
"foo"
);
assert_eq!(
sym("open", &["Repository"], SymbolKindTag::Function, (0, 0)).address(),
"Repository::open"
);
}
}