Compare commits

..

20 Commits

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

* Update project code and add support for StarterPlayer

* Store parent_class in InstigatingSource

* Update snapshots

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

* cargo fmt

* filter spec files

* Update src/cli/plugin.rs

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

* Update src/cli/plugin.rs

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

* fix comments

* encode plugin with rbx_binary

* update build script

* refactor pathbuf error into io error

* fix rojo typo

* remove snafu

* Update `snapshot_from_fs_path`

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

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

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

* Simplify error handling code in runtime

* Checkout with submodules

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

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

* Blacklist .meta.json in JSON handler

* Write to correct property (Source) instead of Value

* Update changelog
2020-03-28 00:36:01 -07:00
Lucien Greathouse
62e51b7535 Upgrade to latest rbx-dom 2020-03-27 23:58:31 -07:00
Lucien Greathouse
729a7f0053 Turn panics into errors in ServeSession 2020-03-26 12:16:55 -07:00
Lucien Greathouse
03c297190d Make ServeSession::new fallible 2020-03-26 12:07:44 -07:00
Lucien Greathouse
9c790eddd7 Tidy up ServeSession now that trait bounds are gone 2020-03-26 12:06:16 -07:00
Lucien Greathouse
8ebe7e332b Update Changelog 2020-03-25 17:02:32 -07:00
Lucien Greathouse
f43777e37e Require a Rojo project again (#307) 2020-03-25 17:01:28 -07:00
Lucien Greathouse
691a8fcdeb Upgrade lockfile using latest rustc 2020-03-25 16:15:21 -07:00
Lucien Greathouse
69c0e8d70e Fix warnings 2020-03-21 17:49:56 -07:00
Lucien Greathouse
330c92c9a8 Refactor ChangeProcessor loop to get rid of panics 2020-03-21 17:47:25 -07:00
Lucien Greathouse
cf0ff60d31 plugin: Add simple signal implementation for future work 2020-03-18 23:31:22 -07:00
Lucien Greathouse
9e9cf5dd1f plugin: Add support for pausing updates tracked by InstanceMap 2020-03-18 23:27:30 -07:00
Lucien Greathouse
5768d8e4a4 plugin: Miscellaneous cleanup 2020-03-18 23:15:03 -07:00
100 changed files with 21915 additions and 1793 deletions

View File

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

5
.gitmodules vendored
View File

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

View File

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

2227
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "rojo"
version = "0.6.0-alpha.3"
version = "6.0.0-rc.1"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0"
@@ -11,7 +11,6 @@ readme = "README.md"
edition = "2018"
exclude = [
"/plugin/**",
"/test-projects/**",
]
@@ -58,10 +57,11 @@ name = "build"
harness = false
[dependencies]
memofs = { version = "0.1.1", path = "memofs" }
memofs = { version = "0.1.2", path = "memofs" }
anyhow = "1.0.27"
backtrace = "0.3"
bincode = "1.2.1"
crossbeam-channel = "0.4.0"
csv = "1.1.1"
env_logger = "0.7.1"
@@ -84,6 +84,7 @@ regex = "1.3.1"
reqwest = "0.9.20"
ritz = "0.1.0"
rlua = "0.17.0"
roblox_install = "0.2.2"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
structopt = "0.3.5"
@@ -95,6 +96,14 @@ uuid = { version = "0.8.1", features = ["v4", "serde"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.6.2"
[build-dependencies]
memofs = { version = "0.1.0", path = "memofs" }
anyhow = "1.0.27"
bincode = "1.2.1"
fs-err = "2.3.0"
maplit = "1.0.1"
[dev-dependencies]
rojo-insta-ext = { path = "rojo-insta-ext" }

View File

@@ -14,7 +14,7 @@
<img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" />
</a>
<a href="https://rojo.space/docs">
<img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo 0.5.x Documentation" />
<img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo Documentation" />
</a>
</div>

View File

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

74
build.rs Normal file
View File

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

View File

@@ -1,7 +1,7 @@
[package]
name = "memofs"
description = "Virtual filesystem with configurable backends."
version = "0.1.1"
version = "0.1.2"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018"
readme = "README.md"
@@ -14,3 +14,4 @@ homepage = "https://github.com/rojo-rbx/rojo/tree/master/memofs"
crossbeam-channel = "0.4.0"
fs-err = "2.3.0"
notify = "4.0.15"
serde = { version = "1.0", features = ["derive"] }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -5,24 +5,12 @@
local RbxDom = require(script.Parent.Parent.RbxDom)
local t = require(script.Parent.Parent.t)
local Log = require(script.Parent.Parent.Log)
local Types = require(script.Parent.Types)
local invariant = require(script.Parent.invariant)
local getCanonicalProperty = require(script.Parent.getCanonicalProperty)
local setCanonicalProperty = require(script.Parent.setCanonicalProperty)
--[[
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),
})
local PatchSet = require(script.Parent.PatchSet)
--[[
Attempt to safely set the parent of an instance.
@@ -86,7 +74,7 @@ end
editable by scripts.
]]
local applyPatchSchema = Types.ifEnabled(t.tuple(
IPatch
PatchSet.validate
))
function Reconciler:applyPatch(patch)
assert(applyPatchSchema(patch))
@@ -287,7 +275,7 @@ local hydrateSchema = Types.ifEnabled(t.tuple(
t.map(Types.RbxId, Types.VirtualInstance),
Types.RbxId,
t.Instance,
IPatch
PatchSet.validate
))
function Reconciler:__hydrateInternal(apiInstances, id, instance, hydratePatch)
assert(hydrateSchema(apiInstances, id, instance, hydratePatch))

View File

@@ -4,7 +4,6 @@ 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ fn run(global: GlobalOptions, subcommand: Subcommand) -> Result<(), Box<dyn Erro
Subcommand::Build(build_options) => cli::build(build_options)?,
Subcommand::Upload(upload_options) => cli::upload(upload_options)?,
Subcommand::Doc => cli::doc()?,
Subcommand::Plugin(plugin_options) => cli::plugin(plugin_options)?,
}
Ok(())
@@ -22,7 +23,7 @@ fn main() {
// PanicInfo's payload is usually a &'static str or String.
// See: https://doc.rust-lang.org/beta/std/panic/struct.PanicInfo.html#method.payload
let message = match panic_info.payload().downcast_ref::<&str>() {
Some(message) => message.to_string(),
Some(&message) => message.to_string(),
None => match panic_info.payload().downcast_ref::<String>() {
Some(message) => message.clone(),
None => "<no message>".to_string(),

View File

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

View File

@@ -44,7 +44,7 @@ pub fn build(options: BuildCommand) -> Result<(), anyhow::Error> {
let vfs = Vfs::new_default();
let session = ServeSession::new(vfs, &options.absolute_project());
let session = ServeSession::new(vfs, &options.absolute_project())?;
let mut cursor = session.message_queue().cursor();
{

View File

@@ -3,6 +3,7 @@
mod build;
mod doc;
mod init;
mod plugin;
mod serve;
mod upload;
@@ -21,6 +22,7 @@ use thiserror::Error;
pub use self::build::*;
pub use self::doc::*;
pub use self::init::*;
pub use self::plugin::*;
pub use self::serve::*;
pub use self::upload::*;
@@ -111,6 +113,9 @@ pub enum Subcommand {
/// Open Rojo's documentation in your browser.
Doc,
/// Manages Rojo's Roblox Studio plugin.
Plugin(PluginCommand),
}
/// Initializes a new Rojo project.
@@ -287,3 +292,21 @@ fn resolve_path(path: &Path) -> Cow<'_, Path> {
Cow::Owned(env::current_dir().unwrap().join(path))
}
}
#[derive(Debug, StructOpt)]
pub enum PluginSubcommand {
/// Install the plugin in Roblox Studio's plugins folder. If the plugin is
/// already installed, installing it again will overwrite the current plugin
/// file.
Install,
/// Removes the plugin if it is installed.
Uninstall,
}
/// Install Rojo's plugin.
#[derive(Debug, StructOpt)]
pub struct PluginCommand {
#[structopt(subcommand)]
subcommand: PluginSubcommand,
}

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

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

View File

@@ -18,7 +18,7 @@ const DEFAULT_PORT: u16 = 34872;
pub fn serve(global: GlobalOptions, options: ServeCommand) -> Result<()> {
let vfs = Vfs::new_default();
let session = Arc::new(ServeSession::new(vfs, &options.absolute_project()));
let session = Arc::new(ServeSession::new(vfs, &options.absolute_project())?);
let port = options
.port

View File

@@ -22,7 +22,7 @@ pub fn upload(options: UploadCommand) -> Result<(), anyhow::Error> {
let vfs = Vfs::new_default();
let session = ServeSession::new(vfs, &options.absolute_project());
let session = ServeSession::new(vfs, &options.absolute_project())?;
let tree = session.tree();
let inner_tree = tree.inner();

View File

@@ -11,6 +11,7 @@ mod auth_cookie;
mod change_processor;
mod error;
mod glob;
mod lua_ast;
mod message_queue;
mod multimap;
mod path_serializer;

279
src/lua_ast.rs Normal file
View File

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

View File

@@ -19,13 +19,13 @@ pub struct ProjectError(#[from] Error);
#[derive(Debug, Error)]
enum Error {
#[error("Rojo project I/O error")]
#[error(transparent)]
Io {
#[from]
source: io::Error,
},
#[error("Error parsing Rojo project")]
#[error("Error parsing Rojo project in path {}", .path.display())]
Json {
source: serde_json::Error,
path: PathBuf,

View File

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

View File

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

View File

@@ -7,30 +7,36 @@ pub enum SnapshotError {
#[error("file name had malformed Unicode")]
FileNameBadUnicode { path: PathBuf },
#[error("file had malformed Unicode contents")]
#[error("file had malformed Unicode contents at path {}", .path.display())]
FileContentsBadUnicode {
source: std::str::Utf8Error,
path: PathBuf,
},
#[error("malformed project file")]
#[error("malformed project file at path {}", .path.display())]
MalformedProject {
source: serde_json::Error,
path: PathBuf,
},
#[error("malformed .model.json file")]
#[error("malformed .model.json file at path {}", .path.display())]
MalformedModelJson {
source: serde_json::Error,
path: PathBuf,
},
#[error("malformed .meta.json file")]
#[error("malformed .meta.json file at path {}", .path.display())]
MalformedMetaJson {
source: serde_json::Error,
path: PathBuf,
},
#[error("malformed JSON at path {}", .path.display())]
MalformedJson {
source: serde_json::Error,
path: PathBuf,
},
#[error(transparent)]
Io {
#[from]
@@ -76,4 +82,11 @@ impl SnapshotError {
path: path.into(),
}
}
pub(crate) fn malformed_json(source: serde_json::Error, path: impl Into<PathBuf>) -> Self {
Self::MalformedJson {
source,
path: path.into(),
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
---
source: src/snapshot_middleware/json.rs
expression: instance_snapshot
---
snapshot_id: ~
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /foo.json
relevant_paths:
- /foo.json
- /foo.meta.json
context: {}
name: foo
class_name: ModuleScript
properties:
Source:
Type: String
Value: "return {\n\t[\"1invalidident\"] = \"nice\",\n\tarray = {1, 2, 3},\n\t[\"false\"] = false,\n\tfloat = 1234.5452,\n\tint = 1234,\n\tnull = nil,\n\tobject = {\n\t\thello = \"world\",\n\t},\n\t[\"true\"] = true,\n}"
children: []

View File

@@ -22,6 +22,7 @@ children:
- /foo
- Child
- $className: Model
- Folder
relevant_paths: []
context: {}
name: Child

View File

@@ -23,6 +23,7 @@ children:
- /foo
- SomeChild
- $className: Model
- Folder
relevant_paths: []
context: {}
name: SomeChild

View File

@@ -1,35 +0,0 @@
---
source: src/serve_session.rs
expression: "view_tree(&session.tree(), &mut redactions)"
---
id: id-1
name: change_file_in_project
class_name: Folder
properties: {}
metadata:
ignore_unknown_instances: true
instigating_source:
Path: /foo/default.project.json
relevant_paths:
- /foo/default.project.json
context: {}
children:
- id: id-2
name: Child
class_name: StringValue
properties:
Value:
Type: String
Value: Changed!
metadata:
ignore_unknown_instances: false
instigating_source:
ProjectNode:
- /foo
- Child
- $path: file.txt
relevant_paths:
- /foo/file.txt
- /foo/file.meta.json
context: {}
children: []

View File

@@ -1,35 +0,0 @@
---
source: src/serve_session.rs
expression: "view_tree(&session.tree(), &mut redactions)"
---
id: id-1
name: change_file_in_project
class_name: Folder
properties: {}
metadata:
ignore_unknown_instances: true
instigating_source:
Path: /foo/default.project.json
relevant_paths:
- /foo/default.project.json
context: {}
children:
- id: id-2
name: Child
class_name: StringValue
properties:
Value:
Type: String
Value: initial content
metadata:
ignore_unknown_instances: false
instigating_source:
ProjectNode:
- /foo
- Child
- $path: file.txt
relevant_paths:
- /foo/file.txt
- /foo/file.meta.json
context: {}
children: []

View File

@@ -1,16 +0,0 @@
---
source: src/serve_session.rs
expression: redactions.redacted_yaml(result)
---
- 1
- - removed: []
added: []
updated:
- id: id-2
changed_name: ~
changed_class_name: ~
changed_properties:
Value:
Type: String
Value: Changed!
changed_metadata: ~

View File

@@ -1,36 +0,0 @@
---
source: src/serve_session.rs
expression: "view_tree(&session.tree(), &mut redactions)"
---
id: id-1
name: root
class_name: Folder
properties: {}
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /root
relevant_paths:
- /root
- /root/init.meta.json
- /root/init.lua
- /root/init.server.lua
- /root/init.client.lua
context: {}
children:
- id: id-2
name: test
class_name: ModuleScript
properties:
Source:
Type: String
Value: This is a test.
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /root/test.lua
relevant_paths:
- /root/test.lua
- /root/test.meta.json
context: {}
children: []

View File

@@ -1,36 +0,0 @@
---
source: src/serve_session.rs
expression: "view_tree(&session.tree(), &mut redactions)"
---
id: id-1
name: root
class_name: Folder
properties: {}
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /root
relevant_paths:
- /root
- /root/init.meta.json
- /root/init.lua
- /root/init.server.lua
- /root/init.client.lua
context: {}
children:
- id: id-2
name: test
class_name: ModuleScript
properties:
Source:
Type: String
Value: This is a test.
metadata:
ignore_unknown_instances: true
instigating_source:
Path: /root/test.lua
relevant_paths:
- /root/test.lua
- /root/test.meta.json
context: {}
children: []

View File

@@ -1,20 +0,0 @@
---
source: src/serve_session.rs
expression: redactions.redacted_yaml(changes)
---
- 1
- - removed: []
added: []
updated:
- id: id-2
changed_name: ~
changed_class_name: ~
changed_properties: {}
changed_metadata:
ignore_unknown_instances: false
instigating_source:
Path: /root/test.lua
relevant_paths:
- /root/test.lua
- /root/test.meta.json
context: {}

View File

@@ -1,20 +0,0 @@
---
source: src/serve_session.rs
expression: "view_tree(&session.tree(), &mut redactions)"
---
id: id-1
name: foo
class_name: StringValue
properties:
Value:
Type: String
Value: World!
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /foo.txt
relevant_paths:
- /foo.txt
- /foo.meta.json
context: {}
children: []

View File

@@ -1,20 +0,0 @@
---
source: src/serve_session.rs
expression: "view_tree(&session.tree(), &mut redactions)"
---
id: id-1
name: foo
class_name: StringValue
properties:
Value:
Type: String
Value: Hello!
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /foo.txt
relevant_paths:
- /foo.txt
- /foo.meta.json
context: {}
children: []

View File

@@ -1,16 +0,0 @@
---
source: src/serve_session.rs
expression: redactions.redacted_yaml(result)
---
- 1
- - removed: []
added: []
updated:
- id: id-1
changed_name: ~
changed_class_name: ~
changed_properties:
Value:
Type: String
Value: World!
changed_metadata: ~

View File

@@ -1,20 +0,0 @@
---
source: src/serve_session.rs
expression: "view_tree(&session.tree(), &mut rm)"
---
id: id-1
name: foo
class_name: Folder
properties: {}
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /foo
relevant_paths:
- /foo
- /foo/init.meta.json
- /foo/init.lua
- /foo/init.server.lua
- /foo/init.client.lua
context: {}
children: []

View File

@@ -1,37 +0,0 @@
---
source: src/serve_session.rs
expression: "view_tree(&session.tree(), &mut rm)"
---
id: id-1
name: HelloWorld
class_name: Folder
properties: {}
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /foo/default.project.json
relevant_paths:
- /foo/src
- /foo/src/init.meta.json
- /foo/src/init.lua
- /foo/src/init.server.lua
- /foo/src/init.client.lua
- /foo/default.project.json
context: {}
children:
- id: id-2
name: hello
class_name: StringValue
properties:
Value:
Type: String
Value: "Hello, world!"
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /foo/src/hello.txt
relevant_paths:
- /foo/src/hello.txt
- /foo/src/hello.meta.json
context: {}
children: []

View File

@@ -1,36 +0,0 @@
---
source: src/serve_session.rs
expression: "view_tree(&session.tree(), &mut rm)"
---
id: id-1
name: root
class_name: Folder
properties: {}
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /root
relevant_paths:
- /root
- /root/init.meta.json
- /root/init.lua
- /root/init.server.lua
- /root/init.client.lua
context: {}
children:
- id: id-2
name: test
class_name: ModuleScript
properties:
Source:
Type: String
Value: This is a test.
metadata:
ignore_unknown_instances: true
instigating_source:
Path: /root/test.lua
relevant_paths:
- /root/test.lua
- /root/test.meta.json
context: {}
children: []

View File

@@ -233,7 +233,7 @@ impl UiService {
}
fn normal_page<'a>(&'a self, body: HtmlContent<'a>) -> HtmlContent<'a> {
let project_name = self.serve_session.project_name().unwrap_or("<unnamed>");
let project_name = self.serve_session.project_name();
let uptime = {
let elapsed = self.serve_session.start_time().elapsed();