diff --git a/CHANGELOG.md b/CHANGELOG.md index f453836e8..5cba7d2cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,43 @@ # Rojo Changelog ## Unreleased Changes +* A new command `rojo syncback` has been added. It can be used as `rojo syncback [path to project] --input [path to file]`. + This command takes a Roblox file and pulls Instances out of it and places them in the correct position in the provided project. + Syncback is primarily controlled by the project file. Any Instances who are either referenced in the project file or a descendant + of one that is will be placed in an appropriate location. + + In addition, a new field has been added to project files, `syncbackRules` to control how it behaves: + + ```json + { + "syncbackRules": { + "ignoreTrees": [ + "ServerStorage/ImportantSecrets", + ], + "ignorePaths": [ + "src/ServerStorage/Secrets/*" + ], + "ignoreProperties": { + "BasePart": ["Color"] + }, + "syncCurrentCamera": false, + "syncUnscriptable": true, + } + } + ``` + + A brief explanation of each field: + + - `ignoreTrees` is a list of paths in the **roblox file** that should be ignored + - `ignorePaths` is a list of paths in the **file system** that should be ignored + - `ignoreProperties` is a list of properties that won't be synced back + - `syncCurrentCamera` is a toggle for whether to sync back the Workspace's CurrentCamera. Defaults to `false`. + - `syncUnscriptable` is a toggle for whether to sync back properties that cannot be set by the Roblox Studio plugin. Defaults to `true`. + + If you are used to the `UpliftGames` version of this feature, there are a few notable differences: + - `syncUnscriptable` defaults to `true` instead of `false` + - `ignoreTrees` doesn't require the root of the project's name in it. + * Projects may now manually link `Ref` properties together using `Attributes`. ([#843]) This has two parts: using `id` or `$id` in JSON files or a `Rojo_Target` attribute, an Instance is given an ID. Then, that ID may be used elsewhere in the project to point to an Instance diff --git a/Cargo.lock b/Cargo.lock index 32ccb785f..80f44662e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,6 +496,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1757,12 +1766,14 @@ dependencies = [ "anyhow", "backtrace", "bincode", + "blake3", "clap 3.2.25", "criterion", "crossbeam-channel", "csv", "embed-resource", "env_logger", + "float-cmp", "fs-err", "futures", "globset", diff --git a/Cargo.toml b/Cargo.toml index 4a0ab7f63..7b2f75b26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,9 @@ uuid = { version = "1.7.0", features = ["v4", "serde"] } clap = { version = "3.2.25", features = ["derive"] } profiling = "1.0.15" +blake3 = "1.5.0" +float-cmp = "0.9.0" + [target.'cfg(windows)'.dependencies] winreg = "0.10.1" diff --git a/crates/memofs/CHANGELOG.md b/crates/memofs/CHANGELOG.md index 44c3aa507..16d578c39 100644 --- a/crates/memofs/CHANGELOG.md +++ b/crates/memofs/CHANGELOG.md @@ -5,6 +5,7 @@ ## 0.3.0 (2024-03-15) * Changed `StdBackend` file watching component to use minimal recursive watches. [#830] * Added `Vfs::read_to_string` and `Vfs::read_to_string_lf_normalized` [#854] +* Added `create_dir` and `create_dir_all` to allow creating directories. [#830]: https://github.com/rojo-rbx/rojo/pull/830 [#854]: https://github.com/rojo-rbx/rojo/pull/854 diff --git a/crates/memofs/src/in_memory_fs.rs b/crates/memofs/src/in_memory_fs.rs index fc8cf2efe..2eb09b8a1 100644 --- a/crates/memofs/src/in_memory_fs.rs +++ b/crates/memofs/src/in_memory_fs.rs @@ -176,6 +176,21 @@ impl VfsBackend for InMemoryFs { } } + fn create_dir(&mut self, path: &Path) -> io::Result<()> { + let mut inner = self.inner.lock().unwrap(); + inner.load_snapshot(path.to_path_buf(), VfsSnapshot::empty_dir()) + } + + fn create_dir_all(&mut self, path: &Path) -> io::Result<()> { + let mut inner = self.inner.lock().unwrap(); + let mut path_buf = path.to_path_buf(); + while let Some(parent) = path_buf.parent() { + inner.load_snapshot(parent.to_path_buf(), VfsSnapshot::empty_dir())?; + path_buf.pop(); + } + inner.load_snapshot(path.to_path_buf(), VfsSnapshot::empty_dir()) + } + fn remove_file(&mut self, path: &Path) -> io::Result<()> { let mut inner = self.inner.lock().unwrap(); diff --git a/crates/memofs/src/lib.rs b/crates/memofs/src/lib.rs index 7a348ff60..028a413b8 100644 --- a/crates/memofs/src/lib.rs +++ b/crates/memofs/src/lib.rs @@ -71,6 +71,8 @@ pub trait VfsBackend: sealed::Sealed + Send + 'static { fn read(&mut self, path: &Path) -> io::Result>; fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()>; fn read_dir(&mut self, path: &Path) -> io::Result; + fn create_dir(&mut self, path: &Path) -> io::Result<()>; + fn create_dir_all(&mut self, path: &Path) -> io::Result<()>; fn metadata(&mut self, path: &Path) -> io::Result; fn remove_file(&mut self, path: &Path) -> io::Result<()>; fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>; @@ -190,6 +192,16 @@ impl VfsInner { Ok(dir) } + fn create_dir>(&mut self, path: P) -> io::Result<()> { + let path = path.as_ref(); + self.backend.create_dir(path) + } + + fn create_dir_all>(&mut self, path: P) -> io::Result<()> { + let path = path.as_ref(); + self.backend.create_dir_all(path) + } + fn remove_file>(&mut self, path: P) -> io::Result<()> { let path = path.as_ref(); let _ = self.backend.unwatch(path); @@ -326,6 +338,31 @@ impl Vfs { self.inner.lock().unwrap().read_dir(path) } + /// Creates a directory at the provided location. + /// + /// Roughly equivalent to [`std::fs::create_dir`][std::fs::create_dir]. + /// Similiar to that function, this function will fail if the parent of the + /// path does not exist. + /// + /// [std::fs::create_dir]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir.html + #[inline] + pub fn create_dir>(&self, path: P) -> io::Result<()> { + let path = path.as_ref(); + self.inner.lock().unwrap().create_dir(path) + } + + /// Creates a directory at the provided location, recursively creating + /// all parent components if they are missing. + /// + /// Roughly equivalent to [`std::fs::create_dir_all`][std::fs::create_dir_all]. + /// + /// [std::fs::create_dir_all]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir_all.html + #[inline] + pub fn create_dir_all>(&self, path: P) -> io::Result<()> { + let path = path.as_ref(); + self.inner.lock().unwrap().create_dir_all(path) + } + /// Remove a file. /// /// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file]. @@ -428,6 +465,31 @@ impl VfsLock<'_> { self.inner.read_dir(path) } + /// Creates a directory at the provided location. + /// + /// Roughly equivalent to [`std::fs::create_dir`][std::fs::create_dir]. + /// Similiar to that function, this function will fail if the parent of the + /// path does not exist. + /// + /// [std::fs::create_dir]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir.html + #[inline] + pub fn create_dir>(&mut self, path: P) -> io::Result<()> { + let path = path.as_ref(); + self.inner.create_dir(path) + } + + /// Creates a directory at the provided location, recursively creating + /// all parent components if they are missing. + /// + /// Roughly equivalent to [`std::fs::create_dir_all`][std::fs::create_dir_all]. + /// + /// [std::fs::create_dir_all]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir_all.html + #[inline] + pub fn create_dir_all>(&mut self, path: P) -> io::Result<()> { + let path = path.as_ref(); + self.inner.create_dir_all(path) + } + /// Remove a file. /// /// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file]. diff --git a/crates/memofs/src/noop_backend.rs b/crates/memofs/src/noop_backend.rs index efc8fd4ae..8e5a24177 100644 --- a/crates/memofs/src/noop_backend.rs +++ b/crates/memofs/src/noop_backend.rs @@ -35,6 +35,20 @@ impl VfsBackend for NoopBackend { )) } + fn create_dir(&mut self, _path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Other, + "NoopBackend doesn't do anything", + )) + } + + fn create_dir_all(&mut self, _path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Other, + "NoopBackend doesn't do anything", + )) + } + fn remove_file(&mut self, _path: &Path) -> io::Result<()> { Err(io::Error::new( io::ErrorKind::Other, diff --git a/crates/memofs/src/std_backend.rs b/crates/memofs/src/std_backend.rs index 0a8fb3b79..fc2e1d208 100644 --- a/crates/memofs/src/std_backend.rs +++ b/crates/memofs/src/std_backend.rs @@ -78,6 +78,14 @@ impl VfsBackend for StdBackend { }) } + fn create_dir(&mut self, path: &Path) -> io::Result<()> { + fs_err::create_dir(path) + } + + fn create_dir_all(&mut self, path: &Path) -> io::Result<()> { + fs_err::create_dir_all(path) + } + fn remove_file(&mut self, path: &Path) -> io::Result<()> { fs_err::remove_file(path) } diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__all_middleware.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__all_middleware.snap new file mode 100644 index 000000000..3db4bb1f6 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__all_middleware.snap @@ -0,0 +1,25 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - src/csv.csv + - src/csv_init/init.csv + - src/dir/client_script.client.luau + - src/dir/init_client_script/init.client.luau + - src/dir/init_module_script/init.luau + - src/dir/init_server_script/init.server.luau + - src/dir/module_script.luau + - src/dir/server_script.server.luau + - src/model_json.model.json + - src/project_json.project.json + - src/rbxm.rbxm + - src/rbxmx.rbxmx + - src/text.txt +added_dirs: + - src/csv_init + - src/dir/init_client_script + - src/dir/init_module_script + - src/dir/init_server_script +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__baseplate.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__baseplate.snap new file mode 100644 index 000000000..41b39653b --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__baseplate.snap @@ -0,0 +1,20 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - Lighting/Atmosphere.model.json + - Lighting/Bloom.model.json + - Lighting/DepthOfField.model.json + - Lighting/Sky.model.json + - Lighting/SunRays.model.json + - Workspace/Baseplate.rbxm + - Workspace/Camera.rbxm + - Workspace/SpawnLocation.rbxm + - Workspace/Terrain.rbxm + - default.project.json +added_dirs: + - Lighting + - Workspace +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__child_but_not.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__child_but_not.snap new file mode 100644 index 000000000..06a661b0c --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__child_but_not.snap @@ -0,0 +1,10 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - OnlyOneCopy/child_of_one.luau + - ReplicatedStorage/child_replicated_storage.luau +added_dirs: [] +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__duplicate_rojo_id.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__duplicate_rojo_id.snap new file mode 100644 index 000000000..407cd50fe --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__duplicate_rojo_id.snap @@ -0,0 +1,11 @@ +--- +source: tests/rojo_test/syncback_util.rs +assertion_line: 48 +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - container.model.json +added_dirs: [] +removed_files: [] +removed_dirs: [] + diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_paths.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_paths.snap new file mode 100644 index 000000000..186606b38 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_paths.snap @@ -0,0 +1,11 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - default.project.json + - src/integer.model.json +added_dirs: + - src +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_paths_removing.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_paths_removing.snap new file mode 100644 index 000000000..c2a0b917e --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_paths_removing.snap @@ -0,0 +1,10 @@ +--- +source: tests/rojo_test/syncback_util.rs +assertion_line: 48 +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: [] +added_dirs: + - src +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_trees.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_trees.snap new file mode 100644 index 000000000..dcdb221c8 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_trees.snap @@ -0,0 +1,10 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - src/IncludeMe/.gitkeep +added_dirs: + - src/IncludeMe +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_trees_removing.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_trees_removing.snap new file mode 100644 index 000000000..9fa9aa7a2 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ignore_trees_removing.snap @@ -0,0 +1,9 @@ +--- +source: tests/rojo_test/syncback_util.rs +assertion_line: 45 +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: [] +added_dirs: [] +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__nested_projects.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__nested_projects.snap new file mode 100644 index 000000000..3531a587d --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__nested_projects.snap @@ -0,0 +1,12 @@ +--- +source: tests/rojo_test/syncback_util.rs +assertion_line: 48 +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - nested.project.json + - string_value.txt +added_dirs: [] +removed_files: [] +removed_dirs: [] + diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__nested_projects_weird.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__nested_projects_weird.snap new file mode 100644 index 000000000..f45539f41 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__nested_projects_weird.snap @@ -0,0 +1,10 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - src/modules/ClientModule.luau + - src/modules/ServerModule.luau +added_dirs: [] +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__project_all_middleware.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__project_all_middleware.snap new file mode 100644 index 000000000..4050efd19 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__project_all_middleware.snap @@ -0,0 +1,24 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - src/client_script.client.luau + - src/csv.csv + - src/csv_init/init.csv + - src/init_client_script/init.client.luau + - src/init_module_script/init.luau + - src/init_server_script/init.server.luau + - src/model_json.model.json + - src/module_script.luau + - src/project_json.project.json + - src/rbxmx.rbxmx + - src/server_script.server.luau + - src/text.txt +added_dirs: + - src/csv_init + - src/init_client_script + - src/init_module_script + - src/init_server_script +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__project_init.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__project_init.snap new file mode 100644 index 000000000..320b704fe --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__project_init.snap @@ -0,0 +1,10 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - src/init.luau +added_dirs: + - src +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__project_reserialize.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__project_reserialize.snap new file mode 100644 index 000000000..441e0b7b9 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__project_reserialize.snap @@ -0,0 +1,10 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - attribute_mismatch.luau + - property_mismatch.project.json +added_dirs: [] +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__rbxm_fallback.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__rbxm_fallback.snap new file mode 100644 index 000000000..4ac816b45 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__rbxm_fallback.snap @@ -0,0 +1,14 @@ +--- +source: tests/rojo_test/syncback_util.rs +assertion_line: 45 +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - ReplicatedStorage/ChildWithDuplicates.rbxm + - ReplicatedStorage/ChildWithoutDuplicates/Child/.gitkeep +added_dirs: + - ReplicatedStorage/ChildWithoutDuplicates + - ReplicatedStorage/ChildWithoutDuplicates/Child +removed_files: [] +removed_dirs: + - ReplicatedStorage/ChildWithDuplicates diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties.snap new file mode 100644 index 000000000..77d69e6e7 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties.snap @@ -0,0 +1,10 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - src/pointer.model.json + - src/target.model.json +added_dirs: [] +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties_blank.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties_blank.snap new file mode 100644 index 000000000..0df33662b --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties_blank.snap @@ -0,0 +1,15 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - default.project.json + - src/Camera.rbxm + - src/Terrain.rbxm + - src/pointer.model.json + - src/target.meta.json + - src/target.txt +added_dirs: + - src +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties_update.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties_update.snap new file mode 100644 index 000000000..77d69e6e7 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__ref_properties_update.snap @@ -0,0 +1,10 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - src/pointer.model.json + - src/target.model.json +added_dirs: [] +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__respect_old_middleware.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__respect_old_middleware.snap new file mode 100644 index 000000000..3ea2c20d9 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__respect_old_middleware.snap @@ -0,0 +1,12 @@ +--- +source: tests/rojo_test/syncback_util.rs +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - Workspace/Baseplate.model.json + - Workspace/Terrain.rbxm + - default.project.json +added_dirs: + - Workspace +removed_files: [] +removed_dirs: [] diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__string_value_project.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__string_value_project.snap new file mode 100644 index 000000000..fb423b5e7 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__string_value_project.snap @@ -0,0 +1,12 @@ +--- +source: tests/rojo_test/syncback_util.rs +assertion_line: 48 +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - default.project.json + - string_value.txt +added_dirs: [] +removed_files: [] +removed_dirs: [] + diff --git a/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__unscriptable_properties.snap b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__unscriptable_properties.snap new file mode 100644 index 000000000..4a16065a1 --- /dev/null +++ b/rojo-test/syncback-test-snapshots/end_to_end__rojo_test__syncback_util__unscriptable_properties.snap @@ -0,0 +1,11 @@ +--- +source: tests/rojo_test/syncback_util.rs +assertion_line: 48 +expression: "visualize_fs_snapshot(&fs_snapshot, &output_path)" +--- +added_files: + - default.project.json +added_dirs: [] +removed_files: [] +removed_dirs: [] + diff --git a/rojo-test/syncback-tests/all_middleware/expected/default.project.json b/rojo-test/syncback-tests/all_middleware/expected/default.project.json new file mode 100644 index 000000000..5a7eba6ce --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "all_middleware", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "src" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/csv.csv b/rojo-test/syncback-tests/all_middleware/expected/src/csv.csv new file mode 100644 index 000000000..b7c422222 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/csv.csv @@ -0,0 +1,2 @@ +Key,Source,Context,Example,es +Ack,Ack!,,An exclamation of despair,¡Ay! diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/csv_init/init.csv b/rojo-test/syncback-tests/all_middleware/expected/src/csv_init/init.csv new file mode 100644 index 000000000..61a49274e --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/csv_init/init.csv @@ -0,0 +1,2 @@ +Key,Source,Context,Example,en +Rojo,Rojo,,Rojo is a really cool program,Red diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/dir/client_script.client.luau b/rojo-test/syncback-tests/all_middleware/expected/src/dir/client_script.client.luau new file mode 100644 index 000000000..7ad910d69 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/dir/client_script.client.luau @@ -0,0 +1 @@ +-- plan difficulty emphasis dorm describe diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/dir/init_client_script/init.client.luau b/rojo-test/syncback-tests/all_middleware/expected/src/dir/init_client_script/init.client.luau new file mode 100644 index 000000000..7d476584b --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/dir/init_client_script/init.client.luau @@ -0,0 +1 @@ +-- city conflict lay soup body diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/dir/init_module_script/init.luau b/rojo-test/syncback-tests/all_middleware/expected/src/dir/init_module_script/init.luau new file mode 100644 index 000000000..9b908cba2 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/dir/init_module_script/init.luau @@ -0,0 +1 @@ +-- absence wheel hay miracle director diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/dir/init_server_script/init.server.luau b/rojo-test/syncback-tests/all_middleware/expected/src/dir/init_server_script/init.server.luau new file mode 100644 index 000000000..3eff9e2d2 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/dir/init_server_script/init.server.luau @@ -0,0 +1 @@ +-- systematic laser makeup soil loud diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/dir/module_script.luau b/rojo-test/syncback-tests/all_middleware/expected/src/dir/module_script.luau new file mode 100644 index 000000000..ff12feb1c --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/dir/module_script.luau @@ -0,0 +1 @@ +-- solve bitter pollution jacket seal diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/dir/server_script.server.luau b/rojo-test/syncback-tests/all_middleware/expected/src/dir/server_script.server.luau new file mode 100644 index 000000000..cca8b6f68 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/dir/server_script.server.luau @@ -0,0 +1 @@ +-- reliable warrant lost low empire diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/dir_with_meta/init.meta.json b/rojo-test/syncback-tests/all_middleware/expected/src/dir_with_meta/init.meta.json new file mode 100644 index 000000000..d62029576 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/dir_with_meta/init.meta.json @@ -0,0 +1,3 @@ +{ + "className": "Configuration" +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/model_json.model.json b/rojo-test/syncback-tests/all_middleware/expected/src/model_json.model.json new file mode 100644 index 000000000..a235fc2c3 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/model_json.model.json @@ -0,0 +1,6 @@ +{ + "className": "StringValue", + "properties": { + "Value": "culture glimpse stay chief onion" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/project_json.project.json b/rojo-test/syncback-tests/all_middleware/expected/src/project_json.project.json new file mode 100644 index 000000000..cb0ce6a2e --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/project_json.project.json @@ -0,0 +1,13 @@ +{ + "name": "project_json", + "tree": { + "$className": "Color3Value", + "$properties": { + "Value": [ + 1.0, + 0.5, + 0.0 + ] + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/rbxm.rbxm b/rojo-test/syncback-tests/all_middleware/expected/src/rbxm.rbxm new file mode 100644 index 000000000..e981fedcb Binary files /dev/null and b/rojo-test/syncback-tests/all_middleware/expected/src/rbxm.rbxm differ diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/rbxmx.rbxmx b/rojo-test/syncback-tests/all_middleware/expected/src/rbxmx.rbxmx new file mode 100644 index 000000000..8f2d62241 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/rbxmx.rbxmx @@ -0,0 +1,15 @@ + + + + rbxmx + + 0 + false + 00000000000000000000000000000000 + -1 + + fireplace addicted army cow stock + 56ddeac77c86b55f061dbf5000007cca + + + \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/expected/src/text.txt b/rojo-test/syncback-tests/all_middleware/expected/src/text.txt new file mode 100644 index 000000000..fbae9b2c2 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/expected/src/text.txt @@ -0,0 +1 @@ +represent slant majority dream proclaim \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/input.rbxl b/rojo-test/syncback-tests/all_middleware/input.rbxl new file mode 100644 index 000000000..afb4a076a Binary files /dev/null and b/rojo-test/syncback-tests/all_middleware/input.rbxl differ diff --git a/rojo-test/syncback-tests/all_middleware/output/default.project.json b/rojo-test/syncback-tests/all_middleware/output/default.project.json new file mode 100644 index 000000000..5a7eba6ce --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/output/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "all_middleware", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "src" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/output/src/csv_init/init.csv b/rojo-test/syncback-tests/all_middleware/output/src/csv_init/init.csv new file mode 100644 index 000000000..24244d6c1 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/output/src/csv_init/init.csv @@ -0,0 +1,2 @@ +Key,Source,Context,Example,en +,,,, diff --git a/rojo-test/syncback-tests/all_middleware/output/src/dir/init_client_script/init.client.lua b/rojo-test/syncback-tests/all_middleware/output/src/dir/init_client_script/init.client.lua new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/all_middleware/output/src/dir/init_module_script/init.lua b/rojo-test/syncback-tests/all_middleware/output/src/dir/init_module_script/init.lua new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/all_middleware/output/src/dir/init_server_script/init.server.lua b/rojo-test/syncback-tests/all_middleware/output/src/dir/init_server_script/init.server.lua new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/all_middleware/output/src/dir_with_meta/init.meta.json b/rojo-test/syncback-tests/all_middleware/output/src/dir_with_meta/init.meta.json new file mode 100644 index 000000000..d62029576 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/output/src/dir_with_meta/init.meta.json @@ -0,0 +1,3 @@ +{ + "className": "Configuration" +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/output/src/model_json.model.json b/rojo-test/syncback-tests/all_middleware/output/src/model_json.model.json new file mode 100644 index 000000000..a095016a7 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/output/src/model_json.model.json @@ -0,0 +1,3 @@ +{ + "className": "StringValue" +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/output/src/project_json.project.json b/rojo-test/syncback-tests/all_middleware/output/src/project_json.project.json new file mode 100644 index 000000000..ed8452050 --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/output/src/project_json.project.json @@ -0,0 +1,6 @@ +{ + "name": "project_json", + "tree": { + "$className": "Color3Value" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/output/src/rbxmx.rbxmx b/rojo-test/syncback-tests/all_middleware/output/src/rbxmx.rbxmx new file mode 100644 index 000000000..ca94c7e0c --- /dev/null +++ b/rojo-test/syncback-tests/all_middleware/output/src/rbxmx.rbxmx @@ -0,0 +1,10 @@ + + true + null + nil + + + rbxmx + + + \ No newline at end of file diff --git a/rojo-test/syncback-tests/all_middleware/output/src/text.txt b/rojo-test/syncback-tests/all_middleware/output/src/text.txt new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/baseplate/expected/Lighting/Atmosphere.model.json b/rojo-test/syncback-tests/baseplate/expected/Lighting/Atmosphere.model.json new file mode 100644 index 000000000..7f8221eca --- /dev/null +++ b/rojo-test/syncback-tests/baseplate/expected/Lighting/Atmosphere.model.json @@ -0,0 +1,17 @@ +{ + "className": "Atmosphere", + "properties": { + "Color": [ + 0.7803921699523926, + 0.7803921699523926, + 0.7803921699523926 + ], + "Decay": [ + 0.4156862795352936, + 0.43921568989753723, + 0.4901960790157318 + ], + "Density": 0.30000001192092896, + "Offset": 0.25 + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/baseplate/expected/Lighting/Bloom.model.json b/rojo-test/syncback-tests/baseplate/expected/Lighting/Bloom.model.json new file mode 100644 index 000000000..8bb51031e --- /dev/null +++ b/rojo-test/syncback-tests/baseplate/expected/Lighting/Bloom.model.json @@ -0,0 +1,7 @@ +{ + "className": "BloomEffect", + "properties": { + "Intensity": 1.0, + "Threshold": 2.0 + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/baseplate/expected/Lighting/DepthOfField.model.json b/rojo-test/syncback-tests/baseplate/expected/Lighting/DepthOfField.model.json new file mode 100644 index 000000000..10e8e0840 --- /dev/null +++ b/rojo-test/syncback-tests/baseplate/expected/Lighting/DepthOfField.model.json @@ -0,0 +1,8 @@ +{ + "className": "DepthOfFieldEffect", + "properties": { + "Enabled": false, + "FarIntensity": 0.10000000149011612, + "InFocusRadius": 30.0 + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/baseplate/expected/Lighting/Sky.model.json b/rojo-test/syncback-tests/baseplate/expected/Lighting/Sky.model.json new file mode 100644 index 000000000..b09cfd38d --- /dev/null +++ b/rojo-test/syncback-tests/baseplate/expected/Lighting/Sky.model.json @@ -0,0 +1,15 @@ +{ + "className": "Sky", + "properties": { + "MoonTextureId": "rbxassetid://6444320592", + "SkyboxBk": "rbxassetid://6444884337", + "SkyboxDn": "rbxassetid://6444884785", + "SkyboxFt": "rbxassetid://6444884337", + "SkyboxLf": "rbxassetid://6444884337", + "SkyboxRt": "rbxassetid://6444884337", + "SkyboxUp": "rbxassetid://6412503613", + "SourceAssetId": 332039975.0, + "SunAngularSize": 11.0, + "SunTextureId": "rbxassetid://6196665106" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/baseplate/expected/Lighting/SunRays.model.json b/rojo-test/syncback-tests/baseplate/expected/Lighting/SunRays.model.json new file mode 100644 index 000000000..38c7c8418 --- /dev/null +++ b/rojo-test/syncback-tests/baseplate/expected/Lighting/SunRays.model.json @@ -0,0 +1,7 @@ +{ + "className": "SunRaysEffect", + "properties": { + "Intensity": 0.009999999776482582, + "Spread": 0.10000000149011612 + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/baseplate/expected/Workspace/Baseplate.rbxm b/rojo-test/syncback-tests/baseplate/expected/Workspace/Baseplate.rbxm new file mode 100644 index 000000000..c2f123906 Binary files /dev/null and b/rojo-test/syncback-tests/baseplate/expected/Workspace/Baseplate.rbxm differ diff --git a/rojo-test/syncback-tests/baseplate/expected/Workspace/Camera.rbxm b/rojo-test/syncback-tests/baseplate/expected/Workspace/Camera.rbxm new file mode 100644 index 000000000..357bd9841 Binary files /dev/null and b/rojo-test/syncback-tests/baseplate/expected/Workspace/Camera.rbxm differ diff --git a/rojo-test/syncback-tests/baseplate/expected/Workspace/SpawnLocation.rbxm b/rojo-test/syncback-tests/baseplate/expected/Workspace/SpawnLocation.rbxm new file mode 100644 index 000000000..de52151d2 Binary files /dev/null and b/rojo-test/syncback-tests/baseplate/expected/Workspace/SpawnLocation.rbxm differ diff --git a/rojo-test/syncback-tests/baseplate/expected/Workspace/Terrain.rbxm b/rojo-test/syncback-tests/baseplate/expected/Workspace/Terrain.rbxm new file mode 100644 index 000000000..271861025 Binary files /dev/null and b/rojo-test/syncback-tests/baseplate/expected/Workspace/Terrain.rbxm differ diff --git a/rojo-test/syncback-tests/baseplate/expected/default.project.json b/rojo-test/syncback-tests/baseplate/expected/default.project.json new file mode 100644 index 000000000..a65e00581 --- /dev/null +++ b/rojo-test/syncback-tests/baseplate/expected/default.project.json @@ -0,0 +1,51 @@ +{ + "name": "Baseplate", + "tree": { + "$className": "DataModel", + "Lighting": { + "$properties": { + "Ambient": [ + 0.27450981736183167, + 0.27450981736183167, + 0.27450981736183167 + ], + "Brightness": 3.0, + "EnvironmentDiffuseScale": 1.0, + "EnvironmentSpecularScale": 1.0, + "FogColor": [ + 0.7529412508010864, + 0.7529412508010864, + 0.7529412508010864 + ], + "GeographicLatitude": 0.0, + "GlobalShadows": true, + "OutdoorAmbient": [ + 0.27450981736183167, + 0.27450981736183167, + 0.27450981736183167 + ], + "Outlines": false, + "ShadowSoftness": 0.20000000298023224, + "Technology": "ShadowMap", + "TimeOfDay": "14:30:00" + }, + "$path": "Lighting" + }, + "Workspace": { + "$properties": { + "EditorLiveScripting": { + "Enum": 0 + }, + "SignalBehavior": "Deferred", + "StreamOutBehavior": "Opportunistic", + "StreamingEnabled": true, + "StreamingIntegrityMode": "PauseOutsideLoadedArea" + }, + "$path": "Workspace" + } + }, + "syncbackRules": { + "syncCurrentCamera": true, + "ignoreReferents": true + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/baseplate/input.rbxl b/rojo-test/syncback-tests/baseplate/input.rbxl new file mode 100644 index 000000000..98f213686 Binary files /dev/null and b/rojo-test/syncback-tests/baseplate/input.rbxl differ diff --git a/rojo-test/syncback-tests/baseplate/output/Lighting/.gitkeep b/rojo-test/syncback-tests/baseplate/output/Lighting/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/baseplate/output/Workspace/.gitkeep b/rojo-test/syncback-tests/baseplate/output/Workspace/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/baseplate/output/default.project.json b/rojo-test/syncback-tests/baseplate/output/default.project.json new file mode 100644 index 000000000..880913a6e --- /dev/null +++ b/rojo-test/syncback-tests/baseplate/output/default.project.json @@ -0,0 +1,16 @@ +{ + "name": "Baseplate", + "tree": { + "$className": "DataModel", + "Lighting": { + "$path": "Lighting" + }, + "Workspace": { + "$path": "Workspace" + } + }, + "syncbackRules": { + "ignoreReferents": true, + "syncCurrentCamera": true + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/child_but_not/expected/OnlyOneCopy/child_of_one.luau b/rojo-test/syncback-tests/child_but_not/expected/OnlyOneCopy/child_of_one.luau new file mode 100644 index 000000000..d23f92e7f --- /dev/null +++ b/rojo-test/syncback-tests/child_but_not/expected/OnlyOneCopy/child_of_one.luau @@ -0,0 +1 @@ +-- displace personality beam leadership occupy diff --git a/rojo-test/syncback-tests/child_but_not/expected/ReplicatedStorage/child_replicated_storage.luau b/rojo-test/syncback-tests/child_but_not/expected/ReplicatedStorage/child_replicated_storage.luau new file mode 100644 index 000000000..dc3e15bf4 --- /dev/null +++ b/rojo-test/syncback-tests/child_but_not/expected/ReplicatedStorage/child_replicated_storage.luau @@ -0,0 +1 @@ +-- skate bottle vain nonsense tablet diff --git a/rojo-test/syncback-tests/child_but_not/expected/default.project.json b/rojo-test/syncback-tests/child_but_not/expected/default.project.json new file mode 100644 index 000000000..d23f524c2 --- /dev/null +++ b/rojo-test/syncback-tests/child_but_not/expected/default.project.json @@ -0,0 +1,12 @@ +{ + "name": "child_but_not", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "ReplicatedStorage", + "OnlyOneCopy": { + "$path": "OnlyOneCopy" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/child_but_not/input.rbxl b/rojo-test/syncback-tests/child_but_not/input.rbxl new file mode 100644 index 000000000..9723a9142 Binary files /dev/null and b/rojo-test/syncback-tests/child_but_not/input.rbxl differ diff --git a/rojo-test/syncback-tests/child_but_not/output/OnlyOneCopy/.gitkeep b/rojo-test/syncback-tests/child_but_not/output/OnlyOneCopy/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/child_but_not/output/ReplicatedStorage/.gitkeep b/rojo-test/syncback-tests/child_but_not/output/ReplicatedStorage/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/child_but_not/output/default.project.json b/rojo-test/syncback-tests/child_but_not/output/default.project.json new file mode 100644 index 000000000..d23f524c2 --- /dev/null +++ b/rojo-test/syncback-tests/child_but_not/output/default.project.json @@ -0,0 +1,12 @@ +{ + "name": "child_but_not", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "ReplicatedStorage", + "OnlyOneCopy": { + "$path": "OnlyOneCopy" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/duplicate_rojo_id/expected/container.model.json b/rojo-test/syncback-tests/duplicate_rojo_id/expected/container.model.json new file mode 100644 index 000000000..aeb7b2b35 --- /dev/null +++ b/rojo-test/syncback-tests/duplicate_rojo_id/expected/container.model.json @@ -0,0 +1,21 @@ +{ + "className": "Folder", + "children": [ + { + "name": "value_1", + "className": "ObjectValue", + "attributes": { + "Rojo_Id": "value_1", + "Rojo_Target_Value": "value_1" + } + }, + { + "name": "value_2", + "className": "ObjectValue", + "attributes": { + "Rojo_Id": "72bc28150ada2e6206442ee300004084", + "Rojo_Target_Value": "72bc28150ada2e6206442ee300004084" + } + } + ] +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/duplicate_rojo_id/expected/default.project.json b/rojo-test/syncback-tests/duplicate_rojo_id/expected/default.project.json new file mode 100644 index 000000000..3343677aa --- /dev/null +++ b/rojo-test/syncback-tests/duplicate_rojo_id/expected/default.project.json @@ -0,0 +1,11 @@ +{ + "name": "duplicate_rojo_id", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "container": { + "$path": "container.model.json" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/duplicate_rojo_id/input.rbxl b/rojo-test/syncback-tests/duplicate_rojo_id/input.rbxl new file mode 100644 index 000000000..8e3e83691 Binary files /dev/null and b/rojo-test/syncback-tests/duplicate_rojo_id/input.rbxl differ diff --git a/rojo-test/syncback-tests/duplicate_rojo_id/output/container.model.json b/rojo-test/syncback-tests/duplicate_rojo_id/output/container.model.json new file mode 100644 index 000000000..9c551865b --- /dev/null +++ b/rojo-test/syncback-tests/duplicate_rojo_id/output/container.model.json @@ -0,0 +1,13 @@ +{ + "className": "Folder", + "children": [ + { + "name": "value_1", + "className": "ObjectValue" + }, + { + "name": "value_2", + "className": "ObjectValue" + } + ] +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/duplicate_rojo_id/output/default.project.json b/rojo-test/syncback-tests/duplicate_rojo_id/output/default.project.json new file mode 100644 index 000000000..3343677aa --- /dev/null +++ b/rojo-test/syncback-tests/duplicate_rojo_id/output/default.project.json @@ -0,0 +1,11 @@ +{ + "name": "duplicate_rojo_id", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "container": { + "$path": "container.model.json" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ignore_paths/expected/default.project.json b/rojo-test/syncback-tests/ignore_paths/expected/default.project.json new file mode 100644 index 000000000..950610ace --- /dev/null +++ b/rojo-test/syncback-tests/ignore_paths/expected/default.project.json @@ -0,0 +1,24 @@ +{ + "name": "ignore_paths", + "tree": { + "$className": "DataModel", + "Workspace": { + "$properties": { + "EditorLiveScripting": { + "Enum": 0 + }, + "SignalBehavior": "Deferred", + "StreamOutBehavior": "Opportunistic", + "StreamingEnabled": true, + "StreamingIntegrityMode": "PauseOutsideLoadedArea" + }, + "$path": "src" + } + }, + "syncbackRules": { + "ignorePaths": [ + "src/*.rbxm", + "src/folder_name/**" + ] + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ignore_paths/expected/src/integer.model.json b/rojo-test/syncback-tests/ignore_paths/expected/src/integer.model.json new file mode 100644 index 000000000..2e88fdf43 --- /dev/null +++ b/rojo-test/syncback-tests/ignore_paths/expected/src/integer.model.json @@ -0,0 +1,6 @@ +{ + "className": "IntValue", + "properties": { + "Value": 1337.0 + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ignore_paths/input.rbxl b/rojo-test/syncback-tests/ignore_paths/input.rbxl new file mode 100644 index 000000000..028cbe826 Binary files /dev/null and b/rojo-test/syncback-tests/ignore_paths/input.rbxl differ diff --git a/rojo-test/syncback-tests/ignore_paths/output/default.project.json b/rojo-test/syncback-tests/ignore_paths/output/default.project.json new file mode 100644 index 000000000..0fb6c0782 --- /dev/null +++ b/rojo-test/syncback-tests/ignore_paths/output/default.project.json @@ -0,0 +1,15 @@ +{ + "name": "ignore_paths", + "tree": { + "$className": "DataModel", + "Workspace": { + "$path": "src" + } + }, + "syncbackRules": { + "ignorePaths": [ + "src/*.rbxm", + "src/folder_name/**" + ] + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ignore_paths/output/src/.gitkeep b/rojo-test/syncback-tests/ignore_paths/output/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/ignore_paths_removing/expected/default.project.json b/rojo-test/syncback-tests/ignore_paths_removing/expected/default.project.json new file mode 100644 index 000000000..deefcff22 --- /dev/null +++ b/rojo-test/syncback-tests/ignore_paths_removing/expected/default.project.json @@ -0,0 +1,33 @@ +{ + "name": "ignore_paths", + "tree": { + "$className": "DataModel", + "Workspace": { + "$properties": { + "CSGAsyncDynamicCollision": { + "Enum": 0 + }, + "DecreaseMinimumPartDensityMode": { + "Enum": 0 + }, + "MoverConstraintRootBehavior": { + "Enum": 0 + }, + "RenderingCacheOptimizations": { + "Enum": 0 + }, + "SignalBehavior": "Deferred", + "StreamOutBehavior": "Opportunistic", + "StreamingEnabled": true, + "StreamingIntegrityMode": "PauseOutsideLoadedArea" + }, + "$path": "src" + } + }, + "syncbackRules": { + "ignorePaths": [ + "*.rbxm", + "*.luau" + ] + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ignore_paths_removing/expected/src/not_in_place.luau b/rojo-test/syncback-tests/ignore_paths_removing/expected/src/not_in_place.luau new file mode 100644 index 000000000..1ba9bedc9 --- /dev/null +++ b/rojo-test/syncback-tests/ignore_paths_removing/expected/src/not_in_place.luau @@ -0,0 +1 @@ +-- This script is not in the input place file. diff --git a/rojo-test/syncback-tests/ignore_paths_removing/input.rbxl b/rojo-test/syncback-tests/ignore_paths_removing/input.rbxl new file mode 100644 index 000000000..78be118bc Binary files /dev/null and b/rojo-test/syncback-tests/ignore_paths_removing/input.rbxl differ diff --git a/rojo-test/syncback-tests/ignore_paths_removing/output/default.project.json b/rojo-test/syncback-tests/ignore_paths_removing/output/default.project.json new file mode 100644 index 000000000..deefcff22 --- /dev/null +++ b/rojo-test/syncback-tests/ignore_paths_removing/output/default.project.json @@ -0,0 +1,33 @@ +{ + "name": "ignore_paths", + "tree": { + "$className": "DataModel", + "Workspace": { + "$properties": { + "CSGAsyncDynamicCollision": { + "Enum": 0 + }, + "DecreaseMinimumPartDensityMode": { + "Enum": 0 + }, + "MoverConstraintRootBehavior": { + "Enum": 0 + }, + "RenderingCacheOptimizations": { + "Enum": 0 + }, + "SignalBehavior": "Deferred", + "StreamOutBehavior": "Opportunistic", + "StreamingEnabled": true, + "StreamingIntegrityMode": "PauseOutsideLoadedArea" + }, + "$path": "src" + } + }, + "syncbackRules": { + "ignorePaths": [ + "*.rbxm", + "*.luau" + ] + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ignore_paths_removing/output/src/not_in_place.luau b/rojo-test/syncback-tests/ignore_paths_removing/output/src/not_in_place.luau new file mode 100644 index 000000000..1ba9bedc9 --- /dev/null +++ b/rojo-test/syncback-tests/ignore_paths_removing/output/src/not_in_place.luau @@ -0,0 +1 @@ +-- This script is not in the input place file. diff --git a/rojo-test/syncback-tests/ignore_trees/expected/default.project.json b/rojo-test/syncback-tests/ignore_trees/expected/default.project.json new file mode 100644 index 000000000..c659671eb --- /dev/null +++ b/rojo-test/syncback-tests/ignore_trees/expected/default.project.json @@ -0,0 +1,14 @@ +{ + "name": "ignore_trees", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "src" + } + }, + "syncbackRules": { + "ignoreTrees": [ + "ReplicatedStorage/IgnoreMe" + ] + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ignore_trees/expected/src/IncludeMe/.gitkeep b/rojo-test/syncback-tests/ignore_trees/expected/src/IncludeMe/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/ignore_trees/input.rbxl b/rojo-test/syncback-tests/ignore_trees/input.rbxl new file mode 100644 index 000000000..afab040ad Binary files /dev/null and b/rojo-test/syncback-tests/ignore_trees/input.rbxl differ diff --git a/rojo-test/syncback-tests/ignore_trees/output/default.project.json b/rojo-test/syncback-tests/ignore_trees/output/default.project.json new file mode 100644 index 000000000..c659671eb --- /dev/null +++ b/rojo-test/syncback-tests/ignore_trees/output/default.project.json @@ -0,0 +1,14 @@ +{ + "name": "ignore_trees", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "src" + } + }, + "syncbackRules": { + "ignoreTrees": [ + "ReplicatedStorage/IgnoreMe" + ] + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ignore_trees/output/src/.gitkeep b/rojo-test/syncback-tests/ignore_trees/output/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/ignore_trees_removing/expected/default.project.json b/rojo-test/syncback-tests/ignore_trees_removing/expected/default.project.json new file mode 100644 index 000000000..ec17e8f7d --- /dev/null +++ b/rojo-test/syncback-tests/ignore_trees_removing/expected/default.project.json @@ -0,0 +1,14 @@ +{ + "name": "ignore_trees_removing", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "src" + } + }, + "syncbackRules": { + "ignoreTrees": [ + "ReplicatedStorage/KeepMe" + ] + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ignore_trees_removing/expected/src/KeepMe/.gitkeep b/rojo-test/syncback-tests/ignore_trees_removing/expected/src/KeepMe/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/ignore_trees_removing/input.rbxl b/rojo-test/syncback-tests/ignore_trees_removing/input.rbxl new file mode 100644 index 000000000..422bbf350 Binary files /dev/null and b/rojo-test/syncback-tests/ignore_trees_removing/input.rbxl differ diff --git a/rojo-test/syncback-tests/ignore_trees_removing/output/default.project.json b/rojo-test/syncback-tests/ignore_trees_removing/output/default.project.json new file mode 100644 index 000000000..ec17e8f7d --- /dev/null +++ b/rojo-test/syncback-tests/ignore_trees_removing/output/default.project.json @@ -0,0 +1,14 @@ +{ + "name": "ignore_trees_removing", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "src" + } + }, + "syncbackRules": { + "ignoreTrees": [ + "ReplicatedStorage/KeepMe" + ] + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ignore_trees_removing/output/src/KeepMe/.gitkeep b/rojo-test/syncback-tests/ignore_trees_removing/output/src/KeepMe/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/nested_projects/expected/default.project.json b/rojo-test/syncback-tests/nested_projects/expected/default.project.json new file mode 100644 index 000000000..edb89e3ca --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects/expected/default.project.json @@ -0,0 +1,11 @@ +{ + "name": "nested_projects", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "Nested": { + "$path": "nested.project.json" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects/expected/nested.project.json b/rojo-test/syncback-tests/nested_projects/expected/nested.project.json new file mode 100644 index 000000000..c71f3be96 --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects/expected/nested.project.json @@ -0,0 +1,15 @@ +{ + "name": "Nested", + "tree": { + "$className": "Configuration", + "BoolValue": { + "$className": "BoolValue", + "$properties": { + "Value": true + } + }, + "StringValue": { + "$path": "string_value.txt" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects/expected/string_value.txt b/rojo-test/syncback-tests/nested_projects/expected/string_value.txt new file mode 100644 index 000000000..cb6a6af21 --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects/expected/string_value.txt @@ -0,0 +1 @@ +effective cover predict pawn south \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects/input.rbxl b/rojo-test/syncback-tests/nested_projects/input.rbxl new file mode 100644 index 000000000..0e06df505 Binary files /dev/null and b/rojo-test/syncback-tests/nested_projects/input.rbxl differ diff --git a/rojo-test/syncback-tests/nested_projects/output/default.project.json b/rojo-test/syncback-tests/nested_projects/output/default.project.json new file mode 100644 index 000000000..edb89e3ca --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects/output/default.project.json @@ -0,0 +1,11 @@ +{ + "name": "nested_projects", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "Nested": { + "$path": "nested.project.json" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects/output/nested.project.json b/rojo-test/syncback-tests/nested_projects/output/nested.project.json new file mode 100644 index 000000000..8cc8ad1e7 --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects/output/nested.project.json @@ -0,0 +1,12 @@ +{ + "name": "Nested", + "tree": { + "$className": "Configuration", + "StringValue": { + "$path": "string_value.txt" + }, + "BoolValue": { + "$className": "BoolValue" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects/output/string_value.txt b/rojo-test/syncback-tests/nested_projects/output/string_value.txt new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/nested_projects_weird/expected/client-only.project.json b/rojo-test/syncback-tests/nested_projects_weird/expected/client-only.project.json new file mode 100644 index 000000000..28468e74b --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects_weird/expected/client-only.project.json @@ -0,0 +1,9 @@ +{ + "name": "client_only", + "tree": { + "$path": "src/modules" + }, + "globIgnorePaths": [ + "**Server**" + ] +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects_weird/expected/default.project.json b/rojo-test/syncback-tests/nested_projects_weird/expected/default.project.json new file mode 100644 index 000000000..9fb46e6f4 --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects_weird/expected/default.project.json @@ -0,0 +1,16 @@ +{ + "name": "nested_projects_but_weird", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "modules": { + "$path": "client-only.project.json" + } + }, + "ServerStorage": { + "modules": { + "$path": "server-only.project.json" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects_weird/expected/server-only.project.json b/rojo-test/syncback-tests/nested_projects_weird/expected/server-only.project.json new file mode 100644 index 000000000..44532ba5b --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects_weird/expected/server-only.project.json @@ -0,0 +1,9 @@ +{ + "name": "server_only", + "tree": { + "$path": "src/modules" + }, + "globIgnorePaths": [ + "**Client**" + ] +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects_weird/expected/src/modules/ClientModule.luau b/rojo-test/syncback-tests/nested_projects_weird/expected/src/modules/ClientModule.luau new file mode 100644 index 000000000..1e4560076 --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects_weird/expected/src/modules/ClientModule.luau @@ -0,0 +1 @@ +-- brake replace prevent interactive helpless diff --git a/rojo-test/syncback-tests/nested_projects_weird/expected/src/modules/ServerModule.luau b/rojo-test/syncback-tests/nested_projects_weird/expected/src/modules/ServerModule.luau new file mode 100644 index 000000000..3b0cc280c --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects_weird/expected/src/modules/ServerModule.luau @@ -0,0 +1 @@ +-- contempt preference dinner monarch combine diff --git a/rojo-test/syncback-tests/nested_projects_weird/input.rbxl b/rojo-test/syncback-tests/nested_projects_weird/input.rbxl new file mode 100644 index 000000000..b0e5bbcd4 Binary files /dev/null and b/rojo-test/syncback-tests/nested_projects_weird/input.rbxl differ diff --git a/rojo-test/syncback-tests/nested_projects_weird/output/client-only.project.json b/rojo-test/syncback-tests/nested_projects_weird/output/client-only.project.json new file mode 100644 index 000000000..28468e74b --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects_weird/output/client-only.project.json @@ -0,0 +1,9 @@ +{ + "name": "client_only", + "tree": { + "$path": "src/modules" + }, + "globIgnorePaths": [ + "**Server**" + ] +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects_weird/output/default.project.json b/rojo-test/syncback-tests/nested_projects_weird/output/default.project.json new file mode 100644 index 000000000..9fb46e6f4 --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects_weird/output/default.project.json @@ -0,0 +1,16 @@ +{ + "name": "nested_projects_but_weird", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "modules": { + "$path": "client-only.project.json" + } + }, + "ServerStorage": { + "modules": { + "$path": "server-only.project.json" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects_weird/output/server-only.project.json b/rojo-test/syncback-tests/nested_projects_weird/output/server-only.project.json new file mode 100644 index 000000000..44532ba5b --- /dev/null +++ b/rojo-test/syncback-tests/nested_projects_weird/output/server-only.project.json @@ -0,0 +1,9 @@ +{ + "name": "server_only", + "tree": { + "$path": "src/modules" + }, + "globIgnorePaths": [ + "**Client**" + ] +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/nested_projects_weird/output/src/modules/.gitkeep b/rojo-test/syncback-tests/nested_projects_weird/output/src/modules/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/default.project.json b/rojo-test/syncback-tests/project_all_middleware/expected/default.project.json new file mode 100644 index 000000000..2fd8876cd --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/default.project.json @@ -0,0 +1,53 @@ +{ + "name": "project_all_middleware", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "client_script": { + "$path": "src/client_script.client.luau" + }, + "csv": { + "$path": "src/csv.csv" + }, + "csv_init": { + "$path": "src/csv_init" + }, + "dir": { + "$path": "src/dir" + }, + "dir_with_meta": { + "$path": "src/dir_with_meta" + }, + "init_client_script": { + "$path": "src/init_client_script" + }, + "init_module_script": { + "$path": "src/init_module_script" + }, + "init_server_script": { + "$path": "src/init_server_script" + }, + "model_json": { + "$path": "src/model_json.model.json" + }, + "module_script": { + "$path": "src/module_script.luau" + }, + "project_json": { + "$path": "src/project_json.project.json" + }, + "rbxm": { + "$path": "src/rbxm.rbxm" + }, + "rbxmx": { + "$path": "src/rbxmx.rbxmx" + }, + "server_script": { + "$path": "src/server_script.server.luau" + }, + "text": { + "$path": "src/text.txt" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/client_script.client.luau b/rojo-test/syncback-tests/project_all_middleware/expected/src/client_script.client.luau new file mode 100644 index 000000000..1a6501120 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/client_script.client.luau @@ -0,0 +1 @@ +-- ghostwriter notorious mutter restless punish \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/csv.csv b/rojo-test/syncback-tests/project_all_middleware/expected/src/csv.csv new file mode 100644 index 000000000..b7c422222 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/csv.csv @@ -0,0 +1,2 @@ +Key,Source,Context,Example,es +Ack,Ack!,,An exclamation of despair,¡Ay! diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/csv_init/init.csv b/rojo-test/syncback-tests/project_all_middleware/expected/src/csv_init/init.csv new file mode 100644 index 000000000..61a49274e --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/csv_init/init.csv @@ -0,0 +1,2 @@ +Key,Source,Context,Example,en +Rojo,Rojo,,Rojo is a really cool program,Red diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/dir/.gitkeep b/rojo-test/syncback-tests/project_all_middleware/expected/src/dir/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/dir_with_meta/init.meta.json b/rojo-test/syncback-tests/project_all_middleware/expected/src/dir_with_meta/init.meta.json new file mode 100644 index 000000000..d62029576 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/dir_with_meta/init.meta.json @@ -0,0 +1,3 @@ +{ + "className": "Configuration" +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/init_client_script/init.client.luau b/rojo-test/syncback-tests/project_all_middleware/expected/src/init_client_script/init.client.luau new file mode 100644 index 000000000..2330a0023 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/init_client_script/init.client.luau @@ -0,0 +1 @@ +-- brag season coffin dilute flourish \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/init_module_script/init.luau b/rojo-test/syncback-tests/project_all_middleware/expected/src/init_module_script/init.luau new file mode 100644 index 000000000..e36c2d878 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/init_module_script/init.luau @@ -0,0 +1 @@ +-- absorb dragon coat crowd effect \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/init_server_script/init.server.luau b/rojo-test/syncback-tests/project_all_middleware/expected/src/init_server_script/init.server.luau new file mode 100644 index 000000000..8518c74fe --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/init_server_script/init.server.luau @@ -0,0 +1 @@ +-- rojo syncback very cool dekkonot \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/model_json.model.json b/rojo-test/syncback-tests/project_all_middleware/expected/src/model_json.model.json new file mode 100644 index 000000000..a75990c38 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/model_json.model.json @@ -0,0 +1,6 @@ +{ + "className": "StringValue", + "properties": { + "Value": "i understand how person299 felt" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/module_script.luau b/rojo-test/syncback-tests/project_all_middleware/expected/src/module_script.luau new file mode 100644 index 000000000..b5c15e2b1 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/module_script.luau @@ -0,0 +1 @@ +-- hospitality publish accumulation onion shaft \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/project_json.project.json b/rojo-test/syncback-tests/project_all_middleware/expected/src/project_json.project.json new file mode 100644 index 000000000..cb0ce6a2e --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/project_json.project.json @@ -0,0 +1,13 @@ +{ + "name": "project_json", + "tree": { + "$className": "Color3Value", + "$properties": { + "Value": [ + 1.0, + 0.5, + 0.0 + ] + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/rbxm.rbxm b/rojo-test/syncback-tests/project_all_middleware/expected/src/rbxm.rbxm new file mode 100644 index 000000000..1fc6714ae Binary files /dev/null and b/rojo-test/syncback-tests/project_all_middleware/expected/src/rbxm.rbxm differ diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/rbxmx.rbxmx b/rojo-test/syncback-tests/project_all_middleware/expected/src/rbxmx.rbxmx new file mode 100644 index 000000000..c17a0d974 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/rbxmx.rbxmx @@ -0,0 +1,15 @@ + + + + rbxmx + + 0 + false + 00000000000000000000000000000000 + -1 + + ripe alike review heart dry + 288c54239308069206387a3a00004126 + + + \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/server_script.server.luau b/rojo-test/syncback-tests/project_all_middleware/expected/src/server_script.server.luau new file mode 100644 index 000000000..ba51694c9 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/server_script.server.luau @@ -0,0 +1 @@ +-- ostracize fraud consciousness seal architecture \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/expected/src/text.txt b/rojo-test/syncback-tests/project_all_middleware/expected/src/text.txt new file mode 100644 index 000000000..a4cb228b7 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/expected/src/text.txt @@ -0,0 +1 @@ +According to all known laws of aviation \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/input.rbxl b/rojo-test/syncback-tests/project_all_middleware/input.rbxl new file mode 100644 index 000000000..824960bac Binary files /dev/null and b/rojo-test/syncback-tests/project_all_middleware/input.rbxl differ diff --git a/rojo-test/syncback-tests/project_all_middleware/output/default.project.json b/rojo-test/syncback-tests/project_all_middleware/output/default.project.json new file mode 100644 index 000000000..2fd8876cd --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/output/default.project.json @@ -0,0 +1,53 @@ +{ + "name": "project_all_middleware", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "client_script": { + "$path": "src/client_script.client.luau" + }, + "csv": { + "$path": "src/csv.csv" + }, + "csv_init": { + "$path": "src/csv_init" + }, + "dir": { + "$path": "src/dir" + }, + "dir_with_meta": { + "$path": "src/dir_with_meta" + }, + "init_client_script": { + "$path": "src/init_client_script" + }, + "init_module_script": { + "$path": "src/init_module_script" + }, + "init_server_script": { + "$path": "src/init_server_script" + }, + "model_json": { + "$path": "src/model_json.model.json" + }, + "module_script": { + "$path": "src/module_script.luau" + }, + "project_json": { + "$path": "src/project_json.project.json" + }, + "rbxm": { + "$path": "src/rbxm.rbxm" + }, + "rbxmx": { + "$path": "src/rbxmx.rbxmx" + }, + "server_script": { + "$path": "src/server_script.server.luau" + }, + "text": { + "$path": "src/text.txt" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/client_script.client.luau b/rojo-test/syncback-tests/project_all_middleware/output/src/client_script.client.luau new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/csv.csv b/rojo-test/syncback-tests/project_all_middleware/output/src/csv.csv new file mode 100644 index 000000000..c5f48af47 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/output/src/csv.csv @@ -0,0 +1,2 @@ +Key,Source,Context,Example,es +,,,, \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/csv_init/init.csv b/rojo-test/syncback-tests/project_all_middleware/output/src/csv_init/init.csv new file mode 100644 index 000000000..24244d6c1 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/output/src/csv_init/init.csv @@ -0,0 +1,2 @@ +Key,Source,Context,Example,en +,,,, diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/dir/.gitkeep b/rojo-test/syncback-tests/project_all_middleware/output/src/dir/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/dir_with_meta/init.meta.json b/rojo-test/syncback-tests/project_all_middleware/output/src/dir_with_meta/init.meta.json new file mode 100644 index 000000000..d62029576 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/output/src/dir_with_meta/init.meta.json @@ -0,0 +1,3 @@ +{ + "className": "Configuration" +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/init_client_script/init.client.lua b/rojo-test/syncback-tests/project_all_middleware/output/src/init_client_script/init.client.lua new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/init_module_script/init.lua b/rojo-test/syncback-tests/project_all_middleware/output/src/init_module_script/init.lua new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/init_server_script/init.server.lua b/rojo-test/syncback-tests/project_all_middleware/output/src/init_server_script/init.server.lua new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/model_json.model.json b/rojo-test/syncback-tests/project_all_middleware/output/src/model_json.model.json new file mode 100644 index 000000000..a095016a7 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/output/src/model_json.model.json @@ -0,0 +1,3 @@ +{ + "className": "StringValue" +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/module_script.luau b/rojo-test/syncback-tests/project_all_middleware/output/src/module_script.luau new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/project_json.project.json b/rojo-test/syncback-tests/project_all_middleware/output/src/project_json.project.json new file mode 100644 index 000000000..ed8452050 --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/output/src/project_json.project.json @@ -0,0 +1,6 @@ +{ + "name": "project_json", + "tree": { + "$className": "Color3Value" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/rbxm.rbxm b/rojo-test/syncback-tests/project_all_middleware/output/src/rbxm.rbxm new file mode 100644 index 000000000..1fc6714ae Binary files /dev/null and b/rojo-test/syncback-tests/project_all_middleware/output/src/rbxm.rbxm differ diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/rbxmx.rbxmx b/rojo-test/syncback-tests/project_all_middleware/output/src/rbxmx.rbxmx new file mode 100644 index 000000000..81a52114b --- /dev/null +++ b/rojo-test/syncback-tests/project_all_middleware/output/src/rbxmx.rbxmx @@ -0,0 +1,7 @@ + + + + rbxmx + + + \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/server_script.server.luau b/rojo-test/syncback-tests/project_all_middleware/output/src/server_script.server.luau new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_all_middleware/output/src/text.txt b/rojo-test/syncback-tests/project_all_middleware/output/src/text.txt new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_init/expected/default.project.json b/rojo-test/syncback-tests/project_init/expected/default.project.json new file mode 100644 index 000000000..34530a847 --- /dev/null +++ b/rojo-test/syncback-tests/project_init/expected/default.project.json @@ -0,0 +1,11 @@ +{ + "name": "project_init", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "script": { + "$path": "src" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_init/expected/src/init.luau b/rojo-test/syncback-tests/project_init/expected/src/init.luau new file mode 100644 index 000000000..918a977f2 --- /dev/null +++ b/rojo-test/syncback-tests/project_init/expected/src/init.luau @@ -0,0 +1 @@ +-- crash voyage confine pump direction diff --git a/rojo-test/syncback-tests/project_init/input.rbxl b/rojo-test/syncback-tests/project_init/input.rbxl new file mode 100644 index 000000000..6ff71a7a0 Binary files /dev/null and b/rojo-test/syncback-tests/project_init/input.rbxl differ diff --git a/rojo-test/syncback-tests/project_init/output/default.project.json b/rojo-test/syncback-tests/project_init/output/default.project.json new file mode 100644 index 000000000..34530a847 --- /dev/null +++ b/rojo-test/syncback-tests/project_init/output/default.project.json @@ -0,0 +1,11 @@ +{ + "name": "project_init", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "script": { + "$path": "src" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_init/output/src/init.lua b/rojo-test/syncback-tests/project_init/output/src/init.lua new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_reserialize/expected/attribute_mismatch.luau b/rojo-test/syncback-tests/project_reserialize/expected/attribute_mismatch.luau new file mode 100644 index 000000000..b6c5da36f --- /dev/null +++ b/rojo-test/syncback-tests/project_reserialize/expected/attribute_mismatch.luau @@ -0,0 +1 @@ +-- satellite beef psychology response supply \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_reserialize/expected/default.project.json b/rojo-test/syncback-tests/project_reserialize/expected/default.project.json new file mode 100644 index 000000000..60605da23 --- /dev/null +++ b/rojo-test/syncback-tests/project_reserialize/expected/default.project.json @@ -0,0 +1,29 @@ +{ + "name": "project_reserialize", + "tree": { + "$className": "DataModel", + "Workspace": { + "attribute_mismatch": { + "$attributes": { + "foo": "bar" + }, + "$path": "attribute_mismatch.luau" + }, + "property_mismatch": { + "$path": "property_mismatch.project.json" + }, + "$properties": { + "EditorLiveScripting": { + "Enum": 0 + }, + "SignalBehavior": "Deferred", + "StreamOutBehavior": "Opportunistic", + "StreamingEnabled": true, + "StreamingIntegrityMode": "PauseOutsideLoadedArea" + }, + "$attributes": { + "Rojo_Target_CurrentCamera": "6d6ae1d713c82fae0620aa1300000375" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_reserialize/expected/property_mismatch.project.json b/rojo-test/syncback-tests/project_reserialize/expected/property_mismatch.project.json new file mode 100644 index 000000000..1ca8b303e --- /dev/null +++ b/rojo-test/syncback-tests/project_reserialize/expected/property_mismatch.project.json @@ -0,0 +1,11 @@ +{ + "name": "property_mismatch", + "tree": { + "$className": "BrickColorValue", + "$properties": { + "Value": { + "BrickColor": 345 + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_reserialize/input.rbxl b/rojo-test/syncback-tests/project_reserialize/input.rbxl new file mode 100644 index 000000000..6d5da6818 Binary files /dev/null and b/rojo-test/syncback-tests/project_reserialize/input.rbxl differ diff --git a/rojo-test/syncback-tests/project_reserialize/output/attribute_mismatch.luau b/rojo-test/syncback-tests/project_reserialize/output/attribute_mismatch.luau new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/project_reserialize/output/default.project.json b/rojo-test/syncback-tests/project_reserialize/output/default.project.json new file mode 100644 index 000000000..60605da23 --- /dev/null +++ b/rojo-test/syncback-tests/project_reserialize/output/default.project.json @@ -0,0 +1,29 @@ +{ + "name": "project_reserialize", + "tree": { + "$className": "DataModel", + "Workspace": { + "attribute_mismatch": { + "$attributes": { + "foo": "bar" + }, + "$path": "attribute_mismatch.luau" + }, + "property_mismatch": { + "$path": "property_mismatch.project.json" + }, + "$properties": { + "EditorLiveScripting": { + "Enum": 0 + }, + "SignalBehavior": "Deferred", + "StreamOutBehavior": "Opportunistic", + "StreamingEnabled": true, + "StreamingIntegrityMode": "PauseOutsideLoadedArea" + }, + "$attributes": { + "Rojo_Target_CurrentCamera": "6d6ae1d713c82fae0620aa1300000375" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/project_reserialize/output/property_mismatch.project.json b/rojo-test/syncback-tests/project_reserialize/output/property_mismatch.project.json new file mode 100644 index 000000000..cb34ee96d --- /dev/null +++ b/rojo-test/syncback-tests/project_reserialize/output/property_mismatch.project.json @@ -0,0 +1,6 @@ +{ + "name": "property_mismatch", + "tree": { + "$className": "BrickColorValue" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/rbxm_fallback/expected/ReplicatedStorage/ChildWithDuplicates.rbxm b/rojo-test/syncback-tests/rbxm_fallback/expected/ReplicatedStorage/ChildWithDuplicates.rbxm new file mode 100644 index 000000000..401981c62 Binary files /dev/null and b/rojo-test/syncback-tests/rbxm_fallback/expected/ReplicatedStorage/ChildWithDuplicates.rbxm differ diff --git a/rojo-test/syncback-tests/rbxm_fallback/expected/ReplicatedStorage/ChildWithoutDuplicates/Child/.gitkeep b/rojo-test/syncback-tests/rbxm_fallback/expected/ReplicatedStorage/ChildWithoutDuplicates/Child/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/rbxm_fallback/expected/default.project.json b/rojo-test/syncback-tests/rbxm_fallback/expected/default.project.json new file mode 100644 index 000000000..d47484219 --- /dev/null +++ b/rojo-test/syncback-tests/rbxm_fallback/expected/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "rbxm_fallback", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "ReplicatedStorage" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/rbxm_fallback/input.rbxl b/rojo-test/syncback-tests/rbxm_fallback/input.rbxl new file mode 100644 index 000000000..28e92a188 Binary files /dev/null and b/rojo-test/syncback-tests/rbxm_fallback/input.rbxl differ diff --git a/rojo-test/syncback-tests/rbxm_fallback/output/ReplicatedStorage/ChildWithDuplicates/.gitkeep b/rojo-test/syncback-tests/rbxm_fallback/output/ReplicatedStorage/ChildWithDuplicates/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/rbxm_fallback/output/default.project.json b/rojo-test/syncback-tests/rbxm_fallback/output/default.project.json new file mode 100644 index 000000000..d47484219 --- /dev/null +++ b/rojo-test/syncback-tests/rbxm_fallback/output/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "rbxm_fallback", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "ReplicatedStorage" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties/expected/default.project.json b/rojo-test/syncback-tests/ref_properties/expected/default.project.json new file mode 100644 index 000000000..c2dd62c30 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties/expected/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "ref_properties", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "src" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties/expected/src/pointer.model.json b/rojo-test/syncback-tests/ref_properties/expected/src/pointer.model.json new file mode 100644 index 000000000..56efa71c2 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties/expected/src/pointer.model.json @@ -0,0 +1,6 @@ +{ + "className": "ObjectValue", + "attributes": { + "Rojo_Target_Value": "test referent id" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties/expected/src/target.model.json b/rojo-test/syncback-tests/ref_properties/expected/src/target.model.json new file mode 100644 index 000000000..d76fa1791 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties/expected/src/target.model.json @@ -0,0 +1,6 @@ +{ + "className": "Folder", + "attributes": { + "Rojo_Id": "test referent id" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties/input.rbxl b/rojo-test/syncback-tests/ref_properties/input.rbxl new file mode 100644 index 000000000..6599191dc Binary files /dev/null and b/rojo-test/syncback-tests/ref_properties/input.rbxl differ diff --git a/rojo-test/syncback-tests/ref_properties/output/default.project.json b/rojo-test/syncback-tests/ref_properties/output/default.project.json new file mode 100644 index 000000000..c2dd62c30 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties/output/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "ref_properties", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "src" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties/output/src/pointer.model.json b/rojo-test/syncback-tests/ref_properties/output/src/pointer.model.json new file mode 100644 index 000000000..54160c22c --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties/output/src/pointer.model.json @@ -0,0 +1,3 @@ +{ + "className": "ObjectValue" +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties/output/src/target.model.json b/rojo-test/syncback-tests/ref_properties/output/src/target.model.json new file mode 100644 index 000000000..f450e4126 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties/output/src/target.model.json @@ -0,0 +1,3 @@ +{ + "className": "Folder" +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_blank/expected/default.project.json b/rojo-test/syncback-tests/ref_properties_blank/expected/default.project.json new file mode 100644 index 000000000..9b140b5df --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_blank/expected/default.project.json @@ -0,0 +1,21 @@ +{ + "name": "ref_properties_blank", + "tree": { + "$className": "DataModel", + "Workspace": { + "$properties": { + "EditorLiveScripting": { + "Enum": 0 + }, + "SignalBehavior": "Deferred", + "StreamOutBehavior": "Opportunistic", + "StreamingEnabled": true, + "StreamingIntegrityMode": "PauseOutsideLoadedArea" + }, + "$attributes": { + "Rojo_Target_CurrentCamera": "62e89c49e4f800c20629c71b00003fcb" + }, + "$path": "src" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_blank/expected/src/Camera.rbxm b/rojo-test/syncback-tests/ref_properties_blank/expected/src/Camera.rbxm new file mode 100644 index 000000000..88e15e017 Binary files /dev/null and b/rojo-test/syncback-tests/ref_properties_blank/expected/src/Camera.rbxm differ diff --git a/rojo-test/syncback-tests/ref_properties_blank/expected/src/Terrain.rbxm b/rojo-test/syncback-tests/ref_properties_blank/expected/src/Terrain.rbxm new file mode 100644 index 000000000..61393150f Binary files /dev/null and b/rojo-test/syncback-tests/ref_properties_blank/expected/src/Terrain.rbxm differ diff --git a/rojo-test/syncback-tests/ref_properties_blank/expected/src/pointer.model.json b/rojo-test/syncback-tests/ref_properties_blank/expected/src/pointer.model.json new file mode 100644 index 000000000..7e5a0daf4 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_blank/expected/src/pointer.model.json @@ -0,0 +1,6 @@ +{ + "className": "ObjectValue", + "attributes": { + "Rojo_Target_Value": "62e89c49e4f800c20629c71b00003fc0" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_blank/expected/src/target.meta.json b/rojo-test/syncback-tests/ref_properties_blank/expected/src/target.meta.json new file mode 100644 index 000000000..4c315c149 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_blank/expected/src/target.meta.json @@ -0,0 +1,5 @@ +{ + "attributes": { + "Rojo_Id": "62e89c49e4f800c20629c71b00003fc0" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_blank/expected/src/target.txt b/rojo-test/syncback-tests/ref_properties_blank/expected/src/target.txt new file mode 100644 index 000000000..fbe2becee --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_blank/expected/src/target.txt @@ -0,0 +1 @@ +monkey academy decade powder warn \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_blank/input.rbxl b/rojo-test/syncback-tests/ref_properties_blank/input.rbxl new file mode 100644 index 000000000..8cfa1a080 Binary files /dev/null and b/rojo-test/syncback-tests/ref_properties_blank/input.rbxl differ diff --git a/rojo-test/syncback-tests/ref_properties_blank/output/default.project.json b/rojo-test/syncback-tests/ref_properties_blank/output/default.project.json new file mode 100644 index 000000000..1fea24fa2 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_blank/output/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "ref_properties_blank", + "tree": { + "$className": "DataModel", + "Workspace": { + "$path": "src" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_blank/output/src/.gitkeep b/rojo-test/syncback-tests/ref_properties_blank/output/src/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/ref_properties_update/expected/default.project.json b/rojo-test/syncback-tests/ref_properties_update/expected/default.project.json new file mode 100644 index 000000000..e06128c37 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_update/expected/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "ref_properties_update", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "src" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_update/expected/src/pointer.model.json b/rojo-test/syncback-tests/ref_properties_update/expected/src/pointer.model.json new file mode 100644 index 000000000..f892b48e3 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_update/expected/src/pointer.model.json @@ -0,0 +1,6 @@ +{ + "className": "ObjectValue", + "attributes": { + "Rojo_Target_Value": "27fbd83abe5d90db0629ccfd00003f79" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_update/expected/src/target.model.json b/rojo-test/syncback-tests/ref_properties_update/expected/src/target.model.json new file mode 100644 index 000000000..a8277ed4e --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_update/expected/src/target.model.json @@ -0,0 +1,9 @@ +{ + "className": "BoolValue", + "properties": { + "Value": true + }, + "attributes": { + "Rojo_Id": "27fbd83abe5d90db0629ccfd00003f79" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_update/input.rbxl b/rojo-test/syncback-tests/ref_properties_update/input.rbxl new file mode 100644 index 000000000..570317ee8 Binary files /dev/null and b/rojo-test/syncback-tests/ref_properties_update/input.rbxl differ diff --git a/rojo-test/syncback-tests/ref_properties_update/output/default.project.json b/rojo-test/syncback-tests/ref_properties_update/output/default.project.json new file mode 100644 index 000000000..e06128c37 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_update/output/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "ref_properties_update", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$path": "src" + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_update/output/src/pointer.model.json b/rojo-test/syncback-tests/ref_properties_update/output/src/pointer.model.json new file mode 100644 index 000000000..54160c22c --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_update/output/src/pointer.model.json @@ -0,0 +1,3 @@ +{ + "className": "ObjectValue" +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/ref_properties_update/output/src/target.model.json b/rojo-test/syncback-tests/ref_properties_update/output/src/target.model.json new file mode 100644 index 000000000..c8a885a33 --- /dev/null +++ b/rojo-test/syncback-tests/ref_properties_update/output/src/target.model.json @@ -0,0 +1,6 @@ +{ + "className": "BoolValue", + "properties": { + "Value": true + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/respect_old_middleware/expected/Workspace/Baseplate.model.json b/rojo-test/syncback-tests/respect_old_middleware/expected/Workspace/Baseplate.model.json new file mode 100644 index 000000000..3c407b8f2 --- /dev/null +++ b/rojo-test/syncback-tests/respect_old_middleware/expected/Workspace/Baseplate.model.json @@ -0,0 +1,54 @@ +{ + "className": "Part", + "children": [ + { + "name": "Texture", + "className": "Texture", + "properties": { + "Color3": [ + 0.0, + 0.0, + 0.0 + ], + "Face": "Top", + "StudsPerTileU": 8.0, + "StudsPerTileV": 8.0, + "Texture": "rbxassetid://6372755229", + "Transparency": 0.800000011920929 + } + } + ], + "properties": { + "Anchored": true, + "BottomSurface": "Smooth", + "CFrame": [ + 0.0, + -8.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0 + ], + "Color": { + "Color3uint8": [ + 91, + 91, + 91 + ] + }, + "FormFactor": "Symmetric", + "Locked": true, + "Size": [ + 2048.0, + 16.0, + 2048.0 + ], + "TopSurface": "Smooth" + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/respect_old_middleware/expected/Workspace/Terrain.rbxm b/rojo-test/syncback-tests/respect_old_middleware/expected/Workspace/Terrain.rbxm new file mode 100644 index 000000000..271861025 Binary files /dev/null and b/rojo-test/syncback-tests/respect_old_middleware/expected/Workspace/Terrain.rbxm differ diff --git a/rojo-test/syncback-tests/respect_old_middleware/expected/default.project.json b/rojo-test/syncback-tests/respect_old_middleware/expected/default.project.json new file mode 100644 index 000000000..41a7477d1 --- /dev/null +++ b/rojo-test/syncback-tests/respect_old_middleware/expected/default.project.json @@ -0,0 +1,21 @@ +{ + "name": "respect_old_middleware", + "tree": { + "$className": "DataModel", + "Workspace": { + "$properties": { + "EditorLiveScripting": { + "Enum": 0 + }, + "SignalBehavior": "Deferred", + "StreamOutBehavior": "Opportunistic", + "StreamingEnabled": true, + "StreamingIntegrityMode": "PauseOutsideLoadedArea" + }, + "$path": "Workspace" + } + }, + "syncbackRules": { + "ignoreReferents": true + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/respect_old_middleware/input.rbxl b/rojo-test/syncback-tests/respect_old_middleware/input.rbxl new file mode 100644 index 000000000..4b27b3692 Binary files /dev/null and b/rojo-test/syncback-tests/respect_old_middleware/input.rbxl differ diff --git a/rojo-test/syncback-tests/respect_old_middleware/output/Workspace/Baseplate.model.json b/rojo-test/syncback-tests/respect_old_middleware/output/Workspace/Baseplate.model.json new file mode 100644 index 000000000..9cc519cdb --- /dev/null +++ b/rojo-test/syncback-tests/respect_old_middleware/output/Workspace/Baseplate.model.json @@ -0,0 +1,9 @@ +{ + "className": "Part", + "children": [ + { + "className": "Texture", + "name": "Texture" + } + ] +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/respect_old_middleware/output/default.project.json b/rojo-test/syncback-tests/respect_old_middleware/output/default.project.json new file mode 100644 index 000000000..39de1647d --- /dev/null +++ b/rojo-test/syncback-tests/respect_old_middleware/output/default.project.json @@ -0,0 +1,12 @@ +{ + "name": "respect_old_middleware", + "tree": { + "$className": "DataModel", + "Workspace": { + "$path": "Workspace" + } + }, + "syncbackRules": { + "ignoreReferents": true + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/string_value_project/expected/default.project.json b/rojo-test/syncback-tests/string_value_project/expected/default.project.json new file mode 100644 index 000000000..7e717416c --- /dev/null +++ b/rojo-test/syncback-tests/string_value_project/expected/default.project.json @@ -0,0 +1,20 @@ +{ + "name": "string_value_project", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "inside_project_file": { + "$className": "StringValue", + "$properties": { + "Value": "imgettingverytiredofwritingthesetests2" + } + }, + "on_file_system": { + "$attributes": { + "imgettingverytiredofwritingthesetests": "person299 was ahead of his time" + }, + "$path": "string_value.txt" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/string_value_project/expected/string_value.txt b/rojo-test/syncback-tests/string_value_project/expected/string_value.txt new file mode 100644 index 000000000..a29b38294 --- /dev/null +++ b/rojo-test/syncback-tests/string_value_project/expected/string_value.txt @@ -0,0 +1 @@ +shout out to the brown bug anthology in person299's admin commands \ No newline at end of file diff --git a/rojo-test/syncback-tests/string_value_project/input.rbxl b/rojo-test/syncback-tests/string_value_project/input.rbxl new file mode 100644 index 000000000..cea6a4853 Binary files /dev/null and b/rojo-test/syncback-tests/string_value_project/input.rbxl differ diff --git a/rojo-test/syncback-tests/string_value_project/output/default.project.json b/rojo-test/syncback-tests/string_value_project/output/default.project.json new file mode 100644 index 000000000..00fa7c2d2 --- /dev/null +++ b/rojo-test/syncback-tests/string_value_project/output/default.project.json @@ -0,0 +1,14 @@ +{ + "name": "string_value_project", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "on_file_system": { + "$path": "string_value.txt" + }, + "inside_project_file": { + "$className": "StringValue" + } + } + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/string_value_project/output/string_value.txt b/rojo-test/syncback-tests/string_value_project/output/string_value.txt new file mode 100644 index 000000000..e69de29bb diff --git a/rojo-test/syncback-tests/unscriptable_properties/expected/default.project.json b/rojo-test/syncback-tests/unscriptable_properties/expected/default.project.json new file mode 100644 index 000000000..e0992a012 --- /dev/null +++ b/rojo-test/syncback-tests/unscriptable_properties/expected/default.project.json @@ -0,0 +1,32 @@ +{ + "name": "unscriptable_properties", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "Model": { + "$className": "Model", + "$properties": { + "Scale": 1337.0, + "WorldPivotData": { + "OptionalCFrame": null + } + } + } + }, + "Workspace": { + "$properties": { + "EditorLiveScripting": { + "Enum": 0 + }, + "SignalBehavior": "Deferred", + "StreamOutBehavior": "Opportunistic", + "StreamingEnabled": true, + "StreamingIntegrityMode": "PauseOutsideLoadedArea" + } + } + }, + "syncbackRules": { + "syncUnscriptable": true, + "ignoreReferents": true + } +} \ No newline at end of file diff --git a/rojo-test/syncback-tests/unscriptable_properties/input.rbxl b/rojo-test/syncback-tests/unscriptable_properties/input.rbxl new file mode 100644 index 000000000..cbb16d4e7 Binary files /dev/null and b/rojo-test/syncback-tests/unscriptable_properties/input.rbxl differ diff --git a/rojo-test/syncback-tests/unscriptable_properties/output/default.project.json b/rojo-test/syncback-tests/unscriptable_properties/output/default.project.json new file mode 100644 index 000000000..37bba6534 --- /dev/null +++ b/rojo-test/syncback-tests/unscriptable_properties/output/default.project.json @@ -0,0 +1,16 @@ +{ + "name": "unscriptable_properties", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "Model": { + "$className": "Model" + } + }, + "Workspace": {} + }, + "syncbackRules": { + "syncUnscriptable": true, + "ignoreReferents": true + } +} \ No newline at end of file diff --git a/src/change_processor.rs b/src/change_processor.rs index 497ba4e79..fc429190c 100644 --- a/src/change_processor.rs +++ b/src/change_processor.rs @@ -183,7 +183,7 @@ impl JobThreadContext { if let Some(instigating_source) = &instance.metadata().instigating_source { match instigating_source { InstigatingSource::Path(path) => fs::remove_file(path).unwrap(), - InstigatingSource::ProjectNode(_, _, _, _) => { + InstigatingSource::ProjectNode { .. } => { log::warn!( "Cannot remove instance {:?}, it's from a project file", id @@ -231,7 +231,7 @@ impl JobThreadContext { log::warn!("Cannot change Source to non-string value."); } } - InstigatingSource::ProjectNode(_, _, _, _) => { + InstigatingSource::ProjectNode { .. } => { log::warn!( "Cannot remove instance {:?}, it's from a project file", id @@ -317,16 +317,21 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option< } }, - InstigatingSource::ProjectNode(project_path, instance_name, project_node, parent_class) => { + InstigatingSource::ProjectNode { + path, + name, + node, + parent_class, + } => { // This instance is the direct subject of a project node. Since // there might be information associated with our instance from // the project file, we snapshot the entire project node again. let snapshot_result = snapshot_project_node( &metadata.context, - project_path, - instance_name, - project_node, + path, + name, + node, vfs, parent_class.as_ref().map(|name| name.as_str()), ); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 065128fb6..7d3d87b18 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -7,6 +7,7 @@ mod init; mod plugin; mod serve; mod sourcemap; +mod syncback; mod upload; use std::{borrow::Cow, env, path::Path, str::FromStr}; @@ -21,6 +22,7 @@ pub use self::init::{InitCommand, InitKind}; pub use self::plugin::{PluginCommand, PluginSubcommand}; pub use self::serve::ServeCommand; pub use self::sourcemap::SourcemapCommand; +pub use self::syncback::SyncbackCommand; pub use self::upload::UploadCommand; /// Command line options that Rojo accepts, defined using the clap crate. @@ -46,6 +48,7 @@ impl Options { Subcommand::FmtProject(subcommand) => subcommand.run(), Subcommand::Doc(subcommand) => subcommand.run(), Subcommand::Plugin(subcommand) => subcommand.run(), + Subcommand::Syncback(subcommand) => subcommand.run(self.global), } } } @@ -119,6 +122,7 @@ pub enum Subcommand { FmtProject(FmtProjectCommand), Doc(DocCommand), Plugin(PluginCommand), + Syncback(SyncbackCommand), } pub(super) fn resolve_path(path: &Path) -> Cow<'_, Path> { diff --git a/src/cli/syncback.rs b/src/cli/syncback.rs new file mode 100644 index 000000000..9a3c1a4ac --- /dev/null +++ b/src/cli/syncback.rs @@ -0,0 +1,291 @@ +use std::{ + io::{self, BufReader, Write as _}, + mem::forget, + path::{Path, PathBuf}, + time::Instant, +}; + +use anyhow::Context; +use clap::Parser; +use fs_err::File; +use memofs::Vfs; +use rbx_dom_weak::{InstanceBuilder, WeakDom}; +use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor}; + +use crate::{ + serve_session::ServeSession, + syncback::{syncback_loop, FsSnapshot}, +}; + +use super::{resolve_path, GlobalOptions}; + +const UNKNOWN_INPUT_KIND_ERR: &str = "Could not detect what kind of file was inputted. \ + Expected input file to end in .rbxl, .rbxlx, .rbxm, or .rbxmx."; + +/// Performs 'syncback' for the provided project, using the `input` file +/// given. +/// +/// Syncback exists to convert Roblox files into a Rojo project automatically. +/// It uses the project.json file provided to traverse the Roblox file passed as +/// to serialize Instances to the file system in a format that Rojo understands. +#[derive(Debug, Parser)] +pub struct SyncbackCommand { + /// Path to the project to sync back to. + #[clap(default_value = "")] + pub project: PathBuf, + + /// Path to the Roblox file to pull Instances from. + #[clap(long, short)] + pub input: PathBuf, + + /// If provided, a list all of the files and directories that will be + /// added or removed is emitted. + #[clap(long, short)] + pub list: bool, + + /// If provided, syncback will not actually write anything to the file + /// system. The command will otherwise run normally. + #[clap(long)] + pub dry_run: bool, + + /// If provided, the prompt for writing to the file system is skipped. + #[clap(long, short = 'y')] + pub non_interactive: bool, +} + +impl SyncbackCommand { + pub fn run(&self, global: GlobalOptions) -> anyhow::Result<()> { + let path_old = resolve_path(&self.project); + let path_new = resolve_path(&self.input); + + let input_kind = FileKind::from_path(&path_new).context(UNKNOWN_INPUT_KIND_ERR)?; + let dom_start_timer = Instant::now(); + let dom_new = read_dom(&path_new, input_kind)?; + log::debug!( + "Finished opening file in {:0.02}s", + dom_start_timer.elapsed().as_secs_f32() + ); + + let vfs = Vfs::new_default(); + vfs.set_watch_enabled(false); + + let project_start_timer = Instant::now(); + let session_old = ServeSession::new(vfs, path_old.clone())?; + log::debug!( + "Finished opening project in {:0.02}s", + project_start_timer.elapsed().as_secs_f32() + ); + + let mut dom_old = session_old.tree(); + + log::debug!("Old root: {}", dom_old.inner().root().class); + log::debug!("New root: {}", dom_new.root().class); + + if log::log_enabled!(log::Level::Trace) { + log::trace!("Children of old root:"); + for child in dom_old.inner().root().children() { + let inst = dom_old.get_instance(*child).unwrap(); + log::trace!("{} (class: {})", inst.name(), inst.class_name()); + } + log::trace!("Children of new root:"); + for child in dom_new.root().children() { + let inst = dom_new.get_by_ref(*child).unwrap(); + log::trace!("{} (class: {})", inst.name, inst.class); + } + } + + let syncback_timer = Instant::now(); + println!("Beginning syncback..."); + let snapshot = syncback_loop( + session_old.vfs(), + &mut dom_old, + dom_new, + session_old.root_project(), + )?; + log::debug!( + "Syncback finished in {:.02}s!", + syncback_timer.elapsed().as_secs_f32() + ); + + let base_path = session_old.root_project().folder_location(); + if self.list { + list_files(&snapshot, global.color.into(), base_path)?; + } + + if !self.dry_run { + if !self.non_interactive { + println!( + "Would write {} files/folders and remove {} files/folders.", + snapshot.added_paths().len(), + snapshot.removed_paths().len() + ); + print!("Is this okay? (Y/N): "); + io::stdout().flush()?; + let mut line = String::with_capacity(1); + io::stdin().read_line(&mut line)?; + line = line.trim().to_lowercase(); + if line != "y" { + println!("Aborting due to user input!"); + return Ok(()); + } + } + println!("Writing to the file system..."); + snapshot.write_to_vfs(base_path, session_old.vfs())?; + println!("Finished syncback.") + } else { + println!( + "Would write {} files/folders and remove {} files/folders.", + snapshot.added_paths().len(), + snapshot.removed_paths().len() + ); + println!("Aborting before writing to file system due to `--dry-run`"); + } + + // It is potentially prohibitively expensive to drop a ServeSession, + // and the program is about to exit anyway so we're just going to forget + // about it. + drop(dom_old); + forget(session_old); + + Ok(()) + } +} + +fn read_dom(path: &Path, file_kind: FileKind) -> anyhow::Result { + let content = BufReader::new(File::open(path)?); + match file_kind { + FileKind::Rbxl => rbx_binary::from_reader(content).with_context(|| { + format!( + "Could not deserialize binary place file at {}", + path.display() + ) + }), + FileKind::Rbxlx => rbx_xml::from_reader(content, xml_decode_config()) + .with_context(|| format!("Could not deserialize XML place file at {}", path.display())), + FileKind::Rbxm => { + let temp_tree = rbx_binary::from_reader(content).with_context(|| { + format!( + "Could not deserialize binary place file at {}", + path.display() + ) + })?; + + process_model_dom(temp_tree) + } + FileKind::Rbxmx => { + let temp_tree = + rbx_xml::from_reader(content, xml_decode_config()).with_context(|| { + format!("Could not deserialize XML model file at {}", path.display()) + })?; + process_model_dom(temp_tree) + } + } +} + +fn process_model_dom(dom: WeakDom) -> anyhow::Result { + let temp_children = dom.root().children(); + if temp_children.len() == 1 { + let real_root = dom.get_by_ref(temp_children[0]).unwrap(); + let mut new_tree = WeakDom::new(InstanceBuilder::new(&real_root.class)); + for (name, property) in &real_root.properties { + new_tree + .root_mut() + .properties + .insert(name.to_owned(), property.to_owned()); + } + + let children = dom.clone_multiple_into_external(real_root.children(), &mut new_tree); + for child in children { + new_tree.transfer_within(child, new_tree.root_ref()); + } + Ok(new_tree) + } else { + anyhow::bail!( + "Rojo does not currently support models with more \ + than one Instance at the Root!" + ); + } +} + +fn xml_decode_config() -> rbx_xml::DecodeOptions<'static> { + rbx_xml::DecodeOptions::new().property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown) +} + +/// The different kinds of input that Rojo can syncback. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileKind { + /// An XML model file. + Rbxmx, + + /// An XML place file. + Rbxlx, + + /// A binary model file. + Rbxm, + + /// A binary place file. + Rbxl, +} + +impl FileKind { + fn from_path(output: &Path) -> Option { + let extension = output.extension()?.to_str()?; + + match extension { + "rbxlx" => Some(FileKind::Rbxlx), + "rbxmx" => Some(FileKind::Rbxmx), + "rbxl" => Some(FileKind::Rbxl), + "rbxm" => Some(FileKind::Rbxm), + _ => None, + } + } +} + +fn list_files(snapshot: &FsSnapshot, color: ColorChoice, base_path: &Path) -> io::Result<()> { + let no_color = ColorSpec::new(); + let mut add_color = ColorSpec::new(); + add_color.set_fg(Some(Color::Green)); + let mut remove_color = ColorSpec::new(); + remove_color.set_fg(Some(Color::Red)); + + // We emit this to stderr because otherwise it'd be impossible + // to pipe it separately from normal output. + let writer = BufferWriter::stderr(color); + let mut buffer = writer.buffer(); + + if snapshot.is_empty() { + writeln!( + &mut buffer, + "No files/directories would be removed or added." + )?; + } else { + let added = snapshot.added_paths(); + if !added.is_empty() { + writeln!(&mut buffer, "Writing files/directories:")?; + buffer.set_color(&add_color)?; + for path in added { + writeln!( + &mut buffer, + "{}", + path.strip_prefix(base_path).unwrap_or(path).display() + )?; + } + buffer.set_color(&no_color)?; + } + let removed = snapshot.removed_paths(); + if !removed.is_empty() { + writeln!(&mut buffer, "Removing files/directories:")?; + buffer.set_color(&remove_color)?; + for path in removed { + writeln!( + &mut buffer, + "{}", + path.strip_prefix(base_path).unwrap_or(path).display() + )?; + } + } + buffer.set_color(&no_color)?; + } + + writer.print(&buffer) +} diff --git a/src/lib.rs b/src/lib.rs index 190209509..2a4db7439 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,9 +21,19 @@ mod serve_session; mod session_id; mod snapshot; mod snapshot_middleware; +mod syncback; +mod variant_eq; mod web; +// TODO: Work out what we should expose publicly + pub use project::*; pub use rojo_ref::*; pub use session_id::SessionId; +pub use snapshot::{ + InstanceContext, InstanceMetadata, InstanceSnapshot, InstanceWithMeta, InstanceWithMetaMut, + RojoDescendants, RojoTree, +}; +pub use snapshot_middleware::{snapshot_from_vfs, Middleware, ScriptType}; +pub use syncback::{syncback_loop, FsSnapshot, SyncbackData, SyncbackSnapshot}; pub use web::interface as web_api; diff --git a/src/project.rs b/src/project.rs index 43920a9a3..ce408b466 100644 --- a/src/project.rs +++ b/src/project.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, HashSet}, ffi::OsStr, fs, io, net::IpAddr, @@ -10,10 +10,7 @@ use memofs::Vfs; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::{ - glob::Glob, resolution::UnresolvedValue, snapshot::SyncRule, - snapshot_middleware::default_sync_rules, -}; +use crate::{glob::Glob, resolution::UnresolvedValue, snapshot::SyncRule, syncback::SyncbackRules}; static PROJECT_FILENAME: &str = "default.project.json"; @@ -97,6 +94,10 @@ pub struct Project { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub glob_ignore_paths: Vec, + /// A list of rules for syncback with this project file. + #[serde(skip_serializing_if = "Option::is_none")] + pub syncback_rules: Option, + /// A list of mappings of globs to syncing rules. If a file matches a glob, /// it will be 'transformed' into an Instance following the rule provided. /// Globs are relative to the folder the project file is in. @@ -273,12 +274,21 @@ pub enum PathNode { } impl PathNode { + /// Returns the path of the `PathNode`, without regard for if it's optional + // or not. + #[inline] pub fn path(&self) -> &Path { match self { PathNode::Required(pathbuf) => pathbuf, PathNode::Optional(OptionalPathNode { optional }) => optional, } } + + /// Returns whether this `PathNode` is optional or not. + #[inline] + pub fn is_optional(&self) -> bool { + matches!(self, PathNode::Optional(_)) + } } /// Describes an instance and its descendants in a project. @@ -308,16 +318,16 @@ pub struct ProjectNode { #[serde( rename = "$properties", default, - skip_serializing_if = "HashMap::is_empty" + skip_serializing_if = "BTreeMap::is_empty" )] - pub properties: HashMap, + pub properties: BTreeMap, #[serde( rename = "$attributes", default, - skip_serializing_if = "HashMap::is_empty" + skip_serializing_if = "BTreeMap::is_empty" )] - pub attributes: HashMap, + pub attributes: BTreeMap, /// Defines the behavior when Rojo encounters unknown instances in Roblox /// Studio during live sync. `$ignoreUnknownInstances` should be considered diff --git a/src/resolution.rs b/src/resolution.rs index 7cba31c4e..73b6575f9 100644 --- a/src/resolution.rs +++ b/src/resolution.rs @@ -37,6 +37,93 @@ impl UnresolvedValue { UnresolvedValue::Ambiguous(partial) => partial.resolve_unambiguous(), } } + + /// Creates an `UnresolvedValue` from a variant, using a class and property + /// name to potentially allow for ambiguous Enum variants. + pub fn from_variant(variant: Variant, class_name: &str, prop_name: &str) -> Self { + let descriptor = find_descriptor(class_name, prop_name); + if descriptor.is_some() { + // We can only use an ambiguous syntax if the property is known + // to the reflection database. + Self::Ambiguous(match variant { + Variant::Enum(rbx_enum) => { + if let Some(property) = descriptor { + if let DataType::Enum(enum_name) = &property.data_type { + let database = rbx_reflection_database::get(); + if let Some(enum_descriptor) = database.enums.get(enum_name) { + for (variant_name, id) in &enum_descriptor.items { + if *id == rbx_enum.to_u32() { + return Self::Ambiguous(AmbiguousValue::String( + variant_name.to_string(), + )); + } + } + } + } + } + return Self::FullyQualified(variant); + } + Variant::Bool(bool) => AmbiguousValue::Bool(bool), + Variant::Float32(n) => AmbiguousValue::Number(n as f64), + Variant::Float64(n) => AmbiguousValue::Number(n), + Variant::Int32(n) => AmbiguousValue::Number(n as f64), + Variant::Int64(n) => AmbiguousValue::Number(n as f64), + Variant::String(str) => AmbiguousValue::String(str), + Variant::Tags(tags) => { + AmbiguousValue::StringArray(tags.iter().map(|s| s.to_string()).collect()) + } + Variant::Content(content) => AmbiguousValue::String(content.into_string()), + Variant::Vector2(vector) => { + AmbiguousValue::Array2([vector.x as f64, vector.y as f64]) + } + Variant::Vector3(vector) => { + AmbiguousValue::Array3([vector.x as f64, vector.y as f64, vector.z as f64]) + } + Variant::Color3(color) => { + AmbiguousValue::Array3([color.r as f64, color.g as f64, color.b as f64]) + } + Variant::CFrame(cf) => AmbiguousValue::Array12([ + cf.position.x as f64, + cf.position.y as f64, + cf.position.z as f64, + cf.orientation.x.x as f64, + cf.orientation.x.y as f64, + cf.orientation.x.z as f64, + cf.orientation.y.x as f64, + cf.orientation.y.y as f64, + cf.orientation.y.z as f64, + cf.orientation.z.x as f64, + cf.orientation.z.y as f64, + cf.orientation.z.z as f64, + ]), + Variant::Attributes(attr) => AmbiguousValue::Attributes(attr), + Variant::Font(font) => AmbiguousValue::Font(font), + Variant::MaterialColors(colors) => AmbiguousValue::MaterialColors(colors), + _ => { + return Self::FullyQualified(variant); + } + }) + } else { + Self::FullyQualified(variant) + } + } + + /// Creates an `UnresolvedValue` from a variant, only returning ambiguous + /// values if they're able to be resolved in a context-free environment. + pub fn from_variant_unambiguous(variant: Variant) -> Self { + match variant { + Variant::String(str) => Self::Ambiguous(AmbiguousValue::String(str)), + Variant::Float64(number) => Self::Ambiguous(AmbiguousValue::Number(number)), + Variant::Bool(bool) => Self::Ambiguous(AmbiguousValue::Bool(bool)), + Variant::BinaryString(bstr) => match std::str::from_utf8(bstr.as_ref()) { + Ok(_) => Self::Ambiguous(AmbiguousValue::String( + String::from_utf8(bstr.into_vec()).unwrap(), + )), + Err(_) => Self::FullyQualified(Variant::BinaryString(bstr)), + }, + _ => Self::FullyQualified(variant), + } + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/serve_session.rs b/src/serve_session.rs index ddb4dc21b..4932932dd 100644 --- a/src/serve_session.rs +++ b/src/serve_session.rs @@ -222,6 +222,10 @@ impl ServeSession { pub fn root_dir(&self) -> &Path { self.root_project.folder_location() } + + pub fn root_project(&self) -> &Project { + &self.root_project + } } #[derive(Debug, Error)] diff --git a/src/snapshot/metadata.rs b/src/snapshot/metadata.rs index abe66df24..71a731efe 100644 --- a/src/snapshot/metadata.rs +++ b/src/snapshot/metadata.rs @@ -62,6 +62,10 @@ pub struct InstanceMetadata { /// Indicates the ID used for Ref properties pointing to this Instance. pub specified_id: Option, + + /// The Middleware that was used to create this Instance. Should generally + /// not be `None` except if the snapshotting process is not completed. + pub middleware: Option, } impl InstanceMetadata { @@ -72,6 +76,7 @@ impl InstanceMetadata { relevant_paths: Vec::new(), context: InstanceContext::default(), specified_id: None, + middleware: None, } } @@ -109,6 +114,13 @@ impl InstanceMetadata { ..self } } + + pub fn middleware(self, middleware: Middleware) -> Self { + Self { + middleware: Some(middleware), + ..self + } + } } impl Default for InstanceMetadata { @@ -215,22 +227,40 @@ impl PathIgnoreRule { } } +/// Represents where a particular Instance or InstanceSnapshot came from. #[derive(Clone, PartialEq, Serialize, Deserialize)] pub enum InstigatingSource { + /// The path the Instance was made from. Path(#[serde(serialize_with = "path_serializer::serialize_absolute")] PathBuf), - ProjectNode( - #[serde(serialize_with = "path_serializer::serialize_absolute")] PathBuf, - String, - ProjectNode, - Option, - ), + /// The node in a Project that the Instance was made from. + ProjectNode { + #[serde(serialize_with = "path_serializer::serialize_absolute")] + path: PathBuf, + name: String, + node: ProjectNode, + parent_class: Option, + }, +} + +impl InstigatingSource { + pub fn path(&self) -> &Path { + match self { + Self::Path(path) => path.as_path(), + Self::ProjectNode { path, .. } => path.as_path(), + } + } } impl fmt::Debug for InstigatingSource { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { InstigatingSource::Path(path) => write!(formatter, "Path({})", path.display()), - InstigatingSource::ProjectNode(path, name, node, parent_class) => write!( + InstigatingSource::ProjectNode { + name, + node, + path, + parent_class, + } => write!( formatter, "ProjectNode({}: {:?}) from path {} and parent class {:?}", name, diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__add_property.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__add_property.snap index 2f3852b55..c4390b6da 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__add_property.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__add_property.snap @@ -14,5 +14,6 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ children: [] diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_after_patch.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_after_patch.snap index 31bdfecc9..fa4d3ebc2 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_after_patch.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_after_patch.snap @@ -12,5 +12,6 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ children: [] diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_initial.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_initial.snap index cafc3f5ed..f07da090e 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_initial.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_initial.snap @@ -14,5 +14,6 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ children: [] diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__set_name_and_class_name.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__set_name_and_class_name.snap index 359ee86ec..cc582633d 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__set_name_and_class_name.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__set_name_and_class_name.snap @@ -12,5 +12,6 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ children: [] diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap index 9147ca9b1..4c4046047 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap @@ -13,6 +13,7 @@ added_instances: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: New class_name: Folder properties: {} diff --git a/src/snapshot/tree.rs b/src/snapshot/tree.rs index 8c30d5ef7..dc8b87cc6 100644 --- a/src/snapshot/tree.rs +++ b/src/snapshot/tree.rs @@ -73,6 +73,13 @@ impl RojoTree { self.inner.root_ref() } + /// Returns the root Instance of this tree. + #[inline] + pub fn root(&self) -> InstanceWithMeta { + self.get_instance(self.get_root_id()) + .expect("RojoTrees should have a root") + } + pub fn get_instance(&self, id: Ref) -> Option { if let Some(instance) = self.inner.get_by_ref(id) { let metadata = self.metadata_map.get(&id).unwrap(); @@ -298,6 +305,10 @@ impl<'a> InstanceWithMeta<'a> { pub fn metadata(&self) -> &'a InstanceMetadata { self.metadata } + + pub fn inner(&self) -> &Instance { + self.instance + } } /// RojoTree's equivalent of `&'a mut Instance`. @@ -347,6 +358,14 @@ impl InstanceWithMetaMut<'_> { pub fn metadata(&self) -> &InstanceMetadata { self.metadata } + + pub fn inner(&self) -> &Instance { + self.instance + } + + pub fn inner_mut(&mut self) -> &mut Instance { + self.instance + } } #[cfg(test)] diff --git a/src/snapshot_middleware/csv.rs b/src/snapshot_middleware/csv.rs index 52e8ab0fd..e38856354 100644 --- a/src/snapshot_middleware/csv.rs +++ b/src/snapshot_middleware/csv.rs @@ -1,15 +1,24 @@ -use std::{collections::BTreeMap, path::Path}; +use std::{ + borrow::Cow, + collections::{BTreeMap, BTreeSet}, + path::Path, +}; use anyhow::Context; use maplit::hashmap; use memofs::{IoResultExt, Vfs}; -use serde::Serialize; +use rbx_dom_weak::types::Variant; +use serde::{Deserialize, Serialize}; -use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}; +use crate::{ + snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, + syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot}, +}; use super::{ - dir::{dir_meta, snapshot_dir_no_meta}, - meta_file::AdjacentMetadata, + dir::{dir_meta, snapshot_dir_no_meta, syncback_dir_no_meta}, + meta_file::{AdjacentMetadata, DirectoryMetadata}, + PathExt as _, }; pub fn snapshot_csv( @@ -57,9 +66,10 @@ pub fn snapshot_csv_init( context: &InstanceContext, vfs: &Vfs, init_path: &Path, + name: &str, ) -> anyhow::Result> { let folder_path = init_path.parent().unwrap(); - let dir_snapshot = snapshot_dir_no_meta(context, vfs, folder_path)?.unwrap(); + let dir_snapshot = snapshot_dir_no_meta(context, vfs, folder_path, name)?.unwrap(); if dir_snapshot.class_name != "Folder" { anyhow::bail!( @@ -76,6 +86,10 @@ pub fn snapshot_csv_init( init_snapshot.children = dir_snapshot.children; init_snapshot.metadata = dir_snapshot.metadata; + init_snapshot + .metadata + .relevant_paths + .push(init_path.to_owned()); if let Some(mut meta) = dir_meta(vfs, folder_path)? { meta.apply_all(&mut init_snapshot)?; @@ -84,27 +98,97 @@ pub fn snapshot_csv_init( Ok(Some(init_snapshot)) } +pub fn syncback_csv<'sync>( + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + let new_inst = snapshot.new_inst(); + + let contents = if let Some(Variant::String(content)) = new_inst.properties.get("Contents") { + content.as_str() + } else { + anyhow::bail!("LocalizationTables must have a `Contents` property that is a String") + }; + let mut fs_snapshot = FsSnapshot::new(); + fs_snapshot.add_file(&snapshot.path, localization_to_csv(contents)?); + + let meta = AdjacentMetadata::from_syncback_snapshot(snapshot, snapshot.path.clone())?; + if let Some(mut meta) = meta { + meta.properties.remove("Contents"); + + if !meta.is_empty() { + let parent = snapshot.path.parent_err()?; + fs_snapshot.add_file( + parent.join(format!("{}.meta.json", new_inst.name)), + serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?, + ) + } + } + + Ok(SyncbackReturn { + fs_snapshot, + children: Vec::new(), + removed_children: Vec::new(), + }) +} + +pub fn syncback_csv_init<'sync>( + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + let new_inst = snapshot.new_inst(); + + let contents = if let Some(Variant::String(content)) = new_inst.properties.get("Contents") { + content.as_str() + } else { + anyhow::bail!("LocalizationTables must have a `Contents` property that is a String") + }; + + let mut dir_syncback = syncback_dir_no_meta(snapshot)?; + dir_syncback.fs_snapshot.add_file( + &snapshot.path.join("init.csv"), + localization_to_csv(contents)?, + ); + + let meta = DirectoryMetadata::from_syncback_snapshot(snapshot, snapshot.path.clone())?; + if let Some(mut meta) = meta { + meta.properties.remove("Contents"); + if !meta.is_empty() { + dir_syncback.fs_snapshot.add_file( + snapshot.path.join("init.meta.json"), + serde_json::to_vec_pretty(&meta) + .context("could not serialize new init.meta.json")?, + ); + } + } + + Ok(dir_syncback) +} + /// Struct that holds any valid row from a Roblox CSV translation table. /// /// We manually deserialize into this table from CSV, but let serde_json handle /// serialization. -#[derive(Debug, Default, Serialize)] +#[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct LocalizationEntry<'a> { #[serde(skip_serializing_if = "Option::is_none")] - key: Option<&'a str>, + key: Option>, #[serde(skip_serializing_if = "Option::is_none")] - context: Option<&'a str>, + context: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - example: Option<&'a str>, + // Roblox writes `examples` for LocalizationTable's Content property, which + // causes it to not roundtrip correctly. + // This is reported here: https://devforum.roblox.com/t/2908720. + // + // To support their mistake, we support an alias named `examples`. + #[serde(skip_serializing_if = "Option::is_none", alias = "examples")] + example: Option>, #[serde(skip_serializing_if = "Option::is_none")] - source: Option<&'a str>, + source: Option>, // We use a BTreeMap here to get deterministic output order. - values: BTreeMap<&'a str, &'a str>, + values: BTreeMap, Cow<'a, str>>, } /// Normally, we'd be able to let the csv crate construct our struct for us. @@ -138,12 +222,14 @@ fn convert_localization_csv(contents: &[u8]) -> Result { } match header { - "Key" => entry.key = Some(value), - "Source" => entry.source = Some(value), - "Context" => entry.context = Some(value), - "Example" => entry.example = Some(value), + "Key" => entry.key = Some(Cow::Borrowed(value)), + "Source" => entry.source = Some(Cow::Borrowed(value)), + "Context" => entry.context = Some(Cow::Borrowed(value)), + "Example" => entry.example = Some(Cow::Borrowed(value)), _ => { - entry.values.insert(header, value); + entry + .values + .insert(Cow::Borrowed(header), Cow::Borrowed(value)); } } } @@ -161,6 +247,57 @@ fn convert_localization_csv(contents: &[u8]) -> Result { Ok(encoded) } +/// Takes a localization table (as a string) and converts it into a CSV file. +/// +/// The CSV file is ordered, so it should be deterministic. +fn localization_to_csv(csv_contents: &str) -> anyhow::Result> { + let mut out = Vec::new(); + let mut writer = csv::Writer::from_writer(&mut out); + + let mut csv: Vec = + serde_json::from_str(csv_contents).context("cannot decode JSON from localization table")?; + + // TODO sort this better + csv.sort_by(|a, b| a.source.partial_cmp(&b.source).unwrap()); + + let mut headers = vec!["Key", "Source", "Context", "Example"]; + // We want both order and a lack of duplicates, so we use a BTreeSet. + let mut extra_headers = BTreeSet::new(); + for entry in &csv { + for lang in entry.values.keys() { + extra_headers.insert(lang.as_ref()); + } + } + headers.extend(extra_headers.iter()); + + writer + .write_record(&headers) + .context("could not write headers for localization table")?; + + let mut record: Vec<&str> = Vec::with_capacity(headers.len()); + for entry in &csv { + record.push(entry.key.as_deref().unwrap_or_default()); + record.push(entry.source.as_deref().unwrap_or_default()); + record.push(entry.context.as_deref().unwrap_or_default()); + record.push(entry.example.as_deref().unwrap_or_default()); + + let values = &entry.values; + for header in &extra_headers { + record.push(values.get(*header).map(AsRef::as_ref).unwrap_or_default()); + } + + writer + .write_record(&record) + .context("cannot write record for localization table")?; + record.clear(); + } + + // We must drop `writer` here to regain access to `out`. + drop(writer); + + Ok(out) +} + #[cfg(test)] mod test { use super::*; diff --git a/src/snapshot_middleware/dir.rs b/src/snapshot_middleware/dir.rs index 092641e7f..4d84fab12 100644 --- a/src/snapshot_middleware/dir.rs +++ b/src/snapshot_middleware/dir.rs @@ -1,17 +1,27 @@ -use std::path::Path; +use std::{ + collections::{HashMap, HashSet}, + path::Path, +}; +use anyhow::Context; use memofs::{DirEntry, IoResultExt, Vfs}; -use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}; +use crate::{ + snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource}, + syncback::{hash_instance, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, +}; use super::{meta_file::DirectoryMetadata, snapshot_from_vfs}; +const EMPTY_DIR_KEEP_NAME: &str = ".gitkeep"; + pub fn snapshot_dir( context: &InstanceContext, vfs: &Vfs, path: &Path, + name: &str, ) -> anyhow::Result> { - let mut snapshot = match snapshot_dir_no_meta(context, vfs, path)? { + let mut snapshot = match snapshot_dir_no_meta(context, vfs, path, name)? { Some(snapshot) => snapshot, None => return Ok(None), }; @@ -44,6 +54,7 @@ pub fn snapshot_dir_no_meta( context: &InstanceContext, vfs: &Vfs, path: &Path, + name: &str, ) -> anyhow::Result> { let passes_filter_rules = |child: &DirEntry| { context @@ -66,32 +77,12 @@ pub fn snapshot_dir_no_meta( } } - let instance_name = path - .file_name() - .expect("Could not extract file name") - .to_str() - .ok_or_else(|| anyhow::anyhow!("File name was not valid UTF-8: {}", path.display()))? - .to_string(); - let meta_path = path.join("init.meta.json"); - let relevant_paths = vec![ - path.to_path_buf(), - meta_path, - // TODO: We shouldn't need to know about Lua existing in this - // middleware. Should we figure out a way for that function to add - // relevant paths to this middleware? - path.join("init.lua"), - path.join("init.luau"), - path.join("init.server.lua"), - path.join("init.server.luau"), - path.join("init.client.lua"), - path.join("init.client.luau"), - path.join("init.csv"), - ]; + let relevant_paths = vec![path.to_path_buf(), meta_path]; let snapshot = InstanceSnapshot::new() - .name(instance_name) + .name(name) .class_name("Folder") .children(snapshot_children) .metadata( @@ -104,6 +95,136 @@ pub fn snapshot_dir_no_meta( Ok(Some(snapshot)) } +pub fn syncback_dir<'sync>( + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + let new_inst = snapshot.new_inst(); + + let mut dir_syncback = syncback_dir_no_meta(snapshot)?; + + let mut meta = DirectoryMetadata::from_syncback_snapshot(snapshot, snapshot.path.clone())?; + if let Some(meta) = &mut meta { + if new_inst.class != "Folder" { + meta.class_name = Some(new_inst.class.clone()); + } + + if !meta.is_empty() { + dir_syncback.fs_snapshot.add_file( + snapshot.path.join("init.meta.json"), + serde_json::to_vec_pretty(&meta) + .context("could not serialize new init.meta.json")?, + ); + } + } + + let metadata_empty = meta + .as_ref() + .map(DirectoryMetadata::is_empty) + .unwrap_or_default(); + if new_inst.children().is_empty() && metadata_empty { + dir_syncback + .fs_snapshot + .add_file(snapshot.path.join(EMPTY_DIR_KEEP_NAME), Vec::new()) + } + + Ok(dir_syncback) +} + +pub fn syncback_dir_no_meta<'sync>( + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + let new_inst = snapshot.new_inst(); + + let mut children = Vec::new(); + let mut removed_children = Vec::new(); + + // We have to enforce unique child names for the file system. + let mut child_names = HashSet::with_capacity(new_inst.children().len()); + let mut duplicate_set = HashSet::new(); + for child_ref in new_inst.children() { + let child = snapshot.get_new_instance(*child_ref).unwrap(); + if !child_names.insert(child.name.to_lowercase()) { + duplicate_set.insert(child.name.as_str()); + } + } + if !duplicate_set.is_empty() { + if duplicate_set.len() <= 25 { + anyhow::bail!( + "Instance has children with duplicate name (case may not exactly match):\n {}", + duplicate_set.into_iter().collect::>().join(", ") + ); + } + anyhow::bail!("Instance has more than 25 children with duplicate names"); + } + + if let Some(old_inst) = snapshot.old_inst() { + let mut old_child_map = HashMap::with_capacity(old_inst.children().len()); + for child in old_inst.children() { + let inst = snapshot.get_old_instance(*child).unwrap(); + old_child_map.insert(inst.name(), inst); + } + + for new_child_ref in new_inst.children() { + let new_child = snapshot.get_new_instance(*new_child_ref).unwrap(); + if let Some(old_child) = old_child_map.remove(new_child.name.as_str()) { + if old_child.metadata().relevant_paths.is_empty() { + log::debug!( + "Skipping instance {} because it doesn't exist on the disk", + old_child.name() + ); + continue; + } else if matches!( + old_child.metadata().instigating_source, + Some(InstigatingSource::ProjectNode { .. }) + ) { + log::debug!( + "Skipping instance {} because it originates in a project file", + old_child.name() + ); + continue; + } + // This child exists in both doms. Pass it on. + children.push(snapshot.with_joined_path(*new_child_ref, Some(old_child.id()))?); + } else { + // The child only exists in the the new dom + children.push(snapshot.with_joined_path(*new_child_ref, None)?); + } + } + // Any children that are in the old dom but not the new one are removed. + removed_children.extend(old_child_map.into_values()); + } else { + // There is no old instance. Just add every child. + for new_child_ref in new_inst.children() { + children.push(snapshot.with_joined_path(*new_child_ref, None)?); + } + } + let mut fs_snapshot = FsSnapshot::new(); + + if let Some(old_ref) = snapshot.old { + let new_hash = hash_instance(snapshot.project(), snapshot.new_tree(), snapshot.new) + .expect("new Instance should be hashable"); + let old_hash = hash_instance(snapshot.project(), snapshot.old_tree(), old_ref) + .expect("old Instance should be hashable"); + + if old_hash != new_hash { + fs_snapshot.add_dir(&snapshot.path); + } else { + log::debug!( + "Skipping reserializing directory {} because old and new tree hash the same", + new_inst.name + ); + } + } else { + fs_snapshot.add_dir(&snapshot.path); + } + + Ok(SyncbackReturn { + fs_snapshot, + children, + removed_children, + }) +} + #[cfg(test)] mod test { use super::*; @@ -119,10 +240,14 @@ mod test { let mut vfs = Vfs::new(imfs); - let instance_snapshot = - snapshot_dir(&InstanceContext::default(), &mut vfs, Path::new("/foo")) - .unwrap() - .unwrap(); + let instance_snapshot = snapshot_dir( + &InstanceContext::default(), + &mut vfs, + Path::new("/foo"), + "foo", + ) + .unwrap() + .unwrap(); insta::assert_yaml_snapshot!(instance_snapshot); } @@ -140,10 +265,14 @@ mod test { let mut vfs = Vfs::new(imfs); - let instance_snapshot = - snapshot_dir(&InstanceContext::default(), &mut vfs, Path::new("/foo")) - .unwrap() - .unwrap(); + let instance_snapshot = snapshot_dir( + &InstanceContext::default(), + &mut vfs, + Path::new("/foo"), + "foo", + ) + .unwrap() + .unwrap(); insta::assert_yaml_snapshot!(instance_snapshot); } diff --git a/src/snapshot_middleware/json_model.rs b/src/snapshot_middleware/json_model.rs index 1341db47e..c176066c3 100644 --- a/src/snapshot_middleware/json_model.rs +++ b/src/snapshot_middleware/json_model.rs @@ -1,13 +1,19 @@ -use std::{borrow::Cow, collections::HashMap, path::Path, str}; +use std::{ + borrow::Cow, + collections::{BTreeMap, HashMap}, + path::Path, + str, +}; use anyhow::Context; use memofs::Vfs; -use rbx_dom_weak::types::{Attributes, Ref}; -use serde::Deserialize; +use rbx_dom_weak::types::{Attributes, Ref, Variant}; +use serde::{Deserialize, Serialize}; use crate::{ resolution::UnresolvedValue, snapshot::{InstanceContext, InstanceSnapshot}, + syncback::{filter_properties_preallocated, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, RojoRef, }; @@ -58,10 +64,87 @@ pub fn snapshot_json_model( Ok(Some(snapshot)) } -#[derive(Debug, Deserialize)] +pub fn syncback_json_model<'sync>( + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + let mut property_buffer = Vec::with_capacity(snapshot.new_inst().properties.len()); + + let mut model = json_model_from_pair(snapshot, &mut property_buffer, snapshot.new); + // We don't need the name on the root, but we do for children. + model.name = None; + + Ok(SyncbackReturn { + fs_snapshot: FsSnapshot::new().with_added_file( + &snapshot.path, + serde_json::to_vec_pretty(&model).context("failed to serialize new JSON Model")?, + ), + children: Vec::new(), + removed_children: Vec::new(), + }) +} + +fn json_model_from_pair<'sync>( + snapshot: &SyncbackSnapshot<'sync>, + prop_buffer: &mut Vec<(&'sync str, &'sync Variant)>, + new: Ref, +) -> JsonModel { + let new_inst = snapshot + .get_new_instance(new) + .expect("all new referents passed to json_model_from_pair should exist"); + + filter_properties_preallocated(snapshot.project(), new_inst, prop_buffer); + + let mut properties = BTreeMap::new(); + let mut attributes = BTreeMap::new(); + for (name, value) in prop_buffer.drain(..) { + 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()), + ); + } + } + Variant::SharedString(_) => { + log::warn!( + "Rojo cannot serialize the property {}.{name} in model.json files.\n\ + If this is not acceptable, resave the Instance at '{}' manually as an RBXM or RBXMX.", new_inst.class, snapshot.get_new_inst_path(new)) + } + _ => { + properties.insert( + name.to_owned(), + UnresolvedValue::from_variant(value.clone(), &new_inst.class, name), + ); + } + } + } + + let mut children = Vec::with_capacity(new_inst.children().len()); + + for new_child_ref in new_inst.children() { + children.push(json_model_from_pair(snapshot, prop_buffer, *new_child_ref)) + } + + JsonModel { + name: Some(new_inst.name.clone()), + class_name: new_inst.class.clone(), + children, + properties, + attributes, + id: None, + } +} + +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct JsonModel { - #[serde(alias = "Name")] + #[serde(alias = "Name", skip_serializing_if = "Option::is_none")] name: Option, #[serde(alias = "ClassName")] @@ -79,13 +162,13 @@ struct JsonModel { #[serde( alias = "Properties", - default = "HashMap::new", - skip_serializing_if = "HashMap::is_empty" + default = "BTreeMap::new", + skip_serializing_if = "BTreeMap::is_empty" )] - properties: HashMap, + properties: BTreeMap, - #[serde(default = "HashMap::new", skip_serializing_if = "HashMap::is_empty")] - attributes: HashMap, + #[serde(default = "BTreeMap::new", skip_serializing_if = "BTreeMap::is_empty")] + attributes: BTreeMap, } impl JsonModel { diff --git a/src/snapshot_middleware/lua.rs b/src/snapshot_middleware/lua.rs index 3f8d26adf..a867a6fc3 100644 --- a/src/snapshot_middleware/lua.rs +++ b/src/snapshot_middleware/lua.rs @@ -1,13 +1,18 @@ use std::{collections::HashMap, path::Path, str}; +use anyhow::Context as _; use memofs::{IoResultExt, Vfs}; -use rbx_dom_weak::types::Enum; +use rbx_dom_weak::types::{Enum, Variant}; -use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}; +use crate::{ + snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, + syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot}, +}; use super::{ - dir::{dir_meta, snapshot_dir_no_meta}, - meta_file::AdjacentMetadata, + dir::{dir_meta, snapshot_dir_no_meta, syncback_dir_no_meta}, + meta_file::{AdjacentMetadata, DirectoryMetadata}, + PathExt as _, }; #[derive(Debug)] @@ -82,10 +87,11 @@ pub fn snapshot_lua_init( context: &InstanceContext, vfs: &Vfs, init_path: &Path, + name: &str, script_type: ScriptType, ) -> anyhow::Result> { let folder_path = init_path.parent().unwrap(); - let dir_snapshot = snapshot_dir_no_meta(context, vfs, folder_path)?.unwrap(); + let dir_snapshot = snapshot_dir_no_meta(context, vfs, folder_path, name)?.unwrap(); if dir_snapshot.class_name != "Folder" { anyhow::bail!( @@ -104,6 +110,10 @@ pub fn snapshot_lua_init( init_snapshot.children = dir_snapshot.children; init_snapshot.metadata = dir_snapshot.metadata; + init_snapshot + .metadata + .relevant_paths + .push(init_path.to_owned()); if let Some(mut meta) = dir_meta(vfs, folder_path)? { meta.apply_all(&mut init_snapshot)?; @@ -112,6 +122,76 @@ pub fn snapshot_lua_init( Ok(Some(init_snapshot)) } +pub fn syncback_lua<'sync>( + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + let new_inst = snapshot.new_inst(); + + let contents = if let Some(Variant::String(source)) = new_inst.properties.get("Source") { + source.as_bytes().to_vec() + } else { + anyhow::bail!("Scripts must have a `Source` property that is a String") + }; + let mut fs_snapshot = FsSnapshot::new(); + fs_snapshot.add_file(&snapshot.path, contents); + + let meta = AdjacentMetadata::from_syncback_snapshot(snapshot, snapshot.path.clone())?; + if let Some(mut meta) = meta { + meta.properties.remove("Source"); + + if !meta.is_empty() { + let parent_location = snapshot.path.parent_err()?; + fs_snapshot.add_file( + parent_location.join(format!("{}.meta.json", new_inst.name)), + serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?, + ); + } + } + + Ok(SyncbackReturn { + fs_snapshot, + // Scripts don't have a child! + children: Vec::new(), + removed_children: Vec::new(), + }) +} + +pub fn syncback_lua_init<'sync>( + script_type: ScriptType, + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + let new_inst = snapshot.new_inst(); + let path = snapshot.path.join(match script_type { + ScriptType::Server => "init.server.luau", + ScriptType::Client => "init.client.luau", + ScriptType::Module => "init.luau", + }); + + let contents = if let Some(Variant::String(source)) = new_inst.properties.get("Source") { + source.as_bytes().to_vec() + } else { + anyhow::bail!("Scripts must have a `Source` property that is a String") + }; + + let mut dir_syncback = syncback_dir_no_meta(snapshot)?; + dir_syncback.fs_snapshot.add_file(&path, contents); + + let meta = DirectoryMetadata::from_syncback_snapshot(snapshot, path.clone())?; + if let Some(mut meta) = meta { + meta.properties.remove("Source"); + + if !meta.is_empty() { + dir_syncback.fs_snapshot.add_file( + snapshot.path.join("init.meta.json"), + serde_json::to_vec_pretty(&meta) + .context("could not serialize new init.meta.json")?, + ); + } + } + + Ok(dir_syncback) +} + #[cfg(test)] mod test { use super::*; diff --git a/src/snapshot_middleware/meta_file.rs b/src/snapshot_middleware/meta_file.rs index e1cd62b78..3004ad2aa 100644 --- a/src/snapshot_middleware/meta_file.rs +++ b/src/snapshot_middleware/meta_file.rs @@ -1,10 +1,17 @@ -use std::{borrow::Cow, collections::HashMap, path::PathBuf}; +use std::{ + borrow::Cow, + collections::BTreeMap, + path::{Path, PathBuf}, +}; use anyhow::{format_err, Context}; -use rbx_dom_weak::types::Attributes; +use memofs::{IoResultExt as _, Vfs}; +use rbx_dom_weak::types::{Attributes, Variant}; use serde::{Deserialize, Serialize}; -use crate::{resolution::UnresolvedValue, snapshot::InstanceSnapshot, RojoRef}; +use crate::{ + resolution::UnresolvedValue, snapshot::InstanceSnapshot, syncback::SyncbackSnapshot, RojoRef, +}; /// Represents metadata in a sibling file with the same basename. /// @@ -19,11 +26,11 @@ pub struct AdjacentMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub ignore_unknown_instances: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub properties: HashMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub properties: BTreeMap, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub attributes: HashMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub attributes: BTreeMap, #[serde(skip)] pub path: PathBuf, @@ -42,6 +49,80 @@ impl AdjacentMetadata { Ok(meta) } + /// Constructs an `AdjacentMetadata` from the provided snapshot, assuming it + /// will be at the provided path. + pub fn from_syncback_snapshot( + snapshot: &SyncbackSnapshot, + path: PathBuf, + ) -> anyhow::Result> { + let mut properties = BTreeMap::new(); + let mut attributes = BTreeMap::new(); + // TODO make this more granular. + // I am breaking the cycle of bad TODOs. This is in reference to the fact + // that right now, this will just not write any metadata at all for + // project nodes, which is not always desirable. We should try to be + // smarter about it. + if let Some(old_inst) = snapshot.old_inst() { + if let Some(source) = &old_inst.metadata().instigating_source { + let source = source.path(); + if source != path { + log::debug!( + "Instigating source for Instance is mismatched so its metadata is being skipped.\nPath: {}", + path.display() + ); + return Ok(None); + } + } + } + + let ignore_unknown_instances = snapshot + .old_inst() + .map(|inst| inst.metadata().ignore_unknown_instances) + .unwrap_or_default(); + + let class = &snapshot.new_inst().class; + for (name, value) in snapshot.get_path_filtered_properties(snapshot.new).unwrap() { + 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()), + ); + } + } + Variant::SharedString(_) => { + log::warn!( + "Rojo cannot serialize the property {}.{name} in meta.json files.\n\ + If this is not acceptable, resave the Instance at '{}' manually as an RBXM or RBXMX.", class, snapshot.get_new_inst_path(snapshot.new)) + } + _ => { + properties.insert( + name.to_owned(), + UnresolvedValue::from_variant(value.clone(), class, name), + ); + } + } + } + + Ok(Some(Self { + ignore_unknown_instances: if ignore_unknown_instances { + Some(true) + } else { + None + }, + properties, + attributes, + path, + id: None, + })) + } + pub fn apply_ignore_unknown_instances(&mut self, snapshot: &mut InstanceSnapshot) { if let Some(ignore) = self.ignore_unknown_instances.take() { snapshot.metadata.ignore_unknown_instances = ignore; @@ -51,7 +132,10 @@ impl AdjacentMetadata { pub fn apply_properties(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> { let path = &self.path; - for (key, unresolved) in self.properties.drain() { + // BTreeMaps don't have an equivalent to HashMap::drain, so the next + // best option is to take ownership of the entire map. Not free, but + // very cheap. + for (key, unresolved) in std::mem::take(&mut self.properties) { let value = unresolved .resolve(&snapshot.class_name, &key) .with_context(|| format!("error applying meta file {}", path.display()))?; @@ -62,7 +146,7 @@ impl AdjacentMetadata { if !self.attributes.is_empty() { let mut attributes = Attributes::new(); - for (key, unresolved) in self.attributes.drain() { + for (key, unresolved) in std::mem::take(&mut self.attributes) { let value = unresolved.resolve_unambiguous()?; attributes.insert(key, value); } @@ -93,6 +177,18 @@ impl AdjacentMetadata { Ok(()) } + /// Returns whether the metadata is 'empty', meaning it doesn't have anything + /// worth persisting in it. Specifically: + /// + /// - The number of properties and attributes is 0 + /// - `ignore_unknown_instances` is None + #[inline] + pub fn is_empty(&self) -> bool { + self.attributes.is_empty() + && self.properties.is_empty() + && self.ignore_unknown_instances.is_none() + } + // TODO: Add method to allow selectively applying parts of metadata and // throwing errors if invalid parts are specified. } @@ -110,11 +206,11 @@ pub struct DirectoryMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub ignore_unknown_instances: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub properties: HashMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub properties: BTreeMap, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub attributes: HashMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub attributes: BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] pub class_name: Option, @@ -136,6 +232,84 @@ impl DirectoryMetadata { Ok(meta) } + /// Constructs a `DirectoryMetadata` from the provided snapshot, assuming it + /// will be at the provided path. + /// + /// This function does not set `ClassName` manually as most uses won't + /// want it set. + pub fn from_syncback_snapshot( + snapshot: &SyncbackSnapshot, + path: PathBuf, + ) -> anyhow::Result> { + let mut properties = BTreeMap::new(); + let mut attributes = BTreeMap::new(); + // TODO make this more granular. + // I am breaking the cycle of bad TODOs. This is in reference to the fact + // that right now, this will just not write any metadata at all for + // project nodes, which is not always desirable. We should try to be + // smarter about it. + if let Some(old_inst) = snapshot.old_inst() { + if let Some(source) = &old_inst.metadata().instigating_source { + let source = source.path(); + if source != path { + log::debug!( + "Instigating source for Instance is mismatched so its metadata is being skipped.\nPath: {}", + path.display() + ); + return Ok(None); + } + } + } + + let ignore_unknown_instances = snapshot + .old_inst() + .map(|inst| inst.metadata().ignore_unknown_instances) + .unwrap_or_default(); + + let class = &snapshot.new_inst().class; + for (name, value) in snapshot.get_path_filtered_properties(snapshot.new).unwrap() { + match value { + Variant::Attributes(attrs) => { + for (name, value) in attrs.iter() { + // We (probably) don't want to preserve internal + // attributes, only user defined ones. + if name.starts_with("RBX") { + continue; + } + attributes.insert( + name.to_owned(), + UnresolvedValue::from_variant_unambiguous(value.clone()), + ); + } + } + Variant::SharedString(_) => { + log::warn!( + "Rojo cannot serialize the property {}.{name} in meta.json files.\n\ + If this is not acceptable, resave the Instance at '{}' manually as an RBXM or RBXMX.", class, snapshot.get_new_inst_path(snapshot.new)) + } + _ => { + properties.insert( + name.to_owned(), + UnresolvedValue::from_variant(value.clone(), class, name), + ); + } + } + } + + Ok(Some(Self { + ignore_unknown_instances: if ignore_unknown_instances { + Some(true) + } else { + None + }, + properties, + attributes, + class_name: None, + path, + id: None, + })) + } + pub fn apply_all(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> { self.apply_ignore_unknown_instances(snapshot); self.apply_class_name(snapshot)?; @@ -170,7 +344,7 @@ impl DirectoryMetadata { fn apply_properties(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> { let path = &self.path; - for (key, unresolved) in self.properties.drain() { + for (key, unresolved) in std::mem::take(&mut self.properties) { let value = unresolved .resolve(&snapshot.class_name, &key) .with_context(|| format!("error applying meta file {}", path.display()))?; @@ -181,7 +355,7 @@ impl DirectoryMetadata { if !self.attributes.is_empty() { let mut attributes = Attributes::new(); - for (key, unresolved) in self.attributes.drain() { + for (key, unresolved) in std::mem::take(&mut self.attributes) { let value = unresolved.resolve_unambiguous()?; attributes.insert(key, value); } @@ -204,4 +378,51 @@ impl DirectoryMetadata { snapshot.metadata.specified_id = self.id.take().map(RojoRef::new); Ok(()) } + + /// Returns whether the metadata is 'empty', meaning it doesn't have anything + /// worth persisting in it. Specifically: + /// + /// - The number of properties and attributes is 0 + /// - `ignore_unknown_instances` is None + /// - `class_name` is either None or not Some("Folder") + #[inline] + pub fn is_empty(&self) -> bool { + self.attributes.is_empty() + && self.properties.is_empty() + && self.ignore_unknown_instances.is_none() + && if let Some(class) = &self.class_name { + class == "Folder" + } else { + true + } + } +} + +/// Retrieves the meta file that should be applied for the provided directory, +/// if it exists. +pub fn dir_meta(vfs: &Vfs, path: &Path) -> anyhow::Result> { + let meta_path = path.join("init.meta.json"); + + if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? { + let metadata = DirectoryMetadata::from_slice(&meta_contents, meta_path)?; + Ok(Some(metadata)) + } else { + Ok(None) + } +} + +/// Retrieves the meta file that should be applied for the provided file, +/// if it exists. +/// +/// The `name` field should be the name the metadata should have. +pub fn file_meta(vfs: &Vfs, path: &Path, name: &str) -> anyhow::Result> { + let mut meta_path = path.with_file_name(name); + meta_path.set_extension("meta.json"); + + if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? { + let metadata = AdjacentMetadata::from_slice(&meta_contents, meta_path)?; + Ok(Some(metadata)) + } else { + Ok(None) + } } diff --git a/src/snapshot_middleware/mod.rs b/src/snapshot_middleware/mod.rs index 410d96ec9..4b1c00f76 100644 --- a/src/snapshot_middleware/mod.rs +++ b/src/snapshot_middleware/mod.rs @@ -27,23 +27,32 @@ use anyhow::Context; use memofs::{IoResultExt, Vfs}; use serde::{Deserialize, Serialize}; -use crate::glob::Glob; -use crate::snapshot::{InstanceContext, InstanceSnapshot, SyncRule}; +use crate::{ + glob::Glob, + syncback::{SyncbackReturn, SyncbackSnapshot}, +}; +use crate::{ + snapshot::{InstanceContext, InstanceSnapshot, SyncRule}, + syncback::validate_file_name, +}; use self::{ - csv::{snapshot_csv, snapshot_csv_init}, - dir::snapshot_dir, + csv::{snapshot_csv, snapshot_csv_init, syncback_csv, syncback_csv_init}, + dir::{snapshot_dir, syncback_dir}, json::snapshot_json, - json_model::snapshot_json_model, - lua::{snapshot_lua, snapshot_lua_init, ScriptType}, - project::snapshot_project, - rbxm::snapshot_rbxm, - rbxmx::snapshot_rbxmx, + json_model::{snapshot_json_model, syncback_json_model}, + lua::{snapshot_lua, snapshot_lua_init, syncback_lua, syncback_lua_init}, + project::{snapshot_project, syncback_project}, + rbxm::{snapshot_rbxm, syncback_rbxm}, + rbxmx::{snapshot_rbxmx, syncback_rbxmx}, toml::snapshot_toml, - txt::snapshot_txt, + txt::{snapshot_txt, syncback_txt}, }; -pub use self::{project::snapshot_project_node, util::emit_legacy_scripts_default}; +pub use self::{ + lua::ScriptType, project::snapshot_project_node, util::emit_legacy_scripts_default, + util::PathExt, +}; /// Returns an `InstanceSnapshot` for the provided path. /// This will inspect the path and find the appropriate middleware for it, @@ -61,41 +70,14 @@ pub fn snapshot_from_vfs( }; if meta.is_dir() { - if let Some(init_path) = get_init_path(vfs, path)? { - // TODO: support user-defined init paths - // If and when we do, make sure to go support it in - // `Project::set_file_name`, as right now it special-cases - // `default.project.json` as an `init` path. - for rule in default_sync_rules() { - if rule.matches(&init_path) { - return match rule.middleware { - Middleware::Project => { - let name = init_path - .parent() - .and_then(Path::file_name) - .and_then(|s| s.to_str()).expect("default.project.json should be inside a folder with a unicode name"); - snapshot_project(context, vfs, &init_path, name) - } - - Middleware::ModuleScript => { - snapshot_lua_init(context, vfs, &init_path, ScriptType::Module) - } - Middleware::ServerScript => { - snapshot_lua_init(context, vfs, &init_path, ScriptType::Server) - } - Middleware::ClientScript => { - snapshot_lua_init(context, vfs, &init_path, ScriptType::Client) - } - - Middleware::Csv => snapshot_csv_init(context, vfs, &init_path), - - _ => snapshot_dir(context, vfs, path), - }; - } - } - snapshot_dir(context, vfs, path) - } else { - snapshot_dir(context, vfs, path) + let (middleware, dir_name, init_path) = get_dir_middleware(vfs, path)?; + // TODO: Support user defined init paths + // If and when we do, make sure to go support it in + // `Project::set_file_name`, as right now it special-cases + // `default.project.json` as an `init` path. + match middleware { + Middleware::Dir => middleware.snapshot(context, vfs, path, dir_name), + _ => middleware.snapshot(context, vfs, &init_path, dir_name), } } else { let file_name = path @@ -114,53 +96,44 @@ pub fn snapshot_from_vfs( } } -/// Gets an `init` path for the given directory. -/// This uses an intrinsic priority list and for compatibility, -/// it should not be changed. -fn get_init_path>(vfs: &Vfs, dir: P) -> anyhow::Result> { - let path = dir.as_ref(); - - let project_path = path.join("default.project.json"); - if vfs.metadata(&project_path).with_not_found()?.is_some() { - return Ok(Some(project_path)); - } - - let init_path = path.join("init.luau"); - if vfs.metadata(&init_path).with_not_found()?.is_some() { - return Ok(Some(init_path)); - } - - let init_path = path.join("init.lua"); - if vfs.metadata(&init_path).with_not_found()?.is_some() { - return Ok(Some(init_path)); - } - - let init_path = path.join("init.server.luau"); - if vfs.metadata(&init_path).with_not_found()?.is_some() { - return Ok(Some(init_path)); - } - - let init_path = path.join("init.server.lua"); - if vfs.metadata(&init_path).with_not_found()?.is_some() { - return Ok(Some(init_path)); - } - - let init_path = path.join("init.client.luau"); - if vfs.metadata(&init_path).with_not_found()?.is_some() { - return Ok(Some(init_path)); - } +/// Gets the appropriate middleware for a directory by checking for `init` +/// files. This uses an intrinsic priority list and for compatibility, +/// that order should be left unchanged. +/// +/// Returns the middleware, the name of the directory, and the path to +/// the init location. +fn get_dir_middleware<'path>( + vfs: &Vfs, + dir_path: &'path Path, +) -> anyhow::Result<(Middleware, &'path str, PathBuf)> { + let dir_name = dir_path + .file_name() + .expect("Could not extract directory name") + .to_str() + .ok_or_else(|| anyhow::anyhow!("File name was not valid UTF-8: {}", dir_path.display()))?; - let init_path = path.join("init.client.lua"); - if vfs.metadata(&init_path).with_not_found()?.is_some() { - return Ok(Some(init_path)); - } + static INIT_PATHS: OnceLock> = OnceLock::new(); + let order = INIT_PATHS.get_or_init(|| { + vec![ + (Middleware::Project, "default.project.json"), + (Middleware::ModuleScriptDir, "init.luau"), + (Middleware::ModuleScriptDir, "init.lua"), + (Middleware::ServerScriptDir, "init.server.luau"), + (Middleware::ServerScriptDir, "init.server.lua"), + (Middleware::ClientScriptDir, "init.client.luau"), + (Middleware::ClientScriptDir, "init.client.lua"), + (Middleware::CsvDir, "init.csv"), + ] + }); - let init_path = path.join("init.csv"); - if vfs.metadata(&init_path).with_not_found()?.is_some() { - return Ok(Some(init_path)); + for (middleware, name) in order { + let test_path = dir_path.join(name); + if vfs.metadata(&test_path).with_not_found()?.is_some() { + return Ok((*middleware, dir_name, test_path)); + } } - Ok(None) + Ok((Middleware::Dir, dir_name, dir_path.to_path_buf())) } /// Gets a snapshot for a path given an InstanceContext and Vfs, taking @@ -190,9 +163,10 @@ fn snapshot_from_path( } /// Represents a possible 'transformer' used by Rojo to turn a file system -/// item into a Roblox Instance. Missing from this list are directories and -/// metadata. This is deliberate, as metadata is not a snapshot middleware -/// and directories do not make sense to turn into files. +/// item into a Roblox Instance. Missing from this list is metadata. +/// This is deliberate, as metadata is not a snapshot middleware. +/// +/// Directories cannot be used for sync rules so they're ignored by Serde. #[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum Middleware { @@ -208,6 +182,17 @@ pub enum Middleware { Toml, Text, Ignore, + + #[serde(skip_deserializing)] + Dir, + #[serde(skip_deserializing)] + ServerScriptDir, + #[serde(skip_deserializing)] + ClientScriptDir, + #[serde(skip_deserializing)] + ModuleScriptDir, + #[serde(skip_deserializing)] + CsvDir, } impl Middleware { @@ -220,7 +205,7 @@ impl Middleware { path: &Path, name: &str, ) -> anyhow::Result> { - match self { + let mut output = match self { Self::Csv => snapshot_csv(context, vfs, path, name), Self::JsonModel => snapshot_json_model(context, vfs, path, name), Self::Json => snapshot_json(context, vfs, path, name), @@ -233,6 +218,111 @@ impl Middleware { Self::Toml => snapshot_toml(context, vfs, path, name), Self::Text => snapshot_txt(context, vfs, path, name), Self::Ignore => Ok(None), + + Self::Dir => snapshot_dir(context, vfs, path, name), + Self::ServerScriptDir => { + snapshot_lua_init(context, vfs, path, name, ScriptType::Server) + } + Self::ClientScriptDir => { + snapshot_lua_init(context, vfs, path, name, ScriptType::Client) + } + Self::ModuleScriptDir => { + snapshot_lua_init(context, vfs, path, name, ScriptType::Module) + } + Self::CsvDir => snapshot_csv_init(context, vfs, path, name), + }; + if let Ok(Some(ref mut snapshot)) = output { + snapshot.metadata.middleware = Some(*self); + } + output + } + + /// Runs the syncback mechanism for the provided middleware given a + /// SyncbackSnapshot. + pub fn syncback<'sync>( + &self, + snapshot: &SyncbackSnapshot<'sync>, + ) -> anyhow::Result> { + let file_name = snapshot.path.file_name().and_then(|s| s.to_str()); + if let Some(file_name) = file_name { + validate_file_name(file_name).with_context(|| { + format!("cannot create a file or directory with name {file_name}") + })?; + } + match self { + Middleware::Csv => syncback_csv(snapshot), + Middleware::JsonModel => syncback_json_model(snapshot), + Middleware::Json => anyhow::bail!("cannot syncback Json middleware"), + // Projects are only generated from files that already exist on the + // file system, so we don't need to pass a file name. + Middleware::Project => syncback_project(snapshot), + Middleware::ServerScript => syncback_lua(snapshot), + Middleware::ClientScript => syncback_lua(snapshot), + Middleware::ModuleScript => syncback_lua(snapshot), + Middleware::Rbxm => syncback_rbxm(snapshot), + Middleware::Rbxmx => syncback_rbxmx(snapshot), + Middleware::Toml => anyhow::bail!("cannot syncback Toml middleware"), + Middleware::Text => syncback_txt(snapshot), + Middleware::Ignore => anyhow::bail!("cannot syncback Ignore middleware"), + Middleware::Dir => syncback_dir(snapshot), + Middleware::ServerScriptDir => syncback_lua_init(ScriptType::Server, snapshot), + Middleware::ClientScriptDir => syncback_lua_init(ScriptType::Client, snapshot), + Middleware::ModuleScriptDir => syncback_lua_init(ScriptType::Module, snapshot), + Middleware::CsvDir => syncback_csv_init(snapshot), + } + } + + /// Returns whether this particular middleware would become a directory. + #[inline] + pub fn is_dir(&self) -> bool { + matches!( + self, + Middleware::Dir + | Middleware::ServerScriptDir + | Middleware::ClientScriptDir + | Middleware::ModuleScriptDir + | Middleware::CsvDir + ) + } + + /// Returns whether this particular middleware sets its own properties. + /// This applies to things like `JsonModel` and `Project`, since they + /// set properties without needing a meta.json file. + /// + /// It does not cover middleware like `ServerScript` or `Csv` because they + /// need a meta.json file to set properties that aren't their designated + /// 'special' properties. + #[inline] + pub fn handles_own_properties(&self) -> bool { + matches!( + self, + Middleware::JsonModel | Middleware::Project | Middleware::Rbxm | Middleware::Rbxmx + ) + } + + /// Attempts to return a middleware that should be used for the given path. + /// + /// Returns `Err` only if the Vfs cannot read information about the path. + pub fn middleware_for_path( + vfs: &Vfs, + sync_rules: &[SyncRule], + path: &Path, + ) -> anyhow::Result> { + let meta = match vfs.metadata(path).with_not_found()? { + Some(meta) => meta, + None => return Ok(None), + }; + + if meta.is_dir() { + let (middleware, _, _) = get_dir_middleware(vfs, path)?; + Ok(Some(middleware)) + } else { + for rule in sync_rules.iter().chain(default_sync_rules()) { + if rule.matches(path) { + return Ok(Some(rule.middleware)); + } + } + Ok(None) } } } diff --git a/src/snapshot_middleware/project.rs b/src/snapshot_middleware/project.rs index 0cc1a4003..0cd0e3599 100644 --- a/src/snapshot_middleware/project.rs +++ b/src/snapshot_middleware/project.rs @@ -1,16 +1,27 @@ -use std::{borrow::Cow, collections::HashMap, path::Path}; +use std::{ + borrow::Cow, + collections::{BTreeMap, HashMap, VecDeque}, + path::Path, +}; use anyhow::{bail, Context}; use memofs::Vfs; -use rbx_dom_weak::types::{Attributes, Ref}; +use rbx_dom_weak::{ + types::{Attributes, Ref, Variant}, + Instance, +}; use rbx_reflection::ClassTag; use crate::{ project::{PathNode, Project, ProjectNode}, + resolution::UnresolvedValue, snapshot::{ - InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource, PathIgnoreRule, - SyncRule, + InstanceContext, InstanceMetadata, InstanceSnapshot, InstanceWithMeta, InstigatingSource, + PathIgnoreRule, SyncRule, }, + snapshot_middleware::Middleware, + syncback::{filter_properties, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, + variant_eq::variant_eq, RojoRef, }; @@ -287,12 +298,12 @@ pub fn snapshot_project_node( metadata.specified_id = Some(RojoRef::new(id.clone())) } - metadata.instigating_source = Some(InstigatingSource::ProjectNode( - project_path.to_path_buf(), - instance_name.to_string(), - node.clone(), - parent_class.map(|name| name.to_owned()), - )); + 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(), @@ -304,6 +315,324 @@ pub fn snapshot_project_node( })) } +pub fn syncback_project<'sync>( + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + 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 { + 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.json 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.json. + // As a result, the `meta.json` syncback code is hardcoded to not work + // if the Instance originates from a project file. However, we should + // ideally use a .meta.json 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 middleware = match Middleware::middleware_for_path( + snapshot.vfs(), + &project.sync_rules, + &full_path, + )? { + Some(middleware) => middleware, + // The only way this can happen at this point is if the path does + // not exist on the file system or there's no middleware for it. + None => anyhow::bail!( + "path does not exist or could not be turned into a file Rojo understands: {}", + full_path.display() + ), + }; + + 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() { + anyhow::bail!( + "Instances that are direct children of an Instance that is made by a project file \ + must have a unique name.\nThe child '{}' of '{}' is duplicated in the place file.", child.name, old_inst.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() { + anyhow::bail!( + "Instances that are direct children of an Instance that is made by a project file \ + must have a unique name.\nThe child '{}' of '{}' is duplicated on the file system.", child.name(), old_inst.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. + for (name, new_child) in new_child_map.drain() { + 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 { + descendant_snapshots.push(snapshot.with_base_path( + &parent_path, + new_child.referent(), + None, + )?); + } + } + } + removed_descendants.extend(old_child_map.drain().map(|(_, v)| v)); + 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, serde_json::to_vec_pretty(&project)?); + break; + } + } + + Ok(SyncbackReturn { + fs_snapshot, + children: descendant_snapshots, + removed_children: removed_descendants, + }) +} + +fn project_node_property_syncback<'inst>( + snapshot: &SyncbackSnapshot, + filtered_properties: HashMap<&'inst str, &'inst 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()), + ); + } + } + Variant::SharedString(_) => { + log::warn!( + "Rojo cannot serialize the property {}.{name} in project files.\n\ + If this is not acceptable, resave the Instance at '{}' manually as an RBXM or RBXMX.", new_inst.class, snapshot.get_new_inst_path(new_inst.referent()) + ); + } + _ => { + properties.insert( + name.to_string(), + 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, + node_attributes: &BTreeMap, + instance: InstanceWithMeta, +) -> anyhow::Result { + 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("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> { // If className wasn't defined from another source, we may be able // to infer one. diff --git a/src/snapshot_middleware/rbxm.rs b/src/snapshot_middleware/rbxm.rs index 7983d0e71..f94c4f5ef 100644 --- a/src/snapshot_middleware/rbxm.rs +++ b/src/snapshot_middleware/rbxm.rs @@ -3,7 +3,10 @@ use std::path::Path; use anyhow::Context; use memofs::Vfs; -use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}; +use crate::{ + snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, + syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot}, +}; #[profiling::function] pub fn snapshot_rbxm( @@ -39,6 +42,24 @@ pub fn snapshot_rbxm( } } +pub fn syncback_rbxm<'sync>( + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + let inst = snapshot.new_inst(); + + // Long-term, we probably want to have some logic for if this contains a + // script. That's a future endeavor though. + let mut serialized = Vec::new(); + rbx_binary::to_writer(&mut serialized, snapshot.new_tree(), &[inst.referent()]) + .context("failed to serialize new rbxm")?; + + Ok(SyncbackReturn { + fs_snapshot: FsSnapshot::new().with_added_file(&snapshot.path, serialized), + children: Vec::new(), + removed_children: Vec::new(), + }) +} + #[cfg(test)] mod test { use super::*; diff --git a/src/snapshot_middleware/rbxmx.rs b/src/snapshot_middleware/rbxmx.rs index 4266dc0d7..0cc84d3e2 100644 --- a/src/snapshot_middleware/rbxmx.rs +++ b/src/snapshot_middleware/rbxmx.rs @@ -2,8 +2,12 @@ use std::path::Path; use anyhow::Context; use memofs::Vfs; +use rbx_xml::EncodeOptions; -use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}; +use crate::{ + snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, + syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot}, +}; pub fn snapshot_rbxmx( context: &InstanceContext, @@ -15,7 +19,7 @@ pub fn snapshot_rbxmx( .property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown); let temp_tree = rbx_xml::from_reader(vfs.read(path)?.as_slice(), options) - .with_context(|| format!("Malformed rbxm file: {}", path.display()))?; + .with_context(|| format!("Malformed rbxmx file: {}", path.display()))?; let root_instance = temp_tree.root(); let children = root_instance.children(); @@ -41,6 +45,32 @@ pub fn snapshot_rbxmx( } } +pub fn syncback_rbxmx<'sync>( + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + let inst = snapshot.new_inst(); + + let options = + EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown); + + // Long-term, we probably want to have some logic for if this contains a + // script. That's a future endeavor though. + let mut serialized = Vec::new(); + rbx_xml::to_writer( + &mut serialized, + snapshot.new_tree(), + &[inst.referent()], + options, + ) + .context("failed to serialize new rbxmx")?; + + Ok(SyncbackReturn { + fs_snapshot: FsSnapshot::new().with_added_file(&snapshot.path, serialized), + children: Vec::new(), + removed_children: Vec::new(), + }) +} + #[cfg(test)] mod test { use super::*; diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap index 9b92ec7df..df634792f 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: LocalizationTable properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap index 7c2d9f6c2..8e97b5050 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: LocalizationTable properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap index ab27bc679..224bbfac7 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap @@ -10,16 +10,10 @@ metadata: relevant_paths: - /foo - /foo/init.meta.json - - /foo/init.lua - - /foo/init.luau - - /foo/init.server.lua - - /foo/init.server.luau - - /foo/init.client.lua - - /foo/init.client.luau - - /foo/init.csv context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: Folder properties: {} diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap index 4a24bdb13..425b5bdad 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap @@ -10,16 +10,10 @@ metadata: relevant_paths: - /foo - /foo/init.meta.json - - /foo/init.lua - - /foo/init.luau - - /foo/init.server.lua - - /foo/init.server.luau - - /foo/init.client.lua - - /foo/init.client.luau - - /foo/init.csv context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: Folder properties: {} @@ -32,16 +26,10 @@ children: relevant_paths: - /foo/Child - /foo/Child/init.meta.json - - /foo/Child/init.lua - - /foo/Child/init.luau - - /foo/Child/init.server.lua - - /foo/Child/init.server.luau - - /foo/Child/init.client.lua - - /foo/Child/init.client.luau - - /foo/Child/init.csv context: emit_legacy_scripts: true specified_id: ~ + middleware: dir name: Child class_name: Folder properties: {} diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap index b41503ad1..1ccc79b99 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: ModuleScript properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap index c6c307057..bf7689775 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap @@ -12,6 +12,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: IntValue properties: @@ -25,6 +26,7 @@ children: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: The Child class_name: StringValue properties: {} diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap index c6c307057..bf7689775 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap @@ -12,6 +12,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: IntValue properties: @@ -25,6 +26,7 @@ children: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: The Child class_name: StringValue properties: {} diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_client_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_client_from_vfs.snap index 92321572b..2977ec9a1 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_client_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_client_from_vfs.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: LocalScript properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_module_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_module_from_vfs.snap index 53c7ee33b..7f604b3a2 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_module_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_module_from_vfs.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: ModuleScript properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_module_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_module_with_meta.snap index 1b7fde092..1a621ecd7 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_module_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_module_with_meta.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: ModuleScript properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_script_disabled.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_script_disabled.snap index d0038e4d0..fd50ee16f 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_script_disabled.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_script_disabled.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: bar class_name: Script properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_script_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_script_with_meta.snap index a5f532402..8476359fd 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_script_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_script_with_meta.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: Script properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_server_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_server_from_vfs.snap index 4ad99cf1a..59f84aba8 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_server_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__class_server_from_vfs.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: Script properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_client_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_client_from_vfs.snap index e9704b1cf..28ddeef7c 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_client_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_client_from_vfs.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: false specified_id: ~ + middleware: ~ name: foo class_name: Script properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_module_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_module_from_vfs.snap index 6f5909c44..3645366e8 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_module_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_module_from_vfs.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: false specified_id: ~ + middleware: ~ name: foo class_name: ModuleScript properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_module_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_module_with_meta.snap index ff8c51ecc..d1881f320 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_module_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_module_with_meta.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: false specified_id: ~ + middleware: ~ name: foo class_name: ModuleScript properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_script_disabled.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_script_disabled.snap index c74043637..b4f3a6c74 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_script_disabled.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_script_disabled.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: false specified_id: ~ + middleware: ~ name: bar class_name: Script properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_script_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_script_with_meta.snap index 512c39911..a87dcf6b0 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_script_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_script_with_meta.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: false specified_id: ~ + middleware: ~ name: foo class_name: Script properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_server_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_server_from_vfs.snap index 4295f6bab..5bc36a9df 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_server_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__runcontext_server_from_vfs.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: false specified_id: ~ + middleware: ~ name: foo class_name: Script properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__no_name_project.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__no_name_project.snap index 7c1978cae..ebf5362dc 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__no_name_project.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__no_name_project.snap @@ -12,6 +12,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: Model properties: {} diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap index 7906dbbf4..1b87aab8a 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap @@ -12,6 +12,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: direct-project class_name: Model properties: {} diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap index 51da06217..cd32e81d0 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: project name: path-property-override class_name: StringValue properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap index 0c9f90abd..f9d6e4a44 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap @@ -12,6 +12,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: children class_name: Folder properties: {} @@ -21,14 +22,16 @@ children: ignore_unknown_instances: true instigating_source: ProjectNode: - - /foo.project.json - - Child - - $className: Model - - Folder + path: /foo.project.json + name: Child + node: + $className: Model + parent_class: Folder relevant_paths: [] context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: Child class_name: Model properties: {} diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap index d8d59d908..da28d1ce2 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: project name: path-project class_name: Model properties: {} diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap index 30b01ebb1..077ebb153 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: project name: path-child-project class_name: Folder properties: {} @@ -22,14 +23,16 @@ children: ignore_unknown_instances: true instigating_source: ProjectNode: - - /foo/other.project.json - - SomeChild - - $className: Model - - Folder + path: /foo/other.project.json + name: SomeChild + node: + $className: Model + parent_class: Folder relevant_paths: [] context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: SomeChild class_name: Model properties: {} diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap index e723e6d22..36e451fa2 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap @@ -14,6 +14,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: text name: path-project class_name: StringValue properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap index 99c6b3b5c..27d7004aa 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap @@ -12,6 +12,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: resolved-properties class_name: StringValue properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap index f42e22467..c6349bae7 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap @@ -12,6 +12,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: unresolved-properties class_name: StringValue properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap index 71a141530..9e72ad999 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: ModuleScript properties: diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap index 54253eb12..b9c07046a 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap @@ -13,6 +13,7 @@ metadata: context: emit_legacy_scripts: true specified_id: ~ + middleware: ~ name: foo class_name: StringValue properties: diff --git a/src/snapshot_middleware/txt.rs b/src/snapshot_middleware/txt.rs index a69b14d1b..2b9a3b589 100644 --- a/src/snapshot_middleware/txt.rs +++ b/src/snapshot_middleware/txt.rs @@ -1,11 +1,16 @@ use std::{path::Path, str}; +use anyhow::Context as _; use maplit::hashmap; use memofs::{IoResultExt, Vfs}; +use rbx_dom_weak::types::Variant; -use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}; +use crate::{ + snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, + syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot}, +}; -use super::meta_file::AdjacentMetadata; +use super::{meta_file::AdjacentMetadata, PathExt as _}; pub fn snapshot_txt( context: &InstanceContext, @@ -41,6 +46,39 @@ pub fn snapshot_txt( Ok(Some(snapshot)) } +pub fn syncback_txt<'sync>( + snapshot: &SyncbackSnapshot<'sync>, +) -> anyhow::Result> { + let new_inst = snapshot.new_inst(); + + let contents = if let Some(Variant::String(source)) = new_inst.properties.get("Value") { + source.as_bytes().to_vec() + } else { + anyhow::bail!("StringValues must have a `Value` property that is a String"); + }; + let mut fs_snapshot = FsSnapshot::new(); + fs_snapshot.add_file(&snapshot.path, contents); + + let meta = AdjacentMetadata::from_syncback_snapshot(snapshot, snapshot.path.clone())?; + if let Some(mut meta) = meta { + meta.properties.remove("Value"); + + if !meta.is_empty() { + let parent = snapshot.path.parent_err()?; + fs_snapshot.add_file( + parent.join(format!("{}.meta.json", new_inst.name)), + serde_json::to_vec_pretty(&meta).context("could not serialize metadata")?, + ); + } + } + + Ok(SyncbackReturn { + fs_snapshot, + children: Vec::new(), + removed_children: Vec::new(), + }) +} + #[cfg(test)] mod test { use super::*; diff --git a/src/snapshot_middleware/util.rs b/src/snapshot_middleware/util.rs index 625910b77..c16edcce6 100644 --- a/src/snapshot_middleware/util.rs +++ b/src/snapshot_middleware/util.rs @@ -16,6 +16,7 @@ pub fn match_trailing<'a>(input: &'a str, suffix: &str) -> Option<&'a str> { pub trait PathExt { fn file_name_ends_with(&self, suffix: &str) -> bool; fn file_name_trim_end<'a>(&'a self, suffix: &str) -> anyhow::Result<&'a str>; + fn parent_err(&self) -> anyhow::Result<&Path>; } impl

PathExt for P @@ -40,6 +41,12 @@ where match_trailing(file_name, suffix) .with_context(|| format!("Path did not end in {}: {}", suffix, path.display())) } + + fn parent_err(&self) -> anyhow::Result<&Path> { + let path = self.as_ref(); + path.parent() + .with_context(|| format!("Path does not have a parent: {}", path.display())) + } } // TEMP function until rojo 8.0, when it can be replaced with bool::default (aka false) diff --git a/src/syncback/file_names.rs b/src/syncback/file_names.rs new file mode 100644 index 000000000..d7c14011c --- /dev/null +++ b/src/syncback/file_names.rs @@ -0,0 +1,119 @@ +//! 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>, +) -> anyhow::Result> { + 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::Project => "project.json", + Middleware::Rbxm => "rbxm", + Middleware::Rbxmx => "rbxmx", + Middleware::Toml => "toml", + Middleware::Text => "txt", + // 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>(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(()) +} diff --git a/src/syncback/fs_snapshot.rs b/src/syncback/fs_snapshot.rs new file mode 100644 index 000000000..50b867820 --- /dev/null +++ b/src/syncback/fs_snapshot.rs @@ -0,0 +1,167 @@ +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>, + /// Paths representing new directories. + added_dirs: HashSet, + /// Paths representing removed files. + removed_files: HashSet, + /// Paths representing removed directories. + removed_dirs: HashSet, +} + +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>(mut self, path: P, data: Vec) -> 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>(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); + } + + /// Adds the provided path as a file with the given contents. + pub fn add_file>(&mut self, path: P, data: Vec) { + self.added_files.insert(path.as_ref().to_path_buf(), data); + } + + /// Adds the provided path as a directory. + pub fn add_dir>(&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>(&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>(&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>(&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.keys().map(PathBuf::as_path)); + list.extend(self.added_dirs.iter().map(PathBuf::as_path)); + + 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.iter().map(PathBuf::as_path)); + list.extend(self.removed_dirs.iter().map(PathBuf::as_path)); + + list + } + + /// Returns a list of file paths that would be added by this `FsSnapshot` + #[inline] + pub fn added_files(&self) -> Vec<&Path> { + self.added_files.keys().map(PathBuf::as_path).collect() + } + + /// Returns a list of directory paths that would be added by this `FsSnapshot` + #[inline] + pub fn added_dirs(&self) -> Vec<&Path> { + self.added_dirs.iter().map(PathBuf::as_path).collect() + } + + /// Returns a list of file paths that would be removed by this `FsSnapshot` + #[inline] + pub fn removed_files(&self) -> Vec<&Path> { + self.removed_files.iter().map(PathBuf::as_path).collect() + } + + /// Returns a list of directory paths that would be removed by this `FsSnapshot` + #[inline] + pub fn removed_dirs(&self) -> Vec<&Path> { + self.removed_dirs.iter().map(PathBuf::as_path).collect() + } +} diff --git a/src/syncback/hash/mod.rs b/src/syncback/hash/mod.rs new file mode 100644 index 000000000..f7a282b6a --- /dev/null +++ b/src/syncback/hash/mod.rs @@ -0,0 +1,121 @@ +//! Hashing utilities for a WeakDom. +mod variant; + +pub use variant::*; + +use blake3::{Hash, Hasher}; +use rbx_dom_weak::{ + types::{Ref, Variant}, + Instance, 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 { + let mut order = descendants(dom, root_ref); + let mut map: HashMap = 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 { + 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, + 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<(&'inst str, &'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<(&'inst str, &'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() + .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) { + 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 +} diff --git a/src/syncback/hash/variant.rs b/src/syncback/hash/variant.rs new file mode 100644 index 000000000..6c41ee7d5 --- /dev/null +++ b/src/syncback/hash/variant.rs @@ -0,0 +1,194 @@ +use blake3::Hasher; +use rbx_dom_weak::types::{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) => { + 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::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) + ) + } + }, + 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)) +} diff --git a/src/syncback/mod.rs b/src/syncback/mod.rs new file mode 100644 index 000000000..e0529600c --- /dev/null +++ b/src/syncback/mod.rs @@ -0,0 +1,521 @@ +mod file_names; +mod fs_snapshot; +mod hash; +mod property_filter; +mod ref_properties; +mod snapshot; + +use anyhow::Context; +use memofs::Vfs; +use rbx_dom_weak::{ + types::{Ref, Variant}, + Instance, 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::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 ref_properties::collect_referents; +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 = OnceLock::new(); + +pub fn syncback_loop( + vfs: &Vfs, + old_tree: &mut RojoTree, + mut new_tree: WeakDom, + project: &Project, +) -> anyhow::Result { + let ignore_patterns = project + .syncback_rules + .as_ref() + .map(|rules| rules.compile_globs()) + .transpose()?; + + // 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("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(syncback.fs_snapshot); + + snapshots.extend(syncback.children); + } + + Ok(fs_snapshot) +} + +pub struct SyncbackReturn<'sync> { + pub fs_snapshot: FsSnapshot, + pub children: Vec>, + pub removed_children: Vec>, +} + +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> = 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, + /// 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, + /// A map of classes to properties to ignore for that class when doing + /// syncback. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + ignore_properties: HashMap>, + /// 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, + /// 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, + /// Whether to skip serializing referent properties like `Model.PrimaryPart` + /// during syncback. Defaults to `false`. + #[serde(skip_serializing_if = "Option::is_none")] + ignore_referents: Option, + /// 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, +} + +impl SyncbackRules { + pub fn compile_globs(&self) -> anyhow::Result> { + 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>, 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: &'project Project, + new_inst: &Instance, +) -> Option> { + let filter = &project.syncback_rules.as_ref()?.ignore_properties; + let mut set = HashSet::new(); + + let database = rbx_reflection_database::get(); + let mut current_class_name = new_inst.class.as_str(); + + loop { + if let Some(list) = filter.get(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 { + 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); + } +} diff --git a/src/syncback/property_filter.rs b/src/syncback/property_filter.rs new file mode 100644 index 000000000..fdbcd1dad --- /dev/null +++ b/src/syncback/property_filter.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; + +use rbx_dom_weak::{types::Variant, Instance}; +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, +) -> HashMap<&'inst str, &'inst Variant> { + let mut map: Vec<(&str, &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<(&'inst str, &'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() + .classes + .get(inst.class.as_str()); + + let predicate = |prop_name: &String, 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(); + 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 +} diff --git a/src/syncback/ref_properties.rs b/src/syncback/ref_properties.rs new file mode 100644 index 000000000..dde9e0196 --- /dev/null +++ b/src/syncback/ref_properties.rs @@ -0,0 +1,182 @@ +//! Implements iterating through an entire WeakDom and linking all Ref +//! properties using attributes. + +use std::collections::{HashSet, VecDeque}; + +use rbx_dom_weak::{ + types::{Attributes, Ref, UniqueId, Variant}, + Instance, 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, + /// A set of referents that need their ID rewritten. This includes + /// Instances that have no existing ID. + need_rewrite: Vec, +} + +#[derive(PartialEq, Eq)] +struct RefLink { + /// The name of a property + name: String, + /// 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 = HashSet::new(); + let mut need_rewrite = Vec::new(); + let mut links = MultiMap::new(); + + let mut queue = VecDeque::new(); + + // Note that this is back-in, front-out. This is important because + // VecDeque::extend is the equivalent to using push_back. + queue.push_back(dom.root_ref()); + while let Some(inst_ref) = queue.pop_front() { + let instance = dom.get_by_ref(inst_ref).unwrap(); + queue.extend(instance.children().iter().copied()); + + for (property_name, prop_value) in &instance.properties { + let Variant::Ref(prop_ref) = prop_value else { + continue; + }; + // Any ref property has to get a Target attribute, which is what the + // `link` map is for. + links.insert( + inst_ref, + RefLink { + name: property_name.to_owned(), + value: *prop_ref, + }, + ); + // Additionally, all ref properties need to have an ID attribute + let existing_id = match dom.get_by_ref(*prop_ref) { + Some(inst) => get_existing_id(inst), + None => continue, + }; + if let Some(existing_id) = existing_id { + // If an ID is already on this Instance, we need to check if it + // is a duplicate. If it is, rewrite it. + if ids.contains(existing_id) { + need_rewrite.push(*prop_ref) + } + ids.insert(existing_id); + } else { + need_rewrite.push(*prop_ref) + } + } + } + + RefLinks { + prop_links: links, + need_rewrite, + } +} + +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("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("UniqueId") { + Some(Variant::UniqueId(id)) => Some(*id), + _ => None, + } + .unwrap_or_else(|| UniqueId::now().unwrap()); + + let attributes = match inst.properties.get_mut("Attributes") { + Some(Variant::Attributes(attrs)) => attrs, + None => { + inst.properties + .insert("Attributes".into(), Attributes::new().into()); + match inst.properties.get_mut("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("Attributes")? { + let id = attrs.get(REF_ID_ATTRIBUTE_NAME)?; + match id { + Variant::String(str) => Some(str), + Variant::BinaryString(bstr) => match std::str::from_utf8(bstr.as_ref()) { + Ok(str) => Some(str), + Err(_) => None, + }, + _ => None, + } + } else { + None + } +} diff --git a/src/syncback/snapshot.rs b/src/syncback/snapshot.rs new file mode 100644 index 000000000..b9f2d8342 --- /dev/null +++ b/src/syncback/snapshot.rs @@ -0,0 +1,264 @@ +use memofs::Vfs; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use crate::{ + snapshot::{InstanceWithMeta, RojoTree}, + snapshot_middleware::Middleware, + Project, +}; +use rbx_dom_weak::{ + types::{Ref, Variant}, + Instance, 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, + pub new: Ref, + pub path: PathBuf, + pub middleware: Option, +} + +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) -> anyhow::Result { + 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, + ) -> anyhow::Result { + 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) -> 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> { + 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> { + 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> { + 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<&HashMap>> { + 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"); + } +} diff --git a/src/variant_eq.rs b/src/variant_eq.rs new file mode 100644 index 000000000..6d80d1cc4 --- /dev/null +++ b/src/variant_eq.rs @@ -0,0 +1,188 @@ +use rbx_dom_weak::types::{PhysicalProperties, Variant, Vector3}; + +/// Accepts three argumets: a float type and two values to compare. +/// +/// Returns a bool indicating whether they're equal. This accounts for NaN such +/// that `approx_eq!(f32, f32::NAN, f32::NAN)` is `true`. +macro_rules! approx_eq { + ($Ty:ty, $a:expr, $b:expr) => { + float_cmp::approx_eq!($Ty, $a, $b) || $a.is_nan() && $b.is_nan() + }; +} + +/// Compares two variants to determine if they're equal. This correctly takes +/// float comparisons into account. +pub fn variant_eq(variant_a: &Variant, variant_b: &Variant) -> bool { + if variant_a.ty() != variant_b.ty() { + return false; + } + + match (variant_a, variant_b) { + (Variant::Attributes(a), Variant::Attributes(b)) => { + // If they're not the same size, we can just abort + if a.len() != b.len() { + return false; + } + + // Since Attributes are stored with a BTreeMap, the keys are sorted + // and we can compare each map's keys in order. + for ((a_name, a_value), (b_name, b_value)) in a.iter().zip(b.iter()) { + if !(a_name == b_name && variant_eq(a_value, b_value)) { + return false; + } + } + + true + } + (Variant::Axes(a), Variant::Axes(b)) => a == b, + (Variant::BinaryString(a), Variant::BinaryString(b)) => a == b, + (Variant::Bool(a), Variant::Bool(b)) => a == b, + (Variant::BrickColor(a), Variant::BrickColor(b)) => a == b, + (Variant::CFrame(a), Variant::CFrame(b)) => { + vector_eq(&a.position, &b.position) + && vector_eq(&a.orientation.x, &b.orientation.x) + && vector_eq(&a.orientation.y, &b.orientation.y) + && vector_eq(&a.orientation.z, &b.orientation.z) + } + (Variant::Color3(a), Variant::Color3(b)) => { + approx_eq!(f32, a.r, b.r) && approx_eq!(f32, a.g, b.g) && approx_eq!(f32, a.b, b.b) + } + (Variant::Color3uint8(a), Variant::Color3uint8(b)) => a == b, + (Variant::ColorSequence(a), Variant::ColorSequence(b)) => { + if a.keypoints.len() != b.keypoints.len() { + return false; + } + let mut a_keypoints: Vec<_> = a.keypoints.iter().collect(); + let mut b_keypoints: Vec<_> = b.keypoints.iter().collect(); + a_keypoints.sort_unstable_by(|k1, k2| k1.time.partial_cmp(&k2.time).unwrap()); + b_keypoints.sort_unstable_by(|k1, k2| k1.time.partial_cmp(&k2.time).unwrap()); + + for (a_kp, b_kp) in a_keypoints.iter().zip(b_keypoints) { + if !(approx_eq!(f32, a_kp.time, b_kp.time) + && approx_eq!(f32, a_kp.color.r, b_kp.color.r) + && approx_eq!(f32, a_kp.color.g, b_kp.color.g) + && approx_eq!(f32, a_kp.color.b, b_kp.color.b)) + { + return false; + } + } + true + } + (Variant::Content(a), Variant::Content(b)) => a == b, + (Variant::Enum(a), Variant::Enum(b)) => a == b, + (Variant::Faces(a), Variant::Faces(b)) => a == b, + (Variant::Float32(a), Variant::Float32(b)) => approx_eq!(f32, *a, *b), + (Variant::Float64(a), Variant::Float64(b)) => approx_eq!(f64, *a, *b), + (Variant::Font(a), Variant::Font(b)) => { + a.weight == b.weight + && a.style == b.style + && a.family == b.family + && a.cached_face_id == b.cached_face_id + } + (Variant::Int32(a), Variant::Int32(b)) => a == b, + (Variant::Int64(a), Variant::Int64(b)) => a == b, + (Variant::MaterialColors(a), Variant::MaterialColors(b)) => a.encode() == b.encode(), + (Variant::NumberRange(a), Variant::NumberRange(b)) => { + approx_eq!(f32, a.max, b.max) && approx_eq!(f32, a.min, b.min) + } + (Variant::NumberSequence(a), Variant::NumberSequence(b)) => { + if a.keypoints.len() != b.keypoints.len() { + return false; + } + let mut a_keypoints: Vec<_> = a.keypoints.iter().collect(); + let mut b_keypoints: Vec<_> = b.keypoints.iter().collect(); + a_keypoints.sort_unstable_by(|k1, k2| k1.time.partial_cmp(&k2.time).unwrap()); + b_keypoints.sort_unstable_by(|k1, k2| k1.time.partial_cmp(&k2.time).unwrap()); + + for (a_kp, b_kp) in a_keypoints.iter().zip(b_keypoints) { + if !(approx_eq!(f32, a_kp.time, b_kp.time) + && approx_eq!(f32, a_kp.value, b_kp.value) + && approx_eq!(f32, a_kp.envelope, b_kp.envelope)) + { + return false; + } + } + true + } + (Variant::OptionalCFrame(a), Variant::OptionalCFrame(b)) => match (a, b) { + (Some(a), Some(b)) => { + vector_eq(&a.position, &b.position) + && vector_eq(&a.orientation.x, &b.orientation.x) + && vector_eq(&a.orientation.y, &b.orientation.y) + && vector_eq(&a.orientation.z, &b.orientation.z) + } + (None, None) => true, + _ => false, + }, + (Variant::PhysicalProperties(a), Variant::PhysicalProperties(b)) => match (a, b) { + (PhysicalProperties::Default, PhysicalProperties::Default) => true, + (PhysicalProperties::Custom(a2), PhysicalProperties::Custom(b2)) => { + approx_eq!(f32, a2.density, b2.density) + && approx_eq!(f32, a2.elasticity, b2.elasticity) + && approx_eq!(f32, a2.friction, b2.friction) + && approx_eq!(f32, a2.elasticity_weight, b2.elasticity_weight) + && approx_eq!(f32, a2.friction_weight, b2.friction_weight) + } + _ => false, + }, + (Variant::Ray(a), Variant::Ray(b)) => { + vector_eq(&a.direction, &b.direction) && vector_eq(&a.origin, &b.origin) + } + (Variant::Rect(a), Variant::Rect(b)) => { + approx_eq!(f32, a.max.x, b.max.x) + && approx_eq!(f32, a.max.y, b.max.y) + && approx_eq!(f32, a.min.x, b.min.x) + && approx_eq!(f32, a.min.y, b.min.y) + } + (Variant::Ref(a), Variant::Ref(b)) => a == b, + (Variant::Region3(a), Variant::Region3(b)) => { + vector_eq(&a.max, &b.max) && vector_eq(&a.min, &b.min) + } + (Variant::Region3int16(a), Variant::Region3int16(b)) => a == b, + (Variant::SecurityCapabilities(a), Variant::SecurityCapabilities(b)) => a == b, + (Variant::SharedString(a), Variant::SharedString(b)) => a == b, + (Variant::Tags(a), Variant::Tags(b)) => { + let mut a_sorted: Vec<&str> = a.iter().collect(); + let mut b_sorted: Vec<&str> = b.iter().collect(); + if a_sorted.len() == b_sorted.len() { + a_sorted.sort_unstable(); + b_sorted.sort_unstable(); + for (a_tag, b_tag) in a_sorted.into_iter().zip(b_sorted) { + if a_tag != b_tag { + return false; + } + } + true + } else { + false + } + } + (Variant::UDim(a), Variant::UDim(b)) => { + approx_eq!(f32, a.scale, b.scale) && a.offset == b.offset + } + (Variant::UDim2(a), Variant::UDim2(b)) => { + approx_eq!(f32, a.x.scale, b.x.scale) + && a.x.offset == b.x.offset + && approx_eq!(f32, a.y.scale, b.y.scale) + && a.y.offset == b.y.offset + } + (Variant::UniqueId(a), Variant::UniqueId(b)) => a == b, + (Variant::String(a), Variant::String(b)) => a == b, + (Variant::Vector2(a), Variant::Vector2(b)) => { + approx_eq!(f32, a.x, b.x) && approx_eq!(f32, a.y, b.y) + } + (Variant::Vector2int16(a), Variant::Vector2int16(b)) => a == b, + (Variant::Vector3(a), Variant::Vector3(b)) => vector_eq(a, b), + (Variant::Vector3int16(a), Variant::Vector3int16(b)) => a == b, + (a, b) => panic!( + "unsupport variant comparison: {:?} and {:?}", + a.ty(), + b.ty() + ), + } +} + +#[inline(always)] +fn vector_eq(a: &Vector3, b: &Vector3) -> bool { + approx_eq!(f32, a.x, b.x) && approx_eq!(f32, a.y, b.y) && approx_eq!(f32, a.z, b.z) +} diff --git a/src/web/ui.rs b/src/web/ui.rs index 675468215..a0b4f8826 100644 --- a/src/web/ui.rs +++ b/src/web/ui.rs @@ -166,6 +166,7 @@ impl UiService {

"specified_id: " { format!("{:?}", metadata.specified_id) }
"ignore_unknown_instances: " { metadata.ignore_unknown_instances.to_string() }
"instigating source: " { format!("{:?}", metadata.instigating_source) }
+
"middleware: " { format!("{:?}", metadata.middleware) }
{ relevant_paths } }; diff --git a/tests/rojo_test/io_util.rs b/tests/rojo_test/io_util.rs index ce940f4ae..7b855e1ff 100644 --- a/tests/rojo_test/io_util.rs +++ b/tests/rojo_test/io_util.rs @@ -5,11 +5,14 @@ use std::{ process::Child, }; +use serde::{ser::SerializeSeq, Serialize, Serializer}; use walkdir::WalkDir; pub static ROJO_PATH: &str = env!("CARGO_BIN_EXE_rojo"); pub static BUILD_TESTS_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/rojo-test/build-tests"); pub static SERVE_TESTS_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/rojo-test/serve-tests"); +pub static SYNCBACK_TESTS_PATH: &str = + concat!(env!("CARGO_MANIFEST_DIR"), "/rojo-test/syncback-tests"); pub fn get_working_dir_path() -> PathBuf { let mut manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -65,3 +68,36 @@ impl Drop for KillOnDrop { } } } + +// Stolen from librojo::path_serializer +pub fn serialize_absolute(path: T, serializer: S) -> Result +where + S: Serializer, + T: AsRef, +{ + let as_str = path + .as_ref() + .as_os_str() + .to_str() + .expect("Invalid Unicode in file path, cannot serialize"); + let replaced = as_str.replace('\\', "/"); + + serializer.serialize_str(&replaced) +} + +#[derive(Serialize)] +struct WithAbsolute<'a>(#[serde(serialize_with = "serialize_absolute")] &'a Path); + +pub fn serialize_vec_absolute(paths: &[T], serializer: S) -> Result +where + S: Serializer, + T: AsRef, +{ + let mut seq = serializer.serialize_seq(Some(paths.len()))?; + + for path in paths { + seq.serialize_element(&WithAbsolute(path.as_ref()))?; + } + + seq.end() +} diff --git a/tests/rojo_test/mod.rs b/tests/rojo_test/mod.rs index fd0cda3a0..6ce465b4f 100644 --- a/tests/rojo_test/mod.rs +++ b/tests/rojo_test/mod.rs @@ -1,3 +1,4 @@ pub mod internable; pub mod io_util; pub mod serve_util; +pub mod syncback_util; diff --git a/tests/rojo_test/syncback_util.rs b/tests/rojo_test/syncback_util.rs new file mode 100644 index 000000000..969e8037a --- /dev/null +++ b/tests/rojo_test/syncback_util.rs @@ -0,0 +1,202 @@ +use std::{borrow::Cow, collections::HashMap, path::Path}; + +use anyhow::Context; +use insta::assert_yaml_snapshot; +use librojo::{snapshot_from_vfs, syncback_loop, FsSnapshot, InstanceContext, Project, RojoTree}; +use memofs::{InMemoryFs, IoResultExt, Vfs, VfsSnapshot}; +use serde::Serialize; + +use crate::rojo_test::io_util::{serialize_vec_absolute, SYNCBACK_TESTS_PATH}; + +const INPUT_FILE: &str = "input.rbxl"; +const EXPECTED_DIR: &str = "expected"; +const OUTPUT_DIR: &str = "output"; + +pub fn basic_syncback_test(name: &str) -> anyhow::Result<()> { + let mut settings = insta::Settings::new(); + let snapshot_path = Path::new(SYNCBACK_TESTS_PATH) + .parent() + .unwrap() + .join("syncback-test-snapshots"); + settings.set_snapshot_path(snapshot_path); + + let test_path = Path::new(SYNCBACK_TESTS_PATH).join(name); + let input_path = test_path.join(INPUT_FILE); + let expected_path = test_path.join(EXPECTED_DIR); + let output_path = test_path.join(OUTPUT_DIR); + + let std_vfs = Vfs::new_default(); + std_vfs.set_watch_enabled(false); + let im_vfs = { + let mut imfs = InMemoryFs::new(); + imfs.load_snapshot(&output_path, to_vfs_snapshot(&std_vfs, &output_path)?)?; + Vfs::new(imfs) + }; + im_vfs.set_watch_enabled(false); + + let input_dom = rbx_binary::from_reader(std_vfs.read(input_path)?.as_slice())?; + + let (mut output_dom, project) = + rojo_tree_from_path(&std_vfs, &output_path.join("default.project.json"))?; + + let fs_snapshot = syncback_loop(&std_vfs, &mut output_dom, input_dom, &project)?; + + settings + .bind(|| assert_yaml_snapshot!(name, visualize_fs_snapshot(&fs_snapshot, &output_path))); + + fs_snapshot.write_to_vfs(&output_path, &im_vfs)?; + let paths = fs_snapshot.added_paths(); + + for path in paths { + let trimmed = path.strip_prefix(&output_path)?; + let expected = expected_path.join(trimmed); + + let expected_meta = std_vfs.metadata(&expected).with_not_found()?; + let output_meta = match im_vfs.metadata(path).with_not_found()? { + Some(meta) => meta, + None => anyhow::bail!( + "Somehow, a path did not exist in the InMemoryVfs: {}", + trimmed.display() + ), + }; + + if let Some(expected_meta) = expected_meta { + match (expected_meta.is_dir(), output_meta.is_dir()) { + (true, true) => {} + (true, false) => anyhow::bail!( + "A path was a file when it should be a directory: {}", + trimmed.display() + ), + (false, true) => anyhow::bail!( + "A path was a directory when it should be a file: {}", + trimmed.display() + ), + (false, false) => { + let output_contents = im_vfs.read(path).unwrap(); + let expected_contents = std_vfs.read(&expected).unwrap(); + + let normalized_output = normalize_line_endings(&output_contents); + let normalized_expected = normalize_line_endings(&expected_contents); + if normalized_output.as_slice() != normalized_expected.as_slice() { + let output_str = std::str::from_utf8(&normalized_output); + let expected_str = std::str::from_utf8(&normalized_expected); + let display = trimmed.display(); + match (output_str, expected_str) { + (Ok(output), Ok(expected)) => anyhow::bail!( + "The contents of a file did not match what was expected: {display}.\n\ + Expected: {expected}\n\ + Actual: {output}" + ), + _ => anyhow::bail!( + "The contents of a file did not match what was expected: {display}. \ + Expected {} bytes, got {}.", + normalized_output.len(), normalized_expected.len() + ), + } + } + } + } + } else { + anyhow::bail!( + "A path existed in the output when it shouldn't: {}", + trimmed.display() + ) + } + } + Ok(()) +} + +fn rojo_tree_from_path(vfs: &Vfs, path: &Path) -> anyhow::Result<(RojoTree, Project)> { + let project = Project::load_fuzzy(vfs, path)? + .with_context(|| format!("no project file located at {}", path.display()))?; + + let context = InstanceContext::with_emit_legacy_scripts(project.emit_legacy_scripts); + + let snapshot = snapshot_from_vfs(&context, vfs, path)?.with_context(|| { + format!( + "could not load project at {} with snapshot middleware", + path.display() + ) + })?; + + Ok((RojoTree::new(snapshot), project)) +} + +fn to_vfs_snapshot(vfs: &Vfs, path: &Path) -> anyhow::Result { + if vfs.metadata(path)?.is_dir() { + let mut children = HashMap::new(); + for item in vfs.read_dir(path)? { + let item = item?; + children.insert( + item.path().to_string_lossy().to_string(), + to_vfs_snapshot(vfs, item.path())?, + ); + } + Ok(VfsSnapshot::dir(children)) + } else { + let contents = vfs.read(path)?; + Ok(VfsSnapshot::file(contents.as_slice())) + } +} + +/// Normalizes the line endings of a vector if it's user-readable. +/// If it isn't, the vector is returned unmodified. +fn normalize_line_endings(input: &Vec) -> Cow> { + match std::str::from_utf8(input) { + Ok(str) => { + let mut new_str = Vec::with_capacity(input.len()); + for line in str.lines() { + new_str.extend(line.as_bytes()); + new_str.push(b'\n') + } + new_str.pop(); + Cow::Owned(new_str) + } + Err(_) => Cow::Borrowed(input), + } +} + +#[derive(Default, Debug, Serialize)] +struct FsSnapshotVisual<'a> { + #[serde(serialize_with = "serialize_vec_absolute")] + added_files: Vec<&'a Path>, + #[serde(serialize_with = "serialize_vec_absolute")] + added_dirs: Vec<&'a Path>, + #[serde(serialize_with = "serialize_vec_absolute")] + removed_files: Vec<&'a Path>, + #[serde(serialize_with = "serialize_vec_absolute")] + removed_dirs: Vec<&'a Path>, +} + +fn visualize_fs_snapshot<'a>(snapshot: &'a FsSnapshot, base_path: &Path) -> FsSnapshotVisual<'a> { + let mut added_files = Vec::new(); + let mut added_dirs = Vec::new(); + let mut removed_files = Vec::new(); + let mut removed_dirs = Vec::new(); + + for file in snapshot.added_files() { + added_files.push(file.strip_prefix(base_path).unwrap()) + } + for file in snapshot.added_dirs() { + added_dirs.push(file.strip_prefix(base_path).unwrap()) + } + for file in snapshot.removed_dirs() { + removed_dirs.push(file.strip_prefix(base_path).unwrap()) + } + for file in snapshot.removed_files() { + removed_files.push(file.strip_prefix(base_path).unwrap()) + } + + added_files.sort_unstable(); + added_dirs.sort_unstable(); + removed_files.sort_unstable(); + removed_dirs.sort_unstable(); + + // Turns out that the debug display for Path isn't stable. Who knew! + FsSnapshotVisual { + added_files, + added_dirs, + removed_files, + removed_dirs, + } +} diff --git a/tests/tests/mod.rs b/tests/tests/mod.rs index a348e2289..bc7100164 100644 --- a/tests/tests/mod.rs +++ b/tests/tests/mod.rs @@ -1,2 +1,3 @@ mod build; mod serve; +mod syncback; diff --git a/tests/tests/syncback.rs b/tests/tests/syncback.rs new file mode 100644 index 000000000..1d9e937c0 --- /dev/null +++ b/tests/tests/syncback.rs @@ -0,0 +1,35 @@ +use crate::rojo_test::syncback_util::basic_syncback_test; + +macro_rules! syncback_basic_test { + ($($test_name:ident$(,)?)*) => {$( + #[test] + fn $test_name() { + let _ = env_logger::try_init(); + + basic_syncback_test(stringify!($test_name)).unwrap() + } + )*}; +} + +syncback_basic_test! { + baseplate, + all_middleware, + unscriptable_properties, + respect_old_middleware, + rbxm_fallback + project_init, + nested_projects, + nested_projects_weird, + ref_properties, + ref_properties_blank, + ref_properties_update, + ignore_paths, + project_reserialize, + project_all_middleware, + duplicate_rojo_id, + string_value_project, + child_but_not, + ignore_trees, + ignore_paths_removing, + ignore_trees_removing, +}