use std::fs;
use std::io;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct IgnorePattern {
pattern: String,
is_directory: bool,
is_negation: bool,
is_absolute: bool,
}
impl IgnorePattern {
pub fn parse(line: &str) -> Option<Self> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let (is_negation, pattern) = if let Some(stripped) = line.strip_prefix('!') {
(true, stripped)
} else {
(false, line)
};
let is_directory = pattern.ends_with('/');
let is_absolute = pattern.starts_with('/');
let pattern = pattern
.trim_end_matches('/')
.trim_start_matches('/')
.to_string();
Some(IgnorePattern {
pattern,
is_directory,
is_negation,
is_absolute,
})
}
pub fn matches(&self, path: &Path, is_dir: bool) -> bool {
if self.is_directory && !is_dir {
return false;
}
let path_str = path.to_string_lossy();
if self.is_absolute {
self.glob_match(&self.pattern, &path_str)
} else {
if self.glob_match(&self.pattern, &path_str) {
return true;
}
for component in path.components() {
let component_str = component.as_os_str().to_string_lossy();
if self.glob_match(&self.pattern, &component_str) {
return true;
}
}
let parts: Vec<&str> = path_str.split('/').collect();
for i in 0..parts.len() {
let suffix = parts[i..].join("/");
if self.glob_match(&self.pattern, &suffix) {
return true;
}
}
false
}
}
fn glob_match(&self, pattern: &str, text: &str) -> bool {
Self::glob_match_recursive(pattern, text)
}
fn glob_match_recursive(pattern: &str, text: &str) -> bool {
if pattern.is_empty() {
return text.is_empty();
}
if pattern == "**" {
return true; }
if let Some(rest_pattern) = pattern.strip_prefix("**/") {
return Self::glob_match_recursive(rest_pattern, text)
|| text.contains('/') && {
let after_slash = text.split_once('/').map(|(_, after)| after).unwrap_or("");
Self::glob_match_recursive(pattern, after_slash)
};
}
if let Some(prefix) = pattern.strip_suffix("/**") {
return text.starts_with(prefix)
&& (text.len() == prefix.len() || text.chars().nth(prefix.len()) == Some('/'));
}
if let Some(star_pos) = pattern.find('*') {
let before = &pattern[..star_pos];
let after = &pattern[star_pos + 1..];
if !text.starts_with(before) {
return false;
}
let remaining_text = &text[before.len()..];
for i in 0..=remaining_text.len() {
let candidate = &remaining_text[i..];
if Self::glob_match_recursive(after, candidate) {
return true;
}
}
false
} else {
pattern == text
}
}
}
#[derive(Debug, Default)]
pub struct ZimIgnore {
patterns: Vec<IgnorePattern>,
}
impl ZimIgnore {
pub fn new() -> Self {
Self::default()
}
pub fn from_file<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let content = fs::read_to_string(path)?;
Ok(Self::from_content(&content))
}
pub fn from_content(content: &str) -> Self {
let patterns = content.lines().filter_map(IgnorePattern::parse).collect();
Self { patterns }
}
pub fn extend(&mut self, other: &ZimIgnore) {
self.patterns.extend(other.patterns.clone());
}
pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
let mut should_ignore = false;
for pattern in &self.patterns {
if pattern.matches(path, is_dir) {
should_ignore = !pattern.is_negation;
}
}
should_ignore
}
pub fn load_for_directory<P: AsRef<Path>>(dir: P) -> Self {
let mut combined = ZimIgnore::new();
let dir = dir.as_ref();
let mut current = Some(dir);
let mut zimignore_files = Vec::new();
while let Some(path) = current {
let zimignore_path = path.join(".zimignore");
if zimignore_path.exists() {
zimignore_files.push(zimignore_path);
}
current = path.parent();
}
for zimignore_path in zimignore_files.into_iter().rev() {
if let Ok(zimignore) = ZimIgnore::from_file(&zimignore_path) {
combined.extend(&zimignore);
}
}
combined
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_pattern_parsing() {
let pattern = IgnorePattern::parse("*.wav").unwrap();
assert_eq!(pattern.pattern, "*.wav");
assert!(!pattern.is_directory);
assert!(!pattern.is_negation);
assert!(!pattern.is_absolute);
let pattern = IgnorePattern::parse("project/live/").unwrap();
assert_eq!(pattern.pattern, "project/live");
assert!(pattern.is_directory);
let pattern = IgnorePattern::parse("!important.wav").unwrap();
assert_eq!(pattern.pattern, "important.wav");
assert!(pattern.is_negation);
assert!(IgnorePattern::parse("# comment").is_none());
assert!(IgnorePattern::parse("").is_none());
}
#[test]
fn test_glob_matching() {
let pattern = IgnorePattern::parse("*.wav").unwrap();
assert!(pattern.matches(&PathBuf::from("test.wav"), false));
assert!(!pattern.matches(&PathBuf::from("test.flac"), false));
let pattern = IgnorePattern::parse("project/live/").unwrap();
assert!(pattern.matches(&PathBuf::from("project/live"), true));
assert!(!pattern.matches(&PathBuf::from("project/live"), false));
let pattern = IgnorePattern::parse("**/temp").unwrap();
assert!(pattern.matches(&PathBuf::from("any/path/temp"), false));
assert!(pattern.matches(&PathBuf::from("temp"), false));
}
#[test]
fn test_zimignore_content() {
let content = r#"
# Ignore DAW files
*.als
project/live/
# But keep important files
!important.als
# Ignore temp directories anywhere
**/temp/
"#;
let zimignore = ZimIgnore::from_content(content);
assert!(zimignore.is_ignored(&PathBuf::from("song.als"), false));
assert!(!zimignore.is_ignored(&PathBuf::from("important.als"), false));
assert!(zimignore.is_ignored(&PathBuf::from("project/live"), true));
assert!(zimignore.is_ignored(&PathBuf::from("any/path/temp"), true));
assert!(zimignore.is_ignored(&PathBuf::from("temp"), true));
}
#[test]
fn test_absolute_vs_relative_patterns() {
let pattern = IgnorePattern::parse("/project/live").unwrap();
assert!(pattern.is_absolute);
assert!(pattern.matches(&PathBuf::from("project/live"), true));
assert!(!pattern.matches(&PathBuf::from("other/project/live"), true));
let pattern = IgnorePattern::parse("project/live").unwrap();
assert!(!pattern.is_absolute);
assert!(pattern.matches(&PathBuf::from("project/live"), true));
assert!(pattern.matches(&PathBuf::from("other/project/live"), true));
}
}