use anyhow::Error;
use anyhow::Result;
use anyhow::anyhow;
use parking_lot::Condvar;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use crate::arg_parser::ConfigDiscovery;
use crate::configuration::POSSIBLE_CONFIG_FILE_NAMES;
use crate::environment::CanonicalizedPathBuf;
use crate::environment::DirEntry;
use crate::environment::Environment;
use crate::environment::PathKind;
use crate::utils::gitignore::DirEntriesHint;
use crate::utils::gitignore::GitIgnoreTree;
use crate::utils::gitignore::GitIgnoreTreeOptions;
use crate::utils::gitignore::resolve_global_gitignore_lines;
use super::ExcludeMatchDetail;
use super::GlobMatcher;
use super::GlobMatcherOptions;
use super::GlobMatchesDetail;
use super::GlobPattern;
use super::GlobPatterns;
use super::escape_glob_text;
use super::is_pattern;
use super::non_negated_glob;
use super::unescape_glob_text;
#[derive(Debug, Default, Clone)]
pub struct GlobOutput {
pub file_paths: Vec<PathBuf>,
pub config_files: Vec<PathBuf>,
pub outside_base_paths: Vec<OutsideBasePath>,
}
#[derive(Debug, Clone)]
pub struct OutsideBasePath {
pub config_search_dir: PathBuf,
pub include_pattern: String,
}
pub struct GlobOptions {
pub start_dir: PathBuf,
pub config_discovery: ConfigDiscovery,
pub current_config_path: Option<PathBuf>,
pub file_patterns: GlobPatterns,
pub pattern_base: CanonicalizedPathBuf,
pub no_gitignore: bool,
}
pub fn glob(environment: &impl Environment, mut opts: GlobOptions) -> Result<GlobOutput> {
if opts
.file_patterns
.arg_includes
.as_ref()
.map(|p| p.iter().all(|p| p.is_negated()))
.unwrap_or(false)
{
log_debug!(environment, "Skipping negated globs: {:?}", opts.file_patterns.arg_includes);
return Ok(Default::default());
}
let start_instant = std::time::Instant::now();
log_debug!(environment, "Globbing: {:?}", opts.file_patterns);
let mut literal_arg_paths = extract_literal_arg_paths(environment, &mut opts.file_patterns, &opts.pattern_base);
rewrite_literal_exclude_paths(environment, &mut opts.file_patterns, &opts.pattern_base);
extract_outside_arg_patterns(&mut opts.file_patterns, &opts.pattern_base, &mut literal_arg_paths.outside_base_paths);
let mut run_traversal = requires_traversal(&opts.file_patterns);
if run_traversal {
opts.start_dir = expand_start_dir_for_arg_patterns(opts.start_dir, &opts.file_patterns, &opts.pattern_base);
} else {
log_debug!(environment, "Skipping traversal because the CLI args were all file paths.");
}
let mut git_ignore_tree = if opts.no_gitignore {
None
} else {
Some(GitIgnoreTree::new(
environment.clone(),
GitIgnoreTreeOptions {
include_paths: opts.file_patterns.include_paths(),
global_gitignore_lines: resolve_global_gitignore_lines(environment),
},
))
};
let glob_matcher = GlobMatcher::new(
opts.file_patterns,
&GlobMatcherOptions {
case_sensitive: true,
base_dir: opts.pattern_base.clone(),
},
)?;
let mut output = GlobOutput {
outside_base_paths: literal_arg_paths.outside_base_paths,
..Default::default()
};
let discover_configs = opts.config_discovery.traverse_descendants();
let mut config_file_finder = DirConfigFileFinder::new(environment, opts.current_config_path.clone());
if run_traversal && opts.start_dir != opts.pattern_base.as_ref() && opts.start_dir.starts_with(opts.pattern_base.as_ref()) {
match check_dir_chain(
&glob_matcher,
&mut git_ignore_tree,
discover_configs.then_some(&mut config_file_finder),
opts.pattern_base.as_ref(),
&opts.start_dir,
) {
DirChainResult::Matched => {}
DirChainResult::Excluded => {
log_debug!(environment, "Skipping traversal because the start directory is excluded.");
run_traversal = false;
}
DirChainResult::HasConfigFile(config_file) => {
log_debug!(environment, "Skipping traversal because the start directory has its own config file.");
push_dedup_config_file(&mut output.config_files, config_file);
run_traversal = false;
}
}
}
for file_path in literal_arg_paths.file_paths {
if run_traversal && file_path.starts_with(&opts.start_dir) {
continue; }
if let Some(parent) = file_path.parent()
&& parent.starts_with(opts.pattern_base.as_ref())
{
match check_dir_chain(
&glob_matcher,
&mut git_ignore_tree,
discover_configs.then_some(&mut config_file_finder),
opts.pattern_base.as_ref(),
parent,
) {
DirChainResult::Matched => {}
DirChainResult::Excluded => continue,
DirChainResult::HasConfigFile(config_file) => {
push_dedup_config_file(&mut output.config_files, config_file);
continue;
}
}
}
if glob_matcher.matches(&file_path) {
output.file_paths.push(file_path);
}
}
if run_traversal {
let shared_state = Arc::new(SharedState::new(opts.start_dir.clone()));
let read_dir_thread_count = resolve_read_dir_thread_count(environment);
log_debug!(environment, "Reading directories on {} thread(s)", read_dir_thread_count);
let read_dir_runner = Arc::new(ReadDirRunner::new(
environment.clone(),
shared_state.clone(),
ReadDirRunnerOptions {
start_dir: opts.start_dir,
config_discovery: opts.config_discovery,
current_config_path: opts.current_config_path,
thread_count: read_dir_thread_count,
},
));
for _ in 0..read_dir_thread_count {
let read_dir_runner = read_dir_runner.clone();
dprint_core::async_runtime::spawn_blocking(move || read_dir_runner.run());
}
let mut glob_matching_processor = GlobMatchingProcessor::new(shared_state, glob_matcher, git_ignore_tree);
let results = glob_matching_processor.run()?;
output.file_paths.extend(results.file_paths);
for config_file in results.config_files {
push_dedup_config_file(&mut output.config_files, config_file);
}
}
log_debug!(environment, "File(s) matched: {:?}", output);
log_debug!(environment, "Finished globbing in {}ms", start_instant.elapsed().as_millis());
Ok(output)
}
struct LiteralArgPaths {
file_paths: Vec<PathBuf>,
outside_base_paths: Vec<OutsideBasePath>,
}
fn extract_literal_arg_paths(environment: &impl Environment, file_patterns: &mut GlobPatterns, pattern_base: &CanonicalizedPathBuf) -> LiteralArgPaths {
let mut seen = HashSet::new();
let mut result = LiteralArgPaths {
file_paths: Vec::new(),
outside_base_paths: Vec::new(),
};
for pattern in file_patterns.arg_includes.iter_mut().flatten() {
if pattern.is_negated() {
rewrite_literal_arg_pattern(environment, pattern, pattern_base);
continue;
}
if !could_be_literal_path(&pattern.relative_pattern) {
continue;
}
let relative_path = pattern.relative_pattern.strip_prefix("./").unwrap_or(&pattern.relative_pattern);
let relative_path = relative_path.trim_end_matches('/');
let relative_path = unescape_glob_text(relative_path);
let (path, is_file) = if relative_path.is_empty() {
(pattern.base_dir.as_ref().to_path_buf(), false)
} else {
let path = if cfg!(windows) {
pattern.base_dir.join(relative_path.replace('/', "\\"))
} else {
pattern.base_dir.join(relative_path.as_ref())
};
let Some(kind) = environment.path_kind(&path) else {
continue;
};
let path = match environment.canonicalize(&path) {
Ok(canonical) => canonical.into_path_buf(),
Err(_) => path,
};
let is_file = match kind {
PathKind::File => true,
PathKind::Dir => false,
PathKind::Symlink => match environment.path_kind(&path) {
Some(PathKind::File) => true,
Some(PathKind::Dir) => false,
_ => continue,
},
};
(path, is_file)
};
if !is_file && pattern_base.as_ref().starts_with(&path) {
if path != *pattern_base.as_ref() && seen.insert(path.clone()) {
result.outside_base_paths.push(OutsideBasePath {
config_search_dir: path.clone(),
include_pattern: path.to_string_lossy().into_owned(),
});
}
pattern.relative_pattern = "**".to_string();
pattern.base_dir = pattern_base.clone();
} else if !path.starts_with(pattern_base.as_ref()) {
if seen.insert(path.clone()) {
result.outside_base_paths.push(OutsideBasePath {
config_search_dir: if is_file {
path.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| path.clone())
} else {
path.clone()
},
include_pattern: path.to_string_lossy().into_owned(),
});
}
pattern.relative_pattern = format!("./{}", escape_glob_text(&relative_path));
} else {
let relative = path.strip_prefix(pattern_base.as_ref()).unwrap().to_string_lossy().replace('\\', "/");
pattern.base_dir = pattern_base.clone();
if is_file {
pattern.relative_pattern = format!("./{}", escape_glob_text(&relative));
if seen.insert(path.clone()) {
result.file_paths.push(path);
}
} else {
pattern.relative_pattern = format!("./{}/**", escape_glob_text(&relative));
}
}
}
result
}
pub fn rewrite_literal_arg_patterns(environment: &impl Environment, file_patterns: &mut GlobPatterns, pattern_base: &CanonicalizedPathBuf) {
for pattern in file_patterns.arg_includes.iter_mut().flatten() {
rewrite_literal_arg_pattern(environment, pattern, pattern_base);
}
rewrite_literal_exclude_paths(environment, file_patterns, pattern_base);
}
fn rewrite_literal_exclude_paths(environment: &impl Environment, file_patterns: &mut GlobPatterns, pattern_base: &CanonicalizedPathBuf) {
for pattern in file_patterns.arg_excludes.iter_mut().flatten() {
rewrite_literal_arg_pattern(environment, pattern, pattern_base);
}
}
pub fn rewrite_literal_arg_pattern(environment: &impl Environment, pattern: &mut GlobPattern, pattern_base: &CanonicalizedPathBuf) {
let is_negated = pattern.is_negated();
let text = non_negated_glob(&pattern.relative_pattern);
if !is_pattern(text) || !could_be_literal_path(text) {
return;
}
let relative_path = text.strip_prefix("./").unwrap_or(text);
let relative_path = relative_path.trim_end_matches('/');
if relative_path.is_empty() {
return;
}
let path = if cfg!(windows) {
pattern.base_dir.join(relative_path.replace('/', "\\"))
} else {
pattern.base_dir.join(relative_path)
};
if !environment.path_exists(&path) {
return;
}
let canonical_path = match environment.canonicalize(&path) {
Ok(canonical) => canonical.into_path_buf(),
Err(_) => path,
};
let (base_dir, relative) = match canonical_path.strip_prefix(pattern_base.as_ref()) {
Ok(relative) if !relative.as_os_str().is_empty() => (pattern_base.clone(), relative.to_string_lossy().replace('\\', "/")),
_ => (pattern.base_dir.clone(), relative_path.to_string()),
};
pattern.base_dir = base_dir;
pattern.relative_pattern = format!("{}./{}", if is_negated { "!" } else { "" }, escape_glob_text(&relative));
}
fn could_be_literal_path(pattern: &str) -> bool {
let mut was_last_escape = false;
for c in pattern.chars() {
if !was_last_escape && matches!(c, '*' | '?') {
return false;
}
was_last_escape = c == '\\' && !was_last_escape;
}
true
}
fn extract_outside_arg_patterns(file_patterns: &mut GlobPatterns, pattern_base: &CanonicalizedPathBuf, outside_base_paths: &mut Vec<OutsideBasePath>) {
let Some(includes) = &mut file_patterns.arg_includes else {
return;
};
includes.retain(|pattern| {
if !is_positive_glob_pattern(pattern) {
return true;
}
let deepest_base = pattern.clone().into_deepest_base().base_dir;
if deepest_base.starts_with(pattern_base) {
return true; }
outside_base_paths.push(OutsideBasePath {
config_search_dir: deepest_base.as_ref().to_path_buf(),
include_pattern: pattern.as_absolute_pattern_text(),
});
pattern_base.starts_with(&deepest_base)
});
}
fn requires_traversal(file_patterns: &GlobPatterns) -> bool {
match &file_patterns.arg_includes {
None => true,
Some(includes) => includes.iter().any(is_positive_glob_pattern),
}
}
fn is_positive_glob_pattern(pattern: &GlobPattern) -> bool {
!pattern.is_negated() && is_pattern(&pattern.relative_pattern)
}
fn expand_start_dir_for_arg_patterns(mut start_dir: PathBuf, file_patterns: &GlobPatterns, pattern_base: &CanonicalizedPathBuf) -> PathBuf {
for pattern in file_patterns.arg_includes.iter().flatten() {
if !is_positive_glob_pattern(pattern) {
continue;
}
let deepest_base = pattern.clone().into_deepest_base().base_dir;
if !deepest_base.starts_with(pattern_base) {
continue;
}
while !deepest_base.as_ref().starts_with(&start_dir) {
match start_dir.parent() {
Some(parent) if start_dir != *pattern_base.as_ref() => start_dir = parent.to_path_buf(),
_ => break,
}
}
}
start_dir
}
enum DirChainResult {
Matched,
Excluded,
HasConfigFile(PathBuf),
}
fn check_dir_chain<TEnvironment: Environment>(
glob_matcher: &GlobMatcher,
git_ignore_tree: &mut Option<GitIgnoreTree<TEnvironment>>,
mut config_file_finder: Option<&mut DirConfigFileFinder<'_, TEnvironment>>,
base_dir: &Path,
dir: &Path,
) -> DirChainResult {
for dir in dirs_from_base_to(base_dir, dir) {
if dir.file_name().is_some_and(|f| f == ".git") {
return DirChainResult::Excluded;
}
match glob_matcher.is_dir_ignored(dir) {
ExcludeMatchDetail::Excluded => return DirChainResult::Excluded,
ExcludeMatchDetail::OptedOutExclude => {}
ExcludeMatchDetail::NotExcluded => {
if let Some(tree) = git_ignore_tree.as_mut()
&& let Some(gitignore) = tree.get_resolved_git_ignore_for_file(dir)
&& gitignore.is_ignored(dir, true)
{
return DirChainResult::Excluded;
}
}
}
if let Some(finder) = config_file_finder.as_deref_mut()
&& let Some(config_file) = finder.find(dir)
{
return DirChainResult::HasConfigFile(config_file);
}
}
DirChainResult::Matched
}
fn dirs_from_base_to<'a>(base_dir: &Path, dir: &'a Path) -> Vec<&'a Path> {
let mut dirs = dir.ancestors().take_while(|ancestor| *ancestor != base_dir).collect::<Vec<_>>();
dirs.reverse();
dirs
}
fn push_dedup_config_file(config_files: &mut Vec<PathBuf>, config_file: PathBuf) {
if !config_files.contains(&config_file) {
config_files.push(config_file);
}
}
struct DirConfigFileFinder<'a, TEnvironment: Environment> {
environment: &'a TEnvironment,
current_config_path: Option<PathBuf>,
cache: HashMap<PathBuf, Option<PathBuf>>,
}
impl<'a, TEnvironment: Environment> DirConfigFileFinder<'a, TEnvironment> {
pub fn new(environment: &'a TEnvironment, current_config_path: Option<PathBuf>) -> Self {
Self {
environment,
current_config_path,
cache: Default::default(),
}
}
pub fn find(&mut self, dir: &Path) -> Option<PathBuf> {
if let Some(result) = self.cache.get(dir) {
return result.clone();
}
let result = POSSIBLE_CONFIG_FILE_NAMES
.iter()
.map(|file_name| dir.join(file_name))
.filter(|path| Some(path.as_path()) != self.current_config_path.as_deref())
.find(|path| self.environment.path_is_file(path));
self.cache.insert(dir.to_path_buf(), result.clone());
result
}
}
const DEFAULT_READ_DIR_THREAD_COUNT: usize = 8;
fn resolve_read_dir_thread_count(environment: &impl Environment) -> usize {
if let Some(count) = environment
.env_var("DPRINT_GLOB_READ_THREADS")
.and_then(|v| v.to_str().and_then(|v| v.parse::<usize>().ok()))
{
return count.max(1);
}
DEFAULT_READ_DIR_THREAD_COUNT
}
struct DirEntries {
path: PathBuf,
entries: Vec<DirOrConfigEntry>,
}
enum DirOrConfigEntry {
Dir(PathBuf),
File(PathBuf),
Config(PathBuf),
}
fn dir_entries_hint(entries: &[DirOrConfigEntry]) -> DirEntriesHint {
let mut hint = DirEntriesHint {
has_git: false,
has_gitignore: false,
};
for entry in entries {
match entry {
DirOrConfigEntry::Dir(path) | DirOrConfigEntry::File(path) => {
match path.file_name().and_then(|f| f.to_str()) {
Some(".gitignore") => hint.has_gitignore = true,
Some(".git") => hint.has_git = true,
_ => continue,
}
}
DirOrConfigEntry::Config(_) => continue,
}
if hint.has_gitignore && hint.has_git {
break; }
}
hint
}
const PUSH_DIR_ENTRIES_BATCH_COUNT: usize = 500;
struct ReadDirRunnerOptions {
start_dir: PathBuf,
config_discovery: ConfigDiscovery,
current_config_path: Option<PathBuf>,
thread_count: usize,
}
struct ReadDirRunner<TEnvironment: Environment> {
environment: TEnvironment,
shared_state: Arc<SharedState>,
options: ReadDirRunnerOptions,
}
impl<TEnvironment: Environment> ReadDirRunner<TEnvironment> {
pub fn new(environment: TEnvironment, shared_state: Arc<SharedState>, options: ReadDirRunnerOptions) -> Self {
Self {
environment,
shared_state,
options,
}
}
pub fn run(&self) {
while let Some(pending_dirs) = self.acquire_dirs() {
let mut pending_count = 0;
let mut all_entries = Vec::new();
for current_dir in pending_dirs {
match self.read_dir_entries(¤t_dir) {
Ok(Some(entries)) => {
pending_count += entries.len();
all_entries.push(DirEntries { path: current_dir, entries });
if pending_count > PUSH_DIR_ENTRIES_BATCH_COUNT {
self.push_entries(std::mem::take(&mut all_entries));
pending_count = 0;
}
}
Ok(None) => continue,
Err(err) => {
self.finish_with_error(err);
return;
}
}
}
self.finish_reading(all_entries);
}
}
fn read_dir_entries(&self, current_dir: &Path) -> Result<Option<Vec<DirOrConfigEntry>>> {
let entries = match self.environment.dir_info(current_dir) {
Ok(entries) => entries,
Err(err) => {
if is_system_volume_error(current_dir, &err) {
return Ok(None);
}
if err.kind() == std::io::ErrorKind::PermissionDenied {
log_warn!(self.environment, "WARNING: Ignoring directory. Permission denied: {}", current_dir.display());
return Ok(None);
}
return Err(anyhow!("Error reading dir '{}': {:#}", current_dir.display(), err));
}
};
if entries.is_empty() {
return Ok(None);
}
let maybe_config_file = if self.options.config_discovery.traverse_descendants() && current_dir != self.options.start_dir {
entries
.iter()
.filter_map(|e| match e {
DirEntry::Directory(_) => None,
DirEntry::File { name, path } => {
if name.to_str().is_some_and(|name| POSSIBLE_CONFIG_FILE_NAMES.contains(&name))
&& Some(path.as_path()) != self.options.current_config_path.as_deref()
{
Some(path)
} else {
None
}
}
})
.next()
} else {
None
};
if let Some(config_file) = maybe_config_file {
Ok(Some(vec![DirOrConfigEntry::Config(config_file.clone())]))
} else {
Ok(Some(
entries
.into_iter()
.map(|e| match e {
DirEntry::Directory(path) => DirOrConfigEntry::Dir(path),
DirEntry::File { path, .. } => DirOrConfigEntry::File(path),
})
.collect::<Vec<_>>(),
))
}
}
fn acquire_dirs(&self) -> Option<Vec<PathBuf>> {
let (lock, cvar) = &self.shared_state.inner;
let mut state = lock.lock();
loop {
if state.shutdown {
return None;
}
if !state.pending_dirs.is_empty() {
let take = read_dir_chunk_size(state.pending_dirs.len(), self.options.thread_count);
let chunk = state.pending_dirs.drain(..take).collect::<Vec<_>>();
state.reading_count += 1;
return Some(chunk);
}
cvar.wait(&mut state);
}
}
fn push_entries(&self, entries: Vec<DirEntries>) {
if entries.is_empty() {
return;
}
let (lock, cvar) = &self.shared_state.inner;
let mut state = lock.lock();
state.pending_entries.push(entries);
cvar.notify_all();
}
fn finish_reading(&self, entries: Vec<DirEntries>) {
let (lock, cvar) = &self.shared_state.inner;
let mut state = lock.lock();
if !entries.is_empty() {
state.pending_entries.push(entries);
}
state.finish_reading();
cvar.notify_all();
}
fn finish_with_error(&self, error: Error) {
let (lock, cvar) = &self.shared_state.inner;
let mut state = lock.lock();
if state.error.is_none() {
state.error = Some(error);
}
state.shutdown = true;
state.finish_reading();
cvar.notify_all();
}
}
const READ_DIR_CHUNK_SIZE: usize = 8;
fn read_dir_chunk_size(pending_len: usize, thread_count: usize) -> usize {
(pending_len / thread_count.max(1)).clamp(1, READ_DIR_CHUNK_SIZE)
}
fn is_system_volume_error(dir_path: &Path, err: &std::io::Error) -> bool {
cfg!(target_os = "windows")
&& matches!(err.raw_os_error(), Some(5))
&& matches!(dir_path.file_name().and_then(|f| f.to_str()), Some("System Volume Information"))
}
struct GlobMatchingProcessor<TEnvironment: Environment> {
shared_state: Arc<SharedState>,
glob_matcher: GlobMatcher,
git_ignore_tree: Option<GitIgnoreTree<TEnvironment>>,
}
impl<TEnvironment: Environment> GlobMatchingProcessor<TEnvironment> {
pub fn new(shared_state: Arc<SharedState>, glob_matcher: GlobMatcher, git_ignore_tree: Option<GitIgnoreTree<TEnvironment>>) -> Self {
Self {
shared_state,
glob_matcher,
git_ignore_tree,
}
}
pub fn run(&mut self) -> Result<GlobOutput> {
let mut output = GlobOutput::default();
loop {
let mut pending_dirs = Vec::new();
match self.get_next_entries() {
Ok(None) => return Ok(output),
Err(err) => return Err(err), Ok(Some(entries)) => {
for dir in entries.into_iter().flatten() {
let dir_hint = dir_entries_hint(&dir.entries);
let gitignore = self
.git_ignore_tree
.as_mut()
.and_then(|t| t.get_resolved_git_ignore_for_dir_children(&dir.path, dir_hint));
for entry in dir.entries {
match entry {
DirOrConfigEntry::Dir(path) => {
let is_ignored = match self.glob_matcher.is_dir_ignored(&path) {
ExcludeMatchDetail::Excluded => true,
ExcludeMatchDetail::OptedOutExclude => false,
ExcludeMatchDetail::NotExcluded => match &gitignore {
Some(gitignore) => {
gitignore.is_ignored(&path, true)
}
None => false,
},
} || path.file_name().map(|f| f == ".git").unwrap_or(false);
if !is_ignored {
pending_dirs.push(path);
}
}
DirOrConfigEntry::File(path) => {
let is_matched = match self.glob_matcher.matches_detail(&path) {
GlobMatchesDetail::Excluded => false,
GlobMatchesDetail::Matched => match &gitignore {
Some(gitignore) => {
!gitignore.is_ignored(&path, false)
}
None => true,
},
GlobMatchesDetail::MatchedOptedOutExclude => true,
GlobMatchesDetail::NotMatched => false,
};
if is_matched {
output.file_paths.push(path);
}
}
DirOrConfigEntry::Config(path) => {
output.config_files.push(path);
}
}
}
}
}
}
self.push_pending_dirs(pending_dirs);
}
}
fn push_pending_dirs(&self, pending_dirs: Vec<PathBuf>) {
if pending_dirs.is_empty() {
return; }
let (lock, cvar) = &self.shared_state.inner;
let mut state = lock.lock();
state.pending_dirs.extend(pending_dirs);
cvar.notify_all();
}
fn get_next_entries(&self) -> Result<Option<Vec<Vec<DirEntries>>>> {
let (lock, cvar) = &self.shared_state.inner;
let mut state = lock.lock();
loop {
if let Some(err) = state.error.take() {
return Err(err);
}
if !state.pending_entries.is_empty() {
return Ok(Some(std::mem::take(&mut state.pending_entries)));
}
if state.reading_count == 0 && state.pending_dirs.is_empty() {
state.shutdown = true;
cvar.notify_all();
return Ok(None);
}
cvar.wait(&mut state);
}
}
}
struct SharedStateInternal {
pending_dirs: VecDeque<PathBuf>,
pending_entries: Vec<Vec<DirEntries>>,
reading_count: usize,
error: Option<Error>,
shutdown: bool,
}
impl SharedStateInternal {
fn finish_reading(&mut self) {
self.reading_count = self.reading_count.checked_sub(1).expect("reading_count underflow");
}
}
struct SharedState {
inner: (Mutex<SharedStateInternal>, Condvar),
}
impl SharedState {
pub fn new(initial_dir: PathBuf) -> Self {
SharedState {
inner: (
Mutex::new(SharedStateInternal {
pending_dirs: VecDeque::from([initial_dir]),
pending_entries: Vec::new(),
reading_count: 0,
error: None,
shutdown: false,
}),
Condvar::new(),
),
}
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use super::*;
use crate::environment::TestEnvironmentBuilder;
use crate::utils::GlobPattern;
#[tokio::test]
async fn should_glob() {
let mut environment_builder = TestEnvironmentBuilder::new();
let mut expected_matches = Vec::new();
environment_builder.write_file("/.git/data.txt", "");
for i in 1..100 {
environment_builder.write_file(format!("/{}.txt", i), "");
expected_matches.push(format!("/{}.txt", i));
environment_builder.write_file(format!("/sub/{}.txt", i), "");
expected_matches.push(format!("/sub/{}.txt", i));
environment_builder.write_file(format!("/sub/ignore/{}.txt", i), "");
environment_builder.write_file(format!("/sub{0}/sub/{0}.txt", i), "");
expected_matches.push(format!("/sub{0}/sub/{0}.txt", i));
if i % 2 == 0 {
environment_builder.write_file(format!("/{}.ps", i), "");
environment_builder.write_file(format!("/sub/{}.ps", i), "");
environment_builder.write_file(format!("/sub/ignore/{}.ps", i), "");
environment_builder.write_file(format!("/sub{0}/sub/{0}.ps", i), "");
}
}
let environment = environment_builder.build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir.clone())]),
arg_excludes: None,
config_excludes: vec![GlobPattern::new("**/ignore".to_string(), root_dir)],
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
expected_matches.sort();
assert_eq!(result, expected_matches);
}
#[tokio::test]
async fn should_match_same_files_regardless_of_read_thread_count() {
let mut builder = TestEnvironmentBuilder::new();
builder.write_file("/.git/HEAD", "");
builder.write_file("/.gitignore", "ignored\n");
for i in 0..200 {
builder.write_file(format!("/dir{}/a.txt", i), "");
builder.write_file(format!("/dir{}/nested/deep/b.txt", i), "");
builder.write_file(format!("/dir{}/ignored/c.txt", i), "");
}
let environment = builder.build();
let root_dir = environment.canonicalize("/").unwrap();
let run = |read_threads: &str| {
environment.set_env_var("DPRINT_GLOB_READ_THREADS", Some(read_threads));
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir.clone())]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
result
};
let serial = run("1");
let parallel = run("16");
assert_eq!(serial, parallel);
assert_eq!(serial.len(), 400);
assert!(serial.iter().all(|p| !p.contains("ignored")));
}
#[tokio::test]
async fn should_respect_git_info_exclude() {
let environment = TestEnvironmentBuilder::new()
.write_file("/.git/info/exclude", "excluded.txt")
.write_file("/included.txt", "")
.write_file("/excluded.txt", "")
.write_file("/sub/included.txt", "")
.write_file("/sub/excluded.txt", "")
.build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
assert_eq!(result, vec!["/included.txt", "/sub/included.txt"]);
}
#[tokio::test]
async fn should_respect_global_gitignore_when_opted_in() {
let environment = TestEnvironmentBuilder::new()
.write_file("/.git/HEAD", "")
.write_file("/global_ignore", "globally_excluded.txt")
.write_file("/included.txt", "")
.write_file("/globally_excluded.txt", "")
.write_file("/sub/included.txt", "")
.write_file("/sub/globally_excluded.txt", "")
.build();
environment.set_env_var("DPRINT_GLOBAL_GITIGNORE", Some("1"));
environment.set_global_gitignore_path("/global_ignore");
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
assert_eq!(result, vec!["/included.txt", "/sub/included.txt"]);
}
#[tokio::test]
async fn should_ignore_global_gitignore_when_not_opted_in() {
let environment = TestEnvironmentBuilder::new()
.write_file("/.git/HEAD", "")
.write_file("/global_ignore", "globally_excluded.txt")
.write_file("/included.txt", "")
.write_file("/globally_excluded.txt", "")
.build();
environment.set_global_gitignore_path("/global_ignore");
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
assert_eq!(result, vec!["/globally_excluded.txt", "/included.txt"]);
}
#[tokio::test]
async fn no_gitignore_should_override_global_gitignore() {
let environment = TestEnvironmentBuilder::new()
.write_file("/.git/HEAD", "")
.write_file("/global_ignore", "globally_excluded.txt")
.write_file("/included.txt", "")
.write_file("/globally_excluded.txt", "")
.build();
environment.set_env_var("DPRINT_GLOBAL_GITIGNORE", Some("1"));
environment.set_global_gitignore_path("/global_ignore");
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: true,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
assert_eq!(result, vec!["/globally_excluded.txt", "/included.txt"]);
}
#[tokio::test]
async fn should_match_literal_file_paths_without_traversal() {
let environment = TestEnvironmentBuilder::new()
.write_file("/sub/file.txt", "")
.write_file("/sub/other.txt", "")
.build();
environment.set_dir_info_error(std::io::Error::other("FAILURE"));
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: Some(vec![
GlobPattern::new("./sub/file.txt".to_string(), root_dir.clone()),
GlobPattern::new("./not_exists.txt".to_string(), root_dir.clone()),
]),
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
assert_eq!(result.file_paths, vec![PathBuf::from("/sub/file.txt")]);
assert!(result.config_files.is_empty());
}
#[tokio::test]
async fn literal_file_path_in_dir_with_config_file_resolves_config() {
let environment = TestEnvironmentBuilder::new()
.write_file("/sub/dprint.json", "{}")
.write_file("/sub/file.txt", "")
.write_file("/sub/nested/dprint.json", "{}")
.write_file("/sub/nested/file.txt", "")
.write_file("/file.txt", "")
.build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: Some(vec![
GlobPattern::new("./file.txt".to_string(), root_dir.clone()),
GlobPattern::new("./sub/file.txt".to_string(), root_dir.clone()),
GlobPattern::new("./sub/nested/file.txt".to_string(), root_dir.clone()),
]),
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
assert_eq!(result.file_paths, vec![PathBuf::from("/file.txt")]);
assert_eq!(result.config_files, vec![PathBuf::from("/sub/dprint.json")]);
}
#[tokio::test]
async fn literal_file_path_ignores_config_files_when_config_discovery_disabled() {
let environment = TestEnvironmentBuilder::new()
.write_file("/sub/dprint.json", "{}")
.write_file("/sub/file.txt", "")
.build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Disabled,
file_patterns: GlobPatterns {
arg_includes: Some(vec![GlobPattern::new("./sub/file.txt".to_string(), root_dir.clone())]),
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
assert_eq!(result.file_paths, vec![PathBuf::from("/sub/file.txt")]);
assert!(result.config_files.is_empty());
}
#[tokio::test]
async fn literal_file_path_in_excluded_dir_not_matched() {
let environment = TestEnvironmentBuilder::new()
.write_file("/ignored/file.txt", "")
.write_file("/ignored/dprint.json", "{}")
.build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: Some(vec![GlobPattern::new("./ignored/file.txt".to_string(), root_dir.clone())]),
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir.clone())]),
arg_excludes: None,
config_excludes: vec![GlobPattern::new("./ignored".to_string(), root_dir)],
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
assert!(result.file_paths.is_empty());
assert!(result.config_files.is_empty());
}
#[tokio::test]
async fn should_traverse_from_glob_pattern_base_dir_outside_start_dir() {
let environment = TestEnvironmentBuilder::new()
.write_file("/other/file.txt", "")
.write_file("/sub/file.txt", "")
.build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/sub"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: Some(vec![GlobPattern::new("./other/**".to_string(), root_dir.clone())]),
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
assert_eq!(result.file_paths, vec![PathBuf::from("/other/file.txt")]);
}
#[tokio::test]
async fn should_expand_literal_dir_path_to_match_contents() {
let environment = TestEnvironmentBuilder::new()
.write_file("/sub/file1.txt", "")
.write_file("/sub/nested/file2.txt", "")
.write_file("/other/file3.txt", "")
.build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: Some(vec![GlobPattern::new("./sub".to_string(), root_dir.clone())]),
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
assert_eq!(result, vec!["/sub/file1.txt", "/sub/nested/file2.txt"]);
}
#[tokio::test]
async fn should_handle_dir_info_erroring() {
let environment = TestEnvironmentBuilder::new().build();
environment.set_dir_info_error(std::io::Error::new(std::io::ErrorKind::Other, "FAILURE"));
let root_dir = environment.canonicalize("/").unwrap();
let err_message = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.err()
.unwrap();
assert_eq!(err_message.to_string(), "Error reading dir '/': FAILURE");
}
#[tokio::test]
async fn should_ignore_permission_denied_error() {
let environment = TestEnvironmentBuilder::new().build();
environment.set_dir_info_error(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Permission denied"));
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![GlobPattern::new("**/*.txt".to_string(), root_dir)]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
);
assert!(result.is_ok());
assert_eq!(
environment.take_stderr_messages(),
vec!["WARNING: Ignoring directory. Permission denied: /".to_string()]
);
}
#[tokio::test]
async fn should_support_excluding_then_including_in_includes() {
let environment = TestEnvironmentBuilder::new().write_file("/dir/a.txt", "").write_file("/dir/b.txt", "").build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![
GlobPattern::new("!**/*.*".to_string(), root_dir.clone()),
GlobPattern::new("**/a.txt".to_string(), root_dir),
]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
assert_eq!(result, vec!["/dir/a.txt"]);
}
#[tokio::test]
async fn should_support_including_then_excluding_then_including() {
let environment = TestEnvironmentBuilder::new()
.write_file("/dir/a.json", "")
.write_file("/dir/b.json", "")
.build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![
GlobPattern::new("**/*.json".to_string(), root_dir.clone()),
GlobPattern::new("!**/*.json".to_string(), root_dir.clone()),
GlobPattern::new("**/a.json".to_string(), root_dir),
]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
assert_eq!(result, vec!["/dir/a.json"]);
}
#[tokio::test]
async fn excluding_dir_but_including_sub_dir() {
let environment = TestEnvironmentBuilder::new()
.write_file("/test/a/a.json", "")
.write_file("/test/a/b/b.json", "")
.write_file("/test/test.json", "")
.build();
let test_dir = environment.canonicalize("/test/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/test/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![
GlobPattern::new("**/*.json".to_string(), test_dir.clone()),
GlobPattern::new("!a/**/*.json".to_string(), test_dir.clone()),
GlobPattern::new("a/b/**/*.json".to_string(), test_dir),
]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/test/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
assert_eq!(result, vec!["/test/a/b/b.json", "/test/test.json"]);
}
#[tokio::test]
async fn should_be_case_sensitive() {
let environment = TestEnvironmentBuilder::new()
.write_file("/src/FooSamlService.java", "")
.write_file("/src/FooMlService.java", "")
.write_file("/src/Other.java", "")
.build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![
GlobPattern::new("**/*.java".to_string(), root_dir.clone()),
GlobPattern::new("!**/*MlService.java".to_string(), root_dir),
]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
assert_eq!(result, vec!["/src/FooSamlService.java", "/src/Other.java"]);
}
#[tokio::test]
async fn excluding_dir_but_including_sub_dir_case_2() {
let environment = TestEnvironmentBuilder::new()
.write_file("/dir/a/a.txt", "")
.write_file("/dir/b/b.txt", "")
.build();
let root_dir = environment.canonicalize("/").unwrap();
let result = glob(
&environment,
GlobOptions {
current_config_path: None,
start_dir: PathBuf::from("/"),
config_discovery: ConfigDiscovery::Default,
file_patterns: GlobPatterns {
arg_includes: None,
config_includes: Some(vec![
GlobPattern::new("**/*.*".to_string(), root_dir.clone()),
GlobPattern::new("!dir/a/**/*".to_string(), root_dir.clone()),
GlobPattern::new("dir/b/b/**/*".to_string(), root_dir),
]),
arg_excludes: None,
config_excludes: Vec::new(),
},
pattern_base: CanonicalizedPathBuf::new_for_testing("/"),
no_gitignore: false,
},
)
.unwrap();
let mut result = result.file_paths.into_iter().map(|r| r.to_string_lossy().to_string()).collect::<Vec<_>>();
result.sort();
assert_eq!(result, vec!["/dir/b/b.txt"]);
}
#[test]
fn should_get_if_could_be_literal_path() {
assert!(could_be_literal_path("routes/[id].svelte"));
assert!(could_be_literal_path("{{myfile}}.yaml"));
assert!(!could_be_literal_path("**/*.txt"));
assert!(!could_be_literal_path("file?.txt"));
assert!(could_be_literal_path("a\\*b"));
assert!(!could_be_literal_path("a\\\\*b"));
}
}