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().unwrap();
91
92            loop {
93                let receiver = session.message_queue().subscribe(cursor);
94                let (new_cursor, _patch_set) = rt.block_on(receiver).unwrap();
95                cursor = new_cursor;
96
97                write_model(&session, &output_path, output_kind)?;
98            }
99        }
100
101        // Avoid dropping ServeSession: it's potentially VERY expensive to drop
102        // and we're about to exit anyways.
103        forget(session);
104
105        Ok(())
106    }
107}
108
109/// The different kinds of output that Rojo can build to.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum OutputKind {
112    /// An XML model file.
113    Rbxmx,
114
115    /// An XML place file.
116    Rbxlx,
117
118    /// A binary model file.
119    Rbxm,
120
121    /// A binary place file.
122    Rbxl,
123}
124
125impl OutputKind {
126    fn from_output_path(output: &Path) -> Option<OutputKind> {
127        let extension = output.extension()?.to_str()?;
128
129        match extension {
130            "rbxlx" => Some(OutputKind::Rbxlx),
131            "rbxmx" => Some(OutputKind::Rbxmx),
132            "rbxl" => Some(OutputKind::Rbxl),
133            "rbxm" => Some(OutputKind::Rbxm),
134            _ => None,
135        }
136    }
137
138    fn from_plugin_path(output: &Path) -> Option<OutputKind> {
139        let extension = output.extension()?.to_str()?;
140
141        match extension {
142            "rbxmx" => Some(OutputKind::Rbxmx),
143            "rbxm" => Some(OutputKind::Rbxm),
144            _ => None,
145        }
146    }
147}
148
149fn xml_encode_config() -> rbx_xml::EncodeOptions<'static> {
150    rbx_xml::EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown)
151}
152
153#[profiling::function]
154fn write_model(
155    session: &ServeSession,
156    output: &Path,
157    output_kind: OutputKind,
158) -> anyhow::Result<()> {
159    println!("Building project '{}'", session.project_name());
160
161    let tree = session.tree();
162    let root_id = tree.get_root_id();
163
164    log::trace!("Opening output file for write");
165    let mut file = BufWriter::new(File::create(output)?);
166
167    match output_kind {
168        OutputKind::Rbxm => {
169            rbx_binary::to_writer(&mut file, tree.inner(), &[root_id])?;
170        }
171        OutputKind::Rbxl => {
172            let root_instance = tree.get_instance(root_id).unwrap();
173            let top_level_ids = root_instance.children();
174
175            rbx_binary::to_writer(&mut file, tree.inner(), top_level_ids)?;
176        }
177        OutputKind::Rbxmx => {
178            // Model files include the root instance of the tree and all its
179            // descendants.
180
181            rbx_xml::to_writer(&mut file, tree.inner(), &[root_id], xml_encode_config())?;
182        }
183        OutputKind::Rbxlx => {
184            // Place files don't contain an entry for the DataModel, but our
185            // WeakDom representation does.
186
187            let root_instance = tree.get_instance(root_id).unwrap();
188            let top_level_ids = root_instance.children();
189
190            rbx_xml::to_writer(&mut file, tree.inner(), top_level_ids, xml_encode_config())?;
191        }
192    }
193
194    file.flush()?;
195
196    let filename = output
197        .file_name()
198        .and_then(|name| name.to_str())
199        .unwrap_or("<invalid utf-8>");
200    println!("Built project to {}", filename);
201
202    Ok(())
203}