use globset::GlobBuilder;
use regex::Regex;
use std::env;
use std::path::{Path, PathBuf};
fn first_char(string: &str) -> char {
string.chars().nth(0).unwrap()
}
fn negate(string: &str) -> String {
format!("!{}", string)
}
fn has_no_middle_separators(string: &str) -> bool {
let segments: Vec<&str> = string.split("/").filter(|s| !s.is_empty()).collect();
segments.len() <= 1
}
fn remove_whitespace(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace()).collect()
}
#[derive(Debug)]
enum Match {
Anywhere,
Relative,
}
#[derive(Debug)]
enum PathKind {
Dir,
File,
Both,
}
pub struct Pattern {
pub string: String,
match_type: Match,
path_kind: PathKind,
negated: bool,
}
impl Pattern {
pub fn new(glob: &str) -> Self {
let has_extension = Regex::new(r"\.[^\*/\\]+$").unwrap();
let negated = glob.starts_with("!");
let without_neg = if negated { &glob[1..] } else { glob };
let normalized_glob = remove_whitespace(without_neg);
let match_type = if !normalized_glob.starts_with("**")
&& first_char(&normalized_glob) != '/'
&& has_no_middle_separators(&normalized_glob)
{
Match::Anywhere
} else {
Match::Relative
};
let path_kind = if has_extension.is_match(&normalized_glob) {
PathKind::File
} else {
if normalized_glob.ends_with("/") {
PathKind::Dir
} else {
PathKind::Both
}
};
Self {
string: String::from(normalized_glob),
negated,
match_type,
path_kind,
}
}
fn get_parents(&self) -> Vec<String> {
let mut segments: Vec<&str> = self.string.split("/").collect();
let mut parents: Vec<String> = Vec::new();
while segments.len() > 1 {
let mut joined = segments[..segments.len() - 1].join("/");
joined.push_str("/");
if joined.starts_with("/") {
parents.push(joined);
} else {
parents.push(format!("/{}", joined));
parents.push(joined);
}
segments.pop();
}
parents.into_iter().filter(|p| !p.is_empty()).collect()
}
}
pub struct Gitignore<P: AsRef<Path>> {
pub root: P,
require_literal_separator: bool,
case_insensitive: bool,
}
impl Default for Gitignore<PathBuf> {
fn default() -> Self {
Self {
root: env::current_dir().unwrap(),
require_literal_separator: true,
case_insensitive: true,
}
}
}
impl<P: AsRef<Path>> Gitignore<P> {
pub fn new(root: P, require_literal_separator: bool, case_insensitive: bool) -> Gitignore<P> {
Gitignore {
root,
require_literal_separator,
case_insensitive,
}
}
fn make_relative(&mut self, p: &str) -> String {
self.require_literal_separator = true;
let root_str = self.root.as_ref().display();
let mut unformatted = p;
if unformatted.ends_with("*") {
unformatted = &p[..p.len() - 1];
}
if p.starts_with("**/") {
return String::from(unformatted);
}
if p.starts_with("/") {
return format!("{}{}", root_str, unformatted);
}
format!("{}/{}", root_str, unformatted)
}
fn make_matchable_anywhere(&mut self, p: &str) -> String {
self.require_literal_separator = false;
let mut unformatted = p;
let root_str = self.root.as_ref().display();
if unformatted.ends_with("*") {
unformatted = &p[..p.len() - 1];
}
format!("{}{}{}", root_str, "/**/", unformatted)
}
fn make_full_path(&mut self, glob: &Pattern, from: &str) -> String {
match (&glob.path_kind, &glob.match_type) {
(PathKind::Both, Match::Anywhere) => self.make_matchable_anywhere(from) + "*",
(PathKind::File, Match::Anywhere) => self.make_matchable_anywhere(from),
(PathKind::Dir, Match::Anywhere) => self.make_matchable_anywhere(from) + "**/*",
(PathKind::Both, Match::Relative) => self.make_relative(from) + "*",
(PathKind::File, Match::Relative) => self.make_relative(from),
(PathKind::Dir, Match::Relative) => self.make_relative(from) + "**/*",
}
}
fn make_relative_to_root(&mut self, glob: &Pattern, from: &str) -> String {
match (&glob.path_kind, &glob.match_type) {
(PathKind::Both, _) => self.make_relative(from) + "*",
(PathKind::File, _) => self.make_relative(from),
(PathKind::Dir, _) => self.make_relative(from) + "**/*",
}
}
fn find_ignored_dirs(&self, lines: &[&str]) -> Vec<String> {
let mut ignored_dirs: Vec<String> = Vec::new();
for line in lines.iter() {
let glob = Pattern::new(line);
let parents: Vec<String> = glob.get_parents().into_iter().map(|p| negate(&p)).collect();
let has_negated_parents = parents.iter().any(|p| lines.contains(&&p[..]));
match glob.path_kind {
PathKind::Both | PathKind::Dir => {
if !glob.negated && !has_negated_parents {
ignored_dirs.push(glob.string);
}
}
_ => (),
}
}
ignored_dirs
}
pub fn ignores(&mut self, lines: &[&str], target: impl AsRef<Path>) -> bool {
let ignored_dirs = self.find_ignored_dirs(lines);
let mut is_ignored = false;
for line in lines.iter() {
let glob = Pattern::new(line);
let has_ignored_parent = ignored_dirs.iter().any(|dir| {
let long_glob = self.make_relative_to_root(&glob, dir);
let matcher = GlobBuilder::new(&long_glob)
.literal_separator(self.require_literal_separator)
.case_insensitive(self.case_insensitive)
.build()
.unwrap()
.compile_matcher();
matcher.is_match(target.as_ref())
});
if has_ignored_parent {
return true;
}
if is_ignored && !glob.negated {
return true;
}
let full_path = self.make_full_path(&glob, &glob.string);
let matcher = GlobBuilder::new(&full_path)
.literal_separator(self.require_literal_separator)
.case_insensitive(self.case_insensitive)
.build()
.unwrap()
.compile_matcher();
let is_match = matcher.is_match(target.as_ref());
is_ignored = if is_match { !glob.negated } else { is_ignored };
}
is_ignored
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multiple_lines() {
let mut ig = Gitignore::default();
let a = vec!["lib/", "!lib/*.js"];
let b = vec!["lib", "!lib/*.js"];
let c = vec!["!lib/*.js", "lib"];
let d = vec!["lib/", "!lib/deep/include.js"];
let e = vec!["/lib/", "!/lib/deep/"];
let f = vec!["lib/", "!/lib/"];
let g = vec!["!/lib/", "lib/"];
let h = vec!["**/remove-items.js"];
let i = vec!["remove-items*"];
let j = vec!["remove*, !remove-items.js"];
let k = vec!["lib/*.js", "!lib/include.js"];
let l = vec!["lib/*.js", "!lib/"];
let m = vec!["lib/", "!lib/"];
let n = vec!["lib/", "!/lib/"];
let o = vec!["*.js", "!lib.js"];
let p = vec!["src/*.js", "target/"];
assert!(ig.ignores(&a, ig.root.join("lib/include.js")));
assert!(ig.ignores(&c, ig.root.join("lib/include.js")));
assert!(ig.ignores(&d, ig.root.join("lib/deep/include.js")));
assert!(ig.ignores(&e, ig.root.join("lib/deep/include.js")));
assert!(ig.ignores(&g, ig.root.join("deep/lib/include.js")));
assert!(ig.ignores(&h, ig.root.join("deep/lib/remove-items.js")));
assert!(ig.ignores(&i, ig.root.join("deep/lib/remove-items.js")));
assert!(ig.ignores(&p, ig.root.join("src/lib.js")));
assert!(!ig.ignores(&j, ig.root.join("deep/lib/remove-items.js")));
assert!(!ig.ignores(&k, ig.root.join("lib/include.js")));
assert!(!ig.ignores(&l, ig.root.join("lib/include.js")));
assert!(!ig.ignores(&m, ig.root.join("lib/include.js")));
assert!(!ig.ignores(&n, ig.root.join("lib/include.js")));
assert!(!ig.ignores(&o, ig.root.join("src/lib.js")));
assert!(!ig.ignores(&b, ig.root.join("lib/include.js")));
assert!(ig.ignores(&d, ig.root.join("lib/deep/ignored.js")));
assert!(ig.ignores(&f, ig.root.join("deep/lib/include.js")));
}
}