use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use anyhow::Result;
use crate::arg_parser::FilePatternArgs;
use crate::configuration::ResolvedConfig;
use crate::environment::CanonicalizedPathBuf;
use crate::environment::Environment;
use crate::utils::ExcludeMatchDetail;
use crate::utils::GitIgnoreTree;
use crate::utils::GitIgnoreTreeOptions;
use crate::utils::GlobMatcher;
use crate::utils::GlobMatcherOptions;
use crate::utils::GlobMatchesDetail;
use crate::utils::GlobPattern;
use crate::utils::GlobPatterns;
use crate::utils::is_absolute_pattern;
use crate::utils::is_negated_glob;
use crate::utils::non_negated_glob;
use crate::utils::resolve_global_gitignore_lines;
use crate::utils::rewrite_literal_arg_pattern;
use crate::utils::rewrite_literal_arg_patterns;
pub struct FileMatcherOptions<'a> {
pub config: &'a ResolvedConfig,
pub args: &'a FilePatternArgs,
pub root_dir: &'a CanonicalizedPathBuf,
pub specified_file_path: Option<&'a Path>,
pub detect_gitignore_changes: bool,
}
pub struct FileMatcher<TEnvironment: Environment> {
glob_matcher: GlobMatcher,
gitignores: Option<GitIgnoreTree<TEnvironment>>,
}
impl<TEnvironment: Environment> FileMatcher<TEnvironment> {
pub fn new(environment: TEnvironment, opts: FileMatcherOptions) -> Result<Self> {
let FileMatcherOptions {
config,
args,
root_dir,
specified_file_path,
detect_gitignore_changes,
} = opts;
let mut patterns = get_all_file_patterns(config, args, root_dir, &environment);
rewrite_literal_arg_patterns(&environment, &mut patterns, &config.base_path);
let gitignores = if args.no_gitignore {
None
} else {
let global_gitignore_lines = resolve_global_gitignore_lines(&environment);
let mut include_paths = patterns.include_paths();
if let Some(path) = specified_file_path {
include_paths.push(path.to_path_buf());
}
Some(GitIgnoreTree::new(
environment,
GitIgnoreTreeOptions {
include_paths,
global_gitignore_lines,
detect_changes: detect_gitignore_changes,
},
))
};
let glob_matcher = GlobMatcher::new(
patterns,
&GlobMatcherOptions {
case_sensitive: true,
base_dir: config.base_path.clone(),
},
)?;
Ok(FileMatcher { glob_matcher, gitignores })
}
pub fn matches_and_dir_not_ignored(&mut self, file_path: &Path) -> bool {
let match_result = self.glob_matcher.matches_detail(file_path);
if matches!(match_result, GlobMatchesDetail::Excluded | GlobMatchesDetail::NotMatched) {
return false;
}
if let Some(gitignores) = self.gitignores.as_mut() {
gitignores.refresh_for_file(file_path);
}
if matches!(match_result, GlobMatchesDetail::Matched) && self.is_gitignored(file_path, false) {
return false;
}
if !file_path.starts_with(self.glob_matcher.base_dir()) {
return false;
}
for ancestor in file_path.ancestors().skip(1) {
if ancestor == self.glob_matcher.base_dir().as_ref() {
break;
}
if let Ok(path) = ancestor.strip_prefix(self.glob_matcher.base_dir()) {
match self.glob_matcher.check_exclude(path, true) {
ExcludeMatchDetail::Excluded => return false,
ExcludeMatchDetail::OptedOutExclude => {}
ExcludeMatchDetail::NotExcluded => {
if self.is_gitignored(ancestor, true) {
return false;
}
}
}
} else {
break;
}
}
true
}
fn is_gitignored(&mut self, path: &Path, is_dir: bool) -> bool {
let Some(gitignores) = self.gitignores.as_mut() else {
return false;
};
let Some(gitignore) = gitignores.get_resolved_git_ignore_for_file(path) else {
return false;
};
gitignore.is_ignored(path, is_dir)
}
}
pub struct OrderedPatternsMatcher {
matcher: GlobMatcher,
}
impl OrderedPatternsMatcher {
pub fn new(patterns: &[String], config_base_path: &CanonicalizedPathBuf) -> Result<Self> {
let matcher = GlobMatcher::new(
GlobPatterns {
shebangs: Vec::new(),
arg_includes: None,
config_includes: None,
arg_excludes: None,
config_excludes: process_config_patterns(patterns)
.map(|pattern| new_config_glob_pattern(pattern, config_base_path).invert())
.collect(),
},
&GlobMatcherOptions {
case_sensitive: true,
base_dir: config_base_path.clone(),
},
)?;
Ok(Self { matcher })
}
pub fn matches(&self, path: impl AsRef<Path>) -> bool {
self.matcher.exclude_detail(path) == ExcludeMatchDetail::OptedOutExclude
}
pub fn is_excluded(&self, path: impl AsRef<Path>) -> bool {
self.matcher.exclude_detail(path) == ExcludeMatchDetail::Excluded
}
}
pub fn get_all_file_patterns(config: &ResolvedConfig, args: &FilePatternArgs, cwd: &CanonicalizedPathBuf, environment: &impl Environment) -> GlobPatterns {
GlobPatterns {
config_includes: get_config_includes_file_patterns(config, args, cwd, environment),
arg_includes: args
.include_patterns
.as_ref()
.map(|patterns| patterns.iter().map(|p| process_cli_pattern(p, cwd, environment)).collect()),
config_excludes: get_config_exclude_file_patterns(config, args, cwd, environment),
arg_excludes: if args.exclude_patterns.is_empty() {
None
} else {
Some(args.exclude_patterns.iter().map(|p| process_cli_pattern(p, cwd, environment)).collect())
},
shebangs: match (&args.include_pattern_overrides, &config.shebangs) {
(None, Some(shebangs)) => shebangs.keys().cloned().collect(),
_ => Vec::new(),
},
}
}
fn get_config_includes_file_patterns(
config: &ResolvedConfig,
args: &FilePatternArgs,
cwd: &CanonicalizedPathBuf,
environment: &impl Environment,
) -> Option<Vec<GlobPattern>> {
let mut file_patterns = Vec::new();
file_patterns.extend(match &args.include_pattern_overrides {
Some(includes_overrides) => {
includes_overrides
.iter()
.map(|p| process_cli_override_pattern(p, cwd, config, environment))
.collect()
}
None => new_config_glob_patterns(process_config_patterns(config.includes.as_ref()?), &config.base_path),
});
Some(file_patterns)
}
fn get_config_exclude_file_patterns(
config: &ResolvedConfig,
args: &FilePatternArgs,
cwd: &CanonicalizedPathBuf,
environment: &impl Environment,
) -> Vec<GlobPattern> {
let mut file_patterns = Vec::new();
file_patterns.extend(match &args.exclude_pattern_overrides {
Some(exclude_overrides) => {
exclude_overrides
.iter()
.map(|p| process_cli_override_pattern(p, cwd, config, environment))
.collect::<Vec<_>>()
}
None => config
.excludes
.as_ref()
.map(|excludes| new_config_glob_patterns(process_config_patterns(excludes), &config.base_path))
.unwrap_or_default(),
});
if !args.allow_node_modules && !is_in_node_modules(cwd.as_ref()) {
let node_modules_exclude = String::from("**/node_modules");
let exclude_node_module_patterns = [
GlobPattern::new(node_modules_exclude.clone(), cwd.clone()),
GlobPattern::new(node_modules_exclude, config.base_path.clone()),
];
for node_modules_exclude in exclude_node_module_patterns {
if !file_patterns.contains(&node_modules_exclude) {
file_patterns.push(node_modules_exclude);
}
}
}
file_patterns
}
pub fn process_cli_path_args(paths: &[PathBuf], cwd: &CanonicalizedPathBuf, environment: &impl Environment) -> Vec<GlobPattern> {
paths
.iter()
.map(|path| process_cli_pattern(&path.to_string_lossy(), cwd, environment))
.collect()
}
fn is_in_node_modules(dir: &Path) -> bool {
dir.components().any(|component| component.as_os_str() == "node_modules")
}
fn process_file_pattern_slashes(file_pattern: &str) -> String {
file_pattern.replace('\\', "/")
}
fn process_cli_override_pattern(file_pattern: &str, cwd: &CanonicalizedPathBuf, config: &ResolvedConfig, environment: &impl Environment) -> GlobPattern {
let mut pattern = process_cli_pattern(file_pattern, cwd, environment);
rewrite_literal_arg_pattern(environment, &mut pattern, &config.base_path);
pattern
}
fn process_cli_pattern(file_pattern: &str, cwd: &CanonicalizedPathBuf, environment: &impl Environment) -> GlobPattern {
let file_pattern = process_file_pattern_slashes(file_pattern);
let is_negated = is_negated_glob(&file_pattern);
let pattern = non_negated_glob(&file_pattern);
if pattern == "." {
return GlobPattern::new(if is_negated { "!./." } else { "**" }.to_string(), cwd.clone());
}
let absolute_pattern = normalize_path(if is_absolute_pattern(&file_pattern) {
PathBuf::from(pattern)
} else {
cwd.join(pattern)
});
let mut base_dir = cwd.clone();
loop {
if let Ok(relative_pattern) = absolute_pattern.strip_prefix(base_dir.as_ref()) {
return build_cli_pattern(relative_pattern, is_negated, base_dir);
}
let Some(parent) = base_dir.parent() else {
break;
};
base_dir = parent;
}
if let Some(root_dir) = absolute_pattern.ancestors().last().filter(|p| !p.as_os_str().is_empty())
&& let Ok(relative_pattern) = absolute_pattern.strip_prefix(root_dir)
&& let Ok(root_dir) = environment.canonicalize(root_dir)
{
return build_cli_pattern(relative_pattern, is_negated, root_dir);
}
GlobPattern::new(file_pattern, cwd.clone())
}
fn build_cli_pattern(relative_pattern: &Path, is_negated: bool, base_dir: CanonicalizedPathBuf) -> GlobPattern {
let relative_pattern = process_file_pattern_slashes(&relative_pattern.to_string_lossy());
let relative_pattern = format!("{}./{}", if is_negated { "!" } else { "" }, relative_pattern);
GlobPattern::new(relative_pattern, base_dir)
}
fn normalize_path(path: PathBuf) -> PathBuf {
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
result.pop();
}
component => result.push(component.as_os_str()),
}
}
result
}
pub fn new_config_glob_patterns(patterns: impl IntoIterator<Item = String>, config_base_path: &CanonicalizedPathBuf) -> Vec<GlobPattern> {
patterns.into_iter().map(|pattern| new_config_glob_pattern(pattern, config_base_path)).collect()
}
pub fn new_config_glob_pattern(pattern: String, config_base_path: &CanonicalizedPathBuf) -> GlobPattern {
let is_negated = is_negated_glob(&pattern);
let non_negated = non_negated_glob(&pattern);
let mut remaining = non_negated.strip_prefix("./").unwrap_or(non_negated);
if !remaining.starts_with("../") {
return GlobPattern::new(pattern, config_base_path.clone());
}
let mut base_dir = config_base_path.clone();
while let Some(rest) = remaining.strip_prefix("../") {
if let Some(parent) = base_dir.parent() {
base_dir = parent;
}
remaining = rest;
}
let relative_pattern = format!("{}./{}", if is_negated { "!" } else { "" }, remaining);
GlobPattern::new(relative_pattern, base_dir)
}
pub fn process_config_patterns(file_patterns: &[String]) -> impl Iterator<Item = String> + '_ {
file_patterns.iter().map(|p| process_config_pattern(p))
}
pub fn process_config_pattern(file_pattern: &str) -> String {
let file_pattern = process_file_pattern_slashes(file_pattern);
if file_pattern.starts_with('/') {
format!(".{}", file_pattern)
} else if file_pattern.starts_with("!/") {
format!("!.{}", &file_pattern[1..])
} else {
file_pattern
}
}
#[cfg(test)]
mod test {
use std::path::PathBuf;
use crate::environment::TestEnvironment;
use super::*;
#[test]
fn should_get_if_in_node_modules() {
assert!(is_in_node_modules(Path::new("/node_modules")));
assert!(is_in_node_modules(Path::new("/node_modules/pkg")));
assert!(is_in_node_modules(Path::new("/a/node_modules/pkg/sub")));
assert!(!is_in_node_modules(Path::new("/")));
assert!(!is_in_node_modules(Path::new("/a/sub")));
assert!(!is_in_node_modules(Path::new("/a/my_node_modules/pkg")));
assert!(!is_in_node_modules(Path::new("/a/node_modules_old")));
}
#[test]
fn should_process_cli_patterns() {
assert_cli_pattern("/test", "/", "./test", "/");
assert_cli_pattern("./test", "/", "./test", "/");
assert_cli_pattern("test", "/", "./test", "/");
assert_cli_pattern("**/test", "/", "./**/test", "/");
assert_cli_pattern("!/test", "/", "!./test", "/");
assert_cli_pattern("!./test", "/", "!./test", "/");
assert_cli_pattern("!test", "/", "!./test", "/");
assert_cli_pattern("!**/test", "/", "!./**/test", "/");
assert_cli_pattern("!.", "/", "!./.", "/");
assert_cli_pattern("../test", "/sub", "./test", "/");
assert_cli_pattern("/test", "/sub", "./test", "/");
}
#[cfg(windows)]
#[test]
fn should_process_cli_patterns_windows() {
assert_cli_pattern("C:/test", "C:\\", "./test", "C:\\");
assert_cli_pattern("C:/test/other", "C:\\test\\", "./other", "C:\\test\\");
assert_cli_pattern("C:/test/other", "C:\\test", "./other", "C:\\test");
assert_cli_pattern("../test", "C:\\sub", "./test", "C:\\");
{
let environment = TestEnvironment::new();
let pattern = process_cli_pattern("V:/test/file.txt", &CanonicalizedPathBuf::new_for_testing("C:\\sub"), &environment);
assert_eq!(pattern.relative_pattern, "./test/file.txt");
assert_eq!(pattern.base_dir, environment.canonicalize("V:/").unwrap());
}
assert_cli_pattern("!C:/test", "C:\\", "!./test", "C:\\");
assert_cli_pattern("!C:/test/other", "C:\\test\\", "!./other", "C:\\test\\");
}
#[track_caller]
fn assert_cli_pattern(file_pattern: &str, cwd: &str, expected_pattern: &str, expected_base_dir: &str) {
let environment = TestEnvironment::new();
let pattern = process_cli_pattern(file_pattern, &CanonicalizedPathBuf::new_for_testing(cwd), &environment);
assert_eq!(pattern.relative_pattern, expected_pattern);
assert_eq!(pattern.base_dir, CanonicalizedPathBuf::new_for_testing(expected_base_dir));
}
#[test]
fn should_create_config_glob_pattern_relative_to_config_dir() {
let base = CanonicalizedPathBuf::new_for_testing("/a/b");
let pattern = |text: &str| {
let pattern = new_config_glob_pattern(text.to_string(), &base);
(pattern.relative_pattern, pattern.base_dir.to_string_lossy().replace('\\', "/"))
};
assert_eq!(pattern("src/**/*.ts"), ("src/**/*.ts".to_string(), "/a/b".to_string()));
assert_eq!(pattern("./src"), ("./src".to_string(), "/a/b".to_string()));
assert_eq!(pattern("../src/**"), ("./src/**".to_string(), "/a".to_string()));
assert_eq!(pattern("./../src"), ("./src".to_string(), "/a".to_string()));
assert_eq!(pattern("!../src"), ("!./src".to_string(), "/a".to_string()));
assert_eq!(pattern("../../**/Cargo.toml"), ("./**/Cargo.toml".to_string(), "/".to_string()));
assert_eq!(pattern("../../../src"), ("./src".to_string(), "/".to_string()));
}
#[test]
fn should_process_config_pattern() {
assert_eq!(process_config_pattern("/test"), "./test");
assert_eq!(process_config_pattern("./test"), "./test");
assert_eq!(process_config_pattern("test"), "test");
assert_eq!(process_config_pattern("**/test"), "**/test");
assert_eq!(process_config_pattern("!/test"), "!./test");
assert_eq!(process_config_pattern("!./test"), "!./test");
assert_eq!(process_config_pattern("!test"), "!test");
assert_eq!(process_config_pattern("!**/test"), "!**/test");
}
#[test]
fn handles_ignored_dir() {
let environment = TestEnvironment::new();
let cwd = CanonicalizedPathBuf::new_for_testing("/testing/dir");
let glob_matcher = GlobMatcher::new(
GlobPatterns {
shebangs: Vec::new(),
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.ts".to_string(), cwd.clone())]),
arg_excludes: None,
config_excludes: vec![GlobPattern::new("sub-dir".to_string(), cwd.clone())],
},
&GlobMatcherOptions {
case_sensitive: true,
base_dir: cwd,
},
)
.unwrap();
let mut file_matcher = FileMatcher {
glob_matcher,
gitignores: Some(GitIgnoreTree::new(environment, GitIgnoreTreeOptions::default())),
};
assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/dir/match.ts", true);
assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/dir/other/match.ts", true);
assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/sub-dir/no-match.ts", false);
assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/sub-dir/nested/no-match.ts", false);
}
#[test]
fn handles_ignored_dir_while_include_is_sub_dir() {
let environment = TestEnvironment::new();
let base_dir = CanonicalizedPathBuf::new_for_testing("/");
let cwd = CanonicalizedPathBuf::new_for_testing("/sub-dir");
environment.mk_dir_all(&cwd).unwrap();
let glob_matcher = GlobMatcher::new(
GlobPatterns {
shebangs: Vec::new(),
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.ts".to_string(), cwd.clone())]),
arg_excludes: None,
config_excludes: vec![GlobPattern::new("**/dist".to_string(), base_dir.clone())],
},
&GlobMatcherOptions {
case_sensitive: true,
base_dir: cwd,
},
)
.unwrap();
let mut file_matcher = FileMatcher {
glob_matcher,
gitignores: Some(GitIgnoreTree::new(environment, GitIgnoreTreeOptions::default())),
};
assert_matches_dir_and_not_ignored(&mut file_matcher, "/sub-dir/dir/match.ts", true);
assert_matches_dir_and_not_ignored(&mut file_matcher, "/sub-dir/dir/other/match.ts", true);
assert_matches_dir_and_not_ignored(&mut file_matcher, "/sub-dir/dist/no-match.ts", false);
}
#[test]
fn handles_gitignored_ancestor_dir() {
let environment = TestEnvironment::new();
let base_dir = CanonicalizedPathBuf::new_for_testing("/testing/dir");
environment.mk_dir_all(base_dir.as_ref()).unwrap();
environment.write_file("/testing/dir/.gitignore", "ignored-dir/\nsub.ts/\n").unwrap();
let glob_matcher = GlobMatcher::new(
GlobPatterns {
shebangs: Vec::new(),
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.ts".to_string(), base_dir.clone())]),
arg_excludes: None,
config_excludes: vec![],
},
&GlobMatcherOptions {
case_sensitive: true,
base_dir: base_dir.clone(),
},
)
.unwrap();
let mut file_matcher = FileMatcher {
glob_matcher,
gitignores: Some(GitIgnoreTree::new(environment, GitIgnoreTreeOptions::default())),
};
assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/dir/ignored-dir/no-match.ts", false);
assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/dir/ignored-dir/nested/no-match.ts", false);
assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/dir/other/match.ts", true);
assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/dir/sub.ts", true);
}
#[test]
fn ignores_gitignored_base_dir_itself() {
let environment = TestEnvironment::new();
let base_dir = CanonicalizedPathBuf::new_for_testing("/testing/dir");
environment.mk_dir_all(base_dir.as_ref()).unwrap();
environment.write_file("/testing/.gitignore", "dir/\n").unwrap();
let glob_matcher = GlobMatcher::new(
GlobPatterns {
shebangs: Vec::new(),
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.ts".to_string(), base_dir.clone())]),
arg_excludes: None,
config_excludes: vec![],
},
&GlobMatcherOptions {
case_sensitive: true,
base_dir: base_dir.clone(),
},
)
.unwrap();
let mut file_matcher = FileMatcher {
glob_matcher,
gitignores: Some(GitIgnoreTree::new(environment, GitIgnoreTreeOptions::default())),
};
assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/dir/match.ts", true);
assert_matches_dir_and_not_ignored(&mut file_matcher, "/testing/dir/sub/match.ts", true);
}
#[track_caller]
fn assert_matches_dir_and_not_ignored(matcher: &mut FileMatcher<TestEnvironment>, path: &str, expected: bool) {
assert_eq!(matcher.matches_and_dir_not_ignored(&PathBuf::from(path)), expected);
}
}