diff --git a/Cargo.lock b/Cargo.lock index a08937cc..e2939241 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -403,6 +403,11 @@ dependencies = [ "generic-array 0.12.3 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "doc-comment" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "dtoa" version = "0.4.4" @@ -1634,6 +1639,7 @@ dependencies = [ "serde 1.0.103 (registry+https://github.com/rust-lang/crates.io-index)", "serde_json 1.0.44 (registry+https://github.com/rust-lang/crates.io-index)", "serde_yaml 0.8.11 (registry+https://github.com/rust-lang/crates.io-index)", + "snafu 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", "structopt 0.3.5 (registry+https://github.com/rust-lang/crates.io-index)", "tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "termcolor 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1847,6 +1853,25 @@ name = "smallvec" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "snafu" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "doc-comment 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", + "snafu-derive 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "snafu-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "snax" version = "0.2.0" @@ -2433,6 +2458,7 @@ dependencies = [ "checksum ctor 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)" = "cd8ce37ad4184ab2ce004c33bf6379185d3b1c95801cab51026bd271bf68eedc" "checksum difference 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" "checksum digest 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +"checksum doc-comment 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "923dea538cea0aa3025e8685b20d6ee21ef99c4f77e954a30febbaac5ec73a97" "checksum dtoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)" = "ea57b42383d091c85abcc2706240b94ab2a8fa1fc81c10ff23c4de06e2a90b5e" "checksum either 1.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "bb1f6b1ce1c140482ea30ddd3335fc0024ac7ee112895426e0a629a6c20adfe3" "checksum encode_unicode 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" @@ -2586,6 +2612,8 @@ dependencies = [ "checksum slab 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8" "checksum smallvec 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)" = "f7b0758c52e15a8b5e3691eae6cc559f08eee9406e548a4477ba4e67770a82b6" "checksum smallvec 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4ecf3b85f68e8abaa7555aa5abdb1153079387e60b718283d732f03897fcfc86" +"checksum snafu 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "41207ca11f96a62cd34e6b7fdf73d322b25ae3848eb9d38302169724bb32cf27" +"checksum snafu-derive 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4c5e338c8b0577457c9dda8e794b6ad7231c96e25b1b0dd5842d52249020c1c0" "checksum snax 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7ef82be0baf3ae45701ab992230f1f4889c15984736d87f37558aea3e4e321af" "checksum string 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d24114bfcceb867ca7f71a0d3fe45d45619ec47a6fbfa98cb14e14250bfa5d6d" "checksum strsim 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" diff --git a/Cargo.toml b/Cargo.toml index 9e203466..e501d3eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ ritz = "0.1.0" rlua = "0.16.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +snafu = "0.6.0" structopt = "0.3" termcolor = "1.0.5" uuid = { version = "0.7", features = ["v4", "serde"] } diff --git a/rojo-test/build-test-snapshots/build_test__rbxmx_in_folder.snap b/rojo-test/build-test-snapshots/build_test__rbxmx_in_folder.snap index 4b9740e1..21a7a396 100644 --- a/rojo-test/build-test-snapshots/build_test__rbxmx_in_folder.snap +++ b/rojo-test/build-test-snapshots/build_test__rbxmx_in_folder.snap @@ -25,12 +25,14 @@ expression: contents 1 null - + + Cool StringValue - + + Did you know that BaseValue.Changed is different than Instance.Changed? diff --git a/rojo-test/build-test-snapshots/build_test__rbxmx_ref.rbxmx.snap b/rojo-test/build-test-snapshots/build_test__rbxmx_ref.rbxmx.snap index 9a796330..26ada371 100644 --- a/rojo-test/build-test-snapshots/build_test__rbxmx_ref.rbxmx.snap +++ b/rojo-test/build-test-snapshots/build_test__rbxmx_ref.rbxmx.snap @@ -6,19 +6,22 @@ expression: contents rbxmx_ref - + + Target - + + Pointed to by ObjectValue Pointer - + + 1 diff --git a/src/change_processor.rs b/src/change_processor.rs index f13241e7..c721260a 100644 --- a/src/change_processor.rs +++ b/src/change_processor.rs @@ -178,15 +178,20 @@ fn update_affected_instances( } } } - InstigatingSource::ProjectNode(instance_name, project_node) => { + InstigatingSource::ProjectNode(project_path, instance_name, project_node) => { // This instance is the direct subject of a project node. Since // there might be information associated with our instance from // the project file, we snapshot the entire project node again. - let snapshot = - snapshot_project_node(&metadata.context, instance_name, project_node, &vfs) - .expect("snapshot failed") - .expect("snapshot did not return an instance"); + let snapshot = snapshot_project_node( + &metadata.context, + &project_path, + instance_name, + project_node, + &vfs, + ) + .expect("snapshot failed") + .expect("snapshot did not return an instance"); let patch_set = compute_patch_set(&snapshot, &tree, id); apply_patch_set(tree, patch_set) diff --git a/src/commands/build.rs b/src/commands/build.rs index d5021dc1..1e3a03a5 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -8,7 +8,7 @@ use failure::Fail; use crate::{ cli::BuildCommand, common_setup, - project::ProjectLoadError, + project::ProjectError, vfs::{FsError, RealFetcher, Vfs, WatchMode}, }; @@ -47,7 +47,7 @@ pub enum BuildError { BinaryModelEncodeError(rbx_binary::EncodeError), #[fail(display = "{}", _0)] - ProjectLoadError(#[fail(cause)] ProjectLoadError), + ProjectError(#[fail(cause)] ProjectError), #[fail(display = "{}", _0)] FsError(#[fail(cause)] FsError), @@ -57,7 +57,7 @@ impl_from!(BuildError { io::Error => IoError, rbx_xml::EncodeError => XmlModelEncodeError, rbx_binary::EncodeError => BinaryModelEncodeError, - ProjectLoadError => ProjectLoadError, + ProjectError => ProjectError, FsError => FsError, }); diff --git a/src/commands/init.rs b/src/commands/init.rs index c4fe437f..2ea6b5e3 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,37 +1,17 @@ use failure::Fail; -use crate::{ - cli::{InitCommand, InitKind}, - project::{Project, ProjectInitError}, -}; +use crate::{cli::InitCommand, project::ProjectError}; #[derive(Debug, Fail)] pub enum InitError { #[fail(display = "Project init error: {}", _0)] - ProjectInitError(#[fail(cause)] ProjectInitError), + ProjectError(#[fail(cause)] ProjectError), } impl_from!(InitError { - ProjectInitError => ProjectInitError, + ProjectError => ProjectError, }); -pub fn init(options: InitCommand) -> Result<(), InitError> { - let (project_path, project_kind) = match options.kind { - InitKind::Place => { - let path = Project::init_place(&options.path)?; - (path, "place") - } - InitKind::Model => { - let path = Project::init_model(&options.path)?; - (path, "model") - } - }; - - println!( - "Created new {} project file at {}", - project_kind, - project_path.display() - ); - - Ok(()) +pub fn init(_options: InitCommand) -> Result<(), InitError> { + unimplemented!("init command"); } diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 06c83614..bc47f981 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -8,7 +8,7 @@ use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor}; use crate::{ cli::ServeCommand, - project::ProjectLoadError, + project::ProjectError, serve_session::ServeSession, vfs::{RealFetcher, Vfs, WatchMode}, web::LiveServer, @@ -19,11 +19,11 @@ const DEFAULT_PORT: u16 = 34872; #[derive(Debug, Fail)] pub enum ServeError { #[fail(display = "Couldn't load project: {}", _0)] - ProjectLoad(#[fail(cause)] ProjectLoadError), + ProjectError(#[fail(cause)] ProjectError), } impl_from!(ServeError { - ProjectLoadError => ProjectLoad, + ProjectError => ProjectError, }); pub fn serve(options: ServeCommand) -> Result<(), ServeError> { diff --git a/src/common_setup.rs b/src/common_setup.rs index 160b4c6c..a744b5b5 100644 --- a/src/common_setup.rs +++ b/src/common_setup.rs @@ -6,7 +6,7 @@ use std::path::Path; use rbx_dom_weak::RbxInstanceProperties; use crate::{ - project::{Project, ProjectLoadError}, + project::Project, snapshot::{ apply_patch_set, compute_patch_set, InstanceContext, InstancePropertiesWithMeta, RojoTree, }, @@ -19,11 +19,7 @@ pub fn start( vfs: &Vfs, ) -> (Option, RojoTree) { log::trace!("Loading project file from {}", fuzzy_project_path.display()); - let maybe_project = match Project::load_fuzzy(fuzzy_project_path) { - Ok(project) => Some(project), - Err(ProjectLoadError::NotFound) => None, - Err(other) => panic!("{}", other), // TODO: return error upward - }; + let maybe_project = Project::load_fuzzy(fuzzy_project_path).expect("TODO: Project load failed"); log::trace!("Constructing initial tree"); let mut tree = RojoTree::new(InstancePropertiesWithMeta { @@ -37,23 +33,13 @@ pub fn start( let root_id = tree.get_root_id(); - log::trace!("Constructing snapshot context"); - let snapshot_context = InstanceContext::default(); - if let Some(project) = &maybe_project { - // If the project file defines no plugins, then there's no need to - // initialize the snapshot plugin context. - if !project.plugins.is_empty() { - // TODO: Initialize plugins in instance context - } - } - log::trace!("Reading project root"); let entry = vfs .get(fuzzy_project_path) .expect("could not get project path"); log::trace!("Generating snapshot of instances from VFS"); - let snapshot = snapshot_from_vfs(&snapshot_context, vfs, &entry) + let snapshot = snapshot_from_vfs(&InstanceContext::default(), vfs, &entry) .expect("snapshot failed") .expect("snapshot did not return an instance"); diff --git a/src/project.rs b/src/project.rs index 532f6590..3ba7ed7e 100644 --- a/src/project.rs +++ b/src/project.rs @@ -1,339 +1,63 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, - fmt, - fs::{self, File}, - io, + fs, io, path::{Path, PathBuf}, }; -use failure::Fail; -use log::warn; -use rbx_dom_weak::{RbxValue, UnresolvedRbxValue}; -use serde::{Deserialize, Serialize, Serializer}; - -static DEFAULT_PLACE: &str = include_str!("../assets/place.project.json"); +use rbx_dom_weak::UnresolvedRbxValue; +use serde::{Deserialize, Serialize}; +use snafu::{ResultExt, Snafu}; pub static PROJECT_FILENAME: &str = "default.project.json"; -/// SourceProject is the format that users author projects on-disk. Since we -/// want to do things like transforming paths to be absolute before handing them -/// off to the rest of Rojo, we use this intermediate struct. -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct SourceProject { - name: String, - tree: SourceProjectNode, +/// Error type returned by any function that handles projects. +#[derive(Debug, Snafu)] +pub struct ProjectError(Error); - #[serde(skip_serializing_if = "Option::is_none")] - serve_port: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - serve_place_ids: Option>, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[cfg_attr(not(feature = "user-plugins"), serde(skip_deserializing))] - plugins: Vec, -} - -impl SourceProject { - /// Consumes the SourceProject and yields a Project, ready for prime-time. - pub fn into_project(self, project_file_location: &Path) -> Project { - let tree = self.tree.into_project_node(project_file_location); - - let project_folder = project_file_location.parent().unwrap(); - let plugins = self - .plugins - .into_iter() - .map(|path| project_folder.join(path)) - .collect(); - - Project { - name: self.name, - tree, - serve_port: self.serve_port, - serve_place_ids: self.serve_place_ids, - plugins, - file_location: PathBuf::from(project_file_location), - } - } -} - -/// An alternative serializer for `UnresolvedRbxValue` that uses the minimum -/// representation of the value. -/// -/// For example, the default Serialize impl might give you: -/// -/// ```json -/// { -/// "Type": "Bool", -/// "Value": true -/// } -/// ``` -/// -/// But in reality, users are expected to write just: -/// -/// ```json -/// true -/// ``` -/// -/// This holds true for other values that might be ambiguous or just have more -/// complicated representations like enums. -fn serialize_unresolved_minimal( - unresolved: &UnresolvedRbxValue, - serializer: S, -) -> Result -where - S: Serializer, -{ - match unresolved { - UnresolvedRbxValue::Ambiguous(_) => unresolved.serialize(serializer), - UnresolvedRbxValue::Concrete(concrete) => match concrete { - RbxValue::Bool { value } => value.serialize(serializer), - RbxValue::CFrame { value } => value.serialize(serializer), - RbxValue::Color3 { value } => value.serialize(serializer), - RbxValue::Color3uint8 { value } => value.serialize(serializer), - RbxValue::Content { value } => value.serialize(serializer), - RbxValue::Float32 { value } => value.serialize(serializer), - RbxValue::Int32 { value } => value.serialize(serializer), - RbxValue::String { value } => value.serialize(serializer), - RbxValue::UDim { value } => value.serialize(serializer), - RbxValue::UDim2 { value } => value.serialize(serializer), - RbxValue::Vector2 { value } => value.serialize(serializer), - RbxValue::Vector2int16 { value } => value.serialize(serializer), - RbxValue::Vector3 { value } => value.serialize(serializer), - RbxValue::Vector3int16 { value } => value.serialize(serializer), - _ => concrete.serialize(serializer), - }, - } -} - -/// A wrapper around serialize_unresolved_minimal that handles the HashMap case. -fn serialize_unresolved_map( - value: &HashMap, - serializer: S, -) -> Result -where - S: Serializer, -{ - use serde::ser::SerializeMap; - - #[derive(Serialize)] - struct Minimal<'a>( - #[serde(serialize_with = "serialize_unresolved_minimal")] &'a UnresolvedRbxValue, - ); - - let mut map = serializer.serialize_map(Some(value.len()))?; - for (k, v) in value { - map.serialize_key(k)?; - map.serialize_value(&Minimal(v))?; - } - map.end() -} - -/// Similar to SourceProject, the structure of nodes in the project tree is -/// slightly different on-disk than how we want to handle them in the rest of -/// Rojo. -#[derive(Debug, Clone, Serialize, Deserialize)] -struct SourceProjectNode { - #[serde(rename = "$className", skip_serializing_if = "Option::is_none")] - class_name: Option, - - #[serde( - rename = "$properties", - default = "HashMap::new", - skip_serializing_if = "HashMap::is_empty", - serialize_with = "serialize_unresolved_map" - )] - properties: HashMap, - - #[serde( - rename = "$ignoreUnknownInstances", - skip_serializing_if = "Option::is_none" - )] - ignore_unknown_instances: Option, - - #[serde(rename = "$path", skip_serializing_if = "Option::is_none")] - path: Option, - - #[serde(flatten)] - children: BTreeMap, -} - -impl SourceProjectNode { - /// Consumes the SourceProjectNode and turns it into a ProjectNode. - pub fn into_project_node(self, project_file_location: &Path) -> ProjectNode { - let children = self - .children - .iter() - .map(|(key, value)| { - ( - key.clone(), - value.clone().into_project_node(project_file_location), - ) - }) - .collect(); - - // Make sure that paths are absolute, transforming them by adding the - // project folder if they're not already absolute. - let path = self.path.as_ref().map(|source_path| { - if Path::new(source_path).is_absolute() { - PathBuf::from(source_path) - } else { - let project_folder_location = project_file_location.parent().unwrap(); - project_folder_location.join(source_path) - } - }); - - ProjectNode { - class_name: self.class_name, - properties: self.properties, - ignore_unknown_instances: self.ignore_unknown_instances, - path, - children, - } - } -} - -#[derive(Debug, Fail)] -pub enum ProjectLoadError { - NotFound, - - Io { - #[fail(cause)] - inner: io::Error, - path: PathBuf, - }, +#[derive(Debug, Snafu)] +enum Error { + /// A general IO error occurred. + Io { source: io::Error, path: PathBuf }, + /// An error with JSON parsing occurred. Json { - #[fail(cause)] - inner: serde_json::Error, + source: serde_json::Error, path: PathBuf, }, } -impl fmt::Display for ProjectLoadError { - fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - use self::ProjectLoadError::*; - - match self { - NotFound => write!(formatter, "Project file not found"), - Io { inner, path } => { - write!(formatter, "I/O error: {} in path {}", inner, path.display()) - } - Json { inner, path } => write!( - formatter, - "JSON error: {} in path {}", - inner, - path.display() - ), - } - } -} - -/// Error returned by Project::init_place and Project::init_model -#[derive(Debug, Fail)] -pub enum ProjectInitError { - AlreadyExists(PathBuf), - IoError(#[fail(cause)] io::Error), - SaveError(#[fail(cause)] ProjectSaveError), - JsonError(#[fail(cause)] serde_json::Error), -} - -impl fmt::Display for ProjectInitError { - fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result { - match self { - ProjectInitError::AlreadyExists(path) => { - write!(output, "Path {} already exists", path.display()) - } - ProjectInitError::IoError(inner) => write!(output, "IO error: {}", inner), - ProjectInitError::SaveError(inner) => write!(output, "{}", inner), - ProjectInitError::JsonError(inner) => write!(output, "{}", inner), - } - } -} - -/// Error returned by Project::save -#[derive(Debug, Fail)] -pub enum ProjectSaveError { - #[fail(display = "JSON error: {}", _0)] - JsonError(#[fail(cause)] serde_json::Error), - - #[fail(display = "IO error: {}", _0)] - IoError(#[fail(cause)] io::Error), -} - -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct ProjectNode { - pub class_name: Option, - pub children: BTreeMap, - pub properties: HashMap, - pub ignore_unknown_instances: Option, - - #[serde(serialize_with = "crate::path_serializer::serialize_option_absolute")] - pub path: Option, -} - -impl ProjectNode { - fn validate_reserved_names(&self) { - for (name, child) in &self.children { - if name.starts_with('$') { - warn!( - "Keys starting with '$' are reserved by Rojo to ensure forward compatibility." - ); - warn!( - "This project uses the key '{}', which should be renamed.", - name - ); - } - - child.validate_reserved_names(); - } - } - - fn to_source_node(&self, project_file_location: &Path) -> SourceProjectNode { - let children = self - .children - .iter() - .map(|(key, value)| (key.clone(), value.to_source_node(project_file_location))) - .collect(); - - // If paths are relative to the project file, transform them to look - // Unixy and write relative paths instead. - // - // This isn't perfect, since it means that paths like .. will stay as - // absolute paths and make projects non-portable. Fixing this probably - // means keeping the paths relative in the project format and making - // everywhere else in Rojo do the resolution locally. - let path = self.path.as_ref().map(|path| { - let project_folder_location = project_file_location.parent().unwrap(); - - match path.strip_prefix(project_folder_location) { - Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"), - Err(_) => format!("{}", path.display()), - } - }); - - SourceProjectNode { - class_name: self.class_name.clone(), - properties: self.properties.clone(), - ignore_unknown_instances: self.ignore_unknown_instances, - children, - path, - } - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct Project { + /// The name of the top-level instance described by the project. pub name: String, + + /// The tree of instances described by this project. Projects always + /// describe at least one instance. pub tree: ProjectNode, + + /// If specified, sets the default port that `rojo serve` should use when + /// using this project for live sync. + #[serde(skip_serializing_if = "Option::is_none")] pub serve_port: Option, + + /// If specified, contains the set of place IDs that this project is + /// compatible with when doing live sync. + /// + /// This setting is intended to help prevent syncing a Rojo project into the + /// wrong Roblox place. + #[serde(skip_serializing_if = "Option::is_none")] pub serve_place_ids: Option>, - pub plugins: Vec, + + /// The path to the file that this project came from. Relative paths in the + /// project should be considered relative to the parent of this field, also + /// given by `Project::folder_location`. + #[serde(skip)] pub file_location: PathBuf, } impl Project { + /// Tells whether the given path describes a Rojo project. pub fn is_project_file(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) @@ -341,97 +65,6 @@ impl Project { .unwrap_or(false) } - pub fn init_place(project_fuzzy_path: &Path) -> Result { - let project_path = Project::pick_path_for_init(project_fuzzy_path)?; - - let project_name = if project_fuzzy_path == project_path { - project_fuzzy_path - .parent() - .expect("Path did not have a parent directory") - .file_name() - .expect("Path did not have a file name") - .to_str() - .expect("Path had invalid Unicode") - } else { - project_fuzzy_path - .file_name() - .expect("Path did not have a file name") - .to_str() - .expect("Path had invalid Unicode") - }; - - let mut project = Project::load_from_slice(DEFAULT_PLACE.as_bytes(), &project_path) - .map_err(ProjectInitError::JsonError)?; - - project.name = project_name.to_owned(); - - project.save().map_err(ProjectInitError::SaveError)?; - - Ok(project_path) - } - - pub fn init_model(project_fuzzy_path: &Path) -> Result { - let project_path = Project::pick_path_for_init(project_fuzzy_path)?; - - let project_name = if project_fuzzy_path == project_path { - project_fuzzy_path - .parent() - .expect("Path did not have a parent directory") - .file_name() - .expect("Path did not have a file name") - .to_str() - .expect("Path had invalid Unicode") - } else { - project_fuzzy_path - .file_name() - .expect("Path did not have a file name") - .to_str() - .expect("Path had invalid Unicode") - }; - - let project_folder_path = project_path - .parent() - .expect("Path did not have a parent directory"); - - let tree = ProjectNode { - path: Some(project_folder_path.join("src")), - ..Default::default() - }; - - let project = Project { - name: project_name.to_string(), - tree, - serve_port: None, - serve_place_ids: None, - plugins: Vec::new(), - file_location: project_path.clone(), - }; - - project.save().map_err(ProjectInitError::SaveError)?; - - Ok(project_path) - } - - fn pick_path_for_init(project_fuzzy_path: &Path) -> Result { - let is_exact = project_fuzzy_path.extension().is_some(); - - let project_path = if is_exact { - project_fuzzy_path.to_path_buf() - } else { - project_fuzzy_path.join(PROJECT_FILENAME) - }; - - match fs::metadata(&project_path) { - Err(error) => match error.kind() { - io::ErrorKind::NotFound => {} - _ => return Err(ProjectInitError::IoError(error)), - }, - Ok(_) => return Err(ProjectInitError::AlreadyExists(project_path)), - } - - Ok(project_path) - } - /// Attempt to locate a project represented by the given path. /// /// This will find a project if the path refers to a `.project.json` file, @@ -464,51 +97,40 @@ impl Project { pub fn load_from_slice( contents: &[u8], project_file_location: &Path, - ) -> Result { - let parsed: SourceProject = serde_json::from_slice(&contents)?; - - Ok(parsed.into_project(project_file_location)) + ) -> Result { + let mut project: Self = serde_json::from_slice(&contents)?; + project.file_location = project_file_location.to_path_buf(); + project.check_compatibility(); + Ok(project) } - pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result { + pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result, ProjectError> { if let Some(project_path) = Self::locate(fuzzy_project_location) { - Self::load_exact(&project_path) + let project = Self::load_exact(&project_path)?; + + Ok(Some(project)) } else { - Err(ProjectLoadError::NotFound) + Ok(None) } } - fn load_exact(project_file_location: &Path) -> Result { - let contents = - fs::read_to_string(project_file_location).map_err(|error| match error.kind() { - io::ErrorKind::NotFound => ProjectLoadError::NotFound, - _ => ProjectLoadError::Io { - inner: error, - path: project_file_location.to_path_buf(), - }, - })?; + fn load_exact(project_file_location: &Path) -> Result { + let contents = fs::read_to_string(project_file_location).context(Io { + path: project_file_location, + })?; - let parsed: SourceProject = - serde_json::from_str(&contents).map_err(|error| ProjectLoadError::Json { - inner: error, - path: project_file_location.to_path_buf(), - })?; - - let project = parsed.into_project(project_file_location); + let mut project: Project = serde_json::from_str(&contents).context(Json { + path: project_file_location, + })?; + project.file_location = project_file_location.to_path_buf(); project.check_compatibility(); Ok(project) } - pub fn save(&self) -> Result<(), ProjectSaveError> { - let source_project = self.to_source_project(); - let mut file = File::create(&self.file_location).map_err(ProjectSaveError::IoError)?; - - serde_json::to_writer_pretty(&mut file, &source_project) - .map_err(ProjectSaveError::JsonError)?; - - Ok(()) + pub fn save(&self) -> Result<(), ProjectError> { + unimplemented!() } /// Checks if there are any compatibility issues with this project file and @@ -520,27 +142,80 @@ impl Project { pub fn folder_location(&self) -> &Path { self.file_location.parent().unwrap() } +} - fn to_source_project(&self) -> SourceProject { - // TODO: Use path_serializer instead of transforming paths between - // String and PathBuf? - let plugins = self - .plugins - .iter() - .map(|path| { - path.strip_prefix(self.folder_location()) - .unwrap() - .display() - .to_string() - }) - .collect(); +/// Describes an instance and its descendants in a project. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ProjectNode { + /// If set, defines the ClassName of the described instance. + /// + /// `$className` MUST be set if `$path` is not set. + /// + /// `$className` CANNOT be set if `$path` is set and the instance described + /// by that path has a ClassName other than Folder. + #[serde(rename = "$className", skip_serializing_if = "Option::is_none")] + pub class_name: Option, - SourceProject { - name: self.name.clone(), - tree: self.tree.to_source_node(&self.file_location), - serve_port: self.serve_port, - plugins, - serve_place_ids: self.serve_place_ids.clone(), + /// Contains all of the children of the described instance. + #[serde(flatten)] + pub children: BTreeMap, + + /// The properties that will be assigned to the resulting instance. + /// + // TODO: Is this legal to set if $path is set? + #[serde( + rename = "$properties", + default, + skip_serializing_if = "HashMap::is_empty" + )] + pub properties: HashMap, + + /// Defines the behavior when Rojo encounters unknown instances in Roblox + /// Studio during live sync. `$ignoreUnknownInstances` should be considered + /// a large hammer and used with care. + /// + /// If set to `true`, those instances will be left alone. This may cause + /// issues when files that turn into instances are removed while Rojo is not + /// running. + /// + /// If set to `false`, Rojo will destroy any instances it does not + /// recognize. + /// + /// If unset, its default value depends on other settings: + /// - If `$path` is not set, defaults to `true` + /// - If `$path` is set, defaults to `false` + #[serde( + rename = "$ignoreUnknownInstances", + skip_serializing_if = "Option::is_none" + )] + pub ignore_unknown_instances: Option, + + /// Defines that this instance should come from the given file path. This + /// path can point to any file type supported by Rojo, including Lua files + /// (`.lua`), Roblox models (`.rbxm`, `.rbxmx`), and localization table + /// spreadsheets (`.csv`). + #[serde( + rename = "$path", + serialize_with = "crate::path_serializer::serialize_option_absolute", + skip_serializing_if = "Option::is_none" + )] + pub path: Option, +} + +impl ProjectNode { + fn validate_reserved_names(&self) { + for (name, child) in &self.children { + if name.starts_with('$') { + log::warn!( + "Keys starting with '$' are reserved by Rojo to ensure forward compatibility." + ); + log::warn!( + "This project uses the key '{}', which should be renamed.", + name + ); + } + + child.validate_reserved_names(); } } } diff --git a/src/snapshot/metadata.rs b/src/snapshot/metadata.rs index 7d956145..d0bba5bc 100644 --- a/src/snapshot/metadata.rs +++ b/src/snapshot/metadata.rs @@ -110,16 +110,24 @@ impl Default for InstanceContext { #[derive(Clone, PartialEq, Serialize, Deserialize)] pub enum InstigatingSource { Path(#[serde(serialize_with = "path_serializer::serialize_absolute")] PathBuf), - ProjectNode(String, ProjectNode), + ProjectNode( + #[serde(serialize_with = "path_serializer::serialize_absolute")] PathBuf, + String, + ProjectNode, + ), } impl fmt::Debug for InstigatingSource { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { InstigatingSource::Path(path) => write!(formatter, "Path({})", path.display()), - InstigatingSource::ProjectNode(name, node) => { - write!(formatter, "ProjectNode({}: {:?}", name, node) - } + InstigatingSource::ProjectNode(path, name, node) => write!( + formatter, + "ProjectNode({}: {:?}) from path {}", + name, + node, + path.display() + ), } } } diff --git a/src/snapshot_middleware/project.rs b/src/snapshot_middleware/project.rs index caf2dc11..b4d99eba 100644 --- a/src/snapshot_middleware/project.rs +++ b/src/snapshot_middleware/project.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, collections::HashMap}; +use std::{borrow::Cow, collections::HashMap, path::Path}; use rbx_reflection::try_resolve_value; @@ -47,8 +47,14 @@ impl SnapshotMiddleware for SnapshotProject { // Snapshotting a project should always return an instance, so this // unwrap is safe. - let mut snapshot = - snapshot_project_node(context, &project.name, &project.tree, vfs)?.unwrap(); + let mut snapshot = snapshot_project_node( + context, + project.folder_location(), + &project.name, + &project.tree, + vfs, + )? + .unwrap(); // Setting the instigating source to the project file path is a little // coarse. @@ -77,6 +83,7 @@ impl SnapshotMiddleware for SnapshotProject { pub fn snapshot_project_node( context: &InstanceContext, + project_folder: &Path, instance_name: &str, node: &ProjectNode, vfs: &Vfs, @@ -91,7 +98,15 @@ pub fn snapshot_project_node( let mut metadata = InstanceMetadata::default(); if let Some(path) = &node.path { - let entry = vfs.get(path)?; + // If the path specified in the project is relative, we assume it's + // relative to the folder that the project is in, project_folder. + let path = if path.is_relative() { + Cow::Owned(project_folder.join(path)) + } else { + Cow::Borrowed(path) + }; + + let entry = vfs.get(path.as_path())?; if let Some(snapshot) = snapshot_from_vfs(context, vfs, &entry)? { // If a class name was already specified, then it'll override the @@ -143,7 +158,9 @@ pub fn snapshot_project_node( .expect("$className or $path must be specified"); for (child_name, child_project_node) in &node.children { - if let Some(child) = snapshot_project_node(context, child_name, child_project_node, vfs)? { + if let Some(child) = + snapshot_project_node(context, project_folder, child_name, child_project_node, vfs)? + { children.push(child); } } @@ -170,6 +187,7 @@ pub fn snapshot_project_node( } metadata.instigating_source = Some(InstigatingSource::ProjectNode( + project_folder.to_path_buf(), instance_name.to_string(), node.clone(), )); diff --git a/src/snapshot_middleware/snapshots/test__project_with_children.snap b/src/snapshot_middleware/snapshots/test__project_with_children.snap index d20102a8..ab659524 100644 --- a/src/snapshot_middleware/snapshots/test__project_with_children.snap +++ b/src/snapshot_middleware/snapshots/test__project_with_children.snap @@ -19,12 +19,9 @@ children: ignore_unknown_instances: true instigating_source: ProjectNode: + - /foo - Child - - class_name: Model - children: {} - properties: {} - ignore_unknown_instances: ~ - path: ~ + - $className: Model relevant_paths: [] context: {} name: Child diff --git a/src/snapshot_middleware/snapshots/test__project_with_path_to_project_with_children.snap b/src/snapshot_middleware/snapshots/test__project_with_path_to_project_with_children.snap index ffe120ca..ba3fb273 100644 --- a/src/snapshot_middleware/snapshots/test__project_with_path_to_project_with_children.snap +++ b/src/snapshot_middleware/snapshots/test__project_with_path_to_project_with_children.snap @@ -20,12 +20,9 @@ children: ignore_unknown_instances: true instigating_source: ProjectNode: + - /foo - SomeChild - - class_name: Model - children: {} - properties: {} - ignore_unknown_instances: ~ - path: ~ + - $className: Model relevant_paths: [] context: {} name: SomeChild diff --git a/src/snapshots/serve_session__change_file_in_project_after.snap b/src/snapshots/serve_session__change_file_in_project_after.snap index 757dc2c5..29afd767 100644 --- a/src/snapshots/serve_session__change_file_in_project_after.snap +++ b/src/snapshots/serve_session__change_file_in_project_after.snap @@ -25,12 +25,9 @@ children: ignore_unknown_instances: false instigating_source: ProjectNode: + - /foo - Child - - class_name: ~ - children: {} - properties: {} - ignore_unknown_instances: ~ - path: /foo/file.txt + - $path: file.txt relevant_paths: - /foo/file.txt - /foo/file.meta.json diff --git a/src/snapshots/serve_session__change_file_in_project_before.snap b/src/snapshots/serve_session__change_file_in_project_before.snap index 96d9cd93..9b20d87f 100644 --- a/src/snapshots/serve_session__change_file_in_project_before.snap +++ b/src/snapshots/serve_session__change_file_in_project_before.snap @@ -25,12 +25,9 @@ children: ignore_unknown_instances: false instigating_source: ProjectNode: + - /foo - Child - - class_name: ~ - children: {} - properties: {} - ignore_unknown_instances: ~ - path: /foo/file.txt + - $path: file.txt relevant_paths: - /foo/file.txt - /foo/file.meta.json