use std::path::{Path, PathBuf};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use memchr::memchr_iter;
use once_cell::sync::Lazy;
use crate::error::BrrrError;
static PARSED_DEFAULT_PATTERNS: Lazy<Vec<(usize, usize)>> = Lazy::new(|| {
parse_pattern_bounds_simd(DEFAULT_PATTERNS.as_bytes())
});
#[inline]
fn parse_pattern_bounds_simd(bytes: &[u8]) -> Vec<(usize, usize)> {
let mut patterns = Vec::with_capacity(64);
let mut line_start = 0;
for newline_pos in memchr_iter(b'\n', bytes) {
if let Some((ps, pe)) = find_pattern_bounds_in_line(&bytes[line_start..newline_pos]) {
patterns.push((line_start + ps, line_start + pe));
}
line_start = newline_pos + 1;
}
if line_start < bytes.len() {
if let Some((ps, pe)) = find_pattern_bounds_in_line(&bytes[line_start..]) {
patterns.push((line_start + ps, line_start + pe));
}
}
patterns
}
#[inline]
fn find_pattern_bounds_in_line(line: &[u8]) -> Option<(usize, usize)> {
let start = line.iter().position(|&b| !b.is_ascii_whitespace())?;
if line[start] == b'#' {
return None;
}
let end = line
.iter()
.rposition(|&b| !b.is_ascii_whitespace())
.map_or(start, |i| i + 1);
Some((start, end))
}
#[allow(dead_code)]
pub const DEFAULT_PATTERNS: &str = r#"# BRRR ignore patterns (gitignore syntax)
# Docs: https://git-scm.com/docs/gitignore
# ===================
# Dependencies
# ===================
node_modules/
.venv/
venv/
env/
__pycache__/
.tox/
.nox/
.pytest_cache/
.mypy_cache/
.ruff_cache/
vendor/
Pods/
# ===================
# Build outputs
# ===================
dist/
build/
out/
target/
*.egg-info/
*.whl
*.pyc
*.pyo
# ===================
# Binary/large files
# ===================
*.so
*.dylib
*.dll
*.exe
*.bin
*.o
*.a
*.lib
# ===================
# IDE/editors
# ===================
.idea/
.vscode/
*.swp
*.swo
*~
# ===================
# Security (always exclude)
# ===================
.env
.env.*
*.pem
*.key
*.p12
*.pfx
credentials.*
secrets.*
# ===================
# Version control
# ===================
.git/
.hg/
.svn/
# ===================
# OS files
# ===================
.DS_Store
Thumbs.db
"#;
#[allow(dead_code)]
#[derive(Debug)]
pub struct BrrrIgnore {
matcher: Gitignore,
root: PathBuf,
}
#[allow(dead_code)]
impl BrrrIgnore {
pub fn new(project_dir: &Path) -> Result<Self, BrrrError> {
let root = project_dir
.canonicalize()
.unwrap_or_else(|_| project_dir.to_path_buf());
let brrrignore_path = root.join(".brrrignore");
let mut builder = GitignoreBuilder::new(&root);
let loaded_brrrignore = if brrrignore_path.exists() {
if let Some(err) = builder.add(&brrrignore_path) {
tracing::warn!("Error loading .brrrignore: {}", err);
false
} else {
tracing::debug!("Loaded .brrrignore from {:?}", brrrignore_path);
true
}
} else {
false
};
if !loaded_brrrignore {
for &(start, end) in PARSED_DEFAULT_PATTERNS.iter() {
let pattern = &DEFAULT_PATTERNS[start..end];
if let Err(e) = builder.add_line(None, pattern) {
tracing::warn!("Invalid pattern '{}': {}", pattern, e);
}
}
tracing::debug!(
"Using {} default ignore patterns (no .brrrignore found)",
PARSED_DEFAULT_PATTERNS.len()
);
}
let matcher = match builder.build() {
Ok(m) => m,
Err(e) => {
tracing::warn!("Failed to build gitignore matcher: {}, trying empty fallback", e);
GitignoreBuilder::new(&root)
.build()
.map_err(|fallback_err| {
BrrrError::Config(format!(
"Failed to build gitignore matcher: {}; fallback also failed: {}",
e, fallback_err
))
})?
}
};
Ok(Self { matcher, root })
}
#[must_use]
pub fn is_ignored(&self, path: &Path) -> bool {
let relative_path = if path.is_absolute() {
path.strip_prefix(&self.root)
.map(Path::to_path_buf)
.unwrap_or_else(|_| path.to_path_buf())
} else {
path.to_path_buf()
};
let is_dir = path.is_dir()
|| relative_path.to_string_lossy().ends_with('/')
|| self.root.join(&relative_path).is_dir();
self.matcher
.matched_path_or_any_parents(&relative_path, is_dir)
.is_ignore()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_brrr_ignore_default_patterns() {
let temp_dir = TempDir::new().unwrap();
let ignore = BrrrIgnore::new(temp_dir.path()).unwrap();
assert!(ignore.is_ignored(Path::new("node_modules/pkg/index.js")));
assert!(ignore.is_ignored(Path::new("__pycache__/module.pyc")));
assert!(ignore.is_ignored(Path::new(".git/config")));
assert!(ignore.is_ignored(Path::new("target/debug/binary")));
assert!(ignore.is_ignored(Path::new(".venv/bin/python")));
}
#[test]
fn test_brrr_ignore_allows_source_files() {
let temp_dir = TempDir::new().unwrap();
let ignore = BrrrIgnore::new(temp_dir.path()).unwrap();
assert!(!ignore.is_ignored(Path::new("src/main.rs")));
assert!(!ignore.is_ignored(Path::new("lib/utils.py")));
assert!(!ignore.is_ignored(Path::new("app.ts")));
}
#[test]
fn test_brrr_ignore_custom_patterns() {
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join(".brrrignore"),
"custom_ignore/\n*.custom\n",
)
.unwrap();
let ignore = BrrrIgnore::new(temp_dir.path()).unwrap();
assert!(ignore.is_ignored(Path::new("custom_ignore/file.txt")));
assert!(ignore.is_ignored(Path::new("test.custom")));
}
#[test]
fn test_gitignore_not_loaded_by_brrrignore() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join(".gitignore"), "*.log\nlogs/\n").unwrap();
let ignore = BrrrIgnore::new(temp_dir.path()).unwrap();
assert!(!ignore.is_ignored(Path::new("debug.log")));
assert!(!ignore.is_ignored(Path::new("logs/app.txt")));
assert!(ignore.is_ignored(Path::new("node_modules/pkg")));
assert!(ignore.is_ignored(Path::new("__pycache__/module.pyc")));
}
#[test]
fn test_brrrignore_only_no_gitignore() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join(".gitignore"), "*.txt\n").unwrap();
fs::write(temp_dir.path().join(".brrrignore"), "*.custom\n").unwrap();
let ignore = BrrrIgnore::new(temp_dir.path()).unwrap();
assert!(ignore.is_ignored(Path::new("test.custom")));
assert!(!ignore.is_ignored(Path::new("test.txt")));
}
#[test]
fn test_simd_pattern_parsing_correctness() {
let test_content = b"# Comment line\n \npattern1\n pattern2 \n# Another comment\npattern3\n";
let bounds = parse_pattern_bounds_simd(test_content);
assert_eq!(bounds.len(), 3);
let pattern1 = std::str::from_utf8(&test_content[bounds[0].0..bounds[0].1]).unwrap();
let pattern2 = std::str::from_utf8(&test_content[bounds[1].0..bounds[1].1]).unwrap();
let pattern3 = std::str::from_utf8(&test_content[bounds[2].0..bounds[2].1]).unwrap();
assert_eq!(pattern1, "pattern1");
assert_eq!(pattern2, "pattern2");
assert_eq!(pattern3, "pattern3");
}
#[test]
fn test_simd_pattern_parsing_edge_cases() {
assert!(parse_pattern_bounds_simd(b"").is_empty());
assert!(parse_pattern_bounds_simd(b"# comment\n# another").is_empty());
assert!(parse_pattern_bounds_simd(b" \n\t\n ").is_empty());
let bounds = parse_pattern_bounds_simd(b"pattern_no_newline");
assert_eq!(bounds.len(), 1);
assert_eq!(&b"pattern_no_newline"[bounds[0].0..bounds[0].1], b"pattern_no_newline");
let bounds = parse_pattern_bounds_simd(b"\t mixed_ws \t\n");
assert_eq!(bounds.len(), 1);
assert_eq!(&b"\t mixed_ws \t\n"[bounds[0].0..bounds[0].1], b"mixed_ws");
}
#[test]
fn test_default_patterns_lazy_static() {
let patterns = &*PARSED_DEFAULT_PATTERNS;
assert!(!patterns.is_empty());
let pattern_strs: Vec<&str> = patterns
.iter()
.map(|&(s, e)| &DEFAULT_PATTERNS[s..e])
.collect();
assert!(pattern_strs.contains(&"node_modules/"));
assert!(pattern_strs.contains(&"__pycache__/"));
assert!(pattern_strs.contains(&".git/"));
assert!(pattern_strs.contains(&"target/"));
assert!(!pattern_strs.iter().any(|p| p.starts_with('#')));
assert!(!pattern_strs.iter().any(|p| p.is_empty()));
}
}