Compare commits

..

15 Commits

Author SHA1 Message Date
Lucien Greathouse
78d97e162c Release v0.5.2 2019-10-14 17:33:43 -07:00
Lucien Greathouse
5d0aa1193f Fix LocalizationTable output order by switching to a BTreeMap.
ACTUALLY closes #173.
2019-10-14 17:29:34 -07:00
Lucien Greathouse
126040a87b Add build tests for init.meta.json 2019-10-12 23:59:54 -07:00
Lucien Greathouse
2c408f4047 Fix errors in 'Sync Details' page 2019-10-08 16:17:13 -07:00
Lucien Greathouse
b53cda787a Add end-to-end build test for Script.Disabled via .meta.json 2019-10-08 16:09:27 -07:00
Lucien Greathouse
7b4455ed51 Release v0.5.1 2019-10-04 12:51:14 -07:00
Lucien Greathouse
5b57025b0b plugin: Only move message cursor in response to retrieveMessages 2019-10-04 11:13:10 -07:00
Lucien Greathouse
ece454e6dd Update dependencies 2019-10-04 10:54:53 -07:00
boyned//Kampfkarren
afa480b07d Fix broken link to sync details (#248) 2019-09-22 17:38:40 -07:00
Lucien Greathouse
c9b695d533 Fix guide to point to release versions instead of alphas 2019-09-20 11:06:01 -07:00
Lucien Greathouse
71c77a09a6 Update docs link to rojo.space 2019-09-19 14:02:26 -07:00
Lucien Greathouse
d309a1359c Update changelog 2019-09-13 17:16:05 -07:00
Lucien Greathouse
b0bb486d9a Improve diagnostics for failed instance creation 2019-09-13 16:00:08 -07:00
Lucien Greathouse
2c7c3348cf Add help page to direct people to Discord, GitHub, and Twitter 2019-09-11 11:36:02 -07:00
Lucien Greathouse
4caac5e6cb Update docs home for 0.5.x 2019-08-27 14:37:26 -07:00
354 changed files with 8882 additions and 55459 deletions

View File

@@ -3,24 +3,16 @@ root = true
[*] [*]
end_of_line = lf end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = false insert_final_newline = false
trim_trailing_whitespace = true
[*.{json,js,css}] [*.{json,js,css}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
[*.md] [*.{md,rs}]
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
[*.{rs,toml}]
indent_style = space
indent_size = 4
insert_final_newline = true
[*.snap]
insert_final_newline = true
[*.lua] [*.lua]
indent_style = tab indent_style = tab

View File

@@ -1,36 +0,0 @@
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
rust_version: [stable, "1.36.0"]
steps:
- uses: actions/checkout@v1
- name: Setup Rust toolchain
run: rustup default ${{ matrix.rust_version }}
- name: Build
run: cargo build --locked --verbose
- name: Run tests
run: cargo test --locked --verbose
- name: Rustfmt and Clippy
run: |
cargo fmt -- --check
cargo clippy
if: matrix.rust_version == 'stable'
- name: Build (All Features)
run: cargo build --locked --verbose --all-features
- name: Run tests (All Features)
run: cargo test --locked --verbose --all-features

View File

@@ -1,60 +0,0 @@
name: Release
on:
push:
tags: ["*"]
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v1
- name: Build release binary
run: cargo build --verbose --locked --release
- name: Upload artifacts
uses: actions/upload-artifact@v1
with:
name: rojo-win64
path: target/release/rojo.exe
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v1
- name: Install Rust
run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
- name: Build release binary
run: |
source $HOME/.cargo/env
cargo build --verbose --locked --release
env:
OPENSSL_STATIC: 1
- name: Upload artifacts
uses: actions/upload-artifact@v1
with:
name: rojo-macos
path: target/release/rojo
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Build
run: cargo build --locked --verbose --release
env:
OPENSSL_STATIC: 1
- name: Upload artifacts
uses: actions/upload-artifact@v1
with:
name: rojo-linux
path: target/release/rojo

15
.gitignore vendored
View File

@@ -1,18 +1,9 @@
# Rust output directory
/target /target
/scratch-project
# Headers for clibrojo /server/failed-snapshots/
/include **/*.rs.bk
# Roblox model and place files in the root, used for debugging
/*.rbxm /*.rbxm
/*.rbxmx /*.rbxmx
/*.rbxl /*.rbxl
/*.rbxlx /*.rbxlx
# Roblox Studio holds 'lock' files on places
*.rbxl.lock
*.rbxlx.lock
# Snapshot files from the 'insta' Rust crate
**/*.snap.new **/*.snap.new

3
.gitmodules vendored
View File

@@ -4,6 +4,9 @@
[submodule "plugin/modules/testez"] [submodule "plugin/modules/testez"]
path = plugin/modules/testez path = plugin/modules/testez
url = https://github.com/Roblox/testez.git url = https://github.com/Roblox/testez.git
[submodule "plugin/modules/lemur"]
path = plugin/modules/lemur
url = https://github.com/LPGhatguy/lemur.git
[submodule "plugin/modules/promise"] [submodule "plugin/modules/promise"]
path = plugin/modules/promise path = plugin/modules/promise
url = https://github.com/LPGhatguy/roblox-lua-promise.git url = https://github.com/LPGhatguy/roblox-lua-promise.git

44
.travis.yml Normal file
View File

@@ -0,0 +1,44 @@
matrix:
include:
# Lua tests are currently disabled because of holes in Lemur that are pretty
# tedious to fix. It should be fixed by either adding missing features to
# Lemur or by migrating to a CI system based on real Roblox instead.
# - language: python
# env:
# - LUA="lua=5.1"
# before_install:
# - pip install hererocks
# - hererocks lua_install -r^ --$LUA
# - export PATH=$PATH:$PWD/lua_install/bin
# install:
# - luarocks install luafilesystem
# - luarocks install busted
# - luarocks install luacov
# - luarocks install luacov-coveralls
# - luarocks install luacheck
# script:
# - cd plugin
# - luacheck src
# - lua -lluacov spec.lua
# after_success:
# - cd plugin
# - luacov-coveralls -e $TRAVIS_BUILD_DIR/lua_install
- language: rust
rust: 1.34.0
cache: cargo
script:
- cargo test --verbose
- language: rust
rust: stable
cache: cargo
script:
- cargo test --verbose

View File

@@ -2,29 +2,8 @@
## Unreleased Changes ## Unreleased Changes
## [0.6.0 Alpha 1](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.1) (January 22, 2020) ## [0.5.2](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.2) (October 14, 2019)
* Fixed an issue where `LocalizationTable` instances would have their column order randomized. ([#173](https://github.com/rojo-rbx/rojo/issues/173))
### General
* Added support for nested project files. ([#95](https://github.com/rojo-rbx/rojo/issues/95))
* Added project file hot-reloading. ([#10](https://github.com/rojo-rbx/rojo/issues/10)])
* Fixed Rojo dropping Ref properties ([#142](https://github.com/rojo-rbx/rojo/issues/142))
* This means that properties like `PrimaryPart` now work!
* Improved live sync protocol to reduce round-trips and improve syncing consistency.
* Improved support for binary model files and places.
### Command Line
* Added `--verbose`/`-v` flag, which can be specified multiple times to increase verbosity.
* Added support for automatically finding Roblox Studio's auth cookie for `rojo upload` on Windows.
* Added support for building, serving and uploading sources that aren't Rojo projects.
* Improved feedback from `rojo serve`.
* Removed support for legacy `roblox-project.json` projects, deprecated in an early Rojo 0.5.0 alpha.
* Rojo no longer traverses directories upwards looking for project files.
* Though undocumented, Rojo 0.5.x will search for a project file contained in any ancestor folders. This feature was removed to better support other 0.6.x features.
### Roblox Studio Plugin
* Added "connecting" state to improve experience when live syncing.
* Added "error" state to show errors in a place that isn't the output panel.
* Improved diagnostics for when the Rojo plugin cannot create an instance.
## [0.5.1](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.1) (October 4, 2019) ## [0.5.1](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.1) (October 4, 2019)
* Fixed an issue where Rojo would drop changes if they happened too quickly ([#252](https://github.com/rojo-rbx/rojo/issues/252)) * Fixed an issue where Rojo would drop changes if they happened too quickly ([#252](https://github.com/rojo-rbx/rojo/issues/252))

View File

@@ -11,13 +11,6 @@ Some of the repositories covered are:
## Code ## Code
Code contributions are welcome for features and bugs that have been reported in the project's bug tracker. We want to make sure that no one wastes their time, so be sure to talk with maintainers about what changes would be accepted before doing any work! Code contributions are welcome for features and bugs that have been reported in the project's bug tracker. We want to make sure that no one wastes their time, so be sure to talk with maintainers about what changes would be accepted before doing any work!
You'll want these tools to work on Rojo:
* Latest stable Rust compiler
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
* Latest stable [Remodel](https://github.com/rojo-rbx/remodel)
* Latest stable [run-in-roblox](https://github.com/rojo-rbx/run-in-roblox)
## Documentation ## Documentation
Documentation impacts way more people than the individual lines of code we write. Documentation impacts way more people than the individual lines of code we write.

1140
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,97 +1,8 @@
[package]
name = "rojo"
version = "0.6.0-alpha.1"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0"
homepage = "https://rojo.space"
documentation = "https://rojo.space/docs"
repository = "https://github.com/rojo-rbx/rojo"
readme = "README.md"
edition = "2018"
exclude = [
"/plugin/**",
"/test-projects/**",
]
[features]
default = []
# Turn on support for specifying glob ignore path rules in the project format.
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.
dev_live_assets = []
[workspace] [workspace]
members = [ members = [
"rojo-test", "server",
"rojo-insta-ext", "rojo-test",
"clibrojo",
] ]
default-members = [ [profile.dev]
".", opt-level = 1
"rojo-test",
"rojo-insta-ext",
]
[lib]
name = "librojo"
path = "src/lib.rs"
[[bin]]
name = "rojo"
path = "src/bin.rs"
[[bench]]
name = "build"
harness = false
[dependencies]
crossbeam-channel = "0.4.0"
csv = "1.1.1"
env_logger = "0.7.1"
futures = "0.1.29"
globset = "0.4.4"
humantime = "1.3.0"
hyper = "0.12.35"
jod-thread = "0.1.0"
lazy_static = "1.4.0"
log = "0.4.8"
maplit = "1.0.1"
notify = "4.0.14"
rbx_binary = "0.5.0"
rbx_dom_weak = "1.10.1"
rbx_reflection = "3.3.408"
rbx_xml = "0.11.3"
regex = "1.3.1"
reqwest = "0.9.20"
ritz = "0.1.0"
rlua = "0.17.0"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
snafu = "0.6.0"
structopt = "0.3.5"
termcolor = "1.0.5"
uuid = { version = "0.8.1", features = ["v4", "serde"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.6.2"
[dev-dependencies]
rojo-insta-ext = { path = "rojo-insta-ext" }
criterion = "0.3"
insta = { version = "0.12.0", features = ["redactions"] }
lazy_static = "1.2"
paste = "0.1"
pretty_assertions = "0.6.1"
serde_yaml = "0.8.9"
tempfile = "3.0"
tokio = "0.1.22"
walkdir = "2.1"

View File

@@ -7,11 +7,14 @@
<div>&nbsp;</div> <div>&nbsp;</div>
<div align="center"> <div align="center">
<a href="https://github.com/rojo-rbx/rojo/actions"> <a href="https://travis-ci.org/rojo-rbx/rojo">
<img src="https://github.com/rojo-rbx/rojo/workflows/CI/badge.svg" alt="Actions status" /> <img src="https://api.travis-ci.org/rojo-rbx/rojo.svg?branch=master" alt="Travis-CI Build Status" />
</a> </a>
<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=version" alt="Latest server version" />
</a>
<a href="https://rojo.space/docs/0.4.x">
<img src="https://img.shields.io/badge/docs-0.4.x-brightgreen.svg" alt="Rojo 0.4.x Documentation" />
</a> </a>
<a href="https://rojo.space/docs/0.5.x"> <a href="https://rojo.space/docs/0.5.x">
<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-0.5.x-brightgreen.svg" alt="Rojo 0.5.x Documentation" />
@@ -41,7 +44,7 @@ Soon, Rojo will be able to:
* Automatically manage your assets on Roblox.com, like images and sounds * Automatically manage your assets on Roblox.com, like images and sounds
* Import custom instances like MoonScript code * Import custom instances like MoonScript code
## [Documentation](https://rojo.space/docs) ## [Documentation](https://rojo.space/docs/latest)
If you find any mistakes, feel free to make changes in the [docs](https://github.com/rojo-rbx/rojo/tree/master/docs) folder of this repository and submit a pull request! If you find any mistakes, feel free to make changes in the [docs](https://github.com/rojo-rbx/rojo/tree/master/docs) folder of this repository and submit a pull request!
## Contributing ## Contributing
@@ -49,7 +52,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
Pull requests are welcome! Pull requests are welcome!
Rojo supports Rust 1.36.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has. Rojo supports Rust 1.34.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License ## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details. Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 B

View File

@@ -1,181 +0,0 @@
* {
margin: 0;
padding: 0;
border: none;
text-decoration: inherit;
color: inherit;
font: inherit;
box-sizing: inherit;
line-height: inherit;
}
html {
box-sizing: border-box;
font-family: sans-serif;
font-size: 18px;
text-decoration: none;
line-height: 1.4;
}
img {
max-width:100%;
max-height:100%;
height: auto;
}
.path-list > li {
margin-left: 1.2em;
font-family: monospace;
}
.root {
display: flex;
flex-direction: column;
margin: 0.5rem auto;
width: 100%;
max-width: 50rem;
background-color: #efefef;
border: 1px solid #666;
border-radius: 4px;
}
.header {
flex: 0 0;
display: flex;
flex-wrap: wrap;
align-items: center;
border-bottom: 1px solid #666;
}
.main {
padding: 1rem;
}
.main-logo {
flex: 0 0 10rem;
margin: 1rem;
}
.stats {
flex: 0 0 20rem;
margin: 1rem;
}
.stat {
display: block;
}
.stat-name {
display: inline;
font-weight: bold;
}
.main-section:not(:last-of-type) {
margin-bottom: 1rem;
}
.section-title {
font-size: 1.8rem;
}
.button-list {
flex: 0 0;
display: flex;
flex-wrap: wrap;
justify-content: space-around;
margin: -1rem;
}
.button {
display: inline-block;
border: 1px solid #666;
padding: 0.3em 1em;
margin: 1rem;
}
.instance {
margin-bottom: 0.5rem;
}
.instance-title {
font-size: 1.2rem;
padding: 0.5rem;
}
.expandable-section {
margin: 0.25rem 0.5rem;
}
.expandable-items {
padding: 0.5rem 1rem;
}
.expandable-input {
display: none;
}
.expandable-label > label {
cursor: pointer;
display: flex;
align-items: center;
align-content: center;
}
.expandable-input ~ .expandable-label .expandable-visualizer {
font-family: monospace;
display: inline-flex;
align-items: center;
align-content: center;
text-align: center;
width: 1rem;
height: 1rem;
font-size: 2rem;
margin: 0 0.5rem;
transition: transform 100ms ease-in-out;
transform-origin: 60% 60%;
}
.expandable-visualizer::before {
content: "";
font-weight: bold;
}
.expandable-input:checked ~ .expandable-label {
border-bottom: 1px solid #bbb;
}
.expandable-input:checked ~ .expandable-label .expandable-visualizer {
transform: rotate(90deg);
}
.expandable-input:not(:checked) ~ .expandable-items {
display: none;
}
.vfs-entry {
}
.vfs-entry-name {
position: relative;
font-family: monospace;
}
.vfs-entry-children .vfs-entry-name::before {
content: "";
width: 0.6em;
height: 1px;
background-color: #999;
position: absolute;
top: 50%;
left: -0.8em;
}
.vfs-entry-note {
font-style: italic;
}
.vfs-entry-children {
padding-left: 0.8em;
margin-left: 0.2em;
border-left: 1px solid #999;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,41 @@
<TextureAtlas imagePath="sheet.png">
<SubTexture name="grey_arrowDownGrey.png" x="78" y="498" width="15" height="10"/>
<SubTexture name="grey_arrowDownWhite.png" x="123" y="496" width="15" height="10"/>
<SubTexture name="grey_arrowUpGrey.png" x="108" y="498" width="15" height="10"/>
<SubTexture name="grey_arrowUpWhite.png" x="93" y="498" width="15" height="10"/>
<SubTexture name="grey_box.png" x="147" y="433" width="38" height="36"/>
<SubTexture name="grey_boxCheckmark.png" x="147" y="469" width="38" height="36"/>
<SubTexture name="grey_boxCross.png" x="185" y="433" width="38" height="36"/>
<SubTexture name="grey_boxTick.png" x="190" y="198" width="36" height="36"/>
<SubTexture name="grey_button00.png" x="0" y="143" width="190" height="45"/>
<SubTexture name="grey_button01.png" x="0" y="188" width="190" height="49"/>
<SubTexture name="grey_button02.png" x="0" y="98" width="190" height="45"/>
<SubTexture name="grey_button03.png" x="0" y="331" width="190" height="49"/>
<SubTexture name="grey_button04.png" x="0" y="286" width="190" height="45"/>
<SubTexture name="grey_button05.png" x="0" y="0" width="195" height="49"/>
<SubTexture name="grey_button06.png" x="0" y="49" width="191" height="49"/>
<SubTexture name="grey_button07.png" x="195" y="0" width="49" height="49"/>
<SubTexture name="grey_button08.png" x="240" y="49" width="49" height="49"/>
<SubTexture name="grey_button09.png" x="98" y="433" width="49" height="45"/>
<SubTexture name="grey_button10.png" x="191" y="49" width="49" height="49"/>
<SubTexture name="grey_button11.png" x="0" y="433" width="49" height="45"/>
<SubTexture name="grey_button12.png" x="244" y="0" width="49" height="49"/>
<SubTexture name="grey_button13.png" x="49" y="433" width="49" height="45"/>
<SubTexture name="grey_button14.png" x="0" y="384" width="190" height="49"/>
<SubTexture name="grey_button15.png" x="0" y="237" width="190" height="49"/>
<SubTexture name="grey_checkmarkGrey.png" x="99" y="478" width="21" height="20"/>
<SubTexture name="grey_checkmarkWhite.png" x="78" y="478" width="21" height="20"/>
<SubTexture name="grey_circle.png" x="185" y="469" width="36" height="36"/>
<SubTexture name="grey_crossGrey.png" x="120" y="478" width="18" height="18"/>
<SubTexture name="grey_crossWhite.png" x="190" y="318" width="18" height="18"/>
<SubTexture name="grey_panel.png" x="190" y="98" width="100" height="100"/>
<SubTexture name="grey_sliderDown.png" x="190" y="234" width="28" height="42"/>
<SubTexture name="grey_sliderEnd.png" x="138" y="478" width="8" height="10"/>
<SubTexture name="grey_sliderHorizontal.png" x="0" y="380" width="190" height="4"/>
<SubTexture name="grey_sliderLeft.png" x="0" y="478" width="39" height="31"/>
<SubTexture name="grey_sliderRight.png" x="39" y="478" width="39" height="31"/>
<SubTexture name="grey_sliderUp.png" x="190" y="276" width="28" height="42"/>
<SubTexture name="grey_sliderVertical.png" x="208" y="318" width="4" height="100"/>
<SubTexture name="grey_tickGrey.png" x="190" y="336" width="17" height="17"/>
<SubTexture name="grey_tickWhite.png" x="190" y="353" width="17" height="17"/>
</TextureAtlas>

Binary file not shown.

View File

@@ -1,42 +0,0 @@
use std::path::Path;
use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
use tempfile::{tempdir, TempDir};
use librojo::cli::{build, BuildCommand};
pub fn benchmark_small_place(c: &mut Criterion) {
bench_build_place(c, "Small Place", "test-projects/benchmark_small_place")
}
criterion_group!(benches, benchmark_small_place);
criterion_main!(benches);
fn bench_build_place(c: &mut Criterion, name: &str, path: &str) {
let mut group = c.benchmark_group(name);
// 'rojo build' generally takes a fair bit of time to execute.
group.sample_size(10);
group.bench_function("build", |b| {
b.iter_batched(
|| place_setup(path),
|(_dir, options)| build(options).unwrap(),
BatchSize::SmallInput,
)
});
group.finish();
}
fn place_setup<P: AsRef<Path>>(input_path: P) -> (TempDir, BuildCommand) {
let dir = tempdir().unwrap();
let input = input_path.as_ref().to_path_buf();
let output = dir.path().join("output.rbxlx");
let options = BuildCommand {
project: input,
output,
};
(dir, options)
}

View File

@@ -1,5 +0,0 @@
#!/bin/sh
set -e
watchexec -c -w plugin "sh -c './bin/install-dev-plugin.sh'"

View File

@@ -1,13 +0,0 @@
#!/bin/sh
set -e
DIR="$( mktemp -d )"
PLUGIN_FILE="$DIR/Rojo.rbxm"
TESTEZ_FILE="$DIR/TestEZ.rbxm"
rojo build plugin -o "$PLUGIN_FILE"
rojo build plugin/testez.project.json -o "$TESTEZ_FILE"
remodel bin/mark-plugin-as-dev.lua "$PLUGIN_FILE" "$TESTEZ_FILE" 2>/dev/null
cp "$PLUGIN_FILE" "$LOCALAPPDATA/Roblox/Plugins/Rojo.rbxm"

View File

@@ -1,5 +0,0 @@
#!/bin/sh
set -e
rojo build plugin -o "$LOCALAPPDATA/Roblox/Plugins/Rojo.rbxm"

View File

@@ -1,12 +0,0 @@
local pluginPath, testezPath = ...
local plugin = remodel.readModelFile(pluginPath)[1]
local testez = remodel.readModelFile(testezPath)[1]
local marker = Instance.new("Folder")
marker.Name = "ROJO_DEV_BUILD"
marker.Parent = plugin
testez.Parent = plugin
remodel.writeModelFile(plugin, pluginPath)

View File

@@ -1,8 +0,0 @@
local pluginPath, placePath = ...
local plugin = remodel.readModelFile(pluginPath)[1]
local place = remodel.readPlaceFile(placePath)
plugin.Parent = place:GetService("ReplicatedStorage")
remodel.writePlaceFile(place, placePath)

View File

@@ -1,6 +0,0 @@
#!/bin/sh
set -e
./bin/run-cli-tests.sh
./bin/run-plugin-tests.sh

View File

@@ -1,9 +0,0 @@
#!/bin/sh
set -e
cargo test --all --locked
cargo fmt -- --check
touch src/lib.rs # Nudge Rust source to make Clippy actually check things
cargo clippy

View File

@@ -1,16 +0,0 @@
#!/bin/sh
set -e
DIR="$( mktemp -d )"
PLUGIN_FILE="$DIR/Rojo.rbxmx"
PLACE_FILE="$DIR/RojoTestPlace.rbxlx"
rojo build plugin -o "$PLUGIN_FILE"
rojo build plugin/place.project.json -o "$PLACE_FILE"
remodel bin/put-plugin-in-test-place.lua "$PLUGIN_FILE" "$PLACE_FILE"
run-in-roblox -s plugin/testBootstrap.server.lua "$PLACE_FILE"
luacheck plugin/src plugin/log plugin/http

21
bin/test-scratch-project Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
# Copies a project from 'test-projects' into a folder that can be messed with
# without accidentally checking the results into version control.
set -e
if [ ! -d "test-projects/$1" ]
then
echo "Pick a project that exists!"
exit 1
fi
if [ -d "scratch-project/$1" ]
then
rm -rf "scratch-project/$1"
fi
mkdir -p scratch-project
cp -r "test-projects/$1" scratch-project
cargo run -- serve "scratch-project/$1"

View File

@@ -1,13 +0,0 @@
[package]
name = "clibrojo"
version = "0.1.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018"
[lib]
crate-type = ["cdylib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rojo = { path = ".." }

View File

@@ -1,19 +0,0 @@
# Rojo as a C Library
This is an experiment to expose a C API for Rojo that would be suitable for embedding it into an existing C/C++ application.
I'm hoping to expand it to drop the HTTP layer and communicate through a channel, which could make it feasible to embed into an existing Roblox IDE with minimal changes or additional code.
## Building
This project is currently not built by default and could break/disappear at any time.
```bash
cargo build -p clibrojo
```
On Windows, Cargo will generate a `clibrojo.dll` and associated `.lib` file. Link these into your project.
To generate the associated C header file to include in the project, use [cbindgen](https://github.com/eqrion/cbindgen):
```bash
cbindgen --crate clibrojo --output include/rojo.h
```

View File

@@ -1,14 +0,0 @@
use std::{ffi::CStr, os::raw::c_char, path::PathBuf};
use librojo::commands::{serve, ServeOptions};
#[no_mangle]
pub extern "C" fn rojo_serve(path: *const c_char) {
let path = unsafe { PathBuf::from(CStr::from_ptr(path).to_str().unwrap()) };
serve(&ServeOptions {
fuzzy_project_path: path,
port: None,
})
.unwrap();
}

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

@@ -87,4 +87,4 @@ Generating and publishing your game is as simple as:
rojo upload --asset_id [PLACE ID] --cookie "[SECURITY COOKIE]" rojo upload --asset_id [PLACE ID] --cookie "[SECURITY COOKIE]"
``` ```
An example project is available on GitHub that deploys to roblox.com from GitHub and Travis-CI automatically: [https://github.com/LPGhatguy/roads](https://github.com/LPGhatguy/roads) An example project is available on GitHub that deploys to Roblox.com from GitHub and Travis-CI automatically: [https://github.com/LPGhatguy/roads](https://github.com/LPGhatguy/roads)

View File

@@ -1,11 +1,4 @@
This is the documentation home for **the Rojo `master` branch**. This is the documentation home for **Rojo 0.5.x**.
!!! warning
Documentation here may not apply to the latest release of Rojo yet.
For documentation for the latest stable release series, 0.5.x, go to:
[https://rojo.space/docs/0.5.x](https://rojo.space/docs/0.5.x)
Available versions of these docs: Available versions of these docs:

View File

@@ -12,7 +12,7 @@ Rojo opens the door to use the absolute best text editors in the world and their
Some very popular editors include [Visual Studio Code](https://code.visualstudio.com) and [Sublime Text](https://www.sublimetext.com). Some very popular editors include [Visual Studio Code](https://code.visualstudio.com) and [Sublime Text](https://www.sublimetext.com).
These advanced text editors have features like multi-cursor editing, go-to symbol, multi-file regex find and replace, bookmarks and much more. These advanced text editors have features like multi-cursor editing, goto symbol, multi-file regex find and replace, bookmarks and much more.
Many Rojo VS Code users also use extensions like: Many Rojo VS Code users also use extensions like:
@@ -41,4 +41,4 @@ Popular tools include:
* [luacheck](https://github.com/mpeterv/luacheck), a static analysis tool to help you write better Lua * [luacheck](https://github.com/mpeterv/luacheck), a static analysis tool to help you write better Lua
* [ripgrep](https://github.com/BurntSushi/ripgrep), an extremely fast code search tool * [ripgrep](https://github.com/BurntSushi/ripgrep), an extremely fast code search tool
* [Tokei](https://github.com/XAMPPRocky/tokei), a tool for statistics like lines of code * [Tokei](https://github.com/XAMPPRocky/tokei), a tool for statistics like lines of code

1
plugin/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/luacov.*

View File

@@ -20,7 +20,6 @@ stds.roblox = {
"CFrame", "CFrame",
"Enum", "Enum",
"Instance", "Instance",
"DockWidgetPluginGuiInfo",
} }
} }

8
plugin/.luacov Normal file
View File

@@ -0,0 +1,8 @@
return {
include = {
"^src",
},
exclude = {
"%.spec$",
},
}

View File

@@ -5,15 +5,6 @@
"Plugin": { "Plugin": {
"$path": "src" "$path": "src"
}, },
"Log": {
"$path": "log"
},
"Http": {
"$path": "http"
},
"Fmt": {
"$path": "fmt"
},
"Roact": { "Roact": {
"$path": "modules/roact/src" "$path": "modules/roact/src"
}, },

View File

@@ -1,245 +0,0 @@
--[[
This library describes a formatting mechanism akin to Rust's std::fmt.
It has a couple building blocks:
* A new syntax for formatting strings, taken verbatim from Rust. It'd also
be possible to use printf-style formatting specifiers to integrate with
the existing string.format utility.
* An equivalent to Rust's `Display` trait. We're mapping the semantics of
tostring and the __tostring metamethod onto this trait. A lot of types
should already have __tostring implementations, too!
* An equivalent to Rust's `Debug` trait. This library Lua-ifies that idea by
inventing a new metamethod, `__fmtDebug`. We pass along the "extended
form" attribute which is the equivalent of the "alternate mode" in Rust's
Debug trait since it's the author's opinion that treating it as a
verbosity flag is semantically accurate.
]]
--[[
The default implementation of __fmtDebug for tables when the extended option
is not set.
]]
local function defaultTableDebug(buffer, input)
buffer:writeRaw("{")
for key, value in pairs(input) do
buffer:write("[{:?}] = {:?}", key, value)
if next(input, key) ~= nil then
buffer:writeRaw(", ")
end
end
buffer:writeRaw("}")
end
--[[
The default implementation of __fmtDebug for tables with the extended option
set.
]]
local function defaultTableDebugExtended(buffer, input)
-- Special case for empty tables.
if next(input) == nil then
buffer:writeRaw("{}")
return
end
buffer:writeLineRaw("{")
buffer:indent()
for key, value in pairs(input) do
buffer:writeLine("[{:?}] = {:#?},", key, value)
end
buffer:unindent()
buffer:writeRaw("}")
end
--[[
The default debug representation for all types.
]]
local function debugImpl(buffer, value, extendedForm)
local valueType = typeof(value)
if valueType == "string" then
local formatted = string.format("%q", value)
buffer:writeRaw(formatted)
elseif valueType == "table" then
local valueMeta = getmetatable(value)
if valueMeta ~= nil and valueMeta.__fmtDebug ~= nil then
-- This type implement's the metamethod we made up to line up with
-- Rust's 'Debug' trait.
valueMeta.__fmtDebug(value, buffer, extendedForm)
else
if extendedForm then
defaultTableDebugExtended(buffer, value)
else
defaultTableDebug(buffer, value)
end
end
elseif valueType == "Instance" then
buffer:writeRaw(value:GetFullName())
else
buffer:writeRaw(tostring(value))
end
end
--[[
Defines and implements the library's template syntax.
]]
local function writeFmt(buffer, template, ...)
local currentArg = 0
local i = 1
local len = #template
while i <= len do
local openBrace = template:find("{", i)
if openBrace == nil then
-- There are no remaining open braces in this string, so we can
-- write the rest of the string to the buffer.
buffer:writeRaw(template:sub(i))
break
else
-- We found an open brace! This could be:
-- - A literal '{', written as '{{'
-- - The beginning of an interpolation, like '{}'
-- - An error, if there's no matching '}'
local charAfterBrace = template:sub(openBrace + 1, openBrace + 1)
if charAfterBrace == "{" then
-- This is a literal brace, so we'll write everything up to this
-- point (including the first brace), and then skip over the
-- second brace.
buffer:writeRaw(template:sub(i, openBrace))
i = openBrace + 2
else
-- This SHOULD be an interpolation. We'll find our matching
-- brace and treat the contents as the formatting specifier.
-- If there were any unwritten characters before this
-- interpolation, write them to the buffer.
if openBrace - i > 0 then
buffer:writeRaw(template:sub(i, openBrace - 1))
end
local closeBrace = template:find("}", openBrace + 1)
assert(closeBrace ~= nil, "Unclosed formatting specifier. Use '{{' to write an open brace.")
local formatSpecifier = template:sub(openBrace + 1, closeBrace - 1)
currentArg = currentArg + 1
local arg = select(currentArg, ...)
if formatSpecifier == "" then
-- This should use the equivalent of Rust's 'Display', ie
-- tostring and the __tostring metamethod.
buffer:writeRaw(tostring(arg))
elseif formatSpecifier == ":?" then
-- This should use the equivalent of Rust's 'Debug',
-- invented for this library as __fmtDebug.
debugImpl(buffer, arg, false)
elseif formatSpecifier == ":#?" then
-- This should use the equivlant of Rust's 'Debug' with the
-- 'alternate' (ie expanded) flag set.
debugImpl(buffer, arg, true)
else
error("unsupported format specifier " .. formatSpecifier, 2)
end
i = closeBrace + 1
end
end
end
end
local function debugOutputBuffer()
local buffer = {}
local startOfLine = true
local indentLevel = 0
local indentation = ""
function buffer:writeLine(template, ...)
writeFmt(self, template, ...)
self:nextLine()
end
function buffer:writeLineRaw(value)
self:writeRaw(value)
self:nextLine()
end
function buffer:write(template, ...)
return writeFmt(self, template, ...)
end
function buffer:writeRaw(value)
if #value > 0 then
if startOfLine and #indentation > 0 then
startOfLine = false
table.insert(self, indentation)
end
table.insert(self, value)
startOfLine = false
end
end
function buffer:nextLine()
table.insert(self, "\n")
startOfLine = true
end
function buffer:indent()
indentLevel = indentLevel + 1
indentation = string.rep(" ", indentLevel)
end
function buffer:unindent()
indentLevel = math.max(0, indentLevel - 1)
indentation = string.rep(" ", indentLevel)
end
function buffer:finish()
return table.concat(self, "")
end
return buffer
end
local function fmt(template, ...)
local buffer = debugOutputBuffer()
writeFmt(buffer, template, ...)
return buffer:finish()
end
--[[
Wrap the given object in a type that implements the given function as its
Debug implementation, and forwards __tostring to the type's underlying
tostring implementation.
]]
local function debugify(object, fmtFunc)
return setmetatable({}, {
__fmtDebug = function(_, ...)
return fmtFunc(object, ...)
end,
__tostring = function()
return tostring(object)
end,
})
end
return {
debugOutputBuffer = debugOutputBuffer,
fmt = fmt,
debugify = debugify,
}

View File

@@ -1,66 +0,0 @@
local HttpService = game:GetService("HttpService")
local Promise = require(script.Parent.Promise)
local Log = require(script.Parent.Log)
local HttpError = require(script.Error)
local HttpResponse = require(script.Response)
local lastRequestId = 0
local Http = {}
Http.Error = HttpError
Http.Response = HttpResponse
local function performRequest(requestParams)
local requestId = lastRequestId + 1
lastRequestId = requestId
Log.trace("HTTP {}({}) {}", requestParams.Method, requestId, requestParams.Url)
if requestParams.Body ~= nil then
Log.trace("{}", requestParams.Body)
end
return Promise.new(function(resolve, reject)
coroutine.wrap(function()
local success, response = pcall(function()
return HttpService:RequestAsync(requestParams)
end)
if success then
Log.trace("Request {} success, status code {}", requestId, response.StatusCode)
resolve(HttpResponse.fromRobloxResponse(response))
else
Log.trace("Request {} failure: {:?}", requestId, response)
reject(HttpError.fromRobloxErrorString(response))
end
end)()
end)
end
function Http.get(url)
return performRequest({
Url = url,
Method = "GET",
})
end
function Http.post(url, body)
return performRequest({
Url = url,
Method = "POST",
Body = body,
})
end
function Http.jsonEncode(object)
return HttpService:JSONEncode(object)
end
function Http.jsonDecode(source)
return HttpService:JSONDecode(source)
end
return Http

View File

@@ -1,5 +0,0 @@
return function()
it("should load", function()
require(script.Parent)
end)
end

View File

@@ -0,0 +1,37 @@
--[[
Loads the Rojo plugin and all of its dependencies.
]]
local function loadEnvironment()
-- If you add any dependencies, add them to this table so they'll be loaded!
local LOAD_MODULES = {
{"src", "Rojo"},
{"modules/promise/lib", "Promise"},
{"modules/testez/lib", "TestEZ"},
}
-- This makes sure we can load Lemur and other libraries that depend on init.lua
package.path = package.path .. ";?/init.lua"
-- If this fails, make sure you've run `lua bin/install-dependencies.lua` first!
local lemur = require("modules.lemur")
-- Create a virtual Roblox tree
local habitat = lemur.Habitat.new()
-- We'll put all of our library code and dependencies here
local modules = lemur.Instance.new("Folder")
modules.Name = "Modules"
modules.Parent = habitat.game:GetService("ReplicatedStorage")
-- Load all of the modules specified above
for _, module in ipairs(LOAD_MODULES) do
local container = habitat:loadFromFs(module[1])
container.Name = module[2]
container.Parent = modules
end
return habitat, modules
end
return loadEnvironment

View File

@@ -1,5 +0,0 @@
return function()
it("should load", function()
require(script.Parent)
end)
end

1
plugin/modules/lemur Submodule

Submodule plugin/modules/lemur added at 96d4166a2d

48
plugin/place.project.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "rojo",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"Rojo": {
"$className": "Folder",
"Plugin": {
"$path": "src"
},
"Roact": {
"$path": "modules/roact/src"
},
"Promise": {
"$path": "modules/promise/lib"
},
"t": {
"$path": "modules/t/lib"
}
},
"TestEZ": {
"$path": "modules/testez/lib"
}
},
"HttpService": {
"$className": "HttpService",
"$properties": {
"HttpEnabled": {
"Type": "Bool",
"Value": true
}
}
},
"ServerScriptService": {
"$className": "ServerScriptService",
"TestBootstrap": {
"$path": "testBootstrap.server.lua"
}
}
}
}

15
plugin/runTest.lua Normal file
View File

@@ -0,0 +1,15 @@
local loadEnvironment = require("loadEnvironment")
local testPath = assert((...), "Please specify a path to a test file.")
local habitat = loadEnvironment()
local testModule = habitat:loadFromFs(testPath)
if testModule == nil then
error("Couldn't find test file at " .. testPath)
end
print("Starting test module.")
habitat:require(testModule)

17
plugin/spec.lua Normal file
View File

@@ -0,0 +1,17 @@
--[[
Loads our library and all of its dependencies, then runs tests using TestEZ.
]]
local loadEnvironment = require("loadEnvironment")
local habitat, modules = loadEnvironment()
-- Load TestEZ and run our tests
local TestEZ = habitat:require(modules.TestEZ)
local results = TestEZ.TestBootstrap:run({modules.Rojo}, TestEZ.Reporters.TextReporter)
-- Did something go wrong?
if results.failureCount > 0 then
os.exit(1)
end

View File

@@ -1,200 +1,152 @@
local Http = require(script.Parent.Parent.Http)
local Log = require(script.Parent.Parent.Log)
local Promise = require(script.Parent.Parent.Promise) local Promise = require(script.Parent.Parent.Promise)
local Config = require(script.Parent.Config) local Config = require(script.Parent.Config)
local Types = require(script.Parent.Types)
local Version = require(script.Parent.Version) local Version = require(script.Parent.Version)
local Http = require(script.Parent.Http)
local HttpError = require(script.Parent.HttpError)
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse) local ApiContext = {}
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse) ApiContext.__index = ApiContext
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
--[[ -- TODO: Audit cases of errors and create enum values for each of them.
Returns a promise that will never resolve nor reject. ApiContext.Error = {
]] ServerIdMismatch = "ServerIdMismatch",
local function hangingPromise()
return Promise.new(function() end) -- The server gave an unexpected 400-category error, which may be the
end -- client's fault.
ClientError = "ClientError",
-- The server gave an unexpected 500-category error, which may be the
-- server's fault.
ServerError = "ServerError",
}
setmetatable(ApiContext.Error, {
__index = function(_, key)
error("Invalid ApiContext.Error name " .. key, 2)
end
})
local function rejectFailedRequests(response) local function rejectFailedRequests(response)
if response.code >= 400 then if response.code >= 400 then
local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body) if response.code < 500 then
return Promise.reject(ApiContext.Error.ClientError)
return Promise.reject(message) else
return Promise.reject(ApiContext.Error.ServerError)
end
end end
return response return response
end end
local function rejectWrongProtocolVersion(infoResponseBody)
if infoResponseBody.protocolVersion ~= Config.protocolVersion then
local message = (
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
"\nMake sure you have matching versions of both the Rojo plugin and server!" ..
"\n\nYour client is version %s, with protocol version %s. It expects server version %s." ..
"\nYour server is version %s, with protocol version %s." ..
"\n\nGo to https://github.com/rojo-rbx/rojo for more details."
):format(
Version.display(Config.version), Config.protocolVersion,
Config.expectedServerVersionString,
infoResponseBody.serverVersion, infoResponseBody.protocolVersion
)
return Promise.reject(message)
end
return Promise.resolve(infoResponseBody)
end
local function rejectWrongPlaceId(infoResponseBody)
if infoResponseBody.expectedPlaceIds ~= nil then
local foundId = false
for _, id in ipairs(infoResponseBody.expectedPlaceIds) do
if id == game.PlaceId then
foundId = true
break
end
end
if not foundId then
local idList = {}
for _, id in ipairs(infoResponseBody.expectedPlaceIds) do
table.insert(idList, "- " .. tostring(id))
end
local message = (
"Found a Rojo server, but its project is set to only be used with a specific list of places." ..
"\nYour place ID is %s, but needs to be one of these:" ..
"\n%s" ..
"\n\nTo change this list, edit 'servePlaceIds' in your .project.json file."
):format(
tostring(game.PlaceId),
table.concat(idList, "\n")
)
return Promise.reject(message)
end
end
return Promise.resolve(infoResponseBody)
end
local ApiContext = {}
ApiContext.__index = ApiContext
function ApiContext.new(baseUrl) function ApiContext.new(baseUrl)
assert(type(baseUrl) == "string") assert(type(baseUrl) == "string")
local self = { local self = {
__baseUrl = baseUrl, baseUrl = baseUrl,
__sessionId = nil, serverId = nil,
__messageCursor = -1, rootInstanceId = nil,
__connected = true, messageCursor = -1,
partitionRoutes = nil,
} }
return setmetatable(self, ApiContext) setmetatable(self, ApiContext)
return self
end end
function ApiContext:__fmtDebug(output) function ApiContext:onMessage(callback)
output:writeLine("ApiContext {{") self.onMessageCallback = callback
output:indent()
output:writeLine("Connected: {}", self.__connected)
output:writeLine("Base URL: {}", self.__baseUrl)
output:writeLine("Session ID: {}", self.__sessionId)
output:writeLine("Message Cursor: {}", self.__messageCursor)
output:unindent()
output:write("}")
end
function ApiContext:disconnect()
self.__connected = false
end
function ApiContext:setMessageCursor(index)
self.__messageCursor = index
end end
function ApiContext:connect() function ApiContext:connect()
local url = ("%s/api/rojo"):format(self.__baseUrl) local url = ("%s/api/rojo"):format(self.baseUrl)
return Http.get(url) return Http.get(url)
:andThen(rejectFailedRequests) :andThen(rejectFailedRequests)
:andThen(Http.Response.json) :andThen(function(response)
:andThen(rejectWrongProtocolVersion) local body = response:json()
:andThen(function(body)
assert(validateApiInfo(body))
return body if body.protocolVersion ~= Config.protocolVersion then
end) local message = (
:andThen(rejectWrongPlaceId) "Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
:andThen(function(body) "\nMake sure you have matching versions of both the Rojo plugin and server!" ..
self.__sessionId = body.sessionId "\n\nYour client is version %s, with protocol version %s. It expects server version %s." ..
"\nYour server is version %s, with protocol version %s." ..
"\n\nGo to https://github.com/LPGhatguy/rojo for more details."
):format(
Version.display(Config.version), Config.protocolVersion,
Config.expectedApiContextVersionString,
body.serverVersion, body.protocolVersion
)
return body return Promise.reject(message)
end
if body.expectedPlaceIds ~= nil then
local foundId = false
for _, id in ipairs(body.expectedPlaceIds) do
if id == game.PlaceId then
foundId = true
break
end
end
if not foundId then
local idList = {}
for _, id in ipairs(body.expectedPlaceIds) do
table.insert(idList, "- " .. tostring(id))
end
local message = (
"Found a Rojo server, but its project is set to only be used with a specific list of places." ..
"\nYour place ID is %s, but needs to be one of these:" ..
"\n%s" ..
"\n\nTo change this list, edit 'servePlaceIds' in roblox-project.json"
):format(
tostring(game.PlaceId),
table.concat(idList, "\n")
)
return Promise.reject(message)
end
end
self.serverId = body.serverId
self.partitionRoutes = body.partitions
self.rootInstanceId = body.rootInstanceId
end) end)
end end
function ApiContext:read(ids) function ApiContext:read(ids)
local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ",")) local url = ("%s/api/read/%s"):format(self.baseUrl, table.concat(ids, ","))
return Http.get(url) return Http.get(url)
:andThen(rejectFailedRequests) :andThen(rejectFailedRequests)
:andThen(Http.Response.json) :andThen(function(response)
:andThen(function(body) local body = response:json()
if body.sessionId ~= self.__sessionId then
if body.serverId ~= self.serverId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
assert(validateApiRead(body))
return body return body
end) end)
end end
function ApiContext:write(patch) function ApiContext:retrieveMessages(initialCursor)
local url = ("%s/api/write"):format(self.__baseUrl) if initialCursor ~= nil then
self.messageCursor = initialCursor
local body = {
sessionId = self.__sessionId,
removed = patch.removed,
updated = patch.updated,
}
-- Only add the 'added' field if the table is non-empty, or else Roblox's
-- JSON implementation will turn the table into an array instead of an
-- object, causing API validation to fail.
if next(patch.added) ~= nil then
body.added = patch.added
end end
body = Http.jsonEncode(body) local url = ("%s/api/subscribe/%s"):format(self.baseUrl, self.messageCursor)
return Http.post(url, body)
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(function(body)
Log.info("Write response: {:?}", body)
return body
end)
end
function ApiContext:retrieveMessages()
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
local function sendRequest() local function sendRequest()
return Http.get(url) return Http.get(url)
:catch(function(err) :catch(function(err)
if err.type == Http.Error.Kind.Timeout then if err.type == HttpError.Error.Timeout then
if self.__connected then return sendRequest()
return sendRequest()
else
return hangingPromise()
end
end end
return Promise.reject(err) return Promise.reject(err)
@@ -203,15 +155,14 @@ function ApiContext:retrieveMessages()
return sendRequest() return sendRequest()
:andThen(rejectFailedRequests) :andThen(rejectFailedRequests)
:andThen(Http.Response.json) :andThen(function(response)
:andThen(function(body) local body = response:json()
if body.sessionId ~= self.__sessionId then
if body.serverId ~= self.serverId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
assert(validateApiSubscribe(body)) self.messageCursor = body.messageCursor
self:setMessageCursor(body.messageCursor)
return body.messages return body.messages
end) end)

View File

@@ -1,7 +1,11 @@
local strict = require(script.Parent.strict)
local Assets = { local Assets = {
Sprites = {}, Sprites = {
WhiteCross = {
asset = "rbxassetid://2738712459",
offset = Vector2.new(190, 318),
size = Vector2.new(18, 18),
},
},
Slices = { Slices = {
RoundBox = { RoundBox = {
asset = "rbxassetid://2773204550", asset = "rbxassetid://2773204550",
@@ -20,7 +24,11 @@ local Assets = {
} }
local function guardForTypos(name, map) local function guardForTypos(name, map)
strict(name, map) setmetatable(map, {
__index = function(_, key)
error(("%q is not a valid member of %s"):format(tostring(key), name), 2)
end
})
for key, child in pairs(map) do for key, child in pairs(map) do
if type(child) == "table" then if type(child) == "table" then

View File

@@ -2,21 +2,17 @@ local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact) local Roact = require(Rojo.Roact)
local Log = require(Rojo.Log)
local ApiContext = require(Plugin.ApiContext)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Config = require(Plugin.Config) local Config = require(Plugin.Config)
local DevSettings = require(Plugin.DevSettings) local DevSettings = require(Plugin.DevSettings)
local ServeSession = require(Plugin.ServeSession) local Logging = require(Plugin.Logging)
local Session = require(Plugin.Session)
local Version = require(Plugin.Version) local Version = require(Plugin.Version)
local preloadAssets = require(Plugin.preloadAssets) local preloadAssets = require(Plugin.preloadAssets)
local strict = require(Plugin.strict)
local ConnectPanel = require(Plugin.Components.ConnectPanel) local ConnectPanel = require(Plugin.Components.ConnectPanel)
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 e = Roact.createElement local e = Roact.createElement
@@ -30,7 +26,7 @@ local function showUpgradeMessage(lastVersion)
Version.display(Config.version), Config.expectedServerVersionString Version.display(Config.version), Config.expectedServerVersionString
) )
Log.info(message) Logging.info(message)
end end
--[[ --[[
@@ -56,23 +52,26 @@ local function checkUpgrade(plugin)
plugin:SetSetting("LastRojoVersion", Config.version) plugin:SetSetting("LastRojoVersion", Config.version)
end end
local AppStatus = strict("AppStatus", { local SessionStatus = {
NotStarted = "NotStarted", Disconnected = "Disconnected",
Connecting = "Connecting",
Connected = "Connected", Connected = "Connected",
Error = "Error", }
setmetatable(SessionStatus, {
__index = function(_, key)
error(("%q is not a valid member of SessionStatus"):format(tostring(key)), 2)
end,
}) })
local App = Roact.Component:extend("App") local App = Roact.Component:extend("App")
function App:init() function App:init()
self:setState({ self:setState({
appStatus = AppStatus.NotStarted, sessionStatus = SessionStatus.Disconnected,
errorMessage = nil,
}) })
self.signals = {} self.signals = {}
self.serveSession = nil self.currentSession = nil
self.displayedVersion = DevSettings:isEnabled() self.displayedVersion = DevSettings:isEnabled()
and Config.codename and Config.codename
@@ -97,7 +96,7 @@ function App:init()
360, 190 -- Minimum size 360, 190 -- Minimum size
) )
self.dockWidget = self.props.plugin:CreateDockWidgetPluginGui("Rojo-" .. self.displayedVersion, widgetInfo) self.dockWidget = self.props.plugin:CreateDockWidgetPluginGui("Rojo-0.5.x", widgetInfo)
self.dockWidget.Name = "Rojo " .. self.displayedVersion self.dockWidget.Name = "Rojo " .. self.displayedVersion
self.dockWidget.Title = "Rojo " .. self.displayedVersion self.dockWidget.Title = "Rojo " .. self.displayedVersion
self.dockWidget.AutoLocalize = false self.dockWidget.AutoLocalize = false
@@ -108,91 +107,56 @@ function App:init()
end) end)
end end
function App:startSession(address, port)
Log.trace("Starting new session")
local baseUrl = ("http://%s:%s"):format(address, port)
self.serveSession = ServeSession.new({
apiContext = ApiContext.new(baseUrl),
})
self.serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then
self:setState({
appStatus = AppStatus.Connecting,
})
elseif status == ServeSession.Status.Connected then
self:setState({
appStatus = AppStatus.Connected,
})
elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil
-- Details being present indicates that this
-- disconnection was from an error.
if details ~= nil then
Log.warn("Disconnected from an error: {}", details)
self:setState({
appStatus = AppStatus.Error,
errorMessage = tostring(details),
})
else
self:setState({
appStatus = AppStatus.NotStarted,
})
end
end
end)
self.serveSession:start()
end
function App:render() function App:render()
local children local children
if self.state.appStatus == AppStatus.NotStarted then if self.state.sessionStatus == SessionStatus.Connected then
children = {
ConnectPanel = e(ConnectPanel, {
startSession = function(address, port)
self:startSession(address, port)
end,
cancel = function()
Log.trace("Canceling session configuration")
self:setState({
appStatus = AppStatus.NotStarted,
})
end,
}),
}
elseif self.state.appStatus == AppStatus.Connecting then
children = {
ConnectingPanel = Roact.createElement(ConnectingPanel),
}
elseif self.state.appStatus == AppStatus.Connected then
children = { children = {
ConnectionActivePanel = e(ConnectionActivePanel, { ConnectionActivePanel = e(ConnectionActivePanel, {
stopSession = function() stopSession = function()
Log.trace("Disconnecting session") Logging.trace("Disconnecting session")
self.serveSession:stop() self.currentSession:disconnect()
self.serveSession = nil self.currentSession = nil
self:setState({ self:setState({
appStatus = AppStatus.NotStarted, sessionStatus = SessionStatus.Disconnected,
}) })
Log.trace("Session terminated by user") Logging.trace("Session terminated by user")
end, end,
}), }),
} }
elseif self.state.appStatus == AppStatus.Error then elseif self.state.sessionStatus == SessionStatus.Disconnected then
children = { children = {
ErrorPanel = Roact.createElement(ErrorPanel, { ConnectPanel = e(ConnectPanel, {
errorMessage = self.state.errorMessage, startSession = function(address, port)
onDismiss = function() Logging.trace("Starting new session")
local success, session = Session.new({
address = address,
port = port,
onError = function(message)
Logging.warn("Rojo session terminated because of an error:\n%s", tostring(message))
self.currentSession = nil
self:setState({
sessionStatus = SessionStatus.Disconnected,
})
end
})
if success then
self.currentSession = session
self:setState({
sessionStatus = SessionStatus.Connected,
})
end
end,
cancel = function()
Logging.trace("Canceling session configuration")
self:setState({ self:setState({
appStatus = AppStatus.NotStarted, sessionStatus = SessionStatus.Disconnected,
}) })
end, end,
}), }),
@@ -205,16 +169,16 @@ function App:render()
end end
function App:didMount() function App:didMount()
Log.trace("Rojo {} initializing", self.displayedVersion) Logging.trace("Rojo %s initializing", self.displayedVersion)
checkUpgrade(self.props.plugin) checkUpgrade(self.props.plugin)
preloadAssets() preloadAssets()
end end
function App:willUnmount() function App:willUnmount()
if self.serveSession ~= nil then if self.currentSession ~= nil then
self.serveSession:stop() self.currentSession:disconnect()
self.serveSession = nil self.currentSession = nil
end end
for _, signal in pairs(self.signals) do for _, signal in pairs(self.signals) do

View File

@@ -1,34 +0,0 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin")
local Theme = require(Plugin.Theme)
local Panel = require(Plugin.Components.Panel)
local FitText = require(Plugin.Components.FitText)
local e = Roact.createElement
local ConnectingPanel = Roact.Component:extend("ConnectingPanel")
function ConnectingPanel:render()
return e(Panel, nil, {
Layout = Roact.createElement("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 8),
}),
Text = e(FitText, {
Padding = Vector2.new(12, 6),
Font = Theme.ButtonFont,
TextSize = 18,
Text = "Connecting...",
TextColor3 = Theme.PrimaryColor,
BackgroundTransparency = 1,
}),
})
end
return ConnectingPanel

View File

@@ -1,69 +0,0 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin")
local Theme = require(Plugin.Theme)
local Panel = require(Plugin.Components.Panel)
local FitText = require(Plugin.Components.FitText)
local FitScrollingFrame = require(Plugin.Components.FitScrollingFrame)
local FormButton = require(Plugin.Components.FormButton)
local e = Roact.createElement
local BUTTON_HEIGHT = 60
local HOR_PADDING = 8
local ErrorPanel = Roact.Component:extend("ErrorPanel")
function ErrorPanel:render()
local errorMessage = self.props.errorMessage
local onDismiss = self.props.onDismiss
return e(Panel, nil, {
Layout = Roact.createElement("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 8),
}),
ErrorContainer = e(FitScrollingFrame, {
containerProps = {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(1, -HOR_PADDING * 2, 1, -BUTTON_HEIGHT),
Position = UDim2.new(0, HOR_PADDING, 0, 0),
ScrollBarImageColor3 = Theme.PrimaryColor,
VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
ScrollingDirection = Enum.ScrollingDirection.Y,
},
}, {
Text = e(FitText, {
Size = UDim2.new(1, 0, 0, 0),
LayoutOrder = 1,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
FitAxis = "Y",
Font = Theme.ButtonFont,
TextSize = 18,
Text = errorMessage,
TextWrap = true,
TextColor3 = Theme.PrimaryColor,
BackgroundTransparency = 1,
}),
}),
DismissButton = e(FormButton, {
layoutOrder = 2,
text = "Dismiss",
secondary = true,
onClick = function()
onDismiss()
end,
}),
})
end
return ErrorPanel

View File

@@ -1,33 +0,0 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Dictionary = require(script.Parent.Parent.Dictionary)
local e = Roact.createElement
local FitScrollingFrame = Roact.Component:extend("FitScrollingFrame")
function FitScrollingFrame:init()
self.sizeBinding, self.setSize = Roact.createBinding(UDim2.new())
end
function FitScrollingFrame:render()
local containerProps = self.props.containerProps
local layoutProps = self.props.layoutProps
local children = Dictionary.merge(self.props[Roact.Children], {
["$Layout"] = e("UIListLayout", Dictionary.merge({
SortOrder = Enum.SortOrder.LayoutOrder,
[Roact.Change.AbsoluteContentSize] = function(instance)
self.setSize(UDim2.new(0, 0, 0, instance.AbsoluteContentSize.Y))
end,
}, layoutProps)),
})
local fullContainerProps = Dictionary.merge(containerProps, {
CanvasSize = self.sizeBinding,
})
return e("ScrollingFrame", fullContainerProps, children)
end
return FitScrollingFrame

View File

@@ -9,7 +9,6 @@ local e = Roact.createElement
local FitText = Roact.Component:extend("FitText") local FitText = Roact.Component:extend("FitText")
function FitText:init() function FitText:init()
self.ref = Roact.createRef()
self.sizeBinding, self.setSize = Roact.createBinding(UDim2.new()) self.sizeBinding, self.setSize = Roact.createBinding(UDim2.new())
end end
@@ -17,15 +16,10 @@ function FitText:render()
local kind = self.props.Kind or "TextLabel" local kind = self.props.Kind or "TextLabel"
local containerProps = Dictionary.merge(self.props, { local containerProps = Dictionary.merge(self.props, {
FitAxis = Dictionary.None,
Kind = Dictionary.None, Kind = Dictionary.None,
Padding = Dictionary.None, Padding = Dictionary.None,
MinSize = Dictionary.None, MinSize = Dictionary.None,
Size = self.sizeBinding, Size = self.sizeBinding
[Roact.Ref] = self.ref,
[Roact.Change.AbsoluteSize] = function()
self:updateTextMeasurements()
end
}) })
return e(kind, containerProps) return e(kind, containerProps)
@@ -42,45 +36,15 @@ end
function FitText:updateTextMeasurements() function FitText:updateTextMeasurements()
local minSize = self.props.MinSize or Vector2.new(0, 0) local minSize = self.props.MinSize or Vector2.new(0, 0)
local padding = self.props.Padding or Vector2.new(0, 0) local padding = self.props.Padding or Vector2.new(0, 0)
local fitAxis = self.props.FitAxis or "XY"
local baseSize = self.props.Size
local text = self.props.Text or "" local text = self.props.Text or ""
local font = self.props.Font or Enum.Font.Legacy local font = self.props.Font or Enum.Font.Legacy
local textSize = self.props.TextSize or 12 local textSize = self.props.TextSize or 12
local containerSize = self.ref.current.AbsoluteSize local measuredText = TextService:GetTextSize(text, textSize, font, Vector2.new(9e6, 9e6))
local totalSize = UDim2.new(
local textBounds 0, math.max(minSize.X, padding.X * 2 + measuredText.X),
0, math.max(minSize.Y, padding.Y * 2 + measuredText.Y))
if fitAxis == "XY" then
textBounds = Vector2.new(9e6, 9e6)
elseif fitAxis == "X" then
textBounds = Vector2.new(9e6, containerSize.Y - padding.Y * 2)
elseif fitAxis == "Y" then
textBounds = Vector2.new(containerSize.X - padding.X * 2, 9e6)
end
local measuredText = TextService:GetTextSize(text, textSize, font, textBounds)
local computedX = math.max(minSize.X, padding.X * 2 + measuredText.X)
local computedY = math.max(minSize.Y, padding.Y * 2 + measuredText.Y)
local totalSize
if fitAxis == "XY" then
totalSize = UDim2.new(
0, computedX,
0, computedY)
elseif fitAxis == "X" then
totalSize = UDim2.new(
0, computedX,
baseSize.Y.Scale, baseSize.Y.Offset)
elseif fitAxis == "Y" then
totalSize = UDim2.new(
baseSize.X.Scale, baseSize.X.Offset,
0, computedY)
end
self.setSize(totalSize) self.setSize(totalSize)
end end

View File

@@ -8,6 +8,8 @@ local Version = require(Plugin.Version)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.Theme) local Theme = require(Plugin.Theme)
local FitText = require(Plugin.Components.FitText)
local e = Roact.createElement local e = Roact.createElement
local RojoFooter = Roact.Component:extend("RojoFooter") local RojoFooter = Roact.Component:extend("RojoFooter")

View File

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

View File

@@ -6,15 +6,13 @@ local Environment = {
Test = "Test", Test = "Test",
} }
local DEFAULT_ENVIRONMENT = Config.isDevBuild and Environment.Dev or Environment.User
local VALUES = { local VALUES = {
LogLevel = { LogLevel = {
type = "IntValue", type = "IntValue",
values = { values = {
[Environment.User] = 2, [Environment.User] = 2,
[Environment.Dev] = 4, [Environment.Dev] = 3,
[Environment.Test] = 4, [Environment.Test] = 3,
}, },
}, },
TypecheckingEnabled = { TypecheckingEnabled = {
@@ -25,14 +23,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
@@ -43,16 +33,6 @@ end
local valueContainer = getValueContainer() local valueContainer = getValueContainer()
game.ChildAdded:Connect(function(child)
local success, name = pcall(function()
return child.Name
end)
if success and name == CONTAINER_NAME then
valueContainer = child
end
end)
local function getStoredValue(name) local function getStoredValue(name)
if valueContainer == nil then if valueContainer == nil then
return nil return nil
@@ -104,7 +84,7 @@ local function getValue(name)
return stored return stored
end end
return VALUES[name].values[DEFAULT_ENVIRONMENT] return VALUES[name].values[Environment.User]
end end
local DevSettings = {} local DevSettings = {}
@@ -140,10 +120,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

75
plugin/src/Http.lua Normal file
View File

@@ -0,0 +1,75 @@
local HttpService = game:GetService("HttpService")
local Promise = require(script.Parent.Parent.Promise)
local Logging = require(script.Parent.Logging)
local HttpError = require(script.Parent.HttpError)
local HttpResponse = require(script.Parent.HttpResponse)
local lastRequestId = 0
-- TODO: Factor out into separate library, especially error handling
local Http = {}
function Http.get(url)
local requestId = lastRequestId + 1
lastRequestId = requestId
Logging.trace("GET(%d) %s", requestId, url)
return Promise.new(function(resolve, reject)
coroutine.wrap(function()
local success, response = pcall(function()
return HttpService:RequestAsync({
Url = url,
Method = "GET",
})
end)
if success then
Logging.trace("Request %d success: status code %s", requestId, response.StatusCode)
resolve(HttpResponse.fromRobloxResponse(response))
else
Logging.trace("Request %d failure: %s", requestId, response)
reject(HttpError.fromErrorString(response))
end
end)()
end)
end
function Http.post(url, body)
local requestId = lastRequestId + 1
lastRequestId = requestId
Logging.trace("POST(%d) %s\n%s", requestId, url, body)
return Promise.new(function(resolve, reject)
coroutine.wrap(function()
local success, response = pcall(function()
return HttpService:RequestAsync({
Url = url,
Method = "POST",
Body = body,
})
end)
if success then
Logging.trace("Request %d success: status code %s", requestId, response.StatusCode)
resolve(HttpResponse.fromRobloxResponse(response))
else
Logging.trace("Request %d failure: %s", requestId, response)
reject(HttpError.fromErrorString(response))
end
end)()
end)
end
function Http.jsonEncode(object)
return HttpService:JSONEncode(object)
end
function Http.jsonDecode(source)
return HttpService:JSONDecode(source)
end
return Http

View File

@@ -1,7 +1,9 @@
local Error = {} local Logging = require(script.Parent.Logging)
Error.__index = Error
Error.Kind = { local HttpError = {}
HttpError.__index = HttpError
HttpError.Error = {
HttpNotEnabled = { HttpNotEnabled = {
message = "Rojo requires HTTP access, which is not enabled.\n" .. message = "Rojo requires HTTP access, which is not enabled.\n" ..
"Check your game settings, located in the 'Home' tab of Studio.", "Check your game settings, located in the 'Home' tab of Studio.",
@@ -11,20 +13,20 @@ Error.Kind = {
"Make sure the server is running -- use 'rojo serve' to run it!", "Make sure the server is running -- use 'rojo serve' to run it!",
}, },
Timeout = { Timeout = {
message = "HTTP request timed out.", message = "Request timed out.",
}, },
Unknown = { Unknown = {
message = "Unknown HTTP error: {{message}}", message = "Unknown error: {{message}}",
}, },
} }
setmetatable(Error.Kind, { setmetatable(HttpError.Error, {
__index = function(_, key) __index = function(_, key)
error(("%q is not a valid member of Http.Error.Kind"):format(tostring(key)), 2) error(("%q is not a valid member of HttpError.Error"):format(tostring(key)), 2)
end, end,
}) })
function Error.new(type, extraMessage) function HttpError.new(type, extraMessage)
extraMessage = extraMessage or "" extraMessage = extraMessage or ""
local message = type.message:gsub("{{message}}", extraMessage) local message = type.message:gsub("{{message}}", extraMessage)
@@ -33,34 +35,38 @@ function Error.new(type, extraMessage)
message = message, message = message,
} }
setmetatable(err, Error) setmetatable(err, HttpError)
return err return err
end end
function Error:__tostring() function HttpError:__tostring()
return self.message return self.message
end end
--[[ --[[
This method shouldn't have to exist. Ugh. This method shouldn't have to exist. Ugh.
]] ]]
function Error.fromRobloxErrorString(message) function HttpError.fromErrorString(message)
local lower = message:lower() local lower = message:lower()
if lower:find("^http requests are not enabled") then if lower:find("^http requests are not enabled") then
return Error.new(Error.Kind.HttpNotEnabled) return HttpError.new(HttpError.Error.HttpNotEnabled)
end end
if lower:find("^httperror: timedout") then if lower:find("^httperror: timedout") then
return Error.new(Error.Kind.Timeout) return HttpError.new(HttpError.Error.Timeout)
end end
if lower:find("^httperror: connectfail") then if lower:find("^httperror: connectfail") then
return Error.new(Error.Kind.ConnectFailed) return HttpError.new(HttpError.Error.ConnectFailed)
end end
return Error.new(Error.Kind.Unknown, message) return HttpError.new(HttpError.Error.Unknown, message)
end end
return Error function HttpError:report()
Logging.warn(self.message)
end
return HttpError

View File

@@ -1,34 +1,34 @@
local HttpService = game:GetService("HttpService") local HttpService = game:GetService("HttpService")
local stringTemplate = [[ local stringTemplate = [[
Http.Response { HttpResponse {
code: %d code: %d
body: %s body: %s
}]] }]]
local Response = {} local HttpResponse = {}
Response.__index = Response HttpResponse.__index = HttpResponse
function Response:__tostring() function HttpResponse:__tostring()
return stringTemplate:format(self.code, self.body) return stringTemplate:format(self.code, self.body)
end end
function Response.fromRobloxResponse(response) function HttpResponse.fromRobloxResponse(response)
local self = { local self = {
body = response.Body, body = response.Body,
code = response.StatusCode, code = response.StatusCode,
headers = response.Headers, headers = response.Headers,
} }
return setmetatable(self, Response) return setmetatable(self, HttpResponse)
end end
function Response:isSuccess() function HttpResponse:isSuccess()
return self.code >= 200 and self.code < 300 return self.code >= 200 and self.code < 300
end end
function Response:json() function HttpResponse:json()
return HttpService:JSONDecode(self.body) return HttpService:JSONDecode(self.body)
end end
return Response return HttpResponse

View File

@@ -1,4 +1,4 @@
local Log = require(script.Parent.Parent.Log) local Logging = require(script.Parent.Logging)
--[[ --[[
A bidirectional map between instance IDs and Roblox instances. It lets us A bidirectional map between instance IDs and Roblox instances. It lets us
@@ -9,79 +9,39 @@ local Log = require(script.Parent.Parent.Log)
local InstanceMap = {} local InstanceMap = {}
InstanceMap.__index = InstanceMap InstanceMap.__index = InstanceMap
function InstanceMap.new(onInstanceChanged) function InstanceMap.new()
local self = { local self = {
fromIds = {}, fromIds = {},
fromInstances = {}, fromInstances = {},
instancesToSignal = {},
onInstanceChanged = onInstanceChanged,
} }
return setmetatable(self, InstanceMap) return setmetatable(self, InstanceMap)
end end
--[[
Disconnect all connections and release all instance references.
]]
function InstanceMap:stop()
-- I think this is safe.
for instance in pairs(self.fromInstances) do
self:removeInstance(instance)
end
end
function InstanceMap:__fmtDebug(output)
output:writeLine("InstanceMap {{")
output:indent()
-- Collect all of the entries in the InstanceMap and sort them by their
-- label, which helps make our output deterministic.
local entries = {}
for id, instance in pairs(self.fromIds) do
local label = string.format("%s (%s)", instance:GetFullName(), instance.ClassName)
table.insert(entries, {id, label})
end
table.sort(entries, function(a, b)
return a[2] < b[2]
end)
for _, entry in ipairs(entries) do
output:writeLine("{}: {}", entry[1], entry[2])
end
output:unindent()
output:write("}")
end
function InstanceMap:insert(id, instance) function InstanceMap:insert(id, instance)
self.fromIds[id] = instance self.fromIds[id] = instance
self.fromInstances[instance] = id self.fromInstances[instance] = id
self:__connectSignals(instance)
end end
function InstanceMap:removeId(id) function InstanceMap:removeId(id)
local instance = self.fromIds[id] local instance = self.fromIds[id]
if instance ~= nil then if instance ~= nil then
self:__disconnectSignals(instance)
self.fromIds[id] = nil self.fromIds[id] = nil
self.fromInstances[instance] = nil self.fromInstances[instance] = nil
else else
Log.warn("Attempted to remove nonexistant ID {}", id) Logging.warn("Attempted to remove nonexistant ID %s", tostring(id))
end end
end end
function InstanceMap:removeInstance(instance) function InstanceMap:removeInstance(instance)
local id = self.fromInstances[instance] local id = self.fromInstances[instance]
self:__disconnectSignals(instance)
if id ~= nil then if id ~= nil then
self.fromInstances[instance] = nil self.fromInstances[instance] = nil
self.fromIds[id] = nil self.fromIds[id] = nil
else else
Log.warn("Attempted to remove nonexistant instance {}", instance) Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance))
end end
end end
@@ -91,7 +51,7 @@ function InstanceMap:destroyInstance(instance)
if id ~= nil then if id ~= nil then
self:destroyId(id) self:destroyId(id)
else else
Log.warn("Attempted to destroy untracked instance {}", instance) Logging.warn("Attempted to destroy untracked instance %s", tostring(instance))
end end
end end
@@ -114,59 +74,7 @@ function InstanceMap:destroyId(id)
instance:Destroy() instance:Destroy()
else else
Log.warn("Attempted to destroy nonexistant ID {}", id) Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id))
end
end
function InstanceMap:__connectSignals(instance)
-- ValueBase instances have an overriden version of the Changed signal that
-- only detects changes to their Value property.
--
-- We can instead connect listener to each individual property that we care
-- about on those objects (Name and Value) to emulate the same idea.
if instance:IsA("ValueBase") then
local signals = {
instance:GetPropertyChangedSignal("Name"):Connect(function()
self:__maybeFireInstanceChanged(instance, "Name")
end),
instance:GetPropertyChangedSignal("Value"):Connect(function()
self:__maybeFireInstanceChanged(instance, "Value")
end),
}
self.instancesToSignal[instance] = signals
else
self.instancesToSignal[instance] = instance.Changed:Connect(function(propertyName)
self:__maybeFireInstanceChanged(instance, propertyName)
end)
end
end
function InstanceMap:__maybeFireInstanceChanged(instance, propertyName)
Log.trace("{}.{} changed", instance:GetFullName(), propertyName)
if self.onInstanceChanged ~= nil then
self.onInstanceChanged(instance, propertyName)
end
end
function InstanceMap:__disconnectSignals(instance)
local signals = self.instancesToSignal[instance]
if signals ~= nil then
-- In most cases, we only have a single signal, so we avoid keeping
-- around the extra table. ValueBase objects force us to use multiple
-- signals to emulate the Instance.Changed event, however.
if typeof(signals) == "table" then
for _, signal in ipairs(signals) do
signal:Disconnect()
end
else
signals:Disconnect()
end
self.instancesToSignal[instance] = nil
end end
end end

View File

@@ -1,55 +1,49 @@
local Fmt = require(script.Parent.Fmt) local DevSettings = require(script.Parent.DevSettings)
local Level = { local Level = {
Error = 0, Error = 0,
Warning = 1, Warning = 1,
Info = 2, Info = 2,
Debug = 3, Trace = 3,
Trace = 4,
} }
local testLogLevel = nil
local function getLogLevel() local function getLogLevel()
return Level.Info if testLogLevel ~= nil then
return testLogLevel
end
return DevSettings:getLogLevel()
end end
local function addTags(tag, message) local function addTags(tag, message)
return tag .. message:gsub("\n", "\n" .. tag) return tag .. message:gsub("\n", "\n" .. tag)
end end
local TRACE_TAG = (" "):rep(15) .. "[Rojo-Trace] "
local INFO_TAG = (" "):rep(15) .. "[Rojo-Info] " local INFO_TAG = (" "):rep(15) .. "[Rojo-Info] "
local DEBUG_TAG = (" "):rep(15) .. "[Rojo-Debug] " local TRACE_TAG = (" "):rep(15) .. "[Rojo-Trace] "
local WARN_TAG = "[Rojo-Warn] " local WARN_TAG = "[Rojo-Warn] "
local Log = {} local Log = {}
Log.Level = Level Log.Level = Level
function Log.setLogLevelThunk(thunk)
getLogLevel = thunk
end
function Log.trace(template, ...) function Log.trace(template, ...)
if getLogLevel() >= Level.Trace then if getLogLevel() >= Level.Trace then
print(addTags(TRACE_TAG, Fmt.fmt(template, ...))) print(addTags(TRACE_TAG, string.format(template, ...)))
end end
end end
function Log.info(template, ...) function Log.info(template, ...)
if getLogLevel() >= Level.Info then if getLogLevel() >= Level.Info then
print(addTags(INFO_TAG, Fmt.fmt(template, ...))) print(addTags(INFO_TAG, string.format(template, ...)))
end
end
function Log.debug(template, ...)
if getLogLevel() >= Level.Debug then
print(addTags(DEBUG_TAG, Fmt.fmt(template, ...)))
end end
end end
function Log.warn(template, ...) function Log.warn(template, ...)
if getLogLevel() >= Level.Warning then if getLogLevel() >= Level.Warning then
warn(addTags(WARN_TAG, Fmt.fmt(template, ...))) warn(addTags(WARN_TAG, string.format(template, ...)))
end end
end end

View File

@@ -1,401 +1,236 @@
--[[
This module defines the meat of the Rojo plugin and how it manages tracking
and mutating the Roblox DOM.
]]
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 InstanceMap = require(script.Parent.InstanceMap)
local invariant = require(script.Parent.invariant) local Logging = require(script.Parent.Logging)
local getCanonicalProperty = require(script.Parent.getCanonicalProperty)
local setCanonicalProperty = require(script.Parent.setCanonicalProperty) local setCanonicalProperty = require(script.Parent.setCanonicalProperty)
local rojoValueToRobloxValue = require(script.Parent.rojoValueToRobloxValue)
local Types = require(script.Parent.Types)
--[[ local function setParent(instance, newParent)
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.
This function will always succeed, even if the actual set failed. This is
important for some types like services that will throw even if their current
parent is already set to the requested parent.
TODO: See if we can eliminate this by being more nuanced with property
assignment?
]]
local function safeSetParent(instance, newParent)
pcall(function() pcall(function()
instance.Parent = newParent instance.Parent = newParent
end) end)
end end
--[[
Similar to setting Parent, some instances really don't like being renamed.
TODO: Should we be throwing away these results or can we be more careful?
]]
local function safeSetName(instance, name)
pcall(function()
instance.Name = name
end)
end
local Reconciler = {} local Reconciler = {}
Reconciler.__index = Reconciler Reconciler.__index = Reconciler
function Reconciler.new(instanceMap) function Reconciler.new()
local self = { local self = {
-- Tracks all of the instances known by the reconciler by ID. instanceMap = InstanceMap.new(),
__instanceMap = instanceMap,
} }
return setmetatable(self, Reconciler) return setmetatable(self, Reconciler)
end end
--[[ function Reconciler:applyUpdate(requestedIds, virtualInstancesById)
See Reconciler:__hydrateInternal(). -- This function may eventually be asynchronous; it will require calls to
]] -- the server to resolve instances that don't exist yet.
function Reconciler:hydrate(apiInstances, id, instance) local visitedIds = {}
local hydratePatch = {
removed = {},
added = {},
updated = {},
}
self:__hydrateInternal(apiInstances, id, instance, hydratePatch) for _, id in ipairs(requestedIds) do
self:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
return hydratePatch
end
--[[
Applies a patch to the Roblox DOM using the reconciler's internal state.
TODO: This function might only apply some of the patch in the future and
require content negotiation with the Rojo server to handle types that aren't
editable by scripts.
]]
local applyPatchSchema = Types.ifEnabled(t.tuple(
IPatch
))
function Reconciler:applyPatch(patch)
assert(applyPatchSchema(patch))
for _, removedIdOrInstance in ipairs(patch.removed) do
local removedInstance
if Types.RbxId(removedIdOrInstance) then
-- If this value is an ID, it's assumed to be an instance that the
-- Rojo server knows about.
removedInstance = self.__instanceMap.fromIds[removedIdOrInstance]
self.__instanceMap:removeId(removedIdOrInstance)
end
-- If this entry was an ID that we didn't know about, removedInstance
-- will be nil, which we guard against in case of minor tree desync.
if removedInstance ~= nil then
-- Ensure that if any descendants are tracked by Rojo, that we
-- properly un-track them.
for _, descendantInstance in ipairs(removedInstance:GetDescendants()) do
self.__instanceMap:removeInstance(descendantInstance)
end
removedInstance:Destroy()
end
end
-- TODO: This loop assumes that apiInstance.ParentId is never nil. The Rojo
-- plugin can't create a new top-level DataModel anyways, so this should
-- only be violated in cases that are already erroneous.
for id, apiInstance in pairs(patch.added) do
if self.__instanceMap.fromIds[id] == nil then
-- Find the first ancestor of this instance that is marked for an
-- addition.
--
-- This helps us make sure we only reify each instance once, and we
-- start from the top.
while patch.added[apiInstance.Parent] ~= nil do
id = apiInstance.Parent
apiInstance = patch.added[id]
end
local parentInstance = self.__instanceMap.fromIds[apiInstance.Parent]
if parentInstance == nil then
invariant(
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
id,
apiInstance.Parent,
self.__instanceMap
)
end
self:__reifyInstance(patch.added, id, parentInstance)
end
end
for _, update in ipairs(patch.updated) do
local instance = self.__instanceMap.fromIds[update.id]
if instance == nil then
invariant(
"Cannot update an instance that does not exist in the reconciler's state.\nInstance {}\nState: {:#?}",
update.id,
self.__instanceMap
)
end
if update.changedClassName ~= nil then
error("TODO: Support changing class name by destroying + recreating instance.")
end
if update.changedName ~= nil then
instance.Name = update.changedName
end
if update.changedMetadata ~= nil then
print("TODO: Support changing metadata, if necessary.")
end
if update.changedProperties ~= nil then
for propertyName, propertyValue in pairs(update.changedProperties) do
-- TODO: Gracefully handle this error instead?
assert(setCanonicalProperty(instance, propertyName, self:__decodeApiValue(propertyValue)))
end
end
end end
end end
--[[ local reconcileSchema = Types.ifEnabled(t.tuple(
Transforms a value into one that can be sent over the network back to the t.map(t.string, Types.VirtualInstance),
Rojo server. t.string,
This operation can fail, and so it returns bool, value.
]]
function Reconciler:encodeApiValue(value)
if typeof(value) == "string" then
return true, {
Type = "String",
Value = value,
}
end
return false
end
--[[
Transforms a value encoded by rbx_dom_weak on the server side into a value
usable by Rojo's reconciler, potentially using RbxDom.
]]
function Reconciler:__decodeApiValue(apiValue)
assert(Types.ApiValue(apiValue))
-- Refs are represented as IDs in the same space that Rojo's protocol uses.
if apiValue.Type == "Ref" then
-- TODO: This ref could be pointing at an instance we haven't created
-- yet!
return self.__instanceMap.fromIds[apiValue.Value]
end
local success, decodedValue = RbxDom.EncodedValue.decode(apiValue)
if not success then
error(decodedValue, 2)
end
return decodedValue
end
--[[
Constructs an instance from an ApiInstance without any of its children.
]]
local reifySingleInstanceSchema = Types.ifEnabled(t.tuple(
Types.ApiInstance
))
function Reconciler:__reifySingleInstance(apiInstance)
assert(reifySingleInstanceSchema(apiInstance))
-- Instance.new can fail if we're passing in something that can't be
-- created, like a service, something enabled with a feature flag, or
-- something that requires higher security than we have.
local ok, instance = pcall(Instance.new, apiInstance.ClassName)
if not ok then
return false, instance
end
-- TODO: When can setting Name fail here?
safeSetName(instance, apiInstance.Name)
for key, value in pairs(apiInstance.Properties) do
setCanonicalProperty(instance, key, self:__decodeApiValue(value))
end
return true, instance
end
--[[
Construct an instance and all of its descendants, parent it to the given
instance, and insert it into the reconciler's internal state.
]]
local reifyInstanceSchema = Types.ifEnabled(t.tuple(
t.map(Types.RbxId, Types.VirtualInstance),
Types.RbxId,
t.Instance t.Instance
)) ))
function Reconciler:__reifyInstance(apiInstances, id, parentInstance)
assert(reifyInstanceSchema(apiInstances, id, parentInstance))
local apiInstance = apiInstances[id]
local ok, instance = self:__reifySingleInstance(apiInstance)
-- TODO: Propagate this error upward to handle it elsewhere?
if not ok then
error(("Couldn't create an instance of type %q, a child of %s"):format(
apiInstance.ClassName,
parentInstance:GetFullName()
))
end
self.__instanceMap:insert(id, instance)
for _, childId in ipairs(apiInstance.Children) do
self:__reifyInstance(apiInstances, childId, instance)
end
safeSetParent(instance, parentInstance)
return instance
end
--[[ --[[
Populates the reconciler's internal state, maps IDs to instances that the Update an existing instance, including its properties and children, to match
Rojo plugin knows about, and generates a patch that would update the Roblox the given information.
tree to match Rojo's view of the tree.
]] ]]
local hydrateSchema = Types.ifEnabled(t.tuple( function Reconciler:reconcile(virtualInstancesById, id, instance)
t.map(Types.RbxId, Types.VirtualInstance), assert(reconcileSchema(virtualInstancesById, id, instance))
Types.RbxId,
t.Instance,
IPatch
))
function Reconciler:__hydrateInternal(apiInstances, id, instance, hydratePatch)
assert(hydrateSchema(apiInstances, id, instance, hydratePatch))
self.__instanceMap:insert(id, instance) local virtualInstance = virtualInstancesById[id]
local apiInstance = apiInstances[id] -- If an instance changes ClassName, we assume it's very different. That's
-- not always the case!
if virtualInstance.ClassName ~= instance.ClassName then
Logging.trace("Switching to reify for %s because ClassName is different", instance:GetFullName())
local function markIdAdded(id) -- TODO: Preserve existing children instead?
local apiInstance = apiInstances[id] local parent = instance.Parent
hydratePatch.added[id] = apiInstance self.instanceMap:destroyId(id)
return self:__reify(virtualInstancesById, id, parent)
for _, childId in ipairs(apiInstance.Children) do
markIdAdded(childId)
end
end end
local changedName = nil self.instanceMap:insert(id, instance)
local changedProperties = {}
if apiInstance.Name ~= instance.Name then -- Some instances don't like being named, even if their name already matches
changedName = apiInstance.Name setCanonicalProperty(instance, "Name", virtualInstance.Name)
end
for propertyName, virtualValue in pairs(apiInstance.Properties) do for key, value in pairs(virtualInstance.Properties) do
local success, existingValue = getCanonicalProperty(instance, propertyName) setCanonicalProperty(instance, key, rojoValueToRobloxValue(value))
if success then
local decodedValue = self:__decodeApiValue(virtualValue)
if existingValue ~= decodedValue then
changedProperties[propertyName] = virtualValue
end
end
end
-- If any properties differed from the virtual instance we read, add it to
-- the hydrate patch so that we can catch up.
if changedName ~= nil or next(changedProperties) ~= nil then
table.insert(hydratePatch.updated, {
id = id,
changedName = changedName,
changedClassName = nil,
changedProperties = changedProperties,
changedMetadata = nil,
})
end end
local existingChildren = instance:GetChildren() local existingChildren = instance:GetChildren()
-- For each existing child, we'll track whether it's been paired with an local unvisitedExistingChildren = {}
-- instance that the Rojo server knows about. for _, child in ipairs(existingChildren) do
local isExistingChildVisited = {} unvisitedExistingChildren[child] = true
for i = 1, #existingChildren do
isExistingChildVisited[i] = false
end end
for _, childId in ipairs(apiInstance.Children) do for _, childId in ipairs(virtualInstance.Children) do
local apiChild = apiInstances[childId] local childData = virtualInstancesById[childId]
local childInstance local existingChildInstance
for instance in pairs(unvisitedExistingChildren) do
local ok, name, className = pcall(function()
return instance.Name, instance.ClassName
end)
for childIndex, instance in ipairs(existingChildren) do if ok then
if not isExistingChildVisited[childIndex] then if name == childData.Name and className == childData.ClassName then
-- We guard accessing Name and ClassName in order to avoid existingChildInstance = instance
-- tripping over children of DataModel that Rojo won't have
-- permissions to access at all.
local ok, name, className = pcall(function()
return instance.Name, instance.ClassName
end)
-- This rule is very conservative and could be loosened in the
-- future, or more heuristics could be introduced.
if ok and name == apiChild.Name and className == apiChild.ClassName then
childInstance = instance
isExistingChildVisited[childIndex] = true
break break
end end
end end
end end
if childInstance ~= nil then if existingChildInstance ~= nil then
-- We found an instance that matches the instance from the API, yay! unvisitedExistingChildren[existingChildInstance] = nil
self:__hydrateInternal(apiInstances, childId, childInstance, hydratePatch) self:reconcile(virtualInstancesById, childId, existingChildInstance)
else else
markIdAdded(childId) Logging.trace(
"Switching to reify for %s.%s because it does not exist",
instance:GetFullName(),
virtualInstancesById[childId].Name
)
self:__reify(virtualInstancesById, childId, instance)
end end
end end
-- Any unvisited children at this point aren't known by Rojo and we can local shouldClearUnknown = self:__shouldClearUnknownChildren(virtualInstance)
-- destroy them unless the user has explicitly asked us to preserve children
-- of this instance. for existingChildInstance in pairs(unvisitedExistingChildren) do
local shouldClearUnknown = self:__shouldClearUnknownChildren(apiInstance) local childId = self.instanceMap.fromInstances[existingChildInstance]
if shouldClearUnknown then
for childIndex, visited in ipairs(isExistingChildVisited) do if childId == nil then
if not visited then if shouldClearUnknown then
table.insert(hydratePatch.removed, existingChildren[childIndex]) existingChildInstance:Destroy()
end end
else
self.instanceMap:destroyInstance(existingChildInstance)
end end
end end
-- The root instance of a project won't have a parent, like the DataModel,
-- so we need to be careful here.
if virtualInstance.Parent ~= nil then
local parent = self.instanceMap.fromIds[virtualInstance.Parent]
if parent == nil then
Logging.info("Instance %s wanted parent of %s", tostring(id), tostring(virtualInstance.Parent))
error("Rojo bug: During reconciliation, an instance referred to an instance ID as parent that does not exist.")
end
-- Some instances, like services, don't like having their Parent
-- property poked, even if we're setting it to the same value.
setParent(instance, parent)
end
return instance
end end
function Reconciler:__shouldClearUnknownChildren(apiInstance) function Reconciler:__shouldClearUnknownChildren(virtualInstance)
if apiInstance.Metadata ~= nil then if virtualInstance.Metadata ~= nil then
return not apiInstance.Metadata.ignoreUnknownInstances return not virtualInstance.Metadata.ignoreUnknownInstances
else else
return true return true
end end
end end
local reifySchema = Types.ifEnabled(t.tuple(
t.map(t.string, Types.VirtualInstance),
t.string,
t.Instance
))
function Reconciler:__reify(virtualInstancesById, id, parent)
assert(reifySchema(virtualInstancesById, id, parent))
local virtualInstance = virtualInstancesById[id]
local ok, instance = pcall(function()
return Instance.new(virtualInstance.ClassName)
end)
if not ok then
error(("Couldn't create an Instance of type %q, a child of %s"):format(virtualInstance.ClassName, parent:GetFullName()))
end
for key, value in pairs(virtualInstance.Properties) do
setCanonicalProperty(instance, key, rojoValueToRobloxValue(value))
end
setCanonicalProperty(instance, "Name", virtualInstance.Name)
for _, childId in ipairs(virtualInstance.Children) do
self:__reify(virtualInstancesById, childId, instance)
end
setParent(instance, parent)
self.instanceMap:insert(id, instance)
return instance
end
local applyUpdatePieceSchema = Types.ifEnabled(t.tuple(
t.string,
t.map(t.string, t.boolean),
t.map(t.string, Types.VirtualInstance)
))
function Reconciler:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
assert(applyUpdatePieceSchema(id, visitedIds, virtualInstancesById))
if visitedIds[id] then
return
end
visitedIds[id] = true
local virtualInstance = virtualInstancesById[id]
local instance = self.instanceMap.fromIds[id]
-- The instance was deleted in this update
if virtualInstance == nil then
self.instanceMap:destroyId(id)
return
end
-- An instance we know about was updated
if instance ~= nil then
self:reconcile(virtualInstancesById, id, instance)
return instance
end
-- If the instance's parent already exists, we can stick it there
local parentInstance = self.instanceMap.fromIds[virtualInstance.Parent]
if parentInstance ~= nil then
self:__reify(virtualInstancesById, id, parentInstance)
return
end
-- Otherwise, we can check if this response payload contained the parent and
-- work from there instead.
local parentData = virtualInstancesById[virtualInstance.Parent]
if parentData ~= nil then
if visitedIds[virtualInstance.Parent] then
error("Rojo bug: An instance was present and marked as visited but its instance was missing")
end
self:__applyUpdatePiece(virtualInstance.Parent, visitedIds, virtualInstancesById)
return
end
Logging.trace("Instance ID %s, parent ID %s", tostring(id), tostring(virtualInstance.Parent))
error("Rojo NYI: Instances with parents that weren't mentioned in an update payload")
end
return Reconciler return Reconciler

View File

@@ -0,0 +1,218 @@
local Reconciler = require(script.Parent.Reconciler)
return function()
it("should leave instances alone if there's nothing specified", function()
local instance = Instance.new("Folder")
instance.Name = "TestFolder"
local instanceId = "test-id"
local virtualInstancesById = {
[instanceId] = {
Name = "TestFolder",
ClassName = "Folder",
Children = {},
Properties = {},
},
}
local reconciler = Reconciler.new()
reconciler:reconcile(virtualInstancesById, instanceId, instance)
end)
it("should assign names from virtual instances", function()
local instance = Instance.new("Folder")
instance.Name = "InitialName"
local instanceId = "test-id"
local virtualInstancesById = {
[instanceId] = {
Name = "NewName",
ClassName = "Folder",
Children = {},
Properties = {},
},
}
local reconciler = Reconciler.new()
reconciler:reconcile(virtualInstancesById, instanceId, instance)
expect(instance.Name).to.equal("NewName")
end)
it("should assign properties from virtual instances", function()
local instance = Instance.new("IntValue")
instance.Name = "TestValue"
instance.Value = 5
local instanceId = "test-id"
local virtualInstancesById = {
[instanceId] = {
Name = "TestValue",
ClassName = "IntValue",
Children = {},
Properties = {
Value = {
Type = "Int32",
Value = 9
}
},
},
}
local reconciler = Reconciler.new()
reconciler:reconcile(virtualInstancesById, instanceId, instance)
expect(instance.Value).to.equal(9)
end)
it("should wipe unknown children by default", function()
local parent = Instance.new("Folder")
parent.Name = "Parent"
local child = Instance.new("Folder")
child.Name = "Child"
local parentId = "test-id"
local virtualInstancesById = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {},
Properties = {},
},
}
local reconciler = Reconciler.new()
reconciler:reconcile(virtualInstancesById, parentId, parent)
expect(#parent:GetChildren()).to.equal(0)
end)
it("should preserve unknown children if ignoreUnknownInstances is set", function()
local parent = Instance.new("Folder")
parent.Name = "Parent"
local child = Instance.new("Folder")
child.Parent = parent
child.Name = "Child"
local parentId = "test-id"
local virtualInstancesById = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {},
Properties = {},
Metadata = {
ignoreUnknownInstances = true,
},
},
}
local reconciler = Reconciler.new()
reconciler:reconcile(virtualInstancesById, parentId, parent)
expect(child.Parent).to.equal(parent)
expect(#parent:GetChildren()).to.equal(1)
end)
it("should remove known removed children", function()
local parent = Instance.new("Folder")
parent.Name = "Parent"
local child = Instance.new("Folder")
child.Parent = parent
child.Name = "Child"
local parentId = "parent-id"
local childId = "child-id"
local reconciler = Reconciler.new()
local virtualInstancesById = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {childId},
Properties = {},
},
[childId] = {
Name = "Child",
ClassName = "Folder",
Children = {},
Properties = {},
},
}
reconciler:reconcile(virtualInstancesById, parentId, parent)
expect(child.Parent).to.equal(parent)
expect(#parent:GetChildren()).to.equal(1)
local newVirtualInstances = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {},
Properties = {},
},
[childId] = nil,
}
reconciler:reconcile(newVirtualInstances, parentId, parent)
expect(child.Parent).to.equal(nil)
expect(#parent:GetChildren()).to.equal(0)
end)
it("should remove known removed children if ignoreUnknownInstances is set", function()
local parent = Instance.new("Folder")
parent.Name = "Parent"
local child = Instance.new("Folder")
child.Parent = parent
child.Name = "Child"
local parentId = "parent-id"
local childId = "child-id"
local reconciler = Reconciler.new()
local virtualInstancesById = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {childId},
Properties = {},
Metadata = {
ignoreUnknownInstances = true,
},
},
[childId] = {
Name = "Child",
ClassName = "Folder",
Children = {},
Properties = {},
},
}
reconciler:reconcile(virtualInstancesById, parentId, parent)
expect(child.Parent).to.equal(parent)
expect(#parent:GetChildren()).to.equal(1)
local newVirtualInstances = {
[parentId] = {
Name = "Parent",
ClassName = "Folder",
Children = {},
Properties = {},
Metadata = {
ignoreUnknownInstances = true,
},
},
[childId] = nil,
}
reconciler:reconcile(newVirtualInstances, parentId, parent)
expect(child.Parent).to.equal(nil)
expect(#parent:GetChildren()).to.equal(0)
end)
end

View File

@@ -1,203 +0,0 @@
local Log = require(script.Parent.Parent.Log)
local Fmt = require(script.Parent.Parent.Fmt)
local t = require(script.Parent.Parent.t)
local DevSettings = require(script.Parent.DevSettings)
local InstanceMap = require(script.Parent.InstanceMap)
local Reconciler = require(script.Parent.Reconciler)
local strict = require(script.Parent.strict)
local Status = strict("Session.Status", {
NotStarted = "NotStarted",
Connecting = "Connecting",
Connected = "Connected",
Disconnected = "Disconnected",
})
local function debugPatch(patch)
return Fmt.debugify(patch, function(patch, output)
output:writeLine("Patch {{")
output:indent()
for removed in ipairs(patch.removed) do
output:writeLine("Remove ID {}", removed)
end
for id, added in pairs(patch.added) do
output:writeLine("Add ID {} {:#?}", id, added)
end
for _, updated in ipairs(patch.updated) do
output:writeLine("Update ID {} {:#?}", updated.id, updated)
end
output:unindent()
output:write("}")
end)
end
local ServeSession = {}
ServeSession.__index = ServeSession
ServeSession.Status = Status
local validateServeOptions = t.strictInterface({
apiContext = t.table,
})
function ServeSession.new(options)
assert(validateServeOptions(options))
-- Declare self ahead of time to capture it in a closure
local self
local function onInstanceChanged(instance, propertyName)
self:__onInstanceChanged(instance, propertyName)
end
local instanceMap = InstanceMap.new(onInstanceChanged)
local reconciler = Reconciler.new(instanceMap)
self = {
__status = Status.NotStarted,
__apiContext = options.apiContext,
__reconciler = reconciler,
__instanceMap = instanceMap,
__statusChangedCallback = nil,
}
setmetatable(self, ServeSession)
return self
end
function ServeSession:__fmtDebug(output)
output:writeLine("ServeSession {{")
output:indent()
output:writeLine("API Context: {:#?}", self.__apiContext)
output:writeLine("Instances: {:#?}", self.__instanceMap)
output:unindent()
output:write("}")
end
function ServeSession:onStatusChanged(callback)
self.__statusChangedCallback = callback
end
function ServeSession:start()
self:__setStatus(Status.Connecting)
self.__apiContext:connect()
:andThen(function(serverInfo)
self:__setStatus(Status.Connected)
local rootInstanceId = serverInfo.rootInstanceId
return self:__initialSync(rootInstanceId)
:andThen(function()
return self:__mainSyncLoop()
end)
end)
:catch(function(err)
self:__stopInternal(err)
end)
end
function ServeSession:stop()
self:__stopInternal()
end
function ServeSession:__onInstanceChanged(instance, propertyName)
if not DevSettings:twoWaySyncEnabled() then
return
end
local instanceId = self.__instanceMap.fromInstances[instance]
if instanceId == nil then
Log.warn("Ignoring change for instance {:?} as it is unknown to Rojo", instance)
return
end
local update = {
id = instanceId,
changedProperties = {},
}
if propertyName == "Name" then
update.changedName = instance.Name
else
local success, encoded = self.__reconciler:encodeApiValue(instance[propertyName])
if not success then
Log.warn("Could not sync back property {:?}.{}", instance, propertyName)
return
end
update.changedProperties[propertyName] = encoded
end
local patch = {
removed = {},
added = {},
updated = {update},
}
self.__apiContext:write(patch)
end
function ServeSession:__initialSync(rootInstanceId)
return self.__apiContext:read({ rootInstanceId })
:andThen(function(readResponseBody)
-- Tell the API Context that we're up-to-date with the version of
-- the tree defined in this response.
self.__apiContext:setMessageCursor(readResponseBody.messageCursor)
Log.trace("Computing changes that plugin needs to make to catch up to server...")
-- Calculate the initial patch to apply to the DataModel to catch us
-- up to what Rojo thinks the place should look like.
local hydratePatch = self.__reconciler:hydrate(
readResponseBody.instances,
rootInstanceId,
game
)
Log.trace("Computed hydration patch: {:#?}", debugPatch(hydratePatch))
-- TODO: Prompt user to notify them of this patch, since it's
-- effectively a conflict between the Rojo server and the client.
self.__reconciler:applyPatch(hydratePatch)
end)
end
function ServeSession:__mainSyncLoop()
return self.__apiContext:retrieveMessages()
:andThen(function(messages)
for _, message in ipairs(messages) do
self.__reconciler:applyPatch(message)
end
if self.__status ~= Status.Disconnected then
return self:__mainSyncLoop()
end
end)
end
function ServeSession:__stopInternal(err)
self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect()
self.__instanceMap:stop()
end
function ServeSession:__setStatus(status, detail)
self.__status = status
if self.__statusChangedCallback ~= nil then
self.__statusChangedCallback(status, detail)
end
end
return ServeSession

97
plugin/src/Session.lua Normal file
View File

@@ -0,0 +1,97 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Promise = require(Rojo.Promise)
local ApiContext = require(script.Parent.ApiContext)
local Reconciler = require(script.Parent.Reconciler)
local Session = {}
Session.__index = Session
function Session.new(config)
local remoteUrl = ("http://%s:%s"):format(config.address, config.port)
local api = ApiContext.new(remoteUrl)
local self = {
onError = config.onError,
disconnected = false,
reconciler = Reconciler.new(),
api = api,
}
api:connect()
:andThen(function()
if self.disconnected then
return
end
return api:read({api.rootInstanceId})
end)
:andThen(function(response)
if self.disconnected then
return
end
self.reconciler:reconcile(response.instances, api.rootInstanceId, game)
return self:__processMessages(response.messageCursor)
end)
:catch(function(message)
self.disconnected = true
self.onError(message)
end)
return not self.disconnected, setmetatable(self, Session)
end
function Session:__processMessages(initialCursor)
if self.disconnected then
return Promise.resolve()
end
return self.api:retrieveMessages(initialCursor)
:andThen(function(messages)
local promise = Promise.resolve(nil)
for _, message in ipairs(messages) do
promise = promise:andThen(function()
return self:__onMessage(message)
end)
end
return promise
end)
:andThen(function()
return self:__processMessages()
end)
end
function Session:__onMessage(message)
if self.disconnected then
return Promise.resolve()
end
local requestedIds = {}
for _, id in ipairs(message.added) do
table.insert(requestedIds, id)
end
for _, id in ipairs(message.updated) do
table.insert(requestedIds, id)
end
for _, id in ipairs(message.removed) do
table.insert(requestedIds, id)
end
return self.api:read(requestedIds)
:andThen(function(response)
return self.reconciler:applyUpdate(requestedIds, response.instances)
end)
end
function Session:disconnect()
self.disconnected = true
end
return Session

View File

@@ -1,6 +1,4 @@
local strict = require(script.Parent.strict) local Theme = {
return strict("Theme", {
ButtonFont = Enum.Font.GothamSemibold, ButtonFont = Enum.Font.GothamSemibold,
InputFont = Enum.Font.Code, InputFont = Enum.Font.Code,
TitleFont = Enum.Font.GothamBold, TitleFont = Enum.Font.GothamBold,
@@ -11,4 +9,12 @@ return strict("Theme", {
PrimaryColor = Color3.fromRGB(64, 64, 64), PrimaryColor = Color3.fromRGB(64, 64, 64),
SecondaryColor = Color3.fromRGB(235, 235, 235), SecondaryColor = Color3.fromRGB(235, 235, 235),
LightTextColor = Color3.fromRGB(160, 160, 160), LightTextColor = Color3.fromRGB(160, 160, 160),
}) }
setmetatable(Theme, {
__index = function(_, key)
error(("%s is not a valid member of Theme"):format(key), 2)
end
})
return Theme

View File

@@ -1,70 +1,21 @@
local t = require(script.Parent.Parent.t) local t = require(script.Parent.Parent.t)
local DevSettings = require(script.Parent.DevSettings) local DevSettings = require(script.Parent.DevSettings)
local strict = require(script.Parent.strict)
local RbxId = t.string local VirtualValue = t.interface({
local ApiValue = t.interface({
Type = t.string, Type = t.string,
Value = t.optional(t.any), Value = t.optional(t.any),
}) })
local ApiInstanceMetadata = t.interface({ local VirtualMetadata = t.interface({
ignoreUnknownInstances = t.optional(t.boolean), ignoreUnknownInstances = t.optional(t.boolean),
}) })
local ApiInstance = t.interface({ local VirtualInstance = t.interface({
Id = RbxId,
Parent = t.optional(RbxId),
Name = t.string, Name = t.string,
ClassName = t.string, ClassName = t.string,
Properties = t.map(t.string, ApiValue), Properties = t.map(t.string, VirtualValue),
Metadata = t.optional(ApiInstanceMetadata), Metadata = t.optional(VirtualMetadata)
Children = t.array(RbxId),
})
local ApiInstanceUpdate = t.interface({
id = RbxId,
changedName = t.optional(t.string),
changedClassName = t.optional(t.string),
changedProperties = t.map(t.string, ApiValue),
changedMetadata = t.optional(ApiInstanceMetadata),
})
local ApiSubscribeMessage = t.interface({
removed = t.array(RbxId),
added = t.map(RbxId, ApiInstance),
updated = t.array(ApiInstanceUpdate),
})
local ApiInfoResponse = t.interface({
sessionId = t.string,
serverVersion = t.string,
protocolVersion = t.number,
expectedPlaceIds = t.optional(t.array(t.number)),
rootInstanceId = RbxId,
})
local ApiReadResponse = t.interface({
sessionId = t.string,
messageCursor = t.number,
instances = t.map(RbxId, ApiInstance),
})
local ApiSubscribeResponse = t.interface({
sessionId = t.string,
messageCursor = t.number,
messages = t.array(ApiSubscribeMessage),
})
local ApiError = t.interface({
kind = t.union(
t.literal("NotFound"),
t.literal("BadRequest"),
t.literal("InternalError")
),
details = t.string,
}) })
local function ifEnabled(innerCheck) local function ifEnabled(innerCheck)
@@ -77,23 +28,9 @@ local function ifEnabled(innerCheck)
end end
end end
return strict("Types", { return {
ifEnabled = ifEnabled, ifEnabled = ifEnabled,
VirtualInstance = VirtualInstance,
ApiInfoResponse = ApiInfoResponse, VirtualMetadata = VirtualMetadata,
ApiReadResponse = ApiReadResponse, VirtualValue = VirtualValue,
ApiSubscribeResponse = ApiSubscribeResponse, }
ApiError = ApiError,
ApiInstance = ApiInstance,
ApiInstanceUpdate = ApiInstanceUpdate,
ApiInstanceMetadata = ApiInstanceMetadata,
ApiSubscribeMessage = ApiSubscribeMessage,
ApiValue = ApiValue,
RbxId = RbxId,
-- Deprecated aliases during transition
VirtualInstance = ApiInstance,
VirtualMetadata = ApiInstanceMetadata,
VirtualValue = ApiValue,
})

View File

@@ -1,39 +0,0 @@
local RbxDom = require(script.Parent.Parent.RbxDom)
--[[
Attempts to set a property on the given instance.
]]
local function getCanonincalProperty(instance, propertyName)
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
-- We can skip unknown properties; they're not likely reflected to Lua.
--
-- A good example of a property like this is `Model.ModelInPrimary`, which
-- is serialized but not reflected to Lua.
if descriptor == nil then
return false, "unknown property"
end
if descriptor.scriptability == "None" or descriptor.scriptability == "Write" then
return false, "unreadable property"
end
local success, valueOrErr = descriptor:read(instance)
if not success then
local err = valueOrErr
-- If we don't have permission to read a property, we can chalk that up
-- to our database being out of date and the engine being right.
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
return false, "permission error"
end
local message = ("Invalid property %s.%s: %s"):format(descriptor.className, descriptor.name, tostring(err))
error(message, 2)
end
return true, valueOrErr
end
return getCanonincalProperty

View File

@@ -2,17 +2,8 @@ if not plugin then
return return
end end
local Log = require(script.Parent.Log)
local DevSettings = require(script.DevSettings)
Log.setLogLevelThunk(function()
return DevSettings:getLogLevel()
end)
local Roact = require(script.Parent.Roact) local Roact = require(script.Parent.Roact)
local Config = require(script.Config)
local App = require(script.Components.App) local App = require(script.Components.App)
local app = Roact.createElement(App, { local app = Roact.createElement(App, {
@@ -23,10 +14,4 @@ local tree = Roact.mount(app, game:GetService("CoreGui"), "Rojo UI")
plugin.Unloading:Connect(function() plugin.Unloading:Connect(function()
Roact.unmount(tree) Roact.unmount(tree)
end) end)
if Config.isDevBuild then
local TestEZ = require(script.Parent.TestEZ)
require(script.runTests)(TestEZ)
end

View File

@@ -1,15 +0,0 @@
return function()
it("should load all submodules", function()
local function loadRecursive(container)
if container:IsA("ModuleScript") and not container.Name:find("%.spec$") then
require(container)
end
for _, child in ipairs(container:GetChildren()) do
loadRecursive(child)
end
end
loadRecursive(script.Parent)
end)
end

View File

@@ -1,29 +0,0 @@
local Fmt = require(script.Parent.Parent.Fmt)
local Config = require(script.Parent.Config)
local invariant
if Config.isDevBuild then
function invariant(message, ...)
message = Fmt.fmt(message, ...)
error("Invariant violation: " .. message, 2)
end
else
function invariant(message, ...)
message = Fmt.fmt(message, ...)
local fullMessage = string.format(
"Rojo detected an invariant violation within itself:\n" ..
"%s\n\n" ..
"This is a bug in Rojo. Please file an issue:\n" ..
"https://github.com/rojo-rbx/rojo/issues",
message
)
error(fullMessage, 2)
end
end
return invariant

View File

@@ -1,7 +1,6 @@
local ContentProvider = game:GetService("ContentProvider") local ContentProvider = game:GetService("ContentProvider")
local Log = require(script.Parent.Parent.Log) local Logging = require(script.Parent.Logging)
local Assets = require(script.Parent.Assets) local Assets = require(script.Parent.Assets)
local function preloadAssets() local function preloadAssets()
@@ -19,7 +18,7 @@ local function preloadAssets()
table.insert(contentUrls, url) table.insert(contentUrls, url)
end end
Log.trace("Preloading assets: {:?}", contentUrls) Logging.trace("Preloading assets: %s", table.concat(contentUrls, ", "))
coroutine.wrap(function() coroutine.wrap(function()
ContentProvider:PreloadAsync(contentUrls) ContentProvider:PreloadAsync(contentUrls)

View File

@@ -0,0 +1,19 @@
local RbxDom = require(script:FindFirstAncestor("Rojo").RbxDom)
local function rojoValueToRobloxValue(value)
-- TODO: Manually decode this value by looking up its GUID The Rojo server
-- doesn't give us valid ref values yet, so this isn't important yet.
if value.Type == "Ref" then
return nil
end
local success, decodedValue = RbxDom.EncodedValue.decode(value)
if not success then
error(decodedValue, 2)
end
return decodedValue
end
return rojoValueToRobloxValue

View File

@@ -0,0 +1,40 @@
local rojoValueToRobloxValue = require(script.Parent.rojoValueToRobloxValue)
return function()
it("should convert primitives", function()
local inputString = {
Type = "String",
Value = "Hello, world!",
}
local inputFloat32 = {
Type = "Float32",
Value = 12341.512,
}
expect(rojoValueToRobloxValue(inputString)).to.equal(inputString.Value)
expect(rojoValueToRobloxValue(inputFloat32)).to.equal(inputFloat32.Value)
end)
it("should convert properties with direct constructors", function()
local inputColor3 = {
Type = "Color3",
Value = {0, 1, 0.5},
}
local outputColor3 = Color3.new(0, 1, 0.5)
local inputCFrame = {
Type = "CFrame",
Value = {
1, 2, 3,
4, 5, 6,
7, 8, 9,
10, 11, 12,
},
}
local outputCFrame = CFrame.new(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
expect(rojoValueToRobloxValue(inputColor3)).to.equal(outputColor3)
expect(rojoValueToRobloxValue(inputCFrame)).to.equal(outputCFrame)
end)
end

View File

@@ -1,5 +1,4 @@
return function(TestEZ) return function()
local Rojo = script.Parent.Parent local TestEZ = require(script.Parent.Parent.TestEZ)
TestEZ.TestBootstrap:run(script.Parent)
TestEZ.TestBootstrap:run({ Rojo.Plugin, Rojo.Http, Rojo.Log }) end
end

View File

@@ -1,4 +1,4 @@
local RbxDom = require(script.Parent.Parent.RbxDom) local RbxDom = require(script:FindFirstAncestor("Rojo").RbxDom)
--[[ --[[
Attempts to set a property on the given instance. Attempts to set a property on the given instance.

View File

@@ -1,24 +0,0 @@
local function strictInner(name, target)
assert(type(name) == "string", "Argument #1 to `strict` must be a string or the table to modify")
assert(type(target) == "table", "Argument #2 to `strict` must be nil or the table to modify")
setmetatable(target, {
__index = function(_, key)
error(("%q is not a valid member of strict table %q"):format(tostring(key), name), 2)
end,
__newindex = function()
error(("Strict table %q is read-only"):format(name), 2)
end,
})
return target
end
return function(nameOrTarget, target)
if type(nameOrTarget) == "string" then
return strictInner(nameOrTarget, target)
else
return strictInner("<unnamed table>", target)
end
end

View File

@@ -12,7 +12,7 @@ if setDevSettings then
DevSettings:createTestSettings() DevSettings:createTestSettings()
end end
require(Rojo.Plugin.runTests)(TestEZ) TestEZ.TestBootstrap:run({Rojo.Plugin})
if setDevSettings then if setDevSettings then
DevSettings:resetValues() DevSettings:resetValues()

View File

@@ -1,6 +0,0 @@
{
"name": "TestEZ",
"tree": {
"$path": "modules/testez/lib"
}
}

5
plugin/tests/empty.lua Normal file
View File

@@ -0,0 +1,5 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Session = require(ReplicatedStorage.Modules.Rojo.Session)
Session.new()

View File

@@ -1,10 +0,0 @@
[package]
name = "rojo-insta-ext"
version = "0.1.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018"
publish = false
[dependencies]
serde = "1.0.99"
serde_yaml = "0.8.9"

View File

@@ -1,2 +0,0 @@
# Rojo Insta Extensions
This crate has add-ons intended for use with Insta that are useful for snapshot tests.

View File

@@ -1,3 +0,0 @@
mod redaction_map;
pub use redaction_map::*;

View File

@@ -1,85 +0,0 @@
use std::collections::HashMap;
use serde::Serialize;
/// Enables redacting any value that serializes as a string.
///
/// Used for transforming Rojo instance IDs into something deterministic.
pub struct RedactionMap {
ids: HashMap<String, usize>,
last_id: usize,
}
impl RedactionMap {
pub fn new() -> Self {
Self {
ids: HashMap::new(),
last_id: 0,
}
}
pub fn get_redacted_value(&self, id: impl ToString) -> Option<String> {
let id = id.to_string();
if self.ids.contains_key(&id) {
Some(id)
} else {
None
}
}
pub fn intern(&mut self, id: impl ToString) {
let last_id = &mut self.last_id;
self.ids.entry(id.to_string()).or_insert_with(|| {
*last_id += 1;
*last_id
});
}
pub fn intern_iter<S: ToString>(&mut self, ids: impl Iterator<Item = S>) {
for id in ids {
self.intern(id.to_string());
}
}
pub fn redacted_yaml(&self, value: impl Serialize) -> serde_yaml::Value {
let mut encoded = serde_yaml::to_value(value).expect("Couldn't encode value as YAML");
self.redact(&mut encoded);
encoded
}
pub fn redact(&self, yaml_value: &mut serde_yaml::Value) {
use serde_yaml::{Mapping, Value};
match yaml_value {
Value::String(value) => {
if let Some(redacted) = self.ids.get(value) {
*yaml_value = Value::String(format!("id-{}", *redacted));
}
}
Value::Sequence(sequence) => {
for value in sequence {
self.redact(value);
}
}
Value::Mapping(mapping) => {
// We can't mutate the keys of a map in-place, so we take
// ownership of the map and rebuild it.
let owned_map = std::mem::replace(mapping, Mapping::new());
let mut new_map = Mapping::with_capacity(owned_map.len());
for (mut key, mut value) in owned_map {
self.redact(&mut key);
self.redact(&mut value);
new_map.insert(key, value);
}
*mapping = new_map;
}
_ => {}
}
}
}

View File

@@ -5,28 +5,10 @@ authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018" edition = "2018"
publish = false publish = false
[features]
default = []
unstable_glob_ignore_paths = []
[dependencies] [dependencies]
env_logger = "0.6.2" insta = "0.10.0"
log = "0.4.8"
paste = "0.1.5" paste = "0.1.5"
rbx_dom_weak = "1.9.0"
reqwest = "0.9.20"
serde = "1.0.99"
serde_json = "1.0.40"
serde_yaml = "0.8.9"
tempfile = "3.1.0" tempfile = "3.1.0"
walkdir = "2.2.9"
rojo-insta-ext = { path = "../rojo-insta-ext" }
# We execute Rojo via std::process::Command, so depend on it so it's built! # We execute Rojo via std::process::Command, so depend on it so it's built!
rojo = { path = ".." } rojo = { path = "../server" }
[dependencies.insta]
git = "https://github.com/mitsuhiko/insta"
features = ["redactions"]

View File

@@ -1,8 +0,0 @@
# rojo-test
This project does end-to-end testing of Rojo by executing it and checking what side-effects it has.
rojo-test is meant to be run as a test with:
```bash
cargo test
```

View File

@@ -1,26 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">deep_nesting</string>
</Properties>
<Item class="Folder" referent="1">
<Properties>
<string name="Name">level-1</string>
</Properties>
<Item class="Folder" referent="2">
<Properties>
<string name="Name">level-2</string>
</Properties>
<Item class="Folder" referent="3">
<Properties>
<string name="Name">level-3</string>
</Properties>
</Item>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -1,33 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">ignore_glob_inner</string>
</Properties>
<Item class="Folder" referent="1">
<Properties>
<string name="Name">src</string>
</Properties>
<Item class="ModuleScript" referent="2">
<Properties>
<string name="Name">outer.spec</string>
<string name="Source">-- This file should be included.</string>
</Properties>
</Item>
</Item>
<Item class="Folder" referent="3">
<Properties>
<string name="Name">subproject</string>
</Properties>
<Item class="ModuleScript" referent="4">
<Properties>
<string name="Name">inner</string>
<string name="Source">-- This file should be included.</string>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -1,17 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">ignore_glob_nested</string>
</Properties>
<Item class="ModuleScript" referent="1">
<Properties>
<string name="Name">include</string>
<string name="Source">-- This file must be present.</string>
</Properties>
</Item>
</Item>
</roblox>

View File

@@ -1,17 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">ignore_glob_spec</string>
</Properties>
<Item class="ModuleScript" referent="1">
<Properties>
<string name="Name">shouldBeIncluded</string>
<string name="Source">-- this file should be present</string>
</Properties>
</Item>
</Item>
</roblox>

View File

@@ -1,18 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="ModuleScript" referent="0">
<Properties>
<string name="Name">init_with_children</string>
<string name="Source">-- init.lua</string>
</Properties>
<Item class="ModuleScript" referent="1">
<Properties>
<string name="Name">other</string>
<string name="Source">-- other.lua</string>
</Properties>
</Item>
</Item>
</roblox>

View File

@@ -1,12 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="StringValue" referent="0">
<Properties>
<string name="Name">plain</string>
<string name="Value">This is a bare text file with no project.</string>
</Properties>
</Item>
</roblox>

View File

@@ -1,11 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">plain_gitkeep</string>
</Properties>
</Item>
</roblox>

View File

@@ -1,29 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">rbxmx_ref</string>
<BinaryString name="Tags">
</BinaryString>
</Properties>
<Item class="StringValue" referent="1">
<Properties>
<string name="Name">Target</string>
<BinaryString name="Tags">
</BinaryString>
<string name="Value">Pointed to by ObjectValue</string>
</Properties>
</Item>
<Item class="ObjectValue" referent="2">
<Properties>
<string name="Name">Pointer</string>
<BinaryString name="Tags">
</BinaryString>
<Ref name="Value">1</Ref>
</Properties>
</Item>
</Item>
</roblox>

View File

@@ -1,12 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="StringValue" referent="0">
<Properties>
<string name="Name">txt</string>
<string name="Value">This is a txt file in a project.</string>
</Properties>
</Item>
</roblox>

View File

@@ -1,2 +0,0 @@
# ignore_glob_inner
Tests that glob ignores defined *inside* nested projects apply to those projects, but not anywhere else.

View File

@@ -1,14 +0,0 @@
{
"name": "ignore_glob_inner",
"tree": {
"$className": "Folder",
"src": {
"$path": "src"
},
"subproject": {
"$path": "subproject"
}
}
}

View File

@@ -1 +0,0 @@
-- This file should be included.

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