forked from rojo-rbx/rojo
335 lines
11 KiB
Rust
335 lines
11 KiB
Rust
use std::{
|
|
borrow::Cow,
|
|
io::{BufWriter, Write},
|
|
mem::forget,
|
|
path::{self, Path, PathBuf},
|
|
};
|
|
|
|
use clap::Parser;
|
|
use fs_err::File;
|
|
use memofs::Vfs;
|
|
use rayon::prelude::*;
|
|
use rbx_dom_weak::{types::Ref, Ustr};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::runtime::Runtime;
|
|
|
|
use crate::{
|
|
serve_session::ServeSession,
|
|
snapshot::{AppliedPatchSet, InstanceWithMeta, RojoTree},
|
|
};
|
|
|
|
use super::resolve_path;
|
|
|
|
const PATH_STRIP_FAILED_ERR: &str = "Failed to create relative paths for project file!";
|
|
const ABSOLUTE_PATH_FAILED_ERR: &str = "Failed to turn relative path into absolute path!";
|
|
|
|
/// Representation of a node in the generated sourcemap tree.
|
|
#[derive(Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SourcemapNode<'a> {
|
|
name: &'a str,
|
|
class_name: Ustr,
|
|
|
|
#[serde(
|
|
default,
|
|
skip_serializing_if = "Vec::is_empty",
|
|
serialize_with = "crate::path_serializer::serialize_vec_absolute"
|
|
)]
|
|
file_paths: Vec<Cow<'a, Path>>,
|
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
children: Vec<SourcemapNode<'a>>,
|
|
}
|
|
|
|
/// Generates a sourcemap file from the Rojo project.
|
|
#[derive(Debug, Parser)]
|
|
pub struct SourcemapCommand {
|
|
/// Path to the project to use for the sourcemap. Defaults to the current
|
|
/// directory.
|
|
#[clap(default_value = "")]
|
|
pub project: PathBuf,
|
|
|
|
/// Where to output the sourcemap. Omit this to use stdout instead of
|
|
/// writing to a file.
|
|
///
|
|
/// Should end in .json.
|
|
#[clap(long, short)]
|
|
pub output: Option<PathBuf>,
|
|
|
|
/// If non-script files should be included or not. Defaults to false.
|
|
#[clap(long)]
|
|
pub include_non_scripts: bool,
|
|
|
|
/// Whether to automatically recreate a snapshot when any input files change.
|
|
#[clap(long)]
|
|
pub watch: bool,
|
|
|
|
/// Whether the sourcemap should use absolute paths instead of relative paths.
|
|
#[clap(long)]
|
|
pub absolute: bool,
|
|
}
|
|
|
|
impl SourcemapCommand {
|
|
pub fn run(self) -> anyhow::Result<()> {
|
|
let project_path = fs_err::canonicalize(resolve_path(&self.project))?;
|
|
|
|
log::trace!("Constructing filesystem with StdBackend");
|
|
let vfs = Vfs::new_default();
|
|
vfs.set_watch_enabled(self.watch);
|
|
|
|
log::trace!("Setting up session for sourcemap generation");
|
|
let session = ServeSession::new(vfs, project_path, None)?;
|
|
let mut cursor = session.message_queue().cursor();
|
|
|
|
let filter = if self.include_non_scripts {
|
|
filter_nothing
|
|
} else {
|
|
filter_non_scripts
|
|
};
|
|
|
|
// Pre-build a rayon threadpool with a low number of threads to avoid
|
|
// dynamic creation overhead on systems with a high number of cpus.
|
|
log::trace!("Setting rayon global threadpool");
|
|
rayon::ThreadPoolBuilder::new()
|
|
.num_threads(num_cpus::get().min(6))
|
|
.build_global()
|
|
.ok();
|
|
|
|
log::trace!("Writing initial sourcemap");
|
|
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
|
|
|
|
if self.watch {
|
|
log::trace!("Setting up runtime for watch mode");
|
|
let rt = Runtime::new().unwrap();
|
|
|
|
loop {
|
|
let receiver = session.message_queue().subscribe(cursor);
|
|
let (new_cursor, patch_set) = rt.block_on(receiver).unwrap();
|
|
cursor = new_cursor;
|
|
|
|
if patch_set_affects_sourcemap(&session, &patch_set, filter) {
|
|
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Avoid dropping ServeSession: it's potentially VERY expensive to drop
|
|
// and we're about to exit anyways.
|
|
forget(session);
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn filter_nothing(_instance: &InstanceWithMeta) -> bool {
|
|
true
|
|
}
|
|
|
|
fn filter_non_scripts(instance: &InstanceWithMeta) -> bool {
|
|
matches!(
|
|
instance.class_name().as_str(),
|
|
"Script" | "LocalScript" | "ModuleScript"
|
|
)
|
|
}
|
|
|
|
fn patch_set_affects_sourcemap(
|
|
session: &ServeSession,
|
|
patch_set: &[AppliedPatchSet],
|
|
filter: fn(&InstanceWithMeta) -> bool,
|
|
) -> bool {
|
|
let tree = session.tree();
|
|
|
|
// A sourcemap has probably changed when:
|
|
patch_set.par_iter().any(|set| {
|
|
// 1. An instance was removed, in which case it will no
|
|
// longer exist in the tree and we cant check the filter
|
|
!set.removed.is_empty()
|
|
// 2. A newly added instance passes the filter
|
|
|| set.added.iter().any(|referent| {
|
|
let instance = tree
|
|
.get_instance(*referent)
|
|
.expect("instance did not exist when updating sourcemap");
|
|
filter(&instance)
|
|
})
|
|
// 3. An existing instance has its class name, name,
|
|
// or file paths changed, and passes the filter
|
|
|| set.updated.iter().any(|updated| {
|
|
let changed = updated.changed_class_name.is_some()
|
|
|| updated.changed_name.is_some()
|
|
|| updated.changed_metadata.is_some();
|
|
if changed {
|
|
let instance = tree
|
|
.get_instance(updated.id)
|
|
.expect("instance did not exist when updating sourcemap");
|
|
filter(&instance)
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
fn recurse_create_node<'a>(
|
|
tree: &'a RojoTree,
|
|
referent: Ref,
|
|
project_dir: &Path,
|
|
filter: fn(&InstanceWithMeta) -> bool,
|
|
use_absolute_paths: bool,
|
|
) -> Option<SourcemapNode<'a>> {
|
|
let instance = tree.get_instance(referent).expect("instance did not exist");
|
|
|
|
let children: Vec<_> = instance
|
|
.children()
|
|
.par_iter()
|
|
.filter_map(|&child_id| {
|
|
recurse_create_node(tree, child_id, project_dir, filter, use_absolute_paths)
|
|
})
|
|
.collect();
|
|
|
|
// If this object has no children and doesn't pass the filter, it doesn't
|
|
// contain any information we're looking for.
|
|
if children.is_empty() && !filter(&instance) {
|
|
return None;
|
|
}
|
|
|
|
let file_paths = instance
|
|
.metadata()
|
|
.relevant_paths
|
|
.iter()
|
|
// Not all paths listed as relevant are guaranteed to exist.
|
|
.filter(|path| path.is_file())
|
|
.map(|path| path.as_path());
|
|
|
|
let mut output_file_paths: Vec<Cow<'a, Path>> =
|
|
Vec::with_capacity(instance.metadata().relevant_paths.len());
|
|
|
|
if use_absolute_paths {
|
|
// It's somewhat important to note here that `path::absolute` takes in a Path and returns a PathBuf
|
|
for val in file_paths {
|
|
output_file_paths.push(Cow::Owned(
|
|
path::absolute(val).expect(ABSOLUTE_PATH_FAILED_ERR),
|
|
));
|
|
}
|
|
} else {
|
|
for val in file_paths {
|
|
output_file_paths.push(Cow::from(
|
|
pathdiff::diff_paths(val, project_dir).expect(PATH_STRIP_FAILED_ERR),
|
|
));
|
|
}
|
|
};
|
|
|
|
Some(SourcemapNode {
|
|
name: instance.name(),
|
|
class_name: instance.class_name(),
|
|
file_paths: output_file_paths,
|
|
children,
|
|
})
|
|
}
|
|
|
|
fn write_sourcemap(
|
|
session: &ServeSession,
|
|
output: Option<&Path>,
|
|
filter: fn(&InstanceWithMeta) -> bool,
|
|
use_absolute_paths: bool,
|
|
) -> anyhow::Result<()> {
|
|
let tree = session.tree();
|
|
|
|
let root_node = recurse_create_node(
|
|
&tree,
|
|
tree.get_root_id(),
|
|
session.root_dir(),
|
|
filter,
|
|
use_absolute_paths,
|
|
);
|
|
|
|
if let Some(output_path) = output {
|
|
let mut file = BufWriter::new(File::create(output_path)?);
|
|
serde_json::to_writer(&mut file, &root_node)?;
|
|
file.flush()?;
|
|
|
|
println!("Created sourcemap at {}", output_path.display());
|
|
} else {
|
|
let output = serde_json::to_string(&root_node)?;
|
|
println!("{}", output);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use crate::cli::sourcemap::SourcemapNode;
|
|
use crate::cli::SourcemapCommand;
|
|
use insta::internals::Content;
|
|
use std::path::Path;
|
|
|
|
#[test]
|
|
fn maps_relative_paths() {
|
|
let sourcemap_dir = tempfile::tempdir().unwrap();
|
|
let sourcemap_output = sourcemap_dir.path().join("sourcemap.json");
|
|
let project_path = fs_err::canonicalize(
|
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("test-projects")
|
|
.join("relative_paths")
|
|
.join("project"),
|
|
)
|
|
.unwrap();
|
|
let sourcemap_command = SourcemapCommand {
|
|
project: project_path,
|
|
output: Some(sourcemap_output.clone()),
|
|
include_non_scripts: false,
|
|
watch: false,
|
|
absolute: false,
|
|
};
|
|
assert!(sourcemap_command.run().is_ok());
|
|
|
|
let raw_sourcemap_contents = fs_err::read_to_string(sourcemap_output.as_path()).unwrap();
|
|
let sourcemap_contents =
|
|
serde_json::from_str::<SourcemapNode>(&raw_sourcemap_contents).unwrap();
|
|
insta::assert_json_snapshot!(sourcemap_contents);
|
|
}
|
|
|
|
#[test]
|
|
fn maps_absolute_paths() {
|
|
let sourcemap_dir = tempfile::tempdir().unwrap();
|
|
let sourcemap_output = sourcemap_dir.path().join("sourcemap.json");
|
|
let project_path = fs_err::canonicalize(
|
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("test-projects")
|
|
.join("relative_paths")
|
|
.join("project"),
|
|
)
|
|
.unwrap();
|
|
let sourcemap_command = SourcemapCommand {
|
|
project: project_path,
|
|
output: Some(sourcemap_output.clone()),
|
|
include_non_scripts: false,
|
|
watch: false,
|
|
absolute: true,
|
|
};
|
|
assert!(sourcemap_command.run().is_ok());
|
|
|
|
let raw_sourcemap_contents = fs_err::read_to_string(sourcemap_output.as_path()).unwrap();
|
|
let sourcemap_contents =
|
|
serde_json::from_str::<SourcemapNode>(&raw_sourcemap_contents).unwrap();
|
|
insta::assert_json_snapshot!(sourcemap_contents, {
|
|
".**.filePaths" => insta::dynamic_redaction(|mut value, _path| {
|
|
let mut paths_count = 0;
|
|
|
|
match value {
|
|
Content::Seq(ref mut vec) => {
|
|
for path in vec.iter().map(|i| i.as_str().unwrap()) {
|
|
assert_eq!(fs_err::canonicalize(path).is_ok(), true, "path was not valid");
|
|
assert_eq!(Path::new(path).is_absolute(), true, "path was not absolute");
|
|
|
|
paths_count += 1;
|
|
}
|
|
}
|
|
_ => panic!("Expected filePaths to be a sequence"),
|
|
}
|
|
format!("[...{} path{} omitted...]", paths_count, if paths_count != 1 { "s" } else { "" } )
|
|
})
|
|
});
|
|
}
|
|
}
|