diff --git a/CHANGELOG.md b/CHANGELOG.md index a43b3b1..d0a52e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,7 @@ release artifacts on the releases page. - Fixes several cross-compilation issues that effected different targets in CI - #182 `cargo update` (@CosmicHorrorDev) - Bumps dependencies to their latest compatible versions -- #183 Switch `memmap` -> `memmap2` (@CosmicHorrorDev) +- #183 Switch file-mapping crate implementation (@CosmicHorrorDev) - Switches away from an unmaintained crate - #184 Add editor config file matching rustfmt config (@CosmicHorrorDev) - Adds an `.editorconfig` file matching the settings listed in the @@ -121,7 +121,7 @@ release artifacts on the releases page. ## [0.6.2] -- Fixed pre-allocated memmap buffer size +- Fixed pre-allocated file-mapping buffer size - Fixed failing tests ## [0.6.0] - 2019-06-15 @@ -188,4 +188,3 @@ To reflect this change, `--input` is also renamed to `--in-place`. This is the f - Files are now written to [atomically](https://github.com/chmln/sd/issues/3) - diff --git a/Cargo.lock b/Cargo.lock index 079efeb..e373b3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -358,15 +358,6 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" -[[package]] -name = "memmap2" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deaba38d7abf1d4cca21cc89e932e542ba2b9258664d2a9ef0e61512039c9375" -dependencies = [ - "libc", -] - [[package]] name = "memoffset" version = "0.9.0" @@ -606,6 +597,19 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sd" version = "1.0.0" +dependencies = [ + "insta", + "proptest", + "rayon", + "regex", + "regex-automata", + "tempfile", + "thiserror", +] + +[[package]] +name = "sd-cli" +version = "1.0.0" dependencies = [ "ansi-to-html", "anyhow", @@ -614,13 +618,9 @@ dependencies = [ "clap_mangen", "console", "insta", - "memmap2", - "proptest", - "rayon", "regex", - "regex-automata", + "sd", "tempfile", - "thiserror", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 87d7651..c3f8fb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,23 @@ [workspace] +resolver = "3" members = [ - ".", + "sd", + "sd-cli", "xtask", ] -[workspace.dependencies.clap] -version = "4.4.6" -features = ["derive", "deprecated", "wrap_help"] +[workspace.dependencies] +tempfile = "3.8.0" +clap = {version = "4.4.6", features = ["derive", "wrap_help"]} + + [workspace.package] edition = "2024" version = "1.0.0" - -[package] -name = "sd" -version.workspace = true -edition.workspace = true -authors = ["Gregory "] +authors = ["Gregory ", "Orión "] description = "An intuitive find & replace CLI" -readme = "README.md" +readme = "../README.md" keywords = ["sed", "find", "replace", "regex"] license = "MIT" homepage = "https://github.com/chmln/sd" @@ -26,23 +25,6 @@ repository = "https://github.com/chmln/sd.git" categories = ["command-line-utilities", "text-processing", "development-tools"] rust-version = "1.86.0" -[dependencies] -regex = "1.10.2" -rayon = "1.8.0" -memmap2 = "0.9.0" -tempfile = "3.8.0" -thiserror = "1.0.50" -clap.workspace = true - -[dev-dependencies] -assert_cmd = "2.1.1" -anyhow = "1.0.75" -clap_mangen = "0.2.14" -proptest = "1.3.1" -console = "0.15.7" -insta = "1.34.0" -ansi-to-html = "0.1.3" -regex-automata = "0.4.3" [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 5b7a9b0..220d7cd 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,11 @@ Simpler syntax for replacing all occurrences: - sed: `sed s/before/after/g` Replace newlines with commas: - - sd: `sd '\n' ','` + - sd: `sd -A '\n' ','` - sed: `sed ':a;N;$!ba;s/\n/,/g'` + Note: this requires `-A` (across mode) since `\n` is a cross-line pattern. + Extracting stuff out of strings containing slashes: - sd: `echo "sample with /path/" | sd '.*(/.*/)' '$1'` - sed: `echo "sample with /path/" | sed -E 's/.*(\\/.*\\/)/\1/g'` @@ -78,6 +80,27 @@ hyperfine --warmup 3 --export-markdown out.md \ Result: ~11.93 times faster +**Line-by-line vs across mode** (1M lines, ~36MB file): + +| Command | Mean [ms] | Relative | +|:---|---:|---:| +| `sd -A 'foo' 'qux'` (across) | 125.6 ± 14.3 | 1.00 | +| `sed s/foo/qux/g` | 316.4 ± 30.0 | 2.52 | +| `sd 'foo' 'qux'` (line-by-line, default) | 357.0 ± 15.0 | 2.84 | + +| Command | Mean [ms] | Relative | +|:---|---:|---:| +| `sd -A '(\w+) world' '$1 earth'` (across) | 254.0 ± 11.2 | 1.00 | +| `sd '(\w+) world' '$1 earth'` (line-by-line, default) | 566.7 ± 16.7 | 2.23 | +| `sed -E 's/(\w+) world/\1 earth/g'` | 4432.7 ± 173.2 | 17.45 | + +Line-by-line mode is ~2-3x slower than across mode but still faster than sed for regex replacements. The tradeoff is dramatically lower memory usage: + +| Mode | Peak RSS | +|:---|---:| +| `sd -A` (across) | 74 MB | +| `sd` (line-by-line, default) | 3 MB | + ## Installation Install through @@ -176,6 +199,21 @@ $ echo "./hello --foo" | sd -- "--foo" "-w" ./hello -w ``` +### Processing modes + +By default, sd processes input **line by line**. This means: +- Low memory usage (only one line in memory at a time) +- Streaming output for stdin (results appear before EOF) +- `^` and `$` match the start/end of each line without phantom matches +- `\s+$` trims trailing whitespace without eating newlines + +If you need patterns to match **across line boundaries** (e.g. replacing `\n` or matching multi-line patterns), use the `-A` / `--across` flag: + +```sh +> echo -e "hello\nworld" | sd -A '\n' ',' +hello,world +``` + ### Escaping special characters To escape the `$` character, use `$$`: diff --git a/gen/completions/_sd b/gen/completions/_sd index 1a8c150..3cdab05 100644 --- a/gen/completions/_sd +++ b/gen/completions/_sd @@ -23,6 +23,8 @@ _sd() { '--preview[Display changes in a human reviewable format (the specifics of the format are likely to change in the future)]' \ '-F[Treat FIND and REPLACE_WITH args as literal strings]' \ '--fixed-strings[Treat FIND and REPLACE_WITH args as literal strings]' \ +'-A[Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming]' \ +'--across[Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming]' \ '-h[Print help (see more with '\''--help'\'')]' \ '--help[Print help (see more with '\''--help'\'')]' \ '-V[Print version]' \ diff --git a/gen/completions/_sd.ps1 b/gen/completions/_sd.ps1 index 4ac2dfb..5e21729 100644 --- a/gen/completions/_sd.ps1 +++ b/gen/completions/_sd.ps1 @@ -29,6 +29,8 @@ Register-ArgumentCompleter -Native -CommandName 'sd' -ScriptBlock { [CompletionResult]::new('--preview', 'preview', [CompletionResultType]::ParameterName, 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)') [CompletionResult]::new('-F', 'F ', [CompletionResultType]::ParameterName, 'Treat FIND and REPLACE_WITH args as literal strings') [CompletionResult]::new('--fixed-strings', 'fixed-strings', [CompletionResultType]::ParameterName, 'Treat FIND and REPLACE_WITH args as literal strings') + [CompletionResult]::new('-A', 'A ', [CompletionResultType]::ParameterName, 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming') + [CompletionResult]::new('--across', 'across', [CompletionResultType]::ParameterName, 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming') [CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') [CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')') [CompletionResult]::new('-V', 'V ', [CompletionResultType]::ParameterName, 'Print version') diff --git a/gen/completions/sd.bash b/gen/completions/sd.bash index b3e8b71..60584b5 100644 --- a/gen/completions/sd.bash +++ b/gen/completions/sd.bash @@ -19,7 +19,7 @@ _sd() { case "${cmd}" in sd) - opts="-p -F -n -f -h -V --preview --fixed-strings --max-replacements --flags --help --version [FILES]..." + opts="-p -F -n -f -A -h -V --preview --fixed-strings --max-replacements --flags --across --help --version [FILES]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/gen/completions/sd.elv b/gen/completions/sd.elv index d737837..69b8a31 100644 --- a/gen/completions/sd.elv +++ b/gen/completions/sd.elv @@ -26,6 +26,8 @@ set edit:completion:arg-completer[sd] = {|@words| cand --preview 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)' cand -F 'Treat FIND and REPLACE_WITH args as literal strings' cand --fixed-strings 'Treat FIND and REPLACE_WITH args as literal strings' + cand -A 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming' + cand --across 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming' cand -h 'Print help (see more with ''--help'')' cand --help 'Print help (see more with ''--help'')' cand -V 'Print version' diff --git a/gen/completions/sd.fish b/gen/completions/sd.fish index 7d324ff..1860893 100644 --- a/gen/completions/sd.fish +++ b/gen/completions/sd.fish @@ -2,5 +2,6 @@ complete -c sd -s n -l max-replacements -d 'Limit the number of replacements tha complete -c sd -s f -l flags -d 'Regex flags. May be combined (like `-f mc`).' -r complete -c sd -s p -l preview -d 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)' complete -c sd -s F -l fixed-strings -d 'Treat FIND and REPLACE_WITH args as literal strings' +complete -c sd -s A -l across -d 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming' complete -c sd -s h -l help -d 'Print help (see more with \'--help\')' complete -c sd -s V -l version -d 'Print version' diff --git a/gen/sd.1 b/gen/sd.1 index 6e8519f..9a41984 100644 --- a/gen/sd.1 +++ b/gen/sd.1 @@ -8,7 +8,7 @@ sd .ie \n(.g .ds Aq \(aq .el .ds Aq ' .SH SYNOPSIS -\fBsd\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-F\fR|\fB\-\-fixed\-strings\fR] [\fB\-n\fR|\fB\-\-max\-replacements\fR] [\fB\-f\fR|\fB\-\-flags\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] <\fIFIND\fR> <\fIREPLACE_WITH\fR> [\fIFILES\fR] +\fBsd\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-F\fR|\fB\-\-fixed\-strings\fR] [\fB\-n\fR|\fB\-\-max\-replacements\fR] [\fB\-f\fR|\fB\-\-flags\fR] [\fB\-A\fR|\fB\-\-across\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] <\fIFIND\fR> <\fIREPLACE_WITH\fR> [\fIFILES\fR] .ie \n(.g .ds Aq \(aq .el .ds Aq ' .SH DESCRIPTION @@ -40,6 +40,9 @@ s \- make `.` match newlines w \- match full words only .TP +\fB\-A\fR, \fB\-\-across\fR +Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming +.TP \fB\-h\fR, \fB\-\-help\fR Print help (see a summary with \*(Aq\-h\*(Aq) .TP @@ -82,7 +85,7 @@ lorem ipsum 23 Indexed capture groups \fB$ echo \*(Aqcargo +nightly watch\*(Aq | sd \*(Aq(\\w+)\\s+\\+(\\w+)\\s+(\\w+)\*(Aq \*(Aqcmd: $1, channel: $2, subcmd: $3\*(Aq\fR .br -123 dollars and 45 cents +cmd: cargo, channel: nightly, subcmd: watch .TP Find & replace in file \fB$ sd \*(Aqwindow.fetch\*(Aq \*(Aqfetch\*(Aq http.js\fR diff --git a/sd-cli/Cargo.toml b/sd-cli/Cargo.toml new file mode 100644 index 0000000..4fea340 --- /dev/null +++ b/sd-cli/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "sd-cli" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "sd" +path = "src/main.rs" + +[dependencies] +sd = { path = "../sd" } +clap.workspace = true + +[dev-dependencies] +assert_cmd = "2.0.12" +anyhow = "1.0.75" +clap_mangen = "0.2.14" +console = "0.15.7" +regex = "1.10.2" +insta = "1.34.0" +ansi-to-html = "0.1.3" +tempfile.workspace = true diff --git a/src/cli.rs b/sd-cli/src/cli.rs similarity index 88% rename from src/cli.rs rename to sd-cli/src/cli.rs index e96e609..73de8fe 100644 --- a/src/cli.rs +++ b/sd-cli/src/cli.rs @@ -57,6 +57,12 @@ w - match full words only */ pub flags: Option, + #[arg(short = 'A', long = "across")] + /// Process each input as a whole rather than line by line. This allows + /// patterns to match across line boundaries but uses more memory and + /// prevents streaming. + pub across: bool, + /// The regexp or string (if using `-F`) to search for. pub find: String, diff --git a/sd-cli/src/main.rs b/sd-cli/src/main.rs new file mode 100644 index 0000000..a8c2812 --- /dev/null +++ b/sd-cli/src/main.rs @@ -0,0 +1,41 @@ +mod cli; + +use clap::Parser; +use std::{io::stdout, process}; + +use sd::{Replacer, Result, Source, process_sources}; + +fn main() { + if let Err(e) = try_main() { + eprintln!("error: {e}"); + process::exit(1); + } +} + +fn try_main() -> Result<()> { + let options = cli::Options::parse(); + + let replacer = Replacer::new( + options.find, + options.replace_with, + options.literal_mode, + options.flags, + options.replacements, + )?; + + let sources = if !options.files.is_empty() { + Source::from_paths(options.files) + } else { + Ok(Source::from_stdin()) + }; + let sources = sources?; + + let mut handle = stdout().lock(); + process_sources( + &replacer, + &sources, + options.preview, + !options.across, + &mut handle, + ) +} diff --git a/tests/cli.rs b/sd-cli/tests/cli.rs similarity index 61% rename from tests/cli.rs rename to sd-cli/tests/cli.rs index 530e16d..590d4cd 100644 --- a/tests/cli.rs +++ b/sd-cli/tests/cli.rs @@ -1,12 +1,11 @@ #[cfg(test)] -#[cfg(not(sd_cross_compile))] // Cross-compilation does not allow to spawn threads but `command.assert()` would do. mod cli { use anyhow::Result; use assert_cmd::{Command, cargo_bin}; use std::{fs, io::prelude::*, path::Path}; fn sd() -> Command { - Command::new(cargo_bin!(env!("CARGO_PKG_NAME"))) + Command::new(cargo_bin!("sd")) } fn assert_file(path: &std::path::Path, content: &str) { @@ -115,11 +114,6 @@ mod cli { String::from_utf8(err.as_output().unwrap().stderr.clone()).unwrap() } - fn bad_replace_helper_plain(replace: &str) -> String { - let stderr = bad_replace_helper_styled(replace); - stderr - } - #[test] fn fixed_strings_ambiguous_replace_is_fine() { sd().args([ @@ -135,7 +129,7 @@ mod cli { #[test] fn ambiguous_replace_basic() { - let plain_stderr = bad_replace_helper_plain("before $1bad after"); + let plain_stderr = bad_replace_helper_styled("before $1bad after"); insta::assert_snapshot!(plain_stderr, @r###" error: The numbered capture group `$1` in the replacement text is ambiguous. hint: Use curly braces to disambiguate it `${1}bad`. @@ -146,7 +140,7 @@ mod cli { #[test] fn ambiguous_replace_variable_width() { - let plain_stderr = bad_replace_helper_plain("\r\n\t$1bad\r"); + let plain_stderr = bad_replace_helper_styled("\r\n\t$1bad\r"); insta::assert_snapshot!(plain_stderr, @r###" error: The numbered capture group `$1` in the replacement text is ambiguous. hint: Use curly braces to disambiguate it `${1}bad`. @@ -157,7 +151,7 @@ mod cli { #[test] fn ambiguous_replace_multibyte_char() { - let plain_stderr = bad_replace_helper_plain("😈$1bad😇"); + let plain_stderr = bad_replace_helper_styled("😈$1bad😇"); insta::assert_snapshot!(plain_stderr, @r###" error: The numbered capture group `$1` in the replacement text is ambiguous. hint: Use curly braces to disambiguate it `${1}bad`. @@ -169,7 +163,7 @@ mod cli { #[test] fn ambiguous_replace_issue_44() { let plain_stderr = - bad_replace_helper_plain("$1Call $2($5, GetFM20ReturnKey(), $6)"); + bad_replace_helper_styled("$1Call $2($5, GetFM20ReturnKey(), $6)"); insta::assert_snapshot!(plain_stderr, @r###" error: The numbered capture group `$1` in the replacement text is ambiguous. hint: Use curly braces to disambiguate it `${1}Call`. @@ -200,7 +194,7 @@ mod cli { file.write_all(b"foo\nfoo\nfoo")?; let path = file.into_temp_path(); - sd().args(["-n", "1", "foo", "bar", path.to_str().unwrap()]) + sd().args(["-A", "-n", "1", "foo", "bar", path.to_str().unwrap()]) .assert() .success(); assert_file(&path, "bar\nfoo\nfoo"); @@ -215,6 +209,7 @@ mod cli { let path = file.into_temp_path(); sd().args([ + "-A", "--preview", "-n", "1", @@ -231,7 +226,7 @@ mod cli { #[test] fn limit_replacements_stdin() { - sd().args(["-n", "1", "foo", "bar"]) + sd().args(["-A", "-n", "1", "foo", "bar"]) .write_stdin("foo\nfoo\nfoo") .assert() .success() @@ -240,7 +235,7 @@ mod cli { #[test] fn limit_replacements_stdin_preview() { - sd().args(["--preview", "-n", "1", "foo", "bar"]) + sd().args(["-A", "--preview", "-n", "1", "foo", "bar"]) .write_stdin("foo\nfoo\nfoo") .assert() .success() @@ -257,7 +252,7 @@ mod cli { ) { let failed_command = command.assert().failure().code(1); - assert_eq!(fs::read_to_string(&valid).unwrap(), UNTOUCHED_CONTENTS); + assert_eq!(fs::read_to_string(valid).unwrap(), UNTOUCHED_CONTENTS); let stderr_orig = std::str::from_utf8(&failed_command.get_output().stderr).unwrap(); @@ -290,40 +285,91 @@ mod cli { #[cfg_attr(not(target_family = "unix"), ignore = "only runs on unix")] #[test] fn correctly_fails_on_unreadable_file() -> Result<()> { - #[cfg(not(target_family = "unix"))] - { - unreachable!("This test should be ignored"); - } - #[cfg(target_family = "unix")] - { - use std::os::unix::fs::OpenOptionsExt; - - let test_dir = - tempfile::Builder::new().prefix("sd-test-").tempdir()?; - let test_home = test_dir.path(); - - let valid = test_home.join("valid"); - fs::write(&valid, UNTOUCHED_CONTENTS)?; - let write_only = { - let path = test_home.join("write_only"); - let mut write_only_file = std::fs::OpenOptions::new() - .mode(0o333) - .create(true) - .write(true) - .open(&path)?; - write!(write_only_file, "unreadable")?; - path - }; - - assert_fails_correctly( - sd().args([".*", ""]).arg(&valid).arg(&write_only), - &valid, - test_home, - "correctly_fails_on_unreadable_file", - ); - - Ok(()) - } + use std::os::unix::fs::OpenOptionsExt; + + let test_dir = tempfile::Builder::new().prefix("sd-test-").tempdir()?; + let test_home = test_dir.path(); + + let valid = test_home.join("valid"); + fs::write(&valid, UNTOUCHED_CONTENTS)?; + let write_only = { + let path = test_home.join("write_only"); + let mut write_only_file = std::fs::OpenOptions::new() + .mode(0o333) + .create(true) + .truncate(true) + .write(true) + .open(&path)?; + write!(write_only_file, "unreadable")?; + path + }; + + assert_fails_correctly( + sd().args([".*", ""]).arg(&valid).arg(&write_only), + &valid, + test_home, + "correctly_fails_on_unreadable_file", + ); + + Ok(()) + } + + #[test] + fn line_by_line_stdin() -> Result<()> { + sd().args(["foo", "bar"]) + .write_stdin("foo\nbaz\nfoo\n") + .assert() + .success() + .stdout("bar\nbaz\nbar\n"); + + Ok(()) + } + + #[test] + fn line_by_line_in_place() -> Result<()> { + let mut file = tempfile::NamedTempFile::new()?; + file.write_all(b"foo\nbaz\nfoo\n")?; + let path = file.into_temp_path(); + + sd().args(["foo", "bar", path.to_str().unwrap()]) + .assert() + .success(); + assert_file(&path, "bar\nbaz\nbar\n"); + + Ok(()) + } + + #[test] + fn line_by_line_preserves_no_trailing_newline() -> Result<()> { + sd().args(["abc", "xyz"]) + .write_stdin("abc") + .assert() + .success() + .stdout("xyz"); + + Ok(()) + } + + #[test] + fn line_by_line_caret_no_phantom() -> Result<()> { + sd().args(["^", "p-"]) + .write_stdin("1\n2\n3\n") + .assert() + .success() + .stdout("p-1\np-2\np-3\n"); + + Ok(()) + } + + #[test] + fn line_by_line_whitespace_trim() -> Result<()> { + sd().args([r"\s+$", ""]) + .write_stdin("a \nb \n") + .assert() + .success() + .stdout("a\nb\n"); + + Ok(()) } // Failing to create a temporary file in the same directory as the input is @@ -333,71 +379,62 @@ mod cli { #[cfg_attr(not(target_family = "unix"), ignore = "only runs on unix")] #[test] fn reports_errors_on_atomic_file_swap_creation_failure() -> Result<()> { - #[cfg(not(target_family = "unix"))] - { - unreachable!("This test should be ignored"); - } - #[cfg(target_family = "unix")] - { - use std::os::unix::fs::PermissionsExt; - - const FIND_REPLACE: [&str; 2] = ["able", "ed"]; - const ORIG_TEXT: &str = "modifiable"; - const MODIFIED_TEXT: &str = "modified"; - - let test_dir = - tempfile::Builder::new().prefix("sd-test-").tempdir()?; - let test_home = test_dir.path().canonicalize()?; - - let writable_dir = test_home.join("writable"); - fs::create_dir(&writable_dir)?; - let writable_dir_file = writable_dir.join("foo"); - fs::write(&writable_dir_file, ORIG_TEXT)?; - - let unwritable_dir = test_home.join("unwritable"); - fs::create_dir(&unwritable_dir)?; - let unwritable_dir_file1 = unwritable_dir.join("bar"); - fs::write(&unwritable_dir_file1, ORIG_TEXT)?; - let unwritable_dir_file2 = unwritable_dir.join("baz"); - fs::write(&unwritable_dir_file2, ORIG_TEXT)?; - let mut perms = fs::metadata(&unwritable_dir)?.permissions(); - perms.set_mode(0o555); - fs::set_permissions(&unwritable_dir, perms)?; - - let failed_command = sd() - .args(FIND_REPLACE) - .arg(&writable_dir_file) - .arg(&unwritable_dir_file1) - .arg(&unwritable_dir_file2) - .assert() - .failure() - .code(1); - - // Confirm that we modified the one file that we were able to - assert_eq!(fs::read_to_string(&writable_dir_file)?, MODIFIED_TEXT); - assert_eq!(fs::read_to_string(&unwritable_dir_file1)?, ORIG_TEXT); - assert_eq!(fs::read_to_string(&unwritable_dir_file2)?, ORIG_TEXT); - - let stderr_orig = - std::str::from_utf8(&failed_command.get_output().stderr) - .unwrap(); - // Normalize unstable path bits - let stderr_partial_norm = stderr_orig - .replace(test_home.to_str().unwrap(), "") - .replace('\\', "/"); - let tmp_file_rep = regex::Regex::new(r"\.tmp\w+")?; - let stderr_norm = - tmp_file_rep.replace_all(&stderr_partial_norm, ""); - insta::assert_snapshot!(stderr_norm); - - // Make the unwritable dir writable again, so it can be cleaned up - // when dropping the temp dir - let mut perms = fs::metadata(&unwritable_dir)?.permissions(); - perms.set_mode(0o777); - fs::set_permissions(&unwritable_dir, perms)?; - test_dir.close()?; - - Ok(()) - } + use std::os::unix::fs::PermissionsExt; + + const FIND_REPLACE: [&str; 2] = ["able", "ed"]; + const ORIG_TEXT: &str = "modifiable"; + const MODIFIED_TEXT: &str = "modified"; + + let test_dir = tempfile::Builder::new().prefix("sd-test-").tempdir()?; + let test_home = test_dir.path().canonicalize()?; + + let writable_dir = test_home.join("writable"); + fs::create_dir(&writable_dir)?; + let writable_dir_file = writable_dir.join("foo"); + fs::write(&writable_dir_file, ORIG_TEXT)?; + + let unwritable_dir = test_home.join("unwritable"); + fs::create_dir(&unwritable_dir)?; + let unwritable_dir_file1 = unwritable_dir.join("bar"); + fs::write(&unwritable_dir_file1, ORIG_TEXT)?; + let unwritable_dir_file2 = unwritable_dir.join("baz"); + fs::write(&unwritable_dir_file2, ORIG_TEXT)?; + let mut perms = fs::metadata(&unwritable_dir)?.permissions(); + perms.set_mode(0o555); + fs::set_permissions(&unwritable_dir, perms)?; + + let failed_command = sd() + .args(FIND_REPLACE) + .arg(&writable_dir_file) + .arg(&unwritable_dir_file1) + .arg(&unwritable_dir_file2) + .assert() + .failure() + .code(1); + + // Confirm that we modified the one file that we were able to + assert_eq!(fs::read_to_string(&writable_dir_file)?, MODIFIED_TEXT); + assert_eq!(fs::read_to_string(&unwritable_dir_file1)?, ORIG_TEXT); + assert_eq!(fs::read_to_string(&unwritable_dir_file2)?, ORIG_TEXT); + + let stderr_orig = + std::str::from_utf8(&failed_command.get_output().stderr).unwrap(); + // Normalize unstable path bits + let stderr_partial_norm = stderr_orig + .replace(test_home.to_str().unwrap(), "") + .replace('\\', "/"); + let tmp_file_rep = regex::Regex::new(r"\.tmp\w+")?; + let stderr_norm = + tmp_file_rep.replace_all(&stderr_partial_norm, ""); + insta::assert_snapshot!(stderr_norm); + + // Make the unwritable dir writable again, so it can be cleaned up + // when dropping the temp dir + let mut perms = fs::metadata(&unwritable_dir)?.permissions(); + perms.set_mode(0o777); + fs::set_permissions(&unwritable_dir, perms)?; + test_dir.close()?; + + Ok(()) } } diff --git a/tests/snapshots/cli__cli__correctly_fails_on_missing_file.snap b/sd-cli/tests/snapshots/cli__cli__correctly_fails_on_missing_file.snap similarity index 100% rename from tests/snapshots/cli__cli__correctly_fails_on_missing_file.snap rename to sd-cli/tests/snapshots/cli__cli__correctly_fails_on_missing_file.snap diff --git a/tests/snapshots/cli__cli__correctly_fails_on_unreadable_file.snap b/sd-cli/tests/snapshots/cli__cli__correctly_fails_on_unreadable_file.snap similarity index 100% rename from tests/snapshots/cli__cli__correctly_fails_on_unreadable_file.snap rename to sd-cli/tests/snapshots/cli__cli__correctly_fails_on_unreadable_file.snap diff --git a/tests/snapshots/cli__cli__reports_errors_on_atomic_file_swap_creation_failure.snap b/sd-cli/tests/snapshots/cli__cli__reports_errors_on_atomic_file_swap_creation_failure.snap similarity index 100% rename from tests/snapshots/cli__cli__reports_errors_on_atomic_file_swap_creation_failure.snap rename to sd-cli/tests/snapshots/cli__cli__reports_errors_on_atomic_file_swap_creation_failure.snap diff --git a/sd/Cargo.toml b/sd/Cargo.toml new file mode 100644 index 0000000..53cc705 --- /dev/null +++ b/sd/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "sd" +version.workspace = true +edition.workspace = true +authors = ["Gregory ", "Orión "] +description = "Core library for the sd find & replace tool" +readme = "../README.md" +keywords = ["sed", "find", "replace", "regex"] +license = "MIT" +homepage = "https://github.com/chmln/sd" +repository = "https://github.com/chmln/sd.git" +categories = ["command-line-utilities", "text-processing", "development-tools"] +rust-version = "1.86.0" + +[dependencies] +regex = "1.10.2" +rayon = "1.8.0" +thiserror = "1.0.50" +tempfile.workspace = true + +[dev-dependencies] +proptest = "1.3.1" +regex-automata = "0.4.3" +insta = "1.34.0" diff --git a/src/error.rs b/sd/src/error.rs similarity index 100% rename from src/error.rs rename to sd/src/error.rs diff --git a/sd/src/input.rs b/sd/src/input.rs new file mode 100644 index 0000000..b2d0098 --- /dev/null +++ b/sd/src/input.rs @@ -0,0 +1,59 @@ +use std::{ + fs::File, + io::{BufRead, BufReader, Read, stdin}, + path::PathBuf, +}; + +use crate::error::{Error, Result}; + +#[derive(Debug, PartialEq)] +pub enum Source { + Stdin, + File(PathBuf), +} + +impl Source { + pub fn from_paths(paths: Vec) -> Result> { + paths + .into_iter() + .map(|path| { + if path.exists() { + Ok(Source::File(path)) + } else { + Err(Error::InvalidPath(path.clone())) + } + }) + .collect() + } + + pub fn from_stdin() -> Vec { + vec![Self::Stdin] + } + + pub fn display(&self) -> String { + match self { + Self::Stdin => "STDIN".to_string(), + Self::File(path) => format!("FILE {}", path.display()), + } + } +} + +pub fn open_source(source: &Source) -> Result> { + match source { + Source::File(path) => { + let file = File::open(path)?; + Ok(Box::new(BufReader::new(file))) + } + Source::Stdin => { + let stdin = stdin().lock(); + Ok(Box::new(BufReader::new(stdin))) + } + } +} + +pub fn read_source(source: &Source) -> Result> { + let mut handle = open_source(source)?; + let mut buf = Vec::new(); + handle.read_to_end(&mut buf)?; + Ok(buf) +} diff --git a/sd/src/lib.rs b/sd/src/lib.rs new file mode 100644 index 0000000..4f4d3b8 --- /dev/null +++ b/sd/src/lib.rs @@ -0,0 +1,398 @@ +mod error; +mod input; +pub mod replacer; +mod unescape; + +use std::{ + fs, + io::{BufRead, BufWriter, Read, Write}, + path::PathBuf, +}; + +pub use self::error::{Error, FailedJobs, Result}; +pub use self::input::{Source, open_source, read_source}; +pub use self::replacer::Replacer; + +/// Core processing function that handles file replacement +pub fn process_sources( + replacer: &Replacer, + sources: &[Source], + preview: bool, + line_by_line: bool, + output_writer: &mut dyn Write, +) -> Result<()> { + if line_by_line { + return process_sources_line_by_line( + replacer, + sources, + preview, + output_writer, + ); + } + + let mut inputs = Vec::new(); + for source in sources.iter() { + let input = match source { + Source::File(path) => { + if path.exists() { + read_source(source)? + } else { + return Err(Error::InvalidPath(path.to_owned())); + } + } + Source::Stdin => read_source(source)?, + }; + + inputs.push(input); + } + + let needs_separator = sources.len() > 1; + + let replaced: Vec<_> = { + use rayon::prelude::*; + inputs + .par_iter() + .map(|input| replacer.replace(input)) + .collect() + }; + + if preview || sources.first() == Some(&Source::Stdin) { + for (source, replaced) in sources.iter().zip(replaced) { + if needs_separator { + writeln!(output_writer, "----- {} -----", source.display())?; + } + output_writer.write_all(&replaced)?; + } + } else { + let mut failed_jobs = Vec::new(); + for (source, replaced) in sources.iter().zip(replaced) { + match source { + Source::File(path) => { + if let Err(e) = write_with_temp(path, &replaced) { + failed_jobs.push((path.to_owned(), e)); + } + } + _ => unreachable!("stdin should go previous branch"), + } + } + if !failed_jobs.is_empty() { + return Err(Error::FailedJobs(FailedJobs(failed_jobs))); + } + } + + Ok(()) +} + +fn process_sources_line_by_line( + replacer: &Replacer, + sources: &[Source], + preview: bool, + output_writer: &mut dyn Write, +) -> Result<()> { + let needs_separator = sources.len() > 1; + + if preview || sources.first() == Some(&Source::Stdin) { + for source in sources { + if needs_separator { + writeln!(output_writer, "----- {} -----", source.display())?; + } + let reader = open_source(source)?; + process_reader_line_by_line(replacer, reader, output_writer)?; + } + } else { + // Pre-validate all files before modifying any, matching the + // whole-file processing path which opens all inputs upfront. + for source in sources { + match source { + Source::File(path) => { + if !path.exists() { + return Err(Error::InvalidPath(path.to_owned())); + } + std::fs::File::open(path)?; + } + _ => unreachable!("stdin should go previous branch"), + } + } + + let mut failed_jobs = Vec::new(); + for source in sources { + match source { + Source::File(path) => { + if let Err(e) = write_file_line_by_line(replacer, path) { + failed_jobs.push((path.to_owned(), e)); + } + } + _ => unreachable!("stdin should go previous branch"), + } + } + if !failed_jobs.is_empty() { + return Err(Error::FailedJobs(FailedJobs(failed_jobs))); + } + } + + Ok(()) +} + +fn process_reader_line_by_line( + replacer: &Replacer, + mut reader: Box, + writer: &mut dyn Write, +) -> Result<()> { + const CHUNK_SIZE: usize = 8192; + + let mut chunk = vec![0u8; CHUNK_SIZE]; + let mut line = Vec::with_capacity(256); + + loop { + let n = reader.read(&mut chunk)?; + if n == 0 { + // Finish any remaining line + if !line.is_empty() { + let replaced = replacer.replace(&line); + writer.write_all(&replaced)?; + } + break; + } + + let mut start = 0; + for (i, &byte) in chunk[..n].iter().enumerate() { + if byte == b'\n' { + // Found a complete line + line.extend_from_slice(&chunk[start..i]); + let replaced = replacer.replace(&line); + writer.write_all(&replaced)?; + writer.write_all(b"\n")?; + line.clear(); + start = i + 1; + } + } + + // Keep partial line for next chunk + if start < n { + line.extend_from_slice(&chunk[start..n]); + } + } + + Ok(()) +} + +fn write_file_line_by_line(replacer: &Replacer, path: &PathBuf) -> Result<()> { + let canonical = fs::canonicalize(path)?; + + let temp = tempfile::NamedTempFile::new_in( + canonical + .parent() + .ok_or_else(|| Error::InvalidPath(canonical.to_path_buf()))?, + )?; + + if let Ok(metadata) = fs::metadata(&canonical) { + temp.as_file().set_permissions(metadata.permissions()).ok(); + } + + { + let source = Source::File(path.clone()); + let reader = open_source(&source)?; + let mut writer = BufWriter::new(temp.as_file()); + process_reader_line_by_line(replacer, reader, &mut writer)?; + writer.flush()?; + } + + temp.persist(&canonical)?; + + Ok(()) +} + +fn write_with_temp(path: &PathBuf, data: &[u8]) -> Result<()> { + let path = fs::canonicalize(path)?; + + let mut temp = tempfile::NamedTempFile::new_in( + path.parent() + .ok_or_else(|| Error::InvalidPath(path.to_path_buf()))?, + )?; + + let file = temp.as_file(); + file.set_len(data.len() as u64)?; + if let Ok(metadata) = fs::metadata(&path) { + file.set_permissions(metadata.permissions()).ok(); + } + + if !data.is_empty() { + temp.as_file_mut().write_all(data)?; + temp.as_file_mut().flush()?; + } + + temp.persist(&path)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_process_sources_with_preview() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "abc123def").unwrap(); + + let replacer = + Replacer::new("abc".into(), "xyz".into(), false, None, 0)?; + let sources = vec![Source::File(file_path)]; + let mut output = Vec::new(); + + process_sources(&replacer, &sources, true, false, &mut output)?; + + let result = String::from_utf8(output).unwrap(); + assert_eq!(result, "xyz123def"); + + Ok(()) + } + + #[test] + fn test_process_sources_in_place() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "abc123def").unwrap(); + + let replacer = + Replacer::new("abc".into(), "xyz".into(), false, None, 0)?; + let sources = vec![Source::File(file_path.clone())]; + let mut output = Vec::new(); + + process_sources(&replacer, &sources, false, false, &mut output)?; + + let result = std::fs::read_to_string(&file_path).unwrap(); + assert_eq!(result, "xyz123def"); + + Ok(()) + } + + #[test] + fn test_process_sources_nonexistent_file() { + let replacer = + Replacer::new("abc".into(), "def".into(), false, None, 0).unwrap(); + let nonexistent = PathBuf::from("/nonexistent/file.txt"); + let sources = vec![Source::File(nonexistent.clone())]; + let mut output = Vec::new(); + + let result = + process_sources(&replacer, &sources, false, false, &mut output); + assert!(result.is_err()); + + match result.unwrap_err() { + Error::InvalidPath(path) => assert_eq!(path, nonexistent), + _ => panic!("Expected InvalidPath error"), + } + } + + #[test] + fn test_write_with_temp() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "original").unwrap(); + + let new_data = b"new content"; + write_with_temp(&file_path, new_data)?; + + let result = std::fs::read_to_string(&file_path).unwrap(); + assert_eq!(result, "new content"); + + Ok(()) + } + + #[test] + fn test_process_sources_line_by_line_preview() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "abc123\ndef456\n").unwrap(); + + let replacer = + Replacer::new("abc".into(), "xyz".into(), false, None, 0)?; + let sources = vec![Source::File(file_path)]; + let mut output = Vec::new(); + + process_sources(&replacer, &sources, true, true, &mut output)?; + + let result = String::from_utf8(output).unwrap(); + assert_eq!(result, "xyz123\ndef456\n"); + + Ok(()) + } + + #[test] + fn test_process_sources_line_by_line_in_place() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "abc123\ndef456\n").unwrap(); + + let replacer = + Replacer::new("abc".into(), "xyz".into(), false, None, 0)?; + let sources = vec![Source::File(file_path.clone())]; + let mut output = Vec::new(); + + process_sources(&replacer, &sources, false, true, &mut output)?; + + let result = std::fs::read_to_string(&file_path).unwrap(); + assert_eq!(result, "xyz123\ndef456\n"); + + Ok(()) + } + + #[test] + fn test_line_by_line_no_trailing_newline() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "abc").unwrap(); + + let replacer = + Replacer::new("abc".into(), "xyz".into(), false, None, 0)?; + let sources = vec![Source::File(file_path)]; + let mut output = Vec::new(); + + process_sources(&replacer, &sources, true, true, &mut output)?; + + let result = String::from_utf8(output).unwrap(); + assert_eq!(result, "xyz"); + + Ok(()) + } + + #[test] + fn test_line_by_line_caret_no_phantom() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "1\n2\n3\n").unwrap(); + + let replacer = Replacer::new("^".into(), "p-".into(), false, None, 0)?; + let sources = vec![Source::File(file_path)]; + let mut output = Vec::new(); + + process_sources(&replacer, &sources, true, true, &mut output)?; + + let result = String::from_utf8(output).unwrap(); + assert_eq!(result, "p-1\np-2\np-3\n"); + + Ok(()) + } + + #[test] + fn test_line_by_line_whitespace_trim() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("test.txt"); + std::fs::write(&file_path, "a \nb \n").unwrap(); + + let replacer = + Replacer::new(r"\s+$".into(), "".into(), false, None, 0)?; + let sources = vec![Source::File(file_path)]; + let mut output = Vec::new(); + + process_sources(&replacer, &sources, true, true, &mut output)?; + + let result = String::from_utf8(output).unwrap(); + assert_eq!(result, "a\nb\n"); + + Ok(()) + } +} diff --git a/src/output.rs b/sd/src/output.rs similarity index 73% rename from src/output.rs rename to sd/src/output.rs index 5f3ec61..99d24b5 100644 --- a/src/output.rs +++ b/sd/src/output.rs @@ -1,11 +1,10 @@ use crate::{Error, Result}; -use memmap2::MmapMut; -use std::{fs, io::Write, ops::DerefMut, path::Path}; +use std::{fs, io::Write, path::Path}; pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { let path = fs::canonicalize(path)?; - let temp = tempfile::NamedTempFile::new_in( + let mut temp = tempfile::NamedTempFile::new_in( path.parent() .ok_or_else(|| Error::InvalidPath(path.to_path_buf()))?, )?; @@ -26,9 +25,8 @@ pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { } if !data.is_empty() { - let mut mmap_temp = unsafe { MmapMut::map_mut(file)? }; - mmap_temp.deref_mut().write_all(data)?; - mmap_temp.flush_async()?; + temp.as_file_mut().write_all(data)?; + temp.as_file_mut().flush()?; } temp.persist(&path)?; diff --git a/src/replacer/mod.rs b/sd/src/replacer/mod.rs similarity index 92% rename from src/replacer/mod.rs rename to sd/src/replacer/mod.rs index d7d5962..2f04311 100644 --- a/src/replacer/mod.rs +++ b/sd/src/replacer/mod.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use crate::{unescape, Result}; +use crate::{Result, unescape}; use regex::bytes::Regex; @@ -8,9 +8,9 @@ use regex::bytes::Regex; mod tests; mod validate; -pub use validate::{validate_replace, InvalidReplaceCapture}; +pub use validate::{InvalidReplaceCapture, validate_replace}; -pub(crate) struct Replacer { +pub struct Replacer { regex: Regex, replace_with: Vec, is_literal: bool, @@ -18,7 +18,7 @@ pub(crate) struct Replacer { } impl Replacer { - pub(crate) fn new( + pub fn new( look_for: String, replace_with: String, is_literal: bool, @@ -69,7 +69,7 @@ impl Replacer { }) } - pub(crate) fn replace<'a>(&'a self, content: &'a [u8]) -> Cow<'a, [u8]> { + pub fn replace<'a>(&'a self, content: &'a [u8]) -> Cow<'a, [u8]> { let regex = &self.regex; let limit = self.replacements; let use_color = false; @@ -94,7 +94,7 @@ impl Replacer { /// A modified form of [`regex::bytes::Regex::replacen`] that supports /// coloring replacements - pub(crate) fn replacen<'haystack, R: regex::bytes::Replacer>( + pub fn replacen<'haystack, R: regex::bytes::Replacer>( regex: ®ex::bytes::Regex, limit: usize, haystack: &'haystack [u8], diff --git a/src/replacer/tests.rs b/sd/src/replacer/tests.rs similarity index 100% rename from src/replacer/tests.rs rename to sd/src/replacer/tests.rs diff --git a/src/replacer/validate.rs b/sd/src/replacer/validate.rs similarity index 97% rename from src/replacer/validate.rs rename to sd/src/replacer/validate.rs index cc006d7..f9fe409 100644 --- a/src/replacer/validate.rs +++ b/sd/src/replacer/validate.rs @@ -94,25 +94,26 @@ impl fmt::Display for InvalidReplaceCapture { } } - // This relies on all non-curly-braced capture chars being 1 byte - let arrows_span = arrows_start.end_offset(invalid_ident.len()); - let mut arrows = " ".repeat(arrows_span.start); - arrows.push_str(&"^".repeat(arrows_span.len())); - let ident = invalid_ident.slice(original_replace); let (number, the_rest) = ident.split_at(*num_leading_digits); + + writeln!( + f, + "The numbered capture group `${number}` in the replacement text is ambiguous." + )?; + let disambiguous = format!("${{{number}}}{the_rest}"); - let error_message = format!( - "The numbered capture group `${number}` in the replacement text is ambiguous.", - ); - let hint_message = format!( - "{}: Use curly braces to disambiguate it `{}`.", - "hint", disambiguous - ); + writeln!( + f, + "hint: Use curly braces to disambiguate it `{disambiguous}`." + )?; - writeln!(f, "{}", error_message)?; - writeln!(f, "{}", hint_message)?; writeln!(f, "{}", formatted)?; + + // This relies on all non-curly-braced capture chars being 1 byte + let arrows_span = arrows_start.end_offset(invalid_ident.len()); + let mut arrows = " ".repeat(arrows_span.start); + arrows.push_str(&"^".repeat(arrows_span.len())); write!(f, "{}", arrows) } } diff --git a/src/snapshots/sd__unescape__test__unescape.snap b/sd/src/snapshots/sd__unescape__test__unescape.snap similarity index 100% rename from src/snapshots/sd__unescape__test__unescape.snap rename to sd/src/snapshots/sd__unescape__test__unescape.snap diff --git a/src/unescape.rs b/sd/src/unescape.rs similarity index 100% rename from src/unescape.rs rename to sd/src/unescape.rs diff --git a/src/input.rs b/src/input.rs deleted file mode 100644 index 3d74f85..0000000 --- a/src/input.rs +++ /dev/null @@ -1,57 +0,0 @@ -use memmap2::{Mmap, MmapOptions}; -use std::{ - fs::File, - io::{Read, stdin}, - path::PathBuf, -}; - -use crate::error::{Error, Result}; - -#[derive(Debug, PartialEq)] -pub(crate) enum Source { - Stdin, - File(PathBuf), -} - -impl Source { - pub(crate) fn from_paths(paths: Vec) -> Result> { - paths - .into_iter() - .map(|path| { - if path.exists() { - Ok(Source::File(path)) - } else { - Err(Error::InvalidPath(path.clone())) - } - }) - .collect() - } - - pub(crate) fn from_stdin() -> Vec { - vec![Self::Stdin] - } - - pub(crate) fn display(&self) -> String { - match self { - Self::Stdin => "STDIN".to_string(), - Self::File(path) => format!("FILE {}", path.display()), - } - } -} - -// TODO: memmap2 docs state that users should implement proper -// procedures to avoid problems the `unsafe` keyword indicate. -// This would be in a later PR. -pub(crate) fn make_mmap(path: &PathBuf) -> Result { - Ok(unsafe { Mmap::map(&File::open(path)?)? }) -} - -pub(crate) fn make_mmap_stdin() -> Result { - let mut handle = stdin().lock(); - let mut buf = Vec::new(); - handle.read_to_end(&mut buf)?; - let mut mmap = MmapOptions::new().len(buf.len()).map_anon()?; - mmap.copy_from_slice(&buf); - let mmap = mmap.make_read_only()?; - Ok(mmap) -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index a760ed9..0000000 --- a/src/main.rs +++ /dev/null @@ -1,98 +0,0 @@ -mod cli; -mod error; -mod input; -mod output; - -pub(crate) mod replacer; -mod unescape; - -use clap::Parser; -use std::{ - io::{Write, stdout}, - process, -}; - -pub(crate) use self::error::{Error, FailedJobs, Result}; -pub(crate) use self::input::Source; -use self::input::{make_mmap, make_mmap_stdin}; -use self::replacer::Replacer; - -fn main() { - if let Err(e) = try_main() { - eprintln!("error: {e}"); - process::exit(1); - } -} - -fn try_main() -> Result<()> { - let options = cli::Options::parse(); - - let replacer = Replacer::new( - options.find, - options.replace_with, - options.literal_mode, - options.flags, - options.replacements, - )?; - - let sources = if !options.files.is_empty() { - Source::from_paths(options.files)? - } else { - Source::from_stdin() - }; - - let mmaps = sources - .iter() - .map(|source| { - Ok(match source { - Source::File(path) => make_mmap(path)?, - Source::Stdin => make_mmap_stdin()?, - }) - }) - .collect::>>()?; - - let replaced: Vec<_> = { - use rayon::prelude::*; - mmaps - .par_iter() - .map(|mmap| replacer.replace(mmap)) - .collect() - }; - - if options.preview || sources.first() == Some(&Source::Stdin) { - let needs_separator = sources.len() > 1; - let mut handle = stdout().lock(); - - for (source, replaced) in sources.iter().zip(replaced) { - if needs_separator { - writeln!(handle, "----- {} -----", source.display())?; - } - handle.write_all(&replaced)?; - } - } else { - // Windows requires closing mmap before writing: - // > The requested operation cannot be performed on a file with a user-mapped section open - #[cfg(target_family = "windows")] - let replaced: Vec> = - replaced.into_iter().map(|r| r.to_vec()).collect(); - #[cfg(target_family = "windows")] - drop(mmaps); - - let failed_jobs = sources - .iter() - .zip(replaced) - .filter_map(|(source, replaced)| match source { - Source::File(path) => output::write_atomic(path, &replaced) - .err() - .map(|e| (path.to_owned(), e)), - _ => None, - }) - .collect::>(); - - if !failed_jobs.is_empty() { - return Err(Error::FailedJobs(FailedJobs(failed_jobs))); - } - } - - Ok(()) -} diff --git a/xtask/src/generate.rs b/xtask/src/generate.rs index c89e2cc..f91f803 100644 --- a/xtask/src/generate.rs +++ b/xtask/src/generate.rs @@ -1,5 +1,5 @@ mod sd { - include!("../../src/cli.rs"); + include!("../../sd-cli/src/cli.rs"); } use sd::Options;