Skip to main content

librojo/cli/
build.rs

1use std::{
2    io::{BufWriter, Write},
3    mem::forget,
4    path::{Path, PathBuf},
5};
6
7use anyhow::{bail, Context};
8use clap::{CommandFactory, Parser};
9use fs_err::File;
10use memofs::Vfs;
11use roblox_install::RobloxStudio;
12use tokio::runtime::Runtime;
13
14use crate::serve_session::ServeSession;
15
16use super::resolve_path;
17
18const UNKNOWN_OUTPUT_KIND_ERR: &str = "Could not detect what kind of file to build. \
19                                       Expected output file to end in .rbxl, .rbxlx, .rbxm, or .rbxmx.";
20const UNKNOWN_PLUGIN_KIND_ERR: &str = "Could not detect what kind of file to build. \
21                                       Expected plugin file to end in .rbxm or .rbxmx.";
22
23/// Generates a model or place file from the Rojo project.
24#[derive(Debug, Parser)]
25pub struct BuildCommand {
26    /// Path to the project to build. Defaults to the current directory.
27    #[clap(default_value = "")]
28    pub project: PathBuf,
29
30    /// Where to output the result.
31    ///
32    /// Should end in .rbxm, .rbxl, .rbxmx, or .rbxlx.
33    #[clap(long, short, conflicts_with = "plugin")]
34    pub output: Option<PathBuf>,
35
36    /// Alternative to the output flag that outputs the result in the local plugins folder.
37    ///
38    /// Should end in .rbxm or .rbxl.
39    #[clap(long, short, conflicts_with = "output")]
40    pub plugin: Option<PathBuf>,
41
42    /// Whether to automatically rebuild when any input files change.
43    #[clap(long)]
44    pub watch: bool,
45}
46
47impl BuildCommand {
48    pub fn run(self) -> anyhow::Result<()> {
49        let (output_path, output_kind) = match (self.output, self.plugin) {
50            (None, None) => {
51                BuildCommand::command()
52                    .error(
53                        clap::ErrorKind::MissingRequiredArgument,
54                        "one of the following arguments must be provided: \n    --output <OUTPUT>\n    --plugin <PLUGIN>",
55                    )
56                    .exit();
57            }
58            (Some(output), None) => {
59                let output_kind =
60                    OutputKind::from_output_path(&output).context(UNKNOWN_OUTPUT_KIND_ERR)?;
61
62                (output, output_kind)
63            }
64            (None, Some(plugin)) => {
65                if plugin.is_absolute() {
66                    bail!("plugin flag path cannot be absolute.")
67                }
68
69                let output_kind =
70                    OutputKind::from_plugin_path(&plugin).context(UNKNOWN_PLUGIN_KIND_ERR)?;
71                let studio = RobloxStudio::locate()?;
72
73                (studio.plugins_path().join(&plugin), output_kind)
74            }
75            _ => unreachable!(),
76        };
77
78        let project_path = resolve_path(&self.project)?;
79
80        log::trace!("Constructing in-memory filesystem");
81        let vfs = Vfs::new_default()?;
82        vfs.set_watch_enabled(self.watch);
83
84        let session = ServeSession::new(vfs, project_path)?;
85        let mut cursor = session.message_queue().cursor();
86
87        write_model(&session, &output_path, output_kind)?;
88
89        if self.watch {
90            let rt = Runtime::new().context("Failed to start the async runtime for watch mode")?;
91
92            loop {
93                let receiver = session.message_queue().subscribe(cursor);
94                let (new_cursor, _patch_set) = match rt.block_on(receiver) {
95                    Ok(message) => message,
96                    // The message queue was dropped, so there is nothing left
97                    // to watch. Stop watching gracefully.
98                    Err(_) => break,
99                };
100                cursor = new_cursor;
101
102                write_model(&session, &output_path, output_kind)?;
103            }
104        }
105
106        // Avoid dropping ServeSession: it's potentially VERY expensive to drop
107        // and we're about to exit anyways.
108        forget(session);
109
110        Ok(())
111    }
112}
113
114/// The different kinds of output that Rojo can build to.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum OutputKind {
117    /// An XML model file.
118    Rbxmx,
119
120    /// An XML place file.
121    Rbxlx,
122
123    /// A binary model file.
124    Rbxm,
125
126    /// A binary place file.
127    Rbxl,
128}
129
130impl OutputKind {
131    fn from_output_path(output: &Path) -> Option<OutputKind> {
132        let extension = output.extension()?.to_str()?;
133
134        match extension {
135            "rbxlx" => Some(OutputKind::Rbxlx),
136            "rbxmx" => Some(OutputKind::Rbxmx),
137            "rbxl" => Some(OutputKind::Rbxl),
138            "rbxm" => Some(OutputKind::Rbxm),
139            _ => None,
140        }
141    }
142
143    fn from_plugin_path(output: &Path) -> Option<OutputKind> {
144        let extension = output.extension()?.to_str()?;
145
146        match extension {
147            "rbxmx" => Some(OutputKind::Rbxmx),
148            "rbxm" => Some(OutputKind::Rbxm),
149            _ => None,
150        }
151    }
152}
153
154fn xml_encode_config() -> rbx_xml::EncodeOptions<'static> {
155    rbx_xml::EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown)
156}
157
158#[profiling::function]
159fn write_model(
160    session: &ServeSession,
161    output: &Path,
162    output_kind: OutputKind,
163) -> anyhow::Result<()> {
164    println!("Building project '{}'", session.project_name());
165
166    let tree = session.tree();
167    let root_id = tree.get_root_id();
168
169    log::trace!("Opening output file for write");
170    let mut file = BufWriter::new(File::create(output)?);
171
172    match output_kind {
173        OutputKind::Rbxm => {
174            rbx_binary::to_writer(&mut file, tree.inner(), &[root_id])?;
175        }
176        OutputKind::Rbxl => {
177            let root_instance = tree.get_instance(root_id).unwrap();
178            let top_level_ids = root_instance.children();
179
180            rbx_binary::to_writer(&mut file, tree.inner(), top_level_ids)?;
181        }
182        OutputKind::Rbxmx => {
183            // Model files include the root instance of the tree and all its
184            // descendants.
185
186            rbx_xml::to_writer(&mut file, tree.inner(), &[root_id], xml_encode_config())?;
187        }
188        OutputKind::Rbxlx => {
189            // Place files don't contain an entry for the DataModel, but our
190            // WeakDom representation does.
191
192            let root_instance = tree.get_instance(root_id).unwrap();
193            let top_level_ids = root_instance.children();
194
195            rbx_xml::to_writer(&mut file, tree.inner(), top_level_ids, xml_encode_config())?;
196        }
197    }
198
199    file.flush()?;
200
201    let filename = output
202        .file_name()
203        .and_then(|name| name.to_str())
204        .unwrap_or("<invalid utf-8>");
205    println!("Built project to {}", filename);
206
207    Ok(())
208}