mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Complete implementation of the 3-pass matching algorithm for ambiguous path handling
- Finalized the transition from a duplicate-name blacklist system to a comprehensive 3-pass matching algorithm for both syncback and forward operations, enhancing the handling of ambiguous paths. - Integrated a new `matching.lua` module to facilitate instance matching during hydration, utilizing unique name matching, class name narrowing, and structural fingerprinting. - Updated various components, including `ChangeBatcher`, `Reconciler`, and `snapshot` modules, to leverage the new matching logic, ensuring robust file management and improved performance. - Removed legacy duplicate handling code and streamlined the encoding process for instances with duplicate names. These changes significantly enhance the clarity and efficiency of file operations, paving the way for improved project management and user experience.
This commit is contained in:
parent
cc77a2cd8a
commit
d6162ac210
21 changed files with 2507 additions and 846 deletions
293
src/syncback/dedup_suffix.rs
Normal file
293
src/syncback/dedup_suffix.rs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
//! Dedup suffix lifecycle management.
|
||||
//!
|
||||
//! When instances have duplicate names, they receive `~N` suffixes on the
|
||||
//! filesystem (e.g., `Foo`, `Foo~1`, `Foo~2`). This module provides shared
|
||||
//! helpers for managing suffix cleanup when instances are deleted.
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Gap-tolerant:** deleting a suffixed instance does NOT renumber remaining
|
||||
//! siblings. Gaps (`Foo`, `Foo~2` without `~1`) are harmless.
|
||||
//! - **Base-name promotion:** when the base-name holder is deleted and multiple
|
||||
//! siblings remain, the lowest-numbered suffix is promoted to base name.
|
||||
//! - **Group-to-1 cleanup:** when a deletion reduces the dedup group to exactly
|
||||
//! 1 remaining instance, its suffix is removed entirely.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Result of analyzing the dedup group after a deletion.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DedupCleanupAction {
|
||||
/// No rename needed. Either the deleted instance had a suffix and others
|
||||
/// remain, or the group is still large enough that no cleanup is required.
|
||||
None,
|
||||
|
||||
/// The dedup group shrank to exactly 1 remaining instance.
|
||||
/// Its suffix must be removed (renamed to the clean base name).
|
||||
RemoveSuffix {
|
||||
/// The current path (with suffix) that should be renamed.
|
||||
from: PathBuf,
|
||||
/// The target path (clean, no suffix).
|
||||
to: PathBuf,
|
||||
},
|
||||
|
||||
/// The base-name holder was deleted and the lowest-numbered suffix must be
|
||||
/// promoted to take the base name.
|
||||
PromoteLowest {
|
||||
/// The current path of the lowest-suffixed sibling.
|
||||
from: PathBuf,
|
||||
/// The base-name path (clean, no suffix).
|
||||
to: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parses a dedup suffix from a filesystem stem.
|
||||
///
|
||||
/// Given `"Foo~3"`, returns `Some(("Foo", 3))`.
|
||||
/// Given `"Foo"`, returns `None`.
|
||||
/// Given `"Foo~abc"` (non-numeric), returns `None`.
|
||||
pub fn parse_dedup_suffix(stem: &str) -> Option<(&str, u32)> {
|
||||
if let Some(tilde_pos) = stem.rfind('~') {
|
||||
let base = &stem[..tilde_pos];
|
||||
let suffix_str = &stem[tilde_pos + 1..];
|
||||
if let Ok(n) = suffix_str.parse::<u32>() {
|
||||
if n > 0 {
|
||||
return Some((base, n));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Checks whether a filename stem has a dedup suffix.
|
||||
pub fn has_dedup_suffix(stem: &str) -> bool {
|
||||
parse_dedup_suffix(stem).is_some()
|
||||
}
|
||||
|
||||
/// Builds a dedup'd filename from a base stem, optional suffix number, and
|
||||
/// extension.
|
||||
///
|
||||
/// - `build_dedup_name("Foo", None, Some("server.luau"))` → `"Foo.server.luau"`
|
||||
/// - `build_dedup_name("Foo", Some(2), Some("server.luau"))` → `"Foo~2.server.luau"`
|
||||
/// - `build_dedup_name("Foo", Some(1), None)` → `"Foo~1"` (directory)
|
||||
/// - `build_dedup_name("Foo", None, None)` → `"Foo"` (directory)
|
||||
pub fn build_dedup_name(base_stem: &str, suffix: Option<u32>, extension: Option<&str>) -> String {
|
||||
let stem = match suffix {
|
||||
Some(n) => format!("{base_stem}~{n}"),
|
||||
None => base_stem.to_string(),
|
||||
};
|
||||
match extension {
|
||||
Some(ext) => format!("{stem}.{ext}"),
|
||||
None => stem,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines the cleanup action needed after removing an instance from a
|
||||
/// dedup group.
|
||||
///
|
||||
/// `remaining_stems` contains the filesystem stems of all siblings that STILL
|
||||
/// exist after the deletion (not including the deleted one). These should share
|
||||
/// the same base stem (before `~N`). If the base-name holder was deleted, it
|
||||
/// should NOT appear in `remaining_stems`.
|
||||
///
|
||||
/// `deleted_was_base` indicates whether the deleted instance held the base name
|
||||
/// (no suffix).
|
||||
///
|
||||
/// Returns the appropriate cleanup action.
|
||||
pub fn compute_cleanup_action(
|
||||
base_stem: &str,
|
||||
extension: Option<&str>,
|
||||
remaining_stems: &[String],
|
||||
deleted_was_base: bool,
|
||||
parent_dir: &Path,
|
||||
) -> DedupCleanupAction {
|
||||
match remaining_stems.len() {
|
||||
0 => {
|
||||
// No siblings remain -- nothing to clean up.
|
||||
DedupCleanupAction::None
|
||||
}
|
||||
1 => {
|
||||
// Group shrank to 1: remove the survivor's suffix entirely.
|
||||
let survivor = &remaining_stems[0];
|
||||
let from_name = build_dedup_name(
|
||||
base_stem,
|
||||
parse_dedup_suffix(survivor).map(|(_, n)| n),
|
||||
extension,
|
||||
);
|
||||
let to_name = build_dedup_name(base_stem, None, extension);
|
||||
if from_name == to_name {
|
||||
// Survivor already has the clean name (was the base holder
|
||||
// and only one other was deleted).
|
||||
DedupCleanupAction::None
|
||||
} else {
|
||||
DedupCleanupAction::RemoveSuffix {
|
||||
from: parent_dir.join(&from_name),
|
||||
to: parent_dir.join(&to_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Multiple siblings remain.
|
||||
if deleted_was_base {
|
||||
// The base-name holder was deleted. Promote the lowest suffix.
|
||||
let mut suffix_numbers: Vec<u32> = remaining_stems
|
||||
.iter()
|
||||
.filter_map(|s| parse_dedup_suffix(s).map(|(_, n)| n))
|
||||
.collect();
|
||||
suffix_numbers.sort();
|
||||
|
||||
if let Some(&lowest) = suffix_numbers.first() {
|
||||
let from_name = build_dedup_name(base_stem, Some(lowest), extension);
|
||||
let to_name = build_dedup_name(base_stem, None, extension);
|
||||
DedupCleanupAction::PromoteLowest {
|
||||
from: parent_dir.join(&from_name),
|
||||
to: parent_dir.join(&to_name),
|
||||
}
|
||||
} else {
|
||||
// All remaining are base-name holders? Shouldn't happen.
|
||||
DedupCleanupAction::None
|
||||
}
|
||||
} else {
|
||||
// A suffixed instance was deleted. Gap-tolerant: no rename.
|
||||
DedupCleanupAction::None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn parse_suffix_basic() {
|
||||
assert_eq!(parse_dedup_suffix("Foo~1"), Some(("Foo", 1)));
|
||||
assert_eq!(parse_dedup_suffix("Foo~2"), Some(("Foo", 2)));
|
||||
assert_eq!(parse_dedup_suffix("Foo~10"), Some(("Foo", 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_suffix_none() {
|
||||
assert_eq!(parse_dedup_suffix("Foo"), None);
|
||||
assert_eq!(parse_dedup_suffix("Foo~0"), None); // 0 is not valid
|
||||
assert_eq!(parse_dedup_suffix("Foo~abc"), None);
|
||||
assert_eq!(parse_dedup_suffix("Foo~"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_suffix_complex_stems() {
|
||||
assert_eq!(parse_dedup_suffix("A_B~3"), Some(("A_B", 3)));
|
||||
assert_eq!(parse_dedup_suffix("My Script~1"), Some(("My Script", 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_suffix() {
|
||||
assert!(has_dedup_suffix("Foo~1"));
|
||||
assert!(!has_dedup_suffix("Foo"));
|
||||
assert!(!has_dedup_suffix("Foo~abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_name_file() {
|
||||
assert_eq!(
|
||||
build_dedup_name("Foo", None, Some("server.luau")),
|
||||
"Foo.server.luau"
|
||||
);
|
||||
assert_eq!(
|
||||
build_dedup_name("Foo", Some(1), Some("server.luau")),
|
||||
"Foo~1.server.luau"
|
||||
);
|
||||
assert_eq!(
|
||||
build_dedup_name("Foo", Some(2), Some("luau")),
|
||||
"Foo~2.luau"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_name_dir() {
|
||||
assert_eq!(build_dedup_name("Foo", None, None), "Foo");
|
||||
assert_eq!(build_dedup_name("Foo", Some(1), None), "Foo~1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_gap_tolerant() {
|
||||
// Delete ~1 from {Foo, Foo~1, Foo~2}: no rename needed.
|
||||
let remaining = vec!["Foo".to_string(), "Foo~2".to_string()];
|
||||
let action = compute_cleanup_action("Foo", None, &remaining, false, Path::new("/parent"));
|
||||
assert_eq!(action, DedupCleanupAction::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_group_to_one() {
|
||||
// Delete base from {Foo, Foo~1}: one remains, remove suffix.
|
||||
let remaining = vec!["Foo~1".to_string()];
|
||||
let action = compute_cleanup_action("Foo", None, &remaining, true, Path::new("/parent"));
|
||||
assert_eq!(
|
||||
action,
|
||||
DedupCleanupAction::RemoveSuffix {
|
||||
from: PathBuf::from("/parent/Foo~1"),
|
||||
to: PathBuf::from("/parent/Foo"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_group_to_one_file() {
|
||||
// Delete base from {Foo.luau, Foo~1.luau}: one remains, remove suffix.
|
||||
let remaining = vec!["Foo~1".to_string()];
|
||||
let action =
|
||||
compute_cleanup_action("Foo", Some("luau"), &remaining, true, Path::new("/parent"));
|
||||
assert_eq!(
|
||||
action,
|
||||
DedupCleanupAction::RemoveSuffix {
|
||||
from: PathBuf::from("/parent/Foo~1.luau"),
|
||||
to: PathBuf::from("/parent/Foo.luau"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_base_deleted_promote_lowest() {
|
||||
// Delete base from {Foo, Foo~1, Foo~2}: promote ~1 to base.
|
||||
let remaining = vec!["Foo~1".to_string(), "Foo~2".to_string()];
|
||||
let action = compute_cleanup_action("Foo", None, &remaining, true, Path::new("/parent"));
|
||||
assert_eq!(
|
||||
action,
|
||||
DedupCleanupAction::PromoteLowest {
|
||||
from: PathBuf::from("/parent/Foo~1"),
|
||||
to: PathBuf::from("/parent/Foo"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_base_deleted_promote_with_gap() {
|
||||
// Delete base from {Foo, Foo~2, Foo~5} (gap at ~1): promote ~2 to base.
|
||||
let remaining = vec!["Foo~2".to_string(), "Foo~5".to_string()];
|
||||
let action = compute_cleanup_action("Foo", None, &remaining, true, Path::new("/parent"));
|
||||
assert_eq!(
|
||||
action,
|
||||
DedupCleanupAction::PromoteLowest {
|
||||
from: PathBuf::from("/parent/Foo~2"),
|
||||
to: PathBuf::from("/parent/Foo"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_suffix_deleted_no_action() {
|
||||
// Delete ~1 from {Foo, Foo~1}: group shrinks to 1.
|
||||
// Since deleted_was_base=false and only 1 remains, remove its suffix.
|
||||
// Wait - if ~1 is deleted and only Foo remains, Foo has no suffix. No rename needed.
|
||||
let remaining = vec!["Foo".to_string()];
|
||||
let action = compute_cleanup_action("Foo", None, &remaining, false, Path::new("/parent"));
|
||||
// "Foo" is already the clean name, so no action.
|
||||
assert_eq!(action, DedupCleanupAction::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_no_remaining() {
|
||||
let action = compute_cleanup_action("Foo", None, &[], false, Path::new("/parent"));
|
||||
assert_eq!(action, DedupCleanupAction::None);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,10 +15,13 @@ use crate::{snapshot::InstanceWithMeta, snapshot_middleware::Middleware};
|
|||
/// - `filename`: The full filesystem name (including extension for file middleware).
|
||||
/// - `needs_meta_name`: `true` when the filesystem name differs from the instance
|
||||
/// name (meaning a `name` field must be written in metadata).
|
||||
/// - `dedup_key`: The bare slug (without extension) that callers must insert into
|
||||
/// `taken_names`. This is the name-level identifier used for collision detection.
|
||||
/// Callers should **always** use `dedup_key` (not `filename`) when accumulating
|
||||
/// into `taken_names`.
|
||||
/// - `dedup_key`: The **full filesystem name component** (slug + extension for
|
||||
/// files, directory name for dirs). Callers should insert
|
||||
/// `dedup_key.to_lowercase()` into `taken_names`.
|
||||
///
|
||||
/// This means `Folder "Foo"` (key `"Foo"`) and `Script "Foo"` (key
|
||||
/// `"Foo.server.luau"`) do NOT collide, while two `Folder "Foo"` instances
|
||||
/// (both key `"Foo"`) DO collide and receive dedup suffixes.
|
||||
///
|
||||
/// If `old_inst` exists, its existing path is preserved (incremental mode).
|
||||
/// For new instances, names with forbidden chars are slugified and deduplicated
|
||||
|
|
@ -35,10 +38,9 @@ pub fn name_for_inst<'a>(
|
|||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.context("sources on the file system should be valid unicode and not be stubs")?;
|
||||
// Derive dedup_key by stripping the middleware extension from the
|
||||
// filename. For Dir middleware the extension is empty so this is a
|
||||
// no-op. This keeps extension logic inside this function.
|
||||
let dedup_key = strip_middleware_extension(name, middleware);
|
||||
// dedup_key is the full filesystem name (including extension).
|
||||
// For Dir middleware the filename IS the directory name (no extension).
|
||||
let dedup_key = name.to_string();
|
||||
Ok((Cow::Borrowed(name), false, dedup_key))
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
|
|
@ -55,32 +57,30 @@ pub fn name_for_inst<'a>(
|
|||
new_inst.name.clone()
|
||||
};
|
||||
|
||||
match middleware {
|
||||
let is_dir = matches!(
|
||||
middleware,
|
||||
Middleware::Dir
|
||||
| Middleware::CsvDir
|
||||
| Middleware::ServerScriptDir
|
||||
| Middleware::ClientScriptDir
|
||||
| Middleware::ModuleScriptDir
|
||||
| Middleware::PluginScriptDir
|
||||
| Middleware::LocalScriptDir
|
||||
| Middleware::LegacyScriptDir => {
|
||||
let deduped = deduplicate_name(&base, taken_names);
|
||||
let needs_meta = needs_slugify || deduped != base;
|
||||
let dedup_key = deduped.clone();
|
||||
Ok((Cow::Owned(deduped), needs_meta, dedup_key))
|
||||
}
|
||||
_ => {
|
||||
let extension = extension_for_middleware(middleware);
|
||||
let deduped = deduplicate_name(&base, taken_names);
|
||||
let needs_meta = needs_slugify || deduped != base;
|
||||
let dedup_key = deduped.clone();
|
||||
Ok((
|
||||
Cow::Owned(format!("{deduped}.{extension}")),
|
||||
needs_meta,
|
||||
dedup_key,
|
||||
))
|
||||
}
|
||||
}
|
||||
| Middleware::CsvDir
|
||||
| Middleware::ServerScriptDir
|
||||
| Middleware::ClientScriptDir
|
||||
| Middleware::ModuleScriptDir
|
||||
| Middleware::PluginScriptDir
|
||||
| Middleware::LocalScriptDir
|
||||
| Middleware::LegacyScriptDir
|
||||
);
|
||||
|
||||
let extension = if is_dir {
|
||||
None
|
||||
} else {
|
||||
Some(extension_for_middleware(middleware))
|
||||
};
|
||||
|
||||
let (deduped_slug, full_fs_name) =
|
||||
deduplicate_name_with_ext(&base, extension, taken_names);
|
||||
let needs_meta = needs_slugify || deduped_slug != base;
|
||||
|
||||
let filename = full_fs_name.clone();
|
||||
Ok((Cow::Owned(filename), needs_meta, full_fs_name))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,12 +268,46 @@ pub fn slugify_name(name: &str) -> String {
|
|||
result
|
||||
}
|
||||
|
||||
/// Appends ~1, ~2, etc. to avoid collisions. Returns the name as-is if
|
||||
/// unclaimed.
|
||||
/// Appends ~1, ~2, etc. to the slug to avoid filesystem-level collisions.
|
||||
/// Returns `(deduped_slug, full_fs_name)`.
|
||||
///
|
||||
/// `extension` is `None` for directory middleware, `Some("server.luau")` etc.
|
||||
/// for file middleware. The collision check uses the full filesystem name
|
||||
/// (slug + extension) so that `Folder "Foo"` (key `"foo"`) and `Script "Foo"`
|
||||
/// (key `"foo.server.luau"`) do NOT collide.
|
||||
///
|
||||
/// Comparisons are **case-insensitive** because Windows and macOS have
|
||||
/// case-insensitive filesystems. `taken_names` must contain **lowercased**
|
||||
/// entries for this to work correctly.
|
||||
/// full filesystem name entries for this to work correctly.
|
||||
pub fn deduplicate_name_with_ext(
|
||||
base: &str,
|
||||
extension: Option<&str>,
|
||||
taken_names: &std::collections::HashSet<String>,
|
||||
) -> (String, String) {
|
||||
let build_full = |slug: &str| -> String {
|
||||
match extension {
|
||||
Some(ext) => format!("{slug}.{ext}"),
|
||||
None => slug.to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
let full_name = build_full(base);
|
||||
if !taken_names.contains(&full_name.to_lowercase()) {
|
||||
return (base.to_string(), full_name);
|
||||
}
|
||||
for i in 1.. {
|
||||
let candidate_slug = format!("{base}~{i}");
|
||||
let candidate_full = build_full(&candidate_slug);
|
||||
if !taken_names.contains(&candidate_full.to_lowercase()) {
|
||||
return (candidate_slug, candidate_full);
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Legacy wrapper: appends ~1, ~2, etc. to avoid collisions using bare slug
|
||||
/// as the collision key. Used by callers that don't yet use full filesystem
|
||||
/// name dedup (e.g., change_processor.rs renames).
|
||||
pub fn deduplicate_name(base: &str, taken_names: &std::collections::HashSet<String>) -> String {
|
||||
let base_lower = base.to_lowercase();
|
||||
if !taken_names.contains(&base_lower) {
|
||||
|
|
@ -794,7 +828,7 @@ mod tests {
|
|||
let dom = make_inst("Foo", "ModuleScript");
|
||||
let child_ref = dom.root().children()[0];
|
||||
let child = dom.get_by_ref(child_ref).unwrap();
|
||||
let taken: HashSet<String> = ["foo".to_string()].into_iter().collect();
|
||||
let taken: HashSet<String> = ["foo.luau".to_string()].into_iter().collect();
|
||||
|
||||
let (filename, needs_meta, _dk) =
|
||||
name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap();
|
||||
|
|
@ -817,11 +851,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn name_for_inst_slug_plus_dedup() {
|
||||
// Name with forbidden chars AND collision with existing slug
|
||||
// Name with forbidden chars AND collision with existing full filesystem name
|
||||
let dom = make_inst("Hey/Bro", "ModuleScript");
|
||||
let child_ref = dom.root().children()[0];
|
||||
let child = dom.get_by_ref(child_ref).unwrap();
|
||||
let taken: HashSet<String> = ["hey_bro".to_string()].into_iter().collect();
|
||||
let taken: HashSet<String> = ["hey_bro.luau".to_string()].into_iter().collect();
|
||||
|
||||
let (filename, needs_meta, _dk) =
|
||||
name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap();
|
||||
|
|
@ -1158,9 +1192,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn nfi_slug_collision_file_middleware() {
|
||||
// For file middleware, dedup compares bare slug against taken.
|
||||
// Using bare names to verify the dedup logic works.
|
||||
let (f, m) = nfi("Hey/Bro", Middleware::ModuleScript, &["hey_bro"]);
|
||||
// For file middleware, dedup compares full filesystem name against taken.
|
||||
let (f, m) = nfi("Hey/Bro", Middleware::ModuleScript, &["hey_bro.luau"]);
|
||||
assert_eq!(f, "Hey_Bro~1.luau");
|
||||
assert!(m);
|
||||
}
|
||||
|
|
@ -1581,10 +1614,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn disk_seed_file_middleware_collides_with_bare_slug() {
|
||||
// taken_names now uses bare slugs (dedup_keys), so file-format
|
||||
// dedup works correctly. "mymodule" in taken matches slug "MyModule".
|
||||
// taken_names stores full filesystem names (lowercased).
|
||||
// A bare directory name "mymodule" does NOT collide with "mymodule.luau".
|
||||
// Seed with "mymodule.luau" to test file-file collision.
|
||||
let r = process_new_children_with_disk_seed(
|
||||
&["mymodule"],
|
||||
&["mymodule.luau"],
|
||||
&[("MyModule", Middleware::ModuleScript)],
|
||||
);
|
||||
assert_eq!(r[0].1, "MyModule~1.luau");
|
||||
|
|
@ -1920,10 +1954,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn nightmare_mixed_middleware_dir_then_file() {
|
||||
// "Shared" as Dir (filename "Shared") then ModuleScript.
|
||||
// Dir filename "Shared" → taken gets "shared".
|
||||
// ModuleScript dedup checks "shared" → MATCH → dedup to "Shared~1".
|
||||
// Then final filename becomes "Shared~1.luau".
|
||||
// "Shared" as Dir (filename "Shared", dedup_key "Shared") then ModuleScript.
|
||||
// Dir dedup_key "shared" in taken does NOT collide with "shared.luau".
|
||||
// Dir and file with same base name occupy different filesystem namespaces.
|
||||
let r = process_new_children_with_disk_seed(
|
||||
&[],
|
||||
&[
|
||||
|
|
@ -1933,17 +1966,16 @@ mod tests {
|
|||
);
|
||||
assert_eq!(r[0].1, "Shared");
|
||||
assert!(!r[0].2);
|
||||
// File middleware bare slug "Shared" matches dir entry "shared" in taken.
|
||||
assert_eq!(r[1].1, "Shared~1.luau");
|
||||
assert!(r[1].2);
|
||||
assert_eq!(r[1].1, "Shared.luau");
|
||||
assert!(!r[1].2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nightmare_mixed_middleware_file_then_dir() {
|
||||
// Reverse order: ModuleScript first, Dir second.
|
||||
// ModuleScript "Shared" → dedup_key "shared" → taken gets "shared"
|
||||
// Dir "Shared" → slug "Shared" → dedup checks "shared" → MATCH
|
||||
// (dedup_key-based taken catches this correctly) → "Shared~1"
|
||||
// ModuleScript "Shared" → dedup_key "shared.luau" in taken.
|
||||
// Dir "Shared" → full_name "Shared", checks "shared" against taken.
|
||||
// "shared" != "shared.luau" → no collision. Dir and file coexist.
|
||||
let r = process_new_children_with_disk_seed(
|
||||
&[],
|
||||
&[
|
||||
|
|
@ -1953,9 +1985,8 @@ mod tests {
|
|||
);
|
||||
assert_eq!(r[0].1, "Shared.luau");
|
||||
assert!(!r[0].2);
|
||||
// Dir slug "Shared" collides with dedup_key "shared" from the ModuleScript
|
||||
assert_eq!(r[1].1, "Shared~1");
|
||||
assert!(r[1].2, "dedup suffix means different from instance name");
|
||||
assert_eq!(r[1].1, "Shared");
|
||||
assert!(!r[1].2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2393,14 +2424,9 @@ mod tests {
|
|||
assert_eq!(name1.as_ref(), "Hey_Bro.luau");
|
||||
assert!(meta1);
|
||||
|
||||
// On the old-inst path the existing filename is preserved,
|
||||
// so a second syncback (incremental) should not change the name.
|
||||
// We can't easily test the full old-inst path here since it
|
||||
// requires InstanceWithMeta, but we verify the dedup_key is
|
||||
// stable: calling strip_middleware_extension on the result gives
|
||||
// the same dedup_key.
|
||||
let dk_from_filename = strip_middleware_extension(&name1, Middleware::ModuleScript);
|
||||
assert_eq!(dk_from_filename.to_lowercase(), dk1.to_lowercase());
|
||||
// dedup_key is now the full filesystem name (including extension).
|
||||
// For file middleware, dedup_key == filename.
|
||||
assert_eq!(dk1.to_lowercase(), name1.as_ref().to_lowercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2424,7 +2450,7 @@ mod tests {
|
|||
.unwrap();
|
||||
assert_eq!(filename.as_ref(), "Hey_Bro.luau");
|
||||
assert!(needs_meta);
|
||||
assert_eq!(dedup_key, "Hey_Bro");
|
||||
assert_eq!(dedup_key, "Hey_Bro.luau");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
769
src/syncback/matching.rs
Normal file
769
src/syncback/matching.rs
Normal file
|
|
@ -0,0 +1,769 @@
|
|||
//! 3-pass instance matching algorithm for syncback.
|
||||
//!
|
||||
//! Pairs new children (from Roblox file) to old children (from filesystem)
|
||||
//! using progressively more expensive matching signals:
|
||||
//!
|
||||
//! - **Pass 1** (O(n)): unique name matching + ClassName narrowing within
|
||||
//! same-name groups.
|
||||
//! - **Pass 2**: Ref property discriminators. If a Ref target is already
|
||||
//! matched in Pass 1, use it to differentiate candidates.
|
||||
//! - **Pass 3**: pairwise similarity scoring using precomputed subtree
|
||||
//! hashes. Greedy best-match-first with DOM child order as tiebreaker.
|
||||
//!
|
||||
//! The algorithm is designed for the syncback context where both "new" and
|
||||
//! "old" sides are WeakDom instances.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use blake3::Hash;
|
||||
use rbx_dom_weak::{
|
||||
types::{Ref, Variant},
|
||||
ustr, WeakDom,
|
||||
};
|
||||
|
||||
/// Result of the matching algorithm.
|
||||
#[derive(Debug)]
|
||||
pub struct MatchResult {
|
||||
/// Pairs of (new_ref, old_ref) that were matched.
|
||||
pub matched: Vec<(Ref, Ref)>,
|
||||
/// New children with no match on the old side (to be created).
|
||||
pub unmatched_new: Vec<Ref>,
|
||||
/// Old children with no match on the new side (to be deleted).
|
||||
pub unmatched_old: Vec<Ref>,
|
||||
}
|
||||
|
||||
/// Run the 3-pass matching algorithm.
|
||||
///
|
||||
/// `new_children` and `old_children` are the Refs of children under the same
|
||||
/// parent in their respective WeakDoms. `new_hashes` and `old_hashes` are
|
||||
/// optional precomputed subtree hashes (from `hash_tree()`).
|
||||
pub fn match_children(
|
||||
new_children: &[Ref],
|
||||
old_children: &[Ref],
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
new_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
old_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
) -> MatchResult {
|
||||
if new_children.is_empty() && old_children.is_empty() {
|
||||
return MatchResult {
|
||||
matched: Vec::new(),
|
||||
unmatched_new: Vec::new(),
|
||||
unmatched_old: Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut matched: Vec<(Ref, Ref)> = Vec::new();
|
||||
let mut remaining_new: Vec<Ref> = new_children.to_vec();
|
||||
let mut remaining_old: Vec<Ref> = old_children.to_vec();
|
||||
|
||||
// ---- Pass 1: unique name matching + ClassName narrowing ----
|
||||
pass1_name_and_class(
|
||||
&mut remaining_new,
|
||||
&mut remaining_old,
|
||||
new_dom,
|
||||
old_dom,
|
||||
&mut matched,
|
||||
);
|
||||
|
||||
// Fast path: if all matched, skip Passes 2 and 3.
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return MatchResult {
|
||||
matched,
|
||||
unmatched_new: remaining_new,
|
||||
unmatched_old: remaining_old,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Pass 2: Ref property discriminators ----
|
||||
// Snapshot the already-matched pairs so we can borrow matched mutably.
|
||||
let matched_snapshot: Vec<(Ref, Ref)> = matched.clone();
|
||||
pass2_ref_discriminators(
|
||||
&mut remaining_new,
|
||||
&mut remaining_old,
|
||||
new_dom,
|
||||
old_dom,
|
||||
&matched_snapshot,
|
||||
&mut matched,
|
||||
);
|
||||
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return MatchResult {
|
||||
matched,
|
||||
unmatched_new: remaining_new,
|
||||
unmatched_old: remaining_old,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Pass 3: similarity scoring ----
|
||||
pass3_similarity(
|
||||
&mut remaining_new,
|
||||
&mut remaining_old,
|
||||
new_dom,
|
||||
old_dom,
|
||||
new_hashes,
|
||||
old_hashes,
|
||||
new_children,
|
||||
old_children,
|
||||
&mut matched,
|
||||
);
|
||||
|
||||
MatchResult {
|
||||
matched,
|
||||
unmatched_new: remaining_new,
|
||||
unmatched_old: remaining_old,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pass 1: Match instances with unique names, then narrow same-name groups by
|
||||
/// ClassName.
|
||||
fn pass1_name_and_class(
|
||||
remaining_new: &mut Vec<Ref>,
|
||||
remaining_old: &mut Vec<Ref>,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
matched: &mut Vec<(Ref, Ref)>,
|
||||
) {
|
||||
// Build name→refs maps for both sides.
|
||||
let mut new_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_new.iter() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_name
|
||||
.entry(inst.name.clone())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_old.iter() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_name
|
||||
.entry(inst.name.to_string())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: Vec<Ref> = Vec::new();
|
||||
let mut matched_old: Vec<Ref> = Vec::new();
|
||||
|
||||
// Sort names for deterministic iteration order.
|
||||
let mut sorted_names: Vec<&String> = new_by_name.keys().collect();
|
||||
sorted_names.sort();
|
||||
|
||||
for name in sorted_names {
|
||||
let new_refs = &new_by_name[name];
|
||||
let Some(old_refs) = old_by_name.get(name.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Case 1: exactly one on each side → instant match.
|
||||
if new_refs.len() == 1 && old_refs.len() == 1 {
|
||||
matched.push((new_refs[0], old_refs[0]));
|
||||
matched_new.push(new_refs[0]);
|
||||
matched_old.push(old_refs[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: multiple on at least one side → try ClassName narrowing.
|
||||
// Build (Name, ClassName) → refs maps within this name group.
|
||||
let mut new_by_class: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||||
for &r in new_refs {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_class.entry(&inst.class).or_default().push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_class: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||||
for &r in old_refs {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_class.entry(&inst.class).or_default().push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// Match (Name, ClassName) pairs that are unique on both sides.
|
||||
for (class_name, new_class_refs) in &new_by_class {
|
||||
if let Some(old_class_refs) = old_by_class.get(class_name) {
|
||||
if new_class_refs.len() == 1 && old_class_refs.len() == 1 {
|
||||
matched.push((new_class_refs[0], old_class_refs[0]));
|
||||
matched_new.push(new_class_refs[0]);
|
||||
matched_old.push(old_class_refs[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove matched refs from remaining lists.
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
}
|
||||
|
||||
/// Pass 2: Use pre-resolved Ref properties to differentiate instances within
|
||||
/// same-name groups.
|
||||
fn pass2_ref_discriminators(
|
||||
remaining_new: &mut Vec<Ref>,
|
||||
remaining_old: &mut Vec<Ref>,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
already_matched: &[(Ref, Ref)],
|
||||
matched: &mut Vec<(Ref, Ref)>,
|
||||
) {
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build bidirectional match maps: new→old and old→new.
|
||||
let new_to_old: HashMap<Ref, Ref> = already_matched.iter().copied().collect();
|
||||
let old_to_new: HashMap<Ref, Ref> = already_matched
|
||||
.iter()
|
||||
.map(|&(n, o)| (o, n))
|
||||
.collect();
|
||||
|
||||
// Collect Ref property targets for each remaining instance.
|
||||
let new_ref_targets: HashMap<Ref, Vec<Ref>> = remaining_new
|
||||
.iter()
|
||||
.map(|&r| (r, collect_ref_property_targets(new_dom, r)))
|
||||
.collect();
|
||||
let old_ref_targets: HashMap<Ref, Vec<Ref>> = remaining_old
|
||||
.iter()
|
||||
.map(|&r| (r, collect_ref_property_targets(old_dom, r)))
|
||||
.collect();
|
||||
|
||||
// Group remaining by name for matching within groups.
|
||||
let mut new_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_new.iter() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_name
|
||||
.entry(inst.name.clone())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_old.iter() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_name
|
||||
.entry(inst.name.to_string())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: Vec<Ref> = Vec::new();
|
||||
let mut matched_old: Vec<Ref> = Vec::new();
|
||||
|
||||
for (name, new_refs) in &new_by_name {
|
||||
let Some(old_refs) = old_by_name.get(name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// For each new instance in this group, compute a "ref signature":
|
||||
// the set of matched counterparts its Ref targets point to.
|
||||
for &new_ref in new_refs {
|
||||
if matched_new.contains(&new_ref) {
|
||||
continue;
|
||||
}
|
||||
let new_targets = &new_ref_targets[&new_ref];
|
||||
if new_targets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Map new targets through the match map to get "translated" targets.
|
||||
let translated: Vec<Ref> = new_targets
|
||||
.iter()
|
||||
.filter_map(|t| new_to_old.get(t).copied())
|
||||
.collect();
|
||||
if translated.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find old candidates that have Ref targets matching the translated set.
|
||||
let mut candidates: Vec<Ref> = Vec::new();
|
||||
for &old_ref in old_refs {
|
||||
if matched_old.contains(&old_ref) {
|
||||
continue;
|
||||
}
|
||||
let old_targets = &old_ref_targets[&old_ref];
|
||||
let old_translated: Vec<Ref> = old_targets
|
||||
.iter()
|
||||
.filter_map(|t| old_to_new.get(t).copied())
|
||||
.collect();
|
||||
|
||||
// Check if the ref signatures overlap.
|
||||
if translated.iter().any(|t| old_targets.contains(t))
|
||||
|| old_translated.iter().any(|t| new_targets.contains(t))
|
||||
{
|
||||
candidates.push(old_ref);
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.len() == 1 {
|
||||
matched.push((new_ref, candidates[0]));
|
||||
matched_new.push(new_ref);
|
||||
matched_old.push(candidates[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
}
|
||||
|
||||
/// Pass 3: Similarity scoring using subtree hashes and greedy assignment.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pass3_similarity(
|
||||
remaining_new: &mut Vec<Ref>,
|
||||
remaining_old: &mut Vec<Ref>,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
new_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
old_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
original_new_children: &[Ref],
|
||||
original_old_children: &[Ref],
|
||||
matched: &mut Vec<(Ref, Ref)>,
|
||||
) {
|
||||
if remaining_new.is_empty() || remaining_old.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group remaining by name.
|
||||
let mut new_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_new.iter() {
|
||||
if let Some(inst) = new_dom.get_by_ref(r) {
|
||||
new_by_name
|
||||
.entry(inst.name.clone())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
let mut old_by_name: HashMap<String, Vec<Ref>> = HashMap::new();
|
||||
for &r in remaining_old.iter() {
|
||||
if let Some(inst) = old_dom.get_by_ref(r) {
|
||||
old_by_name
|
||||
.entry(inst.name.to_string())
|
||||
.or_default()
|
||||
.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched_new: Vec<Ref> = Vec::new();
|
||||
let mut matched_old: Vec<Ref> = Vec::new();
|
||||
|
||||
for (name, new_refs) in &new_by_name {
|
||||
let Some(old_refs) = old_by_name.get(name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Build all candidate pairs with their similarity scores.
|
||||
let mut pairs: Vec<(Ref, Ref, u32)> = Vec::new();
|
||||
for &new_ref in new_refs {
|
||||
if matched_new.contains(&new_ref) {
|
||||
continue;
|
||||
}
|
||||
for &old_ref in old_refs {
|
||||
if matched_old.contains(&old_ref) {
|
||||
continue;
|
||||
}
|
||||
let score = compute_similarity(
|
||||
new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes,
|
||||
);
|
||||
pairs.push((new_ref, old_ref, score));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending. Tiebreaker: original DOM child order
|
||||
// (lower index = earlier in children list = preferred).
|
||||
pairs.sort_by(|a, b| {
|
||||
b.2.cmp(&a.2).then_with(|| {
|
||||
let a_new_idx = original_new_children
|
||||
.iter()
|
||||
.position(|&r| r == a.0)
|
||||
.unwrap_or(usize::MAX);
|
||||
let b_new_idx = original_new_children
|
||||
.iter()
|
||||
.position(|&r| r == b.0)
|
||||
.unwrap_or(usize::MAX);
|
||||
a_new_idx.cmp(&b_new_idx).then_with(|| {
|
||||
let a_old_idx = original_old_children
|
||||
.iter()
|
||||
.position(|&r| r == a.1)
|
||||
.unwrap_or(usize::MAX);
|
||||
let b_old_idx = original_old_children
|
||||
.iter()
|
||||
.position(|&r| r == b.1)
|
||||
.unwrap_or(usize::MAX);
|
||||
a_old_idx.cmp(&b_old_idx)
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
// Greedy assignment: pick highest-scored pair, remove both, repeat.
|
||||
for (new_ref, old_ref, _score) in &pairs {
|
||||
if matched_new.contains(new_ref) || matched_old.contains(old_ref) {
|
||||
continue;
|
||||
}
|
||||
matched.push((*new_ref, *old_ref));
|
||||
matched_new.push(*new_ref);
|
||||
matched_old.push(*old_ref);
|
||||
}
|
||||
}
|
||||
|
||||
remaining_new.retain(|r| !matched_new.contains(r));
|
||||
remaining_old.retain(|r| !matched_old.contains(r));
|
||||
}
|
||||
|
||||
/// Compute a similarity score between two instances.
|
||||
/// Higher = more similar. Signals checked in priority order:
|
||||
/// ClassName (100), Tags (50), Attributes (30), Hash equality (1000),
|
||||
/// Properties overlap (10 per match).
|
||||
fn compute_similarity(
|
||||
new_ref: Ref,
|
||||
old_ref: Ref,
|
||||
new_dom: &WeakDom,
|
||||
old_dom: &WeakDom,
|
||||
new_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
old_hashes: Option<&HashMap<Ref, Hash>>,
|
||||
) -> u32 {
|
||||
let new_inst = match new_dom.get_by_ref(new_ref) {
|
||||
Some(i) => i,
|
||||
None => return 0,
|
||||
};
|
||||
let old_inst = match old_dom.get_by_ref(old_ref) {
|
||||
Some(i) => i,
|
||||
None => return 0,
|
||||
};
|
||||
|
||||
let mut score: u32 = 0;
|
||||
|
||||
// ClassName match is a strong signal.
|
||||
if new_inst.class == old_inst.class {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
// Hash equality: very likely identical content.
|
||||
if let (Some(nh), Some(oh)) = (new_hashes, old_hashes) {
|
||||
if let (Some(new_hash), Some(old_hash)) = (nh.get(&new_ref), oh.get(&old_ref)) {
|
||||
if new_hash == old_hash {
|
||||
score += 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags comparison.
|
||||
if let (Some(Variant::Tags(new_tags)), Some(Variant::Tags(old_tags))) = (
|
||||
new_inst.properties.get(&ustr("Tags")),
|
||||
old_inst.properties.get(&ustr("Tags")),
|
||||
) {
|
||||
if new_tags == old_tags {
|
||||
score += 50;
|
||||
}
|
||||
}
|
||||
|
||||
// Attributes comparison.
|
||||
if let (Some(Variant::Attributes(new_attrs)), Some(Variant::Attributes(old_attrs))) = (
|
||||
new_inst.properties.get(&ustr("Attributes")),
|
||||
old_inst.properties.get(&ustr("Attributes")),
|
||||
) {
|
||||
if new_attrs == old_attrs {
|
||||
score += 30;
|
||||
}
|
||||
}
|
||||
|
||||
// Children count similarity (cheap structural signal).
|
||||
let new_child_count = new_inst.children().len();
|
||||
let old_child_count = old_inst.children().len();
|
||||
if new_child_count == old_child_count {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
/// Collect all Ref-valued property targets for an instance.
|
||||
fn collect_ref_property_targets(dom: &WeakDom, inst_ref: Ref) -> Vec<Ref> {
|
||||
let inst = match dom.get_by_ref(inst_ref) {
|
||||
Some(i) => i,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
let mut targets = Vec::new();
|
||||
for value in inst.properties.values() {
|
||||
if let Variant::Ref(target) = value {
|
||||
if !target.is_none() {
|
||||
targets.push(*target);
|
||||
}
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rbx_dom_weak::InstanceBuilder;
|
||||
|
||||
fn build_test_dom() -> (WeakDom, Ref, Ref, Ref) {
|
||||
let mut dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let root = dom.root_ref();
|
||||
let child_a = dom.insert(root, InstanceBuilder::new("Folder").with_name("Alpha"));
|
||||
let child_b = dom.insert(root, InstanceBuilder::new("Script").with_name("Beta"));
|
||||
(dom, root, child_a, child_b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_names_match_instantly() {
|
||||
let (new_dom, new_root, new_a, new_b) = build_test_dom();
|
||||
let (old_dom, old_root, old_a, old_b) = build_test_dom();
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
let old_children: Vec<Ref> = old_dom
|
||||
.get_by_ref(old_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
|
||||
// Alpha matched to Alpha, Beta to Beta
|
||||
for (new_ref, old_ref) in &result.matched {
|
||||
let new_name = &new_dom.get_by_ref(*new_ref).unwrap().name;
|
||||
let old_name = &old_dom.get_by_ref(*old_ref).unwrap().name;
|
||||
assert_eq!(new_name, old_name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_name_narrowing() {
|
||||
// Two instances named "Handler" but different classes
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
let new_a = new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Script").with_name("Handler"),
|
||||
);
|
||||
let new_b = new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("ModuleScript").with_name("Handler"),
|
||||
);
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
let old_a = old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("ModuleScript").with_name("Handler"),
|
||||
);
|
||||
let old_b = old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Script").with_name("Handler"),
|
||||
);
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
let old_children: Vec<Ref> = old_dom
|
||||
.get_by_ref(old_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
|
||||
// Verify Script matched to Script, ModuleScript to ModuleScript.
|
||||
for (new_ref, old_ref) in &result.matched {
|
||||
let new_class = &new_dom.get_by_ref(*new_ref).unwrap().class;
|
||||
let old_class = &old_dom.get_by_ref(*old_ref).unwrap().class;
|
||||
assert_eq!(new_class, old_class);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_new_and_old() {
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("Alpha"),
|
||||
);
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("NewOnly"),
|
||||
);
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("Alpha"),
|
||||
);
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("OldOnly"),
|
||||
);
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
let old_children: Vec<Ref> = old_dom
|
||||
.get_by_ref(old_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
||||
assert_eq!(result.matched.len(), 1);
|
||||
assert_eq!(result.unmatched_new.len(), 1);
|
||||
assert_eq!(result.unmatched_old.len(), 1);
|
||||
|
||||
let unmatched_new_name = &new_dom
|
||||
.get_by_ref(result.unmatched_new[0])
|
||||
.unwrap()
|
||||
.name;
|
||||
assert_eq!(unmatched_new_name, "NewOnly");
|
||||
|
||||
let unmatched_old_name = &old_dom
|
||||
.get_by_ref(result.unmatched_old[0])
|
||||
.unwrap()
|
||||
.name;
|
||||
assert_eq!(unmatched_old_name, "OldOnly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_fall_to_pass3() {
|
||||
// Two Folders named "Data" on each side, same class.
|
||||
// Pass 1 can't distinguish, Pass 3 uses greedy assignment.
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("Data"),
|
||||
);
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
let old_children: Vec<Ref> = old_dom
|
||||
.get_by_ref(old_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
||||
// Both should be matched (greedy assignment).
|
||||
assert_eq!(result.matched.len(), 2);
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_children() {
|
||||
let new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
|
||||
let result = match_children(&[], &[], &new_dom, &old_dom, None, None);
|
||||
|
||||
assert!(result.matched.is_empty());
|
||||
assert!(result.unmatched_new.is_empty());
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_new_no_old() {
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("A"),
|
||||
);
|
||||
|
||||
let old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let result = match_children(&new_children, &[], &new_dom, &old_dom, None, None);
|
||||
|
||||
assert!(result.matched.is_empty());
|
||||
assert_eq!(result.unmatched_new.len(), 1);
|
||||
assert!(result.unmatched_old.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_stability() {
|
||||
// Run the same matching twice; verify identical results.
|
||||
let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let new_root = new_dom.root_ref();
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Folder").with_name("X"),
|
||||
);
|
||||
new_dom.insert(
|
||||
new_root,
|
||||
InstanceBuilder::new("Script").with_name("Y"),
|
||||
);
|
||||
|
||||
let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||
let old_root = old_dom.root_ref();
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Folder").with_name("X"),
|
||||
);
|
||||
old_dom.insert(
|
||||
old_root,
|
||||
InstanceBuilder::new("Script").with_name("Y"),
|
||||
);
|
||||
|
||||
let new_children: Vec<Ref> = new_dom
|
||||
.get_by_ref(new_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
let old_children: Vec<Ref> = old_dom
|
||||
.get_by_ref(old_root)
|
||||
.unwrap()
|
||||
.children()
|
||||
.to_vec();
|
||||
|
||||
let r1 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
let r2 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None);
|
||||
|
||||
assert_eq!(r1.matched.len(), r2.matched.len());
|
||||
for (a, b) in r1.matched.iter().zip(r2.matched.iter()) {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
pub mod dedup_suffix;
|
||||
mod file_names;
|
||||
mod fs_snapshot;
|
||||
mod hash;
|
||||
pub mod matching;
|
||||
pub mod meta;
|
||||
mod property_filter;
|
||||
mod ref_properties;
|
||||
|
|
|
|||
|
|
@ -132,15 +132,14 @@ fn is_path_unique_with_cache(
|
|||
/// the sync tree to be preserved as path-based attributes.
|
||||
pub fn collect_referents(dom: &WeakDom, pre_prune_paths: &HashMap<Ref, String>) -> RefLinks {
|
||||
let mut path_links: HashMap<Ref, Vec<PathRefLink>> = HashMap::new();
|
||||
let mut id_links: HashMap<Ref, Vec<IdRefLink>> = HashMap::new();
|
||||
let mut targets_needing_id: HashSet<Ref> = HashSet::new();
|
||||
let id_links: HashMap<Ref, Vec<IdRefLink>> = HashMap::new();
|
||||
let targets_needing_id: HashSet<Ref> = HashSet::new();
|
||||
|
||||
// Pre-compute duplicate sibling info in O(N) - this makes all subsequent
|
||||
// path uniqueness checks O(d) instead of O(d × s) where d=depth, s=siblings
|
||||
let has_duplicate_siblings = compute_refs_with_duplicate_siblings(dom);
|
||||
|
||||
// Cache path uniqueness results to avoid re-checking the same target
|
||||
let mut path_unique_cache: HashMap<Ref, bool> = HashMap::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.
|
||||
|
||||
let mut queue = VecDeque::new();
|
||||
queue.push_back(dom.root_ref());
|
||||
|
|
@ -157,35 +156,16 @@ pub fn collect_referents(dom: &WeakDom, pre_prune_paths: &HashMap<Ref, String>)
|
|||
continue;
|
||||
}
|
||||
|
||||
// Check if target exists in current DOM
|
||||
if let Some(_target) = dom.get_by_ref(*target_ref) {
|
||||
// Target exists - check if path is unique (with caching)
|
||||
let is_unique = *path_unique_cache.entry(*target_ref).or_insert_with(|| {
|
||||
is_path_unique_with_cache(dom, *target_ref, &has_duplicate_siblings)
|
||||
if dom.get_by_ref(*target_ref).is_some() {
|
||||
// Target exists in DOM -- always use path-based system.
|
||||
// With dedup suffixes, paths are always unique.
|
||||
let target_path = inst_path(dom, *target_ref);
|
||||
path_links.entry(inst_ref).or_default().push(PathRefLink {
|
||||
name: *prop_name,
|
||||
path: target_path,
|
||||
});
|
||||
|
||||
if is_unique {
|
||||
// Path is unique - use path-based system
|
||||
let target_path = inst_path(dom, *target_ref);
|
||||
path_links.entry(inst_ref).or_default().push(PathRefLink {
|
||||
name: *prop_name,
|
||||
path: target_path,
|
||||
});
|
||||
} else {
|
||||
// Path is NOT unique - fall back to ID-based system
|
||||
log::debug!(
|
||||
"Property {}.{} uses ID-based reference because target has duplicate-named siblings",
|
||||
inst_path(dom, inst_ref),
|
||||
prop_name
|
||||
);
|
||||
id_links.entry(inst_ref).or_default().push(IdRefLink {
|
||||
name: *prop_name,
|
||||
target: *target_ref,
|
||||
});
|
||||
targets_needing_id.insert(*target_ref);
|
||||
}
|
||||
} else if let Some(external_path) = pre_prune_paths.get(target_ref) {
|
||||
// Target was pruned - use path (we can't check uniqueness, assume it's fine)
|
||||
// Target was pruned -- use pre-prune path
|
||||
log::debug!(
|
||||
"Property {}.{} points to pruned instance at '{}', storing as path reference",
|
||||
inst_path(dom, inst_ref),
|
||||
|
|
@ -197,7 +177,6 @@ pub fn collect_referents(dom: &WeakDom, pre_prune_paths: &HashMap<Ref, String>)
|
|||
path: external_path.clone(),
|
||||
});
|
||||
} else {
|
||||
// Truly dangling reference
|
||||
log::warn!(
|
||||
"Property {}.{} will be `nil` on disk because the referenced instance does not exist",
|
||||
inst_path(dom, inst_ref),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue