Skip to main content

librojo/
project.rs

1use std::{
2    collections::{BTreeMap, HashSet},
3    ffi::OsStr,
4    fs, io,
5    net::IpAddr,
6    path::{Path, PathBuf},
7};
8
9use memofs::Vfs;
10use rbx_dom_weak::Ustr;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use crate::{
15    glob::IgnorableGlob, json, resolution::UnresolvedValue, snapshot::SyncRule,
16    syncback::SyncbackRules,
17};
18
19/// Represents 'default' project names that act as `init` files
20pub static DEFAULT_PROJECT_NAMES: [&str; 2] = ["default.project.json", "default.project.jsonc"];
21
22/// Error type returned by any function that handles projects.
23#[derive(Debug, Error)]
24#[error(transparent)]
25pub struct ProjectError(#[from] Error);
26
27#[derive(Debug, Error)]
28enum Error {
29    #[error(
30        "Rojo requires a project file, but no project file was found in path {}\n\
31        See https://rojo.space/docs/ for guides and documentation.",
32        .path.display()
33    )]
34    NoProjectFound { path: PathBuf },
35
36    #[error("The folder for the provided project cannot be used as a project name: {}\n\
37            Consider setting the `name` field on this project.", .path.display())]
38    FolderNameInvalid { path: PathBuf },
39
40    #[error("The file name of the provided project cannot be used as a project name: {}.\n\
41            Consider setting the `name` field on this project.", .path.display())]
42    ProjectNameInvalid { path: PathBuf },
43
44    #[error(transparent)]
45    Io {
46        #[from]
47        source: io::Error,
48    },
49
50    #[error("Error parsing Rojo project in path {}", .path.display())]
51    Json {
52        source: serde_json::Error,
53        path: PathBuf,
54    },
55}
56
57/// Contains all of the configuration for a Rojo-managed project.
58///
59/// Project files are stored in `.project.json` files.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields, rename_all = "camelCase")]
62pub struct Project {
63    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
64    schema: Option<String>,
65
66    /// The name of the top-level instance described by the project.
67    pub name: Option<String>,
68
69    /// The tree of instances described by this project. Projects always
70    /// describe at least one instance.
71    pub tree: ProjectNode,
72
73    /// If specified, sets the default port that `rojo serve` should use when
74    /// using this project for live sync.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub serve_port: Option<u16>,
77
78    /// If specified, contains the set of place IDs that this project is
79    /// compatible with when doing live sync.
80    ///
81    /// This setting is intended to help prevent syncing a Rojo project into the
82    /// wrong Roblox place.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub serve_place_ids: Option<HashSet<u64>>,
85
86    /// If specified, contains a set of place IDs that this project is
87    /// not compatible with when doing live sync.
88    ///
89    /// This setting is intended to help prevent syncing a Rojo project into the
90    /// wrong Roblox place.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub blocked_place_ids: Option<HashSet<u64>>,
93
94    /// If specified, sets the current place's place ID when connecting to the
95    /// Rojo server from Roblox Studio.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub place_id: Option<u64>,
98
99    /// If specified, sets the current place's game ID when connecting to the
100    /// Rojo server from Roblox Studio.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub game_id: Option<u64>,
103
104    /// If specified, this address will be used in place of the default address
105    /// As long as --address is unprovided.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub serve_address: Option<IpAddr>,
108
109    /// Additional `Host`/`Origin` header values that `rojo serve` will accept
110    /// beyond `localhost` and the bind address, such as a hostname like
111    /// `mypc.lan` used to reach a network-exposed server by name. Listing any
112    /// host also turns on `Host`/`Origin` validation for binds where it is
113    /// otherwise off (such as `0.0.0.0`). The `--allowed-hosts` CLI option
114    /// overrides this field when provided.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub serve_allowed_hosts: Vec<String>,
117
118    /// Determines if Rojo should emit scripts with the appropriate `RunContext`
119    /// for `*.client.lua` and `*.server.lua` files in the project instead of
120    /// using `Script` and `LocalScript` Instances.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub emit_legacy_scripts: Option<bool>,
123
124    /// A list of globs, relative to the folder the project file is in, that
125    /// match files that should be excluded if Rojo encounters them.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub glob_ignore_paths: Vec<IgnorableGlob>,
128
129    /// A list of rules for syncback with this project file.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub syncback_rules: Option<SyncbackRules>,
132
133    /// A list of mappings of globs to syncing rules. If a file matches a glob,
134    /// it will be 'transformed' into an Instance following the rule provided.
135    /// Globs are relative to the folder the project file is in.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub sync_rules: Vec<SyncRule>,
138
139    /// The path to the file that this project came from. Relative paths in the
140    /// project should be considered relative to the parent of this field, also
141    /// given by `Project::folder_location`.
142    #[serde(skip)]
143    pub file_location: PathBuf,
144}
145
146impl Project {
147    /// Tells whether the given path describes a Rojo project.
148    pub fn is_project_file(path: &Path) -> bool {
149        path.file_name()
150            .and_then(|name| name.to_str())
151            .map(|name| name.ends_with(".project.json") || name.ends_with(".project.jsonc"))
152            .unwrap_or(false)
153    }
154
155    /// Attempt to locate a project represented by the given path.
156    ///
157    /// This will find a project if the path refers to a `.project.json` file,
158    /// or is a folder that contains a `default.project.json` file.
159    fn locate(path: &Path) -> Option<PathBuf> {
160        let meta = fs::metadata(path).ok()?;
161
162        if meta.is_file() {
163            if Project::is_project_file(path) {
164                Some(path.to_path_buf())
165            } else {
166                None
167            }
168        } else {
169            for filename in DEFAULT_PROJECT_NAMES {
170                let child_path = path.join(filename);
171                let child_meta = fs::metadata(&child_path).ok()?;
172
173                if child_meta.is_file() {
174                    return Some(child_path);
175                }
176            }
177            // This is a folder with the same name as a Rojo default project
178            // file.
179            //
180            // That's pretty weird, but we can roll with it.
181            None
182        }
183    }
184
185    /// Sets the name of a project. The order it handles is as follows:
186    ///
187    /// - If the project is a `default.project.json`, uses the folder's name
188    /// - If a fallback is specified, uses that blindly
189    /// - Otherwise, loops through sync rules (including the default ones!) and
190    ///   uses the name of the first one that matches and is a project file
191    fn set_file_name(&mut self, fallback: Option<&str>) -> Result<(), Error> {
192        let file_name = self
193            .file_location
194            .file_name()
195            .and_then(OsStr::to_str)
196            .ok_or_else(|| Error::ProjectNameInvalid {
197                path: self.file_location.clone(),
198            })?;
199
200        // If you're editing this to be generic, make sure you also alter the
201        // snapshot middleware to support generic init paths.
202        for default_file_name in DEFAULT_PROJECT_NAMES {
203            if file_name == default_file_name {
204                let folder_name = self.folder_location().file_name().and_then(OsStr::to_str);
205                if let Some(folder_name) = folder_name {
206                    self.name = Some(folder_name.to_string());
207                    return Ok(());
208                } else {
209                    return Err(Error::FolderNameInvalid {
210                        path: self.file_location.clone(),
211                    });
212                }
213            }
214        }
215        if let Some(fallback) = fallback {
216            self.name = Some(fallback.to_string());
217        } else {
218            // As of the time of writing (July 10, 2024) there is no way for
219            // this code path to be reachable. It can in theory be reached from
220            // both `load_fuzzy` and `load_exact` but in practice it's never
221            // invoked.
222            // If you're adding this codepath, make sure a test for it exists
223            // and that it handles sync rules appropriately.
224            todo!(
225                "set_file_name doesn't support loading project files that aren't default.project.json without a fallback provided"
226            );
227        }
228
229        Ok(())
230    }
231
232    /// Loads a Project file from the provided contents with its source set as
233    /// the provided location.
234    fn load_from_slice(
235        contents: &[u8],
236        project_file_location: PathBuf,
237        fallback_name: Option<&str>,
238    ) -> Result<Self, Error> {
239        let mut project: Self = json::from_slice(contents).map_err(|e| Error::Json {
240            source: serde_json::Error::io(std::io::Error::new(
241                std::io::ErrorKind::InvalidData,
242                e.to_string(),
243            )),
244            path: project_file_location.clone(),
245        })?;
246        project.file_location = project_file_location;
247        project.check_compatibility();
248        if project.name.is_none() {
249            project.set_file_name(fallback_name)?;
250        }
251
252        Ok(project)
253    }
254
255    /// Loads a Project from a path. This will find the project if it refers to
256    /// a `.project.json` file or if it refers to a directory that contains a
257    /// file named `default.project.json`.
258    pub fn load_fuzzy(
259        vfs: &Vfs,
260        fuzzy_project_location: &Path,
261    ) -> Result<Option<Self>, ProjectError> {
262        if let Some(project_path) = Self::locate(fuzzy_project_location) {
263            let contents = vfs.read(&project_path).map_err(|e| match e.kind() {
264                io::ErrorKind::NotFound => Error::NoProjectFound {
265                    path: project_path.to_path_buf(),
266                },
267                _ => e.into(),
268            })?;
269
270            Ok(Some(Self::load_from_slice(&contents, project_path, None)?))
271        } else {
272            Ok(None)
273        }
274    }
275
276    /// Loads a Project from a path.
277    pub fn load_exact(
278        vfs: &Vfs,
279        project_file_location: &Path,
280        fallback_name: Option<&str>,
281    ) -> Result<Self, ProjectError> {
282        log::debug!(
283            "Loading project file from {}",
284            project_file_location.display()
285        );
286        let project_path = project_file_location.to_path_buf();
287        let contents = vfs.read(&project_path).map_err(|e| match e.kind() {
288            io::ErrorKind::NotFound => Error::NoProjectFound {
289                path: project_path.to_path_buf(),
290            },
291            _ => e.into(),
292        })?;
293
294        Ok(Self::load_from_slice(
295            &contents,
296            project_path,
297            fallback_name,
298        )?)
299    }
300
301    pub(crate) fn load_initial_project(vfs: &Vfs, path: &Path) -> Result<Self, ProjectError> {
302        if Self::is_project_file(path) {
303            Self::load_exact(vfs, path, None)
304        } else {
305            // Check for default projects.
306            for default_project_name in DEFAULT_PROJECT_NAMES {
307                let project_path = path.join(default_project_name);
308                if let Ok(true) = vfs.exists(&project_path) {
309                    return Self::load_exact(vfs, &project_path, None);
310                }
311            }
312            Err(Error::NoProjectFound {
313                path: path.to_path_buf(),
314            }
315            .into())
316        }
317    }
318
319    /// Checks if there are any compatibility issues with this project file and
320    /// warns the user if there are any.
321    fn check_compatibility(&self) {
322        self.tree.validate_reserved_names();
323    }
324
325    pub fn folder_location(&self) -> &Path {
326        self.file_location.parent().unwrap()
327    }
328}
329
330#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
331pub struct OptionalPathNode {
332    #[serde(serialize_with = "crate::path_serializer::serialize_absolute")]
333    pub optional: PathBuf,
334}
335
336impl OptionalPathNode {
337    pub fn new(optional: PathBuf) -> Self {
338        OptionalPathNode { optional }
339    }
340}
341
342/// Describes a path that is either optional or required
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344#[serde(untagged)]
345pub enum PathNode {
346    Required(#[serde(serialize_with = "crate::path_serializer::serialize_absolute")] PathBuf),
347    Optional(OptionalPathNode),
348}
349
350impl PathNode {
351    /// Returns the path of the `PathNode`, without regard for if it's optional
352    // or not.
353    #[inline]
354    pub fn path(&self) -> &Path {
355        match self {
356            PathNode::Required(pathbuf) => pathbuf,
357            PathNode::Optional(OptionalPathNode { optional }) => optional,
358        }
359    }
360
361    /// Returns whether this `PathNode` is optional or not.
362    #[inline]
363    pub fn is_optional(&self) -> bool {
364        matches!(self, PathNode::Optional(_))
365    }
366}
367
368/// Describes an instance and its descendants in a project.
369#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
370pub struct ProjectNode {
371    /// If set, defines the ClassName of the described instance.
372    ///
373    /// `$className` MUST be set if `$path` is not set.
374    ///
375    /// `$className` CANNOT be set if `$path` is set and the instance described
376    /// by that path has a ClassName other than Folder.
377    #[serde(rename = "$className", skip_serializing_if = "Option::is_none")]
378    pub class_name: Option<Ustr>,
379
380    /// If set, defines an ID for the described Instance that can be used
381    /// to refer to it for the purpose of referent properties.
382    #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
383    pub id: Option<String>,
384
385    /// Contains all of the children of the described instance.
386    #[serde(flatten)]
387    pub children: BTreeMap<String, ProjectNode>,
388
389    /// The properties that will be assigned to the resulting instance.
390    ///
391    // TODO: Is this legal to set if $path is set?
392    #[serde(
393        rename = "$properties",
394        default,
395        skip_serializing_if = "BTreeMap::is_empty"
396    )]
397    pub properties: BTreeMap<Ustr, UnresolvedValue>,
398
399    #[serde(
400        rename = "$attributes",
401        default,
402        skip_serializing_if = "BTreeMap::is_empty"
403    )]
404    pub attributes: BTreeMap<String, UnresolvedValue>,
405
406    /// Defines the behavior when Rojo encounters unknown instances in Roblox
407    /// Studio during live sync. `$ignoreUnknownInstances` should be considered
408    /// a large hammer and used with care.
409    ///
410    /// If set to `true`, those instances will be left alone. This may cause
411    /// issues when files that turn into instances are removed while Rojo is not
412    /// running.
413    ///
414    /// If set to `false`, Rojo will destroy any instances it does not
415    /// recognize.
416    ///
417    /// If unset, its default value depends on other settings:
418    /// - If `$path` is not set, defaults to `true`
419    /// - If `$path` is set, defaults to `false`
420    #[serde(
421        rename = "$ignoreUnknownInstances",
422        skip_serializing_if = "Option::is_none"
423    )]
424    pub ignore_unknown_instances: Option<bool>,
425
426    /// Defines that this instance should come from the given file path. This
427    /// path can point to any file type supported by Rojo, including Lua files
428    /// (`.lua`), Roblox models (`.rbxm`, `.rbxmx`), and localization table
429    /// spreadsheets (`.csv`).
430    #[serde(rename = "$path", skip_serializing_if = "Option::is_none")]
431    pub path: Option<PathNode>,
432}
433
434impl ProjectNode {
435    fn validate_reserved_names(&self) {
436        for (name, child) in &self.children {
437            if name.starts_with('$') {
438                log::warn!(
439                    "Keys starting with '$' are reserved by Rojo to ensure forward compatibility."
440                );
441                log::warn!(
442                    "This project uses the key '{}', which should be renamed.",
443                    name
444                );
445            }
446
447            child.validate_reserved_names();
448        }
449    }
450}
451
452#[cfg(test)]
453mod test {
454    use super::*;
455
456    #[test]
457    fn path_node_required() {
458        let path_node: PathNode = json::from_str(r#""src""#).unwrap();
459        assert_eq!(path_node, PathNode::Required(PathBuf::from("src")));
460    }
461
462    #[test]
463    fn path_node_optional() {
464        let path_node: PathNode = json::from_str(r#"{ "optional": "src" }"#).unwrap();
465        assert_eq!(
466            path_node,
467            PathNode::Optional(OptionalPathNode::new(PathBuf::from("src")))
468        );
469    }
470
471    #[test]
472    fn project_node_required() {
473        let project_node: ProjectNode = json::from_str(
474            r#"{
475                "$path": "src"
476            }"#,
477        )
478        .unwrap();
479
480        assert_eq!(
481            project_node.path,
482            Some(PathNode::Required(PathBuf::from("src")))
483        );
484    }
485
486    #[test]
487    fn project_node_optional() {
488        let project_node: ProjectNode = json::from_str(
489            r#"{
490                "$path": { "optional": "src" }
491            }"#,
492        )
493        .unwrap();
494
495        assert_eq!(
496            project_node.path,
497            Some(PathNode::Optional(OptionalPathNode::new(PathBuf::from(
498                "src"
499            ))))
500        );
501    }
502
503    #[test]
504    fn project_node_none() {
505        let project_node: ProjectNode = json::from_str(
506            r#"{
507                "$className": "Folder"
508            }"#,
509        )
510        .unwrap();
511
512        assert_eq!(project_node.path, None);
513    }
514
515    #[test]
516    fn project_node_optional_serialize_absolute() {
517        let project_node: ProjectNode = json::from_str(
518            r#"{
519                "$path": { "optional": "..\\src" }
520            }"#,
521        )
522        .unwrap();
523
524        let serialized = serde_json::to_string(&project_node).unwrap();
525        assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#);
526    }
527
528    #[test]
529    fn project_node_optional_serialize_absolute_no_change() {
530        let project_node: ProjectNode = json::from_str(
531            r#"{
532                "$path": { "optional": "../src" }
533            }"#,
534        )
535        .unwrap();
536
537        let serialized = serde_json::to_string(&project_node).unwrap();
538        assert_eq!(serialized, r#"{"$path":{"optional":"../src"}}"#);
539    }
540
541    #[test]
542    fn project_node_optional_serialize_optional() {
543        let project_node: ProjectNode = json::from_str(
544            r#"{
545                "$path": "..\\src"
546            }"#,
547        )
548        .unwrap();
549
550        let serialized = serde_json::to_string(&project_node).unwrap();
551        assert_eq!(serialized, r#"{"$path":"../src"}"#);
552    }
553
554    #[test]
555    fn project_with_jsonc_features() {
556        // Test that JSONC features (comments and trailing commas) are properly handled
557        let project_json = r#"{
558            // This is a single-line comment
559            "name": "TestProject",
560            /* This is a
561               multi-line comment */
562            "tree": {
563                "$path": "src", // Comment after value
564            },
565            "servePort": 34567,
566            "emitLegacyScripts": false,
567            // Test glob parsing with comments
568            "globIgnorePaths": [
569                "**/*.spec.lua", // Ignore test files
570                "**/*.test.lua",
571            ],
572            "syncRules": [
573                {
574                    "pattern": "*.data.json",
575                    "use": "json", // Trailing comma in object
576                },
577                {
578                    "pattern": "*.module.lua",
579                    "use": "moduleScript",
580                }, // Trailing comma in array
581            ], // Another trailing comma
582        }"#;
583
584        let project = Project::load_from_slice(
585            project_json.as_bytes(),
586            PathBuf::from("/test/default.project.jsonc"),
587            None,
588        )
589        .expect("Failed to parse project with JSONC features");
590
591        // Verify the parsed values
592        assert_eq!(project.name, Some("TestProject".to_string()));
593        assert_eq!(project.serve_port, Some(34567));
594        assert_eq!(project.emit_legacy_scripts, Some(false));
595
596        // Verify glob_ignore_paths were parsed correctly
597        assert_eq!(project.glob_ignore_paths.len(), 2);
598        assert!(project.glob_ignore_paths[0].is_match("test/foo.spec.lua"));
599        assert!(project.glob_ignore_paths[1].is_match("test/bar.test.lua"));
600
601        // Verify sync_rules were parsed correctly
602        assert_eq!(project.sync_rules.len(), 2);
603        assert!(project.sync_rules[0].include.is_match("data.data.json"));
604        assert!(project.sync_rules[1].include.is_match("init.module.lua"));
605    }
606
607    #[test]
608    fn project_with_serve_allowed_hosts() {
609        let project_json = r#"{
610            "name": "TestProject",
611            "tree": { "$path": "src" },
612            "serveAllowedHosts": ["mypc.lan", "192.168.1.5"]
613        }"#;
614
615        let project = Project::load_from_slice(
616            project_json.as_bytes(),
617            PathBuf::from("/test/default.project.json"),
618            None,
619        )
620        .expect("Failed to parse project with serveAllowedHosts");
621
622        assert_eq!(project.serve_allowed_hosts, vec!["mypc.lan", "192.168.1.5"]);
623    }
624
625    #[test]
626    fn project_without_serve_allowed_hosts_defaults_to_empty() {
627        let project_json = r#"{
628            "name": "TestProject",
629            "tree": { "$path": "src" }
630        }"#;
631
632        let project = Project::load_from_slice(
633            project_json.as_bytes(),
634            PathBuf::from("/test/default.project.json"),
635            None,
636        )
637        .expect("Failed to parse project");
638
639        assert!(project.serve_allowed_hosts.is_empty());
640    }
641
642    #[test]
643    fn glob_ignore_paths_negation() {
644        let project_json = r#"{
645            "name": "TestProject",
646            "tree": { "$path": "src" },
647            "globIgnorePaths": [
648                "**/*.spec.lua",
649                "!keep.spec.lua",
650                "\\!literal.lua"
651            ]
652        }"#;
653
654        let project = Project::load_from_slice(
655            project_json.as_bytes(),
656            PathBuf::from("/test/default.project.json"),
657            None,
658        )
659        .expect("project should parse");
660
661        let paths = &project.glob_ignore_paths;
662        assert_eq!(paths.len(), 3);
663
664        assert!(!paths[0].is_negation());
665        assert!(paths[0].is_match("foo.spec.lua"));
666
667        assert!(paths[1].is_negation());
668        assert!(paths[1].is_match("keep.spec.lua"));
669
670        // `\!literal.lua` should match a file literally named `!literal.lua`,
671        // not be parsed as a negation.
672        assert!(!paths[2].is_negation());
673        assert!(paths[2].is_match("!literal.lua"));
674
675        let rules: Vec<_> = paths
676            .iter()
677            .map(|g| crate::snapshot::PathIgnoreRule {
678                base_path: PathBuf::from("/test"),
679                glob: g.clone(),
680            })
681            .collect();
682        assert!(crate::snapshot::is_path_ignored(
683            &rules,
684            "/test/foo.spec.lua"
685        ));
686        assert!(!crate::snapshot::is_path_ignored(
687            &rules,
688            "/test/keep.spec.lua"
689        ));
690        assert!(!crate::snapshot::is_path_ignored(&rules, "/test/plain.lua"));
691    }
692}