diff --git a/DESIGN.md b/DESIGN.md index f1877c39..dba5d405 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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: diff --git a/src/bin.rs b/src/bin.rs index a0c87fb8..2546c9aa 100644 --- a/src/bin.rs +++ b/src/bin.rs @@ -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::(); - - 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::() { - 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); }, } } diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 00000000..96cbbcb3 --- /dev/null +++ b/src/commands/init.rs @@ -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); + }, + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 00000000..58023fa3 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,5 @@ +mod serve; +mod init; + +pub use self::serve::*; +pub use self::init::*; diff --git a/src/commands/serve.rs b/src/commands/serve.rs new file mode 100644 index 00000000..8ffc3790 --- /dev/null +++ b/src/commands/serve.rs @@ -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) { + let server_id = rand::random::(); + + 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()); +} diff --git a/src/core.rs b/src/core.rs index 8300fe0b..9259cca0 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,8 +1 @@ -#[derive(Debug, Clone)] -pub struct Config { - pub port: u64, - pub verbose: bool, - pub server_id: u64, -} - pub type Route = Vec; diff --git a/src/plugin.rs b/src/plugin.rs index 3f18bcbe..2f25bf55 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -2,15 +2,18 @@ use rbx::RbxInstance; use vfs::VfsItem; use core::Route; -// TODO: Add error case? pub enum TransformFileResult { Value(Option), Pass, + + // TODO: Error case } pub enum RbxChangeResult { Write(Option), 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>, } diff --git a/src/project.rs b/src/project.rs index 10392c53..eb45ce21 100644 --- a/src/project.rs +++ b/src/project.rs @@ -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>(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>(location: T) -> Result { 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>(location: T) -> Result { 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>(&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(), } diff --git a/src/rbx.rs b/src/rbx.rs index 151414e0..03019fc8 100644 --- a/src/rbx.rs +++ b/src/rbx.rs @@ -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, } +/// 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 } diff --git a/src/vfs/mod.rs b/src/vfs/mod.rs new file mode 100644 index 00000000..a19dc4d0 --- /dev/null +++ b/src/vfs/mod.rs @@ -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::*; diff --git a/src/vfs/vfs_item.rs b/src/vfs/vfs_item.rs new file mode 100644 index 00000000..ae00c6dc --- /dev/null +++ b/src/vfs/vfs_item.rs @@ -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, + contents: String, + }, + Dir { + route: Vec, + children: HashMap, + }, +} + +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, + } + } +} diff --git a/src/vfs.rs b/src/vfs/vfs_session.rs similarity index 82% rename from src/vfs.rs rename to src/vfs/vfs_session.rs index 00b7617d..504f16b6 100644 --- a/src/vfs.rs +++ b/src/vfs/vfs_session.rs @@ -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, plugin_chain: &'static PluginChain, - - config: Config, } #[derive(Debug, Serialize, Deserialize)] @@ -36,44 +34,13 @@ pub struct VfsChange { route: Vec, } -/// 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, - contents: String, - }, - Dir { - route: Vec, - children: HashMap, - }, -} - -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) { - 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, diff --git a/src/vfs_watch.rs b/src/vfs/vfs_watcher.rs similarity index 90% rename from src/vfs_watch.rs rename to src/vfs/vfs_watcher.rs index cb0d15be..5a5cd91b 100644 --- a/src/vfs_watch.rs +++ b/src/vfs/vfs_watcher.rs @@ -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>, + vfs: Arc>, watchers: Vec, - config: Config, } impl VfsWatcher { - pub fn new(config: Config, vfs: Arc>) -> VfsWatcher { + pub fn new(vfs: Arc>) -> 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) | diff --git a/src/web.rs b/src/web.rs index c475bc6e..dd05b2a4 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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(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 { + // 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 { 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 { Some(parsed) } +/// Reads the body out of a Rouille Request and attempts to turn it into JSON. fn read_json(request: &rouille::Request) -> Option 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>) { +/// Start the Rojo web server and park our current thread. +pub fn start(config: WebConfig, project: Project, plugin_chain: &'static PluginChain, vfs: Arc>) { 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> = 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 = match read_json(&request) { Some(v) => v, None => return rouille::Response::empty_400(),