use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use std::path::Path;
const DEFAULTS: &[&str] = &[
".git/**",
".hg/**",
".svn/**",
".DS_Store",
"**/.DS_Store",
"node_modules/**",
"**/node_modules/**",
"vendor/**",
".venv/**",
"venv/**",
"__pycache__/**",
"**/__pycache__/**",
"target/**",
"dist/**",
"build/**",
".next/**",
".turbo/**",
".svelte-kit/**",
"out/**",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"bun.lockb",
"Cargo.lock",
"Gemfile.lock",
"poetry.lock",
"uv.lock",
"composer.lock",
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.webp",
"*.ico",
"*.bmp",
"*.pdf",
"*.zip",
"*.tar",
"*.gz",
"*.tgz",
"*.bz2",
"*.7z",
"*.so",
"*.dylib",
"*.dll",
"*.a",
"*.o",
"*.exe",
"*.woff",
"*.woff2",
"*.ttf",
"*.eot",
"*.otf",
"*.mp4",
"*.mov",
"*.webm",
"*.mp3",
"*.wav",
".env",
".env.*",
"**/.env",
"**/.env.*",
"*.pem",
"*.key",
"*.p12",
"*.pfx",
"*.crt",
"*.cer",
"*.jks",
"*.keystore",
"id_rsa",
"id_rsa.pub",
"id_dsa",
"id_ecdsa",
"id_ed25519",
"id_ed25519.pub",
"**/id_rsa",
"**/id_rsa.pub",
"**/id_dsa",
"**/id_ecdsa",
"**/id_ed25519",
"**/id_ed25519.pub",
"**/.ssh/**",
"**/.aws/credentials",
"**/.aws/config",
"**/.gcp/credentials.json",
".netrc",
"**/.netrc",
".npmrc",
"**/.npmrc",
".pypirc",
"**/.pypirc",
"kubeconfig",
"*.kubeconfig",
"**/kubeconfig",
];
#[derive(Debug)]
pub struct Matcher {
deny: GlobSet,
allow: GlobSet,
}
impl Matcher {
pub fn load() -> Self {
Self::load_with_root(None)
}
pub fn load_with_root(root: Option<&Path>) -> Self {
let mut deny = GlobSetBuilder::new();
let mut allow = GlobSetBuilder::new();
for raw in DEFAULTS {
push_pattern(&mut deny, &mut allow, raw);
}
for path in lookup_files(root) {
if let Some(text) = read_dripignore_capped(&path) {
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
push_pattern(&mut deny, &mut allow, line);
}
}
}
let deny = deny.build().unwrap_or_else(|_| empty_set());
let allow = allow.build().unwrap_or_else(|_| empty_set());
Matcher { deny, allow }
}
#[allow(dead_code)]
pub fn from_str(text: &str) -> Result<Self> {
let mut deny = GlobSetBuilder::new();
let mut allow = GlobSetBuilder::new();
for raw in DEFAULTS {
push_pattern(&mut deny, &mut allow, raw);
}
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
push_pattern(&mut deny, &mut allow, line);
}
Ok(Matcher {
deny: deny.build().context("building deny globset")?,
allow: allow.build().context("building allow globset")?,
})
}
pub fn is_ignored(&self, path: &Path) -> bool {
let denied = self.matches_path(path, &self.deny);
if !denied {
return false;
}
!self.matches_path(path, &self.allow)
}
fn matches_path(&self, path: &Path, set: &GlobSet) -> bool {
if set.is_match(path) {
return true;
}
if let Some(name) = path.file_name() {
if set.is_match(Path::new(name)) {
return true;
}
}
if let Some(s) = path.to_str() {
if let Some(stripped) = s.strip_prefix("./") {
if set.is_match(Path::new(stripped)) {
return true;
}
}
}
false
}
}
fn push_pattern(deny: &mut GlobSetBuilder, allow: &mut GlobSetBuilder, raw: &str) {
let (negated, body) = match raw.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, raw),
};
let body = canonicalize_dir_pattern(body);
let g = match Glob::new(&body) {
Ok(g) => g,
Err(e) => {
eprintln!("drip: ignoring malformed .dripignore pattern {raw:?}: {e}");
return;
}
};
if negated {
allow.add(g);
} else {
deny.add(g);
}
}
fn canonicalize_dir_pattern(body: &str) -> String {
if !body.ends_with('/') {
return body.to_string();
}
let trimmed = body.trim_end_matches('/');
if trimmed.ends_with("/**") || trimmed == "**" {
return trimmed.to_string();
}
format!("{trimmed}/**")
}
fn lookup_files(root: Option<&Path>) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
if let Ok(p) = std::env::var("DRIP_IGNORE_FILE") {
out.push(std::path::PathBuf::from(p));
}
if let Some(r) = root {
out.push(r.join(".dripignore"));
}
if let Ok(cwd) = std::env::current_dir() {
let candidate = cwd.join(".dripignore");
if !out.contains(&candidate) {
out.push(candidate);
}
}
if let Some(home) = dirs::home_dir() {
out.push(home.join(".dripignore"));
}
out
}
fn empty_set() -> GlobSet {
GlobSetBuilder::new()
.build()
.expect("empty globset always builds")
}
const DRIPIGNORE_CAP: u64 = 256 * 1024;
fn read_dripignore_capped(path: &Path) -> Option<String> {
use std::io::Read;
let f = std::fs::File::open(path).ok()?;
let mut buf = String::new();
f.take(DRIPIGNORE_CAP).read_to_string(&mut buf).ok()?;
Some(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_node_modules() {
let m = Matcher::from_str("").unwrap();
assert!(m.is_ignored(Path::new("node_modules/foo/bar.js")));
assert!(m.is_ignored(Path::new("project/node_modules/x.js")));
}
#[test]
fn defaults_match_lock_files() {
let m = Matcher::from_str("").unwrap();
assert!(m.is_ignored(Path::new("package-lock.json")));
assert!(m.is_ignored(Path::new("/abs/path/Cargo.lock")));
assert!(m.is_ignored(Path::new("./yarn.lock")));
}
#[test]
fn user_pattern_adds_to_defaults() {
let m = Matcher::from_str("secrets/*.txt\n").unwrap();
assert!(m.is_ignored(Path::new("secrets/foo.txt")));
assert!(!m.is_ignored(Path::new("src/main.rs")));
}
#[test]
fn negation_re_includes() {
let m = Matcher::from_str("!Cargo.lock\n").unwrap();
assert!(!m.is_ignored(Path::new("Cargo.lock")));
assert!(m.is_ignored(Path::new("yarn.lock")));
}
#[test]
fn comments_and_blanks_skipped() {
let m = Matcher::from_str("# comment\n\n \nsecrets/*\n").unwrap();
assert!(m.is_ignored(Path::new("secrets/x")));
}
#[test]
fn malformed_pattern_does_not_panic() {
let _ = Matcher::from_str("[unclosed\n").unwrap();
}
#[test]
fn trailing_slash_matches_immediate_children() {
let m = Matcher::from_str("playground/\n").unwrap();
assert!(
m.is_ignored(Path::new("playground/foo.txt")),
"`playground/` must ignore immediate file children",
);
}
#[test]
fn trailing_slash_matches_arbitrary_depth() {
let m = Matcher::from_str("playground/\n").unwrap();
assert!(
m.is_ignored(Path::new("playground/a/b.txt")),
"`playground/` must ignore deeply-nested descendants",
);
assert!(
m.is_ignored(Path::new("playground/a/b/c/d.rs")),
"`playground/` must recurse arbitrarily",
);
}
#[test]
fn trailing_slash_does_not_leak_to_siblings() {
let m = Matcher::from_str("playground/\n").unwrap();
assert!(!m.is_ignored(Path::new("playgroundlol/foo.txt")));
assert!(!m.is_ignored(Path::new("not-playground/foo.txt")));
}
#[test]
fn explicit_double_star_still_works_unchanged() {
let m = Matcher::from_str("playground/**\n").unwrap();
assert!(m.is_ignored(Path::new("playground/foo.txt")));
assert!(m.is_ignored(Path::new("playground/a/b.txt")));
}
#[test]
fn trailing_slash_negation_re_includes_descendants() {
let m = Matcher::from_str("playground/\n!playground/keep.js\n").unwrap();
assert!(m.is_ignored(Path::new("playground/foo.txt")));
assert!(!m.is_ignored(Path::new("playground/keep.js")));
}
#[test]
fn canonicalize_dir_pattern_helper() {
assert_eq!(canonicalize_dir_pattern("playground/"), "playground/**");
assert_eq!(canonicalize_dir_pattern("a/b/"), "a/b/**");
assert_eq!(canonicalize_dir_pattern("playground"), "playground");
assert_eq!(canonicalize_dir_pattern("playground/**"), "playground/**");
assert_eq!(canonicalize_dir_pattern("playground/**/"), "playground/**");
}
}