forked from rojo-rbx/rojo
Compare commits
1 Commits
d7a9ce55db
...
project-cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96a236333 |
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
16
crates/rojo-project/Cargo.toml
Normal file
16
crates/rojo-project/Cargo.toml
Normal file
@@ -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"
|
||||
4
crates/rojo-project/README.md
Normal file
4
crates/rojo-project/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# rojo-project
|
||||
Project file format crate for [Rojo].
|
||||
|
||||
[Rojo]: https://rojo.space
|
||||
@@ -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,
|
||||
7
crates/rojo-project/src/lib.rs
Normal file
7
crates/rojo-project/src/lib.rs
Normal file
@@ -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};
|
||||
21
crates/rojo-project/src/path_serializer.rs
Normal file
21
crates/rojo-project/src/path_serializer.rs
Normal file
@@ -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<S, T>(path: T, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
T: AsRef<Path>,
|
||||
{
|
||||
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)
|
||||
}
|
||||
363
crates/rojo-project/src/project.rs
Normal file
363
crates/rojo-project/src/project.rs
Normal file
@@ -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<u16>,
|
||||
|
||||
/// 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<IpAddr>,
|
||||
|
||||
/// 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<HashSet<u64>>,
|
||||
|
||||
/// 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<u64>,
|
||||
|
||||
/// 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<u64>,
|
||||
|
||||
/// 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<Glob>,
|
||||
|
||||
/// 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<Self> {
|
||||
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<Option<Self>> {
|
||||
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<PathBuf> {
|
||||
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<Self> {
|
||||
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<String>,
|
||||
|
||||
/// Contains all of the children of the described instance.
|
||||
#[serde(flatten)]
|
||||
pub children: BTreeMap<String, ProjectNode>,
|
||||
|
||||
/// The properties that will be assigned to the resulting instance.
|
||||
#[serde(
|
||||
rename = "$properties",
|
||||
default,
|
||||
skip_serializing_if = "HashMap::is_empty"
|
||||
)]
|
||||
pub properties: HashMap<String, UnresolvedValue>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// 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<PathNode>,
|
||||
}
|
||||
|
||||
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"}"#);
|
||||
}
|
||||
}
|
||||
294
crates/rojo-project/src/resolution.rs
Normal file
294
crates/rojo-project/src/resolution.rs
Normal file
@@ -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<Variant> {
|
||||
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<String>),
|
||||
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<Variant> {
|
||||
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::<Vec<_>>();
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ mod tree_view;
|
||||
|
||||
mod auth_cookie;
|
||||
mod change_processor;
|
||||
mod glob;
|
||||
mod lua_ast;
|
||||
mod message_queue;
|
||||
mod multimap;
|
||||
|
||||
380
src/project.rs
380
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<u16>,
|
||||
|
||||
/// 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<HashSet<u64>>,
|
||||
|
||||
/// 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<u64>,
|
||||
|
||||
/// 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<u64>,
|
||||
|
||||
/// 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<IpAddr>,
|
||||
|
||||
/// 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<Glob>,
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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<Self, ProjectError> {
|
||||
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<Option<Self>, 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<Self, Error> {
|
||||
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<String>,
|
||||
|
||||
/// Contains all of the children of the described instance.
|
||||
#[serde(flatten)]
|
||||
pub children: BTreeMap<String, ProjectNode>,
|
||||
|
||||
/// 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<String, UnresolvedValue>,
|
||||
|
||||
/// 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<bool>,
|
||||
|
||||
/// 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<PathNode>,
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user