From 5190377ed7125de0a7bd52bb92e9e67f38eda70b Mon Sep 17 00:00:00 2001 From: Diomidis Spinellis Date: Thu, 20 Aug 2026 18:11:29 +0300 Subject: [PATCH 1/3] Implement GNU R (read next line) command Co-authored-by: Mukunda Katta --- README.md | 1 + src/sed/command.rs | 5 +- src/sed/compiler.rs | 17 ++- src/sed/mod.rs | 2 +- src/sed/{named_writer.rs => named_io.rs} | 123 +++++++++++++++++- src/sed/processor.rs | 13 +- tests/by-util/test_sed.rs | 22 +++- tests/fixtures/sed/output/read_no_newline | 14 ++ tests/fixtures/sed/output/read_one | 15 +++ tests/fixtures/sed/output/read_one_empty | 14 ++ tests/fixtures/sed/output/read_one_many | 23 ++++ tests/fixtures/sed/output/read_one_missing | 14 ++ tests/fixtures/sed/output/read_one_no_newline | 14 ++ tests/fixtures/sed/output/read_one_twice | 16 +++ 14 files changed, 282 insertions(+), 11 deletions(-) rename src/sed/{named_writer.rs => named_io.rs} (61%) create mode 100644 tests/fixtures/sed/output/read_no_newline create mode 100644 tests/fixtures/sed/output/read_one create mode 100644 tests/fixtures/sed/output/read_one_empty create mode 100644 tests/fixtures/sed/output/read_one_many create mode 100644 tests/fixtures/sed/output/read_one_missing create mode 100644 tests/fixtures/sed/output/read_one_no_newline create mode 100644 tests/fixtures/sed/output/read_one_twice diff --git a/README.md b/README.md index 255573fa..d4e5d2b2 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ cargo test * 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. +* An `R` schedules reading the next line from the specified file. * A `W` command writes to a file the pattern's first line. * The `l` command can be optionally followed by the output width. * The `--follow-symlinks` option for in-place editing. diff --git a/src/sed/command.rs b/src/sed/command.rs index 30a47be1..403a0c8f 100644 --- a/src/sed/command.rs +++ b/src/sed/command.rs @@ -10,7 +10,7 @@ use crate::sed::error_handling::{ScriptLocation, runtime_error}; use crate::sed::fast_regex::{Captures, Match, Regex}; -use crate::sed::named_writer::NamedWriter; +use crate::sed::named_io::{NamedReader, NamedWriter}; use crate::sed::script_char_provider::ScriptCharProvider; use crate::sed::script_line_provider::ScriptLineProvider; @@ -367,7 +367,8 @@ pub enum CommandData { BranchTarget(Option>>), // Commands for 'b', 't', 'T', '{' Label(Option), // Label name for 'b', 't', 'T', ':' Path(PathBuf), // File path for 'r' - NamedWriter(Rc>), // File output for 'w' + NamedReader(Rc>), // File input for 'R' + NamedWriter(Rc>), // File output for 'w', 'W' Number(usize), // Number for 'l', 'q', 'Q' (GNU) Substitution(Box), // Substitute command 's' Text(Rc<[u8]>), // Text for 'a', 'c', 'i' diff --git a/src/sed/compiler.rs b/src/sed/compiler.rs index 1ea7a8e1..de5ca28f 100644 --- a/src/sed/compiler.rs +++ b/src/sed/compiler.rs @@ -18,7 +18,7 @@ use crate::sed::delimited_parser::{ }; use crate::sed::error_handling::{ScriptLocation, compilation_error, semantic_error}; use crate::sed::fast_regex::Regex; -use crate::sed::named_writer::NamedWriter; +use crate::sed::named_io::{NamedReader, NamedWriter}; use crate::sed::script_char_provider::ScriptCharProvider; use crate::sed::script_line_provider::{ScriptLineProvider, ScriptValue}; @@ -1107,7 +1107,7 @@ fn compile_empty_command( Ok(CommandHandling::Continue) } -// Handles r +// Handles r, R fn compile_read_file_command( lines: &mut ScriptLineProvider, line: &mut ScriptCharProvider, @@ -1118,7 +1118,11 @@ fn compile_read_file_command( return compilation_error(lines, line, ERR_SANDBOX); } let path = read_file_path(lines, line)?; - cmd.data = CommandData::Path(path); + cmd.data = if cmd.code == 'R' { + CommandData::NamedReader(NamedReader::new(path)) + } else { + CommandData::Path(path) + }; Ok(CommandHandling::Continue) } @@ -1632,10 +1636,16 @@ fn get_cmd_spec( n_addr: 2, handler: compile_execute_command, }), + // F is a GNU extension 'F' if !posix => Ok(CommandSpec { n_addr: 2, handler: compile_empty_command, }), + // R is a GNU extension + 'R' if !posix => Ok(CommandSpec { + n_addr: 2, + handler: compile_read_file_command, + }), 'r' => Ok(CommandSpec { n_addr: if posix { 1 } else { 2 }, handler: compile_read_file_command, @@ -1648,6 +1658,7 @@ fn get_cmd_spec( n_addr: 2, handler: compile_label_command, }), + // W is a GNU extension 'W' if !posix => Ok(CommandSpec { n_addr: 2, handler: compile_write_file_command, diff --git a/src/sed/mod.rs b/src/sed/mod.rs index 7e1b0b68..24234417 100644 --- a/src/sed/mod.rs +++ b/src/sed/mod.rs @@ -15,7 +15,7 @@ pub mod error_handling; pub mod fast_io; pub mod fast_regex; pub mod in_place; -pub mod named_writer; +pub mod named_io; pub mod processor; pub mod script_char_provider; pub mod script_line_provider; diff --git a/src/sed/named_writer.rs b/src/sed/named_io.rs similarity index 61% rename from src/sed/named_writer.rs rename to src/sed/named_io.rs index 39b3cc34..ab5778f3 100644 --- a/src/sed/named_writer.rs +++ b/src/sed/named_io.rs @@ -13,7 +13,7 @@ use crate::sed::error_handling::{ScriptLocation, runtime_error}; use std::cell::RefCell; use std::collections::HashMap; use std::fs::{self, File, OpenOptions}; -use std::io::{BufWriter, Write}; +use std::io::{BufRead, BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -21,10 +21,73 @@ use uucore::display::Quotable; use uucore::error::UResult; thread_local! { - /// Writers indexed by canonical output path, used to share duplicate writes. + /// Readers indexed by canonical input path, used to share state + /// between same-named path reads. + static READERS: RefCell>>> = RefCell::new(HashMap::new()); + /// Writers indexed by canonical output path, used to share same-named + /// paths and to flush writes. static WRITERS: RefCell>>> = RefCell::new(HashMap::new()); } +#[derive(Debug)] +/// Reader that shares line-by-line state for GNU sed's R command. +pub struct NamedReader { + path: PathBuf, + reader: Option>, + done: bool, +} + +impl NamedReader { + /// Create or retrieve the reader associated with `path`. + pub fn new(path: PathBuf) -> Rc> { + let canonical_path = fs::canonicalize(&path).unwrap_or(path); + READERS.with(|readers| { + readers + .borrow_mut() + .entry(canonical_path.clone()) + .or_insert_with(|| { + Rc::new(RefCell::new(Self { + path: canonical_path, + reader: None, + done: false, + })) + }) + .clone() + }) + } + + /// Return the path associated with this reader. + pub fn original_path(&self) -> &Path { + &self.path + } + + /// Read the next line, including its newline. Missing files and read errors + /// are treated as end-of-file, as required by the R command. + pub fn read_line(&mut self) -> Option> { + if self.done { + return None; + } + + if self.reader.is_none() { + if let Ok(file) = File::open(&self.path) { + self.reader = Some(BufReader::new(file)); + } else { + self.done = true; + return None; + } + } + + let mut line = Vec::new(); + match self.reader.as_mut().unwrap().read_until(b'\n', &mut line) { + Ok(0) | Err(_) => { + self.done = true; + None + } + Ok(_) => Some(line), + } + } +} + #[derive(Debug)] /// Writer that tracks its file name for better error messages pub struct NamedWriter { @@ -141,6 +204,62 @@ mod tests { use std::fs; use tempfile::{NamedTempFile, tempdir}; + #[test] + fn test_reader_reads_lines_as_bytes() { + let file = NamedTempFile::new().unwrap(); + let path = file.path().to_path_buf(); + fs::write(&path, b"first\nsecond\xE9").unwrap(); + let reader = NamedReader::new(path); + + assert_eq!(reader.borrow_mut().read_line(), Some(b"first\n".to_vec())); + assert_eq!( + reader.borrow_mut().read_line(), + Some(b"second\xE9".to_vec()) + ); + assert_eq!(reader.borrow_mut().read_line(), None); + assert_eq!(reader.borrow_mut().read_line(), None); + } + + #[test] + fn test_new_reuses_reader_and_shared_position_for_same_path() { + let file = NamedTempFile::new().unwrap(); + let path = file.path().to_path_buf(); + fs::write(&path, b"first\nsecond\n").unwrap(); + let first = NamedReader::new(path.clone()); + let second = NamedReader::new(path); + + assert!(Rc::ptr_eq(&first, &second)); + assert_eq!(first.borrow_mut().read_line(), Some(b"first\n".to_vec())); + assert_eq!(second.borrow_mut().read_line(), Some(b"second\n".to_vec())); + } + + #[test] + fn test_new_reuses_reader_for_canonical_duplicate_path() { + let dir = tempdir().unwrap(); + let path = dir.path().join("input"); + fs::write(&path, b"first\nsecond\n").unwrap(); + let duplicate_path = dir.path().join(".").join("input"); + let first = NamedReader::new(path.clone()); + let second = NamedReader::new(duplicate_path); + + assert!(Rc::ptr_eq(&first, &second)); + assert_eq!( + first.borrow().original_path(), + fs::canonicalize(path).unwrap() + ); + assert_eq!(first.borrow_mut().read_line(), Some(b"first\n".to_vec())); + assert_eq!(second.borrow_mut().read_line(), Some(b"second\n".to_vec())); + } + + #[test] + fn test_reader_silently_ignores_missing_file() { + let dir = tempdir().unwrap(); + let reader = NamedReader::new(dir.path().join("missing")); + + assert_eq!(reader.borrow_mut().read_line(), None); + assert_eq!(reader.borrow_mut().read_line(), None); + } + #[test] fn test_write_line_bytes_appends_newline() { let file = NamedTempFile::new().unwrap(); diff --git a/src/sed/processor.rs b/src/sed/processor.rs index 2aa12317..f41657e7 100644 --- a/src/sed/processor.rs +++ b/src/sed/processor.rs @@ -17,7 +17,7 @@ use crate::sed::error_handling::{ScriptLocation, input_runtime_error}; use crate::sed::fast_io::{IOChunk, LineReader, OutputBuffer}; use crate::sed::fast_regex::Regex; use crate::sed::in_place::InPlace; -use crate::sed::named_writer; +use crate::sed::named_io; use memchr::memchr; use std::borrow::Cow; @@ -826,6 +826,15 @@ fn process_file( .append_elements .push(AppendElement::Path(path.clone())); } + 'R' => { + // Copy one line from the file to standard output later. + let reader = extract_variant!(command, NamedReader); + if let Some(line) = reader.borrow_mut().read_line() { + context + .append_elements + .push(AppendElement::Text(Rc::from(line))); + } + } 's' => { substitute(&mut pattern, &command, context, output)?; } @@ -1007,7 +1016,7 @@ pub fn process_all_files( } // Flush all output files - named_writer::flush_all()?; + named_io::flush_all()?; Ok(()) } diff --git a/tests/by-util/test_sed.rs b/tests/by-util/test_sed.rs index 7f006ea7..ea2c3d26 100644 --- a/tests/by-util/test_sed.rs +++ b/tests/by-util/test_sed.rs @@ -1646,8 +1646,9 @@ check_output!( ); //////////////////////////////////////////////////////////// -// r, w, W commands +// r, R, w, W commands check_output!(read_ok, [format!("4r {LINES2}"), LINES1.to_string()]); +check_output!(read_no_newline, [format!("4r input/no-new-line.txt"), LINES1.to_string()]); check_output!(read_missing, ["5r /xyzzyxyzy42", LINES1]); check_output!(read_empty, ["6r input/empty", LINES1]); check_output!( @@ -1667,6 +1668,25 @@ fn sandbox_rejects_read_command() { .stderr_contains("command not allowed with --sandbox"); } +check_output!(read_one, ["6R input/lines2", LINES1]); +check_output!( + read_one_twice, + ["-e", "5R input/lines2", "-e", "7R input/lines2", LINES1] +); +check_output!(read_one_many, ["R input/lines2", LINES1]); +check_output!(read_one_empty, ["R input/empty", LINES1]); +check_output!(read_one_missing, ["R input/xyzzy42", LINES1]); +check_output!(read_one_no_newline, ["R input/no-new-line.txt", LINES1]); + +#[test] +fn read_one_line_rejected_in_posix_mode() { + new_ucmd!() + .args(&["--posix", "R /tmp/read-one-line"]) + .fails() + .code_is(1) + .stderr_is("sed: