use std::{
collections::{HashMap, HashSet, BTreeMap},
fmt,
fs::{self, File},
io,
path::{Path, PathBuf},
};
use log::warn;
use failure::Fail;
use rbx_dom_weak::{UnresolvedRbxValue, RbxValue};
use serde::{Serialize, Serializer, Deserialize};
static DEFAULT_PLACE: &'static str = include_str!("../assets/place.project.json");
pub static PROJECT_FILENAME: &'static str = "default.project.json";
pub static COMPAT_PROJECT_FILENAME: &'static str = "roblox-project.json";
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct SourceProject {
name: String,
tree: SourceProjectNode,
#[cfg_attr(not(feature = "plugins-enabled"), serde(skip_deserializing))]
#[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")]
plugins: Vec<SourcePlugin>,
#[serde(skip_serializing_if = "Option::is_none")]
serve_port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
serve_place_ids: Option<HashSet<u64>>,
}
impl SourceProject {
pub fn into_project(mut self, project_file_location: &Path) -> Project {
let tree = self.tree.into_project_node(project_file_location);
let plugins = self.plugins
.drain(..)
.map(|source_plugin| source_plugin.into_plugin(project_file_location))
.collect();
Project {
name: self.name,
tree,
plugins,
serve_port: self.serve_port,
serve_place_ids: self.serve_place_ids,
file_location: PathBuf::from(project_file_location),
}
}
}
fn serialize_unresolved_minimal<S>(unresolved: &UnresolvedRbxValue, serializer: S) -> Result<S::Ok, S::Error>
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),
}
},
}
}
fn serialize_unresolved_map<S>(value: &HashMap<String, UnresolvedRbxValue>, serializer: S) -> Result<S::Ok, S::Error>
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()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SourceProjectNode {
#[serde(rename = "$className", skip_serializing_if = "Option::is_none")]
class_name: Option<String>,
#[serde(
rename = "$properties",
default = "HashMap::new",
skip_serializing_if = "HashMap::is_empty",
serialize_with = "serialize_unresolved_map",
)]
properties: HashMap<String, UnresolvedRbxValue>,
#[serde(rename = "$ignoreUnknownInstances", skip_serializing_if = "Option::is_none")]
ignore_unknown_instances: Option<bool>,
#[serde(rename = "$path", skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(flatten)]
children: BTreeMap<String, SourceProjectNode>,
}
impl SourceProjectNode {
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();
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, Serialize, Deserialize)]
struct SourcePlugin {
path: String,
}
impl SourcePlugin {
pub fn into_plugin(self, project_file_location: &Path) -> Plugin {
let path = if Path::new(&self.path).is_absolute() {
PathBuf::from(self.path)
} else {
let project_folder_location = project_file_location.parent().unwrap();
project_folder_location.join(self.path)
};
Plugin {
path,
}
}
}
#[derive(Debug, Fail)]
pub enum ProjectLoadError {
NotFound,
Io {
#[fail(cause)]
inner: io::Error,
path: PathBuf,
},
Json {
#[fail(cause)]
inner: 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())
}
}
}
}
#[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),
}
}
}
#[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<String>,
pub children: BTreeMap<String, ProjectNode>,
pub properties: HashMap<String, UnresolvedRbxValue>,
pub ignore_unknown_instances: Option<bool>,
#[serde(serialize_with = "crate::path_serializer::serialize_option")]
pub path: Option<PathBuf>,
}
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();
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)]
pub struct Plugin {
pub path: PathBuf,
}
impl Plugin {
fn to_source_plugin(&self, project_file_location: &Path) -> SourcePlugin {
let project_folder_location = project_file_location.parent().unwrap();
let path = match self.path.strip_prefix(project_folder_location) {
Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"),
Err(_) => format!("{}", self.path.display()),
};
SourcePlugin {
path,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Project {
pub name: String,
pub tree: ProjectNode,
pub plugins: Vec<Plugin>,
pub serve_port: Option<u16>,
pub serve_place_ids: Option<HashSet<u64>>,
pub file_location: PathBuf,
}
impl Project {
pub fn init_place(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
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_str(DEFAULT_PLACE, &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<PathBuf, ProjectInitError> {
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,
plugins: Vec::new(),
serve_port: None,
serve_place_ids: None,
file_location: project_path.clone(),
};
project.save()
.map_err(ProjectInitError::SaveError)?;
Ok(project_path)
}
fn pick_path_for_init(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
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)
}
fn locate(start_location: &Path) -> Option<PathBuf> {
let location_metadata = fs::metadata(start_location).ok()?;
if location_metadata.is_file() {
return Some(start_location.to_path_buf());
} else if location_metadata.is_dir() {
let with_file = start_location.join(PROJECT_FILENAME);
if let Ok(file_metadata) = fs::metadata(&with_file) {
if file_metadata.is_file() {
return Some(with_file);
}
}
let with_compat_file = start_location.join(COMPAT_PROJECT_FILENAME);
if let Ok(file_metadata) = fs::metadata(&with_compat_file) {
if file_metadata.is_file() {
return Some(with_compat_file);
}
}
}
match start_location.parent() {
Some(parent_location) => Self::locate(parent_location),
None => None,
}
}
fn load_from_str(contents: &str, project_file_location: &Path) -> Result<Project, serde_json::Error> {
let parsed: SourceProject = serde_json::from_str(&contents)?;
Ok(parsed.into_project(project_file_location))
}
pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result<Project, ProjectLoadError> {
if let Some(project_path) = Self::locate(fuzzy_project_location) {
Self::load_exact(&project_path)
} else {
Project::warn_if_4x_project_present(fuzzy_project_location);
Err(ProjectLoadError::NotFound)
}
}
pub fn load_exact(project_file_location: &Path) -> Result<Project, ProjectLoadError> {
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(),
}
})?;
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);
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(())
}
fn check_compatibility(&self) {
let file_name = self.file_location
.file_name().expect("Project file path did not have a file name")
.to_str().expect("Project file path was not valid Unicode");
if file_name == COMPAT_PROJECT_FILENAME {
warn!("Rojo's default project file name changed in 0.5.0-alpha3.");
warn!("Support for the old project file name will be dropped before 0.5.0 releases.");
warn!("Your project file is named {}", COMPAT_PROJECT_FILENAME);
warn!("Rename your project file to {}", PROJECT_FILENAME);
} else if !file_name.ends_with(".project.json") {
warn!("Starting in Rojo 0.5.0-alpha3, it's recommended to give all project files the");
warn!(".project.json extension. This helps Rojo differentiate project files from");
warn!("other JSON files!");
}
self.tree.validate_reserved_names();
}
fn warn_if_4x_project_present(folder: &Path) {
let file_path = folder.join("rojo.json");
if fs::metadata(file_path).is_ok() {
warn!("No Rojo 0.5 project file was found, but a Rojo 0.4 project was.");
warn!("Rojo 0.5.x uses 'default.project.json' files");
warn!("Rojo 0.5.x uses 'rojo.json' files");
warn!("");
warn!("For help upgrading, see:");
warn!("https://lpghatguy.github.io/rojo/guide/migrating-to-epiphany/");
}
}
pub fn folder_location(&self) -> &Path {
self.file_location.parent().unwrap()
}
fn to_source_project(&self) -> SourceProject {
let plugins = self.plugins
.iter()
.map(|plugin| plugin.to_source_plugin(&self.file_location))
.collect();
SourceProject {
name: self.name.clone(),
tree: self.tree.to_source_node(&self.file_location),
plugins,
serve_port: self.serve_port,
serve_place_ids: self.serve_place_ids.clone(),
}
}
}