mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Refactor Reconciler equality checks and enhance path handling
- Introduced a new `trueEquals` function in `trueEquals.lua` for shared value equality checks, improving consistency between `diff.lua` and `matching.lua`. - Removed redundant equality logic from `diff.lua` and `matching.lua`, streamlining the codebase. - Updated path handling in various test snapshots to ensure correct reference values, enhancing the accuracy of reconciliation tests. - Implemented a post-processing step in `syncback` to fix `Rojo_Ref_*` attribute paths with correct dedup suffixes, improving path resolution. These changes enhance the clarity and efficiency of the Reconciler's equality checks and path handling, paving the way for improved project management and user experience.
This commit is contained in:
parent
aabbfb23b2
commit
e207a398b1
14 changed files with 832 additions and 218 deletions
|
|
@ -446,4 +446,54 @@ impl FsSnapshot {
|
|||
removed_dirs.sort_unstable();
|
||||
removed_dirs
|
||||
}
|
||||
|
||||
/// Post-process `Rojo_Ref_*` attribute paths in meta/model JSON files.
|
||||
///
|
||||
/// For each entry in `substitutions` (old_path → new_path), finds all
|
||||
/// `.meta.json5` and `.model.json5` files in the snapshot and replaces
|
||||
/// occurrences of the old path with the new path. This fixes tentative
|
||||
/// ref paths that didn't include dedup suffixes.
|
||||
pub fn fix_ref_paths(&mut self, substitutions: &[(String, String)]) {
|
||||
if substitutions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let ref_paths: Vec<PathBuf> = self
|
||||
.added_files
|
||||
.keys()
|
||||
.filter(|p| {
|
||||
let name = p.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
||||
name.ends_with(".meta.json5")
|
||||
|| name.ends_with(".meta.json")
|
||||
|| name.ends_with(".model.json5")
|
||||
|| name.ends_with(".model.json")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for path in ref_paths {
|
||||
let Some(contents) = self.added_files.get_mut(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = std::str::from_utf8(contents) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("Rojo_Ref_") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut modified = text.to_string();
|
||||
for (old_path, new_path) in substitutions {
|
||||
if old_path == new_path {
|
||||
continue;
|
||||
}
|
||||
// Replace exact path matches and prefix matches (path + "/")
|
||||
modified = modified.replace(old_path.as_str(), new_path.as_str());
|
||||
}
|
||||
|
||||
if modified != text {
|
||||
*contents = modified.into_bytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ use crate::variant_eq::variant_eq;
|
|||
|
||||
const UNMATCHED_PENALTY: u32 = 10_000;
|
||||
|
||||
/// Maximum recursion depth for `compute_change_count`. Beyond this depth,
|
||||
/// only flat property comparison is used (no subtree recursion).
|
||||
const MAX_SCORING_DEPTH: u32 = 3;
|
||||
|
||||
/// Result of the matching algorithm.
|
||||
#[derive(Debug)]
|
||||
pub struct MatchResult {
|
||||
|
|
@ -140,14 +144,22 @@ pub fn match_children(
|
|||
continue;
|
||||
}
|
||||
|
||||
// Score all (A, B) pairs
|
||||
// Score all (A, B) pairs using recursive change-count scoring
|
||||
let mut pairs: Vec<(u32, Ref, Ref)> = Vec::new();
|
||||
let mut best_so_far = u32::MAX;
|
||||
|
||||
for &new_ref in &avail_new {
|
||||
for &old_ref in &avail_old {
|
||||
let cost =
|
||||
count_own_diffs(new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes);
|
||||
let cost = compute_change_count(
|
||||
new_ref,
|
||||
old_ref,
|
||||
new_dom,
|
||||
old_dom,
|
||||
new_hashes,
|
||||
old_hashes,
|
||||
best_so_far,
|
||||
0,
|
||||
);
|
||||
pairs.push((cost, new_ref, old_ref));
|
||||
if cost < best_so_far {
|
||||
best_so_far = cost;
|
||||
|
|
@ -179,16 +191,156 @@ pub fn match_children(
|
|||
}
|
||||
}
|
||||
|
||||
/// Count own property diffs between two WeakDom instances.
|
||||
/// Hash fast-path: if hashes match, return 0 (identical, no property check needed).
|
||||
/// Each differing property = +1. Children count diff = +1.
|
||||
fn count_own_diffs(
|
||||
/// Lightweight matching result used during recursive scoring.
|
||||
struct ScoringMatchResult {
|
||||
matched: Vec<(usize, usize)>,
|
||||
unmatched_new: usize,
|
||||
unmatched_old: usize,
|
||||
}
|
||||
|
||||
/// Match children by reference for scoring purposes (non-consuming).
|
||||
/// Groups by (Name, ClassName), instant-matches 1:1 groups, and scores
|
||||
/// ambiguous groups using `compute_change_count` (mutually recursive).
|
||||
fn match_children_for_scoring(
|
||||
new_children: &[Ref],
|
||||
old_children: &[Ref],
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
new_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
old_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
depth: u32,
|
||||
) -> ScoringMatchResult {
|
||||
let mut new_matched = vec![false; new_children.len()];
|
||||
let mut old_matched = vec![false; old_children.len()];
|
||||
let mut matched = Vec::new();
|
||||
|
||||
if new_children.is_empty() && old_children.is_empty() {
|
||||
return ScoringMatchResult {
|
||||
matched,
|
||||
unmatched_new: 0,
|
||||
unmatched_old: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Group by (Name, ClassName)
|
||||
let mut new_by_key: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
for (i, &r) in new_children.iter().enumerate() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_key
|
||||
.entry((inst.name.clone(), inst.class.to_string()))
|
||||
.or_default()
|
||||
.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut old_by_key: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
for (i, &r) in old_children.iter().enumerate() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_key
|
||||
.entry((inst.name.clone(), inst.class.to_string()))
|
||||
.or_default()
|
||||
.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 1:1 groups: instant match
|
||||
for (key, new_indices) in &new_by_key {
|
||||
if let Some(old_indices) = old_by_key.get(key) {
|
||||
if new_indices.len() == 1 && old_indices.len() == 1 {
|
||||
let ni = new_indices[0];
|
||||
let oi = old_indices[0];
|
||||
matched.push((ni, oi));
|
||||
new_matched[ni] = true;
|
||||
old_matched[oi] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ambiguous groups: score + greedy assign
|
||||
for (key, new_indices) in &new_by_key {
|
||||
let Some(old_indices) = old_by_key.get(key) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let avail_new: Vec<usize> = new_indices
|
||||
.iter()
|
||||
.filter(|&&ni| !new_matched[ni])
|
||||
.copied()
|
||||
.collect();
|
||||
let avail_old: Vec<usize> = old_indices
|
||||
.iter()
|
||||
.filter(|&&oi| !old_matched[oi])
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if avail_new.is_empty() || avail_old.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if avail_new.len() == 1 && avail_old.len() == 1 {
|
||||
let ni = avail_new[0];
|
||||
let oi = avail_old[0];
|
||||
matched.push((ni, oi));
|
||||
new_matched[ni] = true;
|
||||
old_matched[oi] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut pairs: Vec<(u32, usize, usize)> = Vec::new();
|
||||
let mut best_so_far = u32::MAX;
|
||||
for &ni in &avail_new {
|
||||
for &oi in &avail_old {
|
||||
let cost = compute_change_count(
|
||||
new_children[ni],
|
||||
old_children[oi],
|
||||
new_dom,
|
||||
old_dom,
|
||||
new_hashes,
|
||||
old_hashes,
|
||||
best_so_far,
|
||||
depth,
|
||||
);
|
||||
pairs.push((cost, ni, oi));
|
||||
if cost < best_so_far {
|
||||
best_so_far = cost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pairs.sort_by_key(|&(cost, _, _)| cost);
|
||||
|
||||
for &(_, ni, oi) in &pairs {
|
||||
if new_matched[ni] || old_matched[oi] {
|
||||
continue;
|
||||
}
|
||||
matched.push((ni, oi));
|
||||
new_matched[ni] = true;
|
||||
old_matched[oi] = true;
|
||||
}
|
||||
}
|
||||
|
||||
let unmatched_new = new_matched.iter().filter(|&&m| !m).count();
|
||||
let unmatched_old = old_matched.iter().filter(|&&m| !m).count();
|
||||
|
||||
ScoringMatchResult {
|
||||
matched,
|
||||
unmatched_new,
|
||||
unmatched_old,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute total change count between two WeakDom instances, including
|
||||
/// recursive subtree scoring. Hash fast-path: if hashes match, return 0.
|
||||
///
|
||||
/// Mutually recursive with `match_children_for_scoring`.
|
||||
fn compute_change_count(
|
||||
new_ref: Ref,
|
||||
old_ref: Ref,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
new_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
old_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
best_so_far: u32,
|
||||
depth: u32,
|
||||
) -> u32 {
|
||||
// Hash fast-path: identical subtree = 0 cost
|
||||
if let (Some(nh), Some(oh)) = (new_hashes, old_hashes) {
|
||||
|
|
@ -199,6 +351,64 @@ fn count_own_diffs(
|
|||
}
|
||||
}
|
||||
|
||||
let mut cost = count_own_diffs(new_ref, old_ref, new_dom, old_dom);
|
||||
if cost >= best_so_far || depth >= MAX_SCORING_DEPTH {
|
||||
return cost;
|
||||
}
|
||||
|
||||
let new_inst = match new_dom.get_by_ref(new_ref) {
|
||||
Some(i) => i,
|
||||
None => return UNMATCHED_PENALTY,
|
||||
};
|
||||
let old_inst = match old_dom.get_by_ref(old_ref) {
|
||||
Some(i) => i,
|
||||
None => return UNMATCHED_PENALTY,
|
||||
};
|
||||
|
||||
let new_children = new_inst.children();
|
||||
let old_children = old_inst.children();
|
||||
|
||||
if new_children.is_empty() && old_children.is_empty() {
|
||||
return cost;
|
||||
}
|
||||
|
||||
let scoring = match_children_for_scoring(
|
||||
new_children,
|
||||
old_children,
|
||||
new_dom,
|
||||
old_dom,
|
||||
new_hashes,
|
||||
old_hashes,
|
||||
depth + 1,
|
||||
);
|
||||
|
||||
for &(ni, oi) in &scoring.matched {
|
||||
let remaining = best_so_far.saturating_sub(cost);
|
||||
cost += compute_change_count(
|
||||
new_children[ni],
|
||||
old_children[oi],
|
||||
new_dom,
|
||||
old_dom,
|
||||
new_hashes,
|
||||
old_hashes,
|
||||
remaining,
|
||||
depth + 1,
|
||||
);
|
||||
if cost >= best_so_far {
|
||||
return cost;
|
||||
}
|
||||
}
|
||||
|
||||
cost = cost
|
||||
.saturating_add((scoring.unmatched_new + scoring.unmatched_old) as u32 * UNMATCHED_PENALTY);
|
||||
|
||||
cost
|
||||
}
|
||||
|
||||
/// Count own property diffs between two WeakDom instances (flat, non-recursive).
|
||||
/// Each differing property = +1. Children count diff = +1.
|
||||
/// Does NOT include hash fast-path (handled by `compute_change_count`).
|
||||
fn count_own_diffs(new_ref: Ref, old_ref: Ref, new_dom: &WeakDom, old_dom: &WeakDom) -> u32 {
|
||||
let new_inst = match new_dom.get_by_ref(new_ref) {
|
||||
Some(i) => i,
|
||||
None => return UNMATCHED_PENALTY,
|
||||
|
|
@ -210,7 +420,6 @@ fn count_own_diffs(
|
|||
|
||||
let mut cost: u32 = 0;
|
||||
|
||||
// Properties present on new side
|
||||
for (key, new_val) in new_inst.properties.iter() {
|
||||
if let Some(old_val) = old_inst.properties.get(key) {
|
||||
if !variant_eq(new_val, old_val) {
|
||||
|
|
@ -221,14 +430,12 @@ fn count_own_diffs(
|
|||
}
|
||||
}
|
||||
|
||||
// Properties present only on old side
|
||||
for key in old_inst.properties.keys() {
|
||||
if !new_inst.properties.contains_key(key) {
|
||||
cost += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Children count diff
|
||||
if new_inst.children().len() != old_inst.children().len() {
|
||||
cost += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ pub fn syncback_loop_with_stats(
|
|||
}
|
||||
|
||||
log::debug!("Collecting referents for new DOM...");
|
||||
let deferred_referents = collect_referents(&new_tree, &pre_prune_paths);
|
||||
let deferred_referents = collect_referents(&new_tree, &pre_prune_paths, None);
|
||||
|
||||
// Remove any properties that are manually blocked from syncback via the
|
||||
// project file.
|
||||
|
|
@ -451,6 +451,7 @@ pub fn syncback_loop_with_stats(
|
|||
HashSet::new()
|
||||
};
|
||||
|
||||
let ref_path_map = std::cell::RefCell::new(HashMap::new());
|
||||
let syncback_data = SyncbackData {
|
||||
vfs,
|
||||
old_tree,
|
||||
|
|
@ -458,6 +459,7 @@ pub fn syncback_loop_with_stats(
|
|||
project,
|
||||
incremental,
|
||||
stats,
|
||||
ref_path_map: &ref_path_map,
|
||||
};
|
||||
|
||||
// Always start with old reference for the Project middleware.
|
||||
|
|
@ -609,6 +611,32 @@ pub fn syncback_loop_with_stats(
|
|||
snapshots.extend(syncback.children);
|
||||
}
|
||||
|
||||
// Post-process: fix Rojo_Ref_* attribute paths with correct dedup suffixes.
|
||||
// During the walk, we recorded (Ref → final_path) in the ref_path_map.
|
||||
// Compare against tentative_fs_path to find instances where dedup changed the path.
|
||||
{
|
||||
use ref_properties::tentative_fs_path_public;
|
||||
|
||||
let final_map = ref_path_map.borrow();
|
||||
let mut substitutions: Vec<(String, String)> = Vec::new();
|
||||
for (&inst_ref, final_path) in final_map.iter() {
|
||||
let tentative = tentative_fs_path_public(&new_tree, inst_ref);
|
||||
if tentative != *final_path {
|
||||
substitutions.push((tentative, final_path.clone()));
|
||||
}
|
||||
}
|
||||
if !substitutions.is_empty() {
|
||||
// Sort by length descending so longer paths are replaced first
|
||||
// (prevents partial prefix matches on shorter paths)
|
||||
substitutions.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
|
||||
log::debug!(
|
||||
"Fixing {} Rojo_Ref_* paths with dedup suffixes",
|
||||
substitutions.len()
|
||||
);
|
||||
fs_snapshot.fix_ref_paths(&substitutions);
|
||||
}
|
||||
}
|
||||
|
||||
// In clean mode, remove any existing paths that weren't overwritten by new structure.
|
||||
// This ensures no orphaned files remain from the old project layout.
|
||||
if !incremental && !existing_paths.is_empty() {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ use crate::{
|
|||
REF_ID_ATTRIBUTE_NAME, REF_PATH_ATTRIBUTE_PREFIX, REF_POINTER_ATTRIBUTE_PREFIX,
|
||||
};
|
||||
|
||||
/// Public wrapper for `tentative_fs_path` used by the post-processing step
|
||||
/// in `syncback_loop` to compare tentative vs final paths.
|
||||
pub fn tentative_fs_path_public(dom: &WeakDom, target_ref: Ref) -> String {
|
||||
tentative_fs_path(dom, target_ref)
|
||||
}
|
||||
|
||||
/// Compute a filesystem-name-compatible path for an instance in a bare WeakDom.
|
||||
///
|
||||
/// Each path segment is a **tentative filesystem name**: the slugified instance
|
||||
|
|
@ -172,16 +178,28 @@ pub fn collect_all_paths(dom: &WeakDom) -> HashMap<Ref, String> {
|
|||
/// The `pre_prune_paths` parameter should contain paths for instances that may
|
||||
/// have been pruned from the DOM. This allows references to instances outside
|
||||
/// the sync tree to be preserved as path-based attributes.
|
||||
pub fn collect_referents(dom: &WeakDom, pre_prune_paths: &HashMap<Ref, String>) -> RefLinks {
|
||||
///
|
||||
/// The `final_paths` parameter, when provided, contains the definitive
|
||||
/// filesystem-name-based paths assigned during the syncback walk (including
|
||||
/// dedup suffixes like `~1`). These take priority over `tentative_fs_path()`.
|
||||
pub fn collect_referents(
|
||||
dom: &WeakDom,
|
||||
pre_prune_paths: &HashMap<Ref, String>,
|
||||
final_paths: Option<&HashMap<Ref, String>>,
|
||||
) -> RefLinks {
|
||||
let mut path_links: HashMap<Ref, Vec<PathRefLink>> = HashMap::new();
|
||||
let id_links: HashMap<Ref, Vec<IdRefLink>> = HashMap::new();
|
||||
let targets_needing_id: HashSet<Ref> = HashSet::new();
|
||||
|
||||
// With the dedup system, ALL filesystem paths are unique (filesystem names
|
||||
// are unique among siblings by definition). Always use path-based linking.
|
||||
// ID-based fallback ($id/Rojo_Target_*) is only for instances without
|
||||
// filesystem backing (inside .rbxm files), which will be handled when the
|
||||
// syncback flow is restructured to pass filesystem name info.
|
||||
// NOTE: tentative_fs_path() does not include dedup suffixes (~N). For
|
||||
// instances with duplicate names under the same parent, this produces
|
||||
// ambiguous paths. Resolution via get_instance_by_path() returns the
|
||||
// first matching child, which may be wrong.
|
||||
//
|
||||
// When `final_paths` is provided (from the syncback walk), it is used
|
||||
// instead of tentative_fs_path() to get the correct dedup'd path.
|
||||
// Otherwise, falls back to tentative_fs_path() (pre-dedup, potentially
|
||||
// ambiguous for duplicate-named instances).
|
||||
|
||||
let mut queue = VecDeque::new();
|
||||
queue.push_back(dom.root_ref());
|
||||
|
|
@ -199,10 +217,12 @@ pub fn collect_referents(dom: &WeakDom, pre_prune_paths: &HashMap<Ref, String>)
|
|||
}
|
||||
|
||||
if dom.get_by_ref(*target_ref).is_some() {
|
||||
// Target exists in DOM -- always use path-based system.
|
||||
// Use tentative filesystem names so the path is compatible
|
||||
// with get_instance_by_path() resolution.
|
||||
let target_path = tentative_fs_path(dom, *target_ref);
|
||||
// Target exists in DOM -- use path-based system.
|
||||
// Prefer final_paths (dedup-aware) when available,
|
||||
// fall back to tentative_fs_path (pre-dedup).
|
||||
let target_path = final_paths
|
||||
.and_then(|fp| fp.get(target_ref).cloned())
|
||||
.unwrap_or_else(|| tentative_fs_path(dom, *target_ref));
|
||||
path_links.entry(inst_ref).or_default().push(PathRefLink {
|
||||
name: *prop_name,
|
||||
path: target_path,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use indexmap::IndexMap;
|
||||
use memofs::Vfs;
|
||||
use std::collections::HashSet;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{
|
||||
|
|
@ -29,6 +30,10 @@ pub struct SyncbackData<'sync> {
|
|||
pub(super) incremental: bool,
|
||||
/// Statistics tracker for syncback issues.
|
||||
pub(super) stats: &'sync SyncbackStats,
|
||||
/// Records (Ref → ref-path-string) for each instance as filenames are
|
||||
/// assigned during the syncback walk. Used after the walk to fix
|
||||
/// Rojo_Ref_* attribute paths with correct dedup suffixes.
|
||||
pub(super) ref_path_map: &'sync RefCell<HashMap<Ref, String>>,
|
||||
}
|
||||
|
||||
impl<'sync> SyncbackData<'sync> {
|
||||
|
|
@ -87,6 +92,10 @@ impl<'sync> SyncbackSnapshot<'sync> {
|
|||
snapshot.path = self.path.join(&*name);
|
||||
snapshot.needs_meta_name = needs_meta_name;
|
||||
|
||||
// Record the Ref → ref-path mapping for dedup-aware ref linking.
|
||||
// The ref path is built from the parent's entry + the child filename.
|
||||
self.record_ref_path(new_ref, &name);
|
||||
|
||||
Ok((snapshot, needs_meta_name, dedup_key))
|
||||
}
|
||||
|
||||
|
|
@ -126,9 +135,33 @@ impl<'sync> SyncbackSnapshot<'sync> {
|
|||
snapshot.path = base_path.join(&*name);
|
||||
snapshot.needs_meta_name = needs_meta_name;
|
||||
|
||||
// Record the Ref → ref-path mapping for dedup-aware ref linking.
|
||||
self.record_ref_path(new_ref, &name);
|
||||
|
||||
Ok((snapshot, needs_meta_name, dedup_key))
|
||||
}
|
||||
|
||||
/// Records the ref path for a child instance in the shared ref_path_map.
|
||||
/// The ref path is: self's ref path + "/" + child_filename.
|
||||
fn record_ref_path(&self, child_ref: Ref, child_filename: &str) {
|
||||
let parent_path = self
|
||||
.data
|
||||
.ref_path_map
|
||||
.borrow()
|
||||
.get(&self.new)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let child_path = if parent_path.is_empty() {
|
||||
child_filename.to_string()
|
||||
} else {
|
||||
format!("{}/{}", parent_path, child_filename)
|
||||
};
|
||||
self.data
|
||||
.ref_path_map
|
||||
.borrow_mut()
|
||||
.insert(child_ref, child_path);
|
||||
}
|
||||
|
||||
/// Constructs a SyncbackSnapshot with the provided path and refs while
|
||||
/// inheriting the data of the this snapshot.
|
||||
#[inline]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue