4 Commits
Author SHA1 Message Date
Sylvestre Ledru c099152810 sed: underline the offending script character at a terminal
When stderr is a terminal (or UUTILS_DIAG=always), quote the offending
script line and underline the faulty character, in the layout uucore's
diagnostics use. Pipes and the test suite still get the single-line
message.

Both error constructors handle this, so no call site changes.
ScriptLocation now carries the line text, captured only when diagnostics
are on and shared between commands from the same line, so semantic
errors raised after compilation can still be drawn.

Anything that cannot be drawn (non-UTF-8 script, empty line, no line
recorded) leaves the message unchanged.
2026-09-27 20:03:49 +02:00
Sylvestre Ledru 72ed709088 sed: fix panics and zero steps in ~ and , addresses
A range address that ended early made the parser panic: `1~` read past
the end of the line, and `1~p`, `1,~p` or `1,p` handed a command letter
to the address parser, which only knew how to panic on it. A missing
~step now counts as 0, and a comma with no address after it is reported
as an unexpected `,'. The address parser returns an error instead of
panicking.

A step of 0 was not handled either: `2,~0` ran to the end of the input
instead of selecting line 2 alone, and `0~0`, which can select no line,
was accepted.
2026-09-27 16:44:36 +02:00
Sylvestre Ledru 7f9ce84f34 docs: move extensions and incompatibilities to docs/src/extensions.md
Start an mdBook under docs/, laid out as in coreutils, and move the list of
extensions and incompatibilities out of the README into it. The README keeps
a pointer.

A Documentation workflow builds the book with mdbook 0.5.0, the version the
uutils website uses, whenever docs/ changes, and uploads it as an artifact.
mdbook 0.5 rejects the `multilingual` field coreutils still carries (the
website strips it before building), so the book leaves it out.
2026-09-27 11:42:09 +02:00
Sylvestre Ledru cb9135ddac prepare version 0.3.0 2026-09-27 08:14:38 +02:00
15 changed files with 609 additions and 114 deletions

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
name: Documentation
on:
pull_request:
paths:
- 'docs/**'
- '.github/workflows/documentation.yml'
push:
branches:
- main
paths:
- 'docs/**'
- '.github/workflows/documentation.yml'
permissions:
contents: read
jobs:
build-doc:
name: Build the mdBook documentation
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install mdbook
uses: taiki-e/install-action@v2
with:
tool: mdbook@0.5.0
- name: Build documentation
run: mdbook build docs
- name: Upload documentation
uses: actions/upload-artifact@v7
with:
name: documentation
path: docs/book
Generated
+13 -1
View File
@@ -85,6 +85,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ariadne"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8454c8a44ce2cb9cc7e7fae67fc6128465b343b92c6631e94beca3c8d1524ea5"
dependencies = [
"unicode-width",
"yansi",
]
[[package]]
name = "assert_fs"
version = "1.1.4"
@@ -1103,7 +1113,7 @@ dependencies = [
[[package]]
name = "sed"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"assert_fs",
"chrono",
@@ -1129,6 +1139,7 @@ dependencies = [
"tempfile",
"terminal_size",
"textwrap",
"unicode-width",
"uucore",
"uutests",
"xattr",
@@ -1453,6 +1464,7 @@ version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f24a5910ebbf2c0baf486a9125705f8e026a8f7741a3ea8cf5eca3c9257c9bdf"
dependencies = [
"ariadne",
"clap",
"dns-lookup",
"fluent",
+6 -3
View File
@@ -1,11 +1,12 @@
# sed (uutils)
# * see the repository LICENSE, README, and CONTRIBUTING files for more information
# spell-checker:ignore (libs) mangen sed
# spell-checker:ignore (libs) mangen sed binstall chrono codspeed libc memchr memmap rlimit rustix textwrap uucore uutests xattr
# spell-checker:ignore (misc) clippy codegen msvc startswith uninlined uutils
[package]
name = "sed"
version = "0.2.0"
version = "0.3.0"
license = "MIT"
description = "sed ~ implemented as universal (cross-platform) utils, written in Rust"
default-run = "sed"
@@ -62,7 +63,8 @@ sha2 = { version = "0.11.0", default-features = false, features = ["alloc"] }
tempfile = "3.10.1"
terminal_size = "0.4.2"
textwrap = { version = "0.16.1", features = ["terminal_size"] }
uucore = { version = "0.12.0", features = ["libc"] }
unicode-width = "0.2.2"
uucore = { version = "0.12.0", features = ["diagnostics", "libc"] }
rustix = "1.1.4"
xattr = "1.3.1"
@@ -80,6 +82,7 @@ regex = { workspace = true }
tempfile = { workspace = true }
terminal_size = { workspace = true }
textwrap = { workspace = true }
unicode-width = { workspace = true }
uucore = { workspace = true }
rustix = { workspace = true }
+2 -67
View File
@@ -88,73 +88,8 @@ cargo test
```
## Extensions and incompatibilities
### Supported GNU extensions
* Command-line arguments can be specified in long (`--`) form.
* Spaces can precede a regular expression modifier.
* `I` can be used in as a synonym for the `i` (case insensitive) substitution
flag.
* `M` and `m` substitution flags allow multi-line matching.
* In addition to `\n`, other escape sequences (octal, hex, C) are supported
in the strings of the `y` command.
Under POSIX these yield undefined behavior.
* The `a`, `c`, and `i` commands do not require an initial backslash,
allow text to appear on the same line, and support escape sequences
in the specified text.
* The `a`, `i`, `=`, `l`, `q` and `r` commands support address range as an extension to POSIX.
* The substitution command replacement group `\0` is a synonym for &.
* An `F` command outputs the name of the file currently being processed.
* A `Q` command (optionally followed by an exit code) quits immediately.
* The `q` command can be optionally followed by an exit code.
* A `W` command writes to a file the pattern's first line.
* An `R` command reads one line at a time from a file.
* The `l` command can be optionally followed by the output width.
* The `--follow-symlinks` option for in-place editing.
* The `--sandbox` option that limits potentially destructive commands.
* Address 0 can be used to specify an address range that is already
active on line 1 and can finish with the specified regular expression.
* Address steps can be specified in the form of start~step and start,~step
ranges.
* Address 0 can be used in the `r` command to prepend a file.
### Supported BSD and GNU extensions
* The second address in a range can be specified as a relative address with +N.
* In-place editing of file with the `-i` flag.
### New extensions
* Unicode characters can be specified in regular expression pattern, replacement
and transliteration sequences using `\uXXXX` or `\UXXXXXXXX` sequences.
### Incompatible extensions
The `-U` or `--uutil-extensions` option enables useful extensions or bug fixes
that aren't compatible with GNU sed or POSIX.
* The `l` command lists Unicode characters using the `\uXXXX` and `\UXXXXXXXX`
escapes rather than as octal UTF-8 byte sequences.
### Incompatibilities
* Similarly to GNU _sed_, input is processed as raw bytes or as valid UTF-8
(this includes 7-bit ASCII) based on the locale as specified by the
`LC_ALL`, `LC_CTYPE`, and `LANG` environment variables,
with the default being byte processing.
However, in contrast with GNU _sed_, other locales (e.g. ISO-8859-1)
are not supported. If the input is in another code page or encoding
and requires locale-specific processing (e.g. ignore/map case,
character classes), consider converting it through UTF-8 to ensure
the correct handling of locale-specific regular expressions.
This _sed_ program can also handle arbitrary byte sequences
if no part of the input requires treating it as a Rust String.
* Back-references aren't supported when input is processed as bytes
(`LC_ALL=C`).
* The command will report an error and fail if duplicate labels are found
in the script.
This matches the BSD behavior. The GNU version accepts duplicate labels.
* The last line (`$`) address is interpreted as the last non-empty line of
the last file. If files specified in subsequent arguments until the last
one are empty, then the last line condition will never be triggered.
This behavior is consistent with the
[original implementation](https://github.com/dspinellis/unix-history-repo/blob/Research-V7/usr/src/cmd/sed/sed1.c#L665).
* Labels are parsed for alphanumeric characters. The BSD version parses them
until the end of the line, preventing ; to be used as a separator.
The GNU, BSD and new extensions _sed_ supports, and where it differs from GNU
_sed_, are listed in [docs/src/extensions.md](docs/src/extensions.md).
## GNU test suite compatibility
+1
View File
@@ -0,0 +1 @@
book
+7
View File
@@ -0,0 +1,7 @@
[book]
language = "en"
src = "src"
title = "uutils sed Documentation"
[output.html]
git-repository-url = "https://github.com/uutils/sed/tree/main/docs/src"
+5
View File
@@ -0,0 +1,5 @@
# Summary
[Introduction](index.md)
* [Extensions and incompatibilities](extensions.md)
+84
View File
@@ -0,0 +1,84 @@
# Extensions and incompatibilities
The main goal of the project is compatibility with GNU _sed_, but _sed_ also
supports features that GNU _sed_ does not, and differs from it in a few places.
Below is a list of these extensions and incompatibilities.
## Supported GNU extensions
* Command-line arguments can be specified in long (`--`) form.
* Spaces can precede a regular expression modifier.
* `I` can be used in as a synonym for the `i` (case insensitive) substitution
flag.
* `M` and `m` substitution flags allow multi-line matching.
* In addition to `\n`, other escape sequences (octal, hex, C) are supported
in the strings of the `y` command.
Under POSIX these yield undefined behavior.
* The `a`, `c`, and `i` commands do not require an initial backslash,
allow text to appear on the same line, and support escape sequences
in the specified text.
* The `a`, `i`, `=`, `l`, `q` and `r` commands support address range as an extension to POSIX.
* The substitution command replacement group `\0` is a synonym for &.
* An `F` command outputs the name of the file currently being processed.
* A `Q` command (optionally followed by an exit code) quits immediately.
* The `q` command can be optionally followed by an exit code.
* A `W` command writes to a file the pattern's first line.
* An `R` command reads one line at a time from a file.
* The `l` command can be optionally followed by the output width.
* The `--follow-symlinks` option for in-place editing.
* The `--sandbox` option that limits potentially destructive commands.
* Address 0 can be used to specify an address range that is already
active on line 1 and can finish with the specified regular expression.
* Address steps can be specified in the form of start~step and start,~step
ranges.
* Address 0 can be used in the `r` command to prepend a file.
## Supported BSD and GNU extensions
* The second address in a range can be specified as a relative address with +N.
* In-place editing of file with the `-i` flag.
## New extensions
* Unicode characters can be specified in regular expression pattern, replacement
and transliteration sequences using `\uXXXX` or `\UXXXXXXXX` sequences.
* On a terminal, script errors also quote the script line and underline the
character at fault (`UUTILS_DIAG=always` or `never` overrides this):
```
$ sed 's/a/b/q'
sed: <script argument 1>:1:7: error: invalid substitute flag: 'q'
╭─[ <script argument 1>:1:7 ]
│
1 │ s/a/b/q
│ ─
───╯
```
## Incompatible extensions
The `-U` or `--uutil-extensions` option enables useful extensions or bug fixes
that aren't compatible with GNU sed or POSIX.
* The `l` command lists Unicode characters using the `\uXXXX` and `\UXXXXXXXX`
escapes rather than as octal UTF-8 byte sequences.
## Incompatibilities
* Similarly to GNU _sed_, input is processed as raw bytes or as valid UTF-8
(this includes 7-bit ASCII) based on the locale as specified by the
`LC_ALL`, `LC_CTYPE`, and `LANG` environment variables,
with the default being byte processing.
However, in contrast with GNU _sed_, other locales (e.g. ISO-8859-1)
are not supported. If the input is in another code page or encoding
and requires locale-specific processing (e.g. ignore/map case,
character classes), consider converting it through UTF-8 to ensure
the correct handling of locale-specific regular expressions.
This _sed_ program can also handle arbitrary byte sequences
if no part of the input requires treating it as a Rust String.
* Back-references aren't supported when input is processed as bytes
(`LC_ALL=C`).
* The command will report an error and fail if duplicate labels are found
in the script.
This matches the BSD behavior. The GNU version accepts duplicate labels.
* The last line (`$`) address is interpreted as the last non-empty line of
the last file. If files specified in subsequent arguments until the last
one are empty, then the last line condition will never be triggered.
This behavior is consistent with the
[original implementation](https://github.com/dspinellis/unix-history-repo/blob/Research-V7/usr/src/cmd/sed/sed1.c#L665).
* Labels are parsed for alphanumeric characters. The BSD version parses them
until the end of the line, preventing ; to be used as a separator.
+10
View File
@@ -0,0 +1,10 @@
# uutils sed
_sed_ is a Rust reimplementation of the
[sed utility](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/sed.html)
with some [GNU sed](https://www.gnu.org/software/sed/manual/sed.html),
[FreeBSD sed](https://man.freebsd.org/cgi/man.cgi?sed(1)),
and other extensions.
It is part of the [uutils](https://uutils.github.io/) project. The source code
is on [GitHub](https://github.com/uutils/sed).
+1 -1
View File
@@ -897,7 +897,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "sed"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"clap",
"clap_complete",
+41 -34
View File
@@ -359,7 +359,7 @@ fn compile_address_range(
let is_step_match = line.current() == '~'; // E.g. 0~2: Pick even-numbered lines
line.advance();
line.eat_spaces();
let is_step_end = if line.current() == '~' {
let is_step_end = if !line.eol() && line.current() == '~' {
// E.g. /foo/,~10: Start at foo, include all lines until multiple of 10 is reached.
line.advance();
line.eat_spaces();
@@ -373,39 +373,46 @@ fn compile_address_range(
return compilation_error(lines, line, "~step is invalid in POSIX mode");
}
// Look for second address.
if !line.eol() {
let addr2 = compile_address(lines, line, context)?;
// Set step_n to the number specified in the (required numeric) address.
let step_n = if is_step_match || is_step_end {
match addr2 {
Address::Line(n) => n,
_ => {
return compilation_error(
lines,
line,
"~step can only be specified through numeric values",
);
}
}
} else {
0 // dummy, not used
};
if is_line0 && !matches!(addr2, Address::Re(_)) && !is_step_match {
return compilation_error(lines, line, ERR_ADDRESS_0_USAGE);
}
// If needed, transform Address::Line into Address::Step*.
cmd.addr2 = if is_step_match {
Some(Address::StepMatch(step_n))
} else if is_step_end {
Some(Address::StepEnd(step_n))
} else {
Some(addr2)
};
n_addr += 1;
// Look for second address; a missing ~step counts as 0.
let has_addr2 = !line.eol() && (is_address_char(line.current()) || line.current() == '+');
if !(has_addr2 || is_step_match || is_step_end) {
return compilation_error(lines, line, "unexpected `,'");
}
let addr2 = if has_addr2 {
compile_address(lines, line, context)?
} else {
Address::Line(0)
};
// Set step_n to the number specified in the (required numeric) address.
let step_n = if is_step_match || is_step_end {
match addr2 {
Address::Line(n) => n,
_ => {
return compilation_error(
lines,
line,
"~step can only be specified through numeric values",
);
}
}
} else {
0 // dummy, not used
};
// Address 0 needs a regex end or a positive ~step (0~0 selects no line).
if is_line0 && !matches!(addr2, Address::Re(_)) && !(is_step_match && step_n > 0) {
return compilation_error(lines, line, ERR_ADDRESS_0_USAGE);
}
// If needed, transform Address::Line into Address::Step*.
cmd.addr2 = if is_step_match {
Some(Address::StepMatch(step_n))
} else if is_step_end {
Some(Address::StepEnd(step_n))
} else {
Some(addr2)
};
n_addr += 1;
}
// Zero-address read command check
@@ -496,7 +503,7 @@ fn compile_address(
let number = parse_number(lines, line, true)?.unwrap();
Ok(Address::Line(number))
}
_ => panic!("invalid context address"),
_ => compilation_error(lines, line, "expected context address"),
}
}
+151 -7
View File
@@ -12,8 +12,13 @@ use crate::sed::command::ProcessingContext;
use crate::sed::script_char_provider::ScriptCharProvider;
use crate::sed::script_line_provider::ScriptLineProvider;
use std::env;
use std::io::{IsTerminal, stderr};
use std::rc::Rc;
use std::sync::OnceLock;
use unicode_width::UnicodeWidthChar;
use uucore::diagnostics;
use uucore::display::Quotable;
use uucore::error::{UResult, USimpleError};
@@ -23,6 +28,9 @@ pub struct ScriptLocation {
pub input_name: Rc<str>, // Shared input name
pub line_number: usize, // 1-based line number
pub column_number: usize, // 1-based column number
/// The script line, for errors raised after it was consumed.
/// `None` when diagnostics are off.
pub line_text: Option<Rc<str>>,
}
impl Default for ScriptLocation {
@@ -31,6 +39,7 @@ impl Default for ScriptLocation {
input_name: Rc::from("<unknown>"),
line_number: 1,
column_number: 1,
line_text: None,
}
}
}
@@ -42,10 +51,123 @@ impl ScriptLocation {
line_number: lines.get_line_number(),
column_number: line.get_pos() + 1,
input_name: Rc::from(lines.get_input_name()),
line_text: if diagnostics_enabled() {
line.line_text()
} else {
None
},
}
}
}
/// Whether script errors get a snippet. Evaluated once: a location is
/// recorded for every compiled command, and the terminal check is a syscall.
fn diagnostics_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(diagnostics::enabled)
}
/// Format `msg` as an error at the given script position, followed by the
/// offending script line, underlined, when diagnostics are enabled.
fn script_error(
input_name: &str,
line_number: usize,
column_number: usize,
line_text: Option<&str>,
msg: impl ToString,
) -> String {
let message = format!(
"{input_name}:{line_number}:{column_number}: error: {}",
msg.to_string()
);
// Line 0: the script was exhausted, so there is no line to show.
if !diagnostics_enabled() || line_number == 0 {
return message;
}
// A script line that is not valid UTF-8 cannot be drawn.
let Some(text) = line_text.filter(|text| !text.is_empty()) else {
return message;
};
// `column_number` is a 1-based byte column; errors at end of line point
// one past the last character.
let Some(prefix) = text.get(..(column_number - 1).min(text.len())) else {
return message;
};
let drawn = render_snippet(
input_name,
line_number,
column_number,
text,
prefix,
use_color(),
);
format!("{message}\n{drawn}")
}
/// Whether the underline is colored. uucore's diagnostics make the same
/// check but keep it private.
fn use_color() -> bool {
env::var_os("NO_COLOR").is_none() && stderr().is_terminal()
}
/// Draw `text` as line `line_number` of `input_name`, underlining the
/// character that follows `prefix`, in the layout uucore's diagnostics use:
///
/// ```text
/// ╭─[ <script argument 1>:1:7 ]
/// │
/// 1 │ s/a/b/q
/// │ ─
/// ───╯
/// ```
fn render_snippet(
input_name: &str,
line_number: usize,
column_number: usize,
text: &str,
prefix: &str,
color: bool,
) -> String {
let number = line_number.to_string();
let margin = " ".repeat(number.len() + 2);
// Pad by display width so the underline lines up with the character
// above it; tabs are kept as they are, since their width depends on the
// terminal.
let indent: String = prefix
.chars()
.flat_map(|c| {
let (fill, width) = if c == '\t' {
('\t', 1)
} else {
(' ', c.width().unwrap_or(0))
};
std::iter::repeat_n(fill, width)
})
.collect();
// As wide as the offending character; one column past the end of line.
let width = text[prefix.len()..]
.chars()
.next()
.and_then(UnicodeWidthChar::width)
.unwrap_or(1)
.max(1);
let underline = "─".repeat(width);
let underline = if color {
format!("\x1b[31m{underline}\x1b[0m")
} else {
underline
};
format!(
"{margin}╭─[ {input_name}:{line_number}:{column_number} ]\n\
{margin}│\n\
\x20{number} │ {text}\n\
{margin}│ {indent}{underline}\n\
{rule}╯",
rule = "─".repeat(margin.len()),
)
}
/// Fail with msg as a compile error at the provider location.
/// The error's exit code is 1 (compilation phase).
pub fn compilation_error<T>(
@@ -55,12 +177,12 @@ pub fn compilation_error<T>(
) -> UResult<T> {
Err(USimpleError::new(
1,
format!(
"{}:{}:{}: error: {}",
script_error(
lines.get_input_name(),
lines.get_line_number(),
line.get_pos() + 1,
msg.to_string()
str::from_utf8(line.get_line()).ok(),
msg,
),
))
}
@@ -70,12 +192,12 @@ pub fn compilation_error<T>(
fn location_error<T>(location: &ScriptLocation, msg: impl ToString, exit_code: i32) -> UResult<T> {
Err(USimpleError::new(
exit_code,
format!(
"{}:{}:{}: error: {}",
location.input_name,
script_error(
&location.input_name,
location.line_number,
location.column_number,
msg.to_string()
location.line_text.as_deref(),
msg,
),
))
}
@@ -115,3 +237,25 @@ pub fn input_runtime_error<T>(
),
))
}
#[cfg(test)]
mod tests {
use super::*;
// The integration tests check the layout; they cannot get color, as
// their stderr is not a terminal.
#[test]
fn the_underline_is_red_when_colored() {
let drawn = render_snippet("f.sed", 1, 7, "s/a/b/q", "s/a/b/", true);
assert_eq!(
drawn,
concat!(
" ╭─[ f.sed:1:7 ]\n",
" │\n",
" 1 │ s/a/b/q\n",
" │ \x1b[31m─\x1b[0m\n",
"───╯",
)
);
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ fn applies(
Address::Line(n) if linenum >= *n => {
context.last_address = true;
}
Address::RelLine(n) if *n == 0 => {
Address::RelLine(0) | Address::StepMatch(0) | Address::StepEnd(0) => {
context.last_address = true;
}
_ => {
+18
View File
@@ -8,10 +8,15 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use std::cell::OnceCell;
use std::rc::Rc;
#[derive(Debug)]
pub struct ScriptCharProvider {
line: Vec<u8>,
pos: usize,
/// The line as text, shared by the commands compiled from it.
text: OnceCell<Option<Rc<str>>>,
}
impl ScriptCharProvider {
@@ -20,6 +25,7 @@ impl ScriptCharProvider {
Self {
line: line.as_ref().to_vec(),
pos: 0,
text: OnceCell::new(),
}
}
@@ -66,6 +72,18 @@ impl ScriptCharProvider {
pub fn get_pos(&self) -> usize {
self.pos
}
/// Return the whole line being scanned.
pub fn get_line(&self) -> &[u8] {
&self.line
}
/// Return the line as shared text, or `None` if it is not valid UTF-8.
pub fn line_text(&self) -> Option<Rc<str>> {
self.text
.get_or_init(|| str::from_utf8(&self.line).ok().map(Rc::from))
.clone()
}
}
#[cfg(test)]
+228
View File
@@ -1183,6 +1183,166 @@ fn pattern_clear_with_z_is_non_posix() {
}
check_output!(trans_newline, ["-e", r"1N;2y/\n/X/", LINES1]);
////////////////////////////////////////////////////////////
// Rich diagnostics, drawn for a terminal and controlled by UUTILS_DIAG
#[test]
fn diagnostics_stay_out_of_the_way_of_scripts() {
// stderr is not a terminal here, so only the plain message is printed.
for value in [None, Some(""), Some("never")] {
let mut command = new_ucmd!();
if let Some(value) = value {
command.env("UUTILS_DIAG", value);
}
command
.args(&["--posix", "z"])
.fails()
.code_is(1)
.stderr_is("sed: <script argument 1>:1:1: error: invalid command code `z'\n");
}
}
#[test]
fn diagnostics_underline_the_offending_character() {
new_ucmd!()
.env("UUTILS_DIAG", "always")
.args(&["s/a/b/q"])
.fails()
.code_is(1)
.stderr_is(concat!(
"sed: <script argument 1>:1:7: error: invalid substitute flag: 'q'\n",
" ╭─[ <script argument 1>:1:7 ]\n",
" │\n",
" 1 │ s/a/b/q\n",
" │ ─\n",
"───╯\n",
));
}
#[test]
fn diagnostics_can_point_past_the_end_of_the_line() {
new_ucmd!()
.env("UUTILS_DIAG", "always")
.args(&["/adrift"])
.fails()
.code_is(1)
.stderr_is(concat!(
"sed: <script argument 1>:1:8: error: unterminated regular expression\n",
" ╭─[ <script argument 1>:1:8 ]\n",
" │\n",
" 1 │ /adrift\n",
" │ ─\n",
"───╯\n",
));
}
#[test]
fn diagnostics_count_columns_in_bytes_like_the_message() {
// `Ă©` is two bytes: the header must use byte column 8, not 7.
new_ucmd!()
.env("UUTILS_DIAG", "always")
.args(&["s/Ă©/e/z"])
.fails()
.code_is(1)
.stderr_is(concat!(
"sed: <script argument 1>:1:8: error: invalid substitute flag: 'z'\n",
" ╭─[ <script argument 1>:1:8 ]\n",
" │\n",
" 1 │ s/é/e/z\n",
" │ ─\n",
"───╯\n",
));
}
#[test]
fn diagnostics_reach_errors_raised_after_the_line_was_read() {
// Undefined labels are found after compilation; the gutter shows line 2.
new_ucmd!()
.env("UUTILS_DIAG", "always")
.args(&["-e", "p\nb nowhere"])
.fails()
.code_is(1)
.stderr_is(concat!(
"sed: <script argument 1>:2:1: error: undefined label `nowhere'\n",
" ╭─[ <script argument 1>:2:1 ]\n",
" │\n",
" 2 │ b nowhere\n",
" │ ─\n",
"───╯\n",
));
}
#[test]
fn diagnostics_line_up_under_wide_characters() {
// `日本` takes four columns: the underline must sit under `q`.
new_ucmd!()
.env("UUTILS_DIAG", "always")
.args(&["s/日本/x/q"])
.fails()
.code_is(1)
.stderr_is(concat!(
"sed: <script argument 1>:1:12: error: invalid substitute flag: 'q'\n",
" ╭─[ <script argument 1>:1:12 ]\n",
" │\n",
" 1 │ s/日本/x/q\n",
" │ ─\n",
"───╯\n",
));
}
#[test]
fn diagnostics_underline_a_wide_character_across_its_columns() {
new_ucmd!()
.env("UUTILS_DIAG", "always")
.args(&["pć—Ą"])
.fails()
.code_is(1)
.stderr_is(concat!(
"sed: <script argument 1>:1:2: error: extra characters at the end of the p command\n",
" ╭─[ <script argument 1>:1:2 ]\n",
" │\n",
" 1 │ p日\n",
" │ ──\n",
"───╯\n",
));
}
#[test]
fn diagnostics_keep_tabs_and_widen_the_gutter() {
// Line 10 starts with a tab, kept in the indent so the underline lines up.
new_ucmd!()
.env("UUTILS_DIAG", "always")
.args(&[format!("{}\tpz", "p\n".repeat(9))])
.fails()
.code_is(1)
.stderr_is(concat!(
"sed: <script argument 1>:10:3: error: extra characters at the end of the p command\n",
" ╭─[ <script argument 1>:10:3 ]\n",
" │\n",
" 10 │ \tpz\n",
" │ \t ─\n",
"────╯\n",
));
}
#[test]
fn diagnostics_skip_scripts_that_are_not_utf8() {
// A line that is not valid UTF-8 cannot be quoted: only the message.
let mut script = NamedTempFile::new().expect("create temporary sed script");
script
.write_all(b"s/\xff/x/q\n")
.expect("write temporary sed script");
let script_path = script.path().to_str().expect("temporary path is UTF-8");
new_ucmd!()
.env("UUTILS_DIAG", "always")
.args(&["-f", script_path])
.fails()
.code_is(1)
.stderr_is(format!(
"sed: {script_path}:1:7: error: invalid substitute flag: 'q'\n"
));
}
////////////////////////////////////////////////////////////
// Pattern space manipulation: D, d, H, h, N, n, P, p, q, x
check_output!(pattern_print_to_newline, ["-n", r"1{;N;P;P;p;}", LINES1]);
@@ -2546,6 +2706,74 @@ fn test_step_end_non_posix() {
.stderr_is("sed: <script argument 1>:1:4: error: ~step is invalid in POSIX mode\n");
}
#[test]
fn test_step_without_number_is_zero() {
// `N~` and `N,~` select line N alone, like a step of 0.
for script in ["3~=", "3,~=", "3,~0="] {
new_ucmd!()
.args(&["-n", script])
.pipe_in("q\nr\ns\nt\n")
.succeeds()
.stdout_is("3\n");
}
}
#[test]
fn test_step_match_zero_closes_range() {
// `3~0` selects line 3 alone, so `c` prints its text there.
new_ucmd!()
.args(&["3~0c\\X"])
.pipe_in("1\n2\n3\n4\n5\n")
.succeeds()
.stdout_is("1\n2\nX\n4\n5\n");
}
#[test]
fn test_step_non_numeric() {
for (script, column) in [("1~/x/p", 6), ("1,~/x/p", 7)] {
new_ucmd!()
.args(&[script])
.fails()
.code_is(1)
.stderr_is(format!(
"sed: <script argument 1>:1:{column}: error: ~step can only be specified through numeric values\n"
));
}
}
#[test]
fn test_step_without_number_at_end_of_line() {
new_ucmd!()
.args(&["7~"])
.fails()
.code_is(1)
.stderr_is("sed: <script argument 1>:1:3: error: command expected\n");
}
#[test]
fn test_addr0_step_zero() {
for script in ["0~0p", "0~p"] {
new_ucmd!()
.args(&[script])
.fails()
.code_is(1)
.stderr_contains("address 0 can only be used with ~step");
}
}
#[test]
fn test_comma_without_second_address() {
for (script, column) in [("5,", 3), ("5,=", 3), ("5, x", 4)] {
new_ucmd!()
.args(&[script])
.fails()
.code_is(1)
.stderr_is(format!(
"sed: <script argument 1>:1:{column}: error: unexpected `,'\n"
));
}
}
// The following test diverse ways in which regexes are matched.
// Search for 'regex\.' to find them in the code.
#[test]