diff --git a/Cargo.lock b/Cargo.lock index 326db2ba..9525fd99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -738,6 +738,7 @@ dependencies = [ "fnv", "log", "regex", + "serde", ] [[package]] @@ -1831,6 +1832,7 @@ dependencies = [ "ritz", "roblox_install", "rojo-insta-ext", + "rojo-project", "serde", "serde_json", "serde_yaml", @@ -1851,6 +1853,20 @@ dependencies = [ "serde_yaml", ] +[[package]] +name = "rojo-project" +version = "0.1.0" +dependencies = [ + "anyhow", + "globset", + "log", + "rbx_dom_weak", + "rbx_reflection", + "rbx_reflection_database", + "serde", + "serde_json", +] + [[package]] name = "rustc-demangle" version = "0.1.21" diff --git a/Cargo.toml b/Cargo.toml index 14b8a9ef..f158593e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ name = "build" harness = false [dependencies] +rojo-project = { path = "crates/rojo-project" } memofs = { version = "0.2.0", path = "crates/memofs" } # These dependencies can be uncommented when working on rbx-dom simultaneously diff --git a/crates/rojo-project/Cargo.toml b/crates/rojo-project/Cargo.toml new file mode 100644 index 00000000..c8cb651f --- /dev/null +++ b/crates/rojo-project/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rojo-project" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.57" +globset = { version = "0.4.8", features = ["serde1"] } +log = "0.4.17" +rbx_dom_weak = "2.3.0" +rbx_reflection = "4.2.0" +rbx_reflection_database = "0.2.4" +serde = { version = "1.0.137", features = ["derive"] } +serde_json = "1.0.81" diff --git a/crates/rojo-project/README.md b/crates/rojo-project/README.md new file mode 100644 index 00000000..c9101a6a --- /dev/null +++ b/crates/rojo-project/README.md @@ -0,0 +1,4 @@ +# rojo-project +Project file format crate for [Rojo]. + +[Rojo]: https://rojo.space \ No newline at end of file diff --git a/src/glob.rs b/crates/rojo-project/src/glob.rs similarity index 89% rename from src/glob.rs rename to crates/rojo-project/src/glob.rs index 7b17286a..7730b14d 100644 --- a/src/glob.rs +++ b/crates/rojo-project/src/glob.rs @@ -1,6 +1,3 @@ -//! Wrapper around globset's Glob type that has better serialization -//! characteristics by coupling Glob and GlobMatcher into a single type. - use std::path::Path; use globset::{Glob as InnerGlob, GlobMatcher}; @@ -8,6 +5,8 @@ use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; pub use globset::Error; +/// Wrapper around globset's Glob type that has better serialization +/// characteristics by coupling Glob and GlobMatcher into a single type. #[derive(Debug, Clone)] pub struct Glob { inner: InnerGlob, diff --git a/crates/rojo-project/src/lib.rs b/crates/rojo-project/src/lib.rs new file mode 100644 index 00000000..6cbaff3c --- /dev/null +++ b/crates/rojo-project/src/lib.rs @@ -0,0 +1,7 @@ +pub mod glob; +mod path_serializer; +mod project; +mod resolution; + +pub use project::{OptionalPathNode, PathNode, Project, ProjectNode}; +pub use resolution::{AmbiguousValue, UnresolvedValue}; diff --git a/crates/rojo-project/src/path_serializer.rs b/crates/rojo-project/src/path_serializer.rs new file mode 100644 index 00000000..a7547f2c --- /dev/null +++ b/crates/rojo-project/src/path_serializer.rs @@ -0,0 +1,21 @@ +//! Path serializer is used to serialize absolute paths in a cross-platform way, +//! by replacing all directory separators with /. + +use std::path::Path; + +use serde::Serializer; + +pub fn serialize_absolute(path: T, serializer: S) -> Result +where + S: Serializer, + T: AsRef, +{ + let as_str = path + .as_ref() + .as_os_str() + .to_str() + .expect("Invalid Unicode in file path, cannot serialize"); + let replaced = as_str.replace("\\", "/"); + + serializer.serialize_str(&replaced) +} diff --git a/crates/rojo-project/src/project.rs b/crates/rojo-project/src/project.rs new file mode 100644 index 00000000..7cf1522e --- /dev/null +++ b/crates/rojo-project/src/project.rs @@ -0,0 +1,363 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; + +use crate::glob::Glob; +use crate::resolution::UnresolvedValue; + +static PROJECT_FILENAME: &str = "default.project.json"; + +/// Contains all of the configuration for a Rojo-managed project. +/// +/// Rojo project files are stored in `.project.json` files. +#[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. + /// + /// Can be overriden with the `--port` flag. + #[serde(skip_serializing_if = "Option::is_none")] + pub serve_port: Option, + + /// If specified, sets the default IP address that `rojo serve` should use + /// when using this project for live sync. + /// + /// Can be overridden with the `--address` flag. + #[serde(skip_serializing_if = "Option::is_none")] + pub serve_address: 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>, + + /// If specified, sets the current place's place ID when connecting to the + /// Rojo server from Roblox Studio. + #[serde(skip_serializing_if = "Option::is_none")] + pub place_id: Option, + + /// If specified, sets the current place's game ID when connecting to the + /// Rojo server from Roblox Studio. + #[serde(skip_serializing_if = "Option::is_none")] + pub game_id: Option, + + /// A list of globs, relative to the folder the project file is in, that + /// match files that should be excluded if Rojo encounters them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub glob_ignore_paths: 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()) + .map(|name| name.ends_with(".project.json")) + .unwrap_or(false) + } + + /// Loads a project file from a slice and a path that indicates where the + /// project should resolve paths relative to. + pub fn load_from_slice(contents: &[u8], project_file_location: &Path) -> anyhow::Result { + let mut project: Self = serde_json::from_slice(&contents).with_context(|| { + format!( + "Error parsing Rojo project at {}", + project_file_location.display() + ) + })?; + + project.file_location = project_file_location.to_path_buf(); + project.check_compatibility(); + Ok(project) + } + + /// Fuzzy-find a Rojo project and load it. + pub fn load_fuzzy(fuzzy_project_location: &Path) -> anyhow::Result> { + if let Some(project_path) = Self::locate(fuzzy_project_location) { + let project = Self::load_exact(&project_path)?; + + Ok(Some(project)) + } else { + Ok(None) + } + } + + /// Gives the path that all project file paths should resolve relative to. + pub fn folder_location(&self) -> &Path { + self.file_location.parent().unwrap() + } + + /// Attempt to locate a project represented by the given path. + /// + /// This will find a project if the path refers to a `.project.json` file, + /// or is a folder that contains a `default.project.json` file. + fn locate(path: &Path) -> Option { + let meta = fs::metadata(path).ok()?; + + if meta.is_file() { + if Project::is_project_file(path) { + Some(path.to_path_buf()) + } else { + None + } + } else { + let child_path = path.join(PROJECT_FILENAME); + let child_meta = fs::metadata(&child_path).ok()?; + + if child_meta.is_file() { + Some(child_path) + } else { + // This is a folder with the same name as a Rojo default project + // file. + // + // That's pretty weird, but we can roll with it. + None + } + } + } + + fn load_exact(project_file_location: &Path) -> anyhow::Result { + let contents = fs::read_to_string(project_file_location)?; + + let mut project: Project = serde_json::from_str(&contents).with_context(|| { + format!( + "Error parsing Rojo project at {}", + project_file_location.display() + ) + })?; + + project.file_location = project_file_location.to_path_buf(); + project.check_compatibility(); + + Ok(project) + } + + /// Checks if there are any compatibility issues with this project file and + /// warns the user if there are any. + fn check_compatibility(&self) { + self.tree.validate_reserved_names(); + } +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct OptionalPathNode { + #[serde(serialize_with = "crate::path_serializer::serialize_absolute")] + pub optional: PathBuf, +} + +impl OptionalPathNode { + pub fn new(optional: PathBuf) -> Self { + OptionalPathNode { optional } + } +} + +/// Describes a path that is either optional or required +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PathNode { + Required(#[serde(serialize_with = "crate::path_serializer::serialize_absolute")] PathBuf), + Optional(OptionalPathNode), +} + +impl PathNode { + pub fn path(&self) -> &Path { + match self { + PathNode::Required(pathbuf) => &pathbuf, + PathNode::Optional(OptionalPathNode { optional }) => &optional, + } + } +} + +/// 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, + + /// Contains all of the children of the described instance. + #[serde(flatten)] + pub children: BTreeMap, + + /// The properties that will be assigned to the resulting instance. + #[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", 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(); + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn path_node_required() { + let path_node: PathNode = serde_json::from_str(r#""src""#).unwrap(); + assert_eq!(path_node, PathNode::Required(PathBuf::from("src"))); + } + + #[test] + fn path_node_optional() { + let path_node: PathNode = serde_json::from_str(r#"{ "optional": "src" }"#).unwrap(); + assert_eq!( + path_node, + PathNode::Optional(OptionalPathNode::new(PathBuf::from("src"))) + ); + } + + #[test] + fn project_node_required() { + let project_node: ProjectNode = serde_json::from_str( + r#"{ + "$path": "src" + }"#, + ) + .unwrap(); + + assert_eq!( + project_node.path, + Some(PathNode::Required(PathBuf::from("src"))) + ); + } + + #[test] + fn project_node_optional() { + let project_node: ProjectNode = serde_json::from_str( + r#"{ + "$path": { "optional": "src" } + }"#, + ) + .unwrap(); + + assert_eq!( + project_node.path, + Some(PathNode::Optional(OptionalPathNode::new(PathBuf::from( + "src" + )))) + ); + } + + #[test] + fn project_node_none() { + let project_node: ProjectNode = serde_json::from_str( + r#"{ + "$className": "Folder" + }"#, + ) + .unwrap(); + + assert_eq!(project_node.path, None); + } + + #[test] + fn project_node_optional_serialize_absolute() { + let project_node: ProjectNode = serde_json::from_str( + r#"{ + "$path": { "optional": "..\\src" } + }"#, + ) + .unwrap(); + + let serialized = serde_json::to_string(&project_node).unwrap(); + assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#); + } + + #[test] + fn project_node_optional_serialize_absolute_no_change() { + let project_node: ProjectNode = serde_json::from_str( + r#"{ + "$path": { "optional": "../src" } + }"#, + ) + .unwrap(); + + let serialized = serde_json::to_string(&project_node).unwrap(); + assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#); + } + + #[test] + fn project_node_optional_serialize_optional() { + let project_node: ProjectNode = serde_json::from_str( + r#"{ + "$path": "..\\src" + }"#, + ) + .unwrap(); + + let serialized = serde_json::to_string(&project_node).unwrap(); + assert_eq!(serialized, r#"{"$path":"../src"}"#); + } +} diff --git a/crates/rojo-project/src/resolution.rs b/crates/rojo-project/src/resolution.rs new file mode 100644 index 00000000..8f25d992 --- /dev/null +++ b/crates/rojo-project/src/resolution.rs @@ -0,0 +1,294 @@ +use std::borrow::Borrow; + +use anyhow::format_err; +use rbx_dom_weak::types::{ + CFrame, Color3, Content, Enum, Matrix3, Tags, Variant, VariantType, Vector2, Vector3, +}; +use rbx_reflection::{DataType, PropertyDescriptor}; +use serde::{Deserialize, Serialize}; + +/// A user-friendly version of `Variant` that supports specifying ambiguous +/// values. Ambiguous values need a reflection database to be resolved to a +/// usable value. +/// +/// This type is used in Rojo projects and JSON models to make specifying the +/// most common types of properties, like strings or vectors, much easier. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum UnresolvedValue { + FullyQualified(Variant), + Ambiguous(AmbiguousValue), +} + +impl UnresolvedValue { + pub fn resolve(self, class_name: &str, prop_name: &str) -> anyhow::Result { + match self { + UnresolvedValue::FullyQualified(full) => Ok(full), + UnresolvedValue::Ambiguous(partial) => partial.resolve(class_name, prop_name), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AmbiguousValue { + Bool(bool), + String(String), + StringArray(Vec), + Number(f64), + Array2([f64; 2]), + Array3([f64; 3]), + Array4([f64; 4]), + Array12([f64; 12]), +} + +impl AmbiguousValue { + pub fn resolve(self, class_name: &str, prop_name: &str) -> anyhow::Result { + let property = find_descriptor(class_name, prop_name) + .ok_or_else(|| format_err!("Unknown property {}.{}", class_name, prop_name))?; + + match &property.data_type { + DataType::Enum(enum_name) => { + let database = rbx_reflection_database::get(); + + let enum_descriptor = database.enums.get(enum_name).ok_or_else(|| { + format_err!("Unknown enum {}. This is a Rojo bug!", enum_name) + })?; + + let error = |what: &str| { + let mut all_values = enum_descriptor + .items + .keys() + .map(|value| value.borrow()) + .collect::>(); + all_values.sort(); + + let examples = nonexhaustive_list(&all_values); + + format_err!( + "Invalid value for property {}.{}. Got {} but \ + expected a member of the {} enum such as {}", + class_name, + prop_name, + what, + enum_name, + examples, + ) + }; + + let value = match self { + AmbiguousValue::String(value) => value, + unresolved => return Err(error(unresolved.describe())), + }; + + let resolved = enum_descriptor + .items + .get(value.as_str()) + .ok_or_else(|| error(value.as_str()))?; + + Ok(Enum::from_u32(*resolved).into()) + } + DataType::Value(variant_ty) => match (variant_ty, self) { + (VariantType::Bool, AmbiguousValue::Bool(value)) => Ok(value.into()), + + (VariantType::Float32, AmbiguousValue::Number(value)) => Ok((value as f32).into()), + (VariantType::Float64, AmbiguousValue::Number(value)) => Ok(value.into()), + (VariantType::Int32, AmbiguousValue::Number(value)) => Ok((value as i32).into()), + (VariantType::Int64, AmbiguousValue::Number(value)) => Ok((value as i64).into()), + + (VariantType::String, AmbiguousValue::String(value)) => Ok(value.into()), + (VariantType::Tags, AmbiguousValue::StringArray(value)) => { + Ok(Tags::from(value).into()) + } + (VariantType::Content, AmbiguousValue::String(value)) => { + Ok(Content::from(value).into()) + } + + (VariantType::Vector2, AmbiguousValue::Array2(value)) => { + Ok(Vector2::new(value[0] as f32, value[1] as f32).into()) + } + + (VariantType::Vector3, AmbiguousValue::Array3(value)) => { + Ok(Vector3::new(value[0] as f32, value[1] as f32, value[2] as f32).into()) + } + + (VariantType::Color3, AmbiguousValue::Array3(value)) => { + Ok(Color3::new(value[0] as f32, value[1] as f32, value[2] as f32).into()) + } + + (VariantType::CFrame, AmbiguousValue::Array12(value)) => { + let value = value.map(|v| v as f32); + let pos = Vector3::new(value[0], value[1], value[2]); + let orientation = Matrix3::new( + Vector3::new(value[3], value[4], value[5]), + Vector3::new(value[6], value[7], value[8]), + Vector3::new(value[9], value[10], value[11]), + ); + + Ok(CFrame::new(pos, orientation).into()) + } + + (_, unresolved) => Err(format_err!( + "Wrong type of value for property {}.{}. Expected {:?}, got {}", + class_name, + prop_name, + variant_ty, + unresolved.describe(), + )), + }, + _ => Err(format_err!( + "Unknown data type for property {}.{}", + class_name, + prop_name + )), + } + } + + fn describe(&self) -> &'static str { + match self { + AmbiguousValue::Bool(_) => "a bool", + AmbiguousValue::String(_) => "a string", + AmbiguousValue::StringArray(_) => "an array of strings", + AmbiguousValue::Number(_) => "a number", + AmbiguousValue::Array2(_) => "an array of two numbers", + AmbiguousValue::Array3(_) => "an array of three numbers", + AmbiguousValue::Array4(_) => "an array of four numbers", + AmbiguousValue::Array12(_) => "an array of twelve numbers", + } + } +} + +fn find_descriptor( + class_name: &str, + prop_name: &str, +) -> Option<&'static PropertyDescriptor<'static>> { + let database = rbx_reflection_database::get(); + let mut current_class_name = class_name; + + loop { + let class = database.classes.get(current_class_name)?; + if let Some(descriptor) = class.properties.get(prop_name) { + return Some(descriptor); + } + + current_class_name = class.superclass.as_deref()?; + } +} + +/// Outputs a string containing up to MAX_ITEMS entries from the given list. If +/// there are more than MAX_ITEMS items, the number of remaining items will be +/// listed. +fn nonexhaustive_list(values: &[&str]) -> String { + use std::fmt::Write; + + const MAX_ITEMS: usize = 8; + + let mut output = String::new(); + + let last_index = values.len() - 1; + let main_length = last_index.min(9); + + let main_list = &values[..main_length]; + for value in main_list { + output.push_str(value); + output.push_str(", "); + } + + if values.len() > MAX_ITEMS { + write!(output, "or {} more", values.len() - main_length).unwrap(); + } else { + output.push_str("or "); + output.push_str(values[values.len() - 1]); + } + + output +} + +#[cfg(test)] +mod test { + use super::*; + + fn resolve(class: &str, prop: &str, json_value: &str) -> Variant { + let unresolved: UnresolvedValue = serde_json::from_str(json_value).unwrap(); + unresolved.resolve(class, prop).unwrap() + } + + #[test] + fn bools() { + assert_eq!(resolve("BoolValue", "Value", "false"), Variant::Bool(false)); + + // Script.Disabled is inherited from BaseScript + assert_eq!(resolve("Script", "Disabled", "true"), Variant::Bool(true)); + } + + #[test] + fn strings() { + // String literals can stay as strings + assert_eq!( + resolve("StringValue", "Value", "\"Hello!\""), + Variant::String("Hello!".into()), + ); + + // String literals can also turn into Content + assert_eq!( + resolve("Sky", "MoonTextureId", "\"rbxassetid://12345\""), + Variant::Content("rbxassetid://12345".into()), + ); + + // What about BinaryString values? For forward-compatibility reasons, we + // don't support any shorthands for BinaryString. + // + // assert_eq!( + // resolve("Folder", "Tags", "\"a\\u0000b\\u0000c\""), + // Variant::BinaryString(b"a\0b\0c".to_vec().into()), + // ); + } + + #[test] + fn numbers() { + assert_eq!( + resolve("Part", "CollisionGroupId", "123"), + Variant::Int32(123), + ); + + assert_eq!( + resolve("Folder", "SourceAssetId", "532413"), + Variant::Int64(532413), + ); + + assert_eq!(resolve("Part", "Transparency", "1"), Variant::Float32(1.0)); + assert_eq!(resolve("NumberValue", "Value", "1"), Variant::Float64(1.0)); + } + + #[test] + fn vectors() { + assert_eq!( + resolve("ParticleEmitter", "SpreadAngle", "[1, 2]"), + Variant::Vector2(Vector2::new(1.0, 2.0)), + ); + + assert_eq!( + resolve("Part", "Position", "[4, 5, 6]"), + Variant::Vector3(Vector3::new(4.0, 5.0, 6.0)), + ); + } + + #[test] + fn colors() { + assert_eq!( + resolve("Part", "Color", "[1, 1, 1]"), + Variant::Color3(Color3::new(1.0, 1.0, 1.0)), + ); + + // There aren't any user-facing Color3uint8 properties. If there are + // some, we should treat them the same in the future. + } + + #[test] + fn enums() { + assert_eq!( + resolve("Lighting", "Technology", "\"Voxel\""), + Variant::Enum(Enum::from_u32(1)), + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 51195b6c..47e2e154 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ mod tree_view; mod auth_cookie; mod change_processor; -mod glob; mod lua_ast; mod message_queue; mod multimap; diff --git a/src/project.rs b/src/project.rs index 53d52808..4620b5d3 100644 --- a/src/project.rs +++ b/src/project.rs @@ -1,379 +1,3 @@ -use std::{ - collections::{BTreeMap, HashMap, HashSet}, - fs, io, - net::IpAddr, - path::{Path, PathBuf}, -}; +pub use rojo_project::{OptionalPathNode, PathNode, Project, ProjectNode}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::{glob::Glob, resolution::UnresolvedValue}; - -static PROJECT_FILENAME: &str = "default.project.json"; - -/// Error type returned by any function that handles projects. -#[derive(Debug, Error)] -#[error(transparent)] -pub struct ProjectError(#[from] Error); - -#[derive(Debug, Error)] -enum Error { - #[error(transparent)] - Io { - #[from] - source: io::Error, - }, - - #[error("Error parsing Rojo project in path {}", .path.display())] - Json { - source: serde_json::Error, - path: PathBuf, - }, -} - -/// Contains all of the configuration for a Rojo-managed project. -/// -/// Project files are stored in `.project.json` files. -#[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>, - - /// If specified, sets the current place's place ID when connecting to the - /// Rojo server from Roblox Studio. - #[serde(skip_serializing_if = "Option::is_none")] - pub place_id: Option, - - /// If specified, sets the current place's game ID when connecting to the - /// Rojo server from Roblox Studio. - #[serde(skip_serializing_if = "Option::is_none")] - pub game_id: Option, - - /// If specified, this address will be used in place of the default address - /// As long as --address is unprovided. - #[serde(skip_serializing_if = "Option::is_none")] - pub serve_address: Option, - - /// A list of globs, relative to the folder the project file is in, that - /// match files that should be excluded if Rojo encounters them. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub glob_ignore_paths: 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()) - .map(|name| name.ends_with(".project.json")) - .unwrap_or(false) - } - - /// Attempt to locate a project represented by the given path. - /// - /// This will find a project if the path refers to a `.project.json` file, - /// or is a folder that contains a `default.project.json` file. - fn locate(path: &Path) -> Option { - let meta = fs::metadata(path).ok()?; - - if meta.is_file() { - if Project::is_project_file(path) { - Some(path.to_path_buf()) - } else { - None - } - } else { - let child_path = path.join(PROJECT_FILENAME); - let child_meta = fs::metadata(&child_path).ok()?; - - if child_meta.is_file() { - Some(child_path) - } else { - // This is a folder with the same name as a Rojo default project - // file. - // - // That's pretty weird, but we can roll with it. - None - } - } - } - - pub fn load_from_slice( - contents: &[u8], - project_file_location: &Path, - ) -> Result { - let mut project: Self = - serde_json::from_slice(&contents).map_err(|source| Error::Json { - source, - path: project_file_location.to_owned(), - })?; - - project.file_location = project_file_location.to_path_buf(); - project.check_compatibility(); - Ok(project) - } - - pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result, ProjectError> { - if let Some(project_path) = Self::locate(fuzzy_project_location) { - let project = Self::load_exact(&project_path)?; - - Ok(Some(project)) - } else { - Ok(None) - } - } - - fn load_exact(project_file_location: &Path) -> Result { - let contents = fs::read_to_string(project_file_location)?; - - let mut project: Project = - serde_json::from_str(&contents).map_err(|source| Error::Json { - source, - path: project_file_location.to_owned(), - })?; - - project.file_location = project_file_location.to_path_buf(); - project.check_compatibility(); - - Ok(project) - } - - /// Checks if there are any compatibility issues with this project file and - /// warns the user if there are any. - fn check_compatibility(&self) { - self.tree.validate_reserved_names(); - } - - pub fn folder_location(&self) -> &Path { - self.file_location.parent().unwrap() - } -} - -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct OptionalPathNode { - #[serde(serialize_with = "crate::path_serializer::serialize_absolute")] - pub optional: PathBuf, -} - -impl OptionalPathNode { - pub fn new(optional: PathBuf) -> Self { - OptionalPathNode { optional } - } -} - -/// Describes a path that is either optional or required -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum PathNode { - Required(#[serde(serialize_with = "crate::path_serializer::serialize_absolute")] PathBuf), - Optional(OptionalPathNode), -} - -impl PathNode { - pub fn path(&self) -> &Path { - match self { - PathNode::Required(pathbuf) => &pathbuf, - PathNode::Optional(OptionalPathNode { optional }) => &optional, - } - } -} - -/// 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, - - /// 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", 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(); - } - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn path_node_required() { - let path_node: PathNode = serde_json::from_str(r#""src""#).unwrap(); - assert_eq!(path_node, PathNode::Required(PathBuf::from("src"))); - } - - #[test] - fn path_node_optional() { - let path_node: PathNode = serde_json::from_str(r#"{ "optional": "src" }"#).unwrap(); - assert_eq!( - path_node, - PathNode::Optional(OptionalPathNode::new(PathBuf::from("src"))) - ); - } - - #[test] - fn project_node_required() { - let project_node: ProjectNode = serde_json::from_str( - r#"{ - "$path": "src" - }"#, - ) - .unwrap(); - - assert_eq!( - project_node.path, - Some(PathNode::Required(PathBuf::from("src"))) - ); - } - - #[test] - fn project_node_optional() { - let project_node: ProjectNode = serde_json::from_str( - r#"{ - "$path": { "optional": "src" } - }"#, - ) - .unwrap(); - - assert_eq!( - project_node.path, - Some(PathNode::Optional(OptionalPathNode::new(PathBuf::from( - "src" - )))) - ); - } - - #[test] - fn project_node_none() { - let project_node: ProjectNode = serde_json::from_str( - r#"{ - "$className": "Folder" - }"#, - ) - .unwrap(); - - assert_eq!(project_node.path, None); - } - - #[test] - fn project_node_optional_serialize_absolute() { - let project_node: ProjectNode = serde_json::from_str( - r#"{ - "$path": { "optional": "..\\src" } - }"#, - ) - .unwrap(); - - let serialized = serde_json::to_string(&project_node).unwrap(); - assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#); - } - - #[test] - fn project_node_optional_serialize_absolute_no_change() { - let project_node: ProjectNode = serde_json::from_str( - r#"{ - "$path": { "optional": "../src" } - }"#, - ) - .unwrap(); - - let serialized = serde_json::to_string(&project_node).unwrap(); - assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#); - } - - #[test] - fn project_node_optional_serialize_optional() { - let project_node: ProjectNode = serde_json::from_str( - r#"{ - "$path": "..\\src" - }"#, - ) - .unwrap(); - - let serialized = serde_json::to_string(&project_node).unwrap(); - assert_eq!(serialized, r#"{"$path":"../src"}"#); - } -} +pub use anyhow::Error as ProjectError; diff --git a/src/serve_session.rs b/src/serve_session.rs index a4eced44..b2c65479 100644 --- a/src/serve_session.rs +++ b/src/serve_session.rs @@ -16,7 +16,7 @@ use thiserror::Error; use crate::{ change_processor::ChangeProcessor, message_queue::MessageQueue, - project::{Project, ProjectError}, + project::Project, session_id::SessionId, snapshot::{ apply_patch_set, compute_patch_set, AppliedPatchSet, InstanceContext, InstanceSnapshot, @@ -237,12 +237,6 @@ pub enum ServeSessionError { source: io::Error, }, - #[error(transparent)] - Project { - #[from] - source: ProjectError, - }, - #[error(transparent)] Other { #[from] diff --git a/src/snapshot/metadata.rs b/src/snapshot/metadata.rs index 192a0001..7aaa1911 100644 --- a/src/snapshot/metadata.rs +++ b/src/snapshot/metadata.rs @@ -4,9 +4,10 @@ use std::{ sync::Arc, }; +use rojo_project::glob::Glob; use serde::{Deserialize, Serialize}; -use crate::{glob::Glob, path_serializer, project::ProjectNode}; +use crate::{path_serializer, project::ProjectNode}; /// Rojo-specific metadata that can be associated with an instance or a snapshot /// of an instance.