Skip to main content

librojo/snapshot/
metadata.rs

1use std::{
2    fmt,
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use anyhow::Context;
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    glob::{Glob, IgnorableGlob},
12    path_serializer,
13    project::ProjectNode,
14    snapshot_middleware::{emit_legacy_scripts_default, Middleware},
15    RojoRef,
16};
17
18/// Rojo-specific metadata that can be associated with an instance or a snapshot
19/// of an instance.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct InstanceMetadata {
22    /// Whether instances not present in the source should be ignored when
23    /// live-syncing. This is useful when there are instances that Rojo does not
24    /// manage.
25    pub ignore_unknown_instances: bool,
26
27    /// If a change occurs to this instance, the instigating source is what
28    /// should be run through the snapshot functions to regenerate it.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub instigating_source: Option<InstigatingSource>,
31
32    /// The paths that, when changed, could cause the function that generated
33    /// this snapshot to generate a different snapshot. Paths should be included
34    /// even if they don't exist, since the presence of a file can change the
35    /// outcome of a snapshot function.
36    ///
37    /// For example, a file named foo.lua might have these relevant paths:
38    /// - foo.lua
39    /// - foo.meta.json (even if this file doesn't exist!)
40    ///
41    /// A directory named bar/ might have these:
42    /// - bar/
43    /// - bar/init.meta.json
44    /// - bar/init.lua
45    /// - bar/init.server.lua
46    /// - bar/init.client.lua
47    /// - bar/default.project.json
48    ///
49    /// This path is used to make sure that file changes update all instances
50    /// that may need updates.
51    // TODO: Change this to be a SmallVec for performance in common cases?
52    #[serde(serialize_with = "path_serializer::serialize_vec_absolute")]
53    pub relevant_paths: Vec<PathBuf>,
54
55    /// Contains information about this instance that should persist between
56    /// snapshot invocations and is generally inherited.
57    ///
58    /// If an instance has a piece of context attached to it, then the next time
59    /// that instance's instigating source is snapshotted directly, the same
60    /// context will be passed into it.
61    pub context: InstanceContext,
62
63    /// Indicates the ID used for Ref properties pointing to this Instance.
64    pub specified_id: Option<RojoRef>,
65
66    /// The Middleware that was used to create this Instance. Should generally
67    /// not be `None` except if the snapshotting process is not completed.
68    pub middleware: Option<Middleware>,
69
70    /// A schema provided via a JSON file, if one exists. Will be `None` for
71    /// all non-JSON middleware.
72    pub schema: Option<String>,
73}
74
75impl InstanceMetadata {
76    pub fn new() -> Self {
77        Self {
78            ignore_unknown_instances: false,
79            instigating_source: None,
80            relevant_paths: Vec::new(),
81            context: InstanceContext::default(),
82            specified_id: None,
83            middleware: None,
84            schema: None,
85        }
86    }
87
88    pub fn ignore_unknown_instances(self, ignore_unknown_instances: bool) -> Self {
89        Self {
90            ignore_unknown_instances,
91            ..self
92        }
93    }
94
95    pub fn instigating_source(self, instigating_source: impl Into<InstigatingSource>) -> Self {
96        Self {
97            instigating_source: Some(instigating_source.into()),
98            ..self
99        }
100    }
101
102    pub fn relevant_paths(self, relevant_paths: Vec<PathBuf>) -> Self {
103        Self {
104            relevant_paths,
105            ..self
106        }
107    }
108
109    pub fn context(self, context: &InstanceContext) -> Self {
110        Self {
111            context: context.clone(),
112            ..self
113        }
114    }
115
116    pub fn specified_id(self, id: Option<RojoRef>) -> Self {
117        Self {
118            specified_id: id,
119            ..self
120        }
121    }
122
123    pub fn middleware(self, middleware: Middleware) -> Self {
124        Self {
125            middleware: Some(middleware),
126            ..self
127        }
128    }
129
130    pub fn schema(self, schema: Option<String>) -> Self {
131        Self { schema, ..self }
132    }
133}
134
135impl Default for InstanceMetadata {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct InstanceContext {
143    #[serde(skip_serializing_if = "Vec::is_empty")]
144    pub path_ignore_rules: Arc<Vec<PathIgnoreRule>>,
145    pub emit_legacy_scripts: bool,
146    #[serde(skip_serializing_if = "Vec::is_empty")]
147    pub sync_rules: Vec<SyncRule>,
148}
149
150impl InstanceContext {
151    pub fn new() -> Self {
152        Self {
153            path_ignore_rules: Arc::new(Vec::new()),
154            emit_legacy_scripts: emit_legacy_scripts_default().unwrap(),
155            sync_rules: Vec::new(),
156        }
157    }
158
159    pub fn with_emit_legacy_scripts(emit_legacy_scripts: Option<bool>) -> Self {
160        Self {
161            emit_legacy_scripts: emit_legacy_scripts
162                .or_else(emit_legacy_scripts_default)
163                .unwrap(),
164            ..Self::new()
165        }
166    }
167
168    /// Extend the list of ignore rules in the context with the given new rules.
169    pub fn add_path_ignore_rules<I>(&mut self, new_rules: I)
170    where
171        I: IntoIterator<Item = PathIgnoreRule>,
172        I::IntoIter: ExactSizeIterator,
173    {
174        let new_rules = new_rules.into_iter();
175
176        // If the iterator is empty, we can skip cloning our list of ignore
177        // rules and appending to it.
178        if new_rules.len() == 0 {
179            return;
180        }
181
182        let rules = Arc::make_mut(&mut self.path_ignore_rules);
183        rules.extend(new_rules);
184    }
185
186    /// Extend the list of syncing rules in the context with the given new rules.
187    pub fn add_sync_rules<I>(&mut self, new_rules: I)
188    where
189        I: IntoIterator<Item = SyncRule>,
190    {
191        self.sync_rules.extend(new_rules);
192    }
193
194    /// Clears all sync rules for this InstanceContext
195    pub fn clear_sync_rules(&mut self) {
196        self.sync_rules.clear();
197    }
198
199    pub fn set_emit_legacy_scripts(&mut self, emit_legacy_scripts: bool) {
200        self.emit_legacy_scripts = emit_legacy_scripts;
201    }
202
203    /// Returns the middleware specified by the first sync rule that
204    /// matches the provided path. This does not handle default syncing rules.
205    pub fn get_user_sync_rule(&self, path: &Path) -> Option<&SyncRule> {
206        self.sync_rules.iter().find(|&rule| rule.matches(path))
207    }
208}
209
210impl Default for InstanceContext {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217pub struct PathIgnoreRule {
218    /// The path that this glob is relative to. Since ignore globs are defined
219    /// in project files, this will generally be the folder containing the
220    /// project file that defined this glob.
221    #[serde(serialize_with = "path_serializer::serialize_absolute")]
222    pub base_path: PathBuf,
223
224    /// The actual glob that can be matched against the input path.
225    pub glob: IgnorableGlob,
226}
227
228impl PathIgnoreRule {
229    pub fn matches<P: AsRef<Path>>(&self, path: P) -> bool {
230        let path = path.as_ref();
231
232        match path.strip_prefix(&self.base_path) {
233            Ok(suffix) => self.glob.is_match(suffix),
234            Err(_) => false,
235        }
236    }
237
238    pub fn is_negation(&self) -> bool {
239        self.glob.is_negation()
240    }
241}
242
243/// Evaluates an ordered list of [`PathIgnoreRule`]s against a path using
244/// gitignore-style "last match wins" semantics: a path is ignored if the last
245/// rule whose pattern matches it is non-negated. Paths matched by no rule are
246/// not ignored.
247pub fn is_path_ignored<P: AsRef<Path>>(rules: &[PathIgnoreRule], path: P) -> bool {
248    let path = path.as_ref();
249    let mut ignored = false;
250    for rule in rules {
251        if rule.matches(path) {
252            ignored = !rule.is_negation();
253        }
254    }
255    ignored
256}
257
258/// Represents where a particular Instance or InstanceSnapshot came from.
259#[derive(Clone, PartialEq, Serialize, Deserialize)]
260pub enum InstigatingSource {
261    /// The path the Instance was made from.
262    Path(#[serde(serialize_with = "path_serializer::serialize_absolute")] PathBuf),
263    /// The node in a Project that the Instance was made from.
264    ProjectNode {
265        #[serde(serialize_with = "path_serializer::serialize_absolute")]
266        path: PathBuf,
267        name: String,
268        node: ProjectNode,
269        parent_class: Option<String>,
270    },
271}
272
273impl InstigatingSource {
274    pub fn path(&self) -> &Path {
275        match self {
276            Self::Path(path) => path.as_path(),
277            Self::ProjectNode { path, .. } => path.as_path(),
278        }
279    }
280}
281
282impl fmt::Debug for InstigatingSource {
283    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
284        match self {
285            InstigatingSource::Path(path) => write!(formatter, "Path({})", path.display()),
286            InstigatingSource::ProjectNode {
287                name,
288                node,
289                path,
290                parent_class,
291            } => write!(
292                formatter,
293                "ProjectNode({}: {:?}) from path {} and parent class {:?}",
294                name,
295                node,
296                path.display(),
297                parent_class,
298            ),
299        }
300    }
301}
302
303impl From<PathBuf> for InstigatingSource {
304    fn from(path: PathBuf) -> Self {
305        InstigatingSource::Path(path)
306    }
307}
308
309impl From<&Path> for InstigatingSource {
310    fn from(path: &Path) -> Self {
311        InstigatingSource::Path(path.to_path_buf())
312    }
313}
314
315/// Represents an user-specified rule for transforming files
316/// into Instances using a given middleware.
317#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
318pub struct SyncRule {
319    /// A pattern used to determine if a file is included in this SyncRule
320    #[serde(rename = "pattern")]
321    pub include: Glob,
322    /// A pattern used to determine if a file is excluded from this SyncRule.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub exclude: Option<Glob>,
325    /// The middleware specified by the user for this SyncRule
326    #[serde(rename = "use")]
327    pub middleware: Middleware,
328    /// A suffix to trim off of file names, including the file extension.
329    /// If not specified, the file extension is the only thing cut off.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub suffix: Option<String>,
332    /// The 'base' of the glob above, allowing it to be used
333    /// relative to a path instead of absolute.
334    #[serde(skip)]
335    pub base_path: PathBuf,
336}
337
338impl SyncRule {
339    /// Returns whether the given path matches this rule.
340    pub fn matches(&self, path: &Path) -> bool {
341        match path.strip_prefix(&self.base_path) {
342            Ok(suffix) => {
343                if let Some(pattern) = &self.exclude {
344                    if pattern.is_match(suffix) {
345                        return false;
346                    }
347                }
348                self.include.is_match(suffix)
349            }
350            Err(_) => false,
351        }
352    }
353
354    pub fn file_name_for_path<'a>(&self, path: &'a Path) -> anyhow::Result<&'a str> {
355        if let Some(suffix) = &self.suffix {
356            let file_name = path
357                .file_name()
358                .and_then(|s| s.to_str())
359                .with_context(|| format!("file name of {} is invalid", path.display()))?;
360            if file_name.ends_with(suffix) {
361                let end = file_name.len().saturating_sub(suffix.len());
362                Ok(&file_name[..end])
363            } else {
364                Ok(file_name)
365            }
366        } else {
367            // If the user doesn't specify a suffix, we assume they just want
368            // the name of the file (the file_stem)
369            path.file_stem()
370                .and_then(|s| s.to_str())
371                .with_context(|| format!("file name of {} is invalid", path.display()))
372        }
373    }
374}