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 @@
-

+
@@ -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