pub mod apted;
pub mod cost;
pub(crate) mod grouped_greedy_matcher;
pub(crate) mod hash_tree_matching;
pub mod nodes;
pub(crate) mod solve_bottom_up_propagation;
pub(crate) mod solve_greedy_anchor_blocks;
pub(crate) mod solve_hash_descent;
pub(crate) mod solve_heritage_clause_growth;
pub(crate) mod solve_identical_diagnostic_statements;
pub(crate) mod solve_large_flat_subtrees;
pub(crate) mod solve_leading_siblings;
pub(crate) mod solve_leaf_neighbour_agreement;
pub(crate) mod solve_moved_subtrees;
pub(crate) mod solve_multi_maps;
pub(crate) mod solve_mutual_ancestors;
pub(crate) mod solve_nested_condition_collapse;
pub(crate) mod solve_orphaned_leaves;
pub(crate) mod solve_stray_renames;
pub(crate) mod solve_syntax_aware_matching;
pub(crate) mod solve_unique_type_matching;
pub(crate) mod solve_unresolved_nodes;
pub(crate) mod solve_wrap_growth;
pub mod text;
pub mod text_range;
use tree_sitter::Node;
use crate::code::{Code, Language};
use crate::diff::text::TextDiff;
#[derive(Debug, Clone, Default)]
pub struct NodeCache<'code> {
pub before: rustc_hash::FxHashMap<usize, tree_sitter::Node<'code>>,
pub after: rustc_hash::FxHashMap<usize, tree_sitter::Node<'code>>,
}
impl<'code> NodeCache<'code> {
pub fn build(before: &'code Code, after: &'code Code) -> Self {
NodeCache {
before: Self::cache_for(before),
after: Self::cache_for(after),
}
}
fn cache_for(code: &'code Code) -> rustc_hash::FxHashMap<usize, tree_sitter::Node<'code>> {
code.ast
.as_ref()
.map(|ast| {
let mut cache = rustc_hash::FxHashMap::default();
let mut stack = vec![ast.root_node()];
while let Some(node) = stack.pop() {
cache.insert(node.id(), node);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
stack.push(child);
}
}
cache
})
.unwrap_or_default()
}
pub fn get_in_any(&self, node_id: &usize) -> Option<&Node<'_>> {
if self.before.contains_key(node_id) {
return self.before.get(node_id);
}
if self.after.contains_key(node_id) {
return self.after.get(node_id);
}
None
}
}
pub const COST_INSERT: u64 = 1;
pub const COST_DELETE: u64 = 1;
pub const COST_UPDATE: u64 = 1;
#[derive(Debug, Clone)]
pub struct Diff {
pub ast: Option<ASTDiff>,
pub language: Language,
pub text: Option<TextDiff>,
}
impl Default for Diff {
fn default() -> Self {
Self {
ast: None,
language: Language::Unknown,
text: None,
}
}
}
impl Diff {
pub fn from_code(before: &Code, after: &Code) -> Self {
Self::from_code_with_config(before, after, &HeuristicConfig::default())
}
pub fn from_code_with_config(before: &Code, after: &Code, config: &HeuristicConfig) -> Self {
Self::pending_with_config(before, after, config).finish()
}
pub fn pending<'code>(before: &'code Code, after: &'code Code) -> PendingDiff<'code> {
Self::pending_with_config(before, after, &HeuristicConfig::default())
}
pub fn pending_with_config<'code>(
before: &'code Code,
after: &'code Code,
config: &HeuristicConfig,
) -> PendingDiff<'code> {
let node_cache = NodeCache::build(before, after);
let ctx = PassCtx::new(before, after, &node_cache);
let mut ast_diff = ASTDiff {
..Default::default()
};
solve_hash_descent::solve(&ctx, &mut ast_diff);
solve_nested_condition_collapse::solve(&ctx, &mut ast_diff);
solve_heritage_clause_growth::solve(&ctx, &mut ast_diff);
solve_leading_siblings::solve(&ctx, &mut ast_diff);
solve_identical_diagnostic_statements::solve(&ctx, &mut ast_diff);
solve_syntax_aware_matching::solve(&ctx, &mut ast_diff);
let unmatched_before = node_cache
.before
.len()
.saturating_sub(ast_diff.before_node_map.len());
let unmatched_after = node_cache
.after
.len()
.saturating_sub(ast_diff.after_node_map.len());
PendingDiff {
before,
after,
node_cache,
ast_diff,
unmatched_before,
unmatched_after,
config: *config,
}
}
}
pub struct PassCtx<'a> {
pub before: &'a Code,
pub after: &'a Code,
pub node_cache: &'a NodeCache<'a>,
before_metadata: std::borrow::Cow<'a, crate::code::ASTMetadata>,
after_metadata: std::borrow::Cow<'a, crate::code::ASTMetadata>,
}
impl<'a> PassCtx<'a> {
pub fn new(before: &'a Code, after: &'a Code, node_cache: &'a NodeCache<'a>) -> Self {
Self {
before,
after,
node_cache,
before_metadata: crate::code::metadata::metadata_of(before),
after_metadata: crate::code::metadata::metadata_of(after),
}
}
pub fn before_metadata(&self) -> &crate::code::ASTMetadata {
&self.before_metadata
}
pub fn after_metadata(&self) -> &crate::code::ASTMetadata {
&self.after_metadata
}
pub fn language(&self) -> Language {
self.before_metadata.language
}
}
pub const LARGE_RESIDUAL_THRESHOLD: usize = 5000;
pub struct PendingDiff<'code> {
before: &'code Code,
after: &'code Code,
node_cache: NodeCache<'code>,
ast_diff: ASTDiff,
unmatched_before: usize,
unmatched_after: usize,
config: HeuristicConfig,
}
impl<'code> PendingDiff<'code> {
pub fn large_residual(&self) -> bool {
self.unmatched_before.max(self.unmatched_after) > LARGE_RESIDUAL_THRESHOLD
}
#[cfg(test)]
pub fn unmatched_counts(&self) -> (usize, usize) {
(self.unmatched_before, self.unmatched_after)
}
pub fn finish(self) -> Diff {
let PendingDiff {
before,
after,
node_cache,
mut ast_diff,
config,
..
} = self;
let ctx = PassCtx::new(before, after, &node_cache);
if let (Some(before_ast), Some(after_ast)) = (before.ast.as_ref(), after.ast.as_ref()) {
apted::prematch_unique_named_locals(
before_ast.root_node().id(),
after_ast.root_node().id(),
ctx.before_metadata(),
ctx.after_metadata(),
"unique_named_local",
&mut ast_diff,
);
}
if config.solver_bottom_up_propagation {
solve_bottom_up_propagation::solve(&ctx, &mut ast_diff);
}
if config.solver_unique_type_matching {
solve_unique_type_matching::solve(&ctx, &mut ast_diff);
}
apted::for_roots_fallback(before, after, "fast_fallback", &mut ast_diff);
if config.solver_bottom_up_propagation {
solve_bottom_up_propagation::solve(&ctx, &mut ast_diff);
}
solve_orphaned_leaves::solve(&ctx, &mut ast_diff);
if config.solver_moved_subtrees {
solve_moved_subtrees::solve(&ctx, &mut ast_diff);
}
if config.solver_mutual_ancestors {
solve_mutual_ancestors::solve(&ctx, &mut ast_diff);
}
solve_leaf_neighbour_agreement::solve(&ctx, &mut ast_diff);
solve_wrap_growth::solve(&ctx, &mut ast_diff);
solve_stray_renames::solve(&ctx, &mut ast_diff);
solve_multi_maps::solve(&ctx, &mut ast_diff);
solve_unresolved_nodes::solve(&ctx, &mut ast_diff);
Diff {
ast: Some(ast_diff),
language: before.metadata.language.unwrap_or(Language::Unknown),
text: None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct HeuristicConfig {
pub solver_moved_subtrees: bool,
pub solver_bottom_up_propagation: bool,
pub solver_unique_type_matching: bool,
pub solver_mutual_ancestors: bool,
}
impl Default for HeuristicConfig {
fn default() -> Self {
Self {
solver_moved_subtrees: true,
solver_bottom_up_propagation: true,
solver_unique_type_matching: true,
solver_mutual_ancestors: true,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ASTDiff {
pub mapping: rustc_hash::FxHashMap<(usize, usize), ASTMapping>,
pub before_node_map: rustc_hash::FxHashMap<usize, usize>,
pub after_node_map: rustc_hash::FxHashMap<usize, usize>,
pub groups: Vec<NodeGroup>,
before_group: rustc_hash::FxHashMap<usize, usize>,
after_group: rustc_hash::FxHashMap<usize, usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NodeGroup {
pub before: Vec<usize>,
pub after: Vec<usize>,
}
impl ASTDiff {
pub fn add_mapping(&mut self, before_id: usize, after_id: usize, mapping: ASTMapping) {
self.mapping.insert((before_id, after_id), mapping);
self.before_node_map.insert(before_id, after_id);
self.after_node_map.insert(after_id, before_id);
}
pub fn remove_delete_mapping(&mut self, before_id: usize) {
self.mapping.remove(&(before_id, 0));
self.before_node_map.remove(&before_id);
}
pub fn remove_match_mapping(&mut self, before_id: usize, after_id: usize) {
self.mapping.remove(&(before_id, after_id));
self.before_node_map.remove(&before_id);
self.after_node_map.remove(&after_id);
}
pub fn remove_insert_mapping(&mut self, after_id: usize) {
self.mapping.remove(&(0, after_id));
self.after_node_map.remove(&after_id);
}
pub fn is_valid(&self, before: &Code, node_cache: &NodeCache) -> bool {
let language = before.metadata.language.unwrap_or_default();
for (before_id, after_id) in self.mapping.keys() {
if *before_id == 0 || *after_id == 0 {
continue;
}
let before_node = node_cache.before.get(before_id);
let after_node = node_cache.after.get(after_id);
if before_node.is_none() || after_node.is_none() {
return false;
}
let before_node = before_node.unwrap();
let after_node = after_node.unwrap();
if before_node.kind() != after_node.kind()
&& !crate::diff::nodes::kinds_update_allowed(
before_node.kind(),
after_node.kind(),
&language,
)
{
return false;
}
}
let partner_in_group = |group: &NodeGroup, member: usize, before_side: bool| {
let (map, other) = if before_side {
(&self.before_node_map, &group.after)
} else {
(&self.after_node_map, &group.before)
};
map.get(&member)
.is_some_and(|partner| other.contains(partner))
};
self.groups.iter().all(|group| {
group
.before
.iter()
.all(|&b| partner_in_group(group, b, true))
&& group
.after
.iter()
.all(|&a| partner_in_group(group, a, false))
}) && self.node_map_disagreements().is_empty()
}
pub fn node_map_disagreements(&self) -> Vec<(usize, usize)> {
let mut found = std::collections::BTreeSet::new();
for (&b, &a) in &self.before_node_map {
if b != 0 && a != 0 && self.after_node_map.get(&a) != Some(&b) {
if self.before_group.contains_key(&b) {
continue;
}
found.insert((b, a));
}
}
for (&a, &b) in &self.after_node_map {
if a != 0 && b != 0 && self.before_node_map.get(&b) != Some(&a) {
if self.after_group.contains_key(&a) {
continue;
}
found.insert((b, a));
}
}
found.into_iter().collect()
}
pub fn is_complete(&self, before: &Code, after: &Code, node_cache: &NodeCache) -> bool {
let before_root_id = before.ast.as_ref().unwrap().root_node().id();
for node_id in node_cache.before.keys() {
if *node_id != before_root_id && !self.before_node_map.contains_key(node_id) {
return false;
}
}
let after_root_id = after.ast.as_ref().unwrap().root_node().id();
for node_id in node_cache.after.keys() {
if *node_id != after_root_id && !self.after_node_map.contains_key(node_id) {
return false;
}
}
true
}
pub fn add_group(&mut self, before: &[usize], after: &[usize], mapping: ASTMapping) {
assert!(
!before.is_empty() && !after.is_empty(),
"a group needs members on both sides"
);
let index = self.groups.len();
let zipped = before.len().min(after.len());
for i in 0..zipped {
self.add_mapping(before[i], after[i], mapping.clone());
}
for &b in &before[zipped..] {
let a = after[zipped - 1];
self.mapping.insert((b, a), mapping.clone());
self.before_node_map.insert(b, a);
}
for &a in &after[zipped..] {
let b = before[zipped - 1];
self.mapping.insert((b, a), mapping.clone());
self.after_node_map.insert(a, b);
}
for &b in before {
self.before_group.insert(b, index);
}
for &a in after {
self.after_group.insert(a, index);
}
self.groups.push(NodeGroup {
before: before.to_vec(),
after: after.to_vec(),
});
}
pub fn before_group(&self, before_id: usize) -> Option<&NodeGroup> {
self.before_group
.get(&before_id)
.map(|&index| &self.groups[index])
}
pub fn after_group(&self, after_id: usize) -> Option<&NodeGroup> {
self.after_group
.get(&after_id)
.map(|&index| &self.groups[index])
}
pub fn before_partners(&self, before_id: usize) -> &[usize] {
if let Some(group) = self.before_group(before_id) {
return &group.after;
}
match self.before_node_map.get(&before_id) {
Some(partner) if *partner != 0 => std::slice::from_ref(partner),
_ => &[],
}
}
pub fn after_partners(&self, after_id: usize) -> &[usize] {
if let Some(group) = self.after_group(after_id) {
return &group.before;
}
match self.after_node_map.get(&after_id) {
Some(partner) if *partner != 0 => std::slice::from_ref(partner),
_ => &[],
}
}
pub fn mapping_for_node(&self, node_id: &usize) -> Option<(usize, ASTMapping)> {
if let Some(mapped_id) = self.before_node_map.get(node_id)
&& let Some(mapping) = self.mapping.get(&(*node_id, *mapped_id))
{
return Some((*mapped_id, mapping.clone()));
}
if let Some(mapped_id) = self.after_node_map.get(node_id)
&& let Some(mapping) = self.mapping.get(&(*mapped_id, *node_id))
{
return Some((*mapped_id, mapping.clone()));
}
None
}
}
#[derive(Debug, Clone, Default)]
pub struct ASTMapping {
pub cost: u64,
pub operation: ASTMappingOperation,
pub reason: ASTMappingReason,
}
impl ASTMapping {
pub fn identical(reason: ASTMappingReason) -> Self {
Self {
cost: 0,
operation: ASTMappingOperation::Identical,
reason,
}
}
pub fn matched_not_identical(reason: ASTMappingReason) -> Self {
Self {
cost: 0,
operation: ASTMappingOperation::MatchButNotIdentical,
reason,
}
}
pub fn updated(reason: ASTMappingReason) -> Self {
Self {
cost: COST_UPDATE,
operation: ASTMappingOperation::Update,
reason,
}
}
pub fn deleted(reason: ASTMappingReason) -> Self {
Self {
cost: COST_DELETE,
operation: ASTMappingOperation::Delete,
reason,
}
}
pub fn inserted(reason: ASTMappingReason) -> Self {
Self {
cost: COST_INSERT,
operation: ASTMappingOperation::Insert,
reason,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum ASTMappingOperation {
#[default]
NotYetSet,
Identical,
Update,
Insert,
InsertWithChildren,
Delete,
DeleteWithChildren,
MatchButNotIdentical,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum ASTMappingReason {
#[default]
IdenticalHash,
IdenticalHashOfAncestor,
FullyMappingSubtrees,
StructurallyIdenticalSubtrees,
StructurallyIdenticalAncestor,
OptimalIDU,
APTED(&'static str),
FlatSequenceDiff,
MovedSubtree,
LeadingSibling,
GreedyAnchorBlock,
BottomUpPropagation,
UniqueTypeMatching,
LeafBetweenMatchedNeighbours,
UnresolvedNode,
MutualAncestor,
NestedConditionCollapse,
HeritageClauseGrowth,
WrapGrowth,
OrphanedLeafUnderMatchedParent,
MultiMap,
}
impl ASTMappingReason {
pub fn bucket_label(&self) -> &'static str {
match self {
ASTMappingReason::IdenticalHash => "IdHash",
ASTMappingReason::IdenticalHashOfAncestor => "IdHashAnc",
ASTMappingReason::FullyMappingSubtrees => "FullMap",
ASTMappingReason::StructurallyIdenticalSubtrees => "StructId",
ASTMappingReason::StructurallyIdenticalAncestor => "StructAnc",
ASTMappingReason::OptimalIDU => "OptIDU",
ASTMappingReason::APTED(_) => "APTED",
ASTMappingReason::FlatSequenceDiff => "FlatSeq",
ASTMappingReason::MovedSubtree => "Moved",
ASTMappingReason::LeadingSibling => "LeadSib",
ASTMappingReason::GreedyAnchorBlock => "GreedyAnchor",
ASTMappingReason::BottomUpPropagation => "BottomUpProp",
ASTMappingReason::UniqueTypeMatching => "UniqueType",
ASTMappingReason::UnresolvedNode => "Unresolved",
ASTMappingReason::MutualAncestor => "MutualAnc",
ASTMappingReason::NestedConditionCollapse => "CondCollapse",
ASTMappingReason::HeritageClauseGrowth => "HeritageGrowth",
ASTMappingReason::WrapGrowth => "WrapGrowth",
ASTMappingReason::OrphanedLeafUnderMatchedParent => "OrphanLeaf",
ASTMappingReason::LeafBetweenMatchedNeighbours => "NeighbourLeaf",
ASTMappingReason::MultiMap => "MultiMap",
}
}
}
pub fn diff_code(before: &Code, after: &Code) -> Diff {
Diff::from_code(before, after)
}
pub fn diff_code_with_config(before: &Code, after: &Code, config: &HeuristicConfig) -> Diff {
Diff::from_code_with_config(before, after, config)
}
#[cfg(test)]
mod tests {
use crate::{
code::{Code, Language},
test,
};
use anyhow::Result;
use super::*;
#[test]
fn diff_code_does_not_panic_when_language_is_unknown() {
let before = Code::from_string("this is not code, just text", &Language::Unknown);
let after = Code::from_string("this is different text now", &Language::Unknown);
let diff = diff_code(&before, &after);
assert!(
diff.ast.is_some(),
"should still produce an (empty) ASTDiff, not panic"
);
}
#[test]
fn pending_finish_does_not_panic_when_language_is_unknown() {
let before = Code::from_string("this is not code, just text", &Language::Unknown);
let after = Code::from_string("this is different text now", &Language::Unknown);
let diff = Diff::pending(&before, &after).finish();
assert!(diff.ast.is_some());
}
#[test]
fn rust_completely_unrelated_main_files_resolves_fast() -> Result<()> {
let (before, after) =
&*test::helper::handmade_test_code_pair("rust-completely-unrelated-main-files")?;
let pending = Diff::pending(before, after);
assert!(
pending.large_residual(),
"this fixture's residual (~40%/~86% unmatched) should count as large - counts: {:?}",
pending.unmatched_counts()
);
let started = std::time::Instant::now();
let diff = Diff::from_code(before, after);
let elapsed = started.elapsed();
assert!(diff.ast.is_some());
#[cfg(not(debug_assertions))]
{
let instrumented = std::env::var_os("LLVM_PROFILE_FILE").is_some()
|| std::env::var_os("CARGO_LLVM_COV").is_some();
assert!(
instrumented || elapsed < std::time::Duration::from_secs(5),
"expected the terminal fallback to keep this fast, took {elapsed:?}"
);
}
#[cfg(debug_assertions)]
let _ = elapsed;
Ok(())
}
#[test]
fn test_compute_metadata() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
let code = test_codes.get("hello-world.rs").unwrap().clone();
let metadata = crate::code::metadata::compute_ast_metadata(&code)?;
assert!(!metadata.node_to_full_hash.is_empty());
assert!(!metadata.full_hash_to_node.is_empty());
assert!(!metadata.node_to_structural_hash.is_empty());
assert!(!metadata.structural_hash_to_node.is_empty());
assert!(!metadata.reference_nodes_ordered.is_empty());
let root_id = code.ast.as_ref().unwrap().root_node().id();
assert_eq!(metadata.reference_nodes_ordered[0], root_id);
assert_eq!(metadata.reference_nodes_ordered.len(), 2);
Ok(())
}
#[test]
fn diff_empty_rust_code() -> Result<()> {
let before = Code::from_string("", &Language::Rust);
let after = Code::from_string("", &Language::Rust);
let diff = diff_code(&before, &after);
assert!(diff.ast.is_some());
let diff_ast = diff.ast.unwrap();
assert_eq!(diff_ast.mapping.len(), 1);
Ok(())
}
#[test]
fn diff_identical_rust_code() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
let before = test_codes.get("hello-world.rs").unwrap().clone();
let after = test_codes.get("hello-world.rs").unwrap().clone();
let diff = diff_code(&before, &after);
assert!(diff.ast.is_some());
let diff_ast = diff.ast.unwrap();
assert_eq!(diff_ast.mapping.len(), 22);
let before_root_id = before.ast.as_ref().unwrap().root_node().id();
let after_root_id = after.ast.as_ref().unwrap().root_node().id();
let root_mapping = diff_ast
.mapping
.get(&(before_root_id, after_root_id))
.expect("Root node should be mapping");
assert_eq!(root_mapping.reason, ASTMappingReason::IdenticalHash);
assert_eq!(root_mapping.cost, 0);
for ((before_id, after_id), mapping) in &diff_ast.mapping {
if *before_id != before_root_id && *after_id != after_root_id {
assert_eq!(mapping.reason, ASTMappingReason::IdenticalHashOfAncestor);
}
}
Ok(())
}
#[test]
fn diff_hello_world_with_translated_string() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
let before = test_codes.get("hello-world.rs").unwrap().clone();
let after = test_codes.get("zdravo-svijete.rs").unwrap().clone();
let diff = diff_code(&before, &after);
assert!(diff.ast.is_some());
let diff_ast = diff.ast.unwrap();
assert_eq!(diff_ast.mapping.len(), 22);
let before_ast = before.ast.unwrap();
let after_ast = after.ast.unwrap();
let before_string_node = test::helper::node_for_path(
before_ast.root_node(),
&[
"function_item",
"block",
"expression_statement",
"macro_invocation",
"token_tree",
"string_literal",
"string_content",
],
)?;
let after_string_node = test::helper::node_for_path(
after_ast.root_node(),
&[
"function_item",
"block",
"expression_statement",
"macro_invocation",
"token_tree",
"string_literal",
"string_content",
],
)?;
let before_node_id = before_string_node.id();
let after_node_id = after_string_node.id();
let mapping = diff_ast.mapping.get(&(before_node_id, after_node_id));
assert!(mapping.is_some(), "String content nodes should be mapped");
let mapping = mapping.unwrap();
assert_eq!(
mapping.operation,
ASTMappingOperation::Update,
"String content mapping should be an Update operation"
);
assert_eq!(
mapping.reason,
ASTMappingReason::StructurallyIdenticalAncestor,
"String content mapping reason should be StructurallyIdenticalAncestor"
);
assert_eq!(
mapping.cost, COST_UPDATE,
"String content mapping cost should be COST_UPDATE"
);
Ok(())
}
#[test]
fn identical_code_must_always_match() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
for (filename, code) in &test_codes {
let diff = diff_code(code, code);
assert!(
diff.ast.is_some(),
"AST diff should be computed for {}",
filename
);
let diff_ast = diff.ast.unwrap();
let node_cache = NodeCache::build(code, code);
assert!(
diff_ast.is_valid(code, &node_cache),
"Identical code must always produce a valid diff: {}",
filename
);
assert!(
diff_ast.is_complete(code, code, &node_cache),
"Identical code must always produce a complete diff: {}",
filename
);
let before_root_id = code.ast.as_ref().unwrap().root_node().id();
let after_root_id = code.ast.as_ref().unwrap().root_node().id();
let root_mapping = diff_ast
.mapping
.get(&(before_root_id, after_root_id))
.expect("Root node should be mapping");
assert_eq!(
root_mapping.reason,
ASTMappingReason::IdenticalHash,
"Root node should have IdenticalHash reason for {}",
filename
);
assert_eq!(root_mapping.cost, 0);
for ((before_id, after_id), mapping) in &diff_ast.mapping {
if *before_id != before_root_id || *after_id != after_root_id {
assert_eq!(
mapping.reason,
ASTMappingReason::IdenticalHashOfAncestor,
"Non-root node should have IdenticalHashOfAncestor reason for {}, got {:?}",
filename,
mapping.reason
);
}
}
}
Ok(())
}
#[test]
fn hello_world_translations_in_all_languages() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
for (filename, before) in &test_codes {
if !filename.starts_with("hello-world") {
continue;
}
let after = test_codes
.get(&filename.replace("hello-world", "zdravo-svijete"))
.unwrap()
.clone();
let diff = diff_code(before, &after);
assert!(
diff.ast.is_some(),
"AST diff should be computed for {}",
filename
);
let diff_ast = diff.ast.unwrap();
let before_root_id = before.ast.as_ref().unwrap().root_node().id();
let after_root_id = after.ast.as_ref().unwrap().root_node().id();
let root_mapping = diff_ast
.mapping
.get(&(before_root_id, after_root_id))
.expect("Root node should be mapping");
assert_eq!(
root_mapping.reason,
ASTMappingReason::StructurallyIdenticalSubtrees,
"Root node should have StructurallyIdenticalSubtrees reason for {}",
filename
);
assert_eq!(
root_mapping.operation,
ASTMappingOperation::MatchButNotIdentical,
"Root node (an interior node) should be MatchButNotIdentical, not Update, for {}",
filename
);
assert_eq!(root_mapping.cost, 0);
for ((before_id, after_id), mapping) in &diff_ast.mapping {
if *before_id != before_root_id || *after_id != after_root_id {
assert!(
matches!(
mapping.reason,
ASTMappingReason::StructurallyIdenticalAncestor
| ASTMappingReason::IdenticalHash
| ASTMappingReason::IdenticalHashOfAncestor
),
"Non-root node should have StructurallyIdenticalAncestor, IdenticalHash \
or IdenticalHashOfAncestor reason for {}, got {:?}",
filename,
mapping.reason
);
}
}
}
Ok(())
}
#[test]
fn test_is_valid_with_identical_code() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
let before = test_codes.get("hello-world.rs").unwrap().clone();
let after = test_codes.get("hello-world.rs").unwrap().clone();
let diff = diff_code(&before, &after);
let diff_ast = diff.ast.unwrap();
let node_cache = NodeCache::build(&before, &after);
assert!(diff_ast.is_valid(&before, &node_cache));
Ok(())
}
#[test]
fn test_is_valid_with_different_code() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
let before = test_codes.get("hello-world.rs").unwrap().clone();
let after = test_codes.get("zdravo-svijete.rs").unwrap().clone();
let diff = diff_code(&before, &after);
let diff_ast = diff.ast.unwrap();
let node_cache = NodeCache::build(&before, &after);
assert!(diff_ast.is_valid(&before, &node_cache));
Ok(())
}
#[test]
fn test_is_valid_with_invalid_mapping() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
let before = test_codes.get("hello-world.rs").unwrap().clone();
let after = test_codes.get("hello-world.rs").unwrap().clone();
let mut diff = diff_code(&before, &after);
let diff_ast = diff.ast.as_mut().unwrap();
let before_ast = before.ast.as_ref().unwrap();
let after_ast = after.ast.as_ref().unwrap();
let before_root = before_ast.root_node();
let after_root = after_ast.root_node();
let mut before_cursor = before_root.walk();
let mut after_cursor = after_root.walk();
let before_function_item = before_root.children(&mut before_cursor).next().unwrap();
let after_function_item = after_root.children(&mut after_cursor).next().unwrap();
let mut before_leaf_cursor = before_function_item.walk();
let mut after_leaf_cursor = after_function_item.walk();
let before_leaf = before_function_item
.children(&mut before_leaf_cursor)
.find(|child| child.kind() == "identifier")
.unwrap();
let after_leaf = after_function_item
.children(&mut after_leaf_cursor)
.find(|child| child.kind() == "block")
.unwrap();
let invalid_before_id = before_leaf.id();
let invalid_after_id = after_leaf.id();
diff_ast.mapping.clear();
diff_ast.mapping.insert(
(invalid_before_id, invalid_after_id),
ASTMapping::identical(ASTMappingReason::IdenticalHash),
);
let node_cache = NodeCache::build(&before, &after);
assert!(
!diff_ast.is_valid(&before, &node_cache),
"Mapping should be invalid for different node types: {} vs {}",
before_leaf.kind(),
after_leaf.kind()
);
Ok(())
}
#[test]
fn test_is_valid_with_null_mapping() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
let before = test_codes.get("hello-world.rs").unwrap().clone();
let after = test_codes.get("hello-world.rs").unwrap().clone();
let mut diff = diff_code(&before, &after);
let diff_ast = diff.ast.as_mut().unwrap();
diff_ast.mapping.clear();
diff_ast.add_mapping(0, 123, ASTMapping::inserted(ASTMappingReason::OptimalIDU));
diff_ast.add_mapping(456, 0, ASTMapping::deleted(ASTMappingReason::OptimalIDU));
let node_cache = NodeCache::build(&before, &after);
assert!(
diff_ast.is_valid(&before, &node_cache),
"Null mappings (insert/delete) should be valid"
);
Ok(())
}
fn body_statements(code: &Code) -> Vec<usize> {
let root = code.ast.as_ref().unwrap().root_node();
let body = root.child(0).unwrap().child_by_field_name("body").unwrap();
let mut cursor = body.walk();
body.named_children(&mut cursor)
.map(|node| node.id())
.collect()
}
#[test]
fn a_group_zips_its_members_and_pairs_the_surplus_with_the_last_representative() {
let mut diff = ASTDiff::default();
diff.add_group(
&[1, 2, 3],
&[10, 20],
ASTMapping::identical(ASTMappingReason::IdenticalHash),
);
assert_eq!(diff.before_node_map[&1], 10);
assert_eq!(diff.before_node_map[&2], 20);
assert_eq!(diff.before_node_map[&3], 20);
assert_eq!(diff.after_node_map[&20], 2);
assert_eq!(diff.mapping.len(), 3);
assert!(diff.mapping.contains_key(&(3, 20)));
assert!(diff.node_map_disagreements().is_empty());
}
#[test]
fn a_group_member_has_the_whole_other_side_as_partners() {
let mut diff = ASTDiff::default();
let identical = ASTMapping::identical(ASTMappingReason::IdenticalHash);
diff.add_group(&[1], &[10, 20, 30], identical.clone());
diff.add_mapping(2, 40, identical);
diff.add_mapping(3, 0, ASTMapping::deleted(ASTMappingReason::UnresolvedNode));
assert_eq!(diff.before_partners(1), &[10, 20, 30]);
assert_eq!(diff.after_partners(30), &[1]);
assert_eq!(diff.before_partners(2), &[40]);
assert_eq!(diff.after_partners(40), &[2]);
assert!(diff.before_partners(3).is_empty());
assert!(diff.before_partners(99).is_empty());
assert_eq!(
diff.after_group(20),
Some(&NodeGroup {
before: vec![1],
after: vec![10, 20, 30],
})
);
assert_eq!(diff.before_group(2), None);
assert_eq!(
diff.mapping_for_node(&30).map(|(partner, _)| partner),
Some(1)
);
}
#[test]
fn an_overwritten_partner_is_a_disagreement() {
let mut diff = ASTDiff::default();
let identical = ASTMapping::identical(ASTMappingReason::IdenticalHash);
diff.add_mapping(1, 10, identical.clone());
diff.add_mapping(2, 10, identical);
assert_eq!(diff.node_map_disagreements(), vec![(1, 10)]);
}
#[test]
fn a_group_member_whose_partner_leaves_the_group_is_invalid() {
let before = Code::from_string("fn f() { foo(); foo(); }", &crate::code::Language::Rust);
let after = Code::from_string("fn f() { foo(); }", &crate::code::Language::Rust);
let node_cache = NodeCache::build(&before, &after);
let (copies, merged) = (body_statements(&before), body_statements(&after));
assert_eq!((copies.len(), merged.len()), (2, 1));
let mut diff = ASTDiff::default();
diff.add_group(
&copies,
&merged,
ASTMapping::identical(ASTMappingReason::IdenticalHash),
);
assert!(diff.is_valid(&before, &node_cache));
diff.before_node_map.insert(copies[1], 0);
assert!(!diff.is_valid(&before, &node_cache));
}
#[test]
fn test_add_mapping_updates_all_maps() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
let before = test_codes.get("hello-world.rs").unwrap().clone();
let after = test_codes.get("hello-world.rs").unwrap().clone();
let mut diff = ASTDiff {
..Default::default()
};
let before_root_id = before.ast.as_ref().unwrap().root_node().id();
let after_root_id = after.ast.as_ref().unwrap().root_node().id();
diff.add_mapping(
before_root_id,
after_root_id,
ASTMapping::identical(ASTMappingReason::IdenticalHash),
);
assert_eq!(diff.mapping.len(), 1);
assert_eq!(diff.before_node_map.len(), 1);
assert_eq!(diff.after_node_map.len(), 1);
assert!(diff.mapping.contains_key(&(before_root_id, after_root_id)));
assert_eq!(
diff.before_node_map.get(&before_root_id),
Some(&after_root_id)
);
assert_eq!(
diff.after_node_map.get(&after_root_id),
Some(&before_root_id)
);
Ok(())
}
#[test]
fn test_diff_populates_all_maps() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
let before = test_codes.get("hello-world.rs").unwrap().clone();
let after = test_codes.get("hello-world.rs").unwrap().clone();
let diff = diff_code(&before, &after);
let diff_ast = diff.ast.unwrap();
assert_eq!(diff_ast.mapping.len(), diff_ast.before_node_map.len());
assert_eq!(diff_ast.mapping.len(), diff_ast.after_node_map.len());
assert!(!diff_ast.mapping.is_empty());
for (before_id, after_id) in diff_ast.mapping.keys() {
assert_eq!(diff_ast.before_node_map.get(before_id), Some(after_id));
assert_eq!(diff_ast.after_node_map.get(after_id), Some(before_id));
}
Ok(())
}
#[test]
fn remove_match_mapping_leaves_both_nodes_undecided() {
let mut diff = ASTDiff::default();
diff.add_mapping(1, 2, ASTMapping::identical(ASTMappingReason::IdenticalHash));
diff.remove_match_mapping(1, 2);
assert!(diff.mapping.is_empty());
assert!(diff.mapping_for_node(&1).is_none());
assert!(diff.mapping_for_node(&2).is_none());
assert!(!diff.before_node_map.contains_key(&1));
assert!(!diff.after_node_map.contains_key(&2));
}
#[test]
fn is_complete_does_not_require_the_roots_to_be_mapped() -> Result<()> {
let test_codes = test::helper::handmade_test_code()?;
let code = test_codes.get("hello-world.rs").unwrap().clone();
let node_cache = NodeCache::build(&code, &code);
let root_id = code.ast.as_ref().unwrap().root_node().id();
let mut diff = ASTDiff::default();
for id in node_cache.before.keys().filter(|id| **id != root_id) {
diff.add_mapping(
*id,
*id,
ASTMapping::identical(ASTMappingReason::IdenticalHash),
);
}
assert!(diff.is_complete(&code, &code, &node_cache));
Ok(())
}
}