Skip to content
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,31 @@ To escape the `$` character, use `$$`:
❯ echo "foo" | sd 'foo' '$$bar'
$bar
```

### Requiring a match

Like `sed`, sd exits successfully whether or not the pattern matched anything, so
a typo in the pattern looks the same as a file that needed no changes:

```bash
❯ echo "foo" | sd 'typo' 'bar'
foo
❯ echo $?
0
```

Pass `-m` / `--must-match` to exit with status 2 when the pattern matched none of
the input. This is useful in scripts and CI, where a silently vacuous edit is
worse than a loud failure:

```bash
❯ echo "foo" | sd -m 'typo' 'bar'
foo
❯ echo $?
2
```

The flag only changes the exit status. Inputs that did match are replaced either
way, and the status refers to the run as a whole, so one matching file out of
several is enough to succeed. Errors keep their own status of 1, so a script can
tell "the pattern found nothing" apart from "sd could not do its job".
2 changes: 2 additions & 0 deletions gen/completions/_sd
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ _sd() {
'--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]' \
'-m[Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way]' \
'--must-match[Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way]' \
'-h[Print help (see more with '\''--help'\'')]' \
'--help[Print help (see more with '\''--help'\'')]' \
'-V[Print version]' \
Expand Down
2 changes: 2 additions & 0 deletions gen/completions/_sd.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Register-ArgumentCompleter -Native -CommandName 'sd' -ScriptBlock {
[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('-m', 'm', [CompletionResultType]::ParameterName, 'Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way')
[CompletionResult]::new('--must-match', 'must-match', [CompletionResultType]::ParameterName, 'Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way')
[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')
Expand Down
2 changes: 1 addition & 1 deletion gen/completions/sd.bash
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ _sd() {

case "${cmd}" in
sd)
opts="-p -F -n -f -A -h -V --preview --fixed-strings --max-replacements --flags --across --help --version <FIND> <REPLACE_WITH> [FILES]..."
opts="-p -F -n -f -A -m -h -V --preview --fixed-strings --max-replacements --flags --across --must-match --help --version <FIND> <REPLACE_WITH> [FILES]..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
Expand Down
2 changes: 2 additions & 0 deletions gen/completions/sd.elv
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ set edit:completion:arg-completer[sd] = {|@words|
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 -m 'Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way'
cand --must-match 'Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way'
cand -h 'Print help (see more with ''--help'')'
cand --help 'Print help (see more with ''--help'')'
cand -V 'Print version'
Expand Down
1 change: 1 addition & 0 deletions gen/completions/sd.fish
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ complete -c sd -s f -l flags -d 'Regex flags. May be combined (like `-f mc`).' -
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 m -l must-match -d 'Exit with a non-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way'
complete -c sd -s h -l help -d 'Print help (see more with \'--help\')'
complete -c sd -s V -l version -d 'Print version'
7 changes: 6 additions & 1 deletion gen/sd.1
Original file line number Diff line number Diff line change
Expand Up @@ -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\-A\fR|\fB\-\-across\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\-m\fR|\fB\-\-must\-match\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
Expand Down Expand Up @@ -43,6 +43,9 @@ w \- match full words only
\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\-m\fR, \fB\-\-must\-match\fR
Exit with a non\-zero status if the pattern matched no input. Without this flag sd exits 0 whether or not anything was replaced, which matches sed. Inputs that did match are still replaced either way
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help (see a summary with \*(Aq\-h\*(Aq)
.TP
Expand All @@ -66,6 +69,8 @@ Note: sd modifies files in\-place by default. See documentation for examples.
Successful program execution.
.IP 1
Unsuccessful program execution.
.IP 2
The pattern matched no input, and \-\-must\-match was given.
.IP 101
The program panicked.
.ie \n(.g .ds Aq \(aq
Expand Down
6 changes: 6 additions & 0 deletions sd-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ w - match full words only
/// prevents streaming.
pub across: bool,

#[arg(short = 'm', long = "must-match")]
/// Exit with a non-zero status if the pattern matched no input. Without
/// this flag sd exits 0 whether or not anything was replaced, which
/// matches sed. Inputs that did match are still replaced either way.
pub must_match: bool,

/// The regexp or string (if using `-F`) to search for.
pub find: String,

Expand Down
27 changes: 21 additions & 6 deletions sd-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@ use std::{io::stdout, process};

use sd::{Replacer, Result, Source, process_sources};

/// Exit status for `--must-match` when the pattern matched no input.
///
/// Distinct from the status used for errors, so that a script can tell "your
/// pattern found nothing" apart from "sd could not do its job".
const NO_MATCH_EXIT_CODE: i32 = 2;

fn main() {
if let Err(e) = try_main() {
eprintln!("error: {e}");
process::exit(1);
match try_main() {
Ok(true) => {}
Ok(false) => process::exit(NO_MATCH_EXIT_CODE),
Err(e) => {
eprintln!("error: {e}");
process::exit(1);
}
}
}

fn try_main() -> Result<()> {
/// Returns whether the run satisfied `--must-match`; always true without it.
fn try_main() -> Result<bool> {
let options = cli::Options::parse();

let replacer = Replacer::new(
Expand All @@ -31,11 +42,15 @@ fn try_main() -> Result<()> {
let sources = sources?;

let mut handle = stdout().lock();
process_sources(
let matched = process_sources(
&replacer,
&sources,
options.preview,
!options.across,
&mut handle,
)
)?;

// Any replacements have already been written by this point. --must-match
// reports that nothing matched, it does not make the run conditional.
Ok(matched || !options.must_match)
}
151 changes: 147 additions & 4 deletions sd-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,150 @@ mod cli {
Ok(())
}

/// Exit status used by `--must-match` when nothing matched.
const NO_MATCH: i32 = 2;

#[test]
fn no_match_succeeds_by_default() -> Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"abc\n")?;
let path = file.into_temp_path();

sd().args(["nowhere", "here", path.to_str().unwrap()])
.assert()
.success();
assert_file(&path, "abc\n");

sd().args(["nowhere", "here"])
.write_stdin("abc\n")
.assert()
.success()
.stdout("abc\n");

Ok(())
}

#[test]
fn must_match_fails_when_nothing_matched() -> Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"abc\n")?;
let path = file.into_temp_path();

sd().args(["-m", "nowhere", "here", path.to_str().unwrap()])
.assert()
.code(NO_MATCH);
// The exit status reports the outcome, it does not roll anything back,
// and with no match there was nothing to write anyway.
assert_file(&path, "abc\n");

Ok(())
}

#[test]
fn must_match_succeeds_when_something_matched() -> Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"abc\n")?;
let path = file.into_temp_path();

sd().args(["--must-match", "abc", "xyz", path.to_str().unwrap()])
.assert()
.success();
assert_file(&path, "xyz\n");

Ok(())
}

#[test]
fn must_match_stdin() {
sd().args(["-m", "foo", "bar"])
.write_stdin("foo\n")
.assert()
.success()
.stdout("bar\n");

sd().args(["-m", "foo", "bar"])
.write_stdin("baz\n")
.assert()
.code(NO_MATCH)
.stdout("baz\n");
}

#[test]
fn must_match_preview() {
sd().args(["-m", "-p", "foo", "bar"])
.write_stdin("baz\n")
.assert()
.code(NO_MATCH);
}

#[test]
fn must_match_across() -> Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"a\nb\n")?;
let path = file.into_temp_path();

sd().args(["-m", "-A", r"a\nb", "X", path.to_str().unwrap()])
.assert()
.success();
assert_file(&path, "X\n");

sd().args(["-m", "-A", "nowhere", "X", path.to_str().unwrap()])
.assert()
.code(NO_MATCH);

Ok(())
}

/// The status describes the run as a whole, so one matching input is enough.
#[test]
fn must_match_is_satisfied_by_any_input() -> Result<()> {
let dir = tempfile::tempdir()?;
let hit = dir.path().join("hit.txt");
let miss = dir.path().join("miss.txt");
fs::write(&hit, "abc\n")?;
fs::write(&miss, "xyz\n")?;

sd().args([
"-m",
"abc",
"def",
hit.to_str().unwrap(),
miss.to_str().unwrap(),
])
.assert()
.success();
assert_file(&hit, "def\n");
assert_file(&miss, "xyz\n");

fs::write(&hit, "xyz\n")?;
sd().args([
"-m",
"abc",
"def",
hit.to_str().unwrap(),
miss.to_str().unwrap(),
])
.assert()
.code(NO_MATCH);

Ok(())
}

/// Errors keep their own status, so a script can tell "found nothing" apart
/// from "could not run".
#[test]
fn must_match_does_not_mask_errors() -> Result<()> {
let dir = tempfile::tempdir()?;
let missing = dir.path().join("missing.txt");

sd().args(["-m", "abc", "def", missing.to_str().unwrap()])
.assert()
.failure()
.code(1);

Ok(())
}

#[cfg(unix)]
mod unix_only {
use super::*;
Expand Down Expand Up @@ -425,10 +569,9 @@ mod cli {
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();
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(), "<test_home>")
Expand Down
Loading