Support GNU syntax for the a, c, i commands

The original POSIX behavior is now available under --posix.

Issue: #86
This commit is contained in:
Diomidis Spinellis
2025-07-27 13:21:59 +03:00
parent e7a99ca7b5
commit 80a7d0288c
5 changed files with 291 additions and 17 deletions
+3
View File
@@ -49,6 +49,9 @@ cargo run --release
* 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 substitution command replacement group `\0` is a synonym for &.
* A `Q` command (optionally followed by an exit code) quits immediately.
* The `q` command can be optionally followed by an exit code.
+191 -6
View File
@@ -1023,15 +1023,101 @@ fn compile_number_command(
/// Compile commands that take text as an argument.
// Handles a, c, i
// According to POSIX, these commands expect \ followed by text.
// As a GNU extension the initial \ can be ommitted, and from then on
// character escapes are honored.
fn compile_text_command(
lines: &mut ScriptLineProvider,
line: &mut ScriptCharProvider,
cmd: &mut Command,
context: &mut ProcessingContext,
) -> UResult<CommandHandling> {
line.advance(); // Skip the command character.
line.eat_spaces(); // Skip any leading whitespace.
if context.posix {
compile_text_command_posix(lines, line, cmd, context)
} else {
compile_text_command_gnu(lines, line, cmd, context)
}
}
/// Compile commands that take text as an argument (GNU syntax).
// Handles a, c, i; after the command and initial whitespace have been consumed.
// According to POSIX, these commands expect \ followed by text.
// As a GNU extension the initial \ can be ommitted, and from then on
// character escapes are honored.
fn compile_text_command_gnu(
lines: &mut ScriptLineProvider,
line: &mut ScriptCharProvider,
cmd: &mut Command,
_context: &mut ProcessingContext,
) -> UResult<CommandHandling> {
line.advance(); // Skip the command character.
// True after a \ at the end of a line
let mut escaped_newline = false;
line.eat_spaces(); // Skip any leading whitespace.
// Skip optional \.
if !line.eol() && line.current() == '\\' {
line.advance();
escaped_newline = line.eol();
}
// Gather replacement text. Stop on a non-escaped newline.
let mut text = String::new();
'text_content: loop {
if escaped_newline {
match lines.next_line()? {
None => {
break 'text_content;
}
Some(line_string) => {
*line = ScriptCharProvider::new(&line_string);
}
}
escaped_newline = false;
}
// Non-escaped newline
if line.eol() {
text.push('\n');
break 'text_content;
}
if line.current() == '\\' {
line.advance();
if line.eol() {
escaped_newline = true;
text.push('\n');
continue 'text_content;
}
match parse_char_escape(line) {
Some(decoded) => text.push(decoded),
None => {
// Invalid escapes result in the escaped character.
text.push(line.current());
line.advance();
}
}
} else {
text.push(line.current());
line.advance();
}
}
cmd.data = CommandData::Text(Cow::Owned(text));
Ok(CommandHandling::Continue)
}
/// Compile commands that take text as an argument (POSIX syntax).
// Handles a, c, i; after the command and initial whitespace have been consumed.
// According to POSIX, these commands expect \ followed by text.
fn compile_text_command_posix(
lines: &mut ScriptLineProvider,
line: &mut ScriptCharProvider,
cmd: &mut Command,
_context: &mut ProcessingContext,
) -> UResult<CommandHandling> {
if line.eol() || line.current() != '\\' {
return compilation_error(
lines,
@@ -2462,16 +2548,113 @@ mod tests {
}
#[test]
fn test_compile_spaces_single_line_text_command() {
fn test_compile_text_command_posix_spaces_single_line() {
let mut chars = make_char_provider("a \\ ");
let mut lines = make_line_provider(&["line1", "line2"]);
let mut cmd = Command::default();
let mut context = ProcessingContext::default();
context.posix = true;
compile_text_command(&mut lines, &mut chars, &mut cmd, &mut context).unwrap();
match &cmd.data {
CommandData::Text(text) => {
assert_eq!(text, "line1\n");
}
_ => panic!("Expected CommandData::Text"),
}
}
#[test]
fn test_compile_text_command_gnu_optional_backslash() {
let mut chars = make_char_provider("athere");
let mut lines = make_line_provider(&["line1", "line2"]);
let mut cmd = Command::default();
let mut context = ProcessingContext::default();
compile_text_command(&mut lines, &mut chars, &mut cmd, &mut context).unwrap();
match &cmd.data {
CommandData::Text(text) => {
assert_eq!(text, "line1\n");
assert_eq!(text, "there\n");
}
_ => panic!("Expected CommandData::Text"),
}
}
#[test]
fn test_compile_text_command_gnu_optional_backslash_spaces() {
let mut chars = make_char_provider("a \t there");
let mut lines = make_line_provider(&["line1", "line2"]);
let mut cmd = Command::default();
let mut context = ProcessingContext::default();
compile_text_command(&mut lines, &mut chars, &mut cmd, &mut context).unwrap();
match &cmd.data {
CommandData::Text(text) => {
assert_eq!(text, "there\n");
}
_ => panic!("Expected CommandData::Text"),
}
}
#[test]
fn test_compile_text_command_gnu_optional_backslash_eol_eof() {
let mut chars = make_char_provider("a");
let mut lines = make_line_provider(&[]);
let mut cmd = Command::default();
let mut context = ProcessingContext::default();
compile_text_command(&mut lines, &mut chars, &mut cmd, &mut context).unwrap();
match &cmd.data {
CommandData::Text(text) => {
assert_eq!(text, "\n");
}
_ => panic!("Expected CommandData::Text"),
}
}
#[test]
fn test_compile_text_command_gnu_optional_backslash_escape_eof() {
let mut chars = make_char_provider("a\\");
let mut lines = make_line_provider(&[]);
let mut cmd = Command::default();
let mut context = ProcessingContext::default();
compile_text_command(&mut lines, &mut chars, &mut cmd, &mut context).unwrap();
match &cmd.data {
CommandData::Text(text) => {
assert_eq!(text, "");
}
_ => panic!("Expected CommandData::Text"),
}
}
#[test]
fn test_compile_text_command_gnu_no_first_escape() {
let mut chars = make_char_provider("a\\tom");
let mut lines = make_line_provider(&[]);
let mut cmd = Command::default();
let mut context = ProcessingContext::default();
compile_text_command(&mut lines, &mut chars, &mut cmd, &mut context).unwrap();
match &cmd.data {
CommandData::Text(text) => {
assert_eq!(text, "tom\n");
}
_ => panic!("Expected CommandData::Text"),
}
}
#[test]
fn test_compile_text_command_gnu_char_escapes() {
let mut chars = make_char_provider("i\\>\\h\\elll\\bo\\nto\\");
let mut lines = make_line_provider(&["all\\a", ""]);
let mut cmd = Command::default();
let mut context = ProcessingContext::default();
compile_text_command(&mut lines, &mut chars, &mut cmd, &mut context).unwrap();
match &cmd.data {
CommandData::Text(text) => {
assert_eq!(text, ">helll\x08o\nto\nall\x07\n");
}
_ => panic!("Expected CommandData::Text"),
}
@@ -2494,11 +2677,12 @@ mod tests {
}
#[test]
fn test_compile_text_command_without_backslash() {
fn test_compile_text_command_posix_without_backslash() {
let mut chars = make_char_provider("a");
let mut lines = make_line_provider(&["line1", "line2"]);
let mut cmd = Command::default();
let mut context = ProcessingContext::default();
context.posix = true;
let result = compile_text_command(&mut lines, &mut chars, &mut cmd, &mut context);
assert!(result.is_err());
@@ -2507,11 +2691,12 @@ mod tests {
}
#[test]
fn test_compile_text_command_with_trailing_chars() {
fn test_compile_text_command_posix_with_trailing_chars() {
let mut chars = make_char_provider("a \\ foo");
let mut lines = make_line_provider(&["line1", "line2"]);
let mut cmd = Command::default();
let mut context = ProcessingContext::default();
context.posix = true;
let result = compile_text_command(&mut lines, &mut chars, &mut cmd, &mut context);
assert!(result.is_err());
+1 -1
View File
@@ -92,7 +92,7 @@ pub fn uu_app() -> Command {
arg!(-l --length <NUM> "Specify the 'l' command line-wrap length.")
.value_parser(clap::value_parser!(u32)),
arg!(-n --quiet "Suppress automatic printing of pattern space.").aliases(["silent"]),
arg!(--posix "Disable all POSIX extensions."),
arg!(--posix "Disable non-POSIX extensions."),
arg!(-s --separate "Consider files as separate rather than as a long stream."),
arg!(--sandbox "Operate in a sandbox by disabling e/r/w commands."),
arg!(-u --unbuffered "Load minimal input data and flush output buffers regularly."),
+40 -10
View File
@@ -129,6 +129,23 @@ macro_rules! check_output {
};
}
// Run ucmd twice to test POSIX conformance: Once where posix is "--posix"
// and once where it has the dummy value "--follow-symlinks".
// This shall be used to test commands that behave differently under POSIX.
macro_rules! check_output_posix {
($name:ident, [$($args:expr),* $(,)?]) => {
#[test]
fn $name() {
for posix in ["--posix", "--follow-symlinks"] {
new_ucmd!()
.args(&[posix $(, $args)*]) // prepend posix, then add args
.succeeds()
.stdout_is_fixture(&format!("output/{}", stringify!($name)));
}
}
};
}
////////////////////////////////////////////////////////////
// Individual command tests
@@ -496,7 +513,10 @@ tb"#,
////////////////////////////////////////////////////////////
// Text: a, c, i
check_output!(
// Check both POSIX and GNU parsing routines.
check_output_posix!(
text_insert_quit,
[
"-e",
@@ -509,7 +529,7 @@ hello
]
);
check_output!(
check_output_posix!(
text_insert_between_subst,
[
"-n",
@@ -525,7 +545,7 @@ s/^/after_i/p
]
);
check_output!(
check_output_posix!(
text_append_between_subst,
[
"-n",
@@ -542,7 +562,7 @@ s/^/after_a/p
]
);
check_output!(
check_output_posix!(
text_append_before_next,
[
"-n",
@@ -559,7 +579,7 @@ s/$/$/p
]
);
check_output!(
check_output_posix!(
text_change_global,
[
"-n",
@@ -572,7 +592,7 @@ hello
]
);
check_output!(
check_output_posix!(
text_change_line,
[
"-n",
@@ -585,7 +605,7 @@ hello
]
);
check_output!(
check_output_posix!(
text_change_range,
[
"-n",
@@ -598,8 +618,9 @@ hello
]
);
// SunOS and GNU sed behave differently. We follow POSIX.
check_output!(
// SunOS and GNU sed behave differently. We follow POSIX.
// TODO: follow GNU and revert under --posix.
check_output_posix!(
text_change_reverse_range,
[
"-n",
@@ -615,7 +636,7 @@ hello
check_output!(text_delete, ["d", LINES1]);
// Check that the pattern space is deleted.
check_output!(
check_output_posix!(
text_change_print,
[
"-n",
@@ -629,6 +650,15 @@ p
]
);
// GNU syntax extensions:
// Text can follow the initial \.
// Character escapes are supported.
// Invalid escapes result in the escaped character.
check_output!(
text_insert_gnu,
["-e", "i\\>\\h\\elll\x08o\\nto\\\nall\\a", LINES1]
);
////////////////////////////////////////////////////////////
// r, w commands
check_output!(read_ok, [format!("4r {LINES2}"), LINES1.to_string()]);
+56
View File
@@ -0,0 +1,56 @@
>helllo
to
all
l1_1
>helllo
to
all
l1_2
>helllo
to
all
l1_3
>helllo
to
all
l1_4
>helllo
to
all
l1_5
>helllo
to
all
l1_6
>helllo
to
all
l1_7
>helllo
to
all
l1_8
>helllo
to
all
l1_9
>helllo
to
all
l1_10
>helllo
to
all
l1_11
>helllo
to
all
l1_12
>helllo
to
all
l1_13
>helllo
to
all
l1_14