Skip to main content

librojo/cli/
sourcemap.rs

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/// Representation of a node in the generated sourcemap tree.
28#[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/// Generates a sourcemap file from the Rojo project.
46#[derive(Debug, Parser)]
47pub struct SourcemapCommand {
48    /// Path to the project to use for the sourcemap. Defaults to the current
49    /// directory.
50    #[clap(default_value = "")]
51    pub project: PathBuf,
52
53    /// Where to output the sourcemap. Omit this to use stdout instead of
54    /// writing to a file.
55    ///
56    /// Should end in .json.
57    #[clap(long, short)]
58    pub output: Option<PathBuf>,
59
60    /// If non-script files should be included or not. Defaults to false.
61    #[clap(long)]
62    pub include_non_scripts: bool,
63
64    /// Whether to automatically recreate a snapshot when any input files change.
65    #[clap(long)]
66    pub watch: bool,
67
68    /// Whether the sourcemap should use absolute paths instead of relative paths.
69    #[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        // Pre-build a rayon threadpool with a low number of threads to avoid
92        // dynamic creation overhead on systems with a high number of cpus.
93        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                    // The message queue was dropped, so there is nothing left
111                    // to watch. Stop watching gracefully.
112                    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        // Avoid dropping ServeSession: it's potentially VERY expensive to drop
123        // and we're about to exit anyways.
124        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    // A sourcemap has probably changed when:
149    patch_set.par_iter().any(|set| {
150        // 1. An instance was removed, in which case it will no
151        // longer exist in the tree and we cant check the filter
152        !set.removed.is_empty()
153            // 2. A newly added instance passes the filter
154            || 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            // 3. An existing instance has its class name, name,
161            // or file paths changed, and passes the filter
162            || 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 this object has no children and doesn't pass the filter, it doesn't
196    // contain any information we're looking for.
197    if children.is_empty() && !filter(&instance) {
198        return None;
199    }
200
201    let file_paths = instance
202        .metadata()
203        .relevant_paths
204        .iter()
205        // Not all paths listed as relevant are guaranteed to exist.
206        .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        // It's somewhat important to note here that `path::absolute` takes in a Path and returns a PathBuf
214        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}