mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Merge 23fdad2e62 into 988efb45b1
This commit is contained in:
commit
11edc6ec65
18 changed files with 712 additions and 36 deletions
|
|
@ -31,6 +31,7 @@ Making a new release? Simply add the new header with the version and date undern
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
* Added `--base` flag to `rojo build` and `rojo upload` for merging a Rojo project into an existing Roblox file ([#1226])
|
||||||
* `inf` and `nan` values in properties are now synced ([#1176])
|
* `inf` and `nan` values in properties are now synced ([#1176])
|
||||||
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
|
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
|
||||||
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
|
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
|
||||||
|
|
@ -48,6 +49,7 @@ Making a new release? Simply add the new header with the version and date undern
|
||||||
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211
|
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211
|
||||||
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215
|
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215
|
||||||
[#1217]: https://github.com/rojo-rbx/rojo/pull/1217
|
[#1217]: https://github.com/rojo-rbx/rojo/pull/1217
|
||||||
|
[#1226]: https://github.com/rojo-rbx/rojo/pull/1226
|
||||||
|
|
||||||
## [7.7.0-rc.1] (November 27th, 2025)
|
## [7.7.0-rc.1] (November 27th, 2025)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
source: tests/tests/build.rs
|
||||||
|
assertion_line: 112
|
||||||
|
expression: contents
|
||||||
|
---
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="ReplicatedStorage" referent="0">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">ReplicatedStorage</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="ModuleScript" referent="1">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Hello</string>
|
||||||
|
<string name="Source"><![CDATA[-- hello from rojo
|
||||||
|
]]></string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
<Item class="Workspace" referent="2">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Workspace</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="Part" referent="3">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">PreservedPart</string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
source: tests/tests/build.rs
|
||||||
|
assertion_line: 112
|
||||||
|
expression: contents
|
||||||
|
---
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="Folder" referent="0">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">BaseFolder</string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
<Item class="Folder" referent="1">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">base_merge_model</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="ModuleScript" referent="2">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Child</string>
|
||||||
|
<string name="Source"><![CDATA[-- model child
|
||||||
|
]]></string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
---
|
||||||
|
source: tests/tests/build.rs
|
||||||
|
assertion_line: 112
|
||||||
|
expression: contents
|
||||||
|
---
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="ReplicatedStorage" referent="0">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">ReplicatedStorage</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="ModuleScript" referent="1">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">ExistingScript</string>
|
||||||
|
<string name="Source">-- existing</string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
<Item class="ModuleScript" referent="2">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Hello</string>
|
||||||
|
<string name="Source"><![CDATA[-- hello from rojo
|
||||||
|
]]></string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
<Item class="Workspace" referent="3">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Workspace</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="Part" referent="4">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">BasePart</string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
23
rojo-test/build-tests/base_merge_delete/base.rbxlx
Normal file
23
rojo-test/build-tests/base_merge_delete/base.rbxlx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="ReplicatedStorage" referent="RBX1">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">ReplicatedStorage</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="ModuleScript" referent="RBX2">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">ShouldBeDeleted</string>
|
||||||
|
<ProtectedString name="Source"><![CDATA[-- this should be removed]]></ProtectedString>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
<Item class="Workspace" referent="RBX3">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Workspace</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="Part" referent="RBX4">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">PreservedPart</string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "base_merge_delete",
|
||||||
|
"tree": {
|
||||||
|
"$className": "DataModel",
|
||||||
|
"ReplicatedStorage": {
|
||||||
|
"$path": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
rojo-test/build-tests/base_merge_delete/src/Hello.luau
Normal file
1
rojo-test/build-tests/base_merge_delete/src/Hello.luau
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
-- hello from rojo
|
||||||
7
rojo-test/build-tests/base_merge_model/base.rbxmx
Normal file
7
rojo-test/build-tests/base_merge_model/base.rbxmx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="Folder" referent="RBX1">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">BaseFolder</string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "base_merge_model",
|
||||||
|
"tree": {
|
||||||
|
"$path": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
rojo-test/build-tests/base_merge_model/src/Child.luau
Normal file
1
rojo-test/build-tests/base_merge_model/src/Child.luau
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
-- model child
|
||||||
23
rojo-test/build-tests/base_merge_place/base.rbxlx
Normal file
23
rojo-test/build-tests/base_merge_place/base.rbxlx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="ReplicatedStorage" referent="RBX1">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">ReplicatedStorage</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="ModuleScript" referent="RBX2">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">ExistingScript</string>
|
||||||
|
<ProtectedString name="Source"><![CDATA[-- existing]]></ProtectedString>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
<Item class="Workspace" referent="RBX3">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Workspace</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="Part" referent="RBX4">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">BasePart</string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
11
rojo-test/build-tests/base_merge_place/default.project.json
Normal file
11
rojo-test/build-tests/base_merge_place/default.project.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"name": "base_merge_place",
|
||||||
|
"tree": {
|
||||||
|
"$className": "DataModel",
|
||||||
|
"ReplicatedStorage": {
|
||||||
|
"Hello": {
|
||||||
|
"$path": "src/Hello.luau"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
rojo-test/build-tests/base_merge_place/src/Hello.luau
Normal file
1
rojo-test/build-tests/base_merge_place/src/Hello.luau
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
-- hello from rojo
|
||||||
|
|
@ -11,7 +11,7 @@ use memofs::Vfs;
|
||||||
use roblox_install::RobloxStudio;
|
use roblox_install::RobloxStudio;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
use crate::serve_session::ServeSession;
|
use crate::{merge_base, serve_session::ServeSession};
|
||||||
|
|
||||||
use super::resolve_path;
|
use super::resolve_path;
|
||||||
|
|
||||||
|
|
@ -39,6 +39,13 @@ pub struct BuildCommand {
|
||||||
#[clap(long, short, conflicts_with = "output")]
|
#[clap(long, short, conflicts_with = "output")]
|
||||||
pub plugin: Option<PathBuf>,
|
pub plugin: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Path to a base Roblox file (.rbxl, .rbxlx, .rbxm, .rbxmx) to merge
|
||||||
|
/// the project into. The Rojo project tree is merged into this file so
|
||||||
|
/// that the output contains both the base content and the project's
|
||||||
|
/// scripts/configuration.
|
||||||
|
#[clap(long)]
|
||||||
|
pub base: Option<PathBuf>,
|
||||||
|
|
||||||
/// Whether to automatically rebuild when any input files change.
|
/// Whether to automatically rebuild when any input files change.
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub watch: bool,
|
pub watch: bool,
|
||||||
|
|
@ -84,7 +91,7 @@ impl BuildCommand {
|
||||||
let session = ServeSession::new(vfs, project_path)?;
|
let session = ServeSession::new(vfs, project_path)?;
|
||||||
let mut cursor = session.message_queue().cursor();
|
let mut cursor = session.message_queue().cursor();
|
||||||
|
|
||||||
write_model(&session, &output_path, output_kind)?;
|
write_model(&session, &output_path, output_kind, self.base.as_deref())?;
|
||||||
|
|
||||||
if self.watch {
|
if self.watch {
|
||||||
let rt = Runtime::new().unwrap();
|
let rt = Runtime::new().unwrap();
|
||||||
|
|
@ -94,7 +101,7 @@ impl BuildCommand {
|
||||||
let (new_cursor, _patch_set) = rt.block_on(receiver).unwrap();
|
let (new_cursor, _patch_set) = rt.block_on(receiver).unwrap();
|
||||||
cursor = new_cursor;
|
cursor = new_cursor;
|
||||||
|
|
||||||
write_model(&session, &output_path, output_kind)?;
|
write_model(&session, &output_path, output_kind, self.base.as_deref())?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,15 +162,40 @@ fn write_model(
|
||||||
session: &ServeSession,
|
session: &ServeSession,
|
||||||
output: &Path,
|
output: &Path,
|
||||||
output_kind: OutputKind,
|
output_kind: OutputKind,
|
||||||
|
base_path: Option<&Path>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
println!("Building project '{}'", session.project_name());
|
println!("Building project '{}'", session.project_name());
|
||||||
|
|
||||||
let tree = session.tree();
|
let tree = session.tree();
|
||||||
let root_id = tree.get_root_id();
|
|
||||||
|
|
||||||
log::trace!("Opening output file for write");
|
log::trace!("Opening output file for write");
|
||||||
let mut file = BufWriter::new(File::create(output)?);
|
let mut file = BufWriter::new(File::create(output)?);
|
||||||
|
|
||||||
|
if let Some(base_path) = base_path {
|
||||||
|
let base_path = super::resolve_path(base_path);
|
||||||
|
|
||||||
|
log::trace!("Reading base file: {}", base_path.display());
|
||||||
|
let base_dom = merge_base::read_base_dom(&base_path)?;
|
||||||
|
|
||||||
|
log::trace!("Merging Rojo project into base file");
|
||||||
|
let merged_dom = merge_base::merge_rojo_into_base(base_dom, &tree)?;
|
||||||
|
|
||||||
|
// The merged DOM's root is always the WeakDom synthetic root
|
||||||
|
// (DataModel). For both place and model output, we encode its
|
||||||
|
// children — the services/instances — not the root itself.
|
||||||
|
let encode_ids = merged_dom.root().children().to_vec();
|
||||||
|
|
||||||
|
match output_kind {
|
||||||
|
OutputKind::Rbxl | OutputKind::Rbxm => {
|
||||||
|
rbx_binary::to_writer(&mut file, &merged_dom, &encode_ids)?;
|
||||||
|
}
|
||||||
|
OutputKind::Rbxlx | OutputKind::Rbxmx => {
|
||||||
|
rbx_xml::to_writer(&mut file, &merged_dom, &encode_ids, xml_encode_config())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let root_id = tree.get_root_id();
|
||||||
|
|
||||||
match output_kind {
|
match output_kind {
|
||||||
OutputKind::Rbxm => {
|
OutputKind::Rbxm => {
|
||||||
rbx_binary::to_writer(&mut file, tree.inner(), &[root_id])?;
|
rbx_binary::to_writer(&mut file, tree.inner(), &[root_id])?;
|
||||||
|
|
@ -187,7 +219,13 @@ fn write_model(
|
||||||
let root_instance = tree.get_instance(root_id).unwrap();
|
let root_instance = tree.get_instance(root_id).unwrap();
|
||||||
let top_level_ids = root_instance.children();
|
let top_level_ids = root_instance.children();
|
||||||
|
|
||||||
rbx_xml::to_writer(&mut file, tree.inner(), top_level_ids, xml_encode_config())?;
|
rbx_xml::to_writer(
|
||||||
|
&mut file,
|
||||||
|
tree.inner(),
|
||||||
|
top_level_ids,
|
||||||
|
xml_encode_config(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::path::PathBuf;
|
use std::{mem::forget, path::PathBuf};
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
use anyhow::{bail, Context};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
@ -8,7 +8,7 @@ use reqwest::{
|
||||||
StatusCode,
|
StatusCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{auth_cookie::get_auth_cookie, serve_session::ServeSession};
|
use crate::{auth_cookie::get_auth_cookie, merge_base, serve_session::ServeSession};
|
||||||
|
|
||||||
use super::resolve_path;
|
use super::resolve_path;
|
||||||
|
|
||||||
|
|
@ -19,6 +19,13 @@ pub struct UploadCommand {
|
||||||
#[clap(default_value = "")]
|
#[clap(default_value = "")]
|
||||||
pub project: PathBuf,
|
pub project: PathBuf,
|
||||||
|
|
||||||
|
/// Path to a base Roblox file (.rbxl, .rbxlx, .rbxm, .rbxmx) to merge
|
||||||
|
/// with the project before uploading. When provided, the Rojo project
|
||||||
|
/// tree is merged into this file so that the upload contains both the
|
||||||
|
/// base content and the project's scripts.
|
||||||
|
#[clap(long)]
|
||||||
|
pub base: Option<PathBuf>,
|
||||||
|
|
||||||
/// Authenication cookie to use. If not specified, Rojo will attempt to find one from the system automatically.
|
/// Authenication cookie to use. If not specified, Rojo will attempt to find one from the system automatically.
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub cookie: Option<String>,
|
pub cookie: Option<String>,
|
||||||
|
|
@ -44,6 +51,23 @@ impl UploadCommand {
|
||||||
|
|
||||||
let session = ServeSession::new(vfs, project_path)?;
|
let session = ServeSession::new(vfs, project_path)?;
|
||||||
|
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
if let Some(base_path) = &self.base {
|
||||||
|
let base_path = resolve_path(base_path);
|
||||||
|
|
||||||
|
log::trace!("Reading base file: {}", base_path.display());
|
||||||
|
let base_dom = merge_base::read_base_dom(&base_path)?;
|
||||||
|
|
||||||
|
log::trace!("Merging Rojo project into base file");
|
||||||
|
let tree = session.tree();
|
||||||
|
let merged_dom = merge_base::merge_rojo_into_base(base_dom, &tree)?;
|
||||||
|
|
||||||
|
let encode_ids = merged_dom.root().children().to_vec();
|
||||||
|
|
||||||
|
log::trace!("Encoding merged binary model");
|
||||||
|
rbx_binary::to_writer(&mut buffer, &merged_dom, &encode_ids)?;
|
||||||
|
} else {
|
||||||
let tree = session.tree();
|
let tree = session.tree();
|
||||||
let inner_tree = tree.inner();
|
let inner_tree = tree.inner();
|
||||||
let root = inner_tree.root();
|
let root = inner_tree.root();
|
||||||
|
|
@ -53,10 +77,13 @@ impl UploadCommand {
|
||||||
_ => vec![root.referent()],
|
_ => vec![root.referent()],
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut buffer = Vec::new();
|
|
||||||
|
|
||||||
log::trace!("Encoding binary model");
|
log::trace!("Encoding binary model");
|
||||||
rbx_binary::to_writer(&mut buffer, tree.inner(), &encode_ids)?;
|
rbx_binary::to_writer(&mut buffer, inner_tree, &encode_ids)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid dropping ServeSession: it's potentially very expensive to
|
||||||
|
// drop and we're about to exit anyway.
|
||||||
|
forget(session);
|
||||||
|
|
||||||
match (self.cookie, self.api_key, self.universe_id) {
|
match (self.cookie, self.api_key, self.universe_id) {
|
||||||
(cookie, None, universe) => {
|
(cookie, None, universe) => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ mod change_processor;
|
||||||
mod glob;
|
mod glob;
|
||||||
mod json;
|
mod json;
|
||||||
mod lua_ast;
|
mod lua_ast;
|
||||||
|
mod merge_base;
|
||||||
mod message_queue;
|
mod message_queue;
|
||||||
mod multimap;
|
mod multimap;
|
||||||
mod path_serializer;
|
mod path_serializer;
|
||||||
|
|
|
||||||
371
src/merge_base.rs
Normal file
371
src/merge_base.rs
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
//! Shared logic for merging a Rojo project tree into a base Roblox file.
|
||||||
|
//!
|
||||||
|
//! Used by both `rojo build --base` and `rojo upload --base`.
|
||||||
|
|
||||||
|
use std::{io::BufReader, path::Path};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context};
|
||||||
|
use fs_err::File;
|
||||||
|
use rbx_dom_weak::{types::Ref, Instance, WeakDom};
|
||||||
|
|
||||||
|
use crate::snapshot::RojoTree;
|
||||||
|
|
||||||
|
/// A pair of (base instance, Rojo instance) whose children still need merging.
|
||||||
|
struct MergePair {
|
||||||
|
base_ref: Ref,
|
||||||
|
rojo_ref: Ref,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a base Roblox file (.rbxl, .rbxlx, .rbxm, .rbxmx) into a WeakDom.
|
||||||
|
pub fn read_base_dom(path: &Path) -> anyhow::Result<WeakDom> {
|
||||||
|
let extension = path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.context("Base file must have a .rbxl, .rbxlx, .rbxm, or .rbxmx extension")?;
|
||||||
|
|
||||||
|
let content = BufReader::new(
|
||||||
|
File::open(path)
|
||||||
|
.with_context(|| format!("Could not open base file at {}", path.display()))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
match extension {
|
||||||
|
"rbxl" | "rbxm" => rbx_binary::from_reader(content)
|
||||||
|
.with_context(|| format!("Could not deserialize binary file at {}", path.display())),
|
||||||
|
"rbxlx" | "rbxmx" => {
|
||||||
|
let config = rbx_xml::DecodeOptions::new()
|
||||||
|
.property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown);
|
||||||
|
rbx_xml::from_reader(content, config)
|
||||||
|
.with_context(|| format!("Could not deserialize XML file at {}", path.display()))
|
||||||
|
}
|
||||||
|
_ => bail!(
|
||||||
|
"Base file must be .rbxl, .rbxlx, .rbxm, or .rbxmx, got .{}",
|
||||||
|
extension
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges the Rojo project tree into a base WeakDom.
|
||||||
|
///
|
||||||
|
/// For place projects (root class is "DataModel"), iteratively merges Rojo's
|
||||||
|
/// children into matching base children by name and className. For model
|
||||||
|
/// projects, clones the entire Rojo tree as a child of the base root.
|
||||||
|
///
|
||||||
|
/// The `ignore_unknown_instances` metadata on each Rojo instance controls
|
||||||
|
/// whether unmatched base children are preserved or deleted:
|
||||||
|
/// - `true` (default for project nodes without `$path`): preserve
|
||||||
|
/// - `false` (default when `$path` is set): delete
|
||||||
|
pub fn merge_rojo_into_base(mut base: WeakDom, rojo: &RojoTree) -> anyhow::Result<WeakDom> {
|
||||||
|
let rojo_root_id = rojo.get_root_id();
|
||||||
|
let rojo_inner = rojo.inner();
|
||||||
|
let rojo_root = rojo_inner
|
||||||
|
.get_by_ref(rojo_root_id)
|
||||||
|
.expect("Rojo tree should have a root");
|
||||||
|
let base_root_ref = base.root_ref();
|
||||||
|
|
||||||
|
// For model projects (root is not DataModel), clone entire Rojo tree
|
||||||
|
// as a child of the base root.
|
||||||
|
if rojo_root.class.as_str() != "DataModel" {
|
||||||
|
let cloned_ref = rojo_inner.clone_into_external(rojo_root_id, &mut base);
|
||||||
|
base.transfer_within(cloned_ref, base_root_ref);
|
||||||
|
return Ok(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For place projects: iteratively merge children using a stack.
|
||||||
|
let mut stack = vec![MergePair {
|
||||||
|
base_ref: base_root_ref,
|
||||||
|
rojo_ref: rojo_root_id,
|
||||||
|
}];
|
||||||
|
|
||||||
|
while let Some(pair) = stack.pop() {
|
||||||
|
let rojo_children: Vec<Ref> = rojo_inner
|
||||||
|
.get_by_ref(pair.rojo_ref)
|
||||||
|
.expect("Rojo instance should exist")
|
||||||
|
.children()
|
||||||
|
.to_vec();
|
||||||
|
|
||||||
|
let base_children: Vec<Ref> = base
|
||||||
|
.get_by_ref(pair.base_ref)
|
||||||
|
.expect("Base instance should exist")
|
||||||
|
.children()
|
||||||
|
.to_vec();
|
||||||
|
|
||||||
|
let mut base_matched = vec![false; base_children.len()];
|
||||||
|
|
||||||
|
for &rojo_child_ref in &rojo_children {
|
||||||
|
let rojo_child = rojo_inner
|
||||||
|
.get_by_ref(rojo_child_ref)
|
||||||
|
.expect("Rojo child should exist");
|
||||||
|
|
||||||
|
// Find the first unmatched base child with the same name+class.
|
||||||
|
let base_match = base_children
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(idx, base_child_ref)| {
|
||||||
|
if base_matched[*idx] {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let base_child = base
|
||||||
|
.get_by_ref(**base_child_ref)
|
||||||
|
.expect("Base child should exist");
|
||||||
|
base_child.name == rojo_child.name
|
||||||
|
&& base_child.class.as_str() == rojo_child.class.as_str()
|
||||||
|
});
|
||||||
|
|
||||||
|
match base_match {
|
||||||
|
Some((idx, &base_child_ref)) => {
|
||||||
|
base_matched[idx] = true;
|
||||||
|
update_properties(&mut base, base_child_ref, rojo_child);
|
||||||
|
stack.push(MergePair {
|
||||||
|
base_ref: base_child_ref,
|
||||||
|
rojo_ref: rojo_child_ref,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let cloned_ref = rojo_inner.clone_into_external(rojo_child_ref, &mut base);
|
||||||
|
base.transfer_within(cloned_ref, pair.base_ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete unmatched base children if ignore_unknown_instances is false.
|
||||||
|
let ignore_unknown = rojo
|
||||||
|
.get_metadata(pair.rojo_ref)
|
||||||
|
.map(|m| m.ignore_unknown_instances)
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if !ignore_unknown {
|
||||||
|
for (idx, &base_child_ref) in base_children.iter().enumerate() {
|
||||||
|
if !base_matched[idx] {
|
||||||
|
base.destroy(base_child_ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overlays Rojo properties onto a base instance. Properties that exist only
|
||||||
|
/// in the base instance are preserved; properties from Rojo overwrite any
|
||||||
|
/// existing base values.
|
||||||
|
fn update_properties(base: &mut WeakDom, base_ref: Ref, rojo_instance: &Instance) {
|
||||||
|
let base_instance = base
|
||||||
|
.get_by_ref_mut(base_ref)
|
||||||
|
.expect("Base instance should exist");
|
||||||
|
|
||||||
|
base_instance.name.clone_from(&rojo_instance.name);
|
||||||
|
|
||||||
|
for (prop_name, prop_value) in &rojo_instance.properties {
|
||||||
|
base_instance
|
||||||
|
.properties
|
||||||
|
.insert(*prop_name, prop_value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use crate::snapshot::{InstanceMetadata, InstanceSnapshot, RojoTree};
|
||||||
|
use rbx_dom_weak::{types::Variant, ustr, InstanceBuilder};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_adds_new_children() {
|
||||||
|
let mut base = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||||
|
base.insert(
|
||||||
|
base.root_ref(),
|
||||||
|
InstanceBuilder::new("ReplicatedStorage").with_name("ReplicatedStorage"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rojo = RojoTree::new(
|
||||||
|
InstanceSnapshot::new()
|
||||||
|
.name("Game")
|
||||||
|
.class_name("DataModel")
|
||||||
|
.metadata(InstanceMetadata::new().ignore_unknown_instances(true))
|
||||||
|
.children(vec![InstanceSnapshot::new()
|
||||||
|
.name("ReplicatedStorage")
|
||||||
|
.class_name("ReplicatedStorage")
|
||||||
|
.metadata(InstanceMetadata::new().ignore_unknown_instances(true))
|
||||||
|
.children(vec![InstanceSnapshot::new()
|
||||||
|
.name("NewScript")
|
||||||
|
.class_name("ModuleScript")])]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let merged = merge_rojo_into_base(base, &rojo).unwrap();
|
||||||
|
|
||||||
|
let root_children = merged.root().children();
|
||||||
|
assert_eq!(root_children.len(), 1);
|
||||||
|
|
||||||
|
let rs = merged.get_by_ref(root_children[0]).unwrap();
|
||||||
|
assert_eq!(rs.name, "ReplicatedStorage");
|
||||||
|
assert_eq!(rs.children().len(), 1);
|
||||||
|
|
||||||
|
let child = merged.get_by_ref(rs.children()[0]).unwrap();
|
||||||
|
assert_eq!(child.name, "NewScript");
|
||||||
|
assert_eq!(child.class.as_str(), "ModuleScript");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_deletes_unknown_when_not_ignored() {
|
||||||
|
let mut base = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||||
|
let rs_ref = base.insert(
|
||||||
|
base.root_ref(),
|
||||||
|
InstanceBuilder::new("ReplicatedStorage").with_name("ReplicatedStorage"),
|
||||||
|
);
|
||||||
|
base.insert(
|
||||||
|
rs_ref,
|
||||||
|
InstanceBuilder::new("ModuleScript").with_name("OldScript"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rojo = RojoTree::new(
|
||||||
|
InstanceSnapshot::new()
|
||||||
|
.name("Game")
|
||||||
|
.class_name("DataModel")
|
||||||
|
.metadata(InstanceMetadata::new().ignore_unknown_instances(true))
|
||||||
|
.children(vec![InstanceSnapshot::new()
|
||||||
|
.name("ReplicatedStorage")
|
||||||
|
.class_name("ReplicatedStorage")
|
||||||
|
.metadata(InstanceMetadata::new().ignore_unknown_instances(false))
|
||||||
|
.children(vec![InstanceSnapshot::new()
|
||||||
|
.name("NewScript")
|
||||||
|
.class_name("ModuleScript")])]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let merged = merge_rojo_into_base(base, &rojo).unwrap();
|
||||||
|
|
||||||
|
let root_children = merged.root().children();
|
||||||
|
let rs = merged.get_by_ref(root_children[0]).unwrap();
|
||||||
|
assert_eq!(rs.children().len(), 1, "OldScript should have been deleted");
|
||||||
|
|
||||||
|
let child = merged.get_by_ref(rs.children()[0]).unwrap();
|
||||||
|
assert_eq!(child.name, "NewScript");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_preserves_unknown_when_ignored() {
|
||||||
|
let mut base = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||||
|
let rs_ref = base.insert(
|
||||||
|
base.root_ref(),
|
||||||
|
InstanceBuilder::new("ReplicatedStorage").with_name("ReplicatedStorage"),
|
||||||
|
);
|
||||||
|
base.insert(
|
||||||
|
rs_ref,
|
||||||
|
InstanceBuilder::new("ModuleScript").with_name("OldScript"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rojo = RojoTree::new(
|
||||||
|
InstanceSnapshot::new()
|
||||||
|
.name("Game")
|
||||||
|
.class_name("DataModel")
|
||||||
|
.metadata(InstanceMetadata::new().ignore_unknown_instances(true))
|
||||||
|
.children(vec![InstanceSnapshot::new()
|
||||||
|
.name("ReplicatedStorage")
|
||||||
|
.class_name("ReplicatedStorage")
|
||||||
|
.metadata(InstanceMetadata::new().ignore_unknown_instances(true))
|
||||||
|
.children(vec![InstanceSnapshot::new()
|
||||||
|
.name("NewScript")
|
||||||
|
.class_name("ModuleScript")])]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let merged = merge_rojo_into_base(base, &rojo).unwrap();
|
||||||
|
|
||||||
|
let root_children = merged.root().children();
|
||||||
|
let rs = merged.get_by_ref(root_children[0]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rs.children().len(),
|
||||||
|
2,
|
||||||
|
"Both OldScript and NewScript should be present"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_preserves_unmanaged_services() {
|
||||||
|
let mut base = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||||
|
base.insert(
|
||||||
|
base.root_ref(),
|
||||||
|
InstanceBuilder::new("ReplicatedStorage").with_name("ReplicatedStorage"),
|
||||||
|
);
|
||||||
|
base.insert(
|
||||||
|
base.root_ref(),
|
||||||
|
InstanceBuilder::new("Workspace").with_name("Workspace"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rojo project only manages ReplicatedStorage, not Workspace
|
||||||
|
let rojo = RojoTree::new(
|
||||||
|
InstanceSnapshot::new()
|
||||||
|
.name("Game")
|
||||||
|
.class_name("DataModel")
|
||||||
|
.metadata(InstanceMetadata::new().ignore_unknown_instances(true))
|
||||||
|
.children(vec![InstanceSnapshot::new()
|
||||||
|
.name("ReplicatedStorage")
|
||||||
|
.class_name("ReplicatedStorage")
|
||||||
|
.metadata(InstanceMetadata::new().ignore_unknown_instances(true))]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let merged = merge_rojo_into_base(base, &rojo).unwrap();
|
||||||
|
|
||||||
|
let root_children = merged.root().children();
|
||||||
|
assert_eq!(
|
||||||
|
root_children.len(),
|
||||||
|
2,
|
||||||
|
"Both ReplicatedStorage and Workspace should be present"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_overlays_properties() {
|
||||||
|
let mut base = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||||
|
base.insert(
|
||||||
|
base.root_ref(),
|
||||||
|
InstanceBuilder::new("ModuleScript")
|
||||||
|
.with_name("Script")
|
||||||
|
.with_property("Source", Variant::String("-- base source".into()))
|
||||||
|
.with_property("Disabled", Variant::Bool(true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rojo = RojoTree::new(
|
||||||
|
InstanceSnapshot::new()
|
||||||
|
.name("Game")
|
||||||
|
.class_name("DataModel")
|
||||||
|
.metadata(InstanceMetadata::new().ignore_unknown_instances(true))
|
||||||
|
.children(vec![InstanceSnapshot::new()
|
||||||
|
.name("Script")
|
||||||
|
.class_name("ModuleScript")
|
||||||
|
.property(ustr("Source"), Variant::String("-- rojo source".into()))]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let merged = merge_rojo_into_base(base, &rojo).unwrap();
|
||||||
|
|
||||||
|
let script = merged.get_by_ref(merged.root().children()[0]).unwrap();
|
||||||
|
|
||||||
|
// Rojo property overwrites base
|
||||||
|
assert_eq!(
|
||||||
|
script.properties.get(&ustr("Source")),
|
||||||
|
Some(&Variant::String("-- rojo source".into()))
|
||||||
|
);
|
||||||
|
// Base-only property is preserved
|
||||||
|
assert_eq!(
|
||||||
|
script.properties.get(&ustr("Disabled")),
|
||||||
|
Some(&Variant::Bool(true))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_model_clones_into_base() {
|
||||||
|
let base = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||||
|
|
||||||
|
let rojo = RojoTree::new(
|
||||||
|
InstanceSnapshot::new()
|
||||||
|
.name("MyModel")
|
||||||
|
.class_name("Folder"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let merged = merge_rojo_into_base(base, &rojo).unwrap();
|
||||||
|
|
||||||
|
let root_children = merged.root().children();
|
||||||
|
assert_eq!(root_children.len(), 1);
|
||||||
|
|
||||||
|
let model = merged.get_by_ref(root_children[0]).unwrap();
|
||||||
|
assert_eq!(model.name, "MyModel");
|
||||||
|
assert_eq!(model.class.as_str(), "Folder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,27 @@ gen_build_tests! {
|
||||||
no_name_top_level_project,
|
no_name_top_level_project,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
macro_rules! gen_build_base_tests {
|
||||||
|
( $($test_name: ident, $base_file: expr, $output_ext: expr,)* ) => {
|
||||||
|
$(
|
||||||
|
paste::item! {
|
||||||
|
#[test]
|
||||||
|
fn [<build_ $test_name>]() {
|
||||||
|
let _ = env_logger::try_init();
|
||||||
|
|
||||||
|
run_build_base_test(stringify!($test_name), $base_file, $output_ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
gen_build_base_tests! {
|
||||||
|
base_merge_place, "base.rbxlx", "rbxlx",
|
||||||
|
base_merge_delete, "base.rbxlx", "rbxlx",
|
||||||
|
base_merge_model, "base.rbxmx", "rbxmx",
|
||||||
|
}
|
||||||
|
|
||||||
fn run_build_test(test_name: &str) {
|
fn run_build_test(test_name: &str) {
|
||||||
let working_dir = get_working_dir_path();
|
let working_dir = get_working_dir_path();
|
||||||
|
|
||||||
|
|
@ -107,3 +128,49 @@ fn run_build_test(test_name: &str) {
|
||||||
assert_snapshot!(test_name, contents);
|
assert_snapshot!(test_name, contents);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_build_base_test(test_name: &str, base_file: &str, output_ext: &str) {
|
||||||
|
let working_dir = get_working_dir_path();
|
||||||
|
|
||||||
|
let input_path = Path::new(BUILD_TESTS_PATH).join(test_name);
|
||||||
|
let base_path = input_path.join(base_file);
|
||||||
|
|
||||||
|
let output_dir = tempdir().expect("couldn't create temporary directory");
|
||||||
|
let output_path = output_dir
|
||||||
|
.path()
|
||||||
|
.join(format!("{}.{}", test_name, output_ext));
|
||||||
|
|
||||||
|
let output = Command::new(ROJO_PATH)
|
||||||
|
.args([
|
||||||
|
"build",
|
||||||
|
input_path.to_str().unwrap(),
|
||||||
|
"-o",
|
||||||
|
output_path.to_str().unwrap(),
|
||||||
|
"--base",
|
||||||
|
base_path.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.env("RUST_LOG", "error")
|
||||||
|
.current_dir(working_dir)
|
||||||
|
.output()
|
||||||
|
.expect("Couldn't start Rojo");
|
||||||
|
|
||||||
|
print!("{}", String::from_utf8_lossy(&output.stdout));
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
|
||||||
|
assert!(output.status.success(), "Rojo did not exit successfully");
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&output_path).expect("Couldn't read output file");
|
||||||
|
|
||||||
|
let mut settings = insta::Settings::new();
|
||||||
|
|
||||||
|
let snapshot_path = Path::new(BUILD_TESTS_PATH)
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join("build-test-snapshots");
|
||||||
|
|
||||||
|
settings.set_snapshot_path(snapshot_path);
|
||||||
|
|
||||||
|
settings.bind(|| {
|
||||||
|
assert_snapshot!(test_name, contents);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue