2017-01-27 19:00:13 +01:00
|
|
|
extern crate globset;
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
|
2017-02-04 20:52:38 +01:00
|
|
|
use std::collections::HashSet;
|
2016-10-12 04:43:53 +02:00
|
|
|
use std::fs;
|
|
|
|
use std::io;
|
|
|
|
use std::io::Read;
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
2017-02-04 20:52:38 +01:00
|
|
|
pub struct Gitignore {
|
|
|
|
files: Vec<GitignoreFile>,
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
pub enum Error {
|
2017-01-27 19:00:13 +01:00
|
|
|
GlobSet(globset::Error),
|
2016-10-12 04:43:53 +02:00
|
|
|
Io(io::Error),
|
|
|
|
}
|
|
|
|
|
2017-02-04 20:52:38 +01:00
|
|
|
struct GitignoreFile {
|
|
|
|
set: GlobSet,
|
|
|
|
patterns: Vec<Pattern>,
|
|
|
|
root: PathBuf,
|
|
|
|
}
|
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
struct Pattern {
|
|
|
|
pattern: String,
|
|
|
|
pattern_type: PatternType,
|
|
|
|
anchored: bool,
|
|
|
|
}
|
2016-10-12 15:06:01 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
enum PatternType {
|
|
|
|
Ignore,
|
|
|
|
Whitelist,
|
2016-10-12 15:06:01 +02:00
|
|
|
}
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-02-04 20:52:38 +01:00
|
|
|
#[derive(PartialEq)]
|
|
|
|
enum MatchResult {
|
|
|
|
Ignore,
|
|
|
|
Whitelist,
|
|
|
|
None,
|
|
|
|
}
|
|
|
|
|
2017-02-04 22:26:59 +01:00
|
|
|
pub fn load(paths: &[PathBuf]) -> Gitignore {
|
2017-02-04 20:52:38 +01:00
|
|
|
let mut files = vec![];
|
|
|
|
let mut checked_dirs = HashSet::new();
|
|
|
|
|
|
|
|
for path in paths {
|
|
|
|
let mut p = path.to_owned();
|
|
|
|
|
|
|
|
loop {
|
|
|
|
if !checked_dirs.contains(&p) {
|
|
|
|
checked_dirs.insert(p.clone());
|
|
|
|
|
|
|
|
let gitignore_path = p.join(".gitignore");
|
|
|
|
if gitignore_path.exists() {
|
|
|
|
match GitignoreFile::new(&gitignore_path) {
|
|
|
|
Ok(f) => {
|
|
|
|
debug!("Loaded {:?}", gitignore_path);
|
|
|
|
files.push(f);
|
|
|
|
}
|
|
|
|
Err(_) => debug!("Unable to load {:?}", gitignore_path),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Stop if we see a .git directory
|
|
|
|
if let Ok(metadata) = p.join(".git").metadata() {
|
|
|
|
if metadata.is_dir() {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if p.parent().is_none() {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
p.pop();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Gitignore::new(files)
|
|
|
|
}
|
|
|
|
|
|
|
|
impl Gitignore {
|
|
|
|
fn new(files: Vec<GitignoreFile>) -> Gitignore {
|
|
|
|
Gitignore { files: files }
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn is_excluded(&self, path: &Path) -> bool {
|
|
|
|
let mut applicable_files: Vec<&GitignoreFile> = self.files
|
|
|
|
.iter()
|
|
|
|
.filter(|f| path.starts_with(&f.root))
|
|
|
|
.collect();
|
|
|
|
applicable_files.sort_by(|l, r| l.root_len().cmp(&r.root_len()));
|
|
|
|
|
|
|
|
// TODO: add user gitignores
|
|
|
|
|
|
|
|
let mut result = MatchResult::None;
|
|
|
|
|
|
|
|
for file in applicable_files {
|
|
|
|
match file.matches(path) {
|
|
|
|
MatchResult::Ignore => result = MatchResult::Ignore,
|
|
|
|
MatchResult::Whitelist => result = MatchResult::Whitelist,
|
|
|
|
MatchResult::None => {}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
result == MatchResult::Ignore
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
impl GitignoreFile {
|
|
|
|
pub fn new(path: &Path) -> Result<GitignoreFile, Error> {
|
|
|
|
let mut file = try!(fs::File::open(path));
|
|
|
|
let mut contents = String::new();
|
|
|
|
try!(file.read_to_string(&mut contents));
|
|
|
|
|
|
|
|
let lines = contents.lines().collect();
|
|
|
|
let root = path.parent().unwrap();
|
|
|
|
|
|
|
|
GitignoreFile::from_strings(lines, root)
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn from_strings(strs: Vec<&str>, root: &Path) -> Result<GitignoreFile, Error> {
|
|
|
|
let mut builder = GlobSetBuilder::new();
|
|
|
|
let mut patterns = vec![];
|
|
|
|
|
|
|
|
let parsed_patterns = GitignoreFile::parse(strs);
|
|
|
|
for p in parsed_patterns {
|
|
|
|
let mut pat = String::from(p.pattern.clone());
|
|
|
|
if !p.anchored && !pat.starts_with("**/") {
|
|
|
|
pat = "**/".to_string() + &pat;
|
|
|
|
}
|
|
|
|
|
|
|
|
if !pat.ends_with("/**") {
|
|
|
|
pat = pat + "/**";
|
|
|
|
}
|
|
|
|
|
2017-03-23 23:39:38 +01:00
|
|
|
let glob = try!(GlobBuilder::new(&pat).literal_separator(true).build());
|
2017-01-27 19:00:13 +01:00
|
|
|
|
|
|
|
builder.add(glob);
|
|
|
|
patterns.push(p);
|
|
|
|
}
|
|
|
|
|
|
|
|
Ok(GitignoreFile {
|
2017-03-23 23:39:38 +01:00
|
|
|
set: try!(builder.build()),
|
|
|
|
patterns: patterns,
|
|
|
|
root: root.to_owned(),
|
|
|
|
})
|
2017-01-27 19:00:13 +01:00
|
|
|
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
2017-02-04 20:52:38 +01:00
|
|
|
#[cfg(test)]
|
|
|
|
fn is_excluded(&self, path: &Path) -> bool {
|
|
|
|
self.matches(path) == MatchResult::Ignore
|
|
|
|
}
|
|
|
|
|
|
|
|
fn matches(&self, path: &Path) -> MatchResult {
|
2017-01-27 19:00:13 +01:00
|
|
|
let stripped = path.strip_prefix(&self.root);
|
|
|
|
if !stripped.is_ok() {
|
2017-02-04 20:52:38 +01:00
|
|
|
return MatchResult::None;
|
2017-01-27 19:00:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
let matches = self.set.matches(stripped.unwrap());
|
|
|
|
|
|
|
|
for &i in matches.iter().rev() {
|
|
|
|
let pattern = &self.patterns[i];
|
|
|
|
return match pattern.pattern_type {
|
2017-03-23 23:39:38 +01:00
|
|
|
PatternType::Whitelist => MatchResult::Whitelist,
|
|
|
|
PatternType::Ignore => MatchResult::Ignore,
|
|
|
|
};
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
2017-02-04 20:52:38 +01:00
|
|
|
MatchResult::None
|
|
|
|
}
|
|
|
|
|
|
|
|
pub fn root_len(&self) -> usize {
|
|
|
|
self.root.as_os_str().len()
|
2017-01-27 19:00:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
fn parse(contents: Vec<&str>) -> Vec<Pattern> {
|
2017-03-23 23:39:38 +01:00
|
|
|
contents
|
|
|
|
.iter()
|
2017-01-27 19:00:13 +01:00
|
|
|
.filter(|l| !l.is_empty())
|
|
|
|
.filter(|l| !l.starts_with('#'))
|
|
|
|
.map(|l| Pattern::parse(l))
|
|
|
|
.collect()
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl Pattern {
|
2017-01-27 19:00:13 +01:00
|
|
|
fn parse(pattern: &str) -> Pattern {
|
2016-10-12 04:43:53 +02:00
|
|
|
let mut normalized = String::from(pattern);
|
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
let pattern_type = if normalized.starts_with('!') {
|
2016-10-12 04:43:53 +02:00
|
|
|
normalized.remove(0);
|
2017-01-27 19:00:13 +01:00
|
|
|
PatternType::Whitelist
|
2016-10-24 02:12:48 +02:00
|
|
|
} else {
|
2017-01-27 19:00:13 +01:00
|
|
|
PatternType::Ignore
|
2016-10-24 02:12:48 +02:00
|
|
|
};
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2016-10-18 15:39:40 +02:00
|
|
|
let anchored = if normalized.starts_with('/') {
|
2016-10-12 04:43:53 +02:00
|
|
|
normalized.remove(0);
|
2016-10-18 15:39:40 +02:00
|
|
|
true
|
2016-10-24 02:12:48 +02:00
|
|
|
} else {
|
|
|
|
false
|
|
|
|
};
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
if normalized.ends_with('/') {
|
2016-10-12 04:43:53 +02:00
|
|
|
normalized.pop();
|
2017-01-27 19:00:13 +01:00
|
|
|
}
|
2016-10-12 04:43:53 +02:00
|
|
|
|
|
|
|
if normalized.starts_with("\\#") || normalized.starts_with("\\!") {
|
|
|
|
normalized.remove(0);
|
|
|
|
}
|
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
Pattern {
|
|
|
|
pattern: normalized,
|
|
|
|
pattern_type: pattern_type,
|
2016-10-24 02:12:48 +02:00
|
|
|
anchored: anchored,
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
|
|
|
|
impl From<globset::Error> for Error {
|
|
|
|
fn from(error: globset::Error) -> Error {
|
|
|
|
Error::GlobSet(error)
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
impl From<io::Error> for Error {
|
|
|
|
fn from(error: io::Error) -> Error {
|
|
|
|
Error::Io(error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
mod tests {
|
2017-01-27 19:00:13 +01:00
|
|
|
use super::GitignoreFile;
|
2016-10-12 04:43:53 +02:00
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
fn base_dir() -> PathBuf {
|
|
|
|
PathBuf::from("/home/user/dir")
|
|
|
|
}
|
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
fn build_gitignore(pattern: &str) -> GitignoreFile {
|
|
|
|
GitignoreFile::from_strings(vec![pattern], &base_dir()).unwrap()
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_matches_exact() {
|
2017-01-27 19:00:13 +01:00
|
|
|
let file = build_gitignore("Cargo.toml");
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(file.is_excluded(&base_dir().join("Cargo.toml")));
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_does_not_match() {
|
|
|
|
let file = build_gitignore("Cargo.toml");
|
|
|
|
|
|
|
|
assert!(!file.is_excluded(&base_dir().join("src").join("main.rs")));
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_matches_simple_wildcard() {
|
2017-01-27 19:00:13 +01:00
|
|
|
let file = build_gitignore("targ*");
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(file.is_excluded(&base_dir().join("target")));
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
2017-01-27 19:00:13 +01:00
|
|
|
fn test_matches_subdir_exact() {
|
|
|
|
let file = build_gitignore("target");
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(file.is_excluded(&base_dir().join("target/")));
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_matches_subdir() {
|
2017-01-27 19:00:13 +01:00
|
|
|
let file = build_gitignore("target");
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(file.is_excluded(&base_dir().join("target").join("file")));
|
|
|
|
assert!(file.is_excluded(&base_dir().join("target").join("subdir").join("file")));
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_wildcard_with_dir() {
|
2017-01-27 19:00:13 +01:00
|
|
|
let file = build_gitignore("target/f*");
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(file.is_excluded(&base_dir().join("target").join("file")));
|
|
|
|
assert!(!file.is_excluded(&base_dir().join("target").join("subdir").join("file")));
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_leading_slash() {
|
2017-01-27 19:00:13 +01:00
|
|
|
let file = build_gitignore("/*.c");
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(file.is_excluded(&base_dir().join("cat-file.c")));
|
|
|
|
assert!(!file.is_excluded(&base_dir().join("mozilla-sha1").join("sha1.c")));
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_leading_double_wildcard() {
|
2017-01-27 19:00:13 +01:00
|
|
|
let file = build_gitignore("**/foo");
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(file.is_excluded(&base_dir().join("foo")));
|
|
|
|
assert!(file.is_excluded(&base_dir().join("target").join("foo")));
|
|
|
|
assert!(file.is_excluded(&base_dir().join("target").join("subdir").join("foo")));
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_trailing_double_wildcard() {
|
2017-01-27 19:00:13 +01:00
|
|
|
let file = build_gitignore("abc/**");
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(!file.is_excluded(&base_dir().join("def").join("foo")));
|
|
|
|
assert!(file.is_excluded(&base_dir().join("abc").join("foo")));
|
|
|
|
assert!(file.is_excluded(&base_dir().join("abc").join("subdir").join("foo")));
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn test_sandwiched_double_wildcard() {
|
2017-01-27 19:00:13 +01:00
|
|
|
let file = build_gitignore("a/**/b");
|
2016-10-12 04:43:53 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(file.is_excluded(&base_dir().join("a").join("b")));
|
|
|
|
assert!(file.is_excluded(&base_dir().join("a").join("x").join("b")));
|
|
|
|
assert!(file.is_excluded(&base_dir().join("a").join("x").join("y").join("b")));
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|
|
|
|
|
2016-10-12 15:06:01 +02:00
|
|
|
#[test]
|
2017-01-27 19:00:13 +01:00
|
|
|
fn test_empty_file_never_excludes() {
|
|
|
|
let file = GitignoreFile::from_strings(vec![], &base_dir()).unwrap();
|
2016-10-12 15:06:01 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(!file.is_excluded(&base_dir().join("target")));
|
2016-10-12 15:06:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
2017-01-27 19:00:13 +01:00
|
|
|
fn test_checks_all_patterns() {
|
|
|
|
let patterns = vec!["target", "target2"];
|
|
|
|
let file = GitignoreFile::from_strings(patterns, &base_dir()).unwrap();
|
2016-10-12 15:06:01 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(file.is_excluded(&base_dir().join("target").join("foo.txt")));
|
|
|
|
assert!(file.is_excluded(&base_dir().join("target2").join("bar.txt")));
|
2016-10-12 15:06:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
2017-01-27 19:00:13 +01:00
|
|
|
fn test_handles_whitelisting() {
|
|
|
|
let patterns = vec!["target", "!target/foo.txt"];
|
|
|
|
let file = GitignoreFile::from_strings(patterns, &base_dir()).unwrap();
|
2016-10-12 15:06:01 +02:00
|
|
|
|
2017-01-27 19:00:13 +01:00
|
|
|
assert!(!file.is_excluded(&base_dir().join("target").join("foo.txt")));
|
|
|
|
assert!(file.is_excluded(&base_dir().join("target").join("blah.txt")));
|
2016-10-12 15:06:01 +02:00
|
|
|
}
|
2016-10-12 04:43:53 +02:00
|
|
|
}
|