Compare commits

...

47 Commits

Author SHA1 Message Date
Lucien Greathouse
def99a9e4d Update release job to fix 6.0.0 release 2020-03-29 17:09:52 -07:00
Lucien Greathouse
1214fc8b0d Release 6.0.0-rc.1
This change also includes some minor packaging changes in order to make Cargo happy.
2020-03-29 16:58:37 -07:00
Lucien Greathouse
5a5b1268d3 Update changelog 2020-03-29 16:03:58 -07:00
jeparlefrancais
6a1fffd1ce Infer class name (#210)
* infer service names

* Update project code and add support for StarterPlayer

* Store parent_class in InstigatingSource

* Update snapshots

Co-authored-by: Lucien Greathouse <me@lpghatguy.com>
2020-03-29 16:03:15 -07:00
Lucien Greathouse
571ef3060a Update Changelog 2020-03-29 13:46:27 -07:00
jeparlefrancais
3cf82e112f Install plugin from CLI (#304)
* add install command

* cargo fmt

* filter spec files

* Update src/cli/plugin.rs

Co-Authored-By: Lucien Greathouse <me@lpghatguy.com>

* Update src/cli/plugin.rs

Co-Authored-By: Lucien Greathouse <me@lpghatguy.com>

* fix comments

* encode plugin with rbx_binary

* update build script

* refactor pathbuf error into io error

* fix rojo typo

* remove snafu

* Update `snapshot_from_fs_path`

* Print `rerun-if-changed` even for directories, in order to run the
  build.rs script when files are added.

* Switch `filter_map` loop to a regular for loop. I like the FP-style
  iterator stuff in Rust, but I think Result handling is easier in a
  normal loop. Also, I don't believe the result of read_dir implements
  `ExactSizedIterator`, so some of the wins of map+collect aren't there.

* Replace Result::unwrap with ? in build.rs

* Simplify error handling code in runtime

* Checkout with submodules

Co-authored-by: Lucien Greathouse <me@lpghatguy.com>
2020-03-29 13:41:54 -07:00
Lucien Greathouse
9b459c20d6 Fix GHA only running on pushes to master 2020-03-29 13:17:06 -07:00
Lucien Greathouse
5c85cd27c3 Fix default place project.
Closes #311.
2020-03-29 12:21:55 -07:00
Lucien Greathouse
4bf73c7a8a Implement support for turning .json files into Lua modules (#308)
* Stub implementation

* Flesh out feature and add tests. Other snapshots currently failing.

* Blacklist .meta.json in JSON handler

* Write to correct property (Source) instead of Value

* Update changelog
2020-03-28 00:36:01 -07:00
Lucien Greathouse
62e51b7535 Upgrade to latest rbx-dom 2020-03-27 23:58:31 -07:00
Lucien Greathouse
729a7f0053 Turn panics into errors in ServeSession 2020-03-26 12:16:55 -07:00
Lucien Greathouse
03c297190d Make ServeSession::new fallible 2020-03-26 12:07:44 -07:00
Lucien Greathouse
9c790eddd7 Tidy up ServeSession now that trait bounds are gone 2020-03-26 12:06:16 -07:00
Lucien Greathouse
8ebe7e332b Update Changelog 2020-03-25 17:02:32 -07:00
Lucien Greathouse
f43777e37e Require a Rojo project again (#307) 2020-03-25 17:01:28 -07:00
Lucien Greathouse
691a8fcdeb Upgrade lockfile using latest rustc 2020-03-25 16:15:21 -07:00
Lucien Greathouse
69c0e8d70e Fix warnings 2020-03-21 17:49:56 -07:00
Lucien Greathouse
330c92c9a8 Refactor ChangeProcessor loop to get rid of panics 2020-03-21 17:47:25 -07:00
Lucien Greathouse
cf0ff60d31 plugin: Add simple signal implementation for future work 2020-03-18 23:31:22 -07:00
Lucien Greathouse
9e9cf5dd1f plugin: Add support for pausing updates tracked by InstanceMap 2020-03-18 23:27:30 -07:00
Lucien Greathouse
5768d8e4a4 plugin: Miscellaneous cleanup 2020-03-18 23:15:03 -07:00
Lucien Greathouse
3b433e53be Memofs v0.1.1 2020-03-18 18:35:44 -07:00
Lucien Greathouse
28ddf40344 memofs: Update fs-err and use it more 2020-03-18 18:06:58 -07:00
Lucien Greathouse
c1286db9c1 Update Changelog 2020-03-18 16:26:41 -07:00
Lucien Greathouse
f13940262e Update CHANGELOG 2020-03-18 12:03:50 -07:00
Lucien Greathouse
9f0a6101b8 Add configurable color options 2020-03-18 12:03:07 -07:00
Lucien Greathouse
0b0fe01a7c Tidy up root repository files 2020-03-18 11:40:12 -07:00
Lucien Greathouse
85e098d5c8 Update README 2020-03-18 11:36:50 -07:00
Lucien Greathouse
e8d1faf4e2 Update changelog 2020-03-18 10:43:42 -07:00
Lucien Greathouse
2a46df1110 Expose two-way sync.
- Convert plugin DevSettings flag to settings panel feature
- Remove server feature, always enable write API
2020-03-18 10:39:40 -07:00
Lucien Greathouse
1601e6d26e Update changelog 2020-03-17 23:20:40 -07:00
Lucien Greathouse
0e4f6dea2b plugin: Add setting for opening scripts externally 2020-03-17 23:20:05 -07:00
Lucien Greathouse
a2356773dc Add checkbox and fill out settings panel 2020-03-17 23:14:32 -07:00
Lucien Greathouse
4a4da4737d Fix plugin settings persistent 2020-03-17 23:03:59 -07:00
Lucien Greathouse
2cefd1bf2e plugin: Add PluginSettings context item, render it in settings screen 2020-03-17 23:03:01 -07:00
Lucien Greathouse
c5ce15fe34 plugin: Add dummy settings panel 2020-03-17 22:38:53 -07:00
Lucien Greathouse
76dea568c9 Update changelog 2020-03-17 22:30:00 -07:00
Lucien Greathouse
8e81140eff Increase verbosity of logging 2020-03-17 22:29:23 -07:00
Lucien Greathouse
d58e1f0792 Add logging when running rojo build 2020-03-17 22:28:38 -07:00
Lucien Greathouse
830c242751 plugin: Stop using codename in dev mode 2020-03-17 22:25:04 -07:00
Lucien Greathouse
91d45afd0f Add plugin feature 'UnstableOpenScriptsExternally' 2020-03-17 18:13:52 -07:00
Lucien Greathouse
102c77b23e Implement /api/open/{id} to open a script by ID in your default editor 2020-03-17 17:50:54 -07:00
Lucien Greathouse
aa4039a2e7 bye snafu 2020-03-16 23:37:00 -07:00
Lucien Greathouse
c065ded440 Tidy up SnapshotError a lot 2020-03-16 21:35:46 -07:00
Lucien Greathouse
f69096dadb Use thiserror and anyhow for command-level error types 2020-03-16 21:13:38 -07:00
Lucien Greathouse
363f95ba14 memofs: Use fs_err instead of std::fs when possible 2020-03-16 20:35:58 -07:00
Lucien Greathouse
dcc15e8911 Refactor upload to use ServeSession and drop common_setup 2020-03-16 20:20:12 -07:00
118 changed files with 22791 additions and 2299 deletions

View File

@@ -1,6 +1,9 @@
name: CI name: CI
on: [push] on:
pull_request:
push:
branches: ["*"]
jobs: jobs:
build: build:
@@ -13,6 +16,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
with:
submodules: true
- name: Setup Rust toolchain - name: Setup Rust toolchain
run: rustup default ${{ matrix.rust_version }} run: rustup default ${{ matrix.rust_version }}

View File

@@ -10,6 +10,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
with:
submodules: true
- name: Build release binary - name: Build release binary
run: cargo build --verbose --locked --release run: cargo build --verbose --locked --release
@@ -25,6 +27,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
with:
submodules: true
- name: Install Rust - name: Install Rust
run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
@@ -47,6 +51,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
with:
submodules: true
- name: Build - name: Build
run: cargo build --locked --verbose --release run: cargo build --locked --verbose --release

5
.gitmodules vendored
View File

@@ -9,7 +9,4 @@
url = https://github.com/LPGhatguy/roblox-lua-promise.git url = https://github.com/LPGhatguy/roblox-lua-promise.git
[submodule "plugin/modules/t"] [submodule "plugin/modules/t"]
path = plugin/modules/t path = plugin/modules/t
url = https://github.com/osyrisrblx/t.git url = https://github.com/osyrisrblx/t.git
[submodule "plugin/modules/rbx-dom"]
path = plugin/modules/rbx-dom
url = http://github.com/rojo-rbx/rbx-dom

View File

@@ -1,6 +1,20 @@
# Rojo Changelog # Rojo Changelog
## Unreleased Changes for 0.6.x ## Unreleased Changes
## [6.0.0 Release Candidate 1](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.1) (March 29, 2020)
This release jumped from 0.6.0 to 6.0.0. Rojo has been in use in production for many users for quite a long times, and so 6.0 is a more accurate reflection of Rojo's version than a pre-1.0 version.
* Added basic settings panel to plugin, with two settings:
* "Open Scripts Externally": When enabled, opening a script in Studio will instead open it in your default text editor.
* "Two-Way Sync": When enabled, Rojo will attempt to save changes to your place back to the filesystem. **Very early feature, very broken, beware!**
* Added `--color` option to force-enable or force-disable color in Rojo's output.
* Added support for turning `.json` files into `ModuleScript` instances ([#308](https://github.com/rojo-rbx/rojo/pull/308))
* Added `rojo plugin install` and `rojo plugin uninstall` to allow Rojo to manage its Roblox Studio plugin. ([#304](https://github.com/rojo-rbx/rojo/pull/304))
* Class names no longer need to be specified for Roblox services in Rojo projects. ([#210](https://github.com/rojo-rbx/rojo/pull/210))
* The server half of **experimental** two-way sync is now enabled by default.
* Increased default logging verbosity in commands like `rojo build`.
* Rojo now requires a project file again, just like 0.5.4.
## [0.6.0 Alpha 3](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.3) (March 13, 2020) ## [0.6.0 Alpha 3](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.3) (March 13, 2020)
* Added `--watch` argument to `rojo build`. ([#284](https://github.com/rojo-rbx/rojo/pull/284)) * Added `--watch` argument to `rojo build`. ([#284](https://github.com/rojo-rbx/rojo/pull/284))

View File

@@ -33,15 +33,13 @@ Please file issues and we'll try to help figure out what the best way forward is
## Pushing a Rojo Release ## Pushing a Rojo Release
The Rojo release process is pretty manual right now. If you need to do it, here's how: The Rojo release process is pretty manual right now. If you need to do it, here's how:
1. Bump server version in [`server/Cargo.toml`](server/Cargo.toml) 1. Bump server version in [`Cargo.toml`](Cargo.toml)
2. Bump plugin version in [`plugin/src/Config.lua`](plugin/src/Config.lua) 2. Bump plugin version in [`plugin/src/Config.lua`](plugin/src/Config.lua)
3. Run `cargo test` to update `Cargo.lock` and double-check tests 3. Run `cargo test` to update `Cargo.lock` and double-check tests
4. Update [`CHANGELOG.md`](CHANGELOG.md) 4. Update [`CHANGELOG.md`](CHANGELOG.md)
5. Commit! 5. Commit!
* `git add . && git commit -m "Release vX.Y.Z"` * `git add . && git commit -m "Release vX.Y.Z"`
6. Tag the commit with the version from `Cargo.toml` prepended with a v, like `v0.4.13` 6. Tag the commit with the version from `Cargo.toml` prepended with a v, like `v0.4.13`
7. Build Windows release build of CLI
* `cargo build --release`
7. Publish the CLI 7. Publish the CLI
* `cargo publish` * `cargo publish`
8. Build and upload the plugin 8. Build and upload the plugin
@@ -52,4 +50,5 @@ The Rojo release process is pretty manual right now. If you need to do it, here'
10. Copy GitHub release content from previous release 10. Copy GitHub release content from previous release
* Update the leading text with a summary about the release * Update the leading text with a summary about the release
* Paste the changelog notes (as-is!) from [`CHANGELOG.md`](CHANGELOG.md) * Paste the changelog notes (as-is!) from [`CHANGELOG.md`](CHANGELOG.md)
* Write a small summary of each major feature * Write a small summary of each major feature
* Attach release artifacts from GitHub Actions for each platform

2236
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rojo" name = "rojo"
version = "0.6.0-alpha.3" version = "6.0.0-rc.1"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "Enables professional-grade development tools for Roblox developers" description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0" license = "MPL-2.0"
@@ -11,7 +11,6 @@ readme = "README.md"
edition = "2018" edition = "2018"
exclude = [ exclude = [
"/plugin/**",
"/test-projects/**", "/test-projects/**",
] ]
@@ -27,9 +26,6 @@ default = []
# Turn on support for specifying glob ignore path rules in the project format. # Turn on support for specifying glob ignore path rules in the project format.
unstable_glob_ignore_paths = [] unstable_glob_ignore_paths = []
# Turn on the server half of Rojo's unstable two-way sync feature.
unstable_two_way_sync = []
# Enable this feature to live-reload assets from the web UI. # Enable this feature to live-reload assets from the web UI.
dev_live_assets = [] dev_live_assets = []
@@ -61,12 +57,15 @@ name = "build"
harness = false harness = false
[dependencies] [dependencies]
memofs = { version = "0.1.0", path = "memofs" } memofs = { version = "0.1.2", path = "memofs" }
anyhow = "1.0.27"
backtrace = "0.3" backtrace = "0.3"
bincode = "1.2.1"
crossbeam-channel = "0.4.0" crossbeam-channel = "0.4.0"
csv = "1.1.1" csv = "1.1.1"
env_logger = "0.7.1" env_logger = "0.7.1"
fs-err = "2.2.0"
futures = "0.1.29" futures = "0.1.29"
globset = "0.4.4" globset = "0.4.4"
humantime = "1.3.0" humantime = "1.3.0"
@@ -85,17 +84,26 @@ regex = "1.3.1"
reqwest = "0.9.20" reqwest = "0.9.20"
ritz = "0.1.0" ritz = "0.1.0"
rlua = "0.17.0" rlua = "0.17.0"
roblox_install = "0.2.2"
serde = { version = "1.0", features = ["derive", "rc"] } serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0" serde_json = "1.0"
snafu = "0.6.0"
structopt = "0.3.5" structopt = "0.3.5"
termcolor = "1.0.5" termcolor = "1.0.5"
thiserror = "1.0.11"
tokio = "0.1.22" tokio = "0.1.22"
uuid = { version = "0.8.1", features = ["v4", "serde"] } uuid = { version = "0.8.1", features = ["v4", "serde"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.6.2" winreg = "0.6.2"
[build-dependencies]
memofs = { version = "0.1.0", path = "memofs" }
anyhow = "1.0.27"
bincode = "1.2.1"
fs-err = "2.3.0"
maplit = "1.0.1"
[dev-dependencies] [dev-dependencies]
rojo-insta-ext = { path = "rojo-insta-ext" } rojo-insta-ext = { path = "rojo-insta-ext" }

View File

@@ -13,8 +13,8 @@
<a href="https://crates.io/crates/rojo"> <a href="https://crates.io/crates/rojo">
<img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" /> <img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" />
</a> </a>
<a href="https://rojo.space/docs/0.5.x"> <a href="https://rojo.space/docs">
<img src="https://img.shields.io/badge/docs-0.5.x-brightgreen.svg" alt="Rojo 0.5.x Documentation" /> <img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo Documentation" />
</a> </a>
</div> </div>
@@ -34,11 +34,10 @@ Rojo enables:
* Streaming `rbxmx` and `rbxm` models into your game in real time * Streaming `rbxmx` and `rbxm` models into your game in real time
* Packaging and deploying your project to Roblox.com from the command line * Packaging and deploying your project to Roblox.com from the command line
Soon, Rojo will be able to: In the future, Rojo will be able to:
* Automatically convert your existing game to work with Rojo
* Sync instances from Roblox Studio to the filesystem * Sync instances from Roblox Studio to the filesystem
* Automatically manage your assets on Roblox.com, like images and sounds * Automatically convert your existing game to work with Rojo
* Import custom instances like MoonScript code * Import custom instances like MoonScript code
## [Documentation](https://rojo.space/docs) ## [Documentation](https://rojo.space/docs)

View File

@@ -7,7 +7,7 @@
"$className": "ReplicatedStorage", "$className": "ReplicatedStorage",
"Common": { "Common": {
"$path": "src/common" "$path": "src/shared"
} }
}, },

74
build.rs Normal file
View File

@@ -0,0 +1,74 @@
use std::{
env, io,
path::{Path, PathBuf},
};
use fs_err as fs;
use fs_err::File;
use maplit::hashmap;
use memofs::VfsSnapshot;
fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
println!("cargo:rerun-if-changed={}", path.display());
if path.is_dir() {
let mut children = Vec::new();
for entry in fs::read_dir(path)? {
let entry = entry?;
let file_name = entry.file_name().to_str().unwrap().to_owned();
// We can skip any TestEZ test files since they aren't necessary for
// the plugin to run.
if file_name.ends_with(".spec.lua") {
continue;
}
let child_snapshot = snapshot_from_fs_path(&entry.path())?;
children.push((file_name, child_snapshot));
}
Ok(VfsSnapshot::dir(children))
} else {
let content = fs::read_to_string(path)?;
Ok(VfsSnapshot::file(content))
}
}
fn main() -> Result<(), anyhow::Error> {
let out_dir = env::var_os("OUT_DIR").unwrap();
let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap();
let plugin_root = PathBuf::from(root_dir).join("plugin");
let plugin_modules = plugin_root.join("modules");
let snapshot = VfsSnapshot::dir(hashmap! {
"default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?,
"fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?,
"http" => snapshot_from_fs_path(&plugin_root.join("http"))?,
"log" => snapshot_from_fs_path(&plugin_root.join("log"))?,
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?,
"src" => snapshot_from_fs_path(&plugin_root.join("src"))?,
"modules" => VfsSnapshot::dir(hashmap! {
"roact" => VfsSnapshot::dir(hashmap! {
"src" => snapshot_from_fs_path(&plugin_modules.join("roact").join("src"))?
}),
"promise" => VfsSnapshot::dir(hashmap! {
"lib" => snapshot_from_fs_path(&plugin_modules.join("promise").join("lib"))?
}),
"t" => VfsSnapshot::dir(hashmap! {
"lib" => snapshot_from_fs_path(&plugin_modules.join("t").join("lib"))?
}),
}),
});
let out_path = Path::new(&out_dir).join("plugin.bincode");
let out_file = File::create(&out_path)?;
bincode::serialize_into(out_file, &snapshot)?;
Ok(())
}

View File

@@ -1,30 +0,0 @@
digraph Rojo {
concentrate = true;
node [fontname = "sans-serif"];
plugin [label="Roblox Studio Plugin"]
session [label="Session"]
rbx_tree [label="Instance Tree"]
imfs [label="In-Memory Filesystem"]
fs_impl [label="Filesystem Implementation\n(stubbed in tests)"]
fs [label="Real Filesystem"]
snapshot_subsystem [label="Snapshot Subsystem\n(reconciler)"]
snapshot_generator [label="Snapshot Generator"]
user_middleware [label="User Middleware\n(MoonScript, etc.)"]
builtin_middleware [label="Built-in Middleware\n(.lua, .rbxm, etc.)"]
api [label="Web API"]
file_watcher [label="File Watcher"]
session -> imfs
session -> rbx_tree
session -> snapshot_subsystem
session -> snapshot_generator
session -> file_watcher [dir="both"]
file_watcher -> imfs
snapshot_generator -> user_middleware
snapshot_generator -> builtin_middleware
plugin -> api [style="dotted"; dir="both"; minlen=2]
api -> session
imfs -> fs_impl
fs_impl -> fs
}

View File

@@ -2,5 +2,8 @@
## Unreleased Changes ## Unreleased Changes
## 0.1.1 (2020-03-18)
* Improved error messages using the [fs-err](https://crates.io/crates/fs-err) crate.
## 0.1.0 (2020-03-10) ## 0.1.0 (2020-03-10)
* Initial release * Initial release

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "memofs" name = "memofs"
description = "Virtual filesystem with configurable backends." description = "Virtual filesystem with configurable backends."
version = "0.1.0" version = "0.1.2"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018" edition = "2018"
readme = "README.md" readme = "README.md"
@@ -11,5 +11,7 @@ homepage = "https://github.com/rojo-rbx/rojo/tree/master/memofs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
notify = "4.0.15"
crossbeam-channel = "0.4.0" crossbeam-channel = "0.4.0"
fs-err = "2.3.0"
notify = "4.0.15"
serde = { version = "1.0", features = ["derive"] }

View File

@@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
/// A slice of a tree of files. Can be loaded into an /// A slice of a tree of files. Can be loaded into an
/// [`InMemoryFs`](struct.InMemoryFs.html). /// [`InMemoryFs`](struct.InMemoryFs.html).
#[derive(Debug)] #[derive(Debug, Serialize, Deserialize)]
#[non_exhaustive] #[non_exhaustive]
pub enum VfsSnapshot { pub enum VfsSnapshot {
File { File {

View File

@@ -1,4 +1,3 @@
use std::fs;
use std::io; use std::io;
use std::path::Path; use std::path::Path;
use std::sync::mpsc; use std::sync::mpsc;
@@ -55,15 +54,15 @@ impl StdBackend {
impl VfsBackend for StdBackend { impl VfsBackend for StdBackend {
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>> { fn read(&mut self, path: &Path) -> io::Result<Vec<u8>> {
fs::read(path) fs_err::read(path)
} }
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()> { fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()> {
fs::write(path, data) fs_err::write(path, data)
} }
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> { fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
let entries: Result<Vec<_>, _> = fs::read_dir(path)?.collect(); let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect();
let mut entries = entries?; let mut entries = entries?;
entries.sort_by_cached_key(|entry| entry.file_name()); entries.sort_by_cached_key(|entry| entry.file_name());
@@ -78,15 +77,15 @@ impl VfsBackend for StdBackend {
} }
fn remove_file(&mut self, path: &Path) -> io::Result<()> { fn remove_file(&mut self, path: &Path) -> io::Result<()> {
fs::remove_file(path) fs_err::remove_file(path)
} }
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()> { fn remove_dir_all(&mut self, path: &Path) -> io::Result<()> {
fs::remove_dir_all(path) fs_err::remove_dir_all(path)
} }
fn metadata(&mut self, path: &Path) -> io::Result<Metadata> { fn metadata(&mut self, path: &Path) -> io::Result<Metadata> {
let inner = fs::metadata(path)?; let inner = fs_err::metadata(path)?;
Ok(Metadata { Ok(Metadata {
is_file: inner.is_file(), is_file: inner.is_file(),

View File

@@ -14,6 +14,9 @@
"Fmt": { "Fmt": {
"$path": "fmt" "$path": "fmt"
}, },
"RbxDom": {
"$path": "rbx_dom_lua"
},
"Roact": { "Roact": {
"$path": "modules/roact/src" "$path": "modules/roact/src"
}, },
@@ -22,9 +25,6 @@
}, },
"t": { "t": {
"$path": "modules/t/lib" "$path": "modules/t/lib"
},
"RbxDom": {
"$path": "modules/rbx-dom/rbx_dom_lua/src"
} }
} }
} }

View File

@@ -0,0 +1,44 @@
stds.roblox = {
read_globals = {
game = {
other_fields = true,
},
-- Roblox globals
"script",
-- Extra functions
"tick", "warn",
"wait", "typeof",
-- Types
"CFrame",
"Color3",
"Enum",
"Instance",
"NumberRange",
"Rect",
"UDim", "UDim2",
"Vector2", "Vector3",
"Vector2int16", "Vector3int16",
}
}
stds.testez = {
read_globals = {
"describe",
"it", "itFOCUS", "itSKIP",
"FOCUS", "SKIP", "HACK_NO_XPCALL",
"expect",
}
}
ignore = {
"212", -- unused arguments
}
std = "lua51+roblox"
files["**/*.spec.lua"] = {
std = "+testez",
}

View File

@@ -0,0 +1,2 @@
# rbx\_dom\_lua
Roblox Lua implementation of rbx-dom mechanisms, intended to work with rbx\_dom\_weak and friends.

View File

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

View File

@@ -0,0 +1,242 @@
local base64 = require(script.Parent.base64)
local function identity(...)
return ...
end
local function unpackDecoder(f)
return function(value)
return f(unpack(value))
end
end
local function serializeFloat(value)
-- TODO: Figure out a better way to serialize infinity and NaN, neither of
-- which fit into JSON.
if value == math.huge or value == -math.huge then
return 999999999 * math.sign(value)
end
return value
end
local encoders
encoders = {
Bool = identity,
Content = identity,
Float32 = serializeFloat,
Float64 = serializeFloat,
Int32 = identity,
Int64 = identity,
String = identity,
BinaryString = base64.encode,
SharedString = base64.encode,
BrickColor = function(value)
return value.Number
end,
CFrame = function(value)
return {value:GetComponents()}
end,
Color3 = function(value)
return {value.r, value.g, value.b}
end,
NumberRange = function(value)
return {value.Min, value.Max}
end,
NumberSequence = function(value)
local keypoints = {}
for index, keypoint in ipairs(value.Keypoints) do
keypoints[index] = {
Time = keypoint.Time,
Value = keypoint.Value,
Envelope = keypoint.Envelope,
}
end
return {
Keypoints = keypoints,
}
end,
ColorSequence = function(value)
local keypoints = {}
for index, keypoint in ipairs(value.Keypoints) do
keypoints[index] = {
Time = keypoint.Time,
Color = encoders.Color3(keypoint.Value),
}
end
return {
Keypoints = keypoints,
}
end,
Rect = function(value)
return {
Min = {value.Min.X, value.Min.Y},
Max = {value.Max.X, value.Max.Y},
}
end,
UDim = function(value)
return {value.Scale, value.Offset}
end,
UDim2 = function(value)
return {value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset}
end,
Vector2 = function(value)
return {
serializeFloat(value.X),
serializeFloat(value.Y),
}
end,
Vector2int16 = function(value)
return {value.X, value.Y}
end,
Vector3 = function(value)
return {
serializeFloat(value.X),
serializeFloat(value.Y),
serializeFloat(value.Z),
}
end,
Vector3int16 = function(value)
return {value.X, value.Y, value.Z}
end,
PhysicalProperties = function(value)
if value == nil then
return nil
else
return {
Density = value.Density,
Friction = value.Friction,
Elasticity = value.Elasticity,
FrictionWeight = value.FrictionWeight,
ElasticityWeight = value.ElasticityWeight,
}
end
end,
Ref = function(value)
return nil
end,
}
local decoders = {
Bool = identity,
Content = identity,
Enum = identity,
Float32 = identity,
Float64 = identity,
Int32 = identity,
Int64 = identity,
String = identity,
BinaryString = base64.decode,
SharedString = base64.decode,
BrickColor = BrickColor.new,
CFrame = unpackDecoder(CFrame.new),
Color3 = unpackDecoder(Color3.new),
Color3uint8 = unpackDecoder(Color3.fromRGB),
NumberRange = unpackDecoder(NumberRange.new),
UDim = unpackDecoder(UDim.new),
UDim2 = unpackDecoder(UDim2.new),
Vector2 = unpackDecoder(Vector2.new),
Vector2int16 = unpackDecoder(Vector2int16.new),
Vector3 = unpackDecoder(Vector3.new),
Vector3int16 = unpackDecoder(Vector3int16.new),
Rect = function(value)
return Rect.new(value.Min[1], value.Min[2], value.Max[1], value.Max[2])
end,
NumberSequence = function(value)
local keypoints = {}
for index, keypoint in ipairs(value.Keypoints) do
keypoints[index] = NumberSequenceKeypoint.new(
keypoint.Time,
keypoint.Value,
keypoint.Envelope
)
end
return NumberSequence.new(keypoints)
end,
ColorSequence = function(value)
local keypoints = {}
for index, keypoint in ipairs(value.Keypoints) do
keypoints[index] = ColorSequenceKeypoint.new(
keypoint.Time,
Color3.new(unpack(keypoint.Color))
)
end
return ColorSequence.new(keypoints)
end,
PhysicalProperties = function(properties)
if properties == nil then
return nil
else
return PhysicalProperties.new(
properties.Density,
properties.Friction,
properties.Elasticity,
properties.FrictionWeight,
properties.ElasticityWeight
)
end
end,
Ref = function()
return nil
end,
}
local EncodedValue = {}
function EncodedValue.decode(encodedValue)
local decoder = decoders[encodedValue.Type]
if decoder ~= nil then
return true, decoder(encodedValue.Value)
end
return false, "Couldn't decode value " .. tostring(encodedValue.Type)
end
function EncodedValue.encode(rbxValue, propertyType)
assert(propertyType ~= nil, "Property type descriptor is required")
if propertyType.type == "Data" then
local encoder = encoders[propertyType.name]
if encoder == nil then
return false, ("Missing encoder for property type %q"):format(propertyType.name)
end
if encoder ~= nil then
return true, {
Type = propertyType.name,
Value = encoder(rbxValue),
}
end
elseif propertyType.type == "Enum" then
return true, {
Type = "Enum",
Value = rbxValue.Value,
}
end
return false, ("Unknown property descriptor type %q"):format(tostring(propertyType.type))
end
return EncodedValue

View File

@@ -0,0 +1,127 @@
return function()
local RbxDom = require(script.Parent)
local EncodedValue = require(script.Parent.EncodedValue)
it("should decode Rect values", function()
local input = {
Type = "Rect",
Value = {
Min = {1, 2},
Max = {3, 4},
},
}
local output = Rect.new(1, 2, 3, 4)
local ok, decoded = EncodedValue.decode(input)
assert(ok, decoded)
expect(decoded).to.equal(output)
end)
it("should decode ColorSequence values", function()
local input = {
Type = "ColorSequence",
Value = {
Keypoints = {
{
Time = 0,
Color = { 0.12, 0.34, 0.56 },
},
{
Time = 1,
Color = { 0.13, 0.33, 0.37 },
},
}
},
}
local output = ColorSequence.new({
ColorSequenceKeypoint.new(0, Color3.new(0.12, 0.34, 0.56)),
ColorSequenceKeypoint.new(1, Color3.new(0.13, 0.33, 0.37)),
})
local ok, decoded = EncodedValue.decode(input)
assert(ok, decoded)
expect(decoded).to.equal(output)
end)
it("should decode NumberSequence values", function()
local input = {
Type = "NumberSequence",
Value = {
Keypoints = {
{
Time = 0,
Value = 0.5,
Envelope = 0,
},
{
Time = 1,
Value = 0.5,
Envelope = 0,
},
}
},
}
local output = NumberSequence.new({
NumberSequenceKeypoint.new(0, 0.5, 0),
NumberSequenceKeypoint.new(1, 0.5, 0),
})
local ok, decoded = EncodedValue.decode(input)
assert(ok, decoded)
expect(decoded).to.equal(output)
end)
it("should decode PhysicalProperties values", function()
local input = {
Type = "PhysicalProperties",
Value = {
Density = 0.1,
Friction = 0.2,
Elasticity = 0.3,
FrictionWeight = 0.4,
ElasticityWeight = 0.5,
},
}
local output = PhysicalProperties.new(
0.1,
0.2,
0.3,
0.4,
0.5
)
local ok, decoded = EncodedValue.decode(input)
assert(ok, decoded)
expect(decoded).to.equal(output)
end)
-- This part of rbx_dom_lua needs some work still.
itSKIP("should encode Rect values", function()
local input = Rect.new(10, 20, 30, 40)
local output = {
Type = "Rect",
Value = {
Min = {10, 20},
Max = {30, 40},
},
}
local descriptor = RbxDom.findCanonicalPropertyDescriptor("ImageLabel", "SliceCenter")
local ok, encoded = EncodedValue.encode(input, descriptor)
assert(ok, encoded)
expect(encoded.Type).to.equal(output.Type)
expect(encoded.Value.Min[1]).to.equal(output.Value.Min[1])
expect(encoded.Value.Min[2]).to.equal(output.Value.Min[2])
expect(encoded.Value.Max[1]).to.equal(output.Value.Max[1])
expect(encoded.Value.Max[2]).to.equal(output.Value.Max[2])
end)
end

View File

@@ -0,0 +1,28 @@
local Error = {}
Error.__index = Error
Error.Kind = {
UnknownProperty = "UnknownProperty",
PropertyNotReadable = "PropertyNotReadable",
PropertyNotWritable = "PropertyNotWritable",
Roblox = "Roblox",
}
setmetatable(Error.Kind, {
__index = function(_, key)
error(("%q is not a valid member of Error.Kind"):format(tostring(key)), 2)
end,
})
function Error.new(kind, extra)
return setmetatable({
kind = kind,
extra = extra,
}, Error)
end
function Error:__tostring()
return ("Error(%s: %s)"):format(self.kind, tostring(self.extra))
end
return Error

View File

@@ -0,0 +1,80 @@
local Error = require(script.Parent.Error)
local customProperties = require(script.Parent.customProperties)
-- A wrapper around a property descriptor from the reflection database with some
-- extra convenience methods.
--
-- The aim of this API is to facilitate looking up a property once, then reading
-- from it or writing to it multiple times. It's also useful when a consumer
-- wants to check additional constraints on the property before trying to use
-- it, like scriptability.
local PropertyDescriptor = {}
PropertyDescriptor.__index = PropertyDescriptor
local function get(container, key)
return container[key]
end
local function set(container, key, value)
container[key] = value
end
function PropertyDescriptor.fromRaw(data, className, propertyName)
return setmetatable({
scriptability = data.scriptability,
className = className,
name = propertyName,
}, PropertyDescriptor)
end
function PropertyDescriptor:read(instance)
if self.scriptability == "ReadWrite" or self.scriptability == "Read" then
local success, value = xpcall(get, debug.traceback, instance, self.name)
if success then
return success, value
else
return false, Error.new(Error.Kind.Roblox, value)
end
end
if self.scriptability == "Custom" then
local interface = customProperties[self.className][self.name]
return interface.read(instance, self.name)
end
if self.scriptability == "None" or self.scriptability == "Write" then
local fullName = ("%s.%s"):format(instance.className, self.name)
return false, Error.new(Error.Kind.PropertyNotReadable, fullName)
end
error(("Internal error: unexpected value of 'scriptability': %s"):format(tostring(self.scriptability)), 2)
end
function PropertyDescriptor:write(instance, value)
if self.scriptability == "ReadWrite" or self.scriptability == "Write" then
local success, err = xpcall(set, debug.traceback, instance, self.name, value)
if success then
return success
else
return false, Error.new(Error.Kind.Roblox, err)
end
end
if self.scriptability == "Custom" then
local interface = customProperties[self.className][self.name]
return interface.write(instance, self.name, value)
end
if self.scriptability == "None" or self.scriptability == "Read" then
local fullName = ("%s.%s"):format(instance.className, self.name)
return false, Error.new(Error.Kind.PropertyNotWritable, fullName)
end
end
return PropertyDescriptor

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
return {
classes = require(script.classes)
}

View File

@@ -0,0 +1,139 @@
-- Thanks to Tiffany352 for this base64 implementation!
local floor = math.floor
local char = string.char
local function encodeBase64(str)
local out = {}
local nOut = 0
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local strLen = #str
-- 3 octets become 4 hextets
for i = 1, strLen - 2, 3 do
local b1, b2, b3 = str:byte(i, i + 3)
local word = b3 + b2 * 256 + b1 * 256 * 256
local h4 = word % 64 + 1
word = floor(word / 64)
local h3 = word % 64 + 1
word = floor(word / 64)
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = alphabet:sub(h3, h3)
out[nOut + 4] = alphabet:sub(h4, h4)
nOut = nOut + 4
end
local remainder = strLen % 3
if remainder == 2 then
-- 16 input bits -> 3 hextets (2 full, 1 partial)
local b1, b2 = str:byte(-2, -1)
-- partial is 4 bits long, leaving 2 bits of zero padding ->
-- offset = 4
local word = b2 * 4 + b1 * 4 * 256
local h3 = word % 64 + 1
word = floor(word / 64)
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = alphabet:sub(h3, h3)
out[nOut + 4] = "="
elseif remainder == 1 then
-- 8 input bits -> 2 hextets (2 full, 1 partial)
local b1 = str:byte(-1, -1)
-- partial is 2 bits long, leaving 4 bits of zero padding ->
-- offset = 16
local word = b1 * 16
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = "="
out[nOut + 4] = "="
end
-- if the remainder is 0, then no work is needed
return table.concat(out, "")
end
local function decodeBase64(str)
local out = {}
local nOut = 0
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local strLen = #str
local acc = 0
local nAcc = 0
local alphabetLut = {}
for i = 1, #alphabet do
alphabetLut[alphabet:sub(i, i)] = i - 1
end
-- 4 hextets become 3 octets
for i = 1, strLen do
local ch = str:sub(i, i)
local byte = alphabetLut[ch]
if byte then
acc = acc * 64 + byte
nAcc = nAcc + 1
end
if nAcc == 4 then
local b3 = acc % 256
acc = floor(acc / 256)
local b2 = acc % 256
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
out[nOut + 2] = char(b2)
out[nOut + 3] = char(b3)
nOut = nOut + 3
nAcc = 0
acc = 0
end
end
if nAcc == 3 then
-- 3 hextets -> 16 bit output
acc = acc * 64
acc = floor(acc / 256)
local b2 = acc % 256
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
out[nOut + 2] = char(b2)
elseif nAcc == 2 then
-- 2 hextets -> 8 bit output
acc = acc * 64
acc = floor(acc / 256)
acc = acc * 64
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
elseif nAcc == 1 then
error("Base64 has invalid length")
end
return table.concat(out, "")
end
return {
decode = decodeBase64,
encode = encodeBase64,
}

View File

@@ -0,0 +1,29 @@
return function()
local base64 = require(script.Parent.base64)
it("should encode and decode", function()
local function try(str, expected)
local encoded = base64.encode(str)
expect(encoded).to.equal(expected)
expect(base64.decode(encoded)).to.equal(str)
end
try("Man", "TWFu")
try("Ma", "TWE=")
try("M", "TQ==")
try("ManM", "TWFuTQ==")
try(
[[Man is distinguished, not only by his reason, but by this ]]..
[[singular passion from other animals, which is a lust of the ]]..
[[mind, that by a perseverance of delight in the continued and ]]..
[[indefatigable generation of knowledge, exceeds the short ]]..
[[vehemence of any carnal pleasure.]],
[[TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sI]]..
[[GJ1dCBieSB0aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYW]]..
[[xzLCB3aGljaCBpcyBhIGx1c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJ]]..
[[zZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0aGUgY29udGludWVkIGFuZCBpbmRl]]..
[[ZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdlLCBleGNlZWRzIHRoZ]]..
[[SBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=]]
)
end)
end

View File

@@ -0,0 +1,47 @@
local CollectionService = game:GetService("CollectionService")
-- Defines how to read and write properties that aren't directly scriptable.
--
-- The reflection database refers to these as having scriptability = "Custom"
return {
Instance = {
Tags = {
read = function(instance, key)
local tagList = CollectionService:GetTags(instance)
return true, table.concat(tagList, "\0")
end,
write = function(instance, key, value)
local existingTags = CollectionService:GetTags(instance)
local unseenTags = {}
for _, tag in ipairs(existingTags) do
unseenTags[tag] = true
end
local tagList = string.split(value, "\0")
for _, tag in ipairs(tagList) do
unseenTags[tag] = nil
CollectionService:AddTag(instance, tag)
end
for tag in pairs(unseenTags) do
CollectionService:RemoveTag(instance, tag)
end
return true
end,
},
},
LocalizationTable = {
Contents = {
read = function(instance, key)
return true, instance:GetContents()
end,
write = function(instance, key, value)
instance:SetContents(value)
return true
end,
},
},
}

View File

@@ -0,0 +1,67 @@
local ReflectionDatabase = require(script.ReflectionDatabase)
local Error = require(script.Error)
local PropertyDescriptor = require(script.PropertyDescriptor)
local function findCanonicalPropertyDescriptor(className, propertyName)
local currentClassName = className
repeat
local currentClass = ReflectionDatabase.classes[currentClassName]
if currentClass == nil then
return currentClass
end
local propertyData = currentClass.properties[propertyName]
if propertyData ~= nil then
if propertyData.isCanonical then
return PropertyDescriptor.fromRaw(propertyData, currentClassName, propertyName)
end
if propertyData.canonicalName ~= nil then
return PropertyDescriptor.fromRaw(
currentClass.properties[propertyData.canonicalName],
currentClassName,
propertyData.canonicalName)
end
return nil
end
currentClassName = currentClass.superclass
until currentClassName == nil
return nil
end
local function readProperty(instance, propertyName)
local descriptor = findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
if descriptor == nil then
local fullName = ("%s.%s"):format(instance.className, propertyName)
return false, Error.new(Error.Kind.UnknownProperty, fullName)
end
return descriptor:read(instance)
end
local function writeProperty(instance, propertyName, value)
local descriptor = findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
if descriptor == nil then
local fullName = ("%s.%s"):format(instance.className, propertyName)
return false, Error.new(Error.Kind.UnknownProperty, fullName)
end
return descriptor:write(instance, value)
end
return {
readProperty = readProperty,
writeProperty = writeProperty,
findCanonicalPropertyDescriptor = findCanonicalPropertyDescriptor,
Error = Error,
EncodedValue = require(script.EncodedValue),
}

View File

@@ -0,0 +1,7 @@
return function()
local RbxDom = require(script.Parent)
it("should load", function()
expect(RbxDom).to.be.ok()
end)
end

View File

@@ -0,0 +1,35 @@
{
"name": "rbx_dom_lua test place",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"RbxDom": {
"$path": "src"
},
"TestEZ": {
"$path": "modules/testez/lib"
}
},
"ServerScriptService": {
"$className": "ServerScriptService",
"Run Tests": {
"$path": "test.server.lua"
}
},
"Players": {
"$className": "Players",
"$properties": {
"CharacterAutoLoads": false
}
},
"HttpService": {
"$className": "HttpService",
"$properties": {
"HttpEnabled": true
}
}
}
}

View File

@@ -0,0 +1,7 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local LIB_ROOT = ReplicatedStorage.RbxDom
local TestEZ = require(ReplicatedStorage.TestEZ)
TestEZ.TestBootstrap:run({LIB_ROOT})

View File

@@ -233,4 +233,19 @@ function ApiContext:retrieveMessages()
end) end)
end end
function ApiContext:open(id)
local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
return Http.post(url, "")
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
return nil
end)
end
return ApiContext return ApiContext

View File

@@ -13,11 +13,11 @@ local Version = require(Plugin.Version)
local preloadAssets = require(Plugin.preloadAssets) local preloadAssets = require(Plugin.preloadAssets)
local strict = require(Plugin.strict) local strict = require(Plugin.strict)
local Theme = require(Plugin.Components.Theme)
local ConnectPanel = require(Plugin.Components.ConnectPanel) local ConnectPanel = require(Plugin.Components.ConnectPanel)
local ConnectingPanel = require(Plugin.Components.ConnectingPanel) local ConnectingPanel = require(Plugin.Components.ConnectingPanel)
local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel) local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel)
local ErrorPanel = require(Plugin.Components.ErrorPanel) local ErrorPanel = require(Plugin.Components.ErrorPanel)
local SettingsPanel = require(Plugin.Components.SettingsPanel)
local e = Roact.createElement local e = Roact.createElement
@@ -62,6 +62,7 @@ local AppStatus = strict("AppStatus", {
Connecting = "Connecting", Connecting = "Connecting",
Connected = "Connected", Connected = "Connected",
Error = "Error", Error = "Error",
Settings = "Settings",
}) })
local App = Roact.Component:extend("App") local App = Roact.Component:extend("App")
@@ -74,10 +75,7 @@ function App:init()
self.signals = {} self.signals = {}
self.serveSession = nil self.serveSession = nil
self.displayedVersion = Version.display(Config.version)
self.displayedVersion = DevSettings:isEnabled()
and Config.codename
or Version.display(Config.version)
local toolbar = self.props.plugin:CreateToolbar("Rojo " .. self.displayedVersion) local toolbar = self.props.plugin:CreateToolbar("Rojo " .. self.displayedVersion)
@@ -109,12 +107,14 @@ function App:init()
end) end)
end end
function App:startSession(address, port) function App:startSession(address, port, sessionOptions)
Log.trace("Starting new session") Log.trace("Starting new session")
local baseUrl = ("http://%s:%s"):format(address, port) local baseUrl = ("http://%s:%s"):format(address, port)
self.serveSession = ServeSession.new({ self.serveSession = ServeSession.new({
apiContext = ApiContext.new(baseUrl), apiContext = ApiContext.new(baseUrl),
openScriptsExternally = sessionOptions.openScriptsExternally,
twoWaySync = sessionOptions.twoWaySync,
}) })
self.serveSession:onStatusChanged(function(status, details) self.serveSession:onStatusChanged(function(status, details)
@@ -155,8 +155,13 @@ function App:render()
if self.state.appStatus == AppStatus.NotStarted then if self.state.appStatus == AppStatus.NotStarted then
children = { children = {
ConnectPanel = e(ConnectPanel, { ConnectPanel = e(ConnectPanel, {
startSession = function(address, port) startSession = function(address, port, settings)
self:startSession(address, port) self:startSession(address, port, settings)
end,
openSettings = function()
self:setState({
appStatus = AppStatus.Settings,
})
end, end,
cancel = function() cancel = function()
Log.trace("Canceling session configuration") Log.trace("Canceling session configuration")
@@ -169,7 +174,7 @@ function App:render()
} }
elseif self.state.appStatus == AppStatus.Connecting then elseif self.state.appStatus == AppStatus.Connecting then
children = { children = {
ConnectingPanel = Roact.createElement(ConnectingPanel), ConnectingPanel = e(ConnectingPanel),
} }
elseif self.state.appStatus == AppStatus.Connected then elseif self.state.appStatus == AppStatus.Connected then
children = { children = {
@@ -187,9 +192,19 @@ function App:render()
end, end,
}), }),
} }
elseif self.state.appStatus == AppStatus.Settings then
children = {
e(SettingsPanel, {
back = function()
self:setState({
appStatus = AppStatus.NotStarted,
})
end,
}),
}
elseif self.state.appStatus == AppStatus.Error then elseif self.state.appStatus == AppStatus.Error then
children = { children = {
ErrorPanel = Roact.createElement(ErrorPanel, { ErrorPanel = e(ErrorPanel, {
errorMessage = self.state.errorMessage, errorMessage = self.state.errorMessage,
onDismiss = function() onDismiss = function()
self:setState({ self:setState({
@@ -200,11 +215,9 @@ function App:render()
} }
end end
return Roact.createElement(Theme.StudioProvider, nil, { return e(Roact.Portal, {
UI = Roact.createElement(Roact.Portal, { target = self.dockWidget,
target = self.dockWidget, }, children)
}, children),
})
end end
function App:didMount() function App:didMount()

View File

@@ -0,0 +1,39 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact)
local Theme = require(Plugin.Components.Theme)
local e = Roact.createElement
local function Checkbox(props)
local checked = props.checked
local layoutOrder = props.layoutOrder
local onChange = props.onChange
return Theme.with(function(theme)
return e("ImageButton", {
LayoutOrder = layoutOrder,
Size = UDim2.new(0, 20, 0, 20),
BorderSizePixel = 2,
BorderColor3 = theme.Text2,
BackgroundColor3 = theme.Background2,
[Roact.Event.Activated] = function()
onChange(not checked)
end,
}, {
Indicator = e("Frame", {
Size = UDim2.new(0, 18, 0, 18),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
BorderSizePixel = 0,
BackgroundColor3 = theme.Brand1,
BackgroundTransparency = checked and 0 or 1,
})
})
end)
end
return Checkbox

View File

@@ -11,6 +11,7 @@ local FitList = require(Plugin.Components.FitList)
local FitText = require(Plugin.Components.FitText) local FitText = require(Plugin.Components.FitText)
local FormButton = require(Plugin.Components.FormButton) local FormButton = require(Plugin.Components.FormButton)
local FormTextInput = require(Plugin.Components.FormTextInput) local FormTextInput = require(Plugin.Components.FormTextInput)
local PluginSettings = require(Plugin.Components.PluginSettings)
local e = Roact.createElement local e = Roact.createElement
@@ -25,138 +26,157 @@ end
function ConnectPanel:render() function ConnectPanel:render()
local startSession = self.props.startSession local startSession = self.props.startSession
local openSettings = self.props.openSettings
return Theme.with(function(theme) return Theme.with(function(theme)
return e(Panel, nil, { return PluginSettings.with(function(settings)
Layout = e("UIListLayout", { return e(Panel, nil, {
SortOrder = Enum.SortOrder.LayoutOrder, Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Center, HorizontalAlignment = Enum.HorizontalAlignment.Center,
}), VerticalAlignment = Enum.VerticalAlignment.Center,
}),
Inputs = e(FitList, { Inputs = e(FitList, {
containerProps = {
BackgroundTransparency = 1,
LayoutOrder = 1,
},
layoutProps = {
FillDirection = Enum.FillDirection.Horizontal,
Padding = UDim.new(0, 8),
},
paddingProps = {
PaddingTop = UDim.new(0, 20),
PaddingBottom = UDim.new(0, 10),
PaddingLeft = UDim.new(0, 24),
PaddingRight = UDim.new(0, 24),
},
}, {
Address = e(FitList, {
containerProps = { containerProps = {
LayoutOrder = 1,
BackgroundTransparency = 1, BackgroundTransparency = 1,
LayoutOrder = 1,
}, },
layoutProps = { layoutProps = {
Padding = UDim.new(0, 4), FillDirection = Enum.FillDirection.Horizontal,
Padding = UDim.new(0, 8),
},
paddingProps = {
PaddingTop = UDim.new(0, 20),
PaddingBottom = UDim.new(0, 10),
PaddingLeft = UDim.new(0, 24),
PaddingRight = UDim.new(0, 24),
}, },
}, { }, {
Label = e(FitText, { Address = e(FitList, {
Kind = "TextLabel", containerProps = {
LayoutOrder = 1, LayoutOrder = 1,
BackgroundTransparency = 1, BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left, },
Font = theme.TitleFont, layoutProps = {
TextSize = 20, Padding = UDim.new(0, 4),
Text = "Address", },
TextColor3 = theme.Text1, }, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.TitleFont,
TextSize = 20,
Text = "Address",
TextColor3 = theme.Text1,
}),
Input = e(FormTextInput, {
layoutOrder = 2,
width = UDim.new(0, 220),
value = self.state.address,
placeholderValue = Config.defaultHost,
onValueChange = function(newValue)
self:setState({
address = newValue,
})
end,
}),
}), }),
Input = e(FormTextInput, { Port = e(FitList, {
layoutOrder = 2, containerProps = {
width = UDim.new(0, 220), LayoutOrder = 2,
value = self.state.address, BackgroundTransparency = 1,
placeholderValue = Config.defaultHost, },
onValueChange = function(newValue) layoutProps = {
self:setState({ Padding = UDim.new(0, 4),
address = newValue, },
}) }, {
end, Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.TitleFont,
TextSize = 20,
Text = "Port",
TextColor3 = theme.Text1,
}),
Input = e(FormTextInput, {
layoutOrder = 2,
width = UDim.new(0, 80),
value = self.state.port,
placeholderValue = Config.defaultPort,
onValueChange = function(newValue)
self:setState({
port = newValue,
})
end,
}),
}), }),
}), }),
Port = e(FitList, { Buttons = e(FitList, {
fitAxes = "Y",
containerProps = { containerProps = {
BackgroundTransparency = 1,
LayoutOrder = 2, LayoutOrder = 2,
BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0),
}, },
layoutProps = { layoutProps = {
Padding = UDim.new(0, 4), FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
Padding = UDim.new(0, 8),
},
paddingProps = {
PaddingTop = UDim.new(0, 0),
PaddingBottom = UDim.new(0, 20),
PaddingLeft = UDim.new(0, 24),
PaddingRight = UDim.new(0, 24),
}, },
}, { }, {
Label = e(FitText, { e(FormButton, {
Kind = "TextLabel", layoutOrder = 1,
LayoutOrder = 1, text = "Settings",
BackgroundTransparency = 1, secondary = true,
TextXAlignment = Enum.TextXAlignment.Left, onClick = function()
Font = theme.TitleFont, if openSettings ~= nil then
TextSize = 20, openSettings()
Text = "Port", end
TextColor3 = theme.Text1, end,
}), }),
Input = e(FormTextInput, { e(FormButton, {
layoutOrder = 2, layoutOrder = 2,
width = UDim.new(0, 80), text = "Connect",
value = self.state.port, onClick = function()
placeholderValue = Config.defaultPort, if startSession ~= nil then
onValueChange = function(newValue) local address = self.state.address
self:setState({ if address:len() == 0 then
port = newValue, address = Config.defaultHost
}) end
local port = self.state.port
if port:len() == 0 then
port = Config.defaultPort
end
local sessionOptions = {
openScriptsExternally = settings:get("openScriptsExternally"),
twoWaySync = settings:get("twoWaySync"),
}
startSession(address, port, sessionOptions)
end
end, end,
}), }),
}), }),
}), })
end)
Buttons = e(FitList, {
fitAxes = "Y",
containerProps = {
BackgroundTransparency = 1,
LayoutOrder = 2,
Size = UDim2.new(1, 0, 0, 0),
},
layoutProps = {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
Padding = UDim.new(0, 8),
},
paddingProps = {
PaddingTop = UDim.new(0, 0),
PaddingBottom = UDim.new(0, 20),
PaddingLeft = UDim.new(0, 24),
PaddingRight = UDim.new(0, 24),
},
}, {
e(FormButton, {
layoutOrder = 2,
text = "Connect",
onClick = function()
if startSession ~= nil then
local address = self.state.address
if address:len() == 0 then
address = Config.defaultHost
end
local port = self.state.port
if port:len() == 0 then
port = Config.defaultPort
end
startSession(address, port)
end
end,
}),
}),
})
end) end)
end end

View File

@@ -0,0 +1,121 @@
--[[
Persistent plugin settings that can be accessed via Roact context.
]]
local Rojo = script:FindFirstAncestor("Rojo")
local Roact = require(Rojo.Roact)
local defaultSettings = {
openScriptsExternally = false,
twoWaySync = false,
}
local Settings = {}
Settings.__index = Settings
function Settings.fromPlugin(plugin)
local values = {}
for name, defaultValue in pairs(defaultSettings) do
local savedValue = plugin:GetSetting("Rojo_" .. name)
if savedValue == nil then
plugin:SetSetting("Rojo_" .. name, defaultValue)
values[name] = defaultValue
else
values[name] = savedValue
end
end
return setmetatable({
__values = values,
__plugin = plugin,
__updateListeners = {},
}, Settings)
end
function Settings:get(name)
if defaultSettings[name] == nil then
error("Invalid setings name " .. tostring(name), 2)
end
return self.__values[name]
end
function Settings:set(name, value)
self.__plugin:SetSetting("Rojo_" .. name, value)
self.__values[name] = value
for callback in pairs(self.__updateListeners) do
callback(name, value)
end
end
function Settings:onUpdate(newCallback)
local newListeners = {}
for callback in pairs(self.__updateListeners) do
newListeners[callback] = true
end
newListeners[newCallback] = true
self.__updateListeners = newListeners
return function()
local newListeners = {}
for callback in pairs(self.__updateListeners) do
if callback ~= newCallback then
newListeners[callback] = true
end
end
self.__updateListeners = newListeners
end
end
local Context = Roact.createContext(nil)
local StudioProvider = Roact.Component:extend("StudioProvider")
function StudioProvider:init()
self.settings = Settings.fromPlugin(self.props.plugin)
end
function StudioProvider:render()
return Roact.createElement(Context.Provider, {
value = self.settings,
}, self.props[Roact.Children])
end
local InternalConsumer = Roact.Component:extend("InternalConsumer")
function InternalConsumer:render()
return self.props.render(self.props.settings)
end
function InternalConsumer:didMount()
self.disconnect = self.props.settings:onUpdate(function()
-- Trigger a dummy state update to update the settings consumer.
self:setState({})
end)
end
function InternalConsumer:willUnmount()
self.disconnect()
end
local function with(callback)
return Roact.createElement(Context.Consumer, {
render = function(settings)
return Roact.createElement(InternalConsumer, {
settings = settings,
render = callback,
})
end,
})
end
return {
StudioProvider = StudioProvider,
with = with,
}

View File

@@ -0,0 +1,119 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin")
local Checkbox = require(Plugin.Components.Checkbox)
local FitList = require(Plugin.Components.FitList)
local FitText = require(Plugin.Components.FitText)
local FormButton = require(Plugin.Components.FormButton)
local Panel = require(Plugin.Components.Panel)
local PluginSettings = require(Plugin.Components.PluginSettings)
local Theme = require(Plugin.Components.Theme)
local e = Roact.createElement
local SettingsPanel = Roact.Component:extend("SettingsPanel")
function SettingsPanel:render()
local back = self.props.back
return Theme.with(function(theme)
return PluginSettings.with(function(settings)
return e(Panel, nil, {
Layout = Roact.createElement("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 16),
}),
OpenScriptsExternally = e(FitList, {
containerProps = {
LayoutOrder = 1,
BackgroundTransparency = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.MainFont,
TextSize = 16,
Text = "Open Scripts Externally",
TextColor3 = theme.Text1,
}),
Padding = e("Frame", {
Size = UDim2.new(0, 8, 0, 0),
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
Input = e(Checkbox, {
layoutOrder = 3,
checked = settings:get("openScriptsExternally"),
onChange = function(newValue)
settings:set("openScriptsExternally", not settings:get("openScriptsExternally"))
end,
}),
}),
TwoWaySync = e(FitList, {
containerProps = {
LayoutOrder = 2,
BackgroundTransparency = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.MainFont,
TextSize = 16,
Text = "Two-Way Sync (Experimental!)",
TextColor3 = theme.Text1,
}),
Padding = e("Frame", {
Size = UDim2.new(0, 8, 0, 0),
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
Input = e(Checkbox, {
layoutOrder = 3,
checked = settings:get("twoWaySync"),
onChange = function(newValue)
settings:set("twoWaySync", not settings:get("twoWaySync"))
end,
}),
}),
BackButton = e(FormButton, {
layoutOrder = 4,
text = "Okay",
secondary = true,
onClick = function()
back()
end,
}),
})
end)
end)
end
return SettingsPanel

View File

@@ -5,8 +5,8 @@ local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil
return strict("Config", { return strict("Config", {
isDevBuild = isDevBuild, isDevBuild = isDevBuild,
codename = "Epiphany", codename = "Epiphany",
version = {0, 6, 0, "-alpha.3"}, version = {6, 0, 0, "-rc.1"},
expectedServerVersionString = "0.6.0 or newer", expectedServerVersionString = "6.0 or newer",
protocolVersion = 3, protocolVersion = 3,
defaultHost = "localhost", defaultHost = "localhost",
defaultPort = 34872, defaultPort = 34872,

View File

@@ -25,14 +25,6 @@ local VALUES = {
[Environment.Test] = true, [Environment.Test] = true,
}, },
}, },
UnstableTwoWaySync = {
type = "BoolValue",
values = {
[Environment.User] = false,
[Environment.Dev] = false,
[Environment.Test] = false,
},
},
} }
local CONTAINER_NAME = "RojoDevSettings" .. Config.codename local CONTAINER_NAME = "RojoDevSettings" .. Config.codename
@@ -140,10 +132,6 @@ function DevSettings:shouldTypecheck()
return getValue("TypecheckingEnabled") return getValue("TypecheckingEnabled")
end end
function DevSettings:twoWaySyncEnabled()
return getValue("UnstableTwoWaySync")
end
function _G.ROJO_DEV_CREATE() function _G.ROJO_DEV_CREATE()
DevSettings:createDevSettings() DevSettings:createDevSettings()
end end

View File

@@ -11,9 +11,22 @@ InstanceMap.__index = InstanceMap
function InstanceMap.new(onInstanceChanged) function InstanceMap.new(onInstanceChanged)
local self = { local self = {
-- A map from IDs to instances.
fromIds = {}, fromIds = {},
-- A map from instances to IDs.
fromInstances = {}, fromInstances = {},
-- A set of all instances that updates should be paused for. This set
-- should generally be empty, and will be filled by pauseInstance
-- temporarily.
pausedUpdateInstances = {},
-- A map from instances to a signal or list of signals connected to it.
instancesToSignal = {}, instancesToSignal = {},
-- Callback that's invoked whenever an instance is changed and it was
-- not paused.
onInstanceChanged = onInstanceChanged, onInstanceChanged = onInstanceChanged,
} }
@@ -118,6 +131,32 @@ function InstanceMap:destroyId(id)
end end
end end
--[[
Pause updates for an instance momentarily and invoke a callback.
If the callback throws an error, InstanceMap will still be kept in a
consistent state.
]]
function InstanceMap:pauseInstance(instance, callback)
local id = self.fromInstances[instance]
-- If we don't know about this instance, ignore it and do not invoke the
-- callback.
if id == nil then
return
end
self.pausedUpdateInstances[instance] = true
local success, result = xpcall(callback, debug.traceback)
self.pausedUpdateInstances[instance] = false
if success then
return result
else
error(result, 2)
end
end
function InstanceMap:__connectSignals(instance) function InstanceMap:__connectSignals(instance)
-- ValueBase instances have an overriden version of the Changed signal that -- ValueBase instances have an overriden version of the Changed signal that
-- only detects changes to their Value property. -- only detects changes to their Value property.
@@ -150,9 +189,15 @@ end
function InstanceMap:__maybeFireInstanceChanged(instance, propertyName) function InstanceMap:__maybeFireInstanceChanged(instance, propertyName)
Log.trace("{}.{} changed", instance:GetFullName(), propertyName) Log.trace("{}.{} changed", instance:GetFullName(), propertyName)
if self.onInstanceChanged ~= nil then if self.pausedUpdateInstances[instance] then
self.onInstanceChanged(instance, propertyName) return
end end
if self.onInstanceChanged == nil then
return
end
self.onInstanceChanged(instance, propertyName)
end end
function InstanceMap:__disconnectSignals(instance) function InstanceMap:__disconnectSignals(instance)

25
plugin/src/PatchSet.lua Normal file
View File

@@ -0,0 +1,25 @@
--[[
Methods to operate on either a patch created by the hydrate method, or a
patch returned from the API.
]]
local t = require(script.Parent.Parent.t)
local Types = require(script.Parent.Types)
local PatchSet = {}
PatchSet.validate = t.interface({
removed = t.array(t.union(Types.RbxId, t.Instance)),
added = t.map(Types.RbxId, Types.ApiInstance),
updated = t.array(Types.ApiInstanceUpdate),
})
--[[
Invert the given PatchSet using the given instance map.
]]
function PatchSet.invert(patchSet, instanceMap)
error("not yet implemented", 2)
end
return PatchSet

View File

@@ -5,24 +5,12 @@
local RbxDom = require(script.Parent.Parent.RbxDom) local RbxDom = require(script.Parent.Parent.RbxDom)
local t = require(script.Parent.Parent.t) local t = require(script.Parent.Parent.t)
local Log = require(script.Parent.Parent.Log)
local Types = require(script.Parent.Types) local Types = require(script.Parent.Types)
local invariant = require(script.Parent.invariant) local invariant = require(script.Parent.invariant)
local getCanonicalProperty = require(script.Parent.getCanonicalProperty) local getCanonicalProperty = require(script.Parent.getCanonicalProperty)
local setCanonicalProperty = require(script.Parent.setCanonicalProperty) local setCanonicalProperty = require(script.Parent.setCanonicalProperty)
local PatchSet = require(script.Parent.PatchSet)
--[[
This interface represents either a patch created by the hydrate method, or a
patch returned from the API.
This type should be a subset of Types.ApiInstanceUpdate.
]]
local IPatch = t.interface({
removed = t.array(t.union(Types.RbxId, t.Instance)),
added = t.map(Types.RbxId, Types.ApiInstance),
updated = t.array(Types.ApiInstanceUpdate),
})
--[[ --[[
Attempt to safely set the parent of an instance. Attempt to safely set the parent of an instance.
@@ -86,7 +74,7 @@ end
editable by scripts. editable by scripts.
]] ]]
local applyPatchSchema = Types.ifEnabled(t.tuple( local applyPatchSchema = Types.ifEnabled(t.tuple(
IPatch PatchSet.validate
)) ))
function Reconciler:applyPatch(patch) function Reconciler:applyPatch(patch)
assert(applyPatchSchema(patch)) assert(applyPatchSchema(patch))
@@ -287,7 +275,7 @@ local hydrateSchema = Types.ifEnabled(t.tuple(
t.map(Types.RbxId, Types.VirtualInstance), t.map(Types.RbxId, Types.VirtualInstance),
Types.RbxId, Types.RbxId,
t.Instance, t.Instance,
IPatch PatchSet.validate
)) ))
function Reconciler:__hydrateInternal(apiInstances, id, instance, hydratePatch) function Reconciler:__hydrateInternal(apiInstances, id, instance, hydratePatch)
assert(hydrateSchema(apiInstances, id, instance, hydratePatch)) assert(hydrateSchema(apiInstances, id, instance, hydratePatch))

View File

@@ -1,8 +1,9 @@
local StudioService = game:GetService("StudioService")
local Log = require(script.Parent.Parent.Log) local Log = require(script.Parent.Parent.Log)
local Fmt = require(script.Parent.Parent.Fmt) local Fmt = require(script.Parent.Parent.Fmt)
local t = require(script.Parent.Parent.t) local t = require(script.Parent.Parent.t)
local DevSettings = require(script.Parent.DevSettings)
local InstanceMap = require(script.Parent.InstanceMap) local InstanceMap = require(script.Parent.InstanceMap)
local Reconciler = require(script.Parent.Reconciler) local Reconciler = require(script.Parent.Reconciler)
local strict = require(script.Parent.strict) local strict = require(script.Parent.strict)
@@ -43,6 +44,8 @@ ServeSession.Status = Status
local validateServeOptions = t.strictInterface({ local validateServeOptions = t.strictInterface({
apiContext = t.table, apiContext = t.table,
openScriptsExternally = t.boolean,
twoWaySync = t.boolean,
}) })
function ServeSession.new(options) function ServeSession.new(options)
@@ -57,12 +60,28 @@ function ServeSession.new(options)
local instanceMap = InstanceMap.new(onInstanceChanged) local instanceMap = InstanceMap.new(onInstanceChanged)
local reconciler = Reconciler.new(instanceMap) local reconciler = Reconciler.new(instanceMap)
local connections = {}
local connection = StudioService
:GetPropertyChangedSignal("ActiveScript")
:Connect(function()
local activeScript = StudioService.ActiveScript
if activeScript ~= nil then
self:__onActiveScriptChanged(activeScript)
end
end)
table.insert(connections, connection)
self = { self = {
__status = Status.NotStarted, __status = Status.NotStarted,
__apiContext = options.apiContext, __apiContext = options.apiContext,
__openScriptsExternally = options.openScriptsExternally,
__twoWaySync = options.twoWaySync,
__reconciler = reconciler, __reconciler = reconciler,
__instanceMap = instanceMap, __instanceMap = instanceMap,
__statusChangedCallback = nil, __statusChangedCallback = nil,
__connections = connections,
} }
setmetatable(self, ServeSession) setmetatable(self, ServeSession)
@@ -108,8 +127,39 @@ function ServeSession:stop()
self:__stopInternal() self:__stopInternal()
end end
function ServeSession:__onActiveScriptChanged(activeScript)
if not self.__openScriptsExternally then
Log.trace("Not opening script {} because feature not enabled.", activeScript)
return
end
if self.__status ~= Status.Connected then
Log.trace("Not opening script {} because session is not connected.", activeScript)
return
end
local scriptId = self.__instanceMap.fromInstances[activeScript]
if scriptId == nil then
Log.trace("Not opening script {} because it is not known by Rojo.", activeScript)
return
end
Log.debug("Trying to open script {} externally...", activeScript)
-- Force-close the script inside Studio
local existingParent = activeScript.Parent
activeScript.Parent = nil
activeScript.Parent = existingParent
-- Notify the Rojo server to open this script
self.__apiContext:open(scriptId)
end
function ServeSession:__onInstanceChanged(instance, propertyName) function ServeSession:__onInstanceChanged(instance, propertyName)
if not DevSettings:twoWaySyncEnabled() then if not self.__twoWaySync then
return return
end end
@@ -200,6 +250,11 @@ function ServeSession:__stopInternal(err)
self:__setStatus(Status.Disconnected, err) self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect() self.__apiContext:disconnect()
self.__instanceMap:stop() self.__instanceMap:stop()
for _, connection in ipairs(self.__connections) do
connection:Disconnect()
end
self.__connections = {}
end end
function ServeSession:__setStatus(status, detail) function ServeSession:__setStatus(status, detail)

View File

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

View File

@@ -1,7 +1,7 @@
local RbxDom = require(script.Parent.Parent.RbxDom) local RbxDom = require(script.Parent.Parent.RbxDom)
--[[ --[[
Attempts to set a property on the given instance. Attempts to read a property from the given instance.
]] ]]
local function getCanonincalProperty(instance, propertyName) local function getCanonincalProperty(instance, propertyName)
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName) local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)

View File

@@ -14,9 +14,17 @@ local Roact = require(script.Parent.Roact)
local Config = require(script.Config) local Config = require(script.Config)
local App = require(script.Components.App) local App = require(script.Components.App)
local Theme = require(script.Components.Theme)
local PluginSettings = require(script.Components.PluginSettings)
local app = Roact.createElement(App, { local app = Roact.createElement(Theme.StudioProvider, nil, {
plugin = plugin, Roact.createElement(PluginSettings.StudioProvider, {
plugin = plugin,
}, {
RojoUI = Roact.createElement(App, {
plugin = plugin,
}),
})
}) })
local tree = Roact.mount(app, nil, "Rojo UI") local tree = Roact.mount(app, nil, "Rojo UI")

View File

@@ -0,0 +1,28 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="DataModel" referent="0">
<Properties>
<string name="Name">infer-service-name</string>
</Properties>
<Item class="HttpService" referent="1">
<Properties>
<string name="Name">HttpService</string>
<bool name="HttpEnabled">true</bool>
</Properties>
</Item>
<Item class="ReplicatedStorage" referent="2">
<Properties>
<string name="Name">ReplicatedStorage</string>
</Properties>
<Item class="ModuleScript" referent="3">
<Properties>
<string name="Name">Main</string>
<string name="Source">-- hello, from main</string>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,26 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="DataModel" referent="0">
<Properties>
<string name="Name">infer-service-name</string>
</Properties>
<Item class="StarterPlayer" referent="1">
<Properties>
<string name="Name">StarterPlayer</string>
</Properties>
<Item class="StarterCharacterScripts" referent="2">
<Properties>
<string name="Name">StarterCharacterScripts</string>
</Properties>
</Item>
<Item class="StarterPlayerScripts" referent="3">
<Properties>
<string name="Name">StarterPlayerScripts</string>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,23 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="ModuleScript" referent="0">
<Properties>
<string name="Name">json_as_lua</string>
<string name="Source">return {
["1invalidident"] = "nice",
array = {1, 2, 3},
["false"] = false,
float = 1234.5452,
int = 1234,
null = nil,
object = {
hello = "world",
},
["true"] = true,
}</string>
</Properties>
</Item>
</roblox>

View File

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

View File

@@ -0,0 +1,16 @@
{
"name": "infer-service-name",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"Main": {
"$path": "main.lua"
}
},
"HttpService": {
"$properties": {
"HttpEnabled": true
}
}
}
}

View File

@@ -0,0 +1 @@
-- hello, from main

View File

@@ -0,0 +1,11 @@
{
"name": "infer-service-name",
"tree": {
"$className": "DataModel",
"StarterPlayer": {
"StarterPlayerScripts": {},
"StarterCharacterScripts": {}
}
}
}

View File

@@ -0,0 +1 @@
-- hello, from main

View File

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

View File

@@ -0,0 +1,6 @@
{
"name": "json_as_lua",
"tree": {
"$path": "make-me-a-script.json"
}
}

View File

@@ -0,0 +1,12 @@
{
"array": [1, 2, 3],
"object": {
"hello": "world"
},
"true": true,
"false": false,
"null": null,
"int": 1234,
"float": 1234.5452,
"1invalidident": "nice"
}

View File

@@ -1 +0,0 @@
This is a bare text file with no project.

View File

@@ -0,0 +1,6 @@
{
"name": "rbxmx_ref",
"tree": {
"$path": "model.rbxmx"
}
}

View File

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

View File

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

View File

@@ -1 +0,0 @@
Hello, world!

View File

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

View File

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

View File

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

View File

@@ -28,16 +28,19 @@ gen_build_tests! {
csv_in_folder, csv_in_folder,
deep_nesting, deep_nesting,
gitkeep, gitkeep,
infer_service_name,
infer_starter_player,
init_meta_class_name, init_meta_class_name,
init_meta_properties, init_meta_properties,
init_with_children, init_with_children,
json_as_lua,
json_model_in_folder, json_model_in_folder,
json_model_legacy_name, json_model_legacy_name,
module_in_folder, module_in_folder,
module_init, module_init,
plain_gitkeep,
rbxm_in_folder, rbxm_in_folder,
rbxmx_in_folder, rbxmx_in_folder,
rbxmx_ref,
script_meta_disabled, script_meta_disabled,
server_in_folder, server_in_folder,
server_init, server_init,
@@ -52,16 +55,6 @@ gen_build_tests! {
ignore_glob_spec, ignore_glob_spec,
} }
#[test]
fn build_plain_txt() {
run_build_test("plain.txt");
}
#[test]
fn build_rbxmx_ref() {
run_build_test("rbxmx_ref.rbxmx");
}
fn run_build_test(test_name: &str) { fn run_build_test(test_name: &str) {
let build_test_path = get_build_tests_path(); let build_test_path = get_build_tests_path();
let working_dir = get_working_dir_path(); let working_dir = get_working_dir_path();

View File

@@ -35,7 +35,7 @@ fn scripts() {
read_response.intern_and_redact(&mut redactions, root_id) read_response.intern_and_redact(&mut redactions, root_id)
); );
fs::write(session.path().join("foo.lua"), "Updated foo!").unwrap(); fs::write(session.path().join("src/foo.lua"), "Updated foo!").unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap(); let subscribe_response = session.get_api_subscribe(0).unwrap();
assert_yaml_snapshot!( assert_yaml_snapshot!(
@@ -51,26 +51,6 @@ fn scripts() {
}); });
} }
#[test]
fn just_txt() {
run_serve_test("just_txt.txt", |session, mut redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
assert_yaml_snapshot!("just_txt_info", redactions.redacted_yaml(info));
let read_response = session.get_api_read(root_id).unwrap();
assert_yaml_snapshot!(
"just_txt_all",
read_response.intern_and_redact(&mut redactions, root_id)
);
fs::write(session.path(), "Changed content!").unwrap();
// TODO: Directly served files currently don't trigger changed events!
});
}
#[test] #[test]
fn add_folder() { fn add_folder() {
run_serve_test("add_folder", |session, mut redactions| { run_serve_test("add_folder", |session, mut redactions| {
@@ -85,7 +65,7 @@ fn add_folder() {
read_response.intern_and_redact(&mut redactions, root_id) read_response.intern_and_redact(&mut redactions, root_id)
); );
fs::create_dir(session.path().join("my-new-folder")).unwrap(); fs::create_dir(session.path().join("src/my-new-folder")).unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap(); let subscribe_response = session.get_api_subscribe(0).unwrap();
assert_yaml_snapshot!( assert_yaml_snapshot!(
@@ -115,7 +95,7 @@ fn remove_file() {
read_response.intern_and_redact(&mut redactions, root_id) read_response.intern_and_redact(&mut redactions, root_id)
); );
fs::remove_file(session.path().join("hello.txt")).unwrap(); fs::remove_file(session.path().join("src/hello.txt")).unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap(); let subscribe_response = session.get_api_subscribe(0).unwrap();
assert_yaml_snapshot!( assert_yaml_snapshot!(
@@ -145,7 +125,7 @@ fn edit_init() {
read_response.intern_and_redact(&mut redactions, root_id) read_response.intern_and_redact(&mut redactions, root_id)
); );
fs::write(session.path().join("init.lua"), b"-- Edited contents").unwrap(); fs::write(session.path().join("src/init.lua"), b"-- Edited contents").unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap(); let subscribe_response = session.get_api_subscribe(0).unwrap();
assert_yaml_snapshot!( assert_yaml_snapshot!(
@@ -191,7 +171,7 @@ fn move_folder_of_stuff() {
// We're hoping that this rename gets picked up as one event. This test // We're hoping that this rename gets picked up as one event. This test
// will fail otherwise. // will fail otherwise.
fs::rename(stuff_path, session.path().join("new-stuff")).unwrap(); fs::rename(stuff_path, session.path().join("src/new-stuff")).unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap(); let subscribe_response = session.get_api_subscribe(0).unwrap();
assert_yaml_snapshot!( assert_yaml_snapshot!(

View File

@@ -3,15 +3,16 @@ use std::{env, error::Error, panic, process};
use backtrace::Backtrace; use backtrace::Backtrace;
use structopt::StructOpt; use structopt::StructOpt;
use librojo::cli::{self, Options, Subcommand}; use librojo::cli::{self, GlobalOptions, Options, Subcommand};
fn run(subcommand: Subcommand) -> Result<(), Box<dyn Error>> { fn run(global: GlobalOptions, subcommand: Subcommand) -> Result<(), Box<dyn Error>> {
match subcommand { match subcommand {
Subcommand::Init(init_options) => cli::init(init_options)?, Subcommand::Init(init_options) => cli::init(init_options)?,
Subcommand::Serve(serve_options) => cli::serve(serve_options)?, Subcommand::Serve(serve_options) => cli::serve(global, serve_options)?,
Subcommand::Build(build_options) => cli::build(build_options)?, Subcommand::Build(build_options) => cli::build(build_options)?,
Subcommand::Upload(upload_options) => cli::upload(upload_options)?, Subcommand::Upload(upload_options) => cli::upload(upload_options)?,
Subcommand::Doc => cli::doc()?, Subcommand::Doc => cli::doc()?,
Subcommand::Plugin(plugin_options) => cli::plugin(plugin_options)?,
} }
Ok(()) Ok(())
@@ -22,7 +23,7 @@ fn main() {
// PanicInfo's payload is usually a &'static str or String. // PanicInfo's payload is usually a &'static str or String.
// See: https://doc.rust-lang.org/beta/std/panic/struct.PanicInfo.html#method.payload // See: https://doc.rust-lang.org/beta/std/panic/struct.PanicInfo.html#method.payload
let message = match panic_info.payload().downcast_ref::<&str>() { let message = match panic_info.payload().downcast_ref::<&str>() {
Some(message) => message.to_string(), Some(&message) => message.to_string(),
None => match panic_info.payload().downcast_ref::<String>() { None => match panic_info.payload().downcast_ref::<String>() {
Some(message) => message.clone(), Some(message) => message.clone(),
None => "<no message>".to_string(), None => "<no message>".to_string(),
@@ -63,10 +64,10 @@ fn main() {
let options = Options::from_args(); let options = Options::from_args();
let log_filter = match options.verbosity { let log_filter = match options.global.verbosity {
0 => "warn", 0 => "info",
1 => "warn,librojo=info", 1 => "info,librojo=debug",
2 => "warn,librojo=trace", 2 => "info,librojo=trace",
_ => "trace", _ => "trace",
}; };
@@ -77,9 +78,10 @@ fn main() {
.format_timestamp(None) .format_timestamp(None)
// Indent following lines equal to the log level label, like `[ERROR] ` // Indent following lines equal to the log level label, like `[ERROR] `
.format_indent(Some(8)) .format_indent(Some(8))
.write_style(options.global.color.into())
.init(); .init();
if let Err(err) = run(options.subcommand) { if let Err(err) = run(options.global, options.subcommand) {
log::error!("{}", err); log::error!("{}", err);
let mut current_err: &dyn Error = &*err; let mut current_err: &dyn Error = &*err;

View File

@@ -63,15 +63,6 @@ impl ChangeProcessor {
.spawn(move || { .spawn(move || {
log::trace!("ChangeProcessor thread started"); log::trace!("ChangeProcessor thread started");
#[allow(
// Crossbeam's select macro generates code that Clippy doesn't like,
// and Clippy blames us for it.
clippy::drop_copy,
// Crossbeam uses 0 as *const _ and Clippy doesn't like that either,
// but this isn't our fault.
clippy::zero_ptr,
)]
loop { loop {
select! { select! {
recv(vfs_receiver) -> event => { recv(vfs_receiver) -> event => {
@@ -187,7 +178,7 @@ impl JobThreadContext {
if let Some(instigating_source) = &instance.metadata().instigating_source { if let Some(instigating_source) = &instance.metadata().instigating_source {
match instigating_source { match instigating_source {
InstigatingSource::Path(path) => fs::remove_file(path).unwrap(), InstigatingSource::Path(path) => fs::remove_file(path).unwrap(),
InstigatingSource::ProjectNode(_, _, _) => { InstigatingSource::ProjectNode(_, _, _, _) => {
log::warn!( log::warn!(
"Cannot remove instance {}, it's from a project file", "Cannot remove instance {}, it's from a project file",
id id
@@ -235,7 +226,7 @@ impl JobThreadContext {
log::warn!("Cannot change Source to non-string value."); log::warn!("Cannot change Source to non-string value.");
} }
} }
InstigatingSource::ProjectNode(_, _, _) => { InstigatingSource::ProjectNode(_, _, _, _) => {
log::warn!( log::warn!(
"Cannot remove instance {}, it's from a project file", "Cannot remove instance {}, it's from a project file",
id id
@@ -272,12 +263,11 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: RbxId) -> Optio
let instigating_source = match &metadata.instigating_source { let instigating_source = match &metadata.instigating_source {
Some(path) => path, Some(path) => path,
None => { None => {
log::warn!( log::error!(
"Instance {} did not have an instigating source, but was considered for an update.", "Instance {} did not have an instigating source, but was considered for an update.",
id id
); );
log::warn!("This is a Rojo bug. Please file an issue!"); log::error!("This is a bug. Please file an issue!");
return None; return None;
} }
}; };
@@ -285,44 +275,50 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: RbxId) -> Optio
// How we process a file change event depends on what created this // How we process a file change event depends on what created this
// file/folder in the first place. // file/folder in the first place.
let applied_patch_set = match instigating_source { let applied_patch_set = match instigating_source {
InstigatingSource::Path(path) => { InstigatingSource::Path(path) => match vfs.metadata(path).with_not_found() {
let maybe_meta = vfs.metadata(path).with_not_found().unwrap(); Ok(Some(_)) => {
// Our instance was previously created from a path and that
// path still exists. We can generate a snapshot starting at
// that path and use it as the source for our patch.
match maybe_meta { let snapshot = match snapshot_from_vfs(&metadata.context, &vfs, &path) {
Some(_meta) => { Ok(Some(snapshot)) => snapshot,
// Our instance was previously created from a path and Ok(None) => {
// that path still exists. We can generate a snapshot log::error!(
// starting at that path and use it as the source for "Snapshot did not return an instance from path {}",
// our patch. path.display()
);
log::error!("This may be a bug!");
return None;
}
Err(err) => {
log::error!("Snapshot error: {}", ErrorDisplay(err));
return None;
}
};
let snapshot = match snapshot_from_vfs(&metadata.context, &vfs, &path) { let patch_set = compute_patch_set(&snapshot, &tree, id);
Ok(maybe_snapshot) => { apply_patch_set(tree, patch_set)
maybe_snapshot.expect("snapshot did not return an instance")
}
Err(err) => {
log::error!("Snapshot error: {}", ErrorDisplay(err));
return None;
}
};
let patch_set = compute_patch_set(&snapshot, &tree, id);
apply_patch_set(tree, patch_set)
}
None => {
// Our instance was previously created from a path, but
// that path no longer exists.
//
// We associate deleting the instigating file for an
// instance with deleting that instance.
let mut patch_set = PatchSet::new();
patch_set.removed_instances.push(id);
apply_patch_set(tree, patch_set)
}
} }
} Ok(None) => {
InstigatingSource::ProjectNode(project_path, instance_name, project_node) => { // Our instance was previously created from a path, but that
// path no longer exists.
//
// We associate deleting the instigating file for an
// instance with deleting that instance.
let mut patch_set = PatchSet::new();
patch_set.removed_instances.push(id);
apply_patch_set(tree, patch_set)
}
Err(err) => {
log::error!("Error processing filesystem change: {}", ErrorDisplay(err));
return None;
}
},
InstigatingSource::ProjectNode(project_path, instance_name, project_node, parent_class) => {
// This instance is the direct subject of a project node. Since // This instance is the direct subject of a project node. Since
// there might be information associated with our instance from // there might be information associated with our instance from
// the project file, we snapshot the entire project node again. // the project file, we snapshot the entire project node again.
@@ -333,12 +329,18 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: RbxId) -> Optio
instance_name, instance_name,
project_node, project_node,
&vfs, &vfs,
parent_class.as_ref().map(|name| name.as_str()),
); );
let snapshot = match snapshot_result { let snapshot = match snapshot_result {
Ok(maybe_snapshot) => maybe_snapshot.expect("snapshot did not return an instance"), Ok(Some(snapshot)) => snapshot,
Ok(None) => {
log::error!("Snapshot did not return an instance from a project node.");
log::error!("This is a bug!");
return None;
}
Err(err) => { Err(err) => {
log::error!("Snapshot error: {}", ErrorDisplay(err)); log::error!("{}", ErrorDisplay(err));
return None; return None;
} }
}; };

View File

@@ -1,15 +1,13 @@
use std::{ use std::{
fs::File, fs::File,
io::{self, BufWriter, Write}, io::{BufWriter, Write},
}; };
use memofs::Vfs; use memofs::Vfs;
use snafu::{ResultExt, Snafu}; use thiserror::Error;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use crate::{ use crate::{cli::BuildCommand, serve_session::ServeSession, snapshot::RojoTree};
cli::BuildCommand, project::ProjectError, serve_session::ServeSession, snapshot::RojoTree,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputKind { enum OutputKind {
@@ -31,50 +29,22 @@ fn detect_output_kind(options: &BuildCommand) -> Option<OutputKind> {
} }
} }
#[derive(Debug, Snafu)] #[derive(Debug, Error)]
pub struct BuildError(Error);
#[derive(Debug, Snafu)]
enum Error { enum Error {
#[snafu(display("Could not detect what kind of file to create"))] #[error("Could not detect what kind of file to build. Expected output file to end in .rbxl, .rbxlx, .rbxm, or .rbxmx.")]
UnknownOutputKind, UnknownOutputKind,
#[snafu(display("{}", source))]
Io { source: io::Error },
#[snafu(display("{}", source))]
XmlModelEncode { source: rbx_xml::EncodeError },
#[snafu(display("Binary model error: {:?}", source))]
BinaryModelEncode {
#[snafu(source(false))]
source: rbx_binary::EncodeError,
},
#[snafu(display("{}", source))]
Project { source: ProjectError },
}
impl From<rbx_binary::EncodeError> for Error {
fn from(source: rbx_binary::EncodeError) -> Self {
Error::BinaryModelEncode { source }
}
} }
fn xml_encode_config() -> rbx_xml::EncodeOptions { fn xml_encode_config() -> rbx_xml::EncodeOptions {
rbx_xml::EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown) rbx_xml::EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown)
} }
pub fn build(options: BuildCommand) -> Result<(), BuildError> { pub fn build(options: BuildCommand) -> Result<(), anyhow::Error> {
Ok(build_inner(options)?)
}
fn build_inner(options: BuildCommand) -> Result<(), Error> {
log::trace!("Constructing in-memory filesystem"); log::trace!("Constructing in-memory filesystem");
let vfs = Vfs::new_default(); let vfs = Vfs::new_default();
let session = ServeSession::new(vfs, &options.absolute_project()); let session = ServeSession::new(vfs, &options.absolute_project())?;
let mut cursor = session.message_queue().cursor(); let mut cursor = session.message_queue().cursor();
{ {
@@ -98,14 +68,14 @@ fn build_inner(options: BuildCommand) -> Result<(), Error> {
Ok(()) Ok(())
} }
fn write_model(tree: &RojoTree, options: &BuildCommand) -> Result<(), Error> { fn write_model(tree: &RojoTree, options: &BuildCommand) -> Result<(), anyhow::Error> {
let output_kind = detect_output_kind(&options).ok_or(Error::UnknownOutputKind)?; let output_kind = detect_output_kind(&options).ok_or(Error::UnknownOutputKind)?;
log::debug!("Hoping to generate file of type {:?}", output_kind); log::debug!("Hoping to generate file of type {:?}", output_kind);
let root_id = tree.get_root_id(); let root_id = tree.get_root_id();
log::trace!("Opening output file for write"); log::trace!("Opening output file for write");
let file = File::create(&options.output).context(Io)?; let file = File::create(&options.output)?;
let mut file = BufWriter::new(file); let mut file = BufWriter::new(file);
match output_kind { match output_kind {
@@ -113,8 +83,7 @@ fn write_model(tree: &RojoTree, options: &BuildCommand) -> Result<(), Error> {
// Model files include the root instance of the tree and all its // Model files include the root instance of the tree and all its
// descendants. // descendants.
rbx_xml::to_writer(&mut file, tree.inner(), &[root_id], xml_encode_config()) rbx_xml::to_writer(&mut file, tree.inner(), &[root_id], xml_encode_config())?;
.context(XmlModelEncode)?;
} }
OutputKind::Rbxlx => { OutputKind::Rbxlx => {
// Place files don't contain an entry for the DataModel, but our // Place files don't contain an entry for the DataModel, but our
@@ -123,8 +92,7 @@ fn write_model(tree: &RojoTree, options: &BuildCommand) -> Result<(), Error> {
let root_instance = tree.get_instance(root_id).unwrap(); let root_instance = tree.get_instance(root_id).unwrap();
let top_level_ids = root_instance.children(); let top_level_ids = root_instance.children();
rbx_xml::to_writer(&mut file, tree.inner(), top_level_ids, xml_encode_config()) rbx_xml::to_writer(&mut file, tree.inner(), top_level_ids, xml_encode_config())?;
.context(XmlModelEncode)?;
} }
OutputKind::Rbxm => { OutputKind::Rbxm => {
rbx_binary::encode(tree.inner(), &[root_id], &mut file)?; rbx_binary::encode(tree.inner(), &[root_id], &mut file)?;
@@ -141,7 +109,14 @@ fn write_model(tree: &RojoTree, options: &BuildCommand) -> Result<(), Error> {
} }
} }
file.flush().context(Io)?; file.flush()?;
let filename = options
.output
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("<invalid utf-8>");
log::info!("Built project to {}", filename);
Ok(()) Ok(())
} }

View File

@@ -1,26 +1,4 @@
use opener::{open, OpenError}; pub fn doc() -> Result<(), anyhow::Error> {
use snafu::Snafu; opener::open("https://rojo.space/docs")?;
#[derive(Debug, Snafu)]
pub struct DocError(Error);
#[derive(Debug, Snafu)]
enum Error {
Open { source: OpenError },
}
impl From<OpenError> for Error {
fn from(source: OpenError) -> Self {
Error::Open { source }
}
}
pub fn doc() -> Result<(), DocError> {
doc_inner()?;
Ok(())
}
fn doc_inner() -> Result<(), Error> {
open("https://rojo.space/docs")?;
Ok(()) Ok(())
} }

View File

@@ -5,7 +5,7 @@ use std::{
process::{Command, Stdio}, process::{Command, Stdio},
}; };
use snafu::Snafu; use thiserror::Error;
use crate::cli::{InitCommand, InitKind}; use crate::cli::{InitCommand, InitKind};
@@ -20,32 +20,16 @@ static PLACE_PROJECT: &str =
static PLACE_README: &str = include_str!("../../assets/default-place-project/README.md"); static PLACE_README: &str = include_str!("../../assets/default-place-project/README.md");
static PLACE_GIT_IGNORE: &str = include_str!("../../assets/default-place-project/gitignore.txt"); static PLACE_GIT_IGNORE: &str = include_str!("../../assets/default-place-project/gitignore.txt");
#[derive(Debug, Snafu)] #[derive(Debug, Error)]
pub struct InitError(Error);
#[derive(Debug, Snafu)]
enum Error { enum Error {
#[snafu(display("A project file named default.project.json already exists in this folder"))] #[error("A project file named default.project.json already exists in this folder")]
AlreadyExists, AlreadyExists,
#[snafu(display("git init failed"))] #[error("git init failed")]
GitInit, GitInit,
#[snafu(display("I/O error"))]
Io { source: io::Error },
} }
impl From<io::Error> for Error { pub fn init(options: InitCommand) -> Result<(), anyhow::Error> {
fn from(source: io::Error) -> Self {
Self::Io { source }
}
}
pub fn init(options: InitCommand) -> Result<(), InitError> {
Ok(init_inner(options)?)
}
fn init_inner(options: InitCommand) -> Result<(), Error> {
let base_path = options.absolute_path(); let base_path = options.absolute_path();
fs::create_dir_all(&base_path)?; fs::create_dir_all(&base_path)?;
@@ -65,7 +49,7 @@ fn init_inner(options: InitCommand) -> Result<(), Error> {
} }
} }
fn init_place(base_path: &Path, project_params: ProjectParams) -> Result<(), Error> { fn init_place(base_path: &Path, project_params: ProjectParams) -> Result<(), anyhow::Error> {
eprintln!("Creating new place project '{}'", project_params.name); eprintln!("Creating new place project '{}'", project_params.name);
let project_file = project_params.render_template(PLACE_PROJECT); let project_file = project_params.render_template(PLACE_PROJECT);
@@ -109,7 +93,7 @@ fn init_place(base_path: &Path, project_params: ProjectParams) -> Result<(), Err
Ok(()) Ok(())
} }
fn init_model(base_path: &Path, project_params: ProjectParams) -> Result<(), Error> { fn init_model(base_path: &Path, project_params: ProjectParams) -> Result<(), anyhow::Error> {
eprintln!("Creating new model project '{}'", project_params.name); eprintln!("Creating new model project '{}'", project_params.name);
let project_file = project_params.render_template(MODEL_PROJECT); let project_file = project_params.render_template(MODEL_PROJECT);
@@ -147,14 +131,14 @@ impl ProjectParams {
} }
/// Attempt to initialize a Git repository if necessary, and create .gitignore. /// Attempt to initialize a Git repository if necessary, and create .gitignore.
fn try_git_init(path: &Path, git_ignore: &str) -> Result<(), Error> { fn try_git_init(path: &Path, git_ignore: &str) -> Result<(), anyhow::Error> {
if should_git_init(path) { if should_git_init(path) {
log::debug!("Initializing Git repository..."); log::debug!("Initializing Git repository...");
let status = Command::new("git").arg("init").current_dir(path).status()?; let status = Command::new("git").arg("init").current_dir(path).status()?;
if !status.success() { if !status.success() {
return Err(Error::GitInit); return Err(Error::GitInit.into());
} }
} }
@@ -186,7 +170,7 @@ fn should_git_init(path: &Path) -> bool {
} }
/// Write a file if it does not exist yet, otherwise, leave it alone. /// Write a file if it does not exist yet, otherwise, leave it alone.
fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), Error> { fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), anyhow::Error> {
let file_res = OpenOptions::new().write(true).create_new(true).open(path); let file_res = OpenOptions::new().write(true).create_new(true).open(path);
let mut file = match file_res { let mut file = match file_res {
@@ -205,7 +189,7 @@ fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), Error> {
} }
/// Try to create a project file and fail if it already exists. /// Try to create a project file and fail if it already exists.
fn try_create_project(base_path: &Path, contents: &str) -> Result<(), Error> { fn try_create_project(base_path: &Path, contents: &str) -> Result<(), anyhow::Error> {
let project_path = base_path.join("default.project.json"); let project_path = base_path.join("default.project.json");
let file_res = OpenOptions::new() let file_res = OpenOptions::new()
@@ -217,7 +201,7 @@ fn try_create_project(base_path: &Path, contents: &str) -> Result<(), Error> {
Ok(file) => file, Ok(file) => file,
Err(err) => { Err(err) => {
return match err.kind() { return match err.kind() {
io::ErrorKind::AlreadyExists => Err(Error::AlreadyExists), io::ErrorKind::AlreadyExists => Err(Error::AlreadyExists.into()),
_ => Err(err.into()), _ => Err(err.into()),
} }
} }

View File

@@ -3,6 +3,7 @@
mod build; mod build;
mod doc; mod doc;
mod init; mod init;
mod plugin;
mod serve; mod serve;
mod upload; mod upload;
@@ -16,10 +17,12 @@ use std::{
}; };
use structopt::StructOpt; use structopt::StructOpt;
use thiserror::Error;
pub use self::build::*; pub use self::build::*;
pub use self::doc::*; pub use self::doc::*;
pub use self::init::*; pub use self::init::*;
pub use self::plugin::*;
pub use self::serve::*; pub use self::serve::*;
pub use self::upload::*; pub use self::upload::*;
@@ -27,16 +30,73 @@ pub use self::upload::*;
#[derive(Debug, StructOpt)] #[derive(Debug, StructOpt)]
#[structopt(name = "Rojo", about, author)] #[structopt(name = "Rojo", about, author)]
pub struct Options { pub struct Options {
/// Sets verbosity level. Can be specified multiple times. #[structopt(flatten)]
#[structopt(long = "verbose", short, global(true), parse(from_occurrences))] pub global: GlobalOptions,
pub verbosity: u8,
/// Subcommand to run in this invocation. /// Subcommand to run in this invocation.
#[structopt(subcommand)] #[structopt(subcommand)]
pub subcommand: Subcommand, pub subcommand: Subcommand,
} }
/// All of Rojo's subcommands. #[derive(Debug, StructOpt)]
pub struct GlobalOptions {
/// Sets verbosity level. Can be specified multiple times.
#[structopt(long("verbose"), short, global(true), parse(from_occurrences))]
pub verbosity: u8,
/// Set color behavior. Valid values are auto, always, and never.
#[structopt(long("color"), global(true), default_value("auto"))]
pub color: ColorChoice,
}
#[derive(Debug, Clone, Copy)]
pub enum ColorChoice {
Auto,
Always,
Never,
}
impl FromStr for ColorChoice {
type Err = ColorChoiceParseError;
fn from_str(source: &str) -> Result<Self, Self::Err> {
match source {
"auto" => Ok(ColorChoice::Auto),
"always" => Ok(ColorChoice::Always),
"never" => Ok(ColorChoice::Never),
_ => Err(ColorChoiceParseError {
attempted: source.to_owned(),
}),
}
}
}
impl From<ColorChoice> for termcolor::ColorChoice {
fn from(value: ColorChoice) -> Self {
match value {
ColorChoice::Auto => termcolor::ColorChoice::Auto,
ColorChoice::Always => termcolor::ColorChoice::Always,
ColorChoice::Never => termcolor::ColorChoice::Never,
}
}
}
impl From<ColorChoice> for env_logger::WriteStyle {
fn from(value: ColorChoice) -> Self {
match value {
ColorChoice::Auto => env_logger::WriteStyle::Auto,
ColorChoice::Always => env_logger::WriteStyle::Always,
ColorChoice::Never => env_logger::WriteStyle::Never,
}
}
}
#[derive(Debug, Error)]
#[error("Invalid color choice '{attempted}'. Valid values are: auto, always, never")]
pub struct ColorChoiceParseError {
attempted: String,
}
#[derive(Debug, StructOpt)] #[derive(Debug, StructOpt)]
pub enum Subcommand { pub enum Subcommand {
/// Creates a new Rojo project. /// Creates a new Rojo project.
@@ -53,6 +113,9 @@ pub enum Subcommand {
/// Open Rojo's documentation in your browser. /// Open Rojo's documentation in your browser.
Doc, Doc,
/// Manages Rojo's Roblox Studio plugin.
Plugin(PluginCommand),
} }
/// Initializes a new Rojo project. /// Initializes a new Rojo project.
@@ -229,3 +292,21 @@ fn resolve_path(path: &Path) -> Cow<'_, Path> {
Cow::Owned(env::current_dir().unwrap().join(path)) Cow::Owned(env::current_dir().unwrap().join(path))
} }
} }
#[derive(Debug, StructOpt)]
pub enum PluginSubcommand {
/// Install the plugin in Roblox Studio's plugins folder. If the plugin is
/// already installed, installing it again will overwrite the current plugin
/// file.
Install,
/// Removes the plugin if it is installed.
Uninstall,
}
/// Install Rojo's plugin.
#[derive(Debug, StructOpt)]
pub struct PluginCommand {
#[structopt(subcommand)]
subcommand: PluginSubcommand,
}

70
src/cli/plugin.rs Normal file
View File

@@ -0,0 +1,70 @@
use std::{
fs::{self, File},
io::BufWriter,
};
use anyhow::Result;
use memofs::{InMemoryFs, Vfs, VfsSnapshot};
use roblox_install::RobloxStudio;
use crate::{
cli::{PluginCommand, PluginSubcommand},
serve_session::ServeSession,
};
static PLUGIN_BINCODE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/plugin.bincode"));
static PLUGIN_FILE_NAME: &str = "RojoManagedPlugin.rbxm";
pub fn plugin(options: PluginCommand) -> Result<()> {
match options.subcommand {
PluginSubcommand::Install => install_plugin(),
PluginSubcommand::Uninstall => uninstall_plugin(),
}
}
pub fn install_plugin() -> Result<()> {
let plugin_snapshot: VfsSnapshot = bincode::deserialize(PLUGIN_BINCODE)
.expect("Rojo's plugin was not properly packed into Rojo's binary");
let studio = RobloxStudio::locate()?;
let plugins_folder_path = studio.plugins_path();
if !plugins_folder_path.exists() {
log::debug!("Creating Roblox Studio plugins folder");
fs::create_dir(plugins_folder_path)?;
}
let mut in_memory_fs = InMemoryFs::new();
in_memory_fs.load_snapshot("plugin", plugin_snapshot)?;
let vfs = Vfs::new(in_memory_fs);
let session = ServeSession::new(vfs, "plugin")?;
let plugin_path = plugins_folder_path.join(PLUGIN_FILE_NAME);
log::debug!("Writing plugin to {}", plugin_path.display());
let mut file = BufWriter::new(File::create(plugin_path)?);
let tree = session.tree();
let root_id = tree.get_root_id();
rbx_binary::encode(tree.inner(), &[root_id], &mut file)?;
Ok(())
}
fn uninstall_plugin() -> Result<()> {
let studio = RobloxStudio::locate()?;
let plugin_path = studio.plugins_path().join(PLUGIN_FILE_NAME);
if plugin_path.exists() {
log::debug!("Removing existing plugin from {}", plugin_path.display());
fs::remove_file(plugin_path)?;
} else {
log::debug!("Plugin not installed at {}", plugin_path.display());
}
Ok(())
}

View File

@@ -3,28 +3,22 @@ use std::{
sync::Arc, sync::Arc,
}; };
use anyhow::Result;
use memofs::Vfs; use memofs::Vfs;
use snafu::Snafu;
use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor}; use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
use crate::{cli::ServeCommand, serve_session::ServeSession, web::LiveServer}; use crate::{
cli::{GlobalOptions, ServeCommand},
serve_session::ServeSession,
web::LiveServer,
};
const DEFAULT_PORT: u16 = 34872; const DEFAULT_PORT: u16 = 34872;
#[derive(Debug, Snafu)] pub fn serve(global: GlobalOptions, options: ServeCommand) -> Result<()> {
pub struct ServeError(Error);
#[derive(Debug, Snafu)]
enum Error {}
pub fn serve(options: ServeCommand) -> Result<(), ServeError> {
Ok(serve_inner(options)?)
}
fn serve_inner(options: ServeCommand) -> Result<(), Error> {
let vfs = Vfs::new_default(); let vfs = Vfs::new_default();
let session = Arc::new(ServeSession::new(vfs, &options.absolute_project())); let session = Arc::new(ServeSession::new(vfs, &options.absolute_project())?);
let port = options let port = options
.port .port
@@ -33,14 +27,14 @@ fn serve_inner(options: ServeCommand) -> Result<(), Error> {
let server = LiveServer::new(session); let server = LiveServer::new(session);
let _ = show_start_message(port); let _ = show_start_message(port, global.color.into());
server.start(port); server.start(port);
Ok(()) Ok(())
} }
fn show_start_message(port: u16) -> io::Result<()> { fn show_start_message(port: u16, color: ColorChoice) -> io::Result<()> {
let writer = BufferWriter::stdout(ColorChoice::Auto); let writer = BufferWriter::stdout(color);
let mut buffer = writer.buffer(); let mut buffer = writer.buffer();
writeln!(&mut buffer, "Rojo server listening:")?; writeln!(&mut buffer, "Rojo server listening:")?;

View File

@@ -1,45 +1,30 @@
use memofs::Vfs; use memofs::Vfs;
use reqwest::header::{ACCEPT, CONTENT_TYPE, COOKIE, USER_AGENT}; use reqwest::header::{ACCEPT, CONTENT_TYPE, COOKIE, USER_AGENT};
use snafu::{ResultExt, Snafu}; use thiserror::Error;
use crate::{auth_cookie::get_auth_cookie, cli::UploadCommand, common_setup}; use crate::{auth_cookie::get_auth_cookie, cli::UploadCommand, serve_session::ServeSession};
#[derive(Debug, Snafu)] #[derive(Debug, Error)]
pub struct UploadError(Error);
#[derive(Debug, Snafu)]
enum Error { enum Error {
#[snafu(display( #[error("Rojo could not find your Roblox auth cookie. Please pass one via --cookie.")]
"Rojo could not find your Roblox auth cookie. Please pass one via --cookie.",
))]
NeedAuthCookie, NeedAuthCookie,
#[snafu(display("XML model file encode error: {}", source))] #[error("The Roblox API returned an unexpected error: {body}")]
XmlModel { source: rbx_xml::EncodeError },
#[snafu(display("HTTP error: {}", source))]
Http { source: reqwest::Error },
#[snafu(display("Roblox API error: {}", body))]
RobloxApi { body: String }, RobloxApi { body: String },
} }
pub fn upload(options: UploadCommand) -> Result<(), UploadError> { pub fn upload(options: UploadCommand) -> Result<(), anyhow::Error> {
Ok(upload_inner(options)?)
}
fn upload_inner(options: UploadCommand) -> Result<(), Error> {
let cookie = options let cookie = options
.cookie .cookie
.clone() .clone()
.or_else(get_auth_cookie) .or_else(get_auth_cookie)
.ok_or(Error::NeedAuthCookie)?; .ok_or(Error::NeedAuthCookie)?;
log::trace!("Constructing in-memory filesystem");
let vfs = Vfs::new_default(); let vfs = Vfs::new_default();
let (_maybe_project, tree) = common_setup::start(&options.absolute_project(), &vfs); let session = ServeSession::new(vfs, &options.absolute_project())?;
let tree = session.tree();
let inner_tree = tree.inner(); let inner_tree = tree.inner();
let root_id = inner_tree.get_root_id(); let root_id = inner_tree.get_root_id();
let root_instance = inner_tree.get_instance(root_id).unwrap(); let root_instance = inner_tree.get_instance(root_id).unwrap();
@@ -55,7 +40,7 @@ fn upload_inner(options: UploadCommand) -> Result<(), Error> {
let config = rbx_xml::EncodeOptions::new() let config = rbx_xml::EncodeOptions::new()
.property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown); .property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown);
rbx_xml::to_writer(&mut buffer, tree.inner(), &encode_ids, config).context(XmlModel)?; rbx_xml::to_writer(&mut buffer, tree.inner(), &encode_ids, config)?;
let url = format!( let url = format!(
"https://data.roblox.com/Data/Upload.ashx?assetid={}", "https://data.roblox.com/Data/Upload.ashx?assetid={}",
@@ -72,13 +57,13 @@ fn upload_inner(options: UploadCommand) -> Result<(), Error> {
.header(CONTENT_TYPE, "application/xml") .header(CONTENT_TYPE, "application/xml")
.header(ACCEPT, "application/json") .header(ACCEPT, "application/json")
.body(buffer) .body(buffer)
.send() .send()?;
.context(Http)?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(Error::RobloxApi { return Err(Error::RobloxApi {
body: response.text().context(Http)?, body: response.text()?,
}); }
.into());
} }
Ok(()) Ok(())

View File

@@ -1,57 +0,0 @@
//! Initialization routines that are used by more than one Rojo command or
//! utility.
use std::path::Path;
use memofs::Vfs;
use rbx_dom_weak::RbxInstanceProperties;
use crate::{
project::Project,
snapshot::{
apply_patch_set, compute_patch_set, InstanceContext, InstancePropertiesWithMeta,
PathIgnoreRule, RojoTree,
},
snapshot_middleware::snapshot_from_vfs,
};
pub fn start(fuzzy_project_path: &Path, vfs: &Vfs) -> (Option<Project>, RojoTree) {
log::trace!("Loading project file from {}", fuzzy_project_path.display());
let maybe_project = Project::load_fuzzy(fuzzy_project_path).expect("TODO: Project load failed");
log::trace!("Constructing initial tree");
let mut tree = RojoTree::new(InstancePropertiesWithMeta {
properties: RbxInstanceProperties {
name: "ROOT".to_owned(),
class_name: "Folder".to_owned(),
properties: Default::default(),
},
metadata: Default::default(),
});
let root_id = tree.get_root_id();
let mut instance_context = InstanceContext::default();
if let Some(project) = &maybe_project {
let rules = project.glob_ignore_paths.iter().map(|glob| PathIgnoreRule {
glob: glob.clone(),
base_path: project.folder_location().to_path_buf(),
});
instance_context.add_path_ignore_rules(rules);
}
log::trace!("Generating snapshot of instances from VFS");
let snapshot = snapshot_from_vfs(&instance_context, vfs, &fuzzy_project_path)
.expect("snapshot failed")
.expect("snapshot did not return an instance");
log::trace!("Computing patch set");
let patch_set = compute_patch_set(&snapshot, &tree, root_id);
log::trace!("Applying patch set");
apply_patch_set(&mut tree, patch_set);
(maybe_project, tree)
}

View File

@@ -9,9 +9,9 @@ mod tree_view;
mod auth_cookie; mod auth_cookie;
mod change_processor; mod change_processor;
mod common_setup;
mod error; mod error;
mod glob; mod glob;
mod lua_ast;
mod message_queue; mod message_queue;
mod multimap; mod multimap;
mod path_serializer; mod path_serializer;

279
src/lua_ast.rs Normal file
View File

@@ -0,0 +1,279 @@
//! Defines module for defining a small Lua AST for simple codegen.
use std::{
fmt::{self, Write},
num::FpCategory,
};
/// Trait that helps turn a type into an equivalent Lua snippet.
///
/// Designed to be similar to the `Display` trait from Rust's std.
trait FmtLua {
fn fmt_lua(&self, output: &mut LuaStream<'_>) -> fmt::Result;
/// Used to override how this type will appear when used as a table key.
/// Some types, like strings, can have a shorter representation as a table
/// key than the default, safe approach.
fn fmt_table_key(&self, output: &mut LuaStream<'_>) -> fmt::Result {
write!(output, "[")?;
self.fmt_lua(output)?;
write!(output, "]")
}
}
pub(crate) enum Statement {
Return(Expression),
}
impl FmtLua for Statement {
fn fmt_lua(&self, output: &mut LuaStream<'_>) -> fmt::Result {
match self {
Self::Return(literal) => {
write!(output, "return ")?;
literal.fmt_lua(output)
}
}
}
}
impl fmt::Display for Statement {
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
let mut stream = LuaStream::new(output);
FmtLua::fmt_lua(self, &mut stream)
}
}
pub(crate) enum Expression {
Nil,
Bool(bool),
Number(f64),
String(String),
Table(Table),
/// Arrays are not technically distinct from other tables in Lua, but this
/// representation is more convenient.
Array(Vec<Expression>),
}
impl Expression {
pub fn table(entries: Vec<(Expression, Expression)>) -> Self {
Self::Table(Table { entries })
}
}
impl FmtLua for Expression {
fn fmt_lua(&self, output: &mut LuaStream<'_>) -> fmt::Result {
match self {
Self::Nil => write!(output, "nil"),
Self::Bool(inner) => inner.fmt_lua(output),
Self::Number(inner) => inner.fmt_lua(output),
Self::String(inner) => inner.fmt_lua(output),
Self::Table(inner) => inner.fmt_lua(output),
Self::Array(inner) => inner.fmt_lua(output),
}
}
fn fmt_table_key(&self, output: &mut LuaStream<'_>) -> fmt::Result {
match self {
Self::Nil => panic!("nil cannot be a table key"),
Self::Bool(inner) => inner.fmt_table_key(output),
Self::Number(inner) => inner.fmt_table_key(output),
Self::String(inner) => inner.fmt_table_key(output),
Self::Table(inner) => inner.fmt_table_key(output),
Self::Array(inner) => inner.fmt_table_key(output),
}
}
}
impl From<String> for Expression {
fn from(value: String) -> Self {
Self::String(value)
}
}
impl From<&'_ str> for Expression {
fn from(value: &str) -> Self {
Self::String(value.to_owned())
}
}
impl From<Table> for Expression {
fn from(value: Table) -> Self {
Self::Table(value)
}
}
impl FmtLua for bool {
fn fmt_lua(&self, output: &mut LuaStream<'_>) -> fmt::Result {
write!(output, "{}", self)
}
}
impl FmtLua for f64 {
fn fmt_lua(&self, output: &mut LuaStream<'_>) -> fmt::Result {
match self.classify() {
FpCategory::Nan => write!(output, "0/0"),
FpCategory::Infinite => {
if self.is_sign_positive() {
write!(output, "math.huge")
} else {
write!(output, "-math.huge")
}
}
_ => write!(output, "{}", self),
}
}
}
impl FmtLua for String {
fn fmt_lua(&self, output: &mut LuaStream<'_>) -> fmt::Result {
write!(output, "\"{}\"", self)
}
fn fmt_table_key(&self, output: &mut LuaStream<'_>) -> fmt::Result {
if is_valid_ident(self) {
write!(output, "{}", self)
} else {
write!(output, "[\"{}\"]", self)
}
}
}
impl FmtLua for Vec<Expression> {
fn fmt_lua(&self, output: &mut LuaStream<'_>) -> fmt::Result {
write!(output, "{{")?;
for (index, value) in self.iter().enumerate() {
value.fmt_lua(output)?;
if index < self.len() - 1 {
write!(output, ", ")?;
}
}
write!(output, "}}")
}
}
pub(crate) struct Table {
pub entries: Vec<(Expression, Expression)>,
}
impl FmtLua for Table {
fn fmt_lua(&self, output: &mut LuaStream<'_>) -> fmt::Result {
writeln!(output, "{{")?;
output.indent();
for (key, value) in &self.entries {
key.fmt_table_key(output)?;
write!(output, " = ")?;
value.fmt_lua(output)?;
writeln!(output, ",")?;
}
output.unindent();
write!(output, "}}")
}
}
fn is_valid_ident_char_start(value: char) -> bool {
value.is_ascii_alphabetic() || value == '_'
}
fn is_valid_ident_char(value: char) -> bool {
value.is_ascii_alphanumeric() || value == '_'
}
fn is_keyword(value: &str) -> bool {
match value {
"and" | "break" | "do" | "else" | "elseif" | "end" | "false" | "for" | "function"
| "if" | "in" | "local" | "nil" | "not" | "or" | "repeat" | "return" | "then" | "true"
| "until" | "while" => true,
_ => false,
}
}
/// Tells whether the given string is a valid Lua identifier.
fn is_valid_ident(value: &str) -> bool {
if is_keyword(value) {
return false;
}
let mut chars = value.chars();
match chars.next() {
Some(first) => {
if !is_valid_ident_char_start(first) {
return false;
}
}
None => return false,
}
chars.all(is_valid_ident_char)
}
/// Wraps a `fmt::Write` with additional tracking to do pretty-printing of Lua.
///
/// Behaves similarly to `fmt::Formatter`. This trait's relationship to `LuaFmt`
/// is very similar to `Formatter`'s relationship to `Display`.
struct LuaStream<'a> {
indent_level: usize,
is_start_of_line: bool,
inner: &'a mut (dyn fmt::Write + 'a),
}
impl fmt::Write for LuaStream<'_> {
/// Method to support the `write!` and `writeln!` macros. Instead of using a
/// trait directly, these macros just call `write_str` on their first
/// argument.
///
/// This method is also available on `io::Write` and `fmt::Write`.
fn write_str(&mut self, value: &str) -> fmt::Result {
let mut is_first_line = true;
for line in value.split('\n') {
if is_first_line {
is_first_line = false;
} else {
self.line()?;
}
if !line.is_empty() {
if self.is_start_of_line {
self.is_start_of_line = false;
let indentation = "\t".repeat(self.indent_level);
self.inner.write_str(&indentation)?;
}
self.inner.write_str(line)?;
}
}
Ok(())
}
}
impl<'a> LuaStream<'a> {
fn new(inner: &'a mut (dyn fmt::Write + 'a)) -> Self {
LuaStream {
indent_level: 0,
is_start_of_line: true,
inner,
}
}
fn indent(&mut self) {
self.indent_level += 1;
}
fn unindent(&mut self) {
assert!(self.indent_level > 0);
self.indent_level -= 1;
}
fn line(&mut self) -> fmt::Result {
self.is_start_of_line = true;
self.inner.write_str("\n")
}
}

View File

@@ -6,22 +6,26 @@ use std::{
use rbx_dom_weak::UnresolvedRbxValue; use rbx_dom_weak::UnresolvedRbxValue;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use snafu::{ResultExt, Snafu}; use thiserror::Error;
use crate::glob::Glob; use crate::glob::Glob;
static PROJECT_FILENAME: &str = "default.project.json"; static PROJECT_FILENAME: &str = "default.project.json";
/// Error type returned by any function that handles projects. /// Error type returned by any function that handles projects.
#[derive(Debug, Snafu)] #[derive(Debug, Error)]
pub struct ProjectError(Error); #[error(transparent)]
pub struct ProjectError(#[from] Error);
#[derive(Debug, Snafu)] #[derive(Debug, Error)]
enum Error { enum Error {
/// A general IO error occurred. #[error(transparent)]
Io { source: io::Error, path: PathBuf }, Io {
#[from]
source: io::Error,
},
/// An error with JSON parsing occurred. #[error("Error parsing Rojo project in path {}", .path.display())]
Json { Json {
source: serde_json::Error, source: serde_json::Error,
path: PathBuf, path: PathBuf,
@@ -125,14 +129,14 @@ impl Project {
} }
} }
fn load_exact(project_file_location: &Path) -> Result<Self, ProjectError> { fn load_exact(project_file_location: &Path) -> Result<Self, Error> {
let contents = fs::read_to_string(project_file_location).context(Io { let contents = fs::read_to_string(project_file_location)?;
path: project_file_location,
})?;
let mut project: Project = serde_json::from_str(&contents).context(Json { let mut project: Project =
path: project_file_location, serde_json::from_str(&contents).map_err(|source| Error::Json {
})?; source,
path: project_file_location.to_owned(),
})?;
project.file_location = project_file_location.to_path_buf(); project.file_location = project_file_location.to_path_buf();
project.check_compatibility(); project.check_compatibility();
@@ -140,10 +144,6 @@ impl Project {
Ok(project) Ok(project)
} }
pub fn save(&self) -> Result<(), ProjectError> {
unimplemented!()
}
/// Checks if there are any compatibility issues with this project file and /// Checks if there are any compatibility issues with this project file and
/// warns the user if there are any. /// warns the user if there are any.
fn check_compatibility(&self) { fn check_compatibility(&self) {

View File

@@ -1,20 +1,25 @@
use std::{ use std::{
collections::HashSet, collections::HashSet,
path::Path, path::{Path, PathBuf},
sync::{Arc, Mutex, MutexGuard}, sync::{Arc, Mutex, MutexGuard},
time::Instant, time::Instant,
}; };
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use memofs::Vfs; use memofs::Vfs;
use rbx_dom_weak::RbxInstanceProperties;
use thiserror::Error;
use crate::{ use crate::{
change_processor::ChangeProcessor, change_processor::ChangeProcessor,
common_setup,
message_queue::MessageQueue, message_queue::MessageQueue,
project::Project, project::{Project, ProjectError},
session_id::SessionId, session_id::SessionId,
snapshot::{AppliedPatchSet, PatchSet, RojoTree}, snapshot::{
apply_patch_set, compute_patch_set, AppliedPatchSet, InstanceContext,
InstancePropertiesWithMeta, PatchSet, PathIgnoreRule, RojoTree,
},
snapshot_middleware::{snapshot_from_vfs, SnapshotError},
}; };
/// Contains all of the state for a Rojo serve session. /// Contains all of the state for a Rojo serve session.
@@ -43,15 +48,12 @@ pub struct ServeSession {
/// diagnostics. /// diagnostics.
start_time: Instant, start_time: Instant,
/// The root project for the serve session, if there was one defined. /// The root project for the serve session.
/// ///
/// This will be defined if a folder with a `default.project.json` file was /// This will be defined if a folder with a `default.project.json` file was
/// used for starting the serve session, or if the user specified a full /// used for starting the serve session, or if the user specified a full
/// path to a `.project.json` file. /// path to a `.project.json` file.
/// root_project: Project,
/// If `root_project` is None, values from the project should be treated as
/// their defaults.
root_project: Option<Project>,
/// A randomly generated ID for this serve session. It's used to ensure that /// A randomly generated ID for this serve session. It's used to ensure that
/// a client doesn't begin connecting to a different server part way through /// a client doesn't begin connecting to a different server part way through
@@ -82,24 +84,57 @@ pub struct ServeSession {
tree_mutation_sender: Sender<PatchSet>, tree_mutation_sender: Sender<PatchSet>,
} }
/// Methods that need thread-safety bounds on VfsFetcher are limited to this
/// block to prevent needing to spread Send + Sync + 'static into everything
/// that handles ServeSession.
impl ServeSession { impl ServeSession {
/// Start a new serve session from the given in-memory filesystem and start /// Start a new serve session from the given in-memory filesystem and start
/// path. /// path.
/// ///
/// The project file is expected to be loaded out-of-band since it's /// The project file is expected to be loaded out-of-band since it's
/// currently loaded from the filesystem directly instead of through the /// currently loaded from the filesystem directly instead of through the
/// in-memory filesystem layer. /// in-memory filesystem layer.
pub fn new<P: AsRef<Path>>(vfs: Vfs, start_path: P) -> Self { pub fn new<P: AsRef<Path>>(vfs: Vfs, start_path: P) -> Result<Self, ServeSessionError> {
let start_path = start_path.as_ref(); let start_path = start_path.as_ref();
log::trace!("Starting new ServeSession at path {}", start_path.display(),);
let start_time = Instant::now(); let start_time = Instant::now();
let (root_project, tree) = common_setup::start(start_path, &vfs); log::trace!("Starting new ServeSession at path {}", start_path.display());
log::debug!("Loading project file from {}", start_path.display());
let root_project =
Project::load_fuzzy(start_path)?.ok_or_else(|| ServeSessionError::NoProjectFound {
path: start_path.to_owned(),
})?;
let mut tree = RojoTree::new(InstancePropertiesWithMeta {
properties: RbxInstanceProperties {
name: "ROOT".to_owned(),
class_name: "Folder".to_owned(),
properties: Default::default(),
},
metadata: Default::default(),
});
let root_id = tree.get_root_id();
let mut instance_context = InstanceContext::default();
let rules = root_project
.glob_ignore_paths
.iter()
.map(|glob| PathIgnoreRule {
glob: glob.clone(),
base_path: root_project.folder_location().to_path_buf(),
});
instance_context.add_path_ignore_rules(rules);
log::trace!("Generating snapshot of instances from VFS");
let snapshot = snapshot_from_vfs(&instance_context, &vfs, &start_path)?
.expect("snapshot did not return an instance");
log::trace!("Computing initial patch set");
let patch_set = compute_patch_set(&snapshot, &tree, root_id);
log::trace!("Applying initial patch set");
apply_patch_set(&mut tree, patch_set);
let session_id = SessionId::new(); let session_id = SessionId::new();
let message_queue = MessageQueue::new(); let message_queue = MessageQueue::new();
@@ -118,7 +153,7 @@ impl ServeSession {
tree_mutation_receiver, tree_mutation_receiver,
); );
Self { Ok(Self {
change_processor, change_processor,
start_time, start_time,
session_id, session_id,
@@ -127,11 +162,9 @@ impl ServeSession {
message_queue, message_queue,
tree_mutation_sender, tree_mutation_sender,
vfs, vfs,
} })
} }
}
impl ServeSession {
pub fn tree_handle(&self) -> Arc<Mutex<RojoTree>> { pub fn tree_handle(&self) -> Arc<Mutex<RojoTree>> {
Arc::clone(&self.tree) Arc::clone(&self.tree)
} }
@@ -144,6 +177,7 @@ impl ServeSession {
self.tree_mutation_sender.clone() self.tree_mutation_sender.clone()
} }
#[allow(unused)]
pub fn vfs(&self) -> &Vfs { pub fn vfs(&self) -> &Vfs {
&self.vfs &self.vfs
} }
@@ -156,16 +190,12 @@ impl ServeSession {
self.session_id self.session_id
} }
pub fn project_name(&self) -> Option<&str> { pub fn project_name(&self) -> &str {
self.root_project &self.root_project.name
.as_ref()
.map(|project| project.name.as_str())
} }
pub fn project_port(&self) -> Option<u16> { pub fn project_port(&self) -> Option<u16> {
self.root_project self.root_project.serve_port
.as_ref()
.and_then(|project| project.serve_port)
} }
pub fn start_time(&self) -> Instant { pub fn start_time(&self) -> Instant {
@@ -173,217 +203,28 @@ impl ServeSession {
} }
pub fn serve_place_ids(&self) -> Option<&HashSet<u64>> { pub fn serve_place_ids(&self) -> Option<&HashSet<u64>> {
self.root_project self.root_project.serve_place_ids.as_ref()
.as_ref()
.and_then(|project| project.serve_place_ids.as_ref())
} }
} }
/// This module is named to trick Insta into naming the resulting snapshots #[derive(Debug, Error)]
/// correctly. pub enum ServeSessionError {
/// #[error(
/// See https://github.com/mitsuhiko/insta/issues/78 "Rojo requires a project file, but no project file was found in path {}\n\
#[cfg(test)] See https://rojo.space/docs/ for guides and documentation.",
mod serve_session { .path.display()
use super::*; )]
NoProjectFound { path: PathBuf },
use std::{path::PathBuf, time::Duration}; #[error(transparent)]
Project {
#[from]
source: ProjectError,
},
use maplit::hashmap; #[error(transparent)]
use memofs::{InMemoryFs, VfsEvent, VfsSnapshot}; Snapshot {
use rojo_insta_ext::RedactionMap; #[from]
use tokio::{runtime::Runtime, timer::Timeout}; source: SnapshotError,
},
use crate::tree_view::view_tree;
#[test]
fn just_folder() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo", VfsSnapshot::empty_dir())
.unwrap();
let vfs = Vfs::new(imfs);
let session = ServeSession::new(vfs, "/foo");
let mut rm = RedactionMap::new();
insta::assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm));
}
#[test]
fn project_with_folder() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo",
VfsSnapshot::dir(hashmap! {
"default.project.json" => VfsSnapshot::file(r#"
{
"name": "HelloWorld",
"tree": {
"$path": "src"
}
}
"#),
"src" => VfsSnapshot::dir(hashmap! {
"hello.txt" => VfsSnapshot::file("Hello, world!"),
}),
}),
)
.unwrap();
let vfs = Vfs::new(imfs);
let session = ServeSession::new(vfs, "/foo");
let mut rm = RedactionMap::new();
insta::assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm));
}
#[test]
fn script_with_meta() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/root",
VfsSnapshot::dir(hashmap! {
"test.lua" => VfsSnapshot::file("This is a test."),
"test.meta.json" => VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#),
}),
)
.unwrap();
let vfs = Vfs::new(imfs);
let session = ServeSession::new(vfs, "/root");
let mut rm = RedactionMap::new();
insta::assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm));
}
#[test]
fn change_txt_file() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.txt", VfsSnapshot::file("Hello!"))
.unwrap();
let vfs = Vfs::new(imfs.clone());
let session = ServeSession::new(vfs, "/foo.txt");
let mut rm = RedactionMap::new();
insta::assert_yaml_snapshot!(
"change_txt_file_before",
view_tree(&session.tree(), &mut rm)
);
imfs.load_snapshot("/foo.txt", VfsSnapshot::file("World!"))
.unwrap();
let receiver = session.message_queue().subscribe_any();
imfs.raise_event(VfsEvent::Write(PathBuf::from("/foo.txt")));
let receiver = Timeout::new(receiver, Duration::from_millis(200));
let mut rt = Runtime::new().unwrap();
let result = rt.block_on(receiver).unwrap();
insta::assert_yaml_snapshot!("change_txt_file_patch", rm.redacted_yaml(result));
insta::assert_yaml_snapshot!("change_txt_file_after", view_tree(&session.tree(), &mut rm));
}
#[test]
fn change_script_meta() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/root",
VfsSnapshot::dir(hashmap! {
"test.lua" => VfsSnapshot::file("This is a test."),
"test.meta.json" => VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#),
}),
)
.unwrap();
let vfs = Vfs::new(imfs.clone());
let session = ServeSession::new(vfs, "/root");
let mut rm = RedactionMap::new();
insta::assert_yaml_snapshot!(
"change_script_meta_before",
view_tree(&session.tree(), &mut rm)
);
imfs.load_snapshot(
"/root/test.meta.json",
VfsSnapshot::file(r#"{ "ignoreUnknownInstances": false }"#),
)
.unwrap();
let receiver = session.message_queue().subscribe_any();
imfs.raise_event(VfsEvent::Write(PathBuf::from("/root/test.meta.json")));
let receiver = Timeout::new(receiver, Duration::from_millis(200));
let mut rt = Runtime::new().unwrap();
let result = rt.block_on(receiver).unwrap();
insta::assert_yaml_snapshot!("change_script_meta_patch", rm.redacted_yaml(result));
insta::assert_yaml_snapshot!(
"change_script_meta_after",
view_tree(&session.tree(), &mut rm)
);
}
#[test]
fn change_file_in_project() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo",
VfsSnapshot::dir(hashmap! {
"default.project.json" => VfsSnapshot::file(r#"
{
"name": "change_file_in_project",
"tree": {
"$className": "Folder",
"Child": {
"$path": "file.txt"
}
}
}
"#),
"file.txt" => VfsSnapshot::file("initial content"),
}),
)
.unwrap();
let vfs = Vfs::new(imfs.clone());
let session = ServeSession::new(vfs, "/foo");
let mut rm = RedactionMap::new();
insta::assert_yaml_snapshot!(
"change_file_in_project_before",
view_tree(&session.tree(), &mut rm)
);
imfs.load_snapshot("/foo/file.txt", VfsSnapshot::file("Changed!"))
.unwrap();
let receiver = session.message_queue().subscribe_any();
imfs.raise_event(VfsEvent::Write(PathBuf::from("/foo/file.txt")));
let receiver = Timeout::new(receiver, Duration::from_millis(200));
let mut rt = Runtime::new().unwrap();
let result = rt.block_on(receiver).unwrap();
insta::assert_yaml_snapshot!("change_file_in_project_patch", rm.redacted_yaml(result));
insta::assert_yaml_snapshot!(
"change_file_in_project_after",
view_tree(&session.tree(), &mut rm)
);
}
} }

View File

@@ -163,6 +163,7 @@ pub enum InstigatingSource {
#[serde(serialize_with = "path_serializer::serialize_absolute")] PathBuf, #[serde(serialize_with = "path_serializer::serialize_absolute")] PathBuf,
String, String,
ProjectNode, ProjectNode,
Option<String>,
), ),
} }
@@ -170,12 +171,13 @@ impl fmt::Debug for InstigatingSource {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
InstigatingSource::Path(path) => write!(formatter, "Path({})", path.display()), InstigatingSource::Path(path) => write!(formatter, "Path({})", path.display()),
InstigatingSource::ProjectNode(path, name, node) => write!( InstigatingSource::ProjectNode(path, name, node, parent_class) => write!(
formatter, formatter,
"ProjectNode({}: {:?}) from path {}", "ProjectNode({}: {:?}) from path {} and parent class {:?}",
name, name,
node, node,
path.display() path.display(),
parent_class,
), ),
} }
} }

View File

@@ -1,56 +1,68 @@
use std::{error::Error, fmt, io, path::PathBuf}; use std::{io, path::PathBuf};
use snafu::Snafu; use thiserror::Error;
#[derive(Debug)] #[derive(Debug, Error)]
pub struct SnapshotError { pub enum SnapshotError {
detail: SnapshotErrorDetail, #[error("file name had malformed Unicode")]
path: Option<PathBuf>, FileNameBadUnicode { path: PathBuf },
#[error("file had malformed Unicode contents at path {}", .path.display())]
FileContentsBadUnicode {
source: std::str::Utf8Error,
path: PathBuf,
},
#[error("malformed project file at path {}", .path.display())]
MalformedProject {
source: serde_json::Error,
path: PathBuf,
},
#[error("malformed .model.json file at path {}", .path.display())]
MalformedModelJson {
source: serde_json::Error,
path: PathBuf,
},
#[error("malformed .meta.json file at path {}", .path.display())]
MalformedMetaJson {
source: serde_json::Error,
path: PathBuf,
},
#[error("malformed JSON at path {}", .path.display())]
MalformedJson {
source: serde_json::Error,
path: PathBuf,
},
#[error(transparent)]
Io {
#[from]
source: io::Error,
},
} }
impl SnapshotError { impl SnapshotError {
pub fn new(detail: SnapshotErrorDetail, path: Option<impl Into<PathBuf>>) -> Self {
Self {
detail,
path: path.map(Into::into),
}
}
pub(crate) fn wrap(source: impl Into<SnapshotErrorDetail>, path: impl Into<PathBuf>) -> Self {
Self {
detail: source.into(),
path: Some(path.into()),
}
}
pub(crate) fn file_did_not_exist(path: impl Into<PathBuf>) -> Self {
Self {
detail: SnapshotErrorDetail::FileDidNotExist,
path: Some(path.into()),
}
}
pub(crate) fn file_name_bad_unicode(path: impl Into<PathBuf>) -> Self { pub(crate) fn file_name_bad_unicode(path: impl Into<PathBuf>) -> Self {
Self { Self::FileNameBadUnicode { path: path.into() }
detail: SnapshotErrorDetail::FileNameBadUnicode,
path: Some(path.into()),
}
} }
pub(crate) fn file_contents_bad_unicode( pub(crate) fn file_contents_bad_unicode(
source: std::str::Utf8Error, source: std::str::Utf8Error,
path: impl Into<PathBuf>, path: impl Into<PathBuf>,
) -> Self { ) -> Self {
Self { Self::FileContentsBadUnicode {
detail: SnapshotErrorDetail::FileContentsBadUnicode { source }, source,
path: Some(path.into()), path: path.into(),
} }
} }
pub(crate) fn malformed_project(source: serde_json::Error, path: impl Into<PathBuf>) -> Self { pub(crate) fn malformed_project(source: serde_json::Error, path: impl Into<PathBuf>) -> Self {
Self { Self::MalformedProject {
detail: SnapshotErrorDetail::MalformedProject { source }, source,
path: Some(path.into()), path: path.into(),
} }
} }
@@ -58,82 +70,23 @@ impl SnapshotError {
source: serde_json::Error, source: serde_json::Error,
path: impl Into<PathBuf>, path: impl Into<PathBuf>,
) -> Self { ) -> Self {
Self { Self::MalformedModelJson {
detail: SnapshotErrorDetail::MalformedModelJson { source }, source,
path: Some(path.into()), path: path.into(),
} }
} }
pub(crate) fn malformed_meta_json(source: serde_json::Error, path: impl Into<PathBuf>) -> Self { pub(crate) fn malformed_meta_json(source: serde_json::Error, path: impl Into<PathBuf>) -> Self {
Self { Self::MalformedMetaJson {
detail: SnapshotErrorDetail::MalformedMetaJson { source }, source,
path: Some(path.into()), path: path.into(),
}
}
pub(crate) fn malformed_json(source: serde_json::Error, path: impl Into<PathBuf>) -> Self {
Self::MalformedJson {
source,
path: path.into(),
} }
} }
} }
impl Error for SnapshotError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.detail.source()
}
}
impl fmt::Display for SnapshotError {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
match &self.path {
Some(path) => write!(formatter, "{} in path {}", self.detail, path.display()),
None => write!(formatter, "{}", self.detail),
}
}
}
impl From<io::Error> for SnapshotError {
fn from(inner: io::Error) -> Self {
Self::new(inner.into(), Option::<PathBuf>::None)
}
}
impl From<rlua::Error> for SnapshotError {
fn from(error: rlua::Error) -> Self {
Self::new(error.into(), Option::<PathBuf>::None)
}
}
#[derive(Debug, Snafu)]
pub enum SnapshotErrorDetail {
#[snafu(display("I/O error"))]
IoError { source: io::Error },
#[snafu(display("Lua error"))]
Lua { source: rlua::Error },
#[snafu(display("file did not exist"))]
FileDidNotExist,
#[snafu(display("file name had malformed Unicode"))]
FileNameBadUnicode,
#[snafu(display("file had malformed Unicode contents"))]
FileContentsBadUnicode { source: std::str::Utf8Error },
#[snafu(display("malformed project file"))]
MalformedProject { source: serde_json::Error },
#[snafu(display("malformed .model.json file"))]
MalformedModelJson { source: serde_json::Error },
#[snafu(display("malformed .meta.json file"))]
MalformedMetaJson { source: serde_json::Error },
}
impl From<io::Error> for SnapshotErrorDetail {
fn from(source: io::Error) -> Self {
SnapshotErrorDetail::IoError { source }
}
}
impl From<rlua::Error> for SnapshotErrorDetail {
fn from(source: rlua::Error) -> Self {
SnapshotErrorDetail::Lua { source }
}
}

View File

@@ -0,0 +1,142 @@
use std::path::Path;
use maplit::hashmap;
use memofs::{IoResultExt, Vfs};
use rbx_dom_weak::RbxValue;
use crate::{
lua_ast::{Expression, Statement},
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
};
use super::{
error::SnapshotError,
meta_file::AdjacentMetadata,
middleware::{SnapshotInstanceResult, SnapshotMiddleware},
util::match_file_name,
};
/// Catch-all middleware for snapshots on JSON files that aren't used for other
/// features, like Rojo projects, JSON models, or meta files.
pub struct SnapshotJson;
impl SnapshotMiddleware for SnapshotJson {
fn from_vfs(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
let meta = vfs.metadata(path)?;
if meta.is_dir() {
return Ok(None);
}
// FIXME: This middleware should not need to know about the .meta.json
// middleware. Should there be a way to signal "I'm not returning an
// instance and no one should"?
if match_file_name(path, ".meta.json").is_some() {
return Ok(None);
}
let instance_name = match match_file_name(path, ".json") {
Some(name) => name,
None => return Ok(None),
};
let contents = vfs.read(path)?;
let value: serde_json::Value = serde_json::from_slice(&contents)
.map_err(|err| SnapshotError::malformed_json(err, path))?;
let as_lua = json_to_lua(value).to_string();
let properties = hashmap! {
"Source".to_owned() => RbxValue::String {
value: as_lua,
},
};
let meta_path = path.with_file_name(format!("{}.meta.json", instance_name));
let mut snapshot = InstanceSnapshot::new()
.name(instance_name)
.class_name("ModuleScript")
.properties(properties)
.metadata(
InstanceMetadata::new()
.instigating_source(path)
.relevant_paths(vec![path.to_path_buf(), meta_path.clone()])
.context(context),
);
if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? {
let mut metadata = AdjacentMetadata::from_slice(&meta_contents, &meta_path)?;
metadata.apply_all(&mut snapshot);
}
Ok(Some(snapshot))
}
}
fn json_to_lua(value: serde_json::Value) -> Statement {
Statement::Return(json_to_lua_value(value))
}
fn json_to_lua_value(value: serde_json::Value) -> Expression {
use serde_json::Value;
match value {
Value::Null => Expression::Nil,
Value::Bool(value) => Expression::Bool(value),
Value::Number(value) => Expression::Number(value.as_f64().unwrap()),
Value::String(value) => Expression::String(value),
Value::Array(values) => {
Expression::Array(values.into_iter().map(json_to_lua_value).collect())
}
Value::Object(values) => Expression::table(
values
.into_iter()
.map(|(key, value)| (key.into(), json_to_lua_value(value)))
.collect(),
),
}
}
#[cfg(test)]
mod test {
use super::*;
use memofs::{InMemoryFs, VfsSnapshot};
#[test]
fn instance_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo.json",
VfsSnapshot::file(
r#"{
"array": [1, 2, 3],
"object": {
"hello": "world"
},
"true": true,
"false": false,
"null": null,
"int": 1234,
"float": 1234.5452,
"1invalidident": "nice"
}"#,
),
)
.unwrap();
let mut vfs = Vfs::new(imfs.clone());
let instance_snapshot = SnapshotJson::from_vfs(
&InstanceContext::default(),
&mut vfs,
Path::new("/foo.json"),
)
.unwrap()
.unwrap();
insta::assert_yaml_snapshot!(instance_snapshot);
}
}

View File

@@ -6,6 +6,7 @@
mod csv; mod csv;
mod dir; mod dir;
mod error; mod error;
mod json;
mod json_model; mod json_model;
mod lua; mod lua;
mod meta_file; mod meta_file;
@@ -17,20 +18,27 @@ mod rbxmx;
mod txt; mod txt;
mod util; mod util;
pub use self::error::*;
use std::path::Path; use std::path::Path;
use memofs::Vfs; use memofs::Vfs;
use self::middleware::{SnapshotInstanceResult, SnapshotMiddleware};
use self::{
csv::SnapshotCsv, dir::SnapshotDir, json_model::SnapshotJsonModel, lua::SnapshotLua,
project::SnapshotProject, rbxlx::SnapshotRbxlx, rbxm::SnapshotRbxm, rbxmx::SnapshotRbxmx,
txt::SnapshotTxt,
};
use crate::snapshot::InstanceContext; use crate::snapshot::InstanceContext;
use self::{
csv::SnapshotCsv,
dir::SnapshotDir,
json::SnapshotJson,
json_model::SnapshotJsonModel,
lua::SnapshotLua,
middleware::{SnapshotInstanceResult, SnapshotMiddleware},
project::SnapshotProject,
rbxlx::SnapshotRbxlx,
rbxm::SnapshotRbxm,
rbxmx::SnapshotRbxmx,
txt::SnapshotTxt,
};
pub use self::error::*;
pub use self::project::snapshot_project_node; pub use self::project::snapshot_project_node;
macro_rules! middlewares { macro_rules! middlewares {
@@ -65,5 +73,6 @@ middlewares! {
SnapshotLua, SnapshotLua,
SnapshotCsv, SnapshotCsv,
SnapshotTxt, SnapshotTxt,
SnapshotJson,
SnapshotDir, SnapshotDir,
} }

View File

@@ -1,7 +1,7 @@
use std::{borrow::Cow, collections::HashMap, path::Path}; use std::{borrow::Cow, collections::HashMap, path::Path};
use memofs::{IoResultExt, Vfs}; use memofs::{IoResultExt, Vfs};
use rbx_reflection::try_resolve_value; use rbx_reflection::{get_class_descriptor, try_resolve_value};
use crate::{ use crate::{
project::{Project, ProjectNode}, project::{Project, ProjectNode},
@@ -62,6 +62,7 @@ impl SnapshotMiddleware for SnapshotProject {
&project.name, &project.name,
&project.tree, &project.tree,
vfs, vfs,
None,
)? )?
.unwrap(); .unwrap();
@@ -93,6 +94,7 @@ pub fn snapshot_project_node(
instance_name: &str, instance_name: &str,
node: &ProjectNode, node: &ProjectNode,
vfs: &Vfs, vfs: &Vfs,
parent_class: Option<&str>,
) -> SnapshotInstanceResult { ) -> SnapshotInstanceResult {
let name = Cow::Owned(instance_name.to_owned()); let name = Cow::Owned(instance_name.to_owned());
let mut class_name = node let mut class_name = node
@@ -158,13 +160,43 @@ pub fn snapshot_project_node(
} }
let class_name = class_name let class_name = class_name
.or_else(|| {
// If className wasn't defined from another source, we may be able
// to infer one.
let parent_class = parent_class?;
if parent_class == "DataModel" {
// Members of DataModel with names that match known services are
// probably supposed to be those services.
let descriptor = get_class_descriptor(&name)?;
if descriptor.is_service() {
return Some(name.clone());
}
} else if parent_class == "StarterPlayer" {
// StarterPlayer has two special members with their own classes.
if name == "StarterPlayerScripts" || name == "StarterCharacterScripts" {
return Some(name.clone());
}
}
None
})
// TODO: Turn this into an error object. // TODO: Turn this into an error object.
.expect("$className or $path must be specified"); .expect("$className or $path must be specified");
for (child_name, child_project_node) in &node.children { for (child_name, child_project_node) in &node.children {
if let Some(child) = if let Some(child) = snapshot_project_node(
snapshot_project_node(context, project_folder, child_name, child_project_node, vfs)? context,
{ project_folder,
child_name,
child_project_node,
vfs,
Some(&class_name),
)? {
children.push(child); children.push(child);
} }
} }
@@ -194,6 +226,7 @@ pub fn snapshot_project_node(
project_folder.to_path_buf(), project_folder.to_path_buf(),
instance_name.to_string(), instance_name.to_string(),
node.clone(), node.clone(),
parent_class.map(|name| name.to_owned()),
)); ));
Ok(Some(InstanceSnapshot { Ok(Some(InstanceSnapshot {

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