Add Luau-style ref paths migration plan and update related tests

- Introduced a new migration plan for converting `Rojo_Ref_*` attribute paths to Luau require-by-string style, enhancing portability with relative paths.
- Updated existing test snapshots to reflect the new path formats, ensuring consistency across various scenarios.
- Enhanced the `compute_relative_ref_path` and `resolve_ref_path` functions to support the new path semantics, improving path resolution logic.
- Refactored related code in `change_processor`, `syncback`, and `snapshot` modules to accommodate the new reference path handling.

These changes improve the robustness and maintainability of reference path management in the project.
This commit is contained in:
Joe 2026-02-18 04:17:51 -06:00
parent d6169c6d84
commit b8c3520d25
29 changed files with 2147 additions and 179 deletions

View file

@ -535,7 +535,7 @@ mod tests {
meta_content.as_bytes().to_vec(),
);
let substitutions = vec![("Workspace/Foo".to_string(), "Workspace/Foo~2".to_string())];
let substitutions = vec![("Workspace/Foo".to_string(), "./Foo~2".to_string())];
snap.fix_ref_paths(&substitutions);
let result = std::str::from_utf8(
@ -546,7 +546,7 @@ mod tests {
.unwrap();
assert!(
result.contains(r#""Rojo_Ref_PrimaryPart": "Workspace/Foo~2""#),
result.contains(r#""Rojo_Ref_PrimaryPart": "./Foo~2""#),
"Rojo_Ref line should be updated. Got:\n{}",
result
);
@ -566,7 +566,7 @@ mod tests {
b"-- Rojo_Ref_PrimaryPart Workspace/Foo".to_vec(),
);
let substitutions = vec![("Workspace/Foo".to_string(), "Workspace/Foo~2".to_string())];
let substitutions = vec![("Workspace/Foo".to_string(), "./Foo~2".to_string())];
snap.fix_ref_paths(&substitutions);
let result = std::str::from_utf8(
@ -581,7 +581,7 @@ mod tests {
result
);
assert!(
!result.contains("Workspace/Foo~2"),
!result.contains("./Foo~2"),
"Non-meta file should not have substitution applied"
);
}
@ -618,11 +618,11 @@ mod tests {
let substitutions = vec![
(
"__ROJO_REF_00000000000000000000000000000001__".to_string(),
"Beams1/001~5".to_string(),
"./001~5".to_string(),
),
(
"__ROJO_REF_00000000000000000000000000000002__".to_string(),
"Beams1/002~7.model.json5".to_string(),
"./002~7.model.json5".to_string(),
),
];
snap.fix_ref_paths(&substitutions);
@ -635,11 +635,11 @@ mod tests {
.unwrap();
assert!(
result.contains(r#""Rojo_Ref_Attachment0": "Beams1/001~5""#),
result.contains(r#""Rojo_Ref_Attachment0": "./001~5""#),
"Attachment0 should resolve to 001~5. Got:\n{result}"
);
assert!(
result.contains(r#""Rojo_Ref_Attachment1": "Beams1/002~7.model.json5""#),
result.contains(r#""Rojo_Ref_Attachment1": "./002~7.model.json5""#),
"Attachment1 should resolve to 002~7. Got:\n{result}"
);
assert!(
@ -715,15 +715,15 @@ mod tests {
let substitutions = vec![
(
"__ROJO_REF_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa__".to_string(),
"Beams1/001~2".to_string(),
"./001~2".to_string(),
),
(
"__ROJO_REF_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb__".to_string(),
"Beams1/001~5".to_string(),
"./001~5".to_string(),
),
(
"__ROJO_REF_cccccccccccccccccccccccccccccccc__".to_string(),
"Workspace/Model/Handle.model.json5".to_string(),
"@self/Handle.model.json5".to_string(),
),
];
snap.fix_ref_paths(&substitutions);
@ -736,15 +736,15 @@ mod tests {
.unwrap();
assert!(
result.contains(r#""Rojo_Ref_Attachment0": "Beams1/001~2""#),
result.contains(r#""Rojo_Ref_Attachment0": "./001~2""#),
"Attachment0 wrong. Got:\n{result}"
);
assert!(
result.contains(r#""Rojo_Ref_Attachment1": "Beams1/001~5""#),
result.contains(r#""Rojo_Ref_Attachment1": "./001~5""#),
"Attachment1 wrong. Got:\n{result}"
);
assert!(
result.contains(r#""Rojo_Ref_PrimaryPart": "Workspace/Model/Handle.model.json5""#),
result.contains(r#""Rojo_Ref_PrimaryPart": "@self/Handle.model.json5""#),
"PrimaryPart wrong. Got:\n{result}"
);
}

View file

@ -96,10 +96,17 @@ pub fn remove_model_name(model_path: &Path) -> anyhow::Result<RemoveNameOutcome>
/// For each attribute whose key starts with `Rojo_Ref_` and whose string
/// value starts with `old_prefix`, replaces the prefix with `new_prefix`.
/// Returns true if any attribute was updated.
/// Update `Rojo_Ref_*` attributes in a meta/model file after a rename.
///
/// For each attribute, resolves the on-disk relative path to absolute using
/// `source_abs`, checks if the resolved absolute path is affected by the
/// rename (`old_prefix` → `new_prefix`), and if so, recomputes the relative
/// path from `source_abs` to the new absolute target.
pub fn update_ref_paths_in_file(
file_path: &Path,
old_prefix: &str,
new_prefix: &str,
source_abs: &str,
) -> anyhow::Result<bool> {
use crate::REF_PATH_ATTRIBUTE_PREFIX;
@ -118,17 +125,28 @@ pub fn update_ref_paths_in_file(
);
}
let old_prefix_slash = format!("{old_prefix}/");
let mut updated = false;
if let Some(attrs) = val.get_mut("attributes").and_then(|a| a.as_object_mut()) {
for (key, value) in attrs.iter_mut() {
if key.starts_with(REF_PATH_ATTRIBUTE_PREFIX) {
if let Some(path_str) = value.as_str() {
if path_str == old_prefix || path_str.starts_with(&format!("{old_prefix}/")) {
let new_path = format!("{new_prefix}{}", &path_str[old_prefix.len()..]);
*value = serde_json::Value::String(new_path);
updated = true;
}
}
if !key.starts_with(REF_PATH_ATTRIBUTE_PREFIX) {
continue;
}
let Some(path_str) = value.as_str() else {
continue;
};
let Some(resolved) =
crate::resolve_ref_path_to_absolute(path_str, source_abs)
else {
continue;
};
if resolved == old_prefix || resolved.starts_with(&old_prefix_slash) {
let new_abs =
format!("{new_prefix}{}", &resolved[old_prefix.len()..]);
let new_relative =
crate::compute_relative_ref_path(source_abs, &new_abs);
*value = serde_json::Value::String(new_relative);
updated = true;
}
}
}

View file

@ -175,7 +175,8 @@ pub fn syncback_loop_with_stats(
log::debug!("Collecting referents for new DOM...");
let mut deferred_referents = collect_referents(&new_tree, &pre_prune_paths, None);
let placeholder_map = std::mem::take(&mut deferred_referents.placeholder_to_target);
let placeholder_map =
std::mem::take(&mut deferred_referents.placeholder_to_source_and_target);
// Remove any properties that are manually blocked from syncback via the
// project file.
@ -622,12 +623,17 @@ pub fn syncback_loop_with_stats(
let final_map = ref_path_map.borrow();
let mut substitutions: Vec<(String, String)> = Vec::new();
for (placeholder, target_ref) in &placeholder_map {
let final_path = final_map
for (placeholder, (source_ref, target_ref)) in &placeholder_map {
let source_abs = final_map
.get(source_ref)
.cloned()
.unwrap_or_else(|| tentative_fs_path_public(&new_tree, *source_ref));
let target_abs = final_map
.get(target_ref)
.cloned()
.unwrap_or_else(|| tentative_fs_path_public(&new_tree, *target_ref));
substitutions.push((placeholder.clone(), final_path));
let relative = crate::compute_relative_ref_path(&source_abs, &target_abs);
substitutions.push((placeholder.clone(), relative));
}
if !substitutions.is_empty() {
log::debug!(

View file

@ -142,11 +142,11 @@ pub struct RefLinks {
id_links: HashMap<Ref, Vec<IdRefLink>>,
/// Target instances that need a Rojo_Id written (for ID-based system).
targets_needing_id: HashSet<Ref>,
/// Maps placeholder strings to target Refs. Populated when `final_paths`
/// is not available (CLI syncback). After the syncback walk populates the
/// `ref_path_map`, these placeholders are substituted with the correct
/// dedup-aware filesystem paths via `fix_ref_paths`.
pub placeholder_to_target: HashMap<String, Ref>,
/// Maps placeholder strings to (source_ref, target_ref) pairs. Populated
/// when `final_paths` is not available (CLI syncback pre-walk). The
/// placeholder encodes both source and target so that post-processing can
/// compute the correct relative path for each (source, target) pair.
pub placeholder_to_source_and_target: HashMap<String, (Ref, Ref)>,
}
#[derive(PartialEq, Eq)]
@ -195,7 +195,7 @@ pub fn collect_referents(
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();
let mut placeholder_to_target: HashMap<String, Ref> = HashMap::new();
let mut placeholder_to_source_and_target: HashMap<String, (Ref, Ref)> = HashMap::new();
let mut queue = VecDeque::new();
queue.push_back(dom.root_ref());
@ -213,35 +213,36 @@ pub fn collect_referents(
}
if dom.get_by_ref(*target_ref).is_some() {
// Target exists in DOM -- use path-based system.
let target_path = if let Some(fp) = final_paths {
// final_paths available (dedup-aware) -- use directly.
fp.get(target_ref)
let ref_path = if let Some(fp) = final_paths {
let source_abs = fp
.get(&inst_ref)
.cloned()
.unwrap_or_else(|| tentative_fs_path(dom, *target_ref))
.unwrap_or_else(|| tentative_fs_path(dom, inst_ref));
let target_abs = fp
.get(target_ref)
.cloned()
.unwrap_or_else(|| tentative_fs_path(dom, *target_ref));
crate::compute_relative_ref_path(&source_abs, &target_abs)
} else {
// No final_paths yet (pre-walk). Use a unique placeholder
// keyed by the target Ref so that duplicate-named siblings
// get distinct values. The syncback post-processing step
// substitutes these with the correct dedup'd paths.
let placeholder = ref_placeholder(*target_ref);
placeholder_to_target.insert(placeholder.clone(), *target_ref);
let placeholder = ref_placeholder(inst_ref, *target_ref);
placeholder_to_source_and_target
.insert(placeholder.clone(), (inst_ref, *target_ref));
placeholder
};
path_links.entry(inst_ref).or_default().push(PathRefLink {
name: *prop_name,
path: target_path,
path: ref_path,
});
} else if let Some(external_path) = pre_prune_paths.get(target_ref) {
log::debug!(
"Property {}.{} points to pruned instance at '{}', storing as path reference",
"Property {}.{} points to pruned instance at '{}', storing as @game/ reference",
tentative_fs_path(dom, inst_ref),
prop_name,
external_path
);
path_links.entry(inst_ref).or_default().push(PathRefLink {
name: *prop_name,
path: external_path.clone(),
path: format!("@game/{external_path}"),
});
} else {
log::warn!(
@ -257,19 +258,16 @@ pub fn collect_referents(
path_links,
id_links,
targets_needing_id,
placeholder_to_target,
placeholder_to_source_and_target,
}
}
/// Prefix used for ref placeholder strings. Chosen to be unambiguous with
/// real filesystem paths (which cannot contain double underscores in this
/// position).
const REF_PLACEHOLDER_PREFIX: &str = "__ROJO_REF_";
const REF_PLACEHOLDER_SUFFIX: &str = "__";
/// Build a unique placeholder string for a target Ref.
fn ref_placeholder(target: Ref) -> String {
format!("{REF_PLACEHOLDER_PREFIX}{target}{REF_PLACEHOLDER_SUFFIX}")
/// Build a unique placeholder string encoding both source and target Refs.
fn ref_placeholder(source: Ref, target: Ref) -> String {
format!("{REF_PLACEHOLDER_PREFIX}{source}_TO_{target}{REF_PLACEHOLDER_SUFFIX}")
}
/// Writes reference attributes to instances in the DOM.
@ -453,7 +451,7 @@ mod tests {
}
#[test]
fn collect_referents_uses_unique_placeholders_for_duplicate_names() {
fn collect_referents_uses_source_aware_placeholders() {
let (dom, _, attachments, beam) = make_beam_attachment_dom();
let links = collect_referents(&dom, &HashMap::new(), None);
@ -466,16 +464,21 @@ mod tests {
path.starts_with(REF_PLACEHOLDER_PREFIX),
"path should be a placeholder, got: {path}"
);
assert_eq!(
links.placeholder_to_target.get(path).copied(),
Some(attachments[2]),
"placeholder should map to the specific target Ref"
assert!(
path.contains("_TO_"),
"placeholder should encode both source and target, got: {path}"
);
let (src, tgt) = links
.placeholder_to_source_and_target
.get(path)
.expect("placeholder should map to (source, target)");
assert_eq!(*src, beam);
assert_eq!(*tgt, attachments[2]);
}
#[test]
fn placeholders_are_unique_per_target_ref() {
fn placeholders_are_unique_per_source_target_pair() {
let mut dom2 = WeakDom::new(InstanceBuilder::new("DataModel"));
let root2 = dom2.root_ref();
let c2 = dom2.insert(root2, InstanceBuilder::new("Folder").with_name("Beams1"));
@ -491,14 +494,19 @@ mod tests {
let links = collect_referents(&dom2, &HashMap::new(), None);
let placeholders: Vec<&String> = links.placeholder_to_target.keys().collect();
let placeholders: Vec<&String> =
links.placeholder_to_source_and_target.keys().collect();
assert!(
placeholders.len() >= 2,
"should have at least 2 placeholders"
);
let targets: HashSet<&Ref> = links.placeholder_to_target.values().collect();
let targets: HashSet<Ref> = links
.placeholder_to_source_and_target
.values()
.map(|(_, t)| *t)
.collect();
assert!(
targets.contains(&att_a),
"should have placeholder for att_a"
@ -508,7 +516,6 @@ mod tests {
"should have placeholder for att_b"
);
// Crucially: the placeholder strings themselves must be distinct
let unique_placeholders: HashSet<&String> = placeholders.iter().copied().collect();
assert_eq!(
unique_placeholders.len(),
@ -518,20 +525,24 @@ mod tests {
}
#[test]
fn collect_referents_with_final_paths_skips_placeholders() {
fn collect_referents_with_final_paths_produces_relative_paths() {
let (dom, _, attachments, beam) = make_beam_attachment_dom();
let mut final_paths = HashMap::new();
final_paths.insert(attachments[2], "Beams1/001~3".to_string());
final_paths.insert(beam, "Beams1/BeamA.model.json5".to_string());
final_paths.insert(attachments[2], "Beams1/001~3.model.json5".to_string());
let links = collect_referents(&dom, &HashMap::new(), Some(&final_paths));
assert!(
links.placeholder_to_target.is_empty(),
links.placeholder_to_source_and_target.is_empty(),
"should not generate placeholders when final_paths is provided"
);
let beam_links = links.path_links.get(&beam).expect("beam should have refs");
assert_eq!(beam_links[0].path, "Beams1/001~3");
assert_eq!(
beam_links[0].path, "./001~3.model.json5",
"should produce relative path (sibling)"
);
}
}