librojo/cli/
sourcemap.rs

1use std::{
2    io::{BufWriter, Write},
3    mem::forget,
4    path::{Path, PathBuf},
5};
6
7use clap::Parser;
8use fs_err::File;
9use memofs::Vfs;
10use rayon::prelude::*;
11use rbx_dom_weak::{types::Ref, Ustr};
12use serde::Serialize;
13use tokio::runtime::Runtime;
14
15use crate::{
16    serve_session::ServeSession,
17    snapshot::{AppliedPatchSet, InstanceWithMeta, RojoTree},
18};
19
20use super::resolve_path;
21
22const PATH_STRIP_FAILED_ERR: &str = "Failed to create relative paths for project file!";
23
24/// Representation of a node in the generated sourcemap tree.
25#[derive(Serialize)]
26#[serde(rename_all = "camelCase")]
27struct SourcemapNode<'a> {
28    name: &'a str,
29    class_name: Ustr,
30
31    #[serde(skip_serializing_if = "Vec::is_empty")]
32    file_paths: Vec<PathBuf>,
33
34    #[serde(skip_serializing_if = "Vec::is_empty")]
35    children: Vec<SourcemapNode<'a>>,
36}
37
38/// Generates a sourcemap file from the Rojo project.
39#[derive(Debug, Parser)]
40pub struct SourcemapCommand {
41    /// Path to the project to use for the sourcemap. Defaults to the current
42    /// directory.
43    #[clap(default_value = "")]
44    pub project: PathBuf,
45
46    /// Where to output the sourcemap. Omit this to use stdout instead of
47    /// writing to a file.
48    ///
49    /// Should end in .json.
50    #[clap(long, short)]
51    pub output: Option<PathBuf>,
52
53    /// If non-script files should be included or not. Defaults to false.
54    #[clap(long)]
55    pub include_non_scripts: bool,
56
57    /// Whether to automatically recreate a snapshot when any input files change.
58    #[clap(long)]
59    pub watch: bool,
60}
61
62impl SourcemapCommand {
63    pub fn run(self) -> anyhow::Result<()> {
64        let project_path = resolve_path(&self.project);
65
66        log::trace!("Constructing in-memory filesystem");
67        let vfs = Vfs::new_default();
68        vfs.set_watch_enabled(self.watch);
69
70        let session = ServeSession::new(vfs, project_path)?;
71        let mut cursor = session.message_queue().cursor();
72
73        let filter = if self.include_non_scripts {
74            filter_nothing
75        } else {
76            filter_non_scripts
77        };
78
79        // Pre-build a rayon threadpool with a low number of threads to avoid
80        // dynamic creation overhead on systems with a high number of cpus.
81        rayon::ThreadPoolBuilder::new()
82            .num_threads(num_cpus::get().min(6))
83            .build_global()
84            .unwrap();
85
86        write_sourcemap(&session, self.output.as_deref(), filter)?;
87
88        if self.watch {
89            let rt = Runtime::new().unwrap();
90
91            loop {
92                let receiver = session.message_queue().subscribe(cursor);
93                let (new_cursor, patch_set) = rt.block_on(receiver).unwrap();
94                cursor = new_cursor;
95
96                if patch_set_affects_sourcemap(&session, &patch_set, filter) {
97                    write_sourcemap(&session, self.output.as_deref(), filter)?;
98                }
99            }
100        }
101
102        // Avoid dropping ServeSession: it's potentially VERY expensive to drop
103        // and we're about to exit anyways.
104        forget(session);
105
106        Ok(())
107    }
108}
109
110fn filter_nothing(_instance: &InstanceWithMeta) -> bool {
111    true
112}
113
114fn filter_non_scripts(instance: &InstanceWithMeta) -> bool {
115    matches!(
116        instance.class_name().as_str(),
117        "Script" | "LocalScript" | "ModuleScript"
118    )
119}
120
121fn patch_set_affects_sourcemap(
122    session: &ServeSession,
123    patch_set: &[AppliedPatchSet],
124    filter: fn(&InstanceWithMeta) -> bool,
125) -> bool {
126    let tree = session.tree();
127
128    // A sourcemap has probably changed when:
129    patch_set.par_iter().any(|set| {
130        // 1. An instance was removed, in which case it will no
131        // longer exist in the tree and we cant check the filter
132        !set.removed.is_empty()
133            // 2. A newly added instance passes the filter
134            || set.added.iter().any(|referent| {
135                let instance = tree
136                    .get_instance(*referent)
137                    .expect("instance did not exist when updating sourcemap");
138                filter(&instance)
139            })
140            // 3. An existing instance has its class name, name,
141            // or file paths changed, and passes the filter
142            || set.updated.iter().any(|updated| {
143                let changed = updated.changed_class_name.is_some()
144                    || updated.changed_name.is_some()
145                    || updated.changed_metadata.is_some();
146                if changed {
147                    let instance = tree
148                        .get_instance(updated.id)
149                        .expect("instance did not exist when updating sourcemap");
150                    filter(&instance)
151                } else {
152                    false
153                }
154            })
155    })
156}
157
158fn recurse_create_node<'a>(
159    tree: &'a RojoTree,
160    referent: Ref,
161    project_dir: &Path,
162    filter: fn(&InstanceWithMeta) -> bool,
163) -> Option<SourcemapNode<'a>> {
164    let instance = tree.get_instance(referent).expect("instance did not exist");
165
166    let children: Vec<_> = instance
167        .children()
168        .par_iter()
169        .filter_map(|&child_id| recurse_create_node(tree, child_id, project_dir, filter))
170        .collect();
171
172    // If this object has no children and doesn't pass the filter, it doesn't
173    // contain any information we're looking for.
174    if children.is_empty() && !filter(&instance) {
175        return None;
176    }
177
178    let file_paths = instance
179        .metadata()
180        .relevant_paths
181        .iter()
182        // Not all paths listed as relevant are guaranteed to exist.
183        .filter(|path| path.is_file())
184        .map(|path| path.strip_prefix(project_dir).expect(PATH_STRIP_FAILED_ERR))
185        .map(|path| path.to_path_buf())
186        .collect();
187
188    Some(SourcemapNode {
189        name: instance.name(),
190        class_name: instance.class_name(),
191        file_paths,
192        children,
193    })
194}
195
196fn write_sourcemap(
197    session: &ServeSession,
198    output: Option<&Path>,
199    filter: fn(&InstanceWithMeta) -> bool,
200) -> anyhow::Result<()> {
201    let tree = session.tree();
202
203    let root_node = recurse_create_node(&tree, tree.get_root_id(), session.root_dir(), filter);
204
205    if let Some(output_path) = output {
206        let mut file = BufWriter::new(File::create(output_path)?);
207        serde_json::to_writer(&mut file, &root_node)?;
208        file.flush()?;
209
210        println!("Created sourcemap at {}", output_path.display());
211    } else {
212        let output = serde_json::to_string(&root_node)?;
213        println!("{}", output);
214    }
215
216    Ok(())
217}