1use std::{
2 borrow::Cow,
3 io::{BufWriter, Write},
4 mem::forget,
5 path::{self, Path, PathBuf},
6};
7
8use anyhow::Context;
9use clap::Parser;
10use fs_err::File;
11use memofs::Vfs;
12use rayon::prelude::*;
13use rbx_dom_weak::{types::Ref, Ustr};
14use serde::{Deserialize, Serialize};
15use tokio::runtime::Runtime;
16
17use crate::{
18 serve_session::ServeSession,
19 snapshot::{AppliedPatchSet, InstanceWithMeta, RojoTree},
20};
21
22use super::resolve_path;
23
24const PATH_STRIP_FAILED_ERR: &str = "Failed to create relative paths for project file!";
25const ABSOLUTE_PATH_FAILED_ERR: &str = "Failed to turn relative path into absolute path!";
26
27#[derive(Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30struct SourcemapNode<'a> {
31 name: &'a str,
32 class_name: Ustr,
33
34 #[serde(
35 default,
36 skip_serializing_if = "Vec::is_empty",
37 serialize_with = "crate::path_serializer::serialize_vec_absolute"
38 )]
39 file_paths: Vec<Cow<'a, Path>>,
40
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 children: Vec<SourcemapNode<'a>>,
43}
44
45#[derive(Debug, Parser)]
47pub struct SourcemapCommand {
48 #[clap(default_value = "")]
51 pub project: PathBuf,
52
53 #[clap(long, short)]
58 pub output: Option<PathBuf>,
59
60 #[clap(long)]
62 pub include_non_scripts: bool,
63
64 #[clap(long)]
66 pub watch: bool,
67
68 #[clap(long)]
70 pub absolute: bool,
71}
72
73impl SourcemapCommand {
74 pub fn run(self) -> anyhow::Result<()> {
75 let project_path = fs_err::canonicalize(resolve_path(&self.project)?)?;
76
77 log::trace!("Constructing filesystem with StdBackend");
78 let vfs = Vfs::new_default()?;
79 vfs.set_watch_enabled(self.watch);
80
81 log::trace!("Setting up session for sourcemap generation");
82 let session = ServeSession::new(vfs, project_path)?;
83 let mut cursor = session.message_queue().cursor();
84
85 let filter = if self.include_non_scripts {
86 filter_nothing
87 } else {
88 filter_non_scripts
89 };
90
91 log::trace!("Setting rayon global threadpool");
94 rayon::ThreadPoolBuilder::new()
95 .num_threads(num_cpus::get().min(6))
96 .build_global()
97 .ok();
98
99 log::trace!("Writing initial sourcemap");
100 write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
101
102 if self.watch {
103 log::trace!("Setting up runtime for watch mode");
104 let rt = Runtime::new().context("Failed to start the async runtime for watch mode")?;
105
106 loop {
107 let receiver = session.message_queue().subscribe(cursor);
108 let (new_cursor, patch_set) = match rt.block_on(receiver) {
109 Ok(message) => message,
110 Err(_) => break,
113 };
114 cursor = new_cursor;
115
116 if patch_set_affects_sourcemap(&session, &patch_set, filter) {
117 write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
118 }
119 }
120 }
121
122 forget(session);
125
126 Ok(())
127 }
128}
129
130fn filter_nothing(_instance: &InstanceWithMeta) -> bool {
131 true
132}
133
134fn filter_non_scripts(instance: &InstanceWithMeta) -> bool {
135 matches!(
136 instance.class_name().as_str(),
137 "Script" | "LocalScript" | "ModuleScript"
138 )
139}
140
141fn patch_set_affects_sourcemap(
142 session: &ServeSession,
143 patch_set: &[AppliedPatchSet],
144 filter: fn(&InstanceWithMeta) -> bool,
145) -> bool {
146 let tree = session.tree();
147
148 patch_set.par_iter().any(|set| {
150 !set.removed.is_empty()
153 || set.added.iter().any(|referent| {
155 let instance = tree
156 .get_instance(*referent)
157 .expect("instance did not exist when updating sourcemap");
158 filter(&instance)
159 })
160 || set.updated.iter().any(|updated| {
163 let changed = updated.changed_class_name.is_some()
164 || updated.changed_name.is_some()
165 || updated.changed_metadata.is_some();
166 if changed {
167 let instance = tree
168 .get_instance(updated.id)
169 .expect("instance did not exist when updating sourcemap");
170 filter(&instance)
171 } else {
172 false
173 }
174 })
175 })
176}
177
178fn recurse_create_node<'a>(
179 tree: &'a RojoTree,
180 referent: Ref,
181 project_dir: &Path,
182 filter: fn(&InstanceWithMeta) -> bool,
183 use_absolute_paths: bool,
184) -> Option<SourcemapNode<'a>> {
185 let instance = tree.get_instance(referent).expect("instance did not exist");
186
187 let children: Vec<_> = instance
188 .children()
189 .par_iter()
190 .filter_map(|&child_id| {
191 recurse_create_node(tree, child_id, project_dir, filter, use_absolute_paths)
192 })
193 .collect();
194
195 if children.is_empty() && !filter(&instance) {
198 return None;
199 }
200
201 let file_paths = instance
202 .metadata()
203 .relevant_paths
204 .iter()
205 .filter(|path| path.is_file())
207 .map(|path| path.as_path());
208
209 let mut output_file_paths: Vec<Cow<'a, Path>> =
210 Vec::with_capacity(instance.metadata().relevant_paths.len());
211
212 if use_absolute_paths {
213 for val in file_paths {
215 output_file_paths.push(Cow::Owned(
216 path::absolute(val).expect(ABSOLUTE_PATH_FAILED_ERR),
217 ));
218 }
219 } else {
220 for val in file_paths {
221 output_file_paths.push(Cow::from(
222 pathdiff::diff_paths(val, project_dir).expect(PATH_STRIP_FAILED_ERR),
223 ));
224 }
225 };
226
227 Some(SourcemapNode {
228 name: instance.name(),
229 class_name: instance.class_name(),
230 file_paths: output_file_paths,
231 children,
232 })
233}
234
235fn write_sourcemap(
236 session: &ServeSession,
237 output: Option<&Path>,
238 filter: fn(&InstanceWithMeta) -> bool,
239 use_absolute_paths: bool,
240) -> anyhow::Result<()> {
241 let tree = session.tree();
242
243 let root_node = recurse_create_node(
244 &tree,
245 tree.get_root_id(),
246 session.root_dir(),
247 filter,
248 use_absolute_paths,
249 );
250
251 if let Some(output_path) = output {
252 let mut file = BufWriter::new(File::create(output_path)?);
253 serde_json::to_writer(&mut file, &root_node)?;
254 file.flush()?;
255
256 println!("Created sourcemap at {}", output_path.display());
257 } else {
258 let output = serde_json::to_string(&root_node)?;
259 println!("{}", output);
260 }
261
262 Ok(())
263}
264
265#[cfg(test)]
266mod test {
267 use crate::cli::sourcemap::SourcemapNode;
268 use crate::cli::SourcemapCommand;
269 use insta::internals::Content;
270 use std::path::Path;
271
272 #[test]
273 fn maps_relative_paths() {
274 let sourcemap_dir = tempfile::tempdir().unwrap();
275 let sourcemap_output = sourcemap_dir.path().join("sourcemap.json");
276 let project_path = fs_err::canonicalize(
277 Path::new(env!("CARGO_MANIFEST_DIR"))
278 .join("test-projects")
279 .join("relative_paths")
280 .join("project"),
281 )
282 .unwrap();
283 let sourcemap_command = SourcemapCommand {
284 project: project_path,
285 output: Some(sourcemap_output.clone()),
286 include_non_scripts: false,
287 watch: false,
288 absolute: false,
289 };
290 assert!(sourcemap_command.run().is_ok());
291
292 let raw_sourcemap_contents = fs_err::read_to_string(sourcemap_output.as_path()).unwrap();
293 let sourcemap_contents =
294 serde_json::from_str::<SourcemapNode>(&raw_sourcemap_contents).unwrap();
295 insta::assert_json_snapshot!(sourcemap_contents);
296 }
297
298 #[test]
299 fn maps_absolute_paths() {
300 let sourcemap_dir = tempfile::tempdir().unwrap();
301 let sourcemap_output = sourcemap_dir.path().join("sourcemap.json");
302 let project_path = fs_err::canonicalize(
303 Path::new(env!("CARGO_MANIFEST_DIR"))
304 .join("test-projects")
305 .join("relative_paths")
306 .join("project"),
307 )
308 .unwrap();
309 let sourcemap_command = SourcemapCommand {
310 project: project_path,
311 output: Some(sourcemap_output.clone()),
312 include_non_scripts: false,
313 watch: false,
314 absolute: true,
315 };
316 assert!(sourcemap_command.run().is_ok());
317
318 let raw_sourcemap_contents = fs_err::read_to_string(sourcemap_output.as_path()).unwrap();
319 let sourcemap_contents =
320 serde_json::from_str::<SourcemapNode>(&raw_sourcemap_contents).unwrap();
321 insta::assert_json_snapshot!(sourcemap_contents, {
322 ".**.filePaths" => insta::dynamic_redaction(|mut value, _path| {
323 let mut paths_count = 0;
324
325 match value {
326 Content::Seq(ref mut vec) => {
327 for path in vec.iter().map(|i| i.as_str().unwrap()) {
328 assert_eq!(fs_err::canonicalize(path).is_ok(), true, "path was not valid");
329 assert_eq!(Path::new(path).is_absolute(), true, "path was not absolute");
330
331 paths_count += 1;
332 }
333 }
334 _ => panic!("Expected filePaths to be a sequence"),
335 }
336 format!("[...{} path{} omitted...]", paths_count, if paths_count != 1 { "s" } else { "" } )
337 })
338 });
339 }
340}