Move Rojo server into root of the repository

This commit is contained in:
Lucien Greathouse
2019-08-27 16:56:52 -07:00
parent ec9afba029
commit 6f7dbe99fe
48 changed files with 50 additions and 54 deletions

203
src/bin.rs Normal file
View File

@@ -0,0 +1,203 @@
use std::{
env, panic,
path::{Path, PathBuf},
process,
};
use clap::{clap_app, ArgMatches};
use log::error;
use librojo::commands;
fn make_path_absolute(value: &Path) -> PathBuf {
if value.is_absolute() {
PathBuf::from(value)
} else {
let current_dir = env::current_dir().unwrap();
current_dir.join(value)
}
}
fn main() {
{
let log_env = env_logger::Env::default().default_filter_or("warn");
env_logger::Builder::from_env(log_env)
.default_format_timestamp(false)
.init();
}
let app = clap_app!(Rojo =>
(version: env!("CARGO_PKG_VERSION"))
(author: env!("CARGO_PKG_AUTHORS"))
(about: env!("CARGO_PKG_DESCRIPTION"))
(@subcommand init =>
(about: "Creates a new Rojo project")
(@arg PATH: "Path to the place to create the project. Defaults to the current directory.")
(@arg kind: --kind +takes_value "The kind of project to create, 'place' or 'model'. Defaults to place.")
)
(@subcommand serve =>
(about: "Serves the project's files for use with the Rojo Studio plugin.")
(@arg PROJECT: "Path to the project to serve. Defaults to the current directory.")
(@arg port: --port +takes_value "The port to listen on. Defaults to 34872.")
)
(@subcommand build =>
(about: "Generates an rbxmx model file from the project.")
(@arg PROJECT: "Path to the project to serve. Defaults to the current directory.")
(@arg output: --output -o +takes_value +required "Where to output the result.")
)
(@subcommand upload =>
(about: "Generates a place or model file out of the project and uploads it to Roblox.")
(@arg PROJECT: "Path to the project to upload. Defaults to the current directory.")
(@arg kind: --kind +takes_value "The kind of asset to generate, 'place', or 'model'. Defaults to place.")
(@arg cookie: --cookie +takes_value +required "Security cookie to authenticate with.")
(@arg asset_id: --asset_id +takes_value +required "Asset ID to upload to.")
)
);
let matches = app.get_matches();
let result = panic::catch_unwind(|| match matches.subcommand() {
("init", Some(sub_matches)) => start_init(sub_matches),
("serve", Some(sub_matches)) => start_serve(sub_matches),
("build", Some(sub_matches)) => start_build(sub_matches),
("upload", Some(sub_matches)) => start_upload(sub_matches),
_ => eprintln!("Usage: rojo <SUBCOMMAND>\nUse 'rojo help' for more help."),
});
if let Err(error) = result {
let message = match error.downcast_ref::<&str>() {
Some(message) => message.to_string(),
None => match error.downcast_ref::<String>() {
Some(message) => message.clone(),
None => "<no message>".to_string(),
},
};
show_crash_message(&message);
process::exit(1);
}
}
fn show_crash_message(message: &str) {
error!("Rojo crashed!");
error!("This is a bug in Rojo.");
error!("");
error!("Please consider filing a bug: https://github.com/LPGhatguy/rojo/issues");
error!("");
error!("Details: {}", message);
}
fn start_init(sub_matches: &ArgMatches) {
let fuzzy_project_path =
make_path_absolute(Path::new(sub_matches.value_of("PATH").unwrap_or("")));
let kind = sub_matches.value_of("kind");
let options = commands::InitOptions {
fuzzy_project_path,
kind,
};
match commands::init(&options) {
Ok(_) => {}
Err(e) => {
error!("{}", e);
process::exit(1);
}
}
}
fn start_serve(sub_matches: &ArgMatches) {
let fuzzy_project_path = match sub_matches.value_of("PROJECT") {
Some(v) => make_path_absolute(Path::new(v)),
None => std::env::current_dir().unwrap(),
};
let port = match sub_matches.value_of("port") {
Some(v) => match v.parse::<u16>() {
Ok(port) => Some(port),
Err(_) => {
error!("Invalid port {}", v);
process::exit(1);
}
},
None => None,
};
let options = commands::ServeOptions {
fuzzy_project_path,
port,
};
match commands::serve(&options) {
Ok(_) => {}
Err(e) => {
error!("{}", e);
process::exit(1);
}
}
}
fn start_build(sub_matches: &ArgMatches) {
let fuzzy_project_path = match sub_matches.value_of("PROJECT") {
Some(v) => make_path_absolute(Path::new(v)),
None => std::env::current_dir().unwrap(),
};
let output_file = make_path_absolute(Path::new(sub_matches.value_of("output").unwrap()));
let options = commands::BuildOptions {
fuzzy_project_path,
output_file,
output_kind: None, // TODO: Accept from argument
};
match commands::build(&options) {
Ok(_) => {}
Err(e) => {
error!("{}", e);
process::exit(1);
}
}
}
fn start_upload(sub_matches: &ArgMatches) {
let fuzzy_project_path = match sub_matches.value_of("PROJECT") {
Some(v) => make_path_absolute(Path::new(v)),
None => std::env::current_dir().unwrap(),
};
let kind = sub_matches.value_of("kind");
let security_cookie = sub_matches.value_of("cookie").unwrap();
let asset_id: u64 = {
let arg = sub_matches.value_of("asset_id").unwrap();
match arg.parse() {
Ok(v) => v,
Err(_) => {
error!("Invalid place ID {}", arg);
process::exit(1);
}
}
};
let options = commands::UploadOptions {
fuzzy_project_path,
security_cookie: security_cookie.to_string(),
asset_id,
kind,
};
match commands::upload(&options) {
Ok(_) => {}
Err(e) => {
error!("{}", e);
process::exit(1);
}
}
}

142
src/commands/build.rs Normal file
View File

@@ -0,0 +1,142 @@
use std::{
collections::HashMap,
fs::File,
io::{self, BufWriter, Write},
path::PathBuf,
};
use failure::Fail;
use rbx_dom_weak::{RbxInstanceProperties, RbxTree};
use crate::{
imfs::new::{FsError, Imfs, RealFetcher, WatchMode},
snapshot::{apply_patch_set, compute_patch_set},
snapshot_middleware::snapshot_from_imfs,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputKind {
Rbxmx,
Rbxlx,
Rbxm,
Rbxl,
}
fn detect_output_kind(options: &BuildOptions) -> Option<OutputKind> {
let extension = options.output_file.extension()?.to_str()?;
match extension {
"rbxlx" => Some(OutputKind::Rbxlx),
"rbxmx" => Some(OutputKind::Rbxmx),
"rbxl" => Some(OutputKind::Rbxl),
"rbxm" => Some(OutputKind::Rbxm),
_ => None,
}
}
#[derive(Debug)]
pub struct BuildOptions {
pub fuzzy_project_path: PathBuf,
pub output_file: PathBuf,
pub output_kind: Option<OutputKind>,
}
#[derive(Debug, Fail)]
pub enum BuildError {
#[fail(display = "Could not detect what kind of file to create")]
UnknownOutputKind,
#[fail(display = "IO error: {}", _0)]
IoError(#[fail(cause)] io::Error),
#[fail(display = "XML model file error")]
XmlModelEncodeError(rbx_xml::EncodeError),
#[fail(display = "Binary model file error")]
BinaryModelEncodeError(rbx_binary::EncodeError),
#[fail(display = "{}", _0)]
FsError(#[fail(cause)] FsError),
}
impl_from!(BuildError {
io::Error => IoError,
rbx_xml::EncodeError => XmlModelEncodeError,
rbx_binary::EncodeError => BinaryModelEncodeError,
FsError => FsError,
});
fn xml_encode_config() -> rbx_xml::EncodeOptions {
rbx_xml::EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown)
}
pub fn build(options: &BuildOptions) -> Result<(), BuildError> {
let output_kind = options
.output_kind
.or_else(|| detect_output_kind(options))
.ok_or(BuildError::UnknownOutputKind)?;
log::info!("Hoping to generate file of type {:?}", output_kind);
let mut tree = RbxTree::new(RbxInstanceProperties {
name: "ROOT".to_owned(),
class_name: "Folder".to_owned(),
properties: HashMap::new(),
});
let root_id = tree.get_root_id();
log::trace!("Constructing in-memory filesystem");
let mut imfs = Imfs::new(RealFetcher::new(WatchMode::Disabled));
log::trace!("Reading project root");
let entry = imfs
.get(&options.fuzzy_project_path)
.expect("could not get project path");
log::trace!("Generating snapshot of instances from IMFS");
let snapshot = snapshot_from_imfs(&mut imfs, &entry)
.expect("snapshot failed")
.expect("snapshot did not return an instance");
log::trace!("Computing patch set");
let patch_set = compute_patch_set(&snapshot, &tree, root_id);
log::trace!("Applying patch set");
apply_patch_set(&mut tree, &patch_set);
log::trace!("Opening output file for write");
let mut file = BufWriter::new(File::create(&options.output_file)?);
match output_kind {
OutputKind::Rbxmx => {
// Model files include the root instance of the tree and all its
// descendants.
rbx_xml::to_writer(&mut file, &tree, &[root_id], xml_encode_config())?;
}
OutputKind::Rbxlx => {
// Place files don't contain an entry for the DataModel, but our
// RbxTree representation does.
let top_level_ids = tree.get_instance(root_id).unwrap().get_children_ids();
rbx_xml::to_writer(&mut file, &tree, top_level_ids, xml_encode_config())?;
}
OutputKind::Rbxm => {
rbx_binary::encode(&tree, &[root_id], &mut file)?;
}
OutputKind::Rbxl => {
log::warn!("Support for building binary places (rbxl) is still experimental.");
log::warn!("Using the XML place format (rbxlx) is recommended instead.");
log::warn!("For more info, see https://github.com/LPGhatguy/rojo/issues/180");
let top_level_ids = tree.get_instance(root_id).unwrap().get_children_ids();
rbx_binary::encode(&tree, top_level_ids, &mut file)?;
}
}
file.flush()?;
log::trace!("Done!");
Ok(())
}

49
src/commands/init.rs Normal file
View File

@@ -0,0 +1,49 @@
use std::path::PathBuf;
use failure::Fail;
use crate::project::{Project, ProjectInitError};
#[derive(Debug, Fail)]
pub enum InitError {
#[fail(
display = "Invalid project kind '{}', valid kinds are 'place' and 'model'",
_0
)]
InvalidKind(String),
#[fail(display = "Project init error: {}", _0)]
ProjectInitError(#[fail(cause)] ProjectInitError),
}
impl_from!(InitError {
ProjectInitError => ProjectInitError,
});
#[derive(Debug)]
pub struct InitOptions<'a> {
pub fuzzy_project_path: PathBuf,
pub kind: Option<&'a str>,
}
pub fn init(options: &InitOptions) -> Result<(), InitError> {
let (project_path, project_kind) = match options.kind {
Some("place") | None => {
let path = Project::init_place(&options.fuzzy_project_path)?;
(path, "place")
}
Some("model") => {
let path = Project::init_model(&options.fuzzy_project_path)?;
(path, "model")
}
Some(invalid) => return Err(InitError::InvalidKind(invalid.to_string())),
};
println!(
"Created new {} project file at {}",
project_kind,
project_path.display()
);
Ok(())
}

9
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
mod build;
mod init;
mod serve;
mod upload;
pub use self::build::*;
pub use self::init::*;
pub use self::serve::*;
pub use self::upload::*;

91
src/commands/serve.rs Normal file
View File

@@ -0,0 +1,91 @@
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use failure::Fail;
use rbx_dom_weak::{RbxInstanceProperties, RbxTree};
use crate::{
imfs::new::{Imfs, RealFetcher, WatchMode},
project::{Project, ProjectLoadError},
serve_session::ServeSession,
snapshot::{apply_patch_set, compute_patch_set},
snapshot_middleware::snapshot_from_imfs,
web::LiveServer,
};
const DEFAULT_PORT: u16 = 34872;
#[derive(Debug)]
pub struct ServeOptions {
pub fuzzy_project_path: PathBuf,
pub port: Option<u16>,
}
#[derive(Debug, Fail)]
pub enum ServeError {
#[fail(display = "Couldn't load project: {}", _0)]
ProjectLoad(#[fail(cause)] ProjectLoadError),
}
impl_from!(ServeError {
ProjectLoadError => ProjectLoad,
});
pub fn serve(options: &ServeOptions) -> Result<(), ServeError> {
let maybe_project = match Project::load_fuzzy(&options.fuzzy_project_path) {
Ok(project) => Some(project),
Err(ProjectLoadError::NotFound) => None,
Err(other) => return Err(other.into()),
};
let port = options
.port
.or_else(|| {
maybe_project
.as_ref()
.and_then(|project| project.serve_port)
})
.unwrap_or(DEFAULT_PORT);
println!("Rojo server listening on port {}", port);
let mut tree = RbxTree::new(RbxInstanceProperties {
name: "ROOT".to_owned(),
class_name: "Folder".to_owned(),
properties: HashMap::new(),
});
let root_id = tree.get_root_id();
let mut imfs = Imfs::new(RealFetcher::new(WatchMode::Enabled));
let entry = imfs
.get(&options.fuzzy_project_path)
.expect("could not get project path");
let snapshot = snapshot_from_imfs(&mut imfs, &entry)
.expect("snapshot failed")
.expect("snapshot did not return an instance");
let patch_set = compute_patch_set(&snapshot, &tree, root_id);
apply_patch_set(&mut tree, &patch_set);
let session = Arc::new(ServeSession::new(maybe_project));
let server = LiveServer::new(session);
server.start(port);
// let receiver = imfs.change_receiver();
// while let Ok(change) = receiver.recv() {
// imfs.commit_change(&change)
// .expect("Failed to commit Imfs change");
// use notify::DebouncedEvent;
// if let DebouncedEvent::Write(path) = change {
// let contents = imfs.get_contents(path)
// .expect("Failed to read changed path");
// println!("{:?}", std::str::from_utf8(contents));
// }
// }
Ok(())
}

21
src/commands/upload.rs Normal file
View File

@@ -0,0 +1,21 @@
use std::path::PathBuf;
use failure::Fail;
#[derive(Debug, Fail)]
pub enum UploadError {
#[fail(display = "This error cannot happen")]
StubError,
}
#[derive(Debug)]
pub struct UploadOptions<'a> {
pub fuzzy_project_path: PathBuf,
pub security_cookie: String,
pub asset_id: u64,
pub kind: Option<&'a str>,
}
pub fn upload(_options: &UploadOptions) -> Result<(), UploadError> {
unimplemented!("TODO: Reimplement upload command");
}

61
src/imfs/error.rs Normal file
View File

@@ -0,0 +1,61 @@
use std::{fmt, io, path::PathBuf};
use failure::Fail;
pub type FsResult<T> = Result<T, FsError>;
pub use io::ErrorKind as FsErrorKind;
pub trait FsResultExt<T> {
fn with_not_found(self) -> Result<Option<T>, FsError>;
}
impl<T> FsResultExt<T> for Result<T, FsError> {
fn with_not_found(self) -> Result<Option<T>, FsError> {
match self {
Ok(value) => Ok(Some(value)),
Err(ref err) if err.kind() == FsErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
}
// TODO: New error type that contains errors specific to our application,
// wrapping io::Error either directly or through another error type that has
// path information.
//
// It's possible that we should hoist up the path information one more level, or
// destructure/restructure information to hoist the path out of FsError and just
// embed io::Error?
pub enum ImfsError {
NotFound,
WrongKind,
Io(io::Error),
}
/// A wrapper around io::Error that also attaches the path associated with the
/// error.
#[derive(Debug, Fail)]
pub struct FsError {
#[fail(cause)]
inner: io::Error,
path: PathBuf,
}
impl FsError {
pub fn new<P: Into<PathBuf>>(inner: io::Error, path: P) -> FsError {
FsError {
inner,
path: path.into(),
}
}
pub fn kind(&self) -> FsErrorKind {
self.inner.kind()
}
}
impl fmt::Display for FsError {
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
write!(output, "{}: {}", self.path.display(), self.inner)
}
}

32
src/imfs/fetcher.rs Normal file
View File

@@ -0,0 +1,32 @@
use std::{
io,
path::{Path, PathBuf},
};
use crossbeam_channel::Receiver;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
File,
Directory,
}
// TODO: Use our own event type instead of notify's.
pub type ImfsEvent = notify::DebouncedEvent;
/// The generic interface that `Imfs` uses to lazily read files from the disk.
/// In tests, it's stubbed out to do different versions of absolutely nothing
/// depending on the test.
pub trait ImfsFetcher {
fn file_type(&mut self, path: &Path) -> io::Result<FileType>;
fn read_children(&mut self, path: &Path) -> io::Result<Vec<PathBuf>>;
fn read_contents(&mut self, path: &Path) -> io::Result<Vec<u8>>;
fn create_directory(&mut self, path: &Path) -> io::Result<()>;
fn write_file(&mut self, path: &Path, contents: &[u8]) -> io::Result<()>;
fn remove(&mut self, path: &Path) -> io::Result<()>;
fn watch(&mut self, path: &Path);
fn unwatch(&mut self, path: &Path);
fn receiver(&self) -> Receiver<ImfsEvent>;
}

545
src/imfs/imfs.rs Normal file
View File

@@ -0,0 +1,545 @@
use std::{
io,
path::{Path, PathBuf},
};
use crossbeam_channel::Receiver;
use crate::path_map::PathMap;
use super::{
error::{FsError, FsResult},
fetcher::{FileType, ImfsEvent, ImfsFetcher},
snapshot::ImfsSnapshot,
};
/// An in-memory filesystem that can be incrementally populated and updated as
/// filesystem modification events occur.
///
/// All operations on the `Imfs` are lazy and do I/O as late as they can to
/// avoid reading extraneous files or directories from the disk. This means that
/// they all take `self` mutably, and means that it isn't possible to hold
/// references to the internal state of the Imfs while traversing it!
///
/// Most operations return `ImfsEntry` objects to work around this, which is
/// effectively a index into the `Imfs`.
pub struct Imfs<F> {
inner: PathMap<ImfsItem>,
fetcher: F,
}
impl<F: ImfsFetcher> Imfs<F> {
pub fn new(fetcher: F) -> Imfs<F> {
Imfs {
inner: PathMap::new(),
fetcher,
}
}
pub fn change_receiver(&self) -> Receiver<ImfsEvent> {
self.fetcher.receiver()
}
pub fn commit_pending_changes(&mut self) -> FsResult<Vec<ImfsEvent>> {
let receiver = self.fetcher.receiver();
let mut changes = Vec::new();
while let Ok(event) = receiver.try_recv() {
self.commit_change(&event)?;
changes.push(event);
}
Ok(changes)
}
pub fn commit_change(&mut self, event: &ImfsEvent) -> FsResult<()> {
use notify::DebouncedEvent::*;
log::trace!("Committing Imfs change {:?}", event);
match event {
Create(path) => {
self.raise_file_changed(path)?;
}
Write(path) => {
self.raise_file_changed(path)?;
}
Remove(path) => {
self.raise_file_removed(path)?;
}
Rename(from_path, to_path) => {
self.raise_file_removed(from_path)?;
self.raise_file_changed(to_path)?;
}
Error(err, path) => {
log::warn!("Filesystem error detected: {:?} on path {:?}", err, path);
}
Rescan => {
// FIXME: Implement rescanning
log::warn!("Unhandled filesystem rescan event");
}
NoticeWrite(_) | NoticeRemove(_) | Chmod(_) => {}
}
Ok(())
}
pub fn load_from_snapshot(&mut self, path: impl AsRef<Path>, snapshot: ImfsSnapshot) {
let path = path.as_ref();
match snapshot {
ImfsSnapshot::File(file) => {
self.inner.insert(
path.to_path_buf(),
ImfsItem::File(ImfsFile {
path: path.to_path_buf(),
contents: Some(file.contents),
}),
);
}
ImfsSnapshot::Directory(directory) => {
self.inner.insert(
path.to_path_buf(),
ImfsItem::Directory(ImfsDirectory {
path: path.to_path_buf(),
children_enumerated: true,
}),
);
for (child_name, child) in directory.children.into_iter() {
self.load_from_snapshot(path.join(child_name), child);
}
}
}
}
fn raise_file_changed(&mut self, path: impl AsRef<Path>) -> FsResult<()> {
let path = path.as_ref();
if !self.would_be_resident(path) {
return Ok(());
}
let new_type = self
.fetcher
.file_type(path)
.map_err(|err| FsError::new(err, path.to_path_buf()))?;
match self.inner.get_mut(path) {
Some(existing_item) => {
match (existing_item, &new_type) {
(ImfsItem::File(existing_file), FileType::File) => {
// Invalidate the existing file contents.
// We can probably be smarter about this by reading the changed file.
existing_file.contents = None;
}
(ImfsItem::Directory(_), FileType::Directory) => {
// No changes required, a directory updating doesn't mean anything to us.
self.fetcher.watch(path);
}
(ImfsItem::File(_), FileType::Directory) => {
self.inner.remove(path);
self.inner.insert(
path.to_path_buf(),
ImfsItem::new_from_type(FileType::Directory, path),
);
self.fetcher.watch(path);
}
(ImfsItem::Directory(_), FileType::File) => {
self.inner.remove(path);
self.inner.insert(
path.to_path_buf(),
ImfsItem::new_from_type(FileType::File, path),
);
self.fetcher.unwatch(path);
}
}
}
None => {
self.inner
.insert(path.to_path_buf(), ImfsItem::new_from_type(new_type, path));
}
}
Ok(())
}
fn raise_file_removed(&mut self, path: impl AsRef<Path>) -> FsResult<()> {
let path = path.as_ref();
if !self.would_be_resident(path) {
return Ok(());
}
self.inner.remove(path);
self.fetcher.unwatch(path);
Ok(())
}
pub fn get(&mut self, path: impl AsRef<Path>) -> FsResult<ImfsEntry> {
self.read_if_not_exists(path.as_ref())?;
let item = self.inner.get(path.as_ref()).unwrap();
let is_file = match item {
ImfsItem::File(_) => true,
ImfsItem::Directory(_) => false,
};
Ok(ImfsEntry {
path: item.path().to_path_buf(),
is_file,
})
}
pub fn get_contents(&mut self, path: impl AsRef<Path>) -> FsResult<&[u8]> {
let path = path.as_ref();
self.read_if_not_exists(path)?;
match self.inner.get_mut(path).unwrap() {
ImfsItem::File(file) => {
if file.contents.is_none() {
file.contents = Some(
self.fetcher
.read_contents(path)
.map_err(|err| FsError::new(err, path.to_path_buf()))?,
);
}
Ok(file.contents.as_ref().unwrap())
}
ImfsItem::Directory(_) => Err(FsError::new(
io::Error::new(io::ErrorKind::Other, "Can't read a directory"),
path.to_path_buf(),
)),
}
}
pub fn get_children(&mut self, path: impl AsRef<Path>) -> FsResult<Vec<ImfsEntry>> {
let path = path.as_ref();
self.read_if_not_exists(path)?;
match self.inner.get(path).unwrap() {
ImfsItem::Directory(dir) => {
self.fetcher.watch(path);
if dir.children_enumerated {
return self
.inner
.children(path)
.unwrap() // TODO: Handle None here, which means the PathMap entry did not exist.
.into_iter()
.map(PathBuf::from) // Convert paths from &Path to PathBuf
.collect::<Vec<PathBuf>>() // Collect all PathBufs, since self.get needs to borrow self mutably.
.into_iter()
.map(|path| self.get(path))
.collect::<FsResult<Vec<ImfsEntry>>>();
}
self.fetcher
.read_children(path)
.map_err(|err| FsError::new(err, path.to_path_buf()))?
.into_iter()
.map(|path| self.get(path))
.collect::<FsResult<Vec<ImfsEntry>>>()
}
ImfsItem::File(_) => Err(FsError::new(
io::Error::new(io::ErrorKind::Other, "Can't read a directory"),
path.to_path_buf(),
)),
}
}
/// Tells whether the given path, if it were loaded, would be loaded if it
/// existed.
///
/// Returns true if the path is loaded or if its parent is loaded, is a
/// directory, and is marked as having been enumerated before.
///
/// This idea corresponds to whether a file change event should result in
/// tangible changes to the in-memory filesystem. If a path would be
/// resident, we need to read it, and if its contents were known before, we
/// need to update them.
fn would_be_resident(&self, path: &Path) -> bool {
if self.inner.contains_key(path) {
return true;
}
if let Some(parent) = path.parent() {
if let Some(ImfsItem::Directory(dir)) = self.inner.get(parent) {
return !dir.children_enumerated;
}
}
false
}
/// Attempts to read the path into the `Imfs` if it doesn't exist.
///
/// This does not necessitate that file contents or directory children will
/// be read. Depending on the `ImfsFetcher` implementation that the `Imfs`
/// is using, this call may read exactly only the given path and no more.
fn read_if_not_exists(&mut self, path: &Path) -> FsResult<()> {
if !self.inner.contains_key(path) {
let kind = self
.fetcher
.file_type(path)
.map_err(|err| FsError::new(err, path.to_path_buf()))?;
if kind == FileType::Directory {
self.fetcher.watch(path);
}
self.inner
.insert(path.to_path_buf(), ImfsItem::new_from_type(kind, path));
}
Ok(())
}
}
/// A reference to file or folder in an `Imfs`. Can only be produced by the
/// entry existing in the Imfs, but can later point to nothing if something
/// would invalidate that path.
///
/// This struct does not borrow from the Imfs since every operation has the
/// possibility to mutate the underlying data structure and move memory around.
pub struct ImfsEntry {
path: PathBuf,
is_file: bool,
}
impl ImfsEntry {
pub fn path(&self) -> &Path {
&self.path
}
pub fn contents<'imfs>(
&self,
imfs: &'imfs mut Imfs<impl ImfsFetcher>,
) -> FsResult<&'imfs [u8]> {
imfs.get_contents(&self.path)
}
pub fn children(&self, imfs: &mut Imfs<impl ImfsFetcher>) -> FsResult<Vec<ImfsEntry>> {
imfs.get_children(&self.path)
}
pub fn is_file(&self) -> bool {
self.is_file
}
pub fn is_directory(&self) -> bool {
!self.is_file
}
}
/// Internal structure describing potentially partially-resident files and
/// folders in the `Imfs`.
pub enum ImfsItem {
File(ImfsFile),
Directory(ImfsDirectory),
}
impl ImfsItem {
fn path(&self) -> &Path {
match self {
ImfsItem::File(file) => &file.path,
ImfsItem::Directory(dir) => &dir.path,
}
}
fn new_from_type(kind: FileType, path: impl Into<PathBuf>) -> ImfsItem {
match kind {
FileType::Directory => ImfsItem::Directory(ImfsDirectory {
path: path.into(),
children_enumerated: false,
}),
FileType::File => ImfsItem::File(ImfsFile {
path: path.into(),
contents: None,
}),
}
}
}
pub struct ImfsFile {
pub(super) path: PathBuf,
pub(super) contents: Option<Vec<u8>>,
}
pub struct ImfsDirectory {
pub(super) path: PathBuf,
pub(super) children_enumerated: bool,
}
#[cfg(test)]
mod test {
use super::*;
use std::{cell::RefCell, rc::Rc};
use crossbeam_channel::Receiver;
use maplit::hashmap;
use super::super::{error::FsErrorKind, fetcher::ImfsEvent, noop_fetcher::NoopFetcher};
#[test]
fn from_snapshot_file() {
let mut imfs = Imfs::new(NoopFetcher);
let file = ImfsSnapshot::file("hello, world!");
imfs.load_from_snapshot("/hello.txt", file);
let entry = imfs.get_contents("/hello.txt").unwrap();
assert_eq!(entry, b"hello, world!");
}
#[test]
fn from_snapshot_dir() {
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"a.txt" => ImfsSnapshot::file("contents of a.txt"),
"b.lua" => ImfsSnapshot::file("contents of b.lua"),
});
imfs.load_from_snapshot("/dir", dir);
let children = imfs.get_children("/dir").unwrap();
let mut has_a = false;
let mut has_b = false;
for child in children.into_iter() {
if child.path() == Path::new("/dir/a.txt") {
has_a = true;
} else if child.path() == Path::new("/dir/b.lua") {
has_b = true;
} else {
panic!("Unexpected child in /dir");
}
}
assert!(has_a, "/dir/a.txt was missing");
assert!(has_b, "/dir/b.lua was missing");
let a = imfs.get_contents("/dir/a.txt").unwrap();
assert_eq!(a, b"contents of a.txt");
let b = imfs.get_contents("/dir/b.lua").unwrap();
assert_eq!(b, b"contents of b.lua");
}
#[test]
fn changed_event() {
#[derive(Default)]
struct MockState {
a_contents: &'static str,
}
struct MockFetcher {
inner: Rc<RefCell<MockState>>,
}
impl ImfsFetcher for MockFetcher {
fn file_type(&mut self, path: &Path) -> io::Result<FileType> {
if path == Path::new("/dir/a.txt") {
return Ok(FileType::File);
}
unimplemented!();
}
fn read_contents(&mut self, path: &Path) -> io::Result<Vec<u8>> {
if path == Path::new("/dir/a.txt") {
let inner = self.inner.borrow();
return Ok(Vec::from(inner.a_contents));
}
unimplemented!();
}
fn read_children(&mut self, _path: &Path) -> io::Result<Vec<PathBuf>> {
unimplemented!();
}
fn create_directory(&mut self, _path: &Path) -> io::Result<()> {
unimplemented!();
}
fn write_file(&mut self, _path: &Path, _contents: &[u8]) -> io::Result<()> {
unimplemented!();
}
fn remove(&mut self, _path: &Path) -> io::Result<()> {
unimplemented!();
}
fn watch(&mut self, _path: &Path) {}
fn unwatch(&mut self, _path: &Path) {}
fn receiver(&self) -> Receiver<ImfsEvent> {
crossbeam_channel::never()
}
}
let mock_state = Rc::new(RefCell::new(MockState {
a_contents: "Initial contents",
}));
let mut imfs = Imfs::new(MockFetcher {
inner: mock_state.clone(),
});
let a = imfs.get("/dir/a.txt").expect("mock file did not exist");
let contents = a.contents(&mut imfs).expect("mock file contents error");
assert_eq!(contents, b"Initial contents");
{
let mut mock_state = mock_state.borrow_mut();
mock_state.a_contents = "Changed contents";
}
imfs.raise_file_changed("/dir/a.txt")
.expect("error processing file change");
let contents = a.contents(&mut imfs).expect("mock file contents error");
assert_eq!(contents, b"Changed contents");
}
#[test]
fn removed_event_existing() {
let mut imfs = Imfs::new(NoopFetcher);
let file = ImfsSnapshot::file("hello, world!");
imfs.load_from_snapshot("/hello.txt", file);
let hello = imfs.get("/hello.txt").expect("couldn't get hello.txt");
let contents = hello
.contents(&mut imfs)
.expect("couldn't get hello.txt contents");
assert_eq!(contents, b"hello, world!");
imfs.raise_file_removed("/hello.txt")
.expect("error processing file removal");
match imfs.get("hello.txt") {
Err(ref err) if err.kind() == FsErrorKind::NotFound => {}
Ok(_) => {
panic!("hello.txt was not removed from Imfs");
}
Err(err) => {
panic!("Unexpected error: {:?}", err);
}
}
}
}

17
src/imfs/mod.rs Normal file
View File

@@ -0,0 +1,17 @@
mod error;
mod fetcher;
mod imfs;
mod noop_fetcher;
mod real_fetcher;
mod snapshot;
pub use error::*;
pub mod new {
pub use super::error::*;
pub use super::fetcher::*;
pub use super::imfs::*;
pub use super::noop_fetcher::*;
pub use super::real_fetcher::*;
pub use super::snapshot::*;
}

56
src/imfs/noop_fetcher.rs Normal file
View File

@@ -0,0 +1,56 @@
//! Implements the IMFS fetcher interface for a fake filesystem using Rust's
//! std::fs interface.
use std::{
io,
path::{Path, PathBuf},
};
use crossbeam_channel::Receiver;
use super::fetcher::{FileType, ImfsEvent, ImfsFetcher};
pub struct NoopFetcher;
impl ImfsFetcher for NoopFetcher {
fn file_type(&mut self, _path: &Path) -> io::Result<FileType> {
Err(io::Error::new(
io::ErrorKind::NotFound,
"NoopFetcher always returns NotFound",
))
}
fn read_children(&mut self, _path: &Path) -> io::Result<Vec<PathBuf>> {
Err(io::Error::new(
io::ErrorKind::NotFound,
"NoopFetcher always returns NotFound",
))
}
fn read_contents(&mut self, _path: &Path) -> io::Result<Vec<u8>> {
Err(io::Error::new(
io::ErrorKind::NotFound,
"NoopFetcher always returns NotFound",
))
}
fn create_directory(&mut self, _path: &Path) -> io::Result<()> {
Ok(())
}
fn write_file(&mut self, _path: &Path, _contents: &[u8]) -> io::Result<()> {
Ok(())
}
fn remove(&mut self, _path: &Path) -> io::Result<()> {
Ok(())
}
fn watch(&mut self, _path: &Path) {}
fn unwatch(&mut self, _path: &Path) {}
fn receiver(&self) -> Receiver<ImfsEvent> {
crossbeam_channel::never()
}
}

154
src/imfs/real_fetcher.rs Normal file
View File

@@ -0,0 +1,154 @@
//! Implements the IMFS fetcher interface for the real filesystem using Rust's
//! std::fs interface and notify as the file watcher.
use std::{
fs, io,
path::{Path, PathBuf},
sync::mpsc,
time::Duration,
};
use crossbeam_channel::{unbounded, Receiver};
use jod_thread::JoinHandle;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use super::fetcher::{FileType, ImfsEvent, ImfsFetcher};
/// Workaround to disable the file watcher for processes that don't need it,
/// since notify appears hang on to mpsc Sender objects too long, causing Rojo
/// to deadlock on drop.
///
/// We can make constructing the watcher optional in order to hotfix rojo build.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WatchMode {
Enabled,
Disabled,
}
pub struct RealFetcher {
// Drop order is relevant here!
//
// `watcher` must be dropped before `_converter_thread` or else joining the
// thread will cause a deadlock.
watcher: Option<RecommendedWatcher>,
/// Thread handle to convert notify's mpsc channel messages into
/// crossbeam_channel messages.
_converter_thread: JoinHandle<()>,
receiver: Receiver<ImfsEvent>,
}
impl RealFetcher {
pub fn new(watch_mode: WatchMode) -> RealFetcher {
log::trace!("Starting RealFetcher with watch mode {:?}", watch_mode);
let (notify_sender, notify_receiver) = mpsc::channel();
let (sender, receiver) = unbounded();
let handle = jod_thread::Builder::new()
.name("notify message converter".to_owned())
.spawn(move || {
notify_receiver
.into_iter()
.for_each(|event| sender.send(event).unwrap());
})
.expect("Could not start message converter thread");
// TODO: Investigate why notify hangs onto notify_sender too long,
// causing our program to deadlock. Once this is fixed, watcher no
// longer needs to be optional, but is still maybe useful?
let watcher = match watch_mode {
WatchMode::Enabled => Some(
notify::watcher(notify_sender, Duration::from_millis(300))
.expect("Couldn't start 'notify' file watcher"),
),
WatchMode::Disabled => None,
};
RealFetcher {
watcher,
_converter_thread: handle,
receiver,
}
}
}
impl ImfsFetcher for RealFetcher {
fn file_type(&mut self, path: &Path) -> io::Result<FileType> {
let metadata = fs::metadata(path)?;
if metadata.is_file() {
Ok(FileType::File)
} else {
Ok(FileType::Directory)
}
}
fn read_children(&mut self, path: &Path) -> io::Result<Vec<PathBuf>> {
log::trace!("Reading directory {}", path.display());
let mut result = Vec::new();
let iter = fs::read_dir(path)?;
for entry in iter {
result.push(entry?.path());
}
Ok(result)
}
fn read_contents(&mut self, path: &Path) -> io::Result<Vec<u8>> {
log::trace!("Reading file {}", path.display());
fs::read(path)
}
fn create_directory(&mut self, path: &Path) -> io::Result<()> {
log::trace!("Creating directory {}", path.display());
fs::create_dir(path)
}
fn write_file(&mut self, path: &Path, contents: &[u8]) -> io::Result<()> {
log::trace!("Writing path {}", path.display());
fs::write(path, contents)
}
fn remove(&mut self, path: &Path) -> io::Result<()> {
log::trace!("Removing path {}", path.display());
let metadata = fs::metadata(path)?;
if metadata.is_file() {
fs::remove_file(path)
} else {
fs::remove_dir_all(path)
}
}
fn watch(&mut self, path: &Path) {
log::trace!("Watching path {}", path.display());
if let Some(watcher) = self.watcher.as_mut() {
if let Err(err) = watcher.watch(path, RecursiveMode::NonRecursive) {
log::warn!("Couldn't watch path {}: {:?}", path.display(), err);
}
}
}
fn unwatch(&mut self, path: &Path) {
log::trace!("Stopped watching path {}", path.display());
if let Some(watcher) = self.watcher.as_mut() {
if let Err(err) = watcher.unwatch(path) {
log::warn!("Couldn't unwatch path {}: {:?}", path.display(), err);
}
}
}
fn receiver(&self) -> Receiver<ImfsEvent> {
self.receiver.clone()
}
}

33
src/imfs/snapshot.rs Normal file
View File

@@ -0,0 +1,33 @@
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub enum ImfsSnapshot {
File(FileSnapshot),
Directory(DirectorySnapshot),
}
impl ImfsSnapshot {
/// Create a new file ImfsSnapshot with the given contents.
pub fn file(contents: impl Into<Vec<u8>>) -> ImfsSnapshot {
ImfsSnapshot::File(FileSnapshot {
contents: contents.into(),
})
}
/// Create a new directory ImfsSnapshot with the given children.
pub fn dir<S: Into<String>>(children: HashMap<S, ImfsSnapshot>) -> ImfsSnapshot {
let children = children.into_iter().map(|(k, v)| (k.into(), v)).collect();
ImfsSnapshot::Directory(DirectorySnapshot { children })
}
}
#[derive(Debug, Clone)]
pub struct FileSnapshot {
pub contents: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct DirectorySnapshot {
pub children: HashMap<String, ImfsSnapshot>,
}

18
src/impl_from.rs Normal file
View File

@@ -0,0 +1,18 @@
/// Implements 'From' for a list of variants, intended for use with error enums
/// that are wrapping a number of errors from other methods.
#[macro_export]
macro_rules! impl_from {
(
$enum_name: ident {
$($error_type: ty => $variant_name: ident),* $(,)*
}
) => {
$(
impl From<$error_type> for $enum_name {
fn from(error: $error_type) -> $enum_name {
$enum_name::$variant_name(error)
}
}
)*
}
}

19
src/lib.rs Normal file
View File

@@ -0,0 +1,19 @@
#![recursion_limit = "128"]
// Macros
#[macro_use]
mod impl_from;
// Other modules
pub mod commands;
pub mod project;
mod imfs;
mod message_queue;
mod path_map;
mod path_serializer;
mod serve_session;
mod session_id;
mod snapshot;
mod snapshot_middleware;
mod web;

96
src/message_queue.rs Normal file
View File

@@ -0,0 +1,96 @@
use std::{
mem,
sync::{Mutex, RwLock},
};
use futures::sync::oneshot;
struct Listener<T> {
sender: oneshot::Sender<(u32, Vec<T>)>,
cursor: u32,
}
fn fire_listener_if_ready<T: Clone>(
messages: &[T],
listener: Listener<T>,
) -> Result<(), Listener<T>> {
let current_cursor = messages.len() as u32;
if listener.cursor < current_cursor {
let new_messages = messages[(listener.cursor as usize)..].to_vec();
let _ = listener.sender.send((current_cursor, new_messages));
Ok(())
} else {
Err(listener)
}
}
/// A message queue with persistent history that can be subscribed to.
///
/// Definitely non-optimal. This would ideally be a lockless mpmc queue.
#[derive(Default)]
pub struct MessageQueue<T> {
messages: RwLock<Vec<T>>,
message_listeners: Mutex<Vec<Listener<T>>>,
}
impl<T: Clone> MessageQueue<T> {
pub fn new() -> MessageQueue<T> {
MessageQueue {
messages: RwLock::new(Vec::new()),
message_listeners: Mutex::new(Vec::new()),
}
}
pub fn push_messages(&self, new_messages: &[T]) {
let mut message_listeners = self.message_listeners.lock().unwrap();
let mut messages = self.messages.write().unwrap();
messages.extend_from_slice(new_messages);
let mut remaining_listeners = Vec::new();
for listener in message_listeners.drain(..) {
match fire_listener_if_ready(&messages, listener) {
Ok(_) => {}
Err(listener) => remaining_listeners.push(listener),
}
}
// Without this annotation, Rust gets confused since the first argument
// is a MutexGuard, but the second is a Vec.
mem::replace::<Vec<_>>(&mut message_listeners, remaining_listeners);
}
pub fn subscribe(&self, cursor: u32, sender: oneshot::Sender<(u32, Vec<T>)>) {
let listener = {
let listener = Listener { sender, cursor };
let messages = self.messages.read().unwrap();
match fire_listener_if_ready(&messages, listener) {
Ok(_) => return,
Err(listener) => listener,
}
};
let mut message_listeners = self.message_listeners.lock().unwrap();
message_listeners.push(listener);
}
pub fn get_message_cursor(&self) -> u32 {
self.messages.read().unwrap().len() as u32
}
pub fn get_messages_since(&self, cursor: u32) -> (u32, Vec<T>) {
let messages = self.messages.read().unwrap();
let current_cursor = messages.len() as u32;
// Cursor is out of bounds or there are no new messages
if cursor >= current_cursor {
return (current_cursor, Vec::new());
}
(current_cursor, messages[(cursor as usize)..].to_vec())
}
}

284
src/path_map.rs Normal file
View File

@@ -0,0 +1,284 @@
use std::{
collections::{HashMap, HashSet},
path::{self, Path, PathBuf},
};
use log::warn;
use serde::Serialize;
#[derive(Debug, Serialize)]
struct PathMapNode<T> {
value: T,
children: HashSet<PathBuf>,
}
/// A map from paths to another type, like instance IDs, with a bit of
/// additional data that enables removing a path and all of its child paths from
/// the tree more quickly.
#[derive(Debug, Serialize)]
pub struct PathMap<T> {
nodes: HashMap<PathBuf, PathMapNode<T>>,
/// Contains the set of all paths whose parent either does not exist, or is
/// not present in the PathMap.
///
/// Note that these paths may have other _ancestors_ in the tree, but if an
/// orphan's parent path is ever inserted, it will stop being an orphan. It
/// will be... adopted!
orphan_paths: HashSet<PathBuf>,
}
impl<T> Default for PathMap<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> PathMap<T> {
pub fn new() -> PathMap<T> {
PathMap {
nodes: HashMap::new(),
orphan_paths: HashSet::new(),
}
}
pub fn get(&self, path: impl AsRef<Path>) -> Option<&T> {
self.nodes.get(path.as_ref()).map(|v| &v.value)
}
pub fn get_mut(&mut self, path: impl AsRef<Path>) -> Option<&mut T> {
self.nodes.get_mut(path.as_ref()).map(|v| &mut v.value)
}
pub fn children(&self, path: impl AsRef<Path>) -> Option<Vec<&Path>> {
self.nodes
.get(path.as_ref())
.map(|v| v.children.iter().map(AsRef::as_ref).collect())
}
pub fn contains_key(&self, path: impl AsRef<Path>) -> bool {
self.nodes.contains_key(path.as_ref())
}
pub fn insert(&mut self, path: impl Into<PathBuf>, value: T) {
let path = path.into();
self.add_to_parent(path.clone());
// Collect any children that are currently marked as orphaned paths, but
// are actually children of this new node.
let mut children = HashSet::new();
for orphan_path in &self.orphan_paths {
if orphan_path.parent() == Some(&path) {
children.insert(orphan_path.clone());
}
}
for child in &children {
self.orphan_paths.remove(child);
}
self.nodes.insert(path, PathMapNode { value, children });
}
/// Remove the given path and all of its linked descendants, returning all
/// values stored in the map.
pub fn remove(&mut self, root_path: impl AsRef<Path>) -> Vec<(PathBuf, T)> {
let root_path = root_path.as_ref();
self.remove_from_parent(root_path);
let (root_path, root_node) = match self.nodes.remove_entry(root_path) {
Some(node) => node,
None => return Vec::new(),
};
let mut removed_entries = vec![(root_path, root_node.value)];
let mut to_visit: Vec<PathBuf> = root_node.children.into_iter().collect();
while let Some(path) = to_visit.pop() {
match self.nodes.remove_entry(&path) {
Some((path, node)) => {
removed_entries.push((path, node.value));
for child in node.children.into_iter() {
to_visit.push(child);
}
}
None => {
warn!(
"Consistency issue; tried to remove {} but it was already removed",
path.display()
);
}
}
}
removed_entries
}
/// Traverses the route between `start_path` and `target_path` and returns
/// the path closest to `target_path` in the tree.
///
/// This is useful when trying to determine what paths need to be marked as
/// altered when a change to a path is registered. Depending on the order of
/// FS events, a file remove event could be followed by that file's
/// directory being removed, in which case we should process that
/// directory's parent.
pub fn descend(
&self,
start_path: impl Into<PathBuf>,
target_path: impl AsRef<Path>,
) -> PathBuf {
let start_path = start_path.into();
let target_path = target_path.as_ref();
let relative_path = target_path
.strip_prefix(&start_path)
.expect("target_path did not begin with start_path");
let mut current_path = start_path;
for component in relative_path.components() {
match component {
path::Component::Normal(name) => {
let next_path = current_path.join(name);
if self.nodes.contains_key(&next_path) {
current_path = next_path;
} else {
return current_path;
}
}
_ => unreachable!(),
}
}
current_path
}
/// Adds the path to its parent if it's present in the tree, or the set of
/// orphaned paths if it is not.
fn add_to_parent(&mut self, path: PathBuf) {
if let Some(parent_path) = path.parent() {
if let Some(parent) = self.nodes.get_mut(parent_path) {
parent.children.insert(path);
return;
}
}
// In this branch, the path is orphaned because it either doesn't have a
// parent according to Path, or because its parent doesn't exist in the
// PathMap.
self.orphan_paths.insert(path);
}
/// Removes the path from its parent, or from the orphaned paths set if it
/// has no parent.
fn remove_from_parent(&mut self, path: &Path) {
if let Some(parent_path) = path.parent() {
if let Some(parent) = self.nodes.get_mut(parent_path) {
parent.children.remove(path);
return;
}
}
// In this branch, the path is orphaned because it either doesn't have a
// parent according to Path, or because its parent doesn't exist in the
// PathMap.
self.orphan_paths.remove(path);
}
}
#[cfg(test)]
mod test {
use super::*;
use maplit::hashset;
#[test]
fn smoke_test() {
let mut map = PathMap::new();
assert_eq!(map.get("/foo"), None);
map.insert("/foo", 5);
assert_eq!(map.get("/foo"), Some(&5));
map.insert("/foo/bar", 6);
assert_eq!(map.get("/foo"), Some(&5));
assert_eq!(map.get("/foo/bar"), Some(&6));
assert_eq!(map.children("/foo"), Some(vec![Path::new("/foo/bar")]));
}
#[test]
fn orphans() {
let mut map = PathMap::new();
map.insert("/foo/bar", 5);
assert_eq!(map.orphan_paths, hashset!["/foo/bar".into()]);
map.insert("/foo", 6);
assert_eq!(map.orphan_paths, hashset!["/foo".into()]);
}
#[test]
fn remove_one() {
let mut map = PathMap::new();
map.insert("/foo", 6);
assert_eq!(map.remove("/foo"), vec![(PathBuf::from("/foo"), 6),]);
assert_eq!(map.get("/foo"), None);
}
#[test]
fn remove_child() {
let mut map = PathMap::new();
map.insert("/foo", 6);
map.insert("/foo/bar", 12);
assert_eq!(
map.remove("/foo"),
vec![(PathBuf::from("/foo"), 6), (PathBuf::from("/foo/bar"), 12),]
);
assert_eq!(map.get("/foo"), None);
assert_eq!(map.get("/foo/bar"), None);
}
#[test]
fn remove_descendant() {
let mut map = PathMap::new();
map.insert("/foo", 6);
map.insert("/foo/bar", 12);
map.insert("/foo/bar/baz", 18);
assert_eq!(
map.remove("/foo"),
vec![
(PathBuf::from("/foo"), 6),
(PathBuf::from("/foo/bar"), 12),
(PathBuf::from("/foo/bar/baz"), 18),
]
);
assert_eq!(map.get("/foo"), None);
assert_eq!(map.get("/foo/bar"), None);
assert_eq!(map.get("/foo/bar/baz"), None);
}
#[test]
fn remove_not_orphan_descendants() {
let mut map = PathMap::new();
map.insert("/foo", 6);
map.insert("/foo/bar/baz", 12);
assert_eq!(map.remove("/foo"), vec![(PathBuf::from("/foo"), 6),]);
assert_eq!(map.get("/foo"), None);
assert_eq!(map.get("/foo/bar/baz"), Some(&12));
}
}

74
src/path_serializer.rs Normal file
View File

@@ -0,0 +1,74 @@
//! path_serializer is used in cases where we need to serialize relative Path
//! and PathBuf objects in a way that's cross-platform.
//!
//! This is used for the snapshot testing system to make sure that snapshots
//! that reference local paths that are generated on Windows don't fail when run
//! in systems that use a different directory separator.
//!
//! To use, annotate your PathBuf or Option<PathBuf> field with the correct
//! serializer function:
//!
//! ```ignore
//! # use std::path::PathBuf;
//! # use serde::{Serialize, Deserialize};
//!
//! #[derive(Serialize, Deserialize)]
//! struct Mine {
//! name: String,
//!
//! // Use 'crate' instead of librojo if writing code inside Rojo
//! #[serde(serialize_with = "librojo::path_serializer::serialize")]
//! source_path: PathBuf,
//!
//! #[serde(serialize_with = "librojo::path_serializer::serialize_option")]
//! maybe_path: Option<PathBuf>,
//! }
//! ```
//!
//! **The methods in this module can only handle relative paths, since absolute
//! paths are never portable.**
use std::path::{Component, Path};
use serde::Serializer;
pub fn serialize_option<S, T>(maybe_path: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
T: AsRef<Path>,
{
match maybe_path {
Some(path) => serialize(path, serializer),
None => serializer.serialize_none(),
}
}
pub fn serialize<S, T>(path: T, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
T: AsRef<Path>,
{
let path = path.as_ref();
assert!(
path.is_relative(),
"path_serializer can only handle relative paths"
);
let mut output = String::new();
for component in path.components() {
if !output.is_empty() {
output.push('/');
}
match component {
Component::CurDir => output.push('.'),
Component::ParentDir => output.push_str(".."),
Component::Normal(piece) => output.push_str(piece.to_str().unwrap()),
_ => panic!("path_serializer cannot handle absolute path components"),
}
}
serializer.serialize_str(&output)
}

556
src/project.rs Normal file
View File

@@ -0,0 +1,556 @@
use std::{
collections::{BTreeMap, HashMap, HashSet},
fmt,
fs::{self, File},
io,
path::{Path, PathBuf},
};
use failure::Fail;
use log::warn;
use rbx_dom_weak::{RbxValue, UnresolvedRbxValue};
use serde::{Deserialize, Serialize, Serializer};
static DEFAULT_PLACE: &'static str = include_str!("../assets/place.project.json");
pub static PROJECT_FILENAME: &'static str = "default.project.json";
pub static COMPAT_PROJECT_FILENAME: &'static str = "roblox-project.json";
/// SourceProject is the format that users author projects on-disk. Since we
/// want to do things like transforming paths to be absolute before handing them
/// off to the rest of Rojo, we use this intermediate struct.
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct SourceProject {
name: String,
tree: SourceProjectNode,
#[serde(skip_serializing_if = "Option::is_none")]
serve_port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
serve_place_ids: Option<HashSet<u64>>,
}
impl SourceProject {
/// Consumes the SourceProject and yields a Project, ready for prime-time.
pub fn into_project(mut self, project_file_location: &Path) -> Project {
let tree = self.tree.into_project_node(project_file_location);
Project {
name: self.name,
tree,
serve_port: self.serve_port,
serve_place_ids: self.serve_place_ids,
file_location: PathBuf::from(project_file_location),
}
}
}
/// An alternative serializer for `UnresolvedRbxValue` that uses the minimum
/// representation of the value.
///
/// For example, the default Serialize impl might give you:
///
/// ```json
/// {
/// "Type": "Bool",
/// "Value": true
/// }
/// ```
///
/// But in reality, users are expected to write just:
///
/// ```json
/// true
/// ```
///
/// This holds true for other values that might be ambiguous or just have more
/// complicated representations like enums.
fn serialize_unresolved_minimal<S>(
unresolved: &UnresolvedRbxValue,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match unresolved {
UnresolvedRbxValue::Ambiguous(_) => unresolved.serialize(serializer),
UnresolvedRbxValue::Concrete(concrete) => match concrete {
RbxValue::Bool { value } => value.serialize(serializer),
RbxValue::CFrame { value } => value.serialize(serializer),
RbxValue::Color3 { value } => value.serialize(serializer),
RbxValue::Color3uint8 { value } => value.serialize(serializer),
RbxValue::Content { value } => value.serialize(serializer),
RbxValue::Float32 { value } => value.serialize(serializer),
RbxValue::Int32 { value } => value.serialize(serializer),
RbxValue::String { value } => value.serialize(serializer),
RbxValue::UDim { value } => value.serialize(serializer),
RbxValue::UDim2 { value } => value.serialize(serializer),
RbxValue::Vector2 { value } => value.serialize(serializer),
RbxValue::Vector2int16 { value } => value.serialize(serializer),
RbxValue::Vector3 { value } => value.serialize(serializer),
RbxValue::Vector3int16 { value } => value.serialize(serializer),
_ => concrete.serialize(serializer),
},
}
}
/// A wrapper around serialize_unresolved_minimal that handles the HashMap case.
fn serialize_unresolved_map<S>(
value: &HashMap<String, UnresolvedRbxValue>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use serde::ser::SerializeMap;
#[derive(Serialize)]
struct Minimal<'a>(
#[serde(serialize_with = "serialize_unresolved_minimal")] &'a UnresolvedRbxValue,
);
let mut map = serializer.serialize_map(Some(value.len()))?;
for (k, v) in value {
map.serialize_key(k)?;
map.serialize_value(&Minimal(v))?;
}
map.end()
}
/// Similar to SourceProject, the structure of nodes in the project tree is
/// slightly different on-disk than how we want to handle them in the rest of
/// Rojo.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SourceProjectNode {
#[serde(rename = "$className", skip_serializing_if = "Option::is_none")]
class_name: Option<String>,
#[serde(
rename = "$properties",
default = "HashMap::new",
skip_serializing_if = "HashMap::is_empty",
serialize_with = "serialize_unresolved_map"
)]
properties: HashMap<String, UnresolvedRbxValue>,
#[serde(
rename = "$ignoreUnknownInstances",
skip_serializing_if = "Option::is_none"
)]
ignore_unknown_instances: Option<bool>,
#[serde(rename = "$path", skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(flatten)]
children: BTreeMap<String, SourceProjectNode>,
}
impl SourceProjectNode {
/// Consumes the SourceProjectNode and turns it into a ProjectNode.
pub fn into_project_node(self, project_file_location: &Path) -> ProjectNode {
let children = self
.children
.iter()
.map(|(key, value)| {
(
key.clone(),
value.clone().into_project_node(project_file_location),
)
})
.collect();
// Make sure that paths are absolute, transforming them by adding the
// project folder if they're not already absolute.
let path = self.path.as_ref().map(|source_path| {
if Path::new(source_path).is_absolute() {
PathBuf::from(source_path)
} else {
let project_folder_location = project_file_location.parent().unwrap();
project_folder_location.join(source_path)
}
});
ProjectNode {
class_name: self.class_name,
properties: self.properties,
ignore_unknown_instances: self.ignore_unknown_instances,
path,
children,
}
}
}
#[derive(Debug, Fail)]
pub enum ProjectLoadError {
NotFound,
Io {
#[fail(cause)]
inner: io::Error,
path: PathBuf,
},
Json {
#[fail(cause)]
inner: serde_json::Error,
path: PathBuf,
},
}
impl fmt::Display for ProjectLoadError {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
use self::ProjectLoadError::*;
match self {
NotFound => write!(formatter, "Project file not found"),
Io { inner, path } => {
write!(formatter, "I/O error: {} in path {}", inner, path.display())
}
Json { inner, path } => write!(
formatter,
"JSON error: {} in path {}",
inner,
path.display()
),
}
}
}
/// Error returned by Project::init_place and Project::init_model
#[derive(Debug, Fail)]
pub enum ProjectInitError {
AlreadyExists(PathBuf),
IoError(#[fail(cause)] io::Error),
SaveError(#[fail(cause)] ProjectSaveError),
JsonError(#[fail(cause)] serde_json::Error),
}
impl fmt::Display for ProjectInitError {
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
match self {
ProjectInitError::AlreadyExists(path) => {
write!(output, "Path {} already exists", path.display())
}
ProjectInitError::IoError(inner) => write!(output, "IO error: {}", inner),
ProjectInitError::SaveError(inner) => write!(output, "{}", inner),
ProjectInitError::JsonError(inner) => write!(output, "{}", inner),
}
}
}
/// Error returned by Project::save
#[derive(Debug, Fail)]
pub enum ProjectSaveError {
#[fail(display = "JSON error: {}", _0)]
JsonError(#[fail(cause)] serde_json::Error),
#[fail(display = "IO error: {}", _0)]
IoError(#[fail(cause)] io::Error),
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ProjectNode {
pub class_name: Option<String>,
pub children: BTreeMap<String, ProjectNode>,
pub properties: HashMap<String, UnresolvedRbxValue>,
pub ignore_unknown_instances: Option<bool>,
#[serde(serialize_with = "crate::path_serializer::serialize_option")]
pub path: Option<PathBuf>,
}
impl ProjectNode {
fn validate_reserved_names(&self) {
for (name, child) in &self.children {
if name.starts_with('$') {
warn!(
"Keys starting with '$' are reserved by Rojo to ensure forward compatibility."
);
warn!(
"This project uses the key '{}', which should be renamed.",
name
);
}
child.validate_reserved_names();
}
}
fn to_source_node(&self, project_file_location: &Path) -> SourceProjectNode {
let children = self
.children
.iter()
.map(|(key, value)| (key.clone(), value.to_source_node(project_file_location)))
.collect();
// If paths are relative to the project file, transform them to look
// Unixy and write relative paths instead.
//
// This isn't perfect, since it means that paths like .. will stay as
// absolute paths and make projects non-portable. Fixing this probably
// means keeping the paths relative in the project format and making
// everywhere else in Rojo do the resolution locally.
let path = self.path.as_ref().map(|path| {
let project_folder_location = project_file_location.parent().unwrap();
match path.strip_prefix(project_folder_location) {
Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"),
Err(_) => format!("{}", path.display()),
}
});
SourceProjectNode {
class_name: self.class_name.clone(),
properties: self.properties.clone(),
ignore_unknown_instances: self.ignore_unknown_instances,
children,
path,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Project {
pub name: String,
pub tree: ProjectNode,
pub serve_port: Option<u16>,
pub serve_place_ids: Option<HashSet<u64>>,
pub file_location: PathBuf,
}
impl Project {
pub fn init_place(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
let project_path = Project::pick_path_for_init(project_fuzzy_path)?;
let project_name = if project_fuzzy_path == project_path {
project_fuzzy_path
.parent()
.expect("Path did not have a parent directory")
.file_name()
.expect("Path did not have a file name")
.to_str()
.expect("Path had invalid Unicode")
} else {
project_fuzzy_path
.file_name()
.expect("Path did not have a file name")
.to_str()
.expect("Path had invalid Unicode")
};
let mut project = Project::load_from_str(DEFAULT_PLACE, &project_path)
.map_err(ProjectInitError::JsonError)?;
project.name = project_name.to_owned();
project.save().map_err(ProjectInitError::SaveError)?;
Ok(project_path)
}
pub fn init_model(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
let project_path = Project::pick_path_for_init(project_fuzzy_path)?;
let project_name = if project_fuzzy_path == project_path {
project_fuzzy_path
.parent()
.expect("Path did not have a parent directory")
.file_name()
.expect("Path did not have a file name")
.to_str()
.expect("Path had invalid Unicode")
} else {
project_fuzzy_path
.file_name()
.expect("Path did not have a file name")
.to_str()
.expect("Path had invalid Unicode")
};
let project_folder_path = project_path
.parent()
.expect("Path did not have a parent directory");
let tree = ProjectNode {
path: Some(project_folder_path.join("src")),
..Default::default()
};
let project = Project {
name: project_name.to_string(),
tree,
serve_port: None,
serve_place_ids: None,
file_location: project_path.clone(),
};
project.save().map_err(ProjectInitError::SaveError)?;
Ok(project_path)
}
fn pick_path_for_init(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
let is_exact = project_fuzzy_path.extension().is_some();
let project_path = if is_exact {
project_fuzzy_path.to_path_buf()
} else {
project_fuzzy_path.join(PROJECT_FILENAME)
};
match fs::metadata(&project_path) {
Err(error) => match error.kind() {
io::ErrorKind::NotFound => {}
_ => return Err(ProjectInitError::IoError(error)),
},
Ok(_) => return Err(ProjectInitError::AlreadyExists(project_path)),
}
Ok(project_path)
}
fn locate(start_location: &Path) -> Option<PathBuf> {
// TODO: Check for specific error kinds, convert 'not found' to Result.
let location_metadata = fs::metadata(start_location).ok()?;
// If this is a file, assume it's the config the user was looking for.
if location_metadata.is_file() {
return Some(start_location.to_path_buf());
} else if location_metadata.is_dir() {
let with_file = start_location.join(PROJECT_FILENAME);
if let Ok(file_metadata) = fs::metadata(&with_file) {
if file_metadata.is_file() {
return Some(with_file);
}
}
let with_compat_file = start_location.join(COMPAT_PROJECT_FILENAME);
if let Ok(file_metadata) = fs::metadata(&with_compat_file) {
if file_metadata.is_file() {
return Some(with_compat_file);
}
}
}
match start_location.parent() {
Some(parent_location) => Self::locate(parent_location),
None => None,
}
}
fn load_from_str(
contents: &str,
project_file_location: &Path,
) -> Result<Project, serde_json::Error> {
let parsed: SourceProject = serde_json::from_str(&contents)?;
Ok(parsed.into_project(project_file_location))
}
pub fn load_from_slice(
contents: &[u8],
project_file_location: &Path,
) -> Result<Project, serde_json::Error> {
let parsed: SourceProject = serde_json::from_slice(&contents)?;
Ok(parsed.into_project(project_file_location))
}
pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result<Project, ProjectLoadError> {
if let Some(project_path) = Self::locate(fuzzy_project_location) {
Self::load_exact(&project_path)
} else {
Project::warn_if_4x_project_present(fuzzy_project_location);
Err(ProjectLoadError::NotFound)
}
}
pub fn load_exact(project_file_location: &Path) -> Result<Project, ProjectLoadError> {
let contents =
fs::read_to_string(project_file_location).map_err(|error| match error.kind() {
io::ErrorKind::NotFound => ProjectLoadError::NotFound,
_ => ProjectLoadError::Io {
inner: error,
path: project_file_location.to_path_buf(),
},
})?;
let parsed: SourceProject =
serde_json::from_str(&contents).map_err(|error| ProjectLoadError::Json {
inner: error,
path: project_file_location.to_path_buf(),
})?;
let project = parsed.into_project(project_file_location);
project.check_compatibility();
Ok(project)
}
pub fn save(&self) -> Result<(), ProjectSaveError> {
let source_project = self.to_source_project();
let mut file = File::create(&self.file_location).map_err(ProjectSaveError::IoError)?;
serde_json::to_writer_pretty(&mut file, &source_project)
.map_err(ProjectSaveError::JsonError)?;
Ok(())
}
/// Checks if there are any compatibility issues with this project file and
/// warns the user if there are any.
fn check_compatibility(&self) {
let file_name = self
.file_location
.file_name()
.expect("Project file path did not have a file name")
.to_str()
.expect("Project file path was not valid Unicode");
if file_name == COMPAT_PROJECT_FILENAME {
warn!("Rojo's default project file name changed in 0.5.0-alpha3.");
warn!("Support for the old project file name will be dropped before 0.5.0 releases.");
warn!("Your project file is named {}", COMPAT_PROJECT_FILENAME);
warn!("Rename your project file to {}", PROJECT_FILENAME);
} else if !file_name.ends_with(".project.json") {
warn!("Starting in Rojo 0.5.0-alpha3, it's recommended to give all project files the");
warn!(".project.json extension. This helps Rojo differentiate project files from");
warn!("other JSON files!");
}
self.tree.validate_reserved_names();
}
/// Issues a warning if no Rojo 0.5.x project is found, but there's a legacy
/// 0.4.x project in the directory.
fn warn_if_4x_project_present(folder: &Path) {
let file_path = folder.join("rojo.json");
if fs::metadata(file_path).is_ok() {
warn!("No Rojo 0.5 project file was found, but a Rojo 0.4 project was.");
warn!("Rojo 0.5.x uses 'default.project.json' files");
warn!("Rojo 0.5.x uses 'rojo.json' files");
warn!("");
warn!("For help upgrading, see:");
warn!("https://lpghatguy.github.io/rojo/guide/migrating-to-epiphany/");
}
}
pub fn folder_location(&self) -> &Path {
self.file_location.parent().unwrap()
}
fn to_source_project(&self) -> SourceProject {
SourceProject {
name: self.name.clone(),
tree: self.tree.to_source_node(&self.file_location),
serve_port: self.serve_port,
serve_place_ids: self.serve_place_ids.clone(),
}
}
}

30
src/serve_session.rs Normal file
View File

@@ -0,0 +1,30 @@
use std::collections::HashSet;
use crate::{project::Project, session_id::SessionId};
/// Contains all of the state for a Rojo serve session.
pub struct ServeSession {
root_project: Option<Project>,
session_id: SessionId,
}
impl ServeSession {
pub fn new(root_project: Option<Project>) -> ServeSession {
let session_id = SessionId::new();
ServeSession {
session_id,
root_project,
}
}
pub fn session_id(&self) -> SessionId {
self.session_id
}
pub fn serve_place_ids(&self) -> Option<&HashSet<u64>> {
self.root_project
.as_ref()
.and_then(|project| project.serve_place_ids.as_ref())
}
}

11
src/session_id.rs Normal file
View File

@@ -0,0 +1,11 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SessionId(Uuid);
impl SessionId {
pub fn new() -> SessionId {
SessionId(Uuid::new_v4())
}
}

View File

@@ -0,0 +1,62 @@
//! Defines the structure of an instance snapshot.
use std::{borrow::Cow, collections::HashMap};
use rbx_dom_weak::{RbxId, RbxTree, RbxValue};
/// A lightweight description of what an instance should look like. Attempts to
/// be somewhat memory efficient by borrowing from its source data, indicated by
/// the lifetime parameter, `'source`.
///
// Possible future improvements:
// - Use refcounted/interned strings
// - Replace use of RbxValue with a sum of RbxValue + borrowed value
#[derive(Debug, Clone, PartialEq)]
pub struct InstanceSnapshot<'source> {
pub snapshot_id: Option<RbxId>,
pub name: Cow<'source, str>,
pub class_name: Cow<'source, str>,
pub properties: HashMap<String, RbxValue>,
pub children: Vec<InstanceSnapshot<'source>>,
// TODO: Snapshot source, like a file or a project node?
}
impl<'source> InstanceSnapshot<'source> {
pub fn get_owned(&'source self) -> InstanceSnapshot<'static> {
let children: Vec<InstanceSnapshot<'static>> = self
.children
.iter()
.map(InstanceSnapshot::get_owned)
.collect();
InstanceSnapshot {
snapshot_id: None,
name: Cow::Owned(self.name.clone().into_owned()),
class_name: Cow::Owned(self.class_name.clone().into_owned()),
properties: self.properties.clone(),
children,
}
}
pub fn from_tree(tree: &RbxTree, id: RbxId) -> InstanceSnapshot<'static> {
let instance = tree
.get_instance(id)
.expect("instance did not exist in tree");
let children = instance
.get_children_ids()
.iter()
.cloned()
.map(|id| InstanceSnapshot::from_tree(tree, id))
.collect();
InstanceSnapshot {
snapshot_id: Some(id),
name: Cow::Owned(instance.name.clone()),
class_name: Cow::Owned(instance.class_name.clone()),
properties: instance.properties.clone(),
children,
}
}
}

29
src/snapshot/mod.rs Normal file
View File

@@ -0,0 +1,29 @@
//! This module defines the instance snapshot subsystem of Rojo.
//!
//! It defines a way to define the instance tree of a project as a pure function
//! of the filesystem by providing a lightweight instance 'snapshot' type, a
//! method to generate minimal patches, and a method that applies those patches.
//!
//! The aim with this approach is to reduce the number of bugs that arise from
//! attempting to manually update instances in response to filesystem updates.
//! Instead of surgically identifying what needs to change, we can do rough
//! "damage-painting", running our relatively fast snapshot function over
//! anything that could have changed and running it through a diffing function
//! to minimize the set of real changes.
//!
//! Building out a snapshot reconciler is mostly overkill for scripts, since
//! their relationships are mostly simple and well-defined. It becomes very
//! important, however, when dealing with large opaque model files and
//! user-defined plugins.
#![allow(dead_code)]
mod instance_snapshot;
mod patch;
mod patch_apply;
mod patch_compute;
pub use instance_snapshot::InstanceSnapshot;
pub use patch::*;
pub use patch_apply::apply_patch_set;
pub use patch_compute::compute_patch_set;

44
src/snapshot/patch.rs Normal file
View File

@@ -0,0 +1,44 @@
//! Defines the data structures used for describing instance patches.
use std::collections::HashMap;
use rbx_dom_weak::{RbxId, RbxValue};
use super::InstanceSnapshot;
/// A set of different kinds of patches that can be applied to an RbxTree.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct PatchSet<'a> {
pub removed_instances: Vec<RbxId>,
pub added_instances: Vec<PatchAddInstance<'a>>,
pub updated_instances: Vec<PatchUpdateInstance>,
}
impl<'a> PatchSet<'a> {
pub fn new() -> PatchSet<'a> {
PatchSet {
removed_instances: Vec::new(),
added_instances: Vec::new(),
updated_instances: Vec::new(),
}
}
}
/// A patch containing an instance that was added to the tree.
#[derive(Debug, Clone, PartialEq)]
pub struct PatchAddInstance<'a> {
pub parent_id: RbxId,
pub instance: InstanceSnapshot<'a>,
}
/// A patch indicating that properties (or the name) of an instance changed.
#[derive(Debug, Clone, PartialEq)]
pub struct PatchUpdateInstance {
pub id: RbxId,
pub changed_name: Option<String>,
pub changed_class_name: Option<String>,
/// Contains all changed properties. If a property is assigned to `None`,
/// then that property has been removed.
pub changed_properties: HashMap<String, Option<RbxValue>>,
}

240
src/snapshot/patch_apply.rs Normal file
View File

@@ -0,0 +1,240 @@
//! Defines the algorithm for applying generated patches.
use std::collections::HashMap;
use rbx_dom_weak::{RbxId, RbxInstanceProperties, RbxTree, RbxValue};
use super::{
patch::{PatchSet, PatchUpdateInstance},
InstanceSnapshot,
};
pub fn apply_patch_set(tree: &mut RbxTree, patch_set: &PatchSet) {
let mut context = PatchApplyContext::default();
for removed_id in &patch_set.removed_instances {
tree.remove_instance(*removed_id);
}
for add_patch in &patch_set.added_instances {
apply_add_child(&mut context, tree, add_patch.parent_id, &add_patch.instance);
}
for update_patch in &patch_set.updated_instances {
apply_update_child(&context, tree, update_patch);
}
apply_deferred_properties(context, tree);
}
#[derive(Default)]
struct PatchApplyContext {
snapshot_id_to_instance_id: HashMap<RbxId, RbxId>,
properties_to_apply: HashMap<RbxId, HashMap<String, RbxValue>>,
}
/// Apply properties that were deferred in order to get more information.
///
/// Ref properties from snapshots refer to eachother via snapshot ID. Some of
/// these properties are transformed when the patch is computed, notably the
/// instances that the patch computing method is able to pair up.
///
/// The remaining Ref properties need to be handled during patch application,
/// where we build up a map of snapshot IDs to instance IDs as they're created,
/// then apply properties all at once at the end.
fn apply_deferred_properties(context: PatchApplyContext, tree: &mut RbxTree) {
for (id, mut properties) in context.properties_to_apply {
let instance = tree
.get_instance_mut(id)
.expect("Invalid instance ID in deferred property map");
for property_value in properties.values_mut() {
if let RbxValue::Ref { value: Some(id) } = property_value {
if let Some(&instance_id) = context.snapshot_id_to_instance_id.get(id) {
*property_value = RbxValue::Ref {
value: Some(instance_id),
};
}
}
}
instance.properties = properties;
}
}
fn apply_add_child(
context: &mut PatchApplyContext,
tree: &mut RbxTree,
parent_id: RbxId,
snapshot: &InstanceSnapshot,
) {
let properties = RbxInstanceProperties {
name: snapshot.name.clone().into_owned(),
class_name: snapshot.class_name.clone().into_owned(),
// Property assignment is deferred until after we know about all
// instances in this patch.
properties: HashMap::new(),
};
let id = tree.insert_instance(properties, parent_id);
context
.properties_to_apply
.insert(id, snapshot.properties.clone());
if let Some(snapshot_id) = snapshot.snapshot_id {
context.snapshot_id_to_instance_id.insert(snapshot_id, id);
}
for child_snapshot in &snapshot.children {
apply_add_child(context, tree, id, child_snapshot);
}
}
fn apply_update_child(
context: &PatchApplyContext,
tree: &mut RbxTree,
patch: &PatchUpdateInstance,
) {
let instance = tree
.get_instance_mut(patch.id)
.expect("Instance referred to by patch does not exist");
if let Some(name) = &patch.changed_name {
instance.name = name.clone();
}
if let Some(class_name) = &patch.changed_class_name {
instance.class_name = class_name.clone();
}
for (key, property_entry) in &patch.changed_properties {
match property_entry {
// Ref values need to be potentially rewritten from snapshot IDs to
// instance IDs if they referred to an instance that was created as
// part of this patch.
Some(RbxValue::Ref { value: Some(id) }) => {
let new_id = context.snapshot_id_to_instance_id.get(id).unwrap_or(id);
instance.properties.insert(
key.clone(),
RbxValue::Ref {
value: Some(*new_id),
},
);
}
Some(value) => {
instance.properties.insert(key.clone(), value.clone());
}
None => {
instance.properties.remove(key);
}
}
}
}
#[cfg(test)]
mod test {
use super::*;
use std::{borrow::Cow, collections::HashMap};
use maplit::hashmap;
use rbx_dom_weak::RbxValue;
use super::super::patch::PatchAddInstance;
#[test]
fn add_from_empty() {
let _ = env_logger::try_init();
let mut tree = RbxTree::new(RbxInstanceProperties {
name: "Folder".to_owned(),
class_name: "Folder".to_owned(),
properties: HashMap::new(),
});
let root_id = tree.get_root_id();
let snapshot = InstanceSnapshot {
snapshot_id: None,
name: Cow::Borrowed("Foo"),
class_name: Cow::Borrowed("Bar"),
properties: hashmap! {
"Baz".to_owned() => RbxValue::Int32 { value: 5 },
},
children: Vec::new(),
};
let patch_set = PatchSet {
added_instances: vec![PatchAddInstance {
parent_id: root_id,
instance: snapshot.clone(),
}],
..Default::default()
};
apply_patch_set(&mut tree, &patch_set);
let root_instance = tree.get_instance(root_id).unwrap();
let child_id = root_instance.get_children_ids()[0];
let child_instance = tree.get_instance(child_id).unwrap();
assert_eq!(child_instance.name.as_str(), &snapshot.name);
assert_eq!(child_instance.class_name.as_str(), &snapshot.class_name);
assert_eq!(&child_instance.properties, &snapshot.properties);
assert!(child_instance.get_children_ids().is_empty());
}
#[test]
fn update_existing() {
let _ = env_logger::try_init();
let mut tree = RbxTree::new(RbxInstanceProperties {
name: "OldName".to_owned(),
class_name: "OldClassName".to_owned(),
properties: hashmap! {
"Foo".to_owned() => RbxValue::Int32 { value: 7 },
"Bar".to_owned() => RbxValue::Int32 { value: 3 },
"Unchanged".to_owned() => RbxValue::Int32 { value: -5 },
},
});
let root_id = tree.get_root_id();
let patch = PatchUpdateInstance {
id: root_id,
changed_name: Some("Foo".to_owned()),
changed_class_name: Some("NewClassName".to_owned()),
changed_properties: hashmap! {
// The value of Foo has changed
"Foo".to_owned() => Some(RbxValue::Int32 { value: 8 }),
// Bar has been deleted
"Bar".to_owned() => None,
// Baz has been added
"Baz".to_owned() => Some(RbxValue::Int32 { value: 10 }),
},
};
let patch_set = PatchSet {
updated_instances: vec![patch],
..Default::default()
};
apply_patch_set(&mut tree, &patch_set);
let expected_properties = hashmap! {
"Foo".to_owned() => RbxValue::Int32 { value: 8 },
"Baz".to_owned() => RbxValue::Int32 { value: 10 },
"Unchanged".to_owned() => RbxValue::Int32 { value: -5 },
};
let root_instance = tree.get_instance(root_id).unwrap();
assert_eq!(root_instance.name, "Foo");
assert_eq!(root_instance.class_name, "NewClassName");
assert_eq!(root_instance.properties, expected_properties);
}
}

View File

@@ -0,0 +1,330 @@
//! Defines the algorithm for computing a roughly-minimal patch set given an
//! existing instance tree and an instance snapshot.
use std::collections::{HashMap, HashSet};
use rbx_dom_weak::{RbxId, RbxInstance, RbxTree, RbxValue};
use super::{
patch::{PatchAddInstance, PatchSet, PatchUpdateInstance},
InstanceSnapshot,
};
pub fn compute_patch_set<'a>(
snapshot: &'a InstanceSnapshot,
tree: &RbxTree,
id: RbxId,
) -> PatchSet<'a> {
let mut patch_set = PatchSet::new();
let mut context = ComputePatchContext::default();
compute_patch_set_internal(&mut context, snapshot, tree, id, &mut patch_set);
// Rewrite Ref properties to refer to instance IDs instead of snapshot IDs
// for all of the IDs that we know about so far.
rewrite_refs_in_updates(&context, &mut patch_set.updated_instances);
rewrite_refs_in_additions(&context, &mut patch_set.added_instances);
patch_set
}
#[derive(Default)]
struct ComputePatchContext {
snapshot_id_to_instance_id: HashMap<RbxId, RbxId>,
}
fn rewrite_refs_in_updates(context: &ComputePatchContext, updates: &mut [PatchUpdateInstance]) {
for update in updates {
for property_value in update.changed_properties.values_mut() {
if let Some(RbxValue::Ref { value: Some(id) }) = property_value {
if let Some(&instance_id) = context.snapshot_id_to_instance_id.get(id) {
*property_value = Some(RbxValue::Ref {
value: Some(instance_id),
});
}
}
}
}
}
fn rewrite_refs_in_additions(context: &ComputePatchContext, additions: &mut [PatchAddInstance]) {
for addition in additions {
rewrite_refs_in_snapshot(context, &mut addition.instance);
}
}
fn rewrite_refs_in_snapshot(context: &ComputePatchContext, snapshot: &mut InstanceSnapshot) {
for property_value in snapshot.properties.values_mut() {
if let RbxValue::Ref { value: Some(id) } = property_value {
if let Some(&instance_id) = context.snapshot_id_to_instance_id.get(id) {
*property_value = RbxValue::Ref {
value: Some(instance_id),
};
}
}
}
for child in &mut snapshot.children {
rewrite_refs_in_snapshot(context, child);
}
}
fn compute_patch_set_internal<'a>(
context: &mut ComputePatchContext,
snapshot: &'a InstanceSnapshot,
tree: &RbxTree,
id: RbxId,
patch_set: &mut PatchSet<'a>,
) {
if let Some(snapshot_id) = snapshot.snapshot_id {
context.snapshot_id_to_instance_id.insert(snapshot_id, id);
}
let instance = tree
.get_instance(id)
.expect("Instance did not exist in tree");
compute_property_patches(snapshot, instance, patch_set);
compute_children_patches(context, snapshot, tree, id, patch_set);
}
fn compute_property_patches(
snapshot: &InstanceSnapshot,
instance: &RbxInstance,
patch_set: &mut PatchSet,
) {
let mut visited_properties = HashSet::new();
let mut changed_properties = HashMap::new();
let changed_name = if snapshot.name == instance.name {
None
} else {
Some(snapshot.name.clone().into_owned())
};
let changed_class_name = if snapshot.class_name == instance.class_name {
None
} else {
Some(snapshot.class_name.clone().into_owned())
};
for (name, snapshot_value) in &snapshot.properties {
visited_properties.insert(name.as_str());
match instance.properties.get(name) {
Some(instance_value) => {
if snapshot_value != instance_value {
changed_properties.insert(name.clone(), Some(snapshot_value.clone()));
}
}
None => {
changed_properties.insert(name.clone(), Some(snapshot_value.clone()));
}
}
}
for name in instance.properties.keys() {
if visited_properties.contains(name.as_str()) {
continue;
}
changed_properties.insert(name.clone(), None);
}
if changed_properties.is_empty() && changed_name.is_none() {
return;
}
patch_set.updated_instances.push(PatchUpdateInstance {
id: instance.get_id(),
changed_name,
changed_class_name,
changed_properties,
});
}
fn compute_children_patches<'a>(
context: &mut ComputePatchContext,
snapshot: &'a InstanceSnapshot,
tree: &RbxTree,
id: RbxId,
patch_set: &mut PatchSet<'a>,
) {
let instance = tree
.get_instance(id)
.expect("Instance did not exist in tree");
let instance_children = instance.get_children_ids();
let mut paired_instances = vec![false; instance_children.len()];
for snapshot_child in snapshot.children.iter() {
let matching_instance =
instance_children
.iter()
.enumerate()
.find(|(instance_index, instance_child_id)| {
if paired_instances[*instance_index] {
return false;
}
let instance_child = tree
.get_instance(**instance_child_id)
.expect("Instance did not exist in tree");
if snapshot_child.name == instance_child.name
&& snapshot_child.class_name == instance_child.class_name
{
paired_instances[*instance_index] = true;
return true;
}
false
});
match matching_instance {
Some((_, instance_child_id)) => {
compute_patch_set_internal(
context,
snapshot_child,
tree,
*instance_child_id,
patch_set,
);
}
None => {
patch_set.added_instances.push(PatchAddInstance {
parent_id: id,
instance: snapshot_child.clone(),
});
}
}
}
for (instance_index, instance_child_id) in instance_children.iter().enumerate() {
if paired_instances[instance_index] {
continue;
}
patch_set.removed_instances.push(*instance_child_id);
}
}
#[cfg(test)]
mod test {
use super::*;
use std::borrow::Cow;
use maplit::hashmap;
use rbx_dom_weak::RbxInstanceProperties;
/// This test makes sure that rewriting refs in instance update patches to
/// instances that already exists works. We should be able to correlate the
/// snapshot ID and instance ID during patch computation and replace the
/// value before returning from compute_patch_set.
#[test]
fn rewrite_ref_existing_instance_update() {
let tree = RbxTree::new(RbxInstanceProperties {
name: "foo".to_owned(),
class_name: "foo".to_owned(),
properties: HashMap::new(),
});
let root_id = tree.get_root_id();
// This snapshot should be identical to the existing tree except for the
// addition of a prop named Self, which is a self-referential Ref.
let snapshot_id = RbxId::new();
let snapshot = InstanceSnapshot {
snapshot_id: Some(snapshot_id),
properties: hashmap! {
"Self".to_owned() => RbxValue::Ref {
value: Some(snapshot_id),
}
},
name: Cow::Borrowed("foo"),
class_name: Cow::Borrowed("foo"),
children: Vec::new(),
};
let patch_set = compute_patch_set(&snapshot, &tree, root_id);
let expected_patch_set = PatchSet {
updated_instances: vec![PatchUpdateInstance {
id: root_id,
changed_name: None,
changed_class_name: None,
changed_properties: hashmap! {
"Self".to_owned() => Some(RbxValue::Ref {
value: Some(root_id),
}),
},
}],
added_instances: Vec::new(),
removed_instances: Vec::new(),
};
assert_eq!(patch_set, expected_patch_set);
}
/// The same as rewrite_ref_existing_instance_update, except that the
/// property is added in a new instance instead of modifying an existing
/// one.
#[test]
fn rewrite_ref_existing_instance_addition() {
let tree = RbxTree::new(RbxInstanceProperties {
name: "foo".to_owned(),
class_name: "foo".to_owned(),
properties: HashMap::new(),
});
let root_id = tree.get_root_id();
// This patch describes the existing instance with a new child added.
let snapshot_id = RbxId::new();
let snapshot = InstanceSnapshot {
snapshot_id: Some(snapshot_id),
children: vec![InstanceSnapshot {
properties: hashmap! {
"Self".to_owned() => RbxValue::Ref {
value: Some(snapshot_id),
},
},
snapshot_id: None,
name: Cow::Borrowed("child"),
class_name: Cow::Borrowed("child"),
children: Vec::new(),
}],
properties: HashMap::new(),
name: Cow::Borrowed("foo"),
class_name: Cow::Borrowed("foo"),
};
let patch_set = compute_patch_set(&snapshot, &tree, root_id);
let expected_patch_set = PatchSet {
added_instances: vec![PatchAddInstance {
parent_id: root_id,
instance: InstanceSnapshot {
snapshot_id: None,
properties: hashmap! {
"Self".to_owned() => RbxValue::Ref {
value: Some(root_id),
},
},
name: Cow::Borrowed("child"),
class_name: Cow::Borrowed("child"),
children: Vec::new(),
},
}],
updated_instances: Vec::new(),
removed_instances: Vec::new(),
};
assert_eq!(patch_set, expected_patch_set);
}
}

View File

@@ -0,0 +1,7 @@
pub struct InstanceSnapshotContext {
/// Empty struct that will be used later to fill out required Lua state for
/// user plugins.
pub plugin_context: Option<()>,
}
pub struct ImfsSnapshotContext;

View File

@@ -0,0 +1,169 @@
use std::{borrow::Cow, collections::BTreeMap};
use maplit::hashmap;
use rbx_dom_weak::{RbxId, RbxTree, RbxValue};
use serde::Serialize;
use crate::{
imfs::new::{Imfs, ImfsEntry, ImfsFetcher},
snapshot::InstanceSnapshot,
};
use super::middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware};
pub struct SnapshotCsv;
impl SnapshotMiddleware for SnapshotCsv {
fn from_imfs<F: ImfsFetcher>(
imfs: &mut Imfs<F>,
entry: &ImfsEntry,
) -> SnapshotInstanceResult<'static> {
if entry.is_directory() {
return Ok(None);
}
let file_name = entry.path().file_name().unwrap().to_string_lossy();
if !file_name.ends_with(".csv") {
return Ok(None);
}
let instance_name = entry
.path()
.file_stem()
.expect("Could not extract file stem")
.to_string_lossy()
.to_string();
let table_contents = convert_localization_csv(entry.contents(imfs)?);
Ok(Some(InstanceSnapshot {
snapshot_id: None,
name: Cow::Owned(instance_name),
class_name: Cow::Borrowed("LocalizationTable"),
properties: hashmap! {
"Contents".to_owned() => RbxValue::String {
value: table_contents,
},
},
children: Vec::new(),
}))
}
fn from_instance(_tree: &RbxTree, _id: RbxId) -> SnapshotFileResult {
unimplemented!("Snapshotting CSV localization tables");
}
}
/// Struct that holds any valid row from a Roblox CSV translation table.
///
/// We manually deserialize into this table from CSV, but let serde_json handle
/// serialization.
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalizationEntry<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
example: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<&'a str>,
// We use a BTreeMap here to get deterministic output order.
values: BTreeMap<&'a str, &'a str>,
}
/// Normally, we'd be able to let the csv crate construct our struct for us.
///
/// However, because of a limitation with Serde's 'flatten' feature, it's not
/// possible presently to losslessly collect extra string values while using
/// csv+Serde.
///
/// https://github.com/BurntSushi/rust-csv/issues/151
///
/// This function operates in one step in order to minimize data-copying.
fn convert_localization_csv(contents: &[u8]) -> String {
let mut reader = csv::Reader::from_reader(contents);
let headers = reader.headers().expect("TODO: Handle csv errors").clone();
let mut records = Vec::new();
for record in reader.into_records() {
let record = record.expect("TODO: Handle csv errors");
records.push(record);
}
let mut entries = Vec::new();
for record in &records {
let mut entry = LocalizationEntry::default();
for (header, value) in headers.iter().zip(record.into_iter()) {
if header.is_empty() || value.is_empty() {
continue;
}
match header {
"Key" => entry.key = Some(value),
"Source" => entry.source = Some(value),
"Context" => entry.context = Some(value),
"Example" => entry.example = Some(value),
_ => {
entry.values.insert(header, value);
}
}
}
if entry.key.is_none() && entry.source.is_none() {
continue;
}
entries.push(entry);
}
serde_json::to_string(&entries).expect("Could not encode JSON for localization table")
}
#[cfg(test)]
mod test {
use super::*;
use crate::imfs::new::{ImfsSnapshot, NoopFetcher};
#[test]
fn csv_from_imfs() {
let mut imfs = Imfs::new(NoopFetcher);
let file = ImfsSnapshot::file(
r#"
Key,Source,Context,Example,es
Ack,Ack!,,An exclamation of despair,¡Ay!"#,
);
imfs.load_from_snapshot("/foo.csv", file);
let entry = imfs.get("/foo.csv").unwrap();
let instance_snapshot = SnapshotCsv::from_imfs(&mut imfs, &entry).unwrap().unwrap();
let expected_contents =
r#"[{"key":"Ack","example":"An exclamation of despair","source":"Ack!","values":{"es":"¡Ay!"}}]"#;
assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "LocalizationTable");
assert_eq!(instance_snapshot.children, Vec::new());
assert_eq!(
instance_snapshot.properties,
hashmap! {
"Contents".to_owned() => RbxValue::String {
value: expected_contents.to_owned(),
},
}
);
}
}

View File

@@ -0,0 +1,121 @@
use std::{borrow::Cow, collections::HashMap};
use rbx_dom_weak::{RbxId, RbxTree};
use crate::{
imfs::new::{DirectorySnapshot, Imfs, ImfsEntry, ImfsFetcher, ImfsSnapshot},
snapshot::InstanceSnapshot,
};
use super::{
middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware},
snapshot_from_imfs, snapshot_from_instance,
};
pub struct SnapshotDir;
impl SnapshotMiddleware for SnapshotDir {
fn from_imfs<F: ImfsFetcher>(
imfs: &mut Imfs<F>,
entry: &ImfsEntry,
) -> SnapshotInstanceResult<'static> {
if entry.is_file() {
return Ok(None);
}
let children: Vec<ImfsEntry> = entry.children(imfs)?;
let mut snapshot_children = Vec::new();
for child in children.into_iter() {
if let Some(child_snapshot) = snapshot_from_imfs(imfs, &child)? {
snapshot_children.push(child_snapshot);
}
}
let instance_name = entry
.path()
.file_name()
.expect("Could not extract file name")
.to_str()
.unwrap()
.to_string();
Ok(Some(InstanceSnapshot {
snapshot_id: None,
name: Cow::Owned(instance_name),
class_name: Cow::Borrowed("Folder"),
properties: HashMap::new(),
children: snapshot_children,
}))
}
fn from_instance(tree: &RbxTree, id: RbxId) -> SnapshotFileResult {
let instance = tree.get_instance(id).unwrap();
if instance.class_name != "Folder" {
return None;
}
let mut children = HashMap::new();
for child_id in instance.get_children_ids() {
if let Some((name, child)) = snapshot_from_instance(tree, *child_id) {
children.insert(name, child);
}
}
let snapshot = ImfsSnapshot::Directory(DirectorySnapshot { children });
Some((instance.name.clone(), snapshot))
}
}
#[cfg(test)]
mod test {
use super::*;
use maplit::hashmap;
use crate::imfs::new::NoopFetcher;
#[test]
fn empty_folder() {
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir::<String>(HashMap::new());
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo").unwrap();
let instance_snapshot = SnapshotDir::from_imfs(&mut imfs, &entry).unwrap().unwrap();
assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "Folder");
assert_eq!(instance_snapshot.properties, HashMap::new());
assert_eq!(instance_snapshot.children, Vec::new());
}
#[test]
fn folder_in_folder() {
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"Child" => ImfsSnapshot::dir::<String>(HashMap::new()),
});
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo").unwrap();
let instance_snapshot = SnapshotDir::from_imfs(&mut imfs, &entry).unwrap().unwrap();
assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "Folder");
assert_eq!(instance_snapshot.properties, HashMap::new());
assert_eq!(instance_snapshot.children.len(), 1);
let child = &instance_snapshot.children[0];
assert_eq!(child.name, "Child");
assert_eq!(child.class_name, "Folder");
assert_eq!(child.properties, HashMap::new());
assert_eq!(child.children, Vec::new());
}
}

View File

@@ -0,0 +1,91 @@
use std::{error::Error, fmt, path::PathBuf};
use crate::snapshot::InstanceSnapshot;
pub type SnapshotResult<'a> = Result<Option<InstanceSnapshot<'a>>, SnapshotError>;
#[derive(Debug)]
pub struct SnapshotError {
detail: SnapshotErrorDetail,
path: Option<PathBuf>,
}
impl SnapshotError {
pub fn new(detail: SnapshotErrorDetail, path: Option<impl Into<PathBuf>>) -> Self {
SnapshotError {
detail,
path: path.map(Into::into),
}
}
pub(crate) fn file_did_not_exist(path: impl Into<PathBuf>) -> SnapshotError {
SnapshotError {
detail: SnapshotErrorDetail::FileDidNotExist,
path: Some(path.into()),
}
}
pub(crate) fn file_name_bad_unicode(path: impl Into<PathBuf>) -> SnapshotError {
SnapshotError {
detail: SnapshotErrorDetail::FileNameBadUnicode,
path: Some(path.into()),
}
}
pub(crate) fn file_contents_bad_unicode(
inner: std::str::Utf8Error,
path: impl Into<PathBuf>,
) -> SnapshotError {
SnapshotError {
detail: SnapshotErrorDetail::FileContentsBadUnicode { inner },
path: Some(path.into()),
}
}
}
impl Error for SnapshotError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.detail.source()
}
}
impl fmt::Display for SnapshotError {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
match &self.path {
Some(path) => write!(formatter, "{} in path {}", self.detail, path.display()),
None => write!(formatter, "{}", self.detail),
}
}
}
#[derive(Debug)]
pub enum SnapshotErrorDetail {
FileDidNotExist,
FileNameBadUnicode,
FileContentsBadUnicode { inner: std::str::Utf8Error },
}
impl SnapshotErrorDetail {
fn source(&self) -> Option<&(dyn Error + 'static)> {
use self::SnapshotErrorDetail::*;
match self {
FileContentsBadUnicode { inner } => Some(inner),
_ => None,
}
}
}
impl fmt::Display for SnapshotErrorDetail {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
use self::SnapshotErrorDetail::*;
match self {
FileDidNotExist => write!(formatter, "file did not exist"),
FileNameBadUnicode => write!(formatter, "file name had malformed Unicode"),
FileContentsBadUnicode { inner } => {
write!(formatter, "file had malformed unicode: {}", inner)
}
}
}
}

View File

@@ -0,0 +1,189 @@
use std::{borrow::Cow, collections::HashMap};
use rbx_dom_weak::{RbxId, RbxTree, UnresolvedRbxValue};
use rbx_reflection::try_resolve_value;
use serde::Deserialize;
use crate::{
imfs::new::{Imfs, ImfsEntry, ImfsFetcher},
snapshot::InstanceSnapshot,
};
use super::middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware};
pub struct SnapshotJsonModel;
impl SnapshotMiddleware for SnapshotJsonModel {
fn from_imfs<F: ImfsFetcher>(
imfs: &mut Imfs<F>,
entry: &ImfsEntry,
) -> SnapshotInstanceResult<'static> {
if entry.is_directory() {
return Ok(None);
}
let file_name = entry.path().file_name().unwrap().to_string_lossy();
let instance_name = match match_trailing(&file_name, ".model.json") {
Some(name) => name.to_owned(),
None => return Ok(None),
};
let instance: JsonModel =
serde_json::from_slice(entry.contents(imfs)?).expect("TODO: Handle serde_json errors");
if let Some(json_name) = &instance.name {
if json_name != &instance_name {
log::warn!(
"Name from JSON model did not match its file name: {}",
entry.path().display()
);
log::warn!(
"In Rojo < alpha 14, this model is named \"{}\" (from its 'Name' property)",
json_name
);
log::warn!(
"In Rojo >= alpha 14, this model is named \"{}\" (from its file name)",
instance_name
);
log::warn!("'Name' for the top-level instance in a JSON model is now optional and will be ignored.");
}
}
let snapshot = instance.core.into_snapshot(instance_name);
Ok(Some(snapshot))
}
fn from_instance(_tree: &RbxTree, _id: RbxId) -> SnapshotFileResult {
unimplemented!("Snapshotting models");
}
}
fn match_trailing<'a>(input: &'a str, trailer: &str) -> Option<&'a str> {
if input.ends_with(trailer) {
let end = input.len().saturating_sub(trailer.len());
Some(&input[..end])
} else {
None
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JsonModel {
name: Option<String>,
#[serde(flatten)]
core: JsonModelCore,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JsonModelInstance {
name: String,
#[serde(flatten)]
core: JsonModelCore,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JsonModelCore {
class_name: String,
#[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")]
children: Vec<JsonModelInstance>,
#[serde(default = "HashMap::new", skip_serializing_if = "HashMap::is_empty")]
properties: HashMap<String, UnresolvedRbxValue>,
}
impl JsonModelCore {
fn into_snapshot(self, name: String) -> InstanceSnapshot<'static> {
let class_name = self.class_name;
let children = self
.children
.into_iter()
.map(|child| child.core.into_snapshot(child.name))
.collect();
let properties = self
.properties
.into_iter()
.map(|(key, value)| {
try_resolve_value(&class_name, &key, &value).map(|resolved| (key, resolved))
})
.collect::<Result<HashMap<_, _>, _>>()
.expect("TODO: Handle rbx_reflection errors");
InstanceSnapshot {
snapshot_id: None,
name: Cow::Owned(name),
class_name: Cow::Owned(class_name),
properties,
children,
}
}
}
#[cfg(test)]
mod test {
use super::*;
use maplit::hashmap;
use rbx_dom_weak::RbxValue;
use crate::imfs::new::{ImfsSnapshot, NoopFetcher};
#[test]
fn model_from_imfs() {
let mut imfs = Imfs::new(NoopFetcher);
let file = ImfsSnapshot::file(
r#"
{
"Name": "children",
"ClassName": "IntValue",
"Properties": {
"Value": 5
},
"Children": [
{
"Name": "The Child",
"ClassName": "StringValue"
}
]
}
"#,
);
imfs.load_from_snapshot("/foo.model.json", file);
let entry = imfs.get("/foo.model.json").unwrap();
let instance_snapshot = SnapshotJsonModel::from_imfs(&mut imfs, &entry)
.unwrap()
.unwrap();
assert_eq!(
instance_snapshot,
InstanceSnapshot {
snapshot_id: None,
name: Cow::Borrowed("foo"),
class_name: Cow::Borrowed("IntValue"),
properties: hashmap! {
"Value".to_owned() => RbxValue::Int32 {
value: 5,
},
},
children: vec![InstanceSnapshot {
snapshot_id: None,
name: Cow::Borrowed("The Child"),
class_name: Cow::Borrowed("StringValue"),
properties: HashMap::new(),
children: Vec::new(),
},],
}
);
}
}

View File

@@ -0,0 +1,176 @@
use std::{borrow::Cow, str};
use maplit::hashmap;
use rbx_dom_weak::{RbxId, RbxTree, RbxValue};
use crate::{
imfs::new::{FsResultExt, Imfs, ImfsEntry, ImfsFetcher},
snapshot::InstanceSnapshot,
};
use super::middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware};
pub struct SnapshotLua;
impl SnapshotMiddleware for SnapshotLua {
fn from_imfs<F: ImfsFetcher>(
imfs: &mut Imfs<F>,
entry: &ImfsEntry,
) -> SnapshotInstanceResult<'static> {
let file_name = entry.path().file_name().unwrap().to_string_lossy();
if entry.is_directory() {
let module_init_path = entry.path().join("init.lua");
if let Some(init_entry) = imfs.get(module_init_path).with_not_found()? {
if let Some(mut snapshot) = SnapshotLua::from_imfs(imfs, &init_entry)? {
snapshot.name = Cow::Owned(file_name.into_owned());
return Ok(Some(snapshot));
}
}
let server_init_path = entry.path().join("init.server.lua");
if let Some(init_entry) = imfs.get(server_init_path).with_not_found()? {
if let Some(mut snapshot) = SnapshotLua::from_imfs(imfs, &init_entry)? {
snapshot.name = Cow::Owned(file_name.into_owned());
return Ok(Some(snapshot));
}
}
let client_init_path = entry.path().join("init.client.lua");
if let Some(init_entry) = imfs.get(client_init_path).with_not_found()? {
if let Some(mut snapshot) = SnapshotLua::from_imfs(imfs, &init_entry)? {
snapshot.name = Cow::Owned(file_name.into_owned());
return Ok(Some(snapshot));
}
}
}
let (class_name, instance_name) =
if let Some(name) = match_trailing(&file_name, ".server.lua") {
("Script", name)
} else if let Some(name) = match_trailing(&file_name, ".client.lua") {
("LocalScript", name)
} else if let Some(name) = match_trailing(&file_name, ".lua") {
("ModuleScript", name)
} else {
return Ok(None);
};
let contents = entry.contents(imfs)?;
let contents_str = str::from_utf8(contents)
.expect("File content was not valid UTF-8")
.to_string();
let properties = hashmap! {
"Source".to_owned() => RbxValue::String {
value: contents_str,
},
};
Ok(Some(InstanceSnapshot {
snapshot_id: None,
name: Cow::Owned(instance_name.to_owned()),
class_name: Cow::Borrowed(class_name),
properties,
children: Vec::new(),
}))
}
fn from_instance(tree: &RbxTree, id: RbxId) -> SnapshotFileResult {
let instance = tree.get_instance(id).unwrap();
match instance.class_name.as_str() {
"ModuleScript" | "LocalScript" | "Script" => {
unimplemented!("Snapshotting Script instances")
}
_ => None,
}
}
}
fn match_trailing<'a>(input: &'a str, trailer: &str) -> Option<&'a str> {
if input.ends_with(trailer) {
let end = input.len().saturating_sub(trailer.len());
Some(&input[..end])
} else {
None
}
}
#[cfg(test)]
mod test {
use super::*;
use maplit::hashmap;
use crate::imfs::new::{ImfsSnapshot, NoopFetcher};
#[test]
fn module_from_imfs() {
let mut imfs = Imfs::new(NoopFetcher);
let file = ImfsSnapshot::file("Hello there!");
imfs.load_from_snapshot("/foo.lua", file);
let entry = imfs.get("/foo.lua").unwrap();
let instance_snapshot = SnapshotLua::from_imfs(&mut imfs, &entry).unwrap().unwrap();
assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "ModuleScript");
assert_eq!(
instance_snapshot.properties,
hashmap! {
"Source".to_owned() => RbxValue::String {
value: "Hello there!".to_owned(),
},
}
);
}
#[test]
fn server_from_imfs() {
let mut imfs = Imfs::new(NoopFetcher);
let file = ImfsSnapshot::file("Hello there!");
imfs.load_from_snapshot("/foo.server.lua", file);
let entry = imfs.get("/foo.server.lua").unwrap();
let instance_snapshot = SnapshotLua::from_imfs(&mut imfs, &entry).unwrap().unwrap();
assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "Script");
assert_eq!(
instance_snapshot.properties,
hashmap! {
"Source".to_owned() => RbxValue::String {
value: "Hello there!".to_owned(),
},
}
);
}
#[test]
fn client_from_imfs() {
let mut imfs = Imfs::new(NoopFetcher);
let file = ImfsSnapshot::file("Hello there!");
imfs.load_from_snapshot("/foo.client.lua", file);
let entry = imfs.get("/foo.client.lua").unwrap();
let instance_snapshot = SnapshotLua::from_imfs(&mut imfs, &entry).unwrap().unwrap();
assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "LocalScript");
assert_eq!(
instance_snapshot.properties,
hashmap! {
"Source".to_owned() => RbxValue::String {
value: "Hello there!".to_owned(),
},
}
);
}
}

View File

@@ -0,0 +1,27 @@
use std::path::{Path, PathBuf};
use rbx_dom_weak::{RbxId, RbxTree};
use crate::{
imfs::{
new::{Imfs, ImfsEntry, ImfsFetcher, ImfsSnapshot},
FsResult,
},
snapshot::InstanceSnapshot,
};
pub type SnapshotInstanceResult<'a> = FsResult<Option<InstanceSnapshot<'a>>>;
pub type SnapshotFileResult = Option<(String, ImfsSnapshot)>;
pub trait SnapshotMiddleware {
fn from_imfs<F: ImfsFetcher>(
imfs: &mut Imfs<F>,
entry: &ImfsEntry,
) -> SnapshotInstanceResult<'static>;
fn from_instance(tree: &RbxTree, id: RbxId) -> SnapshotFileResult;
fn change_affects_paths(path: &Path) -> Vec<PathBuf> {
vec![path.to_path_buf()]
}
}

View File

@@ -0,0 +1,76 @@
//! Defines the semantics that Rojo uses to turn entries on the filesystem into
//! Roblox instances using the instance snapshot subsystem.
#![allow(dead_code)]
mod context;
mod csv;
mod dir;
mod error;
mod json_model;
mod lua;
mod middleware;
mod project;
mod rbxm;
mod rbxmx;
mod txt;
use rbx_dom_weak::{RbxId, RbxTree};
use self::{
csv::SnapshotCsv,
dir::SnapshotDir,
json_model::SnapshotJsonModel,
lua::SnapshotLua,
middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware},
project::SnapshotProject,
rbxm::SnapshotRbxm,
rbxmx::SnapshotRbxmx,
txt::SnapshotTxt,
};
use crate::imfs::new::{Imfs, ImfsEntry, ImfsFetcher};
macro_rules! middlewares {
( $($middleware: ident,)* ) => {
/// Generates a snapshot of instances from the given ImfsEntry.
pub fn snapshot_from_imfs<F: ImfsFetcher>(
imfs: &mut Imfs<F>,
entry: &ImfsEntry,
) -> SnapshotInstanceResult<'static> {
$(
log::trace!("trying middleware {} on {}", stringify!($middleware), entry.path().display());
if let Some(snapshot) = $middleware::from_imfs(imfs, entry)? {
log::trace!("middleware {} success on {}", stringify!($middleware), entry.path().display());
return Ok(Some(snapshot));
}
)*
log::trace!("no middleware returned Ok(Some)");
Ok(None)
}
/// Generates an in-memory filesystem snapshot of the given Roblox
/// instance.
pub fn snapshot_from_instance(tree: &RbxTree, id: RbxId) -> SnapshotFileResult {
$(
if let Some(result) = $middleware::from_instance(tree, id) {
return Some(result);
}
)*
None
}
};
}
middlewares! {
SnapshotProject,
SnapshotJsonModel,
SnapshotRbxmx,
SnapshotRbxm,
SnapshotLua,
SnapshotCsv,
SnapshotTxt,
SnapshotDir,
}

View File

@@ -0,0 +1,501 @@
use std::{borrow::Cow, collections::HashMap};
use rbx_dom_weak::{RbxId, RbxTree};
use rbx_reflection::try_resolve_value;
use crate::{
imfs::{
new::{Imfs, ImfsEntry, ImfsFetcher},
FsErrorKind,
},
project::{Project, ProjectNode},
snapshot::InstanceSnapshot,
};
use super::{
middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware},
snapshot_from_imfs,
};
pub struct SnapshotProject;
impl SnapshotMiddleware for SnapshotProject {
fn from_imfs<F: ImfsFetcher>(
imfs: &mut Imfs<F>,
entry: &ImfsEntry,
) -> SnapshotInstanceResult<'static> {
if entry.is_directory() {
let project_path = entry.path().join("default.project.json");
match imfs.get(project_path) {
Err(ref err) if err.kind() == FsErrorKind::NotFound => {}
Err(err) => return Err(err),
Ok(entry) => return SnapshotProject::from_imfs(imfs, &entry),
}
}
if !entry.path().to_string_lossy().ends_with(".project.json") {
return Ok(None);
}
let project = Project::load_from_slice(entry.contents(imfs)?, entry.path())
.expect("Invalid project file");
snapshot_project_node(&project.name, &project.tree, imfs)
}
fn from_instance(_tree: &RbxTree, _id: RbxId) -> SnapshotFileResult {
// TODO: Supporting turning instances into projects
None
}
}
fn snapshot_project_node<F: ImfsFetcher>(
instance_name: &str,
node: &ProjectNode,
imfs: &mut Imfs<F>,
) -> SnapshotInstanceResult<'static> {
assert!(
node.ignore_unknown_instances.is_none(),
"TODO: Support $ignoreUnknownInstances"
);
let name = Cow::Owned(instance_name.to_owned());
let mut class_name = node
.class_name
.as_ref()
.map(|name| Cow::Owned(name.clone()));
let mut properties = HashMap::new();
let mut children = Vec::new();
if let Some(path) = &node.path {
let entry = imfs.get(path)?;
if let Some(snapshot) = snapshot_from_imfs(imfs, &entry)? {
// If a class name was already specified, then it'll override the
// class name of this snapshot ONLY if it's a Folder.
//
// This restriction is in place to prevent applying properties to
// instances that don't make sense. The primary use-case for using
// $className and $path at the same time is to use a directory as a
// service in a place file.
class_name = match class_name {
Some(class_name) => {
if snapshot.class_name == "Folder" {
Some(class_name)
} else {
// TODO: Turn this into an error object.
panic!("If $className and $path are specified, $path must yield an instance of class Folder");
}
}
None => Some(snapshot.class_name),
};
// Properties from the snapshot are pulled in unchanged, and
// overridden by properties set on the project node.
properties.reserve(snapshot.properties.len());
for (key, value) in snapshot.properties.into_iter() {
properties.insert(key, value);
}
// The snapshot's children will be merged with the children defined
// in the project node, if there are any.
children.reserve(snapshot.children.len());
for child in snapshot.children.into_iter() {
children.push(child);
}
} else {
// TODO: Should this issue an error instead?
log::warn!(
"$path referred to a path that could not be turned into an instance by Rojo"
);
}
}
let class_name = class_name
// TODO: Turn this into an error object.
.expect("$className or $path must be specified");
for (child_name, child_project_node) in &node.children {
if let Some(child) = snapshot_project_node(child_name, child_project_node, imfs)? {
children.push(child);
}
}
for (key, value) in &node.properties {
let resolved_value = try_resolve_value(&class_name, key, value)
.expect("TODO: Properly handle value resolution errors");
properties.insert(key.clone(), resolved_value);
}
Ok(Some(InstanceSnapshot {
snapshot_id: None,
name,
class_name,
properties,
children,
}))
}
#[cfg(test)]
mod test {
use super::*;
use maplit::hashmap;
use rbx_dom_weak::RbxValue;
use crate::imfs::new::{ImfsSnapshot, NoopFetcher};
#[test]
fn project_from_folder() {
let _ = env_logger::try_init();
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"default.project.json" => ImfsSnapshot::file(r#"
{
"name": "indirect-project",
"tree": {
"$className": "Folder"
}
}
"#),
});
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo").unwrap();
let instance_snapshot = SnapshotProject::from_imfs(&mut imfs, &entry)
.expect("snapshot error")
.expect("snapshot returned no instances");
assert_eq!(instance_snapshot.name, "indirect-project");
assert_eq!(instance_snapshot.class_name, "Folder");
assert_eq!(instance_snapshot.properties, HashMap::new());
assert_eq!(instance_snapshot.children, Vec::new());
}
#[test]
fn project_from_direct_file() {
let _ = env_logger::try_init();
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"hello.project.json" => ImfsSnapshot::file(r#"
{
"name": "direct-project",
"tree": {
"$className": "Model"
}
}
"#),
});
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo/hello.project.json").unwrap();
let instance_snapshot = SnapshotProject::from_imfs(&mut imfs, &entry)
.expect("snapshot error")
.expect("snapshot returned no instances");
assert_eq!(instance_snapshot.name, "direct-project");
assert_eq!(instance_snapshot.class_name, "Model");
assert_eq!(instance_snapshot.properties, HashMap::new());
assert_eq!(instance_snapshot.children, Vec::new());
}
#[test]
fn project_with_resolved_properties() {
let _ = env_logger::try_init();
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"default.project.json" => ImfsSnapshot::file(r#"
{
"name": "resolved-properties",
"tree": {
"$className": "StringValue",
"$properties": {
"Value": {
"Type": "String",
"Value": "Hello, world!"
}
}
}
}
"#),
});
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo").unwrap();
let instance_snapshot = SnapshotProject::from_imfs(&mut imfs, &entry)
.expect("snapshot error")
.expect("snapshot returned no instances");
assert_eq!(instance_snapshot.name, "resolved-properties");
assert_eq!(instance_snapshot.class_name, "StringValue");
assert_eq!(
instance_snapshot.properties,
hashmap! {
"Value".to_owned() => RbxValue::String {
value: "Hello, world!".to_owned(),
},
}
);
assert_eq!(instance_snapshot.children, Vec::new());
}
#[test]
fn project_with_unresolved_properties() {
let _ = env_logger::try_init();
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"default.project.json" => ImfsSnapshot::file(r#"
{
"name": "unresolved-properties",
"tree": {
"$className": "StringValue",
"$properties": {
"Value": "Hi!"
}
}
}
"#),
});
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo").unwrap();
let instance_snapshot = SnapshotProject::from_imfs(&mut imfs, &entry)
.expect("snapshot error")
.expect("snapshot returned no instances");
assert_eq!(instance_snapshot.name, "unresolved-properties");
assert_eq!(instance_snapshot.class_name, "StringValue");
assert_eq!(
instance_snapshot.properties,
hashmap! {
"Value".to_owned() => RbxValue::String {
value: "Hi!".to_owned(),
},
}
);
assert_eq!(instance_snapshot.children, Vec::new());
}
#[test]
fn project_with_children() {
let _ = env_logger::try_init();
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"default.project.json" => ImfsSnapshot::file(r#"
{
"name": "children",
"tree": {
"$className": "Folder",
"Child": {
"$className": "Model"
}
}
}
"#),
});
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo").unwrap();
let instance_snapshot = SnapshotProject::from_imfs(&mut imfs, &entry)
.expect("snapshot error")
.expect("snapshot returned no instances");
assert_eq!(instance_snapshot.name, "children");
assert_eq!(instance_snapshot.class_name, "Folder");
assert_eq!(instance_snapshot.properties, HashMap::new());
assert_eq!(instance_snapshot.children.len(), 1);
let child = &instance_snapshot.children[0];
assert_eq!(child.name, "Child");
assert_eq!(child.class_name, "Model");
assert_eq!(child.properties, HashMap::new());
assert_eq!(child.children, Vec::new());
}
#[test]
fn project_with_path_to_txt() {
let _ = env_logger::try_init();
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"default.project.json" => ImfsSnapshot::file(r#"
{
"name": "path-project",
"tree": {
"$path": "other.txt"
}
}
"#),
"other.txt" => ImfsSnapshot::file("Hello, world!"),
});
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo").unwrap();
let instance_snapshot = SnapshotProject::from_imfs(&mut imfs, &entry)
.expect("snapshot error")
.expect("snapshot returned no instances");
assert_eq!(instance_snapshot.name, "path-project");
assert_eq!(instance_snapshot.class_name, "StringValue");
assert_eq!(
instance_snapshot.properties,
hashmap! {
"Value".to_owned() => RbxValue::String {
value: "Hello, world!".to_owned(),
},
}
);
assert_eq!(instance_snapshot.children, Vec::new());
}
#[test]
fn project_with_path_to_project() {
let _ = env_logger::try_init();
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"default.project.json" => ImfsSnapshot::file(r#"
{
"name": "path-project",
"tree": {
"$path": "other.project.json"
}
}
"#),
"other.project.json" => ImfsSnapshot::file(r#"
{
"name": "other-project",
"tree": {
"$className": "Model"
}
}
"#),
});
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo").unwrap();
let instance_snapshot = SnapshotProject::from_imfs(&mut imfs, &entry)
.expect("snapshot error")
.expect("snapshot returned no instances");
assert_eq!(instance_snapshot.name, "path-project");
assert_eq!(instance_snapshot.class_name, "Model");
assert_eq!(instance_snapshot.properties, HashMap::new());
assert_eq!(instance_snapshot.children, Vec::new());
}
#[test]
fn project_with_path_to_project_with_children() {
let _ = env_logger::try_init();
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"default.project.json" => ImfsSnapshot::file(r#"
{
"name": "path-child-project",
"tree": {
"$path": "other.project.json"
}
}
"#),
"other.project.json" => ImfsSnapshot::file(r#"
{
"name": "other-project",
"tree": {
"$className": "Folder",
"SomeChild": {
"$className": "Model"
}
}
}
"#),
});
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo").unwrap();
let instance_snapshot = SnapshotProject::from_imfs(&mut imfs, &entry)
.expect("snapshot error")
.expect("snapshot returned no instances");
assert_eq!(instance_snapshot.name, "path-child-project");
assert_eq!(instance_snapshot.class_name, "Folder");
assert_eq!(instance_snapshot.properties, HashMap::new());
assert_eq!(instance_snapshot.children.len(), 1);
let child = &instance_snapshot.children[0];
assert_eq!(child.name, "SomeChild");
assert_eq!(child.class_name, "Model");
assert_eq!(child.properties, HashMap::new());
assert_eq!(child.children, Vec::new());
}
/// Ensures that if a property is defined both in the resulting instance
/// from $path and also in $properties, that the $properties value takes
/// precedence.
#[test]
fn project_path_property_overrides() {
let _ = env_logger::try_init();
let mut imfs = Imfs::new(NoopFetcher);
let dir = ImfsSnapshot::dir(hashmap! {
"default.project.json" => ImfsSnapshot::file(r#"
{
"name": "path-property-override",
"tree": {
"$path": "other.project.json",
"$properties": {
"Value": "Changed"
}
}
}
"#),
"other.project.json" => ImfsSnapshot::file(r#"
{
"name": "other-project",
"tree": {
"$className": "StringValue",
"$properties": {
"Value": "Original"
}
}
}
"#),
});
imfs.load_from_snapshot("/foo", dir);
let entry = imfs.get("/foo").unwrap();
let instance_snapshot = SnapshotProject::from_imfs(&mut imfs, &entry)
.expect("snapshot error")
.expect("snapshot returned no instances");
assert_eq!(instance_snapshot.name, "path-property-override");
assert_eq!(instance_snapshot.class_name, "StringValue");
assert_eq!(
instance_snapshot.properties,
hashmap! {
"Value".to_owned() => RbxValue::String {
value: "Changed".to_owned(),
},
}
);
assert_eq!(instance_snapshot.children, Vec::new());
}
}

View File

@@ -0,0 +1,90 @@
use std::{borrow::Cow, collections::HashMap};
use rbx_dom_weak::{RbxId, RbxInstanceProperties, RbxTree};
use crate::{
imfs::new::{Imfs, ImfsEntry, ImfsFetcher},
snapshot::InstanceSnapshot,
};
use super::middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware};
pub struct SnapshotRbxm;
impl SnapshotMiddleware for SnapshotRbxm {
fn from_imfs<F: ImfsFetcher>(
imfs: &mut Imfs<F>,
entry: &ImfsEntry,
) -> SnapshotInstanceResult<'static> {
if entry.is_directory() {
return Ok(None);
}
let file_name = entry.path().file_name().unwrap().to_string_lossy();
if !file_name.ends_with(".rbxm") {
return Ok(None);
}
let instance_name = entry
.path()
.file_stem()
.expect("Could not extract file stem")
.to_string_lossy()
.to_string();
let mut temp_tree = RbxTree::new(RbxInstanceProperties {
name: "DataModel".to_owned(),
class_name: "DataModel".to_owned(),
properties: HashMap::new(),
});
let root_id = temp_tree.get_root_id();
rbx_binary::decode(&mut temp_tree, root_id, entry.contents(imfs)?)
.expect("TODO: Handle rbx_binary errors");
let root_instance = temp_tree.get_instance(root_id).unwrap();
let children = root_instance.get_children_ids();
if children.len() == 1 {
let mut snapshot = InstanceSnapshot::from_tree(&temp_tree, children[0]);
snapshot.name = Cow::Owned(instance_name);
Ok(Some(snapshot))
} else {
panic!("Rojo doesn't have support for model files with zero or more than one top-level instances yet.");
}
}
fn from_instance(_tree: &RbxTree, _id: RbxId) -> SnapshotFileResult {
unimplemented!("Snapshotting models");
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::imfs::new::{ImfsSnapshot, NoopFetcher};
#[test]
fn model_from_imfs() {
let mut imfs = Imfs::new(NoopFetcher);
let file = ImfsSnapshot::file(include_bytes!("../../assets/test-folder.rbxm").to_vec());
imfs.load_from_snapshot("/foo.rbxm", file);
let entry = imfs.get("/foo.rbxm").unwrap();
let instance_snapshot = SnapshotRbxm::from_imfs(&mut imfs, &entry).unwrap().unwrap();
assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "Folder");
assert_eq!(instance_snapshot.children, Vec::new());
// We intentionally don't assert on properties. rbx_binary does not
// distinguish between String and BinaryString. The sample model was
// created by Roblox Studio and has an empty BinaryString "Tags"
// property that currently deserializes incorrectly.
// See: https://github.com/rojo-rbx/rbx-dom/issues/49
}
}

View File

@@ -0,0 +1,95 @@
use std::borrow::Cow;
use rbx_dom_weak::{RbxId, RbxTree};
use crate::{
imfs::new::{Imfs, ImfsEntry, ImfsFetcher},
snapshot::InstanceSnapshot,
};
use super::middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware};
pub struct SnapshotRbxmx;
impl SnapshotMiddleware for SnapshotRbxmx {
fn from_imfs<F: ImfsFetcher>(
imfs: &mut Imfs<F>,
entry: &ImfsEntry,
) -> SnapshotInstanceResult<'static> {
if entry.is_directory() {
return Ok(None);
}
let file_name = entry.path().file_name().unwrap().to_string_lossy();
if !file_name.ends_with(".rbxmx") {
return Ok(None);
}
let instance_name = entry
.path()
.file_stem()
.expect("Could not extract file stem")
.to_string_lossy()
.to_string();
let options = rbx_xml::DecodeOptions::new()
.property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown);
let temp_tree = rbx_xml::from_reader(entry.contents(imfs)?, options)
.expect("TODO: Handle rbx_xml errors");
let root_instance = temp_tree.get_instance(temp_tree.get_root_id()).unwrap();
let children = root_instance.get_children_ids();
if children.len() == 1 {
let mut snapshot = InstanceSnapshot::from_tree(&temp_tree, children[0]);
snapshot.name = Cow::Owned(instance_name);
Ok(Some(snapshot))
} else {
panic!("Rojo doesn't have support for model files with zero or more than one top-level instances yet.");
}
}
fn from_instance(_tree: &RbxTree, _id: RbxId) -> SnapshotFileResult {
unimplemented!("Snapshotting models");
}
}
#[cfg(test)]
mod test {
use super::*;
use std::collections::HashMap;
use crate::imfs::new::{ImfsSnapshot, NoopFetcher};
#[test]
fn model_from_imfs() {
let mut imfs = Imfs::new(NoopFetcher);
let file = ImfsSnapshot::file(
r#"
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">THIS NAME IS IGNORED</string>
</Properties>
</Item>
</roblox>
"#,
);
imfs.load_from_snapshot("/foo.rbxmx", file);
let entry = imfs.get("/foo.rbxmx").unwrap();
let instance_snapshot = SnapshotRbxmx::from_imfs(&mut imfs, &entry)
.unwrap()
.unwrap();
assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "Folder");
assert_eq!(instance_snapshot.properties, HashMap::new());
assert_eq!(instance_snapshot.children, Vec::new());
}
}

View File

@@ -0,0 +1,147 @@
use std::{borrow::Cow, str};
use maplit::hashmap;
use rbx_dom_weak::{RbxId, RbxTree, RbxValue};
use crate::{
imfs::new::{FileSnapshot, Imfs, ImfsEntry, ImfsFetcher, ImfsSnapshot},
snapshot::InstanceSnapshot,
};
use super::middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware};
pub struct SnapshotTxt;
impl SnapshotMiddleware for SnapshotTxt {
fn from_imfs<F: ImfsFetcher>(
imfs: &mut Imfs<F>,
entry: &ImfsEntry,
) -> SnapshotInstanceResult<'static> {
if entry.is_directory() {
return Ok(None);
}
let extension = match entry.path().extension() {
Some(x) => x.to_str().unwrap(),
None => return Ok(None),
};
if extension != "txt" {
return Ok(None);
}
let instance_name = entry
.path()
.file_stem()
.expect("Could not extract file stem")
.to_str()
.unwrap()
.to_string();
let contents = entry.contents(imfs)?;
let contents_str = str::from_utf8(contents)
.expect("File content was not valid UTF-8")
.to_string();
let properties = hashmap! {
"Value".to_owned() => RbxValue::String {
value: contents_str,
},
};
Ok(Some(InstanceSnapshot {
snapshot_id: None,
name: Cow::Owned(instance_name),
class_name: Cow::Borrowed("StringValue"),
properties,
children: Vec::new(),
}))
}
fn from_instance(tree: &RbxTree, id: RbxId) -> SnapshotFileResult {
let instance = tree.get_instance(id).unwrap();
if instance.class_name != "StringValue" {
return None;
}
if !instance.get_children_ids().is_empty() {
return None;
}
let value = match instance.properties.get("Value") {
Some(RbxValue::String { value }) => value.clone(),
Some(_) => panic!("wrong type ahh"),
None => String::new(),
};
let snapshot = ImfsSnapshot::File(FileSnapshot {
contents: value.into_bytes(),
});
let mut file_name = instance.name.clone();
file_name.push_str(".txt");
Some((file_name, snapshot))
}
}
#[cfg(test)]
mod test {
use super::*;
use maplit::hashmap;
use rbx_dom_weak::RbxInstanceProperties;
use crate::imfs::new::NoopFetcher;
#[test]
fn instance_from_imfs() {
let mut imfs = Imfs::new(NoopFetcher);
let file = ImfsSnapshot::file("Hello there!");
imfs.load_from_snapshot("/foo.txt", file);
let entry = imfs.get("/foo.txt").unwrap();
let instance_snapshot = SnapshotTxt::from_imfs(&mut imfs, &entry).unwrap().unwrap();
assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "StringValue");
assert_eq!(
instance_snapshot.properties,
hashmap! {
"Value".to_owned() => RbxValue::String {
value: "Hello there!".to_owned(),
},
}
);
}
#[test]
fn imfs_from_instance() {
let tree = RbxTree::new(string_value("Root", "Hello, world!"));
let root_id = tree.get_root_id();
let (_file_name, _file) = SnapshotTxt::from_instance(&tree, root_id).unwrap();
}
fn folder(name: impl Into<String>) -> RbxInstanceProperties {
RbxInstanceProperties {
name: name.into(),
class_name: "Folder".to_owned(),
properties: Default::default(),
}
}
fn string_value(name: impl Into<String>, value: impl Into<String>) -> RbxInstanceProperties {
RbxInstanceProperties {
name: name.into(),
class_name: "StringValue".to_owned(),
properties: hashmap! {
"Value".to_owned() => RbxValue::String {
value: value.into(),
},
},
}
}
}

145
src/web/api.rs Normal file
View File

@@ -0,0 +1,145 @@
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
//! JSON.
use std::{collections::HashSet, sync::Arc};
use futures::{future, Future};
use hyper::{header, service::Service, Body, Method, Request, Response, StatusCode};
use rbx_dom_weak::RbxId;
use serde::{Deserialize, Serialize};
use crate::{serve_session::ServeSession, session_id::SessionId};
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
const PROTOCOL_VERSION: u64 = 3;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfoResponse<'a> {
pub session_id: SessionId,
pub server_version: &'a str,
pub protocol_version: u64,
pub expected_place_ids: Option<HashSet<u64>>,
// pub root_instance_id: RbxId,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadResponse {
pub session_id: SessionId,
// pub message_cursor: u32,
// pub instances: HashMap<RbxId, InstanceWithMetadata<'a>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscribeResponse {
pub session_id: SessionId,
// pub message_cursor: u32,
// pub messages: Cow<'a, [InstanceChanges]>,
}
fn response_json<T: serde::Serialize>(value: T) -> Response<Body> {
let serialized = match serde_json::to_string(&value) {
Ok(v) => v,
Err(err) => {
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(err.to_string()))
.unwrap();
}
};
Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serialized))
.unwrap()
}
pub struct ApiService {
serve_session: Arc<ServeSession>,
}
impl Service for ApiService {
type ReqBody = Body;
type ResBody = Body;
type Error = hyper::Error;
type Future =
Box<dyn Future<Item = hyper::Response<Self::ReqBody>, Error = Self::Error> + Send>;
fn call(&mut self, request: hyper::Request<Self::ReqBody>) -> Self::Future {
let response = match (request.method(), request.uri().path()) {
(&Method::GET, "/api/rojo") => self.handle_api_rojo(),
(&Method::GET, path) if path.starts_with("/api/read/") => self.handle_api_read(request),
(&Method::GET, path) if path.starts_with("/api/subscribe/") => {
return self.handle_api_subscribe(request);
}
_ => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap(),
};
Box::new(future::ok(response))
}
}
impl ApiService {
pub fn new(serve_session: Arc<ServeSession>) -> ApiService {
ApiService { serve_session }
}
/// Get a summary of information about the server
fn handle_api_rojo(&self) -> Response<Body> {
response_json(&ServerInfoResponse {
server_version: SERVER_VERSION,
protocol_version: PROTOCOL_VERSION,
session_id: self.serve_session.session_id(),
expected_place_ids: self.serve_session.serve_place_ids().map(Clone::clone),
})
}
/// Retrieve any messages past the given cursor index, and if
/// there weren't any, subscribe to receive any new messages.
fn handle_api_subscribe(&self, request: Request<Body>) -> <ApiService as Service>::Future {
let argument = &request.uri().path()["/api/subscribe/".len()..];
let _cursor: u32 = match argument.parse() {
Ok(v) => v,
Err(err) => {
return Box::new(future::ok(
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from(err.to_string()))
.unwrap(),
));
}
};
Box::new(future::ok(response_json(SubscribeResponse {
session_id: self.serve_session.session_id(),
})))
}
fn handle_api_read(&self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/read/".len()..];
let requested_ids: Option<Vec<RbxId>> = argument.split(',').map(RbxId::parse_str).collect();
let _requested_ids = match requested_ids {
Some(id) => id,
None => {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from("Malformed ID list"))
.unwrap();
}
};
response_json(ReadResponse {
session_id: self.serve_session.session_id(),
})
}
}

90
src/web/interface.rs Normal file
View File

@@ -0,0 +1,90 @@
//! Defines the HTTP-based UI. These endpoints generally return HTML and SVG.
use std::sync::Arc;
use futures::{future, Future};
use hyper::{header, service::Service, Body, Method, Request, Response, StatusCode};
use ritz::html;
use crate::serve_session::ServeSession;
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
static HOME_CSS: &str = include_str!("../../assets/index.css");
pub struct InterfaceService {
#[allow(unused)] // TODO: Fill out interface service
serve_session: Arc<ServeSession>,
}
impl Service for InterfaceService {
type ReqBody = Body;
type ResBody = Body;
type Error = hyper::Error;
type Future = Box<dyn Future<Item = Response<Self::ReqBody>, Error = Self::Error> + Send>;
fn call(&mut self, request: Request<Self::ReqBody>) -> Self::Future {
let response = match (request.method(), request.uri().path()) {
(&Method::GET, "/") => self.handle_home(),
(&Method::GET, "/visualize/rbx") => self.handle_visualize_rbx(),
(&Method::GET, "/visualize/imfs") => self.handle_visualize_imfs(),
_ => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap(),
};
Box::new(future::ok(response))
}
}
impl InterfaceService {
pub fn new(serve_session: Arc<ServeSession>) -> InterfaceService {
InterfaceService { serve_session }
}
fn handle_home(&self) -> Response<Body> {
let page = html! {
<html>
<head>
<title>"Rojo"</title>
<style>
{ ritz::UnescapedText::new(HOME_CSS) }
</style>
</head>
<body>
<div class="main">
<h1 class="title">
"Rojo Live Sync is up and running!"
</h1>
<h2 class="subtitle">
"Version " { SERVER_VERSION }
</h2>
<a class="docs" href="https://lpghatguy.github.io/rojo">
"Rojo Documentation"
</a>
</div>
</body>
</html>
};
Response::builder()
.header(header::CONTENT_TYPE, "text/html")
.body(Body::from(format!("<!DOCTYPE html>{}", page)))
.unwrap()
}
fn handle_visualize_rbx(&self) -> Response<Body> {
Response::builder()
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from("TODO: /visualize/rbx"))
.unwrap()
}
fn handle_visualize_imfs(&self) -> Response<Body> {
Response::builder()
.header(header::CONTENT_TYPE, "text/plain")
.body(Body::from("TODO: /visualize/imfs"))
.unwrap()
}
}

70
src/web/mod.rs Normal file
View File

@@ -0,0 +1,70 @@
mod api;
mod interface;
use std::sync::Arc;
use futures::{
future::{self, FutureResult},
Future,
};
use hyper::{service::Service, Body, Request, Response, Server};
use log::trace;
use crate::serve_session::ServeSession;
use self::{api::ApiService, interface::InterfaceService};
pub struct RootService {
api: api::ApiService,
interface: interface::InterfaceService,
}
impl Service for RootService {
type ReqBody = Body;
type ResBody = Body;
type Error = hyper::Error;
type Future = Box<dyn Future<Item = Response<Self::ReqBody>, Error = Self::Error> + Send>;
fn call(&mut self, request: Request<Self::ReqBody>) -> Self::Future {
trace!("{} {}", request.method(), request.uri().path());
if request.uri().path().starts_with("/api") {
self.api.call(request)
} else {
self.interface.call(request)
}
}
}
impl RootService {
pub fn new(serve_session: Arc<ServeSession>) -> RootService {
RootService {
api: ApiService::new(Arc::clone(&serve_session)),
interface: InterfaceService::new(Arc::clone(&serve_session)),
}
}
}
pub struct LiveServer {
serve_session: Arc<ServeSession>,
}
impl LiveServer {
pub fn new(serve_session: Arc<ServeSession>) -> LiveServer {
LiveServer { serve_session }
}
pub fn start(self, port: u16) {
let address = ([127, 0, 0, 1], port).into();
let server = Server::bind(&address)
.serve(move || {
let service: FutureResult<_, hyper::Error> =
future::ok(RootService::new(Arc::clone(&self.serve_session)));
service
})
.map_err(|e| eprintln!("Server error: {}", e));
hyper::rt::run(server);
}
}