diff --git a/Cargo.lock b/Cargo.lock index 5824f1d5..027834be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,11 @@ dependencies = [ "gzip-header 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "difference" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "dtoa" version = "0.4.3" @@ -932,6 +937,26 @@ dependencies = [ "winapi 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "paste" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "paste-impl 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro-hack 0.5.4 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "paste-impl" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro-hack 0.5.4 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro2 0.4.26 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 0.6.11 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 0.15.26 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "percent-encoding" version = "1.0.1" @@ -977,6 +1002,25 @@ name = "pkg-config" version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "pretty_assertions" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "ansi_term 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)", + "difference 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 0.4.26 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 0.6.11 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 0.15.26 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "proc-macro2" version = "0.4.26" @@ -1258,6 +1302,8 @@ dependencies = [ "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", "maplit 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "notify 4.0.7 (registry+https://github.com/rust-lang/crates.io-index)", + "paste 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", + "pretty_assertions 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", "rand 0.4.5 (registry+https://github.com/rust-lang/crates.io-index)", "rbx_binary 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "rbx_tree 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1927,6 +1973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum csv 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)" = "9fd1c44c58078cfbeaf11fbb3eac9ae5534c23004ed770cc4bfb48e658ae4f04" "checksum csv-core 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "fa5cdef62f37e6ffe7d1f07a381bc0db32b7a3ff1cac0de56cb0d81e71f53d65" "checksum deflate 0.7.19 (registry+https://github.com/rust-lang/crates.io-index)" = "8a6abb26e16e8d419b5c78662aa9f82857c2386a073da266840e474d5055ec86" +"checksum difference 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" "checksum dtoa 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6d301140eb411af13d3115f9a562c85cc6b541ade9dfa314132244aaee7489dd" "checksum encoding_rs 0.8.14 (registry+https://github.com/rust-lang/crates.io-index)" = "a69d152eaa438a291636c1971b0a370212165ca8a75759eb66818c5ce9b538f7" "checksum env_logger 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "afb070faf94c85d17d50ca44f6ad076bce18ae92f0037d350947240a36e9d42e" @@ -1993,12 +2040,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum owning_ref 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "49a4b8ea2179e6a2e27411d3bca09ca6dd630821cf6894c6c7c8467a8ee7ef13" "checksum parking_lot 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ab41b4aed082705d1056416ae4468b6ea99d52599ecf3169b00088d43113e337" "checksum parking_lot_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "94c8c7923936b28d546dfd14d4472eaf34c99b14e1c973a32b3e6d4eb04298c9" +"checksum paste 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "f50392d1265092fbee9273414cc40eb6d47d307bd66222c477bb8450c8504f9d" +"checksum paste-impl 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "a3cd512fe3a55e8933b2dcad913e365639db86d512e4004c3084b86864d9467a" "checksum percent-encoding 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "31010dd2e1ac33d5b46a5b413495239882813e0369f8ed8a5e266f173602f831" "checksum phf 0.7.24 (registry+https://github.com/rust-lang/crates.io-index)" = "b3da44b85f8e8dfaec21adae67f95d93244b2ecf6ad2a692320598dcc8e6dd18" "checksum phf_codegen 0.7.24 (registry+https://github.com/rust-lang/crates.io-index)" = "b03e85129e324ad4166b06b2c7491ae27fe3ec353af72e72cd1654c7225d517e" "checksum phf_generator 0.7.24 (registry+https://github.com/rust-lang/crates.io-index)" = "09364cc93c159b8b06b1f4dd8a4398984503483891b0c26b867cf431fb132662" "checksum phf_shared 0.7.24 (registry+https://github.com/rust-lang/crates.io-index)" = "234f71a15de2288bcb7e3b6515828d22af7ec8598ee6d24c3b526fa0a80b67a0" "checksum pkg-config 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)" = "676e8eb2b1b4c9043511a9b7bea0915320d7e502b0a079fb03f9635a5252b18c" +"checksum pretty_assertions 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "3a029430f0d744bc3d15dd474d591bed2402b645d024583082b9f63bb936dac6" +"checksum proc-macro-hack 0.5.4 (registry+https://github.com/rust-lang/crates.io-index)" = "3e90aa19cd73dedc2d0e1e8407473f073d735fef0ab521438de6da8ee449ab66" "checksum proc-macro2 0.4.26 (registry+https://github.com/rust-lang/crates.io-index)" = "38fddd23d98b2144d197c0eca5705632d4fe2667d14a6be5df8934f8d74f1978" "checksum pulldown-cmark 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "eef52fac62d0ea7b9b4dc7da092aa64ea7ec3d90af6679422d3d7e0e14b6ee15" "checksum quick-error 1.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "9274b940887ce9addde99c4eee6b5c44cc494b182b97e73dc8ffdcb3397fd3f0" diff --git a/server/Cargo.toml b/server/Cargo.toml index fa96ef5e..0e878811 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -28,6 +28,9 @@ log = "0.4" maplit = "1.0.1" notify = "4.0" rand = "0.4" +rbx_binary = "0.2.0" +rbx_tree = "0.2.0" +rbx_xml = "0.2.0" regex = "1.0" reqwest = "0.9.5" rouille = "2.1" @@ -35,11 +38,10 @@ serde = "1.0" serde_derive = "1.0" serde_json = "1.0" uuid = { version = "0.7", features = ["v4", "serde"] } -rbx_tree = "0.2.0" -rbx_xml = "0.2.0" -rbx_binary = "0.2.0" [dev-dependencies] tempfile = "3.0" walkdir = "2.1" -lazy_static = "1.2" \ No newline at end of file +lazy_static = "1.2" +pretty_assertions = "0.5.1" +paste = "0.1" \ No newline at end of file diff --git a/server/src/fs_watcher.rs b/server/src/fs_watcher.rs index 33159a55..9641b293 100644 --- a/server/src/fs_watcher.rs +++ b/server/src/fs_watcher.rs @@ -44,13 +44,14 @@ impl FsWatcher { let imfs = imfs.lock().unwrap(); for root_path in imfs.get_roots() { + trace!("Watching path {}", root_path.display()); watcher.watch(root_path, RecursiveMode::Recursive) .expect("Could not watch directory"); } } { - let imfs = Arc::clone(&imfs); + let imfs = Arc::clone(&imfs); let rbx_session = rbx_session.as_ref().map(Arc::clone); thread::spawn(move || { diff --git a/server/src/imfs.rs b/server/src/imfs.rs index cd14b5b1..c144ae5e 100644 --- a/server/src/imfs.rs +++ b/server/src/imfs.rs @@ -35,16 +35,13 @@ impl fmt::Display for FsError { } } -fn add_sync_points(imfs: &mut Imfs, project_node: &ProjectNode) -> Result<(), FsError> { - match project_node { - ProjectNode::Instance(node) => { - for child in node.children.values() { - add_sync_points(imfs, child)?; - } - }, - ProjectNode::SyncPoint(node) => { - imfs.add_root(&node.path)?; - }, +fn add_sync_points(imfs: &mut Imfs, node: &ProjectNode) -> Result<(), FsError> { + if let Some(path) = &node.path { + imfs.add_root(path)?; + } + + for child in node.children.values() { + add_sync_points(imfs, child)?; } Ok(()) diff --git a/server/src/lib.rs b/server/src/lib.rs index 6befeb64..2239a23c 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -6,12 +6,13 @@ pub mod impl_from; pub mod commands; pub mod fs_watcher; pub mod imfs; +pub mod live_session; pub mod message_queue; pub mod path_map; +pub mod path_serializer; pub mod project; pub mod rbx_session; pub mod rbx_snapshot; -pub mod live_session; pub mod session_id; pub mod snapshot_reconciler; pub mod visualize; diff --git a/server/src/path_map.rs b/server/src/path_map.rs index af3f473c..d944541e 100644 --- a/server/src/path_map.rs +++ b/server/src/path_map.rs @@ -1,5 +1,4 @@ use std::{ - collections::hash_map, path::{self, Path, PathBuf}, collections::{HashMap, HashSet}, }; @@ -36,12 +35,6 @@ impl PathMap { self.nodes.get_mut(path).map(|v| &mut v.value) } - pub fn entry<'a>(&'a mut self, path: PathBuf) -> Entry<'a, T> { - Entry { - internal: self.nodes.entry(path), - } - } - pub fn insert(&mut self, path: PathBuf, value: T) { if let Some(parent_path) = path.parent() { if let Some(parent) = self.nodes.get_mut(parent_path) { @@ -116,28 +109,4 @@ impl PathMap { current_path } -} - -pub struct Entry<'a, T> { - internal: hash_map::Entry<'a, PathBuf, PathMapNode>, -} - -impl<'a, T> Entry<'a, T> { - pub fn or_insert(self, value: T) -> &'a mut T { - &mut self.internal.or_insert(PathMapNode { - value, - children: HashSet::new(), - }).value - } -} - -impl<'a, T> Entry<'a, T> - where T: Default -{ - pub fn or_default(self) -> &'a mut T { - &mut self.internal.or_insert(PathMapNode { - value: Default::default(), - children: HashSet::new(), - }).value - } } \ No newline at end of file diff --git a/server/src/path_serializer.rs b/server/src/path_serializer.rs new file mode 100644 index 00000000..214a773a --- /dev/null +++ b/server/src/path_serializer.rs @@ -0,0 +1,69 @@ +//! 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 field with the correct +//! serializer function: +//! +//! ``` +//! # use std::path::PathBuf; +//! # use serde_derive::{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, +//! } +//! ``` +//! +//! **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(maybe_path: &Option, serializer: S) -> Result + where S: Serializer, + T: AsRef, +{ + match maybe_path { + Some(path) => serialize(path, serializer), + None => serializer.serialize_none() + } +} + +pub fn serialize(path: T, serializer: S) -> Result + where S: Serializer, + T: AsRef, +{ + 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) +} \ No newline at end of file diff --git a/server/src/project.rs b/server/src/project.rs index c391e02d..46ea7492 100644 --- a/server/src/project.rs +++ b/server/src/project.rs @@ -15,16 +15,6 @@ use serde_derive::{Serialize, Deserialize}; pub static PROJECT_FILENAME: &'static str = "default.project.json"; pub static COMPAT_PROJECT_FILENAME: &'static str = "roblox-project.json"; -// Methods used for Serde's default value system, which doesn't support using -// value literals directly, only functions that return values. -const fn yeah() -> bool { - true -} - -const fn is_true(value: &bool) -> bool { - *value -} - /// 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. @@ -60,59 +50,47 @@ impl SourceProject { /// slightly different on-disk than how we want to handle them in the rest of /// Rojo. #[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] -enum SourceProjectNode { - Instance { - #[serde(rename = "$className")] - class_name: String, +struct SourceProjectNode { + #[serde(rename = "$className", skip_serializing_if = "Option::is_none")] + class_name: Option, - #[serde(rename = "$properties", default = "HashMap::new", skip_serializing_if = "HashMap::is_empty")] - properties: HashMap, + #[serde(rename = "$properties", default = "HashMap::new", skip_serializing_if = "HashMap::is_empty")] + properties: HashMap, - #[serde(rename = "$ignoreUnknownInstances", default = "yeah", skip_serializing_if = "is_true")] - ignore_unknown_instances: bool, + #[serde(rename = "$ignoreUnknownInstances", skip_serializing_if = "Option::is_none")] + ignore_unknown_instances: Option, - #[serde(flatten)] - children: HashMap, - }, - SyncPoint { - #[serde(rename = "$path")] - path: String, - } + #[serde(rename = "$path", skip_serializing_if = "Option::is_none")] + path: Option, + + #[serde(flatten)] + children: HashMap, } impl SourceProjectNode { /// Consumes the SourceProjectNode and turns it into a ProjectNode. - pub fn into_project_node(self, project_file_location: &Path) -> ProjectNode { - match self { - SourceProjectNode::Instance { class_name, mut children, properties, ignore_unknown_instances } => { - let mut new_children = HashMap::new(); + pub fn into_project_node(mut self, project_file_location: &Path) -> ProjectNode { + let children = self.children.drain() + .map(|(key, value)| (key, value.into_project_node(project_file_location))) + .collect(); - for (node_name, node) in children.drain() { - new_children.insert(node_name, node.into_project_node(project_file_location)); - } + // 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::Instance(InstanceProjectNode { - class_name, - children: new_children, - properties, - metadata: InstanceProjectNodeMetadata { - ignore_unknown_instances, - }, - }) - }, - SourceProjectNode::SyncPoint { path: source_path } => { - let 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::SyncPoint(SyncPointProjectNode { - path, - }) - }, + ProjectNode { + class_name: self.class_name, + properties: self.properties, + ignore_unknown_instances: self.ignore_unknown_instances, + path, + children, } } } @@ -177,75 +155,49 @@ pub enum ProjectSaveError { IoError(#[fail(cause)] io::Error), } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InstanceProjectNodeMetadata { - pub ignore_unknown_instances: bool, -} +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ProjectNode { + pub class_name: Option, + pub children: HashMap, + pub properties: HashMap, + pub ignore_unknown_instances: Option, -impl Default for InstanceProjectNodeMetadata { - fn default() -> InstanceProjectNodeMetadata { - InstanceProjectNodeMetadata { - ignore_unknown_instances: true, - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum ProjectNode { - Instance(InstanceProjectNode), - SyncPoint(SyncPointProjectNode), + #[serde(serialize_with = "crate::path_serializer::serialize_option")] + pub path: Option, } impl ProjectNode { fn to_source_node(&self, project_file_location: &Path) -> SourceProjectNode { - match self { - ProjectNode::Instance(node) => { - let mut children = HashMap::new(); + let children = self.children.iter() + .map(|(key, value)| (key.clone(), value.to_source_node(project_file_location))) + .collect(); - for (key, child) in &node.children { - children.insert(key.clone(), child.to_source_node(project_file_location)); - } + // 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(); - SourceProjectNode::Instance { - class_name: node.class_name.clone(), - children, - properties: node.properties.clone(), - ignore_unknown_instances: node.metadata.ignore_unknown_instances, - } - }, - ProjectNode::SyncPoint(sync_node) => { - 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()), + } + }); - let friendly_path = match sync_node.path.strip_prefix(project_folder_location) { - Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"), - Err(_) => format!("{}", sync_node.path.display()), - }; - - SourceProjectNode::SyncPoint { - path: friendly_path, - } - }, + 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)] -#[serde(rename_all = "camelCase")] -pub struct InstanceProjectNode { - pub class_name: String, - pub children: HashMap, - pub properties: HashMap, - pub metadata: InstanceProjectNodeMetadata, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SyncPointProjectNode { - pub path: PathBuf, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Project { pub name: String, @@ -265,33 +217,31 @@ impl Project { project_fuzzy_path.file_name().unwrap().to_str().unwrap() }; - let tree = ProjectNode::Instance(InstanceProjectNode { - class_name: "DataModel".to_string(), + let tree = ProjectNode { + class_name: Some(String::from("DataModel")), children: hashmap! { - String::from("ReplicatedStorage") => ProjectNode::Instance(InstanceProjectNode { - class_name: String::from("ReplicatedStorage"), + String::from("ReplicatedStorage") => ProjectNode { + class_name: Some(String::from("ReplicatedStorage")), children: hashmap! { - String::from("Source") => ProjectNode::SyncPoint(SyncPointProjectNode { - path: project_folder_path.join("src"), - }), + String::from("Source") => ProjectNode { + path: Some(project_folder_path.join("src")), + ..Default::default() + }, }, - properties: HashMap::new(), - metadata: Default::default(), - }), - String::from("HttpService") => ProjectNode::Instance(InstanceProjectNode { - class_name: String::from("HttpService"), - children: HashMap::new(), + ..Default::default() + }, + String::from("HttpService") => ProjectNode { + class_name: Some(String::from("HttpService")), properties: hashmap! { String::from("HttpEnabled") => RbxValue::Bool { value: true, }, }, - metadata: Default::default(), - }), + ..Default::default() + }, }, - properties: HashMap::new(), - metadata: Default::default(), - }); + ..Default::default() + }; let project = Project { name: project_name.to_string(), @@ -316,9 +266,10 @@ impl Project { project_fuzzy_path.file_name().unwrap().to_str().unwrap() }; - let tree = ProjectNode::SyncPoint(SyncPointProjectNode { - path: project_folder_path.join("src"), - }); + let tree = ProjectNode { + path: Some(project_folder_path.join("src")), + ..Default::default() + }; let project = Project { name: project_name.to_string(), diff --git a/server/src/rbx_session.rs b/server/src/rbx_session.rs index 47a22535..c6e32375 100644 --- a/server/src/rbx_session.rs +++ b/server/src/rbx_session.rs @@ -1,6 +1,6 @@ use std::{ borrow::Cow, - collections::HashMap, + collections::{HashSet, HashMap}, path::{Path, PathBuf}, str, sync::{Arc, Mutex}, @@ -11,11 +11,11 @@ use log::{info, trace}; use rbx_tree::{RbxTree, RbxId}; use crate::{ - project::Project, + project::{Project, ProjectNode}, message_queue::MessageQueue, imfs::{Imfs, ImfsItem}, path_map::PathMap, - rbx_snapshot::{SnapshotContext, snapshot_project_tree, snapshot_imfs_path}, + rbx_snapshot::{snapshot_project_tree, snapshot_project_node, snapshot_imfs_path}, snapshot_reconciler::{InstanceChanges, reify_root, reconcile_subtree}, }; @@ -23,24 +23,28 @@ const INIT_SCRIPT: &str = "init.lua"; const INIT_SERVER_SCRIPT: &str = "init.server.lua"; const INIT_CLIENT_SCRIPT: &str = "init.client.lua"; -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct MetadataPerPath { - pub instance_id: Option, - pub instance_name: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +/// `source_path` or `project_definition` or both must both be Some. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct MetadataPerInstance { - pub source_path: Option, pub ignore_unknown_instances: bool, + + /// The path on the filesystem that the instance was read from the + /// filesystem if it came from the filesystem. + #[serde(serialize_with = "crate::path_serializer::serialize_option")] + pub source_path: Option, + + /// Information about the instance that came from the project that defined + /// it, if that's where it was defined. + /// + /// A key-value pair where the key should be the name of the instance and + /// the value is the ProjectNode from the instance's project. + pub project_definition: Option<(String, ProjectNode)>, } pub struct RbxSession { tree: RbxTree, - // TODO(#105): Change metadata_per_path to PathMap> for - // path aliasing. - metadata_per_path: PathMap, + instances_per_path: PathMap>, metadata_per_instance: HashMap, message_queue: Arc>, imfs: Arc>, @@ -52,17 +56,17 @@ impl RbxSession { imfs: Arc>, message_queue: Arc>, ) -> RbxSession { - let mut metadata_per_path = PathMap::new(); + let mut instances_per_path = PathMap::new(); let mut metadata_per_instance = HashMap::new(); let tree = { let temp_imfs = imfs.lock().unwrap(); - reify_initial_tree(&project, &temp_imfs, &mut metadata_per_path, &mut metadata_per_instance) + reify_initial_tree(&project, &temp_imfs, &mut instances_per_path, &mut metadata_per_instance) }; RbxSession { tree, - metadata_per_path, + instances_per_path, metadata_per_instance, message_queue, imfs, @@ -80,7 +84,7 @@ impl RbxSession { .expect("Path was outside in-memory filesystem roots"); // Find the closest instance in the tree that currently exists - let mut path_to_snapshot = self.metadata_per_path.descend(root_path, path); + let mut path_to_snapshot = self.instances_per_path.descend(root_path, path); // If this is a file that might affect its parent if modified, we // should snapshot its parent instead. @@ -93,42 +97,44 @@ impl RbxSession { trace!("Snapshotting path {}", path_to_snapshot.display()); - let path_metadata = self.metadata_per_path.get(&path_to_snapshot).unwrap(); + let instances_at_path = self.instances_per_path.get(&path_to_snapshot) + .expect("Metadata did not exist for path") + .clone(); - trace!("Metadata for path: {:?}", path_metadata); + for instance_id in &instances_at_path { + let instance_metadata = self.metadata_per_instance.get(&instance_id) + .expect("Metadata for instance ID did not exist"); - let instance_id = path_metadata.instance_id - .expect("Instance did not exist in tree"); + let maybe_snapshot = match &instance_metadata.project_definition { + Some((instance_name, project_node)) => { + snapshot_project_node(&imfs, &project_node, Cow::Owned(instance_name.clone())) + .unwrap_or_else(|_| panic!("Could not generate instance snapshot for path {}", path_to_snapshot.display())) + }, + None => { + snapshot_imfs_path(&imfs, &path_to_snapshot, None) + .unwrap_or_else(|_| panic!("Could not generate instance snapshot for path {}", path_to_snapshot.display())) + }, + }; - // If this instance is a sync point, pull its name out of our - // per-path metadata store. - let instance_name = path_metadata.instance_name.as_ref() - .map(|value| Cow::Owned(value.to_owned())); + let snapshot = match maybe_snapshot { + Some(snapshot) => snapshot, + None => { + trace!("Path resulted in no snapshot being generated."); + return; + }, + }; - let mut context = SnapshotContext { - metadata_per_path: &mut self.metadata_per_path, - }; - let maybe_snapshot = snapshot_imfs_path(&imfs, &mut context, &path_to_snapshot, instance_name) - .unwrap_or_else(|_| panic!("Could not generate instance snapshot for path {}", path_to_snapshot.display())); + trace!("Snapshot: {:#?}", snapshot); - let snapshot = match maybe_snapshot { - Some(snapshot) => snapshot, - None => { - trace!("Path resulted in no snapshot being generated."); - return; - }, - }; - - trace!("Snapshot: {:#?}", snapshot); - - reconcile_subtree( - &mut self.tree, - instance_id, - &snapshot, - &mut self.metadata_per_path, - &mut self.metadata_per_instance, - &mut changes, - ); + reconcile_subtree( + &mut self.tree, + *instance_id, + &snapshot, + &mut self.instances_per_path, + &mut self.metadata_per_instance, + &mut changes, + ); + } } if changes.is_empty() { @@ -170,13 +176,13 @@ impl RbxSession { pub fn path_removed(&mut self, path: &Path) { info!("Path removed: {}", path.display()); - self.metadata_per_path.remove(path); + self.instances_per_path.remove(path); self.path_created_or_updated(path); } pub fn path_renamed(&mut self, from_path: &Path, to_path: &Path) { info!("Path renamed from {} to {}", from_path.display(), to_path.display()); - self.metadata_per_path.remove(from_path); + self.instances_per_path.remove(from_path); self.path_created_or_updated(from_path); self.path_created_or_updated(to_path); } @@ -188,33 +194,26 @@ impl RbxSession { pub fn get_instance_metadata(&self, id: RbxId) -> Option<&MetadataPerInstance> { self.metadata_per_instance.get(&id) } - - pub fn debug_get_metadata_per_path(&self) -> &PathMap { - &self.metadata_per_path - } } pub fn construct_oneoff_tree(project: &Project, imfs: &Imfs) -> RbxTree { - let mut metadata_per_path = PathMap::new(); + let mut instances_per_path = PathMap::new(); let mut metadata_per_instance = HashMap::new(); - reify_initial_tree(project, imfs, &mut metadata_per_path, &mut metadata_per_instance) + reify_initial_tree(project, imfs, &mut instances_per_path, &mut metadata_per_instance) } fn reify_initial_tree( project: &Project, imfs: &Imfs, - metadata_per_path: &mut PathMap, + instances_per_path: &mut PathMap>, metadata_per_instance: &mut HashMap, ) -> RbxTree { - let mut context = SnapshotContext { - metadata_per_path, - }; - let snapshot = snapshot_project_tree(imfs, &mut context, project) + let snapshot = snapshot_project_tree(imfs, project) .expect("Could not snapshot project tree") .expect("Project did not produce any instances"); let mut changes = InstanceChanges::default(); - let tree = reify_root(&snapshot, metadata_per_path, metadata_per_instance, &mut changes); + let tree = reify_root(&snapshot, instances_per_path, metadata_per_instance, &mut changes); tree } \ No newline at end of file diff --git a/server/src/rbx_snapshot.rs b/server/src/rbx_snapshot.rs index fe3a2637..4b08be4f 100644 --- a/server/src/rbx_snapshot.rs +++ b/server/src/rbx_snapshot.rs @@ -22,16 +22,13 @@ use crate::{ project::{ Project, ProjectNode, - InstanceProjectNode, - SyncPointProjectNode, }, snapshot_reconciler::{ RbxSnapshotInstance, snapshot_from_tree, }, - path_map::PathMap, - // TODO: Move MetadataPerPath into this module? - rbx_session::{MetadataPerPath, MetadataPerInstance}, + // TODO: Move MetadataPerInstance into this module? + rbx_session::MetadataPerInstance, }; const INIT_MODULE_NAME: &str = "init.lua"; @@ -40,10 +37,6 @@ const INIT_CLIENT_NAME: &str = "init.client.lua"; pub type SnapshotResult<'a> = Result>, SnapshotError>; -pub struct SnapshotContext<'meta> { - pub metadata_per_path: &'meta mut PathMap, -} - #[derive(Debug, Fail)] pub enum SnapshotError { DidNotExist(PathBuf), @@ -55,6 +48,7 @@ pub enum SnapshotError { }, JsonModelDecodeError { + #[fail(cause)] inner: serde_json::Error, path: PathBuf, }, @@ -68,6 +62,12 @@ pub enum SnapshotError { inner: rbx_binary::DecodeError, path: PathBuf, }, + + ProjectNodeUnusable, + + ProjectNodeInvalidTransmute { + partition_path: PathBuf, + }, } impl fmt::Display for SnapshotError { @@ -78,7 +78,7 @@ impl fmt::Display for SnapshotError { write!(output, "Invalid UTF-8: {} in path {}", inner, path.display()) }, SnapshotError::JsonModelDecodeError { inner, path } => { - write!(output, "Malformed .model.json model: {:?} in path {}", inner, path.display()) + write!(output, "Malformed .model.json model: {} in path {}", inner, path.display()) }, SnapshotError::XmlModelDecodeError { inner, path } => { write!(output, "Malformed rbxmx model: {:?} in path {}", inner, path.display()) @@ -86,107 +86,131 @@ impl fmt::Display for SnapshotError { SnapshotError::BinaryModelDecodeError { inner, path } => { write!(output, "Malformed rbxm model: {:?} in path {}", inner, path.display()) }, + SnapshotError::ProjectNodeUnusable => { + write!(output, "Rojo project nodes must specify either $path or $className.") + }, + SnapshotError::ProjectNodeInvalidTransmute { partition_path } => { + writeln!(output, "Rojo project nodes that specify both $path and $className require that the")?; + writeln!(output, "instance produced by the files pointed to by $path has a ClassName of")?; + writeln!(output, "Folder.")?; + writeln!(output, "")?; + writeln!(output, "Partition target ($path): {}", partition_path.display()) + }, } } } pub fn snapshot_project_tree<'source>( imfs: &'source Imfs, - context: &mut SnapshotContext, project: &'source Project, ) -> SnapshotResult<'source> { - snapshot_project_node(imfs, context, &project.tree, Cow::Borrowed(&project.name)) + snapshot_project_node(imfs, &project.tree, Cow::Borrowed(&project.name)) } -fn snapshot_project_node<'source>( +pub fn snapshot_project_node<'source>( imfs: &'source Imfs, - context: &mut SnapshotContext, - node: &'source ProjectNode, + node: &ProjectNode, instance_name: Cow<'source, str>, ) -> SnapshotResult<'source> { - match node { - ProjectNode::Instance(instance_node) => snapshot_instance_node(imfs, context, instance_node, instance_name), - ProjectNode::SyncPoint(sync_node) => snapshot_sync_point_node(imfs, context, sync_node, instance_name), - } -} + let maybe_snapshot = match &node.path { + Some(path) => snapshot_imfs_path(imfs, &path, Some(instance_name.clone()))?, + None => match &node.class_name { + Some(_class_name) => Some(RbxSnapshotInstance { + name: instance_name.clone(), -fn snapshot_instance_node<'source>( - imfs: &'source Imfs, - context: &mut SnapshotContext, - node: &'source InstanceProjectNode, - instance_name: Cow<'source, str>, -) -> SnapshotResult<'source> { - let mut children = Vec::new(); - - for (child_name, child_project_node) in &node.children { - if let Some(child) = snapshot_project_node(imfs, context, child_project_node, Cow::Borrowed(child_name))? { - children.push(child); - } - } - - Ok(Some(RbxSnapshotInstance { - class_name: Cow::Borrowed(&node.class_name), - name: instance_name, - properties: node.properties.clone(), - children, - metadata: MetadataPerInstance { - source_path: None, - ignore_unknown_instances: node.metadata.ignore_unknown_instances, + // These properties are replaced later in the function to + // reduce code duplication. + class_name: Cow::Borrowed("Folder"), + properties: HashMap::new(), + children: Vec::new(), + metadata: MetadataPerInstance { + source_path: None, + ignore_unknown_instances: true, + project_definition: None, + }, + }), + None => { + return Err(SnapshotError::ProjectNodeUnusable); + }, }, - })) -} - -fn snapshot_sync_point_node<'source>( - imfs: &'source Imfs, - context: &mut SnapshotContext, - node: &'source SyncPointProjectNode, - instance_name: Cow<'source, str>, -) -> SnapshotResult<'source> { - let maybe_snapshot = snapshot_imfs_path(imfs, context, &node.path, Some(instance_name))?; + }; // If the snapshot resulted in no instances, like if it targets an unknown // file or an empty model file, we can early-return. - let snapshot = match maybe_snapshot { + // + // In the future, we might want to issue a warning if the project also + // specified fields like class_name, since the user will probably be + // confused as to why nothing showed up in the tree. + let mut snapshot = match maybe_snapshot { Some(snapshot) => snapshot, - None => return Ok(None), + None => { + // TODO: Return some other sort of marker here instead? If a node + // transitions from None into Some, it's possible that configuration + // from the ProjectNode might be lost since there's nowhere to put + // it! + return Ok(None); + }, }; - // Otherwise, we can log the name of the sync point we just snapshotted. - let path_meta = context.metadata_per_path.entry(node.path.to_owned()).or_default(); - path_meta.instance_name = Some(snapshot.name.clone().into_owned()); + // Applies the class name specified in `class_name` from the project, if it's + // set. + if let Some(class_name) = &node.class_name { + // This can only happen if `path` was specified in the project node and + // that path represented a non-Folder instance. + if snapshot.class_name != "Folder" { + return Err(SnapshotError::ProjectNodeInvalidTransmute { + partition_path: node.path.as_ref().unwrap().to_owned(), + }); + } + + snapshot.class_name = Cow::Owned(class_name.to_owned()); + } + + for (child_name, child_project_node) in &node.children { + if let Some(child) = snapshot_project_node(imfs, child_project_node, Cow::Owned(child_name.clone()))? { + snapshot.children.push(child); + } + } + + for (key, value) in &node.properties { + snapshot.properties.insert(key.clone(), value.clone()); + } + + if let Some(ignore_unknown_instances) = node.ignore_unknown_instances { + snapshot.metadata.ignore_unknown_instances = ignore_unknown_instances; + } + + snapshot.metadata.project_definition = Some((instance_name.into_owned(), node.clone())); Ok(Some(snapshot)) } pub fn snapshot_imfs_path<'source>( imfs: &'source Imfs, - context: &mut SnapshotContext, path: &Path, instance_name: Option>, ) -> SnapshotResult<'source> { // If the given path doesn't exist in the in-memory filesystem, we consider // that an error. match imfs.get(path) { - Some(imfs_item) => snapshot_imfs_item(imfs, context, imfs_item, instance_name), + Some(imfs_item) => snapshot_imfs_item(imfs, imfs_item, instance_name), None => return Err(SnapshotError::DidNotExist(path.to_owned())), } } fn snapshot_imfs_item<'source>( imfs: &'source Imfs, - context: &mut SnapshotContext, item: &'source ImfsItem, instance_name: Option>, ) -> SnapshotResult<'source> { match item { ImfsItem::File(file) => snapshot_imfs_file(file, instance_name), - ImfsItem::Directory(directory) => snapshot_imfs_directory(imfs, context, directory, instance_name), + ImfsItem::Directory(directory) => snapshot_imfs_directory(imfs, directory, instance_name), } } fn snapshot_imfs_directory<'source>( imfs: &'source Imfs, - context: &mut SnapshotContext, directory: &'source ImfsDirectory, instance_name: Option>, ) -> SnapshotResult<'source> { @@ -202,11 +226,11 @@ fn snapshot_imfs_directory<'source>( }); let mut snapshot = if directory.children.contains(&init_path) { - snapshot_imfs_path(imfs, context, &init_path, Some(snapshot_name))?.unwrap() + snapshot_imfs_path(imfs, &init_path, Some(snapshot_name))?.unwrap() } else if directory.children.contains(&init_server_path) { - snapshot_imfs_path(imfs, context, &init_server_path, Some(snapshot_name))?.unwrap() + snapshot_imfs_path(imfs, &init_server_path, Some(snapshot_name))?.unwrap() } else if directory.children.contains(&init_client_path) { - snapshot_imfs_path(imfs, context, &init_client_path, Some(snapshot_name))?.unwrap() + snapshot_imfs_path(imfs, &init_client_path, Some(snapshot_name))?.unwrap() } else { RbxSnapshotInstance { class_name: Cow::Borrowed("Folder"), @@ -216,6 +240,7 @@ fn snapshot_imfs_directory<'source>( metadata: MetadataPerInstance { source_path: None, ignore_unknown_instances: false, + project_definition: None, }, } }; @@ -234,7 +259,7 @@ fn snapshot_imfs_directory<'source>( // them here. }, _ => { - if let Some(child) = snapshot_imfs_path(imfs, context, child_path, None)? { + if let Some(child) = snapshot_imfs_path(imfs, child_path, None)? { snapshot.children.push(child); } }, @@ -316,6 +341,7 @@ fn snapshot_lua_file<'source>( metadata: MetadataPerInstance { source_path: Some(file.path.to_path_buf()), ignore_unknown_instances: false, + project_definition: None, }, })) } @@ -354,6 +380,7 @@ fn snapshot_txt_file<'source>( metadata: MetadataPerInstance { source_path: Some(file.path.to_path_buf()), ignore_unknown_instances: false, + project_definition: None, }, })) } @@ -387,6 +414,7 @@ fn snapshot_csv_file<'source>( metadata: MetadataPerInstance { source_path: Some(file.path.to_path_buf()), ignore_unknown_instances: false, + project_definition: None, }, })) } diff --git a/server/src/snapshot_reconciler.rs b/server/src/snapshot_reconciler.rs index 2562c4cb..0255bc8d 100644 --- a/server/src/snapshot_reconciler.rs +++ b/server/src/snapshot_reconciler.rs @@ -1,8 +1,9 @@ use std::{ - str, borrow::Cow, + cmp::Ordering, collections::{HashMap, HashSet}, fmt, + str, }; use rbx_tree::{RbxTree, RbxId, RbxInstanceProperties, RbxValue}; @@ -10,7 +11,7 @@ use serde_derive::{Serialize, Deserialize}; use crate::{ path_map::PathMap, - rbx_session::{MetadataPerPath, MetadataPerInstance}, + rbx_session::MetadataPerInstance, }; #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -55,7 +56,7 @@ impl InstanceChanges { } } -#[derive(Debug)] +#[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct RbxSnapshotInstance<'a> { pub name: Cow<'a, str>, pub class_name: Cow<'a, str>, @@ -64,6 +65,13 @@ pub struct RbxSnapshotInstance<'a> { pub metadata: MetadataPerInstance, } +impl<'a> PartialOrd for RbxSnapshotInstance<'a> { + fn partial_cmp(&self, other: &RbxSnapshotInstance) -> Option { + Some(self.name.cmp(&other.name) + .then(self.class_name.cmp(&other.class_name))) + } +} + pub fn snapshot_from_tree(tree: &RbxTree, id: RbxId) -> Option> { let instance = tree.get_instance(id)?; @@ -80,31 +88,27 @@ pub fn snapshot_from_tree(tree: &RbxTree, id: RbxId) -> Option, - instance_metadata_map: &mut HashMap, + instance_per_path: &mut PathMap>, + metadata_per_instance: &mut HashMap, changes: &mut InstanceChanges, ) -> RbxTree { let instance = reify_core(snapshot); let mut tree = RbxTree::new(instance); - let root_id = tree.get_root_id(); + let id = tree.get_root_id(); - if let Some(source_path) = &snapshot.metadata.source_path { - let path_meta = metadata_per_path.entry(source_path.to_owned()).or_default(); - path_meta.instance_id = Some(root_id); - } + reify_metadata(snapshot, id, instance_per_path, metadata_per_instance); - instance_metadata_map.insert(root_id, snapshot.metadata.clone()); - - changes.added.insert(root_id); + changes.added.insert(id); for child in &snapshot.children { - reify_subtree(child, &mut tree, root_id, metadata_per_path, instance_metadata_map, changes); + reify_subtree(child, &mut tree, id, instance_per_path, metadata_per_instance, changes); } tree @@ -114,47 +118,58 @@ pub fn reify_subtree( snapshot: &RbxSnapshotInstance, tree: &mut RbxTree, parent_id: RbxId, - metadata_per_path: &mut PathMap, - instance_metadata_map: &mut HashMap, + instance_per_path: &mut PathMap>, + metadata_per_instance: &mut HashMap, changes: &mut InstanceChanges, ) { let instance = reify_core(snapshot); let id = tree.insert_instance(instance, parent_id); - if let Some(source_path) = &snapshot.metadata.source_path { - let path_meta = metadata_per_path.entry(source_path.clone()).or_default(); - path_meta.instance_id = Some(id); - } - - instance_metadata_map.insert(id, snapshot.metadata.clone()); + reify_metadata(snapshot, id, instance_per_path, metadata_per_instance); changes.added.insert(id); for child in &snapshot.children { - reify_subtree(child, tree, id, metadata_per_path, instance_metadata_map, changes); + reify_subtree(child, tree, id, instance_per_path, metadata_per_instance, changes); } } +pub fn reify_metadata( + snapshot: &RbxSnapshotInstance, + instance_id: RbxId, + instance_per_path: &mut PathMap>, + metadata_per_instance: &mut HashMap, +) { + if let Some(source_path) = &snapshot.metadata.source_path { + let path_metadata = match instance_per_path.get_mut(&source_path) { + Some(v) => v, + None => { + instance_per_path.insert(source_path.clone(), Default::default()); + instance_per_path.get_mut(&source_path).unwrap() + }, + }; + + path_metadata.insert(instance_id); + } + + metadata_per_instance.insert(instance_id, snapshot.metadata.clone()); +} + pub fn reconcile_subtree( tree: &mut RbxTree, id: RbxId, snapshot: &RbxSnapshotInstance, - metadata_per_path: &mut PathMap, - instance_metadata_map: &mut HashMap, + instance_per_path: &mut PathMap>, + metadata_per_instance: &mut HashMap, changes: &mut InstanceChanges, ) { - if let Some(source_path) = &snapshot.metadata.source_path { - let path_meta = metadata_per_path.entry(source_path.to_owned()).or_default(); - path_meta.instance_id = Some(id); - } - - instance_metadata_map.insert(id, snapshot.metadata.clone()); + reify_metadata(snapshot, id, instance_per_path, metadata_per_instance); if reconcile_instance_properties(tree.get_instance_mut(id).unwrap(), snapshot) { changes.updated.insert(id); } - reconcile_instance_children(tree, id, snapshot, metadata_per_path, instance_metadata_map, changes); + reconcile_instance_children(tree, id, snapshot, instance_per_path, metadata_per_instance, changes); } fn reify_core(snapshot: &RbxSnapshotInstance) -> RbxInstanceProperties { @@ -234,8 +249,8 @@ fn reconcile_instance_children( tree: &mut RbxTree, id: RbxId, snapshot: &RbxSnapshotInstance, - metadata_per_path: &mut PathMap, - instance_metadata_map: &mut HashMap, + instance_per_path: &mut PathMap>, + metadata_per_instance: &mut HashMap, changes: &mut InstanceChanges, ) { let mut visited_snapshot_indices = HashSet::new(); @@ -287,19 +302,19 @@ fn reconcile_instance_children( } for child_snapshot in &children_to_add { - reify_subtree(child_snapshot, tree, id, metadata_per_path, instance_metadata_map, changes); + reify_subtree(child_snapshot, tree, id, instance_per_path, metadata_per_instance, changes); } for child_id in &children_to_remove { if let Some(subtree) = tree.remove_instance(*child_id) { for id in subtree.iter_all_ids() { - instance_metadata_map.remove(&id); + metadata_per_instance.remove(&id); changes.removed.insert(id); } } } for (child_id, child_snapshot) in &children_to_update { - reconcile_subtree(tree, *child_id, child_snapshot, metadata_per_path, instance_metadata_map, changes); + reconcile_subtree(tree, *child_id, child_snapshot, instance_per_path, metadata_per_instance, changes); } } \ No newline at end of file diff --git a/server/src/visualize.rs b/server/src/visualize.rs index a09326e8..c64bd328 100644 --- a/server/src/visualize.rs +++ b/server/src/visualize.rs @@ -11,7 +11,7 @@ use rbx_tree::RbxId; use crate::{ imfs::{Imfs, ImfsItem}, rbx_session::RbxSession, - web::InstanceMetadata, + web::PublicInstanceMetadata, }; static GRAPHVIZ_HEADER: &str = r#" @@ -74,7 +74,7 @@ fn visualize_rbx_node(session: &RbxSession, id: RbxId, output: &mut fmt::Formatt let mut node_label = format!("{}|{}|{}", node.name, node.class_name, id); if let Some(session_metadata) = session.get_instance_metadata(id) { - let metadata = InstanceMetadata::from_session_metadata(session_metadata); + let metadata = PublicInstanceMetadata::from_session_metadata(session_metadata); node_label.push('|'); node_label.push_str(&serde_json::to_string(&metadata).unwrap()); } diff --git a/server/src/web.rs b/server/src/web.rs index 5c78bdfa..1efd174b 100644 --- a/server/src/web.rs +++ b/server/src/web.rs @@ -27,13 +27,13 @@ static HOME_CONTENT: &str = include_str!("../assets/index.html"); /// Contains the instance metadata relevant to Rojo clients. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct InstanceMetadata { +pub struct PublicInstanceMetadata { ignore_unknown_instances: bool, } -impl InstanceMetadata { - pub fn from_session_metadata(meta: &MetadataPerInstance) -> InstanceMetadata { - InstanceMetadata { +impl PublicInstanceMetadata { + pub fn from_session_metadata(meta: &MetadataPerInstance) -> PublicInstanceMetadata { + PublicInstanceMetadata { ignore_unknown_instances: meta.ignore_unknown_instances, } } @@ -50,7 +50,7 @@ pub struct InstanceWithMetadata<'a> { pub instance: Cow<'a, RbxInstance>, #[serde(rename = "Metadata")] - pub metadata: Option, + pub metadata: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -120,9 +120,6 @@ impl Server { (GET) (/visualize/imfs) => { self.handle_visualize_imfs() }, - (GET) (/visualize/path_metadata) => { - self.handle_visualize_path_metadata() - }, _ => Response::empty_404() ) } @@ -209,7 +206,7 @@ impl Server { for &requested_id in &requested_ids { if let Some(instance) = tree.get_instance(requested_id) { let metadata = rbx_session.get_instance_metadata(requested_id) - .map(InstanceMetadata::from_session_metadata); + .map(PublicInstanceMetadata::from_session_metadata); instances.insert(instance.get_id(), InstanceWithMetadata { instance: Cow::Borrowed(instance), @@ -218,7 +215,7 @@ impl Server { for descendant in tree.descendants(requested_id) { let descendant_meta = rbx_session.get_instance_metadata(descendant.get_id()) - .map(InstanceMetadata::from_session_metadata); + .map(PublicInstanceMetadata::from_session_metadata); instances.insert(descendant.get_id(), InstanceWithMetadata { instance: Cow::Borrowed(descendant), @@ -254,9 +251,4 @@ impl Server { None => Response::text(dot_source), } } - - fn handle_visualize_path_metadata(&self) -> Response { - let rbx_session = self.live_session.rbx_session.lock().unwrap(); - Response::json(&rbx_session.debug_get_metadata_per_path()) - } } \ No newline at end of file diff --git a/server/tests/read_projects.rs b/server/tests/read_projects.rs index 55ae5550..d5314a21 100644 --- a/server/tests/read_projects.rs +++ b/server/tests/read_projects.rs @@ -1,16 +1,15 @@ #[macro_use] extern crate lazy_static; -extern crate librojo; - use std::{ collections::HashMap, path::{Path, PathBuf}, }; +use pretty_assertions::assert_eq; use rbx_tree::RbxValue; use librojo::{ - project::{Project, ProjectNode, InstanceProjectNode, SyncPointProjectNode}, + project::{Project, ProjectNode}, }; lazy_static! { @@ -44,54 +43,52 @@ fn empty_fuzzy_folder() { } #[test] -fn single_sync_point() { - let project_file_location = TEST_PROJECTS_ROOT.join("single-sync-point/default.project.json"); - let project = Project::load_exact(&project_file_location).unwrap(); +fn single_partition_game() { + let project_location = TEST_PROJECTS_ROOT.join("single_partition_game"); + let project = Project::load_fuzzy(&project_location).unwrap(); let expected_project = { - let foo = ProjectNode::SyncPoint(SyncPointProjectNode { - path: project_file_location.parent().unwrap().join("lib"), - }); + let foo = ProjectNode { + path: Some(project_location.join("lib")), + ..Default::default() + }; let mut replicated_storage_children = HashMap::new(); replicated_storage_children.insert("Foo".to_string(), foo); - let replicated_storage = ProjectNode::Instance(InstanceProjectNode { - class_name: "ReplicatedStorage".to_string(), + let replicated_storage = ProjectNode { + class_name: Some(String::from("ReplicatedStorage")), children: replicated_storage_children, - properties: HashMap::new(), - metadata: Default::default(), - }); + ..Default::default() + }; let mut http_service_properties = HashMap::new(); http_service_properties.insert("HttpEnabled".to_string(), RbxValue::Bool { value: true, }); - let http_service = ProjectNode::Instance(InstanceProjectNode { - class_name: "HttpService".to_string(), - children: HashMap::new(), + let http_service = ProjectNode { + class_name: Some(String::from("HttpService")), properties: http_service_properties, - metadata: Default::default(), - }); + ..Default::default() + }; let mut root_children = HashMap::new(); root_children.insert("ReplicatedStorage".to_string(), replicated_storage); root_children.insert("HttpService".to_string(), http_service); - let root_node = ProjectNode::Instance(InstanceProjectNode { - class_name: "DataModel".to_string(), + let root_node = ProjectNode { + class_name: Some(String::from("DataModel")), children: root_children, - properties: HashMap::new(), - metadata: Default::default(), - }); + ..Default::default() + }; Project { name: "single-sync-point".to_string(), tree: root_node, serve_port: None, serve_place_ids: None, - file_location: project_file_location.clone(), + file_location: project_location.join("default.project.json"), } }; @@ -99,9 +96,17 @@ fn single_sync_point() { } #[test] -fn test_model() { - let project_file_location = TEST_PROJECTS_ROOT.join("test-model/default.project.json"); - let project = Project::load_exact(&project_file_location).unwrap(); +fn single_partition_model() { + let project_file_location = TEST_PROJECTS_ROOT.join("single_partition_model"); + let project = Project::load_fuzzy(&project_file_location).unwrap(); assert_eq!(project.name, "test-model"); +} + +#[test] +fn composing_models() { + let project_file_location = TEST_PROJECTS_ROOT.join("composing_models"); + let project = Project::load_fuzzy(&project_file_location).unwrap(); + + assert_eq!(project.name, "composing-models"); } \ No newline at end of file diff --git a/server/tests/snapshots.rs b/server/tests/snapshots.rs new file mode 100644 index 00000000..aab2193e --- /dev/null +++ b/server/tests/snapshots.rs @@ -0,0 +1,124 @@ +use std::{ + fs::{self, File}, + path::{Path, PathBuf}, +}; + +use pretty_assertions::assert_eq; + +use librojo::{ + imfs::Imfs, + project::{Project, ProjectNode}, + rbx_snapshot::snapshot_project_tree, + snapshot_reconciler::{RbxSnapshotInstance}, +}; + +macro_rules! generate_snapshot_tests { + ($($name: ident),*) => { + $( + paste::item! { + #[test] + fn []() { + let tests_folder = Path::new(env!("CARGO_MANIFEST_DIR")).join("../test-projects"); + let project_folder = tests_folder.join(stringify!($name)); + run_snapshot_test(&project_folder); + } + } + )* + }; +} + +generate_snapshot_tests!( + empty, + nested_partitions, + single_partition_game, + single_partition_model, + transmute_partition +); + +const SNAPSHOT_EXPECTED_NAME: &str = "expected-snapshot.json"; + +fn run_snapshot_test(path: &Path) { + println!("Running snapshot from project: {}", path.display()); + + let project = Project::load_fuzzy(path) + .expect("Couldn't load project file for snapshot test"); + + let mut imfs = Imfs::new(); + imfs.add_roots_from_project(&project) + .expect("Could not add IMFS roots to snapshot project"); + + let mut snapshot = snapshot_project_tree(&imfs, &project) + .expect("Could not generate snapshot for snapshot test"); + + if let Some(snapshot) = snapshot.as_mut() { + anonymize_snapshot(path, snapshot); + } + + match read_expected_snapshot(path) { + Some(expected_snapshot) => assert_eq!(snapshot, expected_snapshot), + None => write_expected_snapshot(path, &snapshot), + } +} + +/// Snapshots contain absolute paths, which simplifies much of Rojo. +/// +/// For saving snapshots to the disk, we should strip off the project folder +/// path to make them machine-independent. This doesn't work for paths that fall +/// outside of the project folder, but that's okay here. +/// +/// We also need to sort children, since Rojo tends to enumerate the filesystem +/// in an unpredictable order. +fn anonymize_snapshot(project_folder_path: &Path, snapshot: &mut RbxSnapshotInstance) { + match snapshot.metadata.source_path.as_mut() { + Some(path) => *path = anonymize_path(project_folder_path, path), + None => {}, + } + + match snapshot.metadata.project_definition.as_mut() { + Some((_, project_node)) => anonymize_project_node(project_folder_path, project_node), + None => {}, + } + + snapshot.children.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + for child in snapshot.children.iter_mut() { + anonymize_snapshot(project_folder_path, child); + } +} + +fn anonymize_project_node(project_folder_path: &Path, project_node: &mut ProjectNode) { + match project_node.path.as_mut() { + Some(path) => *path = anonymize_path(project_folder_path, path), + None => {}, + } + + for child_node in project_node.children.values_mut() { + anonymize_project_node(project_folder_path, child_node); + } +} + +fn anonymize_path(project_folder_path: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.strip_prefix(project_folder_path) + .expect("Could not anonymize absolute path") + .to_path_buf() + } else { + path.to_path_buf() + } +} + +fn read_expected_snapshot(path: &Path) -> Option>> { + let contents = fs::read(path.join(SNAPSHOT_EXPECTED_NAME)).ok()?; + let snapshot: Option> = serde_json::from_slice(&contents) + .expect("Could not deserialize snapshot"); + + Some(snapshot) +} + +fn write_expected_snapshot(path: &Path, snapshot: &Option) { + let mut file = File::create(path.join(SNAPSHOT_EXPECTED_NAME)) + .expect("Could not open file to write snapshot"); + + serde_json::to_writer_pretty(&mut file, snapshot) + .expect("Could not serialize snapshot to file"); +} \ No newline at end of file diff --git a/test-projects/composing-models/default.project.json b/test-projects/composing_models/default.project.json similarity index 100% rename from test-projects/composing-models/default.project.json rename to test-projects/composing_models/default.project.json diff --git a/test-projects/composing-models/src/Binary.rbxm b/test-projects/composing_models/src/Binary.rbxm similarity index 100% rename from test-projects/composing-models/src/Binary.rbxm rename to test-projects/composing_models/src/Binary.rbxm diff --git a/test-projects/composing-models/src/Remotes.model.json b/test-projects/composing_models/src/Remotes.model.json similarity index 100% rename from test-projects/composing-models/src/Remotes.model.json rename to test-projects/composing_models/src/Remotes.model.json diff --git a/test-projects/composing-models/src/XML.rbxmx b/test-projects/composing_models/src/XML.rbxmx similarity index 100% rename from test-projects/composing-models/src/XML.rbxmx rename to test-projects/composing_models/src/XML.rbxmx diff --git a/test-projects/empty/expected-snapshot.json b/test-projects/empty/expected-snapshot.json new file mode 100644 index 00000000..266896f3 --- /dev/null +++ b/test-projects/empty/expected-snapshot.json @@ -0,0 +1,20 @@ +{ + "name": "empty", + "class_name": "DataModel", + "properties": {}, + "children": [], + "metadata": { + "ignore_unknown_instances": true, + "source_path": null, + "project_definition": [ + "empty", + { + "class_name": "DataModel", + "children": {}, + "properties": {}, + "ignore_unknown_instances": null, + "path": null + } + ] + } +} \ No newline at end of file diff --git a/test-projects/missing-files/default.project.json b/test-projects/missing_files/default.project.json similarity index 100% rename from test-projects/missing-files/default.project.json rename to test-projects/missing_files/default.project.json diff --git a/test-projects/nested_partitions/default.project.json b/test-projects/nested_partitions/default.project.json new file mode 100644 index 00000000..ad736a81 --- /dev/null +++ b/test-projects/nested_partitions/default.project.json @@ -0,0 +1,9 @@ +{ + "name": "nested-partitions", + "tree": { + "$path": "outer", + "inner": { + "$path": "inner" + } + } +} \ No newline at end of file diff --git a/test-projects/nested_partitions/expected-snapshot.json b/test-projects/nested_partitions/expected-snapshot.json new file mode 100644 index 00000000..3741f613 --- /dev/null +++ b/test-projects/nested_partitions/expected-snapshot.json @@ -0,0 +1,82 @@ +{ + "name": "nested-partitions", + "class_name": "Folder", + "properties": {}, + "children": [ + { + "name": "inner", + "class_name": "Folder", + "properties": {}, + "children": [ + { + "name": "hello", + "class_name": "ModuleScript", + "properties": { + "Source": { + "Type": "String", + "Value": "-- inner/hello.lua" + } + }, + "children": [], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "inner/hello.lua", + "project_definition": null + } + } + ], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "inner", + "project_definition": [ + "inner", + { + "class_name": null, + "children": {}, + "properties": {}, + "ignore_unknown_instances": null, + "path": "inner" + } + ] + } + }, + { + "name": "world", + "class_name": "ModuleScript", + "properties": { + "Source": { + "Type": "String", + "Value": "-- outer/world.lua" + } + }, + "children": [], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "outer/world.lua", + "project_definition": null + } + } + ], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "outer", + "project_definition": [ + "nested-partitions", + { + "class_name": null, + "children": { + "inner": { + "class_name": null, + "children": {}, + "properties": {}, + "ignore_unknown_instances": null, + "path": "inner" + } + }, + "properties": {}, + "ignore_unknown_instances": null, + "path": "outer" + } + ] + } +} \ No newline at end of file diff --git a/test-projects/nested_partitions/inner/hello.lua b/test-projects/nested_partitions/inner/hello.lua new file mode 100644 index 00000000..147fcfca --- /dev/null +++ b/test-projects/nested_partitions/inner/hello.lua @@ -0,0 +1 @@ +-- inner/hello.lua \ No newline at end of file diff --git a/test-projects/nested_partitions/outer/world.lua b/test-projects/nested_partitions/outer/world.lua new file mode 100644 index 00000000..aeab2c49 --- /dev/null +++ b/test-projects/nested_partitions/outer/world.lua @@ -0,0 +1 @@ +-- outer/world.lua \ No newline at end of file diff --git a/test-projects/single-sync-point/lib/sampleTranslation.csv b/test-projects/single-sync-point/lib/sampleTranslation.csv deleted file mode 100644 index 525fbdb2..00000000 --- a/test-projects/single-sync-point/lib/sampleTranslation.csv +++ /dev/null @@ -1,6 +0,0 @@ -Key,Context,Example,Source,es-es,de -,ClickableGroup:BuilderGui:TextLabel,You got 22 hearts!,You got {1} hearts!,, -,,"Team ""Red"" wins!","Team ""{1}"" wins!","¡Gana el equipo ""{1}""!","¡Gana el equipo ""{1}""!" -,Frame:TextLabel,,"{1} killed {2}, with a {3}","{1} mató a {2} con -una escopeta","{1} mató a {2} con -una escopeta" \ No newline at end of file diff --git a/test-projects/single-sync-point/default.project.json b/test-projects/single_partition_game/default.project.json similarity index 100% rename from test-projects/single-sync-point/default.project.json rename to test-projects/single_partition_game/default.project.json diff --git a/test-projects/single_partition_game/expected-snapshot.json b/test-projects/single_partition_game/expected-snapshot.json new file mode 100644 index 00000000..d3cfcc7d --- /dev/null +++ b/test-projects/single_partition_game/expected-snapshot.json @@ -0,0 +1,161 @@ +{ + "name": "single-sync-point", + "class_name": "DataModel", + "properties": {}, + "children": [ + { + "name": "HttpService", + "class_name": "HttpService", + "properties": { + "HttpEnabled": { + "Type": "Bool", + "Value": true + } + }, + "children": [], + "metadata": { + "ignore_unknown_instances": true, + "source_path": null, + "project_definition": [ + "HttpService", + { + "class_name": "HttpService", + "children": {}, + "properties": { + "HttpEnabled": { + "Type": "Bool", + "Value": true + } + }, + "ignore_unknown_instances": null, + "path": null + } + ] + } + }, + { + "name": "ReplicatedStorage", + "class_name": "ReplicatedStorage", + "properties": {}, + "children": [ + { + "name": "Foo", + "class_name": "Folder", + "properties": {}, + "children": [ + { + "name": "foo", + "class_name": "StringValue", + "properties": { + "Value": { + "Type": "String", + "Value": "Hello world, from foo.txt" + } + }, + "children": [], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "lib/foo.txt", + "project_definition": null + } + }, + { + "name": "main", + "class_name": "ModuleScript", + "properties": { + "Source": { + "Type": "String", + "Value": "-- hello, from main" + } + }, + "children": [], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "lib/main.lua", + "project_definition": null + } + } + ], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "lib", + "project_definition": [ + "Foo", + { + "class_name": null, + "children": {}, + "properties": {}, + "ignore_unknown_instances": null, + "path": "lib" + } + ] + } + } + ], + "metadata": { + "ignore_unknown_instances": true, + "source_path": null, + "project_definition": [ + "ReplicatedStorage", + { + "class_name": "ReplicatedStorage", + "children": { + "Foo": { + "class_name": null, + "children": {}, + "properties": {}, + "ignore_unknown_instances": null, + "path": "lib" + } + }, + "properties": {}, + "ignore_unknown_instances": null, + "path": null + } + ] + } + } + ], + "metadata": { + "ignore_unknown_instances": true, + "source_path": null, + "project_definition": [ + "single-sync-point", + { + "class_name": "DataModel", + "children": { + "HttpService": { + "class_name": "HttpService", + "children": {}, + "properties": { + "HttpEnabled": { + "Type": "Bool", + "Value": true + } + }, + "ignore_unknown_instances": null, + "path": null + }, + "ReplicatedStorage": { + "class_name": "ReplicatedStorage", + "children": { + "Foo": { + "class_name": null, + "children": {}, + "properties": {}, + "ignore_unknown_instances": null, + "path": "lib" + } + }, + "properties": {}, + "ignore_unknown_instances": null, + "path": null + } + }, + "properties": {}, + "ignore_unknown_instances": null, + "path": null + } + ] + } +} \ No newline at end of file diff --git a/test-projects/single-sync-point/lib/foo.txt b/test-projects/single_partition_game/lib/foo.txt similarity index 100% rename from test-projects/single-sync-point/lib/foo.txt rename to test-projects/single_partition_game/lib/foo.txt diff --git a/test-projects/single-sync-point/lib/main.lua b/test-projects/single_partition_game/lib/main.lua similarity index 100% rename from test-projects/single-sync-point/lib/main.lua rename to test-projects/single_partition_game/lib/main.lua diff --git a/test-projects/test-model/default.project.json b/test-projects/single_partition_model/default.project.json similarity index 100% rename from test-projects/test-model/default.project.json rename to test-projects/single_partition_model/default.project.json diff --git a/test-projects/single_partition_model/expected-snapshot.json b/test-projects/single_partition_model/expected-snapshot.json new file mode 100644 index 00000000..9014aaaa --- /dev/null +++ b/test-projects/single_partition_model/expected-snapshot.json @@ -0,0 +1,53 @@ +{ + "name": "test-model", + "class_name": "Folder", + "properties": {}, + "children": [ + { + "name": "main", + "class_name": "Script", + "properties": { + "Source": { + "Type": "String", + "Value": "local other = require(script.Parent.other)\n\nprint(other)" + } + }, + "children": [], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "src/main.server.lua", + "project_definition": null + } + }, + { + "name": "other", + "class_name": "ModuleScript", + "properties": { + "Source": { + "Type": "String", + "Value": "return \"Hello, world!\"" + } + }, + "children": [], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "src/other.lua", + "project_definition": null + } + } + ], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "src", + "project_definition": [ + "test-model", + { + "class_name": null, + "children": {}, + "properties": {}, + "ignore_unknown_instances": null, + "path": "src" + } + ] + } +} \ No newline at end of file diff --git a/test-projects/test-model/src/main.server.lua b/test-projects/single_partition_model/src/main.server.lua similarity index 100% rename from test-projects/test-model/src/main.server.lua rename to test-projects/single_partition_model/src/main.server.lua diff --git a/test-projects/test-model/src/other.lua b/test-projects/single_partition_model/src/other.lua similarity index 100% rename from test-projects/test-model/src/other.lua rename to test-projects/single_partition_model/src/other.lua diff --git a/test-projects/transmute_partition/ReplicatedStorage/hello.lua b/test-projects/transmute_partition/ReplicatedStorage/hello.lua new file mode 100644 index 00000000..cf4cd25e --- /dev/null +++ b/test-projects/transmute_partition/ReplicatedStorage/hello.lua @@ -0,0 +1 @@ +-- ReplicatedStorage/hello.lua \ No newline at end of file diff --git a/test-projects/transmute_partition/default.project.json b/test-projects/transmute_partition/default.project.json new file mode 100644 index 00000000..d5f20f34 --- /dev/null +++ b/test-projects/transmute_partition/default.project.json @@ -0,0 +1,11 @@ +{ + "name": "transmute-partition", + "tree": { + "$className": "DataModel", + + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "$path": "ReplicatedStorage" + } + } +} \ No newline at end of file diff --git a/test-projects/transmute_partition/expected-snapshot.json b/test-projects/transmute_partition/expected-snapshot.json new file mode 100644 index 00000000..a6c65c4c --- /dev/null +++ b/test-projects/transmute_partition/expected-snapshot.json @@ -0,0 +1,66 @@ +{ + "name": "transmute-partition", + "class_name": "DataModel", + "properties": {}, + "children": [ + { + "name": "ReplicatedStorage", + "class_name": "ReplicatedStorage", + "properties": {}, + "children": [ + { + "name": "hello", + "class_name": "ModuleScript", + "properties": { + "Source": { + "Type": "String", + "Value": "-- ReplicatedStorage/hello.lua" + } + }, + "children": [], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "ReplicatedStorage/hello.lua", + "project_definition": null + } + } + ], + "metadata": { + "ignore_unknown_instances": false, + "source_path": "ReplicatedStorage", + "project_definition": [ + "ReplicatedStorage", + { + "class_name": "ReplicatedStorage", + "children": {}, + "properties": {}, + "ignore_unknown_instances": null, + "path": "ReplicatedStorage" + } + ] + } + } + ], + "metadata": { + "ignore_unknown_instances": true, + "source_path": null, + "project_definition": [ + "transmute-partition", + { + "class_name": "DataModel", + "children": { + "ReplicatedStorage": { + "class_name": "ReplicatedStorage", + "children": {}, + "properties": {}, + "ignore_unknown_instances": null, + "path": "ReplicatedStorage" + } + }, + "properties": {}, + "ignore_unknown_instances": null, + "path": null + } + ] + } +} \ No newline at end of file