mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-22 21:55:15 +00:00
Implement Syncback to support converting Roblox files to a Rojo project (#937)
This is a very large commit. Consider checking the linked PR for more information.
This commit is contained in:
128
src/syncback/file_names.rs
Normal file
128
src/syncback/file_names.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! Contains logic for generating new file names for Instances based on their
|
||||
//! middleware.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use anyhow::Context;
|
||||
use rbx_dom_weak::Instance;
|
||||
|
||||
use crate::{snapshot::InstanceWithMeta, snapshot_middleware::Middleware};
|
||||
|
||||
pub fn name_for_inst<'old>(
|
||||
middleware: Middleware,
|
||||
new_inst: &Instance,
|
||||
old_inst: Option<InstanceWithMeta<'old>>,
|
||||
) -> anyhow::Result<Cow<'old, str>> {
|
||||
if let Some(old_inst) = old_inst {
|
||||
if let Some(source) = old_inst.metadata().relevant_paths.first() {
|
||||
source
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(Cow::Borrowed)
|
||||
.context("sources on the file system should be valid unicode and not be stubs")
|
||||
} else {
|
||||
// This is technically not /always/ true, but we want to avoid
|
||||
// running syncback on anything that has no instigating source
|
||||
// anyway.
|
||||
anyhow::bail!(
|
||||
"members of 'old' trees should have an instigating source. Somehow, {} did not.",
|
||||
old_inst.name(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Ok(match middleware {
|
||||
Middleware::Dir
|
||||
| Middleware::CsvDir
|
||||
| Middleware::ServerScriptDir
|
||||
| Middleware::ClientScriptDir
|
||||
| Middleware::ModuleScriptDir => Cow::Owned(new_inst.name.clone()),
|
||||
_ => {
|
||||
let extension = extension_for_middleware(middleware);
|
||||
let name = &new_inst.name;
|
||||
validate_file_name(name).with_context(|| {
|
||||
format!("name '{name}' is not legal to write to the file system")
|
||||
})?;
|
||||
Cow::Owned(format!("{name}.{extension}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the extension a provided piece of middleware is supposed to use.
|
||||
pub fn extension_for_middleware(middleware: Middleware) -> &'static str {
|
||||
match middleware {
|
||||
Middleware::Csv => "csv",
|
||||
Middleware::JsonModel => "model.json",
|
||||
Middleware::Json => "json",
|
||||
Middleware::ServerScript => "server.luau",
|
||||
Middleware::ClientScript => "client.luau",
|
||||
Middleware::ModuleScript => "luau",
|
||||
Middleware::PluginScript => "plugin.luau",
|
||||
Middleware::Project => "project.json",
|
||||
Middleware::Rbxm => "rbxm",
|
||||
Middleware::Rbxmx => "rbxmx",
|
||||
Middleware::Toml => "toml",
|
||||
Middleware::Text => "txt",
|
||||
Middleware::Yaml => "yml",
|
||||
|
||||
Middleware::LegacyServerScript
|
||||
| Middleware::LegacyClientScript
|
||||
| Middleware::RunContextServerScript
|
||||
| Middleware::RunContextClientScript => {
|
||||
todo!("syncback does not work on the middleware {middleware:?} yet")
|
||||
}
|
||||
// These are manually specified and not `_` to guard against future
|
||||
// middleware additions missing this function.
|
||||
Middleware::Ignore => unimplemented!("syncback does not work on Ignore middleware"),
|
||||
Middleware::Dir
|
||||
| Middleware::CsvDir
|
||||
| Middleware::ServerScriptDir
|
||||
| Middleware::ClientScriptDir
|
||||
| Middleware::ModuleScriptDir => {
|
||||
unimplemented!("directory middleware requires special treatment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of file names that are not valid on Windows.
|
||||
const INVALID_WINDOWS_NAMES: [&str; 22] = [
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
/// A list of all characters that are outright forbidden to be included
|
||||
/// in a file's name.
|
||||
const FORBIDDEN_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '|', '?', '*', '\\'];
|
||||
|
||||
/// Validates a provided file name to ensure it's allowed on the file system. An
|
||||
/// error is returned if the name isn't allowed, indicating why.
|
||||
/// This takes into account rules for Windows, MacOS, and Linux.
|
||||
///
|
||||
/// In practice however, these broadly overlap so the only unexpected behavior
|
||||
/// is Windows, where there are 22 reserved names.
|
||||
pub fn validate_file_name<S: AsRef<str>>(name: S) -> anyhow::Result<()> {
|
||||
let str = name.as_ref();
|
||||
|
||||
if str.ends_with(' ') {
|
||||
anyhow::bail!("file names cannot end with a space")
|
||||
}
|
||||
if str.ends_with('.') {
|
||||
anyhow::bail!("file names cannot end with '.'")
|
||||
}
|
||||
|
||||
for char in str.chars() {
|
||||
if FORBIDDEN_CHARS.contains(&char) {
|
||||
anyhow::bail!("file names cannot contain <, >, :, \", /, |, ?, *, or \\")
|
||||
} else if char.is_control() {
|
||||
anyhow::bail!("file names cannot contain control characters")
|
||||
}
|
||||
}
|
||||
|
||||
for forbidden in INVALID_WINDOWS_NAMES {
|
||||
if str == forbidden {
|
||||
anyhow::bail!("files cannot be named {str}")
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
191
src/syncback/fs_snapshot.rs
Normal file
191
src/syncback/fs_snapshot.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use memofs::Vfs;
|
||||
|
||||
/// A simple representation of a subsection of a file system.
|
||||
#[derive(Default)]
|
||||
pub struct FsSnapshot {
|
||||
/// Paths representing new files mapped to their contents.
|
||||
added_files: HashMap<PathBuf, Vec<u8>>,
|
||||
/// Paths representing new directories.
|
||||
added_dirs: HashSet<PathBuf>,
|
||||
/// Paths representing removed files.
|
||||
removed_files: HashSet<PathBuf>,
|
||||
/// Paths representing removed directories.
|
||||
removed_dirs: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl FsSnapshot {
|
||||
/// Creates a new `FsSnapshot`.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
added_files: HashMap::new(),
|
||||
added_dirs: HashSet::new(),
|
||||
removed_files: HashSet::new(),
|
||||
removed_dirs: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the given path to the `FsSnapshot` as a file with the given
|
||||
/// contents, then returns it.
|
||||
pub fn with_added_file<P: AsRef<Path>>(mut self, path: P, data: Vec<u8>) -> Self {
|
||||
self.added_files.insert(path.as_ref().to_path_buf(), data);
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds the given path to the `FsSnapshot` as a file with the given
|
||||
/// then returns it.
|
||||
pub fn with_added_dir<P: AsRef<Path>>(mut self, path: P) -> Self {
|
||||
self.added_dirs.insert(path.as_ref().to_path_buf());
|
||||
self
|
||||
}
|
||||
|
||||
/// Merges two `FsSnapshot`s together.
|
||||
#[inline]
|
||||
pub fn merge(&mut self, other: Self) {
|
||||
self.added_files.extend(other.added_files);
|
||||
self.added_dirs.extend(other.added_dirs);
|
||||
self.removed_files.extend(other.removed_files);
|
||||
self.removed_dirs.extend(other.removed_dirs);
|
||||
}
|
||||
|
||||
/// Merges two `FsSnapshot`s together, with a filter applied to the paths.
|
||||
#[inline]
|
||||
pub fn merge_with_filter<F>(&mut self, other: Self, mut predicate: F)
|
||||
where
|
||||
F: FnMut(&Path) -> bool,
|
||||
{
|
||||
self.added_files
|
||||
.extend(other.added_files.into_iter().filter(|(k, _)| predicate(k)));
|
||||
self.added_dirs
|
||||
.extend(other.added_dirs.into_iter().filter(|p| predicate(p)));
|
||||
self.removed_files
|
||||
.extend(other.removed_files.into_iter().filter(|p| predicate(p)));
|
||||
self.removed_dirs
|
||||
.extend(other.removed_dirs.into_iter().filter(|p| predicate(p)));
|
||||
}
|
||||
|
||||
/// Adds the provided path as a file with the given contents.
|
||||
pub fn add_file<P: AsRef<Path>>(&mut self, path: P, data: Vec<u8>) {
|
||||
self.added_files.insert(path.as_ref().to_path_buf(), data);
|
||||
}
|
||||
|
||||
/// Adds the provided path as a directory.
|
||||
pub fn add_dir<P: AsRef<Path>>(&mut self, path: P) {
|
||||
self.added_dirs.insert(path.as_ref().to_path_buf());
|
||||
}
|
||||
|
||||
/// Removes the provided path, as a file.
|
||||
pub fn remove_file<P: AsRef<Path>>(&mut self, path: P) {
|
||||
self.removed_files.insert(path.as_ref().to_path_buf());
|
||||
}
|
||||
|
||||
/// Removes the provided path, as a directory.
|
||||
pub fn remove_dir<P: AsRef<Path>>(&mut self, path: P) {
|
||||
self.removed_dirs.insert(path.as_ref().to_path_buf());
|
||||
}
|
||||
|
||||
/// Writes the `FsSnapshot` to the provided VFS, using the provided `base`
|
||||
/// as a root for the other paths in the `FsSnapshot`.
|
||||
///
|
||||
/// This includes removals, but makes no effort to minimize work done.
|
||||
pub fn write_to_vfs<P: AsRef<Path>>(&self, base: P, vfs: &Vfs) -> io::Result<()> {
|
||||
let mut lock = vfs.lock();
|
||||
|
||||
let base_path = base.as_ref();
|
||||
for dir_path in &self.added_dirs {
|
||||
match lock.create_dir_all(base_path.join(dir_path)) {
|
||||
Ok(_) => (),
|
||||
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
}
|
||||
for (path, contents) in &self.added_files {
|
||||
lock.write(base_path.join(path), contents)?;
|
||||
}
|
||||
for dir_path in &self.removed_dirs {
|
||||
lock.remove_dir_all(base_path.join(dir_path))?;
|
||||
}
|
||||
for path in &self.removed_files {
|
||||
lock.remove_file(base_path.join(path))?;
|
||||
}
|
||||
drop(lock);
|
||||
|
||||
log::debug!(
|
||||
"Wrote {} directories and {} files to the file system",
|
||||
self.added_dirs.len(),
|
||||
self.added_files.len()
|
||||
);
|
||||
log::debug!(
|
||||
"Removed {} directories and {} files from the file system",
|
||||
self.removed_dirs.len(),
|
||||
self.removed_files.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns whether this `FsSnapshot` is empty or not.
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.added_files.is_empty()
|
||||
&& self.added_dirs.is_empty()
|
||||
&& self.removed_files.is_empty()
|
||||
&& self.removed_dirs.is_empty()
|
||||
}
|
||||
|
||||
/// Returns a list of paths that would be added by this `FsSnapshot`.
|
||||
#[inline]
|
||||
pub fn added_paths(&self) -> Vec<&Path> {
|
||||
let mut list = Vec::with_capacity(self.added_files.len() + self.added_dirs.len());
|
||||
list.extend(self.added_files());
|
||||
list.extend(self.added_dirs());
|
||||
|
||||
list
|
||||
}
|
||||
|
||||
/// Returns a list of paths that would be removed by this `FsSnapshot`.
|
||||
#[inline]
|
||||
pub fn removed_paths(&self) -> Vec<&Path> {
|
||||
let mut list = Vec::with_capacity(self.removed_files.len() + self.removed_dirs.len());
|
||||
list.extend(self.removed_files());
|
||||
list.extend(self.removed_dirs());
|
||||
|
||||
list
|
||||
}
|
||||
|
||||
/// Returns a list of file paths that would be added by this `FsSnapshot`
|
||||
#[inline]
|
||||
pub fn added_files(&self) -> Vec<&Path> {
|
||||
let mut added_files: Vec<_> = self.added_files.keys().map(PathBuf::as_path).collect();
|
||||
added_files.sort_unstable();
|
||||
added_files
|
||||
}
|
||||
|
||||
/// Returns a list of directory paths that would be added by this `FsSnapshot`
|
||||
#[inline]
|
||||
pub fn added_dirs(&self) -> Vec<&Path> {
|
||||
let mut added_dirs: Vec<_> = self.added_dirs.iter().map(PathBuf::as_path).collect();
|
||||
added_dirs.sort_unstable();
|
||||
added_dirs
|
||||
}
|
||||
|
||||
/// Returns a list of file paths that would be removed by this `FsSnapshot`
|
||||
#[inline]
|
||||
pub fn removed_files(&self) -> Vec<&Path> {
|
||||
let mut removed_files: Vec<_> = self.removed_files.iter().map(PathBuf::as_path).collect();
|
||||
removed_files.sort_unstable();
|
||||
removed_files
|
||||
}
|
||||
|
||||
/// Returns a list of directory paths that would be removed by this `FsSnapshot`
|
||||
#[inline]
|
||||
pub fn removed_dirs(&self) -> Vec<&Path> {
|
||||
let mut removed_dirs: Vec<_> = self.removed_dirs.iter().map(PathBuf::as_path).collect();
|
||||
removed_dirs.sort_unstable();
|
||||
removed_dirs
|
||||
}
|
||||
}
|
||||
122
src/syncback/hash/mod.rs
Normal file
122
src/syncback/hash/mod.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Hashing utilities for a WeakDom.
|
||||
mod variant;
|
||||
|
||||
pub use variant::*;
|
||||
|
||||
use blake3::{Hash, Hasher};
|
||||
use rbx_dom_weak::{
|
||||
types::{Ref, Variant},
|
||||
Instance, Ustr, WeakDom,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{variant_eq::variant_eq, Project};
|
||||
|
||||
use super::{descendants, filter_properties_preallocated};
|
||||
|
||||
/// Returns a map of every `Ref` in the `WeakDom` to a hashed version of the
|
||||
/// `Instance` it points to, including the properties and descendants of the
|
||||
/// `Instance`.
|
||||
///
|
||||
/// The hashes **do** include the descendants of the Instances in them,
|
||||
/// so they should only be used for comparing subtrees directly.
|
||||
pub fn hash_tree(project: &Project, dom: &WeakDom, root_ref: Ref) -> HashMap<Ref, Hash> {
|
||||
let mut order = descendants(dom, root_ref);
|
||||
let mut map: HashMap<Ref, Hash> = HashMap::with_capacity(order.len());
|
||||
|
||||
let mut prop_list = Vec::with_capacity(2);
|
||||
let mut child_hashes = Vec::new();
|
||||
|
||||
while let Some(referent) = order.pop() {
|
||||
let inst = dom.get_by_ref(referent).unwrap();
|
||||
let mut hasher = hash_inst_filtered(project, inst, &mut prop_list);
|
||||
add_children(inst, &map, &mut child_hashes, &mut hasher);
|
||||
|
||||
map.insert(referent, hasher.finalize());
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
/// Hashes a single Instance from the provided WeakDom, if it exists.
|
||||
///
|
||||
/// This function filters properties using user-provided syncing rules from
|
||||
/// the passed project.
|
||||
#[inline]
|
||||
pub fn hash_instance(project: &Project, dom: &WeakDom, referent: Ref) -> Option<Hash> {
|
||||
let mut prop_list = Vec::with_capacity(2);
|
||||
let inst = dom.get_by_ref(referent)?;
|
||||
|
||||
Some(hash_inst_filtered(project, inst, &mut prop_list).finalize())
|
||||
}
|
||||
|
||||
/// Adds the hashes of children for an Instance to the provided Hasher.
|
||||
fn add_children(
|
||||
inst: &Instance,
|
||||
map: &HashMap<Ref, Hash>,
|
||||
child_hashes: &mut Vec<[u8; 32]>,
|
||||
hasher: &mut Hasher,
|
||||
) {
|
||||
for child_ref in inst.children() {
|
||||
if let Some(hash) = map.get(child_ref) {
|
||||
child_hashes.push(*hash.as_bytes())
|
||||
} else {
|
||||
panic!("Invariant violated: child not hashed before parent")
|
||||
}
|
||||
}
|
||||
child_hashes.sort_unstable();
|
||||
|
||||
for hash in child_hashes.drain(..) {
|
||||
hasher.update(&hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs hashing on an Instance using a filtered property list.
|
||||
/// Does not include the hashes of any children.
|
||||
fn hash_inst_filtered<'inst>(
|
||||
project: &Project,
|
||||
inst: &'inst Instance,
|
||||
prop_list: &mut Vec<(Ustr, &'inst Variant)>,
|
||||
) -> Hasher {
|
||||
filter_properties_preallocated(project, inst, prop_list);
|
||||
|
||||
hash_inst_prefilled(inst, prop_list)
|
||||
}
|
||||
|
||||
/// Performs hashing on an Instance using a pre-filled list of properties.
|
||||
/// It is assumed the property list is **not** sorted, so it is sorted in-line.
|
||||
fn hash_inst_prefilled<'inst>(
|
||||
inst: &'inst Instance,
|
||||
prop_list: &mut Vec<(Ustr, &'inst Variant)>,
|
||||
) -> Hasher {
|
||||
let mut hasher = Hasher::new();
|
||||
hasher.update(inst.name.as_bytes());
|
||||
hasher.update(inst.class.as_bytes());
|
||||
|
||||
prop_list.sort_unstable_by_key(|(name, _)| *name);
|
||||
|
||||
let descriptor = rbx_reflection_database::get()
|
||||
.unwrap()
|
||||
.classes
|
||||
.get(inst.class.as_str());
|
||||
|
||||
if let Some(descriptor) = descriptor {
|
||||
for (name, value) in prop_list.drain(..) {
|
||||
hasher.update(name.as_bytes());
|
||||
if let Some(default) = descriptor.default_properties.get(name.as_str()) {
|
||||
if !variant_eq(default, value) {
|
||||
hash_variant(&mut hasher, value)
|
||||
}
|
||||
} else {
|
||||
hash_variant(&mut hasher, value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (name, value) in prop_list.drain(..) {
|
||||
hasher.update(name.as_bytes());
|
||||
hash_variant(&mut hasher, value)
|
||||
}
|
||||
}
|
||||
|
||||
hasher
|
||||
}
|
||||
212
src/syncback/hash/variant.rs
Normal file
212
src/syncback/hash/variant.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use blake3::Hasher;
|
||||
use rbx_dom_weak::types::{ContentType, PhysicalProperties, Variant, Vector3};
|
||||
|
||||
macro_rules! round {
|
||||
($value:expr) => {
|
||||
(($value * 10.0).round() / 10.0)
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! n_hash {
|
||||
($hash:ident, $($num:expr),*) => {
|
||||
{$(
|
||||
$hash.update(&($num).to_le_bytes());
|
||||
)*}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! hash {
|
||||
($hash:ident, $value:expr) => {{
|
||||
$hash.update($value);
|
||||
}};
|
||||
}
|
||||
|
||||
/// Places `value` into the provided hasher.
|
||||
pub fn hash_variant(hasher: &mut Hasher, value: &Variant) {
|
||||
// We need to round floats, though I'm not sure to what degree we can
|
||||
// realistically do that.
|
||||
match value {
|
||||
Variant::Attributes(attrs) => {
|
||||
let mut sorted: Vec<(&String, &Variant)> = attrs.iter().collect();
|
||||
sorted.sort_unstable_by_key(|(name, _)| *name);
|
||||
for (name, attribute) in sorted {
|
||||
hasher.update(name.as_bytes());
|
||||
hash_variant(hasher, attribute);
|
||||
}
|
||||
}
|
||||
Variant::Axes(a) => hash!(hasher, &[a.bits()]),
|
||||
Variant::BinaryString(bytes) => hash!(hasher, bytes.as_ref()),
|
||||
Variant::Bool(bool) => hash!(hasher, &[*bool as u8]),
|
||||
Variant::BrickColor(color) => n_hash!(hasher, *color as u16),
|
||||
Variant::CFrame(cf) => {
|
||||
vector_hash(hasher, cf.position);
|
||||
vector_hash(hasher, cf.orientation.x);
|
||||
vector_hash(hasher, cf.orientation.y);
|
||||
vector_hash(hasher, cf.orientation.z);
|
||||
}
|
||||
Variant::Color3(color) => {
|
||||
n_hash!(hasher, round!(color.r), round!(color.g), round!(color.b))
|
||||
}
|
||||
Variant::Color3uint8(color) => hash!(hasher, &[color.r, color.b, color.g]),
|
||||
Variant::ColorSequence(seq) => {
|
||||
let mut new = Vec::with_capacity(seq.keypoints.len());
|
||||
for keypoint in &seq.keypoints {
|
||||
new.push(keypoint);
|
||||
}
|
||||
new.sort_unstable_by(|a, b| round!(a.time).partial_cmp(&round!(b.time)).unwrap());
|
||||
for keypoint in new {
|
||||
n_hash!(
|
||||
hasher,
|
||||
round!(keypoint.time),
|
||||
round!(keypoint.color.r),
|
||||
round!(keypoint.color.g),
|
||||
round!(keypoint.color.b)
|
||||
)
|
||||
}
|
||||
}
|
||||
Variant::Content(content) => match content.value() {
|
||||
ContentType::None => {
|
||||
hash!(hasher, &[0]);
|
||||
}
|
||||
ContentType::Uri(uri) => {
|
||||
hash!(hasher, &[1]);
|
||||
hash!(hasher, uri.as_bytes());
|
||||
}
|
||||
ContentType::Object(referent) => {
|
||||
hash!(hasher, &[2]);
|
||||
hash!(hasher, referent.to_string().as_bytes())
|
||||
}
|
||||
other => {
|
||||
panic!("the ContentType {other:?} cannot be hashed as a Variant")
|
||||
}
|
||||
},
|
||||
Variant::ContentId(content) => {
|
||||
let s: &str = content.as_ref();
|
||||
hash!(hasher, s.as_bytes())
|
||||
}
|
||||
Variant::Enum(e) => n_hash!(hasher, e.to_u32()),
|
||||
Variant::Faces(f) => hash!(hasher, &[f.bits()]),
|
||||
Variant::Float32(n) => n_hash!(hasher, round!(*n)),
|
||||
Variant::Float64(n) => n_hash!(hasher, round!(n)),
|
||||
Variant::Font(f) => {
|
||||
n_hash!(hasher, f.weight as u16);
|
||||
n_hash!(hasher, f.style as u8);
|
||||
hash!(hasher, f.family.as_bytes());
|
||||
if let Some(cache) = &f.cached_face_id {
|
||||
hash!(hasher, &[0x01]);
|
||||
hash!(hasher, cache.as_bytes());
|
||||
} else {
|
||||
hash!(hasher, &[0x00]);
|
||||
}
|
||||
}
|
||||
Variant::Int32(n) => n_hash!(hasher, n),
|
||||
Variant::Int64(n) => n_hash!(hasher, n),
|
||||
Variant::MaterialColors(n) => hash!(hasher, n.encode().as_slice()),
|
||||
Variant::NetAssetRef(net_asset) => hash!(hasher, net_asset.hash().as_bytes()),
|
||||
Variant::NumberRange(nr) => n_hash!(hasher, round!(nr.max), round!(nr.min)),
|
||||
Variant::NumberSequence(seq) => {
|
||||
let mut new = Vec::with_capacity(seq.keypoints.len());
|
||||
for keypoint in &seq.keypoints {
|
||||
new.push(keypoint);
|
||||
}
|
||||
new.sort_unstable_by(|a, b| round!(a.time).partial_cmp(&round!(b.time)).unwrap());
|
||||
for keypoint in new {
|
||||
n_hash!(
|
||||
hasher,
|
||||
round!(keypoint.time),
|
||||
round!(keypoint.value),
|
||||
round!(keypoint.envelope)
|
||||
)
|
||||
}
|
||||
}
|
||||
Variant::OptionalCFrame(maybe_cf) => {
|
||||
if let Some(cf) = maybe_cf {
|
||||
hash!(hasher, &[0x01]);
|
||||
vector_hash(hasher, cf.position);
|
||||
vector_hash(hasher, cf.orientation.x);
|
||||
vector_hash(hasher, cf.orientation.y);
|
||||
vector_hash(hasher, cf.orientation.z);
|
||||
} else {
|
||||
hash!(hasher, &[0x00]);
|
||||
}
|
||||
}
|
||||
Variant::PhysicalProperties(properties) => match properties {
|
||||
PhysicalProperties::Default => hash!(hasher, &[0x00]),
|
||||
PhysicalProperties::Custom(custom) => {
|
||||
hash!(hasher, &[0x00]);
|
||||
n_hash!(
|
||||
hasher,
|
||||
round!(custom.density()),
|
||||
round!(custom.friction()),
|
||||
round!(custom.elasticity()),
|
||||
round!(custom.friction_weight()),
|
||||
round!(custom.elasticity_weight()),
|
||||
round!(custom.acoustic_absorption())
|
||||
)
|
||||
}
|
||||
},
|
||||
Variant::Ray(ray) => {
|
||||
vector_hash(hasher, ray.origin);
|
||||
vector_hash(hasher, ray.direction);
|
||||
}
|
||||
Variant::Rect(rect) => n_hash!(
|
||||
hasher,
|
||||
round!(rect.max.x),
|
||||
round!(rect.max.y),
|
||||
round!(rect.min.x),
|
||||
round!(rect.min.y)
|
||||
),
|
||||
Variant::Ref(referent) => hash!(hasher, referent.to_string().as_bytes()),
|
||||
Variant::Region3(region) => {
|
||||
vector_hash(hasher, region.max);
|
||||
vector_hash(hasher, region.min);
|
||||
}
|
||||
Variant::Region3int16(region) => {
|
||||
n_hash!(
|
||||
hasher,
|
||||
region.max.x,
|
||||
region.max.y,
|
||||
region.max.z,
|
||||
region.min.x,
|
||||
region.min.y,
|
||||
region.min.z
|
||||
)
|
||||
}
|
||||
Variant::SecurityCapabilities(capabilities) => n_hash!(hasher, capabilities.bits()),
|
||||
Variant::SharedString(sstr) => hash!(hasher, sstr.hash().as_bytes()),
|
||||
Variant::String(str) => hash!(hasher, str.as_bytes()),
|
||||
Variant::Tags(tags) => {
|
||||
let mut dupe: Vec<&str> = tags.iter().collect();
|
||||
dupe.sort_unstable();
|
||||
for tag in dupe {
|
||||
hash!(hasher, tag.as_bytes())
|
||||
}
|
||||
}
|
||||
Variant::UDim(udim) => n_hash!(hasher, round!(udim.scale), udim.offset),
|
||||
Variant::UDim2(udim) => n_hash!(
|
||||
hasher,
|
||||
round!(udim.y.scale),
|
||||
udim.y.offset,
|
||||
round!(udim.x.scale),
|
||||
udim.x.offset
|
||||
),
|
||||
Variant::Vector2(v2) => n_hash!(hasher, round!(v2.x), round!(v2.y)),
|
||||
Variant::Vector2int16(v2) => n_hash!(hasher, v2.x, v2.y),
|
||||
Variant::Vector3(v3) => vector_hash(hasher, *v3),
|
||||
Variant::Vector3int16(v3) => n_hash!(hasher, v3.x, v3.y, v3.z),
|
||||
|
||||
// Hashing UniqueId properties doesn't make sense
|
||||
Variant::UniqueId(_) => (),
|
||||
|
||||
unknown => {
|
||||
log::warn!(
|
||||
"Encountered unknown Variant {:?} while hashing",
|
||||
unknown.ty()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn vector_hash(hasher: &mut Hasher, vector: Vector3) {
|
||||
n_hash!(hasher, round!(vector.x), round!(vector.y), round!(vector.z))
|
||||
}
|
||||
534
src/syncback/mod.rs
Normal file
534
src/syncback/mod.rs
Normal file
@@ -0,0 +1,534 @@
|
||||
mod file_names;
|
||||
mod fs_snapshot;
|
||||
mod hash;
|
||||
mod property_filter;
|
||||
mod ref_properties;
|
||||
mod snapshot;
|
||||
|
||||
use anyhow::Context;
|
||||
use indexmap::IndexMap;
|
||||
use memofs::Vfs;
|
||||
use rbx_dom_weak::{
|
||||
types::{Ref, Variant},
|
||||
ustr, Instance, Ustr, UstrSet, WeakDom,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
env,
|
||||
path::Path,
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
glob::Glob,
|
||||
snapshot::{InstanceWithMeta, RojoTree},
|
||||
snapshot_middleware::Middleware,
|
||||
syncback::ref_properties::{collect_referents, link_referents},
|
||||
Project,
|
||||
};
|
||||
|
||||
pub use file_names::{extension_for_middleware, name_for_inst, validate_file_name};
|
||||
pub use fs_snapshot::FsSnapshot;
|
||||
pub use hash::*;
|
||||
pub use property_filter::{filter_properties, filter_properties_preallocated};
|
||||
pub use snapshot::{SyncbackData, SyncbackSnapshot};
|
||||
|
||||
/// The name of an enviroment variable to use to override the behavior of
|
||||
/// syncback on model files.
|
||||
/// By default, syncback will use `Rbxm` for model files.
|
||||
/// If this is set to `1`, it will instead use `Rbxmx`. If it is set to `2`,
|
||||
/// it will use `JsonModel`.
|
||||
///
|
||||
/// This will **not** override existing `Rbxm` middleware. It will only impact
|
||||
/// new files.
|
||||
const DEBUG_MODEL_FORMAT_VAR: &str = "ROJO_SYNCBACK_DEBUG";
|
||||
|
||||
/// A glob that can be used to tell if a path contains a `.git` folder.
|
||||
static GIT_IGNORE_GLOB: OnceLock<Glob> = OnceLock::new();
|
||||
|
||||
pub fn syncback_loop(
|
||||
vfs: &Vfs,
|
||||
old_tree: &mut RojoTree,
|
||||
mut new_tree: WeakDom,
|
||||
project: &Project,
|
||||
) -> anyhow::Result<FsSnapshot> {
|
||||
let ignore_patterns = project
|
||||
.syncback_rules
|
||||
.as_ref()
|
||||
.map(|rules| rules.compile_globs())
|
||||
.transpose()?;
|
||||
|
||||
// TODO: Add a better way to tell if the root of a project is a directory
|
||||
let skip_pruning = if let Some(path) = &project.tree.path {
|
||||
let middleware =
|
||||
Middleware::middleware_for_path(vfs, &project.sync_rules, path.path()).unwrap();
|
||||
if let Some(middleware) = middleware {
|
||||
middleware.is_dir()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !skip_pruning {
|
||||
// Strip out any objects from the new tree that aren't in the old tree. This
|
||||
// is necessary so that hashing the roots of each tree won't always result
|
||||
// in different hashes. Shout out to Roblox for serializing a bunch of
|
||||
// Services nobody cares about.
|
||||
log::debug!("Pruning new tree");
|
||||
strip_unknown_root_children(&mut new_tree, old_tree);
|
||||
}
|
||||
|
||||
log::debug!("Collecting referents for new DOM...");
|
||||
let deferred_referents = collect_referents(&new_tree);
|
||||
|
||||
// Remove any properties that are manually blocked from syncback via the
|
||||
// project file.
|
||||
log::debug!("Pre-filtering properties on DOMs");
|
||||
for referent in descendants(&new_tree, new_tree.root_ref()) {
|
||||
let new_inst = new_tree.get_by_ref_mut(referent).unwrap();
|
||||
if let Some(filter) = get_property_filter(project, new_inst) {
|
||||
for prop in filter {
|
||||
new_inst.properties.remove(&prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
for referent in descendants(old_tree.inner(), old_tree.get_root_id()) {
|
||||
let mut old_inst_rojo = old_tree.get_instance_mut(referent).unwrap();
|
||||
let old_inst = old_inst_rojo.inner_mut();
|
||||
if let Some(filter) = get_property_filter(project, old_inst) {
|
||||
for prop in filter {
|
||||
old_inst.properties.remove(&prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle removing the current camera.
|
||||
if let Some(syncback_rules) = &project.syncback_rules {
|
||||
if !syncback_rules.sync_current_camera.unwrap_or_default() {
|
||||
log::debug!("Removing CurrentCamera from new DOM");
|
||||
let mut camera_ref = None;
|
||||
for child_ref in new_tree.root().children() {
|
||||
let inst = new_tree.get_by_ref(*child_ref).unwrap();
|
||||
if inst.class == "Workspace" {
|
||||
camera_ref = inst.properties.get(&ustr("CurrentCamera"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(Variant::Ref(camera_ref)) = camera_ref {
|
||||
if new_tree.get_by_ref(*camera_ref).is_some() {
|
||||
new_tree.destroy(*camera_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ignore_referents = project
|
||||
.syncback_rules
|
||||
.as_ref()
|
||||
.and_then(|s| s.ignore_referents)
|
||||
.unwrap_or_default();
|
||||
if !ignore_referents {
|
||||
log::debug!("Linking referents for new DOM");
|
||||
link_referents(deferred_referents, &mut new_tree)?;
|
||||
} else {
|
||||
log::debug!("Skipping referent linking as per project syncback rules");
|
||||
}
|
||||
|
||||
// As with pruning the children of the new root, we need to ensure the roots
|
||||
// for both DOMs have the same name otherwise their hashes will always be
|
||||
// different.
|
||||
new_tree.root_mut().name = old_tree.root().name().to_string();
|
||||
|
||||
log::debug!("Hashing project DOM");
|
||||
let old_hashes = hash_tree(project, old_tree.inner(), old_tree.get_root_id());
|
||||
log::debug!("Hashing file DOM");
|
||||
let new_hashes = hash_tree(project, &new_tree, new_tree.root_ref());
|
||||
|
||||
let project_path = project.folder_location();
|
||||
|
||||
let syncback_data = SyncbackData {
|
||||
vfs,
|
||||
old_tree,
|
||||
new_tree: &new_tree,
|
||||
project,
|
||||
};
|
||||
|
||||
let mut snapshots = vec![SyncbackSnapshot {
|
||||
data: syncback_data,
|
||||
old: Some(old_tree.get_root_id()),
|
||||
new: new_tree.root_ref(),
|
||||
path: project.file_location.clone(),
|
||||
middleware: Some(Middleware::Project),
|
||||
}];
|
||||
|
||||
let mut fs_snapshot = FsSnapshot::new();
|
||||
|
||||
'syncback: while let Some(snapshot) = snapshots.pop() {
|
||||
let inst_path = snapshot.get_new_inst_path(snapshot.new);
|
||||
// We can quickly check that two subtrees are identical and if they are,
|
||||
// skip reconciling them.
|
||||
if let Some(old_ref) = snapshot.old {
|
||||
match (old_hashes.get(&old_ref), new_hashes.get(&snapshot.new)) {
|
||||
(Some(old), Some(new)) => {
|
||||
if old == new {
|
||||
log::trace!(
|
||||
"Skipping {inst_path} due to it being identically hashed as {old:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => unreachable!("All Instances in both DOMs should have hashes"),
|
||||
}
|
||||
}
|
||||
|
||||
if !is_valid_path(&ignore_patterns, project_path, &snapshot.path) {
|
||||
log::debug!("Skipping {inst_path} because its path matches ignore pattern");
|
||||
continue;
|
||||
}
|
||||
if let Some(syncback_rules) = &project.syncback_rules {
|
||||
// Ignore trees;
|
||||
for ignored in &syncback_rules.ignore_trees {
|
||||
if inst_path.starts_with(ignored.as_str()) {
|
||||
log::debug!("Tree {inst_path} is blocked by project");
|
||||
continue 'syncback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let middleware = get_best_middleware(&snapshot);
|
||||
|
||||
log::trace!(
|
||||
"Middleware for {inst_path} is {:?} (path is {})",
|
||||
middleware,
|
||||
snapshot.path.display()
|
||||
);
|
||||
|
||||
if matches!(middleware, Middleware::Json | Middleware::Toml) {
|
||||
log::warn!("Cannot syncback {middleware:?} at {inst_path}, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
let syncback = match middleware.syncback(&snapshot) {
|
||||
Ok(syncback) => syncback,
|
||||
Err(err) if middleware == Middleware::Dir => {
|
||||
let new_middleware = match env::var(DEBUG_MODEL_FORMAT_VAR) {
|
||||
Ok(value) if value == "1" => Middleware::Rbxmx,
|
||||
Ok(value) if value == "2" => Middleware::JsonModel,
|
||||
_ => Middleware::Rbxm,
|
||||
};
|
||||
let file_name = snapshot
|
||||
.path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.context("Directory middleware should have a name in its path")?;
|
||||
let mut path = snapshot.path.clone();
|
||||
path.set_file_name(format!(
|
||||
"{file_name}.{}",
|
||||
extension_for_middleware(new_middleware)
|
||||
));
|
||||
let new_snapshot = snapshot.with_new_path(path, snapshot.new, snapshot.old);
|
||||
log::warn!(
|
||||
"Could not syncback {inst_path} as a Directory because: {err}.\n\
|
||||
It will instead be synced back as a {new_middleware:?}."
|
||||
);
|
||||
let new_syncback_result = new_middleware
|
||||
.syncback(&new_snapshot)
|
||||
.with_context(|| format!("Failed to syncback {inst_path}"));
|
||||
if new_syncback_result.is_ok() && snapshot.old_inst().is_some() {
|
||||
// We need to remove the old FS representation if we're
|
||||
// reserializing it as an rbxm.
|
||||
fs_snapshot.remove_dir(&snapshot.path);
|
||||
}
|
||||
new_syncback_result?
|
||||
}
|
||||
Err(err) => anyhow::bail!("Failed to syncback {inst_path} because {err}"),
|
||||
};
|
||||
|
||||
if !syncback.removed_children.is_empty() {
|
||||
log::debug!(
|
||||
"removed children for {inst_path}: {}",
|
||||
syncback.removed_children.len()
|
||||
);
|
||||
'remove: for inst in &syncback.removed_children {
|
||||
let path = inst.metadata().instigating_source.as_ref().unwrap().path();
|
||||
let inst_path = snapshot.get_old_inst_path(inst.id());
|
||||
if !is_valid_path(&ignore_patterns, project_path, path) {
|
||||
log::debug!(
|
||||
"Skipping removing {} because its matches an ignore pattern",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(syncback_rules) = &project.syncback_rules {
|
||||
for ignored in &syncback_rules.ignore_trees {
|
||||
if inst_path.starts_with(ignored.as_str()) {
|
||||
log::debug!("Skipping removing {inst_path} because its path is blocked by project");
|
||||
continue 'remove;
|
||||
}
|
||||
}
|
||||
}
|
||||
if path.is_dir() {
|
||||
fs_snapshot.remove_dir(path)
|
||||
} else {
|
||||
fs_snapshot.remove_file(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO provide replacement snapshots for e.g. two way sync
|
||||
|
||||
fs_snapshot.merge_with_filter(syncback.fs_snapshot, |path| {
|
||||
is_valid_path(&ignore_patterns, project_path, path)
|
||||
});
|
||||
|
||||
snapshots.extend(syncback.children);
|
||||
}
|
||||
|
||||
Ok(fs_snapshot)
|
||||
}
|
||||
|
||||
pub struct SyncbackReturn<'sync> {
|
||||
pub fs_snapshot: FsSnapshot,
|
||||
pub children: Vec<SyncbackSnapshot<'sync>>,
|
||||
pub removed_children: Vec<InstanceWithMeta<'sync>>,
|
||||
}
|
||||
|
||||
pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware {
|
||||
// At some point, we're better off using an O(1) method for checking
|
||||
// equality for classes like this.
|
||||
static JSON_MODEL_CLASSES: OnceLock<HashSet<&str>> = OnceLock::new();
|
||||
let json_model_classes = JSON_MODEL_CLASSES.get_or_init(|| {
|
||||
[
|
||||
"Sound",
|
||||
"SoundGroup",
|
||||
"Sky",
|
||||
"Atmosphere",
|
||||
"BloomEffect",
|
||||
"BlurEffect",
|
||||
"ColorCorrectionEffect",
|
||||
"DepthOfFieldEffect",
|
||||
"SunRaysEffect",
|
||||
"ParticleEmitter",
|
||||
"TextChannel",
|
||||
"TextChatCommand",
|
||||
// TODO: Implement a way to use inheritance for this
|
||||
"ChatWindowConfiguration",
|
||||
"ChatInputBarConfiguration",
|
||||
"BubbleChatConfiguration",
|
||||
"ChannelTabsConfiguration",
|
||||
]
|
||||
.into()
|
||||
});
|
||||
|
||||
let old_middleware = snapshot
|
||||
.old_inst()
|
||||
.and_then(|inst| inst.metadata().middleware);
|
||||
let inst = snapshot.new_inst();
|
||||
|
||||
let mut middleware;
|
||||
|
||||
if let Some(override_middleware) = snapshot.middleware {
|
||||
return override_middleware;
|
||||
} else if let Some(old_middleware) = old_middleware {
|
||||
return old_middleware;
|
||||
} else if json_model_classes.contains(inst.class.as_str()) {
|
||||
middleware = Middleware::JsonModel;
|
||||
} else {
|
||||
middleware = match inst.class.as_str() {
|
||||
"Folder" | "Configuration" | "Tool" => Middleware::Dir,
|
||||
"StringValue" => Middleware::Text,
|
||||
"Script" => Middleware::ServerScript,
|
||||
"LocalScript" => Middleware::ClientScript,
|
||||
"ModuleScript" => Middleware::ModuleScript,
|
||||
"LocalizationTable" => Middleware::Csv,
|
||||
// This isn't the ideal way to handle this but it works.
|
||||
name if name.ends_with("Value") => Middleware::JsonModel,
|
||||
_ => Middleware::Rbxm,
|
||||
}
|
||||
}
|
||||
|
||||
if !inst.children().is_empty() {
|
||||
middleware = match middleware {
|
||||
Middleware::ServerScript => Middleware::ServerScriptDir,
|
||||
Middleware::ClientScript => Middleware::ClientScriptDir,
|
||||
Middleware::ModuleScript => Middleware::ModuleScriptDir,
|
||||
Middleware::Csv => Middleware::CsvDir,
|
||||
Middleware::JsonModel | Middleware::Text => Middleware::Dir,
|
||||
_ => middleware,
|
||||
}
|
||||
}
|
||||
|
||||
if middleware == Middleware::Rbxm {
|
||||
middleware = match env::var(DEBUG_MODEL_FORMAT_VAR) {
|
||||
Ok(value) if value == "1" => Middleware::Rbxmx,
|
||||
Ok(value) if value == "2" => Middleware::JsonModel,
|
||||
_ => Middleware::Rbxm,
|
||||
}
|
||||
}
|
||||
|
||||
middleware
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct SyncbackRules {
|
||||
/// A list of subtrees in a file that will be ignored by Syncback.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
ignore_trees: Vec<String>,
|
||||
/// A list of patterns to check against the path an Instance would serialize
|
||||
/// to. If a path matches one of these, the Instance won't be syncbacked.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
ignore_paths: Vec<String>,
|
||||
/// A map of classes to properties to ignore for that class when doing
|
||||
/// syncback.
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
ignore_properties: IndexMap<Ustr, Vec<Ustr>>,
|
||||
/// Whether or not the `CurrentCamera` of `Workspace` is included in the
|
||||
/// syncback or not. Defaults to `false`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sync_current_camera: Option<bool>,
|
||||
/// Whether or not to sync properties that cannot be modified via scripts.
|
||||
/// Defaults to `true`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sync_unscriptable: Option<bool>,
|
||||
/// Whether to skip serializing referent properties like `Model.PrimaryPart`
|
||||
/// during syncback. Defaults to `false`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ignore_referents: Option<bool>,
|
||||
/// Whether the globs specified in `ignore_paths` should be modified to also
|
||||
/// match directories. Defaults to `true`.
|
||||
///
|
||||
/// If this is `true`, it'll take ignore globs that end in `/**` and convert
|
||||
/// them to also handle the directory they're referring to. This is
|
||||
/// generally a better UX.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
create_ignore_dir_paths: Option<bool>,
|
||||
}
|
||||
|
||||
impl SyncbackRules {
|
||||
pub fn compile_globs(&self) -> anyhow::Result<Vec<Glob>> {
|
||||
let mut globs = Vec::with_capacity(self.ignore_paths.len());
|
||||
let dir_ignore_paths = self.create_ignore_dir_paths.unwrap_or(true);
|
||||
|
||||
for pattern in &self.ignore_paths {
|
||||
let glob = Glob::new(pattern)
|
||||
.with_context(|| format!("the pattern '{pattern}' is not a valid glob"))?;
|
||||
globs.push(glob);
|
||||
|
||||
if dir_ignore_paths {
|
||||
if let Some(dir_pattern) = pattern.strip_suffix("/**") {
|
||||
if let Ok(glob) = Glob::new(dir_pattern) {
|
||||
globs.push(glob)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(globs)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_path(globs: &Option<Vec<Glob>>, base_path: &Path, path: &Path) -> bool {
|
||||
let git_glob = GIT_IGNORE_GLOB.get_or_init(|| Glob::new(".git/**").unwrap());
|
||||
let test_path = match path.strip_prefix(base_path) {
|
||||
Ok(suffix) => suffix,
|
||||
Err(_) => path,
|
||||
};
|
||||
if git_glob.is_match(test_path) {
|
||||
return false;
|
||||
}
|
||||
if let Some(ref ignore_paths) = globs {
|
||||
for glob in ignore_paths {
|
||||
if glob.is_match(test_path) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns a set of properties that should not be written with syncback if
|
||||
/// one exists. This list is read directly from the Project and takes
|
||||
/// inheritance into effect.
|
||||
///
|
||||
/// It **does not** handle properties that should not serialize for other
|
||||
/// reasons, such as being defaults or being marked as not serializing in the
|
||||
/// ReflectionDatabase.
|
||||
fn get_property_filter(project: &Project, new_inst: &Instance) -> Option<UstrSet> {
|
||||
let filter = &project.syncback_rules.as_ref()?.ignore_properties;
|
||||
let mut set = UstrSet::default();
|
||||
|
||||
let database = rbx_reflection_database::get().unwrap();
|
||||
let mut current_class_name = new_inst.class.as_str();
|
||||
|
||||
loop {
|
||||
if let Some(list) = filter.get(&ustr(current_class_name)) {
|
||||
set.extend(list)
|
||||
}
|
||||
|
||||
let class = database.classes.get(current_class_name)?;
|
||||
if let Some(super_class) = class.superclass.as_ref() {
|
||||
current_class_name = super_class;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Some(set)
|
||||
}
|
||||
|
||||
/// Produces a list of descendants in the WeakDom such that all children come
|
||||
/// before their parents.
|
||||
fn descendants(dom: &WeakDom, root_ref: Ref) -> Vec<Ref> {
|
||||
let mut queue = VecDeque::new();
|
||||
let mut ordered = Vec::new();
|
||||
queue.push_front(root_ref);
|
||||
|
||||
while let Some(referent) = queue.pop_front() {
|
||||
let inst = dom
|
||||
.get_by_ref(referent)
|
||||
.expect("Invariant: WeakDom had a Ref that wasn't inside it");
|
||||
ordered.push(referent);
|
||||
for child in inst.children() {
|
||||
queue.push_back(*child)
|
||||
}
|
||||
}
|
||||
|
||||
ordered
|
||||
}
|
||||
|
||||
/// Removes the children of `new`'s root that are not also children of `old`'s
|
||||
/// root.
|
||||
///
|
||||
/// This does not care about duplicates, and only filters based on names and
|
||||
/// class names.
|
||||
fn strip_unknown_root_children(new: &mut WeakDom, old: &RojoTree) {
|
||||
let old_root = old.root();
|
||||
let old_root_children: HashMap<&str, InstanceWithMeta> = old_root
|
||||
.children()
|
||||
.iter()
|
||||
.map(|referent| {
|
||||
let inst = old
|
||||
.get_instance(*referent)
|
||||
.expect("all children of a DOM's root should exist");
|
||||
(inst.name(), inst)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let root_children = new.root().children().to_vec();
|
||||
|
||||
for child_ref in root_children {
|
||||
let child = new
|
||||
.get_by_ref(child_ref)
|
||||
.expect("all children of the root should exist in the DOM");
|
||||
if let Some(old) = old_root_children.get(child.name.as_str()) {
|
||||
if old.class_name() == child.class {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log::trace!("Pruning root child {} of class {}", child.name, child.class);
|
||||
new.destroy(child_ref);
|
||||
}
|
||||
}
|
||||
111
src/syncback/property_filter.rs
Normal file
111
src/syncback/property_filter.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use rbx_dom_weak::{types::Variant, Instance, Ustr, UstrMap};
|
||||
use rbx_reflection::{PropertyKind, PropertySerialization, Scriptability};
|
||||
|
||||
use crate::{variant_eq::variant_eq, Project};
|
||||
|
||||
/// Returns a map of properties from `inst` that are both allowed under the
|
||||
/// user-provided settings, are not their default value, and serialize.
|
||||
pub fn filter_properties<'inst>(
|
||||
project: &Project,
|
||||
inst: &'inst Instance,
|
||||
) -> UstrMap<&'inst Variant> {
|
||||
let mut map: Vec<(Ustr, &Variant)> = Vec::with_capacity(inst.properties.len());
|
||||
filter_properties_preallocated(project, inst, &mut map);
|
||||
|
||||
map.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Fills `allocation` with a list of properties from `inst` that are
|
||||
/// user-provided settings, are not their default value, and serialize.
|
||||
pub fn filter_properties_preallocated<'inst>(
|
||||
project: &Project,
|
||||
inst: &'inst Instance,
|
||||
allocation: &mut Vec<(Ustr, &'inst Variant)>,
|
||||
) {
|
||||
let sync_unscriptable = project
|
||||
.syncback_rules
|
||||
.as_ref()
|
||||
.and_then(|s| s.sync_unscriptable)
|
||||
.unwrap_or(true);
|
||||
|
||||
let class_data = rbx_reflection_database::get()
|
||||
.unwrap()
|
||||
.classes
|
||||
.get(inst.class.as_str());
|
||||
|
||||
let predicate = |prop_name: &Ustr, prop_value: &Variant| {
|
||||
// We don't want to serialize Ref or UniqueId properties in JSON files
|
||||
if matches!(prop_value, Variant::Ref(_) | Variant::UniqueId(_)) {
|
||||
return true;
|
||||
}
|
||||
if !should_property_serialize(&inst.class, prop_name) {
|
||||
return true;
|
||||
}
|
||||
if !sync_unscriptable {
|
||||
if let Some(data) = class_data {
|
||||
if let Some(prop_data) = data.properties.get(prop_name.as_str()) {
|
||||
if matches!(prop_data.scriptability, Scriptability::None) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
if let Some(class_data) = class_data {
|
||||
let defaults = &class_data.default_properties;
|
||||
for (name, value) in &inst.properties {
|
||||
if predicate(name, value) {
|
||||
continue;
|
||||
}
|
||||
if let Some(default) = defaults.get(name.as_str()) {
|
||||
if !variant_eq(value, default) {
|
||||
allocation.push((*name, value));
|
||||
}
|
||||
} else {
|
||||
allocation.push((*name, value));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (name, value) in &inst.properties {
|
||||
if predicate(name, value) {
|
||||
continue;
|
||||
}
|
||||
allocation.push((*name, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_property_serialize(class_name: &str, prop_name: &str) -> bool {
|
||||
let database = rbx_reflection_database::get().unwrap();
|
||||
let mut current_class_name = class_name;
|
||||
|
||||
loop {
|
||||
let class_data = match database.classes.get(current_class_name) {
|
||||
Some(data) => data,
|
||||
None => return true,
|
||||
};
|
||||
if let Some(data) = class_data.properties.get(prop_name) {
|
||||
log::trace!("found {class_name}.{prop_name} on {current_class_name}");
|
||||
return match &data.kind {
|
||||
// It's not really clear if this can ever happen but I want to
|
||||
// support it just in case!
|
||||
PropertyKind::Alias { alias_for } => {
|
||||
should_property_serialize(current_class_name, alias_for)
|
||||
}
|
||||
// Migrations and aliases are happily handled for us by parsers
|
||||
// so we don't really need to handle them.
|
||||
PropertyKind::Canonical { serialization } => {
|
||||
!matches!(serialization, PropertySerialization::DoesNotSerialize)
|
||||
}
|
||||
kind => unimplemented!("unknown property kind {kind:?}"),
|
||||
};
|
||||
} else if let Some(super_class) = class_data.superclass.as_ref() {
|
||||
current_class_name = super_class;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
192
src/syncback/ref_properties.rs
Normal file
192
src/syncback/ref_properties.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
//! Implements iterating through an entire WeakDom and linking all Ref
|
||||
//! properties using attributes.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use rbx_dom_weak::{
|
||||
types::{Attributes, Ref, UniqueId, Variant},
|
||||
ustr, Instance, Ustr, WeakDom,
|
||||
};
|
||||
|
||||
use crate::{multimap::MultiMap, REF_ID_ATTRIBUTE_NAME, REF_POINTER_ATTRIBUTE_PREFIX};
|
||||
|
||||
pub struct RefLinks {
|
||||
/// A map of referents to each of their Ref properties.
|
||||
prop_links: MultiMap<Ref, RefLink>,
|
||||
/// A set of referents that need their ID rewritten. This includes
|
||||
/// Instances that have no existing ID.
|
||||
need_rewrite: HashSet<Ref>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct RefLink {
|
||||
/// The name of a property
|
||||
name: Ustr,
|
||||
/// The value of the property.
|
||||
value: Ref,
|
||||
}
|
||||
|
||||
/// Iterates through a WeakDom and collects referent properties.
|
||||
///
|
||||
/// They can be linked to a dom later using `link_referents`.
|
||||
pub fn collect_referents(dom: &WeakDom) -> RefLinks {
|
||||
let mut ids = HashMap::new();
|
||||
let mut need_rewrite = HashSet::new();
|
||||
let mut links = MultiMap::new();
|
||||
|
||||
// Note that this is back-in, front-out. This is important because
|
||||
// VecDeque::extend is the equivalent to using push_back.
|
||||
let mut queue = VecDeque::new();
|
||||
queue.push_back(dom.root_ref());
|
||||
while let Some(inst_ref) = queue.pop_front() {
|
||||
let pointer = dom.get_by_ref(inst_ref).unwrap();
|
||||
queue.extend(pointer.children().iter().copied());
|
||||
|
||||
for (prop_name, prop_value) in &pointer.properties {
|
||||
let Variant::Ref(prop_value) = prop_value else {
|
||||
continue;
|
||||
};
|
||||
if prop_value.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
links.insert(
|
||||
inst_ref,
|
||||
RefLink {
|
||||
name: *prop_name,
|
||||
value: *prop_value,
|
||||
},
|
||||
);
|
||||
|
||||
let target = dom
|
||||
.get_by_ref(*prop_value)
|
||||
.expect("Refs in DOM should point to valid Instances");
|
||||
|
||||
// 1. Check if target has an ID
|
||||
if let Some(id) = get_existing_id(target) {
|
||||
// If it does, we need to check whether that ID is a duplicate
|
||||
if let Some(id_ref) = ids.get(id) {
|
||||
// If the same ID points to a new Instance, rewrite it.
|
||||
if id_ref != prop_value {
|
||||
if log::log_enabled!(log::Level::Trace) {
|
||||
log::trace!(
|
||||
"{} needs an id rewritten because it has the same id as {}",
|
||||
target.name,
|
||||
dom.get_by_ref(*id_ref).unwrap().name
|
||||
);
|
||||
}
|
||||
need_rewrite.insert(*prop_value);
|
||||
}
|
||||
}
|
||||
ids.insert(id, *prop_value);
|
||||
} else {
|
||||
log::trace!("{} needs an id rewritten because it has no id but is referred to by {}.{prop_name}", target.name, pointer.name);
|
||||
// If it does not, it needs one.
|
||||
need_rewrite.insert(*prop_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RefLinks {
|
||||
need_rewrite,
|
||||
prop_links: links,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn link_referents(links: RefLinks, dom: &mut WeakDom) -> anyhow::Result<()> {
|
||||
write_id_attributes(&links, dom)?;
|
||||
|
||||
let mut prop_list = Vec::new();
|
||||
|
||||
for (inst_id, properties) in links.prop_links {
|
||||
for ref_link in properties {
|
||||
let prop_inst = match dom.get_by_ref(ref_link.value) {
|
||||
Some(inst) => inst,
|
||||
None => continue,
|
||||
};
|
||||
let id = get_existing_id(prop_inst)
|
||||
.expect("all Instances that are pointed to should have an ID");
|
||||
prop_list.push((ref_link.name, Variant::String(id.to_owned())));
|
||||
}
|
||||
let inst = match dom.get_by_ref_mut(inst_id) {
|
||||
Some(inst) => inst,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let mut attributes: Attributes = match inst.properties.remove(&ustr("Attributes")) {
|
||||
Some(Variant::Attributes(attrs)) => attrs,
|
||||
None => Attributes::new(),
|
||||
Some(value) => {
|
||||
anyhow::bail!(
|
||||
"expected Attributes to be of type 'Attributes' but it was of type '{:?}'",
|
||||
value.ty()
|
||||
);
|
||||
}
|
||||
}
|
||||
.into_iter()
|
||||
.filter(|(name, _)| !name.starts_with(REF_POINTER_ATTRIBUTE_PREFIX))
|
||||
.collect();
|
||||
|
||||
for (prop_name, prop_value) in prop_list.drain(..) {
|
||||
attributes.insert(
|
||||
format!("{REF_POINTER_ATTRIBUTE_PREFIX}{prop_name}"),
|
||||
prop_value,
|
||||
);
|
||||
}
|
||||
|
||||
inst.properties
|
||||
.insert("Attributes".into(), attributes.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_id_attributes(links: &RefLinks, dom: &mut WeakDom) -> anyhow::Result<()> {
|
||||
for referent in &links.need_rewrite {
|
||||
let inst = match dom.get_by_ref_mut(*referent) {
|
||||
Some(inst) => inst,
|
||||
None => continue,
|
||||
};
|
||||
let unique_id = match inst.properties.get(&ustr("UniqueId")) {
|
||||
Some(Variant::UniqueId(id)) => Some(*id),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or_else(|| UniqueId::now().unwrap());
|
||||
|
||||
let attributes = match inst.properties.get_mut(&ustr("Attributes")) {
|
||||
Some(Variant::Attributes(attrs)) => attrs,
|
||||
None => {
|
||||
inst.properties
|
||||
.insert("Attributes".into(), Attributes::new().into());
|
||||
match inst.properties.get_mut(&ustr("Attributes")) {
|
||||
Some(Variant::Attributes(attrs)) => attrs,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
Some(value) => {
|
||||
anyhow::bail!(
|
||||
"expected Attributes to be of type 'Attributes' but it was of type '{:?}'",
|
||||
value.ty()
|
||||
);
|
||||
}
|
||||
};
|
||||
attributes.insert(
|
||||
REF_ID_ATTRIBUTE_NAME.into(),
|
||||
Variant::String(unique_id.to_string()),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_existing_id(inst: &Instance) -> Option<&str> {
|
||||
if let Variant::Attributes(attrs) = inst.properties.get(&ustr("Attributes"))? {
|
||||
let id = attrs.get(REF_ID_ATTRIBUTE_NAME)?;
|
||||
match id {
|
||||
Variant::String(str) => Some(str),
|
||||
Variant::BinaryString(bstr) => std::str::from_utf8(bstr.as_ref()).ok(),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
259
src/syncback/snapshot.rs
Normal file
259
src/syncback/snapshot.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use indexmap::IndexMap;
|
||||
use memofs::Vfs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{
|
||||
snapshot::{InstanceWithMeta, RojoTree},
|
||||
snapshot_middleware::Middleware,
|
||||
Project,
|
||||
};
|
||||
use rbx_dom_weak::{
|
||||
types::{Ref, Variant},
|
||||
Instance, Ustr, UstrMap, WeakDom,
|
||||
};
|
||||
|
||||
use super::{get_best_middleware, name_for_inst, property_filter::filter_properties};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SyncbackData<'sync> {
|
||||
pub(super) vfs: &'sync Vfs,
|
||||
pub(super) old_tree: &'sync RojoTree,
|
||||
pub(super) new_tree: &'sync WeakDom,
|
||||
pub(super) project: &'sync Project,
|
||||
}
|
||||
|
||||
pub struct SyncbackSnapshot<'sync> {
|
||||
pub data: SyncbackData<'sync>,
|
||||
pub old: Option<Ref>,
|
||||
pub new: Ref,
|
||||
pub path: PathBuf,
|
||||
pub middleware: Option<Middleware>,
|
||||
}
|
||||
|
||||
impl<'sync> SyncbackSnapshot<'sync> {
|
||||
/// Constructs a SyncbackSnapshot from the provided refs
|
||||
/// while inheriting this snapshot's path and data. This should be used for
|
||||
/// directories.
|
||||
#[inline]
|
||||
pub fn with_joined_path(&self, new_ref: Ref, old_ref: Option<Ref>) -> anyhow::Result<Self> {
|
||||
let mut snapshot = Self {
|
||||
data: self.data,
|
||||
old: old_ref,
|
||||
new: new_ref,
|
||||
path: PathBuf::new(),
|
||||
middleware: None,
|
||||
};
|
||||
let middleware = get_best_middleware(&snapshot);
|
||||
let name = name_for_inst(middleware, snapshot.new_inst(), snapshot.old_inst())?;
|
||||
snapshot.path = self.path.join(name.as_ref());
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Constructs a SyncbackSnapshot from the provided refs and a base path,
|
||||
/// while inheriting this snapshot's data.
|
||||
///
|
||||
/// The actual path of the snapshot is made by getting a file name for the
|
||||
/// snapshot and then appending it to the provided base path.
|
||||
#[inline]
|
||||
pub fn with_base_path(
|
||||
&self,
|
||||
base_path: &Path,
|
||||
new_ref: Ref,
|
||||
old_ref: Option<Ref>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let mut snapshot = Self {
|
||||
data: self.data,
|
||||
old: old_ref,
|
||||
new: new_ref,
|
||||
path: PathBuf::new(),
|
||||
middleware: None,
|
||||
};
|
||||
let middleware = get_best_middleware(&snapshot);
|
||||
let name = name_for_inst(middleware, snapshot.new_inst(), snapshot.old_inst())?;
|
||||
snapshot.path = base_path.join(name.as_ref());
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Constructs a SyncbackSnapshot with the provided path and refs while
|
||||
/// inheriting the data of the this snapshot.
|
||||
#[inline]
|
||||
pub fn with_new_path(&self, path: PathBuf, new_ref: Ref, old_ref: Option<Ref>) -> Self {
|
||||
Self {
|
||||
data: self.data,
|
||||
old: old_ref,
|
||||
new: new_ref,
|
||||
path,
|
||||
middleware: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows a middleware to be 'forced' onto a SyncbackSnapshot to override
|
||||
/// the attempts to derive it.
|
||||
#[inline]
|
||||
pub fn middleware(mut self, middleware: Middleware) -> Self {
|
||||
self.middleware = Some(middleware);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a map of properties for an Instance from the 'new' tree
|
||||
/// with filtering done to avoid noise. This method filters out properties
|
||||
/// that are not meant to be present in Instances that are represented
|
||||
/// specially by a path, like `LocalScript.Source` and `StringValue.Value`.
|
||||
///
|
||||
/// This method is not necessary or desired for blobs like Rbxm or non-path
|
||||
/// middlewares like JsonModel.
|
||||
#[inline]
|
||||
#[must_use]
|
||||
pub fn get_path_filtered_properties(&self, new_ref: Ref) -> Option<UstrMap<&'sync Variant>> {
|
||||
let inst = self.get_new_instance(new_ref)?;
|
||||
|
||||
// The only filtering we have to do is filter out properties that are
|
||||
// special-cased in some capacity.
|
||||
let properties = filter_properties(self.data.project, inst)
|
||||
.into_iter()
|
||||
.filter(|(name, _)| !filter_out_property(inst, name))
|
||||
.collect();
|
||||
|
||||
Some(properties)
|
||||
}
|
||||
|
||||
/// Returns a path to the provided Instance in the new DOM. This path is
|
||||
/// where you would look for the object in Roblox Studio.
|
||||
#[inline]
|
||||
pub fn get_new_inst_path(&self, referent: Ref) -> String {
|
||||
inst_path(self.new_tree(), referent)
|
||||
}
|
||||
|
||||
/// Returns a path to the provided Instance in the old DOM. This path is
|
||||
/// where you would look for the object in Roblox Studio.
|
||||
#[inline]
|
||||
pub fn get_old_inst_path(&self, referent: Ref) -> String {
|
||||
inst_path(self.old_tree(), referent)
|
||||
}
|
||||
|
||||
/// Returns an Instance from the old tree with the provided referent, if it
|
||||
/// exists.
|
||||
#[inline]
|
||||
pub fn get_old_instance(&self, referent: Ref) -> Option<InstanceWithMeta<'sync>> {
|
||||
self.data.old_tree.get_instance(referent)
|
||||
}
|
||||
|
||||
/// Returns an Instance from the new tree with the provided referent, if it
|
||||
/// exists.
|
||||
#[inline]
|
||||
pub fn get_new_instance(&self, referent: Ref) -> Option<&'sync Instance> {
|
||||
self.data.new_tree.get_by_ref(referent)
|
||||
}
|
||||
|
||||
/// The 'old' Instance this snapshot is for, if it exists.
|
||||
#[inline]
|
||||
pub fn old_inst(&self) -> Option<InstanceWithMeta<'sync>> {
|
||||
self.old
|
||||
.and_then(|old| self.data.old_tree.get_instance(old))
|
||||
}
|
||||
|
||||
/// The 'new' Instance this snapshot is for.
|
||||
#[inline]
|
||||
pub fn new_inst(&self) -> &'sync Instance {
|
||||
self.data
|
||||
.new_tree
|
||||
.get_by_ref(self.new)
|
||||
.expect("SyncbackSnapshot should not contain invalid referents")
|
||||
}
|
||||
|
||||
/// Returns the root Project that was used to make this snapshot.
|
||||
#[inline]
|
||||
pub fn project(&self) -> &'sync Project {
|
||||
self.data.project
|
||||
}
|
||||
|
||||
/// Returns the underlying VFS being used for syncback.
|
||||
#[inline]
|
||||
pub fn vfs(&self) -> &'sync Vfs {
|
||||
self.data.vfs
|
||||
}
|
||||
|
||||
/// Returns the WeakDom used for the 'new' tree.
|
||||
#[inline]
|
||||
pub fn new_tree(&self) -> &'sync WeakDom {
|
||||
self.data.new_tree
|
||||
}
|
||||
|
||||
/// Returns the WeakDom used for the 'old' tree.
|
||||
#[inline]
|
||||
pub fn old_tree(&self) -> &'sync WeakDom {
|
||||
self.data.old_tree.inner()
|
||||
}
|
||||
|
||||
/// Returns user-specified property ignore rules.
|
||||
#[inline]
|
||||
pub fn ignore_props(&self) -> Option<&IndexMap<Ustr, Vec<Ustr>>> {
|
||||
self.data
|
||||
.project
|
||||
.syncback_rules
|
||||
.as_ref()
|
||||
.map(|rules| &rules.ignore_properties)
|
||||
}
|
||||
|
||||
/// Returns user-specified ignore tree.
|
||||
#[inline]
|
||||
pub fn ignore_tree(&self) -> Option<&[String]> {
|
||||
self.data
|
||||
.project
|
||||
.syncback_rules
|
||||
.as_ref()
|
||||
.map(|rules| rules.ignore_trees.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_out_property(inst: &Instance, prop_name: &str) -> bool {
|
||||
match inst.class.as_str() {
|
||||
"Script" | "LocalScript" | "ModuleScript" => {
|
||||
// These properties shouldn't be set by scripts that are created via
|
||||
// `$path` or via being on the file system.
|
||||
prop_name == "Source" || prop_name == "ScriptGuid"
|
||||
}
|
||||
"LocalizationTable" => prop_name == "Contents",
|
||||
"StringValue" => prop_name == "Value",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inst_path(dom: &WeakDom, referent: Ref) -> String {
|
||||
let mut path = Vec::new();
|
||||
|
||||
let mut inst = dom.get_by_ref(referent);
|
||||
while let Some(instance) = inst {
|
||||
path.push(instance.name.as_str());
|
||||
inst = dom.get_by_ref(instance.parent());
|
||||
}
|
||||
// This is to avoid the root's name from appearing in the path. Not
|
||||
// optimal, but should be fine.
|
||||
path.pop();
|
||||
|
||||
path.reverse();
|
||||
path.join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
||||
|
||||
use super::inst_path as inst_path_outer;
|
||||
|
||||
#[test]
|
||||
fn inst_path() {
|
||||
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
|
||||
|
||||
let child_1 = new_tree.insert(new_tree.root_ref(), InstanceBuilder::new("Child1"));
|
||||
let child_2 = new_tree.insert(child_1, InstanceBuilder::new("Child2"));
|
||||
let child_3 = new_tree.insert(child_2, InstanceBuilder::new("Child3"));
|
||||
|
||||
assert_eq!(inst_path_outer(&new_tree, new_tree.root_ref()), "");
|
||||
assert_eq!(inst_path_outer(&new_tree, child_1), "Child1");
|
||||
assert_eq!(inst_path_outer(&new_tree, child_2), "Child1/Child2");
|
||||
assert_eq!(inst_path_outer(&new_tree, child_3), "Child1/Child2/Child3");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user