mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-22 21:55:15 +00:00
Compare commits
1 Commits
master
...
18fdbce8b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 18fdbce8b0 |
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -44,13 +44,6 @@ jobs:
|
||||
with:
|
||||
name: Rojo.rbxm
|
||||
path: Rojo.rbxm
|
||||
|
||||
- name: Upload Plugin to Roblox
|
||||
env:
|
||||
RBX_API_KEY: ${{ secrets.PLUGIN_UPLOAD_TOKEN }}
|
||||
RBX_UNIVERSE_ID: ${{ vars.PLUGIN_CI_PLACE_ID }}
|
||||
RBX_PLACE_ID: ${{ vars.PLUGIN_CI_UNIVERSE_ID }}
|
||||
run: lune run upload-plugin Rojo.rbxm
|
||||
|
||||
build:
|
||||
needs: ["create-release"]
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -19,6 +19,3 @@
|
||||
[submodule "plugin/Packages/msgpack-luau"]
|
||||
path = plugin/Packages/msgpack-luau
|
||||
url = https://github.com/cipharius/msgpack-luau/
|
||||
[submodule ".lune/opencloud-execute"]
|
||||
path = .lune/opencloud-execute
|
||||
url = https://github.com/Dekkonot/opencloud-luau-execute-lune.git
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
return {
|
||||
luau = {
|
||||
languagemode = "strict",
|
||||
aliases = {
|
||||
lune = "~/.lune/.typedefs/0.10.4/",
|
||||
},
|
||||
},
|
||||
}
|
||||
Submodule .lune/opencloud-execute deleted from 8ae86dd3ad
@@ -1,51 +0,0 @@
|
||||
local args: any = ...
|
||||
assert(args, "no arguments passed to script")
|
||||
|
||||
local input: buffer = args.BinaryInput
|
||||
|
||||
local AssetService = game:GetService("AssetService")
|
||||
local SerializationService = game:GetService("SerializationService")
|
||||
local EncodingService = game:GetService("EncodingService")
|
||||
|
||||
local input_hash: buffer = EncodingService:ComputeBufferHash(input, Enum.HashAlgorithm.Sha256)
|
||||
local hex_hash: { string } = table.create(buffer.len(input_hash))
|
||||
for i = 0, buffer.len(input_hash) - 1 do
|
||||
table.insert(hex_hash, string.format("%02x", buffer.readu8(input_hash, i)))
|
||||
end
|
||||
|
||||
print(`Deserializing plugin file (size: {buffer.len(input)} bytes, hash: {table.concat(hex_hash, "")})`)
|
||||
local plugin = SerializationService:DeserializeInstancesAsync(input)[1]
|
||||
|
||||
local UploadDetails = require(plugin.UploadDetails) :: any
|
||||
local PLUGIN_ID = UploadDetails.assetId
|
||||
local PLUGIN_NAME = UploadDetails.name
|
||||
local PLUGIN_DESCRIPTION = UploadDetails.description
|
||||
local PLUGIN_CREATOR_ID = UploadDetails.creatorId
|
||||
local PLUGIN_CREATOR_TYPE = UploadDetails.creatorType
|
||||
|
||||
assert(typeof(PLUGIN_ID) == "number", "UploadDetails did not contain a number field 'assetId'")
|
||||
assert(typeof(PLUGIN_NAME) == "string", "UploadDetails did not contain a string field 'name'")
|
||||
assert(typeof(PLUGIN_DESCRIPTION) == "string", "UploadDetails did not contain a string field 'description'")
|
||||
assert(typeof(PLUGIN_CREATOR_ID) == "number", "UploadDetails did not contain a number field 'creatorId'")
|
||||
assert(typeof(PLUGIN_CREATOR_TYPE) == "string", "UploadDetails did not contain a string field 'creatorType'")
|
||||
assert(
|
||||
Enum.AssetCreatorType:FromName(PLUGIN_CREATOR_TYPE) ~= nil,
|
||||
"UploadDetails field 'creatorType' was not a valid member of Enum.AssetCreatorType"
|
||||
)
|
||||
|
||||
print(`Uploading to {PLUGIN_ID}`)
|
||||
print(`Plugin Name: {PLUGIN_NAME}`)
|
||||
print(`Plugin Description: {PLUGIN_DESCRIPTION}`)
|
||||
|
||||
local result, version_or_err = AssetService:CreateAssetVersionAsync(plugin, Enum.AssetType.Plugin, PLUGIN_ID, {
|
||||
["Name"] = PLUGIN_NAME,
|
||||
["Description"] = PLUGIN_DESCRIPTION,
|
||||
["CreatorId"] = PLUGIN_CREATOR_ID,
|
||||
["CreatorType"] = Enum.AssetCreatorType:FromName(PLUGIN_CREATOR_TYPE),
|
||||
})
|
||||
|
||||
if result ~= Enum.CreateAssetResult.Success then
|
||||
error(`Plugin failed to upload because: {result.Name} - {version_or_err}`)
|
||||
end
|
||||
|
||||
print(`Plugin uploaded successfully. New version is {version_or_err}.`)
|
||||
@@ -1,78 +0,0 @@
|
||||
local fs = require("@lune/fs")
|
||||
local process = require("@lune/process")
|
||||
local stdio = require("@lune/stdio")
|
||||
|
||||
local luau_execute = require("./opencloud-execute")
|
||||
|
||||
local UNIVERSE_ID = process.env["RBX_UNIVERSE_ID"]
|
||||
local PLACE_ID = process.env["RBX_PLACE_ID"]
|
||||
|
||||
local version_string = fs.readFile("plugin/Version.txt")
|
||||
local versions = { string.match(version_string, "^v?(%d+)%.(%d+)%.(%d+)(.*)$") }
|
||||
if versions[4] ~= "" then
|
||||
print("This release is a pre-release. Skipping uploading plugin.")
|
||||
process.exit(0)
|
||||
end
|
||||
|
||||
local plugin_path = process.args[1]
|
||||
assert(
|
||||
typeof(plugin_path) == "string",
|
||||
"no plugin path provided, expected usage is `lune run upload-plugin [PATH TO RBXM]`."
|
||||
)
|
||||
|
||||
-- For local testing
|
||||
if process.env["CI"] ~= "true" then
|
||||
local rojo = process.exec("rojo", { "build", "plugin.project.json", "--output", plugin_path })
|
||||
if not rojo.ok then
|
||||
stdio.ewrite("plugin upload failed because: could not build plugin.rbxm\n\n")
|
||||
stdio.ewrite(rojo.stderr)
|
||||
stdio.ewrite("\n")
|
||||
process.exit(1)
|
||||
end
|
||||
else
|
||||
assert(fs.isFile(plugin_path), `Plugin file did not exist at {plugin_path}`)
|
||||
end
|
||||
local plugin_content = fs.readFile(plugin_path)
|
||||
|
||||
local engine_script = fs.readFile(".lune/scripts/plugin-upload.luau")
|
||||
|
||||
print("Creating task to upload plugin")
|
||||
local task = luau_execute.create_task_latest(UNIVERSE_ID, PLACE_ID, engine_script, 300, false, plugin_content)
|
||||
|
||||
print("Waiting for task to finish")
|
||||
local success = luau_execute.await_finish(task)
|
||||
if not success then
|
||||
local error = luau_execute.get_error(task)
|
||||
assert(error, "could not fetch error from task")
|
||||
stdio.ewrite("plugin upload failed because: task did not finish successfully\n\n")
|
||||
stdio.ewrite(error.code)
|
||||
stdio.ewrite("\n")
|
||||
stdio.ewrite(error.message)
|
||||
stdio.ewrite("\n")
|
||||
process.exit(1)
|
||||
end
|
||||
|
||||
print("Output from task:\n")
|
||||
for _, log in luau_execute.get_structured_logs(task) do
|
||||
if log.messageType == "ERROR" then
|
||||
stdio.write(stdio.color("red"))
|
||||
stdio.write(log.message)
|
||||
stdio.write("\n")
|
||||
stdio.write(stdio.color("reset"))
|
||||
elseif log.messageType == "INFO" then
|
||||
stdio.write(stdio.color("cyan"))
|
||||
stdio.write(log.message)
|
||||
stdio.write("\n")
|
||||
stdio.write(stdio.color("reset"))
|
||||
elseif log.messageType == "WARNING" then
|
||||
stdio.write(stdio.color("yellow"))
|
||||
stdio.write(log.message)
|
||||
stdio.write("\n")
|
||||
stdio.write(stdio.color("reset"))
|
||||
else
|
||||
stdio.write(stdio.color("reset"))
|
||||
stdio.write(log.message)
|
||||
stdio.write("\n")
|
||||
stdio.write(stdio.color("reset"))
|
||||
end
|
||||
end
|
||||
@@ -33,6 +33,7 @@ Making a new release? Simply add the new header with the version and date undern
|
||||
|
||||
* `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])
|
||||
* Implemented support for the "name" property in meta/model JSON files. ([#1187])
|
||||
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
|
||||
* Added actors and bindable/remote event/function variants to be synced back as JSON files. ([#1199])
|
||||
* Fixed a bug where MacOS paths weren't being handled correctly. ([#1201])
|
||||
@@ -42,6 +43,7 @@ Making a new release? Simply add the new header with the version and date undern
|
||||
|
||||
[#1176]: https://github.com/rojo-rbx/rojo/pull/1176
|
||||
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179
|
||||
[#1187]: https://github.com/rojo-rbx/rojo/pull/1187
|
||||
[#1192]: https://github.com/rojo-rbx/rojo/pull/1192
|
||||
[#1199]: https://github.com/rojo-rbx/rojo/pull/1199
|
||||
[#1201]: https://github.com/rojo-rbx/rojo/pull/1201
|
||||
|
||||
1
build.rs
1
build.rs
@@ -75,7 +75,6 @@ fn main() -> Result<(), anyhow::Error> {
|
||||
"src" => snapshot_from_fs_path(&plugin_dir.join("src"))?,
|
||||
"Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?,
|
||||
"Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?,
|
||||
"UploadDetails.json" => snapshot_from_fs_path(&plugin_dir.join("UploadDetails.json"))?,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
},
|
||||
"Version": {
|
||||
"$path": "plugin/Version.txt"
|
||||
},
|
||||
"UploadDetails": {
|
||||
"$path": "plugin/UploadDetails.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"assetId": 13916111004,
|
||||
"name": "Rojo",
|
||||
"description": "The plugin portion of Rojo, a tool to enable professional tooling for Roblox developers.",
|
||||
"creatorId": 32644114,
|
||||
"creatorType": "Group"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
<Item class="Folder" referent="0">
|
||||
<Properties>
|
||||
<string name="Name">json_model_legacy_name</string>
|
||||
</Properties>
|
||||
<Item class="Folder" referent="1">
|
||||
<Properties>
|
||||
<string name="Name">Expected Name</string>
|
||||
</Properties>
|
||||
</Item>
|
||||
</Item>
|
||||
</roblox>
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
source: tests/tests/build.rs
|
||||
assertion_line: 109
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
<Item class="DataModel" referent="0">
|
||||
<Properties>
|
||||
<string name="Name">model_json_name_input</string>
|
||||
</Properties>
|
||||
<Item class="Workspace" referent="1">
|
||||
<Properties>
|
||||
<string name="Name">Workspace</string>
|
||||
<bool name="NeedsPivotMigration">false</bool>
|
||||
</Properties>
|
||||
<Item class="StringValue" referent="2">
|
||||
<Properties>
|
||||
<string name="Name">/Bar</string>
|
||||
</Properties>
|
||||
</Item>
|
||||
</Item>
|
||||
</Item>
|
||||
</roblox>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tests/tests/build.rs
|
||||
assertion_line: 108
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
<Item class="Folder" referent="0">
|
||||
<Properties>
|
||||
<string name="Name">slugified_name_roundtrip</string>
|
||||
</Properties>
|
||||
<Item class="Script" referent="1">
|
||||
<Properties>
|
||||
<string name="Name">/Script</string>
|
||||
<token name="RunContext">0</token>
|
||||
<string name="Source"><![CDATA[print("Hello world!")
|
||||
]]></string>
|
||||
</Properties>
|
||||
</Item>
|
||||
</Item>
|
||||
</roblox>
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "json_model_legacy_name",
|
||||
"tree": {
|
||||
"$path": "folder"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"Name": "Overridden Name",
|
||||
"ClassName": "Folder"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "model_json_name_input",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"Workspace": {
|
||||
"$className": "Workspace",
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "/Bar",
|
||||
"className": "StringValue"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "/Script"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
print("Hello world!")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "slugified_name_roundtrip",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "/Script"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
print("Hello world!")
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tests/rojo_test/syncback_util.rs
|
||||
assertion_line: 101
|
||||
expression: "String::from_utf8_lossy(&output.stdout)"
|
||||
---
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tests/rojo_test/syncback_util.rs
|
||||
assertion_line: 101
|
||||
expression: "String::from_utf8_lossy(&output.stdout)"
|
||||
---
|
||||
Writing default.project.json
|
||||
Writing src/Camera.rbxm
|
||||
Writing src/Terrain.rbxm
|
||||
Writing src/_Folder/init.meta.json
|
||||
Writing src/_Script.meta.json
|
||||
Writing src/_Script.server.luau
|
||||
Writing src
|
||||
Writing src/_Folder
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tests/tests/syncback.rs
|
||||
assertion_line: 31
|
||||
expression: src/foo.model.json
|
||||
---
|
||||
{
|
||||
"name": "/Bar",
|
||||
"className": "StringValue"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tests/tests/syncback.rs
|
||||
assertion_line: 31
|
||||
expression: src/_Folder.model.json
|
||||
---
|
||||
{
|
||||
"className": "Folder"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tests/tests/syncback.rs
|
||||
assertion_line: 31
|
||||
expression: src/_Folder/init.meta.json
|
||||
---
|
||||
{
|
||||
"name": "/Folder"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tests/tests/syncback.rs
|
||||
assertion_line: 31
|
||||
expression: src/_Script.meta.json
|
||||
---
|
||||
{
|
||||
"name": "/Script"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tests/tests/syncback.rs
|
||||
assertion_line: 31
|
||||
expression: src/_Script.server.luau
|
||||
---
|
||||
print("Hello world!")
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tests/tests/syncback.rs
|
||||
assertion_line: 31
|
||||
expression: src/_Script/init.meta.json
|
||||
---
|
||||
{
|
||||
"name": "/Script"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tests/tests/syncback.rs
|
||||
assertion_line: 31
|
||||
expression: src/_Script/init.server.luau
|
||||
---
|
||||
print("Hello world!")
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "model_json_name",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"Workspace": {
|
||||
"$className": "Workspace",
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "/Bar",
|
||||
"className": "StringValue"
|
||||
}
|
||||
|
||||
BIN
rojo-test/syncback-tests/model_json_name/input.rbxl
Normal file
BIN
rojo-test/syncback-tests/model_json_name/input.rbxl
Normal file
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "slugified_name",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"Workspace": {
|
||||
"$className": "Workspace",
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
rojo-test/syncback-tests/slugified_name/input.rbxl
Normal file
BIN
rojo-test/syncback-tests/slugified_name/input.rbxl
Normal file
Binary file not shown.
@@ -3,4 +3,3 @@ rojo = "rojo-rbx/rojo@7.5.1"
|
||||
selene = "Kampfkarren/selene@0.29.0"
|
||||
stylua = "JohnnyMorganz/stylua@2.1.0"
|
||||
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"
|
||||
lune = "lune-org/lune@0.10.4"
|
||||
|
||||
@@ -98,5 +98,5 @@ fn uninstall_plugin() -> anyhow::Result<()> {
|
||||
|
||||
#[test]
|
||||
fn plugin_initialize() {
|
||||
let _ = initialize_plugin().unwrap();
|
||||
assert!(initialize_plugin().is_ok())
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@ pub struct InstanceMetadata {
|
||||
/// A schema provided via a JSON file, if one exists. Will be `None` for
|
||||
/// all non-JSON middleware.
|
||||
pub schema: Option<String>,
|
||||
|
||||
/// A custom name specified via meta.json or model.json files. If present,
|
||||
/// this name will be used for the instance while the filesystem name will
|
||||
/// be slugified to remove illegal characters.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub specified_name: Option<String>,
|
||||
}
|
||||
|
||||
impl InstanceMetadata {
|
||||
@@ -82,6 +88,7 @@ impl InstanceMetadata {
|
||||
specified_id: None,
|
||||
middleware: None,
|
||||
schema: None,
|
||||
specified_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +137,13 @@ impl InstanceMetadata {
|
||||
pub fn schema(self, schema: Option<String>) -> Self {
|
||||
Self { schema, ..self }
|
||||
}
|
||||
|
||||
pub fn specified_name(self, specified_name: Option<String>) -> Self {
|
||||
Self {
|
||||
specified_name,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InstanceMetadata {
|
||||
|
||||
@@ -35,20 +35,14 @@ pub fn snapshot_json_model(
|
||||
format!("File is not a valid JSON model: {}", path.display())
|
||||
})?;
|
||||
|
||||
if let Some(top_level_name) = &instance.name {
|
||||
let new_name = format!("{}.model.json", top_level_name);
|
||||
// If the JSON has a name property, preserve it in metadata for syncback
|
||||
let specified_name = instance.name.clone();
|
||||
|
||||
log::warn!(
|
||||
"Model at path {} had a top-level Name field. \
|
||||
This field has been ignored since Rojo 6.0.\n\
|
||||
Consider removing this field and renaming the file to {}.",
|
||||
new_name,
|
||||
path.display()
|
||||
);
|
||||
// Use the name from JSON if present, otherwise fall back to filename-derived name
|
||||
if instance.name.is_none() {
|
||||
instance.name = Some(name.to_owned());
|
||||
}
|
||||
|
||||
instance.name = Some(name.to_owned());
|
||||
|
||||
let id = instance.id.take().map(RojoRef::new);
|
||||
let schema = instance.schema.take();
|
||||
|
||||
@@ -62,7 +56,8 @@ pub fn snapshot_json_model(
|
||||
.relevant_paths(vec![vfs.canonicalize(path)?])
|
||||
.context(context)
|
||||
.specified_id(id)
|
||||
.schema(schema);
|
||||
.schema(schema)
|
||||
.specified_name(specified_name);
|
||||
|
||||
Ok(Some(snapshot))
|
||||
}
|
||||
@@ -81,6 +76,7 @@ pub fn syncback_json_model<'sync>(
|
||||
// schemas will ever exist in one project for it to matter, but it
|
||||
// could have a performance cost.
|
||||
model.schema = old_inst.metadata().schema.clone();
|
||||
model.name = old_inst.metadata().specified_name.clone();
|
||||
}
|
||||
|
||||
Ok(SyncbackReturn {
|
||||
|
||||
@@ -158,8 +158,16 @@ pub fn syncback_lua<'sync>(
|
||||
|
||||
if !meta.is_empty() {
|
||||
let parent_location = snapshot.path.parent_err()?;
|
||||
let instance_name = &snapshot.new_inst().name;
|
||||
let slugified;
|
||||
let meta_name = if crate::syncback::validate_file_name(instance_name).is_err() {
|
||||
slugified = crate::syncback::slugify_name(instance_name);
|
||||
&slugified
|
||||
} else {
|
||||
instance_name
|
||||
};
|
||||
fs_snapshot.add_file(
|
||||
parent_location.join(format!("{}.meta.json", new_inst.name)),
|
||||
parent_location.join(format!("{}.meta.json", meta_name)),
|
||||
serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ use rbx_dom_weak::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
json, resolution::UnresolvedValue, snapshot::InstanceSnapshot, syncback::SyncbackSnapshot,
|
||||
json,
|
||||
resolution::UnresolvedValue,
|
||||
snapshot::InstanceSnapshot,
|
||||
syncback::{validate_file_name, SyncbackSnapshot},
|
||||
RojoRef,
|
||||
};
|
||||
|
||||
@@ -36,6 +39,9 @@ pub struct AdjacentMetadata {
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub attributes: IndexMap<String, UnresolvedValue>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub path: PathBuf,
|
||||
}
|
||||
@@ -144,6 +150,24 @@ impl AdjacentMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
let name = snapshot
|
||||
.old_inst()
|
||||
.and_then(|inst| inst.metadata().specified_name.clone())
|
||||
.or_else(|| {
|
||||
// If this is a new instance and its name is invalid for the filesystem,
|
||||
// we need to specify the name in meta.json so it can be preserved
|
||||
if snapshot.old_inst().is_none() {
|
||||
let instance_name = &snapshot.new_inst().name;
|
||||
if validate_file_name(instance_name).is_err() {
|
||||
Some(instance_name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Some(Self {
|
||||
ignore_unknown_instances: if ignore_unknown_instances {
|
||||
Some(true)
|
||||
@@ -155,6 +179,7 @@ impl AdjacentMetadata {
|
||||
path,
|
||||
id: None,
|
||||
schema,
|
||||
name,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -213,11 +238,26 @@ impl AdjacentMetadata {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_name(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> {
|
||||
if self.name.is_some() && snapshot.metadata.specified_name.is_some() {
|
||||
anyhow::bail!(
|
||||
"cannot specify a name using {} (instance has a name from somewhere else)",
|
||||
self.path.display()
|
||||
);
|
||||
}
|
||||
if let Some(name) = &self.name {
|
||||
snapshot.name = name.clone().into();
|
||||
}
|
||||
snapshot.metadata.specified_name = self.name.take();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_all(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> {
|
||||
self.apply_ignore_unknown_instances(snapshot);
|
||||
self.apply_properties(snapshot)?;
|
||||
self.apply_id(snapshot)?;
|
||||
self.apply_schema(snapshot)?;
|
||||
self.apply_name(snapshot)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -226,11 +266,13 @@ impl AdjacentMetadata {
|
||||
///
|
||||
/// - The number of properties and attributes is 0
|
||||
/// - `ignore_unknown_instances` is None
|
||||
/// - `name` is None
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.attributes.is_empty()
|
||||
&& self.properties.is_empty()
|
||||
&& self.ignore_unknown_instances.is_none()
|
||||
&& self.name.is_none()
|
||||
}
|
||||
|
||||
// TODO: Add method to allow selectively applying parts of metadata and
|
||||
@@ -262,6 +304,9 @@ pub struct DirectoryMetadata {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub class_name: Option<Ustr>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub path: PathBuf,
|
||||
}
|
||||
@@ -372,6 +417,24 @@ impl DirectoryMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
let name = snapshot
|
||||
.old_inst()
|
||||
.and_then(|inst| inst.metadata().specified_name.clone())
|
||||
.or_else(|| {
|
||||
// If this is a new instance and its name is invalid for the filesystem,
|
||||
// we need to specify the name in meta.json so it can be preserved
|
||||
if snapshot.old_inst().is_none() {
|
||||
let instance_name = &snapshot.new_inst().name;
|
||||
if validate_file_name(instance_name).is_err() {
|
||||
Some(instance_name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Some(Self {
|
||||
ignore_unknown_instances: if ignore_unknown_instances {
|
||||
Some(true)
|
||||
@@ -384,6 +447,7 @@ impl DirectoryMetadata {
|
||||
path,
|
||||
id: None,
|
||||
schema,
|
||||
name,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -393,6 +457,7 @@ impl DirectoryMetadata {
|
||||
self.apply_properties(snapshot)?;
|
||||
self.apply_id(snapshot)?;
|
||||
self.apply_schema(snapshot)?;
|
||||
self.apply_name(snapshot)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -464,17 +529,33 @@ impl DirectoryMetadata {
|
||||
snapshot.metadata.schema = self.schema.take();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_name(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> {
|
||||
if self.name.is_some() && snapshot.metadata.specified_name.is_some() {
|
||||
anyhow::bail!(
|
||||
"cannot specify a name using {} (instance has a name from somewhere else)",
|
||||
self.path.display()
|
||||
);
|
||||
}
|
||||
if let Some(name) = &self.name {
|
||||
snapshot.name = name.clone().into();
|
||||
}
|
||||
snapshot.metadata.specified_name = self.name.take();
|
||||
Ok(())
|
||||
}
|
||||
/// Returns whether the metadata is 'empty', meaning it doesn't have anything
|
||||
/// worth persisting in it. Specifically:
|
||||
///
|
||||
/// - The number of properties and attributes is 0
|
||||
/// - `ignore_unknown_instances` is None
|
||||
/// - `class_name` is either None or not Some("Folder")
|
||||
/// - `name` is None
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.attributes.is_empty()
|
||||
&& self.properties.is_empty()
|
||||
&& self.ignore_unknown_instances.is_none()
|
||||
&& self.name.is_none()
|
||||
&& if let Some(class) = &self.class_name {
|
||||
class == "Folder"
|
||||
} else {
|
||||
|
||||
@@ -8,11 +8,11 @@ use rbx_dom_weak::Instance;
|
||||
|
||||
use crate::{snapshot::InstanceWithMeta, snapshot_middleware::Middleware};
|
||||
|
||||
pub fn name_for_inst<'old>(
|
||||
pub fn name_for_inst<'a>(
|
||||
middleware: Middleware,
|
||||
new_inst: &Instance,
|
||||
old_inst: Option<InstanceWithMeta<'old>>,
|
||||
) -> anyhow::Result<Cow<'old, str>> {
|
||||
new_inst: &'a Instance,
|
||||
old_inst: Option<InstanceWithMeta<'a>>,
|
||||
) -> anyhow::Result<Cow<'a, str>> {
|
||||
if let Some(old_inst) = old_inst {
|
||||
if let Some(source) = old_inst.metadata().relevant_paths.first() {
|
||||
source
|
||||
@@ -35,14 +35,24 @@ pub fn name_for_inst<'old>(
|
||||
| Middleware::CsvDir
|
||||
| Middleware::ServerScriptDir
|
||||
| Middleware::ClientScriptDir
|
||||
| Middleware::ModuleScriptDir => Cow::Owned(new_inst.name.clone()),
|
||||
| Middleware::ModuleScriptDir => {
|
||||
if validate_file_name(&new_inst.name).is_err() {
|
||||
Cow::Owned(slugify_name(&new_inst.name))
|
||||
} else {
|
||||
Cow::Borrowed(&new_inst.name)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let extension = extension_for_middleware(middleware);
|
||||
let name = &new_inst.name;
|
||||
validate_file_name(name).with_context(|| {
|
||||
format!("name '{name}' is not legal to write to the file system")
|
||||
})?;
|
||||
Cow::Owned(format!("{name}.{extension}"))
|
||||
let slugified;
|
||||
let final_name = if validate_file_name(&new_inst.name).is_err() {
|
||||
slugified = slugify_name(&new_inst.name);
|
||||
&slugified
|
||||
} else {
|
||||
&new_inst.name
|
||||
};
|
||||
|
||||
Cow::Owned(format!("{final_name}.{extension}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -94,6 +104,39 @@ const INVALID_WINDOWS_NAMES: [&str; 22] = [
|
||||
/// in a file's name.
|
||||
const FORBIDDEN_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '|', '?', '*', '\\'];
|
||||
|
||||
/// Slugifies a name by replacing forbidden characters with underscores
|
||||
/// and ensuring the result is a valid file name
|
||||
pub fn slugify_name(name: &str) -> String {
|
||||
let mut result = String::with_capacity(name.len());
|
||||
|
||||
for ch in name.chars() {
|
||||
if FORBIDDEN_CHARS.contains(&ch) {
|
||||
result.push('_');
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Windows reserved names by appending an underscore
|
||||
let result_lower = result.to_lowercase();
|
||||
for forbidden in INVALID_WINDOWS_NAMES {
|
||||
if result_lower == forbidden.to_lowercase() {
|
||||
result.push('_');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
while result.ends_with(' ') || result.ends_with('.') {
|
||||
result.pop();
|
||||
}
|
||||
|
||||
if result.is_empty() || result.chars().all(|c| c == '_') {
|
||||
result = "instance".to_string();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Validates a provided file name to ensure it's allowed on the file system. An
|
||||
/// error is returned if the name isn't allowed, indicating why.
|
||||
/// This takes into account rules for Windows, MacOS, and Linux.
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::{
|
||||
Project,
|
||||
};
|
||||
|
||||
pub use file_names::{extension_for_middleware, name_for_inst, validate_file_name};
|
||||
pub use file_names::{extension_for_middleware, name_for_inst, slugify_name, validate_file_name};
|
||||
pub use fs_snapshot::FsSnapshot;
|
||||
pub use hash::*;
|
||||
pub use property_filter::{filter_properties, filter_properties_preallocated};
|
||||
|
||||
@@ -41,7 +41,6 @@ gen_build_tests! {
|
||||
issue_546,
|
||||
json_as_lua,
|
||||
json_model_in_folder,
|
||||
json_model_legacy_name,
|
||||
module_in_folder,
|
||||
module_init,
|
||||
nested_runcontext,
|
||||
@@ -55,6 +54,8 @@ gen_build_tests! {
|
||||
script_meta_disabled,
|
||||
server_in_folder,
|
||||
server_init,
|
||||
slugified_name_roundtrip,
|
||||
model_json_name_input,
|
||||
txt,
|
||||
txt_in_folder,
|
||||
unresolved_values,
|
||||
|
||||
@@ -86,4 +86,9 @@ syncback_tests! {
|
||||
sync_rules => ["src/module.modulescript", "src/text.text"],
|
||||
// Ensures that the `syncUnscriptable` setting works
|
||||
unscriptable_properties => ["default.project.json"],
|
||||
// Ensures that instances with names containing illegal characters get slugified filenames
|
||||
// and preserve their original names in meta.json without forcing directories for leaf scripts
|
||||
slugified_name => ["src/_Script.meta.json", "src/_Script.server.luau", "src/_Folder/init.meta.json"],
|
||||
// Ensures that .model.json files preserve the name property
|
||||
model_json_name => ["src/foo.model.json"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user