mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
- Marked all todos in the slugify branch audit plan as completed, confirming the successful verification of core invariants and sync processes. - Introduced a new plan to address critical bugs, correctness issues, and refactors identified during the audit, including enhancements to metadata handling and slugification logic. - Added tests to ensure proper handling of filenames with forbidden characters and deduplication of instances with colliding names. - Refactored metadata operations to improve clarity and maintainability, consolidating logic for reading and writing the `name` field in `.meta.json5` files.
1336 lines
47 KiB
Rust
1336 lines
47 KiB
Rust
use std::{
|
|
borrow::Cow,
|
|
collections::{BTreeMap, HashMap, 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(path.to_path_buf());
|
|
|
|
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);
|
|
}
|
|
|
|
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_child_map.insert(&child.name, child).is_some() {
|
|
let parent_path = inst_path(snapshot.new_tree(), new_inst.referent());
|
|
anyhow::bail!(
|
|
"Instances that are direct children of an Instance that is made by a project file \
|
|
must have a unique name.\nThe child '{}' is duplicated in the place file.\n\
|
|
Full path: {}/{}", child.name, 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_child_map.insert(child.name(), child).is_some() {
|
|
let parent_path = inst_path(snapshot.old_tree(), old_inst.id());
|
|
anyhow::bail!(
|
|
"Instances that are direct children of an Instance that is made by a project file \
|
|
must have a unique name.\nThe child '{}' is duplicated on the file system.\n\
|
|
Full path: {}/{}", child.name(), parent_path, child.name()
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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 (bare slugs) per parent directory so siblings
|
|
// under the same parent deduplicate against each other.
|
|
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 slugified instance names
|
|
// so that new-only children correctly dedup against existing siblings
|
|
// (e.g., new "utils" won't collide with existing "Utils.luau").
|
|
// Only in incremental mode: in clean mode, old_ref is forced to None
|
|
// for all children, so every child is treated as new and pre-seeding
|
|
// would cause false collisions (spurious ~1 suffixes).
|
|
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() {
|
|
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);
|
|
}
|
|
}
|