1 | /*! |
2 | The gitignore module provides a way to match globs from a gitignore file |
3 | against file paths. |
4 | |
5 | Note that this module implements the specification as described in the |
6 | `gitignore` man page from scratch. That is, this module does *not* shell out to |
7 | the `git` command line tool. |
8 | */ |
9 | |
10 | use std::cell::RefCell; |
11 | use std::env; |
12 | use std::fs::File; |
13 | use std::io::{self, BufRead, Read}; |
14 | use std::path::{Path, PathBuf}; |
15 | use std::str; |
16 | use std::sync::Arc; |
17 | |
18 | use globset::{Candidate, GlobBuilder, GlobSet, GlobSetBuilder}; |
19 | use regex::bytes::Regex; |
20 | use thread_local::ThreadLocal; |
21 | |
22 | use crate::pathutil::{is_file_name, strip_prefix}; |
23 | use crate::{Error, Match, PartialErrorBuilder}; |
24 | |
25 | /// Glob represents a single glob in a gitignore file. |
26 | /// |
27 | /// This is used to report information about the highest precedent glob that |
28 | /// matched in one or more gitignore files. |
29 | #[derive (Clone, Debug)] |
30 | pub struct Glob { |
31 | /// The file path that this glob was extracted from. |
32 | from: Option<PathBuf>, |
33 | /// The original glob string. |
34 | original: String, |
35 | /// The actual glob string used to convert to a regex. |
36 | actual: String, |
37 | /// Whether this is a whitelisted glob or not. |
38 | is_whitelist: bool, |
39 | /// Whether this glob should only match directories or not. |
40 | is_only_dir: bool, |
41 | } |
42 | |
43 | impl Glob { |
44 | /// Returns the file path that defined this glob. |
45 | pub fn from(&self) -> Option<&Path> { |
46 | self.from.as_ref().map(|p| &**p) |
47 | } |
48 | |
49 | /// The original glob as it was defined in a gitignore file. |
50 | pub fn original(&self) -> &str { |
51 | &self.original |
52 | } |
53 | |
54 | /// The actual glob that was compiled to respect gitignore |
55 | /// semantics. |
56 | pub fn actual(&self) -> &str { |
57 | &self.actual |
58 | } |
59 | |
60 | /// Whether this was a whitelisted glob or not. |
61 | pub fn is_whitelist(&self) -> bool { |
62 | self.is_whitelist |
63 | } |
64 | |
65 | /// Whether this glob must match a directory or not. |
66 | pub fn is_only_dir(&self) -> bool { |
67 | self.is_only_dir |
68 | } |
69 | |
70 | /// Returns true if and only if this glob has a `**/` prefix. |
71 | fn has_doublestar_prefix(&self) -> bool { |
72 | self.actual.starts_with("**/" ) || self.actual == "**" |
73 | } |
74 | } |
75 | |
76 | /// Gitignore is a matcher for the globs in one or more gitignore files |
77 | /// in the same directory. |
78 | #[derive (Clone, Debug)] |
79 | pub struct Gitignore { |
80 | set: GlobSet, |
81 | root: PathBuf, |
82 | globs: Vec<Glob>, |
83 | num_ignores: u64, |
84 | num_whitelists: u64, |
85 | matches: Option<Arc<ThreadLocal<RefCell<Vec<usize>>>>>, |
86 | } |
87 | |
88 | impl Gitignore { |
89 | /// Creates a new gitignore matcher from the gitignore file path given. |
90 | /// |
91 | /// If it's desirable to include multiple gitignore files in a single |
92 | /// matcher, or read gitignore globs from a different source, then |
93 | /// use `GitignoreBuilder`. |
94 | /// |
95 | /// This always returns a valid matcher, even if it's empty. In particular, |
96 | /// a Gitignore file can be partially valid, e.g., when one glob is invalid |
97 | /// but the rest aren't. |
98 | /// |
99 | /// Note that I/O errors are ignored. For more granular control over |
100 | /// errors, use `GitignoreBuilder`. |
101 | pub fn new<P: AsRef<Path>>( |
102 | gitignore_path: P, |
103 | ) -> (Gitignore, Option<Error>) { |
104 | let path = gitignore_path.as_ref(); |
105 | let parent = path.parent().unwrap_or(Path::new("/" )); |
106 | let mut builder = GitignoreBuilder::new(parent); |
107 | let mut errs = PartialErrorBuilder::default(); |
108 | errs.maybe_push_ignore_io(builder.add(path)); |
109 | match builder.build() { |
110 | Ok(gi) => (gi, errs.into_error_option()), |
111 | Err(err) => { |
112 | errs.push(err); |
113 | (Gitignore::empty(), errs.into_error_option()) |
114 | } |
115 | } |
116 | } |
117 | |
118 | /// Creates a new gitignore matcher from the global ignore file, if one |
119 | /// exists. |
120 | /// |
121 | /// The global config file path is specified by git's `core.excludesFile` |
122 | /// config option. |
123 | /// |
124 | /// Git's config file location is `$HOME/.gitconfig`. If `$HOME/.gitconfig` |
125 | /// does not exist or does not specify `core.excludesFile`, then |
126 | /// `$XDG_CONFIG_HOME/git/ignore` is read. If `$XDG_CONFIG_HOME` is not |
127 | /// set or is empty, then `$HOME/.config/git/ignore` is used instead. |
128 | pub fn global() -> (Gitignore, Option<Error>) { |
129 | GitignoreBuilder::new("" ).build_global() |
130 | } |
131 | |
132 | /// Creates a new empty gitignore matcher that never matches anything. |
133 | /// |
134 | /// Its path is empty. |
135 | pub fn empty() -> Gitignore { |
136 | Gitignore { |
137 | set: GlobSet::empty(), |
138 | root: PathBuf::from("" ), |
139 | globs: vec![], |
140 | num_ignores: 0, |
141 | num_whitelists: 0, |
142 | matches: None, |
143 | } |
144 | } |
145 | |
146 | /// Returns the directory containing this gitignore matcher. |
147 | /// |
148 | /// All matches are done relative to this path. |
149 | pub fn path(&self) -> &Path { |
150 | &*self.root |
151 | } |
152 | |
153 | /// Returns true if and only if this gitignore has zero globs, and |
154 | /// therefore never matches any file path. |
155 | pub fn is_empty(&self) -> bool { |
156 | self.set.is_empty() |
157 | } |
158 | |
159 | /// Returns the total number of globs, which should be equivalent to |
160 | /// `num_ignores + num_whitelists`. |
161 | pub fn len(&self) -> usize { |
162 | self.set.len() |
163 | } |
164 | |
165 | /// Returns the total number of ignore globs. |
166 | pub fn num_ignores(&self) -> u64 { |
167 | self.num_ignores |
168 | } |
169 | |
170 | /// Returns the total number of whitelisted globs. |
171 | pub fn num_whitelists(&self) -> u64 { |
172 | self.num_whitelists |
173 | } |
174 | |
175 | /// Returns whether the given path (file or directory) matched a pattern in |
176 | /// this gitignore matcher. |
177 | /// |
178 | /// `is_dir` should be true if the path refers to a directory and false |
179 | /// otherwise. |
180 | /// |
181 | /// The given path is matched relative to the path given when building |
182 | /// the matcher. Specifically, before matching `path`, its prefix (as |
183 | /// determined by a common suffix of the directory containing this |
184 | /// gitignore) is stripped. If there is no common suffix/prefix overlap, |
185 | /// then `path` is assumed to be relative to this matcher. |
186 | pub fn matched<P: AsRef<Path>>( |
187 | &self, |
188 | path: P, |
189 | is_dir: bool, |
190 | ) -> Match<&Glob> { |
191 | if self.is_empty() { |
192 | return Match::None; |
193 | } |
194 | self.matched_stripped(self.strip(path.as_ref()), is_dir) |
195 | } |
196 | |
197 | /// Returns whether the given path (file or directory, and expected to be |
198 | /// under the root) or any of its parent directories (up to the root) |
199 | /// matched a pattern in this gitignore matcher. |
200 | /// |
201 | /// NOTE: This method is more expensive than walking the directory hierarchy |
202 | /// top-to-bottom and matching the entries. But, is easier to use in cases |
203 | /// when a list of paths are available without a hierarchy. |
204 | /// |
205 | /// `is_dir` should be true if the path refers to a directory and false |
206 | /// otherwise. |
207 | /// |
208 | /// The given path is matched relative to the path given when building |
209 | /// the matcher. Specifically, before matching `path`, its prefix (as |
210 | /// determined by a common suffix of the directory containing this |
211 | /// gitignore) is stripped. If there is no common suffix/prefix overlap, |
212 | /// then `path` is assumed to be relative to this matcher. |
213 | /// |
214 | /// # Panics |
215 | /// |
216 | /// This method panics if the given file path is not under the root path |
217 | /// of this matcher. |
218 | pub fn matched_path_or_any_parents<P: AsRef<Path>>( |
219 | &self, |
220 | path: P, |
221 | is_dir: bool, |
222 | ) -> Match<&Glob> { |
223 | if self.is_empty() { |
224 | return Match::None; |
225 | } |
226 | let mut path = self.strip(path.as_ref()); |
227 | assert!(!path.has_root(), "path is expected to be under the root" ); |
228 | |
229 | match self.matched_stripped(path, is_dir) { |
230 | Match::None => (), // walk up |
231 | a_match => return a_match, |
232 | } |
233 | while let Some(parent) = path.parent() { |
234 | match self.matched_stripped(parent, /* is_dir */ true) { |
235 | Match::None => path = parent, // walk up |
236 | a_match => return a_match, |
237 | } |
238 | } |
239 | Match::None |
240 | } |
241 | |
242 | /// Like matched, but takes a path that has already been stripped. |
243 | fn matched_stripped<P: AsRef<Path>>( |
244 | &self, |
245 | path: P, |
246 | is_dir: bool, |
247 | ) -> Match<&Glob> { |
248 | if self.is_empty() { |
249 | return Match::None; |
250 | } |
251 | let path = path.as_ref(); |
252 | let _matches = self.matches.as_ref().unwrap().get_or_default(); |
253 | let mut matches = _matches.borrow_mut(); |
254 | let candidate = Candidate::new(path); |
255 | self.set.matches_candidate_into(&candidate, &mut *matches); |
256 | for &i in matches.iter().rev() { |
257 | let glob = &self.globs[i]; |
258 | if !glob.is_only_dir() || is_dir { |
259 | return if glob.is_whitelist() { |
260 | Match::Whitelist(glob) |
261 | } else { |
262 | Match::Ignore(glob) |
263 | }; |
264 | } |
265 | } |
266 | Match::None |
267 | } |
268 | |
269 | /// Strips the given path such that it's suitable for matching with this |
270 | /// gitignore matcher. |
271 | fn strip<'a, P: 'a + AsRef<Path> + ?Sized>( |
272 | &'a self, |
273 | path: &'a P, |
274 | ) -> &'a Path { |
275 | let mut path = path.as_ref(); |
276 | // A leading ./ is completely superfluous. We also strip it from |
277 | // our gitignore root path, so we need to strip it from our candidate |
278 | // path too. |
279 | if let Some(p) = strip_prefix("./" , path) { |
280 | path = p; |
281 | } |
282 | // Strip any common prefix between the candidate path and the root |
283 | // of the gitignore, to make sure we get relative matching right. |
284 | // BUT, a file name might not have any directory components to it, |
285 | // in which case, we don't want to accidentally strip any part of the |
286 | // file name. |
287 | // |
288 | // As an additional special case, if the root is just `.`, then we |
289 | // shouldn't try to strip anything, e.g., when path begins with a `.`. |
290 | if self.root != Path::new("." ) && !is_file_name(path) { |
291 | if let Some(p) = strip_prefix(&self.root, path) { |
292 | path = p; |
293 | // If we're left with a leading slash, get rid of it. |
294 | if let Some(p) = strip_prefix("/" , path) { |
295 | path = p; |
296 | } |
297 | } |
298 | } |
299 | path |
300 | } |
301 | } |
302 | |
303 | /// Builds a matcher for a single set of globs from a .gitignore file. |
304 | #[derive (Clone, Debug)] |
305 | pub struct GitignoreBuilder { |
306 | builder: GlobSetBuilder, |
307 | root: PathBuf, |
308 | globs: Vec<Glob>, |
309 | case_insensitive: bool, |
310 | } |
311 | |
312 | impl GitignoreBuilder { |
313 | /// Create a new builder for a gitignore file. |
314 | /// |
315 | /// The path given should be the path at which the globs for this gitignore |
316 | /// file should be matched. Note that paths are always matched relative |
317 | /// to the root path given here. Generally, the root path should correspond |
318 | /// to the *directory* containing a `.gitignore` file. |
319 | pub fn new<P: AsRef<Path>>(root: P) -> GitignoreBuilder { |
320 | let root = root.as_ref(); |
321 | GitignoreBuilder { |
322 | builder: GlobSetBuilder::new(), |
323 | root: strip_prefix("./" , root).unwrap_or(root).to_path_buf(), |
324 | globs: vec![], |
325 | case_insensitive: false, |
326 | } |
327 | } |
328 | |
329 | /// Builds a new matcher from the globs added so far. |
330 | /// |
331 | /// Once a matcher is built, no new globs can be added to it. |
332 | pub fn build(&self) -> Result<Gitignore, Error> { |
333 | let nignore = self.globs.iter().filter(|g| !g.is_whitelist()).count(); |
334 | let nwhite = self.globs.iter().filter(|g| g.is_whitelist()).count(); |
335 | let set = self |
336 | .builder |
337 | .build() |
338 | .map_err(|err| Error::Glob { glob: None, err: err.to_string() })?; |
339 | Ok(Gitignore { |
340 | set: set, |
341 | root: self.root.clone(), |
342 | globs: self.globs.clone(), |
343 | num_ignores: nignore as u64, |
344 | num_whitelists: nwhite as u64, |
345 | matches: Some(Arc::new(ThreadLocal::default())), |
346 | }) |
347 | } |
348 | |
349 | /// Build a global gitignore matcher using the configuration in this |
350 | /// builder. |
351 | /// |
352 | /// This consumes ownership of the builder unlike `build` because it |
353 | /// must mutate the builder to add the global gitignore globs. |
354 | /// |
355 | /// Note that this ignores the path given to this builder's constructor |
356 | /// and instead derives the path automatically from git's global |
357 | /// configuration. |
358 | pub fn build_global(mut self) -> (Gitignore, Option<Error>) { |
359 | match gitconfig_excludes_path() { |
360 | None => (Gitignore::empty(), None), |
361 | Some(path) => { |
362 | if !path.is_file() { |
363 | (Gitignore::empty(), None) |
364 | } else { |
365 | let mut errs = PartialErrorBuilder::default(); |
366 | errs.maybe_push_ignore_io(self.add(path)); |
367 | match self.build() { |
368 | Ok(gi) => (gi, errs.into_error_option()), |
369 | Err(err) => { |
370 | errs.push(err); |
371 | (Gitignore::empty(), errs.into_error_option()) |
372 | } |
373 | } |
374 | } |
375 | } |
376 | } |
377 | } |
378 | |
379 | /// Add each glob from the file path given. |
380 | /// |
381 | /// The file given should be formatted as a `gitignore` file. |
382 | /// |
383 | /// Note that partial errors can be returned. For example, if there was |
384 | /// a problem adding one glob, an error for that will be returned, but |
385 | /// all other valid globs will still be added. |
386 | pub fn add<P: AsRef<Path>>(&mut self, path: P) -> Option<Error> { |
387 | let path = path.as_ref(); |
388 | let file = match File::open(path) { |
389 | Err(err) => return Some(Error::Io(err).with_path(path)), |
390 | Ok(file) => file, |
391 | }; |
392 | let rdr = io::BufReader::new(file); |
393 | let mut errs = PartialErrorBuilder::default(); |
394 | for (i, line) in rdr.lines().enumerate() { |
395 | let lineno = (i + 1) as u64; |
396 | let line = match line { |
397 | Ok(line) => line, |
398 | Err(err) => { |
399 | errs.push(Error::Io(err).tagged(path, lineno)); |
400 | break; |
401 | } |
402 | }; |
403 | if let Err(err) = self.add_line(Some(path.to_path_buf()), &line) { |
404 | errs.push(err.tagged(path, lineno)); |
405 | } |
406 | } |
407 | errs.into_error_option() |
408 | } |
409 | |
410 | /// Add each glob line from the string given. |
411 | /// |
412 | /// If this string came from a particular `gitignore` file, then its path |
413 | /// should be provided here. |
414 | /// |
415 | /// The string given should be formatted as a `gitignore` file. |
416 | #[cfg (test)] |
417 | fn add_str( |
418 | &mut self, |
419 | from: Option<PathBuf>, |
420 | gitignore: &str, |
421 | ) -> Result<&mut GitignoreBuilder, Error> { |
422 | for line in gitignore.lines() { |
423 | self.add_line(from.clone(), line)?; |
424 | } |
425 | Ok(self) |
426 | } |
427 | |
428 | /// Add a line from a gitignore file to this builder. |
429 | /// |
430 | /// If this line came from a particular `gitignore` file, then its path |
431 | /// should be provided here. |
432 | /// |
433 | /// If the line could not be parsed as a glob, then an error is returned. |
434 | pub fn add_line( |
435 | &mut self, |
436 | from: Option<PathBuf>, |
437 | mut line: &str, |
438 | ) -> Result<&mut GitignoreBuilder, Error> { |
439 | #![allow (deprecated)] |
440 | |
441 | if line.starts_with("#" ) { |
442 | return Ok(self); |
443 | } |
444 | if !line.ends_with(" \\ " ) { |
445 | line = line.trim_right(); |
446 | } |
447 | if line.is_empty() { |
448 | return Ok(self); |
449 | } |
450 | let mut glob = Glob { |
451 | from: from, |
452 | original: line.to_string(), |
453 | actual: String::new(), |
454 | is_whitelist: false, |
455 | is_only_dir: false, |
456 | }; |
457 | let mut is_absolute = false; |
458 | if line.starts_with(" \\!" ) || line.starts_with(" \\#" ) { |
459 | line = &line[1..]; |
460 | is_absolute = line.chars().nth(0) == Some('/' ); |
461 | } else { |
462 | if line.starts_with("!" ) { |
463 | glob.is_whitelist = true; |
464 | line = &line[1..]; |
465 | } |
466 | if line.starts_with("/" ) { |
467 | // `man gitignore` says that if a glob starts with a slash, |
468 | // then the glob can only match the beginning of a path |
469 | // (relative to the location of gitignore). We achieve this by |
470 | // simply banning wildcards from matching /. |
471 | line = &line[1..]; |
472 | is_absolute = true; |
473 | } |
474 | } |
475 | // If it ends with a slash, then this should only match directories, |
476 | // but the slash should otherwise not be used while globbing. |
477 | if line.as_bytes().last() == Some(&b'/' ) { |
478 | glob.is_only_dir = true; |
479 | line = &line[..line.len() - 1]; |
480 | // If the slash was escaped, then remove the escape. |
481 | // See: https://github.com/BurntSushi/ripgrep/issues/2236 |
482 | if line.as_bytes().last() == Some(&b' \\' ) { |
483 | line = &line[..line.len() - 1]; |
484 | } |
485 | } |
486 | glob.actual = line.to_string(); |
487 | // If there is a literal slash, then this is a glob that must match the |
488 | // entire path name. Otherwise, we should let it match anywhere, so use |
489 | // a **/ prefix. |
490 | if !is_absolute && !line.chars().any(|c| c == '/' ) { |
491 | // ... but only if we don't already have a **/ prefix. |
492 | if !glob.has_doublestar_prefix() { |
493 | glob.actual = format!("**/ {}" , glob.actual); |
494 | } |
495 | } |
496 | // If the glob ends with `/**`, then we should only match everything |
497 | // inside a directory, but not the directory itself. Standard globs |
498 | // will match the directory. So we add `/*` to force the issue. |
499 | if glob.actual.ends_with("/**" ) { |
500 | glob.actual = format!(" {}/*" , glob.actual); |
501 | } |
502 | let parsed = GlobBuilder::new(&glob.actual) |
503 | .literal_separator(true) |
504 | .case_insensitive(self.case_insensitive) |
505 | .backslash_escape(true) |
506 | .build() |
507 | .map_err(|err| Error::Glob { |
508 | glob: Some(glob.original.clone()), |
509 | err: err.kind().to_string(), |
510 | })?; |
511 | self.builder.add(parsed); |
512 | self.globs.push(glob); |
513 | Ok(self) |
514 | } |
515 | |
516 | /// Toggle whether the globs should be matched case insensitively or not. |
517 | /// |
518 | /// When this option is changed, only globs added after the change will be |
519 | /// affected. |
520 | /// |
521 | /// This is disabled by default. |
522 | pub fn case_insensitive( |
523 | &mut self, |
524 | yes: bool, |
525 | ) -> Result<&mut GitignoreBuilder, Error> { |
526 | // TODO: This should not return a `Result`. Fix this in the next semver |
527 | // release. |
528 | self.case_insensitive = yes; |
529 | Ok(self) |
530 | } |
531 | } |
532 | |
533 | /// Return the file path of the current environment's global gitignore file. |
534 | /// |
535 | /// Note that the file path returned may not exist. |
536 | fn gitconfig_excludes_path() -> Option<PathBuf> { |
537 | // git supports $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config. Notably, |
538 | // both can be active at the same time, where $HOME/.gitconfig takes |
539 | // precedent. So if $HOME/.gitconfig defines a `core.excludesFile`, then |
540 | // we're done. |
541 | match gitconfig_home_contents().and_then(|x: Vec| parse_excludes_file(&x)) { |
542 | Some(path: PathBuf) => return Some(path), |
543 | None => {} |
544 | } |
545 | match gitconfig_xdg_contents().and_then(|x: Vec| parse_excludes_file(&x)) { |
546 | Some(path: PathBuf) => return Some(path), |
547 | None => {} |
548 | } |
549 | excludes_file_default() |
550 | } |
551 | |
552 | /// Returns the file contents of git's global config file, if one exists, in |
553 | /// the user's home directory. |
554 | fn gitconfig_home_contents() -> Option<Vec<u8>> { |
555 | let home: PathBuf = match home_dir() { |
556 | None => return None, |
557 | Some(home: PathBuf) => home, |
558 | }; |
559 | let mut file: BufReader = match File::open(path:home.join(path:".gitconfig" )) { |
560 | Err(_) => return None, |
561 | Ok(file: File) => io::BufReader::new(inner:file), |
562 | }; |
563 | let mut contents: Vec = vec![]; |
564 | file.read_to_end(&mut contents).ok().map(|_| contents) |
565 | } |
566 | |
567 | /// Returns the file contents of git's global config file, if one exists, in |
568 | /// the user's XDG_CONFIG_HOME directory. |
569 | fn gitconfig_xdg_contents() -> Option<Vec<u8>> { |
570 | let path: Option = envOption::var_os(key:"XDG_CONFIG_HOME" ) |
571 | .and_then(|x: OsString| if x.is_empty() { None } else { Some(PathBuf::from(x)) }) |
572 | .or_else(|| home_dir().map(|p: PathBuf| p.join(path:".config" ))) |
573 | .map(|x: PathBuf| x.join(path:"git/config" )); |
574 | let mut file: BufReader = match path.and_then(|p: PathBuf| File::open(path:p).ok()) { |
575 | None => return None, |
576 | Some(file: File) => io::BufReader::new(inner:file), |
577 | }; |
578 | let mut contents: Vec = vec![]; |
579 | file.read_to_end(&mut contents).ok().map(|_| contents) |
580 | } |
581 | |
582 | /// Returns the default file path for a global .gitignore file. |
583 | /// |
584 | /// Specifically, this respects XDG_CONFIG_HOME. |
585 | fn excludes_file_default() -> Option<PathBuf> { |
586 | envOption::var_os(key:"XDG_CONFIG_HOME" ) |
587 | .and_then(|x: OsString| if x.is_empty() { None } else { Some(PathBuf::from(x)) }) |
588 | .or_else(|| home_dir().map(|p: PathBuf| p.join(path:".config" ))) |
589 | .map(|x: PathBuf| x.join(path:"git/ignore" )) |
590 | } |
591 | |
592 | /// Extract git's `core.excludesfile` config setting from the raw file contents |
593 | /// given. |
594 | fn parse_excludes_file(data: &[u8]) -> Option<PathBuf> { |
595 | // N.B. This is the lazy approach, and isn't technically correct, but |
596 | // probably works in more circumstances. I guess we would ideally have |
597 | // a full INI parser. Yuck. |
598 | lazy_static::lazy_static! { |
599 | static ref RE: Regex = |
600 | Regex::new(r"(?im)^\s*excludesfile\s*=\s*(.+)\s*$" ).unwrap(); |
601 | }; |
602 | let caps: Captures<'_> = match RE.captures(text:data) { |
603 | None => return None, |
604 | Some(caps: Captures<'_>) => caps, |
605 | }; |
606 | str::from_utf8(&caps[1]).ok().map(|s: &str| PathBuf::from(expand_tilde(path:s))) |
607 | } |
608 | |
609 | /// Expands ~ in file paths to the value of $HOME. |
610 | fn expand_tilde(path: &str) -> String { |
611 | let home: String = match home_dir() { |
612 | None => return path.to_string(), |
613 | Some(home: PathBuf) => home.to_string_lossy().into_owned(), |
614 | }; |
615 | path.replace(from:"~" , &home) |
616 | } |
617 | |
618 | /// Returns the location of the user's home directory. |
619 | fn home_dir() -> Option<PathBuf> { |
620 | // We're fine with using env::home_dir for now. Its bugs are, IMO, pretty |
621 | // minor corner cases. We should still probably eventually migrate to |
622 | // the `dirs` crate to get a proper implementation. |
623 | #![allow (deprecated)] |
624 | env::home_dir() |
625 | } |
626 | |
627 | #[cfg (test)] |
628 | mod tests { |
629 | use super::{Gitignore, GitignoreBuilder}; |
630 | use std::path::Path; |
631 | |
632 | fn gi_from_str<P: AsRef<Path>>(root: P, s: &str) -> Gitignore { |
633 | let mut builder = GitignoreBuilder::new(root); |
634 | builder.add_str(None, s).unwrap(); |
635 | builder.build().unwrap() |
636 | } |
637 | |
638 | macro_rules! ignored { |
639 | ($name:ident, $root:expr, $gi:expr, $path:expr) => { |
640 | ignored!($name, $root, $gi, $path, false); |
641 | }; |
642 | ($name:ident, $root:expr, $gi:expr, $path:expr, $is_dir:expr) => { |
643 | #[test] |
644 | fn $name() { |
645 | let gi = gi_from_str($root, $gi); |
646 | assert!(gi.matched($path, $is_dir).is_ignore()); |
647 | } |
648 | }; |
649 | } |
650 | |
651 | macro_rules! not_ignored { |
652 | ($name:ident, $root:expr, $gi:expr, $path:expr) => { |
653 | not_ignored!($name, $root, $gi, $path, false); |
654 | }; |
655 | ($name:ident, $root:expr, $gi:expr, $path:expr, $is_dir:expr) => { |
656 | #[test] |
657 | fn $name() { |
658 | let gi = gi_from_str($root, $gi); |
659 | assert!(!gi.matched($path, $is_dir).is_ignore()); |
660 | } |
661 | }; |
662 | } |
663 | |
664 | const ROOT: &'static str = "/home/foobar/rust/rg" ; |
665 | |
666 | ignored!(ig1, ROOT, "months" , "months" ); |
667 | ignored!(ig2, ROOT, "*.lock" , "Cargo.lock" ); |
668 | ignored!(ig3, ROOT, "*.rs" , "src/main.rs" ); |
669 | ignored!(ig4, ROOT, "src/*.rs" , "src/main.rs" ); |
670 | ignored!(ig5, ROOT, "/*.c" , "cat-file.c" ); |
671 | ignored!(ig6, ROOT, "/src/*.rs" , "src/main.rs" ); |
672 | ignored!(ig7, ROOT, "!src/main.rs \n*.rs" , "src/main.rs" ); |
673 | ignored!(ig8, ROOT, "foo/" , "foo" , true); |
674 | ignored!(ig9, ROOT, "**/foo" , "foo" ); |
675 | ignored!(ig10, ROOT, "**/foo" , "src/foo" ); |
676 | ignored!(ig11, ROOT, "**/foo/**" , "src/foo/bar" ); |
677 | ignored!(ig12, ROOT, "**/foo/**" , "wat/src/foo/bar/baz" ); |
678 | ignored!(ig13, ROOT, "**/foo/bar" , "foo/bar" ); |
679 | ignored!(ig14, ROOT, "**/foo/bar" , "src/foo/bar" ); |
680 | ignored!(ig15, ROOT, "abc/**" , "abc/x" ); |
681 | ignored!(ig16, ROOT, "abc/**" , "abc/x/y" ); |
682 | ignored!(ig17, ROOT, "abc/**" , "abc/x/y/z" ); |
683 | ignored!(ig18, ROOT, "a/**/b" , "a/b" ); |
684 | ignored!(ig19, ROOT, "a/**/b" , "a/x/b" ); |
685 | ignored!(ig20, ROOT, "a/**/b" , "a/x/y/b" ); |
686 | ignored!(ig21, ROOT, r"\!xy" , "!xy" ); |
687 | ignored!(ig22, ROOT, r"\#foo" , "#foo" ); |
688 | ignored!(ig23, ROOT, "foo" , "./foo" ); |
689 | ignored!(ig24, ROOT, "target" , "grep/target" ); |
690 | ignored!(ig25, ROOT, "Cargo.lock" , "./tabwriter-bin/Cargo.lock" ); |
691 | ignored!(ig26, ROOT, "/foo/bar/baz" , "./foo/bar/baz" ); |
692 | ignored!(ig27, ROOT, "foo/" , "xyz/foo" , true); |
693 | ignored!(ig28, "./src" , "/llvm/" , "./src/llvm" , true); |
694 | ignored!(ig29, ROOT, "node_modules/ " , "node_modules" , true); |
695 | ignored!(ig30, ROOT, "**/" , "foo/bar" , true); |
696 | ignored!(ig31, ROOT, "path1/*" , "path1/foo" ); |
697 | ignored!(ig32, ROOT, ".a/b" , ".a/b" ); |
698 | ignored!(ig33, "./" , ".a/b" , ".a/b" ); |
699 | ignored!(ig34, "." , ".a/b" , ".a/b" ); |
700 | ignored!(ig35, "./." , ".a/b" , ".a/b" ); |
701 | ignored!(ig36, "././" , ".a/b" , ".a/b" ); |
702 | ignored!(ig37, "././." , ".a/b" , ".a/b" ); |
703 | ignored!(ig38, ROOT, " \\[" , "[" ); |
704 | ignored!(ig39, ROOT, " \\?" , "?" ); |
705 | ignored!(ig40, ROOT, " \\*" , "*" ); |
706 | ignored!(ig41, ROOT, " \\a" , "a" ); |
707 | ignored!(ig42, ROOT, "s*.rs" , "sfoo.rs" ); |
708 | ignored!(ig43, ROOT, "**" , "foo.rs" ); |
709 | ignored!(ig44, ROOT, "**/**/*" , "a/foo.rs" ); |
710 | |
711 | not_ignored!(ignot1, ROOT, "amonths" , "months" ); |
712 | not_ignored!(ignot2, ROOT, "monthsa" , "months" ); |
713 | not_ignored!(ignot3, ROOT, "/src/*.rs" , "src/grep/src/main.rs" ); |
714 | not_ignored!(ignot4, ROOT, "/*.c" , "mozilla-sha1/sha1.c" ); |
715 | not_ignored!(ignot5, ROOT, "/src/*.rs" , "src/grep/src/main.rs" ); |
716 | not_ignored!(ignot6, ROOT, "*.rs \n!src/main.rs" , "src/main.rs" ); |
717 | not_ignored!(ignot7, ROOT, "foo/" , "foo" , false); |
718 | not_ignored!(ignot8, ROOT, "**/foo/**" , "wat/src/afoo/bar/baz" ); |
719 | not_ignored!(ignot9, ROOT, "**/foo/**" , "wat/src/fooa/bar/baz" ); |
720 | not_ignored!(ignot10, ROOT, "**/foo/bar" , "foo/src/bar" ); |
721 | not_ignored!(ignot11, ROOT, "#foo" , "#foo" ); |
722 | not_ignored!(ignot12, ROOT, " \n\n\n" , "foo" ); |
723 | not_ignored!(ignot13, ROOT, "foo/**" , "foo" , true); |
724 | not_ignored!( |
725 | ignot14, |
726 | "./third_party/protobuf" , |
727 | "m4/ltoptions.m4" , |
728 | "./third_party/protobuf/csharp/src/packages/repositories.config" |
729 | ); |
730 | not_ignored!(ignot15, ROOT, "!/bar" , "foo/bar" ); |
731 | not_ignored!(ignot16, ROOT, "* \n!**/" , "foo" , true); |
732 | not_ignored!(ignot17, ROOT, "src/*.rs" , "src/grep/src/main.rs" ); |
733 | not_ignored!(ignot18, ROOT, "path1/*" , "path2/path1/foo" ); |
734 | not_ignored!(ignot19, ROOT, "s*.rs" , "src/foo.rs" ); |
735 | |
736 | fn bytes(s: &str) -> Vec<u8> { |
737 | s.to_string().into_bytes() |
738 | } |
739 | |
740 | fn path_string<P: AsRef<Path>>(path: P) -> String { |
741 | path.as_ref().to_str().unwrap().to_string() |
742 | } |
743 | |
744 | #[test ] |
745 | fn parse_excludes_file1() { |
746 | let data = bytes("[core] \nexcludesFile = /foo/bar" ); |
747 | let got = super::parse_excludes_file(&data).unwrap(); |
748 | assert_eq!(path_string(got), "/foo/bar" ); |
749 | } |
750 | |
751 | #[test ] |
752 | fn parse_excludes_file2() { |
753 | let data = bytes("[core] \nexcludesFile = ~/foo/bar" ); |
754 | let got = super::parse_excludes_file(&data).unwrap(); |
755 | assert_eq!(path_string(got), super::expand_tilde("~/foo/bar" )); |
756 | } |
757 | |
758 | #[test ] |
759 | fn parse_excludes_file3() { |
760 | let data = bytes("[core] \nexcludeFile = /foo/bar" ); |
761 | assert!(super::parse_excludes_file(&data).is_none()); |
762 | } |
763 | |
764 | // See: https://github.com/BurntSushi/ripgrep/issues/106 |
765 | #[test ] |
766 | fn regression_106() { |
767 | gi_from_str("/" , " " ); |
768 | } |
769 | |
770 | #[test ] |
771 | fn case_insensitive() { |
772 | let gi = GitignoreBuilder::new(ROOT) |
773 | .case_insensitive(true) |
774 | .unwrap() |
775 | .add_str(None, "*.html" ) |
776 | .unwrap() |
777 | .build() |
778 | .unwrap(); |
779 | assert!(gi.matched("foo.html" , false).is_ignore()); |
780 | assert!(gi.matched("foo.HTML" , false).is_ignore()); |
781 | assert!(!gi.matched("foo.htm" , false).is_ignore()); |
782 | assert!(!gi.matched("foo.HTM" , false).is_ignore()); |
783 | } |
784 | |
785 | ignored!(cs1, ROOT, "*.html" , "foo.html" ); |
786 | not_ignored!(cs2, ROOT, "*.html" , "foo.HTML" ); |
787 | not_ignored!(cs3, ROOT, "*.html" , "foo.htm" ); |
788 | not_ignored!(cs4, ROOT, "*.html" , "foo.HTM" ); |
789 | } |
790 | |