diff --git a/Cargo.toml b/Cargo.toml index 3216193..98c0b05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ version = "0.7.6" name = "sd" version.workspace = true edition.workspace = true + authors = ["Gregory "] description = "An intuitive find & replace CLI" readme = "README.md" diff --git a/src/cli.rs b/src/cli.rs index afc45a6..0686f1c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -66,6 +66,11 @@ w - match full words only /// Note: sd modifies files in-place by default. See documentation for /// examples. pub files: Vec, + + + /// Extra find and replace pairs. + #[arg(short, long, num_args(2))] + pub extra: Vec, } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index de24644..f5868c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ fn main() -> Result<()> { options.literal_mode, options.flags, options.replacements, + options.extra, )?, ) .run(options.preview)?; diff --git a/src/replacer.rs b/src/replacer.rs index f6d5d21..23ba1e3 100644 --- a/src/replacer.rs +++ b/src/replacer.rs @@ -1,12 +1,18 @@ use crate::{utils, Error, Result}; use regex::bytes::Regex; -use std::{fs, fs::File, io::prelude::*, path::Path}; +use std::{borrow::Cow, fs, fs::File, io::prelude::*, path::Path}; -pub(crate) struct Replacer { +#[derive(Debug)] +struct Pair { regex: Regex, - replace_with: Vec, - is_literal: bool, - replacements: usize, + rep: Vec, +} + +#[derive(Debug)] +pub(crate) struct Replacer { + pairs: Vec, + is_literal: bool, // -s + max_replacements: usize, } impl Replacer { @@ -16,24 +22,31 @@ impl Replacer { is_literal: bool, flags: Option, replacements: Option, + extra: Vec, ) -> Result { - let (look_for, replace_with) = if is_literal { - (regex::escape(&look_for), replace_with.into_bytes()) - } else { - ( - look_for, - utils::unescape(&replace_with) - .unwrap_or(replace_with) - .into_bytes(), - ) - }; - - let mut regex = regex::bytes::RegexBuilder::new(&look_for); - regex.multi_line(true); - - if let Some(flags) = flags { - flags.chars().for_each(|c| { - #[rustfmt::skip] + fn create( + look_for: String, + replace_with: String, + is_literal: bool, + flags: Option<&str>, + ) -> Result { + let (look_for, replace_with) = if is_literal { + (regex::escape(&look_for), replace_with.into_bytes()) + } else { + ( + look_for, + utils::unescape(&replace_with) + .unwrap_or(replace_with) + .into_bytes(), + ) + }; + + let mut regex = regex::bytes::RegexBuilder::new(&look_for); + regex.multi_line(true); + + if let Some(flags) = flags { + for c in flags.chars() { + #[rustfmt::skip] match c { 'c' => { regex.case_insensitive(false); }, 'i' => { regex.case_insensitive(true); }, @@ -53,19 +66,46 @@ impl Replacer { }, _ => {}, }; - }); - }; + } + }; + Ok(Pair { + regex: regex.build()?, + rep: replace_with, + }) + } - Ok(Self { - regex: regex.build()?, + let capacity = extra.len() / 2 + 1; + let mut pairs = Vec::with_capacity(capacity); + pairs.push(create( + look_for, replace_with, is_literal, - replacements: replacements.unwrap_or(0), + flags.as_deref(), + )?); + + let mut it = extra.into_iter(); + while let Some(look_for) = it.next() { + let replace_with = it + .next() + .expect("The extra pattern list doesn't have an even lenght"); + + pairs.push(create( + look_for, + replace_with, + is_literal, + flags.as_deref(), + )?); + } + + Ok(Self { + pairs, + is_literal, + max_replacements: replacements.unwrap_or(0), }) } pub(crate) fn has_matches(&self, content: &[u8]) -> bool { - self.regex.is_match(content) + self.pairs.iter().any(|r| r.regex.is_match(content)) } pub(crate) fn check_not_empty(mut file: File) -> Result<()> { @@ -74,50 +114,56 @@ impl Replacer { Ok(()) } - pub(crate) fn replace<'a>( - &'a self, - content: &'a [u8], - ) -> std::borrow::Cow<'a, [u8]> { - if self.is_literal { - self.regex.replacen( - content, - self.replacements, - regex::bytes::NoExpand(&self.replace_with), - ) - } else { - self.regex - .replacen(content, self.replacements, &*self.replace_with) + pub(crate) fn replace<'a>(&'a self, content: &'a [u8]) -> Cow<'a, [u8]> { + let mut result = Cow::Borrowed(content); + for Pair { regex, rep } in self.pairs.iter() { + let res = if self.is_literal { + let rep = regex::bytes::NoExpand(rep.as_slice()); + regex.replacen(&result, self.max_replacements, rep) + } else { + regex.replacen(&result, self.max_replacements, rep) + }; + + result = Cow::Owned(res.into_owned()); } + result } pub(crate) fn replace_preview<'a>( &'a self, - content: &[u8], - ) -> std::borrow::Cow<'a, [u8]> { - let mut v = Vec::::new(); - let mut captures = self.regex.captures_iter(content); - - self.regex.split(content).for_each(|sur_text| { - use regex::bytes::Replacer; - - v.extend(sur_text); - if let Some(capture) = captures.next() { - v.extend_from_slice( - ansi_term::Color::Green.prefix().to_string().as_bytes(), - ); - if self.is_literal { - regex::bytes::NoExpand(&self.replace_with) - .replace_append(&capture, &mut v); - } else { - (&*self.replace_with).replace_append(&capture, &mut v); + content: &'a [u8], + ) -> Cow<'a, [u8]> { + let mut content = Cow::Borrowed(content); + + for Pair { regex, rep } in self.pairs.iter() { + let rep = rep.as_slice(); + + let mut v = Vec::::new(); + let mut captures = regex.captures_iter(&content); + + for sur_text in regex.split(&content) { + use regex::bytes::Replacer; + + v.extend(sur_text); + if let Some(capture) = captures.next() { + v.extend_from_slice( + ansi_term::Color::Green.prefix().to_string().as_bytes(), + ); + if self.is_literal { + regex::bytes::NoExpand(&rep) + .replace_append(&capture, &mut v); + } else { + (&*rep).replace_append(&capture, &mut v); + } + v.extend_from_slice( + ansi_term::Color::Green.suffix().to_string().as_bytes(), + ); } - v.extend_from_slice( - ansi_term::Color::Green.suffix().to_string().as_bytes(), - ); } - }); + content = Cow::Owned(v); + } - return std::borrow::Cow::Owned(v); + content } pub(crate) fn replace_file(&self, path: &Path) -> Result<()> { @@ -130,7 +176,7 @@ impl Replacer { let source = File::open(path)?; let meta = fs::metadata(path)?; - let mmap_source = unsafe { Mmap::map(&source)? }; + let mmap_source = unsafe { Mmap::map(&source) }?; let replaced = self.replace(&mmap_source); let target = tempfile::NamedTempFile::new_in( @@ -142,7 +188,7 @@ impl Replacer { file.set_permissions(meta.permissions())?; if !replaced.is_empty() { - let mut mmap_target = unsafe { MmapMut::map_mut(file)? }; + let mut mmap_target = unsafe { MmapMut::map_mut(file) }?; mmap_target.deref_mut().write_all(&replaced)?; mmap_target.flush_async()?; } @@ -173,6 +219,7 @@ mod tests { literal, flags.map(ToOwned::to_owned), None, + vec![], ) .unwrap(); assert_eq!( @@ -216,4 +263,22 @@ mod tests { fn full_word_replace() { replace("abc", "def", false, Some("w"), "abcd abc", "abcd def"); } + + #[test] + fn test_multipattern() { + let replacer = Replacer::new( + "foo".to_owned(), + "bar".to_owned(), + false, + None, + None, + vec!["qux".into(), "quux".into(), "bing".into(), "bong".into()], + ) + .unwrap(); + + assert_eq!( + std::str::from_utf8(&replacer.replace("foo qux bing".as_bytes())), + Ok("bar quux bong") + ); + } }