Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow exclude/ignore by absolute path #1272

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions contrib/completion/_fd
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ _fd() {
'*'{-t+,--type=}"[filter search by type]:type:(($fd_types))"
'*'{-e+,--extension=}'[filter search by file extension]:extension'
'*'{-E+,--exclude=}'[exclude files/directories that match the given glob pattern]:glob pattern'
'*--exclude-absolute=[exclude files/directories whose absolute path match the given glob pattern]:glob pattern'
'*'{-S+,--size=}'[limit search by file size]:size limit:->size'
'(-o --owner)'{-o+,--owner=}'[filter by owning user and/or group]:owner and/or group:->owner'

Expand Down
19 changes: 17 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub struct Opts {
no_hidden: (),

/// Show search results from files and directories that would otherwise be
/// ignored by '.gitignore', '.ignore', '.fdignore', or the global ignore file.
/// ignored by '.gitignore', '.ignore', '.fdignore', or the global ignore files.
/// The flag can be overridden with --ignore.
#[arg(
long,
Expand Down Expand Up @@ -106,7 +106,7 @@ pub struct Opts {
)]
pub no_ignore_parent: bool,

/// Do not respect the global ignore file
/// Do not respect the global ignore files
#[arg(long, hide = true)]
pub no_global_ignore_file: bool,

Expand Down Expand Up @@ -300,6 +300,21 @@ pub struct Opts {
)]
pub exclude: Vec<String>,

/// Exclude files/directories whose absolute path match the given glob pattern.
/// This filter is applied on top of all other ignore logic. Multiple exclude patterns
/// can be specified.
///
/// Note that using this filter causes fd to perform an extra canonicalization
/// for every path traversed, which incurs a non-trivial performance penalty.
/// Use at your own discretion.
#[arg(
long,
value_name = "pattern",
help = "Exclude entries whose absolute path match the given glob pattern",
long_help
)]
pub exclude_absolute: Vec<String>,

/// Do not traverse into directories that match the search criteria. If
/// you want to exclude specific directories, use the '--exclude=…' option.
#[arg(long, hide_short_help = true, conflicts_with_all(&["size", "exact_depth"]),
Expand Down
20 changes: 20 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{path::PathBuf, sync::Arc, time::Duration};

use globset::GlobMatcher;
use lscolors::LsColors;
use regex::bytes::RegexSet;

Expand Down Expand Up @@ -95,6 +96,9 @@ pub struct Config {
/// A list of glob patterns that should be excluded from the search.
pub exclude_patterns: Vec<String>,

/// A list of glob matchers that should exclude matched entries by their absolute paths.
pub exclude_absolute_matchers: Vec<GlobMatcher>,

/// A list of custom ignore files.
pub ignore_files: Vec<PathBuf>,

Expand Down Expand Up @@ -130,3 +134,19 @@ impl Config {
self.command.is_none()
}
}

/// Get the platform-specific config directory for fd.
pub fn get_fd_config_dir() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
let mut dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| dirs_next::home_dir().map(|d| d.join(".config")))?;

#[cfg(not(target_os = "macos"))]
let mut dir = dirs_next::config_dir()?;

dir.push("fd");

Some(dir)
}
73 changes: 68 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ use std::time;
use anyhow::{anyhow, bail, Context, Result};
use atty::Stream;
use clap::{CommandFactory, Parser};
use globset::GlobBuilder;
use globset::{Glob, GlobBuilder, GlobMatcher};
use lscolors::LsColors;
use regex::bytes::{Regex, RegexBuilder, RegexSetBuilder};

use crate::cli::{ColorWhen, Opts};
use crate::config::Config;
use crate::config::{get_fd_config_dir, Config};
use crate::error::print_error;
use crate::exec::CommandSet;
use crate::exit_codes::ExitCode;
use crate::filetypes::FileTypes;
Expand Down Expand Up @@ -233,6 +234,28 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result<Config
let command = extract_command(&mut opts, colored_output)?;
let has_command = command.is_some();

let read_global_ignore =
!(opts.no_ignore || opts.rg_alias_ignore() || opts.no_global_ignore_file);
let exclude_absolute_matchers = {
let mut matchers = vec![];

// absolute excludes from CLI
for glob_str in opts.exclude_absolute.iter() {
// invalid globs from CLI are hard errors
let m = Glob::new(glob_str)?.compile_matcher();
matchers.push(m);
}
// absolute excludes from global `ignore-absolute` file
if read_global_ignore {
match read_and_build_global_exclude_absolute_matchers() {
Ok(mut v) => matchers.append(&mut v),
Err(err) => print_error(format!("Cannot read global ignore-absolute file. {err}.")),
}
}

matchers
};

Ok(Config {
case_sensitive,
search_full_path: opts.full_path,
Expand All @@ -241,9 +264,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result<Config
read_vcsignore: !(opts.no_ignore || opts.rg_alias_ignore() || opts.no_ignore_vcs),
require_git_to_read_vcsignore: !opts.no_require_git,
read_parent_ignore: !opts.no_ignore_parent,
read_global_ignore: !(opts.no_ignore
|| opts.rg_alias_ignore()
|| opts.no_global_ignore_file),
read_global_ignore,
follow_links: opts.follow,
one_file_system: opts.one_file_system,
null_separator: opts.null_separator,
Expand Down Expand Up @@ -297,6 +318,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result<Config
command: command.map(Arc::new),
batch_size: opts.batch_size,
exclude_patterns: opts.exclude.iter().map(|p| String::from("!") + p).collect(),
exclude_absolute_matchers,
ignore_files: std::mem::take(&mut opts.ignore_file),
size_constraints: size_limits,
time_constraints,
Expand Down Expand Up @@ -468,3 +490,44 @@ fn build_regex(pattern_regex: String, config: &Config) -> Result<regex::bytes::R
)
})
}

fn read_and_build_global_exclude_absolute_matchers() -> Result<Vec<GlobMatcher>> {
let file_content = match get_fd_config_dir()
.map(|p| p.join("ignore-absolute"))
.filter(|p| p.is_file())
{
Some(path) => std::fs::read_to_string(path)?,
// not an error if the file doesn't exist
None => return Ok(vec![]),
};

let matchers = file_content
.lines()
// trim trailing spaces, unless escaped with backslash (`\`)
.map(|raw| {
let naive_trimmed = raw.trim_end_matches(' ');
if raw.len() == naive_trimmed.len() {
raw
} else if naive_trimmed.ends_with('\\') {
&raw[..naive_trimmed.len() + 1]
} else {
naive_trimmed
}
})
// skip empty lines and comments
.filter(|line| !line.is_empty() && !line.starts_with('#'))
// build matchers
.filter_map(|line| match Glob::new(line) {
Ok(glob) => Some(glob.compile_matcher()),
// invalid globs from config file are warnings
Err(err) => {
print_error(format!(
"Malformed pattern in global ignore-absolute file. {err}."
));
None
}
})
.collect();

Ok(matchers)
}
44 changes: 33 additions & 11 deletions src/walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use ignore::overrides::OverrideBuilder;
use ignore::{self, WalkBuilder};
use regex::bytes::Regex;

use crate::config::get_fd_config_dir;
use crate::config::Config;
use crate::dir_entry::DirEntry;
use crate::error::print_error;
Expand Down Expand Up @@ -89,17 +90,8 @@ pub fn scan(paths: &[PathBuf], patterns: Arc<Vec<Regex>>, config: Arc<Config>) -
}

if config.read_global_ignore {
#[cfg(target_os = "macos")]
let config_dir_op = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| dirs_next::home_dir().map(|d| d.join(".config")));

#[cfg(not(target_os = "macos"))]
let config_dir_op = dirs_next::config_dir();

if let Some(global_ignore_file) = config_dir_op
.map(|p| p.join("fd").join("ignore"))
if let Some(global_ignore_file) = get_fd_config_dir()
.map(|p| p.join("ignore"))
.filter(|p| p.is_file())
{
let result = walker.add_ignore(global_ignore_file);
Expand Down Expand Up @@ -534,6 +526,36 @@ fn spawn_senders(
}
}

// Exclude by absolute path
// `ignore` crate does not intend to support this, so it's implemented here independently
// see https://github.com/BurntSushi/ripgrep/issues/2366
// This is done last because canonicalisation has non-trivial cost
if !config.exclude_absolute_matchers.is_empty() {
match entry_path.canonicalize() {
Ok(path) => {
if config
.exclude_absolute_matchers
.iter()
.any(|glob| glob.is_match(&path))
{
// Ideally we want to return `WalkState::Skip` to emulate gitignore's
// behavior of skipping any matched directory entirely
// Unfortunately this will make the search behaviour inconsistent
// because this filter happens outside of the directory walker
//
// E.g. Given directory structure `/foo/bar/` and CWD `/`:
// - `fd --exclude-absolute '/foo'` will return nothing
// - `fd --exclude-absolute '/foo' bar` will return '/foo/bar'
// Obviously this makes no sense
return ignore::WalkState::Continue;
}
}
Err(err) => {
print_error(format!("Cannot canonicalize {entry_path:?}. {err}."));
}
}
}

if config.is_printing() {
if let Some(ls_colors) = &config.ls_colors {
// Compute colors in parallel
Expand Down