Reorganize some of the unwieldly parts of the codebase

* Moved commands into their own folder to reduce `bin.rs`'s size
* Moved all of the VFS-related functionality into its own folder
* Documented a lot of functions, including a few very obscure ones
This commit is contained in:
Lucien Greathouse
2018-01-03 16:45:46 -08:00
parent d8bcbee463
commit 58b244b7e9
14 changed files with 267 additions and 212 deletions

View File

@@ -22,7 +22,7 @@ The `rojo serve` command uses three major components:
### Transform Plugins
Transform plugins (or filter plugins?) can interject in three places:
* Transform a `VfsItem` that's being read into an `RbxInstance` in the VFS
* Transform an `Rbxitem` that's being written into a `VfsItem` in the VFS
* Transform an `RbxInstance` that's being written into a `VfsItem` in the VFS
* Transform a file change into paths that need to be updated in the VFS watcher
The plan is to have several built-in plugins that can be rearranged/configured in project settings:

View File

@@ -1,15 +1,7 @@
#[macro_use]
extern crate serde_derive;
#[macro_use]
extern crate rouille;
#[macro_use]
extern crate clap;
#[macro_use]
extern crate lazy_static;
#[macro_use] extern crate serde_derive;
#[macro_use] extern crate rouille;
#[macro_use] extern crate clap;
#[macro_use] extern crate lazy_static;
extern crate notify;
extern crate rand;
extern crate serde;
@@ -21,22 +13,15 @@ pub mod core;
pub mod project;
pub mod pathext;
pub mod vfs;
pub mod vfs_watch;
pub mod rbx;
pub mod plugin;
pub mod plugins;
pub mod commands;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use std::process;
use core::Config;
use pathext::canonicalish;
use project::{Project, ProjectLoadError};
use plugin::{PluginChain};
use plugins::{DefaultPlugin, JsonModelPlugin, ScriptPlugin};
use vfs::Vfs;
use vfs_watch::VfsWatcher;
fn main() {
let matches = clap_app!(rojo =>
@@ -68,27 +53,13 @@ fn main() {
_ => true,
};
let server_id = rand::random::<u64>();
if verbose {
println!("Server ID: {}", server_id);
}
match matches.subcommand() {
("init", sub_matches) => {
let sub_matches = sub_matches.unwrap();
let project_path = Path::new(sub_matches.value_of("PATH").unwrap_or("."));
let full_path = canonicalish(project_path);
match Project::init(&full_path) {
Ok(_) => {
println!("Created new empty project at {}", full_path.display());
},
Err(e) => {
eprintln!("Failed to create new project.\n{}", e);
std::process::exit(1);
},
}
commands::init(&full_path);
},
("serve", sub_matches) => {
let sub_matches = sub_matches.unwrap();
@@ -98,126 +69,29 @@ fn main() {
None => std::env::current_dir().unwrap(),
};
if verbose {
println!("Attempting to locate project at {}", project_path.display());
}
let project = match Project::load(&project_path) {
Ok(v) => {
println!("Using project from {}", project_path.display());
v
},
Err(err) => {
match err {
ProjectLoadError::InvalidJson(serde_err) => {
eprintln!(
"Found invalid JSON!\nProject in: {}\nError: {}",
project_path.display(),
serde_err,
);
std::process::exit(1);
},
ProjectLoadError::FailedToOpen | ProjectLoadError::FailedToRead => {
eprintln!("Found project file, but failed to read it!");
eprintln!(
"Check the permissions of the project file at\n{}",
project_path.display(),
);
std::process::exit(1);
},
_ => {
// Any other error is fine; use the default project.
},
}
println!("Found no project file, using default project...");
Project::default()
},
};
let port = {
match sub_matches.value_of("port") {
Some(source) => match source.parse::<u64>() {
Ok(value) => value,
Ok(value) => Some(value),
Err(_) => {
eprintln!("Invalid port '{}'", source);
std::process::exit(1);
process::exit(1);
},
},
None => project.serve_port,
None => None,
}
};
lazy_static! {
static ref PLUGIN_CHAIN: PluginChain = PluginChain::new(vec![
Box::new(ScriptPlugin::new()),
Box::new(JsonModelPlugin::new()),
Box::new(DefaultPlugin::new()),
]);
}
let config = Config {
port,
verbose,
server_id,
};
if verbose {
println!("Loading VFS...");
}
let vfs = {
let mut vfs = Vfs::new(config.clone(), &PLUGIN_CHAIN);
for (name, project_partition) in &project.partitions {
let path = {
let given_path = Path::new(&project_partition.path);
if given_path.is_absolute() {
given_path.to_path_buf()
} else {
project_path.join(given_path)
}
};
if verbose {
println!(
"Partition '{}': {} @ {}",
name,
project_partition.target,
project_partition.path
);
}
vfs.partitions.insert(name.clone(), path);
}
Arc::new(Mutex::new(vfs))
};
{
let vfs = vfs.clone();
let config = config.clone();
thread::spawn(move || {
VfsWatcher::new(config, vfs).start();
});
}
println!("Server listening on port {}", port);
web::start(config.clone(), project.clone(), &PLUGIN_CHAIN, vfs.clone());
commands::serve(&project_path, verbose, port);
},
("pack", _) => {
eprintln!("'rojo pack' is not yet implemented!");
std::process::exit(1);
process::exit(1);
},
_ => {
eprintln!("Please specify a subcommand!");
eprintln!("Try 'rojo help' for information.");
std::process::exit(1);
process::exit(1);
},
}
}

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

@@ -0,0 +1,16 @@
use std::path::PathBuf;
use std::process;
use project::Project;
pub fn init(project_path: &PathBuf) {
match Project::init(project_path) {
Ok(_) => {
println!("Created new empty project at {}", project_path.display());
},
Err(e) => {
eprintln!("Failed to create new project.\n{}", e);
process::exit(1);
},
}
}

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

@@ -0,0 +1,5 @@
mod serve;
mod init;
pub use self::serve::*;
pub use self::init::*;

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

@@ -0,0 +1,114 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use std::process;
use rand;
use project::{Project, ProjectLoadError};
use plugin::{PluginChain};
use plugins::{DefaultPlugin, JsonModelPlugin, ScriptPlugin};
use vfs::{VfsSession, VfsWatcher};
use web;
pub fn serve(project_path: &PathBuf, verbose: bool, port: Option<u64>) {
let server_id = rand::random::<u64>();
if verbose {
println!("Attempting to locate project at {}", project_path.display());
}
let project = match Project::load(project_path) {
Ok(v) => {
println!("Using project from {}", project_path.display());
v
},
Err(err) => {
match err {
ProjectLoadError::InvalidJson(serde_err) => {
eprintln!(
"Found invalid JSON!\nProject in: {}\nError: {}",
project_path.display(),
serde_err,
);
process::exit(1);
},
ProjectLoadError::FailedToOpen | ProjectLoadError::FailedToRead => {
eprintln!("Found project file, but failed to read it!");
eprintln!(
"Check the permissions of the project file at\n{}",
project_path.display(),
);
process::exit(1);
},
_ => {
// Any other error is fine; use the default project.
},
}
println!("Found no project file, using default project...");
Project::default()
},
};
let web_config = web::WebConfig {
verbose,
port: port.unwrap_or(project.serve_port),
server_id,
};
lazy_static! {
static ref PLUGIN_CHAIN: PluginChain = PluginChain::new(vec![
Box::new(ScriptPlugin::new()),
Box::new(JsonModelPlugin::new()),
Box::new(DefaultPlugin::new()),
]);
}
if verbose {
println!("Loading VFS...");
}
let vfs = {
let mut vfs = VfsSession::new(&PLUGIN_CHAIN);
for (name, project_partition) in &project.partitions {
let path = {
let given_path = Path::new(&project_partition.path);
if given_path.is_absolute() {
given_path.to_path_buf()
} else {
project_path.join(given_path)
}
};
if verbose {
println!(
"Partition '{}': {} @ {}",
name,
project_partition.target,
project_partition.path
);
}
vfs.partitions.insert(name.clone(), path);
}
Arc::new(Mutex::new(vfs))
};
{
let vfs = vfs.clone();
thread::spawn(move || {
VfsWatcher::new(vfs).start();
});
}
println!("Server listening on port {}", web_config.port);
web::start(web_config, project.clone(), &PLUGIN_CHAIN, vfs.clone());
}

View File

@@ -1,8 +1 @@
#[derive(Debug, Clone)]
pub struct Config {
pub port: u64,
pub verbose: bool,
pub server_id: u64,
}
pub type Route = Vec<String>;

View File

@@ -2,15 +2,18 @@ use rbx::RbxInstance;
use vfs::VfsItem;
use core::Route;
// TODO: Add error case?
pub enum TransformFileResult {
Value(Option<RbxInstance>),
Pass,
// TODO: Error case
}
pub enum RbxChangeResult {
Write(Option<VfsItem>),
Pass,
// TODO: Error case
}
pub enum FileChangeResult {
@@ -19,11 +22,20 @@ pub enum FileChangeResult {
}
pub trait Plugin {
/// Invoked when a file is read from the filesystem and needs to be turned
/// into a Roblox instance.
fn transform_file(&self, plugins: &PluginChain, vfs_item: &VfsItem) -> TransformFileResult;
/// Invoked when a Roblox Instance change is reported by the Roblox Studio
/// plugin and needs to be turned into a file to save.
fn handle_rbx_change(&self, route: &Route, rbx_item: &RbxInstance) -> RbxChangeResult;
/// Invoked when a file changes on the filesystem. The result defines what
/// routes are marked as needing to be refreshed.
fn handle_file_change(&self, route: &Route) -> FileChangeResult;
}
/// A set of plugins that are composed in order.
pub struct PluginChain {
plugins: Vec<Box<Plugin + Send + Sync>>,
}

View File

@@ -44,10 +44,19 @@ impl fmt::Display for ProjectInitError {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectPartition {
/// A slash-separated path to a file or folder, relative to the project's
/// directory.
pub path: String,
/// A dot-separated route to a Roblox instance, relative to game.
pub target: String,
}
/// Represents a project configured by a user for use with Rojo. Holds anything
/// that can be configured with `rojo.json`.
///
/// In the future, this object will hold dependency information and other handy
/// configurables
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Project {
@@ -57,6 +66,7 @@ pub struct Project {
}
impl Project {
/// Creates a new empty Project object with the given name.
pub fn new<T: Into<String>>(name: T) -> Project {
Project {
name: name.into(),
@@ -64,10 +74,12 @@ impl Project {
}
}
/// Initializes a new project inside the given folder path.
pub fn init<T: AsRef<Path>>(location: T) -> Result<Project, ProjectInitError> {
let location = location.as_ref();
let package_path = location.join(PROJECT_FILENAME);
// We abort if the project file already exists.
match fs::metadata(&package_path) {
Ok(_) => return Err(ProjectInitError::AlreadyExists),
Err(_) => {},
@@ -78,11 +90,14 @@ impl Project {
Err(_) => return Err(ProjectInitError::FailedToCreate),
};
// Try to give the project a meaningful name.
// If we can't, we'll just fall back to a default.
let name = match location.file_name() {
Some(v) => v.to_string_lossy().into_owned(),
None => "new-project".to_string(),
};
// Configure the project with all of the values we know so far.
let project = Project::new(name);
let serialized = serde_json::to_string_pretty(&project).unwrap();
@@ -94,6 +109,8 @@ impl Project {
Ok(project)
}
/// Attempts to load a project from the file named PROJECT_FILENAME from the
/// given folder.
pub fn load<T: AsRef<Path>>(location: T) -> Result<Project, ProjectLoadError> {
let package_path = location.as_ref().join(Path::new(PROJECT_FILENAME));
@@ -120,6 +137,7 @@ impl Project {
}
}
/// Saves the given project file to the given folder with the appropriate name.
pub fn save<T: AsRef<Path>>(&self, location: T) -> Result<(), ProjectSaveError> {
let package_path = location.as_ref().join(Path::new(PROJECT_FILENAME));
@@ -139,7 +157,7 @@ impl Project {
impl Default for Project {
fn default() -> Project {
Project {
name: "some-project".to_string(),
name: "new-project".to_string(),
serve_port: 8000,
partitions: HashMap::new(),
}

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap;
/// Represents data about a Roblox instance
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RbxInstance {
@@ -9,10 +10,14 @@ pub struct RbxInstance {
pub properties: HashMap<String, RbxValue>,
}
/// Any kind value that can be used by Roblox
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum RbxValue {
String {
value: String,
},
// TODO: Other primitives
// TODO: Compound types like Vector3
}

7
src/vfs/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
mod vfs_session;
mod vfs_item;
mod vfs_watcher;
pub use self::vfs_session::*;
pub use self::vfs_item::*;
pub use self::vfs_watcher::*;

31
src/vfs/vfs_item.rs Normal file
View File

@@ -0,0 +1,31 @@
use std::collections::HashMap;
/// A VfsItem represents either a file or directory as it came from the filesystem.
///
/// The interface here is intentionally simplified to make it easier to traverse
/// files that have been read into memory.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum VfsItem {
File {
route: Vec<String>,
contents: String,
},
Dir {
route: Vec<String>,
children: HashMap<String, VfsItem>,
},
}
impl VfsItem {
pub fn name(&self) -> &String {
self.route().last().unwrap()
}
pub fn route(&self) -> &[String] {
match self {
&VfsItem::File { ref route, .. } => route,
&VfsItem::Dir { ref route, .. } => route,
}
}
}

View File

@@ -4,14 +4,14 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Instant;
use core::Config;
use plugin::PluginChain;
use vfs::VfsItem;
/// Represents a virtual layer over multiple parts of the filesystem.
///
/// Paths in this system are represented as slices of strings, and are always
/// relative to a partition, which is an absolute path into the real filesystem.
pub struct Vfs {
pub struct VfsSession {
/// Contains all of the partitions mounted by the Vfs.
///
/// These must be absolute paths!
@@ -25,8 +25,6 @@ pub struct Vfs {
pub change_history: Vec<VfsChange>,
plugin_chain: &'static PluginChain,
config: Config,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -36,44 +34,13 @@ pub struct VfsChange {
route: Vec<String>,
}
/// A VfsItem represents either a file or directory as it came from the filesystem.
///
/// The interface here is intentionally simplified to make it easier to traverse
/// files that have been read into memory.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum VfsItem {
File {
route: Vec<String>,
contents: String,
},
Dir {
route: Vec<String>,
children: HashMap<String, VfsItem>,
},
}
impl VfsItem {
pub fn name(&self) -> &String {
self.route().last().unwrap()
}
pub fn route(&self) -> &[String] {
match self {
&VfsItem::File { ref route, .. } => route,
&VfsItem::Dir { ref route, .. } => route,
}
}
}
impl Vfs {
pub fn new(config: Config, plugin_chain: &'static PluginChain) -> Vfs {
Vfs {
impl VfsSession {
pub fn new(plugin_chain: &'static PluginChain) -> VfsSession {
VfsSession {
partitions: HashMap::new(),
start_time: Instant::now(),
change_history: Vec::new(),
plugin_chain,
config,
}
}
@@ -185,16 +152,8 @@ impl Vfs {
/// Register a new change to the filesystem at the given timestamp and VFS
/// route.
pub fn add_change(&mut self, timestamp: f64, route: Vec<String>) {
if self.config.verbose {
println!("Received change {:?}, running through plugins...", route);
}
match self.plugin_chain.handle_file_change(&route) {
Some(routes) => {
if self.config.verbose {
println!("Adding changes from plugin: {:?}", routes);
}
for route in routes {
self.change_history.push(VfsChange {
timestamp,

View File

@@ -4,24 +4,21 @@ use std::time::Duration;
use notify::{DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
use core::Config;
use pathext::path_to_route;
use vfs::Vfs;
use vfs::VfsSession;
/// An object that registers watchers on the real filesystem and relays those
/// changes to the virtual filesystem layer.
pub struct VfsWatcher {
vfs: Arc<Mutex<Vfs>>,
vfs: Arc<Mutex<VfsSession>>,
watchers: Vec<RecommendedWatcher>,
config: Config,
}
impl VfsWatcher {
pub fn new(config: Config, vfs: Arc<Mutex<Vfs>>) -> VfsWatcher {
pub fn new(vfs: Arc<Mutex<VfsSession>>) -> VfsWatcher {
VfsWatcher {
vfs,
watchers: Vec::new(),
config,
}
}
@@ -45,7 +42,6 @@ impl VfsWatcher {
{
let vfs = self.vfs.clone();
let config = self.config.clone();
thread::spawn(move || {
loop {
@@ -53,10 +49,6 @@ impl VfsWatcher {
let mut vfs = vfs.lock().unwrap();
let current_time = vfs.current_time();
if config.verbose {
println!("FS event {:?}", event);
}
match event {
DebouncedEvent::Write(ref change_path) |
DebouncedEvent::Create(ref change_path) |

View File

@@ -5,14 +5,20 @@ use rouille;
use serde;
use serde_json;
use core::Config;
use project::Project;
use vfs::{Vfs, VfsChange};
use vfs::{VfsSession, VfsChange};
use rbx::RbxInstance;
use plugin::PluginChain;
static MAX_BODY_SIZE: usize = 25 * 1024 * 1024; // 25 MiB
/// The set of configuration the web server needs to start.
pub struct WebConfig {
pub port: u64,
pub verbose: bool,
pub server_id: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ServerInfo<'a> {
@@ -51,7 +57,12 @@ fn json<T: serde::Serialize>(value: T) -> rouille::Response {
rouille::Response::from_data("application/json", data)
}
/// Pulls text that may be JSON out of a Rouille Request object.
///
/// Doesn't do any actual parsing -- all this method does is verify the content
/// type of the request and read the request's body.
fn read_json_text(request: &rouille::Request) -> Option<String> {
// Bail out if the request body isn't marked as JSON
match request.header("Content-Type") {
Some(header) => if !header.starts_with("application/json") {
return None;
@@ -64,14 +75,15 @@ fn read_json_text(request: &rouille::Request) -> Option<String> {
None => return None,
};
// Allocate a buffer and read up to MAX_BODY_SIZE+1 bytes into it.
let mut out = Vec::new();
match body.take(MAX_BODY_SIZE.saturating_add(1) as u64)
.read_to_end(&mut out)
{
match body.take(MAX_BODY_SIZE.saturating_add(1) as u64).read_to_end(&mut out) {
Ok(_) => {},
Err(_) => return None,
}
// If the body was too big (MAX_BODY_SIZE+1), we abort instead of trying to
// process it.
if out.len() > MAX_BODY_SIZE {
return None;
}
@@ -84,6 +96,7 @@ fn read_json_text(request: &rouille::Request) -> Option<String> {
Some(parsed)
}
/// Reads the body out of a Rouille Request and attempts to turn it into JSON.
fn read_json<T>(request: &rouille::Request) -> Option<T>
where
T: serde::de::DeserializeOwned,
@@ -98,10 +111,13 @@ where
Err(_) => return None,
};
// TODO: Change return type to some sort of Result
Some(parsed)
}
pub fn start(config: Config, project: Project, plugin_chain: &'static PluginChain, vfs: Arc<Mutex<Vfs>>) {
/// Start the Rojo web server and park our current thread.
pub fn start(config: WebConfig, project: Project, plugin_chain: &'static PluginChain, vfs: Arc<Mutex<VfsSession>>) {
let address = format!("localhost:{}", config.port);
let server_id = config.server_id.to_string();
@@ -109,6 +125,8 @@ pub fn start(config: Config, project: Project, plugin_chain: &'static PluginChai
rouille::start_server(address, move |request| {
router!(request,
(GET) (/) => {
// Get a summary of information about the server.
let current_time = {
let vfs = vfs.lock().unwrap();
@@ -125,6 +143,8 @@ pub fn start(config: Config, project: Project, plugin_chain: &'static PluginChai
},
(GET) (/changes/{ last_time: f64 }) => {
// Get the list of changes since the given time.
let vfs = vfs.lock().unwrap();
let current_time = vfs.current_time();
let changes = vfs.changes_since(last_time);
@@ -137,11 +157,16 @@ pub fn start(config: Config, project: Project, plugin_chain: &'static PluginChai
},
(POST) (/read) => {
// Read some instances from the server according to a JSON
// format body.
let read_request: Vec<Vec<String>> = match read_json(&request) {
Some(v) => v,
None => return rouille::Response::empty_400(),
};
// Read the files off of the filesystem that the client
// requested.
let (items, current_time) = {
let vfs = vfs.lock().unwrap();
@@ -159,6 +184,8 @@ pub fn start(config: Config, project: Project, plugin_chain: &'static PluginChai
(items, current_time)
};
// Transform all of our VfsItem objects into Roblox instances
// the client can use.
let rbx_items = items
.iter()
.map(|item| {
@@ -182,6 +209,8 @@ pub fn start(config: Config, project: Project, plugin_chain: &'static PluginChai
},
(POST) (/write) => {
// Not yet implemented.
let _write_request: Vec<WriteSpecifier> = match read_json(&request) {
Some(v) => v,
None => return rouille::Response::empty_400(),