Files
rojo/src/snapshot_middleware/lua.rs
astrid 110b9f0df3 feat: resolve duplicate sibling names with incrementing suffixes
Instead of bailing when children have duplicate filesystem names,
syncback now resolves collisions by appending incrementing suffixes
(e.g. Foo, Foo1, Foo2). This handles both init-renamed children and
any other name collisions. Meta stem derivation is now path-based
to correctly handle collision suffixes and dotted names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:30:46 +01:00

663 lines
19 KiB
Rust

use std::{path::Path, str};
use anyhow::Context as _;
use memofs::Vfs;
use rbx_dom_weak::{
types::{Enum, Variant},
ustr, HashMapExt as _, UstrMap,
};
use crate::{
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot},
};
use super::{
dir::{snapshot_dir_no_meta, syncback_dir_no_meta},
meta_file::{AdjacentMetadata, DirectoryMetadata},
PathExt as _,
};
#[derive(Debug)]
pub enum ScriptType {
Server,
Client,
Module,
Plugin,
LegacyServer,
LegacyClient,
RunContextServer,
RunContextClient,
}
/// Core routine for turning Lua files into snapshots.
pub fn snapshot_lua(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
script_type: ScriptType,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let run_context_enums = &rbx_reflection_database::get()
.unwrap()
.enums
.get("RunContext")
.expect("Unable to get RunContext enums!")
.items;
let (class_name, run_context) = match script_type {
ScriptType::Server => {
if context.emit_legacy_scripts {
("Script", run_context_enums.get("Legacy"))
} else {
("Script", run_context_enums.get("Server"))
}
}
ScriptType::Client => {
if context.emit_legacy_scripts {
("LocalScript", None)
} else {
("Script", run_context_enums.get("Client"))
}
}
ScriptType::Module => ("ModuleScript", None),
ScriptType::Plugin => ("Script", run_context_enums.get("Plugin")),
ScriptType::LegacyServer => ("Script", run_context_enums.get("Legacy")),
ScriptType::LegacyClient => ("LocalScript", None),
ScriptType::RunContextServer => ("Script", run_context_enums.get("Server")),
ScriptType::RunContextClient => ("Script", run_context_enums.get("Client")),
};
let contents = vfs.read_to_string_lf_normalized(path)?;
let contents_str = contents.as_str();
let mut properties = UstrMap::with_capacity(2);
properties.insert(ustr("Source"), contents_str.into());
if let Some(run_context) = run_context {
properties.insert(
ustr("RunContext"),
Enum::from_u32(run_context.to_owned()).into(),
);
}
let mut snapshot = InstanceSnapshot::new()
.name(name)
.class_name(class_name)
.properties(properties)
.metadata(
InstanceMetadata::new()
.instigating_source(path)
.relevant_paths(vec![vfs.canonicalize(path)?])
.context(context),
);
AdjacentMetadata::read_and_apply_all(vfs, path, name, &mut snapshot)?;
Ok(Some(snapshot))
}
/// Attempts to snapshot an 'init' Lua script contained inside of a folder with
/// the given name.
///
/// Scripts named `init.lua`, `init.server.lua`, or `init.client.lua` usurp
/// their parents, which acts similarly to `__init__.py` from the Python world.
pub fn snapshot_lua_init(
context: &InstanceContext,
vfs: &Vfs,
init_path: &Path,
name: &str,
script_type: ScriptType,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let folder_path = init_path.parent().unwrap();
let dir_snapshot = snapshot_dir_no_meta(context, vfs, folder_path, name)?.unwrap();
if dir_snapshot.class_name != "Folder" {
anyhow::bail!(
"init.lua, init.server.lua, and init.client.lua can \
only be used if the instance produced by the containing \
directory would be a Folder.\n\
\n\
The directory {} turned into an instance of class {}.",
folder_path.display(),
dir_snapshot.class_name
);
}
let mut init_snapshot =
snapshot_lua(context, vfs, init_path, &dir_snapshot.name, script_type)?.unwrap();
init_snapshot.children = dir_snapshot.children;
init_snapshot.metadata = dir_snapshot.metadata;
// The directory snapshot middleware includes all possible init paths
// so we don't need to add it here.
DirectoryMetadata::read_and_apply_all(vfs, folder_path, &mut init_snapshot)?;
Ok(Some(init_snapshot))
}
pub fn syncback_lua<'sync>(
snapshot: &SyncbackSnapshot<'sync>,
) -> anyhow::Result<SyncbackReturn<'sync>> {
let new_inst = snapshot.new_inst();
let contents = if let Some(Variant::String(source)) = new_inst.properties.get(&ustr("Source")) {
source.as_bytes().to_vec()
} else {
anyhow::bail!("Scripts must have a `Source` property that is a String")
};
let mut fs_snapshot = FsSnapshot::new();
fs_snapshot.add_file(&snapshot.path, contents);
let meta = AdjacentMetadata::from_syncback_snapshot(snapshot, snapshot.path.clone())?;
if let Some(mut meta) = meta {
// Scripts have relatively few properties that we care about, so shifting
// is fine.
meta.properties.shift_remove(&ustr("Source"));
if !meta.is_empty() {
let parent_location = snapshot.path.parent_err()?;
let file_name = snapshot
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let meta_stem = file_name
.strip_suffix(".server.luau")
.or_else(|| file_name.strip_suffix(".server.lua"))
.or_else(|| file_name.strip_suffix(".client.luau"))
.or_else(|| file_name.strip_suffix(".client.lua"))
.or_else(|| file_name.strip_suffix(".plugin.luau"))
.or_else(|| file_name.strip_suffix(".plugin.lua"))
.or_else(|| file_name.strip_suffix(".luau"))
.or_else(|| file_name.strip_suffix(".lua"))
.unwrap_or(file_name);
fs_snapshot.add_file(
parent_location.join(format!("{meta_stem}.meta.json")),
serde_json::to_vec_pretty(&meta).context("cannot serialize metadata")?,
);
}
}
Ok(SyncbackReturn {
fs_snapshot,
// Scripts don't have a child!
children: Vec::new(),
removed_children: Vec::new(),
})
}
pub fn syncback_lua_init<'sync>(
script_type: ScriptType,
snapshot: &SyncbackSnapshot<'sync>,
) -> anyhow::Result<SyncbackReturn<'sync>> {
let new_inst = snapshot.new_inst();
let path = snapshot.path.join(match script_type {
ScriptType::Server => "init.server.luau",
ScriptType::Client => "init.client.luau",
ScriptType::Module => "init.luau",
_ => anyhow::bail!("syncback is not yet implemented for {script_type:?}"),
});
let contents = if let Some(Variant::String(source)) = new_inst.properties.get(&ustr("Source")) {
source.as_bytes().to_vec()
} else {
anyhow::bail!("Scripts must have a `Source` property that is a String")
};
let mut dir_syncback = syncback_dir_no_meta(snapshot)?;
dir_syncback.fs_snapshot.add_file(&path, contents);
let meta = DirectoryMetadata::from_syncback_snapshot(snapshot, path.clone())?;
if let Some(mut meta) = meta {
// Scripts have relatively few properties that we care about, so shifting
// is fine.
meta.properties.shift_remove(&ustr("Source"));
if !meta.is_empty() {
dir_syncback.fs_snapshot.add_file(
snapshot.path.join("init.meta.json"),
serde_json::to_vec_pretty(&meta)
.context("could not serialize new init.meta.json")?,
);
}
}
Ok(dir_syncback)
}
#[cfg(test)]
mod test {
use super::*;
use memofs::{InMemoryFs, VfsSnapshot};
#[test]
fn class_module_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&vfs,
Path::new("/foo.lua"),
"foo",
ScriptType::Module,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn runcontext_module_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&vfs,
Path::new("/foo.lua"),
"foo",
ScriptType::Module,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn plugin_module_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.plugin.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&vfs,
Path::new("/foo.plugin.lua"),
"foo",
ScriptType::Plugin,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn class_server_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.server.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&vfs,
Path::new("/foo.server.lua"),
"foo",
ScriptType::Server,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn runcontext_server_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.server.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&vfs,
Path::new("/foo.server.lua"),
"foo",
ScriptType::Server,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn class_client_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.client.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&vfs,
Path::new("/foo.client.lua"),
"foo",
ScriptType::Client,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn runcontext_client_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.client.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&vfs,
Path::new("/foo.client.lua"),
"foo",
ScriptType::Client,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn init_module_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/root",
VfsSnapshot::dir([("init.lua", VfsSnapshot::file("Hello!"))]),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua_init(
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&vfs,
Path::new("/root/init.lua"),
"root",
ScriptType::Module,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn init_module_from_vfs_with_meta() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/root",
VfsSnapshot::dir([
("init.lua", VfsSnapshot::file("Hello!")),
(
"init.meta.json",
VfsSnapshot::file(r#"{"id": "manually specified"}"#),
),
]),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua_init(
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&vfs,
Path::new("/root/init.lua"),
"root",
ScriptType::Module,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn class_module_with_meta() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
imfs.load_snapshot(
"/foo.meta.json",
VfsSnapshot::file(
r#"
{
"ignoreUnknownInstances": true
}
"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&vfs,
Path::new("/foo.lua"),
"foo",
ScriptType::Module,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn runcontext_module_with_meta() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
imfs.load_snapshot(
"/foo.meta.json",
VfsSnapshot::file(
r#"
{
"ignoreUnknownInstances": true
}
"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&vfs,
Path::new("/foo.lua"),
"foo",
ScriptType::Module,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn class_script_with_meta() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.server.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
imfs.load_snapshot(
"/foo.meta.json",
VfsSnapshot::file(
r#"
{
"ignoreUnknownInstances": true
}
"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&vfs,
Path::new("/foo.server.lua"),
"foo",
ScriptType::Server,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn runcontext_script_with_meta() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.server.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
imfs.load_snapshot(
"/foo.meta.json",
VfsSnapshot::file(
r#"
{
"ignoreUnknownInstances": true
}
"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&vfs,
Path::new("/foo.server.lua"),
"foo",
ScriptType::Server,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn class_script_disabled() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/bar.server.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
imfs.load_snapshot(
"/bar.meta.json",
VfsSnapshot::file(
r#"
{
"properties": {
"Disabled": true
}
}
"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(true)),
&vfs,
Path::new("/bar.server.lua"),
"bar",
ScriptType::Server,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
#[test]
fn runcontext_script_disabled() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/bar.server.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
imfs.load_snapshot(
"/bar.meta.json",
VfsSnapshot::file(
r#"
{
"properties": {
"Disabled": true
}
}
"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs);
let instance_snapshot = snapshot_lua(
&InstanceContext::with_emit_legacy_scripts(Some(false)),
&vfs,
Path::new("/bar.server.lua"),
"bar",
ScriptType::Server,
)
.unwrap()
.unwrap();
insta::with_settings!({ sort_maps => true }, {
insta::assert_yaml_snapshot!(instance_snapshot);
});
}
}