Add ignorable glob support

This commit is contained in:
EgoMoose 2026-05-05 11:52:59 -04:00
parent 825726c883
commit 40177ebb4f
4 changed files with 138 additions and 14 deletions

View file

@ -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<Self, Error> {
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<P: AsRef<Path>>(&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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.raw)
}
}
impl<'de> Deserialize<'de> for IgnorableGlob {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let pattern = String::deserialize(deserializer)?;
IgnorableGlob::new(&pattern).map_err(D::Error::custom)
}
}

View file

@ -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<Glob>,
pub glob_ignore_paths: Vec<IgnorableGlob>,
/// 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"));
}
}

View file

@ -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<P: AsRef<Path>>(&self, path: P) -> bool {
pub fn matches<P: AsRef<Path>>(&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<P: AsRef<Path>>(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)]

View file

@ -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<Option<InstanceSnapshot>> {
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();