Skip to main content

librojo/snapshot_middleware/
mod.rs

1//! Defines the semantics that Rojo uses to turn entries on the filesystem into
2//! Roblox instances using the instance snapshot subsystem.
3//!
4//! These modules define how files turn into instances.
5
6#![allow(dead_code)]
7
8mod csv;
9mod dir;
10mod json;
11mod json_model;
12mod lua;
13mod meta_file;
14mod project;
15mod rbxm;
16mod rbxmx;
17mod toml;
18mod txt;
19mod util;
20mod yaml;
21
22use std::{
23    path::{Path, PathBuf},
24    sync::OnceLock,
25};
26
27use anyhow::Context;
28use memofs::{IoResultExt, Vfs};
29use serde::{Deserialize, Serialize};
30
31use crate::{
32    glob::Glob,
33    project::DEFAULT_PROJECT_NAMES,
34    syncback::{SyncbackReturn, SyncbackSnapshot},
35};
36use crate::{
37    snapshot::{InstanceContext, InstanceSnapshot, SyncRule},
38    syncback::validate_file_name,
39};
40
41use self::{
42    csv::{snapshot_csv, snapshot_csv_init, syncback_csv, syncback_csv_init},
43    dir::{snapshot_dir, syncback_dir},
44    json::snapshot_json,
45    json_model::{snapshot_json_model, syncback_json_model},
46    lua::{snapshot_lua, snapshot_lua_init, syncback_lua, syncback_lua_init},
47    project::{snapshot_project, syncback_project},
48    rbxm::{snapshot_rbxm, syncback_rbxm},
49    rbxmx::{snapshot_rbxmx, syncback_rbxmx},
50    toml::snapshot_toml,
51    txt::{snapshot_txt, syncback_txt},
52    yaml::snapshot_yaml,
53};
54
55pub use self::{
56    lua::ScriptType, project::snapshot_project_node, util::emit_legacy_scripts_default,
57    util::PathExt,
58};
59
60/// Returns an `InstanceSnapshot` for the provided path.
61/// This will inspect the path and find the appropriate middleware for it,
62/// taking user-written rules into account. Then, it will attempt to convert
63/// the path into an InstanceSnapshot using that middleware.
64#[profiling::function]
65pub fn snapshot_from_vfs(
66    context: &InstanceContext,
67    vfs: &Vfs,
68    path: &Path,
69) -> anyhow::Result<Option<InstanceSnapshot>> {
70    let meta = match vfs.metadata(path).with_not_found()? {
71        Some(meta) => meta,
72        None => return Ok(None),
73    };
74
75    if meta.is_dir() {
76        let (middleware, dir_name, init_path) = get_dir_middleware(vfs, path)?;
77        // TODO: Support user defined init paths
78        // If and when we do, make sure to go support it in
79        // `Project::set_file_name`, as right now it special-cases
80        // `default.project.json` as an `init` path.
81        match middleware {
82            Middleware::Dir => middleware.snapshot(context, vfs, path, dir_name),
83            _ => middleware.snapshot(context, vfs, &init_path, dir_name),
84        }
85    } else {
86        let file_name = path
87            .file_name()
88            .and_then(|n| n.to_str())
89            .with_context(|| format!("file name of {} is invalid", path.display()))?;
90
91        // TODO: Is this even necessary anymore?
92        match file_name {
93            "init.server.luau" | "init.server.lua" | "init.client.luau" | "init.client.lua"
94            | "init.plugin.luau" | "init.plugin.lua" | "init.luau" | "init.lua" | "init.csv" => {
95                return Ok(None)
96            }
97            _ => {}
98        }
99
100        snapshot_from_path(context, vfs, path)
101    }
102}
103
104/// Gets the appropriate middleware for a directory by checking for `init`
105/// files. This uses an intrinsic priority list and for compatibility,
106/// that order should be left unchanged.
107///
108/// Returns the middleware, the name of the directory, and the path to
109/// the init location.
110fn get_dir_middleware<'path>(
111    vfs: &Vfs,
112    dir_path: &'path Path,
113) -> anyhow::Result<(Middleware, &'path str, PathBuf)> {
114    let dir_name = dir_path
115        .file_name()
116        .expect("Could not extract directory name")
117        .to_str()
118        .ok_or_else(|| anyhow::anyhow!("File name was not valid UTF-8: {}", dir_path.display()))?;
119
120    static INIT_PATHS: OnceLock<Vec<(Middleware, &str)>> = OnceLock::new();
121    let order = INIT_PATHS.get_or_init(|| {
122        vec![
123            (Middleware::ModuleScriptDir, "init.luau"),
124            (Middleware::ModuleScriptDir, "init.lua"),
125            (Middleware::ServerScriptDir, "init.server.luau"),
126            (Middleware::ServerScriptDir, "init.server.lua"),
127            (Middleware::ClientScriptDir, "init.client.luau"),
128            (Middleware::ClientScriptDir, "init.client.lua"),
129            (Middleware::PluginScriptDir, "init.plugin.lua"),
130            (Middleware::PluginScriptDir, "init.plugin.luau"),
131            (Middleware::CsvDir, "init.csv"),
132        ]
133    });
134
135    for default_project_name in DEFAULT_PROJECT_NAMES {
136        let project_path = dir_path.join(default_project_name);
137        if vfs.metadata(&project_path).with_not_found()?.is_some() {
138            return Ok((Middleware::Project, dir_name, project_path));
139        }
140    }
141
142    for (middleware, name) in order {
143        let test_path = dir_path.join(name);
144        if vfs.metadata(&test_path).with_not_found()?.is_some() {
145            return Ok((*middleware, dir_name, test_path));
146        }
147    }
148
149    Ok((Middleware::Dir, dir_name, dir_path.to_path_buf()))
150}
151
152/// Gets a snapshot for a path given an InstanceContext and Vfs, taking
153/// user specified sync rules into account.
154fn snapshot_from_path(
155    context: &InstanceContext,
156    vfs: &Vfs,
157    path: &Path,
158) -> anyhow::Result<Option<InstanceSnapshot>> {
159    if let Some(rule) = context.get_user_sync_rule(path) {
160        return rule
161            .middleware
162            .snapshot(context, vfs, path, rule.file_name_for_path(path)?);
163    } else {
164        for rule in default_sync_rules() {
165            if rule.matches(path) {
166                return rule.middleware.snapshot(
167                    context,
168                    vfs,
169                    path,
170                    rule.file_name_for_path(path)?,
171                );
172            }
173        }
174    }
175    Ok(None)
176}
177
178/// Represents a possible 'transformer' used by Rojo to turn a file system
179/// item into a Roblox Instance. Missing from this list is metadata.
180/// This is deliberate, as metadata is not a snapshot middleware.
181///
182/// Directories cannot be used for sync rules so they're ignored by Serde.
183#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
184#[serde(rename_all = "camelCase")]
185pub enum Middleware {
186    Csv,
187    JsonModel,
188    Json,
189    ServerScript,
190    ClientScript,
191    ModuleScript,
192    PluginScript,
193    LegacyClientScript,
194    LegacyServerScript,
195    RunContextServerScript,
196    RunContextClientScript,
197    Project,
198    Rbxm,
199    Rbxmx,
200    Toml,
201    Text,
202    Yaml,
203    Ignore,
204
205    #[serde(skip_deserializing)]
206    Dir,
207    #[serde(skip_deserializing)]
208    ServerScriptDir,
209    #[serde(skip_deserializing)]
210    ClientScriptDir,
211    #[serde(skip_deserializing)]
212    PluginScriptDir,
213    #[serde(skip_deserializing)]
214    ModuleScriptDir,
215    #[serde(skip_deserializing)]
216    CsvDir,
217}
218
219impl Middleware {
220    /// Creates a snapshot for the given path from the Middleware with
221    /// the provided name.
222    fn snapshot(
223        &self,
224        context: &InstanceContext,
225        vfs: &Vfs,
226        path: &Path,
227        name: &str,
228    ) -> anyhow::Result<Option<InstanceSnapshot>> {
229        let mut output = match self {
230            Self::Csv => snapshot_csv(context, vfs, path, name),
231            Self::JsonModel => snapshot_json_model(context, vfs, path, name),
232            Self::Json => snapshot_json(context, vfs, path, name),
233            Self::ServerScript => snapshot_lua(context, vfs, path, name, ScriptType::Server),
234            Self::ClientScript => snapshot_lua(context, vfs, path, name, ScriptType::Client),
235            Self::ModuleScript => snapshot_lua(context, vfs, path, name, ScriptType::Module),
236            Self::PluginScript => snapshot_lua(context, vfs, path, name, ScriptType::Plugin),
237            Self::LegacyClientScript => {
238                snapshot_lua(context, vfs, path, name, ScriptType::LegacyClient)
239            }
240            Self::LegacyServerScript => {
241                snapshot_lua(context, vfs, path, name, ScriptType::LegacyServer)
242            }
243            Self::RunContextClientScript => {
244                snapshot_lua(context, vfs, path, name, ScriptType::RunContextClient)
245            }
246            Self::RunContextServerScript => {
247                snapshot_lua(context, vfs, path, name, ScriptType::RunContextServer)
248            }
249            Self::Project => snapshot_project(context, vfs, path, name),
250            Self::Rbxm => snapshot_rbxm(context, vfs, path, name),
251            Self::Rbxmx => snapshot_rbxmx(context, vfs, path, name),
252            Self::Toml => snapshot_toml(context, vfs, path, name),
253            Self::Text => snapshot_txt(context, vfs, path, name),
254            Self::Yaml => snapshot_yaml(context, vfs, path, name),
255            Self::Ignore => Ok(None),
256
257            Self::Dir => snapshot_dir(context, vfs, path, name),
258            Self::ServerScriptDir => {
259                snapshot_lua_init(context, vfs, path, name, ScriptType::Server)
260            }
261            Self::ClientScriptDir => {
262                snapshot_lua_init(context, vfs, path, name, ScriptType::Client)
263            }
264            Self::PluginScriptDir => {
265                snapshot_lua_init(context, vfs, path, name, ScriptType::Plugin)
266            }
267            Self::ModuleScriptDir => {
268                snapshot_lua_init(context, vfs, path, name, ScriptType::Module)
269            }
270            Self::CsvDir => snapshot_csv_init(context, vfs, path, name),
271        };
272        if let Ok(Some(ref mut snapshot)) = output {
273            snapshot.metadata.middleware = Some(*self);
274        }
275        output
276    }
277
278    /// Runs the syncback mechanism for the provided middleware given a
279    /// SyncbackSnapshot.
280    pub fn syncback<'sync>(
281        &self,
282        snapshot: &SyncbackSnapshot<'sync>,
283    ) -> anyhow::Result<SyncbackReturn<'sync>> {
284        let file_name = snapshot.path.file_name().and_then(|s| s.to_str());
285        if let Some(file_name) = file_name {
286            validate_file_name(file_name).with_context(|| {
287                format!("cannot create a file or directory with name {file_name}")
288            })?;
289        }
290        match self {
291            Middleware::Csv => syncback_csv(snapshot),
292            Middleware::JsonModel => syncback_json_model(snapshot),
293            Middleware::Json => anyhow::bail!("cannot syncback Json middleware"),
294            // Projects are only generated from files that already exist on the
295            // file system, so we don't need to pass a file name.
296            Middleware::Project => syncback_project(snapshot),
297            Middleware::ServerScript => syncback_lua(snapshot),
298            Middleware::ClientScript => syncback_lua(snapshot),
299            Middleware::ModuleScript => syncback_lua(snapshot),
300            Middleware::Rbxm => syncback_rbxm(snapshot),
301            Middleware::Rbxmx => syncback_rbxmx(snapshot),
302            Middleware::Toml => anyhow::bail!("cannot syncback Toml middleware"),
303            Middleware::Text => syncback_txt(snapshot),
304            Middleware::Yaml => anyhow::bail!("cannot syncback Yaml middleware"),
305            Middleware::Ignore => anyhow::bail!("cannot syncback Ignore middleware"),
306            Middleware::Dir => syncback_dir(snapshot),
307            Middleware::ServerScriptDir => syncback_lua_init(ScriptType::Server, snapshot),
308            Middleware::ClientScriptDir => syncback_lua_init(ScriptType::Client, snapshot),
309            Middleware::PluginScriptDir => syncback_lua_init(ScriptType::Plugin, snapshot),
310            Middleware::ModuleScriptDir => syncback_lua_init(ScriptType::Module, snapshot),
311            Middleware::CsvDir => syncback_csv_init(snapshot),
312
313            Middleware::PluginScript
314            | Middleware::LegacyServerScript
315            | Middleware::LegacyClientScript
316            | Middleware::RunContextServerScript
317            | Middleware::RunContextClientScript => {
318                anyhow::bail!("syncback is not implemented for {self:?} yet")
319            }
320        }
321    }
322
323    /// Returns whether this particular middleware would become a directory.
324    #[inline]
325    pub fn is_dir(&self) -> bool {
326        matches!(
327            self,
328            Middleware::Dir
329                | Middleware::ServerScriptDir
330                | Middleware::ClientScriptDir
331                | Middleware::PluginScriptDir
332                | Middleware::ModuleScriptDir
333                | Middleware::CsvDir
334        )
335    }
336
337    /// Returns whether this particular middleware sets its own properties.
338    /// This applies to things like `JsonModel` and `Project`, since they
339    /// set properties without needing a meta.json file.
340    ///
341    /// It does not cover middleware like `ServerScript` or `Csv` because they
342    /// need a meta.json file to set properties that aren't their designated
343    /// 'special' properties.
344    #[inline]
345    pub fn handles_own_properties(&self) -> bool {
346        matches!(
347            self,
348            Middleware::JsonModel | Middleware::Project | Middleware::Rbxm | Middleware::Rbxmx
349        )
350    }
351
352    /// Attempts to return a middleware that should be used for the given path.
353    ///
354    /// Returns `Err` only if the Vfs cannot read information about the path.
355    pub fn middleware_for_path(
356        vfs: &Vfs,
357        sync_rules: &[SyncRule],
358        path: &Path,
359    ) -> anyhow::Result<Option<Self>> {
360        let meta = match vfs.metadata(path).with_not_found()? {
361            Some(meta) => meta,
362            None => return Ok(None),
363        };
364
365        if meta.is_dir() {
366            let (middleware, _, _) = get_dir_middleware(vfs, path)?;
367            Ok(Some(middleware))
368        } else {
369            for rule in sync_rules.iter().chain(default_sync_rules()) {
370                if rule.matches(path) {
371                    return Ok(Some(rule.middleware));
372                }
373            }
374            Ok(None)
375        }
376    }
377}
378
379/// A helper for easily defining a SyncRule. Arguments are passed literally
380/// to this macro in the order `include`, `middleware`, `suffix`,
381/// and `exclude`. Both `suffix` and `exclude` are optional.
382///
383/// All arguments except `middleware` are expected to be strings.
384/// The `middleware` parameter is expected to be a variant of `Middleware`,
385/// not including the enum name itself.
386macro_rules! sync_rule {
387    ($pattern:expr, $middleware:ident) => {
388        SyncRule {
389            middleware: Middleware::$middleware,
390            include: Glob::new($pattern).unwrap(),
391            exclude: None,
392            suffix: None,
393            base_path: PathBuf::new(),
394        }
395    };
396    ($pattern:expr, $middleware:ident, $suffix:expr) => {
397        SyncRule {
398            middleware: Middleware::$middleware,
399            include: Glob::new($pattern).unwrap(),
400            exclude: None,
401            suffix: Some($suffix.into()),
402            base_path: PathBuf::new(),
403        }
404    };
405    ($pattern:expr, $middleware:ident, $suffix:expr, $exclude:expr) => {
406        SyncRule {
407            middleware: Middleware::$middleware,
408            include: Glob::new($pattern).unwrap(),
409            exclude: Some(Glob::new($exclude).unwrap()),
410            suffix: Some($suffix.into()),
411            base_path: PathBuf::new(),
412        }
413    };
414}
415
416/// Defines the 'default' syncing rules that Rojo uses.
417/// These do not broadly overlap, but the order matters for some in the case of
418/// e.g. JSON models.
419pub fn default_sync_rules() -> &'static [SyncRule] {
420    static DEFAULT_SYNC_RULES: OnceLock<Vec<SyncRule>> = OnceLock::new();
421
422    DEFAULT_SYNC_RULES.get_or_init(|| {
423        vec![
424            sync_rule!("*.server.lua", ServerScript, ".server.lua"),
425            sync_rule!("*.server.luau", ServerScript, ".server.luau"),
426            sync_rule!("*.client.lua", ClientScript, ".client.lua"),
427            sync_rule!("*.client.luau", ClientScript, ".client.luau"),
428            sync_rule!("*.plugin.lua", PluginScript, ".plugin.lua"),
429            sync_rule!("*.plugin.luau", PluginScript, ".plugin.luau"),
430            sync_rule!("*.{lua,luau}", ModuleScript),
431            sync_rule!("*.project.json", Project, ".project.json"),
432            sync_rule!("*.project.jsonc", Project, ".project.jsonc"),
433            sync_rule!("*.model.json", JsonModel, ".model.json"),
434            sync_rule!("*.model.jsonc", JsonModel, ".model.jsonc"),
435            sync_rule!("*.json", Json, ".json", "*.meta.json"),
436            sync_rule!("*.jsonc", Json, ".jsonc", "*.meta.jsonc"),
437            sync_rule!("*.toml", Toml),
438            sync_rule!("*.csv", Csv),
439            sync_rule!("*.txt", Text),
440            sync_rule!("*.rbxmx", Rbxmx),
441            sync_rule!("*.rbxm", Rbxm),
442            sync_rule!("*.{yml,yaml}", Yaml),
443        ]
444    })
445}