forked from rojo-rbx/rojo
Adds a CLI flag that forces syncback to use JSON representations instead of binary .rbxm files. Instances with children become directories with init.meta.json; leaf instances become .model.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
289 lines
9.5 KiB
Rust
289 lines
9.5 KiB
Rust
use std::{
|
|
io::{self, BufReader, Write as _},
|
|
mem::forget,
|
|
path::{Path, PathBuf},
|
|
time::Instant,
|
|
};
|
|
|
|
use anyhow::Context;
|
|
use clap::Parser;
|
|
use fs_err::File;
|
|
use memofs::Vfs;
|
|
use rbx_dom_weak::{InstanceBuilder, WeakDom};
|
|
use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
|
|
|
|
use crate::{
|
|
path_serializer::display_absolute,
|
|
serve_session::ServeSession,
|
|
syncback::{syncback_loop, FsSnapshot},
|
|
};
|
|
|
|
use super::{resolve_path, GlobalOptions};
|
|
|
|
const UNKNOWN_INPUT_KIND_ERR: &str = "Could not detect what kind of file was inputted. \
|
|
Expected input file to end in .rbxl, .rbxlx, .rbxm, or .rbxmx.";
|
|
|
|
/// Performs 'syncback' for the provided project, using the `input` file
|
|
/// given.
|
|
///
|
|
/// Syncback exists to convert Roblox files into a Rojo project automatically.
|
|
/// It uses the project.json file provided to traverse the Roblox file passed as
|
|
/// to serialize Instances to the file system in a format that Rojo understands.
|
|
///
|
|
/// To ease programmatic use, this command pipes all normal output to stderr.
|
|
#[derive(Debug, Parser)]
|
|
pub struct SyncbackCommand {
|
|
/// Path to the project to sync back to.
|
|
#[clap(default_value = "")]
|
|
pub project: PathBuf,
|
|
|
|
/// Path to the Roblox file to pull Instances from.
|
|
#[clap(long, short)]
|
|
pub input: PathBuf,
|
|
|
|
/// If provided, a list all of the files and directories that will be
|
|
/// added or removed is emitted into stdout.
|
|
#[clap(long, short)]
|
|
pub list: bool,
|
|
|
|
/// If provided, syncback will not actually write anything to the file
|
|
/// system. The command will otherwise run normally.
|
|
#[clap(long)]
|
|
pub dry_run: bool,
|
|
|
|
/// If provided, the prompt for writing to the file system is skipped.
|
|
#[clap(long, short = 'y')]
|
|
pub non_interactive: bool,
|
|
|
|
/// If provided, forces syncback to use JSON model files instead of binary
|
|
/// .rbxm files for instances that would otherwise serialize as binary.
|
|
#[clap(long)]
|
|
pub dangerously_force_json: bool,
|
|
}
|
|
|
|
impl SyncbackCommand {
|
|
pub fn run(&self, global: GlobalOptions) -> anyhow::Result<()> {
|
|
let path_old = resolve_path(&self.project);
|
|
let path_new = resolve_path(&self.input);
|
|
|
|
let input_kind = FileKind::from_path(&path_new).context(UNKNOWN_INPUT_KIND_ERR)?;
|
|
let dom_start_timer = Instant::now();
|
|
let dom_new = read_dom(&path_new, input_kind)?;
|
|
log::debug!(
|
|
"Finished opening file in {:0.02}s",
|
|
dom_start_timer.elapsed().as_secs_f32()
|
|
);
|
|
|
|
let vfs = Vfs::new_default();
|
|
vfs.set_watch_enabled(false);
|
|
|
|
let project_start_timer = Instant::now();
|
|
let session_old = ServeSession::new(vfs, path_old.clone())?;
|
|
log::debug!(
|
|
"Finished opening project in {:0.02}s",
|
|
project_start_timer.elapsed().as_secs_f32()
|
|
);
|
|
|
|
let mut dom_old = session_old.tree();
|
|
|
|
log::debug!("Old root: {}", dom_old.inner().root().class);
|
|
log::debug!("New root: {}", dom_new.root().class);
|
|
|
|
if log::log_enabled!(log::Level::Trace) {
|
|
log::trace!("Children of old root:");
|
|
for child in dom_old.inner().root().children() {
|
|
let inst = dom_old.get_instance(*child).unwrap();
|
|
log::trace!("{} (class: {})", inst.name(), inst.class_name());
|
|
}
|
|
log::trace!("Children of new root:");
|
|
for child in dom_new.root().children() {
|
|
let inst = dom_new.get_by_ref(*child).unwrap();
|
|
log::trace!("{} (class: {})", inst.name, inst.class);
|
|
}
|
|
}
|
|
|
|
let syncback_timer = Instant::now();
|
|
eprintln!("Beginning syncback...");
|
|
let snapshot = syncback_loop(
|
|
session_old.vfs(),
|
|
&mut dom_old,
|
|
dom_new,
|
|
session_old.root_project(),
|
|
self.dangerously_force_json,
|
|
)?;
|
|
log::debug!(
|
|
"Syncback finished in {:.02}s!",
|
|
syncback_timer.elapsed().as_secs_f32()
|
|
);
|
|
|
|
let base_path = session_old.root_project().folder_location();
|
|
if self.list {
|
|
list_files(&snapshot, global.color.into(), base_path)?;
|
|
}
|
|
|
|
if !self.dry_run {
|
|
if !self.non_interactive {
|
|
eprintln!(
|
|
"Would write {} files/folders and remove {} files/folders.",
|
|
snapshot.added_paths().len(),
|
|
snapshot.removed_paths().len()
|
|
);
|
|
eprint!("Is this okay? (Y/N): ");
|
|
io::stderr().flush()?;
|
|
let mut line = String::with_capacity(1);
|
|
io::stdin().read_line(&mut line)?;
|
|
line = line.trim().to_lowercase();
|
|
if line != "y" {
|
|
eprintln!("Aborting due to user input!");
|
|
return Ok(());
|
|
}
|
|
}
|
|
eprintln!("Writing to the file system...");
|
|
snapshot.write_to_vfs(base_path, session_old.vfs())?;
|
|
eprintln!("Finished syncback.")
|
|
} else {
|
|
eprintln!(
|
|
"Would write {} files/folders and remove {} files/folders.",
|
|
snapshot.added_paths().len(),
|
|
snapshot.removed_paths().len()
|
|
);
|
|
eprintln!("Aborting before writing to file system due to `--dry-run`");
|
|
}
|
|
|
|
// It is potentially prohibitively expensive to drop a ServeSession,
|
|
// and the program is about to exit anyway so we're just going to forget
|
|
// about it.
|
|
drop(dom_old);
|
|
forget(session_old);
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn read_dom(path: &Path, file_kind: FileKind) -> anyhow::Result<WeakDom> {
|
|
let content = BufReader::new(File::open(path)?);
|
|
match file_kind {
|
|
FileKind::Rbxl => rbx_binary::from_reader(content).with_context(|| {
|
|
format!(
|
|
"Could not deserialize binary place file at {}",
|
|
path.display()
|
|
)
|
|
}),
|
|
FileKind::Rbxlx => rbx_xml::from_reader(content, xml_decode_config())
|
|
.with_context(|| format!("Could not deserialize XML place file at {}", path.display())),
|
|
FileKind::Rbxm => {
|
|
let temp_tree = rbx_binary::from_reader(content).with_context(|| {
|
|
format!(
|
|
"Could not deserialize binary place file at {}",
|
|
path.display()
|
|
)
|
|
})?;
|
|
|
|
process_model_dom(temp_tree)
|
|
}
|
|
FileKind::Rbxmx => {
|
|
let temp_tree =
|
|
rbx_xml::from_reader(content, xml_decode_config()).with_context(|| {
|
|
format!("Could not deserialize XML model file at {}", path.display())
|
|
})?;
|
|
process_model_dom(temp_tree)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn process_model_dom(dom: WeakDom) -> anyhow::Result<WeakDom> {
|
|
let temp_children = dom.root().children();
|
|
if temp_children.len() == 1 {
|
|
let real_root = dom.get_by_ref(temp_children[0]).unwrap();
|
|
let mut new_tree = WeakDom::new(InstanceBuilder::new(real_root.class));
|
|
for (name, property) in &real_root.properties {
|
|
new_tree
|
|
.root_mut()
|
|
.properties
|
|
.insert(*name, property.to_owned());
|
|
}
|
|
|
|
let children = dom.clone_multiple_into_external(real_root.children(), &mut new_tree);
|
|
for child in children {
|
|
new_tree.transfer_within(child, new_tree.root_ref());
|
|
}
|
|
Ok(new_tree)
|
|
} else {
|
|
anyhow::bail!(
|
|
"Rojo does not currently support models with more \
|
|
than one Instance at the Root!"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn xml_decode_config() -> rbx_xml::DecodeOptions<'static> {
|
|
rbx_xml::DecodeOptions::new().property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown)
|
|
}
|
|
|
|
/// The different kinds of input that Rojo can syncback.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum FileKind {
|
|
/// An XML model file.
|
|
Rbxmx,
|
|
|
|
/// An XML place file.
|
|
Rbxlx,
|
|
|
|
/// A binary model file.
|
|
Rbxm,
|
|
|
|
/// A binary place file.
|
|
Rbxl,
|
|
}
|
|
|
|
impl FileKind {
|
|
fn from_path(output: &Path) -> Option<FileKind> {
|
|
let extension = output.extension()?.to_str()?;
|
|
|
|
match extension {
|
|
"rbxlx" => Some(FileKind::Rbxlx),
|
|
"rbxmx" => Some(FileKind::Rbxmx),
|
|
"rbxl" => Some(FileKind::Rbxl),
|
|
"rbxm" => Some(FileKind::Rbxm),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn list_files(snapshot: &FsSnapshot, color: ColorChoice, base_path: &Path) -> io::Result<()> {
|
|
let no_color = ColorSpec::new();
|
|
let mut add_color = ColorSpec::new();
|
|
add_color.set_fg(Some(Color::Green));
|
|
let mut remove_color = ColorSpec::new();
|
|
remove_color.set_fg(Some(Color::Red));
|
|
|
|
let writer = BufferWriter::stdout(color);
|
|
let mut buffer = writer.buffer();
|
|
|
|
let added = snapshot.added_paths();
|
|
if !added.is_empty() {
|
|
buffer.set_color(&add_color)?;
|
|
for path in added {
|
|
writeln!(
|
|
&mut buffer,
|
|
"Writing {}",
|
|
display_absolute(path.strip_prefix(base_path).unwrap_or(path))
|
|
)?;
|
|
}
|
|
}
|
|
let removed = snapshot.removed_paths();
|
|
if !removed.is_empty() {
|
|
buffer.set_color(&remove_color)?;
|
|
for path in removed {
|
|
writeln!(
|
|
&mut buffer,
|
|
"Removing {}",
|
|
display_absolute(path.strip_prefix(base_path).unwrap_or(path))
|
|
)?;
|
|
}
|
|
}
|
|
buffer.set_color(&no_color)?;
|
|
|
|
writer.print(&buffer)
|
|
}
|