librojo/cli/
sourcemap.rs

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