mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-20 20:55:50 +00:00
Compare commits
67 Commits
v0.5.0-alp
...
v0.5.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
459673bd59 | ||
|
|
2968b70e6b | ||
|
|
b6989a18fc | ||
|
|
4d6a504836 | ||
|
|
6c3737df68 | ||
|
|
9f382ed9bd | ||
|
|
f9e86e58d6 | ||
|
|
469f9c927f | ||
|
|
312724189b | ||
|
|
ec0a1f1ce4 | ||
|
|
ad93631ef8 | ||
|
|
3b6238ff93 | ||
|
|
5b9facee00 | ||
|
|
376f2a554a | ||
|
|
5fd0bd3db9 | ||
|
|
2deb3bbf23 | ||
|
|
01bef0c2b8 | ||
|
|
b65a8ce680 | ||
|
|
5fc4f63238 | ||
|
|
9b0e0c175b | ||
|
|
eb97e925e6 | ||
|
|
16f8975b18 | ||
|
|
5073fce2f7 | ||
|
|
cf5036eec6 | ||
|
|
20be37dd8b | ||
|
|
93349ae2dc | ||
|
|
be81de74cd | ||
|
|
88e739090d | ||
|
|
7f324f1957 | ||
|
|
4f31c9e72f | ||
|
|
c9a663ed39 | ||
|
|
105d8aeb6b | ||
|
|
6ea1211bc5 | ||
|
|
c13291a598 | ||
|
|
aaa78c618c | ||
|
|
2890c677d4 | ||
|
|
51a010de00 | ||
|
|
ca0aabd814 | ||
|
|
91d1ba1910 | ||
|
|
c7c739dc00 | ||
|
|
7a8389bf11 | ||
|
|
5f062b8ea3 | ||
|
|
b9ee14a0f9 | ||
|
|
c3baf73455 | ||
|
|
4a597e0ba7 | ||
|
|
d5f3e25bea | ||
|
|
5e4c1a8359 | ||
|
|
d86e655ad2 | ||
|
|
80154bbf9f | ||
|
|
be853ba2a7 | ||
|
|
4d3036d030 | ||
|
|
ecb9b5e28f | ||
|
|
38e3c198f2 | ||
|
|
2f64501556 | ||
|
|
2c2554d73d | ||
|
|
69d1accf3f | ||
|
|
785bdb8ecb | ||
|
|
78a1947cec | ||
|
|
0ff59ecb4e | ||
|
|
b58fed16b4 | ||
|
|
6719be02c3 | ||
|
|
8757834e07 | ||
|
|
aa243d1b8a | ||
|
|
aeb18eb124 | ||
|
|
6c3e118ee3 | ||
|
|
3c0fe4d684 | ||
|
|
12fd9aa1ef |
2
.cargo/config
Normal file
2
.cargo/config
Normal file
@@ -0,0 +1,2 @@
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,4 +2,4 @@
|
||||
/target
|
||||
/scratch-project
|
||||
**/*.rs.bk
|
||||
/generate-docs.run
|
||||
/server/failed-snapshots/
|
||||
9
.gitmodules
vendored
9
.gitmodules
vendored
@@ -1,12 +1,6 @@
|
||||
[submodule "plugin/modules/roact"]
|
||||
path = plugin/modules/roact
|
||||
url = https://github.com/Roblox/roact.git
|
||||
[submodule "plugin/modules/rodux"]
|
||||
path = plugin/modules/rodux
|
||||
url = https://github.com/Roblox/rodux.git
|
||||
[submodule "plugin/modules/roact-rodux"]
|
||||
path = plugin/modules/roact-rodux
|
||||
url = https://github.com/Roblox/roact-rodux.git
|
||||
[submodule "plugin/modules/testez"]
|
||||
path = plugin/modules/testez
|
||||
url = https://github.com/Roblox/testez.git
|
||||
@@ -16,3 +10,6 @@
|
||||
[submodule "plugin/modules/promise"]
|
||||
path = plugin/modules/promise
|
||||
url = https://github.com/LPGhatguy/roblox-lua-promise.git
|
||||
[submodule "plugin/modules/t"]
|
||||
path = plugin/modules/t
|
||||
url = https://github.com/osyrisrblx/t.git
|
||||
60
.travis.yml
60
.travis.yml
@@ -1,38 +1,54 @@
|
||||
matrix:
|
||||
include:
|
||||
- language: python
|
||||
env:
|
||||
- LUA="lua=5.1"
|
||||
# Lua tests are currently disabled because of holes in Lemur that are pretty
|
||||
# tedious to fix. It should be fixed by either adding missing features to
|
||||
# Lemur or by migrating to a CI system based on real Roblox instead.
|
||||
|
||||
before_install:
|
||||
- pip install hererocks
|
||||
- hererocks lua_install -r^ --$LUA
|
||||
- export PATH=$PATH:$PWD/lua_install/bin
|
||||
# - language: python
|
||||
# env:
|
||||
# - LUA="lua=5.1"
|
||||
|
||||
install:
|
||||
- luarocks install luafilesystem
|
||||
- luarocks install busted
|
||||
- luarocks install luacov
|
||||
- luarocks install luacov-coveralls
|
||||
- luarocks install luacheck
|
||||
# before_install:
|
||||
# - pip install hererocks
|
||||
# - hererocks lua_install -r^ --$LUA
|
||||
# - export PATH=$PATH:$PWD/lua_install/bin
|
||||
|
||||
script:
|
||||
- cd plugin
|
||||
- luacheck src
|
||||
- lua -lluacov spec.lua
|
||||
# install:
|
||||
# - luarocks install luafilesystem
|
||||
# - luarocks install busted
|
||||
# - luarocks install luacov
|
||||
# - luarocks install luacov-coveralls
|
||||
# - luarocks install luacheck
|
||||
|
||||
after_success:
|
||||
- cd plugin
|
||||
- luacov-coveralls -e $TRAVIS_BUILD_DIR/lua_install
|
||||
# script:
|
||||
# - cd plugin
|
||||
# - luacheck src
|
||||
# - lua -lluacov spec.lua
|
||||
|
||||
# after_success:
|
||||
# - cd plugin
|
||||
# - luacov-coveralls -e $TRAVIS_BUILD_DIR/lua_install
|
||||
|
||||
- language: rust
|
||||
rust: stable
|
||||
rust: 1.32.0
|
||||
cache: cargo
|
||||
|
||||
script:
|
||||
- cargo test --verbose
|
||||
- cargo test --verbose --all-features
|
||||
|
||||
- language: rust
|
||||
rust: stable
|
||||
cache: cargo
|
||||
|
||||
script:
|
||||
- cargo test --verbose
|
||||
- cargo test --verbose --all-features
|
||||
|
||||
- language: rust
|
||||
rust: beta
|
||||
cache: cargo
|
||||
|
||||
script:
|
||||
- cargo test --verbose
|
||||
- cargo test --verbose
|
||||
- cargo test --verbose --all-features
|
||||
38
CHANGELOG.md
38
CHANGELOG.md
@@ -2,6 +2,44 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0 Alpha 6](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.6) (March 19, 2019)
|
||||
* Fixed `rojo init` giving unexpected results by upgrading to `rbx_dom_weak` 1.1.0
|
||||
* Fixed live server not responding when the Rojo plugin is connected ([#133](https://github.com/LPGhatguy/rojo/issues/133))
|
||||
* Updated default place file:
|
||||
* Improved default properties to be closer to Studio's built-in 'Baseplate' template
|
||||
* Added a baseplate to the project file (Thanks, [@AmaranthineCodices](https://github.com/AmaranthineCodices/)!)
|
||||
* Added more type support to Rojo plugin
|
||||
* Fixed some cases where the Rojo plugin would leave around objects that it knows should be deleted
|
||||
* Updated plugin to correctly listen to `Plugin.Unloading` when installing or uninstalling new plugins
|
||||
|
||||
## [0.5.0 Alpha 5](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.5) (March 1, 2019)
|
||||
* Upgraded core dependencies, which improves compatibility for lots of instance types
|
||||
* Upgraded from `rbx_tree` 0.2.0 to `rbx_dom_weak` 1.0.0
|
||||
* Upgraded from `rbx_xml` 0.2.0 to `rbx_xml` 0.4.0
|
||||
* Upgraded from `rbx_binary` 0.2.0 to `rbx_binary` 0.4.0
|
||||
* Added support for non-primitive types in the Rojo plugin.
|
||||
* Types like `Color3` and `CFrame` can now be updated live!
|
||||
* Fixed plugin assets flashing in on first load ([#121](https://github.com/LPGhatguy/rojo/issues/121))
|
||||
* Changed Rojo's HTTP server from Rouille to Hyper, which reduced the release size by around a megabyte.
|
||||
* Added property type inference to projects, which makes specifying services a lot easier ([#130](https://github.com/LPGhatguy/rojo/pull/130))
|
||||
* Made error messages from invalid and missing files more user-friendly
|
||||
|
||||
## [0.5.0 Alpha 4](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.4) (February 8, 2019)
|
||||
* Added support for nested partitions ([#102](https://github.com/LPGhatguy/rojo/issues/102))
|
||||
* Added support for 'transmuting' partitions ([#112](https://github.com/LPGhatguy/rojo/issues/112))
|
||||
* Added support for aliasing filesystem paths ([#105](https://github.com/LPGhatguy/rojo/issues/105))
|
||||
* Changed Windows builds to statically link the CRT ([#89](https://github.com/LPGhatguy/rojo/issues/89))
|
||||
|
||||
## [0.5.0 Alpha 3](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.3) (February 1, 2019)
|
||||
* Changed default project file name from `roblox-project.json` to `default.project.json` ([#120](https://github.com/LPGhatguy/rojo/pull/120))
|
||||
* The old file name will still be supported until 0.5.0 is fully released.
|
||||
* Added warning when loading project files that don't end in `.project.json`
|
||||
* This new extension enables Rojo to distinguish project files from random JSON files, which is necessary to support nested projects.
|
||||
* Added new (empty) diagnostic page served from the server
|
||||
* Added better error messages for when a file is missing that's referenced by a Rojo project
|
||||
* Added support for visualization endpoints returning GraphViz source when Dot is not available
|
||||
* Fixed an in-memory filesystem regression introduced recently ([#119](https://github.com/LPGhatguy/rojo/pull/119))
|
||||
|
||||
## [0.5.0 Alpha 2](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.2) (January 28, 2019)
|
||||
* Added support for `.model.json` files, compatible with 0.4.x
|
||||
* Fixed in-memory filesystem not handling out-of-order filesystem change events
|
||||
|
||||
1045
Cargo.lock
generated
1045
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ If you use a plugin that _isn't_ Rojo for syncing code, open an issue and let me
|
||||
## Contributing
|
||||
Pull requests are welcome!
|
||||
|
||||
All pull requests are run against a test suite on Travis CI. That test suite should always pass!
|
||||
Rojo supports Rust 1.32 and newer. Any changes to the minimum required compiler version require a _minor_ version bump.
|
||||
|
||||
## License
|
||||
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.
|
||||
@@ -1,3 +1,7 @@
|
||||
[TOC]
|
||||
|
||||
## Creating the Rojo Project
|
||||
|
||||
To use Rojo to build a place, you'll need to create a new project file, which tells Rojo how your project is structured on-disk and in Roblox.
|
||||
|
||||
Create a new folder, then run `rojo init` inside that folder to initialize an empty project.
|
||||
@@ -9,7 +13,7 @@ cd my-new-project
|
||||
rojo init
|
||||
```
|
||||
|
||||
Rojo will make a small project file in your directory, named `roblox-project.json`. It'll make sure that any code in the directory `src` will get put into `ReplicatedStorage.Source`.
|
||||
Rojo will make a small project file in your directory, named `default.project.json`. It'll make sure that any code in the directory `src` will get put into `ReplicatedStorage.Source`.
|
||||
|
||||
Speaking of, let's make sure we create a directory named `src`, and maybe a Lua file inside of it:
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
Rojo has two components:
|
||||
|
||||
* The server, a binary written in Rust
|
||||
* The plugin, a Roblox Studio plugin written in Lua
|
||||
|
||||
It's important that the plugin and server are compatible. The plugin will show errors in the Roblox Studio Output window if there is a version mismatch.
|
||||
|
||||
## Installing the Server
|
||||
To install the server, either:
|
||||
|
||||
* If you have Rust installed, use `cargo install rojo`
|
||||
* Or, download a pre-built Windows binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases)
|
||||
|
||||
**The Rojo binary must be run from the command line, like Terminal on MacOS or `cmd.exe` on Windows. It's recommended that you put the Rojo binary on your `PATH` to make this easier.**
|
||||
|
||||
## Installing the Plugin
|
||||
To install the plugin, either:
|
||||
|
||||
* Install the plugin from the [Roblox plugin page](https://www.roblox.com/library/1211549683/Rojo).
|
||||
* This gives you less control over what version you install -- you will always have the latest version.
|
||||
* Or, download the latest release from [the GitHub releases section](https://github.com/LPGhatguy/rojo/releases) and install it into your Roblox plugins folder
|
||||
* You can open this folder by clicking the "Plugins Folder" button from the Plugins toolbar in Roblox Studio
|
||||
|
||||
## Visual Studio Code Extension
|
||||
If you use Visual Studio Code on Windows, you can install [Evaera's unofficial Rojo extension](https://marketplace.visualstudio.com/items?itemName=evaera.vscode-rojo), which will install both halves of Rojo for you. It even has a nifty UI to add partitions and start/stop the Rojo server!
|
||||
BIN
docs/images/plugins-folder-in-studio.png
Normal file
BIN
docs/images/plugins-folder-in-studio.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -1,4 +1,4 @@
|
||||
This is the documentation home for Rojo.
|
||||
This is the documentation home for Rojo 0.5.x.
|
||||
|
||||
Available versions of these docs:
|
||||
|
||||
|
||||
45
docs/installation.md
Normal file
45
docs/installation.md
Normal file
@@ -0,0 +1,45 @@
|
||||
[TOC]
|
||||
|
||||
## Overview
|
||||
|
||||
Rojo has two components:
|
||||
|
||||
* The command line interface (CLI)
|
||||
* The Roblox Studio plugin
|
||||
|
||||
!!! info
|
||||
It's important that your installed version of the plugin and CLI are compatible.
|
||||
|
||||
The plugin will show errors in the Roblox Studio output window if there is a version mismatch.
|
||||
|
||||
## Installing the CLI
|
||||
|
||||
### Installing from GitHub
|
||||
If you're on Windows, there are pre-built binaries available from Rojo's [GitHub Releases page](https://github.com/LPGhatguy/rojo/releases).
|
||||
|
||||
The Rojo CLI must be run from the command line, like Terminal.app on MacOS or `cmd.exe` on Windows. It's recommended that you put the Rojo CLI executable on your `PATH` to make this easier.
|
||||
|
||||
### Installing from Cargo
|
||||
If you have Rust installed, the easiest way to get Rojo is with Cargo!
|
||||
|
||||
To install the latest 0.5.0 alpha, use:
|
||||
|
||||
```sh
|
||||
cargo install rojo --version 0.5.0-alpha.6
|
||||
```
|
||||
|
||||
## Installing the Plugin
|
||||
|
||||
### Installing from GitHub
|
||||
The Rojo Roblox Studio plugin is available available from Rojo's [GitHub Releases page](https://github.com/LPGhatguy/rojo/releases).
|
||||
|
||||
Download the attached `rbxm` file and put it into your Roblox Studio plugins folder. You can find that folder by pressing **Plugins Folder** from your Plugins toolbar in Roblox Studio:
|
||||
|
||||

|
||||
{: align="center" }
|
||||
|
||||
### Installing from Roblox.com
|
||||
Visit [Rojo's Roblox.com Plugin page](https://www.roblox.com/library/1997686364/Rojo-0-5-0-alpha-3) in Roblox Studio and press **Install**.
|
||||
|
||||
## Visual Studio Code Extension
|
||||
If you use Visual Studio Code on Windows, you can install [Evaera's unofficial Rojo extension](https://marketplace.visualstudio.com/items?itemName=evaera.vscode-rojo), which will install both halves of Rojo for you. It even has a nifty UI to add partitions and start/stop the Rojo server!
|
||||
45
docs/internals/overview.md
Normal file
45
docs/internals/overview.md
Normal file
@@ -0,0 +1,45 @@
|
||||
This document aims to give a general overview of how Rojo works. It's intended for people who want to contribute to the project as well as anyone who's just curious how the tool works!
|
||||
|
||||
[TOC]
|
||||
|
||||
## CLI
|
||||
|
||||
### RbxTree
|
||||
Rojo uses a library named [`rbx_tree`](https://github.com/LPGhatguy/rbx-tree) as its implementation of the Roblox DOM. It serves as a common format for serialization to all the formats Rojo supports!
|
||||
|
||||
Rojo uses two related libraries to deserialize instances from Roblox's file formats, `rbx_xml` and `rbx_binary`.
|
||||
|
||||
### In-Memory Filesystem (IMFS)
|
||||
Relevant source files:
|
||||
|
||||
* [`server/src/imfs.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/imfs.rs)
|
||||
* [`server/src/fs_watcher.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/fs_watcher.rs)
|
||||
|
||||
Rojo keeps an in-memory copy of all files that it needs reasons about. This enables taking fast, stateless, tear-tree snapshots of files to turn them into instances.
|
||||
|
||||
Keeping an in-memory copy of file contents will also enable Rojo to debounce changes that are caused by Rojo itself. This'll happen when two-way sync finally happens.
|
||||
|
||||
### Snapshot Reconciler
|
||||
Relevant source files:
|
||||
|
||||
* [`server/src/snapshot_reconciler.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/snapshot_reconciler.rs)
|
||||
* [`server/src/rbx_snapshot.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/rbx_snapshot.rs)
|
||||
* [`server/src/rbx_session.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/rbx_session.rs)
|
||||
|
||||
To simplify incremental updates of instances, Rojo generates lightweight snapshots describing how files map to instances. This means that Rojo can treat file change events similarly to damage painting as opposed to trying to surgically update the correct instances.
|
||||
|
||||
This approach reduces the number of desynchronization bugs, reduces the complexity of important pieces of the codebase, and makes writing plugins a lot easier.
|
||||
|
||||
### HTTP API
|
||||
Relevant source files:
|
||||
|
||||
* [`server/src/web.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/web.rs)
|
||||
|
||||
The Rojo live-sync server and Roblox Studio plugin communicate via HTTP.
|
||||
|
||||
Requests sent from the plugin to the server are regular HTTP requests.
|
||||
|
||||
Messages sent from the server to the plugin are delivered via HTTP long-polling. This is an approach that uses long-lived HTTP requests that restart on timeout. It's largely been replaced by WebSockets, but Roblox doesn't have support for them.
|
||||
|
||||
## Roblox Studio Plugin
|
||||
TODO
|
||||
@@ -1,7 +1,9 @@
|
||||
Rojo underwent a large refactor during most of 2018 to enable a bunch of new features and lay groundwork for lots more in 2019. As such, Rojo **0.5.x** projects are not compatible with Rojo **0.4.x** projects.
|
||||
|
||||
[TOC]
|
||||
|
||||
## Supporting Both 0.4.x and 0.5.x
|
||||
Rojo 0.5.x uses a different name for its project format. While 0.4.x used `rojo.json`, 0.5.x uses `roblox-project.json`, which allows them to coexist.
|
||||
Rojo 0.5.x uses a different name for its project format. While 0.4.x used `rojo.json`, 0.5.x uses `default.project.json`, which allows them to coexist.
|
||||
|
||||
If you aren't sure about upgrading or want to upgrade gradually, it's possible to keep both files in the same project without causing problems.
|
||||
|
||||
|
||||
100
docs/project-format.md
Normal file
100
docs/project-format.md
Normal file
@@ -0,0 +1,100 @@
|
||||
[TOC]
|
||||
|
||||
## Project File
|
||||
|
||||
Rojo projects are JSON files that have the `.project.json` extension. They have these fields:
|
||||
|
||||
* `name`: A string indicating the name of the project.
|
||||
* This is only used for diagnostics.
|
||||
* `tree`: An [Instance Description](#instance-description) describing the root instance of the project.
|
||||
|
||||
## Instance Description
|
||||
Instance Descriptions correspond one-to-one with the actual Roblox Instances in the project. They can be specified directly in the project file or be pulled from the filesystem.
|
||||
|
||||
* `$className`: The ClassName of the Instance being described.
|
||||
* Optional if `$path` is specified.
|
||||
* `$path`: The path on the filesystem to pull files from into the project.
|
||||
* Optional if `$className` is specified.
|
||||
* Paths are relative to the folder containing the project file.
|
||||
* `$properties`: Properties to apply to the instance. Values should be [Instance Property Values](#instance-property-value).
|
||||
* Optional
|
||||
* `$ignoreUnknownInstances`: Whether instances that Rojo doesn't know about should be deleted.
|
||||
* Optional
|
||||
* Default is `false` if `$path` is specified, otherwise `true`.
|
||||
|
||||
All other fields in an Instance Description are turned into instances whose name is the key. These values should also be Instance Descriptions!
|
||||
|
||||
Instance Descriptions are fairly verbose and strict. In the future, it'll be possible for Rojo to infer class names for known services like `Workspace`.
|
||||
|
||||
## Instance Property Value
|
||||
The shape of Instance Property Values is defined by the [rbx_tree](https://github.com/LPGhatguy/rbx-tree) library, so it uses slightly different conventions than the rest of Rojo.
|
||||
|
||||
Each value should be an object with the following required fields:
|
||||
|
||||
* `Type`: The type of property to represent.
|
||||
* [Supported types can be found here](https://github.com/LPGhatguy/rbx-tree#property-type-coverage).
|
||||
* `Value`: The value of the property.
|
||||
* The shape of this field depends on which property type is being used. `Vector3` and `Color3` values are both represented as a list of numbers, for example.
|
||||
|
||||
Instance Property Values are intentionally very strict. Rojo will eventually be able to infer types for you!
|
||||
|
||||
## Example Projects
|
||||
This project bundles up everything in the `src` directory. It'd be suitable for making a plugin or model:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "AwesomeLibrary",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This project describes the layout you might use if you were making the next hit simulator game, *Sisyphus Simulator*:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Sisyphus Simulator",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
|
||||
"HttpService": {
|
||||
"$className": "HttpService",
|
||||
"$properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"$path": "src/ReplicatedStorage"
|
||||
},
|
||||
|
||||
"StarterPlayer": {
|
||||
"$className": "StarterPlayer",
|
||||
|
||||
"StarterPlayerScripts": {
|
||||
"$className": "StarterPlayerScripts",
|
||||
"$path": "src/StarterPlayerScripts"
|
||||
}
|
||||
},
|
||||
|
||||
"Workspace": {
|
||||
"$className": "Workspace",
|
||||
"$properties": {
|
||||
"Gravity": {
|
||||
"Type": "Float32",
|
||||
"Value": 67.3
|
||||
}
|
||||
},
|
||||
|
||||
"Terrain": {
|
||||
"$path": "Terrain.rbxm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,5 +1,7 @@
|
||||
This page aims to describe how Rojo turns files on the filesystem into Roblox objects.
|
||||
|
||||
[TOC]
|
||||
|
||||
## Overview
|
||||
| File Name | Instance Type |
|
||||
| -------------- | ------------------- |
|
||||
@@ -22,7 +24,7 @@ Some common cases you might hit are:
|
||||
* `MeshPart.MeshId`
|
||||
* `HttpService.HttpEnabled`
|
||||
|
||||
For a list of all property types that Rojo can reason about, both when live-syncing and when building place files, look at [rbx_tree's type coverage documentation](https://github.com/LPGhatguy/rbx-tree/tree/master/rbx_tree#coverage).
|
||||
For a list of all property types that Rojo can reason about, both when live-syncing and when building place files, look at [rbx_tree's type coverage chart](https://github.com/LPGhatguy/rbx-tree#property-type-coverage).
|
||||
|
||||
## Folders
|
||||
Any directory on the filesystem will turn into a `Folder` instance unless it contains an 'init' script, described below.
|
||||
@@ -34,19 +36,13 @@ If a directory contains a file named `init.server.lua`, `init.client.lua`, or `i
|
||||
|
||||
For example, these files:
|
||||
|
||||
<div align="center">
|
||||
<a href="../images/sync-example-files.svg">
|
||||
<img src="../images/sync-example-files.svg" alt="Tree of files on disk" />
|
||||
</a>
|
||||
</div>
|
||||

|
||||
{: align="center" }
|
||||
|
||||
Will turn into these instances in Roblox:
|
||||
|
||||
<div align="center">
|
||||
<a href="../images/sync-example-instances.svg">
|
||||
<img src="../images/sync-example-instances.svg" alt="Tree of instances in Roblox" />
|
||||
</a>
|
||||
</div>
|
||||

|
||||
{: align="center" }
|
||||
|
||||
## Localization Tables
|
||||
Any CSV files are transformed into `LocalizationTable` instances. Rojo expects these files to follow the same format that Roblox does when importing and exporting localization information.
|
||||
@@ -84,18 +80,12 @@ A JSON model describing a folder containing a `Part` and a `RemoteEvent` could b
|
||||
|
||||
It would turn into instances in this shape:
|
||||
|
||||
<div align="center">
|
||||
<a href="../images/sync-example-json-model.svg">
|
||||
<img src="../images/sync-example-json-model.svg" alt="Tree of instances in Roblox" />
|
||||
</a>
|
||||
</div>
|
||||

|
||||
{: align="center" }
|
||||
|
||||
## Binary and XML Models
|
||||
Rojo supports both binary (`.rbxm`) and XML (`.rbxmx`) models generated by Roblox Studio or another tool.
|
||||
|
||||
Not all property types are supported!
|
||||
Not all property types are supported for all formats!
|
||||
|
||||
For a rundown of supported types, see:
|
||||
|
||||
* [rbxm Type Coverage](https://github.com/LPGhatguy/rbx-tree/tree/master/rbx_binary#coverage)
|
||||
* [rbxmx Type Coverage](https://github.com/LPGhatguy/rbx-tree/tree/master/rbx_xml#coverage)
|
||||
For a rundown of supported types, check out [rbx_tree's type coverage chart](https://github.com/LPGhatguy/rbx-tree#property-type-coverage).
|
||||
@@ -1,16 +1,19 @@
|
||||
There are a number of existing plugins for Roblox that move code from the filesystem into Roblox.
|
||||
|
||||
Besides Rojo, there is:
|
||||
Besides Rojo, you might consider:
|
||||
|
||||
* [Studio Bridge](https://github.com/vocksel/studio-bridge) by [Vocksel](https://github.com/vocksel)
|
||||
* [RbxRefresh](https://github.com/osyrisrblx/RbxRefresh) by [Osyris](https://github.com/osyrisrblx)
|
||||
* [RbxSync](https://github.com/evaera/RbxSync) by [evaera](https://github.com/evaera)
|
||||
* [CodeSync](https://github.com/MemoryPenguin/CodeSync) and [rbx-exteditor](https://github.com/MemoryPenguin/rbx-exteditor) by [MemoryPenguin](https://github.com/MemoryPenguin)
|
||||
* [rbxmk](https://github.com/anaminus/rbxmk) by [Anaminus](https://github.com/anaminus)
|
||||
* [rbxmk by Anaminus](https://github.com/anaminus/rbxmk)
|
||||
* [Rofresh by Osyris](https://github.com/osyrisrblx/rofresh)
|
||||
* [RbxRefresh by Osyris](https://github.com/osyrisrblx/RbxRefresh)
|
||||
* [Studio Bridge by Vocksel](https://github.com/vocksel/studio-bridge)
|
||||
* [Elixir by Vocksel](https://github.com/vocksel/elixir)
|
||||
* [RbxSync by evaera](https://github.com/evaera/RbxSync)
|
||||
* [CodeSync by MemoryPenguin](https://github.com/MemoryPenguin/CodeSync)
|
||||
* [rbx-exteditor by MemoryPenguin](https://github.com/MemoryPenguin/rbx-exteditor)
|
||||
|
||||
So why did I build Rojo?
|
||||
|
||||
Each of these tools solves what is essentially the same problem from a few different angles. The goal of Rojo is to take all of the lessons and ideas learned from these projects and build a tool that can solve the problem for good.
|
||||
Each of these tools solves what is essentially the same problem from a few different angles. The goal of Rojo is to take all of the lessons and ideas learned from these projects and build a tool that can solve this problem for good.
|
||||
|
||||
Additionally:
|
||||
|
||||
|
||||
10
mkdocs.yml
10
mkdocs.yml
@@ -11,11 +11,13 @@ theme:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Why Rojo?: why-rojo.md
|
||||
- Getting Started:
|
||||
- Installation: getting-started/installation.md
|
||||
- Creating a Place with Rojo: getting-started/creating-a-place.md
|
||||
- Sync Details: sync-details.md
|
||||
- Installation: installation.md
|
||||
- Creating a Place with Rojo: creating-a-place.md
|
||||
- Migrating from 0.4.x to 0.5.x: migrating-to-epiphany.md
|
||||
- Project Format: project-format.md
|
||||
- Sync Details: sync-details.md
|
||||
- Rojo Internals:
|
||||
- Internals Overview: internals/overview.md
|
||||
|
||||
extra_css:
|
||||
- extra.css
|
||||
|
||||
@@ -13,6 +13,7 @@ stds.roblox = {
|
||||
|
||||
-- Types
|
||||
"Vector2", "Vector3",
|
||||
"Vector2int16", "Vector3int16",
|
||||
"Color3",
|
||||
"UDim", "UDim2",
|
||||
"Rect",
|
||||
|
||||
@@ -8,14 +8,11 @@
|
||||
"Roact": {
|
||||
"$path": "modules/roact/lib"
|
||||
},
|
||||
"Rodux": {
|
||||
"$path": "modules/rodux/lib"
|
||||
},
|
||||
"RoactRodux": {
|
||||
"$path": "modules/roact-rodux/lib"
|
||||
},
|
||||
"Promise": {
|
||||
"$path": "modules/promise/lib"
|
||||
},
|
||||
"t": {
|
||||
"$path": "modules/t/lib/t.lua"
|
||||
}
|
||||
}
|
||||
}
|
||||
Submodule plugin/modules/roact-rodux deleted from 5d2e6885fc
Submodule plugin/modules/rodux deleted from 862f1c769a
1
plugin/modules/t
Submodule
1
plugin/modules/t
Submodule
Submodule plugin/modules/t added at a3a80ebf0a
@@ -15,14 +15,11 @@
|
||||
"Roact": {
|
||||
"$path": "modules/roact/lib"
|
||||
},
|
||||
"Rodux": {
|
||||
"$path": "modules/rodux/lib"
|
||||
},
|
||||
"RoactRodux": {
|
||||
"$path": "modules/roact-rodux/lib"
|
||||
},
|
||||
"Promise": {
|
||||
"$path": "modules/promise/lib"
|
||||
},
|
||||
"t": {
|
||||
"$path": "modules/t/lib/t.lua"
|
||||
}
|
||||
},
|
||||
"TestEZ": {
|
||||
@@ -40,8 +37,8 @@
|
||||
}
|
||||
},
|
||||
|
||||
"TestService": {
|
||||
"$className": "TestService",
|
||||
"ServerScriptService": {
|
||||
"$className": "ServerScriptService",
|
||||
|
||||
"TestBootstrap": {
|
||||
"$path": "testBootstrap.server.lua"
|
||||
@@ -1,9 +1,7 @@
|
||||
local sheetAsset = "rbxassetid://2738712459"
|
||||
|
||||
local Assets = {
|
||||
Sprites = {
|
||||
WhiteCross = {
|
||||
asset = sheetAsset,
|
||||
asset = "rbxassetid://2738712459",
|
||||
offset = Vector2.new(190, 318),
|
||||
size = Vector2.new(18, 18),
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ local Config = require(Plugin.Config)
|
||||
local Version = require(Plugin.Version)
|
||||
local Logging = require(Plugin.Logging)
|
||||
local DevSettings = require(Plugin.DevSettings)
|
||||
local preloadAssets = require(Plugin.preloadAssets)
|
||||
|
||||
local ConnectPanel = require(Plugin.Components.ConnectPanel)
|
||||
local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel)
|
||||
@@ -177,6 +178,15 @@ function App:didMount()
|
||||
})
|
||||
end
|
||||
end)
|
||||
|
||||
preloadAssets()
|
||||
end
|
||||
|
||||
function App:willUnmount()
|
||||
if self.currentSession ~= nil then
|
||||
self.currentSession:disconnect()
|
||||
self.currentSession = nil
|
||||
end
|
||||
end
|
||||
|
||||
function App:didUpdate()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
return {
|
||||
codename = "Epiphany",
|
||||
version = {0, 5, 0, "-alpha.2"},
|
||||
version = {0, 5, 0, "-alpha.6"},
|
||||
expectedServerVersionString = "0.5.0 or newer",
|
||||
protocolVersion = 2,
|
||||
defaultHost = "localhost",
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
local Config = require(script.Parent.Config)
|
||||
|
||||
local Environment = {
|
||||
User = "User",
|
||||
Dev = "Dev",
|
||||
Test = "Test",
|
||||
}
|
||||
|
||||
local VALUES = {
|
||||
LogLevel = {
|
||||
type = "IntValue",
|
||||
defaultUserValue = 2,
|
||||
defaultDevValue = 3,
|
||||
values = {
|
||||
[Environment.User] = 2,
|
||||
[Environment.Dev] = 3,
|
||||
[Environment.Test] = 3,
|
||||
},
|
||||
},
|
||||
TypecheckingEnabled = {
|
||||
type = "BoolValue",
|
||||
values = {
|
||||
[Environment.User] = false,
|
||||
[Environment.Dev] = true,
|
||||
[Environment.Test] = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -42,7 +59,9 @@ local function setStoredValue(name, kind, value)
|
||||
object.Value = value
|
||||
end
|
||||
|
||||
local function createAllValues()
|
||||
local function createAllValues(environment)
|
||||
assert(Environment[environment] ~= nil, "Invalid environment")
|
||||
|
||||
valueContainer = getValueContainer()
|
||||
|
||||
if valueContainer == nil then
|
||||
@@ -52,20 +71,57 @@ local function createAllValues()
|
||||
end
|
||||
|
||||
for name, value in pairs(VALUES) do
|
||||
setStoredValue(name, value.type, value.defaultDevValue)
|
||||
setStoredValue(name, value.type, value.values[environment])
|
||||
end
|
||||
end
|
||||
|
||||
_G[("ROJO_%s_DEV_CREATE"):format(Config.codename:upper())] = createAllValues
|
||||
local function getValue(name)
|
||||
assert(VALUES[name] ~= nil, "Invalid DevSettings name")
|
||||
|
||||
local stored = getStoredValue(name)
|
||||
|
||||
if stored ~= nil then
|
||||
return stored
|
||||
end
|
||||
|
||||
return VALUES[name].values[Environment.User]
|
||||
end
|
||||
|
||||
local DevSettings = {}
|
||||
|
||||
function DevSettings:createDevSettings()
|
||||
createAllValues(Environment.Dev)
|
||||
end
|
||||
|
||||
function DevSettings:createTestSettings()
|
||||
createAllValues(Environment.Test)
|
||||
end
|
||||
|
||||
function DevSettings:hasChangedValues()
|
||||
return valueContainer ~= nil
|
||||
end
|
||||
|
||||
function DevSettings:resetValues()
|
||||
if valueContainer then
|
||||
valueContainer:Destroy()
|
||||
valueContainer = nil
|
||||
end
|
||||
end
|
||||
|
||||
function DevSettings:isEnabled()
|
||||
return valueContainer ~= nil
|
||||
end
|
||||
|
||||
function DevSettings:getLogLevel()
|
||||
return getStoredValue("LogLevel") or VALUES.LogLevel.defaultUserValue
|
||||
return getValue("LogLevel")
|
||||
end
|
||||
|
||||
function DevSettings:shouldTypecheck()
|
||||
return getValue("TypecheckingEnabled")
|
||||
end
|
||||
|
||||
function _G.ROJO_DEV_CREATE()
|
||||
DevSettings:createDevSettings()
|
||||
end
|
||||
|
||||
return DevSettings
|
||||
81
plugin/src/InstanceMap.lua
Normal file
81
plugin/src/InstanceMap.lua
Normal file
@@ -0,0 +1,81 @@
|
||||
local Logging = require(script.Parent.Logging)
|
||||
|
||||
--[[
|
||||
A bidirectional map between instance IDs and Roblox instances. It lets us
|
||||
keep track of every instance we know about.
|
||||
|
||||
TODO: Track ancestry to catch when stuff moves?
|
||||
]]
|
||||
local InstanceMap = {}
|
||||
InstanceMap.__index = InstanceMap
|
||||
|
||||
function InstanceMap.new()
|
||||
local self = {
|
||||
fromIds = {},
|
||||
fromInstances = {},
|
||||
}
|
||||
|
||||
return setmetatable(self, InstanceMap)
|
||||
end
|
||||
|
||||
function InstanceMap:insert(id, instance)
|
||||
self.fromIds[id] = instance
|
||||
self.fromInstances[instance] = id
|
||||
end
|
||||
|
||||
function InstanceMap:removeId(id)
|
||||
local instance = self.fromIds[id]
|
||||
|
||||
if instance ~= nil then
|
||||
self.fromIds[id] = nil
|
||||
self.fromInstances[instance] = nil
|
||||
else
|
||||
Logging.warn("Attempted to remove nonexistant ID %s", tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
function InstanceMap:removeInstance(instance)
|
||||
local id = self.fromInstances[instance]
|
||||
|
||||
if id ~= nil then
|
||||
self.fromInstances[instance] = nil
|
||||
self.fromIds[id] = nil
|
||||
else
|
||||
Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance))
|
||||
end
|
||||
end
|
||||
|
||||
function InstanceMap:destroyInstance(instance)
|
||||
local id = self.fromInstances[instance]
|
||||
|
||||
if id ~= nil then
|
||||
self:destroyId(id)
|
||||
else
|
||||
Logging.warn("Attempted to destroy untracked instance %s", tostring(instance))
|
||||
end
|
||||
end
|
||||
|
||||
function InstanceMap:destroyId(id)
|
||||
local instance = self.fromIds[id]
|
||||
self:removeId(id)
|
||||
|
||||
if instance ~= nil then
|
||||
local descendantsToDestroy = {}
|
||||
|
||||
for otherInstance in pairs(self.fromInstances) do
|
||||
if otherInstance:IsDescendantOf(instance) then
|
||||
table.insert(descendantsToDestroy, otherInstance)
|
||||
end
|
||||
end
|
||||
|
||||
for _, otherInstance in ipairs(descendantsToDestroy) do
|
||||
self:removeInstance(otherInstance)
|
||||
end
|
||||
|
||||
instance:Destroy()
|
||||
else
|
||||
Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
return InstanceMap
|
||||
@@ -1,108 +1,17 @@
|
||||
local t = require(script.Parent.Parent.t)
|
||||
|
||||
local InstanceMap = require(script.Parent.InstanceMap)
|
||||
local Logging = require(script.Parent.Logging)
|
||||
|
||||
local function makeInstanceMap()
|
||||
local self = {
|
||||
fromIds = {},
|
||||
fromInstances = {},
|
||||
}
|
||||
|
||||
function self:insert(id, instance)
|
||||
self.fromIds[id] = instance
|
||||
self.fromInstances[instance] = id
|
||||
end
|
||||
|
||||
function self:removeId(id)
|
||||
local instance = self.fromIds[id]
|
||||
|
||||
if instance ~= nil then
|
||||
self.fromIds[id] = nil
|
||||
self.fromInstances[instance] = nil
|
||||
else
|
||||
Logging.warn("Attempted to remove nonexistant ID %s", tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
function self:removeInstance(instance)
|
||||
local id = self.fromInstances[instance]
|
||||
|
||||
if id ~= nil then
|
||||
self.fromInstances[instance] = nil
|
||||
self.fromIds[id] = nil
|
||||
else
|
||||
Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance))
|
||||
end
|
||||
end
|
||||
|
||||
function self:destroyId(id)
|
||||
local instance = self.fromIds[id]
|
||||
self:removeId(id)
|
||||
|
||||
if instance ~= nil then
|
||||
local descendantsToDestroy = {}
|
||||
|
||||
for otherInstance in pairs(self.fromInstances) do
|
||||
if otherInstance:IsDescendantOf(instance) then
|
||||
table.insert(descendantsToDestroy, otherInstance)
|
||||
end
|
||||
end
|
||||
|
||||
for _, otherInstance in ipairs(descendantsToDestroy) do
|
||||
self:removeInstance(otherInstance)
|
||||
end
|
||||
|
||||
instance:Destroy()
|
||||
else
|
||||
Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
local function setProperty(instance, key, value)
|
||||
-- The 'Contents' property of LocalizationTable isn't directly exposed, but
|
||||
-- has corresponding (deprecated) getters and setters.
|
||||
if key == "Contents" and instance.ClassName == "LocalizationTable" then
|
||||
instance:SetContents(value)
|
||||
return
|
||||
end
|
||||
|
||||
-- If we don't have permissions to access this value at all, we can skip it.
|
||||
local readSuccess, existingValue = pcall(function()
|
||||
return instance[key]
|
||||
end)
|
||||
|
||||
if not readSuccess then
|
||||
-- An error will be thrown if there was a permission issue or if the
|
||||
-- property doesn't exist. In the latter case, we should tell the user
|
||||
-- because it's probably their fault.
|
||||
if existingValue:find("lacking permission") then
|
||||
Logging.trace("Permission error reading property %s on class %s", tostring(key), instance.ClassName)
|
||||
return
|
||||
else
|
||||
error(("Invalid property %s on class %s: %s"):format(tostring(key), instance.ClassName, existingValue), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local writeSuccess, err = pcall(function()
|
||||
if existingValue ~= value then
|
||||
instance[key] = value
|
||||
end
|
||||
end)
|
||||
|
||||
if not writeSuccess then
|
||||
error(("Cannot set property %s on class %s: %s"):format(tostring(key), instance.ClassName, err), 2)
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
local setCanonicalProperty = require(script.Parent.setCanonicalProperty)
|
||||
local rojoValueToRobloxValue = require(script.Parent.rojoValueToRobloxValue)
|
||||
local Types = require(script.Parent.Types)
|
||||
|
||||
local Reconciler = {}
|
||||
Reconciler.__index = Reconciler
|
||||
|
||||
function Reconciler.new()
|
||||
local self = {
|
||||
instanceMap = makeInstanceMap(),
|
||||
instanceMap = InstanceMap.new(),
|
||||
}
|
||||
|
||||
return setmetatable(self, Reconciler)
|
||||
@@ -118,11 +27,18 @@ function Reconciler:applyUpdate(requestedIds, virtualInstancesById)
|
||||
end
|
||||
end
|
||||
|
||||
local reconcileSchema = Types.ifEnabled(t.tuple(
|
||||
t.map(t.string, Types.VirtualInstance),
|
||||
t.string,
|
||||
t.Instance
|
||||
))
|
||||
--[[
|
||||
Update an existing instance, including its properties and children, to match
|
||||
the given information.
|
||||
]]
|
||||
function Reconciler:reconcile(virtualInstancesById, id, instance)
|
||||
assert(reconcileSchema(virtualInstancesById, id, instance))
|
||||
|
||||
local virtualInstance = virtualInstancesById[id]
|
||||
|
||||
-- If an instance changes ClassName, we assume it's very different. That's
|
||||
@@ -137,10 +53,10 @@ function Reconciler:reconcile(virtualInstancesById, id, instance)
|
||||
self.instanceMap:insert(id, instance)
|
||||
|
||||
-- Some instances don't like being named, even if their name already matches
|
||||
setProperty(instance, "Name", virtualInstance.Name)
|
||||
setCanonicalProperty(instance, "Name", virtualInstance.Name)
|
||||
|
||||
for key, value in pairs(virtualInstance.Properties) do
|
||||
setProperty(instance, key, value.Value)
|
||||
setCanonicalProperty(instance, key, rojoValueToRobloxValue(value))
|
||||
end
|
||||
|
||||
local existingChildren = instance:GetChildren()
|
||||
@@ -175,10 +91,17 @@ function Reconciler:reconcile(virtualInstancesById, id, instance)
|
||||
end
|
||||
end
|
||||
|
||||
if self:__shouldClearUnknownInstances(virtualInstance) then
|
||||
for existingChildInstance in pairs(unvisitedExistingChildren) do
|
||||
self.instanceMap:removeInstance(existingChildInstance)
|
||||
existingChildInstance:Destroy()
|
||||
local shouldClearUnknown = self:__shouldClearUnknownChildren(virtualInstance)
|
||||
|
||||
for existingChildInstance in pairs(unvisitedExistingChildren) do
|
||||
local childId = self.instanceMap.fromInstances[existingChildInstance]
|
||||
|
||||
if childId == nil then
|
||||
if shouldClearUnknown then
|
||||
existingChildInstance:Destroy()
|
||||
end
|
||||
else
|
||||
self.instanceMap:destroyInstance(existingChildInstance)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -194,16 +117,13 @@ function Reconciler:reconcile(virtualInstancesById, id, instance)
|
||||
|
||||
-- Some instances, like services, don't like having their Parent
|
||||
-- property poked, even if we're setting it to the same value.
|
||||
setProperty(instance, "Parent", parent)
|
||||
if instance.Parent ~= parent then
|
||||
instance.Parent = parent
|
||||
end
|
||||
setCanonicalProperty(instance, "Parent", parent)
|
||||
end
|
||||
|
||||
return instance
|
||||
end
|
||||
|
||||
function Reconciler:__shouldClearUnknownInstances(virtualInstance)
|
||||
function Reconciler:__shouldClearUnknownChildren(virtualInstance)
|
||||
if virtualInstance.Metadata ~= nil then
|
||||
return not virtualInstance.Metadata.ignoreUnknownInstances
|
||||
else
|
||||
@@ -211,29 +131,44 @@ function Reconciler:__shouldClearUnknownInstances(virtualInstance)
|
||||
end
|
||||
end
|
||||
|
||||
local reifySchema = Types.ifEnabled(t.tuple(
|
||||
t.map(t.string, Types.VirtualInstance),
|
||||
t.string,
|
||||
t.Instance
|
||||
))
|
||||
|
||||
function Reconciler:__reify(virtualInstancesById, id, parent)
|
||||
assert(reifySchema(virtualInstancesById, id, parent))
|
||||
|
||||
local virtualInstance = virtualInstancesById[id]
|
||||
|
||||
local instance = Instance.new(virtualInstance.ClassName)
|
||||
|
||||
for key, value in pairs(virtualInstance.Properties) do
|
||||
-- TODO: Branch on value.Type
|
||||
setProperty(instance, key, value.Value)
|
||||
setCanonicalProperty(instance, key, rojoValueToRobloxValue(value))
|
||||
end
|
||||
|
||||
instance.Name = virtualInstance.Name
|
||||
setCanonicalProperty(instance, "Name", virtualInstance.Name)
|
||||
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
self:__reify(virtualInstancesById, childId, instance)
|
||||
end
|
||||
|
||||
setProperty(instance, "Parent", parent)
|
||||
setCanonicalProperty(instance, "Parent", parent)
|
||||
self.instanceMap:insert(id, instance)
|
||||
|
||||
return instance
|
||||
end
|
||||
|
||||
local applyUpdatePieceSchema = Types.ifEnabled(t.tuple(
|
||||
t.string,
|
||||
t.map(t.string, t.boolean),
|
||||
t.map(t.string, Types.VirtualInstance)
|
||||
))
|
||||
|
||||
function Reconciler:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
|
||||
assert(applyUpdatePieceSchema(id, visitedIds, virtualInstancesById))
|
||||
|
||||
if visitedIds[id] then
|
||||
return
|
||||
end
|
||||
|
||||
218
plugin/src/Reconciler.spec.lua
Normal file
218
plugin/src/Reconciler.spec.lua
Normal file
@@ -0,0 +1,218 @@
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
|
||||
return function()
|
||||
it("should leave instances alone if there's nothing specified", function()
|
||||
local instance = Instance.new("Folder")
|
||||
instance.Name = "TestFolder"
|
||||
|
||||
local instanceId = "test-id"
|
||||
local virtualInstancesById = {
|
||||
[instanceId] = {
|
||||
Name = "TestFolder",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
}
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
reconciler:reconcile(virtualInstancesById, instanceId, instance)
|
||||
end)
|
||||
|
||||
it("should assign names from virtual instances", function()
|
||||
local instance = Instance.new("Folder")
|
||||
instance.Name = "InitialName"
|
||||
|
||||
local instanceId = "test-id"
|
||||
local virtualInstancesById = {
|
||||
[instanceId] = {
|
||||
Name = "NewName",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
}
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
reconciler:reconcile(virtualInstancesById, instanceId, instance)
|
||||
|
||||
expect(instance.Name).to.equal("NewName")
|
||||
end)
|
||||
|
||||
it("should assign properties from virtual instances", function()
|
||||
local instance = Instance.new("IntValue")
|
||||
instance.Name = "TestValue"
|
||||
instance.Value = 5
|
||||
|
||||
local instanceId = "test-id"
|
||||
local virtualInstancesById = {
|
||||
[instanceId] = {
|
||||
Name = "TestValue",
|
||||
ClassName = "IntValue",
|
||||
Children = {},
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "Int32",
|
||||
Value = 9
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
reconciler:reconcile(virtualInstancesById, instanceId, instance)
|
||||
|
||||
expect(instance.Value).to.equal(9)
|
||||
end)
|
||||
|
||||
it("should wipe unknown children by default", function()
|
||||
local parent = Instance.new("Folder")
|
||||
parent.Name = "Parent"
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Name = "Child"
|
||||
|
||||
local parentId = "test-id"
|
||||
local virtualInstancesById = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
}
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
reconciler:reconcile(virtualInstancesById, parentId, parent)
|
||||
|
||||
expect(#parent:GetChildren()).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should preserve unknown children if ignoreUnknownInstances is set", function()
|
||||
local parent = Instance.new("Folder")
|
||||
parent.Name = "Parent"
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Parent = parent
|
||||
child.Name = "Child"
|
||||
|
||||
local parentId = "test-id"
|
||||
local virtualInstancesById = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
Metadata = {
|
||||
ignoreUnknownInstances = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
reconciler:reconcile(virtualInstancesById, parentId, parent)
|
||||
|
||||
expect(child.Parent).to.equal(parent)
|
||||
expect(#parent:GetChildren()).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should remove known removed children", function()
|
||||
local parent = Instance.new("Folder")
|
||||
parent.Name = "Parent"
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Parent = parent
|
||||
child.Name = "Child"
|
||||
|
||||
local parentId = "parent-id"
|
||||
local childId = "child-id"
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
|
||||
local virtualInstancesById = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {childId},
|
||||
Properties = {},
|
||||
},
|
||||
[childId] = {
|
||||
Name = "Child",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
}
|
||||
reconciler:reconcile(virtualInstancesById, parentId, parent)
|
||||
|
||||
expect(child.Parent).to.equal(parent)
|
||||
expect(#parent:GetChildren()).to.equal(1)
|
||||
|
||||
local newVirtualInstances = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
[childId] = nil,
|
||||
}
|
||||
reconciler:reconcile(newVirtualInstances, parentId, parent)
|
||||
|
||||
expect(child.Parent).to.equal(nil)
|
||||
expect(#parent:GetChildren()).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should remove known removed children if ignoreUnknownInstances is set", function()
|
||||
local parent = Instance.new("Folder")
|
||||
parent.Name = "Parent"
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Parent = parent
|
||||
child.Name = "Child"
|
||||
|
||||
local parentId = "parent-id"
|
||||
local childId = "child-id"
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
|
||||
local virtualInstancesById = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {childId},
|
||||
Properties = {},
|
||||
Metadata = {
|
||||
ignoreUnknownInstances = true,
|
||||
},
|
||||
},
|
||||
[childId] = {
|
||||
Name = "Child",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
}
|
||||
reconciler:reconcile(virtualInstancesById, parentId, parent)
|
||||
|
||||
expect(child.Parent).to.equal(parent)
|
||||
expect(#parent:GetChildren()).to.equal(1)
|
||||
|
||||
local newVirtualInstances = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
Metadata = {
|
||||
ignoreUnknownInstances = true,
|
||||
},
|
||||
},
|
||||
[childId] = nil,
|
||||
}
|
||||
reconciler:reconcile(newVirtualInstances, parentId, parent)
|
||||
|
||||
expect(child.Parent).to.equal(nil)
|
||||
expect(#parent:GetChildren()).to.equal(0)
|
||||
end)
|
||||
end
|
||||
36
plugin/src/Types.lua
Normal file
36
plugin/src/Types.lua
Normal file
@@ -0,0 +1,36 @@
|
||||
local t = require(script.Parent.Parent.t)
|
||||
|
||||
local DevSettings = require(script.Parent.DevSettings)
|
||||
|
||||
local VirtualValue = t.interface({
|
||||
Type = t.string,
|
||||
Value = t.optional(t.any),
|
||||
})
|
||||
|
||||
local VirtualMetadata = t.interface({
|
||||
ignoreUnknownInstances = t.optional(t.boolean),
|
||||
})
|
||||
|
||||
local VirtualInstance = t.interface({
|
||||
Name = t.string,
|
||||
ClassName = t.string,
|
||||
Properties = t.map(t.string, VirtualValue),
|
||||
Metadata = t.optional(VirtualMetadata)
|
||||
})
|
||||
|
||||
local function ifEnabled(innerCheck)
|
||||
return function(...)
|
||||
if DevSettings:shouldTypecheck() then
|
||||
return innerCheck(...)
|
||||
else
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
ifEnabled = ifEnabled,
|
||||
VirtualInstance = VirtualInstance,
|
||||
VirtualMetadata = VirtualMetadata,
|
||||
VirtualValue = VirtualValue,
|
||||
}
|
||||
@@ -4,10 +4,6 @@ end
|
||||
|
||||
local Roact = require(script.Parent.Roact)
|
||||
|
||||
Roact.setGlobalConfig({
|
||||
elementTracing = true,
|
||||
})
|
||||
|
||||
local App = require(script.Components.App)
|
||||
|
||||
local app = Roact.createElement(App, {
|
||||
@@ -16,4 +12,6 @@ local app = Roact.createElement(App, {
|
||||
|
||||
Roact.mount(app, game:GetService("CoreGui"), "Rojo UI")
|
||||
|
||||
-- TODO: Detect another instance of Rojo coming online and shut down this one.
|
||||
plugin.Unloading:Connect(function()
|
||||
Roact.unmount(app)
|
||||
end)
|
||||
28
plugin/src/preloadAssets.lua
Normal file
28
plugin/src/preloadAssets.lua
Normal file
@@ -0,0 +1,28 @@
|
||||
local ContentProvider = game:GetService("ContentProvider")
|
||||
|
||||
local Logging = require(script.Parent.Logging)
|
||||
local Assets = require(script.Parent.Assets)
|
||||
|
||||
local function preloadAssets()
|
||||
local contentUrls = {}
|
||||
|
||||
for _, sprite in pairs(Assets.Sprites) do
|
||||
table.insert(contentUrls, sprite.asset)
|
||||
end
|
||||
|
||||
for _, slice in pairs(Assets.Slices) do
|
||||
table.insert(contentUrls, slice.asset)
|
||||
end
|
||||
|
||||
for _, url in pairs(Assets.Images) do
|
||||
table.insert(contentUrls, url)
|
||||
end
|
||||
|
||||
Logging.trace("Preloading assets: %s", table.concat(contentUrls, ", "))
|
||||
|
||||
coroutine.wrap(function()
|
||||
ContentProvider:PreloadAsync(contentUrls)
|
||||
end)()
|
||||
end
|
||||
|
||||
return preloadAssets
|
||||
38
plugin/src/rojoValueToRobloxValue.lua
Normal file
38
plugin/src/rojoValueToRobloxValue.lua
Normal file
@@ -0,0 +1,38 @@
|
||||
local primitiveTypes = {
|
||||
Bool = true,
|
||||
Enum = true,
|
||||
Float32 = true,
|
||||
Float64 = true,
|
||||
Int32 = true,
|
||||
Int64 = true,
|
||||
String = true,
|
||||
}
|
||||
|
||||
local directConstructors = {
|
||||
CFrame = CFrame.new,
|
||||
Color3 = Color3.new,
|
||||
Color3uint8 = Color3.fromRGB,
|
||||
Rect = Rect.new,
|
||||
UDim = UDim.new,
|
||||
UDim2 = UDim2.new,
|
||||
Vector2 = Vector2.new,
|
||||
Vector2int16 = Vector2int16.new,
|
||||
Vector3 = Vector3.new,
|
||||
Vector3int16 = Vector3int16.new,
|
||||
}
|
||||
|
||||
local function rojoValueToRobloxValue(value)
|
||||
if primitiveTypes[value.Type] then
|
||||
return value.Value
|
||||
end
|
||||
|
||||
local constructor = directConstructors[value.Type]
|
||||
if constructor ~= nil then
|
||||
return constructor(unpack(value.Value))
|
||||
end
|
||||
|
||||
local errorMessage = ("The Rojo plugin doesn't know how to handle values of type %q yet!"):format(tostring(value.Type))
|
||||
error(errorMessage)
|
||||
end
|
||||
|
||||
return rojoValueToRobloxValue
|
||||
40
plugin/src/rojoValueToRobloxValue.spec.lua
Normal file
40
plugin/src/rojoValueToRobloxValue.spec.lua
Normal file
@@ -0,0 +1,40 @@
|
||||
local rojoValueToRobloxValue = require(script.Parent.rojoValueToRobloxValue)
|
||||
|
||||
return function()
|
||||
it("should convert primitives", function()
|
||||
local inputString = {
|
||||
Type = "String",
|
||||
Value = "Hello, world!",
|
||||
}
|
||||
|
||||
local inputFloat32 = {
|
||||
Type = "Float32",
|
||||
Value = 12341.512,
|
||||
}
|
||||
|
||||
expect(rojoValueToRobloxValue(inputString)).to.equal(inputString.Value)
|
||||
expect(rojoValueToRobloxValue(inputFloat32)).to.equal(inputFloat32.Value)
|
||||
end)
|
||||
|
||||
it("should convert properties with direct constructors", function()
|
||||
local inputColor3 = {
|
||||
Type = "Color3",
|
||||
Value = {0, 1, 0.5},
|
||||
}
|
||||
local outputColor3 = Color3.new(0, 1, 0.5)
|
||||
|
||||
local inputCFrame = {
|
||||
Type = "CFrame",
|
||||
Value = {
|
||||
1, 2, 3,
|
||||
4, 5, 6,
|
||||
7, 8, 9,
|
||||
10, 11, 12,
|
||||
},
|
||||
}
|
||||
local outputCFrame = CFrame.new(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
|
||||
|
||||
expect(rojoValueToRobloxValue(inputColor3)).to.equal(outputColor3)
|
||||
expect(rojoValueToRobloxValue(inputCFrame)).to.equal(outputCFrame)
|
||||
end)
|
||||
end
|
||||
52
plugin/src/setCanonicalProperty.lua
Normal file
52
plugin/src/setCanonicalProperty.lua
Normal file
@@ -0,0 +1,52 @@
|
||||
local Logging = require(script.Parent.Logging)
|
||||
|
||||
--[[
|
||||
Attempts to set a property on the given instance.
|
||||
|
||||
This method deals in terms of what Rojo calls 'canonical properties', which
|
||||
don't necessarily exist either in serialization or in Lua-reflected APIs,
|
||||
but may be present in the API dump.
|
||||
|
||||
Ideally, canonical properties map 1:1 with properties we can assign, but in
|
||||
some cases like LocalizationTable contents and CollectionService tags, we
|
||||
have to read/write properties a little differently.
|
||||
]]
|
||||
local function setCanonicalProperty(instance, key, value)
|
||||
-- The 'Contents' property of LocalizationTable isn't directly exposed, but
|
||||
-- has corresponding (deprecated) getters and setters.
|
||||
if instance.ClassName == "LocalizationTable" and key == "Contents" then
|
||||
instance:SetContents(value)
|
||||
return
|
||||
end
|
||||
|
||||
-- If we don't have permissions to access this value at all, we can skip it.
|
||||
local readSuccess, existingValue = pcall(function()
|
||||
return instance[key]
|
||||
end)
|
||||
|
||||
if not readSuccess then
|
||||
-- An error will be thrown if there was a permission issue or if the
|
||||
-- property doesn't exist. In the latter case, we should tell the user
|
||||
-- because it's probably their fault.
|
||||
if existingValue:find("lacking permission") then
|
||||
Logging.trace("Permission error reading property %s on class %s", tostring(key), instance.ClassName)
|
||||
return
|
||||
else
|
||||
error(("Invalid property %s on class %s: %s"):format(tostring(key), instance.ClassName, existingValue), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local writeSuccess, err = pcall(function()
|
||||
if existingValue ~= value then
|
||||
instance[key] = value
|
||||
end
|
||||
end)
|
||||
|
||||
if not writeSuccess then
|
||||
error(("Cannot set property %s on class %s: %s"):format(tostring(key), instance.ClassName, err), 2)
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
return setCanonicalProperty
|
||||
@@ -1,2 +1,19 @@
|
||||
local TestEZ = require(game.ReplicatedStorage.TestEZ)
|
||||
TestEZ.TestBootstrap:run(game.ReplicatedStorage.Rojo.plugin)
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local TestEZ = require(ReplicatedStorage.TestEZ)
|
||||
|
||||
local Rojo = ReplicatedStorage.Rojo
|
||||
|
||||
local DevSettings = require(Rojo.Plugin.DevSettings)
|
||||
|
||||
local setDevSettings = not DevSettings:hasChangedValues()
|
||||
|
||||
if setDevSettings then
|
||||
DevSettings:createTestSettings()
|
||||
end
|
||||
|
||||
TestEZ.TestBootstrap:run({Rojo.Plugin})
|
||||
|
||||
if setDevSettings then
|
||||
DevSettings:resetValues()
|
||||
end
|
||||
@@ -1,12 +1,16 @@
|
||||
[package]
|
||||
name = "rojo"
|
||||
version = "0.5.0-alpha.2"
|
||||
version = "0.5.0-alpha.6"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
description = "A tool to create robust Roblox projects"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/LPGhatguy/rojo"
|
||||
edition = "2018"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
server-plugins = []
|
||||
|
||||
[lib]
|
||||
name = "librojo"
|
||||
path = "src/lib.rs"
|
||||
@@ -15,31 +19,32 @@ path = "src/lib.rs"
|
||||
name = "rojo"
|
||||
path = "src/bin.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
bundle-plugin = []
|
||||
|
||||
[dependencies]
|
||||
clap = "2.27"
|
||||
csv = "1.0"
|
||||
env_logger = "0.5"
|
||||
env_logger = "0.6"
|
||||
failure = "0.1.3"
|
||||
futures = "0.1"
|
||||
hyper = "0.12"
|
||||
log = "0.4"
|
||||
maplit = "1.0.1"
|
||||
notify = "4.0"
|
||||
rand = "0.4"
|
||||
rbx_binary = "0.4.0"
|
||||
rbx_dom_weak = "1.2.0"
|
||||
rbx_xml = "0.5.0"
|
||||
rbx_reflection = "2.0.374"
|
||||
regex = "1.0"
|
||||
reqwest = "0.9.5"
|
||||
rouille = "2.1"
|
||||
rlua = "0.16"
|
||||
ritz = "0.1.0"
|
||||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "0.7", features = ["v4", "serde"] }
|
||||
rbx_tree = "0.2.0"
|
||||
rbx_xml = "0.2.0"
|
||||
rbx_binary = "0.2.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.0"
|
||||
walkdir = "2.1"
|
||||
lazy_static = "1.2"
|
||||
lazy_static = "1.2"
|
||||
pretty_assertions = "0.6.1"
|
||||
paste = "0.1"
|
||||
43
server/assets/index.css
Normal file
43
server/assets/index.css
Normal file
@@ -0,0 +1,43 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: sans-serif;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 60rem;
|
||||
background-color: #efefef;
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.docs {
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
66
server/assets/place.project.json
Normal file
66
server/assets/place.project.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "[placeholder]",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"HttpService": {
|
||||
"$className": "HttpService",
|
||||
"$properties": {
|
||||
"HttpEnabled": true
|
||||
}
|
||||
},
|
||||
"Lighting": {
|
||||
"$className": "Lighting",
|
||||
"$properties": {
|
||||
"Ambient": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"Brightness": 2,
|
||||
"GlobalShadows": true,
|
||||
"Outlines": false,
|
||||
"Technology": "Voxel"
|
||||
}
|
||||
},
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"Source": {
|
||||
"$path": "src"
|
||||
}
|
||||
},
|
||||
"SoundService": {
|
||||
"$className": "SoundService",
|
||||
"$properties": {
|
||||
"RespectFilteringEnabled": true
|
||||
}
|
||||
},
|
||||
"Workspace": {
|
||||
"$className": "Workspace",
|
||||
"$properties": {
|
||||
"FilteringEnabled": true
|
||||
},
|
||||
"Baseplate": {
|
||||
"$className": "Part",
|
||||
"$properties": {
|
||||
"Anchored": true,
|
||||
"Color": [
|
||||
0.38823,
|
||||
0.37254,
|
||||
0.38823
|
||||
],
|
||||
"Locked": true,
|
||||
"Position": [
|
||||
0,
|
||||
-10,
|
||||
0
|
||||
],
|
||||
"Size": [
|
||||
512,
|
||||
20,
|
||||
512
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,14 @@ fn make_path_absolute(value: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_default_env()
|
||||
.default_format_timestamp(false)
|
||||
.init();
|
||||
{
|
||||
let log_env = env_logger::Env::default()
|
||||
.default_filter_or("warn");
|
||||
|
||||
env_logger::Builder::from_env(log_env)
|
||||
.default_format_timestamp(false)
|
||||
.init();
|
||||
}
|
||||
|
||||
let app = clap_app!(Rojo =>
|
||||
(version: env!("CARGO_PKG_VERSION"))
|
||||
|
||||
@@ -8,9 +8,10 @@ use log::info;
|
||||
use failure::Fail;
|
||||
|
||||
use crate::{
|
||||
rbx_session::construct_oneoff_tree,
|
||||
imfs::{Imfs, FsError},
|
||||
project::{Project, ProjectLoadFuzzyError},
|
||||
imfs::Imfs,
|
||||
rbx_session::construct_oneoff_tree,
|
||||
rbx_snapshot::SnapshotError,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -55,14 +56,22 @@ pub enum BuildError {
|
||||
XmlModelEncodeError(rbx_xml::EncodeError),
|
||||
|
||||
#[fail(display = "Binary model file error")]
|
||||
BinaryModelEncodeError(rbx_binary::EncodeError)
|
||||
BinaryModelEncodeError(rbx_binary::EncodeError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
FsError(#[fail(cause)] FsError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
SnapshotError(#[fail(cause)] SnapshotError),
|
||||
}
|
||||
|
||||
impl_from!(BuildError {
|
||||
ProjectLoadFuzzyError => ProjectLoadError,
|
||||
io::Error => IoError,
|
||||
rbx_xml::EncodeError => XmlModelEncodeError,
|
||||
rbx_binary::EncodeError => BinaryModelEncodeError
|
||||
rbx_binary::EncodeError => BinaryModelEncodeError,
|
||||
FsError => FsError,
|
||||
SnapshotError => SnapshotError,
|
||||
});
|
||||
|
||||
pub fn build(options: &BuildOptions) -> Result<(), BuildError> {
|
||||
@@ -75,13 +84,14 @@ pub fn build(options: &BuildOptions) -> Result<(), BuildError> {
|
||||
info!("Looking for project at {}", options.fuzzy_project_path.display());
|
||||
|
||||
let project = Project::load_fuzzy(&options.fuzzy_project_path)?;
|
||||
project.check_compatibility();
|
||||
|
||||
info!("Found project at {}", project.file_location.display());
|
||||
info!("Using project {:#?}", project);
|
||||
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)?;
|
||||
let tree = construct_oneoff_tree(&project, &imfs);
|
||||
let tree = construct_oneoff_tree(&project, &imfs)?;
|
||||
let mut file = File::create(&options.output_file)?;
|
||||
|
||||
match output_kind {
|
||||
|
||||
@@ -8,8 +8,9 @@ use failure::Fail;
|
||||
|
||||
use crate::{
|
||||
project::{Project, ProjectLoadFuzzyError},
|
||||
web::Server,
|
||||
live_session::LiveSession,
|
||||
web::LiveServer,
|
||||
imfs::FsError,
|
||||
live_session::{LiveSession, LiveSessionError},
|
||||
};
|
||||
|
||||
const DEFAULT_PORT: u16 = 34872;
|
||||
@@ -24,22 +25,31 @@ pub struct ServeOptions {
|
||||
pub enum ServeError {
|
||||
#[fail(display = "Project load error: {}", _0)]
|
||||
ProjectLoadError(#[fail(cause)] ProjectLoadFuzzyError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
FsError(#[fail(cause)] FsError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
LiveSessionError(#[fail(cause)] LiveSessionError),
|
||||
}
|
||||
|
||||
impl_from!(ServeError {
|
||||
ProjectLoadFuzzyError => ProjectLoadError,
|
||||
FsError => FsError,
|
||||
LiveSessionError => LiveSessionError,
|
||||
});
|
||||
|
||||
pub fn serve(options: &ServeOptions) -> Result<(), ServeError> {
|
||||
info!("Looking for project at {}", options.fuzzy_project_path.display());
|
||||
|
||||
let project = Arc::new(Project::load_fuzzy(&options.fuzzy_project_path)?);
|
||||
project.check_compatibility();
|
||||
|
||||
info!("Found project at {}", project.file_location.display());
|
||||
info!("Using project {:#?}", project);
|
||||
|
||||
let live_session = Arc::new(LiveSession::new(Arc::clone(&project)).unwrap());
|
||||
let server = Server::new(Arc::clone(&live_session));
|
||||
let live_session = Arc::new(LiveSession::new(Arc::clone(&project))?);
|
||||
let server = LiveServer::new(live_session);
|
||||
|
||||
let port = options.port
|
||||
.or(project.serve_port)
|
||||
@@ -47,7 +57,7 @@ pub fn serve(options: &ServeOptions) -> Result<(), ServeError> {
|
||||
|
||||
println!("Rojo server listening on port {}", port);
|
||||
|
||||
server.listen(port);
|
||||
server.start(port);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,9 +9,10 @@ use failure::Fail;
|
||||
use reqwest::header::{ACCEPT, USER_AGENT, CONTENT_TYPE, COOKIE};
|
||||
|
||||
use crate::{
|
||||
rbx_session::construct_oneoff_tree,
|
||||
imfs::{Imfs, FsError},
|
||||
project::{Project, ProjectLoadFuzzyError},
|
||||
imfs::Imfs,
|
||||
rbx_session::construct_oneoff_tree,
|
||||
rbx_snapshot::SnapshotError,
|
||||
};
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
@@ -33,6 +34,12 @@ pub enum UploadError {
|
||||
|
||||
#[fail(display = "XML model file error")]
|
||||
XmlModelEncodeError(rbx_xml::EncodeError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
FsError(#[fail(cause)] FsError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
SnapshotError(#[fail(cause)] SnapshotError),
|
||||
}
|
||||
|
||||
impl_from!(UploadError {
|
||||
@@ -40,6 +47,8 @@ impl_from!(UploadError {
|
||||
io::Error => IoError,
|
||||
reqwest::Error => HttpError,
|
||||
rbx_xml::EncodeError => XmlModelEncodeError,
|
||||
FsError => FsError,
|
||||
SnapshotError => SnapshotError,
|
||||
});
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -56,13 +65,14 @@ pub fn upload(options: &UploadOptions) -> Result<(), UploadError> {
|
||||
info!("Looking for project at {}", options.fuzzy_project_path.display());
|
||||
|
||||
let project = Project::load_fuzzy(&options.fuzzy_project_path)?;
|
||||
project.check_compatibility();
|
||||
|
||||
info!("Found project at {}", project.file_location.display());
|
||||
info!("Using project {:#?}", project);
|
||||
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)?;
|
||||
let tree = construct_oneoff_tree(&project, &imfs);
|
||||
let tree = construct_oneoff_tree(&project, &imfs)?;
|
||||
|
||||
let root_id = tree.get_root_id();
|
||||
let mut contents = Vec::new();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::{
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
time::Duration,
|
||||
path::Path,
|
||||
ops::Deref,
|
||||
thread,
|
||||
};
|
||||
|
||||
use log::trace;
|
||||
use log::{warn, trace};
|
||||
use notify::{
|
||||
self,
|
||||
DebouncedEvent,
|
||||
@@ -20,7 +22,67 @@ use crate::{
|
||||
|
||||
const WATCH_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
fn handle_event(imfs: &Mutex<Imfs>, rbx_session: &Mutex<RbxSession>, event: DebouncedEvent) {
|
||||
/// Watches for changes on the filesystem and links together the in-memory
|
||||
/// filesystem and in-memory Roblox tree.
|
||||
pub struct FsWatcher {
|
||||
watcher: RecommendedWatcher,
|
||||
}
|
||||
|
||||
impl FsWatcher {
|
||||
/// Start a new FS watcher, watching all of the roots currently attached to
|
||||
/// the given Imfs.
|
||||
///
|
||||
/// `rbx_session` is optional to make testing easier. If it isn't `None`,
|
||||
/// events will be passed to it after they're given to the Imfs.
|
||||
pub fn start(imfs: Arc<Mutex<Imfs>>, rbx_session: Option<Arc<Mutex<RbxSession>>>) -> FsWatcher {
|
||||
let (watch_tx, watch_rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = notify::watcher(watch_tx, WATCH_TIMEOUT)
|
||||
.expect("Could not create filesystem watcher");
|
||||
|
||||
{
|
||||
let imfs = imfs.lock().unwrap();
|
||||
|
||||
for root_path in imfs.get_roots() {
|
||||
trace!("Watching path {}", root_path.display());
|
||||
watcher.watch(root_path, RecursiveMode::Recursive)
|
||||
.expect("Could not watch directory");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let imfs = Arc::clone(&imfs);
|
||||
let rbx_session = rbx_session.as_ref().map(Arc::clone);
|
||||
|
||||
thread::spawn(move || {
|
||||
trace!("Watcher thread started");
|
||||
while let Ok(event) = watch_rx.recv() {
|
||||
// handle_fs_event expects an Option<&Mutex<T>>, but we have
|
||||
// an Option<Arc<Mutex<T>>>, so we coerce with Deref.
|
||||
let session_ref = rbx_session.as_ref().map(Deref::deref);
|
||||
|
||||
handle_fs_event(&imfs, session_ref, event);
|
||||
}
|
||||
trace!("Watcher thread stopped");
|
||||
});
|
||||
}
|
||||
|
||||
FsWatcher {
|
||||
watcher,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_watching_path(&mut self, path: &Path) {
|
||||
match self.watcher.unwatch(path) {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
warn!("Could not unwatch path {}: {}", path.display(), e);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_fs_event(imfs: &Mutex<Imfs>, rbx_session: Option<&Mutex<RbxSession>>, event: DebouncedEvent) {
|
||||
match event {
|
||||
DebouncedEvent::Create(path) => {
|
||||
trace!("Path created: {}", path.display());
|
||||
@@ -30,7 +92,7 @@ fn handle_event(imfs: &Mutex<Imfs>, rbx_session: &Mutex<RbxSession>, event: Debo
|
||||
imfs.path_created(&path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(rbx_session) = rbx_session {
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_created(&path);
|
||||
}
|
||||
@@ -43,7 +105,7 @@ fn handle_event(imfs: &Mutex<Imfs>, rbx_session: &Mutex<RbxSession>, event: Debo
|
||||
imfs.path_updated(&path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(rbx_session) = rbx_session {
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_updated(&path);
|
||||
}
|
||||
@@ -56,20 +118,20 @@ fn handle_event(imfs: &Mutex<Imfs>, rbx_session: &Mutex<RbxSession>, event: Debo
|
||||
imfs.path_removed(&path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(rbx_session) = rbx_session {
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_removed(&path);
|
||||
}
|
||||
},
|
||||
DebouncedEvent::Rename(from_path, to_path) => {
|
||||
trace!("Path rename: {} to {}", from_path.display(), to_path.display());
|
||||
trace!("Path renamed: {} to {}", from_path.display(), to_path.display());
|
||||
|
||||
{
|
||||
let mut imfs = imfs.lock().unwrap();
|
||||
imfs.path_moved(&from_path, &to_path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(rbx_session) = rbx_session {
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_renamed(&from_path, &to_path);
|
||||
}
|
||||
@@ -78,49 +140,4 @@ fn handle_event(imfs: &Mutex<Imfs>, rbx_session: &Mutex<RbxSession>, event: Debo
|
||||
trace!("Unhandled FS event: {:?}", other);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches for changes on the filesystem and links together the in-memory
|
||||
/// filesystem and in-memory Roblox tree.
|
||||
pub struct FsWatcher {
|
||||
#[allow(unused)]
|
||||
watchers: Vec<RecommendedWatcher>,
|
||||
}
|
||||
|
||||
impl FsWatcher {
|
||||
pub fn start(imfs: Arc<Mutex<Imfs>>, rbx_session: Arc<Mutex<RbxSession>>) -> FsWatcher {
|
||||
let mut watchers = Vec::new();
|
||||
|
||||
{
|
||||
let imfs_temp = imfs.lock().unwrap();
|
||||
|
||||
for root_path in imfs_temp.get_roots() {
|
||||
let (watch_tx, watch_rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = notify::watcher(watch_tx, WATCH_TIMEOUT)
|
||||
.expect("Could not create `notify` watcher");
|
||||
|
||||
watcher.watch(root_path, RecursiveMode::Recursive)
|
||||
.expect("Could not watch directory");
|
||||
|
||||
watchers.push(watcher);
|
||||
|
||||
let imfs = Arc::clone(&imfs);
|
||||
let rbx_session = Arc::clone(&rbx_session);
|
||||
let root_path = root_path.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
trace!("Watcher thread ({}) started", root_path.display());
|
||||
while let Ok(event) = watch_rx.recv() {
|
||||
handle_event(&imfs, &rbx_session, event);
|
||||
}
|
||||
trace!("Watcher thread ({}) stopped", root_path.display());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
FsWatcher {
|
||||
watchers,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,48 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{self, Path, PathBuf},
|
||||
cmp::Ordering,
|
||||
collections::{HashMap, HashSet, BTreeSet},
|
||||
fmt,
|
||||
fs,
|
||||
io,
|
||||
path::{self, Path, PathBuf},
|
||||
};
|
||||
|
||||
use failure::Fail;
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
|
||||
use crate::project::{Project, ProjectNode};
|
||||
|
||||
fn add_sync_points(imfs: &mut Imfs, project_node: &ProjectNode) -> io::Result<()> {
|
||||
match project_node {
|
||||
ProjectNode::Instance(node) => {
|
||||
for child in node.children.values() {
|
||||
add_sync_points(imfs, child)?;
|
||||
}
|
||||
},
|
||||
ProjectNode::SyncPoint(node) => {
|
||||
imfs.add_root(&node.path)?;
|
||||
},
|
||||
/// A wrapper around io::Error that also attaches the path associated with the
|
||||
/// error.
|
||||
#[derive(Debug, Fail)]
|
||||
pub struct FsError {
|
||||
#[fail(cause)]
|
||||
inner: io::Error,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl FsError {
|
||||
fn new<P: Into<PathBuf>>(inner: io::Error, path: P) -> FsError {
|
||||
FsError {
|
||||
inner,
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FsError {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(output, "{}: {}", self.path.display(), self.inner)
|
||||
}
|
||||
}
|
||||
|
||||
fn add_sync_points(imfs: &mut Imfs, node: &ProjectNode) -> Result<(), FsError> {
|
||||
if let Some(path) = &node.path {
|
||||
imfs.add_root(path)?;
|
||||
}
|
||||
|
||||
for child in node.children.values() {
|
||||
add_sync_points(imfs, child)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -44,7 +68,7 @@ impl Imfs {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_roots_from_project(&mut self, project: &Project) -> io::Result<()> {
|
||||
pub fn add_roots_from_project(&mut self, project: &Project) -> Result<(), FsError> {
|
||||
add_sync_points(self, &project.tree)
|
||||
}
|
||||
|
||||
@@ -63,7 +87,7 @@ impl Imfs {
|
||||
self.items.get(path)
|
||||
}
|
||||
|
||||
pub fn add_root(&mut self, path: &Path) -> io::Result<()> {
|
||||
pub fn add_root(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(!self.is_within_roots(path));
|
||||
|
||||
@@ -72,21 +96,33 @@ impl Imfs {
|
||||
self.descend_and_read_from_disk(path)
|
||||
}
|
||||
|
||||
pub fn path_created(&mut self, path: &Path) -> io::Result<()> {
|
||||
pub fn remove_root(&mut self, path: &Path) {
|
||||
debug_assert!(path.is_absolute());
|
||||
|
||||
if self.roots.get(path).is_some() {
|
||||
self.remove_item(path);
|
||||
|
||||
if let Some(parent_path) = path.parent() {
|
||||
self.unlink_child(parent_path, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_created(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(path));
|
||||
|
||||
self.descend_and_read_from_disk(path)
|
||||
}
|
||||
|
||||
pub fn path_updated(&mut self, path: &Path) -> io::Result<()> {
|
||||
pub fn path_updated(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(path));
|
||||
|
||||
self.descend_and_read_from_disk(path)
|
||||
}
|
||||
|
||||
pub fn path_removed(&mut self, path: &Path) -> io::Result<()> {
|
||||
pub fn path_removed(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(path));
|
||||
|
||||
@@ -99,7 +135,7 @@ impl Imfs {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn path_moved(&mut self, from_path: &Path, to_path: &Path) -> io::Result<()> {
|
||||
pub fn path_moved(&mut self, from_path: &Path, to_path: &Path) -> Result<(), FsError> {
|
||||
self.path_removed(from_path)?;
|
||||
self.path_created(to_path)?;
|
||||
Ok(())
|
||||
@@ -149,9 +185,9 @@ impl Imfs {
|
||||
}
|
||||
}
|
||||
|
||||
fn descend_and_read_from_disk(&mut self, path: &Path) -> io::Result<()> {
|
||||
fn descend_and_read_from_disk(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
let root_path = self.get_root_path(path)
|
||||
.expect("Tried to mkdirp for path that wasn't within roots!");
|
||||
.expect("Tried to descent and read for path that wasn't within roots!");
|
||||
|
||||
// If this path is a root, we should read the entire thing.
|
||||
if root_path == path {
|
||||
@@ -170,7 +206,6 @@ impl Imfs {
|
||||
if self.items.contains_key(&next_path) {
|
||||
current_path = next_path;
|
||||
} else {
|
||||
self.read_from_disk(¤t_path)?;
|
||||
break;
|
||||
}
|
||||
},
|
||||
@@ -178,14 +213,16 @@ impl Imfs {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
self.read_from_disk(¤t_path)
|
||||
}
|
||||
|
||||
fn read_from_disk(&mut self, path: &Path) -> io::Result<()> {
|
||||
let metadata = fs::metadata(path)?;
|
||||
fn read_from_disk(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
let metadata = fs::metadata(path)
|
||||
.map_err(|e| FsError::new(e, path))?;
|
||||
|
||||
if metadata.is_file() {
|
||||
let contents = fs::read(path)?;
|
||||
let contents = fs::read(path)
|
||||
.map_err(|e| FsError::new(e, path))?;
|
||||
let item = ImfsItem::File(ImfsFile {
|
||||
path: path.to_path_buf(),
|
||||
contents,
|
||||
@@ -201,13 +238,18 @@ impl Imfs {
|
||||
} else if metadata.is_dir() {
|
||||
let item = ImfsItem::Directory(ImfsDirectory {
|
||||
path: path.to_path_buf(),
|
||||
children: HashSet::new(),
|
||||
children: BTreeSet::new(),
|
||||
});
|
||||
|
||||
self.items.insert(path.to_path_buf(), item);
|
||||
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let dir_children = fs::read_dir(path)
|
||||
.map_err(|e| FsError::new(e, path))?;
|
||||
|
||||
for entry in dir_children {
|
||||
let entry = entry
|
||||
.map_err(|e| FsError::new(e, path))?;
|
||||
|
||||
let child_path = entry.path();
|
||||
|
||||
self.read_from_disk(&child_path)?;
|
||||
@@ -244,19 +286,43 @@ impl Imfs {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImfsFile {
|
||||
pub path: PathBuf,
|
||||
pub contents: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImfsDirectory {
|
||||
pub path: PathBuf,
|
||||
pub children: HashSet<PathBuf>,
|
||||
impl PartialOrd for ImfsFile {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
impl Ord for ImfsFile {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.path.cmp(&other.path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImfsDirectory {
|
||||
pub path: PathBuf,
|
||||
pub children: BTreeSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl PartialOrd for ImfsDirectory {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for ImfsDirectory {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.path.cmp(&other.path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ImfsItem {
|
||||
File(ImfsFile),
|
||||
Directory(ImfsDirectory),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![recursion_limit="128"]
|
||||
|
||||
// Macros
|
||||
#[macro_use]
|
||||
pub mod impl_from;
|
||||
@@ -6,14 +8,14 @@ pub mod impl_from;
|
||||
pub mod commands;
|
||||
pub mod fs_watcher;
|
||||
pub mod imfs;
|
||||
pub mod live_session;
|
||||
pub mod message_queue;
|
||||
pub mod path_map;
|
||||
pub mod path_serializer;
|
||||
pub mod project;
|
||||
pub mod rbx_session;
|
||||
pub mod rbx_snapshot;
|
||||
pub mod live_session;
|
||||
pub mod session_id;
|
||||
pub mod snapshot_reconciler;
|
||||
pub mod visualize;
|
||||
pub mod web;
|
||||
pub mod web_util;
|
||||
pub mod web;
|
||||
@@ -1,22 +1,40 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
mem,
|
||||
sync::{Arc, Mutex},
|
||||
io,
|
||||
};
|
||||
|
||||
use failure::Fail;
|
||||
|
||||
use crate::{
|
||||
fs_watcher::FsWatcher,
|
||||
imfs::{Imfs, FsError},
|
||||
message_queue::MessageQueue,
|
||||
project::Project,
|
||||
imfs::Imfs,
|
||||
session_id::SessionId,
|
||||
rbx_session::RbxSession,
|
||||
rbx_snapshot::SnapshotError,
|
||||
session_id::SessionId,
|
||||
snapshot_reconciler::InstanceChanges,
|
||||
fs_watcher::FsWatcher,
|
||||
};
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum LiveSessionError {
|
||||
#[fail(display = "{}", _0)]
|
||||
Fs(#[fail(cause)] FsError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
Snapshot(#[fail(cause)] SnapshotError),
|
||||
}
|
||||
|
||||
impl_from!(LiveSessionError {
|
||||
FsError => Fs,
|
||||
SnapshotError => Snapshot,
|
||||
});
|
||||
|
||||
/// Contains all of the state for a Rojo live-sync session.
|
||||
pub struct LiveSession {
|
||||
pub project: Arc<Project>,
|
||||
pub session_id: SessionId,
|
||||
project: Arc<Project>,
|
||||
session_id: SessionId,
|
||||
pub message_queue: Arc<MessageQueue<InstanceChanges>>,
|
||||
pub rbx_session: Arc<Mutex<RbxSession>>,
|
||||
pub imfs: Arc<Mutex<Imfs>>,
|
||||
@@ -24,7 +42,7 @@ pub struct LiveSession {
|
||||
}
|
||||
|
||||
impl LiveSession {
|
||||
pub fn new(project: Arc<Project>) -> io::Result<LiveSession> {
|
||||
pub fn new(project: Arc<Project>) -> Result<LiveSession, LiveSessionError> {
|
||||
let imfs = {
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)?;
|
||||
@@ -37,18 +55,18 @@ impl LiveSession {
|
||||
Arc::clone(&project),
|
||||
Arc::clone(&imfs),
|
||||
Arc::clone(&message_queue),
|
||||
)));
|
||||
)?));
|
||||
|
||||
let fs_watcher = FsWatcher::start(
|
||||
Arc::clone(&imfs),
|
||||
Arc::clone(&rbx_session),
|
||||
Some(Arc::clone(&rbx_session)),
|
||||
);
|
||||
|
||||
let session_id = SessionId::new();
|
||||
|
||||
Ok(LiveSession {
|
||||
project,
|
||||
session_id,
|
||||
project,
|
||||
message_queue,
|
||||
rbx_session,
|
||||
imfs,
|
||||
@@ -56,7 +74,26 @@ impl LiveSession {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_project(&self) -> &Project {
|
||||
/// Restarts the live session using the given project while preserving the
|
||||
/// internal session ID.
|
||||
pub fn restart_with_new_project(&mut self, project: Arc<Project>) -> Result<(), LiveSessionError> {
|
||||
let mut new_session = LiveSession::new(project)?;
|
||||
new_session.session_id = self.session_id;
|
||||
|
||||
mem::replace(self, new_session);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn root_project(&self) -> &Project {
|
||||
&self.project
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.session_id
|
||||
}
|
||||
|
||||
pub fn serve_place_ids(&self) -> &Option<HashSet<u64>> {
|
||||
&self.project.serve_place_ids
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
mpsc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
RwLock,
|
||||
Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
use futures::sync::mpsc;
|
||||
|
||||
/// A unique identifier, not guaranteed to be generated in any order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ListenerId(usize);
|
||||
@@ -21,8 +22,7 @@ pub fn get_listener_id() -> ListenerId {
|
||||
|
||||
/// A message queue with persistent history that can be subscribed to.
|
||||
///
|
||||
/// Definitely non-optimal, but a simple design that works well for the
|
||||
/// synchronous web server Rojo uses, Rouille.
|
||||
/// Definitely non-optimal. This would ideally be a lockless mpmc queue.
|
||||
#[derive(Default)]
|
||||
pub struct MessageQueue<T> {
|
||||
messages: RwLock<Vec<T>>,
|
||||
@@ -38,15 +38,15 @@ impl<T: Clone> MessageQueue<T> {
|
||||
}
|
||||
|
||||
pub fn push_messages(&self, new_messages: &[T]) {
|
||||
let message_listeners = self.message_listeners.lock().unwrap();
|
||||
let mut message_listeners = self.message_listeners.lock().unwrap();
|
||||
|
||||
{
|
||||
let mut messages = self.messages.write().unwrap();
|
||||
messages.extend_from_slice(new_messages);
|
||||
}
|
||||
|
||||
for listener in message_listeners.values() {
|
||||
listener.send(()).unwrap();
|
||||
for listener in message_listeners.values_mut() {
|
||||
listener.try_send(()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::{
|
||||
collections::hash_map,
|
||||
path::{self, Path, PathBuf},
|
||||
collections::{HashMap, HashSet},
|
||||
};
|
||||
@@ -21,6 +20,12 @@ pub struct PathMap<T> {
|
||||
nodes: HashMap<PathBuf, PathMapNode<T>>,
|
||||
}
|
||||
|
||||
impl<T> Default for PathMap<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PathMap<T> {
|
||||
pub fn new() -> PathMap<T> {
|
||||
PathMap {
|
||||
@@ -36,12 +41,6 @@ impl<T> PathMap<T> {
|
||||
self.nodes.get_mut(path).map(|v| &mut v.value)
|
||||
}
|
||||
|
||||
pub fn entry<'a>(&'a mut self, path: PathBuf) -> Entry<'a, T> {
|
||||
Entry {
|
||||
internal: self.nodes.entry(path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, path: PathBuf, value: T) {
|
||||
if let Some(parent_path) = path.parent() {
|
||||
if let Some(parent) = self.nodes.get_mut(parent_path) {
|
||||
@@ -116,28 +115,4 @@ impl<T> PathMap<T> {
|
||||
|
||||
current_path
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Entry<'a, T> {
|
||||
internal: hash_map::Entry<'a, PathBuf, PathMapNode<T>>,
|
||||
}
|
||||
|
||||
impl<'a, T> Entry<'a, T> {
|
||||
pub fn or_insert(self, value: T) -> &'a mut T {
|
||||
&mut self.internal.or_insert(PathMapNode {
|
||||
value,
|
||||
children: HashSet::new(),
|
||||
}).value
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> Entry<'a, T>
|
||||
where T: Default
|
||||
{
|
||||
pub fn or_default(self) -> &'a mut T {
|
||||
&mut self.internal.or_insert(PathMapNode {
|
||||
value: Default::default(),
|
||||
children: HashSet::new(),
|
||||
}).value
|
||||
}
|
||||
}
|
||||
69
server/src/path_serializer.rs
Normal file
69
server/src/path_serializer.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! path_serializer is used in cases where we need to serialize relative Path
|
||||
//! and PathBuf objects in a way that's cross-platform.
|
||||
//!
|
||||
//! This is used for the snapshot testing system to make sure that snapshots
|
||||
//! that reference local paths that are generated on Windows don't fail when run
|
||||
//! in systems that use a different directory separator.
|
||||
//!
|
||||
//! To use, annotate your PathBuf or Option<PathBuf> field with the correct
|
||||
//! serializer function:
|
||||
//!
|
||||
//! ```
|
||||
//! # use std::path::PathBuf;
|
||||
//! # use serde_derive::{Serialize, Deserialize};
|
||||
//!
|
||||
//! #[derive(Serialize, Deserialize)]
|
||||
//! struct Mine {
|
||||
//! name: String,
|
||||
//!
|
||||
//! // Use 'crate' instead of librojo if writing code inside Rojo
|
||||
//! #[serde(serialize_with = "librojo::path_serializer::serialize")]
|
||||
//! source_path: PathBuf,
|
||||
//!
|
||||
//! #[serde(serialize_with = "librojo::path_serializer::serialize_option")]
|
||||
//! maybe_path: Option<PathBuf>,
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! **The methods in this module can only handle relative paths, since absolute
|
||||
//! paths are never portable.**
|
||||
|
||||
use std::path::{Component, Path};
|
||||
|
||||
use serde::Serializer;
|
||||
|
||||
pub fn serialize_option<S, T>(maybe_path: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer,
|
||||
T: AsRef<Path>,
|
||||
{
|
||||
match maybe_path {
|
||||
Some(path) => serialize(path, serializer),
|
||||
None => serializer.serialize_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize<S, T>(path: T, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer,
|
||||
T: AsRef<Path>,
|
||||
{
|
||||
let path = path.as_ref();
|
||||
|
||||
assert!(path.is_relative(), "path_serializer can only handle relative paths");
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
for component in path.components() {
|
||||
if !output.is_empty() {
|
||||
output.push('/');
|
||||
}
|
||||
|
||||
match component {
|
||||
Component::CurDir => output.push('.'),
|
||||
Component::ParentDir => output.push_str(".."),
|
||||
Component::Normal(piece) => output.push_str(piece.to_str().unwrap()),
|
||||
_ => panic!("path_serializer cannot handle absolute path components"),
|
||||
}
|
||||
}
|
||||
|
||||
serializer.serialize_str(&output)
|
||||
}
|
||||
@@ -1,27 +1,21 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::{HashMap, HashSet, BTreeMap},
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use log::warn;
|
||||
use failure::Fail;
|
||||
use maplit::hashmap;
|
||||
use rbx_tree::RbxValue;
|
||||
use rbx_dom_weak::{UnresolvedRbxValue, RbxValue};
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use serde::{Serialize, Serializer};
|
||||
|
||||
pub static PROJECT_FILENAME: &'static str = "roblox-project.json";
|
||||
static DEFAULT_PLACE: &'static str = include_str!("../assets/place.project.json");
|
||||
|
||||
// Methods used for Serde's default value system, which doesn't support using
|
||||
// value literals directly, only functions that return values.
|
||||
const fn yeah() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn is_true(value: &bool) -> bool {
|
||||
*value
|
||||
}
|
||||
pub static PROJECT_FILENAME: &'static str = "default.project.json";
|
||||
pub static COMPAT_PROJECT_FILENAME: &'static str = "roblox-project.json";
|
||||
|
||||
/// SourceProject is the format that users author projects on-disk. Since we
|
||||
/// want to do things like transforming paths to be absolute before handing them
|
||||
@@ -32,6 +26,10 @@ struct SourceProject {
|
||||
name: String,
|
||||
tree: SourceProjectNode,
|
||||
|
||||
#[cfg_attr(not(feature = "plugins-enabled"), serde(skip_deserializing))]
|
||||
#[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")]
|
||||
plugins: Vec<SourcePlugin>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
serve_port: Option<u16>,
|
||||
|
||||
@@ -41,12 +39,17 @@ struct SourceProject {
|
||||
|
||||
impl SourceProject {
|
||||
/// Consumes the SourceProject and yields a Project, ready for prime-time.
|
||||
pub fn into_project(self, project_file_location: &Path) -> Project {
|
||||
pub fn into_project(mut self, project_file_location: &Path) -> Project {
|
||||
let tree = self.tree.into_project_node(project_file_location);
|
||||
let plugins = self.plugins
|
||||
.drain(..)
|
||||
.map(|source_plugin| source_plugin.into_plugin(project_file_location))
|
||||
.collect();
|
||||
|
||||
Project {
|
||||
name: self.name,
|
||||
tree,
|
||||
plugins,
|
||||
serve_port: self.serve_port,
|
||||
serve_place_ids: self.serve_place_ids,
|
||||
file_location: PathBuf::from(project_file_location),
|
||||
@@ -54,63 +57,144 @@ impl SourceProject {
|
||||
}
|
||||
}
|
||||
|
||||
/// An alternative serializer for `UnresolvedRbxValue` that uses the minimum
|
||||
/// representation of the value.
|
||||
///
|
||||
/// For example, the default Serialize impl might give you:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "Type": "Bool",
|
||||
/// "Value": true
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// But in reality, users are expected to write just:
|
||||
///
|
||||
/// ```json
|
||||
/// true
|
||||
/// ```
|
||||
///
|
||||
/// This holds true for other values that might be ambiguous or just have more
|
||||
/// complicated representations like enums.
|
||||
fn serialize_unresolved_minimal<S>(unresolved: &UnresolvedRbxValue, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer
|
||||
{
|
||||
match unresolved {
|
||||
UnresolvedRbxValue::Ambiguous(_) => unresolved.serialize(serializer),
|
||||
UnresolvedRbxValue::Concrete(concrete) => {
|
||||
match concrete {
|
||||
RbxValue::Bool { value } => value.serialize(serializer),
|
||||
RbxValue::CFrame { value } => value.serialize(serializer),
|
||||
RbxValue::Color3 { value } => value.serialize(serializer),
|
||||
RbxValue::Color3uint8 { value } => value.serialize(serializer),
|
||||
RbxValue::Content { value } => value.serialize(serializer),
|
||||
RbxValue::Enum { value } => value.serialize(serializer),
|
||||
RbxValue::Float32 { value } => value.serialize(serializer),
|
||||
RbxValue::Int32 { value } => value.serialize(serializer),
|
||||
RbxValue::String { value } => value.serialize(serializer),
|
||||
RbxValue::UDim { value } => value.serialize(serializer),
|
||||
RbxValue::UDim2 { value } => value.serialize(serializer),
|
||||
RbxValue::Vector2 { value } => value.serialize(serializer),
|
||||
RbxValue::Vector2int16 { value } => value.serialize(serializer),
|
||||
RbxValue::Vector3 { value } => value.serialize(serializer),
|
||||
RbxValue::Vector3int16 { value } => value.serialize(serializer),
|
||||
_ => concrete.serialize(serializer),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper around serialize_unresolved_minimal that handles the HashMap case.
|
||||
fn serialize_unresolved_map<S>(value: &HashMap<String, UnresolvedRbxValue>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer
|
||||
{
|
||||
use serde::ser::SerializeMap;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Minimal<'a>(
|
||||
#[serde(serialize_with = "serialize_unresolved_minimal")]
|
||||
&'a UnresolvedRbxValue
|
||||
);
|
||||
|
||||
let mut map = serializer.serialize_map(Some(value.len()))?;
|
||||
for (k, v) in value {
|
||||
map.serialize_key(k)?;
|
||||
map.serialize_value(&Minimal(v))?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
||||
/// Similar to SourceProject, the structure of nodes in the project tree is
|
||||
/// slightly different on-disk than how we want to handle them in the rest of
|
||||
/// Rojo.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum SourceProjectNode {
|
||||
Instance {
|
||||
#[serde(rename = "$className")]
|
||||
class_name: String,
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SourceProjectNode {
|
||||
#[serde(rename = "$className", skip_serializing_if = "Option::is_none")]
|
||||
class_name: Option<String>,
|
||||
|
||||
#[serde(rename = "$properties", default = "HashMap::new", skip_serializing_if = "HashMap::is_empty")]
|
||||
properties: HashMap<String, RbxValue>,
|
||||
#[serde(
|
||||
rename = "$properties",
|
||||
default = "HashMap::new",
|
||||
skip_serializing_if = "HashMap::is_empty",
|
||||
serialize_with = "serialize_unresolved_map",
|
||||
)]
|
||||
properties: HashMap<String, UnresolvedRbxValue>,
|
||||
|
||||
#[serde(rename = "$ignoreUnknownInstances", default = "yeah", skip_serializing_if = "is_true")]
|
||||
ignore_unknown_instances: bool,
|
||||
#[serde(rename = "$ignoreUnknownInstances", skip_serializing_if = "Option::is_none")]
|
||||
ignore_unknown_instances: Option<bool>,
|
||||
|
||||
#[serde(flatten)]
|
||||
children: HashMap<String, SourceProjectNode>,
|
||||
},
|
||||
SyncPoint {
|
||||
#[serde(rename = "$path")]
|
||||
path: String,
|
||||
}
|
||||
#[serde(rename = "$path", skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
children: BTreeMap<String, SourceProjectNode>,
|
||||
}
|
||||
|
||||
impl SourceProjectNode {
|
||||
/// Consumes the SourceProjectNode and turns it into a ProjectNode.
|
||||
pub fn into_project_node(self, project_file_location: &Path) -> ProjectNode {
|
||||
match self {
|
||||
SourceProjectNode::Instance { class_name, mut children, properties, ignore_unknown_instances } => {
|
||||
let mut new_children = HashMap::new();
|
||||
let children = self.children.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone().into_project_node(project_file_location)))
|
||||
.collect();
|
||||
|
||||
for (node_name, node) in children.drain() {
|
||||
new_children.insert(node_name, node.into_project_node(project_file_location));
|
||||
}
|
||||
// Make sure that paths are absolute, transforming them by adding the
|
||||
// project folder if they're not already absolute.
|
||||
let path = self.path.as_ref().map(|source_path| {
|
||||
if Path::new(source_path).is_absolute() {
|
||||
PathBuf::from(source_path)
|
||||
} else {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
project_folder_location.join(source_path)
|
||||
}
|
||||
});
|
||||
|
||||
ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name,
|
||||
children: new_children,
|
||||
properties,
|
||||
metadata: InstanceProjectNodeMetadata {
|
||||
ignore_unknown_instances,
|
||||
},
|
||||
})
|
||||
},
|
||||
SourceProjectNode::SyncPoint { path: source_path } => {
|
||||
let path = if Path::new(&source_path).is_absolute() {
|
||||
PathBuf::from(source_path)
|
||||
} else {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
project_folder_location.join(source_path)
|
||||
};
|
||||
ProjectNode {
|
||||
class_name: self.class_name,
|
||||
properties: self.properties,
|
||||
ignore_unknown_instances: self.ignore_unknown_instances,
|
||||
path,
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path,
|
||||
})
|
||||
},
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SourcePlugin {
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl SourcePlugin {
|
||||
pub fn into_plugin(self, project_file_location: &Path) -> Plugin {
|
||||
let path = if Path::new(&self.path).is_absolute() {
|
||||
PathBuf::from(self.path)
|
||||
} else {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
project_folder_location.join(self.path)
|
||||
};
|
||||
|
||||
Plugin {
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +237,7 @@ pub enum ProjectInitError {
|
||||
AlreadyExists(PathBuf),
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
SaveError(#[fail(cause)] ProjectSaveError),
|
||||
JsonError(#[fail(cause)] serde_json::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for ProjectInitError {
|
||||
@@ -161,6 +246,7 @@ impl fmt::Display for ProjectInitError {
|
||||
ProjectInitError::AlreadyExists(path) => write!(output, "Path {} already exists", path.display()),
|
||||
ProjectInitError::IoError(inner) => write!(output, "IO error: {}", inner),
|
||||
ProjectInitError::SaveError(inner) => write!(output, "{}", inner),
|
||||
ProjectInitError::JsonError(inner) => write!(output, "{}", inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,79 +261,73 @@ pub enum ProjectSaveError {
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceProjectNodeMetadata {
|
||||
pub ignore_unknown_instances: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ProjectNode {
|
||||
pub class_name: Option<String>,
|
||||
pub children: BTreeMap<String, ProjectNode>,
|
||||
pub properties: HashMap<String, UnresolvedRbxValue>,
|
||||
pub ignore_unknown_instances: Option<bool>,
|
||||
|
||||
impl Default for InstanceProjectNodeMetadata {
|
||||
fn default() -> InstanceProjectNodeMetadata {
|
||||
InstanceProjectNodeMetadata {
|
||||
ignore_unknown_instances: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ProjectNode {
|
||||
Instance(InstanceProjectNode),
|
||||
SyncPoint(SyncPointProjectNode),
|
||||
#[serde(serialize_with = "crate::path_serializer::serialize_option")]
|
||||
pub path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ProjectNode {
|
||||
fn to_source_node(&self, project_file_location: &Path) -> SourceProjectNode {
|
||||
match self {
|
||||
ProjectNode::Instance(node) => {
|
||||
let mut children = HashMap::new();
|
||||
let children = self.children.iter()
|
||||
.map(|(key, value)| (key.clone(), value.to_source_node(project_file_location)))
|
||||
.collect();
|
||||
|
||||
for (key, child) in &node.children {
|
||||
children.insert(key.clone(), child.to_source_node(project_file_location));
|
||||
}
|
||||
// If paths are relative to the project file, transform them to look
|
||||
// Unixy and write relative paths instead.
|
||||
//
|
||||
// This isn't perfect, since it means that paths like .. will stay as
|
||||
// absolute paths and make projects non-portable. Fixing this probably
|
||||
// means keeping the paths relative in the project format and making
|
||||
// everywhere else in Rojo do the resolution locally.
|
||||
let path = self.path.as_ref().map(|path| {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
|
||||
SourceProjectNode::Instance {
|
||||
class_name: node.class_name.clone(),
|
||||
children,
|
||||
properties: node.properties.clone(),
|
||||
ignore_unknown_instances: node.metadata.ignore_unknown_instances,
|
||||
}
|
||||
},
|
||||
ProjectNode::SyncPoint(sync_node) => {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
match path.strip_prefix(project_folder_location) {
|
||||
Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"),
|
||||
Err(_) => format!("{}", path.display()),
|
||||
}
|
||||
});
|
||||
|
||||
let friendly_path = match sync_node.path.strip_prefix(project_folder_location) {
|
||||
Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"),
|
||||
Err(_) => format!("{}", sync_node.path.display()),
|
||||
};
|
||||
|
||||
SourceProjectNode::SyncPoint {
|
||||
path: friendly_path,
|
||||
}
|
||||
},
|
||||
SourceProjectNode {
|
||||
class_name: self.class_name.clone(),
|
||||
properties: self.properties.clone(),
|
||||
ignore_unknown_instances: self.ignore_unknown_instances,
|
||||
children,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceProjectNode {
|
||||
pub class_name: String,
|
||||
pub children: HashMap<String, ProjectNode>,
|
||||
pub properties: HashMap<String, RbxValue>,
|
||||
pub metadata: InstanceProjectNodeMetadata,
|
||||
pub struct Plugin {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncPointProjectNode {
|
||||
pub path: PathBuf,
|
||||
impl Plugin {
|
||||
fn to_source_plugin(&self, project_file_location: &Path) -> SourcePlugin {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
let path = match self.path.strip_prefix(project_folder_location) {
|
||||
Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"),
|
||||
Err(_) => format!("{}", self.path.display()),
|
||||
};
|
||||
|
||||
SourcePlugin {
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub name: String,
|
||||
pub tree: ProjectNode,
|
||||
pub plugins: Vec<Plugin>,
|
||||
pub serve_port: Option<u16>,
|
||||
pub serve_place_ids: Option<HashSet<u64>>,
|
||||
pub file_location: PathBuf,
|
||||
@@ -256,48 +336,16 @@ pub struct Project {
|
||||
impl Project {
|
||||
pub fn init_place(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
|
||||
let project_path = Project::init_pick_path(project_fuzzy_path)?;
|
||||
let project_folder_path = project_path.parent().unwrap();
|
||||
let project_name = if project_fuzzy_path == project_path {
|
||||
project_fuzzy_path.parent().unwrap().file_name().unwrap().to_str().unwrap()
|
||||
} else {
|
||||
project_fuzzy_path.file_name().unwrap().to_str().unwrap()
|
||||
};
|
||||
|
||||
let tree = ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: "DataModel".to_string(),
|
||||
children: hashmap! {
|
||||
String::from("ReplicatedStorage") => ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: String::from("ReplicatedStorage"),
|
||||
children: hashmap! {
|
||||
String::from("Source") => ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path: project_folder_path.join("src"),
|
||||
}),
|
||||
},
|
||||
properties: HashMap::new(),
|
||||
metadata: Default::default(),
|
||||
}),
|
||||
String::from("HttpService") => ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: String::from("HttpService"),
|
||||
children: HashMap::new(),
|
||||
properties: hashmap! {
|
||||
String::from("HttpEnabled") => RbxValue::Bool {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
metadata: Default::default(),
|
||||
}),
|
||||
},
|
||||
properties: HashMap::new(),
|
||||
metadata: Default::default(),
|
||||
});
|
||||
let mut project = Project::load_from_str(DEFAULT_PLACE, &project_path)
|
||||
.map_err(ProjectInitError::JsonError)?;
|
||||
|
||||
let project = Project {
|
||||
name: project_name.to_string(),
|
||||
tree,
|
||||
serve_port: None,
|
||||
serve_place_ids: None,
|
||||
file_location: project_path.clone(),
|
||||
};
|
||||
project.name = project_name.to_owned();
|
||||
|
||||
project.save()
|
||||
.map_err(ProjectInitError::SaveError)?;
|
||||
@@ -314,13 +362,15 @@ impl Project {
|
||||
project_fuzzy_path.file_name().unwrap().to_str().unwrap()
|
||||
};
|
||||
|
||||
let tree = ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path: project_folder_path.join("src"),
|
||||
});
|
||||
let tree = ProjectNode {
|
||||
path: Some(project_folder_path.join("src")),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let project = Project {
|
||||
name: project_name.to_string(),
|
||||
tree,
|
||||
plugins: Vec::new(),
|
||||
serve_port: None,
|
||||
serve_place_ids: None,
|
||||
file_location: project_path.clone(),
|
||||
@@ -362,11 +412,17 @@ impl Project {
|
||||
} else if location_metadata.is_dir() {
|
||||
let with_file = start_location.join(PROJECT_FILENAME);
|
||||
|
||||
if let Ok(with_file_metadata) = fs::metadata(&with_file) {
|
||||
if with_file_metadata.is_file() {
|
||||
if let Ok(file_metadata) = fs::metadata(&with_file) {
|
||||
if file_metadata.is_file() {
|
||||
return Some(with_file);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let with_compat_file = start_location.join(COMPAT_PROJECT_FILENAME);
|
||||
|
||||
if let Ok(file_metadata) = fs::metadata(&with_compat_file) {
|
||||
if file_metadata.is_file() {
|
||||
return Some(with_compat_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,6 +433,12 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_str(contents: &str, project_file_location: &Path) -> Result<Project, serde_json::Error> {
|
||||
let parsed: SourceProject = serde_json::from_str(&contents)?;
|
||||
|
||||
Ok(parsed.into_project(project_file_location))
|
||||
}
|
||||
|
||||
pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result<Project, ProjectLoadFuzzyError> {
|
||||
let project_path = Self::locate(fuzzy_project_location)
|
||||
.ok_or(ProjectLoadFuzzyError::NotFound)?;
|
||||
@@ -405,10 +467,39 @@ impl Project {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks if there are any compatibility issues with this project file and
|
||||
/// warns the user if there are any.
|
||||
pub fn check_compatibility(&self) {
|
||||
let file_name = self.file_location
|
||||
.file_name().unwrap()
|
||||
.to_str().expect("Project file path was not valid Unicode!");
|
||||
|
||||
if file_name == COMPAT_PROJECT_FILENAME {
|
||||
warn!("Rojo's default project file name changed in 0.5.0-alpha3.");
|
||||
warn!("Support for the old project file name will be dropped before 0.5.0 releases.");
|
||||
warn!("Your project file is named {}", COMPAT_PROJECT_FILENAME);
|
||||
warn!("Rename your project file to {}", PROJECT_FILENAME);
|
||||
} else if !file_name.ends_with(".project.json") {
|
||||
warn!("Starting in Rojo 0.5.0-alpha3, it's recommended to give all project files the");
|
||||
warn!(".project.json extension. This helps Rojo differentiate project files from");
|
||||
warn!("other JSON files!");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn folder_location(&self) -> &Path {
|
||||
self.file_location.parent().unwrap()
|
||||
}
|
||||
|
||||
fn to_source_project(&self) -> SourceProject {
|
||||
let plugins = self.plugins
|
||||
.iter()
|
||||
.map(|plugin| plugin.to_source_plugin(&self.file_location))
|
||||
.collect();
|
||||
|
||||
SourceProject {
|
||||
name: self.name.clone(),
|
||||
tree: self.tree.to_source_node(&self.file_location),
|
||||
plugins,
|
||||
serve_port: self.serve_port,
|
||||
serve_place_ids: self.serve_place_ids.clone(),
|
||||
}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
collections::{HashSet, HashMap},
|
||||
path::{Path, PathBuf},
|
||||
str,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use rlua::Lua;
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use log::{info, trace};
|
||||
use rbx_tree::{RbxTree, RbxId};
|
||||
use log::{info, trace, error};
|
||||
use rbx_dom_weak::{RbxTree, RbxId};
|
||||
|
||||
use crate::{
|
||||
project::Project,
|
||||
project::{Project, ProjectNode},
|
||||
message_queue::MessageQueue,
|
||||
imfs::{Imfs, ImfsItem},
|
||||
path_map::PathMap,
|
||||
rbx_snapshot::{SnapshotContext, snapshot_project_tree, snapshot_imfs_path},
|
||||
rbx_snapshot::{
|
||||
SnapshotError,
|
||||
SnapshotContext,
|
||||
SnapshotPluginContext,
|
||||
SnapshotPluginEntry,
|
||||
snapshot_project_tree,
|
||||
snapshot_project_node,
|
||||
snapshot_imfs_path,
|
||||
},
|
||||
snapshot_reconciler::{InstanceChanges, reify_root, reconcile_subtree},
|
||||
};
|
||||
|
||||
@@ -23,24 +32,31 @@ const INIT_SCRIPT: &str = "init.lua";
|
||||
const INIT_SERVER_SCRIPT: &str = "init.server.lua";
|
||||
const INIT_CLIENT_SCRIPT: &str = "init.client.lua";
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MetadataPerPath {
|
||||
pub instance_id: Option<RbxId>,
|
||||
pub instance_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
/// `source_path` or `project_definition` or both must both be Some.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct MetadataPerInstance {
|
||||
pub source_path: Option<PathBuf>,
|
||||
pub ignore_unknown_instances: bool,
|
||||
|
||||
/// The path on the filesystem that the instance was read from the
|
||||
/// filesystem if it came from the filesystem.
|
||||
#[serde(serialize_with = "crate::path_serializer::serialize_option")]
|
||||
pub source_path: Option<PathBuf>,
|
||||
|
||||
/// Information about the instance that came from the project that defined
|
||||
/// it, if that's where it was defined.
|
||||
///
|
||||
/// A key-value pair where the key should be the name of the instance and
|
||||
/// the value is the ProjectNode from the instance's project.
|
||||
pub project_definition: Option<(String, ProjectNode)>,
|
||||
}
|
||||
|
||||
/// Contains all of the state needed to update an `RbxTree` in real time using
|
||||
/// the in-memory filesystem, as well as messaging to Rojo clients what
|
||||
/// instances have actually updated at any point.
|
||||
pub struct RbxSession {
|
||||
tree: RbxTree,
|
||||
|
||||
// TODO(#105): Change metadata_per_path to PathMap<Vec<MetadataPerPath>> for
|
||||
// path aliasing.
|
||||
metadata_per_path: PathMap<MetadataPerPath>,
|
||||
instances_per_path: PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: HashMap<RbxId, MetadataPerInstance>,
|
||||
message_queue: Arc<MessageQueue<InstanceChanges>>,
|
||||
imfs: Arc<Mutex<Imfs>>,
|
||||
@@ -51,22 +67,60 @@ impl RbxSession {
|
||||
project: Arc<Project>,
|
||||
imfs: Arc<Mutex<Imfs>>,
|
||||
message_queue: Arc<MessageQueue<InstanceChanges>>,
|
||||
) -> RbxSession {
|
||||
let mut metadata_per_path = PathMap::new();
|
||||
) -> Result<RbxSession, SnapshotError> {
|
||||
let mut instances_per_path = PathMap::new();
|
||||
let mut metadata_per_instance = HashMap::new();
|
||||
|
||||
let plugin_context = if cfg!(feature = "server-plugins") {
|
||||
let lua = Lua::new();
|
||||
let mut callback_key = None;
|
||||
|
||||
lua.context(|context| {
|
||||
let callback = context.load(r#"
|
||||
return function(snapshot)
|
||||
print("got my snapshot:", snapshot)
|
||||
print("name:", snapshot.name, "class name:", snapshot.className)
|
||||
end"#)
|
||||
.set_name("a cool plugin").unwrap()
|
||||
.call::<(), rlua::Function>(()).unwrap();
|
||||
|
||||
callback_key = Some(context.create_registry_value(callback).unwrap());
|
||||
});
|
||||
|
||||
let plugins = vec![
|
||||
SnapshotPluginEntry {
|
||||
file_name_filter: String::new(),
|
||||
callback: callback_key.unwrap(),
|
||||
}
|
||||
];
|
||||
|
||||
Some(SnapshotPluginContext { lua, plugins })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let context = SnapshotContext {
|
||||
plugin_context,
|
||||
};
|
||||
|
||||
let tree = {
|
||||
let temp_imfs = imfs.lock().unwrap();
|
||||
reify_initial_tree(&project, &temp_imfs, &mut metadata_per_path, &mut metadata_per_instance)
|
||||
reify_initial_tree(
|
||||
&project,
|
||||
&context,
|
||||
&temp_imfs,
|
||||
&mut instances_per_path,
|
||||
&mut metadata_per_instance,
|
||||
)?
|
||||
};
|
||||
|
||||
RbxSession {
|
||||
Ok(RbxSession {
|
||||
tree,
|
||||
metadata_per_path,
|
||||
instances_per_path,
|
||||
metadata_per_instance,
|
||||
message_queue,
|
||||
imfs,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn path_created_or_updated(&mut self, path: &Path) {
|
||||
@@ -80,7 +134,7 @@ impl RbxSession {
|
||||
.expect("Path was outside in-memory filesystem roots");
|
||||
|
||||
// Find the closest instance in the tree that currently exists
|
||||
let mut path_to_snapshot = self.metadata_per_path.descend(root_path, path);
|
||||
let mut path_to_snapshot = self.instances_per_path.descend(root_path, path);
|
||||
|
||||
// If this is a file that might affect its parent if modified, we
|
||||
// should snapshot its parent instead.
|
||||
@@ -93,42 +147,54 @@ impl RbxSession {
|
||||
|
||||
trace!("Snapshotting path {}", path_to_snapshot.display());
|
||||
|
||||
let path_metadata = self.metadata_per_path.get(&path_to_snapshot).unwrap();
|
||||
let instances_at_path = self.instances_per_path.get(&path_to_snapshot)
|
||||
.expect("Metadata did not exist for path")
|
||||
.clone();
|
||||
|
||||
trace!("Metadata for path: {:?}", path_metadata);
|
||||
|
||||
let instance_id = path_metadata.instance_id
|
||||
.expect("Instance did not exist in tree");
|
||||
|
||||
// If this instance is a sync point, pull its name out of our
|
||||
// per-path metadata store.
|
||||
let instance_name = path_metadata.instance_name.as_ref()
|
||||
.map(|value| Cow::Owned(value.to_owned()));
|
||||
|
||||
let mut context = SnapshotContext {
|
||||
metadata_per_path: &mut self.metadata_per_path,
|
||||
};
|
||||
let maybe_snapshot = snapshot_imfs_path(&imfs, &mut context, &path_to_snapshot, instance_name)
|
||||
.unwrap_or_else(|_| panic!("Could not generate instance snapshot for path {}", path_to_snapshot.display()));
|
||||
|
||||
let snapshot = match maybe_snapshot {
|
||||
Some(snapshot) => snapshot,
|
||||
None => {
|
||||
trace!("Path resulted in no snapshot being generated.");
|
||||
return;
|
||||
},
|
||||
let context = SnapshotContext {
|
||||
plugin_context: None,
|
||||
};
|
||||
|
||||
trace!("Snapshot: {:#?}", snapshot);
|
||||
for instance_id in &instances_at_path {
|
||||
let instance_metadata = self.metadata_per_instance.get(&instance_id)
|
||||
.expect("Metadata for instance ID did not exist");
|
||||
|
||||
reconcile_subtree(
|
||||
&mut self.tree,
|
||||
instance_id,
|
||||
&snapshot,
|
||||
&mut self.metadata_per_path,
|
||||
&mut self.metadata_per_instance,
|
||||
&mut changes,
|
||||
);
|
||||
let maybe_snapshot = match &instance_metadata.project_definition {
|
||||
Some((instance_name, project_node)) => {
|
||||
snapshot_project_node(&context, &imfs, &project_node, Cow::Owned(instance_name.clone()))
|
||||
// .unwrap_or_else(|_| panic!("Could not generate instance snapshot for path {}", path_to_snapshot.display()))
|
||||
},
|
||||
None => {
|
||||
snapshot_imfs_path(&context, &imfs, &path_to_snapshot, None)
|
||||
// .unwrap_or_else(|_| panic!("Could not generate instance snapshot for path {}", path_to_snapshot.display()))
|
||||
},
|
||||
};
|
||||
|
||||
let snapshot = match maybe_snapshot {
|
||||
Ok(Some(snapshot)) => snapshot,
|
||||
Ok(None) => {
|
||||
trace!("Path resulted in no snapshot being generated.");
|
||||
return;
|
||||
},
|
||||
Err(err) => {
|
||||
error!("Rojo couldn't turn one of the project's files into Roblox instances.");
|
||||
error!("Any changes to the file have been ignored.");
|
||||
error!("{}", err);
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
trace!("Snapshot: {:#?}", snapshot);
|
||||
|
||||
reconcile_subtree(
|
||||
&mut self.tree,
|
||||
*instance_id,
|
||||
&snapshot,
|
||||
&mut self.instances_per_path,
|
||||
&mut self.metadata_per_instance,
|
||||
&mut changes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if changes.is_empty() {
|
||||
@@ -170,13 +236,13 @@ impl RbxSession {
|
||||
|
||||
pub fn path_removed(&mut self, path: &Path) {
|
||||
info!("Path removed: {}", path.display());
|
||||
self.metadata_per_path.remove(path);
|
||||
self.instances_per_path.remove(path);
|
||||
self.path_created_or_updated(path);
|
||||
}
|
||||
|
||||
pub fn path_renamed(&mut self, from_path: &Path, to_path: &Path) {
|
||||
info!("Path renamed from {} to {}", from_path.display(), to_path.display());
|
||||
self.metadata_per_path.remove(from_path);
|
||||
self.instances_per_path.remove(from_path);
|
||||
self.path_created_or_updated(from_path);
|
||||
self.path_created_or_updated(to_path);
|
||||
}
|
||||
@@ -185,36 +251,39 @@ impl RbxSession {
|
||||
&self.tree
|
||||
}
|
||||
|
||||
pub fn get_all_instance_metadata(&self) -> &HashMap<RbxId, MetadataPerInstance> {
|
||||
&self.metadata_per_instance
|
||||
}
|
||||
|
||||
pub fn get_instance_metadata(&self, id: RbxId) -> Option<&MetadataPerInstance> {
|
||||
self.metadata_per_instance.get(&id)
|
||||
}
|
||||
|
||||
pub fn debug_get_metadata_per_path(&self) -> &PathMap<MetadataPerPath> {
|
||||
&self.metadata_per_path
|
||||
}
|
||||
}
|
||||
|
||||
pub fn construct_oneoff_tree(project: &Project, imfs: &Imfs) -> RbxTree {
|
||||
let mut metadata_per_path = PathMap::new();
|
||||
pub fn construct_oneoff_tree(project: &Project, imfs: &Imfs) -> Result<RbxTree, SnapshotError> {
|
||||
let mut instances_per_path = PathMap::new();
|
||||
let mut metadata_per_instance = HashMap::new();
|
||||
reify_initial_tree(project, imfs, &mut metadata_per_path, &mut metadata_per_instance)
|
||||
let context = SnapshotContext {
|
||||
plugin_context: None,
|
||||
};
|
||||
|
||||
reify_initial_tree(project, &context, imfs, &mut instances_per_path, &mut metadata_per_instance)
|
||||
}
|
||||
|
||||
fn reify_initial_tree(
|
||||
project: &Project,
|
||||
context: &SnapshotContext,
|
||||
imfs: &Imfs,
|
||||
metadata_per_path: &mut PathMap<MetadataPerPath>,
|
||||
instances_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
) -> RbxTree {
|
||||
let mut context = SnapshotContext {
|
||||
metadata_per_path,
|
||||
) -> Result<RbxTree, SnapshotError> {
|
||||
let snapshot = match snapshot_project_tree(&context, imfs, project)? {
|
||||
Some(snapshot) => snapshot,
|
||||
None => panic!("Project did not produce any instances"),
|
||||
};
|
||||
let snapshot = snapshot_project_tree(imfs, &mut context, project)
|
||||
.expect("Could not snapshot project tree")
|
||||
.expect("Project did not produce any instances");
|
||||
|
||||
let mut changes = InstanceChanges::default();
|
||||
let tree = reify_root(&snapshot, metadata_per_path, metadata_per_instance, &mut changes);
|
||||
let tree = reify_root(&snapshot, instances_per_path, metadata_per_instance, &mut changes);
|
||||
|
||||
tree
|
||||
Ok(tree)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Defines how Rojo transforms files into instances through the snapshot
|
||||
//! system.
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
@@ -6,11 +9,13 @@ use std::{
|
||||
str,
|
||||
};
|
||||
|
||||
use rlua::Lua;
|
||||
use failure::Fail;
|
||||
use log::info;
|
||||
use maplit::hashmap;
|
||||
use rbx_tree::{RbxTree, RbxValue, RbxInstanceProperties};
|
||||
use rbx_dom_weak::{RbxTree, RbxValue, RbxInstanceProperties};
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use rbx_reflection::{try_resolve_value, ValueResolveError};
|
||||
|
||||
use crate::{
|
||||
imfs::{
|
||||
@@ -22,28 +27,68 @@ use crate::{
|
||||
project::{
|
||||
Project,
|
||||
ProjectNode,
|
||||
InstanceProjectNode,
|
||||
SyncPointProjectNode,
|
||||
},
|
||||
snapshot_reconciler::{
|
||||
RbxSnapshotInstance,
|
||||
snapshot_from_tree,
|
||||
},
|
||||
path_map::PathMap,
|
||||
// TODO: Move MetadataPerPath into this module?
|
||||
rbx_session::{MetadataPerPath, MetadataPerInstance},
|
||||
// TODO: Move MetadataPerInstance into this module?
|
||||
rbx_session::MetadataPerInstance,
|
||||
};
|
||||
|
||||
const INIT_MODULE_NAME: &str = "init.lua";
|
||||
const INIT_SERVER_NAME: &str = "init.server.lua";
|
||||
const INIT_CLIENT_NAME: &str = "init.client.lua";
|
||||
|
||||
pub type SnapshotResult<'a> = Result<Option<RbxSnapshotInstance<'a>>, SnapshotError>;
|
||||
|
||||
pub struct SnapshotContext<'meta> {
|
||||
pub metadata_per_path: &'meta mut PathMap<MetadataPerPath>,
|
||||
pub struct SnapshotContext {
|
||||
pub plugin_context: Option<SnapshotPluginContext>,
|
||||
}
|
||||
|
||||
/// Context that's only relevant to generating snapshots if there are plugins
|
||||
/// associated with the project.
|
||||
///
|
||||
/// It's possible that this needs some sort of extra nesting/filtering to
|
||||
/// support nested projects, since their plugins should only apply to
|
||||
/// themselves.
|
||||
pub struct SnapshotPluginContext {
|
||||
pub lua: Lua,
|
||||
pub plugins: Vec<SnapshotPluginEntry>,
|
||||
}
|
||||
|
||||
pub struct SnapshotPluginEntry {
|
||||
/// Simple file name suffix filter to avoid running plugins on every file
|
||||
/// change.
|
||||
pub file_name_filter: String,
|
||||
|
||||
/// A key into the Lua registry created by [`create_registry_value`] that
|
||||
/// refers to a function that can be called to transform a file/instance
|
||||
/// pair according to how the plugin needs to operate.
|
||||
///
|
||||
/// [`create_registry_value`]: https://docs.rs/rlua/0.16.2/rlua/struct.Context.html#method.create_registry_value
|
||||
pub callback: rlua::RegistryKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LuaRbxSnapshot(RbxSnapshotInstance<'static>);
|
||||
|
||||
impl rlua::UserData for LuaRbxSnapshot {
|
||||
fn add_methods<'lua, M: rlua::UserDataMethods<'lua, Self>>(methods: &mut M) {
|
||||
methods.add_meta_method(rlua::MetaMethod::Index, |_context, this, key: String| {
|
||||
match key.as_str() {
|
||||
"name" => Ok(this.0.name.clone().into_owned()),
|
||||
"className" => Ok(this.0.class_name.clone().into_owned()),
|
||||
_ => Err(rlua::Error::RuntimeError(format!("{} is not a valid member of RbxSnapshotInstance", &key))),
|
||||
}
|
||||
});
|
||||
|
||||
methods.add_meta_method(rlua::MetaMethod::ToString, |_context, _this, _args: ()| {
|
||||
Ok("RbxSnapshotInstance")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub type SnapshotResult<'a> = Result<Option<RbxSnapshotInstance<'a>>, SnapshotError>;
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum SnapshotError {
|
||||
DidNotExist(PathBuf),
|
||||
@@ -55,6 +100,7 @@ pub enum SnapshotError {
|
||||
},
|
||||
|
||||
JsonModelDecodeError {
|
||||
#[fail(cause)]
|
||||
inner: serde_json::Error,
|
||||
path: PathBuf,
|
||||
},
|
||||
@@ -68,6 +114,25 @@ pub enum SnapshotError {
|
||||
inner: rbx_binary::DecodeError,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
ProjectNodeUnusable,
|
||||
|
||||
ProjectNodeInvalidTransmute {
|
||||
partition_path: PathBuf,
|
||||
},
|
||||
|
||||
PropertyResolveError {
|
||||
#[fail(cause)]
|
||||
inner: ValueResolveError,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ValueResolveError> for SnapshotError {
|
||||
fn from(inner: ValueResolveError) -> SnapshotError {
|
||||
SnapshotError::PropertyResolveError {
|
||||
inner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SnapshotError {
|
||||
@@ -78,7 +143,7 @@ impl fmt::Display for SnapshotError {
|
||||
write!(output, "Invalid UTF-8: {} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::JsonModelDecodeError { inner, path } => {
|
||||
write!(output, "Malformed .model.json model: {:?} in path {}", inner, path.display())
|
||||
write!(output, "Malformed .model.json model: {} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::XmlModelDecodeError { inner, path } => {
|
||||
write!(output, "Malformed rbxmx model: {:?} in path {}", inner, path.display())
|
||||
@@ -86,107 +151,138 @@ impl fmt::Display for SnapshotError {
|
||||
SnapshotError::BinaryModelDecodeError { inner, path } => {
|
||||
write!(output, "Malformed rbxm model: {:?} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::ProjectNodeUnusable => {
|
||||
write!(output, "Rojo project nodes must specify either $path or $className.")
|
||||
},
|
||||
SnapshotError::ProjectNodeInvalidTransmute { partition_path } => {
|
||||
writeln!(output, "Rojo project nodes that specify both $path and $className require that the")?;
|
||||
writeln!(output, "instance produced by the files pointed to by $path has a ClassName of")?;
|
||||
writeln!(output, "Folder.")?;
|
||||
writeln!(output, "")?;
|
||||
writeln!(output, "Partition target ($path): {}", partition_path.display())
|
||||
},
|
||||
SnapshotError::PropertyResolveError { inner } => write!(output, "{}", inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot_project_tree<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
project: &'source Project,
|
||||
) -> SnapshotResult<'source> {
|
||||
snapshot_project_node(imfs, context, &project.tree, Cow::Borrowed(&project.name))
|
||||
snapshot_project_node(context, imfs, &project.tree, Cow::Borrowed(&project.name))
|
||||
}
|
||||
|
||||
fn snapshot_project_node<'source>(
|
||||
pub fn snapshot_project_node<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
node: &'source ProjectNode,
|
||||
node: &ProjectNode,
|
||||
instance_name: Cow<'source, str>,
|
||||
) -> SnapshotResult<'source> {
|
||||
match node {
|
||||
ProjectNode::Instance(instance_node) => snapshot_instance_node(imfs, context, instance_node, instance_name),
|
||||
ProjectNode::SyncPoint(sync_node) => snapshot_sync_point_node(imfs, context, sync_node, instance_name),
|
||||
}
|
||||
}
|
||||
let maybe_snapshot = match &node.path {
|
||||
Some(path) => snapshot_imfs_path(context, imfs, &path, Some(instance_name.clone()))?,
|
||||
None => match &node.class_name {
|
||||
Some(_class_name) => Some(RbxSnapshotInstance {
|
||||
name: instance_name.clone(),
|
||||
|
||||
fn snapshot_instance_node<'source>(
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
node: &'source InstanceProjectNode,
|
||||
instance_name: Cow<'source, str>,
|
||||
) -> SnapshotResult<'source> {
|
||||
let mut children = Vec::new();
|
||||
|
||||
for (child_name, child_project_node) in &node.children {
|
||||
if let Some(child) = snapshot_project_node(imfs, context, child_project_node, Cow::Borrowed(child_name))? {
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(RbxSnapshotInstance {
|
||||
class_name: Cow::Borrowed(&node.class_name),
|
||||
name: instance_name,
|
||||
properties: node.properties.clone(),
|
||||
children,
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: None,
|
||||
ignore_unknown_instances: node.metadata.ignore_unknown_instances,
|
||||
// These properties are replaced later in the function to
|
||||
// reduce code duplication.
|
||||
class_name: Cow::Borrowed("Folder"),
|
||||
properties: HashMap::new(),
|
||||
children: Vec::new(),
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: None,
|
||||
ignore_unknown_instances: true,
|
||||
project_definition: None,
|
||||
},
|
||||
}),
|
||||
None => {
|
||||
return Err(SnapshotError::ProjectNodeUnusable);
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn snapshot_sync_point_node<'source>(
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
node: &'source SyncPointProjectNode,
|
||||
instance_name: Cow<'source, str>,
|
||||
) -> SnapshotResult<'source> {
|
||||
let maybe_snapshot = snapshot_imfs_path(imfs, context, &node.path, Some(instance_name))?;
|
||||
};
|
||||
|
||||
// If the snapshot resulted in no instances, like if it targets an unknown
|
||||
// file or an empty model file, we can early-return.
|
||||
let snapshot = match maybe_snapshot {
|
||||
//
|
||||
// In the future, we might want to issue a warning if the project also
|
||||
// specified fields like class_name, since the user will probably be
|
||||
// confused as to why nothing showed up in the tree.
|
||||
let mut snapshot = match maybe_snapshot {
|
||||
Some(snapshot) => snapshot,
|
||||
None => return Ok(None),
|
||||
None => {
|
||||
// TODO: Return some other sort of marker here instead? If a node
|
||||
// transitions from None into Some, it's possible that configuration
|
||||
// from the ProjectNode might be lost since there's nowhere to put
|
||||
// it!
|
||||
return Ok(None);
|
||||
},
|
||||
};
|
||||
|
||||
// Otherwise, we can log the name of the sync point we just snapshotted.
|
||||
let path_meta = context.metadata_per_path.entry(node.path.to_owned()).or_default();
|
||||
path_meta.instance_name = Some(snapshot.name.clone().into_owned());
|
||||
// Applies the class name specified in `class_name` from the project, if it's
|
||||
// set.
|
||||
if let Some(class_name) = &node.class_name {
|
||||
// This can only happen if `path` was specified in the project node and
|
||||
// that path represented a non-Folder instance.
|
||||
if snapshot.class_name != "Folder" {
|
||||
return Err(SnapshotError::ProjectNodeInvalidTransmute {
|
||||
partition_path: node.path.as_ref().unwrap().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
snapshot.class_name = Cow::Owned(class_name.to_owned());
|
||||
}
|
||||
|
||||
for (child_name, child_project_node) in &node.children {
|
||||
if let Some(child) = snapshot_project_node(context, imfs, child_project_node, Cow::Owned(child_name.clone()))? {
|
||||
snapshot.children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in &node.properties {
|
||||
let resolved_value = try_resolve_value(&snapshot.class_name, key, value)?;
|
||||
snapshot.properties.insert(key.clone(), resolved_value);
|
||||
}
|
||||
|
||||
if let Some(ignore_unknown_instances) = node.ignore_unknown_instances {
|
||||
snapshot.metadata.ignore_unknown_instances = ignore_unknown_instances;
|
||||
}
|
||||
|
||||
snapshot.metadata.project_definition = Some((instance_name.into_owned(), node.clone()));
|
||||
|
||||
Ok(Some(snapshot))
|
||||
}
|
||||
|
||||
pub fn snapshot_imfs_path<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
path: &Path,
|
||||
instance_name: Option<Cow<'source, str>>,
|
||||
) -> SnapshotResult<'source> {
|
||||
// If the given path doesn't exist in the in-memory filesystem, we consider
|
||||
// that an error.
|
||||
match imfs.get(path) {
|
||||
Some(imfs_item) => snapshot_imfs_item(imfs, context, imfs_item, instance_name),
|
||||
Some(imfs_item) => snapshot_imfs_item(context, imfs, imfs_item, instance_name),
|
||||
None => return Err(SnapshotError::DidNotExist(path.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_imfs_item<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
item: &'source ImfsItem,
|
||||
instance_name: Option<Cow<'source, str>>,
|
||||
) -> SnapshotResult<'source> {
|
||||
match item {
|
||||
ImfsItem::File(file) => snapshot_imfs_file(file, instance_name),
|
||||
ImfsItem::Directory(directory) => snapshot_imfs_directory(imfs, context, directory, instance_name),
|
||||
ImfsItem::File(file) => snapshot_imfs_file(context, file, instance_name),
|
||||
ImfsItem::Directory(directory) => snapshot_imfs_directory(context, imfs, directory, instance_name),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_imfs_directory<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
directory: &'source ImfsDirectory,
|
||||
instance_name: Option<Cow<'source, str>>,
|
||||
) -> SnapshotResult<'source> {
|
||||
@@ -202,11 +298,11 @@ fn snapshot_imfs_directory<'source>(
|
||||
});
|
||||
|
||||
let mut snapshot = if directory.children.contains(&init_path) {
|
||||
snapshot_imfs_path(imfs, context, &init_path, Some(snapshot_name))?.unwrap()
|
||||
snapshot_imfs_path(context, imfs, &init_path, Some(snapshot_name))?.unwrap()
|
||||
} else if directory.children.contains(&init_server_path) {
|
||||
snapshot_imfs_path(imfs, context, &init_server_path, Some(snapshot_name))?.unwrap()
|
||||
snapshot_imfs_path(context, imfs, &init_server_path, Some(snapshot_name))?.unwrap()
|
||||
} else if directory.children.contains(&init_client_path) {
|
||||
snapshot_imfs_path(imfs, context, &init_client_path, Some(snapshot_name))?.unwrap()
|
||||
snapshot_imfs_path(context, imfs, &init_client_path, Some(snapshot_name))?.unwrap()
|
||||
} else {
|
||||
RbxSnapshotInstance {
|
||||
class_name: Cow::Borrowed("Folder"),
|
||||
@@ -216,6 +312,7 @@ fn snapshot_imfs_directory<'source>(
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: None,
|
||||
ignore_unknown_instances: false,
|
||||
project_definition: None,
|
||||
},
|
||||
}
|
||||
};
|
||||
@@ -234,7 +331,7 @@ fn snapshot_imfs_directory<'source>(
|
||||
// them here.
|
||||
},
|
||||
_ => {
|
||||
if let Some(child) = snapshot_imfs_path(imfs, context, child_path, None)? {
|
||||
if let Some(child) = snapshot_imfs_path(context, imfs, child_path, None)? {
|
||||
snapshot.children.push(child);
|
||||
}
|
||||
},
|
||||
@@ -245,6 +342,7 @@ fn snapshot_imfs_directory<'source>(
|
||||
}
|
||||
|
||||
fn snapshot_imfs_file<'source>(
|
||||
context: &SnapshotContext,
|
||||
file: &'source ImfsFile,
|
||||
instance_name: Option<Cow<'source, str>>,
|
||||
) -> SnapshotResult<'source> {
|
||||
@@ -280,6 +378,20 @@ fn snapshot_imfs_file<'source>(
|
||||
info!("File generated no snapshot: {}", file.path.display());
|
||||
}
|
||||
|
||||
if let Some(snapshot) = maybe_snapshot.as_ref() {
|
||||
if let Some(plugin_context) = &context.plugin_context {
|
||||
for plugin in &plugin_context.plugins {
|
||||
let owned_snapshot = snapshot.get_owned();
|
||||
let registry_key = &plugin.callback;
|
||||
|
||||
plugin_context.lua.context(move |context| {
|
||||
let callback: rlua::Function = context.registry_value(registry_key).unwrap();
|
||||
callback.call::<_, ()>(LuaRbxSnapshot(owned_snapshot)).unwrap();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(maybe_snapshot)
|
||||
}
|
||||
|
||||
@@ -316,6 +428,7 @@ fn snapshot_lua_file<'source>(
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: Some(file.path.to_path_buf()),
|
||||
ignore_unknown_instances: false,
|
||||
project_definition: None,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -354,6 +467,7 @@ fn snapshot_txt_file<'source>(
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: Some(file.path.to_path_buf()),
|
||||
ignore_unknown_instances: false,
|
||||
project_definition: None,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -387,6 +501,7 @@ fn snapshot_csv_file<'source>(
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: Some(file.path.to_path_buf()),
|
||||
ignore_unknown_instances: false,
|
||||
project_definition: None,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
//! Defines the snapshot subsystem of Rojo, which defines a lightweight instance
|
||||
//! representation (`RbxSnapshotInstance`) and a system to incrementally update
|
||||
//! an `RbxTree` based on snapshots.
|
||||
|
||||
use std::{
|
||||
str,
|
||||
borrow::Cow,
|
||||
cmp::Ordering,
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
str,
|
||||
};
|
||||
|
||||
use rbx_tree::{RbxTree, RbxId, RbxInstanceProperties, RbxValue};
|
||||
use rbx_dom_weak::{RbxTree, RbxId, RbxInstanceProperties, RbxValue};
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
|
||||
use crate::{
|
||||
path_map::PathMap,
|
||||
rbx_session::{MetadataPerPath, MetadataPerInstance},
|
||||
rbx_session::MetadataPerInstance,
|
||||
};
|
||||
|
||||
/// Contains all of the IDs that were modified when the snapshot reconciler
|
||||
/// applied an update.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct InstanceChanges {
|
||||
pub added: HashSet<RbxId>,
|
||||
@@ -55,7 +62,9 @@ impl InstanceChanges {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// A lightweight, hierarchical representation of an instance that can be
|
||||
/// applied to the tree.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RbxSnapshotInstance<'a> {
|
||||
pub name: Cow<'a, str>,
|
||||
pub class_name: Cow<'a, str>,
|
||||
@@ -64,6 +73,34 @@ pub struct RbxSnapshotInstance<'a> {
|
||||
pub metadata: MetadataPerInstance,
|
||||
}
|
||||
|
||||
impl<'a> RbxSnapshotInstance<'a> {
|
||||
pub fn get_owned(&'a self) -> RbxSnapshotInstance<'static> {
|
||||
let children: Vec<RbxSnapshotInstance<'static>> = self.children.iter()
|
||||
.map(RbxSnapshotInstance::get_owned)
|
||||
.collect();
|
||||
|
||||
RbxSnapshotInstance {
|
||||
name: Cow::Owned(self.name.clone().into_owned()),
|
||||
class_name: Cow::Owned(self.class_name.clone().into_owned()),
|
||||
properties: self.properties.clone(),
|
||||
children,
|
||||
metadata: self.metadata.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> PartialOrd for RbxSnapshotInstance<'a> {
|
||||
fn partial_cmp(&self, other: &RbxSnapshotInstance) -> Option<Ordering> {
|
||||
Some(self.name.cmp(&other.name)
|
||||
.then(self.class_name.cmp(&other.class_name)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates an `RbxSnapshotInstance` from an existing `RbxTree` and an ID to
|
||||
/// use as the root of the snapshot.
|
||||
///
|
||||
/// This is used to transform instances created by rbx_xml and rbx_binary into
|
||||
/// snapshots that can be applied to the tree to reduce instance churn.
|
||||
pub fn snapshot_from_tree(tree: &RbxTree, id: RbxId) -> Option<RbxSnapshotInstance<'static>> {
|
||||
let instance = tree.get_instance(id)?;
|
||||
|
||||
@@ -80,81 +117,93 @@ pub fn snapshot_from_tree(tree: &RbxTree, id: RbxId) -> Option<RbxSnapshotInstan
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: None,
|
||||
ignore_unknown_instances: false,
|
||||
project_definition: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Constructs a new `RbxTree` out of a snapshot and places to attach metadata.
|
||||
pub fn reify_root(
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
metadata_per_path: &mut PathMap<MetadataPerPath>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
instance_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
changes: &mut InstanceChanges,
|
||||
) -> RbxTree {
|
||||
let instance = reify_core(snapshot);
|
||||
let mut tree = RbxTree::new(instance);
|
||||
let root_id = tree.get_root_id();
|
||||
let id = tree.get_root_id();
|
||||
|
||||
if let Some(source_path) = &snapshot.metadata.source_path {
|
||||
let path_meta = metadata_per_path.entry(source_path.to_owned()).or_default();
|
||||
path_meta.instance_id = Some(root_id);
|
||||
}
|
||||
reify_metadata(snapshot, id, instance_per_path, metadata_per_instance);
|
||||
|
||||
instance_metadata_map.insert(root_id, snapshot.metadata.clone());
|
||||
|
||||
changes.added.insert(root_id);
|
||||
changes.added.insert(id);
|
||||
|
||||
for child in &snapshot.children {
|
||||
reify_subtree(child, &mut tree, root_id, metadata_per_path, instance_metadata_map, changes);
|
||||
reify_subtree(child, &mut tree, id, instance_per_path, metadata_per_instance, changes);
|
||||
}
|
||||
|
||||
tree
|
||||
}
|
||||
|
||||
/// Adds instances to a portion of the given `RbxTree`, used for when new
|
||||
/// instances are created.
|
||||
pub fn reify_subtree(
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
tree: &mut RbxTree,
|
||||
parent_id: RbxId,
|
||||
metadata_per_path: &mut PathMap<MetadataPerPath>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
instance_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
changes: &mut InstanceChanges,
|
||||
) {
|
||||
let instance = reify_core(snapshot);
|
||||
let id = tree.insert_instance(instance, parent_id);
|
||||
|
||||
if let Some(source_path) = &snapshot.metadata.source_path {
|
||||
let path_meta = metadata_per_path.entry(source_path.clone()).or_default();
|
||||
path_meta.instance_id = Some(id);
|
||||
}
|
||||
|
||||
instance_metadata_map.insert(id, snapshot.metadata.clone());
|
||||
reify_metadata(snapshot, id, instance_per_path, metadata_per_instance);
|
||||
|
||||
changes.added.insert(id);
|
||||
|
||||
for child in &snapshot.children {
|
||||
reify_subtree(child, tree, id, metadata_per_path, instance_metadata_map, changes);
|
||||
reify_subtree(child, tree, id, instance_per_path, metadata_per_instance, changes);
|
||||
}
|
||||
}
|
||||
|
||||
fn reify_metadata(
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
instance_id: RbxId,
|
||||
instance_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
) {
|
||||
if let Some(source_path) = &snapshot.metadata.source_path {
|
||||
let path_metadata = match instance_per_path.get_mut(&source_path) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
instance_per_path.insert(source_path.clone(), Default::default());
|
||||
instance_per_path.get_mut(&source_path).unwrap()
|
||||
},
|
||||
};
|
||||
|
||||
path_metadata.insert(instance_id);
|
||||
}
|
||||
|
||||
metadata_per_instance.insert(instance_id, snapshot.metadata.clone());
|
||||
}
|
||||
|
||||
/// Updates existing instances in an existing `RbxTree`, potentially adding,
|
||||
/// updating, or removing children and properties.
|
||||
pub fn reconcile_subtree(
|
||||
tree: &mut RbxTree,
|
||||
id: RbxId,
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
metadata_per_path: &mut PathMap<MetadataPerPath>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
instance_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
changes: &mut InstanceChanges,
|
||||
) {
|
||||
if let Some(source_path) = &snapshot.metadata.source_path {
|
||||
let path_meta = metadata_per_path.entry(source_path.to_owned()).or_default();
|
||||
path_meta.instance_id = Some(id);
|
||||
}
|
||||
|
||||
instance_metadata_map.insert(id, snapshot.metadata.clone());
|
||||
reify_metadata(snapshot, id, instance_per_path, metadata_per_instance);
|
||||
|
||||
if reconcile_instance_properties(tree.get_instance_mut(id).unwrap(), snapshot) {
|
||||
changes.updated.insert(id);
|
||||
}
|
||||
|
||||
reconcile_instance_children(tree, id, snapshot, metadata_per_path, instance_metadata_map, changes);
|
||||
reconcile_instance_children(tree, id, snapshot, instance_per_path, metadata_per_instance, changes);
|
||||
}
|
||||
|
||||
fn reify_core(snapshot: &RbxSnapshotInstance) -> RbxInstanceProperties {
|
||||
@@ -234,8 +283,8 @@ fn reconcile_instance_children(
|
||||
tree: &mut RbxTree,
|
||||
id: RbxId,
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
metadata_per_path: &mut PathMap<MetadataPerPath>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
instance_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
changes: &mut InstanceChanges,
|
||||
) {
|
||||
let mut visited_snapshot_indices = HashSet::new();
|
||||
@@ -287,19 +336,19 @@ fn reconcile_instance_children(
|
||||
}
|
||||
|
||||
for child_snapshot in &children_to_add {
|
||||
reify_subtree(child_snapshot, tree, id, metadata_per_path, instance_metadata_map, changes);
|
||||
reify_subtree(child_snapshot, tree, id, instance_per_path, metadata_per_instance, changes);
|
||||
}
|
||||
|
||||
for child_id in &children_to_remove {
|
||||
if let Some(subtree) = tree.remove_instance(*child_id) {
|
||||
for id in subtree.iter_all_ids() {
|
||||
instance_metadata_map.remove(&id);
|
||||
metadata_per_instance.remove(&id);
|
||||
changes.removed.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (child_id, child_snapshot) in &children_to_update {
|
||||
reconcile_subtree(tree, *child_id, child_snapshot, metadata_per_path, instance_metadata_map, changes);
|
||||
reconcile_subtree(tree, *child_id, child_snapshot, instance_per_path, metadata_per_instance, changes);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt,
|
||||
io::Write,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
use rbx_tree::RbxId;
|
||||
use log::warn;
|
||||
use rbx_dom_weak::{RbxTree, RbxId};
|
||||
|
||||
use crate::{
|
||||
imfs::{Imfs, ImfsItem},
|
||||
rbx_session::RbxSession,
|
||||
web::InstanceMetadata,
|
||||
web::api::PublicInstanceMetadata,
|
||||
rbx_session::MetadataPerInstance,
|
||||
};
|
||||
|
||||
static GRAPHVIZ_HEADER: &str = r#"
|
||||
@@ -27,13 +30,21 @@ digraph RojoTree {
|
||||
"#;
|
||||
|
||||
/// Compiles DOT source to SVG by invoking dot on the command line.
|
||||
pub fn graphviz_to_svg(source: &str) -> String {
|
||||
let mut child = Command::new("dot")
|
||||
pub fn graphviz_to_svg(source: &str) -> Option<String> {
|
||||
let command = Command::new("dot")
|
||||
.arg("-Tsvg")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to spawn GraphViz process -- make sure it's installed in order to use /api/visualize");
|
||||
.spawn();
|
||||
|
||||
let mut child = match command {
|
||||
Ok(child) => child,
|
||||
Err(_) => {
|
||||
warn!("Failed to spawn GraphViz process to visualize current state.");
|
||||
warn!("If you want pretty graphs, install GraphViz and make sure 'dot' is on your PATH!");
|
||||
return None;
|
||||
},
|
||||
};
|
||||
|
||||
{
|
||||
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
|
||||
@@ -41,7 +52,22 @@ pub fn graphviz_to_svg(source: &str) -> String {
|
||||
}
|
||||
|
||||
let output = child.wait_with_output().expect("Failed to read stdout");
|
||||
String::from_utf8(output.stdout).expect("Failed to parse stdout as UTF-8")
|
||||
Some(String::from_utf8(output.stdout).expect("Failed to parse stdout as UTF-8"))
|
||||
}
|
||||
|
||||
pub struct VisualizeRbxTree<'a, 'b> {
|
||||
pub tree: &'a RbxTree,
|
||||
pub metadata: &'b HashMap<RbxId, MetadataPerInstance>,
|
||||
}
|
||||
|
||||
impl<'a, 'b> fmt::Display for VisualizeRbxTree<'a, 'b> {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
writeln!(output, "{}", GRAPHVIZ_HEADER)?;
|
||||
|
||||
visualize_instance(&self.tree, self.tree.get_root_id(), &self.metadata, output)?;
|
||||
|
||||
writeln!(output, "}}")
|
||||
}
|
||||
}
|
||||
|
||||
/// A Display wrapper struct to visualize an RbxSession as SVG.
|
||||
@@ -49,37 +75,39 @@ pub struct VisualizeRbxSession<'a>(pub &'a RbxSession);
|
||||
|
||||
impl<'a> fmt::Display for VisualizeRbxSession<'a> {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
writeln!(output, "{}", GRAPHVIZ_HEADER)?;
|
||||
|
||||
visualize_rbx_node(self.0, self.0.get_tree().get_root_id(), output)?;
|
||||
|
||||
writeln!(output, "}}")?;
|
||||
|
||||
Ok(())
|
||||
writeln!(output, "{}", VisualizeRbxTree {
|
||||
tree: self.0.get_tree(),
|
||||
metadata: self.0.get_all_instance_metadata(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn visualize_rbx_node(session: &RbxSession, id: RbxId, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
let node = session.get_tree().get_instance(id).unwrap();
|
||||
fn visualize_instance(
|
||||
tree: &RbxTree,
|
||||
id: RbxId,
|
||||
metadata: &HashMap<RbxId, MetadataPerInstance>,
|
||||
output: &mut fmt::Formatter,
|
||||
) -> fmt::Result {
|
||||
let instance = tree.get_instance(id).unwrap();
|
||||
|
||||
let mut node_label = format!("{}|{}|{}", node.name, node.class_name, id);
|
||||
let mut instance_label = format!("{}|{}|{}", instance.name, instance.class_name, id);
|
||||
|
||||
if let Some(session_metadata) = session.get_instance_metadata(id) {
|
||||
let metadata = InstanceMetadata::from_session_metadata(session_metadata);
|
||||
node_label.push('|');
|
||||
node_label.push_str(&serde_json::to_string(&metadata).unwrap());
|
||||
if let Some(session_metadata) = metadata.get(&id) {
|
||||
let metadata = PublicInstanceMetadata::from_session_metadata(session_metadata);
|
||||
instance_label.push('|');
|
||||
instance_label.push_str(&serde_json::to_string(&metadata).unwrap());
|
||||
}
|
||||
|
||||
node_label = node_label
|
||||
instance_label = instance_label
|
||||
.replace("\"", """)
|
||||
.replace("{", "\\{")
|
||||
.replace("}", "\\}");
|
||||
|
||||
writeln!(output, " \"{}\" [label=\"{}\"]", id, node_label)?;
|
||||
writeln!(output, " \"{}\" [label=\"{}\"]", id, instance_label)?;
|
||||
|
||||
for &child_id in node.get_children_ids() {
|
||||
for &child_id in instance.get_children_ids() {
|
||||
writeln!(output, " \"{}\" -> \"{}\"", id, child_id)?;
|
||||
visualize_rbx_node(session, child_id, output)?;
|
||||
visualize_instance(tree, child_id, metadata, output)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{mpsc, Arc},
|
||||
};
|
||||
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use log::trace;
|
||||
use rouille::{
|
||||
self,
|
||||
router,
|
||||
Request,
|
||||
Response,
|
||||
};
|
||||
use rbx_tree::{RbxId, RbxInstance};
|
||||
|
||||
use crate::{
|
||||
live_session::LiveSession,
|
||||
session_id::SessionId,
|
||||
snapshot_reconciler::InstanceChanges,
|
||||
visualize::{VisualizeRbxSession, VisualizeImfs, graphviz_to_svg},
|
||||
rbx_session::{MetadataPerInstance},
|
||||
};
|
||||
|
||||
/// Contains the instance metadata relevant to Rojo clients.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceMetadata {
|
||||
ignore_unknown_instances: bool,
|
||||
}
|
||||
|
||||
impl InstanceMetadata {
|
||||
pub fn from_session_metadata(meta: &MetadataPerInstance) -> InstanceMetadata {
|
||||
InstanceMetadata {
|
||||
ignore_unknown_instances: meta.ignore_unknown_instances,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Used to attach metadata specific to Rojo to instances, which come from the
|
||||
/// rbx_tree crate.
|
||||
///
|
||||
/// Both fields are wrapped in Cow in order to make owned-vs-borrowed simpler
|
||||
/// for tests.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceWithMetadata<'a> {
|
||||
#[serde(flatten)]
|
||||
pub instance: Cow<'a, RbxInstance>,
|
||||
|
||||
#[serde(rename = "Metadata")]
|
||||
pub metadata: Option<InstanceMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerInfoResponse<'a> {
|
||||
pub session_id: SessionId,
|
||||
pub server_version: &'a str,
|
||||
pub protocol_version: u64,
|
||||
pub expected_place_ids: Option<HashSet<u64>>,
|
||||
pub root_instance_id: RbxId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadResponse<'a> {
|
||||
pub session_id: SessionId,
|
||||
pub message_cursor: u32,
|
||||
pub instances: HashMap<RbxId, InstanceWithMetadata<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscribeResponse<'a> {
|
||||
pub session_id: SessionId,
|
||||
pub message_cursor: u32,
|
||||
pub messages: Cow<'a, [InstanceChanges]>,
|
||||
}
|
||||
|
||||
pub struct Server {
|
||||
live_session: Arc<LiveSession>,
|
||||
server_version: &'static str,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(live_session: Arc<LiveSession>) -> Server {
|
||||
Server {
|
||||
live_session,
|
||||
server_version: env!("CARGO_PKG_VERSION"),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
pub fn handle_request(&self, request: &Request) -> Response {
|
||||
trace!("Request {} {}", request.method(), request.url());
|
||||
|
||||
router!(request,
|
||||
(GET) (/) => {
|
||||
Response::text("Rojo is up and running!")
|
||||
},
|
||||
|
||||
(GET) (/api/rojo) => {
|
||||
// Get a summary of information about the server.
|
||||
|
||||
let rbx_session = self.live_session.rbx_session.lock().unwrap();
|
||||
let tree = rbx_session.get_tree();
|
||||
|
||||
Response::json(&ServerInfoResponse {
|
||||
server_version: self.server_version,
|
||||
protocol_version: 2,
|
||||
session_id: self.live_session.session_id,
|
||||
expected_place_ids: self.live_session.project.serve_place_ids.clone(),
|
||||
root_instance_id: tree.get_root_id(),
|
||||
})
|
||||
},
|
||||
|
||||
(GET) (/api/subscribe/{ cursor: u32 }) => {
|
||||
// Retrieve any messages past the given cursor index, and if
|
||||
// there weren't any, subscribe to receive any new messages.
|
||||
|
||||
let message_queue = Arc::clone(&self.live_session.message_queue);
|
||||
|
||||
// Did the client miss any messages since the last subscribe?
|
||||
{
|
||||
let (new_cursor, new_messages) = message_queue.get_messages_since(cursor);
|
||||
|
||||
if !new_messages.is_empty() {
|
||||
return Response::json(&SubscribeResponse {
|
||||
session_id: self.live_session.session_id,
|
||||
messages: Cow::Borrowed(&new_messages),
|
||||
message_cursor: new_cursor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let sender_id = message_queue.subscribe(tx);
|
||||
|
||||
match rx.recv() {
|
||||
Ok(_) => (),
|
||||
Err(_) => return Response::text("error!").with_status_code(500),
|
||||
}
|
||||
|
||||
message_queue.unsubscribe(sender_id);
|
||||
|
||||
{
|
||||
let (new_cursor, new_messages) = message_queue.get_messages_since(cursor);
|
||||
|
||||
return Response::json(&SubscribeResponse {
|
||||
session_id: self.live_session.session_id,
|
||||
messages: Cow::Owned(new_messages),
|
||||
message_cursor: new_cursor,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
(GET) (/api/read/{ id_list: String }) => {
|
||||
let message_queue = Arc::clone(&self.live_session.message_queue);
|
||||
|
||||
let requested_ids: Option<Vec<RbxId>> = id_list
|
||||
.split(',')
|
||||
.map(RbxId::parse_str)
|
||||
.collect();
|
||||
|
||||
let requested_ids = match requested_ids {
|
||||
Some(id) => id,
|
||||
None => return rouille::Response::text("Malformed ID list").with_status_code(400),
|
||||
};
|
||||
|
||||
let rbx_session = self.live_session.rbx_session.lock().unwrap();
|
||||
let tree = rbx_session.get_tree();
|
||||
|
||||
let message_cursor = message_queue.get_message_cursor();
|
||||
|
||||
let mut instances = HashMap::new();
|
||||
|
||||
for &requested_id in &requested_ids {
|
||||
if let Some(instance) = tree.get_instance(requested_id) {
|
||||
let metadata = rbx_session.get_instance_metadata(requested_id)
|
||||
.map(InstanceMetadata::from_session_metadata);
|
||||
|
||||
instances.insert(instance.get_id(), InstanceWithMetadata {
|
||||
instance: Cow::Borrowed(instance),
|
||||
metadata,
|
||||
});
|
||||
|
||||
for descendant in tree.descendants(requested_id) {
|
||||
let descendant_meta = rbx_session.get_instance_metadata(descendant.get_id())
|
||||
.map(InstanceMetadata::from_session_metadata);
|
||||
|
||||
instances.insert(descendant.get_id(), InstanceWithMetadata {
|
||||
instance: Cow::Borrowed(descendant),
|
||||
metadata: descendant_meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Response::json(&ReadResponse {
|
||||
session_id: self.live_session.session_id,
|
||||
message_cursor,
|
||||
instances,
|
||||
})
|
||||
},
|
||||
|
||||
(GET) (/visualize/rbx) => {
|
||||
let rbx_session = self.live_session.rbx_session.lock().unwrap();
|
||||
|
||||
let dot_source = format!("{}", VisualizeRbxSession(&rbx_session));
|
||||
|
||||
Response::svg(graphviz_to_svg(&dot_source))
|
||||
},
|
||||
|
||||
(GET) (/visualize/imfs) => {
|
||||
let imfs = self.live_session.imfs.lock().unwrap();
|
||||
|
||||
let dot_source = format!("{}", VisualizeImfs(&imfs));
|
||||
|
||||
Response::svg(graphviz_to_svg(&dot_source))
|
||||
},
|
||||
|
||||
(GET) (/visualize/path_metadata) => {
|
||||
let rbx_session = self.live_session.rbx_session.lock().unwrap();
|
||||
|
||||
Response::json(&rbx_session.debug_get_metadata_per_path())
|
||||
},
|
||||
|
||||
_ => Response::empty_404()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn listen(self, port: u16) {
|
||||
let address = format!("0.0.0.0:{}", port);
|
||||
|
||||
rouille::start_server(address, move |request| self.handle_request(request));
|
||||
}
|
||||
}
|
||||
266
server/src/web/api.rs
Normal file
266
server/src/web/api.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
|
||||
//! JSON.
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use futures::{future, Future, stream::Stream, sync::mpsc};
|
||||
use hyper::{
|
||||
service::Service,
|
||||
header,
|
||||
StatusCode,
|
||||
Method,
|
||||
Body,
|
||||
Request,
|
||||
Response,
|
||||
};
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use rbx_dom_weak::{RbxId, RbxInstance};
|
||||
|
||||
use crate::{
|
||||
live_session::LiveSession,
|
||||
session_id::SessionId,
|
||||
snapshot_reconciler::InstanceChanges,
|
||||
rbx_session::{MetadataPerInstance},
|
||||
};
|
||||
|
||||
/// Contains the instance metadata relevant to Rojo clients.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicInstanceMetadata {
|
||||
ignore_unknown_instances: bool,
|
||||
}
|
||||
|
||||
impl PublicInstanceMetadata {
|
||||
pub fn from_session_metadata(meta: &MetadataPerInstance) -> PublicInstanceMetadata {
|
||||
PublicInstanceMetadata {
|
||||
ignore_unknown_instances: meta.ignore_unknown_instances,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Used to attach metadata specific to Rojo to instances, which come from the
|
||||
/// rbx_dom_weak crate.
|
||||
///
|
||||
/// Both fields are wrapped in Cow in order to make owned-vs-borrowed simpler
|
||||
/// for tests.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceWithMetadata<'a> {
|
||||
#[serde(flatten)]
|
||||
pub instance: Cow<'a, RbxInstance>,
|
||||
|
||||
#[serde(rename = "Metadata")]
|
||||
pub metadata: Option<PublicInstanceMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerInfoResponse<'a> {
|
||||
pub session_id: SessionId,
|
||||
pub server_version: &'a str,
|
||||
pub protocol_version: u64,
|
||||
pub expected_place_ids: Option<HashSet<u64>>,
|
||||
pub root_instance_id: RbxId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadResponse<'a> {
|
||||
pub session_id: SessionId,
|
||||
pub message_cursor: u32,
|
||||
pub instances: HashMap<RbxId, InstanceWithMetadata<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubscribeResponse<'a> {
|
||||
pub session_id: SessionId,
|
||||
pub message_cursor: u32,
|
||||
pub messages: Cow<'a, [InstanceChanges]>,
|
||||
}
|
||||
|
||||
fn response_json<T: serde::Serialize>(value: T) -> Response<Body> {
|
||||
let serialized = match serde_json::to_string(&value) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "text/plain")
|
||||
.body(Body::from(err.to_string()))
|
||||
.unwrap();
|
||||
},
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serialized))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub struct ApiService {
|
||||
live_session: Arc<LiveSession>,
|
||||
server_version: &'static str,
|
||||
}
|
||||
|
||||
impl Service for ApiService {
|
||||
type ReqBody = Body;
|
||||
type ResBody = Body;
|
||||
type Error = hyper::Error;
|
||||
type Future = Box<dyn Future<Item = hyper::Response<Self::ReqBody>, Error = Self::Error> + Send>;
|
||||
|
||||
fn call(&mut self, request: hyper::Request<Self::ReqBody>) -> Self::Future {
|
||||
let response = match (request.method(), request.uri().path()) {
|
||||
(&Method::GET, "/api/rojo") => self.handle_api_rojo(),
|
||||
(&Method::GET, path) if path.starts_with("/api/read/") => self.handle_api_read(request),
|
||||
(&Method::GET, path) if path.starts_with("/api/subscribe/") => {
|
||||
return self.handle_api_subscribe(request);
|
||||
}
|
||||
_ => {
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
Box::new(future::ok(response))
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiService {
|
||||
pub fn new(live_session: Arc<LiveSession>) -> ApiService {
|
||||
ApiService {
|
||||
live_session,
|
||||
server_version: env!("CARGO_PKG_VERSION"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a summary of information about the server
|
||||
fn handle_api_rojo(&self) -> Response<Body> {
|
||||
let rbx_session = self.live_session.rbx_session.lock().unwrap();
|
||||
let tree = rbx_session.get_tree();
|
||||
|
||||
response_json(&ServerInfoResponse {
|
||||
server_version: self.server_version,
|
||||
protocol_version: 2,
|
||||
session_id: self.live_session.session_id(),
|
||||
expected_place_ids: self.live_session.serve_place_ids().clone(),
|
||||
root_instance_id: tree.get_root_id(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Retrieve any messages past the given cursor index, and if
|
||||
/// there weren't any, subscribe to receive any new messages.
|
||||
fn handle_api_subscribe(&self, request: Request<Body>) -> <ApiService as Service>::Future {
|
||||
let argument = &request.uri().path()["/api/subscribe/".len()..];
|
||||
let cursor: u32 = match argument.parse() {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
return Box::new(future::ok(Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "text/plain")
|
||||
.body(Body::from(err.to_string()))
|
||||
.unwrap()));
|
||||
},
|
||||
};
|
||||
|
||||
let message_queue = Arc::clone(&self.live_session.message_queue);
|
||||
|
||||
// Did the client miss any messages since the last subscribe?
|
||||
{
|
||||
let (new_cursor, new_messages) = message_queue.get_messages_since(cursor);
|
||||
|
||||
if !new_messages.is_empty() {
|
||||
return Box::new(future::ok(response_json(&SubscribeResponse {
|
||||
session_id: self.live_session.session_id(),
|
||||
messages: Cow::Borrowed(&new_messages),
|
||||
message_cursor: new_cursor,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel(1024);
|
||||
let sender_id = message_queue.subscribe(tx);
|
||||
let session_id = self.live_session.session_id();
|
||||
|
||||
let result = rx.into_future()
|
||||
.and_then(move |_| {
|
||||
message_queue.unsubscribe(sender_id);
|
||||
|
||||
let (new_cursor, new_messages) = message_queue.get_messages_since(cursor);
|
||||
|
||||
Box::new(future::ok(response_json(SubscribeResponse {
|
||||
session_id: session_id,
|
||||
messages: Cow::Owned(new_messages),
|
||||
message_cursor: new_cursor,
|
||||
})))
|
||||
})
|
||||
.or_else(|e| {
|
||||
Box::new(future::ok(Response::builder()
|
||||
.status(500)
|
||||
.body(Body::from(format!("Internal Error: {:?}", e)))
|
||||
.unwrap()))
|
||||
});
|
||||
|
||||
Box::new(result)
|
||||
}
|
||||
|
||||
fn handle_api_read(&self, request: Request<Body>) -> Response<Body> {
|
||||
let argument = &request.uri().path()["/api/read/".len()..];
|
||||
let requested_ids: Option<Vec<RbxId>> = argument
|
||||
.split(',')
|
||||
.map(RbxId::parse_str)
|
||||
.collect();
|
||||
|
||||
let message_queue = Arc::clone(&self.live_session.message_queue);
|
||||
|
||||
let requested_ids = match requested_ids {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "text/plain")
|
||||
.body(Body::from("Malformed ID list"))
|
||||
.unwrap();
|
||||
},
|
||||
};
|
||||
|
||||
let rbx_session = self.live_session.rbx_session.lock().unwrap();
|
||||
let tree = rbx_session.get_tree();
|
||||
|
||||
let message_cursor = message_queue.get_message_cursor();
|
||||
|
||||
let mut instances = HashMap::new();
|
||||
|
||||
for &requested_id in &requested_ids {
|
||||
if let Some(instance) = tree.get_instance(requested_id) {
|
||||
let metadata = rbx_session.get_instance_metadata(requested_id)
|
||||
.map(PublicInstanceMetadata::from_session_metadata);
|
||||
|
||||
instances.insert(instance.get_id(), InstanceWithMetadata {
|
||||
instance: Cow::Borrowed(instance),
|
||||
metadata,
|
||||
});
|
||||
|
||||
for descendant in tree.descendants(requested_id) {
|
||||
let descendant_meta = rbx_session.get_instance_metadata(descendant.get_id())
|
||||
.map(PublicInstanceMetadata::from_session_metadata);
|
||||
|
||||
instances.insert(descendant.get_id(), InstanceWithMetadata {
|
||||
instance: Cow::Borrowed(descendant),
|
||||
metadata: descendant_meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response_json(&ReadResponse {
|
||||
session_id: self.live_session.session_id(),
|
||||
message_cursor,
|
||||
instances,
|
||||
})
|
||||
}
|
||||
}
|
||||
121
server/src/web/interface.rs
Normal file
121
server/src/web/interface.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! Defines the HTTP-based UI. These endpoints generally return HTML and SVG.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::{future, Future};
|
||||
use hyper::{
|
||||
service::Service,
|
||||
header,
|
||||
Body,
|
||||
Method,
|
||||
StatusCode,
|
||||
Request,
|
||||
Response,
|
||||
};
|
||||
use ritz::html;
|
||||
|
||||
use crate::{
|
||||
live_session::LiveSession,
|
||||
visualize::{VisualizeRbxSession, VisualizeImfs, graphviz_to_svg},
|
||||
};
|
||||
|
||||
static HOME_CSS: &str = include_str!("../../assets/index.css");
|
||||
|
||||
pub struct InterfaceService {
|
||||
live_session: Arc<LiveSession>,
|
||||
server_version: &'static str,
|
||||
}
|
||||
|
||||
impl Service for InterfaceService {
|
||||
type ReqBody = Body;
|
||||
type ResBody = Body;
|
||||
type Error = hyper::Error;
|
||||
type Future = Box<dyn Future<Item = Response<Self::ReqBody>, Error = Self::Error> + Send>;
|
||||
|
||||
fn call(&mut self, request: Request<Self::ReqBody>) -> Self::Future {
|
||||
let response = match (request.method(), request.uri().path()) {
|
||||
(&Method::GET, "/") => self.handle_home(),
|
||||
(&Method::GET, "/visualize/rbx") => self.handle_visualize_rbx(),
|
||||
(&Method::GET, "/visualize/imfs") => self.handle_visualize_imfs(),
|
||||
_ => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
};
|
||||
|
||||
Box::new(future::ok(response))
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceService {
|
||||
pub fn new(live_session: Arc<LiveSession>) -> InterfaceService {
|
||||
InterfaceService {
|
||||
live_session,
|
||||
server_version: env!("CARGO_PKG_VERSION"),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_home(&self) -> Response<Body> {
|
||||
let page = html! {
|
||||
<html>
|
||||
<head>
|
||||
<title>"Rojo"</title>
|
||||
<style>
|
||||
{ ritz::UnescapedText::new(HOME_CSS) }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="main">
|
||||
<h1 class="title">
|
||||
"Rojo Live Sync is up and running!"
|
||||
</h1>
|
||||
<h2 class="subtitle">
|
||||
"Version " { self.server_version }
|
||||
</h2>
|
||||
<a class="docs" href="https://lpghatguy.github.io/rojo">
|
||||
"Rojo Documentation"
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/html")
|
||||
.body(Body::from(format!("<!DOCTYPE html>{}", page)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn handle_visualize_rbx(&self) -> Response<Body> {
|
||||
let rbx_session = self.live_session.rbx_session.lock().unwrap();
|
||||
let dot_source = format!("{}", VisualizeRbxSession(&rbx_session));
|
||||
|
||||
match graphviz_to_svg(&dot_source) {
|
||||
Some(svg) => Response::builder()
|
||||
.header(header::CONTENT_TYPE, "image/svg+xml")
|
||||
.body(Body::from(svg))
|
||||
.unwrap(),
|
||||
None => Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/plain")
|
||||
.body(Body::from(dot_source))
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_visualize_imfs(&self) -> Response<Body> {
|
||||
let imfs = self.live_session.imfs.lock().unwrap();
|
||||
let dot_source = format!("{}", VisualizeImfs(&imfs));
|
||||
|
||||
match graphviz_to_svg(&dot_source) {
|
||||
Some(svg) => Response::builder()
|
||||
.header(header::CONTENT_TYPE, "image/svg+xml")
|
||||
.body(Body::from(svg))
|
||||
.unwrap(),
|
||||
None => Response::builder()
|
||||
.header(header::CONTENT_TYPE, "text/plain")
|
||||
.body(Body::from(dot_source))
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
85
server/src/web/mod.rs
Normal file
85
server/src/web/mod.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
// TODO: This module needs to be public for visualize, we should move
|
||||
// PublicInstanceMetadata and switch this private!
|
||||
pub mod api;
|
||||
mod interface;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::trace;
|
||||
use futures::{
|
||||
future::{self, FutureResult},
|
||||
Future,
|
||||
};
|
||||
use hyper::{
|
||||
service::Service,
|
||||
Body,
|
||||
Request,
|
||||
Response,
|
||||
Server,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
live_session::LiveSession,
|
||||
};
|
||||
|
||||
use self::{
|
||||
api::ApiService,
|
||||
interface::InterfaceService,
|
||||
};
|
||||
|
||||
pub struct RootService {
|
||||
api: api::ApiService,
|
||||
interface: interface::InterfaceService,
|
||||
}
|
||||
|
||||
impl Service for RootService {
|
||||
type ReqBody = Body;
|
||||
type ResBody = Body;
|
||||
type Error = hyper::Error;
|
||||
type Future = Box<dyn Future<Item = Response<Self::ReqBody>, Error = Self::Error> + Send>;
|
||||
|
||||
fn call(&mut self, request: Request<Self::ReqBody>) -> Self::Future {
|
||||
trace!("{} {}", request.method(), request.uri().path());
|
||||
|
||||
if request.uri().path().starts_with("/api") {
|
||||
self.api.call(request)
|
||||
} else {
|
||||
self.interface.call(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RootService {
|
||||
pub fn new(live_session: Arc<LiveSession>) -> RootService {
|
||||
RootService {
|
||||
api: ApiService::new(Arc::clone(&live_session)),
|
||||
interface: InterfaceService::new(Arc::clone(&live_session)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LiveServer {
|
||||
live_session: Arc<LiveSession>,
|
||||
}
|
||||
|
||||
impl LiveServer {
|
||||
pub fn new(live_session: Arc<LiveSession>) -> LiveServer {
|
||||
LiveServer {
|
||||
live_session,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(self, port: u16) {
|
||||
let address = ([127, 0, 0, 1], port).into();
|
||||
|
||||
let server = Server::bind(&address)
|
||||
.serve(move || {
|
||||
let service: FutureResult<_, hyper::Error> =
|
||||
future::ok(RootService::new(Arc::clone(&self.live_session)));
|
||||
service
|
||||
})
|
||||
.map_err(|e| eprintln!("Server error: {}", e));
|
||||
|
||||
hyper::rt::run(server);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use std::io::Read;
|
||||
|
||||
use rouille;
|
||||
use serde;
|
||||
use serde_json;
|
||||
|
||||
static MAX_BODY_SIZE: usize = 100 * 1024 * 1024; // 100 MiB
|
||||
|
||||
/// Pulls text that may be JSON out of a Rouille Request object.
|
||||
///
|
||||
/// Doesn't do any actual parsing -- all this method does is verify the content
|
||||
/// type of the request and read the request's body.
|
||||
fn read_json_text(request: &rouille::Request) -> Option<String> {
|
||||
// Bail out if the request body isn't marked as JSON
|
||||
let content_type = request.header("Content-Type")?;
|
||||
|
||||
if !content_type.starts_with("application/json") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let body = request.data()?;
|
||||
|
||||
// Allocate a buffer and read up to MAX_BODY_SIZE+1 bytes into it.
|
||||
let mut out = Vec::new();
|
||||
body.take(MAX_BODY_SIZE.saturating_add(1) as u64).read_to_end(&mut out).ok()?;
|
||||
|
||||
// If the body was too big (MAX_BODY_SIZE+1), we abort instead of trying to
|
||||
// process it.
|
||||
if out.len() > MAX_BODY_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
/// Reads the body out of a Rouille Request and attempts to turn it into JSON.
|
||||
pub fn read_json<T>(request: &rouille::Request) -> Option<T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
let body = read_json_text(&request)?;
|
||||
serde_json::from_str(&body).ok()?
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
io,
|
||||
collections::{HashMap, HashSet, BTreeSet},
|
||||
fs,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use failure::Error;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
use librojo::{
|
||||
@@ -19,7 +19,7 @@ enum FsEvent {
|
||||
Moved(PathBuf, PathBuf),
|
||||
}
|
||||
|
||||
fn send_events(imfs: &mut Imfs, events: &[FsEvent]) -> io::Result<()> {
|
||||
fn send_events(imfs: &mut Imfs, events: &[FsEvent]) -> Result<(), Error> {
|
||||
for event in events {
|
||||
match event {
|
||||
FsEvent::Created(path) => imfs.path_created(path)?,
|
||||
@@ -56,7 +56,7 @@ fn check_expected(real: &Imfs, expected: &ExpectedImfs) {
|
||||
}
|
||||
}
|
||||
|
||||
fn base_tree() -> io::Result<(TempDir, Imfs, ExpectedImfs, TestResources)> {
|
||||
fn base_tree() -> Result<(TempDir, Imfs, ExpectedImfs, TestResources), Error> {
|
||||
let root = tempdir()?;
|
||||
|
||||
let foo_path = root.path().join("foo");
|
||||
@@ -80,7 +80,7 @@ fn base_tree() -> io::Result<(TempDir, Imfs, ExpectedImfs, TestResources)> {
|
||||
expected_roots.insert(root.path().to_path_buf());
|
||||
|
||||
let root_item = {
|
||||
let mut children = HashSet::new();
|
||||
let mut children = BTreeSet::new();
|
||||
children.insert(foo_path.clone());
|
||||
children.insert(bar_path.clone());
|
||||
|
||||
@@ -91,7 +91,7 @@ fn base_tree() -> io::Result<(TempDir, Imfs, ExpectedImfs, TestResources)> {
|
||||
};
|
||||
|
||||
let foo_item = {
|
||||
let mut children = HashSet::new();
|
||||
let mut children = BTreeSet::new();
|
||||
children.insert(baz_path.clone());
|
||||
|
||||
ImfsItem::Directory(ImfsDirectory {
|
||||
@@ -125,7 +125,7 @@ fn base_tree() -> io::Result<(TempDir, Imfs, ExpectedImfs, TestResources)> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_read() -> io::Result<()> {
|
||||
fn initial_read() -> Result<(), Error> {
|
||||
let (_root, imfs, expected_imfs, _resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
@@ -134,7 +134,7 @@ fn initial_read() -> io::Result<()> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_files() -> io::Result<()> {
|
||||
fn adding_files() -> Result<(), Error> {
|
||||
let (root, mut imfs, mut expected_imfs, resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
@@ -178,7 +178,7 @@ fn adding_files() -> io::Result<()> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_folder() -> io::Result<()> {
|
||||
fn adding_folder() -> Result<(), Error> {
|
||||
let (root, imfs, mut expected_imfs, _resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
@@ -199,7 +199,7 @@ fn adding_folder() -> io::Result<()> {
|
||||
}
|
||||
|
||||
let folder_item = {
|
||||
let mut children = HashSet::new();
|
||||
let mut children = BTreeSet::new();
|
||||
children.insert(file1_path.clone());
|
||||
children.insert(file2_path.clone());
|
||||
|
||||
@@ -255,7 +255,36 @@ fn adding_folder() -> io::Result<()> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_file() -> io::Result<()> {
|
||||
fn updating_files() -> Result<(), Error> {
|
||||
let (_root, mut imfs, mut expected_imfs, resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
|
||||
fs::write(&resources.bar_path, b"bar updated")?;
|
||||
fs::write(&resources.baz_path, b"baz updated")?;
|
||||
|
||||
imfs.path_updated(&resources.bar_path)?;
|
||||
imfs.path_updated(&resources.baz_path)?;
|
||||
|
||||
let bar_updated_item = ImfsItem::File(ImfsFile {
|
||||
path: resources.bar_path.clone(),
|
||||
contents: b"bar updated".to_vec(),
|
||||
});
|
||||
let baz_updated_item = ImfsItem::File(ImfsFile {
|
||||
path: resources.baz_path.clone(),
|
||||
contents: b"baz updated".to_vec(),
|
||||
});
|
||||
|
||||
expected_imfs.items.insert(resources.bar_path.clone(), bar_updated_item);
|
||||
expected_imfs.items.insert(resources.baz_path.clone(), baz_updated_item);
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_file() -> Result<(), Error> {
|
||||
let (root, mut imfs, mut expected_imfs, resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
@@ -279,7 +308,7 @@ fn removing_file() -> io::Result<()> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_folder() -> io::Result<()> {
|
||||
fn removing_folder() -> Result<(), Error> {
|
||||
let (root, imfs, mut expected_imfs, resources) = base_tree()?;
|
||||
|
||||
check_expected(&imfs, &expected_imfs);
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
#[macro_use] extern crate lazy_static;
|
||||
|
||||
extern crate librojo;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, BTreeMap},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use rbx_tree::RbxValue;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rbx_dom_weak::RbxValue;
|
||||
|
||||
use librojo::{
|
||||
project::{Project, ProjectNode, InstanceProjectNode, SyncPointProjectNode},
|
||||
project::{Project, ProjectNode},
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
@@ -21,7 +20,7 @@ lazy_static! {
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("empty/roblox-project.json");
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("empty/default.project.json");
|
||||
let project = Project::load_exact(&project_file_location).unwrap();
|
||||
|
||||
assert_eq!(project.name, "empty");
|
||||
@@ -29,7 +28,7 @@ fn empty() {
|
||||
|
||||
#[test]
|
||||
fn empty_fuzzy_file() {
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("empty/roblox-project.json");
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("empty/default.project.json");
|
||||
let project = Project::load_fuzzy(&project_file_location).unwrap();
|
||||
|
||||
assert_eq!(project.name, "empty");
|
||||
@@ -44,54 +43,53 @@ fn empty_fuzzy_folder() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_sync_point() {
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("single-sync-point/roblox-project.json");
|
||||
let project = Project::load_exact(&project_file_location).unwrap();
|
||||
fn single_partition_game() {
|
||||
let project_location = TEST_PROJECTS_ROOT.join("single_partition_game");
|
||||
let project = Project::load_fuzzy(&project_location).unwrap();
|
||||
|
||||
let expected_project = {
|
||||
let foo = ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path: project_file_location.parent().unwrap().join("lib"),
|
||||
});
|
||||
let foo = ProjectNode {
|
||||
path: Some(project_location.join("lib")),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut replicated_storage_children = HashMap::new();
|
||||
let mut replicated_storage_children = BTreeMap::new();
|
||||
replicated_storage_children.insert("Foo".to_string(), foo);
|
||||
|
||||
let replicated_storage = ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: "ReplicatedStorage".to_string(),
|
||||
let replicated_storage = ProjectNode {
|
||||
class_name: Some(String::from("ReplicatedStorage")),
|
||||
children: replicated_storage_children,
|
||||
properties: HashMap::new(),
|
||||
metadata: Default::default(),
|
||||
});
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut http_service_properties = HashMap::new();
|
||||
http_service_properties.insert("HttpEnabled".to_string(), RbxValue::Bool {
|
||||
value: true,
|
||||
});
|
||||
}.into());
|
||||
|
||||
let http_service = ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: "HttpService".to_string(),
|
||||
children: HashMap::new(),
|
||||
let http_service = ProjectNode {
|
||||
class_name: Some(String::from("HttpService")),
|
||||
properties: http_service_properties,
|
||||
metadata: Default::default(),
|
||||
});
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut root_children = HashMap::new();
|
||||
let mut root_children = BTreeMap::new();
|
||||
root_children.insert("ReplicatedStorage".to_string(), replicated_storage);
|
||||
root_children.insert("HttpService".to_string(), http_service);
|
||||
|
||||
let root_node = ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: "DataModel".to_string(),
|
||||
let root_node = ProjectNode {
|
||||
class_name: Some(String::from("DataModel")),
|
||||
children: root_children,
|
||||
properties: HashMap::new(),
|
||||
metadata: Default::default(),
|
||||
});
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Project {
|
||||
name: "single-sync-point".to_string(),
|
||||
tree: root_node,
|
||||
plugins: Vec::new(),
|
||||
serve_port: None,
|
||||
serve_place_ids: None,
|
||||
file_location: project_file_location.clone(),
|
||||
file_location: project_location.join("default.project.json"),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,9 +97,17 @@ fn single_sync_point() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model() {
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("test-model/roblox-project.json");
|
||||
let project = Project::load_exact(&project_file_location).unwrap();
|
||||
fn single_partition_model() {
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("single_partition_model");
|
||||
let project = Project::load_fuzzy(&project_file_location).unwrap();
|
||||
|
||||
assert_eq!(project.name, "test-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composing_models() {
|
||||
let project_file_location = TEST_PROJECTS_ROOT.join("composing_models");
|
||||
let project = Project::load_fuzzy(&project_file_location).unwrap();
|
||||
|
||||
assert_eq!(project.name, "composing-models");
|
||||
}
|
||||
115
server/tests/snapshot_reconciler.rs
Normal file
115
server/tests/snapshot_reconciler.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
mod test_util;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use rbx_dom_weak::{RbxTree, RbxInstanceProperties};
|
||||
|
||||
use librojo::{
|
||||
snapshot_reconciler::{RbxSnapshotInstance, reconcile_subtree},
|
||||
};
|
||||
|
||||
use test_util::tree::trees_equal;
|
||||
|
||||
// TODO: Snapshot application isn't communicative right now with the current
|
||||
// snapshot reconciler. In practice this mostly isn't a problem, but presents
|
||||
// a problem trying to rely on determinism to make snapshot tests.
|
||||
// #[test]
|
||||
fn patch_communicativity() {
|
||||
let base_tree = RbxTree::new(RbxInstanceProperties {
|
||||
name: "DataModel".into(),
|
||||
class_name: "DataModel".into(),
|
||||
properties: HashMap::new(),
|
||||
});
|
||||
|
||||
let patch_a = RbxSnapshotInstance {
|
||||
name: "DataModel".into(),
|
||||
class_name: "DataModel".into(),
|
||||
children: vec![
|
||||
RbxSnapshotInstance {
|
||||
name: "Child-A".into(),
|
||||
class_name: "Folder".into(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let patch_b = RbxSnapshotInstance {
|
||||
name: "DataModel".into(),
|
||||
class_name: "DataModel".into(),
|
||||
children: vec![
|
||||
RbxSnapshotInstance {
|
||||
name: "Child-B".into(),
|
||||
class_name: "Folder".into(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let patch_combined = RbxSnapshotInstance {
|
||||
name: "DataModel".into(),
|
||||
class_name: "DataModel".into(),
|
||||
children: vec![
|
||||
RbxSnapshotInstance {
|
||||
name: "Child-A".into(),
|
||||
class_name: "Folder".into(),
|
||||
..Default::default()
|
||||
},
|
||||
RbxSnapshotInstance {
|
||||
name: "Child-B".into(),
|
||||
class_name: "Folder".into(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let root_id = base_tree.get_root_id();
|
||||
|
||||
let mut tree_a = base_tree.clone();
|
||||
|
||||
reconcile_subtree(
|
||||
&mut tree_a,
|
||||
root_id,
|
||||
&patch_a,
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
);
|
||||
|
||||
reconcile_subtree(
|
||||
&mut tree_a,
|
||||
root_id,
|
||||
&patch_combined,
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
);
|
||||
|
||||
let mut tree_b = base_tree.clone();
|
||||
|
||||
reconcile_subtree(
|
||||
&mut tree_b,
|
||||
root_id,
|
||||
&patch_b,
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
);
|
||||
|
||||
reconcile_subtree(
|
||||
&mut tree_b,
|
||||
root_id,
|
||||
&patch_combined,
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
&mut Default::default(),
|
||||
);
|
||||
|
||||
match trees_equal(&tree_a, &tree_b) {
|
||||
Ok(_) => {}
|
||||
Err(e) => panic!("{}", e),
|
||||
}
|
||||
}
|
||||
68
server/tests/snapshot_snapshots.rs
Normal file
68
server/tests/snapshot_snapshots.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
mod test_util;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use librojo::{
|
||||
imfs::Imfs,
|
||||
project::Project,
|
||||
rbx_snapshot::{SnapshotContext, snapshot_project_tree},
|
||||
};
|
||||
|
||||
use crate::test_util::{
|
||||
snapshot::*,
|
||||
};
|
||||
|
||||
macro_rules! generate_snapshot_tests {
|
||||
($($name: ident),*) => {
|
||||
$(
|
||||
paste::item! {
|
||||
#[test]
|
||||
fn [<snapshot_ $name>]() {
|
||||
let _ = env_logger::try_init();
|
||||
|
||||
let tests_folder = Path::new(env!("CARGO_MANIFEST_DIR")).join("../test-projects");
|
||||
let project_folder = tests_folder.join(stringify!($name));
|
||||
run_snapshot_test(&project_folder);
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
generate_snapshot_tests!(
|
||||
empty,
|
||||
multi_partition_game,
|
||||
nested_partitions,
|
||||
single_partition_game,
|
||||
single_partition_model,
|
||||
transmute_partition
|
||||
);
|
||||
|
||||
fn run_snapshot_test(path: &Path) {
|
||||
println!("Running snapshot from project: {}", path.display());
|
||||
|
||||
let project = Project::load_fuzzy(path)
|
||||
.expect("Couldn't load project file for snapshot test");
|
||||
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)
|
||||
.expect("Could not add IMFS roots to snapshot project");
|
||||
|
||||
let context = SnapshotContext {
|
||||
plugin_context: None,
|
||||
};
|
||||
|
||||
let mut snapshot = snapshot_project_tree(&context, &imfs, &project)
|
||||
.expect("Could not generate snapshot for snapshot test");
|
||||
|
||||
if let Some(snapshot) = snapshot.as_mut() {
|
||||
anonymize_snapshot(path, snapshot);
|
||||
}
|
||||
|
||||
match read_expected_snapshot(path) {
|
||||
Some(expected_snapshot) => assert_eq!(snapshot, expected_snapshot),
|
||||
None => write_expected_snapshot(path, &snapshot),
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,13 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::fs::{create_dir, copy};
|
||||
use std::path::Path;
|
||||
use std::io;
|
||||
|
||||
use rouille::Request;
|
||||
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use librojo::web::Server;
|
||||
|
||||
pub trait HttpTestUtil {
|
||||
fn get_string(&self, url: &str) -> String;
|
||||
}
|
||||
|
||||
impl HttpTestUtil for Server {
|
||||
fn get_string(&self, url: &str) -> String {
|
||||
let info_request = Request::fake_http("GET", url, vec![], vec![]);
|
||||
let response = self.handle_request(&info_request);
|
||||
|
||||
assert_eq!(response.status_code, 200);
|
||||
|
||||
let (mut reader, _) = response.data.into_reader_and_size();
|
||||
let mut body = String::new();
|
||||
reader.read_to_string(&mut body).unwrap();
|
||||
|
||||
body
|
||||
}
|
||||
}
|
||||
pub mod snapshot;
|
||||
pub mod tree;
|
||||
|
||||
pub fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> {
|
||||
for entry in WalkDir::new(from) {
|
||||
@@ -51,4 +33,4 @@ pub fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> {
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
79
server/tests/test_util/snapshot.rs
Normal file
79
server/tests/test_util/snapshot.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use librojo::{
|
||||
project::ProjectNode,
|
||||
snapshot_reconciler::RbxSnapshotInstance,
|
||||
rbx_session::MetadataPerInstance,
|
||||
};
|
||||
|
||||
const SNAPSHOT_EXPECTED_NAME: &str = "expected-snapshot.json";
|
||||
|
||||
/// Snapshots contain absolute paths, which simplifies much of Rojo.
|
||||
///
|
||||
/// For saving snapshots to the disk, we should strip off the project folder
|
||||
/// path to make them machine-independent. This doesn't work for paths that fall
|
||||
/// outside of the project folder, but that's okay here.
|
||||
///
|
||||
/// We also need to sort children, since Rojo tends to enumerate the filesystem
|
||||
/// in an unpredictable order.
|
||||
pub fn anonymize_snapshot(project_folder_path: &Path, snapshot: &mut RbxSnapshotInstance) {
|
||||
anonymize_metadata(project_folder_path, &mut snapshot.metadata);
|
||||
|
||||
snapshot.children.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
|
||||
for child in snapshot.children.iter_mut() {
|
||||
anonymize_snapshot(project_folder_path, child);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn anonymize_metadata(project_folder_path: &Path, metadata: &mut MetadataPerInstance) {
|
||||
match metadata.source_path.as_mut() {
|
||||
Some(path) => *path = anonymize_path(project_folder_path, path),
|
||||
None => {},
|
||||
}
|
||||
|
||||
match metadata.project_definition.as_mut() {
|
||||
Some((_, project_node)) => anonymize_project_node(project_folder_path, project_node),
|
||||
None => {},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn anonymize_project_node(project_folder_path: &Path, project_node: &mut ProjectNode) {
|
||||
match project_node.path.as_mut() {
|
||||
Some(path) => *path = anonymize_path(project_folder_path, path),
|
||||
None => {},
|
||||
}
|
||||
|
||||
for child_node in project_node.children.values_mut() {
|
||||
anonymize_project_node(project_folder_path, child_node);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn anonymize_path(project_folder_path: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.strip_prefix(project_folder_path)
|
||||
.expect("Could not anonymize absolute path")
|
||||
.to_path_buf()
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_expected_snapshot(path: &Path) -> Option<Option<RbxSnapshotInstance<'static>>> {
|
||||
let contents = fs::read(path.join(SNAPSHOT_EXPECTED_NAME)).ok()?;
|
||||
let snapshot: Option<RbxSnapshotInstance<'static>> = serde_json::from_slice(&contents)
|
||||
.expect("Could not deserialize snapshot");
|
||||
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
pub fn write_expected_snapshot(path: &Path, snapshot: &Option<RbxSnapshotInstance>) {
|
||||
let mut file = File::create(path.join(SNAPSHOT_EXPECTED_NAME))
|
||||
.expect("Could not open file to write snapshot");
|
||||
|
||||
serde_json::to_writer_pretty(&mut file, snapshot)
|
||||
.expect("Could not serialize snapshot to file");
|
||||
}
|
||||
351
server/tests/test_util/tree.rs
Normal file
351
server/tests/test_util/tree.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
//! Defines a mechanism to compare two RbxTree objects and generate a useful
|
||||
//! diff if they aren't the same. These methods ignore IDs, which are randomly
|
||||
//! generated whenever a tree is constructed anyways. This makes matching up
|
||||
//! pairs of instances that should be the same potentially difficult.
|
||||
//!
|
||||
//! It relies on a couple different ideas:
|
||||
//! - Instances with the same name and class name are matched as the same
|
||||
//! instance. See basic_equal for this logic
|
||||
//! - A path of period-delimited names (like Roblox's GetFullName) should be
|
||||
//! enough to debug most issues. If it isn't, we can do something fun like
|
||||
//! generate GraphViz graphs.
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
hash::Hash,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use log::error;
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use rbx_dom_weak::{RbxId, RbxTree};
|
||||
|
||||
use librojo::{
|
||||
rbx_session::MetadataPerInstance,
|
||||
live_session::LiveSession,
|
||||
visualize::{VisualizeRbxTree, graphviz_to_svg},
|
||||
};
|
||||
|
||||
use super::snapshot::anonymize_metadata;
|
||||
|
||||
/// Marks a 'step' in the test, which will snapshot the session's current
|
||||
/// RbxTree object and compare it against the saved snapshot if it exists.
|
||||
pub fn tree_step(step: &str, live_session: &LiveSession, source_path: &Path) {
|
||||
let rbx_session = live_session.rbx_session.lock().unwrap();
|
||||
let tree = rbx_session.get_tree();
|
||||
|
||||
let project_folder = live_session.root_project().folder_location();
|
||||
let metadata = rbx_session.get_all_instance_metadata()
|
||||
.iter()
|
||||
.map(|(key, meta)| {
|
||||
let mut meta = meta.clone();
|
||||
anonymize_metadata(project_folder, &mut meta);
|
||||
(*key, meta)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tree_with_metadata = TreeWithMetadata {
|
||||
tree: Cow::Borrowed(&tree),
|
||||
metadata: Cow::Owned(metadata),
|
||||
};
|
||||
|
||||
match read_tree_by_name(source_path, step) {
|
||||
Some(expected) => match trees_and_metadata_equal(&expected, &tree_with_metadata) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("Trees at step '{}' were not equal.\n{}", step, e);
|
||||
|
||||
let expected_gv = format!("{}", VisualizeRbxTree {
|
||||
tree: &expected.tree,
|
||||
metadata: &expected.metadata,
|
||||
});
|
||||
|
||||
let actual_gv = format!("{}", VisualizeRbxTree {
|
||||
tree: &tree_with_metadata.tree,
|
||||
metadata: &tree_with_metadata.metadata,
|
||||
});
|
||||
|
||||
let output_dir = PathBuf::from("failed-snapshots");
|
||||
fs::create_dir_all(&output_dir)
|
||||
.expect("Could not create failed-snapshots directory");
|
||||
|
||||
let expected_basename = format!("{}-{}-expected", live_session.root_project().name, step);
|
||||
let actual_basename = format!("{}-{}-actual", live_session.root_project().name, step);
|
||||
|
||||
let mut expected_out = output_dir.join(expected_basename);
|
||||
let mut actual_out = output_dir.join(actual_basename);
|
||||
|
||||
match (graphviz_to_svg(&expected_gv), graphviz_to_svg(&actual_gv)) {
|
||||
(Some(expected_svg), Some(actual_svg)) => {
|
||||
expected_out.set_extension("svg");
|
||||
actual_out.set_extension("svg");
|
||||
|
||||
fs::write(&expected_out, expected_svg)
|
||||
.expect("Couldn't write expected SVG");
|
||||
|
||||
fs::write(&actual_out, actual_svg)
|
||||
.expect("Couldn't write actual SVG");
|
||||
}
|
||||
_ => {
|
||||
expected_out.set_extension("gv");
|
||||
actual_out.set_extension("gv");
|
||||
|
||||
fs::write(&expected_out, expected_gv)
|
||||
.expect("Couldn't write expected GV");
|
||||
|
||||
fs::write(&actual_out, actual_gv)
|
||||
.expect("Couldn't write actual GV");
|
||||
}
|
||||
}
|
||||
|
||||
error!("Output at {} and {}", expected_out.display(), actual_out.display());
|
||||
|
||||
panic!("Tree mismatch at step '{}'", step);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
write_tree_by_name(source_path, step, &tree_with_metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_cow_map<K: Clone + Eq + Hash, V: Clone>() -> Cow<'static, HashMap<K, V>> {
|
||||
Cow::Owned(HashMap::new())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TreeWithMetadata<'a> {
|
||||
#[serde(flatten)]
|
||||
pub tree: Cow<'a, RbxTree>,
|
||||
|
||||
#[serde(default = "new_cow_map")]
|
||||
pub metadata: Cow<'a, HashMap<RbxId, MetadataPerInstance>>,
|
||||
}
|
||||
|
||||
fn read_tree_by_name(path: &Path, identifier: &str) -> Option<TreeWithMetadata<'static>> {
|
||||
let mut file_path = path.join(identifier);
|
||||
file_path.set_extension("tree.json");
|
||||
|
||||
let contents = fs::read(&file_path).ok()?;
|
||||
let tree: TreeWithMetadata = serde_json::from_slice(&contents)
|
||||
.expect("Could not deserialize tree");
|
||||
|
||||
Some(tree)
|
||||
}
|
||||
|
||||
fn write_tree_by_name(path: &Path, identifier: &str, tree: &TreeWithMetadata) {
|
||||
let mut file_path = path.join(identifier);
|
||||
file_path.set_extension("tree.json");
|
||||
|
||||
let mut file = File::create(file_path)
|
||||
.expect("Could not open file to write tree");
|
||||
|
||||
serde_json::to_writer_pretty(&mut file, tree)
|
||||
.expect("Could not serialize tree to file");
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TreeMismatch {
|
||||
pub path: Cow<'static, str>,
|
||||
pub detail: Cow<'static, str>,
|
||||
}
|
||||
|
||||
impl TreeMismatch {
|
||||
pub fn new<'a, A: Into<Cow<'a, str>>, B: Into<Cow<'a, str>>>(path: A, detail: B) -> TreeMismatch {
|
||||
TreeMismatch {
|
||||
path: Cow::Owned(path.into().into_owned()),
|
||||
detail: Cow::Owned(detail.into().into_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_parent(mut self, name: &str) -> TreeMismatch {
|
||||
self.path.to_mut().insert(0, '.');
|
||||
self.path.to_mut().insert_str(0, name);
|
||||
|
||||
TreeMismatch {
|
||||
path: self.path,
|
||||
detail: self.detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TreeMismatch {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
writeln!(formatter, "Tree mismatch at path {}", self.path)?;
|
||||
writeln!(formatter, "{}", self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trees_equal(
|
||||
left_tree: &RbxTree,
|
||||
right_tree: &RbxTree,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left = TreeWithMetadata {
|
||||
tree: Cow::Borrowed(left_tree),
|
||||
metadata: Cow::Owned(HashMap::new()),
|
||||
};
|
||||
|
||||
let right = TreeWithMetadata {
|
||||
tree: Cow::Borrowed(right_tree),
|
||||
metadata: Cow::Owned(HashMap::new()),
|
||||
};
|
||||
|
||||
trees_and_metadata_equal(&left, &right)
|
||||
}
|
||||
|
||||
fn trees_and_metadata_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
right_tree: &TreeWithMetadata,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left_id = left_tree.tree.get_root_id();
|
||||
let right_id = right_tree.tree.get_root_id();
|
||||
|
||||
instances_equal(left_tree, left_id, right_tree, right_id)
|
||||
}
|
||||
|
||||
fn instances_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
left_id: RbxId,
|
||||
right_tree: &TreeWithMetadata,
|
||||
right_id: RbxId,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
basic_equal(left_tree, left_id, right_tree, right_id)?;
|
||||
properties_equal(left_tree, left_id, right_tree, right_id)?;
|
||||
children_equal(left_tree, left_id, right_tree, right_id)?;
|
||||
metadata_equal(left_tree, left_id, right_tree, right_id)
|
||||
}
|
||||
|
||||
fn basic_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
left_id: RbxId,
|
||||
right_tree: &TreeWithMetadata,
|
||||
right_id: RbxId,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left_instance = left_tree.tree.get_instance(left_id)
|
||||
.expect("ID did not exist in left tree");
|
||||
|
||||
let right_instance = right_tree.tree.get_instance(right_id)
|
||||
.expect("ID did not exist in right tree");
|
||||
|
||||
if left_instance.name != right_instance.name {
|
||||
let message = format!("Name did not match ('{}' vs '{}')", left_instance.name, right_instance.name);
|
||||
|
||||
return Err(TreeMismatch::new(&left_instance.name, message));
|
||||
}
|
||||
|
||||
if left_instance.class_name != right_instance.class_name {
|
||||
let message = format!("Class name did not match ('{}' vs '{}')", left_instance.class_name, right_instance.class_name);
|
||||
|
||||
return Err(TreeMismatch::new(&left_instance.name, message));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn properties_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
left_id: RbxId,
|
||||
right_tree: &TreeWithMetadata,
|
||||
right_id: RbxId,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left_instance = left_tree.tree.get_instance(left_id)
|
||||
.expect("ID did not exist in left tree");
|
||||
|
||||
let right_instance = right_tree.tree.get_instance(right_id)
|
||||
.expect("ID did not exist in right tree");
|
||||
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
for (key, left_value) in &left_instance.properties {
|
||||
visited.insert(key);
|
||||
|
||||
let right_value = right_instance.properties.get(key);
|
||||
|
||||
if Some(left_value) != right_value {
|
||||
let message = format!(
|
||||
"Property {}:\n\tLeft: {:?}\n\tRight: {:?}",
|
||||
key,
|
||||
Some(left_value),
|
||||
right_value,
|
||||
);
|
||||
|
||||
return Err(TreeMismatch::new(&left_instance.name, message));
|
||||
}
|
||||
}
|
||||
|
||||
for (key, right_value) in &right_instance.properties {
|
||||
if visited.contains(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let left_value = left_instance.properties.get(key);
|
||||
|
||||
if left_value != Some(right_value) {
|
||||
let message = format!(
|
||||
"Property {}:\n\tLeft: {:?}\n\tRight: {:?}",
|
||||
key,
|
||||
left_value,
|
||||
Some(right_value),
|
||||
);
|
||||
|
||||
return Err(TreeMismatch::new(&left_instance.name, message));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn children_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
left_id: RbxId,
|
||||
right_tree: &TreeWithMetadata,
|
||||
right_id: RbxId,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left_instance = left_tree.tree.get_instance(left_id)
|
||||
.expect("ID did not exist in left tree");
|
||||
|
||||
let right_instance = right_tree.tree.get_instance(right_id)
|
||||
.expect("ID did not exist in right tree");
|
||||
|
||||
let left_children = left_instance.get_children_ids();
|
||||
let right_children = right_instance.get_children_ids();
|
||||
|
||||
if left_children.len() != right_children.len() {
|
||||
return Err(TreeMismatch::new(&left_instance.name, "Instances had different numbers of children"));
|
||||
}
|
||||
|
||||
for (left_child_id, right_child_id) in left_children.iter().zip(right_children) {
|
||||
instances_equal(left_tree, *left_child_id, right_tree, *right_child_id)
|
||||
.map_err(|e| e.add_parent(&left_instance.name))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata_equal(
|
||||
left_tree: &TreeWithMetadata,
|
||||
left_id: RbxId,
|
||||
right_tree: &TreeWithMetadata,
|
||||
right_id: RbxId,
|
||||
) -> Result<(), TreeMismatch> {
|
||||
let left_meta = left_tree.metadata.get(&left_id);
|
||||
let right_meta = right_tree.metadata.get(&right_id);
|
||||
|
||||
if left_meta != right_meta {
|
||||
let left_instance = left_tree.tree.get_instance(left_id)
|
||||
.expect("Left instance didn't exist in tree");
|
||||
|
||||
let message = format!(
|
||||
"Metadata mismatch:\n\tLeft: {:?}\n\tRight: {:?}",
|
||||
left_meta,
|
||||
right_meta,
|
||||
);
|
||||
|
||||
return Err(TreeMismatch::new(&left_instance.name, message));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
68
server/tests/tree_snapshots.rs
Normal file
68
server/tests/tree_snapshots.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
mod test_util;
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tempfile::{tempdir, TempDir};
|
||||
|
||||
use librojo::{
|
||||
live_session::LiveSession,
|
||||
project::Project,
|
||||
};
|
||||
|
||||
use crate::test_util::{
|
||||
copy_recursive,
|
||||
tree::tree_step,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn multi_partition_game() {
|
||||
let _ = env_logger::try_init();
|
||||
let source_path = project_path("multi_partition_game");
|
||||
|
||||
let (dir, live_session) = start_session(&source_path);
|
||||
tree_step("initial", &live_session, &source_path);
|
||||
|
||||
let added_path = dir.path().join("a/added");
|
||||
fs::create_dir_all(&added_path)
|
||||
.expect("Couldn't create directory");
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
|
||||
tree_step("with_dir", &live_session, &source_path);
|
||||
|
||||
let moved_path = dir.path().join("b/added");
|
||||
fs::rename(&added_path, &moved_path)
|
||||
.expect("Couldn't rename directory");
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
|
||||
tree_step("with_moved_dir", &live_session, &source_path);
|
||||
}
|
||||
|
||||
/// Find the path to the given test project relative to the manifest.
|
||||
fn project_path(name: &str) -> PathBuf {
|
||||
let mut path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../test-projects");
|
||||
path.push(name);
|
||||
path
|
||||
}
|
||||
|
||||
/// Starts a new LiveSession for the project located at the given file path.
|
||||
fn start_session(source_path: &Path) -> (TempDir, LiveSession) {
|
||||
let dir = tempdir()
|
||||
.expect("Couldn't create temporary directory");
|
||||
|
||||
copy_recursive(&source_path, dir.path())
|
||||
.expect("Couldn't copy project to temporary directory");
|
||||
|
||||
let project = Arc::new(Project::load_fuzzy(dir.path())
|
||||
.expect("Couldn't load project from temp directory"));
|
||||
|
||||
let live_session = LiveSession::new(Arc::clone(&project))
|
||||
.expect("Couldn't start live session");
|
||||
|
||||
(dir, live_session)
|
||||
}
|
||||
20
test-projects/empty/expected-snapshot.json
Normal file
20
test-projects/empty/expected-snapshot.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "empty",
|
||||
"class_name": "DataModel",
|
||||
"properties": {},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"empty",
|
||||
{
|
||||
"class_name": "DataModel",
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
6
test-projects/malformed-stuff/default.project.json
Normal file
6
test-projects/malformed-stuff/default.project.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "malformed-stuff",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
2
test-projects/malformed-stuff/src/bad-model.model.json
Normal file
2
test-projects/malformed-stuff/src/bad-model.model.json
Normal file
@@ -0,0 +1,2 @@
|
||||
ahhh this isn't a JSON model
|
||||
bamboozled again
|
||||
6
test-projects/missing_files/default.project.json
Normal file
6
test-projects/missing_files/default.project.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "missing-files",
|
||||
"tree": {
|
||||
"$path": "does-not-exist"
|
||||
}
|
||||
}
|
||||
1
test-projects/multi_partition_game/a/foo.txt
Normal file
1
test-projects/multi_partition_game/a/foo.txt
Normal file
@@ -0,0 +1 @@
|
||||
Hello world, from a/foo.txt
|
||||
1
test-projects/multi_partition_game/a/main.lua
Normal file
1
test-projects/multi_partition_game/a/main.lua
Normal file
@@ -0,0 +1 @@
|
||||
-- hello, from a/main.lua
|
||||
1
test-projects/multi_partition_game/b/something.lua
Normal file
1
test-projects/multi_partition_game/b/something.lua
Normal file
@@ -0,0 +1 @@
|
||||
-- b/something.lua
|
||||
21
test-projects/multi_partition_game/default.project.json
Normal file
21
test-projects/multi_partition_game/default.project.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "multi_partition_game",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"Ack": {
|
||||
"$path": "a"
|
||||
},
|
||||
"Bar": {
|
||||
"$path": "b"
|
||||
}
|
||||
},
|
||||
"HttpService": {
|
||||
"$className": "HttpService",
|
||||
"$properties": {
|
||||
"HttpEnabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
212
test-projects/multi_partition_game/expected-snapshot.json
Normal file
212
test-projects/multi_partition_game/expected-snapshot.json
Normal file
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"name": "multi_partition_game",
|
||||
"class_name": "DataModel",
|
||||
"properties": {},
|
||||
"children": [
|
||||
{
|
||||
"name": "HttpService",
|
||||
"class_name": "HttpService",
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"HttpService",
|
||||
{
|
||||
"class_name": "HttpService",
|
||||
"children": {},
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ReplicatedStorage",
|
||||
"class_name": "ReplicatedStorage",
|
||||
"properties": {},
|
||||
"children": [
|
||||
{
|
||||
"name": "Ack",
|
||||
"class_name": "Folder",
|
||||
"properties": {},
|
||||
"children": [
|
||||
{
|
||||
"name": "foo",
|
||||
"class_name": "StringValue",
|
||||
"properties": {
|
||||
"Value": {
|
||||
"Type": "String",
|
||||
"Value": "Hello world, from a/foo.txt"
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a/foo.txt",
|
||||
"project_definition": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "main",
|
||||
"class_name": "ModuleScript",
|
||||
"properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- hello, from a/main.lua"
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a/main.lua",
|
||||
"project_definition": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a",
|
||||
"project_definition": [
|
||||
"Ack",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Bar",
|
||||
"class_name": "Folder",
|
||||
"properties": {},
|
||||
"children": [
|
||||
{
|
||||
"name": "something",
|
||||
"class_name": "ModuleScript",
|
||||
"properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- b/something.lua"
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "b/something.lua",
|
||||
"project_definition": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "b",
|
||||
"project_definition": [
|
||||
"Bar",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"ReplicatedStorage",
|
||||
{
|
||||
"class_name": "ReplicatedStorage",
|
||||
"children": {
|
||||
"Bar": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
},
|
||||
"Ack": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"multi_partition_game",
|
||||
{
|
||||
"class_name": "DataModel",
|
||||
"children": {
|
||||
"ReplicatedStorage": {
|
||||
"class_name": "ReplicatedStorage",
|
||||
"children": {
|
||||
"Bar": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
},
|
||||
"Ack": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
},
|
||||
"HttpService": {
|
||||
"class_name": "HttpService",
|
||||
"children": {},
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
242
test-projects/multi_partition_game/initial.tree.json
Normal file
242
test-projects/multi_partition_game/initial.tree.json
Normal file
@@ -0,0 +1,242 @@
|
||||
{
|
||||
"instances": {
|
||||
"8d44bb30-db3c-4366-a6c5-633bd1441885": {
|
||||
"Name": "main",
|
||||
"ClassName": "ModuleScript",
|
||||
"Properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- hello, from a/main.lua"
|
||||
}
|
||||
},
|
||||
"Id": "8d44bb30-db3c-4366-a6c5-633bd1441885",
|
||||
"Children": [],
|
||||
"Parent": "1aafa29b-bdca-40a0-a677-7ead327b84ce"
|
||||
},
|
||||
"b1c9928c-bf11-427f-90eb-b672c811d859": {
|
||||
"Name": "Bar",
|
||||
"ClassName": "Folder",
|
||||
"Properties": {},
|
||||
"Id": "b1c9928c-bf11-427f-90eb-b672c811d859",
|
||||
"Children": [
|
||||
"8d690a2a-e987-4c86-b9ac-18e6d3a98503"
|
||||
],
|
||||
"Parent": "9f141826-14c2-492b-b360-2558712f0c08"
|
||||
},
|
||||
"645ba594-4482-441f-9f41-5bb9c444405b": {
|
||||
"Name": "multi_partition_game",
|
||||
"ClassName": "DataModel",
|
||||
"Properties": {},
|
||||
"Id": "645ba594-4482-441f-9f41-5bb9c444405b",
|
||||
"Children": [
|
||||
"b1298bcc-e370-44a6-9ef4-fbefa290124c",
|
||||
"9f141826-14c2-492b-b360-2558712f0c08"
|
||||
],
|
||||
"Parent": null
|
||||
},
|
||||
"9f141826-14c2-492b-b360-2558712f0c08": {
|
||||
"Name": "ReplicatedStorage",
|
||||
"ClassName": "ReplicatedStorage",
|
||||
"Properties": {},
|
||||
"Id": "9f141826-14c2-492b-b360-2558712f0c08",
|
||||
"Children": [
|
||||
"1aafa29b-bdca-40a0-a677-7ead327b84ce",
|
||||
"b1c9928c-bf11-427f-90eb-b672c811d859"
|
||||
],
|
||||
"Parent": "645ba594-4482-441f-9f41-5bb9c444405b"
|
||||
},
|
||||
"8d690a2a-e987-4c86-b9ac-18e6d3a98503": {
|
||||
"Name": "something",
|
||||
"ClassName": "ModuleScript",
|
||||
"Properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- b/something.lua"
|
||||
}
|
||||
},
|
||||
"Id": "8d690a2a-e987-4c86-b9ac-18e6d3a98503",
|
||||
"Children": [],
|
||||
"Parent": "b1c9928c-bf11-427f-90eb-b672c811d859"
|
||||
},
|
||||
"b1298bcc-e370-44a6-9ef4-fbefa290124c": {
|
||||
"Name": "HttpService",
|
||||
"ClassName": "HttpService",
|
||||
"Properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"Id": "b1298bcc-e370-44a6-9ef4-fbefa290124c",
|
||||
"Children": [],
|
||||
"Parent": "645ba594-4482-441f-9f41-5bb9c444405b"
|
||||
},
|
||||
"54f2f276-964f-4c60-87d8-5fb2209c97c9": {
|
||||
"Name": "foo",
|
||||
"ClassName": "StringValue",
|
||||
"Properties": {
|
||||
"Value": {
|
||||
"Type": "String",
|
||||
"Value": "Hello world, from a/foo.txt"
|
||||
}
|
||||
},
|
||||
"Id": "54f2f276-964f-4c60-87d8-5fb2209c97c9",
|
||||
"Children": [],
|
||||
"Parent": "1aafa29b-bdca-40a0-a677-7ead327b84ce"
|
||||
},
|
||||
"1aafa29b-bdca-40a0-a677-7ead327b84ce": {
|
||||
"Name": "Ack",
|
||||
"ClassName": "Folder",
|
||||
"Properties": {},
|
||||
"Id": "1aafa29b-bdca-40a0-a677-7ead327b84ce",
|
||||
"Children": [
|
||||
"54f2f276-964f-4c60-87d8-5fb2209c97c9",
|
||||
"8d44bb30-db3c-4366-a6c5-633bd1441885"
|
||||
],
|
||||
"Parent": "9f141826-14c2-492b-b360-2558712f0c08"
|
||||
}
|
||||
},
|
||||
"root_id": "645ba594-4482-441f-9f41-5bb9c444405b",
|
||||
"metadata": {
|
||||
"645ba594-4482-441f-9f41-5bb9c444405b": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"multi_partition_game",
|
||||
{
|
||||
"class_name": "DataModel",
|
||||
"children": {
|
||||
"HttpService": {
|
||||
"class_name": "HttpService",
|
||||
"children": {},
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
},
|
||||
"ReplicatedStorage": {
|
||||
"class_name": "ReplicatedStorage",
|
||||
"children": {
|
||||
"Ack": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
},
|
||||
"Bar": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"1aafa29b-bdca-40a0-a677-7ead327b84ce": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a",
|
||||
"project_definition": [
|
||||
"Ack",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
}
|
||||
]
|
||||
},
|
||||
"b1c9928c-bf11-427f-90eb-b672c811d859": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "b",
|
||||
"project_definition": [
|
||||
"Bar",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
}
|
||||
]
|
||||
},
|
||||
"54f2f276-964f-4c60-87d8-5fb2209c97c9": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a/foo.txt",
|
||||
"project_definition": null
|
||||
},
|
||||
"9f141826-14c2-492b-b360-2558712f0c08": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"ReplicatedStorage",
|
||||
{
|
||||
"class_name": "ReplicatedStorage",
|
||||
"children": {
|
||||
"Ack": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
},
|
||||
"Bar": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"8d44bb30-db3c-4366-a6c5-633bd1441885": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a/main.lua",
|
||||
"project_definition": null
|
||||
},
|
||||
"b1298bcc-e370-44a6-9ef4-fbefa290124c": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"HttpService",
|
||||
{
|
||||
"class_name": "HttpService",
|
||||
"children": {},
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"8d690a2a-e987-4c86-b9ac-18e6d3a98503": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "b/something.lua",
|
||||
"project_definition": null
|
||||
}
|
||||
}
|
||||
}
|
||||
256
test-projects/multi_partition_game/with_dir.tree.json
Normal file
256
test-projects/multi_partition_game/with_dir.tree.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"instances": {
|
||||
"8d44bb30-db3c-4366-a6c5-633bd1441885": {
|
||||
"Name": "main",
|
||||
"ClassName": "ModuleScript",
|
||||
"Properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- hello, from a/main.lua"
|
||||
}
|
||||
},
|
||||
"Id": "8d44bb30-db3c-4366-a6c5-633bd1441885",
|
||||
"Children": [],
|
||||
"Parent": "1aafa29b-bdca-40a0-a677-7ead327b84ce"
|
||||
},
|
||||
"b1c9928c-bf11-427f-90eb-b672c811d859": {
|
||||
"Name": "Bar",
|
||||
"ClassName": "Folder",
|
||||
"Properties": {},
|
||||
"Id": "b1c9928c-bf11-427f-90eb-b672c811d859",
|
||||
"Children": [
|
||||
"8d690a2a-e987-4c86-b9ac-18e6d3a98503"
|
||||
],
|
||||
"Parent": "9f141826-14c2-492b-b360-2558712f0c08"
|
||||
},
|
||||
"645ba594-4482-441f-9f41-5bb9c444405b": {
|
||||
"Name": "multi_partition_game",
|
||||
"ClassName": "DataModel",
|
||||
"Properties": {},
|
||||
"Id": "645ba594-4482-441f-9f41-5bb9c444405b",
|
||||
"Children": [
|
||||
"b1298bcc-e370-44a6-9ef4-fbefa290124c",
|
||||
"9f141826-14c2-492b-b360-2558712f0c08"
|
||||
],
|
||||
"Parent": null
|
||||
},
|
||||
"9f141826-14c2-492b-b360-2558712f0c08": {
|
||||
"Name": "ReplicatedStorage",
|
||||
"ClassName": "ReplicatedStorage",
|
||||
"Properties": {},
|
||||
"Id": "9f141826-14c2-492b-b360-2558712f0c08",
|
||||
"Children": [
|
||||
"1aafa29b-bdca-40a0-a677-7ead327b84ce",
|
||||
"b1c9928c-bf11-427f-90eb-b672c811d859"
|
||||
],
|
||||
"Parent": "645ba594-4482-441f-9f41-5bb9c444405b"
|
||||
},
|
||||
"8d690a2a-e987-4c86-b9ac-18e6d3a98503": {
|
||||
"Name": "something",
|
||||
"ClassName": "ModuleScript",
|
||||
"Properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- b/something.lua"
|
||||
}
|
||||
},
|
||||
"Id": "8d690a2a-e987-4c86-b9ac-18e6d3a98503",
|
||||
"Children": [],
|
||||
"Parent": "b1c9928c-bf11-427f-90eb-b672c811d859"
|
||||
},
|
||||
"b1298bcc-e370-44a6-9ef4-fbefa290124c": {
|
||||
"Name": "HttpService",
|
||||
"ClassName": "HttpService",
|
||||
"Properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"Id": "b1298bcc-e370-44a6-9ef4-fbefa290124c",
|
||||
"Children": [],
|
||||
"Parent": "645ba594-4482-441f-9f41-5bb9c444405b"
|
||||
},
|
||||
"46353305-8818-48fe-94fd-80cf0c5d974c": {
|
||||
"Name": "added",
|
||||
"ClassName": "Folder",
|
||||
"Properties": {},
|
||||
"Id": "46353305-8818-48fe-94fd-80cf0c5d974c",
|
||||
"Children": [],
|
||||
"Parent": "1aafa29b-bdca-40a0-a677-7ead327b84ce"
|
||||
},
|
||||
"54f2f276-964f-4c60-87d8-5fb2209c97c9": {
|
||||
"Name": "foo",
|
||||
"ClassName": "StringValue",
|
||||
"Properties": {
|
||||
"Value": {
|
||||
"Type": "String",
|
||||
"Value": "Hello world, from a/foo.txt"
|
||||
}
|
||||
},
|
||||
"Id": "54f2f276-964f-4c60-87d8-5fb2209c97c9",
|
||||
"Children": [],
|
||||
"Parent": "1aafa29b-bdca-40a0-a677-7ead327b84ce"
|
||||
},
|
||||
"1aafa29b-bdca-40a0-a677-7ead327b84ce": {
|
||||
"Name": "Ack",
|
||||
"ClassName": "Folder",
|
||||
"Properties": {},
|
||||
"Id": "1aafa29b-bdca-40a0-a677-7ead327b84ce",
|
||||
"Children": [
|
||||
"54f2f276-964f-4c60-87d8-5fb2209c97c9",
|
||||
"8d44bb30-db3c-4366-a6c5-633bd1441885",
|
||||
"46353305-8818-48fe-94fd-80cf0c5d974c"
|
||||
],
|
||||
"Parent": "9f141826-14c2-492b-b360-2558712f0c08"
|
||||
}
|
||||
},
|
||||
"root_id": "645ba594-4482-441f-9f41-5bb9c444405b",
|
||||
"metadata": {
|
||||
"b1c9928c-bf11-427f-90eb-b672c811d859": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "b",
|
||||
"project_definition": [
|
||||
"Bar",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
}
|
||||
]
|
||||
},
|
||||
"54f2f276-964f-4c60-87d8-5fb2209c97c9": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a/foo.txt",
|
||||
"project_definition": null
|
||||
},
|
||||
"8d44bb30-db3c-4366-a6c5-633bd1441885": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a/main.lua",
|
||||
"project_definition": null
|
||||
},
|
||||
"9f141826-14c2-492b-b360-2558712f0c08": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"ReplicatedStorage",
|
||||
{
|
||||
"class_name": "ReplicatedStorage",
|
||||
"children": {
|
||||
"Ack": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
},
|
||||
"Bar": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"b1298bcc-e370-44a6-9ef4-fbefa290124c": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"HttpService",
|
||||
{
|
||||
"class_name": "HttpService",
|
||||
"children": {},
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"46353305-8818-48fe-94fd-80cf0c5d974c": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a/added",
|
||||
"project_definition": null
|
||||
},
|
||||
"1aafa29b-bdca-40a0-a677-7ead327b84ce": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a",
|
||||
"project_definition": [
|
||||
"Ack",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
}
|
||||
]
|
||||
},
|
||||
"8d690a2a-e987-4c86-b9ac-18e6d3a98503": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "b/something.lua",
|
||||
"project_definition": null
|
||||
},
|
||||
"645ba594-4482-441f-9f41-5bb9c444405b": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"multi_partition_game",
|
||||
{
|
||||
"class_name": "DataModel",
|
||||
"children": {
|
||||
"HttpService": {
|
||||
"class_name": "HttpService",
|
||||
"children": {},
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
},
|
||||
"ReplicatedStorage": {
|
||||
"class_name": "ReplicatedStorage",
|
||||
"children": {
|
||||
"Ack": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
},
|
||||
"Bar": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
256
test-projects/multi_partition_game/with_moved_dir.tree.json
Normal file
256
test-projects/multi_partition_game/with_moved_dir.tree.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"instances": {
|
||||
"8d44bb30-db3c-4366-a6c5-633bd1441885": {
|
||||
"Name": "main",
|
||||
"ClassName": "ModuleScript",
|
||||
"Properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- hello, from a/main.lua"
|
||||
}
|
||||
},
|
||||
"Id": "8d44bb30-db3c-4366-a6c5-633bd1441885",
|
||||
"Children": [],
|
||||
"Parent": "1aafa29b-bdca-40a0-a677-7ead327b84ce"
|
||||
},
|
||||
"b1c9928c-bf11-427f-90eb-b672c811d859": {
|
||||
"Name": "Bar",
|
||||
"ClassName": "Folder",
|
||||
"Properties": {},
|
||||
"Id": "b1c9928c-bf11-427f-90eb-b672c811d859",
|
||||
"Children": [
|
||||
"8d690a2a-e987-4c86-b9ac-18e6d3a98503",
|
||||
"a8566e76-0495-45a3-a713-1c59ab39453b"
|
||||
],
|
||||
"Parent": "9f141826-14c2-492b-b360-2558712f0c08"
|
||||
},
|
||||
"645ba594-4482-441f-9f41-5bb9c444405b": {
|
||||
"Name": "multi_partition_game",
|
||||
"ClassName": "DataModel",
|
||||
"Properties": {},
|
||||
"Id": "645ba594-4482-441f-9f41-5bb9c444405b",
|
||||
"Children": [
|
||||
"b1298bcc-e370-44a6-9ef4-fbefa290124c",
|
||||
"9f141826-14c2-492b-b360-2558712f0c08"
|
||||
],
|
||||
"Parent": null
|
||||
},
|
||||
"9f141826-14c2-492b-b360-2558712f0c08": {
|
||||
"Name": "ReplicatedStorage",
|
||||
"ClassName": "ReplicatedStorage",
|
||||
"Properties": {},
|
||||
"Id": "9f141826-14c2-492b-b360-2558712f0c08",
|
||||
"Children": [
|
||||
"1aafa29b-bdca-40a0-a677-7ead327b84ce",
|
||||
"b1c9928c-bf11-427f-90eb-b672c811d859"
|
||||
],
|
||||
"Parent": "645ba594-4482-441f-9f41-5bb9c444405b"
|
||||
},
|
||||
"8d690a2a-e987-4c86-b9ac-18e6d3a98503": {
|
||||
"Name": "something",
|
||||
"ClassName": "ModuleScript",
|
||||
"Properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- b/something.lua"
|
||||
}
|
||||
},
|
||||
"Id": "8d690a2a-e987-4c86-b9ac-18e6d3a98503",
|
||||
"Children": [],
|
||||
"Parent": "b1c9928c-bf11-427f-90eb-b672c811d859"
|
||||
},
|
||||
"b1298bcc-e370-44a6-9ef4-fbefa290124c": {
|
||||
"Name": "HttpService",
|
||||
"ClassName": "HttpService",
|
||||
"Properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"Id": "b1298bcc-e370-44a6-9ef4-fbefa290124c",
|
||||
"Children": [],
|
||||
"Parent": "645ba594-4482-441f-9f41-5bb9c444405b"
|
||||
},
|
||||
"54f2f276-964f-4c60-87d8-5fb2209c97c9": {
|
||||
"Name": "foo",
|
||||
"ClassName": "StringValue",
|
||||
"Properties": {
|
||||
"Value": {
|
||||
"Type": "String",
|
||||
"Value": "Hello world, from a/foo.txt"
|
||||
}
|
||||
},
|
||||
"Id": "54f2f276-964f-4c60-87d8-5fb2209c97c9",
|
||||
"Children": [],
|
||||
"Parent": "1aafa29b-bdca-40a0-a677-7ead327b84ce"
|
||||
},
|
||||
"a8566e76-0495-45a3-a713-1c59ab39453b": {
|
||||
"Name": "added",
|
||||
"ClassName": "Folder",
|
||||
"Properties": {},
|
||||
"Id": "a8566e76-0495-45a3-a713-1c59ab39453b",
|
||||
"Children": [],
|
||||
"Parent": "b1c9928c-bf11-427f-90eb-b672c811d859"
|
||||
},
|
||||
"1aafa29b-bdca-40a0-a677-7ead327b84ce": {
|
||||
"Name": "Ack",
|
||||
"ClassName": "Folder",
|
||||
"Properties": {},
|
||||
"Id": "1aafa29b-bdca-40a0-a677-7ead327b84ce",
|
||||
"Children": [
|
||||
"54f2f276-964f-4c60-87d8-5fb2209c97c9",
|
||||
"8d44bb30-db3c-4366-a6c5-633bd1441885"
|
||||
],
|
||||
"Parent": "9f141826-14c2-492b-b360-2558712f0c08"
|
||||
}
|
||||
},
|
||||
"root_id": "645ba594-4482-441f-9f41-5bb9c444405b",
|
||||
"metadata": {
|
||||
"645ba594-4482-441f-9f41-5bb9c444405b": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"multi_partition_game",
|
||||
{
|
||||
"class_name": "DataModel",
|
||||
"children": {
|
||||
"HttpService": {
|
||||
"class_name": "HttpService",
|
||||
"children": {},
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
},
|
||||
"ReplicatedStorage": {
|
||||
"class_name": "ReplicatedStorage",
|
||||
"children": {
|
||||
"Ack": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
},
|
||||
"Bar": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"1aafa29b-bdca-40a0-a677-7ead327b84ce": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a",
|
||||
"project_definition": [
|
||||
"Ack",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
}
|
||||
]
|
||||
},
|
||||
"a8566e76-0495-45a3-a713-1c59ab39453b": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "b/added",
|
||||
"project_definition": null
|
||||
},
|
||||
"8d690a2a-e987-4c86-b9ac-18e6d3a98503": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "b/something.lua",
|
||||
"project_definition": null
|
||||
},
|
||||
"8d44bb30-db3c-4366-a6c5-633bd1441885": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a/main.lua",
|
||||
"project_definition": null
|
||||
},
|
||||
"9f141826-14c2-492b-b360-2558712f0c08": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"ReplicatedStorage",
|
||||
{
|
||||
"class_name": "ReplicatedStorage",
|
||||
"children": {
|
||||
"Ack": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "a"
|
||||
},
|
||||
"Bar": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"b1c9928c-bf11-427f-90eb-b672c811d859": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "b",
|
||||
"project_definition": [
|
||||
"Bar",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "b"
|
||||
}
|
||||
]
|
||||
},
|
||||
"54f2f276-964f-4c60-87d8-5fb2209c97c9": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "a/foo.txt",
|
||||
"project_definition": null
|
||||
},
|
||||
"b1298bcc-e370-44a6-9ef4-fbefa290124c": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"HttpService",
|
||||
{
|
||||
"class_name": "HttpService",
|
||||
"children": {},
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
9
test-projects/nested_partitions/default.project.json
Normal file
9
test-projects/nested_partitions/default.project.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "nested-partitions",
|
||||
"tree": {
|
||||
"$path": "outer",
|
||||
"inner": {
|
||||
"$path": "inner"
|
||||
}
|
||||
}
|
||||
}
|
||||
82
test-projects/nested_partitions/expected-snapshot.json
Normal file
82
test-projects/nested_partitions/expected-snapshot.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "nested-partitions",
|
||||
"class_name": "Folder",
|
||||
"properties": {},
|
||||
"children": [
|
||||
{
|
||||
"name": "inner",
|
||||
"class_name": "Folder",
|
||||
"properties": {},
|
||||
"children": [
|
||||
{
|
||||
"name": "hello",
|
||||
"class_name": "ModuleScript",
|
||||
"properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- inner/hello.lua"
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "inner/hello.lua",
|
||||
"project_definition": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "inner",
|
||||
"project_definition": [
|
||||
"inner",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "inner"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "world",
|
||||
"class_name": "ModuleScript",
|
||||
"properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- outer/world.lua"
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "outer/world.lua",
|
||||
"project_definition": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "outer",
|
||||
"project_definition": [
|
||||
"nested-partitions",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {
|
||||
"inner": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "inner"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "outer"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1
test-projects/nested_partitions/inner/hello.lua
Normal file
1
test-projects/nested_partitions/inner/hello.lua
Normal file
@@ -0,0 +1 @@
|
||||
-- inner/hello.lua
|
||||
1
test-projects/nested_partitions/outer/world.lua
Normal file
1
test-projects/nested_partitions/outer/world.lua
Normal file
@@ -0,0 +1 @@
|
||||
-- outer/world.lua
|
||||
@@ -1,6 +0,0 @@
|
||||
Key,Context,Example,Source,es-es,de
|
||||
,ClickableGroup:BuilderGui:TextLabel,You got 22 hearts!,You got {1} hearts!,,
|
||||
,,"Team ""Red"" wins!","Team ""{1}"" wins!","¡Gana el equipo ""{1}""!","¡Gana el equipo ""{1}""!"
|
||||
,Frame:TextLabel,,"{1} killed {2}, with a {3}","{1} mató a {2} con
|
||||
una escopeta","{1} mató a {2} con
|
||||
una escopeta"
|
||||
|
@@ -11,10 +11,7 @@
|
||||
"HttpService": {
|
||||
"$className": "HttpService",
|
||||
"$properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
"HttpEnabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
161
test-projects/single_partition_game/expected-snapshot.json
Normal file
161
test-projects/single_partition_game/expected-snapshot.json
Normal file
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"name": "single-sync-point",
|
||||
"class_name": "DataModel",
|
||||
"properties": {},
|
||||
"children": [
|
||||
{
|
||||
"name": "HttpService",
|
||||
"class_name": "HttpService",
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"HttpService",
|
||||
{
|
||||
"class_name": "HttpService",
|
||||
"children": {},
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ReplicatedStorage",
|
||||
"class_name": "ReplicatedStorage",
|
||||
"properties": {},
|
||||
"children": [
|
||||
{
|
||||
"name": "Foo",
|
||||
"class_name": "Folder",
|
||||
"properties": {},
|
||||
"children": [
|
||||
{
|
||||
"name": "foo",
|
||||
"class_name": "StringValue",
|
||||
"properties": {
|
||||
"Value": {
|
||||
"Type": "String",
|
||||
"Value": "Hello world, from foo.txt"
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "lib/foo.txt",
|
||||
"project_definition": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "main",
|
||||
"class_name": "ModuleScript",
|
||||
"properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "-- hello, from main"
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "lib/main.lua",
|
||||
"project_definition": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "lib",
|
||||
"project_definition": [
|
||||
"Foo",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "lib"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"ReplicatedStorage",
|
||||
{
|
||||
"class_name": "ReplicatedStorage",
|
||||
"children": {
|
||||
"Foo": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "lib"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": true,
|
||||
"source_path": null,
|
||||
"project_definition": [
|
||||
"single-sync-point",
|
||||
{
|
||||
"class_name": "DataModel",
|
||||
"children": {
|
||||
"HttpService": {
|
||||
"class_name": "HttpService",
|
||||
"children": {},
|
||||
"properties": {
|
||||
"HttpEnabled": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
}
|
||||
},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
},
|
||||
"ReplicatedStorage": {
|
||||
"class_name": "ReplicatedStorage",
|
||||
"children": {
|
||||
"Foo": {
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "lib"
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
53
test-projects/single_partition_model/expected-snapshot.json
Normal file
53
test-projects/single_partition_model/expected-snapshot.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "test-model",
|
||||
"class_name": "Folder",
|
||||
"properties": {},
|
||||
"children": [
|
||||
{
|
||||
"name": "main",
|
||||
"class_name": "Script",
|
||||
"properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "local other = require(script.Parent.other)\n\nprint(other)"
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "src/main.server.lua",
|
||||
"project_definition": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "other",
|
||||
"class_name": "ModuleScript",
|
||||
"properties": {
|
||||
"Source": {
|
||||
"Type": "String",
|
||||
"Value": "return \"Hello, world!\""
|
||||
}
|
||||
},
|
||||
"children": [],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "src/other.lua",
|
||||
"project_definition": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"ignore_unknown_instances": false,
|
||||
"source_path": "src",
|
||||
"project_definition": [
|
||||
"test-model",
|
||||
{
|
||||
"class_name": null,
|
||||
"children": {},
|
||||
"properties": {},
|
||||
"ignore_unknown_instances": null,
|
||||
"path": "src"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user