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 multiple search-and-replace paths #93

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 20 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ anyhow = "1.0.32"
atty = "0.2.14"
clap = { version = "3.0.7", features = ["derive"] }
colored = "2.0"
dyn-clone = "1.0.5"
ignore = "0.4"
Inflector = "0.11"
os_str_bytes = "6.0.1"
patricia_tree = "0.3.1"
regex = "1.5.1"


Expand Down
20 changes: 13 additions & 7 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ struct Options {
parse(from_os_str),
help = "The source path. Defaults to the working directory"
)]
path: Option<PathBuf>,
paths: Vec<PathBuf>,

#[clap(
long = "--no-regex",
Expand Down Expand Up @@ -176,7 +176,7 @@ pub fn run() -> Result<()> {
ignored,
ignored_file_types,
no_regex,
path,
mut paths,
pattern,
replacement,
selected_file_types,
Expand Down Expand Up @@ -217,11 +217,13 @@ pub fn run() -> Result<()> {
ignored_file_types,
};

let path = path.unwrap_or_else(|| Path::new(".").to_path_buf());
if path == PathBuf::from("-") {
if paths.is_empty() {
paths.push(Path::new(".").to_path_buf());
}
if paths.len() == 1 && paths.first().unwrap() == &PathBuf::from("-") {
run_on_stdin(query)
} else {
run_on_directory(console, path, settings, query)
run_on_directory(console, paths, settings, query)
}
}

Expand All @@ -241,12 +243,16 @@ fn run_on_stdin(query: Query) -> Result<()> {

fn run_on_directory(
console: Console,
path: PathBuf,
paths: Vec<PathBuf>,
settings: Settings,
query: Query,
) -> Result<()> {
let dry_run = settings.dry_run;
let mut directory_patcher = DirectoryPatcher::new(&console, &path, &settings);
let mut directory_patcher = DirectoryPatcher::new(
&console,
Box::new(paths.iter().map(|p| -> &Path { &*p })),
&settings,
);
directory_patcher.run(&query)?;
let stats = directory_patcher.stats();
if stats.total_replacements() == 0 {
Expand Down
48 changes: 44 additions & 4 deletions src/directory_patcher.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
use anyhow::{Context, Result};
use dyn_clone::DynClone;
use std::fmt::Debug;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};

use crate::console::Console;
use crate::file_patcher::FilePatcher;
use crate::query::Query;
use crate::settings::Settings;
use crate::stats::Stats;

use self::path_deduplicator::PathDeduplicator;

mod path_deduplicator;

#[derive(Debug)]
/// Used to run replacement query on every text file present in a given path
/// ```rust
Expand All @@ -29,22 +37,32 @@ use crate::stats::Stats;
// Note: keep the dry_run: true in the doc test above or the integration test
// will fail ...
pub struct DirectoryPatcher<'a> {
path: &'a Path,
paths: Box<dyn PathsIter<'a> + 'a>,
settings: &'a Settings,
console: &'a Console,
stats: Stats,
}

pub trait PathsIter<'a>
where
Self: Debug + DynClone + Iterator<Item = &'a Path> + Send,
{
}

dyn_clone::clone_trait_object!(<'a> PathsIter<'a>);

impl<'a, T> PathsIter<'a> for T where Self: Debug + DynClone + Iterator<Item = &'a Path> + Send + 'a {}

impl<'a> DirectoryPatcher<'a> {
pub fn new(
console: &'a Console,
path: &'a Path,
paths: Box<dyn PathsIter<'a> + 'a>,
settings: &'a Settings,
) -> DirectoryPatcher<'a> {
let stats = Stats::default();
DirectoryPatcher {
console,
path,
paths,
settings,
stats,
}
Expand Down Expand Up @@ -116,7 +134,19 @@ impl<'a> DirectoryPatcher<'a> {
}
}
let types_matcher = types_builder.build()?;
let mut walk_builder = ignore::WalkBuilder::new(&self.path);

let mut paths = self.paths.clone();

let mut walk_builder = ignore::WalkBuilder::new(
paths
.next()
.expect("internal error: expected at least one path"),
);

for path in paths {
walk_builder.add(path);
}

walk_builder.types(types_matcher);
// Note: the walk_builder configures the "ignore" settings of the Walker,
// hence the negations
Expand All @@ -126,6 +156,16 @@ impl<'a> DirectoryPatcher<'a> {
if self.settings.hidden {
walk_builder.hidden(false);
}

let path_deduplicator = Arc::new(Mutex::new(PathDeduplicator::new()));
walk_builder.filter_entry(move |dir_entry| {
fs::canonicalize(dir_entry.path()).map_or(false, |abs_path_buf| {
let was_not_seen_before =
path_deduplicator.lock().unwrap().insert_path(&abs_path_buf);
was_not_seen_before
})
});

Ok(walk_builder.build())
}
}
22 changes: 22 additions & 0 deletions src/directory_patcher/path_deduplicator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use std::path::Path;

use os_str_bytes::RawOsStr;
use patricia_tree::PatriciaSet;

#[derive(Debug, Default)]
pub struct PathDeduplicator {
set: PatriciaSet,
}

impl PathDeduplicator {
pub fn new() -> Self {
Self::default()
}

// Returns `true` if the given `path` was called for this instance before.
pub fn insert_path(&mut self, path: &Path) -> bool {
let Self { set } = self;
let raw = RawOsStr::new(path.as_os_str());
set.insert(raw.as_raw_bytes())
}
}
38 changes: 21 additions & 17 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ fn assert_not_replaced(path: &Path) {
assert!(contents.contains("old"));
}

fn run_ruplacer(data_path: &Path, settings: Settings) -> Result<Stats> {
fn run_ruplacer(data_path: &[&Path], settings: Settings) -> Result<Stats> {
let console = Console::new();
let mut directory_patcher = DirectoryPatcher::new(&console, data_path, &settings);
let mut directory_patcher = DirectoryPatcher::new(
&console,
Box::new(data_path.into_iter().cloned()),
&settings,
);
directory_patcher.run(&Query::substring("old", "new"))?;
Ok(directory_patcher.stats())
}
Expand All @@ -65,7 +69,7 @@ fn test_replace_old_by_new() {
let data_path = setup_test(&tmp_dir);

let settings = Settings::default();
run_ruplacer(&data_path, settings).unwrap();
run_ruplacer(&[&data_path], settings).unwrap();
let top_txt_path = data_path.join("top.txt");
assert_replaced(&top_txt_path);

Expand All @@ -80,7 +84,7 @@ fn test_stats() {
let data_path = setup_test(&tmp_dir);

let settings = Settings::default();
let stats = run_ruplacer(&data_path, settings).unwrap();
let stats = run_ruplacer(&[&data_path], settings).unwrap();
assert!(stats.matching_files() > 1);
assert!(stats.total_replacements() > 1);
}
Expand All @@ -94,7 +98,7 @@ fn test_dry_run() {
dry_run: true,
..Default::default()
};
run_ruplacer(&data_path, settings).unwrap();
run_ruplacer(&[&data_path], settings).unwrap();

let top_txt_path = data_path.join("top.txt");
assert_not_replaced(&top_txt_path);
Expand All @@ -106,7 +110,7 @@ fn test_skip_hidden_and_ignored_by_default() {
let data_path = setup_test(&tmp_dir);

let settings = Settings::default();
run_ruplacer(&data_path, settings).unwrap();
run_ruplacer(&[&data_path], settings).unwrap();

let hidden_path = data_path.join(".hidden.txt");
assert_not_replaced(&hidden_path);
Expand All @@ -124,7 +128,7 @@ fn test_can_replace_hidden_files() {
hidden: true,
..Default::default()
};
run_ruplacer(&data_path, settings).unwrap();
run_ruplacer(&[&data_path], settings).unwrap();

let hidden_path = data_path.join(".hidden.txt");
assert_replaced(&hidden_path);
Expand All @@ -139,7 +143,7 @@ fn test_can_replace_ignored_files() {
ignored: true,
..Default::default()
};
run_ruplacer(&data_path, settings).unwrap();
run_ruplacer(&[&data_path], settings).unwrap();

let ignored_path = data_path.join("ignore.txt");
assert_replaced(&ignored_path);
Expand All @@ -153,7 +157,7 @@ fn test_skip_non_utf8_files() {
fs::write(bin_path, b"caf\xef\n").unwrap();

let settings = Settings::default();
run_ruplacer(&data_path, settings).unwrap();
run_ruplacer(&[&data_path], settings).unwrap();
}

fn add_python_file(data_path: &Path) -> PathBuf {
Expand All @@ -172,7 +176,7 @@ fn test_select_file_types() {
selected_file_types: vec!["py".to_string()],
..Default::default()
};
let stats = run_ruplacer(&data_path, settings).unwrap();
let stats = run_ruplacer(&[&data_path], settings).unwrap();

assert_eq!(stats.matching_files(), 1);
}
Expand All @@ -187,7 +191,7 @@ fn test_select_file_types_by_glob_pattern_1() {
selected_file_types: vec!["*.py".to_string()],
..Default::default()
};
let stats = run_ruplacer(&data_path, settings).unwrap();
let stats = run_ruplacer(&[&data_path], settings).unwrap();

assert_eq!(stats.matching_files(), 1);
}
Expand All @@ -202,7 +206,7 @@ fn test_select_file_types_by_glob_pattern_2() {
selected_file_types: vec!["f*.py".to_string()],
..Default::default()
};
let stats = run_ruplacer(&data_path, settings).unwrap();
let stats = run_ruplacer(&[&data_path], settings).unwrap();

assert_eq!(stats.matching_files(), 1);
}
Expand All @@ -216,7 +220,7 @@ fn test_select_file_types_by_incorrect_glob_pattern() {
selected_file_types: vec!["[*.py".to_string()],
..Default::default()
};
let err = run_ruplacer(&data_path, settings).unwrap_err();
let err = run_ruplacer(&[&data_path], settings).unwrap_err();
assert!(err.to_string().contains("error parsing glob"));
}

Expand All @@ -229,7 +233,7 @@ fn test_ignore_file_types() {
ignored_file_types: vec!["py".to_string()],
..Default::default()
};
run_ruplacer(&data_path, settings).unwrap();
run_ruplacer(&[&data_path], settings).unwrap();

assert_not_replaced(&py_path);
}
Expand All @@ -243,7 +247,7 @@ fn test_ignore_file_types_by_glob_pattern_1() {
ignored_file_types: vec!["*.py".to_string()],
..Default::default()
};
run_ruplacer(&data_path, settings).unwrap();
run_ruplacer(&[&data_path], settings).unwrap();

assert_not_replaced(&py_path);
}
Expand All @@ -257,7 +261,7 @@ fn test_ignore_file_types_by_glob_pattern_2() {
ignored_file_types: vec!["f*.py".to_string()],
..Default::default()
};
run_ruplacer(&data_path, settings).unwrap();
run_ruplacer(&[&data_path], settings).unwrap();

assert_not_replaced(&py_path);
}
Expand All @@ -270,6 +274,6 @@ fn test_ignore_file_types_by_incorrect_glob_pattern() {
ignored_file_types: vec!["[.py".to_string()],
..Default::default()
};
let err = run_ruplacer(&data_path, settings).unwrap_err();
let err = run_ruplacer(&[&data_path], settings).unwrap_err();
assert!(err.to_string().contains("unrecognized file type"));
}