From 0bdf62d93a8fb274c3c401c587e6e76778c719dd Mon Sep 17 00:00:00 2001 From: "Manu [tennox]" <2084639+tennox@users.noreply.github.com> Date: Wed, 17 May 2023 10:14:59 +0100 Subject: [PATCH 1/4] #191 Add --no-swap flag --- src/cli.rs | 4 +++ src/main.rs | 1 + src/replacer.rs | 66 ++++++++++++++++++++++++++++++++----------------- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 862919e..9290436 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -55,6 +55,10 @@ w - match full words only /// use captured values like $1, $2, etc. pub replace_with: String, + #[arg(long)] + /// Overwrite file instead of creating tmp file and swaping atomically + pub no_swap: bool, + /// The path to file(s). This is optional - sd can also read from STDIN. ///{n}{n}Note: sd modifies files in-place by default. See documentation for /// examples. diff --git a/src/main.rs b/src/main.rs index de24644..97abb9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ fn main() -> Result<()> { options.literal_mode, options.flags, options.replacements, + options.no_swap, )?, ) .run(options.preview)?; diff --git a/src/replacer.rs b/src/replacer.rs index f6d5d21..57f45d4 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::{ + fs, + fs::{File, OpenOptions}, + io::{prelude::*, SeekFrom}, + path::Path, +}; pub(crate) struct Replacer { regex: Regex, replace_with: Vec, is_literal: bool, replacements: usize, + no_swap: bool, } impl Replacer { @@ -16,6 +22,7 @@ impl Replacer { is_literal: bool, flags: Option, replacements: Option, + no_swap: bool, ) -> Result { let (look_for, replace_with) = if is_literal { (regex::escape(&look_for), replace_with.into_bytes()) @@ -61,6 +68,7 @@ impl Replacer { replace_with, is_literal, replacements: replacements.unwrap_or(0), + no_swap, }) } @@ -128,29 +136,42 @@ impl Replacer { return Ok(()); } - let source = File::open(path)?; - let meta = fs::metadata(path)?; - let mmap_source = unsafe { Mmap::map(&source)? }; - let replaced = self.replace(&mmap_source); - - let target = tempfile::NamedTempFile::new_in( - path.parent() - .ok_or_else(|| Error::InvalidPath(path.to_path_buf()))?, - )?; - let file = target.as_file(); - file.set_len(replaced.len() as u64)?; - file.set_permissions(meta.permissions())?; - - if !replaced.is_empty() { - let mut mmap_target = unsafe { MmapMut::map_mut(file)? }; - mmap_target.deref_mut().write_all(&replaced)?; - mmap_target.flush_async()?; - } + if self.no_swap { + let mut source = + OpenOptions::new().read(true).write(true).open(path)?; + let mut buffer = Vec::new(); + source.read_to_end(&mut buffer)?; + + let replaced = self.replace(&buffer); + + source.seek(SeekFrom::Start(0))?; + source.write_all(&replaced)?; + source.set_len(replaced.len() as u64)?; + } else { + let source = File::open(path)?; + let meta = fs::metadata(path)?; + let mmap_source = unsafe { Mmap::map(&source)? }; + let replaced = self.replace(&mmap_source); + + let target = tempfile::NamedTempFile::new_in( + path.parent() + .ok_or_else(|| Error::InvalidPath(path.to_path_buf()))?, + )?; + let file = target.as_file(); + file.set_len(replaced.len() as u64)?; + file.set_permissions(meta.permissions())?; + + if !replaced.is_empty() { + let mut mmap_target = unsafe { MmapMut::map_mut(file)? }; + mmap_target.deref_mut().write_all(&replaced)?; + mmap_target.flush_async()?; + } - drop(mmap_source); - drop(source); + drop(mmap_source); + drop(source); + target.persist(fs::canonicalize(path)?)?; + } - target.persist(fs::canonicalize(path)?)?; Ok(()) } } @@ -173,6 +194,7 @@ mod tests { literal, flags.map(ToOwned::to_owned), None, + false, ) .unwrap(); assert_eq!( From ca55e8cd3b88a4afa7f62925a48a6cee26b644ac Mon Sep 17 00:00:00 2001 From: "Manu [tennox]" <2084639+tennox@users.noreply.github.com> Date: Wed, 17 May 2023 10:31:14 +0100 Subject: [PATCH 2/4] #191 parameterize tests to include no-swap flag --- Cargo.toml | 1 + tests/cli.rs | 78 +++++++++++++++++++++++++++++++++------------------- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d39183d..b133801 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ clap = { version = "4.2.7", features = ["derive", "deprecated", "wrap_help"] } [dev-dependencies] assert_cmd = "1.0.3" anyhow = "1.0.38" +rstest = "0.17.0" [build-dependencies] clap = "4.2.7" diff --git a/tests/cli.rs b/tests/cli.rs index 29756b3..e36486e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -3,6 +3,7 @@ mod cli { use anyhow::Result; use assert_cmd::Command; + use rstest::rstest; use std::io::prelude::*; fn sd() -> Command { @@ -25,36 +26,46 @@ mod cli { Ok(()) } - #[test] - fn in_place() -> Result<()> { + #[rstest] + fn in_place(#[values(false, true)] no_swap: bool) -> Result<()> { let mut file = tempfile::NamedTempFile::new()?; file.write_all(b"abc123def")?; let path = file.into_temp_path(); - sd().args(["abc\\d+", "", path.to_str().unwrap()]) - .assert() - .success(); + let mut cmd = sd(); + cmd.args(["abc\\d+", "", path.to_str().unwrap()]); + if no_swap { + cmd.arg("--no-swap"); + } + cmd.assert().success(); assert_file(&path, "def"); Ok(()) } - #[test] - fn in_place_with_empty_result_file() -> Result<()> { + #[rstest] + fn in_place_with_empty_result_file( + #[values(false, true)] no_swap: bool, + ) -> Result<()> { let mut file = tempfile::NamedTempFile::new()?; file.write_all(b"a7c")?; let path = file.into_temp_path(); - sd().args(["a\\dc", "", path.to_str().unwrap()]) - .assert() - .success(); + let mut cmd = sd(); + cmd.args(["a\\dc", "", path.to_str().unwrap()]); + if no_swap { + cmd.arg("--no-swap"); + } + cmd.assert().success(); assert_file(&path, ""); Ok(()) } - #[test] - fn in_place_following_symlink() -> Result<()> { + #[rstest] + fn in_place_following_symlink( + #[values(false, true)] no_swap: bool, + ) -> Result<()> { let dir = tempfile::tempdir()?; let path = dir.path(); let file = path.join("file"); @@ -63,9 +74,12 @@ mod cli { create_soft_link(&file, &link)?; std::fs::write(&file, "abc123def")?; - sd().args(["abc\\d+", "", link.to_str().unwrap()]) - .assert() - .success(); + let mut cmd = sd(); + cmd.args(["abc\\d+", "", link.to_str().unwrap()]); + if no_swap { + cmd.arg("--no-swap"); + } + cmd.assert().success(); assert_file(&file, "def"); assert!(std::fs::symlink_metadata(link)?.file_type().is_symlink()); @@ -73,29 +87,35 @@ mod cli { Ok(()) } - #[test] - fn replace_into_stdout() -> Result<()> { + #[rstest] + fn replace_into_stdout(#[values(false, true)] no_swap: bool) -> Result<()> { let mut file = tempfile::NamedTempFile::new()?; file.write_all(b"abc123def")?; - sd().args(["-p", "abc\\d+", "", file.path().to_str().unwrap()]) - .assert() - .success() - .stdout(format!( - "{}{}def\n", - ansi_term::Color::Green.prefix(), - ansi_term::Color::Green.suffix() - )); + let mut cmd = sd(); + cmd.args(["-p", "abc\\d+", "", file.path().to_str().unwrap()]); + if no_swap { + cmd.arg("--no-swap"); + } + cmd.assert().success().stdout(format!( + "{}{}def\n", + ansi_term::Color::Green.prefix(), + ansi_term::Color::Green.suffix() + )); assert_file(file.path(), "abc123def"); Ok(()) } - #[test] - fn stdin() -> Result<()> { - sd().args(["abc\\d+", ""]) - .write_stdin("abc123def") + #[rstest] + fn stdin(#[values(false, true)] no_swap: bool) -> Result<()> { + let mut cmd = sd(); + cmd.args(["abc\\d+", ""]); + if no_swap { + cmd.arg("--no-swap"); + } + cmd.write_stdin("abc123def") .assert() .success() .stdout("def"); From 03063a6faee32465a0002d51657ba36bbf56babf Mon Sep 17 00:00:00 2001 From: Peterlits Zo Date: Thu, 1 Jun 2023 21:59:58 +0800 Subject: [PATCH 3/4] Add a library crate (#194) --- src/cli.rs | 3 ++- src/input.rs | 22 +++++++++++++++++++-- src/lib.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 29 +++++++++------------------ 4 files changed, 86 insertions(+), 23 deletions(-) create mode 100644 src/lib.rs diff --git a/src/cli.rs b/src/cli.rs index 9290436..bc644af 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -60,7 +60,8 @@ w - match full words only pub no_swap: bool, /// The path to file(s). This is optional - sd can also read from STDIN. - ///{n}{n}Note: sd modifies files in-place by default. See documentation for + /// + /// Note: sd modifies files in-place by default. See documentation for /// examples. pub files: Vec, } diff --git a/src/input.rs b/src/input.rs index 5e551ad..b0a3dbc 100644 --- a/src/input.rs +++ b/src/input.rs @@ -4,14 +4,31 @@ use crate::{Error, Replacer, Result}; use is_terminal::IsTerminal; +/// The source files we regard as the input. #[derive(Debug)] -pub(crate) enum Source { +pub enum Source { Stdin, Files(Vec), } impl Source { - pub(crate) fn recursive() -> Result { + pub fn with_file(file: T) -> Self + where T: Into + { + Self::with_files(vec![file]) + } + + pub fn with_files(files: Vec) -> Self + where T: Into + { + Self::Files( + files.into_iter() + .map(|file_path| file_path.into()) + .collect() + ) + } + + pub fn recursive() -> Result { Ok(Self::Files( ignore::WalkBuilder::new(".") .hidden(false) @@ -36,6 +53,7 @@ impl App { pub(crate) fn new(source: Source, replacer: Replacer) -> Self { Self { source, replacer } } + pub(crate) fn run(&self, preview: bool) -> Result<()> { let is_tty = std::io::stdout().is_terminal(); diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8ff134c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,55 @@ +pub mod input; +pub mod error; +pub mod replacer; +pub mod utils; + +pub use self::input::Source; +pub use error::{Error, Result}; +use input::App; +pub(crate) use replacer::Replacer; + +pub struct ReplaceConf { + pub source: Source, + pub preview: bool, + pub no_swap: bool, + pub literal_mode: bool, + pub flags: Option, + pub replacements: Option, +} + +impl Default for ReplaceConf { + fn default() -> Self { + Self { + source: Source::Stdin, + preview: false, + no_swap: false, + literal_mode: false, + flags: None, + replacements: None, + } + } +} + +/// For example: +/// +/// ```no_run +/// replace("foo", "bar", ReplaceConf { +/// source: Source::with_files(vec!["./foo.md", "./bar.md", "./foobar.md"]), +/// ..ReplaceConf::default() +/// }) +/// ``` +pub fn replace(find: String, replace_with: String, replace_conf: ReplaceConf) -> Result<()> { + App::new( + replace_conf.source, + Replacer::new( + find, + replace_with, + replace_conf.literal_mode, + replace_conf.flags, + replace_conf.replacements, + replace_conf.no_swap, + )?, + ) + .run(replace_conf.preview)?; + Ok(()) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 97abb9d..6ee079d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,9 @@ mod cli; -mod error; -mod input; - -pub(crate) mod replacer; -pub(crate) mod utils; - -pub(crate) use self::input::{App, Source}; -pub(crate) use error::{Error, Result}; -use replacer::Replacer; use clap::Parser; +use sd::{Result, Source, replace, ReplaceConf}; + fn main() -> Result<()> { let options = cli::Options::parse(); @@ -22,17 +15,13 @@ fn main() -> Result<()> { Source::Stdin }; - App::new( + replace(options.find, options.replace_with, ReplaceConf { source, - Replacer::new( - options.find, - options.replace_with, - options.literal_mode, - options.flags, - options.replacements, - options.no_swap, - )?, - ) - .run(options.preview)?; + preview: options.preview, + no_swap: options.no_swap, + literal_mode: options.literal_mode, + flags: options.flags, + replacements: options.replacements, + })?; Ok(()) } From 2810fe7772427b6c5729b9b060993cb669323850 Mon Sep 17 00:00:00 2001 From: Peterlits Zo Date: Fri, 2 Jun 2023 20:35:02 +0800 Subject: [PATCH 4/4] Improve the library interface (#194) Add config builder, test and support `&str` argument. --- src/lib.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8ff134c..01edbe4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ -pub mod input; pub mod error; +pub mod input; pub mod replacer; pub mod utils; @@ -17,6 +17,10 @@ pub struct ReplaceConf { pub replacements: Option, } +pub struct ReplaceConfBuilder { + inner: ReplaceConf, +} + impl Default for ReplaceConf { fn default() -> Self { Self { @@ -30,20 +34,49 @@ impl Default for ReplaceConf { } } +impl ReplaceConf { + pub fn builder() -> ReplaceConfBuilder { + ReplaceConfBuilder { + inner: Self::default(), + } + } +} + +impl ReplaceConfBuilder { + pub fn set_source(mut self, source: Source) -> Self { + self.inner.source = source; + self + } + + pub fn build(self) -> ReplaceConf { + self.inner + } +} + /// For example: -/// +/// /// ```no_run +/// use sd::{replace, ReplaceConf, Source}; +/// /// replace("foo", "bar", ReplaceConf { /// source: Source::with_files(vec!["./foo.md", "./bar.md", "./foobar.md"]), /// ..ReplaceConf::default() -/// }) +/// }); /// ``` -pub fn replace(find: String, replace_with: String, replace_conf: ReplaceConf) -> Result<()> { +pub fn replace( + find: F, + replace_with: R, + replace_conf: ReplaceConf, +) -> Result<()> +where + F: Into, + R: Into, +{ App::new( replace_conf.source, Replacer::new( - find, - replace_with, + find.into(), + replace_with.into(), replace_conf.literal_mode, replace_conf.flags, replace_conf.replacements, @@ -52,4 +85,35 @@ pub fn replace(find: String, replace_with: String, replace_conf: ReplaceConf) -> ) .run(replace_conf.preview)?; Ok(()) -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use std::{fs::{File, self}, io::{Write, Read}}; + + use super::*; + + #[test] + fn it_works() { + // Create files for test. + for p in &vec!["./for-test-foo", "./for-test-bar"] { + let mut f = File::create(p).unwrap(); + f.write_all(b"foo bar foz").unwrap(); + } + + // Run it. + let replace_conf = ReplaceConf::builder() + .set_source(Source::with_files(vec!["./for-test-foo", "./for-test-bar"])) + .build(); + replace("foo", "bar", replace_conf).unwrap(); + + // Assert and cleanup. + for p in &vec!["./for-test-foo", "./for-test-bar"] { + let mut f = File::open(p).unwrap(); + let mut buf = vec![]; + f.read_to_end(&mut buf).unwrap(); + assert_eq!(buf, b"bar bar foz"); + fs::remove_file(p).unwrap(); + } + } +}