Compare commits

..

21 Commits

Author SHA1 Message Date
110b9f0df3 feat: resolve duplicate sibling names with incrementing suffixes
Instead of bailing when children have duplicate filesystem names,
syncback now resolves collisions by appending incrementing suffixes
(e.g. Foo, Foo1, Foo2). This handles both init-renamed children and
any other name collisions. Meta stem derivation is now path-based
to correctly handle collision suffixes and dotted names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:30:46 +01:00
917d17a738 fix: simplify meta stem to instance name + slugify + init-prefix
Drop the strip_suffix(extension) approach for computing adjacent meta
file names. Instead, use the instance name directly (slugified if it
has invalid filesystem chars, prefixed with '_' if it's "init"). This
is the same logic as the original code plus init-prefix handling, and
correctly preserves dots in instance names like "Name.new".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:09:45 +01:00
14bbdaf560 fix: handle dotted names and .lua extension in meta path + name check
Two bugs:

1. Meta stem fallback used raw instance name (unslugged), so names with
   forbidden chars like '/' would create bogus directory components in
   the meta path. Fix: fallback now slugifies + init-prefixes, matching
   name_for_inst.

2. AdjacentMetadata name check used split('.').next() to extract the
   filesystem stem, breaking dotted names like "Name.new" (stem became
   "Name", mismatched the instance name, wrote an unnecessary name
   property). Fix: check the conditions that cause name_for_inst to
   diverge (invalid chars or init-prefix) directly instead of comparing
   path stems.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:30:21 +01:00
5b1b5db06c fix: derive adjacent meta stem from snapshot path, not instance name
The previous fix used split('.').next() to get the meta stem from the
snapshot path, which only takes the first dot-segment. This broke names
containing dots (e.g. "Name.new" → "Name.new.luau" would produce
"Name.meta.json" instead of "Name.new.meta.json").

Strip the full middleware extension (e.g. ".server.luau", ".txt") from
the snapshot path filename instead. This correctly handles all cases:
  Name.new.luau      → Name.new  → Name.new.meta.json
  _Init.server.luau  → _Init     → _Init.meta.json
  Name.new.txt       → Name.new  → Name.new.meta.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 16:33:09 +01:00
33dd0f5ed1 fix: derive adjacent meta path from snapshot path, not instance name
When a script/txt/csv child is renamed by name_for_inst (e.g. "Init" →
"_Init.luau"), the adjacent meta file must follow the same name. All
three callers were using the Roblox instance name to construct the meta
path, producing "Init.meta.json" instead of "_Init.meta.json" — which
collides with the parent directory's "init.meta.json" on
case-insensitive file systems.

Fix by deriving the meta stem from the first dot-segment of the
snapshot path file name, which already holds the resolved name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 21:53:53 +01:00
95fe993de3 feat: auto-resolve init-name conflicts during syncback
When a child instance has a Roblox name that would produce a filesystem
name of "init" (case-insensitive), syncback now automatically prefixes
it with '_' (e.g. "Init" → "_Init.luau") instead of erroring. The
corresponding meta.json writes the original name via the `name` property
so Rojo can restore it on the next snapshot.

The sibling dedup check is updated to use actual on-disk names for
existing children and the resolved (init-prefixed) name for new ones,
so genuine collisions still error while false positives from the `name`
property are avoided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 01:05:31 +01:00
ari
a6e9939d6c Merge branch 'master' into name-prop 2026-01-20 01:10:20 +01:00
5957368c04 Remove redundant code
Can't remember why I added this one
2026-01-20 01:08:59 +01:00
78916c8a63 Revert 2 semantic changes 2026-01-20 00:59:34 +01:00
791ccfcfd1 Remove addition of 'Actor' to json_model_classes 2026-01-20 00:55:03 +01:00
3500ebe02a Update CHANGELOG.md 2026-01-20 00:54:18 +01:00
0e1364945f Avoid clone in src/syncback/file_names.rs 2026-01-12 14:41:12 +01:00
ari
3a6aae65f7 Avoid clone in src/syncback/file_names.rs
Co-authored-by: krakow10 <krakow20@gmail.com>
2026-01-12 14:35:46 +01:00
ari
d13d229eef Avoid clone in src/snapshot_middleware/json_model.rs
Co-authored-by: krakow10 <krakow20@gmail.com>
2026-01-12 14:35:18 +01:00
ari
9a485d88ce Avoid clone in src/snapshot_middleware/lua.rs
Co-authored-by: krakow10 <krakow20@gmail.com>
2026-01-12 14:35:06 +01:00
020d72faef fix: improve middleware selection for actor and other container classes 2025-12-18 05:10:53 +01:00
60d150f4c6 feat: optimize name handling for leaf scripts with invalid names
Prefer slugified filenames + adjacent meta files for scripts without children instead of forcing directory creation
2025-12-18 04:43:47 +01:00
73dab330b5 test: remove oudated json_model_legacy_name test 2025-12-15 20:32:28 +01:00
790312a5b0 fix: lack of .model.json support 2025-12-15 20:26:25 +01:00
5c396322d9 fix: name prop not properly syncing 2025-12-15 19:08:18 +01:00
37e44e474a feat: support name property in meta and model jsons 2025-12-15 18:45:59 +01:00
121 changed files with 1271 additions and 2974 deletions

View File

@@ -45,13 +45,6 @@ jobs:
name: Rojo.rbxm name: Rojo.rbxm
path: Rojo.rbxm path: Rojo.rbxm
- name: Upload Plugin to Roblox
env:
RBX_API_KEY: ${{ secrets.PLUGIN_UPLOAD_TOKEN }}
RBX_UNIVERSE_ID: ${{ vars.PLUGIN_CI_PLACE_ID }}
RBX_PLACE_ID: ${{ vars.PLUGIN_CI_UNIVERSE_ID }}
run: lune run upload-plugin Rojo.rbxm
build: build:
needs: ["create-release"] needs: ["create-release"]
strategy: strategy:

6
.gitmodules vendored
View File

@@ -16,9 +16,3 @@
[submodule "plugin/Packages/Highlighter"] [submodule "plugin/Packages/Highlighter"]
path = plugin/Packages/Highlighter path = plugin/Packages/Highlighter
url = https://github.com/boatbomber/highlighter.git url = https://github.com/boatbomber/highlighter.git
[submodule "plugin/Packages/msgpack-luau"]
path = plugin/Packages/msgpack-luau
url = https://github.com/cipharius/msgpack-luau/
[submodule ".lune/opencloud-execute"]
path = .lune/opencloud-execute
url = https://github.com/Dekkonot/opencloud-luau-execute-lune.git

View File

@@ -1,8 +0,0 @@
return {
luau = {
languagemode = "strict",
aliases = {
lune = "~/.lune/.typedefs/0.10.4/",
},
},
}

View File

@@ -1,51 +0,0 @@
local args: any = ...
assert(args, "no arguments passed to script")
local input: buffer = args.BinaryInput
local AssetService = game:GetService("AssetService")
local SerializationService = game:GetService("SerializationService")
local EncodingService = game:GetService("EncodingService")
local input_hash: buffer = EncodingService:ComputeBufferHash(input, Enum.HashAlgorithm.Sha256)
local hex_hash: { string } = table.create(buffer.len(input_hash))
for i = 0, buffer.len(input_hash) - 1 do
table.insert(hex_hash, string.format("%02x", buffer.readu8(input_hash, i)))
end
print(`Deserializing plugin file (size: {buffer.len(input)} bytes, hash: {table.concat(hex_hash, "")})`)
local plugin = SerializationService:DeserializeInstancesAsync(input)[1]
local UploadDetails = require(plugin.UploadDetails) :: any
local PLUGIN_ID = UploadDetails.assetId
local PLUGIN_NAME = UploadDetails.name
local PLUGIN_DESCRIPTION = UploadDetails.description
local PLUGIN_CREATOR_ID = UploadDetails.creatorId
local PLUGIN_CREATOR_TYPE = UploadDetails.creatorType
assert(typeof(PLUGIN_ID) == "number", "UploadDetails did not contain a number field 'assetId'")
assert(typeof(PLUGIN_NAME) == "string", "UploadDetails did not contain a string field 'name'")
assert(typeof(PLUGIN_DESCRIPTION) == "string", "UploadDetails did not contain a string field 'description'")
assert(typeof(PLUGIN_CREATOR_ID) == "number", "UploadDetails did not contain a number field 'creatorId'")
assert(typeof(PLUGIN_CREATOR_TYPE) == "string", "UploadDetails did not contain a string field 'creatorType'")
assert(
Enum.AssetCreatorType:FromName(PLUGIN_CREATOR_TYPE) ~= nil,
"UploadDetails field 'creatorType' was not a valid member of Enum.AssetCreatorType"
)
print(`Uploading to {PLUGIN_ID}`)
print(`Plugin Name: {PLUGIN_NAME}`)
print(`Plugin Description: {PLUGIN_DESCRIPTION}`)
local result, version_or_err = AssetService:CreateAssetVersionAsync(plugin, Enum.AssetType.Plugin, PLUGIN_ID, {
["Name"] = PLUGIN_NAME,
["Description"] = PLUGIN_DESCRIPTION,
["CreatorId"] = PLUGIN_CREATOR_ID,
["CreatorType"] = Enum.AssetCreatorType:FromName(PLUGIN_CREATOR_TYPE),
})
if result ~= Enum.CreateAssetResult.Success then
error(`Plugin failed to upload because: {result.Name} - {version_or_err}`)
end
print(`Plugin uploaded successfully. New version is {version_or_err}.`)

View File

@@ -1,78 +0,0 @@
local fs = require("@lune/fs")
local process = require("@lune/process")
local stdio = require("@lune/stdio")
local luau_execute = require("./opencloud-execute")
local UNIVERSE_ID = process.env["RBX_UNIVERSE_ID"]
local PLACE_ID = process.env["RBX_PLACE_ID"]
local version_string = fs.readFile("plugin/Version.txt")
local versions = { string.match(version_string, "^v?(%d+)%.(%d+)%.(%d+)(.*)$") }
if versions[4] ~= "" then
print("This release is a pre-release. Skipping uploading plugin.")
process.exit(0)
end
local plugin_path = process.args[1]
assert(
typeof(plugin_path) == "string",
"no plugin path provided, expected usage is `lune run upload-plugin [PATH TO RBXM]`."
)
-- For local testing
if process.env["CI"] ~= "true" then
local rojo = process.exec("rojo", { "build", "plugin.project.json", "--output", plugin_path })
if not rojo.ok then
stdio.ewrite("plugin upload failed because: could not build plugin.rbxm\n\n")
stdio.ewrite(rojo.stderr)
stdio.ewrite("\n")
process.exit(1)
end
else
assert(fs.isFile(plugin_path), `Plugin file did not exist at {plugin_path}`)
end
local plugin_content = fs.readFile(plugin_path)
local engine_script = fs.readFile(".lune/scripts/plugin-upload.luau")
print("Creating task to upload plugin")
local task = luau_execute.create_task_latest(UNIVERSE_ID, PLACE_ID, engine_script, 300, false, plugin_content)
print("Waiting for task to finish")
local success = luau_execute.await_finish(task)
if not success then
local error = luau_execute.get_error(task)
assert(error, "could not fetch error from task")
stdio.ewrite("plugin upload failed because: task did not finish successfully\n\n")
stdio.ewrite(error.code)
stdio.ewrite("\n")
stdio.ewrite(error.message)
stdio.ewrite("\n")
process.exit(1)
end
print("Output from task:\n")
for _, log in luau_execute.get_structured_logs(task) do
if log.messageType == "ERROR" then
stdio.write(stdio.color("red"))
stdio.write(log.message)
stdio.write("\n")
stdio.write(stdio.color("reset"))
elseif log.messageType == "INFO" then
stdio.write(stdio.color("cyan"))
stdio.write(log.message)
stdio.write("\n")
stdio.write(stdio.color("reset"))
elseif log.messageType == "WARNING" then
stdio.write(stdio.color("yellow"))
stdio.write(log.message)
stdio.write("\n")
stdio.write(stdio.color("reset"))
else
stdio.write(stdio.color("reset"))
stdio.write(log.message)
stdio.write("\n")
stdio.write(stdio.color("reset"))
end
end

View File

@@ -30,40 +30,15 @@ Making a new release? Simply add the new header with the version and date undern
--> -->
## Unreleased ## Unreleased
* `inf` and `nan` values in properties are now synced ([#1176])
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179]) * Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
* Implemented support for the "name" property in meta/model JSON files. ([#1187])
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192]) * Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
* Added actors and bindable/remote event/function variants to be synced back as JSON files. ([#1199])
* Fixed a bug where MacOS paths weren't being handled correctly. ([#1201]) * Fixed a bug where MacOS paths weren't being handled correctly. ([#1201])
* Fixed a bug where the notification timeout thread would fail to cancel on unmount ([#1211])
* Added a "Forget" option to the sync reminder notification to avoid being reminded for that place in the future ([#1215])
* Improves relative path calculation for sourcemap generation to avoid issues with Windows UNC paths. ([#1217])
* Fixed missing support for init.plugin.lua and init.plugin.luau. ([#1252])
* Add support for gitignore-style negation in `globIgnorePaths` and syncback's `ignorePaths` ([#1256])
* Fixed the sync fallback scrambling sibling order; replacements are now re-parented ancestors-first and in their original child order. ([#1265])
* Instances that share a name and class are now robustly matched on resync by comparing their properties, instead of relying on child order alone. ([#1266])
* Rojo now reports a clear error instead of panicking in several cases, including when the `serve` port is already in use, when a synced file is read-only or locked, when the filesystem watcher can't be created, and when the working directory is inaccessible. ([#1267])
* Fixed `/api/serialize` returning success when a requested instance ID is missing from the serve session tree. ([#1272])
* `rojo serve` now validates the `Host`/`Origin` headers to protect the local/private server against DNS rebinding, gates `/api/open` to local clients, and warns when bound to a network-reachable address. The accepted hosts can be extended with the `--allowed-hosts` option or a project's `serveAllowedHosts` field, for example to reach a network-exposed server by hostname. ([#1270])
* Fixed syncback not removing stale `$properties` entries when Studio resets a property to its engine default. ([#1244])
[#1176]: https://github.com/rojo-rbx/rojo/pull/1176
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179 [#1179]: https://github.com/rojo-rbx/rojo/pull/1179
[#1187]: https://github.com/rojo-rbx/rojo/pull/1187
[#1192]: https://github.com/rojo-rbx/rojo/pull/1192 [#1192]: https://github.com/rojo-rbx/rojo/pull/1192
[#1199]: https://github.com/rojo-rbx/rojo/pull/1199
[#1201]: https://github.com/rojo-rbx/rojo/pull/1201 [#1201]: https://github.com/rojo-rbx/rojo/pull/1201
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215
[#1217]: https://github.com/rojo-rbx/rojo/pull/1217
[#1252]: https://github.com/rojo-rbx/rojo/pull/1252
[#1256]: https://github.com/rojo-rbx/rojo/pull/1256
[#1265]: https://github.com/rojo-rbx/rojo/pull/1265
[#1266]: https://github.com/rojo-rbx/rojo/pull/1266
[#1267]: https://github.com/rojo-rbx/rojo/pull/1267
[#1272]: https://github.com/rojo-rbx/rojo/pull/1272
[#1270]: https://github.com/rojo-rbx/rojo/pull/1270
[#1244]: https://github.com/rojo-rbx/rojo/pull/1244
## [7.7.0-rc.1] (November 27th, 2025) ## [7.7.0-rc.1] (November 27th, 2025)

View File

@@ -13,27 +13,11 @@ Code contributions are welcome for features and bugs that have been reported in
You'll want these tools to work on Rojo: You'll want these tools to work on Rojo:
* Rust 1.88 or newer * Latest stable Rust compiler
* Rustfmt and Clippy are used for code formatting and linting. * Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
* [Rokit](https://github.com/rojo-rbx/rokit) * [Rokit](https://github.com/rojo-rbx/rokit)
* [Luau Language Server](https://github.com/JohnnyMorganz/luau-lsp) (Only needed if working on the Studio plugin.) * [Luau Language Server](https://github.com/JohnnyMorganz/luau-lsp) (Only needed if working on the Studio plugin.)
Rokit installs the pinned Rojo, Selene, StyLua, Lune, and run-in-roblox versions listed in [`rokit.toml`](rokit.toml):
```bash
rokit install
```
Before opening a pull request, run the relevant checks:
```bash
cargo test
cargo fmt -- --check
cargo clippy
stylua --check plugin/src
selene plugin/src
```
When working on the Studio plugin, we recommend using this command to automatically rebuild the plugin when you save a change: When working on the Studio plugin, we recommend using this command to automatically rebuild the plugin when you save a change:
*(Make sure you've enabled the Studio setting to reload plugins on file change!)* *(Make sure you've enabled the Studio setting to reload plugins on file change!)*
@@ -44,7 +28,7 @@ bash scripts/watch-build-plugin.sh
You can also run the plugin's unit tests with the following: You can also run the plugin's unit tests with the following:
*(If you are not using Rokit, make sure you have `run-in-roblox` installed first!)* *(Make sure you have `run-in-roblox` installed first!)*
```bash ```bash
bash scripts/unit-test-plugin.sh bash scripts/unit-test-plugin.sh
@@ -64,26 +48,26 @@ Please file issues and we'll try to help figure out what the best way forward is
## Local Development Gotchas ## Local Development Gotchas
If your build fails with an error about a missing path under `plugin/Packages`, such as `plugin/Packages/Roact`, you need to update your Git submodules. If your build fails with "Error: failed to open file `D:\code\rojo\plugin\modules\roact\src`" you need to update your Git submodules.
Run the command and try building again: `git submodule update --init --recursive`. Run the command and try building again: `git submodule update --init --recursive`.
## Pushing a Rojo Release ## Pushing a Rojo Release
The Rojo release process is driven by the GitHub Actions release workflow. If you need to do it, here's how: The Rojo release process is pretty manual right now. If you need to do it, here's how:
1. Bump server version in [`Cargo.toml`](Cargo.toml) 1. Bump server version in [`Cargo.toml`](Cargo.toml)
2. Bump plugin version in [`plugin/Version.txt`](plugin/Version.txt) 2. Bump plugin version in [`plugin/src/Config.lua`](plugin/src/Config.lua)
* The build checks that the Cargo and plugin versions match. 3. Run `cargo test` to update `Cargo.lock` and run tests
3. Run `cargo test` to update `Cargo.lock` after the version bump and run tests
4. Update [`CHANGELOG.md`](CHANGELOG.md) 4. Update [`CHANGELOG.md`](CHANGELOG.md)
5. Commit! 5. Commit!
* `git add . && git commit -m "Release vX.Y.Z"` * `git add . && git commit -m "Release vX.Y.Z"`
6. Tag the commit 6. Tag the commit
* `git tag vX.Y.Z` * `git tag vX.Y.Z`
7. Push commits and tags 7. Publish the CLI
* `git push && git push --tags`
8. Wait for the GitHub Actions release workflow to create the draft release and upload CLI/plugin artifacts
9. Publish the CLI crate
* `cargo publish` * `cargo publish`
8. Publish the Plugin
* `cargo run -- upload plugin --asset_id 6415005344`
9. Push commits and tags
* `git push && git push --tags`
10. Copy GitHub release content from previous release 10. Copy GitHub release content from previous release
* Update the leading text with a summary about the release * Update the leading text with a summary about the release
* Paste the changelog notes (as-is!) from [`CHANGELOG.md`](CHANGELOG.md) * Paste the changelog notes (as-is!) from [`CHANGELOG.md`](CHANGELOG.md)

19
Cargo.lock generated
View File

@@ -1520,12 +1520,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -2074,7 +2068,6 @@ dependencies = [
"num_cpus", "num_cpus",
"opener", "opener",
"paste", "paste",
"pathdiff",
"pretty_assertions", "pretty_assertions",
"profiling", "profiling",
"rayon", "rayon",
@@ -2085,12 +2078,10 @@ dependencies = [
"rbx_xml", "rbx_xml",
"reqwest", "reqwest",
"ritz", "ritz",
"rmp-serde",
"roblox_install", "roblox_install",
"rojo-insta-ext", "rojo-insta-ext",
"semver", "semver",
"serde", "serde",
"serde_bytes",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"strum", "strum",
@@ -2231,16 +2222,6 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_cbor" name = "serde_cbor"
version = "0.11.2" version = "0.11.2"

View File

@@ -100,13 +100,10 @@ clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.15" profiling = "1.0.15"
yaml-rust2 = "0.10.3" yaml-rust2 = "0.10.3"
data-encoding = "2.8.0" data-encoding = "2.8.0"
pathdiff = "0.2.3"
blake3 = "1.5.0" blake3 = "1.5.0"
float-cmp = "0.9.0" float-cmp = "0.9.0"
indexmap = { version = "2.10.0", features = ["serde"] } indexmap = { version = "2.10.0", features = ["serde"] }
rmp-serde = "1.3.0"
serde_bytes = "0.11.19"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.10.1" winreg = "0.10.1"
@@ -125,7 +122,7 @@ semver = "1.0.22"
rojo-insta-ext = { path = "crates/rojo-insta-ext" } rojo-insta-ext = { path = "crates/rojo-insta-ext" }
criterion = "0.3.6" criterion = "0.3.6"
insta = { version = "1.36.1", features = ["redactions", "yaml", "json"] } insta = { version = "1.36.1", features = ["redactions", "yaml"] }
paste = "1.0.14" paste = "1.0.14"
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"
serde_yaml = "0.8.26" serde_yaml = "0.8.26"

View File

@@ -25,11 +25,12 @@ Rojo enables:
* Versioning your game, library, or plugin using Git or another VCS * Versioning your game, library, or plugin using Git or another VCS
* Streaming `rbxmx` and `rbxm` models into your game in real time * Streaming `rbxmx` and `rbxm` models into your game in real time
* Packaging and deploying your project to Roblox.com from the command line * Packaging and deploying your project to Roblox.com from the command line
* Pulling Instances from Roblox place and model files back into an existing Rojo project with `rojo syncback`
Rojo also has an optional two-way sync setting in the Studio plugin for syncing supported Studio edits back to the filesystem. In the future, Rojo will be able to:
Some workflows, like fully automatic conversion of every existing game into a Rojo project, are still limited and may require manual project configuration. * Sync instances from Roblox Studio to the filesystem
* Automatically convert your existing game to work with Rojo
* Import custom instances like MoonScript code
## [Documentation](https://rojo.space/docs) ## [Documentation](https://rojo.space/docs)
Documentation is hosted in the [rojo.space repository](https://github.com/rojo-rbx/rojo.space). Documentation is hosted in the [rojo.space repository](https://github.com/rojo-rbx/rojo.space).

View File

@@ -30,11 +30,6 @@ fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
continue; continue;
} }
// Ignore images in msgpack-luau because they aren't UTF-8 encoded.
if file_name.ends_with(".png") {
continue;
}
let child_snapshot = snapshot_from_fs_path(&entry.path())?; let child_snapshot = snapshot_from_fs_path(&entry.path())?;
children.push((file_name, child_snapshot)); children.push((file_name, child_snapshot));
} }
@@ -75,7 +70,6 @@ fn main() -> Result<(), anyhow::Error> {
"src" => snapshot_from_fs_path(&plugin_dir.join("src"))?, "src" => snapshot_from_fs_path(&plugin_dir.join("src"))?,
"Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?, "Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?,
"Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?, "Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?,
"UploadDetails.json" => snapshot_from_fs_path(&plugin_dir.join("UploadDetails.json"))?,
}), }),
}); });

View File

@@ -2,9 +2,6 @@
## Unreleased Changes ## Unreleased Changes
* Added `Vfs::canonicalize`. [#1201] * Added `Vfs::canonicalize`. [#1201]
* **Breaking:** `StdBackend::new` and `Vfs::new_default` now return `io::Result`, so a failure to create the filesystem watcher is reported as an error instead of panicking. The `Default` implementation for `StdBackend` has been removed as a result. [#1267]
[#1267]: https://github.com/rojo-rbx/rojo/pull/1267
## 0.3.1 (2025-11-27) ## 0.3.1 (2025-11-27)
* Added `Vfs::exists`. [#1169] * Added `Vfs::exists`. [#1169]

View File

@@ -255,11 +255,8 @@ pub struct Vfs {
impl Vfs { impl Vfs {
/// Creates a new `Vfs` with the default backend, `StdBackend`. /// Creates a new `Vfs` with the default backend, `StdBackend`.
/// pub fn new_default() -> Self {
/// Returns an error if the filesystem watcher could not be initialized, Self::new(StdBackend::new())
/// which can happen in restricted or sandboxed environments.
pub fn new_default() -> io::Result<Self> {
Ok(Self::new(StdBackend::new()?))
} }
/// Creates a new `Vfs` with the given backend. /// Creates a new `Vfs` with the given backend.
@@ -642,7 +639,7 @@ mod test {
let file_path = dir.path().join("file.txt"); let file_path = dir.path().join("file.txt");
fs_err::write(&file_path, contents.to_string()).unwrap(); fs_err::write(&file_path, contents.to_string()).unwrap();
let vfs = Vfs::new(StdBackend::new().unwrap()); let vfs = Vfs::new(StdBackend::new());
let canonicalized = vfs.canonicalize(&file_path).unwrap(); let canonicalized = vfs.canonicalize(&file_path).unwrap();
assert_eq!(canonicalized, file_path.canonicalize().unwrap()); assert_eq!(canonicalized, file_path.canonicalize().unwrap());
assert_eq!( assert_eq!(
@@ -656,7 +653,7 @@ mod test {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test"); let file_path = dir.path().join("test");
let vfs = Vfs::new(StdBackend::new().unwrap()); let vfs = Vfs::new(StdBackend::new());
let err = vfs.canonicalize(&file_path).unwrap_err(); let err = vfs.canonicalize(&file_path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound); assert_eq!(err.kind(), io::ErrorKind::NotFound);
} }

View File

@@ -17,9 +17,9 @@ pub struct StdBackend {
} }
impl StdBackend { impl StdBackend {
pub fn new() -> io::Result<StdBackend> { pub fn new() -> StdBackend {
let (notify_tx, notify_rx) = mpsc::channel(); let (notify_tx, notify_rx) = mpsc::channel();
let watcher = watcher(notify_tx, Duration::from_millis(50)).map_err(io::Error::other)?; let watcher = watcher(notify_tx, Duration::from_millis(50)).unwrap();
let (tx, rx) = crossbeam_channel::unbounded(); let (tx, rx) = crossbeam_channel::unbounded();
@@ -46,11 +46,11 @@ impl StdBackend {
Result::<(), crossbeam_channel::SendError<VfsEvent>>::Ok(()) Result::<(), crossbeam_channel::SendError<VfsEvent>>::Ok(())
}); });
Ok(Self { Self {
watcher, watcher,
watcher_receiver: rx, watcher_receiver: rx,
watches: HashSet::new(), watches: HashSet::new(),
}) }
} }
} }
@@ -134,3 +134,9 @@ impl VfsBackend for StdBackend {
self.watcher.unwatch(path).map_err(io::Error::other) self.watcher.unwatch(path).map_err(io::Error::other)
} }
} }
impl Default for StdBackend {
fn default() -> Self {
Self::new()
}
}

View File

@@ -22,9 +22,6 @@
}, },
"Version": { "Version": {
"$path": "plugin/Version.txt" "$path": "plugin/Version.txt"
},
"UploadDetails": {
"$path": "plugin/UploadDetails.json"
} }
} }
} }

View File

@@ -1,7 +0,0 @@
{
"assetId": 13916111004,
"name": "Rojo",
"description": "The plugin portion of Rojo, a tool to enable professional tooling for Roblox developers.",
"creatorId": 32644114,
"creatorType": "Group"
}

View File

@@ -1,7 +1,5 @@
local HttpService = game:GetService("HttpService") local HttpService = game:GetService("HttpService")
local msgpack = require(script.Parent.Parent.msgpack)
local stringTemplate = [[ local stringTemplate = [[
Http.Response { Http.Response {
code: %d code: %d
@@ -33,8 +31,4 @@ function Response:json()
return HttpService:JSONDecode(self.body) return HttpService:JSONDecode(self.body)
end end
function Response:msgpack()
return msgpack.decode(self.body)
end
return Response return Response

View File

@@ -1,8 +1,7 @@
local HttpService = game:GetService("HttpService") local HttpService = game:GetService("HttpService")
local Log = require(script.Parent.Log)
local msgpack = require(script.Parent.msgpack)
local Promise = require(script.Parent.Promise) local Promise = require(script.Parent.Promise)
local Log = require(script.Parent.Log)
local HttpError = require(script.Error) local HttpError = require(script.Error)
local HttpResponse = require(script.Response) local HttpResponse = require(script.Response)
@@ -14,13 +13,6 @@ local Http = {}
Http.Error = HttpError Http.Error = HttpError
Http.Response = HttpResponse Http.Response = HttpResponse
-- Monkey patch msgpack.UInt64.new to lossily convert the low and high bits of the integer
-- to a native Luau number. We should change the upstream decoder to emit a native
-- integer, once those are live.
function msgpack.UInt64.new(mostSignificantPart: number, leastSignificantPart: number): number
return (mostSignificantPart % 2 ^ 32) * 2 ^ 32 + (leastSignificantPart % 2 ^ 32)
end
local function performRequest(requestParams) local function performRequest(requestParams)
local requestId = lastRequestId + 1 local requestId = lastRequestId + 1
lastRequestId = requestId lastRequestId = requestId
@@ -76,12 +68,4 @@ function Http.jsonDecode(source)
return HttpService:JSONDecode(source) return HttpService:JSONDecode(source)
end end
function Http.msgpackEncode(object)
return msgpack.encode(object)
end
function Http.msgpackDecode(source)
return msgpack.decode(source)
end
return Http return Http

View File

@@ -1,8 +1,8 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage") local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage:WaitForChild("Packages", 10):WaitForChild("TestEZ", 10)) local TestEZ = require(ReplicatedStorage.Packages:WaitForChild("TestEZ", 10))
local Rojo = ReplicatedStorage:WaitForChild("Rojo", 10) local Rojo = ReplicatedStorage.Rojo
local Settings = require(Rojo.Plugin.Settings) local Settings = require(Rojo.Plugin.Settings)
Settings:set("logLevel", "Trace") Settings:set("logLevel", "Trace")

View File

@@ -145,7 +145,7 @@ function ApiContext:connect()
return Http.get(url) return Http.get(url)
:andThen(rejectFailedRequests) :andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack) :andThen(Http.Response.json)
:andThen(rejectWrongProtocolVersion) :andThen(rejectWrongProtocolVersion)
:andThen(function(body) :andThen(function(body)
assert(validateApiInfo(body)) assert(validateApiInfo(body))
@@ -163,7 +163,7 @@ end
function ApiContext:read(ids) function ApiContext:read(ids)
local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ",")) local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ","))
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body) return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
@@ -191,9 +191,9 @@ function ApiContext:write(patch)
table.insert(updated, fixedUpdate) table.insert(updated, fixedUpdate)
end end
-- Only add the 'added' field if the table is non-empty, or else the msgpack -- Only add the 'added' field if the table is non-empty, or else Roblox's
-- encode implementation will turn the table into an array instead of a map, -- JSON implementation will turn the table into an array instead of an
-- causing API validation to fail. -- object, causing API validation to fail.
local added local added
if next(patch.added) ~= nil then if next(patch.added) ~= nil then
added = patch.added added = patch.added
@@ -206,16 +206,13 @@ function ApiContext:write(patch)
added = added, added = added,
} }
body = Http.msgpackEncode(body) body = Http.jsonEncode(body)
return Http.post(url, body) return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(responseBody)
:andThen(rejectFailedRequests) Log.info("Write response: {:?}", responseBody)
:andThen(Http.Response.msgpack)
:andThen(function(responseBody)
Log.info("Write response: {:?}", responseBody)
return responseBody return responseBody
end) end)
end end
function ApiContext:connectWebSocket(packetHandlers) function ApiContext:connectWebSocket(packetHandlers)
@@ -237,7 +234,7 @@ function ApiContext:connectWebSocket(packetHandlers)
local closed, errored, received local closed, errored, received
received = self.__wsClient.MessageReceived:Connect(function(msg) received = self.__wsClient.MessageReceived:Connect(function(msg)
local data = Http.msgpackDecode(msg) local data = Http.jsonDecode(msg)
if data.sessionId ~= self.__sessionId then if data.sessionId ~= self.__sessionId then
Log.warn("Received message with wrong session ID; ignoring") Log.warn("Received message with wrong session ID; ignoring")
return return
@@ -283,7 +280,7 @@ end
function ApiContext:open(id) function ApiContext:open(id)
local url = ("%s/api/open/%s"):format(self.__baseUrl, id) local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
return Http.post(url, ""):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body) return Http.post(url, ""):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
@@ -294,11 +291,11 @@ end
function ApiContext:serialize(ids: { string }) function ApiContext:serialize(ids: { string })
local url = ("%s/api/serialize"):format(self.__baseUrl) local url = ("%s/api/serialize"):format(self.__baseUrl)
local request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids }) local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
return Http.post(url, request_body) return Http.post(url, request_body)
:andThen(rejectFailedRequests) :andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack) :andThen(Http.Response.json)
:andThen(function(response_body) :andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
@@ -312,11 +309,11 @@ end
function ApiContext:refPatch(ids: { string }) function ApiContext:refPatch(ids: { string })
local url = ("%s/api/ref-patch"):format(self.__baseUrl) local url = ("%s/api/ref-patch"):format(self.__baseUrl)
local request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids }) local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
return Http.post(url, request_body) return Http.post(url, request_body)
:andThen(rejectFailedRequests) :andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack) :andThen(Http.Response.json)
:andThen(function(response_body) :andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")

View File

@@ -19,15 +19,9 @@ local FullscreenNotification = Roact.Component:extend("FullscreeFullscreenNotifi
function FullscreenNotification:init() function FullscreenNotification:init()
self.transparency, self.setTransparency = Roact.createBinding(0) self.transparency, self.setTransparency = Roact.createBinding(0)
self.lifetime = self.props.timeout self.lifetime = self.props.timeout
self.dismissed = false
end end
function FullscreenNotification:dismiss() function FullscreenNotification:dismiss()
if self.dismissed then
return
end
self.dismissed = true
if self.props.onClose then if self.props.onClose then
self.props.onClose() self.props.onClose()
end end
@@ -65,7 +59,7 @@ function FullscreenNotification:didMount()
end end
function FullscreenNotification:willUnmount() function FullscreenNotification:willUnmount()
if self.timeout and coroutine.status(self.timeout) == "suspended" then if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout) task.cancel(self.timeout)
end end
end end

View File

@@ -25,7 +25,6 @@ function Notification:init()
self.binding = bindingUtil.fromMotor(self.motor) self.binding = bindingUtil.fromMotor(self.motor)
self.lifetime = self.props.timeout self.lifetime = self.props.timeout
self.dismissed = false
self.motor:onStep(function(value) self.motor:onStep(function(value)
if value <= 0 and self.props.onClose then if value <= 0 and self.props.onClose then
@@ -35,11 +34,6 @@ function Notification:init()
end end
function Notification:dismiss() function Notification:dismiss()
if self.dismissed then
return
end
self.dismissed = true
self.motor:setGoal(Flipper.Spring.new(0, { self.motor:setGoal(Flipper.Spring.new(0, {
frequency = 5, frequency = 5,
dampingRatio = 1, dampingRatio = 1,
@@ -81,7 +75,7 @@ function Notification:didMount()
end end
function Notification:willUnmount() function Notification:willUnmount()
if self.timeout and coroutine.status(self.timeout) == "suspended" then if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout) task.cancel(self.timeout)
end end
end end

View File

@@ -301,19 +301,6 @@ function App:setPriorSyncInfo(host: string, port: string, projectName: string)
Settings:set("priorEndpoints", priorSyncInfos) Settings:set("priorEndpoints", priorSyncInfos)
end end
function App:forgetPriorSyncInfo()
local priorSyncInfos = Settings:get("priorEndpoints")
if not priorSyncInfos then
priorSyncInfos = {}
end
local id = tostring(game.PlaceId)
priorSyncInfos[id] = nil
Log.trace("Erased last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorSyncInfos)
end
function App:getHostAndPort() function App:getHostAndPort()
local host = self.host:getValue() local host = self.host:getValue()
local port = self.port:getValue() local port = self.port:getValue()
@@ -448,8 +435,7 @@ function App:checkSyncReminder()
self:findActiveServer() self:findActiveServer()
:andThen(function(serverInfo, host, port) :andThen(function(serverInfo, host, port)
self:sendSyncReminder( self:sendSyncReminder(
`Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`, `Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`
{ "Connect", "Dismiss" }
) )
end) end)
:catch(function() :catch(function()
@@ -460,8 +446,7 @@ function App:checkSyncReminder()
local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp) local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp)
self:sendSyncReminder( self:sendSyncReminder(
`You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?`, `You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?`
{ "Connect", "Forget", "Dismiss" }
) )
end end
end) end)
@@ -501,16 +486,12 @@ function App:stopSyncReminderPolling()
end end
end end
function App:sendSyncReminder(message: string, shownActions: { string }) function App:sendSyncReminder(message: string)
local syncReminderMode = Settings:get("syncReminderMode") local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then if syncReminderMode == "None" then
return return
end end
local connectIndex = table.find(shownActions, "Connect")
local forgetIndex = table.find(shownActions, "Forget")
local dismissIndex = table.find(shownActions, "Dismiss")
self.dismissSyncReminder = self:addNotification({ self.dismissSyncReminder = self:addNotification({
text = message, text = message,
timeout = 120, timeout = 120,
@@ -519,39 +500,24 @@ function App:sendSyncReminder(message: string, shownActions: { string })
self.dismissSyncReminder = nil self.dismissSyncReminder = nil
end, end,
actions = { actions = {
Connect = if connectIndex Connect = {
then { text = "Connect",
text = "Connect", style = "Solid",
style = "Solid", layoutOrder = 1,
layoutOrder = connectIndex, onClick = function()
onClick = function() self:startSession()
self:startSession() end,
end, },
} Dismiss = {
else nil, text = "Dismiss",
Forget = if forgetIndex style = "Bordered",
then { layoutOrder = 2,
text = "Forget", onClick = function()
style = "Bordered", -- If the user dismisses the reminder,
layoutOrder = forgetIndex, -- then we don't need to remind them again
onClick = function() self:stopSyncReminderPolling()
-- The user doesn't want to be reminded again about this sync end,
self:forgetPriorSyncInfo() },
end,
}
else nil,
Dismiss = if dismissIndex
then {
text = "Dismiss",
style = "Bordered",
layoutOrder = dismissIndex,
onClick = function()
-- If the user dismisses the reminder,
-- then we don't need to remind them again
self:stopSyncReminderPolling()
end,
}
else nil,
}, },
}) })
end end

View File

@@ -1,44 +0,0 @@
--[[
Counts how many of a virtual instance's properties match the live values on a
candidate Roblox instance. `hydrate` uses this to break ties when several
existing children share the same Name and ClassName.
This mirrors the read -> decode -> compare flow that `diff` uses, reusing the
same `getProperty`, `decodeValue`, and `trueEquals` helpers.
]]
local getProperty = require(script.Parent.getProperty)
local decodeValue = require(script.Parent.decodeValue)
local trueEquals = require(script.Parent.trueEquals)
local function countMatchingProperties(instance, virtualInstance, instanceMap)
local score = 0
for propertyName, virtualValue in virtualInstance.Properties do
-- Skip refs. During hydration the instanceMap is still being built
-- top-down, so a ref may point at an instance we haven't hydrated yet
-- and therefore can't decode reliably. Refs are also a poor
-- disambiguator between same-named siblings.
if next(virtualValue) == "Ref" then
continue
end
local getSuccess, existingValue = getProperty(instance, propertyName)
if not getSuccess then
continue
end
local decodeSuccess, decodedValue = decodeValue(virtualValue, instanceMap)
if not decodeSuccess then
continue
end
if trueEquals(existingValue, decodedValue) then
score += 1
end
end
return score
end
return countMatchingProperties

View File

@@ -1,91 +0,0 @@
return function()
local countMatchingProperties = require(script.Parent.countMatchingProperties)
local InstanceMap = require(script.Parent.Parent.InstanceMap)
it("counts properties whose values match the instance", function()
local instance = Instance.new("StringValue")
instance.Value = "hello"
local virtualInstance = {
ClassName = "StringValue",
Name = "Value",
Properties = {
Value = { String = "hello" },
},
Children = {},
}
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(1)
end)
it("does not count properties whose values differ", function()
local instance = Instance.new("StringValue")
instance.Value = "hello"
local virtualInstance = {
ClassName = "StringValue",
Name = "Value",
Properties = {
Value = { String = "different" },
},
Children = {},
}
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(0)
end)
it("counts multiple matching properties independently", function()
local instance = Instance.new("Part")
instance.Anchored = true
instance.CanCollide = false
local virtualInstance = {
ClassName = "Part",
Name = "Part",
Properties = {
Anchored = { Bool = true },
CanCollide = { Bool = false },
},
Children = {},
}
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(2)
-- Flip one so only a single property matches.
instance.CanCollide = true
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(1)
end)
it("skips unknown properties without counting or erroring", function()
local instance = Instance.new("Folder")
local virtualInstance = {
ClassName = "Folder",
Name = "Folder",
Properties = {
FAKE_PROPERTY = { String = "nope" },
},
Children = {},
}
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(0)
end)
it("skips Ref properties without counting or erroring", function()
local instance = Instance.new("ObjectValue")
local virtualInstance = {
ClassName = "ObjectValue",
Name = "ObjectValue",
Properties = {
-- A ref must be skipped rather than decoded: during hydration
-- the target may not be in the map yet.
Value = { Ref = "00000000000000000000000000000000" },
},
Children = {},
}
expect(countMatchingProperties(instance, virtualInstance, InstanceMap.new())).to.equal(0)
end)
end

View File

@@ -10,12 +10,100 @@ local invariant = require(script.Parent.Parent.invariant)
local getProperty = require(script.Parent.getProperty) local getProperty = require(script.Parent.getProperty)
local Error = require(script.Parent.Error) local Error = require(script.Parent.Error)
local decodeValue = require(script.Parent.decodeValue) local decodeValue = require(script.Parent.decodeValue)
local trueEquals = require(script.Parent.trueEquals)
local function isEmpty(table) local function isEmpty(table)
return next(table) == nil return next(table) == nil
end end
local function fuzzyEq(a: number, b: number, epsilon: number): boolean
return math.abs(a - b) < epsilon
end
local function trueEquals(a, b): boolean
-- Exit early for simple equality values
if a == b then
return true
end
-- Treat nil and { Ref = "000...0" } as equal
if
(a == nil and type(b) == "table" and b.Ref == "00000000000000000000000000000000")
or (b == nil and type(a) == "table" and a.Ref == "00000000000000000000000000000000")
then
return true
end
local typeA, typeB = typeof(a), typeof(b)
-- For tables, try recursive deep equality
if typeA == "table" and typeB == "table" then
local checkedKeys = {}
for key, value in a do
checkedKeys[key] = true
if not trueEquals(value, b[key]) then
return false
end
end
for key, value in b do
if checkedKeys[key] then
continue
end
if not trueEquals(value, a[key]) then
return false
end
end
return true
-- For numbers, compare with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "number" and typeB == "number" then
return fuzzyEq(a, b, 0.0001)
-- For EnumItem->number, compare the EnumItem's value
elseif typeA == "number" and typeB == "EnumItem" then
return a == b.Value
elseif typeA == "EnumItem" and typeB == "number" then
return a.Value == b
-- For Color3s, compare to RGB ints to avoid floating point inequality
elseif typeA == "Color3" and typeB == "Color3" then
local aR, aG, aB = math.floor(a.R * 255), math.floor(a.G * 255), math.floor(a.B * 255)
local bR, bG, bB = math.floor(b.R * 255), math.floor(b.G * 255), math.floor(b.B * 255)
return aR == bR and aG == bG and aB == bB
-- For CFrames, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "CFrame" and typeB == "CFrame" then
local aComponents, bComponents = { a:GetComponents() }, { b:GetComponents() }
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
-- For Vector3s, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "Vector3" and typeB == "Vector3" then
local aComponents, bComponents = { a.X, a.Y, a.Z }, { b.X, b.Y, b.Z }
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
-- For Vector2s, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "Vector2" and typeB == "Vector2" then
local aComponents, bComponents = { a.X, a.Y }, { b.X, b.Y }
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
end
return false
end
local function shouldDeleteUnknownInstances(virtualInstance) local function shouldDeleteUnknownInstances(virtualInstance)
if virtualInstance.Metadata ~= nil then if virtualInstance.Metadata ~= nil then
return not virtualInstance.Metadata.ignoreUnknownInstances return not virtualInstance.Metadata.ignoreUnknownInstances

View File

@@ -3,22 +3,9 @@
concrete instances and assigning them IDs. concrete instances and assigning them IDs.
]] ]]
local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
local invariant = require(script.Parent.Parent.invariant) local invariant = require(script.Parent.Parent.invariant)
local countMatchingProperties = require(script.Parent.countMatchingProperties)
-- When several existing children share a Name and ClassName we disambiguate local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
-- them by scoring how well each one's properties match the virtual instance.
-- That scoring is far more expensive than a Name/ClassName check, so we only do
-- it for reasonably-sized groups. Larger groups (e.g. a folder with thousands of
-- identically-named parts) fall back to the original order-based matching, which
-- bounds the added work to roughly MAX_CANDIDATES_TO_SCORE^2 property reads per
-- group regardless of how large the group is.
local MAX_CANDIDATES_TO_SCORE = 32
local function hydrateInner(stats, instanceMap, virtualInstances, rootId, rootInstance)
local virtualInstance = virtualInstances[rootId] local virtualInstance = virtualInstances[rootId]
if virtualInstance == nil then if virtualInstance == nil then
@@ -26,163 +13,38 @@ local function hydrateInner(stats, instanceMap, virtualInstances, rootId, rootIn
end end
instanceMap:insert(rootId, rootInstance) instanceMap:insert(rootId, rootInstance)
stats.hydrated += 1
local existingChildren = rootInstance:GetChildren() local existingChildren = rootInstance:GetChildren()
-- Group existing children by Name then ClassName so each virtual child can -- For each existing child, we'll track whether it's been paired with an
-- find its candidate matches without scanning every sibling. This is what -- instance that the Rojo server knows about.
-- keeps hydration fast for parents with thousands of children. Nesting the local isExistingChildVisited = {}
-- two tables (rather than a combined key) keeps the Name and ClassName checks for i = 1, #existingChildren do
-- exact, with no way for one to bleed into the other. isExistingChildVisited[i] = false
local buckets = {}
for _, childInstance in existingChildren do
-- We guard accessing Name and ClassName in order to avoid tripping over
-- children of DataModel that Rojo won't have permissions to access at all.
local accessSuccess, name, className = pcall(function()
return childInstance.Name, childInstance.ClassName
end)
if not accessSuccess then
continue
end
local bucketsByClassName = buckets[name]
if bucketsByClassName == nil then
bucketsByClassName = {}
buckets[name] = bucketsByClassName
end
local bucket = bucketsByClassName[className]
if bucket == nil then
bucket = { cursor = 1, instances = {} }
bucketsByClassName[className] = bucket
end
table.insert(bucket.instances, childInstance)
end end
-- Tracks which existing children have already been paired, so one instance
-- isn't matched to two different virtual instances.
local visited = {}
for _, childId in ipairs(virtualInstance.Children) do for _, childId in ipairs(virtualInstance.Children) do
local virtualChild = virtualInstances[childId] local virtualChild = virtualInstances[childId]
local bucketsByClassName = buckets[virtualChild.Name] for childIndex, childInstance in existingChildren do
local bucket = bucketsByClassName and bucketsByClassName[virtualChild.ClassName] if not isExistingChildVisited[childIndex] then
if bucket == nil then -- We guard accessing Name and ClassName in order to avoid
-- No existing instance matches; diff will mark this id for creation. -- tripping over children of DataModel that Rojo won't have
Log.trace( -- permissions to access at all.
"hydrate: no existing instance matches {} ({}) for id {}", local accessSuccess, name, className = pcall(function()
virtualChild.Name, return childInstance.Name, childInstance.ClassName
virtualChild.ClassName, end)
childId
)
continue
end
local instances = bucket.instances -- This rule is very conservative and could be loosened in the
-- future, or more heuristics could be introduced.
-- Advance past any leading children that have already been paired. The if accessSuccess and name == virtualChild.Name and className == virtualChild.ClassName then
-- cursor makes order-based matching amortized O(1) per child even for isExistingChildVisited[childIndex] = true
-- very large groups, rather than rescanning the visited prefix. hydrate(instanceMap, virtualInstances, childId, childInstance)
while bucket.cursor <= #instances and visited[instances[bucket.cursor]] do break
bucket.cursor += 1
end
if bucket.cursor > #instances then
-- Every matching instance has already been paired with an earlier id.
Log.trace(
"hydrate: no unpaired instance left for {} ({}) for id {}",
virtualChild.Name,
virtualChild.ClassName,
childId
)
continue
end
-- The cursor points at the earliest unvisited child, so the slots from
-- here to the end bound how many candidates remain. Visited children
-- after the cursor (gaps) only appear once a group is small enough to be
-- scored -- the order-based path below always takes the earliest, which
-- keeps the visited region a contiguous prefix. So whenever this count
-- exceeds the cap it is exact, and we can pick the earliest match without
-- collecting anything.
local remaining = #instances - bucket.cursor + 1
local match
if remaining > MAX_CANDIDATES_TO_SCORE then
-- Too many to score affordably; take the earliest in child order,
-- reproducing the original Name + ClassName behavior.
match = instances[bucket.cursor]
Log.trace(
"hydrate: {} candidates named {} ({}) exceeds the scoring cap of {}; matching id {} by child order",
remaining,
virtualChild.Name,
virtualChild.ClassName,
MAX_CANDIDATES_TO_SCORE,
childId
)
else
-- Collect the (at most `remaining`) unvisited candidates.
local candidates = {}
for index = bucket.cursor, #instances do
local childInstance = instances[index]
if not visited[childInstance] then
table.insert(candidates, childInstance)
end end
end end
if #candidates == 1 then
-- Only one candidate, so there's nothing to disambiguate.
match = candidates[1]
else
-- Break the tie by choosing the candidate whose properties best
-- match the virtual instance, falling back to the earliest in
-- child order when scores are equal.
local bestScore = -1
for _, childInstance in candidates do
local score = countMatchingProperties(childInstance, virtualChild, instanceMap)
if score > bestScore then
bestScore = score
match = childInstance
end
end
stats.ambiguousGroups += 1
stats.candidatesScored += #candidates
Log.trace(
"hydrate: disambiguated {} candidates named {} ({}) for id {} by property match (best score {})",
#candidates,
virtualChild.Name,
virtualChild.ClassName,
childId,
bestScore
)
end
end end
visited[match] = true
hydrateInner(stats, instanceMap, virtualInstances, childId, match)
end end
end end
local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
-- Tallies of the work hydration did, surfaced in a single debug log below so
-- the cost of property-based disambiguation is visible without per-node spam.
local stats = {
hydrated = 0,
ambiguousGroups = 0,
candidatesScored = 0,
}
hydrateInner(stats, instanceMap, virtualInstances, rootId, rootInstance)
Log.debug(
"Hydrated {} instances ({} ambiguous name+class groups, {} candidates scored)",
stats.hydrated,
stats.ambiguousGroups,
stats.candidatesScored
)
end
return hydrate return hydrate

View File

@@ -126,140 +126,4 @@ return function()
expect(knownInstances.fromIds["CHILD1"]).to.equal(child1) expect(knownInstances.fromIds["CHILD1"]).to.equal(child1)
expect(knownInstances.fromIds["CHILD2"]).to.equal(child2) expect(knownInstances.fromIds["CHILD2"]).to.equal(child2)
end) end)
it("should disambiguate duplicate-named siblings by matching properties", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = { "CHILD_A", "CHILD_B" },
},
CHILD_A = {
ClassName = "StringValue",
Name = "a",
Properties = { Value = { String = "first" } },
Children = {},
},
CHILD_B = {
ClassName = "StringValue",
Name = "a",
Properties = { Value = { String = "second" } },
Children = {},
},
}
local rootInstance = Instance.new("Folder")
-- Created in the reverse order of the virtual children, so a purely
-- order-based tiebreak would mis-pair them.
local child1 = Instance.new("StringValue")
child1.Name = "a"
child1.Value = "second"
child1.Parent = rootInstance
local child2 = Instance.new("StringValue")
child2.Name = "a"
child2.Value = "first"
child2.Parent = rootInstance
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(3)
expect(knownInstances.fromIds["CHILD_A"]).to.equal(child2)
expect(knownInstances.fromIds["CHILD_B"]).to.equal(child1)
end)
it("should fall back to child order for duplicate-named siblings with no distinguishing properties", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = { "CHILD_A", "CHILD_B" },
},
CHILD_A = {
ClassName = "Folder",
Name = "a",
Properties = {},
Children = {},
},
CHILD_B = {
ClassName = "Folder",
Name = "a",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
local child1 = Instance.new("Folder")
child1.Name = "a"
child1.Parent = rootInstance
local child2 = Instance.new("Folder")
child2.Name = "a"
child2.Parent = rootInstance
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(3)
-- With equal scores the earliest unvisited child wins, preserving the
-- original order-based behavior.
expect(knownInstances.fromIds["CHILD_A"]).to.equal(child1)
expect(knownInstances.fromIds["CHILD_B"]).to.equal(child2)
end)
it("should fall back to child order for very large duplicate-named groups", function()
-- More candidates than hydrate is willing to score at once. The group
-- must fall back to order-based matching, so virtual child N pairs with
-- existing child N regardless of properties.
local count = 64
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
local expectedInstances = {}
for i = 1, count do
local id = "CHILD_" .. i
table.insert(virtualInstances.ROOT.Children, id)
virtualInstances[id] = {
ClassName = "StringValue",
Name = "a",
-- Distinct values that, if scored, would pair by value rather
-- than by order.
Properties = { Value = { String = "value " .. i } },
Children = {},
}
local child = Instance.new("StringValue")
child.Name = "a"
child.Value = "value " .. (count - i + 1)
child.Parent = rootInstance
expectedInstances[id] = child
end
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(count + 1)
for id, expectedInstance in expectedInstances do
expect(knownInstances.fromIds[id]).to.equal(expectedInstance)
end
end)
end end

View File

@@ -1,100 +0,0 @@
--[[
Fuzzy value-equality used to compare a decoded virtual property value against
the live value read from a real instance. Shared by `diff` (to decide whether
a property changed) and `hydrate` (to score candidate instances).
]]
local function fuzzyEq(a: number, b: number, epsilon: number): boolean
return math.abs(a - b) < epsilon
end
local function trueEquals(a, b): boolean
-- Exit early for simple equality values
if a == b then
return true
end
-- Treat nil and { Ref = "000...0" } as equal
if
(a == nil and type(b) == "table" and b.Ref == "00000000000000000000000000000000")
or (b == nil and type(a) == "table" and a.Ref == "00000000000000000000000000000000")
then
return true
end
local typeA, typeB = typeof(a), typeof(b)
-- For tables, try recursive deep equality
if typeA == "table" and typeB == "table" then
local checkedKeys = {}
for key, value in a do
checkedKeys[key] = true
if not trueEquals(value, b[key]) then
return false
end
end
for key, value in b do
if checkedKeys[key] then
continue
end
if not trueEquals(value, a[key]) then
return false
end
end
return true
-- For NaN, check if both values are not equal to themselves
elseif a ~= a and b ~= b then
return true
-- For numbers, compare with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "number" and typeB == "number" then
return fuzzyEq(a, b, 0.0001)
-- For EnumItem->number, compare the EnumItem's value
elseif typeA == "number" and typeB == "EnumItem" then
return a == b.Value
elseif typeA == "EnumItem" and typeB == "number" then
return a.Value == b
-- For Color3s, compare to RGB ints to avoid floating point inequality
elseif typeA == "Color3" and typeB == "Color3" then
local aR, aG, aB = math.floor(a.R * 255), math.floor(a.G * 255), math.floor(a.B * 255)
local bR, bG, bB = math.floor(b.R * 255), math.floor(b.G * 255), math.floor(b.B * 255)
return aR == bR and aG == bG and aB == bB
-- For CFrames, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "CFrame" and typeB == "CFrame" then
local aComponents, bComponents = { a:GetComponents() }, { b:GetComponents() }
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
-- For Vector3s, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "Vector3" and typeB == "Vector3" then
local aComponents, bComponents = { a.X, a.Y, a.Z }, { b.X, b.Y, b.Z }
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
-- For Vector2s, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "Vector2" and typeB == "Vector2" then
local aComponents, bComponents = { a.X, a.Y }, { b.X, b.Y }
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
end
return false
end
return trueEquals

View File

@@ -18,7 +18,6 @@ local PatchSet = require(script.Parent.PatchSet)
local Reconciler = require(script.Parent.Reconciler) local Reconciler = require(script.Parent.Reconciler)
local strict = require(script.Parent.strict) local strict = require(script.Parent.strict)
local Settings = require(script.Parent.Settings) local Settings = require(script.Parent.Settings)
local orderSwaps = require(script.Parent.orderSwaps)
local Status = strict("Session.Status", { local Status = strict("Session.Status", {
NotStarted = "NotStarted", NotStarted = "NotStarted",
@@ -321,14 +320,6 @@ function ServeSession:__replaceInstances(idList)
return false return false
end end
-- Roblox appends to GetChildren() on every reparent, so the order in which
-- we re-parent replacements determines their final sibling order.
-- We process ancestors before descendants (so each replacement's
-- parent already exists when we re-parent it) and siblings in their original
-- GetChildren() order. Because the loop below moves the old instance's
-- children into the replacement *before* re-parenting the replacement, this
-- rebuilds GetChildren() exactly as it was before the swap.
local swaps = {}
for id, replacement in replacements do for id, replacement in replacements do
local oldInstance = self.__instanceMap.fromIds[id] local oldInstance = self.__instanceMap.fromIds[id]
if not oldInstance then if not oldInstance then
@@ -337,16 +328,6 @@ function ServeSession:__replaceInstances(idList)
continue continue
end end
table.insert(swaps, {
id = id,
replacement = replacement,
oldInstance = oldInstance,
})
end
for _, swap in orderSwaps(swaps) do
local id, replacement, oldInstance = swap.id, swap.replacement, swap.oldInstance
self.__instanceMap:insert(id, replacement) self.__instanceMap:insert(id, replacement)
Log.trace("Swapping Instance {} out via api/models/ endpoint", id) Log.trace("Swapping Instance {} out via api/models/ endpoint", id)
local oldParent = oldInstance.Parent local oldParent = oldInstance.Parent

View File

@@ -1,44 +0,0 @@
--[[
Determines the order in which `ServeSession:__replaceInstances` should swap
instances so that sibling order is preserved.
Roblox appends to `GetChildren()` on every reparent, so the order in which we
re-parent replacements determines their final sibling order. To rebuild
`GetChildren()` exactly as it was before the swap we must:
* process ancestors before descendants, so each replacement's parent already
exists when we re-parent the replacement, and
* process siblings in their original `GetChildren()` order.
`swaps` is an array of `{ id, replacement, oldInstance }` entries. This sorts
the array in place (annotating each entry with `depth`/`siblingIndex`) and
returns it.
]]
local function orderSwaps(swaps)
for _, swap in swaps do
local depth = 0
local ancestor = swap.oldInstance.Parent
while ancestor ~= nil do
depth += 1
ancestor = ancestor.Parent
end
swap.depth = depth
local siblingIndex = 0
if swap.oldInstance.Parent ~= nil then
siblingIndex = table.find(swap.oldInstance.Parent:GetChildren(), swap.oldInstance) or 0
end
swap.siblingIndex = siblingIndex
end
table.sort(swaps, function(a, b)
if a.depth ~= b.depth then
return a.depth < b.depth
end
return a.siblingIndex < b.siblingIndex
end)
return swaps
end
return orderSwaps

View File

@@ -1,57 +0,0 @@
return function()
local orderSwaps = require(script.Parent.orderSwaps)
it("orders same-named siblings by their original GetChildren order", function()
local parent = Instance.new("Model")
local a1 = Instance.new("Part")
a1.Name = "a"
a1.Parent = parent
local a2 = Instance.new("Part")
a2.Name = "a"
a2.Parent = parent
local a3 = Instance.new("Part")
a3.Name = "a"
a3.Parent = parent
-- Input deliberately out of sibling order.
-- orderSwaps must restore the GetChildren() order.
local ordered = orderSwaps({
{ id = "3", oldInstance = a3 },
{ id = "1", oldInstance = a1 },
{ id = "2", oldInstance = a2 },
})
expect(ordered[1].oldInstance).to.equal(a1)
expect(ordered[2].oldInstance).to.equal(a2)
expect(ordered[3].oldInstance).to.equal(a3)
end)
it("orders ancestors before descendants", function()
local root = Instance.new("Model")
local child = Instance.new("Folder")
child.Parent = root
local grandchild = Instance.new("Part")
grandchild.Parent = child
local ordered = orderSwaps({
{ id = "grandchild", oldInstance = grandchild },
{ id = "child", oldInstance = child },
{ id = "root", oldInstance = root },
})
expect(ordered[1].oldInstance).to.equal(root)
expect(ordered[2].oldInstance).to.equal(child)
expect(ordered[3].oldInstance).to.equal(grandchild)
end)
it("returns a single swap unchanged", function()
local part = Instance.new("Part")
local ordered = orderSwaps({
{ id = "1", oldInstance = part },
})
expect(#ordered).to.equal(1)
expect(ordered[1].oldInstance).to.equal(part)
end)
end

View File

@@ -1,16 +0,0 @@
---
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">json_model_legacy_name</string>
</Properties>
<Item class="Folder" referent="1">
<Properties>
<string name="Name">Expected Name</string>
</Properties>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,23 @@
---
source: tests/tests/build.rs
assertion_line: 109
expression: contents
---
<roblox version="4">
<Item class="DataModel" referent="0">
<Properties>
<string name="Name">model_json_name_input</string>
</Properties>
<Item class="Workspace" referent="1">
<Properties>
<string name="Name">Workspace</string>
<bool name="NeedsPivotMigration">false</bool>
</Properties>
<Item class="StringValue" referent="2">
<Properties>
<string name="Name">/Bar</string>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -1,27 +0,0 @@
---
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">plugin_init</string>
</Properties>
<Item class="Script" referent="1">
<Properties>
<string name="Name">lua</string>
<token name="RunContext">3</token>
<string name="Source"><![CDATA[return "From folder/lua/init.plugin.lua"
]]></string>
</Properties>
</Item>
<Item class="Script" referent="2">
<Properties>
<string name="Name">luau</string>
<token name="RunContext">3</token>
<string name="Source"><![CDATA[return "From folder/luau/init.plugin.luau"
]]></string>
</Properties>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,20 @@
---
source: tests/tests/build.rs
assertion_line: 108
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">slugified_name_roundtrip</string>
</Properties>
<Item class="Script" referent="1">
<Properties>
<string name="Name">/Script</string>
<token name="RunContext">0</token>
<string name="Source"><![CDATA[print("Hello world!")
]]></string>
</Properties>
</Item>
</Item>
</roblox>

View File

@@ -1,6 +0,0 @@
{
"name": "json_model_legacy_name",
"tree": {
"$path": "folder"
}
}

View File

@@ -1,4 +0,0 @@
{
"Name": "Overridden Name",
"ClassName": "Folder"
}

View File

@@ -0,0 +1,11 @@
{
"name": "model_json_name_input",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"$path": "src"
}
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "/Bar",
"className": "StringValue"
}

View File

@@ -1,6 +0,0 @@
{
"name": "plugin_init",
"tree": {
"$path": "folder"
}
}

View File

@@ -1 +0,0 @@
return "From folder/lua/init.plugin.lua"

View File

@@ -1 +0,0 @@
return "From folder/luau/init.plugin.luau"

View File

@@ -0,0 +1,4 @@
{
"name": "/Script"
}

View File

@@ -0,0 +1,2 @@
print("Hello world!")

View File

@@ -0,0 +1,6 @@
{
"name": "slugified_name_roundtrip",
"tree": {
"$path": "src"
}
}

View File

@@ -0,0 +1,3 @@
{
"name": "/Script"
}

View File

@@ -0,0 +1 @@
print("Hello world!")

View File

@@ -1,5 +1,6 @@
--- ---
source: tests/rojo_test/syncback_util.rs source: tests/rojo_test/syncback_util.rs
assertion_line: 101
expression: "String::from_utf8_lossy(&output.stdout)" expression: "String::from_utf8_lossy(&output.stdout)"
--- ---
Writing default.project.json

View File

@@ -1,73 +0,0 @@
---
source: tests/rojo_test/syncback_util.rs
expression: src/ChildWithDuplicates.rbxm
---
num_types: 1
num_instances: 3
chunks:
- Inst:
type_id: 0
type_name: Folder
object_format: 0
referents:
- 0
- 1
- 2
- Prop:
type_id: 0
prop_name: AttributesSerialize
prop_type: String
values:
- ""
- ""
- ""
- Prop:
type_id: 0
prop_name: Capabilities
prop_type: SecurityCapabilities
values:
- 0
- 0
- 0
- Prop:
type_id: 0
prop_name: Name
prop_type: String
values:
- DuplicateChild
- DuplicateChild
- ChildWithDuplicates
- Prop:
type_id: 0
prop_name: DefinesCapabilities
prop_type: Bool
values:
- false
- false
- false
- Prop:
type_id: 0
prop_name: SourceAssetId
prop_type: Int64
values:
- -1
- -1
- -1
- Prop:
type_id: 0
prop_name: Tags
prop_type: String
values:
- ""
- ""
- ""
- Prnt:
version: 0
links:
- - 0
- 2
- - 1
- 2
- - 2
- -1
- End

View File

@@ -1,9 +1,12 @@
--- ---
source: tests/rojo_test/syncback_util.rs source: tests/rojo_test/syncback_util.rs
assertion_line: 101
expression: "String::from_utf8_lossy(&output.stdout)" expression: "String::from_utf8_lossy(&output.stdout)"
--- ---
Writing src/ChildWithDuplicates.rbxm Writing src/ChildWithDuplicates/DuplicateChild/.gitkeep
Writing src/ChildWithDuplicates/DuplicateChild1/.gitkeep
Writing src/ChildWithoutDuplicates/Child/.gitkeep Writing src/ChildWithoutDuplicates/Child/.gitkeep
Writing src/ChildWithDuplicates/DuplicateChild
Writing src/ChildWithDuplicates/DuplicateChild1
Writing src/ChildWithoutDuplicates Writing src/ChildWithoutDuplicates
Writing src/ChildWithoutDuplicates/Child Writing src/ChildWithoutDuplicates/Child
Removing src/ChildWithDuplicates

View File

@@ -0,0 +1,13 @@
---
source: tests/rojo_test/syncback_util.rs
assertion_line: 101
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing default.project.json
Writing src/Camera.rbxm
Writing src/Terrain.rbxm
Writing src/_Folder/init.meta.json
Writing src/_Script.meta.json
Writing src/_Script.server.luau
Writing src
Writing src/_Folder

View File

@@ -0,0 +1,9 @@
---
source: tests/tests/syncback.rs
assertion_line: 31
expression: src/foo.model.json
---
{
"name": "/Bar",
"className": "StringValue"
}

View File

@@ -1,59 +0,0 @@
---
source: tests/tests/syncback.rs
expression: default.project.json
---
{
"name": "SyncbackTest",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"TestPart": {
"$className": "Part",
"$properties": {
"Anchored": true,
"Color": {
"Color3uint8": [
0,
0,
255
]
}
}
},
"$properties": {
"EnableSLIMAvatars": {
"Enum": 0
},
"ImprovedAnimationConstraint": {
"Enum": 0
},
"ImprovedPhysicsReplication": {
"Enum": 0
},
"LayeredClothingCacheOptimizations": {
"Enum": 0
},
"MeshStreamingAndImprovedLods": {
"Enum": 0
},
"NextGenerationReplication": {
"Enum": 0
},
"PlayerScriptsUseInputActionSystem": {
"Enum": 0
},
"UseFixedSimulation": {
"Enum": 0
},
"UseNewLuauTypeSolver": "Disabled",
"ValidateEnabledProximityPrompt": {
"Enum": 0
}
},
"$attributes": {
"Rojo_Target_CurrentCamera": "302d573157260ee80a3baa32000003b5"
}
}
}
}

View File

@@ -0,0 +1,6 @@
---
source: tests/tests/syncback.rs
assertion_line: 31
expression: src/ChildWithDuplicates/DuplicateChild1/.gitkeep
---

View File

@@ -0,0 +1,6 @@
---
source: tests/tests/syncback.rs
assertion_line: 31
expression: src/ChildWithDuplicates/DuplicateChild/.gitkeep
---

View File

@@ -0,0 +1,8 @@
---
source: tests/tests/syncback.rs
assertion_line: 31
expression: src/_Folder.model.json
---
{
"className": "Folder"
}

View File

@@ -0,0 +1,8 @@
---
source: tests/tests/syncback.rs
assertion_line: 31
expression: src/_Folder/init.meta.json
---
{
"name": "/Folder"
}

View File

@@ -0,0 +1,8 @@
---
source: tests/tests/syncback.rs
assertion_line: 31
expression: src/_Script.meta.json
---
{
"name": "/Script"
}

View File

@@ -0,0 +1,6 @@
---
source: tests/tests/syncback.rs
assertion_line: 31
expression: src/_Script.server.luau
---
print("Hello world!")

View File

@@ -0,0 +1,8 @@
---
source: tests/tests/syncback.rs
assertion_line: 31
expression: src/_Script/init.meta.json
---
{
"name": "/Script"
}

View File

@@ -0,0 +1,6 @@
---
source: tests/tests/syncback.rs
assertion_line: 31
expression: src/_Script/init.server.luau
---
print("Hello world!")

View File

@@ -0,0 +1,11 @@
{
"name": "model_json_name",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"$path": "src"
}
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "/Bar",
"className": "StringValue"
}

Binary file not shown.

View File

@@ -1,21 +0,0 @@
{
"name": "SyncbackTest",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"TestPart": {
"$className": "Part",
"$properties": {
"Transparency": 1.0,
"Anchored": true,
"Color": [
1.0,
0.0,
0.0
]
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
{
"name": "slugified_name",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"$path": "src"
}
}
}

Binary file not shown.

View File

@@ -3,4 +3,3 @@ rojo = "rojo-rbx/rojo@7.5.1"
selene = "Kampfkarren/selene@0.29.0" selene = "Kampfkarren/selene@0.29.0"
stylua = "JohnnyMorganz/stylua@2.1.0" stylua = "JohnnyMorganz/stylua@2.1.0"
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"
lune = "lune-org/lune@0.10.4"

View File

@@ -200,15 +200,7 @@ impl JobThreadContext {
if let Some(instance) = tree.get_instance(id) { if let Some(instance) = tree.get_instance(id) {
if let Some(instigating_source) = &instance.metadata().instigating_source { if let Some(instigating_source) = &instance.metadata().instigating_source {
match instigating_source { match instigating_source {
InstigatingSource::Path(path) => { InstigatingSource::Path(path) => fs::remove_file(path).unwrap(),
if let Err(err) = fs::remove_file(path) {
log::error!(
"Failed to remove file {}: {}",
path.display(),
err
);
}
}
InstigatingSource::ProjectNode { .. } => { InstigatingSource::ProjectNode { .. } => {
log::warn!( log::warn!(
"Cannot remove instance {:?}, it's from a project file", "Cannot remove instance {:?}, it's from a project file",
@@ -252,13 +244,7 @@ impl JobThreadContext {
match instigating_source { match instigating_source {
InstigatingSource::Path(path) => { InstigatingSource::Path(path) => {
if let Some(Variant::String(value)) = changed_value { if let Some(Variant::String(value)) = changed_value {
if let Err(err) = fs::write(path, value) { fs::write(path, value).unwrap();
log::error!(
"Failed to write file {}: {}",
path.display(),
err
);
}
} else { } else {
log::warn!("Cannot change Source to non-string value."); log::warn!("Cannot change Source to non-string value.");
} }

View File

@@ -75,10 +75,10 @@ impl BuildCommand {
_ => unreachable!(), _ => unreachable!(),
}; };
let project_path = resolve_path(&self.project)?; let project_path = resolve_path(&self.project);
log::trace!("Constructing in-memory filesystem"); log::trace!("Constructing in-memory filesystem");
let vfs = Vfs::new_default()?; let vfs = Vfs::new_default();
vfs.set_watch_enabled(self.watch); vfs.set_watch_enabled(self.watch);
let session = ServeSession::new(vfs, project_path)?; let session = ServeSession::new(vfs, project_path)?;
@@ -87,16 +87,11 @@ impl BuildCommand {
write_model(&session, &output_path, output_kind)?; write_model(&session, &output_path, output_kind)?;
if self.watch { if self.watch {
let rt = Runtime::new().context("Failed to start the async runtime for watch mode")?; let rt = Runtime::new().unwrap();
loop { loop {
let receiver = session.message_queue().subscribe(cursor); let receiver = session.message_queue().subscribe(cursor);
let (new_cursor, _patch_set) = match rt.block_on(receiver) { let (new_cursor, _patch_set) = rt.block_on(receiver).unwrap();
Ok(message) => message,
// The message queue was dropped, so there is nothing left
// to watch. Stop watching gracefully.
Err(_) => break,
};
cursor = new_cursor; cursor = new_cursor;
write_model(&session, &output_path, output_kind)?; write_model(&session, &output_path, output_kind)?;

View File

@@ -18,10 +18,10 @@ pub struct FmtProjectCommand {
impl FmtProjectCommand { impl FmtProjectCommand {
pub fn run(self) -> anyhow::Result<()> { pub fn run(self) -> anyhow::Result<()> {
let vfs = Vfs::new_default()?; let vfs = Vfs::new_default();
vfs.set_watch_enabled(false); vfs.set_watch_enabled(false);
let base_path = resolve_path(&self.project)?; let base_path = resolve_path(&self.project);
let project = Project::load_fuzzy(&vfs, &base_path)? let project = Project::load_fuzzy(&vfs, &base_path)?
.context("A project file is required to run 'rojo fmt-project'")?; .context("A project file is required to run 'rojo fmt-project'")?;

View File

@@ -9,7 +9,7 @@ use std::{
io::{self, Write}, io::{self, Write},
}; };
use anyhow::{bail, format_err, Context}; use anyhow::{bail, format_err};
use clap::Parser; use clap::Parser;
use fs_err as fs; use fs_err as fs;
use fs_err::OpenOptions; use fs_err::OpenOptions;
@@ -42,9 +42,9 @@ pub struct InitCommand {
impl InitCommand { impl InitCommand {
pub fn run(self) -> anyhow::Result<()> { pub fn run(self) -> anyhow::Result<()> {
let template = self.kind.template()?; let template = self.kind.template();
let base_path = resolve_path(&self.path)?; let base_path = resolve_path(&self.path);
fs::create_dir_all(&base_path)?; fs::create_dir_all(&base_path)?;
let canonical = fs::canonicalize(&base_path)?; let canonical = fs::canonicalize(&base_path)?;
@@ -128,7 +128,7 @@ pub enum InitKind {
} }
impl InitKind { impl InitKind {
fn template(&self) -> anyhow::Result<InMemoryFs> { fn template(&self) -> InMemoryFs {
let template_path = match self { let template_path = match self {
Self::Place => "place", Self::Place => "place",
Self::Model => "model", Self::Model => "model",
@@ -136,24 +136,20 @@ impl InitKind {
}; };
let snapshot: VfsSnapshot = bincode::deserialize(TEMPLATE_BINCODE) let snapshot: VfsSnapshot = bincode::deserialize(TEMPLATE_BINCODE)
.context("Rojo's templates were not properly packed into Rojo's binary. This is a bug in Rojo; please file an issue.")?; .expect("Rojo's templates were not properly packed into Rojo's binary");
let VfsSnapshot::Dir { mut children } = snapshot else { if let VfsSnapshot::Dir { mut children } = snapshot {
bail!("Rojo's templates were packed as a file instead of a directory. This is a bug in Rojo; please file an issue."); if let Some(template) = children.remove(template_path) {
}; let mut fs = InMemoryFs::new();
fs.load_snapshot("", template)
let template = children.remove(template_path).ok_or_else(|| { .expect("loading a template in memory should never fail");
format_err!( fs
"The template for project type {:?} is missing. This is a bug in Rojo; please file an issue.", } else {
self panic!("template for project type {:?} is missing", self)
) }
})?; } else {
panic!("Rojo's templates were packed as a file instead of a directory")
let mut fs = InMemoryFs::new(); }
fs.load_snapshot("", template)
.context("Failed to load Rojo's bundled template into memory")?;
Ok(fs)
} }
} }

View File

@@ -12,7 +12,6 @@ mod upload;
use std::{borrow::Cow, env, path::Path, str::FromStr}; use std::{borrow::Cow, env, path::Path, str::FromStr};
use anyhow::Context;
use clap::Parser; use clap::Parser;
use thiserror::Error; use thiserror::Error;
@@ -126,14 +125,10 @@ pub enum Subcommand {
Syncback(SyncbackCommand), Syncback(SyncbackCommand),
} }
pub(super) fn resolve_path(path: &Path) -> anyhow::Result<Cow<'_, Path>> { pub(super) fn resolve_path(path: &Path) -> Cow<'_, Path> {
if path.is_absolute() { if path.is_absolute() {
Ok(Cow::Borrowed(path)) Cow::Borrowed(path)
} else { } else {
let current_dir = env::current_dir().context( Cow::Owned(env::current_dir().unwrap().join(path))
"Could not determine the current working directory. \
It may have been deleted, or Rojo may not have permission to access it.",
)?;
Ok(Cow::Owned(current_dir.join(path)))
} }
} }

View File

@@ -98,5 +98,5 @@ fn uninstall_plugin() -> anyhow::Result<()> {
#[test] #[test]
fn plugin_initialize() { fn plugin_initialize() {
let _ = initialize_plugin().unwrap(); assert!(initialize_plugin().is_ok())
} }

View File

@@ -31,21 +31,13 @@ pub struct ServeCommand {
/// it has none. /// it has none.
#[clap(long)] #[clap(long)]
pub port: Option<u16>, pub port: Option<u16>,
/// Extra `Host`/`Origin` values the server will accept, beyond localhost and
/// the bind address (for example a hostname like `mypc.lan`). Repeat the
/// option or comma-separate to allow several. When given, this overrides the
/// project's `serveAllowedHosts`. Listing any host also turns on Host/Origin
/// validation for binds where it is otherwise off (such as `0.0.0.0`).
#[clap(long, value_delimiter = ',')]
pub allowed_hosts: Vec<String>,
} }
impl ServeCommand { impl ServeCommand {
pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> { pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> {
let project_path = resolve_path(&self.project)?; let project_path = resolve_path(&self.project);
let vfs = Vfs::new_default()?; let vfs = Vfs::new_default();
let session = Arc::new(ServeSession::new(vfs, project_path)?); let session = Arc::new(ServeSession::new(vfs, project_path)?);
@@ -59,19 +51,10 @@ impl ServeCommand {
.or_else(|| session.project_port()) .or_else(|| session.project_port())
.unwrap_or(DEFAULT_PORT); .unwrap_or(DEFAULT_PORT);
// The CLI flag, when given, replaces the project's list rather than
// merging with it, matching how --address and --port override theirs.
let allowed_hosts = if self.allowed_hosts.is_empty() {
session.serve_allowed_hosts().to_vec()
} else {
self.allowed_hosts
};
let server = LiveServer::new(session); let server = LiveServer::new(session);
server.start((ip, port).into(), allowed_hosts, || { let _ = show_start_message(ip, port, global.color.into());
let _ = show_start_message(ip, port, global.color.into()); server.start((ip, port).into());
})?;
Ok(()) Ok(())
} }
@@ -103,25 +86,6 @@ fn show_start_message(bind_address: IpAddr, port: u16, color: ColorChoice) -> io
writeln!(&mut buffer)?; writeln!(&mut buffer)?;
if !bind_address.is_loopback() {
let mut warning = ColorSpec::new();
warning.set_fg(Some(Color::Yellow)).set_bold(true);
buffer.set_color(&warning)?;
writeln!(
&mut buffer,
"WARNING: This server is bound to {address_string}, which is reachable from the \
network.\n\
The serve API is unauthenticated, so anyone who can reach {address_string}:{port} \
can read\n\
and modify your project's source. Prefer binding to localhost and tunneling (e.g. \
SSH,\n\
Tailscale, or WireGuard) when you need remote access."
)?;
buffer.set_color(&ColorSpec::new())?;
writeln!(&mut buffer)?;
}
buffer.set_color(&ColorSpec::new())?; buffer.set_color(&ColorSpec::new())?;
write!(&mut buffer, "Visit ")?; write!(&mut buffer, "Visit ")?;

View File

@@ -1,35 +0,0 @@
---
source: src/cli/sourcemap.rs
expression: sourcemap_contents
---
{
"name": "default",
"className": "DataModel",
"filePaths": "[...1 path omitted...]",
"children": [
{
"name": "ReplicatedStorage",
"className": "ReplicatedStorage",
"children": [
{
"name": "Project",
"className": "ModuleScript",
"filePaths": "[...1 path omitted...]",
"children": [
{
"name": "Module",
"className": "Folder",
"children": [
{
"name": "module",
"className": "ModuleScript",
"filePaths": "[...1 path omitted...]"
}
]
}
]
}
]
}
]
}

View File

@@ -1,41 +0,0 @@
---
source: src/cli/sourcemap.rs
expression: sourcemap_contents
---
{
"name": "default",
"className": "DataModel",
"filePaths": [
"default.project.json"
],
"children": [
{
"name": "ReplicatedStorage",
"className": "ReplicatedStorage",
"children": [
{
"name": "Project",
"className": "ModuleScript",
"filePaths": [
"src/init.luau"
],
"children": [
{
"name": "Module",
"className": "Folder",
"children": [
{
"name": "module",
"className": "ModuleScript",
"filePaths": [
"../module/module.luau"
]
}
]
}
]
}
]
}
]
}

View File

@@ -5,13 +5,12 @@ use std::{
path::{self, Path, PathBuf}, path::{self, Path, PathBuf},
}; };
use anyhow::Context;
use clap::Parser; use clap::Parser;
use fs_err::File; use fs_err::File;
use memofs::Vfs; use memofs::Vfs;
use rayon::prelude::*; use rayon::prelude::*;
use rbx_dom_weak::{types::Ref, Ustr}; use rbx_dom_weak::{types::Ref, Ustr};
use serde::{Deserialize, Serialize}; use serde::Serialize;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use crate::{ use crate::{
@@ -25,20 +24,19 @@ const PATH_STRIP_FAILED_ERR: &str = "Failed to create relative paths for project
const ABSOLUTE_PATH_FAILED_ERR: &str = "Failed to turn relative path into absolute path!"; const ABSOLUTE_PATH_FAILED_ERR: &str = "Failed to turn relative path into absolute path!";
/// Representation of a node in the generated sourcemap tree. /// Representation of a node in the generated sourcemap tree.
#[derive(Serialize, Deserialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct SourcemapNode<'a> { struct SourcemapNode<'a> {
name: &'a str, name: &'a str,
class_name: Ustr, class_name: Ustr,
#[serde( #[serde(
default,
skip_serializing_if = "Vec::is_empty", skip_serializing_if = "Vec::is_empty",
serialize_with = "crate::path_serializer::serialize_vec_absolute" serialize_with = "crate::path_serializer::serialize_vec_absolute"
)] )]
file_paths: Vec<Cow<'a, Path>>, file_paths: Vec<Cow<'a, Path>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
children: Vec<SourcemapNode<'a>>, children: Vec<SourcemapNode<'a>>,
} }
@@ -72,13 +70,12 @@ pub struct SourcemapCommand {
impl SourcemapCommand { impl SourcemapCommand {
pub fn run(self) -> anyhow::Result<()> { pub fn run(self) -> anyhow::Result<()> {
let project_path = fs_err::canonicalize(resolve_path(&self.project)?)?; let project_path = resolve_path(&self.project);
log::trace!("Constructing filesystem with StdBackend"); log::trace!("Constructing in-memory filesystem");
let vfs = Vfs::new_default()?; let vfs = Vfs::new_default();
vfs.set_watch_enabled(self.watch); vfs.set_watch_enabled(self.watch);
log::trace!("Setting up session for sourcemap generation");
let session = ServeSession::new(vfs, project_path)?; let session = ServeSession::new(vfs, project_path)?;
let mut cursor = session.message_queue().cursor(); let mut cursor = session.message_queue().cursor();
@@ -90,27 +87,19 @@ impl SourcemapCommand {
// Pre-build a rayon threadpool with a low number of threads to avoid // Pre-build a rayon threadpool with a low number of threads to avoid
// dynamic creation overhead on systems with a high number of cpus. // dynamic creation overhead on systems with a high number of cpus.
log::trace!("Setting rayon global threadpool");
rayon::ThreadPoolBuilder::new() rayon::ThreadPoolBuilder::new()
.num_threads(num_cpus::get().min(6)) .num_threads(num_cpus::get().min(6))
.build_global() .build_global()
.ok(); .unwrap();
log::trace!("Writing initial sourcemap");
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?; write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
if self.watch { if self.watch {
log::trace!("Setting up runtime for watch mode"); let rt = Runtime::new().unwrap();
let rt = Runtime::new().context("Failed to start the async runtime for watch mode")?;
loop { loop {
let receiver = session.message_queue().subscribe(cursor); let receiver = session.message_queue().subscribe(cursor);
let (new_cursor, patch_set) = match rt.block_on(receiver) { let (new_cursor, patch_set) = rt.block_on(receiver).unwrap();
Ok(message) => message,
// The message queue was dropped, so there is nothing left
// to watch. Stop watching gracefully.
Err(_) => break,
};
cursor = new_cursor; cursor = new_cursor;
if patch_set_affects_sourcemap(&session, &patch_set, filter) { if patch_set_affects_sourcemap(&session, &patch_set, filter) {
@@ -219,7 +208,7 @@ fn recurse_create_node<'a>(
} else { } else {
for val in file_paths { for val in file_paths {
output_file_paths.push(Cow::from( output_file_paths.push(Cow::from(
pathdiff::diff_paths(val, project_dir).expect(PATH_STRIP_FAILED_ERR), val.strip_prefix(project_dir).expect(PATH_STRIP_FAILED_ERR),
)); ));
} }
}; };
@@ -261,80 +250,3 @@ fn write_sourcemap(
Ok(()) Ok(())
} }
#[cfg(test)]
mod test {
use crate::cli::sourcemap::SourcemapNode;
use crate::cli::SourcemapCommand;
use insta::internals::Content;
use std::path::Path;
#[test]
fn maps_relative_paths() {
let sourcemap_dir = tempfile::tempdir().unwrap();
let sourcemap_output = sourcemap_dir.path().join("sourcemap.json");
let project_path = fs_err::canonicalize(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("test-projects")
.join("relative_paths")
.join("project"),
)
.unwrap();
let sourcemap_command = SourcemapCommand {
project: project_path,
output: Some(sourcemap_output.clone()),
include_non_scripts: false,
watch: false,
absolute: false,
};
assert!(sourcemap_command.run().is_ok());
let raw_sourcemap_contents = fs_err::read_to_string(sourcemap_output.as_path()).unwrap();
let sourcemap_contents =
serde_json::from_str::<SourcemapNode>(&raw_sourcemap_contents).unwrap();
insta::assert_json_snapshot!(sourcemap_contents);
}
#[test]
fn maps_absolute_paths() {
let sourcemap_dir = tempfile::tempdir().unwrap();
let sourcemap_output = sourcemap_dir.path().join("sourcemap.json");
let project_path = fs_err::canonicalize(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("test-projects")
.join("relative_paths")
.join("project"),
)
.unwrap();
let sourcemap_command = SourcemapCommand {
project: project_path,
output: Some(sourcemap_output.clone()),
include_non_scripts: false,
watch: false,
absolute: true,
};
assert!(sourcemap_command.run().is_ok());
let raw_sourcemap_contents = fs_err::read_to_string(sourcemap_output.as_path()).unwrap();
let sourcemap_contents =
serde_json::from_str::<SourcemapNode>(&raw_sourcemap_contents).unwrap();
insta::assert_json_snapshot!(sourcemap_contents, {
".**.filePaths" => insta::dynamic_redaction(|mut value, _path| {
let mut paths_count = 0;
match value {
Content::Seq(ref mut vec) => {
for path in vec.iter().map(|i| i.as_str().unwrap()) {
assert_eq!(fs_err::canonicalize(path).is_ok(), true, "path was not valid");
assert_eq!(Path::new(path).is_absolute(), true, "path was not absolute");
paths_count += 1;
}
}
_ => panic!("Expected filePaths to be a sequence"),
}
format!("[...{} path{} omitted...]", paths_count, if paths_count != 1 { "s" } else { "" } )
})
});
}
}

View File

@@ -58,8 +58,8 @@ pub struct SyncbackCommand {
impl SyncbackCommand { impl SyncbackCommand {
pub fn run(&self, global: GlobalOptions) -> anyhow::Result<()> { pub fn run(&self, global: GlobalOptions) -> anyhow::Result<()> {
let path_old = resolve_path(&self.project)?; let path_old = resolve_path(&self.project);
let path_new = resolve_path(&self.input)?; let path_new = resolve_path(&self.input);
let input_kind = FileKind::from_path(&path_new).context(UNKNOWN_INPUT_KIND_ERR)?; let input_kind = FileKind::from_path(&path_new).context(UNKNOWN_INPUT_KIND_ERR)?;
let dom_start_timer = Instant::now(); let dom_start_timer = Instant::now();
@@ -69,7 +69,7 @@ impl SyncbackCommand {
dom_start_timer.elapsed().as_secs_f32() dom_start_timer.elapsed().as_secs_f32()
); );
let vfs = Vfs::new_default()?; let vfs = Vfs::new_default();
vfs.set_watch_enabled(false); vfs.set_watch_enabled(false);
let project_start_timer = Instant::now(); let project_start_timer = Instant::now();

View File

@@ -38,9 +38,9 @@ pub struct UploadCommand {
impl UploadCommand { impl UploadCommand {
pub fn run(self) -> Result<(), anyhow::Error> { pub fn run(self) -> Result<(), anyhow::Error> {
let project_path = resolve_path(&self.project)?; let project_path = resolve_path(&self.project);
let vfs = Vfs::new_default()?; let vfs = Vfs::new_default();
let session = ServeSession::new(vfs, project_path)?; let session = ServeSession::new(vfs, project_path)?;

View File

@@ -48,61 +48,3 @@ impl<'de> Deserialize<'de> for Glob {
Glob::new(&glob).map_err(D::Error::custom) Glob::new(&glob).map_err(D::Error::custom)
} }
} }
/// A glob with optional gitignore-style negation. A leading `!` marks the
/// pattern as a negation (re-includes paths that an earlier rule excluded).
/// To match a literal `!` at the start of a pattern, escape it with `\!`.
#[derive(Debug, Clone)]
pub struct IgnorableGlob {
glob: Glob,
negated: bool,
raw: String,
}
impl IgnorableGlob {
pub fn new(pattern: &str) -> Result<Self, Error> {
let (negated, body) = if let Some(rest) = pattern.strip_prefix('!') {
(true, rest)
} else if pattern.starts_with(r"\!") {
(false, &pattern[1..])
} else {
(false, pattern)
};
Ok(IgnorableGlob {
glob: Glob::new(body)?,
negated,
raw: pattern.to_owned(),
})
}
pub fn is_match<P: AsRef<Path>>(&self, path: P) -> bool {
self.glob.is_match(path)
}
pub fn is_negation(&self) -> bool {
self.negated
}
}
impl PartialEq for IgnorableGlob {
fn eq(&self, other: &Self) -> bool {
self.negated == other.negated && self.glob == other.glob
}
}
impl Eq for IgnorableGlob {}
impl Serialize for IgnorableGlob {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.raw)
}
}
impl<'de> Deserialize<'de> for IgnorableGlob {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let pattern = String::deserialize(deserializer)?;
IgnorableGlob::new(&pattern).map_err(D::Error::custom)
}
}

View File

@@ -12,8 +12,7 @@ use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
glob::IgnorableGlob, json, resolution::UnresolvedValue, snapshot::SyncRule, glob::Glob, json, resolution::UnresolvedValue, snapshot::SyncRule, syncback::SyncbackRules,
syncback::SyncbackRules,
}; };
/// Represents 'default' project names that act as `init` files /// Represents 'default' project names that act as `init` files
@@ -106,15 +105,6 @@ pub struct Project {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub serve_address: Option<IpAddr>, pub serve_address: Option<IpAddr>,
/// Additional `Host`/`Origin` header values that `rojo serve` will accept
/// beyond `localhost` and the bind address, such as a hostname like
/// `mypc.lan` used to reach a network-exposed server by name. Listing any
/// host also turns on `Host`/`Origin` validation for binds where it is
/// otherwise off (such as `0.0.0.0`). The `--allowed-hosts` CLI option
/// overrides this field when provided.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub serve_allowed_hosts: Vec<String>,
/// Determines if Rojo should emit scripts with the appropriate `RunContext` /// Determines if Rojo should emit scripts with the appropriate `RunContext`
/// for `*.client.lua` and `*.server.lua` files in the project instead of /// for `*.client.lua` and `*.server.lua` files in the project instead of
/// using `Script` and `LocalScript` Instances. /// using `Script` and `LocalScript` Instances.
@@ -124,7 +114,7 @@ pub struct Project {
/// A list of globs, relative to the folder the project file is in, that /// A list of globs, relative to the folder the project file is in, that
/// match files that should be excluded if Rojo encounters them. /// match files that should be excluded if Rojo encounters them.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub glob_ignore_paths: Vec<IgnorableGlob>, pub glob_ignore_paths: Vec<Glob>,
/// A list of rules for syncback with this project file. /// A list of rules for syncback with this project file.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -603,90 +593,4 @@ mod test {
assert!(project.sync_rules[0].include.is_match("data.data.json")); assert!(project.sync_rules[0].include.is_match("data.data.json"));
assert!(project.sync_rules[1].include.is_match("init.module.lua")); assert!(project.sync_rules[1].include.is_match("init.module.lua"));
} }
#[test]
fn project_with_serve_allowed_hosts() {
let project_json = r#"{
"name": "TestProject",
"tree": { "$path": "src" },
"serveAllowedHosts": ["mypc.lan", "192.168.1.5"]
}"#;
let project = Project::load_from_slice(
project_json.as_bytes(),
PathBuf::from("/test/default.project.json"),
None,
)
.expect("Failed to parse project with serveAllowedHosts");
assert_eq!(project.serve_allowed_hosts, vec!["mypc.lan", "192.168.1.5"]);
}
#[test]
fn project_without_serve_allowed_hosts_defaults_to_empty() {
let project_json = r#"{
"name": "TestProject",
"tree": { "$path": "src" }
}"#;
let project = Project::load_from_slice(
project_json.as_bytes(),
PathBuf::from("/test/default.project.json"),
None,
)
.expect("Failed to parse project");
assert!(project.serve_allowed_hosts.is_empty());
}
#[test]
fn glob_ignore_paths_negation() {
let project_json = r#"{
"name": "TestProject",
"tree": { "$path": "src" },
"globIgnorePaths": [
"**/*.spec.lua",
"!keep.spec.lua",
"\\!literal.lua"
]
}"#;
let project = Project::load_from_slice(
project_json.as_bytes(),
PathBuf::from("/test/default.project.json"),
None,
)
.expect("project should parse");
let paths = &project.glob_ignore_paths;
assert_eq!(paths.len(), 3);
assert!(!paths[0].is_negation());
assert!(paths[0].is_match("foo.spec.lua"));
assert!(paths[1].is_negation());
assert!(paths[1].is_match("keep.spec.lua"));
// `\!literal.lua` should match a file literally named `!literal.lua`,
// not be parsed as a negation.
assert!(!paths[2].is_negation());
assert!(paths[2].is_match("!literal.lua"));
let rules: Vec<_> = paths
.iter()
.map(|g| crate::snapshot::PathIgnoreRule {
base_path: PathBuf::from("/test"),
glob: g.clone(),
})
.collect();
assert!(crate::snapshot::is_path_ignored(
&rules,
"/test/foo.spec.lua"
));
assert!(!crate::snapshot::is_path_ignored(
&rules,
"/test/keep.spec.lua"
));
assert!(!crate::snapshot::is_path_ignored(&rules, "/test/plain.lua"));
}
} }

View File

@@ -207,10 +207,6 @@ impl ServeSession {
self.root_project.serve_address self.root_project.serve_address
} }
pub fn serve_allowed_hosts(&self) -> &[String] {
&self.root_project.serve_allowed_hosts
}
pub fn root_dir(&self) -> &Path { pub fn root_dir(&self) -> &Path {
self.root_project.folder_location() self.root_project.folder_location()
} }

View File

@@ -8,7 +8,7 @@ use anyhow::Context;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
glob::{Glob, IgnorableGlob}, glob::Glob,
path_serializer, path_serializer,
project::ProjectNode, project::ProjectNode,
snapshot_middleware::{emit_legacy_scripts_default, Middleware}, snapshot_middleware::{emit_legacy_scripts_default, Middleware},
@@ -70,6 +70,12 @@ pub struct InstanceMetadata {
/// A schema provided via a JSON file, if one exists. Will be `None` for /// A schema provided via a JSON file, if one exists. Will be `None` for
/// all non-JSON middleware. /// all non-JSON middleware.
pub schema: Option<String>, pub schema: Option<String>,
/// A custom name specified via meta.json or model.json files. If present,
/// this name will be used for the instance while the filesystem name will
/// be slugified to remove illegal characters.
#[serde(skip_serializing_if = "Option::is_none")]
pub specified_name: Option<String>,
} }
impl InstanceMetadata { impl InstanceMetadata {
@@ -82,6 +88,7 @@ impl InstanceMetadata {
specified_id: None, specified_id: None,
middleware: None, middleware: None,
schema: None, schema: None,
specified_name: None,
} }
} }
@@ -130,6 +137,13 @@ impl InstanceMetadata {
pub fn schema(self, schema: Option<String>) -> Self { pub fn schema(self, schema: Option<String>) -> Self {
Self { schema, ..self } Self { schema, ..self }
} }
pub fn specified_name(self, specified_name: Option<String>) -> Self {
Self {
specified_name,
..self
}
}
} }
impl Default for InstanceMetadata { impl Default for InstanceMetadata {
@@ -222,37 +236,18 @@ pub struct PathIgnoreRule {
pub base_path: PathBuf, pub base_path: PathBuf,
/// The actual glob that can be matched against the input path. /// The actual glob that can be matched against the input path.
pub glob: IgnorableGlob, pub glob: Glob,
} }
impl PathIgnoreRule { impl PathIgnoreRule {
pub fn matches<P: AsRef<Path>>(&self, path: P) -> bool { pub fn passes<P: AsRef<Path>>(&self, path: P) -> bool {
let path = path.as_ref(); let path = path.as_ref();
match path.strip_prefix(&self.base_path) { match path.strip_prefix(&self.base_path) {
Ok(suffix) => self.glob.is_match(suffix), Ok(suffix) => !self.glob.is_match(suffix),
Err(_) => false, Err(_) => true,
} }
} }
pub fn is_negation(&self) -> bool {
self.glob.is_negation()
}
}
/// Evaluates an ordered list of [`PathIgnoreRule`]s against a path using
/// gitignore-style "last match wins" semantics: a path is ignored if the last
/// rule whose pattern matches it is non-negated. Paths matched by no rule are
/// not ignored.
pub fn is_path_ignored<P: AsRef<Path>>(rules: &[PathIgnoreRule], path: P) -> bool {
let path = path.as_ref();
let mut ignored = false;
for rule in rules {
if rule.matches(path) {
ignored = !rule.is_negation();
}
}
ignored
} }
/// Represents where a particular Instance or InstanceSnapshot came from. /// Represents where a particular Instance or InstanceSnapshot came from.

View File

@@ -8,7 +8,7 @@ use rbx_dom_weak::{
ustr, HashMapExt as _, UstrMap, UstrSet, ustr, HashMapExt as _, UstrMap, UstrSet,
}; };
use crate::{variant_eq::variant_eq, RojoRef, REF_POINTER_ATTRIBUTE_PREFIX}; use crate::{RojoRef, REF_POINTER_ATTRIBUTE_PREFIX};
use super::{ use super::{
patch::{PatchAdd, PatchSet, PatchUpdate}, patch::{PatchAdd, PatchSet, PatchUpdate},
@@ -127,7 +127,7 @@ fn compute_property_patches(
match instance.properties().get(&name) { match instance.properties().get(&name) {
Some(instance_value) => { Some(instance_value) => {
if !variant_eq(&snapshot_value, instance_value) { if &snapshot_value != instance_value {
changed_properties.insert(name, Some(snapshot_value)); changed_properties.insert(name, Some(snapshot_value));
} }
} }

View File

@@ -109,8 +109,14 @@ pub fn syncback_csv<'sync>(
if !meta.is_empty() { if !meta.is_empty() {
let parent = snapshot.path.parent_err()?; let parent = snapshot.path.parent_err()?;
let file_name = snapshot
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let meta_stem = file_name.strip_suffix(".csv").unwrap_or(file_name);
fs_snapshot.add_file( fs_snapshot.add_file(
parent.join(format!("{}.meta.json", new_inst.name)), parent.join(format!("{meta_stem}.meta.json")),
serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?, serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?,
) )
} }
@@ -195,7 +201,7 @@ struct LocalizationEntry<'a> {
/// https://github.com/BurntSushi/rust-csv/issues/151 /// https://github.com/BurntSushi/rust-csv/issues/151
/// ///
/// This function operates in one step in order to minimize data-copying. /// This function operates in one step in order to minimize data-copying.
fn convert_localization_csv(contents: &[u8]) -> anyhow::Result<String> { fn convert_localization_csv(contents: &[u8]) -> Result<String, csv::Error> {
let mut reader = csv::Reader::from_reader(contents); let mut reader = csv::Reader::from_reader(contents);
let headers = reader.headers()?.clone(); let headers = reader.headers()?.clone();
@@ -237,7 +243,7 @@ fn convert_localization_csv(contents: &[u8]) -> anyhow::Result<String> {
} }
let encoded = let encoded =
serde_json::to_string(&entries).context("Could not encode JSON for localization table")?; serde_json::to_string(&entries).expect("Could not encode JSON for localization table");
Ok(encoded) Ok(encoded)
} }

View File

@@ -7,13 +7,14 @@ use anyhow::Context;
use memofs::{DirEntry, Vfs}; use memofs::{DirEntry, Vfs};
use crate::{ use crate::{
snapshot::{ snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource},
is_path_ignored, InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource, syncback::{
extension_for_middleware, hash_instance, FsSnapshot, SyncbackReturn,
SyncbackSnapshot,
}, },
syncback::{hash_instance, FsSnapshot, SyncbackReturn, SyncbackSnapshot},
}; };
use super::{meta_file::DirectoryMetadata, snapshot_from_vfs}; use super::{meta_file::DirectoryMetadata, snapshot_from_vfs, Middleware};
const EMPTY_DIR_KEEP_NAME: &str = ".gitkeep"; const EMPTY_DIR_KEEP_NAME: &str = ".gitkeep";
@@ -43,8 +44,12 @@ pub fn snapshot_dir_no_meta(
path: &Path, path: &Path,
name: &str, name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> { ) -> anyhow::Result<Option<InstanceSnapshot>> {
let passes_filter_rules = let passes_filter_rules = |child: &DirEntry| {
|child: &DirEntry| !is_path_ignored(&context.path_ignore_rules, child.path()); context
.path_ignore_rules
.iter()
.all(|rule| rule.passes(child.path()))
};
let mut snapshot_children = Vec::new(); let mut snapshot_children = Vec::new();
@@ -72,8 +77,6 @@ pub fn snapshot_dir_no_meta(
normalized_path.join("init.server.luau"), normalized_path.join("init.server.luau"),
normalized_path.join("init.client.lua"), normalized_path.join("init.client.lua"),
normalized_path.join("init.client.luau"), normalized_path.join("init.client.luau"),
normalized_path.join("init.plugin.lua"),
normalized_path.join("init.plugin.luau"),
normalized_path.join("init.csv"), normalized_path.join("init.csv"),
]; ];
@@ -91,6 +94,22 @@ pub fn snapshot_dir_no_meta(
Ok(Some(snapshot)) Ok(Some(snapshot))
} }
/// Splits a filesystem name into (stem, extension) based on middleware type.
/// For directory middleware, the extension is empty. For file middleware,
/// the extension comes from `extension_for_middleware`.
fn split_name_and_ext(name: &str, middleware: Middleware) -> (&str, &str) {
if middleware.is_dir() {
(name, "")
} else {
let ext = extension_for_middleware(middleware);
if let Some(stem) = name.strip_suffix(&format!(".{ext}")) {
(stem, ext)
} else {
(name, "")
}
}
}
pub fn syncback_dir<'sync>( pub fn syncback_dir<'sync>(
snapshot: &SyncbackSnapshot<'sync>, snapshot: &SyncbackSnapshot<'sync>,
) -> anyhow::Result<SyncbackReturn<'sync>> { ) -> anyhow::Result<SyncbackReturn<'sync>> {
@@ -134,65 +153,128 @@ pub fn syncback_dir_no_meta<'sync>(
let mut children = Vec::new(); let mut children = Vec::new();
let mut removed_children = Vec::new(); let mut removed_children = Vec::new();
// We have to enforce unique child names for the file system. // Build the old child map early so it can be used for deduplication below.
let mut child_names = HashSet::with_capacity(new_inst.children().len()); let mut old_child_map = HashMap::new();
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::<Vec<&str>>().join(", ")
);
}
anyhow::bail!("Instance has more than 25 children with duplicate names");
}
if let Some(old_inst) = snapshot.old_inst() { 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() { for child in old_inst.children() {
let inst = snapshot.get_old_instance(*child).unwrap(); let inst = snapshot.get_old_instance(*child).unwrap();
old_child_map.insert(inst.name(), inst); old_child_map.insert(inst.name(), inst);
} }
}
for new_child_ref in new_inst.children() { // --- Two-pass collision resolution ---
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()) { // Pass 1: Collect each child's base filesystem name and old ref, applying
if old_child.metadata().relevant_paths.is_empty() { // skip conditions. Track which names are used (lowercased) so we can
log::debug!( // detect collisions.
"Skipping instance {} because it doesn't exist on the disk", struct ChildEntry {
old_child.name() new_ref: rbx_dom_weak::types::Ref,
); old_ref: Option<rbx_dom_weak::types::Ref>,
continue; base_name: String,
} else if matches!( middleware: Middleware,
old_child.metadata().instigating_source, skip: bool,
Some(InstigatingSource::ProjectNode { .. }) }
) {
log::debug!( let mut entries = Vec::with_capacity(new_inst.children().len());
"Skipping instance {} because it originates in a project file", let mut used_names: HashSet<String> = HashSet::with_capacity(new_inst.children().len());
old_child.name() let mut collision_indices: Vec<usize> = Vec::new();
);
continue; for new_child_ref in new_inst.children() {
} let new_child = snapshot.get_new_instance(*new_child_ref).unwrap();
// This child exists in both doms. Pass it on.
children.push(snapshot.with_joined_path(*new_child_ref, Some(old_child.id()))?); // Determine old_ref and apply skip conditions.
} else { let old_child = if snapshot.old_inst().is_some() {
// The child only exists in the the new dom old_child_map.remove(new_child.name.as_str())
children.push(snapshot.with_joined_path(*new_child_ref, None)?); } else {
None
};
let mut skip = false;
if let Some(ref old) = old_child {
if old.metadata().relevant_paths.is_empty() {
log::debug!(
"Skipping instance {} because it doesn't exist on the disk",
old.name()
);
skip = true;
} else if matches!(
old.metadata().instigating_source,
Some(InstigatingSource::ProjectNode { .. })
) {
log::debug!(
"Skipping instance {} because it originates in a project file",
old.name()
);
skip = true;
} }
} }
// Any children that are in the old dom but not the new one are removed.
removed_children.extend(old_child_map.into_values()); let old_ref = old_child.as_ref().map(|o| o.id());
} else {
// There is no old instance. Just add every child. if skip {
for new_child_ref in new_inst.children() { entries.push(ChildEntry {
children.push(snapshot.with_joined_path(*new_child_ref, None)?); new_ref: *new_child_ref,
old_ref,
base_name: String::new(),
middleware: Middleware::Dir,
skip: true,
});
continue;
} }
let (middleware, base_name) =
snapshot.child_middleware_and_name(*new_child_ref, old_ref)?;
let idx = entries.len();
let lower = base_name.to_lowercase();
if !used_names.insert(lower) {
// Name already claimed — needs resolution.
collision_indices.push(idx);
}
entries.push(ChildEntry {
new_ref: *new_child_ref,
old_ref,
base_name,
middleware,
skip: false,
});
}
// Pass 2: Resolve collisions by appending incrementing suffixes.
for idx in collision_indices {
let entry = &entries[idx];
let (stem, ext) = split_name_and_ext(&entry.base_name, entry.middleware);
let mut counter = 1u32;
loop {
let candidate = if ext.is_empty() {
format!("{stem}{counter}")
} else {
format!("{stem}{counter}.{ext}")
};
let lower = candidate.to_lowercase();
if used_names.insert(lower) {
// Safe to mutate — we only visit each collision index once.
let entry = &mut entries[idx];
entry.base_name = candidate;
break;
}
counter += 1;
}
}
// Create snapshots from resolved entries.
for entry in &entries {
if entry.skip {
continue;
}
let resolved_path = snapshot.path.join(&entry.base_name);
children.push(snapshot.with_new_path(resolved_path, entry.new_ref, entry.old_ref));
}
// Any children that are in the old dom but not the new one are removed.
if snapshot.old_inst().is_some() {
removed_children.extend(old_child_map.into_values());
} }
let mut fs_snapshot = FsSnapshot::new(); let mut fs_snapshot = FsSnapshot::new();
@@ -225,6 +307,12 @@ pub fn syncback_dir_no_meta<'sync>(
mod test { mod test {
use super::*; use super::*;
use std::path::PathBuf;
use crate::{
snapshot::{InstanceMetadata, InstanceSnapshot},
Project, RojoTree, SyncbackData, SyncbackSnapshot,
};
use memofs::{InMemoryFs, VfsSnapshot}; use memofs::{InMemoryFs, VfsSnapshot};
#[test] #[test]
@@ -261,4 +349,302 @@ mod test {
insta::assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
fn make_project() -> Project {
serde_json::from_str(r#"{"tree": {"$className": "DataModel"}}"#).unwrap()
}
fn make_vfs() -> Vfs {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/root", VfsSnapshot::empty_dir()).unwrap();
Vfs::new(imfs)
}
/// Two children whose Roblox names are identical when lowercased ("Alpha"
/// and "alpha") but live at different filesystem paths because of the
/// `name` property ("Beta/" and "Alpha/" respectively). The dedup check
/// must use the actual filesystem paths, not the raw Roblox names, to
/// avoid a false-positive duplicate error.
#[test]
fn syncback_no_false_duplicate_with_name_prop() {
use rbx_dom_weak::{InstanceBuilder, WeakDom};
// Old child A: Roblox name "Alpha", on disk at "/root/Beta"
// (name property maps "Alpha" → "Beta" on the filesystem)
let old_child_a = InstanceSnapshot::new()
.name("Alpha")
.class_name("Folder")
.metadata(
InstanceMetadata::new()
.instigating_source(PathBuf::from("/root/Beta"))
.relevant_paths(vec![PathBuf::from("/root/Beta")]),
);
// Old child B: Roblox name "alpha", on disk at "/root/Alpha"
let old_child_b = InstanceSnapshot::new()
.name("alpha")
.class_name("Folder")
.metadata(
InstanceMetadata::new()
.instigating_source(PathBuf::from("/root/Alpha"))
.relevant_paths(vec![PathBuf::from("/root/Alpha")]),
);
let old_parent = InstanceSnapshot::new()
.name("Parent")
.class_name("Folder")
.children(vec![old_child_a, old_child_b])
.metadata(
InstanceMetadata::new()
.instigating_source(PathBuf::from("/root"))
.relevant_paths(vec![PathBuf::from("/root")]),
);
let old_tree = RojoTree::new(old_parent);
// New state: same two children in Roblox.
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
let new_parent = new_tree.insert(
new_tree.root_ref(),
InstanceBuilder::new("Folder").with_name("Parent"),
);
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Alpha"));
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("alpha"));
let vfs = make_vfs();
let project = make_project();
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
let snapshot = SyncbackSnapshot {
data,
old: Some(old_tree.get_root_id()),
new: new_parent,
path: PathBuf::from("/root"),
middleware: None,
};
let result = syncback_dir_no_meta(&snapshot);
assert!(
result.is_ok(),
"should not error when two children have the same lowercased Roblox \
name but map to distinct filesystem paths: {:?}",
result.as_ref().err(),
);
}
/// Two completely new children with the same name get resolved via
/// incrementing suffixes instead of erroring.
#[test]
fn syncback_resolves_sibling_duplicate_names() {
use rbx_dom_weak::{InstanceBuilder, WeakDom};
let old_parent = InstanceSnapshot::new()
.name("Parent")
.class_name("Folder")
.metadata(
InstanceMetadata::new()
.instigating_source(PathBuf::from("/root"))
.relevant_paths(vec![PathBuf::from("/root")]),
);
let old_tree = RojoTree::new(old_parent);
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
let new_parent = new_tree.insert(
new_tree.root_ref(),
InstanceBuilder::new("Folder").with_name("Parent"),
);
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Foo"));
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Foo"));
let vfs = make_vfs();
let project = make_project();
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
let snapshot = SyncbackSnapshot {
data,
old: Some(old_tree.get_root_id()),
new: new_parent,
path: PathBuf::from("/root"),
middleware: None,
};
let result = syncback_dir_no_meta(&snapshot);
assert!(
result.is_ok(),
"should resolve duplicate names with suffixes, not error: {:?}",
result.as_ref().err(),
);
let children = result.unwrap().children;
let mut names: Vec<String> = children
.iter()
.map(|c| c.path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(names, vec!["Foo", "Foo1"]);
}
/// A new child named "Init" (as a ModuleScript) would naively become
/// "Init.luau", which case-insensitively matches the parent's reserved
/// "init.luau". Syncback must resolve this automatically by prefixing the
/// filesystem name with '_' (→ "_Init.luau") rather than erroring.
#[test]
fn syncback_resolves_init_name_conflict() {
use rbx_dom_weak::{InstanceBuilder, WeakDom};
let old_parent = InstanceSnapshot::new()
.name("Parent")
.class_name("Folder")
.metadata(
InstanceMetadata::new()
.instigating_source(PathBuf::from("/root"))
.relevant_paths(vec![PathBuf::from("/root")]),
);
let old_tree = RojoTree::new(old_parent);
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
let new_parent = new_tree.insert(
new_tree.root_ref(),
InstanceBuilder::new("Folder").with_name("Parent"),
);
new_tree.insert(
new_parent,
InstanceBuilder::new("ModuleScript").with_name("Init"),
);
let vfs = make_vfs();
let project = make_project();
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
let snapshot = SyncbackSnapshot {
data,
old: Some(old_tree.get_root_id()),
new: new_parent,
path: PathBuf::from("/root"),
middleware: None,
};
let result = syncback_dir_no_meta(&snapshot);
assert!(
result.is_ok(),
"should resolve init-name conflict by prefixing '_', not error: {:?}",
result.as_ref().err(),
);
// The child should have been placed at "_Init.luau", not "Init.luau".
let child_file_name = result
.unwrap()
.children
.into_iter()
.next()
.and_then(|c| c.path.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_default();
assert!(
child_file_name.starts_with('_'),
"child filesystem name should start with '_' to avoid init collision, \
got: {child_file_name}",
);
}
/// A child whose filesystem name is stored with a slugified prefix (e.g.
/// "_Init") must NOT be blocked — only the bare "init" stem is reserved.
#[test]
fn syncback_allows_slugified_init_name() {
use rbx_dom_weak::{InstanceBuilder, WeakDom};
// Existing child: on disk as "_Init" (slugified from a name with an
// illegal character), its stem is "_init" which is not reserved.
let old_child = InstanceSnapshot::new()
.name("Init")
.class_name("Folder")
.metadata(
InstanceMetadata::new()
.instigating_source(PathBuf::from("/root/_Init"))
.relevant_paths(vec![PathBuf::from("/root/_Init")]),
);
let old_parent = InstanceSnapshot::new()
.name("Parent")
.class_name("Folder")
.children(vec![old_child])
.metadata(
InstanceMetadata::new()
.instigating_source(PathBuf::from("/root"))
.relevant_paths(vec![PathBuf::from("/root")]),
);
let old_tree = RojoTree::new(old_parent);
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
let new_parent = new_tree.insert(
new_tree.root_ref(),
InstanceBuilder::new("Folder").with_name("Parent"),
);
new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Init"));
let vfs = make_vfs();
let project = make_project();
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
let snapshot = SyncbackSnapshot {
data,
old: Some(old_tree.get_root_id()),
new: new_parent,
path: PathBuf::from("/root"),
middleware: None,
};
let result = syncback_dir_no_meta(&snapshot);
assert!(
result.is_ok(),
"should allow a child whose filesystem name is slugified away from \
the reserved 'init' stem: {:?}",
result.as_ref().err(),
);
}
/// Two new children both named "Init" (ModuleScripts) should get
/// "_Init.luau" and "_Init1.luau" respectively.
#[test]
fn syncback_resolves_multiple_init_conflicts() {
use rbx_dom_weak::{InstanceBuilder, WeakDom};
let old_parent = InstanceSnapshot::new()
.name("Parent")
.class_name("Folder")
.metadata(
InstanceMetadata::new()
.instigating_source(PathBuf::from("/root"))
.relevant_paths(vec![PathBuf::from("/root")]),
);
let old_tree = RojoTree::new(old_parent);
let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
let new_parent = new_tree.insert(
new_tree.root_ref(),
InstanceBuilder::new("Folder").with_name("Parent"),
);
new_tree.insert(
new_parent,
InstanceBuilder::new("ModuleScript").with_name("Init"),
);
new_tree.insert(
new_parent,
InstanceBuilder::new("ModuleScript").with_name("Init"),
);
let vfs = make_vfs();
let project = make_project();
let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
let snapshot = SyncbackSnapshot {
data,
old: Some(old_tree.get_root_id()),
new: new_parent,
path: PathBuf::from("/root"),
middleware: None,
};
let result = syncback_dir_no_meta(&snapshot);
assert!(
result.is_ok(),
"should resolve multiple init conflicts with suffixes: {:?}",
result.as_ref().err(),
);
let children = result.unwrap().children;
let mut names: Vec<String> = children
.iter()
.map(|c| c.path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(names, vec!["_Init.luau", "_Init1.luau"]);
}
} }

View File

@@ -35,20 +35,14 @@ pub fn snapshot_json_model(
format!("File is not a valid JSON model: {}", path.display()) format!("File is not a valid JSON model: {}", path.display())
})?; })?;
if let Some(top_level_name) = &instance.name { // If the JSON has a name property, preserve it in metadata for syncback
let new_name = format!("{}.model.json", top_level_name); let specified_name = instance.name.clone();
log::warn!( // Use the name from JSON if present, otherwise fall back to filename-derived name
"Model at path {} had a top-level Name field. \ if instance.name.is_none() {
This field has been ignored since Rojo 6.0.\n\ instance.name = Some(name.to_owned());
Consider removing this field and renaming the file to {}.",
new_name,
path.display()
);
} }
instance.name = Some(name.to_owned());
let id = instance.id.take().map(RojoRef::new); let id = instance.id.take().map(RojoRef::new);
let schema = instance.schema.take(); let schema = instance.schema.take();
@@ -62,7 +56,8 @@ pub fn snapshot_json_model(
.relevant_paths(vec![vfs.canonicalize(path)?]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context) .context(context)
.specified_id(id) .specified_id(id)
.schema(schema); .schema(schema)
.specified_name(specified_name);
Ok(Some(snapshot)) Ok(Some(snapshot))
} }
@@ -81,6 +76,7 @@ pub fn syncback_json_model<'sync>(
// schemas will ever exist in one project for it to matter, but it // schemas will ever exist in one project for it to matter, but it
// could have a performance cost. // could have a performance cost.
model.schema = old_inst.metadata().schema.clone(); model.schema = old_inst.metadata().schema.clone();
model.name = old_inst.metadata().specified_name.clone();
} }
Ok(SyncbackReturn { Ok(SyncbackReturn {

View File

@@ -158,8 +158,23 @@ pub fn syncback_lua<'sync>(
if !meta.is_empty() { if !meta.is_empty() {
let parent_location = snapshot.path.parent_err()?; let parent_location = snapshot.path.parent_err()?;
let file_name = snapshot
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let meta_stem = file_name
.strip_suffix(".server.luau")
.or_else(|| file_name.strip_suffix(".server.lua"))
.or_else(|| file_name.strip_suffix(".client.luau"))
.or_else(|| file_name.strip_suffix(".client.lua"))
.or_else(|| file_name.strip_suffix(".plugin.luau"))
.or_else(|| file_name.strip_suffix(".plugin.lua"))
.or_else(|| file_name.strip_suffix(".luau"))
.or_else(|| file_name.strip_suffix(".lua"))
.unwrap_or(file_name);
fs_snapshot.add_file( fs_snapshot.add_file(
parent_location.join(format!("{}.meta.json", new_inst.name)), parent_location.join(format!("{meta_stem}.meta.json")),
serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?, serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?,
); );
} }
@@ -182,7 +197,6 @@ pub fn syncback_lua_init<'sync>(
ScriptType::Server => "init.server.luau", ScriptType::Server => "init.server.luau",
ScriptType::Client => "init.client.luau", ScriptType::Client => "init.client.luau",
ScriptType::Module => "init.luau", ScriptType::Module => "init.luau",
ScriptType::Plugin => "init.plugin.luau",
_ => anyhow::bail!("syncback is not yet implemented for {script_type:?}"), _ => anyhow::bail!("syncback is not yet implemented for {script_type:?}"),
}); });

View File

@@ -10,7 +10,10 @@ use rbx_dom_weak::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
json, resolution::UnresolvedValue, snapshot::InstanceSnapshot, syncback::SyncbackSnapshot, json,
resolution::UnresolvedValue,
snapshot::InstanceSnapshot,
syncback::{validate_file_name, SyncbackSnapshot},
RojoRef, RojoRef,
}; };
@@ -36,6 +39,9 @@ pub struct AdjacentMetadata {
#[serde(default, skip_serializing_if = "IndexMap::is_empty")] #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub attributes: IndexMap<String, UnresolvedValue>, pub attributes: IndexMap<String, UnresolvedValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip)] #[serde(skip)]
pub path: PathBuf, pub path: PathBuf,
} }
@@ -144,6 +150,26 @@ impl AdjacentMetadata {
} }
} }
let name = snapshot
.old_inst()
.and_then(|inst| inst.metadata().specified_name.clone())
.or_else(|| {
// Write name when name_for_inst would produce a different
// filesystem stem (slugification or init-prefix).
if snapshot.old_inst().is_none() {
let instance_name = &snapshot.new_inst().name;
if validate_file_name(instance_name).is_err()
|| instance_name.to_lowercase() == "init"
{
Some(instance_name.clone())
} else {
None
}
} else {
None
}
});
Ok(Some(Self { Ok(Some(Self {
ignore_unknown_instances: if ignore_unknown_instances { ignore_unknown_instances: if ignore_unknown_instances {
Some(true) Some(true)
@@ -155,6 +181,7 @@ impl AdjacentMetadata {
path, path,
id: None, id: None,
schema, schema,
name,
})) }))
} }
@@ -213,11 +240,26 @@ impl AdjacentMetadata {
Ok(()) Ok(())
} }
fn apply_name(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> {
if self.name.is_some() && snapshot.metadata.specified_name.is_some() {
anyhow::bail!(
"cannot specify a name using {} (instance has a name from somewhere else)",
self.path.display()
);
}
if let Some(name) = &self.name {
snapshot.name = name.clone().into();
}
snapshot.metadata.specified_name = self.name.take();
Ok(())
}
pub fn apply_all(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> { pub fn apply_all(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> {
self.apply_ignore_unknown_instances(snapshot); self.apply_ignore_unknown_instances(snapshot);
self.apply_properties(snapshot)?; self.apply_properties(snapshot)?;
self.apply_id(snapshot)?; self.apply_id(snapshot)?;
self.apply_schema(snapshot)?; self.apply_schema(snapshot)?;
self.apply_name(snapshot)?;
Ok(()) Ok(())
} }
@@ -226,11 +268,13 @@ impl AdjacentMetadata {
/// ///
/// - The number of properties and attributes is 0 /// - The number of properties and attributes is 0
/// - `ignore_unknown_instances` is None /// - `ignore_unknown_instances` is None
/// - `name` is None
#[inline] #[inline]
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.attributes.is_empty() self.attributes.is_empty()
&& self.properties.is_empty() && self.properties.is_empty()
&& self.ignore_unknown_instances.is_none() && self.ignore_unknown_instances.is_none()
&& self.name.is_none()
} }
// TODO: Add method to allow selectively applying parts of metadata and // TODO: Add method to allow selectively applying parts of metadata and
@@ -262,6 +306,9 @@ pub struct DirectoryMetadata {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub class_name: Option<Ustr>, pub class_name: Option<Ustr>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip)] #[serde(skip)]
pub path: PathBuf, pub path: PathBuf,
} }
@@ -372,6 +419,26 @@ impl DirectoryMetadata {
} }
} }
let name = snapshot
.old_inst()
.and_then(|inst| inst.metadata().specified_name.clone())
.or_else(|| {
// Write name when name_for_inst would produce a different
// directory name (slugification or init-prefix).
if snapshot.old_inst().is_none() {
let instance_name = &snapshot.new_inst().name;
if validate_file_name(instance_name).is_err()
|| instance_name.to_lowercase() == "init"
{
Some(instance_name.clone())
} else {
None
}
} else {
None
}
});
Ok(Some(Self { Ok(Some(Self {
ignore_unknown_instances: if ignore_unknown_instances { ignore_unknown_instances: if ignore_unknown_instances {
Some(true) Some(true)
@@ -384,6 +451,7 @@ impl DirectoryMetadata {
path, path,
id: None, id: None,
schema, schema,
name,
})) }))
} }
@@ -393,6 +461,7 @@ impl DirectoryMetadata {
self.apply_properties(snapshot)?; self.apply_properties(snapshot)?;
self.apply_id(snapshot)?; self.apply_id(snapshot)?;
self.apply_schema(snapshot)?; self.apply_schema(snapshot)?;
self.apply_name(snapshot)?;
Ok(()) Ok(())
} }
@@ -464,17 +533,33 @@ impl DirectoryMetadata {
snapshot.metadata.schema = self.schema.take(); snapshot.metadata.schema = self.schema.take();
Ok(()) Ok(())
} }
fn apply_name(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> {
if self.name.is_some() && snapshot.metadata.specified_name.is_some() {
anyhow::bail!(
"cannot specify a name using {} (instance has a name from somewhere else)",
self.path.display()
);
}
if let Some(name) = &self.name {
snapshot.name = name.clone().into();
}
snapshot.metadata.specified_name = self.name.take();
Ok(())
}
/// Returns whether the metadata is 'empty', meaning it doesn't have anything /// Returns whether the metadata is 'empty', meaning it doesn't have anything
/// worth persisting in it. Specifically: /// worth persisting in it. Specifically:
/// ///
/// - The number of properties and attributes is 0 /// - The number of properties and attributes is 0
/// - `ignore_unknown_instances` is None /// - `ignore_unknown_instances` is None
/// - `class_name` is either None or not Some("Folder") /// - `class_name` is either None or not Some("Folder")
/// - `name` is None
#[inline] #[inline]
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.attributes.is_empty() self.attributes.is_empty()
&& self.properties.is_empty() && self.properties.is_empty()
&& self.ignore_unknown_instances.is_none() && self.ignore_unknown_instances.is_none()
&& self.name.is_none()
&& if let Some(class) = &self.class_name { && if let Some(class) = &self.class_name {
class == "Folder" class == "Folder"
} else { } else {

View File

@@ -91,9 +91,7 @@ pub fn snapshot_from_vfs(
// TODO: Is this even necessary anymore? // TODO: Is this even necessary anymore?
match file_name { match file_name {
"init.server.luau" | "init.server.lua" | "init.client.luau" | "init.client.lua" "init.server.luau" | "init.server.lua" | "init.client.luau" | "init.client.lua"
| "init.plugin.luau" | "init.plugin.lua" | "init.luau" | "init.lua" | "init.csv" => { | "init.luau" | "init.lua" | "init.csv" => return Ok(None),
return Ok(None)
}
_ => {} _ => {}
} }
@@ -126,8 +124,6 @@ fn get_dir_middleware<'path>(
(Middleware::ServerScriptDir, "init.server.lua"), (Middleware::ServerScriptDir, "init.server.lua"),
(Middleware::ClientScriptDir, "init.client.luau"), (Middleware::ClientScriptDir, "init.client.luau"),
(Middleware::ClientScriptDir, "init.client.lua"), (Middleware::ClientScriptDir, "init.client.lua"),
(Middleware::PluginScriptDir, "init.plugin.lua"),
(Middleware::PluginScriptDir, "init.plugin.luau"),
(Middleware::CsvDir, "init.csv"), (Middleware::CsvDir, "init.csv"),
] ]
}); });
@@ -209,8 +205,6 @@ pub enum Middleware {
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
ClientScriptDir, ClientScriptDir,
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
PluginScriptDir,
#[serde(skip_deserializing)]
ModuleScriptDir, ModuleScriptDir,
#[serde(skip_deserializing)] #[serde(skip_deserializing)]
CsvDir, CsvDir,
@@ -261,9 +255,6 @@ impl Middleware {
Self::ClientScriptDir => { Self::ClientScriptDir => {
snapshot_lua_init(context, vfs, path, name, ScriptType::Client) snapshot_lua_init(context, vfs, path, name, ScriptType::Client)
} }
Self::PluginScriptDir => {
snapshot_lua_init(context, vfs, path, name, ScriptType::Plugin)
}
Self::ModuleScriptDir => { Self::ModuleScriptDir => {
snapshot_lua_init(context, vfs, path, name, ScriptType::Module) snapshot_lua_init(context, vfs, path, name, ScriptType::Module)
} }
@@ -306,7 +297,6 @@ impl Middleware {
Middleware::Dir => syncback_dir(snapshot), Middleware::Dir => syncback_dir(snapshot),
Middleware::ServerScriptDir => syncback_lua_init(ScriptType::Server, snapshot), Middleware::ServerScriptDir => syncback_lua_init(ScriptType::Server, snapshot),
Middleware::ClientScriptDir => syncback_lua_init(ScriptType::Client, snapshot), Middleware::ClientScriptDir => syncback_lua_init(ScriptType::Client, snapshot),
Middleware::PluginScriptDir => syncback_lua_init(ScriptType::Plugin, snapshot),
Middleware::ModuleScriptDir => syncback_lua_init(ScriptType::Module, snapshot), Middleware::ModuleScriptDir => syncback_lua_init(ScriptType::Module, snapshot),
Middleware::CsvDir => syncback_csv_init(snapshot), Middleware::CsvDir => syncback_csv_init(snapshot),
@@ -328,7 +318,6 @@ impl Middleware {
Middleware::Dir Middleware::Dir
| Middleware::ServerScriptDir | Middleware::ServerScriptDir
| Middleware::ClientScriptDir | Middleware::ClientScriptDir
| Middleware::PluginScriptDir
| Middleware::ModuleScriptDir | Middleware::ModuleScriptDir
| Middleware::CsvDir | Middleware::CsvDir
) )

View File

@@ -344,11 +344,6 @@ pub fn syncback_project<'sync>(
let mut new_child_map = HashMap::new(); let mut new_child_map = HashMap::new();
let mut node_changed_map = Vec::new(); let mut node_changed_map = Vec::new();
// Tracks whether any stale default-valued properties were removed from
// project nodes. If so, we must reserialize even if
// project_node_should_reserialize wouldn't otherwise detect a change
// (it only compares node properties forward, not in reverse).
let mut removed_stale_properties = false;
let mut node_queue = VecDeque::with_capacity(1); let mut node_queue = VecDeque::with_capacity(1);
node_queue.push_back((&mut project.tree, old_inst, snapshot.new_inst())); node_queue.push_back((&mut project.tree, old_inst, snapshot.new_inst()));
@@ -407,12 +402,10 @@ pub fn syncback_project<'sync>(
// We only want to set properties if it needs it. // We only want to set properties if it needs it.
if !middleware.handles_own_properties() { if !middleware.handles_own_properties() {
removed_stale_properties |= project_node_property_syncback_path(snapshot, new_inst, node);
project_node_property_syncback_path(snapshot, new_inst, node);
} }
} else { } else {
removed_stale_properties |= project_node_property_syncback_no_path(snapshot, new_inst, node);
project_node_property_syncback_no_path(snapshot, new_inst, node);
} }
for child_ref in new_inst.children() { for child_ref in new_inst.children() {
@@ -514,18 +507,12 @@ pub fn syncback_project<'sync>(
} }
let mut fs_snapshot = FsSnapshot::new(); let mut fs_snapshot = FsSnapshot::new();
let mut needs_reserialize = removed_stale_properties; for (node_properties, node_attributes, old_inst) in node_changed_map {
if !needs_reserialize { if project_node_should_reserialize(node_properties, node_attributes, old_inst)? {
for (node_properties, node_attributes, old_inst) in node_changed_map { fs_snapshot.add_file(project_path, serde_json::to_vec_pretty(&project)?);
if project_node_should_reserialize(node_properties, node_attributes, old_inst)? { break;
needs_reserialize = true;
break;
}
} }
} }
if needs_reserialize {
fs_snapshot.add_file(project_path, serde_json::to_vec_pretty(&project)?);
}
Ok(SyncbackReturn { Ok(SyncbackReturn {
fs_snapshot, fs_snapshot,
@@ -534,18 +521,15 @@ pub fn syncback_project<'sync>(
}) })
} }
/// Syncs properties from the new instance into the project node.
/// Returns `true` if any stale properties were removed (i.e. properties
/// that existed in the project node but are now at their engine default).
fn project_node_property_syncback( fn project_node_property_syncback(
_snapshot: &SyncbackSnapshot, _snapshot: &SyncbackSnapshot,
filtered_properties: UstrMap<&Variant>, filtered_properties: UstrMap<&Variant>,
new_inst: &Instance, new_inst: &Instance,
node: &mut ProjectNode, node: &mut ProjectNode,
) -> bool { ) {
let properties = &mut node.properties; let properties = &mut node.properties;
let mut attributes = BTreeMap::new(); let mut attributes = BTreeMap::new();
for (&name, &value) in &filtered_properties { for (name, value) in filtered_properties {
match value { match value {
Variant::Attributes(attrs) => { Variant::Attributes(attrs) => {
for (attr_name, attr_value) in attrs.iter() { for (attr_name, attr_value) in attrs.iter() {
@@ -568,48 +552,14 @@ fn project_node_property_syncback(
} }
} }
} }
// Remove stale properties: entries that exist in the project node's
// $properties but are no longer in the filtered (non-default) properties
// from the instance. This handles the case where Studio resets a property
// to its engine default — filter_properties won't include it, so we need
// to clean up the now-stale project entry.
let class_data = rbx_reflection_database::get()
.ok()
.and_then(|db| db.classes.get(new_inst.class.as_str()));
let len_before = properties.len();
properties.retain(|prop_name, _| {
if filtered_properties.contains_key(prop_name) {
return true;
}
// Only remove if the property has a known default value in the
// reflection database. If there's no default, the property might be
// absent from the instance for other reasons (e.g. unknown property),
// so we conservatively keep it.
if let Some(data) = &class_data {
if data.default_properties.contains_key(prop_name.as_str()) {
log::debug!(
"Removing stale property '{}' from project node for class '{}': \
value has been reset to engine default",
prop_name,
new_inst.class
);
return false;
}
}
true
});
let removed_stale = properties.len() < len_before;
node.attributes = attributes; node.attributes = attributes;
removed_stale
} }
fn project_node_property_syncback_path( fn project_node_property_syncback_path(
snapshot: &SyncbackSnapshot, snapshot: &SyncbackSnapshot,
new_inst: &Instance, new_inst: &Instance,
node: &mut ProjectNode, node: &mut ProjectNode,
) -> bool { ) {
let filtered_properties = snapshot let filtered_properties = snapshot
.get_path_filtered_properties(new_inst.referent()) .get_path_filtered_properties(new_inst.referent())
.unwrap(); .unwrap();
@@ -620,7 +570,7 @@ fn project_node_property_syncback_no_path(
snapshot: &SyncbackSnapshot, snapshot: &SyncbackSnapshot,
new_inst: &Instance, new_inst: &Instance,
node: &mut ProjectNode, node: &mut ProjectNode,
) -> bool { ) {
let filtered_properties = filter_properties(snapshot.project(), new_inst); let filtered_properties = filter_properties(snapshot.project(), new_inst);
project_node_property_syncback(snapshot, filtered_properties, new_inst, node) project_node_property_syncback(snapshot, filtered_properties, new_inst, node)
} }

View File

@@ -15,8 +15,6 @@ metadata:
- /root/init.server.luau - /root/init.server.luau
- /root/init.client.lua - /root/init.client.lua
- /root/init.client.luau - /root/init.client.luau
- /root/init.plugin.lua
- /root/init.plugin.luau
- /root/init.csv - /root/init.csv
- /root/init.meta.json - /root/init.meta.json
- /root/init.meta.jsonc - /root/init.meta.jsonc

View File

@@ -15,8 +15,6 @@ metadata:
- /root/init.server.luau - /root/init.server.luau
- /root/init.client.lua - /root/init.client.lua
- /root/init.client.luau - /root/init.client.luau
- /root/init.plugin.lua
- /root/init.plugin.luau
- /root/init.csv - /root/init.csv
- /root/init.meta.json - /root/init.meta.json
- /root/init.meta.jsonc - /root/init.meta.jsonc

View File

@@ -15,8 +15,6 @@ metadata:
- /foo/init.server.luau - /foo/init.server.luau
- /foo/init.client.lua - /foo/init.client.lua
- /foo/init.client.luau - /foo/init.client.luau
- /foo/init.plugin.lua
- /foo/init.plugin.luau
- /foo/init.csv - /foo/init.csv
- /foo/init.meta.json - /foo/init.meta.json
- /foo/init.meta.jsonc - /foo/init.meta.jsonc

Some files were not shown because too many files have changed in this diff Show More