diff --git a/src/sed/command.rs b/src/sed/command.rs index 43dedbb7..93693446 100644 --- a/src/sed/command.rs +++ b/src/sed/command.rs @@ -10,6 +10,7 @@ use crate::sed::error_handling::{ScriptLocation, runtime_error}; use crate::sed::fast_regex::{Captures, Match, Regex}; +use crate::sed::named_reader::NamedReader; use crate::sed::named_writer::NamedWriter; use crate::sed::script_char_provider::ScriptCharProvider; use crate::sed::script_line_provider::ScriptLineProvider; @@ -368,6 +369,7 @@ pub enum CommandData { BranchTarget(Option>>), // Commands for 'b', 't', 'T', '{' Label(Option), // Label name for 'b', 't', 'T', ':' Path(PathBuf), // File path for 'r' + NamedReader(Rc>), // Successive file lines for 'R' NamedWriter(Rc>), // File output for 'w' Number(usize), // Number for 'l', 'q', 'Q' (GNU) Substitution(Box), // Substitute command 's' diff --git a/src/sed/compiler.rs b/src/sed/compiler.rs index 1ea7a8e1..2159785a 100644 --- a/src/sed/compiler.rs +++ b/src/sed/compiler.rs @@ -18,6 +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_reader::NamedReader; use crate::sed::named_writer::NamedWriter; use crate::sed::script_char_provider::ScriptCharProvider; use crate::sed::script_line_provider::{ScriptLineProvider, ScriptValue}; @@ -1122,6 +1123,21 @@ fn compile_read_file_command( Ok(CommandHandling::Continue) } +// Handles R +fn compile_read_line_command( + lines: &mut ScriptLineProvider, + line: &mut ScriptCharProvider, + cmd: &mut Command, + context: &mut ProcessingContext, +) -> UResult { + if context.sandbox { + return compilation_error(lines, line, ERR_SANDBOX); + } + let path = read_file_path(lines, line)?; + cmd.data = CommandData::NamedReader(NamedReader::new(path)); + Ok(CommandHandling::Continue) +} + // Handles w fn compile_write_file_command( lines: &mut ScriptLineProvider, @@ -1636,6 +1652,10 @@ fn get_cmd_spec( n_addr: 2, handler: compile_empty_command, }), + 'R' if !posix => Ok(CommandSpec { + n_addr: 2, + handler: compile_read_line_command, + }), 'r' => Ok(CommandSpec { n_addr: if posix { 1 } else { 2 }, handler: compile_read_file_command, @@ -3021,6 +3041,31 @@ mod tests { assert!(err.to_string().contains(ERR_SANDBOX)); } + // compile_read_line_command (R) + #[test] + fn test_compile_read_line_command_rejected_under_sandbox() { + let (mut lines, mut chars) = make_providers("R input.txt"); + let mut cmd = Command::default(); + let mut context = ctx(); + context.sandbox = true; + + let err = + compile_read_line_command(&mut lines, &mut chars, &mut cmd, &mut context).unwrap_err(); + assert!(err.to_string().contains(ERR_SANDBOX)); + } + + #[test] + fn test_compile_read_line_command_sets_named_reader() { + let (mut lines, mut chars) = make_providers("R input.txt"); + let mut cmd = Command::default(); + let mut context = ctx(); + + let handling = + compile_read_line_command(&mut lines, &mut chars, &mut cmd, &mut context).unwrap(); + assert!(matches!(handling, CommandHandling::Continue)); + assert!(matches!(cmd.data, CommandData::NamedReader(_))); + } + // compile_write_file_command #[test] fn test_compile_write_file_command_rejected_under_sandbox() { diff --git a/src/sed/mod.rs b/src/sed/mod.rs index 1ed79a56..cd9a05af 100644 --- a/src/sed/mod.rs +++ b/src/sed/mod.rs @@ -15,6 +15,7 @@ pub mod error_handling; pub mod fast_io; pub mod fast_regex; pub mod in_place; +pub mod named_reader; pub mod named_writer; pub mod processor; pub mod script_char_provider; diff --git a/src/sed/named_reader.rs b/src/sed/named_reader.rs new file mode 100644 index 00000000..58a32c15 --- /dev/null +++ b/src/sed/named_reader.rs @@ -0,0 +1,101 @@ +// An abstraction for input files read one line at a time by the `R` command +// +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 Diomidis Spinellis +// +// This file is part of the uutils sed package. +// It is licensed under the MIT License. +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::cell::RefCell; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::rc::Rc; + +#[derive(Debug)] +/// State of the file backing an `R` command, opened lazily on first use. +enum State { + Unopened, + Open(BufReader), + Exhausted, +} + +#[derive(Debug)] +/// Reader that yields successive lines of a file for the GNU `R` command. +/// The file is opened on first use; a file that cannot be opened or read is +/// treated as having no more lines, matching GNU sed (no error is raised). +pub struct NamedReader { + path: PathBuf, + state: State, +} + +impl NamedReader { + /// Create a reader for `path` without opening it yet. + pub fn new(path: PathBuf) -> Rc> { + Rc::new(RefCell::new(NamedReader { + path, + state: State::Unopened, + })) + } + + /// Return the next line of the file, including its trailing newline if + /// present, or `None` once the file is exhausted or could not be read. + pub fn next_line(&mut self) -> Option> { + if matches!(self.state, State::Unopened) { + self.state = match File::open(&self.path) { + Ok(file) => State::Open(BufReader::new(file)), + Err(_) => State::Exhausted, + }; + } + + let State::Open(reader) = &mut self.state else { + return None; + }; + + let mut line = Vec::new(); + match reader.read_until(b'\n', &mut line) { + Ok(0) | Err(_) => { + self.state = State::Exhausted; + None + } + Ok(_) => Some(line), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn yields_successive_lines_then_none() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"one\ntwo\n").unwrap(); + let reader = NamedReader::new(file.path().to_path_buf()); + + assert_eq!(reader.borrow_mut().next_line(), Some(b"one\n".to_vec())); + assert_eq!(reader.borrow_mut().next_line(), Some(b"two\n".to_vec())); + assert_eq!(reader.borrow_mut().next_line(), None); + assert_eq!(reader.borrow_mut().next_line(), None); + } + + #[test] + fn last_line_without_newline_is_preserved() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"abc").unwrap(); + let reader = NamedReader::new(file.path().to_path_buf()); + + assert_eq!(reader.borrow_mut().next_line(), Some(b"abc".to_vec())); + assert_eq!(reader.borrow_mut().next_line(), None); + } + + #[test] + fn missing_file_yields_no_lines() { + let reader = NamedReader::new(PathBuf::from("/nonexistent/xyzzy-42-does-not-exist")); + assert_eq!(reader.borrow_mut().next_line(), None); + } +} diff --git a/src/sed/processor.rs b/src/sed/processor.rs index b703d77c..ccf301ef 100644 --- a/src/sed/processor.rs +++ b/src/sed/processor.rs @@ -821,6 +821,15 @@ fn process_file( context.quiet = true; break; } + 'R' => { + // Queue the file's next line for output at end of cycle. + let reader = extract_variant!(command, NamedReader); + if let Some(line) = reader.borrow_mut().next_line() { + context + .append_elements + .push(AppendElement::Text(line.into())); + } + } 'r' => { // Copy the file to standard output at a later point. let path = extract_variant!(command, Path); diff --git a/tests/by-util/test_sed.rs b/tests/by-util/test_sed.rs index 4c5eebaa..30031faa 100644 --- a/tests/by-util/test_sed.rs +++ b/tests/by-util/test_sed.rs @@ -1862,6 +1862,81 @@ fn write_first_line_with_w_command_is_non_posix() { .stderr_is("sed: