mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-21 05:06:29 +00:00
Move Rojo server into root of the repository
This commit is contained in:
203
src/bin.rs
Normal file
203
src/bin.rs
Normal 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
142
src/commands/build.rs
Normal 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
49
src/commands/init.rs
Normal 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
9
src/commands/mod.rs
Normal 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
91
src/commands/serve.rs
Normal 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
21
src/commands/upload.rs
Normal 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
61
src/imfs/error.rs
Normal 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
32
src/imfs/fetcher.rs
Normal 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
545
src/imfs/imfs.rs
Normal 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
17
src/imfs/mod.rs
Normal 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
56
src/imfs/noop_fetcher.rs
Normal 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
154
src/imfs/real_fetcher.rs
Normal 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
33
src/imfs/snapshot.rs
Normal 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
18
src/impl_from.rs
Normal 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
19
src/lib.rs
Normal 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
96
src/message_queue.rs
Normal 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
284
src/path_map.rs
Normal 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
74
src/path_serializer.rs
Normal 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
556
src/project.rs
Normal 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
30
src/serve_session.rs
Normal 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
11
src/session_id.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
62
src/snapshot/instance_snapshot.rs
Normal file
62
src/snapshot/instance_snapshot.rs
Normal 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
29
src/snapshot/mod.rs
Normal 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
44
src/snapshot/patch.rs
Normal 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
240
src/snapshot/patch_apply.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
330
src/snapshot/patch_compute.rs
Normal file
330
src/snapshot/patch_compute.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
7
src/snapshot_middleware/context.rs
Normal file
7
src/snapshot_middleware/context.rs
Normal 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;
|
||||
169
src/snapshot_middleware/csv.rs
Normal file
169
src/snapshot_middleware/csv.rs
Normal 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(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
121
src/snapshot_middleware/dir.rs
Normal file
121
src/snapshot_middleware/dir.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
91
src/snapshot_middleware/error.rs
Normal file
91
src/snapshot_middleware/error.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
src/snapshot_middleware/json_model.rs
Normal file
189
src/snapshot_middleware/json_model.rs
Normal 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(),
|
||||
},],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
176
src/snapshot_middleware/lua.rs
Normal file
176
src/snapshot_middleware/lua.rs
Normal 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(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/snapshot_middleware/middleware.rs
Normal file
27
src/snapshot_middleware/middleware.rs
Normal 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()]
|
||||
}
|
||||
}
|
||||
76
src/snapshot_middleware/mod.rs
Normal file
76
src/snapshot_middleware/mod.rs
Normal 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,
|
||||
}
|
||||
501
src/snapshot_middleware/project.rs
Normal file
501
src/snapshot_middleware/project.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
90
src/snapshot_middleware/rbxm.rs
Normal file
90
src/snapshot_middleware/rbxm.rs
Normal 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
|
||||
}
|
||||
}
|
||||
95
src/snapshot_middleware/rbxmx.rs
Normal file
95
src/snapshot_middleware/rbxmx.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
147
src/snapshot_middleware/txt.rs
Normal file
147
src/snapshot_middleware/txt.rs
Normal 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
145
src/web/api.rs
Normal 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
90
src/web/interface.rs
Normal 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
70
src/web/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user