1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
use error;
use pattern;

use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};

/// Represents a `.gitignore` file. Use this to load the `.gitignore` file, parse the patterns,
/// and then check if a given path would be excluded by any rules contained therein.
///
/// # Examples
///
/// ```
/// # use std::env;
/// # let pwd = env::current_dir().unwrap();
/// # let gitignore_path = pwd.join(".gitignore");
/// let file = gitignore::File::new(&gitignore_path).unwrap();
/// # let path_to_test_if_excluded = pwd.join("target");
/// assert!(file.is_excluded(&path_to_test_if_excluded).unwrap())
/// ```
#[derive(Debug)]
pub struct File<'a> {
    patterns: Vec<pattern::Pattern<'a>>,
    root: &'a Path
}

impl<'b> File<'b> {
    /// Parse the given `.gitignore` file for patterns, allowing any arbitrary path to be checked
    /// against the set of rules to test for exclusion.
    ///
    /// The value of `gitignore_path` must be an absolute path.
    pub fn new(gitignore_path: &'b Path) -> Result<File<'b>, error::Error> {
        let root = gitignore_path.parent().unwrap();
        let patterns = try!(File::patterns(gitignore_path, root));

        Ok(File {
            patterns: patterns,
            root: root
        })
    }

    /// Returns true if, after checking against all the patterns found in the `.gitignore` file,
    /// the given path is matched any of the globs (applying negated patterns as expected). Note
    /// this function also returns false if the path does not exist.
    ///
    /// If the value for `path` is not absolute, it will assumed to be relative to the current
    /// working directory.
    pub fn is_excluded(&self, path: &'b Path) -> Result<bool, error::Error> {
        self.included_files().map(|files| !files.contains(&path.to_path_buf()))
    }

    /// Returns a list of files that are not excluded by the rules in the loaded
    /// `.gitignore` file. It recurses through all subdirectories and returns
    /// everything that is not ignored.
    pub fn included_files(&self) -> Result<Vec<PathBuf>, error::Error> {
        let mut files: Vec<PathBuf> = vec![];
        let mut roots = vec![self.root.to_path_buf()];

        while let Some(root) = roots.pop() {
            let entries = try!(fs::read_dir(root));

            for entry in entries {
                let path = try!(entry).path();
                if path.ends_with(".git") {
                    continue;
                }

                let matches = self.file_is_excluded(&path);
                if matches.is_err() || try!(matches) {
                    continue;
                }

                files.push(path.to_path_buf());

                let metadata = fs::metadata(&path);
                if !metadata.is_err() && try!(metadata).is_dir() {
                    roots.push(path);
                }
            }
        }

        Ok(files)
    }

    /// Returns true if, after checking against all the patterns found in the `.gitignore` file,
    /// the given path is matched any of the globs (applying negated patterns as expected).
    ///
    /// If the value for `path` is not absolute, it will assumed to be relative to the current
    /// working directory.
    ///
    /// Note very importantly that this method _does not_ check if the parent directories are
    /// excluded. This is only for determining if the file itself matched any rules.
    fn file_is_excluded(&self, path: &'b Path) -> Result<bool, error::Error> {
        let abs_path = self.abs_path(path);
        let directory = try!(fs::metadata(&abs_path)).is_dir();
        Ok(self.patterns.iter().fold(false, |acc, pattern| {
            let matches = pattern.is_excluded(&abs_path, directory);
            if !matches {
                acc
            } else {
                !pattern.negation
            }
        }))
    }

    /// Given the path to the `.gitignore` file and the root folder within which it resides,
    /// parse out all the patterns and collect them up into a vector of patterns.
    fn patterns(path: &'b Path, root: &'b Path) -> Result<Vec<pattern::Pattern<'b>>, error::Error> {
        let mut file = try!(fs::File::open(path));
        let mut s = String::new();
        try!(file.read_to_string(&mut s));
        Ok(s.lines().filter_map(|line| {
            if !line.trim().is_empty() {
                pattern::Pattern::new(line, root).ok()
            } else {
                None
            }
        }).collect())
    }

    /// Given a path, make it absolute if relative by joining it to a given root, otherwise leave
    /// absolute as originally given.
    fn abs_path(&self, path: &'b Path) -> PathBuf {
        if path.is_absolute() {
            path.to_owned()
        } else {
            self.root.join(path)
        }
    }
}

#[cfg(test)]
mod tests {
    extern crate glob;
    extern crate tempdir;

    use super::File;

    use std::fs;
    use std::io::Write;
    use std::path::{Path,PathBuf};

    #[cfg(feature = "nightly")]
    use test::Bencher;

    struct TestEnv<'a> {
        gitignore: &'a Path,
        paths: Vec<PathBuf>
    }

    #[test]
    fn test_new_file_with_empty() {
        with_fake_repo("", vec!["bar.foo"], |test_env| {
            let file = File::new(test_env.gitignore).unwrap();
            for path in test_env.paths.iter() {
                assert!(!file.is_excluded(path.as_path()).unwrap());
            }
        })
    }

    #[test]
    fn test_new_file_with_unanchored_wildcard() {
        with_fake_repo("*.foo", vec!["bar.foo"], |test_env| {
            let file = File::new(test_env.gitignore).unwrap();
            for path in test_env.paths.iter() {
                assert!(file.is_excluded(path.as_path()).unwrap());
            }
        })
    }

    #[test]
    fn test_new_file_with_anchored() {
        with_fake_repo("/out", vec!["out"], |test_env| {
            let file = File::new(test_env.gitignore).unwrap();
            for path in test_env.paths.iter() {
                assert!(file.is_excluded(path.as_path()).unwrap());
            }
        })
    }

    #[test]
    fn test_included_files() {
        with_fake_repo("*.foo", vec!["bar.foo", "foo", "bar"], |test_env| {
            let file = File::new(test_env.gitignore).unwrap();
            let files: Vec<String> = file.included_files().unwrap().iter().map(|path|
                path.file_name().unwrap().to_str().unwrap().to_string()
            ).collect();

            // We can't compare the vec directly, as the order can differ
            // depending on underlying platform. Instead, let's break it
            // apart into the respective assertions.
            assert!(files.len() == 3);
            assert!(files.contains(&".gitignore".to_string()));
            assert!(files.contains(&"bar".to_string()));
            assert!(files.contains(&"foo".to_string()));
        })
    }

    #[test]
    fn test_nested_files() {
        with_fake_repo("woo", vec!["win", "woo/hoo", "woo/boo/shoo"], |test_env| {
            let file = File::new(test_env.gitignore).unwrap();
            let files: Vec<String> = file.included_files().unwrap().iter().map(|path|
                path.file_name().unwrap().to_str().unwrap().to_string()
            ).collect();

            // We can't compare the vec directly, as the order can differ
            // depending on underlying platform. Instead, let's break it
            // apart into the respective assertions
            assert!(files.len() == 2);
            assert!(files.contains(&".gitignore".to_string()));
            assert!(files.contains(&"win".to_string()));
        })
    }

    #[test]
    fn test_included_by_ignore_pattern() {
        with_fake_repo("*\n!assets/\n!assets/**\n!.git*", vec!["assets/foo/bar.html"], |test_env| {
            let file = File::new(test_env.gitignore).unwrap();
            let files: Vec<String> = file.included_files().unwrap().iter().map(|path|
                path.file_name().unwrap().to_str().unwrap().to_string()
            ).collect();

            // We can't compare the vec directly, as the order can differ
            // depending on underlying platform. Instead, let's break it
            // apart into the respective assertions
            assert!(files.len() == 4);
            assert!(files.contains(&".gitignore".to_string()));
            assert!(files.contains(&"assets".to_string()));
            assert!(files.contains(&"foo".to_string()));
            assert!(files.contains(&"bar.html".to_string()));
        })
    }
    #[cfg(feature = "nightly")]
    #[bench]
    fn bench_new_file(b: &mut Bencher) {
        let path = Path::new(".gitignore");
        b.iter(|| {
            File::new(path).unwrap();
        })
    }

    #[cfg(feature = "nightly")]
    #[bench]
    fn bench_file_match(b: &mut Bencher) {
        let file = File::new(Path::new(".gitignore")).unwrap();
        let path = Path::new("/dev/null");

        b.iter(|| {
            file.is_excluded(path).unwrap();
        })
    }

    fn with_fake_repo<F>(ignore_contents: &str, files: Vec<&str>, callback: F)
        where F: Fn(&TestEnv) {
        let dir = tempdir::TempDir::new("gitignore_tests").unwrap();

        let paths = files.iter().map(|file| {
            let path = dir.path().join(file);
            path.parent().map(|parent| fs::create_dir_all(&parent));
            write_to_file(&path, "");
            path
        }).collect();

        let gitignore= dir.path().join(".gitignore");
        write_to_file(gitignore.as_path(), ignore_contents);
        let test_env = TestEnv {
            gitignore: gitignore.as_path(),
            paths: paths
        };

        callback(&test_env);
        dir.close().unwrap();
    }

    fn write_to_file(path: &Path, contents: &str) {
        let mut file = fs::File::create(path).unwrap();
        file.write_all(contents.as_bytes()).unwrap();
    }
}