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

Replace path pattern with include and exclude fields #78

Merged
merged 8 commits into from
Mar 7, 2025
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add initial glob matching for include and exclude files
thomasschafer committed Feb 27, 2025

Verified

This commit was signed with the committer’s verified signature.
thomasschafer Tom Schafer
commit 3542caee955bf49bf9b5d74e078b8f963efda4fc
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -43,7 +43,8 @@ When on the search screen the following fields are available:
- **Fixed strings**: If enabled, search with plain case-sensitive strings. If disabled, search with regex.
- **Match whole word**: If enabled, only match when the search string forms the entire word and not a substring in a larger word. For instance, if the search string is "foo", "foo bar" would be matched but not "foobar".
- **Match case**: If enabled, match the case of the search string exactly, e.g. a search string of `Bar` would match `foo Bar baz` but not `foo bar baz`.
- **Path pattern (regex)**: Regex pattern that file paths must match. The relative path of the file is matched against: for instance, if searching in `/foo/`, if the path pattern is set to `bar` then `/foo/bar.txt` and `/foo/bar/file.rs` will be included. In the same example, if the path pattern is set to `foo` then `/foo/bar.txt` will *not* be included, but `/foo/foo.txt` will be.
- **Files to include**: Glob pattern that file paths must match: for instance, `*.rs` matches all files with the `.rs` extension.
- **Files to exclude**: Glob pattern that file paths must not match: for instance, `env/` ignores all files in the `env` directory. This field takes precedence over the pattern in the "Files to include" field.

## Installation

100 changes: 60 additions & 40 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use anyhow::Error;
use crossterm::event::KeyEvent;
use fancy_regex::Regex as FancyRegex;
use ignore::WalkState;
use ignore::{
overrides::{Override, OverrideBuilder},
WalkState,
};
use log::warn;
use parking_lot::{
MappedRwLockReadGuard, MappedRwLockWriteGuard, RwLock, RwLockReadGuard, RwLockWriteGuard,
@@ -242,8 +245,9 @@ pub enum FieldName {
Replace,
FixedStrings,
WholeWord,
PathPattern,
MatchCase,
IncludeFiles,
ExcludeFiles,
}

pub struct SearchFieldValues<'a> {
@@ -252,7 +256,8 @@ pub struct SearchFieldValues<'a> {
pub fixed_strings: bool,
pub whole_word: bool,
pub match_case: bool,
pub filename_pattern: &'a str,
pub include_files: &'a str,
pub exclude_files: &'a str,
}
impl<'a> Default for SearchFieldValues<'a> {
fn default() -> SearchFieldValues<'a> {
@@ -262,7 +267,8 @@ impl<'a> Default for SearchFieldValues<'a> {
fixed_strings: Self::DEFAULT_FIXED_STRINGS,
whole_word: Self::DEFAULT_WHOLE_WORD,
match_case: Self::DEFAULT_MATCH_CASE,
filename_pattern: Self::DEFAULT_FILENAME_PATTERN,
include_files: Self::DEFAULT_INCLUDE_FILES,
exclude_files: Self::DEFAULT_EXCLUDE_FILES,
}
}
}
@@ -273,7 +279,8 @@ impl SearchFieldValues<'_> {
const DEFAULT_FIXED_STRINGS: bool = false;
const DEFAULT_WHOLE_WORD: bool = false;
const DEFAULT_MATCH_CASE: bool = true;
const DEFAULT_FILENAME_PATTERN: &'static str = "";
const DEFAULT_INCLUDE_FILES: &'static str = "";
const DEFAULT_EXCLUDE_FILES: &'static str = "";

pub fn whole_word_default() -> bool {
Self::DEFAULT_WHOLE_WORD
@@ -289,7 +296,7 @@ pub struct SearchField {
pub field: Arc<RwLock<Field>>,
}

pub const NUM_SEARCH_FIELDS: usize = 6;
pub const NUM_SEARCH_FIELDS: usize = 7;

pub struct SearchFields {
pub fields: [SearchField; NUM_SEARCH_FIELDS],
@@ -350,10 +357,13 @@ impl SearchFields {
);
define_field_accessor!(whole_word, FieldName::WholeWord, Checkbox, CheckboxField);
define_field_accessor!(match_case, FieldName::MatchCase, Checkbox, CheckboxField);
define_field_accessor!(path_pattern, FieldName::PathPattern, Text, TextField);
define_field_accessor!(include_files, FieldName::IncludeFiles, Text, TextField);
define_field_accessor!(exclude_files, FieldName::ExcludeFiles, Text, TextField);

define_field_accessor_mut!(search_mut, FieldName::Search, Text, TextField);
define_field_accessor_mut!(path_pattern_mut, FieldName::PathPattern, Text, TextField);
// TODO: use to set and clear errors
define_field_accessor_mut!(include_files_mut, FieldName::IncludeFiles, Text, TextField);
define_field_accessor_mut!(exclude_files_mut, FieldName::ExcludeFiles, Text, TextField);

pub fn with_values(search_field_values: SearchFieldValues<'_>) -> Self {
Self {
@@ -381,10 +391,12 @@ impl SearchFields {
field: Arc::new(RwLock::new(Field::checkbox(search_field_values.match_case))),
},
SearchField {
name: FieldName::PathPattern,
field: Arc::new(RwLock::new(Field::text(
search_field_values.filename_pattern,
))),
name: FieldName::IncludeFiles,
field: Arc::new(RwLock::new(Field::text(search_field_values.include_files))),
},
SearchField {
name: FieldName::ExcludeFiles,
field: Arc::new(RwLock::new(Field::text(search_field_values.exclude_files))),
},
],
highlighted: 0,
@@ -449,21 +461,21 @@ impl SearchFields {
Ok(result)
}

pub fn path_pattern_parsed(&self) -> anyhow::Result<Option<SearchType>> {
let path_patt_text = &self.path_pattern().text;
let result = if path_patt_text.is_empty() {
None
} else {
Some({
if self.advanced_regex {
SearchType::PatternAdvanced(FancyRegex::new(path_patt_text)?)
} else {
SearchType::Pattern(Regex::new(path_patt_text)?)
}
})
};
Ok(result)
}
// pub fn path_pattern_parsed(&self) -> anyhow::Result<Option<SearchType>> {
// let path_patt_text = &self.path_pattern().text;
// let result = if path_patt_text.is_empty() {
// None
// } else {
// Some({
// if self.advanced_regex {
// SearchType::PatternAdvanced(FancyRegex::new(path_patt_text)?)
// } else {
// SearchType::Pattern(Regex::new(path_patt_text)?)
// }
// })
// };
// Ok(result)
// }
}

enum ValidatedField<T> {
@@ -829,18 +841,8 @@ impl App {
Ok(p) => ValidatedField::Parsed(p),
};

let path_pattern = match self.search_fields.path_pattern_parsed() {
Err(e) => {
self.search_fields
.path_pattern_mut()
.set_error("Couldn't parse regex".to_owned(), e.to_string());
ValidatedField::Error
}
Ok(r) => ValidatedField::Parsed(r),
};

let (search_pattern, path_pattern) = match (search_pattern, path_pattern) {
(ValidatedField::Parsed(s), ValidatedField::Parsed(p)) => (s, p),
let (search_pattern, overrides) = match (search_pattern, self.overrides()) {
(ValidatedField::Parsed(s), Ok(overrides)) => (s, overrides),
_ => {
self.search_fields.show_error_popup = true;
return Ok(None);
@@ -852,13 +854,31 @@ impl App {
self.search_fields.replace().text(),
self.search_fields.whole_word().checked,
self.search_fields.match_case().checked,
path_pattern,
overrides,
self.directory.clone(),
self.include_hidden,
background_processing_sender.clone(),
)))
}

fn overrides(&mut self) -> anyhow::Result<Override> {
let mut overrides = OverrideBuilder::new(self.directory.clone());
// TODO: check this works
for f in self.search_fields.include_files().text().split(",") {
if !f.is_empty() {
overrides.add(f.trim())?;
}
}
// TODO: reduce duplication
for f in self.search_fields.exclude_files().text().split(",") {
if !f.is_empty() {
overrides.add(&format!("!{}", f.trim()))?;
}
}
let overrides = overrides.build()?;
Ok(overrides)
}

pub fn update_search_results(
parsed_fields: ParsedFields,
background_processing_sender: UnboundedSender<BackgroundProcessingEvent>,
30 changes: 6 additions & 24 deletions src/replace.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use content_inspector::{inspect, ContentType};
use fancy_regex::Regex as FancyRegex;
use ignore::overrides::Override;
use ignore::{WalkBuilder, WalkParallel};
use log::warn;
use regex::Regex;
@@ -8,10 +9,7 @@ use tokio::io::AsyncBufReadExt;
use tokio::sync::mpsc::UnboundedSender;
use tokio::{fs::File, io::BufReader};

use crate::{
app::{BackgroundProcessingEvent, SearchFieldValues, SearchResult},
utils::relative_path_from,
};
use crate::app::{BackgroundProcessingEvent, SearchFieldValues, SearchResult};

fn replacement_if_match(line: &str, search: &SearchType, replace: &str) -> Option<String> {
if line.is_empty() || search.is_empty() {
@@ -80,7 +78,7 @@ fn convert_regex(search: SearchType, whole_word: bool, match_case: bool) -> Sear
pub struct ParsedFields {
search: SearchType,
replace: String,
path_pattern: Option<SearchType>,
overrides: Override,
// TODO: `root_dir` and `include_hidden` are duplicated across this and App
root_dir: PathBuf,
include_hidden: bool,
@@ -96,7 +94,7 @@ impl ParsedFields {
replace: String,
whole_word: bool,
match_case: bool,
path_pattern: Option<SearchType>,
overrides: Override,
root_dir: PathBuf,
include_hidden: bool,
background_processing_sender: UnboundedSender<BackgroundProcessingEvent>,
@@ -111,20 +109,14 @@ impl ParsedFields {
Self {
search,
replace,
path_pattern,
overrides,
root_dir,
include_hidden,
background_processing_sender,
}
}

pub async fn handle_path(&self, path: &Path) {
if let Some(ref p) = self.path_pattern {
if !self.matches_pattern(path, p) {
return;
}
}

match File::open(path).await {
Ok(file) => {
let reader = BufReader::new(file);
@@ -171,20 +163,10 @@ impl ParsedFields {
}
}

fn matches_pattern(&self, path: &Path, p: &SearchType) -> bool {
let relative_path = relative_path_from(&self.root_dir, path);
let relative_path = relative_path.as_str();

match p {
SearchType::Pattern(ref p) => p.is_match(relative_path),
SearchType::PatternAdvanced(ref p) => p.is_match(relative_path).unwrap(),
SearchType::Fixed(ref s) => relative_path.contains(s),
}
}

pub(crate) fn build_walker(&self) -> WalkParallel {
WalkBuilder::new(&self.root_dir)
.hidden(!self.include_hidden)
.overrides(self.overrides.clone())
.filter_entry(|entry| entry.file_name() != ".git")
.build_parallel()
}
3 changes: 2 additions & 1 deletion src/ui.rs
Original file line number Diff line number Diff line change
@@ -25,7 +25,8 @@ impl FieldName {
FieldName::FixedStrings => "Fixed strings",
FieldName::WholeWord => "Match whole word",
FieldName::MatchCase => "Match case",
FieldName::PathPattern => "Path pattern (regex)",
FieldName::IncludeFiles => "Files to include",
FieldName::ExcludeFiles => "Files to exclude",
}
}
}