librojo/
project.rs

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