Compare commits

...

44 Commits

Author SHA1 Message Date
Micah
f4e2f5aefc Release Rojo 7.4.3 (#959) 2024-08-06 11:26:00 -07:00
Micah
8ceb40a24e Update rbx-binary to 0.7.6 (#958) 2024-08-06 11:21:41 -07:00
Micah
3e53d67412 In the plugin, don't write properties if they're nil and also a number (#955) 2024-08-02 10:02:32 -07:00
Micah
844f51d916 Update version of aftman used in release workflow 2024-07-23 11:14:18 -07:00
Micah
26974ffd4c Release v7.4.2 (#950) 2024-07-23 11:02:23 -07:00
Micah
91f5b4a675 Update memofs in 7.4.x branch (#949)
Backports the release of memofs v0.3.0
2024-07-23 10:42:32 -07:00
Micah
d179240139 Update rbx_dom for 7.4.x branch (#948) 2024-07-23 10:35:06 -07:00
Micah
67b6a7e198 Backport #917 to Rojo 7.4.x branch (#947) 2024-07-22 12:11:28 -07:00
Micah
3b721242c1 Backport #893 and #903 to Rojo 7.4 (#946)
As part of prep for a 7.4.2 release, this backports changes to the 7.4.X
branch that we can reasonably ship in 7.4.2 without too many code
changes.
2024-07-22 11:55:28 -07:00
EgoMoose
c6ceaa5c87 Trim plugin version string (#889)
This PR is a very small change that fixes the string pattern that reads
the rojo version from `Version.txt`. Currently this reads an extra
new-line character which makes reading the version text in the plugin
difficult.

It seems the rust side of things already trims the string when
comparing, but the lua side does not.

Current:

![pO6gtOXAZq](https://github.com/rojo-rbx/rojo/assets/6201941/1a03fced-f2b5-4a4e-a82d-e11fb0a52af7)

Fix:

![RobloxStudioBeta_GHmiJKAoa3](https://github.com/rojo-rbx/rojo/assets/6201941/3ce711df-fdc6-4f20-8771-5f5118ee013f)

Apologies if I skipped over some process of submitting a bug and / or am
basing on the wrong branch etc.
2024-03-13 09:49:33 -07:00
Kenneth Loeffler
af9629c53f Release 7.4.1 (#872) 2024-02-20 17:41:45 -08:00
Micah
9509909f46 Backport #870 (optional project names) to 7.4.x (#871)
Unlike most of the other backports, this code couldn't be directly
translated so it had to be re-implemented. Luckily, it is very simple.
This implementation is a bit messy and heavy handed with potential
panics, but I think it's probably fine since file names that aren't
UTF-8 aren't really supported anyway. The original implementation is a
lot cleaner though.

The test snapshots are (almost) all identical between the 7.5
implementation and this one. The sole exception is with the path in the
`snapshot_middleware::project` test, since I didn't feel like adding a
`name` parameter to `snapshot_project` in this implementation.
2024-02-20 17:25:05 -08:00
Kenneth Loeffler
88efbd433f Backport #868 to 7.4 (custom pivot geter/setter) (#869)
This PR backports some changes to rbx_dom_lua to fix serving model
pivots
2024-02-20 12:22:27 -08:00
Kenneth Loeffler
f716928683 Add entry for model pivot build fix to 7.4.x changelog (#867) 2024-02-20 12:09:13 -08:00
Kenneth Loeffler
e23d024ba3 Insert Model.NeedsPivotMigration in insert_instance when missing (#865) 2024-02-20 09:11:26 -08:00
Kenneth Loeffler
591419611e Backport #854 to Rojo 7.4 (Lua LF normalization) (#857) 2024-02-14 10:18:46 -08:00
Kenneth Loeffler
f68beab1df Backport #847 to 7.4 (gracefully handle gateway timeouts) (#851)
This PR adds a fix for gateway timeout handling to the 7.4.x branch

Co-authored-by: boatbomber <zack@boatbomber.com>
2024-02-03 20:30:10 -08:00
Kenneth Loeffler
2798610afd Backport #848, #846, #845, #844 to 7.4 (#849)
Co-authored-by: boatbomber <zack@boatbomber.com>
2024-02-01 13:23:51 -08:00
Micah
c0a96e3811 Release v7.4.0 (#837) 2024-01-16 12:12:40 -08:00
Micah
9d0d76f0a5 Ensure plugin and Cargo version match exact at compile-time (#836) 2024-01-16 14:09:10 -06:00
Micah
c7173ac832 Don't serialize emitLegacyScripts if it's None (#835) 2024-01-16 10:09:16 -08:00
boatbomber
b12ce47e7e Don't remind to sync if the lock is claimed (#833)
If the sync lock is claimed in Team Create, the user cannot sync.
Therefore, a sync reminder notification is unhelpful as it is calling to
an invalid action.
2024-01-12 12:35:29 -08:00
Barış
269272983b Changed file extensions of init command from lua to luau (#831) 2024-01-05 16:00:49 -08:00
Kenneth Loeffler
6adc5eb9fb Conserve CI minutes via cache, skip macOS+Windows MSRV builds (#827)
Windows and macOS runners consume GitHub Actions minutes at [2x and 10x
the rate of Linux runners,
respectively](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#minute-multipliers).
This is a bit concerning now that there are two Windows jobs and two
macOS jobs, introduced in #825.

This PR aims to reduce the cost by:
* Adding [rust-cache](https://github.com/Swatinem/rust-cache/) to reduce
the amount of time spent. I'm aware there were some concerns raised
about CI caches in general in #496 - are they still a blocker?
* Removing the unnecessary Windows and macOS MSRV build jobs. If an MSRV
build fails on one platform due to usage of new language features, then
it will fail on all of them.

@Kampfkarren may have to change this repository's required status checks
before this PR can be merged
2024-01-02 15:49:24 -08:00
boatbomber
fd8bc8ae3f Improve visualization for arrays (#829) 2024-01-02 17:32:37 +00:00
Kenneth Loeffler
3369b0d429 Downgrade to Notify 4, use FSEvents, use minimal recursive watches (#830) 2024-01-02 09:26:06 -08:00
Kenneth Loeffler
097d39e8ce Fix move_folders_of_stuff (#826)
This is a fairly important test verifying whether the action of moving a
folder into a watched folder is correctly detected and processed. It was
disabled in
b43b45be8f.
The fact that it failed indicates a possible bug in change processing,
so in this PR, I'll re-enable the test, investigate why it fails, and
fix it.
2023-12-31 12:02:54 -08:00
Kenneth Loeffler
11fa08e6d6 Run CI workflow on Windows and macOS (#825)
This PR adds macOS and Windows jobs to the CI workflow. This allows us
to see when changes break functionality on any supported platform, which
is particularly important for changes that involve the file system or
file watcher.
2023-12-29 17:46:58 -08:00
Kenneth Loeffler
96987af71d Fix broken serve tests on macOS (#824)
Right now, serve tests will fail when Rojo is built with the FSEvent
backend. The cause is essentially due to the fact that `/var` (where
temporary directories for serve tests are located) on macOS is actually
a symlink to `/private/var`. Paths coming from FSEvent always have
symlinks expanded, but Rojo never expands symlinks. So, Rojo's paths
during these tests look like `/var/*` while the FSEvent paths look like
`/private/var/*`. When Rojo's change processor receives these events, it
considers them outside the project and does not apply any changes,
causing serve tests to time out.

To work around this, we can call `Path::canonicalize` before passing the
project path to `rojo serve` during serve tests. Rojo does need to
better support symlinks (which would also solve the problem), but I
think that can be left for another day because it's larger in scope and
I mostly just want working tests before addressing #609.
2023-12-28 23:17:00 +00:00
Vee
23327cb3ef Fix preloading assets in plugin (#819)
`gatherAssetUrlsRecursive` now returns asset URLs deeper than one layer.
2023-12-04 16:02:25 +00:00
Jack T
b43b45be8f Upgrade to Notify 6 (#816) 2023-11-23 16:16:43 -08:00
Micah
41994ec82e Release v7.4.0-rc3 (#811) 2023-10-25 17:01:06 -07:00
Micah
cd14ea7c62 Remove unnecessary borrows (#810) 2023-10-25 17:59:02 -05:00
Kenneth Loeffler
9f13bca6b8 rbx_dom_lua rojo-rbx/rbx-dom@440f3723 (attribute validation) (#809)
Brings over some changes to rbx_dom_lua to validate attribute names
before calling `Instance:SetAttribute`. This should prevent Rojo from
falling over when it attempts to sync an attribute with an invalid name.
2023-10-25 14:21:58 -07:00
Kenneth Loeffler
f4252c3e97 Update changelog in preparation for 7.4.0-rc3 (#808)
Summarizes recent changes since 7.4.0-rc2 in the changelog
2023-10-23 22:54:48 +00:00
Kenneth Loeffler
6598867d3d Bump rbx_binary to 0.7.3 (#807)
Bumps rbx_binary's version to 0.7.3 to bring in the missing
`SecurityCapabilities` serialization fallback default
2023-10-23 22:43:00 +00:00
dependabot[bot]
f39e040a0d Bump rustix from 0.38.15 to 0.38.20 (#806) 2023-10-23 22:38:28 +00:00
Kenneth Loeffler
a3d140269b Demote unapplied patch warnings to debug logs (#805)
These warnings always appear for properties like `Capabilities`,
`SourceAssetId`, etc. and tend to scare users who are syncing models.
This information is now surfaced in the patch visualizer, so I think
these warnings can be demoted to debug logs.
2023-10-23 11:47:50 -07:00
Kenneth Loeffler
feac29ea40 Fix PatchTree incorrect changeList entries on decode failure (#804) 2023-10-22 16:58:12 -07:00
Kenneth Loeffler
834c8cdbca rbx_dom_lua rojo-rbx/rbx-dom@0e10232b (SecurityCapabilities) (#803)
Closes #802.
2023-10-22 16:57:59 -07:00
Kenneth Loeffler
d441fbdf91 Bump rbx_dom_lua rojo-rbx/rbx-dom@e7a5b91c (ScriptEditorService) (#801) 2023-10-17 14:15:47 -07:00
Filip Tibell
e897f524dc Skip sourcemap generation when unaffected by changes in watch mode (#800) 2023-10-13 08:38:21 -07:00
Micah
1caf9446d8 Rojo 7.4.0-rc2 (#798) 2023-10-04 05:36:57 +00:00
Micah
bfd2c885db Properly handle build metadata in semver parsing in plugin (#797) 2023-10-04 05:23:18 +00:00
93 changed files with 6121 additions and 1254 deletions

View File

@@ -24,3 +24,6 @@ insert_final_newline = true
[*.lua] [*.lua]
indent_style = tab indent_style = tab
[*.luau]
indent_style = tab

View File

@@ -12,11 +12,11 @@ on:
jobs: jobs:
build: build:
name: Build and Test name: Build and Test
runs-on: ubuntu-latest runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
rust_version: [stable, 1.70.0] os: [ubuntu-latest, windows-latest, macos-latest]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -26,10 +26,13 @@ jobs:
- name: Install Rust - name: Install Rust
uses: actions-rs/toolchain@v1 uses: actions-rs/toolchain@v1
with: with:
toolchain: ${{ matrix.rust_version }} toolchain: stable
override: true override: true
profile: minimal profile: minimal
- name: Rust cache
uses: Swatinem/rust-cache@v2
- name: Setup Aftman - name: Setup Aftman
uses: ok-nick/setup-aftman@v0.3.0 uses: ok-nick/setup-aftman@v0.3.0
with: with:
@@ -41,8 +44,35 @@ jobs:
- name: Test - name: Test
run: cargo test --locked --verbose run: cargo test --locked --verbose
msrv:
name: Check MSRV
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: 1.70.0
override: true
profile: minimal
- name: Rust cache
uses: Swatinem/rust-cache@v2
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.3.0
with:
version: 'v0.2.7'
- name: Build
run: cargo build --locked --verbose
lint: lint:
name: Rustfmt, Clippy, & Stylua name: Rustfmt, Clippy, Stylua, & Selene
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -57,6 +87,9 @@ jobs:
override: true override: true
components: rustfmt, clippy components: rustfmt, clippy
- name: Rust cache
uses: Swatinem/rust-cache@v2
- name: Setup Aftman - name: Setup Aftman
uses: ok-nick/setup-aftman@v0.3.0 uses: ok-nick/setup-aftman@v0.3.0
with: with:
@@ -65,6 +98,9 @@ jobs:
- name: Stylua - name: Stylua
run: stylua --check plugin/src run: stylua --check plugin/src
- name: Selene
run: selene plugin/src
- name: Rustfmt - name: Rustfmt
run: cargo fmt -- --check run: cargo fmt -- --check

View File

@@ -36,7 +36,7 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
trust-check: false trust-check: false
version: 'v0.2.6' version: 'v0.3.0'
- name: Build Plugin - name: Build Plugin
run: rojo build plugin --output Rojo.rbxm run: rojo build plugin --output Rojo.rbxm

View File

@@ -2,6 +2,76 @@
## Unreleased Changes ## Unreleased Changes
## [7.4.3] - August 6th, 2024
* Fixed issue with building binary files introduced in 7.4.2
* Fixed `value of type nil cannot be converted to number` warning spam in output. [#955]
[#955]: https://github.com/rojo-rbx/rojo/pull/893
## [7.4.2] - July 23, 2024
* Added Never option to Confirmation ([#893])
* Fixed removing trailing newlines ([#903])
* Updated the internal property database, correcting an issue with `SurfaceAppearance.Color` that was reported [here][Surface_Appearance_Color_1] and [here][Surface_Appearance_Color_2] ([#948])
[#893]: https://github.com/rojo-rbx/rojo/pull/893
[#903]: https://github.com/rojo-rbx/rojo/pull/903
[#948]: https://github.com/rojo-rbx/rojo/pull/948
[Surface_Appearance_Color_1]: https://devforum.roblox.com/t/jailbreak-custom-character-turned-shiny-black-no-texture/3075563
[Surface_Appearance_Color_2]: https://devforum.roblox.com/t/surfaceappearance-not-displaying-correctly/3075588
## [7.4.1] - February 20, 2024
* Made the `name` field optional on project files ([#870])
Files named `default.project.json` inherit the name of the folder they're in and all other projects
are named as expect (e.g. `foo.project.json` becomes an Instance named `foo`)
There is no change in behavior if `name` is set.
* Fixed incorrect results when building model pivots ([#865])
* Fixed incorrect results when serving model pivots ([#868])
* Rojo now converts any line endings to LF, preventing spurious diffs when syncing Lua files on Windows ([#854])
* Fixed Rojo plugin failing to connect when project contains certain unreadable properties ([#848])
* Fixed various cases where patch visualizer would not display sync failures ([#845], [#844])
* Fixed http error handling so Rojo can be used in Github Codespaces ([#847])
[#848]: https://github.com/rojo-rbx/rojo/pull/848
[#845]: https://github.com/rojo-rbx/rojo/pull/845
[#844]: https://github.com/rojo-rbx/rojo/pull/844
[#847]: https://github.com/rojo-rbx/rojo/pull/847
[#854]: https://github.com/rojo-rbx/rojo/pull/854
[#865]: https://github.com/rojo-rbx/rojo/pull/865
[#868]: https://github.com/rojo-rbx/rojo/pull/868
[#870]: https://github.com/rojo-rbx/rojo/pull/870
## [7.4.0] - January 16, 2024
* Improved the visualization for array properties like Tags ([#829])
* Significantly improved performance of `rojo serve`, `rojo build --watch`, and `rojo sourcemap --watch` on macOS. ([#830])
* Changed *.lua files that init command generates to *.luau ([#831])
* Does not remind users to sync if the sync lock is claimed already ([#833])
[#829]: https://github.com/rojo-rbx/rojo/pull/829
[#830]: https://github.com/rojo-rbx/rojo/pull/830
[#831]: https://github.com/rojo-rbx/rojo/pull/831
[#833]: https://github.com/rojo-rbx/rojo/pull/833
## [7.4.0-rc3] - October 25, 2023
* Changed `sourcemap --watch` to only generate the sourcemap when it's necessary ([#800])
* Switched script source property getter and setter to `ScriptEditorService` methods ([#801])
This ensures that the script editor reflects any changes Rojo makes to a script while it is open in the script editor.
* Fixed issues when handling `SecurityCapabilities` values ([#803], [#807])
* Fixed Rojo plugin erroring out when attempting to sync attributes with invalid names ([#809])
[#800]: https://github.com/rojo-rbx/rojo/pull/800
[#801]: https://github.com/rojo-rbx/rojo/pull/801
[#803]: https://github.com/rojo-rbx/rojo/pull/803
[#807]: https://github.com/rojo-rbx/rojo/pull/807
[#809]: https://github.com/rojo-rbx/rojo/pull/809
## [7.4.0-rc2] - October 3, 2023
* Fixed bug with parsing version for plugin validation ([#797])
[#797]: https://github.com/rojo-rbx/rojo/pull/797
## [7.4.0-rc1] - October 3, 2023 ## [7.4.0-rc1] - October 3, 2023
### Additions ### Additions
#### Project format #### Project format
@@ -118,7 +188,6 @@
* Add buttons for navigation on the Connected page ([#722]) * Add buttons for navigation on the Connected page ([#722])
### Fixes ### Fixes
* Significantly improved performance of `rojo serve` and `rojo build` on macOS. [#783]
* Significantly improved performance of `rojo sourcemap` ([#668]) * Significantly improved performance of `rojo sourcemap` ([#668])
* Fixed the diff visualizer of connected sessions. ([#674]) * Fixed the diff visualizer of connected sessions. ([#674])
* Fixed disconnected session activity. ([#675]) * Fixed disconnected session activity. ([#675])
@@ -152,7 +221,6 @@
[#770]: https://github.com/rojo-rbx/rojo/pull/770 [#770]: https://github.com/rojo-rbx/rojo/pull/770
[#771]: https://github.com/rojo-rbx/rojo/pull/771 [#771]: https://github.com/rojo-rbx/rojo/pull/771
[#774]: https://github.com/rojo-rbx/rojo/pull/774 [#774]: https://github.com/rojo-rbx/rojo/pull/774
[#783]: https://github.com/rojo-rbx/rojo/pull/783
[rbx-dom#299]: https://github.com/rojo-rbx/rbx-dom/pull/299 [rbx-dom#299]: https://github.com/rojo-rbx/rbx-dom/pull/299
[rbx-dom#296]: https://github.com/rojo-rbx/rbx-dom/pull/296 [rbx-dom#296]: https://github.com/rojo-rbx/rbx-dom/pull/296

33
Cargo.lock generated
View File

@@ -1073,7 +1073,7 @@ dependencies = [
[[package]] [[package]]
name = "memofs" name = "memofs"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"fs-err", "fs-err",
@@ -1586,9 +1586,9 @@ dependencies = [
[[package]] [[package]]
name = "rbx_binary" name = "rbx_binary"
version = "0.7.2" version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10942950a57c939e540a2f977ba55e9140007d7e96c532d455502c290fdf710d" checksum = "49ee5134b59834b17940d20dd2e057b6fe6902c1e566900e83106c50503b970e"
dependencies = [ dependencies = [
"log", "log",
"lz4", "lz4",
@@ -1601,9 +1601,9 @@ dependencies = [
[[package]] [[package]]
name = "rbx_dom_weak" name = "rbx_dom_weak"
version = "2.6.0" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843a2e0e1446623625943f7228d9d4b5cf3883017e3964733600682506864b34" checksum = "34d35df0f09290d32976f655366342676a6645b87c39b6949473b9d28a969733"
dependencies = [ dependencies = [
"rbx_types", "rbx_types",
"serde", "serde",
@@ -1611,9 +1611,9 @@ dependencies = [
[[package]] [[package]]
name = "rbx_reflection" name = "rbx_reflection"
version = "4.4.0" version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41e762dfca3217d2d37da631de2fa0d1616edaa61a0a2633263d5d3305baf8c3" checksum = "04ca5496737668378b17bacc9090ad361fc9c8b5f346bbd33162e083c98fa248"
dependencies = [ dependencies = [
"rbx_types", "rbx_types",
"serde", "serde",
@@ -1622,9 +1622,9 @@ dependencies = [
[[package]] [[package]]
name = "rbx_reflection_database" name = "rbx_reflection_database"
version = "0.2.9+roblox-596" version = "0.2.11+roblox-634"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b18f088a2b4aa66324ec97b5b6ffacb53188aef19f3497d95d6a1d1dbb28e66" checksum = "399ab2e1fa27c8428fe43fc4148d8085d187881f1c59cefea3711a2112e9cccc"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"rbx_reflection", "rbx_reflection",
@@ -1634,9 +1634,9 @@ dependencies = [
[[package]] [[package]]
name = "rbx_types" name = "rbx_types"
version = "1.7.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a991523e3ad5f43a4d121cb4a1e5bc23f7826bb4a1db5aa51e94f1073150ec" checksum = "6ed7bbc0e1864143546b12ee0cf64a1a6f447d8ce7baf4fae755e4581929d230"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.13.1",
"bitflags 1.3.2", "bitflags 1.3.2",
@@ -1649,9 +1649,9 @@ dependencies = [
[[package]] [[package]]
name = "rbx_xml" name = "rbx_xml"
version = "0.13.2" version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc87343301303ff0510903fb7eb3dbd1c75bdb6ab780fea6091bdc3f58b5829f" checksum = "1c2abac6e71c97a56243f00c9c2def504fe4b698019d854dd8720da700a80d7c"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.13.1",
"log", "log",
@@ -1831,7 +1831,7 @@ dependencies = [
[[package]] [[package]]
name = "rojo" name = "rojo"
version = "7.4.0-rc1" version = "7.4.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"backtrace", "backtrace",
@@ -1852,7 +1852,6 @@ dependencies = [
"log", "log",
"maplit", "maplit",
"memofs", "memofs",
"notify",
"num_cpus", "num_cpus",
"opener", "opener",
"paste", "paste",
@@ -1908,9 +1907,9 @@ dependencies = [
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "0.38.15" version = "0.38.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f9da0cbd88f9f09e7814e388301c8414c51c62aa6ce1e4b5c551d49d96e531" checksum = "67ce50cb2e16c2903e30d1cbccfd8387a74b9d4c938b6a4c5ec6cc7556f7a8a0"
dependencies = [ dependencies = [
"bitflags 2.4.0", "bitflags 2.4.0",
"errno", "errno",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rojo" name = "rojo"
version = "7.4.0-rc1" version = "7.4.3"
rust-version = "1.70.0" rust-version = "1.70.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "Enables professional-grade development tools for Roblox developers" description = "Enables professional-grade development tools for Roblox developers"
@@ -40,7 +40,7 @@ name = "build"
harness = false harness = false
[dependencies] [dependencies]
memofs = { version = "0.2.0", path = "crates/memofs" } memofs = { version = "0.3.0", path = "crates/memofs" }
# These dependencies can be uncommented when working on rbx-dom simultaneously # These dependencies can be uncommented when working on rbx-dom simultaneously
# rbx_binary = { path = "../rbx-dom/rbx_binary" } # rbx_binary = { path = "../rbx-dom/rbx_binary" }
@@ -49,11 +49,11 @@ memofs = { version = "0.2.0", path = "crates/memofs" }
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" } # rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
# rbx_xml = { path = "../rbx-dom/rbx_xml" } # rbx_xml = { path = "../rbx-dom/rbx_xml" }
rbx_binary = "0.7.2" rbx_binary = "0.7.6"
rbx_dom_weak = "2.6.0" rbx_dom_weak = "2.8.0"
rbx_reflection = "4.4.0" rbx_reflection = "4.6.0"
rbx_reflection_database = "0.2.8" rbx_reflection_database = "0.2.11"
rbx_xml = "0.13.2" rbx_xml = "0.13.4"
anyhow = "1.0.44" anyhow = "1.0.44"
backtrace = "0.3.61" backtrace = "0.3.61"
@@ -69,7 +69,6 @@ hyper = { version = "0.14.13", features = ["server", "tcp", "http1"] }
jod-thread = "0.1.2" jod-thread = "0.1.2"
log = "0.4.14" log = "0.4.14"
maplit = "1.0.2" maplit = "1.0.2"
notify = "4.0.17"
num_cpus = "1.15.0" num_cpus = "1.15.0"
opener = "0.5.0" opener = "0.5.0"
rayon = "1.7.0" rayon = "1.7.0"
@@ -95,7 +94,7 @@ tracy-client = { version = "0.13.2", optional = true }
winreg = "0.10.1" winreg = "0.10.1"
[build-dependencies] [build-dependencies]
memofs = { version = "0.2.0", path = "crates/memofs" } memofs = { version = "0.3.0", path = "crates/memofs" }
embed-resource = "1.6.4" embed-resource = "1.6.4"
anyhow = "1.0.44" anyhow = "1.0.44"

View File

@@ -1,5 +1,5 @@
[tools] [tools]
rojo = "rojo-rbx/rojo@7.3.0" rojo = "rojo-rbx/rojo@7.4.1"
selene = "Kampfkarren/selene@0.25.0" selene = "Kampfkarren/selene@0.26.1"
stylua = "JohnnyMorganz/stylua@0.18.2" stylua = "JohnnyMorganz/stylua@0.18.2"
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"

View File

@@ -46,14 +46,10 @@ fn main() -> Result<(), anyhow::Error> {
let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?; let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?;
let plugin_version = let plugin_version =
Version::parse(fs::read_to_string(&plugin_root.join("Version.txt"))?.trim())?; Version::parse(fs::read_to_string(plugin_root.join("Version.txt"))?.trim())?;
assert!( assert_eq!(
our_version.major == plugin_version.major, our_version, plugin_version,
"plugin version does not match Cargo version"
);
assert!(
our_version.minor == plugin_version.minor,
"plugin version does not match Cargo version" "plugin version does not match Cargo version"
); );

View File

@@ -1,7 +1,13 @@
# memofs Changelog # memofs Changelog
## Unreleased Changes ## Unreleased Changes
* Changed the `StdBackend` file watcher to use `PollWatcher` on macOS.
## 0.3.0 (2024-03-15)
* Changed `StdBackend` file watching component to use minimal recursive watches. [#830]
* Added `Vfs::read_to_string` and `Vfs::read_to_string_lf_normalized` [#854]
[#830]: https://github.com/rojo-rbx/rojo/pull/830
[#854]: https://github.com/rojo-rbx/rojo/pull/854
## 0.2.0 (2021-08-23) ## 0.2.0 (2021-08-23)
* Updated to `crossbeam-channel` 0.5.1. * Updated to `crossbeam-channel` 0.5.1.

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "memofs" name = "memofs"
description = "Virtual filesystem with configurable backends." description = "Virtual filesystem with configurable backends."
version = "0.2.0" version = "0.3.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018" edition = "2018"
readme = "README.md" readme = "README.md"

View File

@@ -22,9 +22,9 @@ mod noop_backend;
mod snapshot; mod snapshot;
mod std_backend; mod std_backend;
use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard}; use std::sync::{Arc, Mutex, MutexGuard};
use std::{io, str};
pub use in_memory_fs::InMemoryFs; pub use in_memory_fs::InMemoryFs;
pub use noop_backend::NoopBackend; pub use noop_backend::NoopBackend;
@@ -155,6 +155,24 @@ impl VfsInner {
Ok(Arc::new(contents)) Ok(Arc::new(contents))
} }
fn read_to_string<P: AsRef<Path>>(&mut self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
let contents = self.backend.read(path)?;
if self.watch_enabled {
self.backend.watch(path)?;
}
let contents_str = str::from_utf8(&contents).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("File was not valid UTF-8: {}", path.display()),
)
})?;
Ok(Arc::new(contents_str.into()))
}
fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> { fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> {
let path = path.as_ref(); let path = path.as_ref();
let contents = contents.as_ref(); let contents = contents.as_ref();
@@ -258,6 +276,33 @@ impl Vfs {
self.inner.lock().unwrap().read(path) self.inner.lock().unwrap().read(path)
} }
/// Read a file from the VFS (or from the underlying backend if it isn't
/// resident) into a string.
///
/// Roughly equivalent to [`std::fs::read_to_string`][std::fs::read_to_string].
///
/// [std::fs::read_to_string]: https://doc.rust-lang.org/stable/std/fs/fn.read_to_string.html
#[inline]
pub fn read_to_string<P: AsRef<Path>>(&self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
self.inner.lock().unwrap().read_to_string(path)
}
/// Read a file from the VFS (or the underlying backend if it isn't
/// resident) into a string, and normalize its line endings to LF.
///
/// Roughly equivalent to [`std::fs::read_to_string`][std::fs::read_to_string], but also performs
/// line ending normalization.
///
/// [std::fs::read_to_string]: https://doc.rust-lang.org/stable/std/fs/fn.read_to_string.html
#[inline]
pub fn read_to_string_lf_normalized<P: AsRef<Path>>(&self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
let contents = self.inner.lock().unwrap().read_to_string(path)?;
Ok(contents.replace("\r\n", "\n").into())
}
/// Write a file to the VFS and the underlying backend. /// Write a file to the VFS and the underlying backend.
/// ///
/// Roughly equivalent to [`std::fs::write`][std::fs::write]. /// Roughly equivalent to [`std::fs::write`][std::fs::write].
@@ -428,3 +473,23 @@ impl VfsLock<'_> {
self.inner.commit_event(event) self.inner.commit_event(event)
} }
} }
#[cfg(test)]
mod test {
use crate::{InMemoryFs, Vfs, VfsSnapshot};
/// https://github.com/rojo-rbx/rojo/issues/899
#[test]
fn read_to_string_lf_normalized_keeps_trailing_newline() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("test", VfsSnapshot::file("bar\r\nfoo\r\n\r\n"))
.unwrap();
let vfs = Vfs::new(imfs);
assert_eq!(
vfs.read_to_string_lf_normalized("test").unwrap().as_str(),
"bar\nfoo\n\n"
);
}
}

View File

@@ -1,38 +1,24 @@
use std::io; use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::mpsc; use std::sync::mpsc;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use std::{collections::HashSet, io};
use crossbeam_channel::Receiver; use crossbeam_channel::Receiver;
use notify::{DebouncedEvent, RecursiveMode, Watcher}; use notify::{watcher, DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
#[cfg(target_os = "macos")]
use notify::PollWatcher;
#[cfg(not(target_os = "macos"))]
use notify::{watcher, RecommendedWatcher};
use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent}; use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent};
/// `VfsBackend` that uses `std::fs` and the `notify` crate. /// `VfsBackend` that uses `std::fs` and the `notify` crate.
pub struct StdBackend { pub struct StdBackend {
// We use PollWatcher on macos because using the KQueue watcher
// can cause some gnarly performance problems.
#[cfg(target_os = "macos")]
watcher: PollWatcher,
#[cfg(not(target_os = "macos"))]
watcher: RecommendedWatcher, watcher: RecommendedWatcher,
watcher_receiver: Receiver<VfsEvent>, watcher_receiver: Receiver<VfsEvent>,
watches: HashSet<PathBuf>,
} }
impl StdBackend { impl StdBackend {
pub fn new() -> StdBackend { pub fn new() -> StdBackend {
let (notify_tx, notify_rx) = mpsc::channel(); let (notify_tx, notify_rx) = mpsc::channel();
#[cfg(target_os = "macos")]
let watcher = PollWatcher::new(notify_tx, Duration::from_millis(50)).unwrap();
#[cfg(not(target_os = "macos"))]
let watcher = watcher(notify_tx, Duration::from_millis(50)).unwrap(); let watcher = watcher(notify_tx, Duration::from_millis(50)).unwrap();
let (tx, rx) = crossbeam_channel::unbounded(); let (tx, rx) = crossbeam_channel::unbounded();
@@ -63,6 +49,7 @@ impl StdBackend {
Self { Self {
watcher, watcher,
watcher_receiver: rx, watcher_receiver: rx,
watches: HashSet::new(),
} }
} }
} }
@@ -112,12 +99,22 @@ impl VfsBackend for StdBackend {
} }
fn watch(&mut self, path: &Path) -> io::Result<()> { fn watch(&mut self, path: &Path) -> io::Result<()> {
self.watcher if self.watches.contains(path)
.watch(path, RecursiveMode::NonRecursive) || path
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner)) .ancestors()
.any(|ancestor| self.watches.contains(ancestor))
{
Ok(())
} else {
self.watches.insert(path.to_path_buf());
self.watcher
.watch(path, RecursiveMode::Recursive)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
}
} }
fn unwatch(&mut self, path: &Path) -> io::Result<()> { fn unwatch(&mut self, path: &Path) -> io::Result<()> {
self.watches.remove(path);
self.watcher self.watcher
.unwatch(path) .unwatch(path)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner)) .map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))

View File

@@ -1 +1 @@
7.4.0-rc1 7.4.3

View File

@@ -3,12 +3,12 @@ Error.__index = Error
Error.Kind = { Error.Kind = {
HttpNotEnabled = { HttpNotEnabled = {
message = "Rojo requires HTTP access, which is not enabled.\n" .. message = "Rojo requires HTTP access, which is not enabled.\n"
"Check your game settings, located in the 'Home' tab of Studio.", .. "Check your game settings, located in the 'Home' tab of Studio.",
}, },
ConnectFailed = { ConnectFailed = {
message = "Couldn't connect to the Rojo server.\n" .. message = "Couldn't connect to the Rojo server.\n"
"Make sure the server is running — use 'rojo serve' to run it!", .. "Make sure the server is running — use 'rojo serve' to run it!",
}, },
Timeout = { Timeout = {
message = "HTTP request timed out.", message = "HTTP request timed out.",
@@ -63,4 +63,13 @@ function Error.fromRobloxErrorString(message)
return Error.new(Error.Kind.Unknown, message) return Error.new(Error.Kind.Unknown, message)
end end
function Error.fromResponse(response)
local lower = (response.body or ""):lower()
if response.code == 408 or response.code == 504 or lower:find("timed? ?out") then
return Error.new(Error.Kind.Timeout)
end
return Error.new(Error.Kind.Unknown, string.format("%s: %s", tostring(response.code), tostring(response.body)))
end
return Error return Error

View File

@@ -30,8 +30,13 @@ local function performRequest(requestParams)
end) end)
if success then if success then
Log.trace("Request {} success, status code {}", requestId, response.StatusCode) Log.trace("Request {} success, response {:#?}", requestId, response)
resolve(HttpResponse.fromRobloxResponse(response)) local httpResponse = HttpResponse.fromRobloxResponse(response)
if httpResponse:isSuccess() then
resolve(httpResponse)
else
reject(HttpError.fromResponse(httpResponse))
end
else else
Log.trace("Request {} failure: {:?}", requestId, response) Log.trace("Request {} failure: {:?}", requestId, response)
reject(HttpError.fromRobloxErrorString(response)) reject(HttpError.fromRobloxErrorString(response))

View File

@@ -412,15 +412,6 @@ types = {
end, end,
}, },
SecurityCapabilities = {
fromPod = function(_pod)
error("SecurityCapabilities is not implemented")
end,
toPod = function(_roblox)
error("SecurityCapabilities is not implemented")
end,
},
SharedString = { SharedString = {
fromPod = function(_pod) fromPod = function(_pod)
error("SharedString is not supported") error("SharedString is not supported")
@@ -502,9 +493,32 @@ types = {
}, },
} }
types.OptionalCFrame = {
fromPod = function(pod)
if pod == nil then
return nil
else
return types.CFrame.fromPod(pod)
end
end,
toPod = function(roblox)
if roblox == nil then
return nil
else
return types.CFrame.toPod(roblox)
end
end,
}
function EncodedValue.decode(encodedValue) function EncodedValue.decode(encodedValue)
local ty, value = next(encodedValue) local ty, value = next(encodedValue)
if ty == nil then
-- If the encoded pair is empty, assume it is an unoccupied optional value
return true, nil
end
local typeImpl = types[ty] local typeImpl = types[ty]
if typeImpl == nil then if typeImpl == nil then
return false, "Couldn't decode value " .. tostring(ty) return false, "Couldn't decode value " .. tostring(ty)

View File

@@ -370,6 +370,41 @@
}, },
"ty": "NumberSequence" "ty": "NumberSequence"
}, },
"OptionalCFrame-None": {
"value": {
"OptionalCFrame": null
},
"ty": "OptionalCFrame"
},
"OptionalCFrame-Some": {
"value": {
"OptionalCFrame": {
"position": [
0.0,
0.0,
0.0
],
"orientation": [
[
1.0,
0.0,
0.0
],
[
0.0,
1.0,
0.0
],
[
0.0,
0.0,
1.0
]
]
}
},
"ty": "OptionalCFrame"
},
"PhysicalProperties-Custom": { "PhysicalProperties-Custom": {
"value": { "value": {
"PhysicalProperties": { "PhysicalProperties": {

View File

@@ -1,4 +1,5 @@
local CollectionService = game:GetService("CollectionService") local CollectionService = game:GetService("CollectionService")
local ScriptEditorService = game:GetService("ScriptEditorService")
--- A list of `Enum.Material` values that are used for Terrain.MaterialColors --- A list of `Enum.Material` values that are used for Terrain.MaterialColors
local TERRAIN_MATERIAL_COLORS = { local TERRAIN_MATERIAL_COLORS = {
@@ -36,9 +37,24 @@ return {
end, end,
write = function(instance, _, value) write = function(instance, _, value)
local existing = instance:GetAttributes() local existing = instance:GetAttributes()
local didAllWritesSucceed = true
for key, attr in pairs(value) do for attributeName, attributeValue in pairs(value) do
instance:SetAttribute(key, attr) local isNameValid =
-- For our SetAttribute to succeed, the attribute name must be
-- less than or equal to 100 characters...
#attributeName <= 100
-- ...must only contain alphanumeric characters, periods, hyphens,
-- underscores, or forward slashes...
and attributeName:match("[^%w%.%-_/]") == nil
-- ... and must not use the RBX prefix, which is reserved by Roblox.
and attributeName:sub(1, 3) ~= "RBX"
if isNameValid then
instance:SetAttribute(attributeName, attributeValue)
else
didAllWritesSucceed = false
end
end end
for key in pairs(existing) do for key in pairs(existing) do
@@ -47,7 +63,7 @@ return {
end end
end end
return true return didAllWritesSucceed
end, end,
}, },
Tags = { Tags = {
@@ -95,6 +111,19 @@ return {
return true, instance:ScaleTo(value) return true, instance:ScaleTo(value)
end, end,
}, },
WorldPivotData = {
read = function(instance)
return true, instance.WorldPivot
end,
write = function(instance, _, value)
if value == nil then
return true, nil
else
instance.WorldPivot = value
return true
end
end,
},
}, },
Terrain = { Terrain = {
MaterialColors = { MaterialColors = {
@@ -116,4 +145,34 @@ return {
end, end,
}, },
}, },
Script = {
Source = {
read = function(instance: Script)
return true, ScriptEditorService:GetEditorSource(instance)
end,
write = function(instance: Script, _, value: string)
task.spawn(function()
ScriptEditorService:UpdateSourceAsync(instance, function()
return value
end)
end)
return true
end,
},
},
ModuleScript = {
Source = {
read = function(instance: ModuleScript)
return true, ScriptEditorService:GetEditorSource(instance)
end,
write = function(instance: ModuleScript, _, value: string)
task.spawn(function()
ScriptEditorService:UpdateSourceAsync(instance, function()
return value
end)
end)
return true
end,
},
},
} }

File diff suppressed because it is too large Load Diff

View File

@@ -185,10 +185,10 @@ function ApiContext:write(patch)
body = Http.jsonEncode(body) body = Http.jsonEncode(body)
return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body) return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(responseBody)
Log.info("Write response: {:?}", body) Log.info("Write response: {:?}", responseBody)
return body return responseBody
end) end)
end end

View File

@@ -167,7 +167,7 @@ function Dropdown:render()
self.setContentSize(object.AbsoluteContentSize) self.setContentSize(object.AbsoluteContentSize)
end, end,
}), }),
Roact.createFragment(optionButtons), Options = Roact.createFragment(optionButtons),
}), }),
}) })
else nil, else nil,

View File

@@ -53,8 +53,23 @@ local function DisplayValue(props)
elseif next(props.value) == nil then elseif next(props.value) == nil then
-- If it's empty, show empty braces -- If it's empty, show empty braces
textRepresentation = "{}" textRepresentation = "{}"
elseif next(props.value) == 1 then
-- We don't need to support mixed tables, so checking the first key is enough
-- to determine if it's a simple array
local out, i = table.create(#props.value), 0
for _, v in props.value do
i += 1
-- Wrap strings in quotes
if type(v) == "string" then
v = '"' .. v .. '"'
end
out[i] = tostring(v)
end
textRepresentation = "{ " .. table.concat(out, ", ") .. " }"
else else
-- If it has children, list them out -- Otherwise, show the table contents as a dictionary
local out, i = {}, 0 local out, i = {}, 0
for k, v in pairs(props.value) do for k, v in pairs(props.value) do
i += 1 i += 1

View File

@@ -97,21 +97,16 @@ function DomLabel:render()
-- Line guides help indent depth remain readable -- Line guides help indent depth remain readable
local lineGuides = {} local lineGuides = {}
for i = 1, props.depth or 0 do for i = 1, props.depth or 0 do
table.insert( lineGuides["Line_" .. i] = e("Frame", {
lineGuides, Size = UDim2.new(0, 2, 1, 2),
e("Frame", { Position = UDim2.new(0, (20 * i) + 15, 0, -1),
Name = "Line_" .. i, BorderSizePixel = 0,
Size = UDim2.new(0, 2, 1, 2), BackgroundTransparency = props.transparency,
Position = UDim2.new(0, (20 * i) + 15, 0, -1), BackgroundColor3 = theme.BorderedContainer.BorderColor,
BorderSizePixel = 0, })
BackgroundTransparency = props.transparency,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
})
)
end end
return e("Frame", { return e("Frame", {
Name = "Change",
ClipsDescendants = true, ClipsDescendants = true,
BackgroundColor3 = if props.patchType then theme.Diff[props.patchType] else nil, BackgroundColor3 = if props.patchType then theme.Diff[props.patchType] else nil,
BorderSizePixel = 0, BorderSizePixel = 0,

View File

@@ -42,7 +42,7 @@ end
function TextButton:render() function TextButton:render()
return Theme.with(function(theme) return Theme.with(function(theme)
local textSize = local textSize =
TextService:GetTextSize(self.props.text, 18, Enum.Font.GothamSemibold, Vector2.new(math.huge, math.huge)) TextService:GetTextSize(self.props.text, 18, Enum.Font.GothamMedium, Vector2.new(math.huge, math.huge))
local style = self.props.style local style = self.props.style
@@ -83,7 +83,7 @@ function TextButton:render()
Text = e("TextLabel", { Text = e("TextLabel", {
Text = self.props.text, Text = self.props.text,
Font = Enum.Font.GothamSemibold, Font = Enum.Font.GothamMedium,
TextSize = 18, TextSize = 18,
TextColor3 = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.TextColor, theme.Disabled.TextColor), TextColor3 = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.TextColor, theme.Disabled.TextColor),
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,

View File

@@ -1,5 +1,3 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages

View File

@@ -26,7 +26,7 @@ local function invertTbl(tbl)
end end
local invertedLevels = invertTbl(Log.Level) local invertedLevels = invertTbl(Log.Level)
local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId" } local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId", "Never" }
local function Navbar(props) local function Navbar(props)
return Theme.with(function(theme) return Theme.with(function(theme)

View File

@@ -19,7 +19,6 @@ local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local strict = require(script.Parent.Parent.strict) local strict = require(script.Parent.Parent.strict)

View File

@@ -136,6 +136,7 @@ function App:init()
and self.serveSession == nil and self.serveSession == nil
and Settings:get("syncReminder") and Settings:get("syncReminder")
and self:getLastSyncTimestamp() and self:getLastSyncTimestamp()
and (self:isSyncLockAvailable())
then then
self:addNotification("You've previously synced this place. Would you like to reconnect?", 300, { self:addNotification("You've previously synced this place. Would you like to reconnect?", 300, {
Connect = { Connect = {
@@ -283,12 +284,39 @@ function App:getHostAndPort()
return host, port return host, port
end end
function App:isSyncLockAvailable()
if #Players:GetPlayers() == 0 then
-- Team Create is not active, so no one can be holding the lock
return true
end
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then
-- No lock is made yet, so it is available
return true
end
if lock.Value and lock.Value ~= Players.LocalPlayer and lock.Value.Parent then
-- Someone else is holding the lock
return false, lock.Value
end
-- The lock exists, but is not claimed
return true
end
function App:claimSyncLock() function App:claimSyncLock()
if #Players:GetPlayers() == 0 then if #Players:GetPlayers() == 0 then
Log.trace("Skipping sync lock because this isn't in Team Create") Log.trace("Skipping sync lock because this isn't in Team Create")
return true return true
end end
local isAvailable, priorOwner = self:isSyncLockAvailable()
if not isAvailable then
Log.trace("Skipping sync lock because it is already claimed")
return false, priorOwner
end
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock") local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then if not lock then
lock = Instance.new("ObjectValue") lock = Instance.new("ObjectValue")
@@ -300,11 +328,6 @@ function App:claimSyncLock()
return true return true
end end
if lock.Value and lock.Value ~= Players.LocalPlayer and lock.Value.Parent then
Log.trace("Found existing sync lock owned by {}", lock.Value)
return false, lock.Value
end
lock.Value = Players.LocalPlayer lock.Value = Players.LocalPlayer
Log.trace("Claimed existing sync lock") Log.trace("Claimed existing sync lock")
return true return true
@@ -493,6 +516,9 @@ function App:startSession()
return "Accept" return "Accept"
end end
end end
elseif confirmationBehavior == "Never" then
Log.trace("Accepting patch without confirmation because behavior is set to Never")
return "Accept"
end end
-- The datamodel name gets overwritten by Studio, making confirmation of it intrusive -- The datamodel name gets overwritten by Studio, making confirmation of it intrusive

View File

@@ -3,8 +3,10 @@ local strict = require(script.Parent.strict)
local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil
local Version = script.Parent.Parent.Version local Version = script.Parent.Parent.Version
local realVersion = Version.Value:split(".") local trimmedVersionValue = Version.Value:gsub("^%s+", ""):gsub("%s+$", "")
local major, minor, patch, metadata = trimmedVersionValue:match("^(%d+)%.(%d+)%.(%d+)(.*)$")
local realVersion = { major, minor, patch, metadata }
for i = 1, 3 do for i = 1, 3 do
local num = tonumber(realVersion[i]) local num = tonumber(realVersion[i])
if num then if num then

View File

@@ -113,27 +113,29 @@ end
function InstanceMap:destroyInstance(instance) function InstanceMap:destroyInstance(instance)
local id = self.fromInstances[instance] local id = self.fromInstances[instance]
local descendants = instance:GetDescendants()
instance:Destroy()
-- After the instance is successfully destroyed,
-- we can remove all the id mappings
if id ~= nil then if id ~= nil then
self:removeId(id) self:removeId(id)
end end
for _, descendantInstance in ipairs(instance:GetDescendants()) do for _, descendantInstance in descendants do
self:removeInstance(descendantInstance) self:removeInstance(descendantInstance)
end end
instance:Destroy()
end end
function InstanceMap:destroyId(id) function InstanceMap:destroyId(id)
local instance = self.fromIds[id] local instance = self.fromIds[id]
self:removeId(id)
if instance ~= nil then if instance ~= nil then
for _, descendantInstance in ipairs(instance:GetDescendants()) do self:destroyInstance(instance)
self:removeInstance(descendantInstance) else
end -- There is no instance with this id, so we can just remove the id
-- without worrying about instance destruction
instance:Destroy() self:removeId(id)
end end
end end

View File

@@ -233,7 +233,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
addProp( addProp(
prop, prop,
if currentSuccess then currentValue else "[Error]", if currentSuccess then currentValue else "[Error]",
if incomingSuccess then incomingValue else next(incoming) if incomingSuccess then incomingValue else select(2, next(incoming))
) )
end end
@@ -359,7 +359,7 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
if success then if success then
table.insert(changeList, { prop, "N/A", incomingValue }) table.insert(changeList, { prop, "N/A", incomingValue })
else else
table.insert(changeList, { prop, "N/A", next(incoming) }) table.insert(changeList, { prop, "N/A", select(2, next(incoming)) })
end end
end end
@@ -426,22 +426,71 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch)
-- Update isWarning metadata -- Update isWarning metadata
for _, failedChange in unappliedPatch.updated do for _, failedChange in unappliedPatch.updated do
local node = tree:getNode(failedChange.id) local node = tree:getNode(failedChange.id)
if node then if not node then
node.isWarning = true continue
Log.trace("Marked node as warning: {} {}", node.id, node.name)
if node.changeList then
for _, change in node.changeList do
if failedChange.changedProperties[change[1]] then
Log.trace(" Marked property as warning: {}", change[1])
if change[4] == nil then
change[4] = {}
end
change[4].isWarning = true
end
end
end
end end
node.isWarning = true
Log.trace("Marked node as warning: {} {}", node.id, node.name)
if not node.changeList then
continue
end
for _, change in node.changeList do
local property = change[1]
local propertyFailedToApply = if property == "Name"
then failedChange.changedName ~= nil -- Name is not in changedProperties, so it needs a special case
else failedChange.changedProperties[property] ~= nil
if not propertyFailedToApply then
-- This change didn't fail, no need to mark
continue
end
if change[4] == nil then
change[4] = { isWarning = true }
else
change[4].isWarning = true
end
Log.trace(" Marked property as warning: {}.{}", node.name, property)
end
end
for failedAdditionId in unappliedPatch.added do
local node = tree:getNode(failedAdditionId)
if not node then
continue
end
node.isWarning = true
Log.trace("Marked node as warning: {} {}", node.id, node.name)
if not node.changeList then
continue
end
for _, change in node.changeList do
-- Failed addition means that all properties failed to be added
if change[4] == nil then
change[4] = { isWarning = true }
else
change[4].isWarning = true
end
Log.trace(" Marked property as warning: {}.{}", node.name, change[1])
end
end
for _, failedRemovalIdOrInstance in unappliedPatch.removed do
local failedRemovalId = if Types.RbxId(failedRemovalIdOrInstance)
then failedRemovalIdOrInstance
else instanceMap.fromInstances[failedRemovalIdOrInstance]
if not failedRemovalId then
continue
end
local node = tree:getNode(failedRemovalId)
if not node then
continue
end
node.isWarning = true
Log.trace("Marked node as warning: {} {}", node.id, node.name)
end end
-- Update if instances exist -- Update if instances exist

View File

@@ -25,10 +25,15 @@ local function applyPatch(instanceMap, patch)
local unappliedPatch = PatchSet.newEmpty() local unappliedPatch = PatchSet.newEmpty()
for _, removedIdOrInstance in ipairs(patch.removed) do for _, removedIdOrInstance in ipairs(patch.removed) do
if Types.RbxId(removedIdOrInstance) then local removeInstanceSuccess = pcall(function()
instanceMap:destroyId(removedIdOrInstance) if Types.RbxId(removedIdOrInstance) then
else instanceMap:destroyId(removedIdOrInstance)
instanceMap:destroyInstance(removedIdOrInstance) else
instanceMap:destroyInstance(removedIdOrInstance)
end
end)
if not removeInstanceSuccess then
table.insert(unappliedPatch.removed, removedIdOrInstance)
end end
end end
@@ -170,7 +175,13 @@ local function applyPatch(instanceMap, patch)
end end
if update.changedName ~= nil then if update.changedName ~= nil then
instance.Name = update.changedName local setNameSuccess = pcall(function()
instance.Name = update.changedName
end)
if not setNameSuccess then
unappliedUpdate.changedName = update.changedName
partiallyApplied = true
end
end end
if update.changedMetadata ~= nil then if update.changedMetadata ~= nil then
@@ -183,15 +194,15 @@ local function applyPatch(instanceMap, patch)
if update.changedProperties ~= nil then if update.changedProperties ~= nil then
for propertyName, propertyValue in pairs(update.changedProperties) do for propertyName, propertyValue in pairs(update.changedProperties) do
local ok, decodedValue = decodeValue(propertyValue, instanceMap) local decodeSuccess, decodedValue = decodeValue(propertyValue, instanceMap)
if not ok then if not decodeSuccess then
unappliedUpdate.changedProperties[propertyName] = propertyValue unappliedUpdate.changedProperties[propertyName] = propertyValue
partiallyApplied = true partiallyApplied = true
continue continue
end end
local ok = setProperty(instance, propertyName, decodedValue) local setPropertySuccess = setProperty(instance, propertyName, decodedValue)
if not ok then if not setPropertySuccess then
unappliedUpdate.changedProperties[propertyName] = propertyValue unappliedUpdate.changedProperties[propertyName] = propertyValue
partiallyApplied = true partiallyApplied = true
end end

View File

@@ -27,9 +27,9 @@ local function decodeValue(encodedValue, instanceMap)
end end
end end
local ok, decodedValue = RbxDom.EncodedValue.decode(encodedValue) local decodeSuccess, decodedValue = RbxDom.EncodedValue.decode(encodedValue)
if not ok then if not decodeSuccess then
return false, return false,
Error.new(Error.CannotDecodeValue, { Error.new(Error.CannotDecodeValue, {
encodedValue = encodedValue, encodedValue = encodedValue,

View File

@@ -147,13 +147,13 @@ local function diff(instanceMap, virtualInstances, rootId)
local changedProperties = {} local changedProperties = {}
for propertyName, virtualValue in pairs(virtualInstance.Properties) do for propertyName, virtualValue in pairs(virtualInstance.Properties) do
local ok, existingValueOrErr = getProperty(instance, propertyName) local getProperySuccess, existingValueOrErr = getProperty(instance, propertyName)
if ok then if getProperySuccess then
local existingValue = existingValueOrErr local existingValue = existingValueOrErr
local ok, decodedValue = decodeValue(virtualValue, instanceMap) local decodeSuccess, decodedValue = decodeValue(virtualValue, instanceMap)
if ok then if decodeSuccess then
if not trueEquals(existingValue, decodedValue) then if not trueEquals(existingValue, decodedValue) then
Log.debug( Log.debug(
"{}.{} changed from '{}' to '{}'", "{}.{} changed from '{}' to '{}'",
@@ -165,7 +165,6 @@ local function diff(instanceMap, virtualInstances, rootId)
changedProperties[propertyName] = virtualValue changedProperties[propertyName] = virtualValue
end end
else else
local propertyType = next(virtualValue)
Log.warn( Log.warn(
"Failed to decode property {}.{}. Encoded property was: {:#?}", "Failed to decode property {}.{}. Encoded property was: {:#?}",
virtualInstance.ClassName, virtualInstance.ClassName,
@@ -178,10 +177,8 @@ local function diff(instanceMap, virtualInstances, rootId)
if err.kind == Error.UnknownProperty then if err.kind == Error.UnknownProperty then
Log.trace("Skipping unknown property {}.{}", err.details.className, err.details.propertyName) Log.trace("Skipping unknown property {}.{}", err.details.className, err.details.propertyName)
elseif err.kind == Error.UnreadableProperty then
Log.trace("Skipping unreadable property {}.{}", err.details.className, err.details.propertyName)
else else
return false, err Log.trace("Skipping unreadable property {}.{}", err.details.className, err.details.propertyName)
end end
end end
end end
@@ -220,9 +217,9 @@ local function diff(instanceMap, virtualInstances, rootId)
table.insert(patch.removed, childInstance) table.insert(patch.removed, childInstance)
end end
else else
local ok, err = diffInternal(childId) local diffSuccess, err = diffInternal(childId)
if not ok then if not diffSuccess then
return false, err return false, err
end end
end end
@@ -243,9 +240,9 @@ local function diff(instanceMap, virtualInstances, rootId)
return true return true
end end
local ok, err = diffInternal(rootId) local diffSuccess, err = diffInternal(rootId)
if not ok then if not diffSuccess then
return false, err return false, err
end end

View File

@@ -31,13 +31,13 @@ local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
-- We guard accessing Name and ClassName in order to avoid -- We guard accessing Name and ClassName in order to avoid
-- tripping over children of DataModel that Rojo won't have -- tripping over children of DataModel that Rojo won't have
-- permissions to access at all. -- permissions to access at all.
local ok, name, className = pcall(function() local accessSuccess, name, className = pcall(function()
return childInstance.Name, childInstance.ClassName return childInstance.Name, childInstance.ClassName
end) end)
-- This rule is very conservative and could be loosened in the -- This rule is very conservative and could be loosened in the
-- future, or more heuristics could be introduced. -- future, or more heuristics could be introduced.
if ok and name == virtualChild.Name and className == virtualChild.ClassName then if accessSuccess and name == virtualChild.Name and className == virtualChild.ClassName then
isExistingChildVisited[childIndex] = true isExistingChildVisited[childIndex] = true
hydrate(instanceMap, virtualInstances, childId, childInstance) hydrate(instanceMap, virtualInstances, childId, childInstance)
break break

View File

@@ -53,9 +53,9 @@ function reifyInner(instanceMap, virtualInstances, id, parentInstance, unapplied
-- Instance.new can fail if we're passing in something that can't be -- Instance.new can fail if we're passing in something that can't be
-- created, like a service, something enabled with a feature flag, or -- created, like a service, something enabled with a feature flag, or
-- something that requires higher security than we have. -- something that requires higher security than we have.
local ok, instance = pcall(Instance.new, virtualInstance.ClassName) local createSuccess, instance = pcall(Instance.new, virtualInstance.ClassName)
if not ok then if not createSuccess then
addAllToPatch(unappliedPatch, virtualInstances, id) addAllToPatch(unappliedPatch, virtualInstances, id)
return return
end end
@@ -80,14 +80,14 @@ function reifyInner(instanceMap, virtualInstances, id, parentInstance, unapplied
continue continue
end end
local ok, value = decodeValue(virtualValue, instanceMap) local decodeSuccess, value = decodeValue(virtualValue, instanceMap)
if not ok then if not decodeSuccess then
unappliedProperties[propertyName] = virtualValue unappliedProperties[propertyName] = virtualValue
continue continue
end end
local ok = setProperty(instance, propertyName, value) local setPropertySuccess = setProperty(instance, propertyName, value)
if not ok then if not setPropertySuccess then
unappliedProperties[propertyName] = virtualValue unappliedProperties[propertyName] = virtualValue
end end
end end
@@ -148,8 +148,8 @@ function applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
continue continue
end end
local ok = setProperty(entry.instance, entry.propertyName, targetInstance) local setPropertySuccess = setProperty(entry.instance, entry.propertyName, targetInstance)
if not ok then if not setPropertySuccess then
markFailed(entry.id, entry.propertyName, entry.virtualValue) markFailed(entry.id, entry.propertyName, entry.virtualValue)
end end
end end

View File

@@ -3,7 +3,6 @@ return function()
local PatchSet = require(script.Parent.Parent.PatchSet) local PatchSet = require(script.Parent.Parent.PatchSet)
local InstanceMap = require(script.Parent.Parent.InstanceMap) local InstanceMap = require(script.Parent.Parent.InstanceMap)
local Error = require(script.Parent.Error)
local function isEmpty(table) local function isEmpty(table)
return next(table) == nil, "Table was not empty" return next(table) == nil, "Table was not empty"

View File

@@ -7,7 +7,7 @@ local Log = require(Packages.Log)
local RbxDom = require(Packages.RbxDom) local RbxDom = require(Packages.RbxDom)
local Error = require(script.Parent.Error) local Error = require(script.Parent.Error)
local function setProperty(instance, propertyName, value) local function setProperty(instance: Instance, propertyName: string, value: unknown): boolean
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName) local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
-- We can skip unknown properties; they're not likely reflected to Lua. -- We can skip unknown properties; they're not likely reflected to Lua.
@@ -28,9 +28,16 @@ local function setProperty(instance, propertyName, value)
}) })
end end
local ok, err = descriptor:write(instance, value) if value == nil then
if descriptor.dataType == "Float32" or descriptor.dataType == "Float64" then
Log.trace("Skipping nil {} property {}.{}", descriptor.dataType, instance.ClassName, propertyName)
return true
end
end
if not ok then local writeSuccess, err = descriptor:write(instance, value)
if not writeSuccess then
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
return false, return false,
Error.new(Error.LackingPropertyPermissions, { Error.new(Error.LackingPropertyPermissions, {

View File

@@ -21,8 +21,8 @@ local Status = strict("Session.Status", {
Disconnected = "Disconnected", Disconnected = "Disconnected",
}) })
local function debugPatch(patch) local function debugPatch(object)
return Fmt.debugify(patch, function(patch, output) return Fmt.debugify(object, function(patch, output)
output:writeLine("Patch {{") output:writeLine("Patch {{")
output:indent() output:indent()
@@ -197,7 +197,7 @@ function ServeSession:__onActiveScriptChanged(activeScript)
local existingParent = activeScript.Parent local existingParent = activeScript.Parent
activeScript.Parent = nil activeScript.Parent = nil
for i = 1, 3 do for _ = 1, 3 do
RunService.Heartbeat:Wait() RunService.Heartbeat:Wait()
end end
@@ -251,7 +251,10 @@ function ServeSession:__initialSync(serverInfo)
if userDecision == "Abort" then if userDecision == "Abort" then
return Promise.reject("Aborted Rojo sync operation") return Promise.reject("Aborted Rojo sync operation")
elseif userDecision == "Reject" and self.__twoWaySync then elseif userDecision == "Reject" then
if not self.__twoWaySync then
return Promise.reject("Cannot reject sync operation without two-way sync enabled")
end
-- The user wants their studio DOM to write back to their Rojo DOM -- The user wants their studio DOM to write back to their Rojo DOM
-- so we will reverse the patch and send it back -- so we will reverse the patch and send it back
@@ -268,7 +271,7 @@ function ServeSession:__initialSync(serverInfo)
table.insert(inversePatch.updated, update) table.insert(inversePatch.updated, update)
end end
-- Add the removed instances back to Rojo -- Add the removed instances back to Rojo
-- selene:allow(empty_if, unused_variable) -- selene:allow(empty_if, unused_variable, empty_loop)
for _, instance in catchUpPatch.removed do for _, instance in catchUpPatch.removed do
-- TODO: Generate ID for our instance and add it to inversePatch.added -- TODO: Generate ID for our instance and add it to inversePatch.added
end end
@@ -277,16 +280,20 @@ function ServeSession:__initialSync(serverInfo)
table.insert(inversePatch.removed, id) table.insert(inversePatch.removed, id)
end end
self.__apiContext:write(inversePatch) return self.__apiContext:write(inversePatch)
elseif userDecision == "Accept" then elseif userDecision == "Accept" then
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch) local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch)
if not PatchSet.isEmpty(unappliedPatch) then if not PatchSet.isEmpty(unappliedPatch) then
Log.warn( Log.debug(
"Could not apply all changes requested by the Rojo server:\n{}", "Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch) PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
) )
end end
return Promise.resolve()
else
return Promise.reject("Invalid user decision: " .. userDecision)
end end
end) end)
end end
@@ -309,7 +316,7 @@ function ServeSession:__mainSyncLoop()
local unappliedPatch = self.__reconciler:applyPatch(message) local unappliedPatch = self.__reconciler:applyPatch(message)
if not PatchSet.isEmpty(unappliedPatch) then if not PatchSet.isEmpty(unappliedPatch) then
Log.warn( Log.debug(
"Could not apply all changes requested by the Rojo server:\n{}", "Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch) PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
) )

View File

@@ -1,53 +0,0 @@
--[[
Create a new signal that can be connected to, disconnected from, and fired.
Usage:
local signal = createSignal()
local disconnect = signal:connect(function(...)
print("fired:", ...)
end)
signal:fire("a", "b", "c")
disconnect()
Avoids mutating listeners list directly to prevent iterator invalidation if
a listener is disconnected while the signal is firing.
]]
local function createSignal()
local listeners = {}
local function connect(newListener)
local nextListeners = {}
for listener in pairs(listeners) do
nextListeners[listener] = true
end
nextListeners[newListener] = true
listeners = nextListeners
return function()
local nextListeners = {}
for listener in pairs(listeners) do
if listener ~= newListener then
nextListeners[listener] = true
end
end
listeners = nextListeners
end
end
local function fire(...)
for listener in pairs(listeners) do
listener(...)
end
end
return {
connect = connect,
fire = fire,
}
end
return createSignal

View File

@@ -13,7 +13,7 @@ function gatherAssetUrlsRecursive(currentTable, currentUrls)
if typeof(value) == "string" then if typeof(value) == "string" then
table.insert(currentUrls, value) table.insert(currentUrls, value)
elseif typeof(value) == "table" then elseif typeof(value) == "table" then
gatherAssetUrlsRecursive(value) gatherAssetUrlsRecursive(value, currentUrls)
end end
end end

View File

@@ -0,0 +1,22 @@
---
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">top-level</string>
</Properties>
<Item class="Folder" referent="1">
<Properties>
<string name="Name">second-level</string>
</Properties>
<Item class="IntValue" referent="2">
<Properties>
<string name="Name">third-level</string>
<int64 name="Value">1337</int64>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,22 @@
---
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">no_name_project</string>
</Properties>
<Item class="Folder" referent="1">
<Properties>
<string name="Name">second-level</string>
</Properties>
<Item class="BoolValue" referent="2">
<Properties>
<string name="Name">bool_value</string>
<bool name="Value">true</bool>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,13 @@
---
source: tests/tests/build.rs
assertion_line: 104
expression: contents
---
<roblox version="4">
<Item class="StringValue" referent="0">
<Properties>
<string name="Name">no_name_top_level_project</string>
<string name="Value">If this isn't named `no_name_top_level_project`, something went wrong!</string>
</Properties>
</Item>
</roblox>

View File

@@ -1,7 +1,6 @@
--- ---
source: tests/tests/build.rs source: tests/tests/build.rs
expression: contents expression: contents
--- ---
<roblox version="4"> <roblox version="4">
<Item class="Folder" referent="0"> <Item class="Folder" referent="0">
@@ -25,6 +24,7 @@ expression: contents
<R21>0</R21> <R21>0</R21>
<R22>1</R22> <R22>1</R22>
</CoordinateFrame> </CoordinateFrame>
<bool name="NeedsPivotMigration">false</bool>
<Ref name="PrimaryPart">null</Ref> <Ref name="PrimaryPart">null</Ref>
<BinaryString name="Tags"></BinaryString> <BinaryString name="Tags"></BinaryString>
</Properties> </Properties>

View File

@@ -7,7 +7,7 @@ expression: contents
<Properties> <Properties>
<string name="Name">server_init</string> <string name="Name">server_init</string>
<token name="RunContext">0</token> <token name="RunContext">0</token>
<string name="Source">return "From folder/init.server.lua"</string> <string name="Source">return "From folder/init.server.luau"</string>
</Properties> </Properties>
</Item> </Item>
</roblox> </roblox>

View File

@@ -1,7 +1,6 @@
--- ---
source: tests/tests/build.rs source: tests/tests/build.rs
expression: contents expression: contents
--- ---
<roblox version="4"> <roblox version="4">
<Item class="DataModel" referent="0"> <Item class="DataModel" referent="0">
@@ -22,6 +21,7 @@ expression: contents
<Item class="Workspace" referent="2"> <Item class="Workspace" referent="2">
<Properties> <Properties>
<string name="Name">Workspace</string> <string name="Name">Workspace</string>
<bool name="NeedsPivotMigration">false</bool>
</Properties> </Properties>
<Item class="BoolValue" referent="3"> <Item class="BoolValue" referent="3">
<Properties> <Properties>

View File

@@ -0,0 +1,9 @@
{
"name": "top-level",
"tree": {
"$className": "Folder",
"second-level": {
"$path": "src"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"tree": {
"$className": "IntValue",
"$properties": {
"Value": 1337
}
}
}

View File

@@ -0,0 +1,9 @@
{
"name": "no_name_project",
"tree": {
"$className": "Folder",
"second-level": {
"$path": "src"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"tree": {
"$className": "BoolValue",
"$properties": {
"Value": true
}
}
}

View File

@@ -0,0 +1,8 @@
{
"tree": {
"$className": "StringValue",
"$properties": {
"Value": "If this isn't named `no_name_top_level_project`, something went wrong!"
}
}
}

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)" expression: "read_response.intern_and_redact(&mut redactions, root_id)"
--- ---
instances: instances:
id-2: id-2:
@@ -22,7 +21,9 @@ instances:
ignoreUnknownInstances: false ignoreUnknownInstances: false
Name: test Name: test
Parent: id-2 Parent: id-2
Properties: {} Properties:
NeedsPivotMigration:
Bool: false
messageCursor: 1 messageCursor: 1
sessionId: id-1 sessionId: id-1

View File

@@ -1,7 +1,6 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
messageCursor: 1 messageCursor: 1
messages: messages:
@@ -14,7 +13,9 @@ messages:
ignoreUnknownInstances: false ignoreUnknownInstances: false
Name: test Name: test
Parent: id-2 Parent: id-2
Properties: {} Properties:
NeedsPivotMigration:
Bool: false
removed: [] removed: []
updated: [] updated: []
sessionId: id-1 sessionId: id-1

View File

@@ -0,0 +1,39 @@
---
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:
id-2:
Children:
- id-3
ClassName: Folder
Id: id-2
Metadata:
ignoreUnknownInstances: true
Name: top-level
Parent: "00000000000000000000000000000000"
Properties: {}
id-3:
Children:
- id-4
ClassName: Folder
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: second-level
Parent: id-2
Properties: {}
id-4:
Children: []
ClassName: IntValue
Id: id-4
Metadata:
ignoreUnknownInstances: true
Name: third-level
Parent: id-3
Properties:
Value:
Int64: 1337
messageCursor: 0
sessionId: id-1

View File

@@ -0,0 +1,14 @@
---
source: tests/tests/serve.rs
assertion_line: 316
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: top-level
protocolVersion: 4
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -0,0 +1,40 @@
---
source: tests/tests/serve.rs
assertion_line: 338
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:
id-2:
Children:
- id-3
ClassName: Folder
Id: id-2
Metadata:
ignoreUnknownInstances: true
Name: no_name_project
Parent: "00000000000000000000000000000000"
Properties: {}
id-3:
Children:
- id-4
ClassName: Folder
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: second-level
Parent: id-2
Properties: {}
id-4:
Children: []
ClassName: BoolValue
Id: id-4
Metadata:
ignoreUnknownInstances: true
Name: bool_value
Parent: id-3
Properties:
Value:
Bool: true
messageCursor: 0
sessionId: id-1

View File

@@ -0,0 +1,14 @@
---
source: tests/tests/serve.rs
assertion_line: 335
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: no_name_project
protocolVersion: 4
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -0,0 +1,19 @@
---
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:
id-2:
Children: []
ClassName: StringValue
Id: id-2
Metadata:
ignoreUnknownInstances: true
Name: no_name_top_level_project
Parent: "00000000000000000000000000000000"
Properties:
Value:
String: "If this isn't named `no_name_top_level_project`, something went wrong!"
messageCursor: 0
sessionId: id-1

View File

@@ -0,0 +1,20 @@
---
source: tests/tests/serve.rs
assertion_line: 306
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:
id-2:
Children: []
ClassName: StringValue
Id: id-2
Metadata:
ignoreUnknownInstances: true
Name: no_name_top_level_project
Parent: "00000000000000000000000000000000"
Properties:
Value:
String: "If this isn't named `no_name_top_level_project`, something went wrong!"
messageCursor: 0
sessionId: id-1

View File

@@ -0,0 +1,14 @@
---
source: tests/tests/serve.rs
assertion_line: 300
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: no_name_top_level_project
protocolVersion: 4
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -0,0 +1,9 @@
{
"name": "top-level",
"tree": {
"$className": "Folder",
"second-level": {
"$path": "src"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"tree": {
"$className": "IntValue",
"$properties": {
"Value": 1337
}
}
}

View File

@@ -0,0 +1,9 @@
{
"name": "no_name_project",
"tree": {
"$className": "Folder",
"second-level": {
"$path": "src"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"tree": {
"$className": "BoolValue",
"$properties": {
"Value": true
}
}
}

View File

@@ -0,0 +1,8 @@
{
"tree": {
"$className": "StringValue",
"$properties": {
"Value": "If this isn't named `no_name_top_level_project`, something went wrong!"
}
}
}

View File

@@ -1,6 +1,5 @@
use std::{ use std::{
fs, fs,
path::PathBuf,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
@@ -126,14 +125,41 @@ impl JobThreadContext {
// For a given VFS event, we might have many changes to different parts // For a given VFS event, we might have many changes to different parts
// of the tree. Calculate and apply all of these changes. // of the tree. Calculate and apply all of these changes.
let applied_patches = match event { let applied_patches = match event {
VfsEvent::Write(path) => { VfsEvent::Create(path) | VfsEvent::Remove(path) | VfsEvent::Write(path) => {
if path.is_dir() { let mut tree = self.tree.lock().unwrap();
return; let mut applied_patches = Vec::new();
// Find the nearest ancestor to this path that has
// associated instances in the tree. This helps make sure
// that we handle additions correctly, especially if we
// receive events for descendants of a large tree being
// created all at once.
let mut current_path = path.as_path();
let affected_ids = loop {
let ids = tree.get_ids_at_path(&current_path);
log::trace!("Path {} affects IDs {:?}", current_path.display(), ids);
if !ids.is_empty() {
break ids.to_vec();
}
log::trace!("Trying parent path...");
match current_path.parent() {
Some(parent) => current_path = parent,
None => break Vec::new(),
}
};
for id in affected_ids {
if let Some(patch) = compute_and_apply_changes(&mut tree, &self.vfs, id) {
if !patch.is_empty() {
applied_patches.push(patch);
}
}
} }
on_vfs_event(path, &self.tree, &self.vfs)
} applied_patches
VfsEvent::Create(path) | VfsEvent::Remove(path) => {
on_vfs_event(path, &self.tree, &self.vfs)
} }
_ => { _ => {
log::warn!("Unhandled VFS event: {:?}", event); log::warn!("Unhandled VFS event: {:?}", event);
@@ -236,45 +262,6 @@ impl JobThreadContext {
} }
} }
// Find the nearest ancestor to this path that has
// associated instances in the tree. This helps make sure
// that we handle additions correctly, especially if we
// receive events for descendants of a large tree being
// created all at once.
fn on_vfs_event(
path: PathBuf,
tree: &Arc<Mutex<RojoTree>>,
vfs: &Arc<Vfs>,
) -> Vec<AppliedPatchSet> {
let mut tree = tree.lock().unwrap();
let mut applied_patches = Vec::new();
let mut current_path = path.as_path();
let affected_ids = loop {
let ids = tree.get_ids_at_path(current_path);
log::trace!("Path {} affects IDs {:?}", current_path.display(), ids);
if !ids.is_empty() {
break ids.to_vec();
}
log::trace!("Trying parent path...");
match current_path.parent() {
Some(parent) => current_path = parent,
None => break Vec::new(),
}
};
for id in affected_ids {
if let Some(patch) = compute_and_apply_changes(&mut tree, vfs, id) {
if !patch.is_empty() {
applied_patches.push(patch);
}
}
}
applied_patches
}
fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<AppliedPatchSet> { fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<AppliedPatchSet> {
let metadata = tree let metadata = tree
.get_metadata(id) .get_metadata(id)

View File

@@ -146,7 +146,7 @@ impl OutputKind {
} }
} }
fn xml_encode_config() -> rbx_xml::EncodeOptions { fn xml_encode_config() -> rbx_xml::EncodeOptions<'static> {
rbx_xml::EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown) rbx_xml::EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown)
} }

View File

@@ -2,6 +2,7 @@ use std::path::PathBuf;
use anyhow::Context; use anyhow::Context;
use clap::Parser; use clap::Parser;
use memofs::Vfs;
use crate::project::Project; use crate::project::Project;
@@ -17,8 +18,11 @@ pub struct FmtProjectCommand {
impl FmtProjectCommand { impl FmtProjectCommand {
pub fn run(self) -> anyhow::Result<()> { pub fn run(self) -> anyhow::Result<()> {
let vfs = Vfs::new_default();
vfs.set_watch_enabled(false);
let base_path = resolve_path(&self.project); let base_path = resolve_path(&self.project);
let project = Project::load_fuzzy(&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'")?;
let serialized = serde_json::to_string_pretty(&project) let serialized = serde_json::to_string_pretty(&project)

View File

@@ -13,7 +13,7 @@ use super::resolve_path;
static MODEL_PROJECT: &str = static MODEL_PROJECT: &str =
include_str!("../../assets/default-model-project/default.project.json"); include_str!("../../assets/default-model-project/default.project.json");
static MODEL_README: &str = include_str!("../../assets/default-model-project/README.md"); static MODEL_README: &str = include_str!("../../assets/default-model-project/README.md");
static MODEL_INIT: &str = include_str!("../../assets/default-model-project/src-init.lua"); static MODEL_INIT: &str = include_str!("../../assets/default-model-project/src-init.luau");
static MODEL_GIT_IGNORE: &str = include_str!("../../assets/default-model-project/gitignore.txt"); static MODEL_GIT_IGNORE: &str = include_str!("../../assets/default-model-project/gitignore.txt");
static PLACE_PROJECT: &str = static PLACE_PROJECT: &str =
@@ -116,17 +116,17 @@ fn init_place(base_path: &Path, project_params: ProjectParams) -> anyhow::Result
fs::create_dir_all(src.join(&src_client))?; fs::create_dir_all(src.join(&src_client))?;
write_if_not_exists( write_if_not_exists(
&src_shared.join("Hello.lua"), &src_shared.join("Hello.luau"),
"return function()\n\tprint(\"Hello, world!\")\nend", "return function()\n\tprint(\"Hello, world!\")\nend",
)?; )?;
write_if_not_exists( write_if_not_exists(
&src_server.join("init.server.lua"), &src_server.join("init.server.luau"),
"print(\"Hello world, from server!\")", "print(\"Hello world, from server!\")",
)?; )?;
write_if_not_exists( write_if_not_exists(
&src_client.join("init.client.lua"), &src_client.join("init.client.luau"),
"print(\"Hello world, from client!\")", "print(\"Hello world, from client!\")",
)?; )?;
@@ -149,7 +149,7 @@ fn init_model(base_path: &Path, project_params: ProjectParams) -> anyhow::Result
fs::create_dir_all(&src)?; fs::create_dir_all(&src)?;
let init = project_params.render_template(MODEL_INIT); let init = project_params.render_template(MODEL_INIT);
write_if_not_exists(&src.join("init.lua"), &init)?; write_if_not_exists(&src.join("init.luau"), &init)?;
let git_ignore = project_params.render_template(MODEL_GIT_IGNORE); let git_ignore = project_params.render_template(MODEL_GIT_IGNORE);
try_git_init(base_path, &git_ignore)?; try_git_init(base_path, &git_ignore)?;
@@ -170,7 +170,7 @@ fn init_plugin(base_path: &Path, project_params: ProjectParams) -> anyhow::Resul
fs::create_dir_all(&src)?; fs::create_dir_all(&src)?;
write_if_not_exists( write_if_not_exists(
&src.join("init.server.lua"), &src.join("init.server.luau"),
"print(\"Hello world, from plugin!\")\n", "print(\"Hello world, from plugin!\")\n",
)?; )?;

View File

@@ -14,7 +14,7 @@ use tokio::runtime::Runtime;
use crate::{ use crate::{
serve_session::ServeSession, serve_session::ServeSession,
snapshot::{InstanceWithMeta, RojoTree}, snapshot::{AppliedPatchSet, InstanceWithMeta, RojoTree},
}; };
use super::resolve_path; use super::resolve_path;
@@ -90,10 +90,12 @@ impl SourcemapCommand {
loop { loop {
let receiver = session.message_queue().subscribe(cursor); let receiver = session.message_queue().subscribe(cursor);
let (new_cursor, _patch_set) = rt.block_on(receiver).unwrap(); let (new_cursor, patch_set) = rt.block_on(receiver).unwrap();
cursor = new_cursor; cursor = new_cursor;
write_sourcemap(&session, self.output.as_deref(), filter)?; if patch_set_affects_sourcemap(&session, &patch_set, filter) {
write_sourcemap(&session, self.output.as_deref(), filter)?;
}
} }
} }
@@ -116,6 +118,43 @@ fn filter_non_scripts(instance: &InstanceWithMeta) -> bool {
) )
} }
fn patch_set_affects_sourcemap(
session: &ServeSession,
patch_set: &[AppliedPatchSet],
filter: fn(&InstanceWithMeta) -> bool,
) -> bool {
let tree = session.tree();
// A sourcemap has probably changed when:
patch_set.par_iter().any(|set| {
// 1. An instance was removed, in which case it will no
// longer exist in the tree and we cant check the filter
!set.removed.is_empty()
// 2. A newly added instance passes the filter
|| set.added.iter().any(|referent| {
let instance = tree
.get_instance(*referent)
.expect("instance did not exist when updating sourcemap");
filter(&instance)
})
// 3. An existing instance has its class name, name,
// or file paths changed, and passes the filter
|| set.updated.iter().any(|updated| {
let changed = updated.changed_class_name.is_some()
|| updated.changed_name.is_some()
|| updated.changed_metadata.is_some();
if changed {
let instance = tree
.get_instance(updated.id)
.expect("instance did not exist when updating sourcemap");
filter(&instance)
} else {
false
}
})
})
}
fn recurse_create_node<'a>( fn recurse_create_node<'a>(
tree: &'a RojoTree, tree: &'a RojoTree,
referent: Ref, referent: Ref,

View File

@@ -24,7 +24,7 @@ impl<K: Hash + Eq, V: Eq> MultiMap<K, V> {
K: Borrow<Q>, K: Borrow<Q>,
Q: Hash + Eq, Q: Hash + Eq,
{ {
self.inner.get(k.borrow()).map(Vec::as_slice).unwrap_or(&[]) self.inner.get(k).map(Vec::as_slice).unwrap_or(&[])
} }
pub fn insert(&mut self, k: K, v: V) { pub fn insert(&mut self, k: K, v: V) {

View File

@@ -1,16 +1,16 @@
use std::{ use std::{
collections::{BTreeMap, HashMap, HashSet}, collections::{BTreeMap, HashMap, HashSet},
ffi::OsStr,
fs, io, fs, io,
net::IpAddr, net::IpAddr,
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use memofs::Vfs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{glob::Glob, resolution::UnresolvedValue};
glob::Glob, resolution::UnresolvedValue, snapshot_middleware::emit_legacy_scripts_default,
};
static PROJECT_FILENAME: &str = "default.project.json"; static PROJECT_FILENAME: &str = "default.project.json";
@@ -21,6 +21,14 @@ pub struct ProjectError(#[from] Error);
#[derive(Debug, Error)] #[derive(Debug, Error)]
enum Error { enum Error {
#[error("The folder for the provided project cannot be used as a project name: {}\n\
Consider setting the `name` field on this project.", .path.display())]
FolderNameInvalid { path: PathBuf },
#[error("The file name of the provided project cannot be used as a project name: {}.\n\
Consider setting the `name` field on this project.", .path.display())]
ProjectNameInvalid { path: PathBuf },
#[error(transparent)] #[error(transparent)]
Io { Io {
#[from] #[from]
@@ -41,7 +49,7 @@ enum Error {
#[serde(deny_unknown_fields, rename_all = "camelCase")] #[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct Project { pub struct Project {
/// The name of the top-level instance described by the project. /// The name of the top-level instance described by the project.
pub name: String, pub name: Option<String>,
/// The tree of instances described by this project. Projects always /// The tree of instances described by this project. Projects always
/// describe at least one instance. /// describe at least one instance.
@@ -75,12 +83,10 @@ 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>,
/// Determines if rojo should emit scripts with the appropriate `RunContext` for `*.client.lua` and `*.server.lua` files in the project. /// Determines if Rojo should emit scripts with the appropriate `RunContext`
/// Or, if rojo should keep the legacy behavior of emitting LocalScripts and Scripts with legacy Runcontext /// for `*.client.lua` and `*.server.lua` files in the project instead of
#[serde( /// using `Script` and `LocalScript` Instances.
default = "emit_legacy_scripts_default", #[serde(skip_serializing_if = "Option::is_none")]
skip_serializing_if = "Option::is_none"
)]
pub emit_legacy_scripts: Option<bool>, pub emit_legacy_scripts: Option<bool>,
/// 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
@@ -133,43 +139,91 @@ impl Project {
} }
} }
pub fn load_from_slice( /// Sets the name of a project. The order it handles is as follows:
///
/// - If the project is a `default.project.json`, uses the folder's name
/// - If a fallback is specified, uses that blindly
/// - Otherwise, loops through sync rules (including the default ones!) and
/// uses the name of the first one that matches and is a project file
fn set_file_name(&mut self, fallback: Option<&str>) -> Result<(), Error> {
let file_name = self
.file_location
.file_name()
.and_then(OsStr::to_str)
.ok_or_else(|| Error::ProjectNameInvalid {
path: self.file_location.clone(),
})?;
// If you're editing this to be generic, make sure you also alter the
// snapshot middleware to support generic init paths.
if file_name == PROJECT_FILENAME {
let folder_name = self.folder_location().file_name().and_then(OsStr::to_str);
if let Some(folder_name) = folder_name {
self.name = Some(folder_name.to_string());
} else {
return Err(Error::FolderNameInvalid {
path: self.file_location.clone(),
});
}
} else if let Some(fallback) = fallback {
self.name = Some(fallback.to_string());
} else {
unimplemented!(
"7.4.X branch will hopefully never have a case where fallback isn't provided to set_file_name"
);
}
Ok(())
}
/// Loads a Project file from the provided contents with its source set as
/// the provided location.
fn load_from_slice(
contents: &[u8], contents: &[u8],
project_file_location: &Path, project_file_location: PathBuf,
) -> Result<Self, ProjectError> { fallback_name: Option<&str>,
) -> Result<Self, Error> {
let mut project: Self = serde_json::from_slice(contents).map_err(|source| Error::Json { let mut project: Self = serde_json::from_slice(contents).map_err(|source| Error::Json {
source, source,
path: project_file_location.to_owned(), path: project_file_location.clone(),
})?; })?;
project.file_location = project_file_location;
project.file_location = project_file_location.to_path_buf();
project.check_compatibility(); project.check_compatibility();
if project.name.is_none() {
project.set_file_name(fallback_name)?;
}
Ok(project) Ok(project)
} }
pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result<Option<Self>, ProjectError> { /// Loads a Project from a path. This will find the project if it refers to
/// a `.project.json` file or if it refers to a directory that contains a
/// file named `default.project.json`.
pub fn load_fuzzy(
vfs: &Vfs,
fuzzy_project_location: &Path,
) -> Result<Option<Self>, ProjectError> {
if let Some(project_path) = Self::locate(fuzzy_project_location) { if let Some(project_path) = Self::locate(fuzzy_project_location) {
let project = Self::load_exact(&project_path)?; let contents = vfs.read(&project_path).map_err(Error::from)?;
Ok(Some(Self::load_from_slice(&contents, project_path, None)?))
Ok(Some(project))
} else { } else {
Ok(None) Ok(None)
} }
} }
fn load_exact(project_file_location: &Path) -> Result<Self, Error> { /// Loads a Project from a path.
let contents = fs::read_to_string(project_file_location)?; pub fn load_exact(
vfs: &Vfs,
let mut project: Project = project_file_location: &Path,
serde_json::from_str(&contents).map_err(|source| Error::Json { fallback_name: Option<&str>,
source, ) -> Result<Self, ProjectError> {
path: project_file_location.to_owned(), let project_path = project_file_location.to_path_buf();
})?; let contents = vfs.read(&project_path).map_err(Error::from)?;
Ok(Self::load_from_slice(
project.file_location = project_file_location.to_path_buf(); &contents,
project.check_compatibility(); project_path,
fallback_name,
Ok(project) )?)
} }
/// Checks if there are any compatibility issues with this project file and /// Checks if there are any compatibility issues with this project file and

View File

@@ -9,7 +9,6 @@ use std::{
}; };
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use memofs::IoResultExt;
use memofs::Vfs; use memofs::Vfs;
use thiserror::Error; use thiserror::Error;
@@ -110,9 +109,9 @@ impl ServeSession {
log::debug!("Loading project file from {}", project_path.display()); log::debug!("Loading project file from {}", project_path.display());
let root_project = match vfs.read(&project_path).with_not_found()? { let root_project = match Project::load_exact(&vfs, &project_path, None) {
Some(contents) => Project::load_from_slice(&contents, &project_path)?, Ok(project) => project,
None => { Err(_) => {
return Err(ServeSessionError::NoProjectFound { return Err(ServeSessionError::NoProjectFound {
path: project_path.to_path_buf(), path: project_path.to_path_buf(),
}); });
@@ -190,7 +189,10 @@ impl ServeSession {
} }
pub fn project_name(&self) -> &str { pub fn project_name(&self) -> &str {
&self.root_project.name self.root_project
.name
.as_ref()
.expect("all top-level projects must have their name set")
} }
pub fn project_port(&self) -> Option<u16> { pub fn project_port(&self) -> Option<u16> {

View File

@@ -87,10 +87,28 @@ impl RojoTree {
} }
pub fn insert_instance(&mut self, parent_ref: Ref, snapshot: InstanceSnapshot) -> Ref { pub fn insert_instance(&mut self, parent_ref: Ref, snapshot: InstanceSnapshot) -> Ref {
// !!!!!!!!!! UGLY HACK !!!!!!!!!!
//
// This is a set of special cases working around a more general problem upstream
// in rbx-dom that causes pivots to not build to file correctly, described in
// github.com/rojo-rbx/rojo/issues/628.
//
// We need to insert the NeedsPivotMigration property with a value of false on
// every instance that inherits from Model for pivots to build correctly.
let hack_needs_pivot_migration = match snapshot.class_name.as_ref() {
"Model" | "Actor" | "Tool" | "HopperBin" | "Flag" | "WorldModel" | "Workspace"
if !snapshot.properties.contains_key("NeedsPivotMigration") =>
{
vec![("NeedsPivotMigration", Variant::Bool(false))]
}
_ => Vec::new(),
};
let builder = InstanceBuilder::empty() let builder = InstanceBuilder::empty()
.with_class(snapshot.class_name.into_owned()) .with_class(snapshot.class_name.into_owned())
.with_name(snapshot.name.into_owned()) .with_name(snapshot.name.into_owned())
.with_properties(snapshot.properties); .with_properties(snapshot.properties)
.with_properties(hack_needs_pivot_migration);
let referent = self.inner.insert(parent_ref, builder); let referent = self.inner.insert(parent_ref, builder);
self.insert_metadata(referent, snapshot.metadata); self.insert_metadata(referent, snapshot.metadata);

View File

@@ -1,6 +1,5 @@
use std::{collections::HashMap, path::Path, str}; use std::{collections::HashMap, path::Path};
use anyhow::Context;
use memofs::{IoResultExt, Vfs}; use memofs::{IoResultExt, Vfs};
use rbx_dom_weak::types::Enum; use rbx_dom_weak::types::Enum;
@@ -58,10 +57,8 @@ pub fn snapshot_lua(
(_, ScriptType::Module) => ("ModuleScript", None), (_, ScriptType::Module) => ("ModuleScript", None),
}; };
let contents = vfs.read(path)?; let contents = vfs.read_to_string_lf_normalized(path)?;
let contents_str = str::from_utf8(&contents) let contents_str = contents.as_str();
.with_context(|| format!("File was not valid UTF-8: {}", path.display()))?
.to_owned();
let mut properties = HashMap::with_capacity(2); let mut properties = HashMap::with_capacity(2);
properties.insert("Source".to_owned(), contents_str.into()); properties.insert("Source".to_owned(), contents_str.into());

View File

@@ -1,4 +1,4 @@
use std::{borrow::Cow, collections::HashMap, path::Path}; use std::{borrow::Cow, collections::HashMap, ffi::OsStr, path::Path};
use anyhow::{bail, Context}; use anyhow::{bail, Context};
use memofs::Vfs; use memofs::Vfs;
@@ -19,9 +19,39 @@ pub fn snapshot_project(
vfs: &Vfs, vfs: &Vfs,
path: &Path, path: &Path,
) -> anyhow::Result<Option<InstanceSnapshot>> { ) -> anyhow::Result<Option<InstanceSnapshot>> {
let project = Project::load_from_slice(&vfs.read(path)?, path) let fallback_name = match path.file_name().and_then(OsStr::to_str) {
Some("default.project.json") => path
.parent()
.and_then(Path::file_name)
.and_then(OsStr::to_str),
Some(name) => name.strip_suffix(".project.json"),
None => anyhow::bail!(
"project file does not have valid utf-8 name: {}",
path.display()
),
};
let project = Project::load_exact(vfs, path, fallback_name)
.with_context(|| format!("File was not a valid Rojo project: {}", path.display()))?; .with_context(|| format!("File was not a valid Rojo project: {}", path.display()))?;
// This is not how I would normally do this, but this is a temporary
// implementation. The one in 7.5+ is better.
let project_name = project.name.as_deref().unwrap_or_else(|| {
let file_name = path
.file_name()
.and_then(|s| s.to_str())
.expect("project file names should be valid UTF-8");
if file_name == "default.project.json" {
path.parent()
.and_then(Path::file_name)
.and_then(|s| s.to_str())
.expect("default.project.json should be inside a folder with a valid UTF-8 name")
} else {
file_name
.strip_suffix(".project.json")
.expect("project file names should end with .project.json")
}
});
let mut context = context.clone(); let mut context = context.clone();
let rules = project.glob_ignore_paths.iter().map(|glob| PathIgnoreRule { let rules = project.glob_ignore_paths.iter().map(|glob| PathIgnoreRule {
@@ -37,7 +67,7 @@ pub fn snapshot_project(
.unwrap(), .unwrap(),
); );
match snapshot_project_node(&context, path, &project.name, &project.tree, vfs, None)? { match snapshot_project_node(&context, path, project_name, &project.tree, vfs, None)? {
Some(found_snapshot) => { Some(found_snapshot) => {
let mut snapshot = found_snapshot; let mut snapshot = found_snapshot;
// Setting the instigating source to the project file path is a little // Setting the instigating source to the project file path is a little
@@ -669,4 +699,36 @@ mod test {
insta::assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test]
fn no_name_project() {
let _ = env_logger::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/no_name_project",
VfsSnapshot::dir(hashmap! {
"default.project.json" => VfsSnapshot::file(r#"
{
"tree": {
"$className": "Model"
}
}
"#),
}),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_project(
&InstanceContext::default(),
&vfs,
Path::new("/no_name_project/default.project.json"),
)
.expect("snapshot error")
.expect("snapshot returned no instances");
insta::assert_yaml_snapshot!(instance_snapshot);
}
} }

View File

@@ -0,0 +1,18 @@
---
source: src/snapshot_middleware/project.rs
expression: instance_snapshot
---
snapshot_id: "00000000000000000000000000000000"
metadata:
ignore_unknown_instances: true
instigating_source:
Path: /no_name_project/default.project.json
relevant_paths:
- /no_name_project/default.project.json
context:
emit_legacy_scripts: true
name: no_name_project
class_name: Model
properties: {}
children: []

View File

@@ -1,6 +1,5 @@
use std::{path::Path, str}; use std::path::Path;
use anyhow::Context;
use maplit::hashmap; use maplit::hashmap;
use memofs::{IoResultExt, Vfs}; use memofs::{IoResultExt, Vfs};
@@ -14,11 +13,8 @@ pub fn snapshot_txt(
path: &Path, path: &Path,
) -> anyhow::Result<Option<InstanceSnapshot>> { ) -> anyhow::Result<Option<InstanceSnapshot>> {
let name = path.file_name_trim_end(".txt")?; let name = path.file_name_trim_end(".txt")?;
let contents = vfs.read_to_string(path)?;
let contents = vfs.read(path)?; let contents_str = contents.as_str();
let contents_str = str::from_utf8(&contents)
.with_context(|| format!("File was not valid UTF-8: {}", path.display()))?
.to_owned();
let properties = hashmap! { let properties = hashmap! {
"Value".to_owned() => contents_str.into(), "Value".to_owned() => contents_str.into(),

View File

@@ -159,7 +159,7 @@ impl ApiService {
}) })
.unwrap(); .unwrap();
json_ok(&WriteResponse { session_id }) json_ok(WriteResponse { session_id })
} }
async fn handle_api_read(&self, request: Request<Body>) -> Response<Body> { async fn handle_api_read(&self, request: Request<Body>) -> Response<Body> {
@@ -271,7 +271,7 @@ impl ApiService {
}, },
}; };
json_ok(&OpenResponse { json_ok(OpenResponse {
session_id: self.serve_session.session_id(), session_id: self.serve_session.session_id(),
}) })
} }

View File

@@ -4,6 +4,6 @@
"$path": "src" "$path": "src"
}, },
"plugins": [ "plugins": [
"test-plugin.lua" "test-plugin.luau"
] ]
} }

View File

@@ -66,7 +66,11 @@ impl TestServeSession {
let source_path = Path::new(SERVE_TESTS_PATH).join(name); let source_path = Path::new(SERVE_TESTS_PATH).join(name);
let dir = tempdir().expect("Couldn't create temporary directory"); let dir = tempdir().expect("Couldn't create temporary directory");
let project_path = dir.path().join(name); let project_path = dir
.path()
.canonicalize()
.expect("Couldn't canonicalize temporary directory path")
.join(name);
let source_is_file = fs::metadata(&source_path).unwrap().is_file(); let source_is_file = fs::metadata(&source_path).unwrap().is_file();
@@ -83,7 +87,7 @@ impl TestServeSession {
let port_string = port.to_string(); let port_string = port.to_string();
let rojo_process = Command::new(ROJO_PATH) let rojo_process = Command::new(ROJO_PATH)
.args(&[ .args([
"serve", "serve",
project_path.to_str().unwrap(), project_path.to_str().unwrap(),
"--port", "--port",
@@ -141,14 +145,14 @@ impl TestServeSession {
pub fn get_api_rojo(&self) -> Result<ServerInfoResponse, reqwest::Error> { pub fn get_api_rojo(&self) -> Result<ServerInfoResponse, reqwest::Error> {
let url = format!("http://localhost:{}/api/rojo", self.port); let url = format!("http://localhost:{}/api/rojo", self.port);
let body = reqwest::blocking::get(&url)?.text()?; let body = reqwest::blocking::get(url)?.text()?;
Ok(serde_json::from_str(&body).expect("Server returned malformed response")) Ok(serde_json::from_str(&body).expect("Server returned malformed response"))
} }
pub fn get_api_read(&self, id: Ref) -> Result<ReadResponse, reqwest::Error> { pub fn get_api_read(&self, id: Ref) -> Result<ReadResponse, reqwest::Error> {
let url = format!("http://localhost:{}/api/read/{}", self.port, id); let url = format!("http://localhost:{}/api/read/{}", self.port, id);
let body = reqwest::blocking::get(&url)?.text()?; let body = reqwest::blocking::get(url)?.text()?;
Ok(serde_json::from_str(&body).expect("Server returned malformed response")) Ok(serde_json::from_str(&body).expect("Server returned malformed response"))
} }
@@ -159,7 +163,7 @@ impl TestServeSession {
) -> Result<SubscribeResponse<'static>, reqwest::Error> { ) -> Result<SubscribeResponse<'static>, reqwest::Error> {
let url = format!("http://localhost:{}/api/subscribe/{}", self.port, cursor); let url = format!("http://localhost:{}/api/subscribe/{}", self.port, cursor);
reqwest::blocking::get(&url)?.json() reqwest::blocking::get(url)?.json()
} }
} }

View File

@@ -59,6 +59,9 @@ gen_build_tests! {
txt_in_folder, txt_in_folder,
unresolved_values, unresolved_values,
weldconstraint, weldconstraint,
no_name_default_project,
no_name_project,
no_name_top_level_project,
} }
fn run_build_test(test_name: &str) { fn run_build_test(test_name: &str) {
@@ -70,7 +73,7 @@ fn run_build_test(test_name: &str) {
let output_path = output_dir.path().join(format!("{}.rbxmx", test_name)); let output_path = output_dir.path().join(format!("{}.rbxmx", test_name));
let output = Command::new(ROJO_PATH) let output = Command::new(ROJO_PATH)
.args(&[ .args([
"build", "build",
input_path.to_str().unwrap(), input_path.to_str().unwrap(),
"-o", "-o",

View File

@@ -255,3 +255,68 @@ fn add_optional_folder() {
); );
}); });
} }
#[test]
fn no_name_default_project() {
run_serve_test("no_name_default_project", |session, mut redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
assert_yaml_snapshot!(
"no_name_default_project_info",
redactions.redacted_yaml(info)
);
let read_response = session.get_api_read(root_id).unwrap();
assert_yaml_snapshot!(
"no_name_default_project_all",
read_response.intern_and_redact(&mut redactions, root_id)
);
});
}
#[test]
fn no_name_project() {
run_serve_test("no_name_project", |session, mut redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
assert_yaml_snapshot!("no_name_project_info", redactions.redacted_yaml(info));
let read_response = session.get_api_read(root_id).unwrap();
assert_yaml_snapshot!(
"no_name_project_all",
read_response.intern_and_redact(&mut redactions, root_id)
);
});
}
#[test]
fn no_name_top_level_project() {
run_serve_test("no_name_top_level_project", |session, mut redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
assert_yaml_snapshot!(
"no_name_top_level_project_info",
redactions.redacted_yaml(info)
);
let read_response = session.get_api_read(root_id).unwrap();
assert_yaml_snapshot!(
"no_name_top_level_project_all",
read_response.intern_and_redact(&mut redactions, root_id)
);
let project_path = session.path().join("default.project.json");
let mut project_contents = fs::read_to_string(&project_path).unwrap();
project_contents.push('\n');
fs::write(&project_path, project_contents).unwrap();
// The cursor shouldn't be changing so this snapshot is fine for testing
// the response.
let read_response = session.get_api_read(root_id).unwrap();
assert_yaml_snapshot!(
"no_name_top_level_project_all-2",
read_response.intern_and_redact(&mut redactions, root_id)
);
});
}