use std::fs;
use std::path::{Path, PathBuf};
use super::strip_utf8_bom;
pub const DEFAULT_DISCOVERY_SKIP_DIRS: &[&str] = &[
".git",
".github",
".next",
".nx",
".open-next",
".turbo",
".venv",
".vercel",
".xbp",
"build",
"coverage",
"dist",
"node_modules",
"out",
"target",
"tmp",
"vendor",
"venv",
];
#[derive(Debug, Clone, PartialEq, Eq)]
struct IgnorePattern {
raw: String,
negated: bool,
dir_only: bool,
body: String,
anchored: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct XbpIgnoreSet {
patterns: Vec<IgnorePattern>,
}
impl XbpIgnoreSet {
pub fn empty() -> Self {
Self {
patterns: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
pub fn patterns(&self) -> impl Iterator<Item = &str> {
self.patterns.iter().map(|pattern| pattern.raw.as_str())
}
pub fn from_patterns<I, S>(patterns: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut set = Self::empty();
set.extend_patterns(patterns);
set
}
pub fn from_file_content(content: &str) -> Self {
let mut set = Self::empty();
set.extend_file_content(content);
set
}
pub fn extend_patterns<I, S>(&mut self, patterns: I)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for pattern in patterns {
if let Some(parsed) = parse_ignore_pattern(pattern.as_ref()) {
self.patterns.push(parsed);
}
}
}
pub fn extend_file_content(&mut self, content: &str) {
let stripped = strip_utf8_bom(content);
for line in stripped.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(parsed) = parse_ignore_pattern(trimmed) {
self.patterns.push(parsed);
}
}
}
pub fn merge(&mut self, other: &XbpIgnoreSet) {
self.patterns.extend(other.patterns.iter().cloned());
}
pub fn is_ignored_relative(&self, relative: &str, is_dir: bool) -> bool {
let normalized = normalize_relative_path(relative);
if normalized.is_empty() || normalized == "." {
return false;
}
let mut ignored = false;
for pattern in &self.patterns {
if pattern_matches(pattern, &normalized, is_dir) {
ignored = !pattern.negated;
}
}
ignored
}
pub fn is_ignored_path(&self, project_root: &Path, path: &Path) -> bool {
let relative = match path.strip_prefix(project_root) {
Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
Err(_) => path.to_string_lossy().replace('\\', "/"),
};
let is_dir = path.is_dir();
self.is_ignored_relative(&relative, is_dir)
}
pub fn skips_dir_name(&self, dir_name: &str) -> bool {
let name = dir_name.trim().trim_matches(|ch| ch == '/' || ch == '\\');
if name.is_empty() || name == "." || name == ".." {
return false;
}
self.is_ignored_relative(name, true)
}
pub fn ignores_service_root(&self, root_directory: &str) -> bool {
let normalized = normalize_relative_path(root_directory);
if normalized.is_empty() || normalized == "." {
return false;
}
self.is_ignored_relative(&normalized, true)
}
pub fn ignores_version_target(&self, target: &str) -> bool {
let normalized = normalize_relative_path(target);
if normalized.is_empty() {
return false;
}
if self.is_ignored_relative(&normalized, false) {
return true;
}
if let Some(parent) = Path::new(&normalized).parent() {
let parent_rel = parent.to_string_lossy().replace('\\', "/");
if !parent_rel.is_empty() && parent_rel != "." {
return self.is_ignored_relative(&parent_rel, true);
}
}
false
}
}
pub fn load_project_xbp_ignore(project_root: &Path, extra_patterns: &[String]) -> XbpIgnoreSet {
let mut set = XbpIgnoreSet::from_patterns(
DEFAULT_DISCOVERY_SKIP_DIRS
.iter()
.map(|name| (*name).to_string()),
);
for candidate in xbp_ignore_file_candidates(project_root) {
if let Ok(content) = fs::read_to_string(&candidate) {
set.extend_file_content(&content);
}
}
if !extra_patterns.is_empty() {
set.extend_patterns(extra_patterns.iter().map(String::as_str));
}
set
}
pub fn xbp_ignore_file_candidates(project_root: &Path) -> Vec<PathBuf> {
vec![
project_root.join(".xbpignore"),
project_root.join(".xbp").join(".xbpignore"),
]
}
pub fn default_xbp_ignore_path(project_root: &Path) -> PathBuf {
project_root.join(".xbp").join(".xbpignore")
}
fn parse_ignore_pattern(raw: &str) -> Option<IgnorePattern> {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let (negated, rest) = if let Some(rest) = trimmed.strip_prefix('!') {
(true, rest.trim())
} else {
(false, trimmed)
};
if rest.is_empty() {
return None;
}
let dir_only = rest.ends_with('/');
let without_slash = rest.trim_end_matches(|ch| ch == '/' || ch == '\\');
let normalized = without_slash.replace('\\', "/");
let anchored = normalized.starts_with('/');
let body = normalized
.trim_start_matches('/')
.trim_start_matches("./")
.trim_matches('/')
.to_string();
if body.is_empty() {
return None;
}
Some(IgnorePattern {
raw: trimmed.to_string(),
negated,
dir_only,
body,
anchored,
})
}
fn normalize_relative_path(raw: &str) -> String {
raw.trim()
.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_string()
}
fn pattern_matches(pattern: &IgnorePattern, path: &str, is_dir: bool) -> bool {
if pattern.dir_only && !is_dir {
let prefix = &pattern.body;
if path == *prefix || path.starts_with(&format!("{prefix}/")) {
return path != *prefix || is_dir;
}
if path == *prefix {
return false;
}
return path_has_directory_match(path, pattern);
}
if pattern.anchored {
return path_glob_match(&pattern.body, path)
|| (!pattern.dir_only && path_is_under_glob(&pattern.body, path));
}
if path_glob_match(&pattern.body, path) {
return true;
}
if path_is_under_glob(&pattern.body, path) {
return true;
}
if !pattern.body.contains('/') && !pattern.body.contains("**") {
return path
.split('/')
.any(|component| path_glob_match(&pattern.body, component));
}
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
for start in 0..segments.len() {
let suffix = segments[start..].join("/");
if path_glob_match(&pattern.body, &suffix)
|| path_is_under_glob(&pattern.body, &suffix)
{
return true;
}
}
false
}
fn path_has_directory_match(path: &str, pattern: &IgnorePattern) -> bool {
if pattern.anchored {
return path == pattern.body || path.starts_with(&format!("{}/", pattern.body));
}
if !pattern.body.contains('/') && !pattern.body.contains('*') {
return path
.split('/')
.any(|component| component.eq_ignore_ascii_case(&pattern.body));
}
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
for start in 0..segments.len() {
let suffix = segments[start..].join("/");
if path_glob_match(&pattern.body, &suffix)
|| suffix.starts_with(&format!("{}/", pattern.body))
|| path_is_under_glob(&pattern.body, &suffix)
{
return true;
}
}
false
}
fn path_is_under_glob(pattern: &str, path: &str) -> bool {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
for end in 1..segments.len() {
let parent = segments[..end].join("/");
if path_glob_match(pattern, &parent) {
return true;
}
}
false
}
fn path_glob_match(pattern: &str, path: &str) -> bool {
let pattern = pattern.trim_matches('/');
let path = path.trim_matches('/');
if pattern == "**" {
return true;
}
glob_match_segments(
&split_glob_segments(pattern),
&path.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>(),
)
}
fn split_glob_segments(pattern: &str) -> Vec<&str> {
pattern.split('/').filter(|s| !s.is_empty()).collect()
}
fn glob_match_segments(pattern: &[&str], path: &[&str]) -> bool {
fn rec(p: &[&str], s: &[&str]) -> bool {
match (p.first().copied(), s.first().copied()) {
(None, None) => true,
(Some("**"), _) => {
if rec(&p[1..], s) {
return true;
}
if s.is_empty() {
return false;
}
rec(p, &s[1..])
}
(Some(pat), Some(seg)) => {
if segment_glob_match(pat, seg) {
rec(&p[1..], &s[1..])
} else {
false
}
}
(Some(_), None) | (None, Some(_)) => false,
}
}
rec(pattern, path)
}
fn segment_glob_match(pattern: &str, segment: &str) -> bool {
if pattern == "*" {
return true;
}
if !pattern.contains('*') {
return pattern == segment;
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == segment;
}
let mut rest = segment;
for (index, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if index == 0 {
if !rest.starts_with(part) {
return false;
}
rest = &rest[part.len()..];
continue;
}
if index == parts.len() - 1 {
return rest.ends_with(part);
}
if let Some(pos) = rest.find(part) {
rest = &rest[pos + part.len()..];
} else {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_root(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let path = std::env::temp_dir().join(format!("xbp-ignore-{name}-{nanos}"));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(path.join(".xbp")).expect("mkdir");
path
}
#[test]
fn matches_unanchored_folder_anywhere() {
let set = XbpIgnoreSet::from_patterns(["node_modules"]);
assert!(set.is_ignored_relative("node_modules", true));
assert!(set.is_ignored_relative("apps/web/node_modules", true));
assert!(set.is_ignored_relative("apps/web/node_modules/pkg/index.js", false));
assert!(!set.is_ignored_relative("apps/web/src/main.ts", false));
}
#[test]
fn matches_anchored_and_directory_only_patterns() {
let set = XbpIgnoreSet::from_patterns(["/docs/", "packages/icons"]);
assert!(set.is_ignored_relative("docs", true));
assert!(set.is_ignored_relative("docs/guide.md", false));
assert!(!set.is_ignored_relative("apps/docs/guide.md", false));
assert!(set.is_ignored_relative("packages/icons", true));
assert!(set.is_ignored_relative("packages/icons/package.json", false));
assert!(!set.is_ignored_relative("packages/web/package.json", false));
}
#[test]
fn supports_negation() {
let set = XbpIgnoreSet::from_patterns(["secrets/", "!secrets/public.env"]);
assert!(set.is_ignored_relative("secrets/private.key", false));
assert!(!set.is_ignored_relative("secrets/public.env", false));
}
#[test]
fn loads_dot_xbp_ignore_and_yaml_patterns() {
let root = temp_root("load");
fs::write(
root.join(".xbp").join(".xbpignore"),
"# project ignores\npackages/icons/\n*.snap\n",
)
.expect("write ignore");
fs::write(root.join(".xbpignore"), "legacy-root-ignore/\n").expect("write root ignore");
let set = load_project_xbp_ignore(
&root,
&["e2e/".to_string(), "packages/icons/README.md".to_string()],
);
assert!(set.ignores_service_root("packages/icons"));
assert!(set.ignores_version_target("packages/icons/package.json"));
assert!(set.is_ignored_relative("legacy-root-ignore/foo", false));
assert!(set.is_ignored_relative("e2e/test.spec.ts", false));
assert!(set.is_ignored_relative("apps/web/__snapshots__/a.snap", false));
assert!(set.skips_dir_name("node_modules"));
assert!(set.skips_dir_name("target"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn glob_double_star_and_star_segment() {
let set = XbpIgnoreSet::from_patterns(["**/dist", "apps/*/tmp"]);
assert!(set.is_ignored_relative("packages/foo/dist/index.js", false));
assert!(set.is_ignored_relative("dist", true));
assert!(set.is_ignored_relative("apps/web/tmp", true));
assert!(set.is_ignored_relative("apps/web/tmp/cache", false));
assert!(!set.is_ignored_relative("apps/web/src/tmp-file.ts", false));
}
}