From 6fa925a402d7bebf74aaa0dca63394264b0d62db Mon Sep 17 00:00:00 2001 From: Lucien Greathouse Date: Sun, 1 Apr 2018 23:22:04 -0700 Subject: [PATCH] Merge plugin back into main repository (#49) --- .editorconfig | 6 - .gitmodules | 15 + .travis.yml | 42 ++- CHANGES.md | 3 +- README.md | 57 ++-- plugin/.editorconfig | 4 + plugin/.gitignore | 1 + plugin/.luacheckrc | 56 ++++ plugin/.luacov | 8 + plugin/modules/lemur | 1 + plugin/modules/roact | 1 + plugin/modules/roact-rodux | 1 + plugin/modules/rodux | 1 + plugin/modules/testez | 1 + plugin/rojo.json | 30 ++ plugin/spec.lua | 69 ++++ plugin/src/Config.lua | 7 + plugin/src/Config.spec.lua | 7 + plugin/src/Http.lua | 67 ++++ plugin/src/HttpError.lua | 57 ++++ plugin/src/HttpResponse.lua | 20 ++ plugin/src/Main.server.lua | 79 +++++ plugin/src/Plugin.lua | 198 +++++++++++ plugin/src/Promise.lua | 311 ++++++++++++++++++ plugin/src/Promise.spec.lua | 262 +++++++++++++++ plugin/src/Reconciler.lua | 203 ++++++++++++ plugin/src/RouteMap.lua | 103 ++++++ plugin/src/Server.lua | 89 +++++ plugin/src/Version.lua | 40 +++ plugin/src/Version.spec.lua | 28 ++ plugin/src/runTests.lua | 4 + plugin/tests/runTests.server.lua | 2 + server/.editorconfig | 5 + .gitignore => server/.gitignore | 0 Cargo.lock => server/Cargo.lock | 0 Cargo.toml => server/Cargo.toml | 0 rustfmt.toml => server/rustfmt.toml | 0 {src => server/src}/bin.rs | 0 {src => server/src}/commands/init.rs | 0 {src => server/src}/commands/mod.rs | 0 {src => server/src}/commands/serve.rs | 0 {src => server/src}/core.rs | 0 {src => server/src}/pathext.rs | 0 {src => server/src}/plugin.rs | 0 {src => server/src}/plugins/default_plugin.rs | 0 .../src}/plugins/json_model_plugin.rs | 0 {src => server/src}/plugins/mod.rs | 0 {src => server/src}/plugins/script_plugin.rs | 0 {src => server/src}/project.rs | 0 {src => server/src}/rbx.rs | 0 {src => server/src}/vfs/mod.rs | 0 {src => server/src}/vfs/vfs_item.rs | 0 {src => server/src}/vfs/vfs_session.rs | 0 {src => server/src}/vfs/vfs_watcher.rs | 0 {src => server/src}/web.rs | 0 55 files changed, 1742 insertions(+), 36 deletions(-) create mode 100644 .gitmodules create mode 100644 plugin/.editorconfig create mode 100644 plugin/.gitignore create mode 100644 plugin/.luacheckrc create mode 100644 plugin/.luacov create mode 160000 plugin/modules/lemur create mode 160000 plugin/modules/roact create mode 160000 plugin/modules/roact-rodux create mode 160000 plugin/modules/rodux create mode 160000 plugin/modules/testez create mode 100644 plugin/rojo.json create mode 100644 plugin/spec.lua create mode 100644 plugin/src/Config.lua create mode 100644 plugin/src/Config.spec.lua create mode 100644 plugin/src/Http.lua create mode 100644 plugin/src/HttpError.lua create mode 100644 plugin/src/HttpResponse.lua create mode 100644 plugin/src/Main.server.lua create mode 100644 plugin/src/Plugin.lua create mode 100644 plugin/src/Promise.lua create mode 100644 plugin/src/Promise.spec.lua create mode 100644 plugin/src/Reconciler.lua create mode 100644 plugin/src/RouteMap.lua create mode 100644 plugin/src/Server.lua create mode 100644 plugin/src/Version.lua create mode 100644 plugin/src/Version.spec.lua create mode 100644 plugin/src/runTests.lua create mode 100644 plugin/tests/runTests.server.lua create mode 100644 server/.editorconfig rename .gitignore => server/.gitignore (100%) rename Cargo.lock => server/Cargo.lock (100%) rename Cargo.toml => server/Cargo.toml (100%) rename rustfmt.toml => server/rustfmt.toml (100%) rename {src => server/src}/bin.rs (100%) rename {src => server/src}/commands/init.rs (100%) rename {src => server/src}/commands/mod.rs (100%) rename {src => server/src}/commands/serve.rs (100%) rename {src => server/src}/core.rs (100%) rename {src => server/src}/pathext.rs (100%) rename {src => server/src}/plugin.rs (100%) rename {src => server/src}/plugins/default_plugin.rs (100%) rename {src => server/src}/plugins/json_model_plugin.rs (100%) rename {src => server/src}/plugins/mod.rs (100%) rename {src => server/src}/plugins/script_plugin.rs (100%) rename {src => server/src}/project.rs (100%) rename {src => server/src}/rbx.rs (100%) rename {src => server/src}/vfs/mod.rs (100%) rename {src => server/src}/vfs/vfs_item.rs (100%) rename {src => server/src}/vfs/vfs_session.rs (100%) rename {src => server/src}/vfs/vfs_watcher.rs (100%) rename {src => server/src}/web.rs (100%) diff --git a/.editorconfig b/.editorconfig index c2c2496b..263d65f2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,12 +4,6 @@ root = true end_of_line = lf charset = utf-8 -[*.rs] -indent_style = space -indent_size = 4 -trim_trailing_whitespace = true -insert_final_newline = true - [*.json] indent_style = space indent_size = 2 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..4d521fd8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,15 @@ +[submodule "plugin/modules/roact"] + path = plugin/modules/roact + url = https://github.com/Roblox/roact.git +[submodule "plugin/modules/rodux"] + path = plugin/modules/rodux + url = https://github.com/Roblox/rodux.git +[submodule "plugin/modules/roact-rodux"] + path = plugin/modules/roact-rodux + url = https://github.com/Roblox/roact-rodux.git +[submodule "plugin/modules/testez"] + path = plugin/modules/testez + url = https://github.com/Roblox/testez.git +[submodule "plugin/modules/lemur"] + path = plugin/modules/lemur + url = https://github.com/LPGhatguy/lemur.git diff --git a/.travis.yml b/.travis.yml index 2657bd4d..b83c1564 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,39 @@ -language: rust +matrix: + include: + - language: python + env: + - LUA="lua=5.1" -rust: - - stable - - beta \ No newline at end of file + 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: stable + + script: + - cd server + - cargo test --verbose + - language: rust + rust: beta + + script: + - cd server + - cargo test --verbose diff --git a/CHANGES.md b/CHANGES.md index 9a1113d8..19a6cf7b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,7 +1,8 @@ # Rojo Change Log ## Current Master -*No changes* +* Merged plugin repository into main Rojo repository for easier tracking +* Improved `RouteMap` object tracking; this should fix some cases of duplicated instances being synced into the tree. ## 0.4.0 (March 27, 2018) * Protocol version 1, which shifts more responsibility onto the server diff --git a/README.md b/README.md index 9e750c25..6137b612 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- Rojo + Rojo
 
@@ -14,15 +14,12 @@
-**Rojo** is a flexible multi-tool designed for creating robust Roblox projects. It's in early development, but is still useful for many projects. +**Rojo** is a flexible multi-tool designed for creating robust Roblox projects. It's designed for power users who want to use the **best tools available** for building games, libraries, and plugins. -This is the main Rojo repository, containing the Rojo CLI. The Roblox Studio plugin is contained in [the rojo-plugin repository](https://github.com/LPGhatguy/rojo-plugin). - ## Features - -Rojo has a number of desirable features *right now*: +Rojo lets you: * Work on scripts from the filesystem, in your favorite editor * Version your place, library, or plugin using Git or another VCS @@ -30,24 +27,34 @@ Rojo has a number of desirable features *right now*: Later this year, Rojo will be able to: -* Sync rbxmx-format Roblox models bi-directionally between the filesystem and Roblox Studio -* Package libraries and plugins into `rbxmx` files from the command line +* Sync `rbxmx` models between the filesystem and Roblox Studio +* Package projects into `rbxmx` files from the command line ## Installation Rojo has two components: -* The command line interface (CLI), written in Rust -* The [Roblox Studio plugin](https://www.roblox.com/library/1211549683/Rojo), written in Lua -To install the command line tool, there are two options: -* Cargo, if you have Rust installed - * Use `cargo install rojo` -- Rojo will be available with the `rojo` command -* Download a pre-built Windows binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases) +* The server, a binary written in Rust +* The plugin, a Roblox Studio plugin written in Lua -## Usage +It's important that the plugin and server are compatible. The plugin will show errors in the Roblox Studio Output window if there is a version mismatch. + +To install the server, either: + +* If you have Rust installed, use `cargo install rojo` +* Or, download a pre-built Windows binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases) + +To install the plugin, either: + +* Install the plugin from the [Roblox plugin page](https://www.roblox.com/library/1211549683/Rojo). + * This gives you less control over what version you install -- you will always have the latest version. +* Or, download the latest release from [the GitHub releases section](https://github.com/LPGhatguy/rojo/releases) and install it into your Roblox plugins folder + * You can open this folder by clicking the "Plugins Folder" button from the Plugins toolbar in Roblox Studio + +## Server Usage For more help, use `rojo help`. ### New Project -Just create a new folder and tell Rojo to initialize it! +Create a new folder, then use `rojo init` inside that folder to initialize an empty project: ```sh mkdir my-new-project @@ -56,9 +63,9 @@ cd my-new-project rojo init ``` -Rojo will create an empty project in the directory. +Rojo will create an empty project file named `rojo.json` in the directory. -The default project looks like this: +The default project file is: ```json { @@ -69,7 +76,7 @@ The default project looks like this: ``` ### Start Dev Server -To create a server that allows the Rojo Studio plugin to access your project, use: +To start the Rojo dev server, use: ```sh rojo serve @@ -83,7 +90,7 @@ The tool will tell you whether it found an existing project. You should then be In the mean-time, manually migrating scripts is probably the best route forward. ### Syncing into Roblox -In order to sync code into Roblox, you'll need toadd one or more *partitions* to your configuration. A partition tells Rojo how to map directories on your filesystem to Roblox objects. +In order to sync code into Roblox, you'll need to add one or more *partitions* to your configuration. A partition tells Rojo how to map directories on your filesystem to Roblox objects. Each entry in the `partitions` map has a unique name, a filesystem path, and the full name of the Roblox object to sync into. @@ -142,9 +149,11 @@ Will turn into these instances in Roblox: * `my-game` (`LocalScript` with source from `my-game/init.client.lua`) * `foo` (`ModuleScript` with source from `my-game/foo.lua`) -`*.model.json` files are intended as a simple way to represent non-script Roblox instances on the filesystem until `rbxmx` and `rbxlx` support is implemented in Rojo. +`*.model.json` files are a way to represent simple Roblox instances on the filesystem until `rbxmx` and `rbxlx` support is implemented in Rojo. -JSON Model files are fairly strict, with every property being required. They generally look like this: +This feature is intended for small instances, like `RemoteEvent` or `*Value` objects. + +JSON Model files are strict, with every property being required. They look like this: ```json { @@ -192,9 +201,7 @@ I also have a couple tools that Rojo intends to replace: ## Contributing Pull requests are welcome! -The `master` branch of both repositories have tests running on Travis for every commit and pull request. The test suite on `master` should always pass! - -The Rojo and Rojo Plugin repositories should stay in sync with eachother, so that the current `master` of each repository can be used together. +All pull requests are run against a test suite on Travis CI. That test suite should always pass! ## License Rojo is available under the terms of the MIT license. See [LICENSE.md](LICENSE.md) for details. \ No newline at end of file diff --git a/plugin/.editorconfig b/plugin/.editorconfig new file mode 100644 index 00000000..da77c5d6 --- /dev/null +++ b/plugin/.editorconfig @@ -0,0 +1,4 @@ +[*.lua] +indent_style = tab +trim_trailing_whitespace = true +insert_final_newline = true \ No newline at end of file diff --git a/plugin/.gitignore b/plugin/.gitignore new file mode 100644 index 00000000..92b37c63 --- /dev/null +++ b/plugin/.gitignore @@ -0,0 +1 @@ +/luacov.* \ No newline at end of file diff --git a/plugin/.luacheckrc b/plugin/.luacheckrc new file mode 100644 index 00000000..5e011987 --- /dev/null +++ b/plugin/.luacheckrc @@ -0,0 +1,56 @@ +stds.roblox = { + read_globals = { + game = { + other_fields = true, + }, + + -- Roblox globals + "script", + + -- Extra functions + "tick", "warn", "spawn", + "wait", "settings", "typeof", + + -- Types + "Vector2", "Vector3", + "Color3", + "UDim", "UDim2", + "Rect", + "CFrame", + "Enum", + "Instance", + } +} + +stds.plugin = { + read_globals = { + "plugin", + } +} + +stds.testez = { + read_globals = { + "describe", + "it", "itFOCUS", "itSKIP", + "FOCUS", "SKIP", "HACK_NO_XPCALL", + "expect", + } +} + +ignore = { + "212", -- unused arguments + "421", -- shadowing local variable + "422", -- shadowing argument + "431", -- shadowing upvalue + "432", -- shadowing upvalue argument +} + +std = "lua51+roblox" + +files["**/*.server.lua"] = { + std = "+plugin", +} + +files["**/*.spec.lua"] = { + std = "+testez", +} \ No newline at end of file diff --git a/plugin/.luacov b/plugin/.luacov new file mode 100644 index 00000000..996d3cc0 --- /dev/null +++ b/plugin/.luacov @@ -0,0 +1,8 @@ +return { + include = { + "^src", + }, + exclude = { + "%.spec$", + }, +} \ No newline at end of file diff --git a/plugin/modules/lemur b/plugin/modules/lemur new file mode 160000 index 00000000..852c71b8 --- /dev/null +++ b/plugin/modules/lemur @@ -0,0 +1 @@ +Subproject commit 852c71b897ff346f6a76796a418b9da4255d04b1 diff --git a/plugin/modules/roact b/plugin/modules/roact new file mode 160000 index 00000000..bbb06631 --- /dev/null +++ b/plugin/modules/roact @@ -0,0 +1 @@ +Subproject commit bbb066316149f9b56fdf7718d48bcded7288d86f diff --git a/plugin/modules/roact-rodux b/plugin/modules/roact-rodux new file mode 160000 index 00000000..97fbfee9 --- /dev/null +++ b/plugin/modules/roact-rodux @@ -0,0 +1 @@ +Subproject commit 97fbfee90af6abaa2118c9abd4532210793c26dc diff --git a/plugin/modules/rodux b/plugin/modules/rodux new file mode 160000 index 00000000..b8ba4863 --- /dev/null +++ b/plugin/modules/rodux @@ -0,0 +1 @@ +Subproject commit b8ba4863359f3a05ac5a8f1993d02b5410e5e718 diff --git a/plugin/modules/testez b/plugin/modules/testez new file mode 160000 index 00000000..442b7192 --- /dev/null +++ b/plugin/modules/testez @@ -0,0 +1 @@ +Subproject commit 442b71926d4e9bd9933bbdd87d95679062723dad diff --git a/plugin/rojo.json b/plugin/rojo.json new file mode 100644 index 00000000..c1cbdb8c --- /dev/null +++ b/plugin/rojo.json @@ -0,0 +1,30 @@ +{ + "name": "rojo", + "servePort": 8000, + "partitions": { + "plugin": { + "path": "src", + "target": "ReplicatedStorage.Rojo.plugin" + }, + "modules/roact": { + "path": "modules/roact/lib", + "target": "ReplicatedStorage.Rojo.modules.Roact" + }, + "modules/rodux": { + "path": "modules/rodux/lib", + "target": "ReplicatedStorage.Rojo.modules.Rodux" + }, + "modules/roact-rodux": { + "path": "modules/roact-rodux/lib", + "target": "ReplicatedStorage.Rojo.modules.RoactRodux" + }, + "modules/testez": { + "path": "modules/testez/lib", + "target": "ReplicatedStorage.TestEZ" + }, + "tests": { + "path": "tests", + "target": "TestService" + } + } +} \ No newline at end of file diff --git a/plugin/spec.lua b/plugin/spec.lua new file mode 100644 index 00000000..02325bdd --- /dev/null +++ b/plugin/spec.lua @@ -0,0 +1,69 @@ +--[[ + Loads our library and all of its dependencies, then runs tests using TestEZ. +]] + +-- If you add any dependencies, add them to this table so they'll be loaded! +local LOAD_MODULES = { + {"src", "Plugin"}, + {"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") + +--[[ + Collapses ModuleScripts named 'init' into their parent folders. + + This is the same result as the collapsing mechanism from Rojo. +]] +local function collapse(root) + local init = root:FindFirstChild("init") + if init then + init.Name = root.Name + init.Parent = root.Parent + + for _, child in ipairs(root:GetChildren()) do + child.Parent = init + end + + root:Destroy() + root = init + end + + for _, child in ipairs(root:GetChildren()) do + if child:IsA("Folder") then + collapse(child) + end + end + + return root +end + +-- Create a virtual Roblox tree +local habitat = lemur.Habitat.new() + +-- We'll put all of our library code and dependencies here +local Root = lemur.Instance.new("Folder") +Root.Name = "Root" + +-- Load all of the modules specified above +for _, module in ipairs(LOAD_MODULES) do + local container = lemur.Instance.new("Folder", Root) + container.Name = module[2] + habitat:loadFromFs(module[1], container) +end + +collapse(Root) + +-- Load TestEZ and run our tests +local TestEZ = habitat:require(Root.TestEZ) + +local results = TestEZ.TestBootstrap:run(Root.Plugin, TestEZ.Reporters.TextReporter) + +-- Did something go wrong? +if results.failureCount > 0 then + os.exit(1) +end diff --git a/plugin/src/Config.lua b/plugin/src/Config.lua new file mode 100644 index 00000000..b4bfa3be --- /dev/null +++ b/plugin/src/Config.lua @@ -0,0 +1,7 @@ +return { + pollingRate = 0.2, + version = {0, 4, 1}, + expectedServerVersionString = "0.4.x", + protocolVersion = 1, + dev = false, +} diff --git a/plugin/src/Config.spec.lua b/plugin/src/Config.spec.lua new file mode 100644 index 00000000..ce9b43fd --- /dev/null +++ b/plugin/src/Config.spec.lua @@ -0,0 +1,7 @@ +return function() + local Config = require(script.Parent.Config) + + it("should have 'dev' disabled", function() + expect(Config.dev).to.equal(false) + end) +end diff --git a/plugin/src/Http.lua b/plugin/src/Http.lua new file mode 100644 index 00000000..98997b9f --- /dev/null +++ b/plugin/src/Http.lua @@ -0,0 +1,67 @@ +local HttpService = game:GetService("HttpService") + +local HTTP_DEBUG = false + +local Promise = require(script.Parent.Promise) +local HttpError = require(script.Parent.HttpError) +local HttpResponse = require(script.Parent.HttpResponse) + +local function dprint(...) + if HTTP_DEBUG then + print(...) + end +end + +local Http = {} +Http.__index = Http + +function Http.new(baseUrl) + assert(type(baseUrl) == "string", "Http.new needs a baseUrl!") + + local http = { + baseUrl = baseUrl + } + + setmetatable(http, Http) + + return http +end + +function Http:get(endpoint) + dprint("\nGET", endpoint) + return Promise.new(function(resolve, reject) + spawn(function() + local ok, result = pcall(function() + return HttpService:GetAsync(self.baseUrl .. endpoint, true) + end) + + if ok then + dprint("\t", result, "\n") + resolve(HttpResponse.new(result)) + else + reject(HttpError.fromErrorString(result)) + end + end) + end) +end + +function Http:post(endpoint, body) + dprint("\nPOST", endpoint) + dprint(body) + return Promise.new(function(resolve, reject) + spawn(function() + local ok, result = pcall(function() + return HttpService:PostAsync(self.baseUrl .. endpoint, body) + end) + + if ok then + dprint("\t", result, "\n") + resolve(HttpResponse.new(result)) + else + reject(HttpError.fromErrorString(result)) + end + end) + end) +end + +return Http diff --git a/plugin/src/HttpError.lua b/plugin/src/HttpError.lua new file mode 100644 index 00000000..9da4b946 --- /dev/null +++ b/plugin/src/HttpError.lua @@ -0,0 +1,57 @@ +local HttpError = {} +HttpError.__index = HttpError + +HttpError.Error = { + HttpNotEnabled = { + message = "Rojo requires HTTP access, which is not enabled.\n" .. + "Check your game settings, located in the 'Home' tab of Studio.", + }, + ConnectFailed = { + message = "Rojo plugin couldn't connect to the Rojo server.\n" .. + "Make sure the server is running -- use 'Rojo serve' to run it!", + }, + Unknown = { + message = "Rojo encountered an unknown error: {{message}}", + }, +} + +function HttpError.new(type, extraMessage) + extraMessage = extraMessage or "" + local message = type.message:gsub("{{message}}", extraMessage) + + local err = { + type = type, + message = message, + } + + setmetatable(err, HttpError) + + return err +end + +function HttpError:__tostring() + return self.message +end + +--[[ + This method shouldn't have to exist. Ugh. +]] +function HttpError.fromErrorString(err) + err = err:lower() + + if err:find("^http requests are not enabled") then + return HttpError.new(HttpError.Error.HttpNotEnabled) + end + + if err:find("^curl error") then + return HttpError.new(HttpError.Error.ConnectFailed) + end + + return HttpError.new(HttpError.Error.Unknown, err) +end + +function HttpError:report() + warn(self.message) +end + +return HttpError diff --git a/plugin/src/HttpResponse.lua b/plugin/src/HttpResponse.lua new file mode 100644 index 00000000..0b85db94 --- /dev/null +++ b/plugin/src/HttpResponse.lua @@ -0,0 +1,20 @@ +local HttpService = game:GetService("HttpService") + +local HttpResponse = {} +HttpResponse.__index = HttpResponse + +function HttpResponse.new(body) + local response = { + body = body, + } + + setmetatable(response, HttpResponse) + + return response +end + +function HttpResponse:json() + return HttpService:JSONDecode(self.body) +end + +return HttpResponse diff --git a/plugin/src/Main.server.lua b/plugin/src/Main.server.lua new file mode 100644 index 00000000..062dc414 --- /dev/null +++ b/plugin/src/Main.server.lua @@ -0,0 +1,79 @@ +if not plugin then + return +end + +local Plugin = require(script.Parent.Plugin) +local Config = require(script.Parent.Config) +local Version = require(script.Parent.Version) + +--[[ + Check if the user is using a newer version of Rojo than last time. If they + are, show them a reminder to make sure they check their server version. +]] +local function checkUpgrade() + local lastVersion = plugin:GetSetting("LastRojoVersion") + + if lastVersion then + local wasUpgraded = Version.compare(Config.version, lastVersion) == 1 + + if wasUpgraded then + local message = ( + "\nRojo detected an upgrade from version %s to version %s." .. + "\nMake sure you have also upgraded your server!" .. + "\n\nRojo version %s is intended for use with server version %s.\n" + ):format( + Version.display(lastVersion), Version.display(Config.version), + Version.display(Config.version), Config.expectedServerVersionString + ) + + print(message) + end + end + + -- When developing Rojo, there's no use in storing that version number. + if not Config.dev then + plugin:SetSetting("LastRojoVersion", Config.version) + end +end + +local function main() + local pluginInstance = Plugin.new() + + local displayedVersion = Config.dev and "DEV" or Version.display(Config.version) + + local toolbar = plugin:CreateToolbar("Rojo Plugin " .. displayedVersion) + + toolbar:CreateButton("Test Connection", "Connect to Rojo Server", "") + .Click:Connect(function() + checkUpgrade() + + pluginInstance:connect() + :catch(function(err) + warn(err) + end) + end) + + toolbar:CreateButton("Sync In", "Sync into Roblox Studio", "") + .Click:Connect(function() + checkUpgrade() + + pluginInstance:syncIn() + :catch(function(err) + warn(err) + end) + end) + + toolbar:CreateButton("Toggle Polling", "Poll server for changes", "") + .Click:Connect(function() + checkUpgrade() + + spawn(function() + pluginInstance:togglePolling() + :catch(function(err) + warn(err) + end) + end) + end) +end + +main() diff --git a/plugin/src/Plugin.lua b/plugin/src/Plugin.lua new file mode 100644 index 00000000..92cdbd4b --- /dev/null +++ b/plugin/src/Plugin.lua @@ -0,0 +1,198 @@ +local Config = require(script.Parent.Config) +local Http = require(script.Parent.Http) +local Server = require(script.Parent.Server) +local Promise = require(script.Parent.Promise) +local Reconciler = require(script.Parent.Reconciler) + +local function collectMatch(source, pattern) + local result = {} + + for match in source:gmatch(pattern) do + table.insert(result, match) + end + + return result +end + +local Plugin = {} +Plugin.__index = Plugin + +function Plugin.new() + local address = "localhost" + local port = Config.dev and 8001 or 8000 + + local remote = ("http://%s:%d"):format(address, port) + + local self = { + _http = Http.new(remote), + _reconciler = Reconciler.new(), + _server = nil, + _polling = false, + } + + setmetatable(self, Plugin) + + do + local screenGui = Instance.new("ScreenGui") + screenGui.Name = "Rojo UI" + screenGui.Parent = game.CoreGui + screenGui.DisplayOrder = -1 + screenGui.Enabled = false + + local label = Instance.new("TextLabel") + label.Font = Enum.Font.SourceSans + label.TextSize = 20 + label.Text = "Rojo polling..." + label.BackgroundColor3 = Color3.fromRGB(31, 31, 31) + label.BackgroundTransparency = 0.5 + label.BorderSizePixel = 0 + label.TextColor3 = Color3.new(1, 1, 1) + label.Size = UDim2.new(0, 120, 0, 28) + label.Position = UDim2.new(0, 0, 0, 0) + label.Parent = screenGui + + self._label = screenGui + end + + return self +end + +function Plugin:server() + if not self._server then + self._server = Server.connect(self._http) + :catch(function(err) + self._server = nil + return Promise.reject(err) + end) + end + + return self._server +end + +function Plugin:connect() + print("Testing connection...") + + return self:server() + :andThen(function(server) + return server:getInfo() + end) + :andThen(function(result) + print("Server found!") + print("Protocol version:", result.protocolVersion) + print("Server version:", result.serverVersion) + end) +end + +function Plugin:togglePolling() + if self._polling then + self:stopPolling() + + return Promise.resolve(nil) + else + return self:startPolling() + end +end + +function Plugin:stopPolling() + if not self._polling then + return + end + + print("Stopped polling.") + + self._polling = false + self._label.Enabled = false +end + +function Plugin:_pull(server, project, routes) + local items = server:read(routes):await() + + for index = 1, #routes do + local itemRoute = routes[index] + local partitionName = itemRoute[1] + local partition = project.partitions[partitionName] + local item = items[index] + + local partitionRoute = collectMatch(partition.target, "[^.]+") + + -- If the item route's length was 1, we need to rename the instance to + -- line up with the partition's root object name. + -- + -- This is a HACK! + if #itemRoute == 1 then + if item then + local objectName = partition.target:match("[^.]+$") + item.Name = objectName + end + end + + local fullRoute = {} + for _, piece in ipairs(partitionRoute) do + table.insert(fullRoute, piece) + end + + for i = 2, #itemRoute do + table.insert(fullRoute, itemRoute[i]) + end + + self._reconciler:reconcileRoute(fullRoute, item, itemRoute) + end +end + +function Plugin:startPolling() + if self._polling then + return + end + + print("Starting to poll...") + + self._polling = true + self._label.Enabled = true + + return self:server() + :andThen(function(server) + self:syncIn():await() + + local project = server:getInfo():await().project + + while self._polling do + local changes = server:getChanges():await() + + if #changes > 0 then + local routes = {} + + for _, change in ipairs(changes) do + table.insert(routes, change.route) + end + + self:_pull(server, project, routes) + end + + wait(Config.pollingRate) + end + end) + :catch(function() + self:stopPolling() + end) +end + +function Plugin:syncIn() + print("Syncing from server...") + + return self:server() + :andThen(function(server) + local project = server:getInfo():await().project + + local routes = {} + + for name in pairs(project.partitions) do + table.insert(routes, {name}) + end + + self:_pull(server, project, routes) + + print("Sync successful!") + end) +end + +return Plugin diff --git a/plugin/src/Promise.lua b/plugin/src/Promise.lua new file mode 100644 index 00000000..cdcea791 --- /dev/null +++ b/plugin/src/Promise.lua @@ -0,0 +1,311 @@ +--[[ + An implementation of Promises similar to Promise/A+. +]] + +local PROMISE_DEBUG = false + +-- If promise debugging is on, use a version of pcall that warns on failure. +-- This is useful for finding errors that happen within Promise itself. +local wpcall +if PROMISE_DEBUG then + wpcall = function(f, ...) + local result = { pcall(f, ...) } + + if not result[1] then + warn(result[2]) + end + + return unpack(result) + end +else + wpcall = pcall +end + +--[[ + Creates a function that invokes a callback with correct error handling and + resolution mechanisms. +]] +local function createAdvancer(callback, resolve, reject) + return function(...) + local result = { wpcall(callback, ...) } + local ok = table.remove(result, 1) + + if ok then + resolve(unpack(result)) + else + reject(unpack(result)) + end + end +end + +local function isEmpty(t) + return next(t) == nil +end + +local Promise = {} +Promise.__index = Promise + +Promise.Status = { + Started = "Started", + Resolved = "Resolved", + Rejected = "Rejected", +} + +--[[ + Constructs a new Promise with the given initializing callback. + + This is generally only called when directly wrapping a non-promise API into + a promise-based version. + + The callback will receive 'resolve' and 'reject' methods, used to start + invoking the promise chain. + + For example: + + local function get(url) + return Promise.new(function(resolve, reject) + spawn(function() + resolve(HttpService:GetAsync(url)) + end) + end) + end + + get("https://google.com") + :andThen(function(stuff) + print("Got some stuff!", stuff) + end) +]] +function Promise.new(callback) + local promise = { + -- Used to locate where a promise was created + _source = debug.traceback(), + + -- A tag to identify us as a promise + _type = "Promise", + + _status = Promise.Status.Started, + + -- A table containing a list of all results, whether success or failure. + -- Only valid if _status is set to something besides Started + _value = nil, + + -- If an error occurs with no observers, this will be set. + _unhandledRejection = false, + + -- Queues representing functions we should invoke when we update! + _queuedResolve = {}, + _queuedReject = {}, + } + + setmetatable(promise, Promise) + + local function resolve(...) + promise:_resolve(...) + end + + local function reject(...) + promise:_reject(...) + end + + local ok, err = wpcall(callback, resolve, reject) + + if not ok and promise._status == Promise.Status.Started then + reject(err) + end + + return promise +end + +--[[ + Create a promise that represents the immediately resolved value. +]] +function Promise.resolve(value) + return Promise.new(function(resolve) + resolve(value) + end) +end + +--[[ + Create a promise that represents the immediately rejected value. +]] +function Promise.reject(value) + return Promise.new(function(_, reject) + reject(value) + end) +end + +--[[ + Returns a new promise that: + * is resolved when all input promises resolve + * is rejected if ANY input promises reject +]] +function Promise.all(...) + error("unimplemented", 2) +end + +--[[ + Is the given object a Promise instance? +]] +function Promise.is(object) + if type(object) ~= "table" then + return false + end + + return object._type == "Promise" +end + +--[[ + Creates a new promise that receives the result of this promise. + + The given callbacks are invoked depending on that result. +]] +function Promise:andThen(successHandler, failureHandler) + self._unhandledRejection = false + + -- Create a new promise to follow this part of the chain + return Promise.new(function(resolve, reject) + -- Our default callbacks just pass values onto the next promise. + -- This lets success and failure cascade correctly! + + local successCallback = resolve + if successHandler then + successCallback = createAdvancer(successHandler, resolve, reject) + end + + local failureCallback = reject + if failureHandler then + failureCallback = createAdvancer(failureHandler, resolve, reject) + end + + if self._status == Promise.Status.Started then + -- If we haven't resolved yet, put ourselves into the queue + table.insert(self._queuedResolve, successCallback) + table.insert(self._queuedReject, failureCallback) + elseif self._status == Promise.Status.Resolved then + -- This promise has already resolved! Trigger success immediately. + successCallback(unpack(self._value)) + elseif self._status == Promise.Status.Rejected then + -- This promise died a terrible death! Trigger failure immediately. + failureCallback(unpack(self._value)) + end + end) +end + +--[[ + Used to catch any errors that may have occurred in the promise. +]] +function Promise:catch(failureCallback) + return self:andThen(nil, failureCallback) +end + +--[[ + Yield until the promise is completed. + + This matches the execution model of normal Roblox functions. +]] +function Promise:await() + self._unhandledRejection = false + + if self._status == Promise.Status.Started then + local result + local bindable = Instance.new("BindableEvent") + + self:andThen(function(...) + result = {...} + bindable:Fire(true) + end, function(...) + result = {...} + bindable:Fire(false) + end) + + local ok = bindable.Event:Wait() + bindable:Destroy() + + if not ok then + error(tostring(result[1]), 2) + end + + return unpack(result) + elseif self._status == Promise.Status.Resolved then + return unpack(self._value) + elseif self._status == Promise.Status.Rejected then + error(tostring(self._value[1]), 2) + end +end + +function Promise:_resolve(...) + if self._status ~= Promise.Status.Started then + return + end + + -- If the resolved value was a Promise, we chain onto it! + if Promise.is((...)) then + -- Without this warning, arguments sometimes mysteriously disappear + if select("#", ...) > 1 then + local message = ( + "When returning a Promise from andThen, extra arguments are " .. + "discarded! See:\n\n%s" + ):format( + self._source + ) + warn(message) + end + + (...):andThen(function(...) + self:_resolve(...) + end, function(...) + self:_reject(...) + end) + + return + end + + self._status = Promise.Status.Resolved + self._value = {...} + + -- We assume that these callbacks will not throw errors. + for _, callback in ipairs(self._queuedResolve) do + callback(...) + end +end + +function Promise:_reject(...) + if self._status ~= Promise.Status.Started then + return + end + + self._status = Promise.Status.Rejected + self._value = {...} + + -- If there are any rejection handlers, call those! + if not isEmpty(self._queuedReject) then + -- We assume that these callbacks will not throw errors. + for _, callback in ipairs(self._queuedReject) do + callback(...) + end + else + -- At this point, no one was able to observe the error. + -- An error handler might still be attached if the error occurred + -- synchronously. We'll wait one tick, and if there are still no + -- observers, then we should put a message in the console. + + self._unhandledRejection = true + local err = tostring((...)) + + spawn(function() + -- Someone observed the error, hooray! + if not self._unhandledRejection then + return + end + + -- Build a reasonable message + local message = ("Unhandled promise rejection:\n\n%s\n\n%s"):format( + err, + self._source + ) + warn(message) + end) + end +end + +return Promise diff --git a/plugin/src/Promise.spec.lua b/plugin/src/Promise.spec.lua new file mode 100644 index 00000000..2193fcb6 --- /dev/null +++ b/plugin/src/Promise.spec.lua @@ -0,0 +1,262 @@ +return function() + local Promise = require(script.Parent.Promise) + + describe("Promise.new", function() + it("should instantiate with a callback", function() + local promise = Promise.new(function() end) + + expect(promise).to.be.ok() + end) + + it("should invoke the given callback with resolve and reject", function() + local callCount = 0 + local resolveArg + local rejectArg + + local promise = Promise.new(function(resolve, reject) + callCount = callCount + 1 + resolveArg = resolve + rejectArg = reject + end) + + expect(promise).to.be.ok() + + expect(callCount).to.equal(1) + expect(resolveArg).to.be.a("function") + expect(rejectArg).to.be.a("function") + expect(promise._status).to.equal(Promise.Status.Started) + end) + + it("should resolve promises on resolve()", function() + local callCount = 0 + + local promise = Promise.new(function(resolve) + callCount = callCount + 1 + resolve() + end) + + expect(promise).to.be.ok() + expect(callCount).to.equal(1) + expect(promise._status).to.equal(Promise.Status.Resolved) + end) + + it("should reject promises on reject()", function() + local callCount = 0 + + local promise = Promise.new(function(resolve, reject) + callCount = callCount + 1 + reject() + end) + + expect(promise).to.be.ok() + expect(callCount).to.equal(1) + expect(promise._status).to.equal(Promise.Status.Rejected) + end) + + it("should reject on error in callback", function() + local callCount = 0 + + local promise = Promise.new(function() + callCount = callCount + 1 + error("hahah") + end) + + expect(promise).to.be.ok() + expect(callCount).to.equal(1) + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]:find("hahah")).to.be.ok() + end) + end) + + describe("Promise.resolve", function() + it("should immediately resolve with a value", function() + local promise = Promise.resolve(5) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Resolved) + expect(promise._value[1]).to.equal(5) + end) + + it("should chain onto passed promises", function() + local promise = Promise.resolve(Promise.new(function(_, reject) + reject(7) + end)) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]).to.equal(7) + end) + end) + + describe("Promise.reject", function() + it("should immediately reject with a value", function() + local promise = Promise.reject(6) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]).to.equal(6) + end) + + it("should pass a promise as-is as an error", function() + local innerPromise = Promise.new(function(resolve) + resolve(6) + end) + + local promise = Promise.reject(innerPromise) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]).to.equal(innerPromise) + end) + end) + + describe("Promise:andThen", function() + it("should chain onto resolved promises", function() + local args + local argsLength + local callCount = 0 + local badCallCount = 0 + + local promise = Promise.resolve(5) + + local chained = promise + :andThen(function(...) + args = {...} + argsLength = select("#", ...) + callCount = callCount + 1 + end, function() + badCallCount = badCallCount + 1 + end) + + expect(badCallCount).to.equal(0) + + expect(callCount).to.equal(1) + expect(argsLength).to.equal(1) + expect(args[1]).to.equal(5) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Resolved) + expect(promise._value[1]).to.equal(5) + + expect(chained).to.be.ok() + expect(chained).never.to.equal(promise) + expect(chained._status).to.equal(Promise.Status.Resolved) + expect(#chained._value).to.equal(0) + end) + + it("should chain onto rejected promises", function() + local args + local argsLength + local callCount = 0 + local badCallCount = 0 + + local promise = Promise.reject(5) + + local chained = promise + :andThen(function(...) + badCallCount = badCallCount + 1 + end, function(...) + args = {...} + argsLength = select("#", ...) + callCount = callCount + 1 + end) + + expect(badCallCount).to.equal(0) + + expect(callCount).to.equal(1) + expect(argsLength).to.equal(1) + expect(args[1]).to.equal(5) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]).to.equal(5) + + expect(chained).to.be.ok() + expect(chained).never.to.equal(promise) + expect(chained._status).to.equal(Promise.Status.Resolved) + expect(#chained._value).to.equal(0) + end) + + it("should chain onto asynchronously resolved promises", function() + local args + local argsLength + local callCount = 0 + local badCallCount = 0 + + local startResolution + local promise = Promise.new(function(resolve) + startResolution = resolve + end) + + local chained = promise + :andThen(function(...) + args = {...} + argsLength = select("#", ...) + callCount = callCount + 1 + end, function() + badCallCount = badCallCount + 1 + end) + + expect(callCount).to.equal(0) + expect(badCallCount).to.equal(0) + + startResolution(6) + + expect(badCallCount).to.equal(0) + + expect(callCount).to.equal(1) + expect(argsLength).to.equal(1) + expect(args[1]).to.equal(6) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Resolved) + expect(promise._value[1]).to.equal(6) + + expect(chained).to.be.ok() + expect(chained).never.to.equal(promise) + expect(chained._status).to.equal(Promise.Status.Resolved) + expect(#chained._value).to.equal(0) + end) + + it("should chain onto asynchronously rejected promises", function() + local args + local argsLength + local callCount = 0 + local badCallCount = 0 + + local startResolution + local promise = Promise.new(function(_, reject) + startResolution = reject + end) + + local chained = promise + :andThen(function() + badCallCount = badCallCount + 1 + end, function(...) + args = {...} + argsLength = select("#", ...) + callCount = callCount + 1 + end) + + expect(callCount).to.equal(0) + expect(badCallCount).to.equal(0) + + startResolution(6) + + expect(badCallCount).to.equal(0) + + expect(callCount).to.equal(1) + expect(argsLength).to.equal(1) + expect(args[1]).to.equal(6) + + expect(promise).to.be.ok() + expect(promise._status).to.equal(Promise.Status.Rejected) + expect(promise._value[1]).to.equal(6) + + expect(chained).to.be.ok() + expect(chained).never.to.equal(promise) + expect(chained._status).to.equal(Promise.Status.Resolved) + expect(#chained._value).to.equal(0) + end) + end) +end diff --git a/plugin/src/Reconciler.lua b/plugin/src/Reconciler.lua new file mode 100644 index 00000000..22d7968d --- /dev/null +++ b/plugin/src/Reconciler.lua @@ -0,0 +1,203 @@ +local RouteMap = require(script.Parent.RouteMap) + +local function classEqual(rbx, className) + if className == "*" then + return true + end + + return rbx.ClassName == className +end + +local function reparent(rbx, parent) + if rbx then + -- It's possible that 'rbx' is a service or some other object that we + -- can't change the parent of. That's the only reason why Parent would + -- fail except for rbx being previously destroyed! + pcall(function() + rbx.Parent = parent + end) + end +end + +--[[ + Attempts to match up Roblox instances and object specifiers for + reconciliation. + + An object is considered a match if they have the same Name and ClassName. + + primaryChildren and secondaryChildren can each be either a list of Roblox + instances or object specifiers. Since they share a common shape, switching + the two around isn't problematic! + + visited is expected to be an empty table initially. It will be filled with + the set of children that have been visited so far. +]] +local function findNextChildPair(primaryChildren, secondaryChildren, visited) + for _, primaryChild in ipairs(primaryChildren) do + if not visited[primaryChild] then + visited[primaryChild] = true + + for _, secondaryChild in ipairs(secondaryChildren) do + if primaryChild.ClassName == secondaryChild.ClassName and primaryChild.Name == secondaryChild.Name then + visited[secondaryChild] = true + + return primaryChild, secondaryChild + end + end + + return primaryChild, nil + end + end + + return nil, nil +end + +local Reconciler = {} +Reconciler.__index = Reconciler + +function Reconciler.new() + local reconciler = { + _routeMap = RouteMap.new(), + } + + setmetatable(reconciler, Reconciler) + + return reconciler +end + +--[[ + A semi-smart algorithm that attempts to apply the given item's children to + an existing Roblox object. +]] +function Reconciler:_reconcileChildren(rbx, item) + local visited = {} + local rbxChildren = rbx:GetChildren() + + -- Reconcile any children that were added or updated + while true do + local itemChild, rbxChild = findNextChildPair(item.Children, rbxChildren, visited) + + if not itemChild then + break + end + + reparent(self:reconcile(rbxChild, itemChild), rbx) + end + + -- Reconcile any children that were deleted + while true do + local rbxChild, itemChild = findNextChildPair(rbxChildren, item.Children, visited) + + if not rbxChild then + break + end + + reparent(self:reconcile(rbxChild, itemChild), rbx) + end +end + +--[[ + Construct a new Roblox object from the given item. +]] +function Reconciler:_reify(item) + local className = item.ClassName + + -- "*" represents a match of any class. It reifies as a folder! + if className == "*" then + className = "Folder" + end + + local rbx = Instance.new(className) + rbx.Name = item.Name + + for key, property in pairs(item.Properties) do + -- TODO: Check for compound types, like Vector3! + rbx[key] = property.Value + end + + self:_reconcileChildren(rbx, item) + + if item.Route then + self._routeMap:insert(item.Route, rbx) + end + + return rbx +end + +--[[ + Apply the changes represented by the given item to a Roblox object that's a + child of the given instance. +]] +function Reconciler:reconcile(rbx, item) + -- Item was deleted + if not item then + if rbx then + rbx:Destroy() + end + + return nil + end + + -- Item was created! + if not rbx then + return self:_reify(item) + end + + -- Item changed type! + if not classEqual(rbx, item.ClassName) then + rbx:Destroy() + + rbx = self:_reify(item) + end + + -- Apply all properties, Roblox will de-duplicate changes + for key, property in pairs(item.Properties) do + -- TODO: Transform property value based on property.Type + -- Right now, we assume that 'value' is primitive! + + rbx[key] = property.Value + end + + -- Use a dumb algorithm for reconciling children + self:_reconcileChildren(rbx, item) + + return rbx +end + +function Reconciler:reconcileRoute(route, item, itemRoute) + local parent + local rbx = game + + for i = 1, #route do + local piece = route[i] + + local child = rbx:FindFirstChild(piece) + + -- We should get services instead of making folders here. + if rbx == game and not child then + local _ + _, child = pcall(game.GetService, game, piece) + end + + -- We don't want to create a folder if we're reaching our target item! + if not child and i ~= #route then + child = Instance.new("Folder") + child.Parent = rbx + child.Name = piece + end + + parent = rbx + rbx = child + end + + -- Let's check the route map! + if not rbx then + rbx = self._routeMap:get(itemRoute) + end + + rbx = self:reconcile(rbx, item) + + reparent(rbx, parent) +end + +return Reconciler diff --git a/plugin/src/RouteMap.lua b/plugin/src/RouteMap.lua new file mode 100644 index 00000000..d76e5a0b --- /dev/null +++ b/plugin/src/RouteMap.lua @@ -0,0 +1,103 @@ +--[[ + A map from Route objects (given by the server) to Roblox instances (created + by the plugin). +]] + +local function hashRoute(route) + return table.concat(route, "/") +end + +local RouteMap = {} +RouteMap.__index = RouteMap + +function RouteMap.new() + local self = { + _map = {}, + _reverseMap = {}, + _connectionsByRbx = {}, + } + + setmetatable(self, RouteMap) + + return self +end + +function RouteMap:insert(route, rbx) + local hashed = hashRoute(route) + + self._map[hashed] = rbx + self._reverseMap[rbx] = hashed + self._connectionsByRbx[rbx] = rbx.AncestryChanged:Connect(function(_, parent) + if parent == nil then + self:removeByRbx(rbx) + end + end) +end + +function RouteMap:get(route) + return self._map[hashRoute(route)] +end + +function RouteMap:removeByRoute(route) + local hashedRoute = hashRoute(route) + local rbx = self._map[hashedRoute] + + if rbx then + self._map[hashedRoute] = nil + self._reverseMap[rbx] = nil + self._connectionsByRbx[rbx] = nil + end +end + +function RouteMap:removeByRbx(rbx) + local hashedRoute = self._reverseMap[rbx] + + if hashedRoute then + self._map[hashedRoute] = nil + self._reverseMap[rbx] = nil + self._connectionsByRbx[rbx] = nil + end +end + +function RouteMap:removeRbxDescendants(parentRbx) + for rbx in pairs(self._reverseMap) do + if rbx:IsDescendantOf(parentRbx) then + self:removeByRbx(rbx) + end + end +end + +function RouteMap:clear() + self._map = {} + self._reverseMap = {} + + for object in pairs(self._connectionsByRbx) do + object:Disconnect() + end + + self._connectionsByRbx = {} +end + +function RouteMap:visualize() + -- Log all of our keys so that the visualization has a stable order. + local keys = {} + + for key in pairs(self._map) do + table.insert(keys, key) + end + + table.sort(keys) + + local buffer = {} + for _, key in ipairs(keys) do + local visualized = ("- %s: %s"):format( + key, + self._map[key]:GetFullName() + ) + table.insert(buffer, visualized) + end + + return table.concat(buffer, "\n") +end + +return RouteMap diff --git a/plugin/src/Server.lua b/plugin/src/Server.lua new file mode 100644 index 00000000..5619426f --- /dev/null +++ b/plugin/src/Server.lua @@ -0,0 +1,89 @@ +local HttpService = game:GetService("HttpService") + +local Config = require(script.Parent.Config) +local Promise = require(script.Parent.Promise) +local Version = require(script.Parent.Version) + +local Server = {} +Server.__index = Server + +--[[ + Create a new Server using the given HTTP implementation and replacer. + + If the context becomes invalid, `replacer` will be invoked with a new + context that should be suitable to replace this one. + + Attempting to invoke methods on an invalid conext will throw errors! +]] +function Server.connect(http) + local context = { + http = http, + serverId = nil, + currentTime = 0, + } + + setmetatable(context, Server) + + return context:_start() +end + +function Server:_start() + return self:getInfo() + :andThen(function(response) + if response.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/LPGhatguy/rojo for more details." + ):format( + Version.display(Config.version), Config.protocolVersion, + Config.expectedServerVersionString, + response.serverVersion, response.protocolVersion + ) + + return Promise.reject(message) + end + + self.serverId = response.serverId + self.currentTime = response.currentTime + + return self + end) +end + +function Server:getInfo() + return self.http:get("/") + :andThen(function(response) + response = response:json() + + return response + end) +end + +function Server:read(paths) + local body = HttpService:JSONEncode(paths) + + return self.http:post("/read", body) + :andThen(function(response) + response = response:json() + + return response.items + end) +end + +function Server:getChanges() + local url = ("/changes/%f"):format(self.currentTime) + + return self.http:get(url) + :andThen(function(response) + response = response:json() + + self.currentTime = response.currentTime + + return response.changes + end) +end + +return Server diff --git a/plugin/src/Version.lua b/plugin/src/Version.lua new file mode 100644 index 00000000..24483fa0 --- /dev/null +++ b/plugin/src/Version.lua @@ -0,0 +1,40 @@ +local function compare(a, b) + if a > b then + return 1 + elseif a < b then + return -1 + end + + return 0 +end + +local Version = {} + +--[[ + Compares two versions of the form {major, minor, revision}. + + If a is newer than b, 1. + If a is older than b, -1. + If a and b are the same, 0. +]] +function Version.compare(a, b) + local major = compare(a[1], b[1]) + local minor = compare(a[2] or 0, b[2] or 0) + local revision = compare(a[3] or 0, b[3] or 0) + + if major ~= 0 then + return major + end + + if minor ~= 0 then + return minor + end + + return revision +end + +function Version.display(version) + return table.concat(version, ".") +end + +return Version diff --git a/plugin/src/Version.spec.lua b/plugin/src/Version.spec.lua new file mode 100644 index 00000000..7be002e9 --- /dev/null +++ b/plugin/src/Version.spec.lua @@ -0,0 +1,28 @@ +return function() + local Version = require(script.Parent.Version) + + it("should compare equal versions", function() + expect(Version.compare({1, 2, 3}, {1, 2, 3})).to.equal(0) + expect(Version.compare({0, 4, 0}, {0, 4})).to.equal(0) + expect(Version.compare({0, 0, 123}, {0, 0, 123})).to.equal(0) + expect(Version.compare({26}, {26})).to.equal(0) + expect(Version.compare({26, 42}, {26, 42})).to.equal(0) + expect(Version.compare({1, 0, 0}, {1})).to.equal(0) + end) + + it("should compare newer, older versions", function() + expect(Version.compare({1}, {0})).to.equal(1) + expect(Version.compare({1, 1}, {1, 0})).to.equal(1) + end) + + it("should compare different major versions", function() + expect(Version.compare({1, 3, 2}, {2, 2, 1})).to.equal(-1) + expect(Version.compare({1, 2}, {2, 1})).to.equal(-1) + expect(Version.compare({1}, {2})).to.equal(-1) + end) + + it("should compare different minor versions", function() + expect(Version.compare({1, 2, 3}, {1, 3, 2})).to.equal(-1) + expect(Version.compare({50, 1}, {50, 2})).to.equal(-1) + end) +end diff --git a/plugin/src/runTests.lua b/plugin/src/runTests.lua new file mode 100644 index 00000000..9b1da76e --- /dev/null +++ b/plugin/src/runTests.lua @@ -0,0 +1,4 @@ +return function() + local TestEZ = require(script.Parent.Parent.TestEZ) + TestEZ.TestBootstrap:run(script.Parent) +end diff --git a/plugin/tests/runTests.server.lua b/plugin/tests/runTests.server.lua new file mode 100644 index 00000000..be1a94d5 --- /dev/null +++ b/plugin/tests/runTests.server.lua @@ -0,0 +1,2 @@ +local TestEZ = require(game.ReplicatedStorage.TestEZ) +TestEZ.TestBootstrap:run(game.ReplicatedStorage.Rojo.plugin) diff --git a/server/.editorconfig b/server/.editorconfig new file mode 100644 index 00000000..1be11156 --- /dev/null +++ b/server/.editorconfig @@ -0,0 +1,5 @@ +[*.rs] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true \ No newline at end of file diff --git a/.gitignore b/server/.gitignore similarity index 100% rename from .gitignore rename to server/.gitignore diff --git a/Cargo.lock b/server/Cargo.lock similarity index 100% rename from Cargo.lock rename to server/Cargo.lock diff --git a/Cargo.toml b/server/Cargo.toml similarity index 100% rename from Cargo.toml rename to server/Cargo.toml diff --git a/rustfmt.toml b/server/rustfmt.toml similarity index 100% rename from rustfmt.toml rename to server/rustfmt.toml diff --git a/src/bin.rs b/server/src/bin.rs similarity index 100% rename from src/bin.rs rename to server/src/bin.rs diff --git a/src/commands/init.rs b/server/src/commands/init.rs similarity index 100% rename from src/commands/init.rs rename to server/src/commands/init.rs diff --git a/src/commands/mod.rs b/server/src/commands/mod.rs similarity index 100% rename from src/commands/mod.rs rename to server/src/commands/mod.rs diff --git a/src/commands/serve.rs b/server/src/commands/serve.rs similarity index 100% rename from src/commands/serve.rs rename to server/src/commands/serve.rs diff --git a/src/core.rs b/server/src/core.rs similarity index 100% rename from src/core.rs rename to server/src/core.rs diff --git a/src/pathext.rs b/server/src/pathext.rs similarity index 100% rename from src/pathext.rs rename to server/src/pathext.rs diff --git a/src/plugin.rs b/server/src/plugin.rs similarity index 100% rename from src/plugin.rs rename to server/src/plugin.rs diff --git a/src/plugins/default_plugin.rs b/server/src/plugins/default_plugin.rs similarity index 100% rename from src/plugins/default_plugin.rs rename to server/src/plugins/default_plugin.rs diff --git a/src/plugins/json_model_plugin.rs b/server/src/plugins/json_model_plugin.rs similarity index 100% rename from src/plugins/json_model_plugin.rs rename to server/src/plugins/json_model_plugin.rs diff --git a/src/plugins/mod.rs b/server/src/plugins/mod.rs similarity index 100% rename from src/plugins/mod.rs rename to server/src/plugins/mod.rs diff --git a/src/plugins/script_plugin.rs b/server/src/plugins/script_plugin.rs similarity index 100% rename from src/plugins/script_plugin.rs rename to server/src/plugins/script_plugin.rs diff --git a/src/project.rs b/server/src/project.rs similarity index 100% rename from src/project.rs rename to server/src/project.rs diff --git a/src/rbx.rs b/server/src/rbx.rs similarity index 100% rename from src/rbx.rs rename to server/src/rbx.rs diff --git a/src/vfs/mod.rs b/server/src/vfs/mod.rs similarity index 100% rename from src/vfs/mod.rs rename to server/src/vfs/mod.rs diff --git a/src/vfs/vfs_item.rs b/server/src/vfs/vfs_item.rs similarity index 100% rename from src/vfs/vfs_item.rs rename to server/src/vfs/vfs_item.rs diff --git a/src/vfs/vfs_session.rs b/server/src/vfs/vfs_session.rs similarity index 100% rename from src/vfs/vfs_session.rs rename to server/src/vfs/vfs_session.rs diff --git a/src/vfs/vfs_watcher.rs b/server/src/vfs/vfs_watcher.rs similarity index 100% rename from src/vfs/vfs_watcher.rs rename to server/src/vfs/vfs_watcher.rs diff --git a/src/web.rs b/server/src/web.rs similarity index 100% rename from src/web.rs rename to server/src/web.rs