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

@ -144,7 +144,7 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
let mut path_rewrites = Vec::new();
for (id, map) in context.path_refs_to_rewrite {
for (prop_name, path) in map {
if let Some(target) = tree.get_instance_by_path(&path) {
if let Some(target) = tree.resolve_ref_path(&path, id) {
log::debug!(
"Resolved path reference {} -> {} (path: {})",
prop_name,

View file

@ -104,7 +104,7 @@ fn compute_property_patches(
let mut visited_properties = UstrSet::default();
let mut changed_properties = UstrMap::new();
let attribute_ref_properties = compute_ref_properties(snapshot, tree);
let attribute_ref_properties = compute_ref_properties(snapshot, tree, instance.id());
let changed_name = if snapshot.name == instance.name() {
None
@ -251,6 +251,7 @@ fn compute_children_patches(
fn compute_ref_properties(
snapshot: &InstanceSnapshot,
tree: &RojoTree,
source_ref: Ref,
) -> UstrMap<Option<Variant>> {
let mut map = UstrMap::new();
let attributes = match snapshot.properties.get(&ustr("Attributes")) {
@ -290,7 +291,7 @@ fn compute_ref_properties(
let Some(path) = crate::variant_as_str(attr_value, attr_name) else {
continue;
};
if let Some(target_id) = tree.get_instance_by_path(path) {
if let Some(target_id) = tree.resolve_ref_path(path, source_ref) {
map.insert(ustr(prop_name), Some(Variant::Ref(target_id)));
} else {
// Path not found yet - will be resolved during patch apply

View file

@ -285,6 +285,109 @@ impl RojoTree {
Some(current_ref)
}
/// Resolve a Luau require-by-string style ref path to a target instance.
///
/// `source_ref` is the instance that owns the `Rojo_Ref_*` attribute.
/// Prefix semantics (per Roblox require-by-string):
/// - `@game/rest` -- resolve from DataModel root
/// - `@self` -- return source_ref
/// - `@self/rest` -- resolve from source_ref
/// - `./rest` -- resolve from source_ref's parent
/// - `../rest` -- resolve from source_ref's grandparent
/// - bare path (no prefix) -- legacy fallback, resolve like `@game/`
///
/// During segment walk, `..` navigates to the parent.
pub fn resolve_ref_path(&self, path: &str, source_ref: Ref) -> Option<Ref> {
if let Some(rest) = path.strip_prefix("@game/") {
return self.walk_segments(self.get_root_id(), rest);
}
if path == "@game" {
return Some(self.get_root_id());
}
if path == "@self" {
return Some(source_ref);
}
if let Some(rest) = path.strip_prefix("@self/") {
return self.walk_segments(source_ref, rest);
}
if let Some(rest) = path.strip_prefix("./") {
let parent = self.inner.get_by_ref(source_ref)?.parent();
if parent.is_none() {
return None;
}
return self.walk_segments(parent, rest);
}
if let Some(rest) = path.strip_prefix("../") {
let parent = self.inner.get_by_ref(source_ref)?.parent();
if parent.is_none() {
return None;
}
let grandparent = self.inner.get_by_ref(parent)?.parent();
if grandparent.is_none() {
return None;
}
return self.walk_segments(grandparent, rest);
}
// Bare path (no prefix) -- legacy backward compatibility
self.walk_segments(self.get_root_id(), path)
}
/// Walk `/`-separated segments from a starting Ref, handling `..` as
/// "go to parent". Each non-`..` segment matches a child by filesystem
/// name (case-insensitive), falling back to instance name.
fn walk_segments(&self, start: Ref, rest: &str) -> Option<Ref> {
if rest.is_empty() {
return Some(start);
}
let mut current_ref = start;
for segment in rest.split('/') {
if segment.is_empty() {
continue;
}
if segment == ".." {
let inst = self.inner.get_by_ref(current_ref)?;
let parent = inst.parent();
if parent.is_none() {
return None;
}
current_ref = parent;
continue;
}
let current = self.inner.get_by_ref(current_ref)?;
let children = current.children();
let mut found = false;
for &child_ref in children {
let fs_name = self.filesystem_name_for(child_ref);
if fs_name.eq_ignore_ascii_case(segment) {
current_ref = child_ref;
found = true;
break;
}
}
if !found {
for &child_ref in children {
let child = self.inner.get_by_ref(child_ref)?;
if child.name.eq_ignore_ascii_case(segment) {
current_ref = child_ref;
found = true;
break;
}
}
}
if !found {
return None;
}
}
Some(current_ref)
}
/// Returns the filesystem name for an instance (the filename or directory
/// name on disk). Derived from `instigating_source` if available, otherwise
/// falls back to the instance name.