rojo/src/snapshot_middleware/project.rs
Joe d6162ac210 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.
2026-02-17 02:43:25 -06:00

1404 lines
50 KiB
Rust

use std::{
borrow::Cow,
collections::{BTreeMap, HashMap, HashSet, VecDeque},
path::Path,
};
use anyhow::{bail, Context};
use memofs::Vfs;
use rbx_dom_weak::{
types::{Attributes, Ref, Variant},
ustr, HashMapExt as _, Instance, Ustr, UstrMap,
};
use rbx_reflection::ClassTag;
use crate::{
project::{PathNode, Project, ProjectNode},
resolution::UnresolvedValue,
snapshot::{
InstanceContext, InstanceMetadata, InstanceSnapshot, InstanceWithMeta, InstigatingSource,
PathIgnoreRule, SyncRule,
},
snapshot_middleware::Middleware,
syncback::{
filter_properties, inst_path, name_needs_slugify, slugify_name,
FsSnapshot, SyncbackReturn, SyncbackSnapshot,
},
variant_eq::variant_eq,
RojoRef,
};
use super::snapshot_from_vfs;
/// Checks if a class transition is recoverable in clean mode.
///
/// Recoverable transitions:
/// - Folder -> Script (creates init.luau)
/// - Script -> Folder (removes init.luau)
/// - Script -> Script (changes init.server/client.luau type)
fn can_transition_class(old_class: &str, new_class: &str) -> bool {
let is_script = |c: &str| matches!(c, "ModuleScript" | "Script" | "LocalScript");
let is_folder = |c: &str| c == "Folder";
// Folder can become a script (by creating init file)
if is_folder(old_class) && is_script(new_class) {
return true;
}
// Script can become Folder (by removing init file - handled by orphan removal)
if is_script(old_class) && is_folder(new_class) {
return true;
}
// Script can change type (e.g., ModuleScript -> LocalScript)
if is_script(old_class) && is_script(new_class) {
return true;
}
false
}
/// Determines the correct script-dir middleware for a Script based on its RunContext property.
///
/// Script class has a RunContext property that determines the correct middleware:
/// - Server → ServerScriptDir (init.server.luau)
/// - Client → ClientScriptDir (init.client.luau)
/// - Plugin → PluginScriptDir (init.plugin.luau)
/// - Legacy → LegacyScriptDir (init.legacy.luau)
///
/// If RunContext is not set or unrecognized, defaults to LegacyScriptDir.
fn middleware_for_script(inst: &Instance) -> Middleware {
let run_context_enums = rbx_reflection_database::get()
.ok()
.and_then(|db| db.enums.get("RunContext"))
.map(|e| &e.items);
let run_context_value = inst
.properties
.get(&ustr("RunContext"))
.and_then(|v| match v {
Variant::Enum(e) => Some(e.to_u32()),
_ => None,
});
if let (Some(enums), Some(value)) = (run_context_enums, run_context_value) {
for (name, &enum_value) in enums {
if enum_value == value {
return match *name {
"Server" => Middleware::ServerScriptDir,
"Client" => Middleware::ClientScriptDir,
"Plugin" => Middleware::PluginScriptDir,
"Legacy" => Middleware::LegacyScriptDir,
_ => Middleware::LegacyScriptDir,
};
}
}
}
// Default to LegacyScriptDir if no RunContext or unrecognized
Middleware::LegacyScriptDir
}
pub fn snapshot_project(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let project = Project::load_exact(vfs, path, Some(name))
.with_context(|| format!("File was not a valid Rojo project: {}", path.display()))?;
let project_name = match project.name.as_deref() {
Some(name) => name,
None => panic!("Project is missing a name"),
};
let mut context = context.clone();
context.clear_sync_rules();
let rules = project.glob_ignore_paths.iter().map(|glob| PathIgnoreRule {
glob: glob.clone(),
base_path: project.folder_location().to_path_buf(),
});
let sync_rules = project.sync_rules.iter().map(|rule| SyncRule {
base_path: project.folder_location().to_path_buf(),
..rule.clone()
});
context.add_sync_rules(sync_rules);
context.add_path_ignore_rules(rules);
match snapshot_project_node(&context, path, project_name, &project.tree, vfs, None)? {
Some(found_snapshot) => {
let mut snapshot = found_snapshot;
// Setting the instigating source to the project file path is a little
// coarse.
//
// Ideally, we'd only snapshot the project file if the project file
// actually changed. Because Rojo only has the concept of one
// relevant path -> snapshot path mapping per instance, we pick the more
// conservative approach of snapshotting the project file if any
// relevant paths changed.
snapshot.metadata.instigating_source = Some(path.to_path_buf().into());
// Mark this snapshot (the root node of the project file) as being
// related to the project file.
//
// We SHOULD NOT mark the project file as a relevant path for any
// nodes that aren't roots. They'll be updated as part of the project
// file being updated.
snapshot
.metadata
.relevant_paths
.push(vfs.canonicalize(path)?);
Ok(Some(snapshot))
}
None => Ok(None),
}
}
pub fn snapshot_project_node(
context: &InstanceContext,
project_path: &Path,
instance_name: &str,
node: &ProjectNode,
vfs: &Vfs,
parent_class: Option<&str>,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let project_folder = project_path.parent().unwrap();
let mut class_name_from_path = None;
let name = Cow::Owned(instance_name.to_owned());
let mut properties = UstrMap::new();
let mut children = Vec::new();
let mut metadata = InstanceMetadata::new().context(context);
if let Some(path_node) = &node.path {
let path = path_node.path();
// If the path specified in the project is relative, we assume it's
// relative to the folder that the project is in, project_folder.
let full_path = if path.is_relative() {
Cow::Owned(project_folder.join(path))
} else {
Cow::Borrowed(path)
};
if let Some(snapshot) = snapshot_from_vfs(context, vfs, &full_path)? {
class_name_from_path = Some(snapshot.class_name);
// Properties from the snapshot are pulled in unchanged, and
// overridden by properties set on the project node.
properties.reserve(snapshot.properties.len());
for (key, value) in snapshot.properties.into_iter() {
properties.insert(key, value);
}
// The snapshot's children will be merged with the children defined
// in the project node, if there are any.
children.reserve(snapshot.children.len());
for child in snapshot.children.into_iter() {
children.push(child);
}
// Take the snapshot's metadata as-is, which will be mutated later
// on.
metadata = snapshot.metadata;
}
}
let class_name_from_inference = infer_class_name(&name, parent_class);
let class_name = match (
node.class_name,
class_name_from_path,
class_name_from_inference,
&node.path,
) {
// These are the easy, happy paths!
(Some(project), None, None, _) => project,
(None, Some(path), None, _) => path,
(None, None, Some(inference), _) => inference,
// If the user specifies a class name, but there's an inferred class
// name, we prefer the name listed explicitly by the user.
(Some(project), None, Some(_), _) => project,
// If the user has a $path pointing to a folder and we're able to infer
// a class name, let's use the inferred name. If the path we're pointing
// to isn't a folder, though, that's a user error.
(None, Some(path), Some(inference), _) => {
if path == "Folder" {
inference
} else {
path
}
}
(Some(project), Some(path), _, _) => {
if path == "Folder" {
project
} else {
bail!(
"ClassName for Instance \"{}\" was specified in both the project file (as \"{}\") and from the filesystem (as \"{}\").\n\
If $className and $path are both set, $path must refer to a Folder.
\n\
Project path: {}\n\
Filesystem path: {}\n",
instance_name,
project,
path,
project_path.display(),
node.path.as_ref().unwrap().path().display()
);
}
}
(None, None, None, Some(PathNode::Optional(_))) => {
return Ok(None);
}
(_, None, _, Some(PathNode::Required(path))) => {
anyhow::bail!(
"Rojo project referred to a file using $path that could not be turned into a Roblox Instance by Rojo.\n\
Check that the file exists and is a file type known by Rojo.\n\
\n\
Project path: {}\n\
File $path: {}",
project_path.display(),
path.display(),
);
}
(None, None, None, None) => {
bail!(
"Instance \"{}\" is missing some required information.\n\
One of the following must be true:\n\
- $className must be set to the name of a Roblox class\n\
- $path must be set to a path of an instance\n\
- The instance must be a known service, like ReplicatedStorage\n\
\n\
Project path: {}",
instance_name,
project_path.display(),
);
}
};
for (child_name, child_project_node) in &node.children {
if let Some(child) = snapshot_project_node(
context,
project_path,
child_name,
child_project_node,
vfs,
Some(&class_name),
)? {
children.push(child);
}
}
for (key, unresolved) in &node.properties {
let value = unresolved
.clone()
.resolve(&class_name, key)
.with_context(|| {
format!(
"Unresolvable property in project at path {}",
project_path.display()
)
})?;
match key.as_str() {
"Name" | "Parent" => {
log::warn!(
"Property '{}' cannot be set manually, ignoring. Attempted to set in '{}' at {}",
key,
instance_name,
project_path.display()
);
continue;
}
_ => {}
}
properties.insert(*key, value);
}
if !node.attributes.is_empty() {
let mut attributes = Attributes::new();
for (key, unresolved) in &node.attributes {
let value = unresolved.clone().resolve_unambiguous().with_context(|| {
format!(
"Unresolvable attribute in project at path {}",
project_path.display()
)
})?;
attributes.insert(key.clone(), value);
}
properties.insert("Attributes".into(), attributes.into());
}
// If the user specified $ignoreUnknownInstances, overwrite the existing
// value.
//
// If the user didn't specify it AND $path was not specified (meaning
// there's no existing value we'd be stepping on from a project file or meta
// file), set it to true.
if let Some(ignore) = node.ignore_unknown_instances {
metadata.ignore_unknown_instances = ignore;
} else if node.path.is_none() {
// TODO: Introduce a strict mode where $ignoreUnknownInstances is never
// set implicitly.
metadata.ignore_unknown_instances = true;
}
if let Some(id) = &node.id {
metadata.specified_id = Some(RojoRef::new(id.clone()))
}
metadata.instigating_source = Some(InstigatingSource::ProjectNode {
path: project_path.to_path_buf(),
name: instance_name.to_string(),
node: node.clone(),
parent_class: parent_class.map(|name| name.to_owned()),
});
Ok(Some(InstanceSnapshot {
snapshot_id: Ref::none(),
name,
class_name,
properties,
children,
metadata,
}))
}
pub fn syncback_project<'sync>(
snapshot: &SyncbackSnapshot<'sync>,
) -> anyhow::Result<SyncbackReturn<'sync>> {
let old_inst = snapshot
.old_inst()
.expect("projects should always exist in both trees");
// Generally, the path of a project is the first thing added to the relevant
// paths. So, we take the last one.
let project_path = old_inst
.metadata()
.relevant_paths
.last()
.expect("all projects should have a relevant path");
let vfs = snapshot.vfs();
log::debug!("Reloading project {} from vfs", project_path.display(),);
let mut project = Project::load_exact(vfs, project_path, None)?;
let base_path = project.folder_location().to_path_buf();
// Sync rules for this project do not have their base rule set but it is
// important when performing syncback on other projects.
for rule in &mut project.sync_rules {
rule.base_path.clone_from(&base_path)
}
let mut descendant_snapshots = Vec::new();
let mut removed_descendants = Vec::new();
let mut ref_to_path_map = HashMap::new();
let mut old_child_map = HashMap::new();
let mut new_child_map = HashMap::new();
let mut node_changed_map = Vec::new();
let mut node_queue = VecDeque::with_capacity(1);
node_queue.push_back((&mut project.tree, old_inst, snapshot.new_inst()));
while let Some((node, old_inst, new_inst)) = node_queue.pop_front() {
log::debug!("Processing node {}", old_inst.name());
if old_inst.class_name() != new_inst.class {
// In clean mode, allow recoverable class transitions
// (e.g., Folder -> ModuleScript by creating init.luau)
let can_recover =
can_transition_class(old_inst.class_name().as_str(), new_inst.class.as_str());
if !snapshot.data.is_incremental() && can_recover {
log::debug!(
"Clean mode: allowing class transition {} -> {} for {}",
old_inst.class_name(),
new_inst.class,
old_inst.name()
);
} else {
anyhow::bail!(
"Cannot change the class of {} in project file {}.\n\
Current class is {}, it is a {} in the input file.",
old_inst.name(),
project_path.display(),
old_inst.class_name(),
new_inst.class
);
}
}
// TODO handle meta.json5 files in this branch. Right now, we perform
// syncback if a node has `$path` set but the Middleware aren't aware
// that the Instances they're running on originate in a project.json5.
// As a result, the `meta.json5` syncback code is hardcoded to not work
// if the Instance originates from a project file. However, we should
// ideally use a .meta.json5 over the project node if it exists already.
if node.path.is_some() {
// Since the node has a path, we have to run syncback on it.
let node_path = node.path.as_ref().map(PathNode::path).expect(
"Project nodes with a path must have a path \
If you see this message, something went seriously wrong. Please report it.",
);
let full_path = if node_path.is_absolute() {
node_path.to_path_buf()
} else {
base_path.join(node_path)
};
let mut middleware = match Middleware::middleware_for_path(
snapshot.vfs(),
&project.sync_rules,
&full_path,
)? {
Some(middleware) => middleware,
None => {
// Path doesn't exist on filesystem. In clean mode, we can
// determine the middleware from the new instance and let
// syncback create the necessary files/directories.
if !snapshot.data.is_incremental() {
// Determine middleware based on new instance class (and RunContext for Scripts)
let inferred_middleware = match new_inst.class.as_str() {
"ModuleScript" => Middleware::ModuleScriptDir,
"Script" => middleware_for_script(new_inst),
"LocalScript" => Middleware::LocalScriptDir,
"Folder" => Middleware::Dir,
// For other classes, default to Dir
_ => Middleware::Dir,
};
log::debug!(
"Clean mode: path {} doesn't exist, inferring {:?} middleware for class {}",
full_path.display(),
inferred_middleware,
new_inst.class
);
inferred_middleware
} else {
anyhow::bail!(
"Rojo project referred to a file using $path that could not be turned \
into a Roblox Instance by Rojo.\n\
File $path: {}",
full_path.display()
)
}
}
};
// In clean mode, we may need to override middleware when the filesystem
// structure doesn't match the new instance type.
if !snapshot.data.is_incremental() {
// Case 1: Filesystem has a directory (Dir middleware) but new instance
// is a script - use script-dir middleware to create the init file.
if middleware == Middleware::Dir {
let script_middleware = match new_inst.class.as_str() {
"ModuleScript" => Some(Middleware::ModuleScriptDir),
"Script" => Some(middleware_for_script(new_inst)),
"LocalScript" => Some(Middleware::LocalScriptDir),
_ => None,
};
if let Some(new_middleware) = script_middleware {
log::debug!(
"Clean mode: overriding Dir->{:?} middleware for {} (class {})",
new_middleware,
full_path.display(),
new_inst.class
);
middleware = new_middleware;
}
}
// Case 2: Filesystem has a script-dir (init.luau exists) but new instance
// is a Folder - use Dir middleware. The init file will be removed as orphan.
let is_script_dir_middleware = matches!(
middleware,
Middleware::ModuleScriptDir
| Middleware::ServerScriptDir
| Middleware::ClientScriptDir
| Middleware::LocalScriptDir
| Middleware::PluginScriptDir
| Middleware::LegacyScriptDir
);
if is_script_dir_middleware && new_inst.class == "Folder" {
log::debug!(
"Clean mode: overriding {:?}->Dir middleware for {} (class Folder)",
middleware,
full_path.display()
);
middleware = Middleware::Dir;
}
// Case 3: Filesystem has a script-dir but new instance is a DIFFERENT script type.
// e.g., ModuleScriptDir (init.luau) but new instance is Script → use correct script-dir.
// Without this, changing a ModuleScript to Script in Studio would silently keep
// the old init.luau file, causing the script to be read as ModuleScript on next build.
// For Scripts, we also check RunContext to pick the correct middleware (Server/Client/Plugin/Legacy).
if is_script_dir_middleware {
let correct_middleware = match new_inst.class.as_str() {
"ModuleScript" => Some(Middleware::ModuleScriptDir),
"Script" => Some(middleware_for_script(new_inst)),
"LocalScript" => Some(Middleware::LocalScriptDir),
_ => None,
};
if let Some(new_middleware) = correct_middleware {
if middleware != new_middleware {
log::debug!(
"Clean mode: overriding {:?}->{:?} middleware for {} (class {} changed)",
middleware,
new_middleware,
full_path.display(),
new_inst.class
);
middleware = new_middleware;
}
}
}
}
descendant_snapshots.push(
snapshot
.with_new_path(full_path.clone(), new_inst.referent(), Some(old_inst.id()))
.middleware(middleware),
);
ref_to_path_map.insert(new_inst.referent(), full_path);
// We only want to set properties if it needs it.
if !middleware.handles_own_properties() {
project_node_property_syncback_path(snapshot, new_inst, node);
}
} else {
project_node_property_syncback_no_path(snapshot, new_inst, node);
}
// Detect duplicate children using case-insensitive comparison.
// Project KEYS are unique by JSON definition; only filesystem children
// under project nodes may collide. Duplicates are skipped with a warning
// for now -- the dedup system (Phase 1c/1d) will handle them properly.
let mut new_duplicate_names: HashSet<String> = HashSet::new();
{
let mut seen: HashSet<String> = HashSet::new();
for child_ref in new_inst.children() {
let child = snapshot
.get_new_instance(*child_ref)
.expect("all children of Instances should be in new DOM");
let lower = child.name.to_lowercase();
if !seen.insert(lower.clone()) {
new_duplicate_names.insert(lower);
}
}
}
if !new_duplicate_names.is_empty() {
let parent_path = inst_path(snapshot.new_tree(), new_inst.referent());
for child_ref in new_inst.children() {
let child = snapshot
.get_new_instance(*child_ref)
.expect("all children of Instances should be in new DOM");
if new_duplicate_names.contains(&child.name.to_lowercase()) {
log::warn!(
"Skipping duplicate-named child '{}' under ProjectNode '{}' -- \
cannot reliably sync yet. Full path: {}/{}",
child.name,
parent_path,
parent_path,
child.name
);
}
}
}
for child_ref in new_inst.children() {
let child = snapshot
.get_new_instance(*child_ref)
.expect("all children of Instances should be in new DOM");
if !new_duplicate_names.contains(&child.name.to_lowercase()) {
new_child_map.insert(&child.name, child);
}
}
let mut old_duplicate_names: HashSet<String> = HashSet::new();
{
let mut seen: HashSet<String> = HashSet::new();
for child_ref in old_inst.children() {
let child = snapshot
.get_old_instance(*child_ref)
.expect("all children of Instances should be in old DOM");
let lower = child.name().to_lowercase();
if !seen.insert(lower.clone()) {
old_duplicate_names.insert(lower);
}
}
}
if !old_duplicate_names.is_empty() {
let parent_path = inst_path(snapshot.old_tree(), old_inst.id());
for child_ref in old_inst.children() {
let child = snapshot
.get_old_instance(*child_ref)
.expect("all children of Instances should be in old DOM");
if old_duplicate_names.contains(&child.name().to_lowercase()) {
log::warn!(
"Skipping duplicate-named child '{}' under ProjectNode '{}' -- \
cannot reliably sync yet. Full path: {}/{}",
child.name(),
parent_path,
parent_path,
child.name()
);
}
}
}
for child_ref in old_inst.children() {
let child = snapshot
.get_old_instance(*child_ref)
.expect("all children of Instances should be in old DOM");
if !old_duplicate_names.contains(&child.name().to_lowercase()) {
old_child_map.insert(child.name(), child);
}
}
// This loop does basic matching of Instance children to the node's
// children. It ensures that `new_child_map` and `old_child_map` will
// only contain Instances that don't belong to the project after this.
for (child_name, child_node) in &mut node.children {
// If a node's path is optional, we want to skip it if the path
// doesn't exist since it isn't in the current old DOM.
if let Some(path) = &child_node.path {
if path.is_optional() {
let real_path = if path.path().is_absolute() {
path.path().to_path_buf()
} else {
base_path.join(path.path())
};
if !real_path.exists() {
log::warn!(
"Skipping node '{child_name}' of project because it is optional and not present on the disk.\n\
If this is not deliberate, please create a file or directory at {}", real_path.display()
);
continue;
}
}
}
let new_equivalent = new_child_map.remove(child_name);
let old_equivalent = old_child_map.remove(child_name.as_str());
match (new_equivalent, old_equivalent) {
(Some(new), Some(old)) => node_queue.push_back((child_node, old, new)),
(_, None) => anyhow::bail!(
"The child '{child_name}' of Instance '{}' would be removed.\n\
Syncback cannot add or remove Instances from project {}",
old_inst.name(),
project_path.display()
),
(None, _) => anyhow::bail!(
"The child '{child_name}' of Instance '{}' is present only in a project file,\n\
and not the provided file. Syncback cannot add or remove Instances from project:\n{}.",
old_inst.name(), project_path.display(),
)
}
}
// All of the children in this loop are by their nature not in the
// project, so we just need to run syncback on them.
//
// Track taken names (full filesystem name components, lowercased)
// per parent directory so siblings under the same parent deduplicate
// against each other using filesystem-level collision detection.
let mut taken_names_per_dir: std::collections::HashMap<
std::path::PathBuf,
std::collections::HashSet<String>,
> = std::collections::HashMap::new();
// Pre-seed taken_names from old children's actual filesystem names
// (full filename including extension) so that new-only children
// correctly dedup against existing siblings.
if snapshot.data.is_incremental() {
if let Some(parent_path) = ref_to_path_map.get(&new_inst.referent()) {
let taken = taken_names_per_dir.entry(parent_path.clone()).or_default();
for old_child in old_child_map.values() {
if let Some(path) = old_child.metadata().relevant_paths.first() {
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
// Use the full filename (including extension) as the
// dedup key so that "Foo" (dir) and "Foo.server.luau"
// (file) do NOT collide.
taken.insert(filename.to_lowercase());
}
} else {
// Fallback (shouldn't happen for old instances with disk presence)
let name = old_child.name();
let slug = if name_needs_slugify(name) {
slugify_name(name).to_lowercase()
} else {
name.to_lowercase()
};
taken.insert(slug);
}
}
}
}
// Sort remaining children by name for deterministic dedup ordering.
// Without this, HashMap iteration order determines which sibling gets
// the base slug vs ~1, causing non-deterministic output across runs.
let mut remaining_children: Vec<_> = new_child_map.drain().collect();
remaining_children.sort_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
for (name, new_child) in remaining_children {
// Skip instances of ignored classes
if snapshot.should_ignore_class(&new_child.class) {
// Also remove from old_child_map so it won't be marked as removed
old_child_map.remove(name.as_str());
log::debug!(
"Skipping instance {} because its class {} is ignored",
new_child.name,
new_child.class
);
continue;
}
let parent_path = match ref_to_path_map.get(&new_child.parent()) {
Some(path) => path.clone(),
None => {
log::debug!("Skipping child {name} of node because it has no parent_path");
continue;
}
};
// If a child also exists in the old tree, it will be caught in the
// syncback on the project node path above (or is itself a node).
// So the only things we need to run seperately is new children.
if old_child_map.remove(name.as_str()).is_none() {
let parent_middleware =
Middleware::middleware_for_path(vfs, &project.sync_rules, &parent_path)?
.expect("project nodes should have a middleware if they have children.");
// If this node points directly to a project, it may still have
// children but they'll be handled by syncback. This isn't a
// concern with directories because they're singular things,
// files that contain their own children.
if parent_middleware != Middleware::Project {
let taken_names = taken_names_per_dir.entry(parent_path.clone()).or_default();
let (child_snap, _needs_meta, dedup_key) = snapshot.with_base_path(
&parent_path,
new_child.referent(),
None,
taken_names,
)?;
taken_names.insert(dedup_key.to_lowercase());
descendant_snapshots.push(child_snap);
}
}
}
// Filter out instances of ignored classes from removal
removed_descendants.extend(old_child_map.drain().filter_map(|(_, inst)| {
if snapshot.should_ignore_class(inst.class_name().as_str()) {
log::debug!(
"Not removing instance {} because its class {} is ignored",
inst.name(),
inst.class_name()
);
None
} else {
Some(inst)
}
}));
node_changed_map.push((&node.properties, &node.attributes, old_inst))
}
let mut fs_snapshot = FsSnapshot::new();
for (node_properties, node_attributes, old_inst) in node_changed_map {
if project_node_should_reserialize(node_properties, node_attributes, old_inst)? {
fs_snapshot.add_file(project_path, crate::json::to_vec_pretty_sorted(&project)?);
break;
}
}
Ok(SyncbackReturn {
fs_snapshot,
children: descendant_snapshots,
removed_children: removed_descendants,
})
}
fn project_node_property_syncback(
_snapshot: &SyncbackSnapshot,
filtered_properties: UstrMap<&Variant>,
new_inst: &Instance,
node: &mut ProjectNode,
) {
let properties = &mut node.properties;
let mut attributes = BTreeMap::new();
for (name, value) in filtered_properties {
match value {
Variant::Attributes(attrs) => {
for (attr_name, attr_value) in attrs.iter() {
// We (probably) don't want to preserve internal attributes,
// only user defined ones.
if attr_name.starts_with("RBX") {
continue;
}
attributes.insert(
attr_name.clone(),
UnresolvedValue::from_variant_unambiguous(attr_value.clone()),
);
}
}
_ => {
properties.insert(
name,
UnresolvedValue::from_variant(value.clone(), &new_inst.class, &name),
);
}
}
}
node.attributes = attributes;
}
fn project_node_property_syncback_path(
snapshot: &SyncbackSnapshot,
new_inst: &Instance,
node: &mut ProjectNode,
) {
let filtered_properties = snapshot
.get_path_filtered_properties(new_inst.referent())
.unwrap();
project_node_property_syncback(snapshot, filtered_properties, new_inst, node)
}
fn project_node_property_syncback_no_path(
snapshot: &SyncbackSnapshot,
new_inst: &Instance,
node: &mut ProjectNode,
) {
let filtered_properties = filter_properties(snapshot.project(), new_inst);
project_node_property_syncback(snapshot, filtered_properties, new_inst, node)
}
fn project_node_should_reserialize(
node_properties: &BTreeMap<Ustr, UnresolvedValue>,
node_attributes: &BTreeMap<String, UnresolvedValue>,
instance: InstanceWithMeta,
) -> anyhow::Result<bool> {
for (prop_name, unresolved_node_value) in node_properties {
if let Some(inst_value) = instance.properties().get(prop_name) {
let node_value = unresolved_node_value
.clone()
.resolve(&instance.class_name(), prop_name)?;
if !variant_eq(inst_value, &node_value) {
return Ok(true);
}
} else {
return Ok(true);
}
}
match instance.properties().get(&ustr("Attributes")) {
Some(Variant::Attributes(inst_attributes)) => {
// This will also catch if one is empty but the other isn't
if node_attributes.len() != inst_attributes.len() {
Ok(true)
} else {
for (attr_name, unresolved_node_value) in node_attributes {
if let Some(inst_value) = inst_attributes.get(attr_name.as_str()) {
let node_value = unresolved_node_value.clone().resolve_unambiguous()?;
if !variant_eq(inst_value, &node_value) {
return Ok(true);
}
} else {
return Ok(true);
}
}
Ok(false)
}
}
Some(_) => Ok(true),
None => {
if !node_attributes.is_empty() {
Ok(true)
} else {
Ok(false)
}
}
}
}
fn infer_class_name(name: &str, parent_class: Option<&str>) -> Option<Ustr> {
// If className wasn't defined from another source, we may be able
// to infer one.
let parent_class = parent_class?;
if parent_class == "DataModel" {
// Members of DataModel with names that match known services are
// probably supposed to be those services.
let descriptor = rbx_reflection_database::get().unwrap().classes.get(name)?;
if descriptor.tags.contains(&ClassTag::Service) {
return Some(ustr(name));
}
} else if parent_class == "StarterPlayer" {
// StarterPlayer has two special members with their own classes.
if name == "StarterPlayerScripts" || name == "StarterCharacterScripts" {
return Some(ustr(name));
}
} else if parent_class == "Workspace" {
// Workspace has a special Terrain class inside it
if name == "Terrain" {
return Some(ustr(name));
}
}
None
}
// #[cfg(feature = "broken-tests")]
#[cfg(test)]
mod test {
use super::*;
use memofs::{InMemoryFs, VfsSnapshot};
#[ignore = "Functionality moved to root snapshot middleware"]
#[test]
fn project_from_folder() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo",
VfsSnapshot::dir([(
"default.project.json5",
VfsSnapshot::file(
r#"
{
"name": "indirect-project",
"tree": {
"$className": "Folder"
}
}
"#,
),
)]),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/foo"),
"NOT_IN_SNAPSHOT",
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
#[test]
fn project_from_direct_file() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo",
VfsSnapshot::dir([(
"hello.project.json5",
VfsSnapshot::file(
r#"
{
"name": "direct-project",
"tree": {
"$className": "Model"
}
}
"#,
),
)]),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/foo/hello.project.json5"),
"NOT_IN_SNAPSHOT",
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
#[test]
fn project_with_resolved_properties() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo.project.json5",
VfsSnapshot::file(
r#"
{
"name": "resolved-properties",
"tree": {
"$className": "StringValue",
"$properties": {
"Value": {
"String": "Hello, world!"
}
}
}
}
"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/foo.project.json5"),
"NOT_IN_SNAPSHOT",
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
#[test]
fn project_with_unresolved_properties() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo.project.json5",
VfsSnapshot::file(
r#"
{
"name": "unresolved-properties",
"tree": {
"$className": "StringValue",
"$properties": {
"Value": "Hi!"
}
}
}
"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/foo.project.json5"),
"NOT_IN_SNAPSHOT",
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
#[test]
fn project_with_children() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo.project.json5",
VfsSnapshot::file(
r#"
{
"name": "children",
"tree": {
"$className": "Folder",
"Child": {
"$className": "Model"
}
}
}
"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/foo.project.json5"),
"NOT_IN_SNAPSHOT",
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
#[test]
fn project_with_path_to_txt() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo",
VfsSnapshot::dir([
(
"default.project.json5",
VfsSnapshot::file(
r#"
{
"name": "path-project",
"tree": {
"$path": "other.txt"
}
}
"#,
),
),
("other.txt", VfsSnapshot::file("Hello, world!")),
]),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/foo/default.project.json5"),
"NOT_IN_SNAPSHOT",
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
#[test]
fn project_with_path_to_project() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo",
VfsSnapshot::dir([
(
"default.project.json5",
VfsSnapshot::file(
r#"
{
"name": "path-project",
"tree": {
"$path": "other.project.json5"
}
}
"#,
),
),
(
"other.project.json5",
VfsSnapshot::file(
r#"
{
"name": "other-project",
"tree": {
"$className": "Model"
}
}
"#,
),
),
]),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/foo/default.project.json5"),
"NOT_IN_SNAPSHOT",
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
#[test]
fn project_with_path_to_project_with_children() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo",
VfsSnapshot::dir([
(
"default.project.json5",
VfsSnapshot::file(
r#"
{
"name": "path-child-project",
"tree": {
"$path": "other.project.json5"
}
}
"#,
),
),
(
"other.project.json5",
VfsSnapshot::file(
r#"
{
"name": "other-project",
"tree": {
"$className": "Folder",
"SomeChild": {
"$className": "Model"
}
}
}
"#,
),
),
]),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/foo/default.project.json5"),
"NOT_IN_SNAPSHOT",
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
/// Ensures that if a property is defined both in the resulting instance
/// from $path and also in $properties, that the $properties value takes
/// precedence.
#[test]
fn project_path_property_overrides() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo",
VfsSnapshot::dir([
(
"default.project.json5",
VfsSnapshot::file(
r#"
{
"name": "path-property-override",
"tree": {
"$path": "other.project.json5",
"$properties": {
"Value": "Changed"
}
}
}
"#,
),
),
(
"other.project.json5",
VfsSnapshot::file(
r#"
{
"name": "other-project",
"tree": {
"$className": "StringValue",
"$properties": {
"Value": "Original"
}
}
}
"#,
),
),
]),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/foo/default.project.json5"),
"NOT_IN_SNAPSHOT",
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
#[test]
fn no_name_project() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo",
VfsSnapshot::dir([(
"default.project.json5",
VfsSnapshot::file(
r#"
{
"tree": {
"$className": "Model"
}
}
"#,
),
)]),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/foo/default.project.json5"),
"no_name_project",
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
}