diff --git a/src/glob.rs b/src/glob.rs index 96437c38..c0146863 100644 --- a/src/glob.rs +++ b/src/glob.rs @@ -48,3 +48,61 @@ impl<'de> Deserialize<'de> for Glob { Glob::new(&glob).map_err(D::Error::custom) } } + +/// A glob with optional gitignore-style negation. A leading `!` marks the +/// pattern as a negation (re-includes paths that an earlier rule excluded). +/// To match a literal `!` at the start of a pattern, escape it with `\!`. +#[derive(Debug, Clone)] +pub struct IgnorableGlob { + glob: Glob, + negated: bool, + raw: String, +} + +impl IgnorableGlob { + pub fn new(pattern: &str) -> Result { + let (negated, body) = if let Some(rest) = pattern.strip_prefix('!') { + (true, rest) + } else if pattern.starts_with(r"\!") { + (false, &pattern[1..]) + } else { + (false, pattern) + }; + + Ok(IgnorableGlob { + glob: Glob::new(body)?, + negated, + raw: pattern.to_owned(), + }) + } + + pub fn is_match>(&self, path: P) -> bool { + self.glob.is_match(path) + } + + pub fn is_negation(&self) -> bool { + self.negated + } +} + +impl PartialEq for IgnorableGlob { + fn eq(&self, other: &Self) -> bool { + self.negated == other.negated && self.glob == other.glob + } +} + +impl Eq for IgnorableGlob {} + +impl Serialize for IgnorableGlob { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.raw) + } +} + +impl<'de> Deserialize<'de> for IgnorableGlob { + fn deserialize>(deserializer: D) -> Result { + let pattern = String::deserialize(deserializer)?; + + IgnorableGlob::new(&pattern).map_err(D::Error::custom) + } +} diff --git a/src/project.rs b/src/project.rs index 83c1b1bb..60379be1 100644 --- a/src/project.rs +++ b/src/project.rs @@ -11,7 +11,7 @@ use rbx_dom_weak::{Ustr, UstrMap}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::{glob::Glob, json, resolution::UnresolvedValue, snapshot::SyncRule}; +use crate::{glob::IgnorableGlob, json, resolution::UnresolvedValue, snapshot::SyncRule}; static PROJECT_FILENAME: &str = "default.project.json"; @@ -111,7 +111,7 @@ pub struct Project { /// A list of globs, relative to the folder the project file is in, that /// match files that should be excluded if Rojo encounters them. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub glob_ignore_paths: Vec, + pub glob_ignore_paths: Vec, /// A list of mappings of globs to syncing rules. If a file matches a glob, /// it will be 'transformed' into an Instance following the rule provided. @@ -550,4 +550,55 @@ mod test { assert!(project.sync_rules[0].include.is_match("data.data.json")); assert!(project.sync_rules[1].include.is_match("init.module.lua")); } + + #[test] + fn glob_ignore_paths_negation() { + let project_json = r#"{ + "name": "TestProject", + "tree": { "$path": "src" }, + "globIgnorePaths": [ + "**/*.spec.lua", + "!keep.spec.lua", + "\\!literal.lua" + ] + }"#; + + let project = Project::load_from_slice( + project_json.as_bytes(), + PathBuf::from("/test/default.project.json"), + None, + ) + .expect("project should parse"); + + let paths = &project.glob_ignore_paths; + assert_eq!(paths.len(), 3); + + assert!(!paths[0].is_negation()); + assert!(paths[0].is_match("foo.spec.lua")); + + assert!(paths[1].is_negation()); + assert!(paths[1].is_match("keep.spec.lua")); + + // `\!literal.lua` should match a file literally named `!literal.lua`, + // not be parsed as a negation. + assert!(!paths[2].is_negation()); + assert!(paths[2].is_match("!literal.lua")); + + let rules: Vec<_> = paths + .iter() + .map(|g| crate::snapshot::PathIgnoreRule { + base_path: PathBuf::from("/test"), + glob: g.clone(), + }) + .collect(); + assert!(crate::snapshot::is_path_ignored( + &rules, + "/test/foo.spec.lua" + )); + assert!(!crate::snapshot::is_path_ignored( + &rules, + "/test/keep.spec.lua" + )); + assert!(!crate::snapshot::is_path_ignored(&rules, "/test/plain.lua")); + } } diff --git a/src/snapshot/metadata.rs b/src/snapshot/metadata.rs index 20505f29..db4a3791 100644 --- a/src/snapshot/metadata.rs +++ b/src/snapshot/metadata.rs @@ -8,7 +8,7 @@ use anyhow::Context; use serde::{Deserialize, Serialize}; use crate::{ - glob::Glob, + glob::{Glob, IgnorableGlob}, path_serializer, project::ProjectNode, snapshot_middleware::{emit_legacy_scripts_default, Middleware}, @@ -201,18 +201,37 @@ pub struct PathIgnoreRule { pub base_path: PathBuf, /// The actual glob that can be matched against the input path. - pub glob: Glob, + pub glob: IgnorableGlob, } impl PathIgnoreRule { - pub fn passes>(&self, path: P) -> bool { + pub fn matches>(&self, path: P) -> bool { let path = path.as_ref(); match path.strip_prefix(&self.base_path) { - Ok(suffix) => !self.glob.is_match(suffix), - Err(_) => true, + Ok(suffix) => self.glob.is_match(suffix), + Err(_) => false, } } + + pub fn is_negation(&self) -> bool { + self.glob.is_negation() + } +} + +/// Evaluates an ordered list of [`PathIgnoreRule`]s against a path using +/// gitignore-style "last match wins" semantics: a path is ignored if the last +/// rule whose pattern matches it is non-negated. Paths matched by no rule are +/// not ignored. +pub fn is_path_ignored>(rules: &[PathIgnoreRule], path: P) -> bool { + let path = path.as_ref(); + let mut ignored = false; + for rule in rules { + if rule.matches(path) { + ignored = !rule.is_negation(); + } + } + ignored } #[derive(Clone, PartialEq, Serialize, Deserialize)] diff --git a/src/snapshot_middleware/dir.rs b/src/snapshot_middleware/dir.rs index 5cfab8d5..52613562 100644 --- a/src/snapshot_middleware/dir.rs +++ b/src/snapshot_middleware/dir.rs @@ -2,7 +2,7 @@ use std::path::Path; use memofs::{DirEntry, IoResultExt, Vfs}; -use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}; +use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, is_path_ignored}; use super::{meta_file::DirectoryMetadata, snapshot_from_vfs}; @@ -45,12 +45,8 @@ pub fn snapshot_dir_no_meta( vfs: &Vfs, path: &Path, ) -> anyhow::Result> { - let passes_filter_rules = |child: &DirEntry| { - context - .path_ignore_rules - .iter() - .all(|rule| rule.passes(child.path())) - }; + let passes_filter_rules = + |child: &DirEntry| !is_path_ignored(&context.path_ignore_rules, child.path()); let mut snapshot_children = Vec::new();