diff --git a/Cargo.lock b/Cargo.lock index 62fd9d4..d0fd8ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,7 +1172,6 @@ dependencies = [ "rayon", "rstest", "serde", - "similar", "tempfile", "titlecase", "tree-sitter", diff --git a/Cargo.toml b/Cargo.toml index 4eb1f08..177e752 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,6 @@ ignore = "0.4.23" itertools = "0.13.0" log = "0.4.22" pathdiff = "0.2.1" -similar = "2.6.0" tempfile = "3.13.0" titlecase = "3.3.0" tree-sitter = "0.23.0" diff --git a/src/iterext.rs b/src/iterext.rs new file mode 100644 index 0000000..37b6764 --- /dev/null +++ b/src/iterext.rs @@ -0,0 +1,34 @@ +/// Extension trait that adds parallel zipping functionality to iterators over iterators. +pub trait ParallelZipExt: Iterator { + /// Zips multiple iterators in parallel, such that the nth invocation yields a + /// [`Vec`] of all nth items of the subiterators. + fn parallel_zip(self) -> ParallelZip + where + Self: Sized, + Self::Item: Iterator; +} + +/// An iterator similar to [`std::iter::zip`], but instead it zips over *multiple +/// iterators* in parallel, such that the nth invocation yields a [`Vec`] of all +/// nth items of its subiterators. +#[derive(Debug)] +pub struct ParallelZip(Vec); + +impl Iterator for ParallelZip { + type Item = Vec; + + fn next(&mut self) -> Option { + self.0.iter_mut().map(Iterator::next).collect() + } +} + +// Implement the extension trait for any iterator whose items are themselves iterators +impl ParallelZipExt for T +where + T: Iterator, + T::Item: Iterator, +{ + fn parallel_zip(self) -> ParallelZip { + ParallelZip(self.collect()) + } +} diff --git a/src/lib.rs b/src/lib.rs index e6654c1..3ba00fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -195,3 +195,6 @@ pub const GLOBAL_SCOPE: &str = r".*"; /// The type of regular expression used throughout the crate. Abstracts away the /// underlying implementation. pub use fancy_regex::Regex as RegexPattern; + +/// Custom iterator extensions. +pub mod iterext; diff --git a/src/main.rs b/src/main.rs index 7392c6c..569b218 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,6 @@ use ignore::{WalkBuilder, WalkState}; use itertools::Itertools; use log::{debug, error, info, trace, LevelFilter}; use pathdiff::diff_paths; -use similar::{ChangeTag, TextDiff}; #[cfg(feature = "german")] use srgn::actions::German; use srgn::actions::{ @@ -24,6 +23,7 @@ use srgn::actions::{ }; #[cfg(feature = "symbols")] use srgn::actions::{Symbols, SymbolsInversion}; +use srgn::iterext::ParallelZipExt; use srgn::scoping::langs::LanguageScoper; use srgn::scoping::literal::{Literal, LiteralError}; use srgn::scoping::regex::{Regex, RegexError}; @@ -168,7 +168,11 @@ fn main() -> Result<()> { info!("Will use search mode."); // Modelled after ripgrep! let style = Style { - fg: Some(Color::Red), + fg: if options.dry_run { + Some(Color::Green) // "Would change to this", like git diff + } else { + Some(Color::Red) // "Found!", like ripgrep + }, styles: vec![Styles::Bold], ..Default::default() }; @@ -187,13 +191,13 @@ fn main() -> Result<()> { } let pipeline = if options.dry_run { - let action = Style { - fg: Some(Color::Green), + let action: Box = Box::new(Style { + fg: Some(Color::Red), styles: vec![Styles::Bold], ..Default::default() - }; - let action: Box = Box::new(action); - vec![actions, vec![action]] + }); + let color_only = vec![action]; + vec![color_only, actions] } else { vec![actions] }; @@ -579,7 +583,7 @@ fn process_path( debug!("Processing path: {:?}", path); - let (new_contents, old_contents, filesize, changed) = { + let (new_contents, filesize, changed) = { let mut file = File::open(&path)?; let filesize = file.metadata().map_or(0, |m| m.len()); @@ -599,7 +603,7 @@ fn process_path( pipeline, )?; - (destination, source, filesize, changed) + (destination, filesize, changed) }; // Hold the lock so results aren't intertwined @@ -629,37 +633,17 @@ fn process_path( } if changed { - trace!( - "File contents changed, will process file: {}", - path.display() + debug!("Got new file contents, writing to file: {:?}", path); + assert!( + !global_options.dry_run, + // Dry run leverages search mode, so should never get here. Assert for + // extra safety. + "Dry running, but attempted to write file!" ); + fs::write(&path, new_contents.as_bytes())?; - if global_options.dry_run { - debug!( - "Dry-running: will not overwrite file '{}' with new contents: {}", - path.display(), - new_contents.escape_debug() - ); - writeln!(stdout, "{}", format!("{}:", path.display()).magenta())?; - unreachable!("will never get here"); - - let diff = TextDiff::from_lines(&old_contents, &new_contents); - for change in diff.iter_all_changes() { - let (sign, color) = match change.tag() { - ChangeTag::Delete => ('-', Color::Red), - ChangeTag::Insert => ('+', Color::Green), - ChangeTag::Equal => continue, - }; - - write!(stdout, "{}", format!("{sign} {change}").color(color))?; - } - } else { - debug!("Got new file contents, writing to file: {:?}", path); - fs::write(&path, new_contents.as_bytes())?; - - // Confirm after successful processing. - writeln!(stdout, "{}", path.display())?; - } + // Confirm after successful processing. + writeln!(stdout, "{}", path.display())?; } else { debug!( "Skipping writing file anew (nothing changed): {}", @@ -721,7 +705,10 @@ fn apply( view.squeeze(); } - for actions in pipeline { + // Give each pipeline its own fresh view + let mut views = vec![view; pipeline.len()]; + + for (actions, view) in pipeline.iter().zip_eq(&mut views) { for action in *actions { view.map_with_context(action)?; } @@ -730,21 +717,27 @@ fn apply( debug!("Writing to destination."); let line_based = global_options.only_matching || global_options.line_numbers; if line_based { - for (i, line) in view.lines().into_iter().enumerate() { + let line_views = views.iter().map(|v| v.lines().into_iter()).collect_vec(); + + for (i, lines) in line_views.into_iter().parallel_zip().enumerate() { let i = i + 1; - if !global_options.only_matching || line.has_any_in_scope() { - if global_options.line_numbers { - // `ColoredString` needs to be 'evaluated' to do anything; make sure - // to not forget even if this is moved outside of `format!`. - #[allow(clippy::to_string_in_format_args)] - destination.push_str(&format!("{}:", i.to_string().green().to_string())); - } + for line in lines { + if !global_options.only_matching || line.has_any_in_scope() { + if global_options.line_numbers { + // `ColoredString` needs to be 'evaluated' to do anything; make sure + // to not forget even if this is moved outside of `format!`. + #[allow(clippy::to_string_in_format_args)] + destination.push_str(&format!("{}:", i.to_string().green().to_string())); + } - destination.push_str(&line.to_string()); + destination.push_str(&line.to_string()); + } } } } else { - destination.push_str(&view.to_string()); + for view in views { + destination.push_str(&view.to_string()); + } }; debug!("Done writing to destination."); diff --git a/tests/snapshots/cli__tests__files-inplace-python-dry-run-macos.snap b/tests/snapshots/cli__tests__files-inplace-python-dry-run-macos.snap index 4232a0c..9fc1e52 100644 --- a/tests/snapshots/cli__tests__files-inplace-python-dry-run-macos.snap +++ b/tests/snapshots/cli__tests__files-inplace-python-dry-run-macos.snap @@ -12,15 +12,18 @@ args: - baz stdin: ~ stdout: - - "1.py:\n" - - "- # This string is found and touched: foo\n" - - "+ # This string is found and touched: baz\n" - - "- def foo(bar: int) -> int:\n" - - "+ def baz(bar: int) -> int:\n" - - "subdir/2.py:\n" - - "- def foo(bar: int) -> int:\n" - - "+ def baz(bar: int) -> int:\n" - - "subdir/subdir/3.py:\n" - - "- def foo(bar: int) -> int:\n" - - "+ def baz(bar: int) -> int:\n" + - "1.py\n" + - "1:# This string is found and touched: foo\n" + - "1:# This string is found and touched: baz\n" + - "4:def foo(bar: int) -> int:\n" + - "4:def baz(bar: int) -> int:\n" + - "\n" + - "subdir/2.py\n" + - "1:def foo(bar: int) -> int:\n" + - "1:def baz(bar: int) -> int:\n" + - "\n" + - "subdir/subdir/3.py\n" + - "1:def foo(bar: int) -> int:\n" + - "1:def baz(bar: int) -> int:\n" + - "\n" exit_code: 0 diff --git a/tests/snapshots/cli__tests__language-scoping-and-files-inplace-python-dry-run-macos.snap b/tests/snapshots/cli__tests__language-scoping-and-files-inplace-python-dry-run-macos.snap index d3aae96..64dd3fb 100644 --- a/tests/snapshots/cli__tests__language-scoping-and-files-inplace-python-dry-run-macos.snap +++ b/tests/snapshots/cli__tests__language-scoping-and-files-inplace-python-dry-run-macos.snap @@ -14,10 +14,12 @@ args: - baz stdin: ~ stdout: - - "subdir/2.py:\n" - - "- def foo(bar: int) -> int:\n" - - "+ def baz(bar: int) -> int:\n" - - "subdir/subdir/3.py:\n" - - "- def foo(bar: int) -> int:\n" - - "+ def baz(bar: int) -> int:\n" + - "subdir/2.py\n" + - "1:def foo(bar: int) -> int:\n" + - "1:def baz(bar: int) -> int:\n" + - "\n" + - "subdir/subdir/3.py\n" + - "1:def foo(bar: int) -> int:\n" + - "1:def baz(bar: int) -> int:\n" + - "\n" exit_code: 0 diff --git a/tests/snapshots/cli__tests__language-scoping-inplace-python-dry-run-macos.snap b/tests/snapshots/cli__tests__language-scoping-inplace-python-dry-run-macos.snap index b1b3835..670e9a0 100644 --- a/tests/snapshots/cli__tests__language-scoping-inplace-python-dry-run-macos.snap +++ b/tests/snapshots/cli__tests__language-scoping-inplace-python-dry-run-macos.snap @@ -12,16 +12,20 @@ args: - baz stdin: ~ stdout: - - "1-shebanged:\n" - - "- def foo(bar: int) -> int:\n" - - "+ def baz(bar: int) -> int:\n" - - "1.py:\n" - - "- def foo(bar: int) -> int:\n" - - "+ def baz(bar: int) -> int:\n" - - "subdir/2.py:\n" - - "- def foo(bar: int) -> int:\n" - - "+ def baz(bar: int) -> int:\n" - - "subdir/subdir/3.py:\n" - - "- def foo(bar: int) -> int:\n" - - "+ def baz(bar: int) -> int:\n" + - "1-shebanged\n" + - "9:def foo(bar: int) -> int:\n" + - "9:def baz(bar: int) -> int:\n" + - "\n" + - "1.py\n" + - "4:def foo(bar: int) -> int:\n" + - "4:def baz(bar: int) -> int:\n" + - "\n" + - "subdir/2.py\n" + - "1:def foo(bar: int) -> int:\n" + - "1:def baz(bar: int) -> int:\n" + - "\n" + - "subdir/subdir/3.py\n" + - "1:def foo(bar: int) -> int:\n" + - "1:def baz(bar: int) -> int:\n" + - "\n" exit_code: 0