#![allow(clippy::unwrap_used)]
use async_trait::async_trait;
use regex::Regex;
use super::search_common::{build_regex, build_regex_opts};
use super::{Builtin, Context, read_text_file};
use crate::error::{Error, Result};
use crate::interpreter::ExecResult;
fn bre_to_ere(pattern: &str) -> String {
let mut result = String::with_capacity(pattern.len());
let chars: Vec<char> = pattern.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '\\' && i + 1 < chars.len() {
match chars[i + 1] {
'(' | ')' | '{' | '}' | '+' | '?' | '|' => {
result.push(chars[i + 1]);
i += 2;
}
_ => {
result.push('\\');
result.push(chars[i + 1]);
i += 2;
}
}
} else if chars[i] == '(' || chars[i] == ')' || chars[i] == '{' || chars[i] == '}' {
result.push('\\');
result.push(chars[i]);
i += 1;
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
pub struct Sed;
#[derive(Debug)]
enum SedCommand {
Substitute {
pattern: Regex,
replacement: String,
global: bool,
nth: Option<usize>, print_only: bool,
},
Delete,
Print,
Quit,
QuitNoprint, Append(String),
Insert(String),
Change(String), HoldCopy, HoldAppend, GetCopy, GetAppend, Exchange, Group(Vec<(Option<Address>, bool, SedCommand)>), Label(String), Branch(Option<String>), BranchIfSub(Option<String>), }
#[derive(Debug, Clone)]
enum Address {
All,
Line(usize),
Range(usize, usize),
Regex(Regex),
Last,
RegexRange(Regex, Regex), LineRegexRange(usize, Regex), Step(usize, usize), ZeroRegex(Regex), }
impl Address {
fn matches_simple(&self, line_num: usize, total_lines: usize, line: &str) -> bool {
match self {
Address::All => true,
Address::Line(n) => line_num == *n,
Address::Range(start, end) => line_num >= *start && line_num <= *end,
Address::Regex(re) => re.is_match(line),
Address::Last => line_num == total_lines,
Address::Step(first, step) => {
if *step == 0 {
line_num == *first
} else if *first == 0 {
line_num.is_multiple_of(*step)
} else {
line_num >= *first && (line_num - *first).is_multiple_of(*step)
}
}
Address::RegexRange(_, _) | Address::LineRegexRange(_, _) | Address::ZeroRegex(_) => {
false
}
}
}
fn matches_with_state(
&self,
line_num: usize,
total_lines: usize,
line: &str,
in_range: &mut bool,
) -> bool {
match self {
Address::RegexRange(start_re, end_re) => {
if *in_range {
if end_re.is_match(line) {
*in_range = false;
}
true
} else if start_re.is_match(line) {
*in_range = true;
true
} else {
false
}
}
Address::LineRegexRange(start_line, end_re) => {
if *in_range {
if end_re.is_match(line) {
*in_range = false;
}
true
} else if line_num >= *start_line {
*in_range = true;
true
} else {
false
}
}
Address::ZeroRegex(end_re) => {
if *in_range {
if end_re.is_match(line) {
*in_range = false;
}
true
} else if line_num == 1 {
*in_range = true;
if end_re.is_match(line) {
*in_range = false;
}
true
} else {
false
}
}
_ => self.matches_simple(line_num, total_lines, line),
}
}
}
struct SedOptions {
commands: Vec<(Option<Address>, bool, SedCommand)>, files: Vec<String>,
in_place: bool,
quiet: bool,
extended_regex: bool,
}
impl SedOptions {
fn parse(args: &[String]) -> Result<Self> {
let mut opts = SedOptions {
commands: Vec::new(),
files: Vec::new(),
in_place: false,
quiet: false,
extended_regex: false,
};
for arg in args {
if arg == "-E" || arg == "-r" {
opts.extended_regex = true;
}
}
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "-n" {
opts.quiet = true;
} else if arg == "-i" {
opts.in_place = true;
} else if arg == "-E" || arg == "-r" {
} else if arg == "-e" {
i += 1;
if i < args.len() {
let (addr, negate, cmd) = parse_sed_command(&args[i], opts.extended_regex)?;
opts.commands.push((addr, negate, cmd));
}
} else if arg.starts_with('-') {
} else if opts.commands.is_empty() {
for cmd_str in split_sed_commands(arg) {
let trimmed = cmd_str.trim();
if !trimmed.is_empty() {
let (addr, negate, cmd) = parse_sed_command(trimmed, opts.extended_regex)?;
opts.commands.push((addr, negate, cmd));
}
}
} else {
opts.files.push(arg.clone());
}
i += 1;
}
if opts.commands.is_empty() {
return Err(Error::Execution("sed: no command given".to_string()));
}
Ok(opts)
}
}
fn split_sed_commands(s: &str) -> Vec<&str> {
let mut result = Vec::new();
let mut start = 0;
let mut in_subst = false;
let mut delim_count = 0;
let mut delim: Option<char> = None;
let mut escaped = false;
let mut brace_depth = 0;
let chars: Vec<char> = s.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if escaped {
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if !in_subst && c == 's' && i + 1 < chars.len() {
in_subst = true;
delim = Some(chars[i + 1]);
delim_count = 0;
} else if in_subst {
if Some(c) == delim {
delim_count += 1;
if delim_count >= 3 {
in_subst = false;
}
}
} else if c == '{' {
brace_depth += 1;
} else if c == '}' {
brace_depth -= 1;
} else if c == ';' && brace_depth == 0 {
result.push(&s[start..i]);
start = i + 1;
}
}
if start < s.len() {
result.push(&s[start..]);
}
result
}
fn parse_address(s: &str) -> Result<(Option<Address>, &str)> {
if s.is_empty() {
return Ok((None, s));
}
let first_char = s.chars().next().unwrap();
if first_char.is_ascii_digit() {
let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
let num: usize = s[..end]
.parse()
.map_err(|_| Error::Execution("sed: invalid address".to_string()))?;
let rest = &s[end..];
if let Some(after_tilde) = rest.strip_prefix('~') {
let end2 = after_tilde
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after_tilde.len());
if end2 > 0 {
let step: usize = after_tilde[..end2]
.parse()
.map_err(|_| Error::Execution("sed: invalid step address".to_string()))?;
return Ok((Some(Address::Step(num, step)), &after_tilde[end2..]));
}
}
if let Some(rest) = rest.strip_prefix(',') {
if let Some(after_dollar) = rest.strip_prefix('$') {
return Ok((Some(Address::Range(num, usize::MAX)), after_dollar));
}
if let Some(after_slash) = rest.strip_prefix('/') {
let end2 = after_slash.find('/').ok_or_else(|| {
Error::Execution("sed: unterminated address regex".to_string())
})?;
let pattern = &after_slash[..end2];
let regex = build_regex(pattern)
.map_err(|e| Error::Execution(format!("sed: invalid regex: {}", e)))?;
if num == 0 {
return Ok((Some(Address::ZeroRegex(regex)), &after_slash[end2 + 1..]));
}
return Ok((
Some(Address::LineRegexRange(num, regex)),
&after_slash[end2 + 1..],
));
}
let end2 = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
if end2 > 0 {
let num2: usize = rest[..end2]
.parse()
.map_err(|_| Error::Execution("sed: invalid address".to_string()))?;
return Ok((Some(Address::Range(num, num2)), &rest[end2..]));
}
return Ok((Some(Address::Line(num)), rest));
}
return Ok((Some(Address::Line(num)), rest));
}
if let Some(after_dollar) = s.strip_prefix('$') {
return Ok((Some(Address::Last), after_dollar));
}
if first_char == '/' {
let end = s[1..]
.find('/')
.ok_or_else(|| Error::Execution("sed: unterminated address regex".to_string()))?;
let pattern = &s[1..end + 1];
let regex = build_regex(pattern)
.map_err(|e| Error::Execution(format!("sed: invalid regex: {}", e)))?;
let rest = &s[end + 2..];
if let Some(after_comma) = rest.strip_prefix(',') {
if let Some(after_dollar) = after_comma.strip_prefix('$') {
return Ok((
Some(Address::RegexRange(
regex,
Regex::new("$^").unwrap(), )),
after_dollar,
));
}
if let Some(after_slash) = after_comma.strip_prefix('/') {
let end2 = after_slash.find('/').ok_or_else(|| {
Error::Execution("sed: unterminated address regex".to_string())
})?;
let pattern2 = &after_slash[..end2];
let regex2 = build_regex(pattern2)
.map_err(|e| Error::Execution(format!("sed: invalid regex: {}", e)))?;
return Ok((
Some(Address::RegexRange(regex, regex2)),
&after_slash[end2 + 1..],
));
}
let end2 = after_comma
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after_comma.len());
if end2 > 0
&& let Ok(line_num) = after_comma[..end2].parse::<usize>()
{
return Ok((
Some(Address::Range(0, line_num)), &after_comma[end2..],
));
}
}
return Ok((Some(Address::Regex(regex)), rest));
}
Ok((None, s))
}
fn parse_sed_command(s: &str, extended_regex: bool) -> Result<(Option<Address>, bool, SedCommand)> {
let (address, rest) = parse_address(s)?;
if rest.is_empty() {
return Err(Error::Execution("sed: missing command".to_string()));
}
let (negate, rest) = if let Some(r) = rest.strip_prefix('!') {
(true, r)
} else {
(false, rest)
};
if rest.is_empty() {
return Err(Error::Execution("sed: missing command".to_string()));
}
let first_char = rest.chars().next().unwrap();
match first_char {
's' => {
if rest.len() < 4 {
return Err(Error::Execution("sed: invalid substitution".to_string()));
}
let delim = rest.chars().nth(1).unwrap();
let rest = &rest[2..];
let mut parts = Vec::new();
let mut current = String::new();
let mut escaped = false;
for c in rest.chars() {
if escaped {
current.push(c);
escaped = false;
} else if c == '\\' {
escaped = true;
current.push(c);
} else if c == delim {
parts.push(current);
current = String::new();
} else {
current.push(c);
}
}
parts.push(current);
if parts.len() < 2 {
return Err(Error::Execution("sed: invalid substitution".to_string()));
}
let pattern = &parts[0];
let replacement = &parts[1];
let flags = parts.get(2).map(|s| s.as_str()).unwrap_or("");
let pattern = if extended_regex {
pattern.clone()
} else {
bre_to_ere(pattern)
};
let case_insensitive = flags.contains('i');
let regex = build_regex_opts(&pattern, case_insensitive)
.map_err(|e| Error::Execution(format!("sed: invalid pattern: {}", e)))?;
let replacement = replacement
.replace("\\&", "\x00") .replace('&', "${0}")
.replace("\x00", "&");
let replacement = Regex::new(r"\\(\d+)")
.unwrap()
.replace_all(&replacement, r"$${$1}")
.to_string();
let replacement = replacement
.replace("\\\\", "\x01") .replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\/", "/")
.replace("\x01", "\\");
let nth = flags
.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<usize>()
.ok()
.filter(|&n| n > 0);
Ok((
address,
negate,
SedCommand::Substitute {
pattern: regex,
replacement,
global: flags.contains('g'),
nth,
print_only: flags.contains('p'),
},
))
}
'd' => Ok((address.or(Some(Address::All)), negate, SedCommand::Delete)),
'p' => Ok((address.or(Some(Address::All)), negate, SedCommand::Print)),
'q' => Ok((address, negate, SedCommand::Quit)),
'a' => {
let text = if rest.len() > 1 && rest.chars().nth(1) == Some('\\') {
rest[2..].to_string()
} else {
rest[1..].to_string()
};
Ok((address, negate, SedCommand::Append(text)))
}
'i' => {
let text = if rest.len() > 1 && rest.chars().nth(1) == Some('\\') {
rest[2..].to_string()
} else {
rest[1..].to_string()
};
Ok((address, negate, SedCommand::Insert(text)))
}
'c' => {
let text = if rest.len() > 1 && rest.chars().nth(1) == Some('\\') {
rest[2..].to_string()
} else {
rest[1..].to_string()
};
Ok((address, negate, SedCommand::Change(text)))
}
'h' => Ok((address, negate, SedCommand::HoldCopy)),
'H' => Ok((address, negate, SedCommand::HoldAppend)),
'g' if rest.len() == 1 || !rest[1..].starts_with('/') => {
Ok((address, negate, SedCommand::GetCopy))
}
'G' => Ok((address, negate, SedCommand::GetAppend)),
'x' => Ok((address, negate, SedCommand::Exchange)),
'Q' => Ok((address, negate, SedCommand::QuitNoprint)),
':' => {
let label = rest[1..].trim().to_string();
Ok((None, false, SedCommand::Label(label)))
}
'b' => {
let label = rest[1..].trim();
let label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
Ok((address, negate, SedCommand::Branch(label)))
}
't' => {
let label = rest[1..].trim();
let label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
Ok((address, negate, SedCommand::BranchIfSub(label)))
}
'{' => {
let inner = rest[1..].trim();
let inner = inner.strip_suffix('}').unwrap_or(inner);
let mut group_cmds = Vec::new();
for cmd_str in split_sed_commands(inner) {
let trimmed = cmd_str.trim();
if !trimmed.is_empty() {
let (a, n, c) = parse_sed_command(trimmed, extended_regex)?;
group_cmds.push((a, n, c));
}
}
Ok((address, negate, SedCommand::Group(group_cmds)))
}
_ => Err(Error::Execution(format!(
"sed: unknown command: {}",
first_char
))),
}
}
fn replace_nth<'a>(
pattern: &Regex,
text: &'a str,
replacement: &str,
n: usize,
) -> std::borrow::Cow<'a, str> {
let mut count = 0;
for mat in pattern.find_iter(text) {
count += 1;
if count == n {
let mut result = String::new();
result.push_str(&text[..mat.start()]);
let replaced = pattern.replace(mat.as_str(), replacement);
result.push_str(&replaced);
result.push_str(&text[mat.end()..]);
return std::borrow::Cow::Owned(result);
}
}
std::borrow::Cow::Borrowed(text)
}
struct LineState {
current_line: String,
should_print: bool,
deleted: bool,
extra_output: Vec<String>, insert_text: Option<String>,
append_text: Option<String>,
quit: bool,
quit_noprint: bool,
hold_space: String,
sub_happened: bool, }
fn exec_sed_cmd(cmd: &SedCommand, state: &mut LineState, _line_num: usize, _total_lines: usize) {
match cmd {
SedCommand::Substitute {
pattern,
replacement,
global,
nth,
print_only,
} => {
let new_line = if *global {
pattern.replace_all(&state.current_line, replacement.as_str())
} else if let Some(n) = nth {
replace_nth(pattern, &state.current_line, replacement, *n)
} else {
pattern.replace(&state.current_line, replacement.as_str())
};
if new_line != state.current_line {
state.current_line = new_line.into_owned();
state.sub_happened = true;
if *print_only {
state.extra_output.push(state.current_line.clone());
}
}
}
SedCommand::Delete => {
state.deleted = true;
state.should_print = false;
}
SedCommand::Print => {
state.extra_output.push(state.current_line.clone());
}
SedCommand::Quit => {
state.quit = true;
}
SedCommand::QuitNoprint => {
state.quit_noprint = true;
}
SedCommand::Append(text) => {
state.append_text = Some(text.clone());
}
SedCommand::Insert(text) => {
state.insert_text = Some(text.clone());
}
SedCommand::Change(text) => {
state.current_line = text.clone();
state.deleted = false;
state.should_print = true;
}
SedCommand::HoldCopy => {
state.hold_space = state.current_line.clone();
}
SedCommand::HoldAppend => {
state.hold_space.push('\n');
state.hold_space.push_str(&state.current_line);
}
SedCommand::GetCopy => {
state.current_line = state.hold_space.clone();
}
SedCommand::GetAppend => {
state.current_line.push('\n');
state.current_line.push_str(&state.hold_space);
}
SedCommand::Exchange => {
std::mem::swap(&mut state.current_line, &mut state.hold_space);
}
SedCommand::Group(cmds) => {
for (_addr, _negate, sub_cmd) in cmds {
exec_sed_cmd(sub_cmd, state, _line_num, _total_lines);
if state.deleted || state.quit || state.quit_noprint {
break;
}
}
}
SedCommand::Label(_) | SedCommand::Branch(_) | SedCommand::BranchIfSub(_) => {}
}
}
fn count_commands(cmds: &[(Option<Address>, bool, SedCommand)]) -> usize {
let mut count = 0;
for (_, _, cmd) in cmds {
count += 1;
if let SedCommand::Group(sub) = cmd {
count += count_commands(sub);
}
}
count
}
#[async_trait]
impl Builtin for Sed {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let opts = SedOptions::parse(ctx.args)?;
let inputs: Vec<(Option<String>, String)> = if opts.files.is_empty() {
vec![(None, ctx.stdin.unwrap_or("").to_string())]
} else {
let mut inputs = Vec::new();
for file in &opts.files {
let path = if file.starts_with('/') {
std::path::PathBuf::from(file)
} else {
ctx.cwd.join(file)
};
let text = match read_text_file(&*ctx.fs, &path, "sed").await {
Ok(t) => t,
Err(e) => return Ok(e),
};
inputs.push((Some(file.clone()), text));
}
inputs
};
let mut output = String::new();
let mut warnings = String::new();
let mut modified_files: Vec<(String, String)> = Vec::new();
for (filename, content) in inputs {
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
let mut file_output = String::new();
let mut hold_space = String::new();
let mut global_quit = false;
let mut range_state: Vec<bool> = vec![false; count_commands(&opts.commands)];
for (idx, line) in lines.iter().enumerate() {
if global_quit {
break;
}
let line_num = idx + 1;
let mut state = LineState {
current_line: line.to_string(),
should_print: !opts.quiet,
deleted: false,
extra_output: Vec::new(),
insert_text: None,
append_text: None,
quit: false,
quit_noprint: false,
hold_space: hold_space.clone(),
sub_happened: false,
};
let mut cmd_idx = 0;
let max_iterations = 1000; let mut iterations = 0;
while cmd_idx < opts.commands.len() && iterations < max_iterations {
iterations += 1;
let (addr, negate, cmd) = &opts.commands[cmd_idx];
let addr_matches = addr
.as_ref()
.map(|a| {
a.matches_with_state(
line_num,
total_lines,
&state.current_line,
&mut range_state[cmd_idx],
)
})
.unwrap_or(true);
let should_apply = if *negate { !addr_matches } else { addr_matches };
if !should_apply {
cmd_idx += 1;
continue;
}
match cmd {
SedCommand::Label(_) => {
cmd_idx += 1;
}
SedCommand::Branch(label) => {
if let Some(label) = label {
if let Some(pos) = find_label(&opts.commands, label) {
cmd_idx = pos;
} else {
cmd_idx += 1;
}
} else {
break;
}
}
SedCommand::BranchIfSub(label) => {
if state.sub_happened {
state.sub_happened = false;
if let Some(label) = label {
if let Some(pos) = find_label(&opts.commands, label) {
cmd_idx = pos;
} else {
cmd_idx += 1;
}
} else {
break;
}
} else {
cmd_idx += 1;
}
}
_ => {
exec_sed_cmd(cmd, &mut state, line_num, total_lines);
cmd_idx += 1;
}
}
if state.deleted || state.quit || state.quit_noprint {
break;
}
}
if iterations >= max_iterations {
warnings.push_str(&format!(
"sed: warning: branch/label loop limit ({max_iterations}) reached on line {line_num}; output may be truncated\n"
));
}
hold_space = state.hold_space;
if let Some(text) = state.insert_text {
file_output.push_str(&text);
file_output.push('\n');
}
if state.quit_noprint {
global_quit = true;
} else {
for extra in &state.extra_output {
file_output.push_str(extra);
file_output.push('\n');
}
if !state.deleted && state.should_print {
file_output.push_str(&state.current_line);
file_output.push('\n');
}
if let Some(text) = state.append_text {
file_output.push_str(&text);
file_output.push('\n');
}
if state.quit {
global_quit = true;
}
}
}
if opts.in_place {
if let Some(fname) = filename {
modified_files.push((fname, file_output));
}
} else {
output.push_str(&file_output);
}
}
for (filename, content) in modified_files {
let path = if filename.starts_with('/') {
std::path::PathBuf::from(&filename)
} else {
ctx.cwd.join(&filename)
};
if let Err(e) = ctx.fs.write_file(&path, content.as_bytes()).await {
return Ok(ExecResult::err(format!("sed: {}: {}", filename, e), 1));
}
}
if warnings.is_empty() {
Ok(ExecResult::ok(output))
} else {
Ok(ExecResult {
stdout: output,
stderr: warnings,
exit_code: 0,
..Default::default()
})
}
}
}
fn find_label(cmds: &[(Option<Address>, bool, SedCommand)], target: &str) -> Option<usize> {
for (i, (_, _, cmd)) in cmds.iter().enumerate() {
if let SedCommand::Label(name) = cmd
&& name == target
{
return Some(i);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::InMemoryFs;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
async fn run_sed(args: &[&str], stdin: Option<&str>) -> Result<ExecResult> {
let sed = Sed;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
sed.execute(ctx).await
}
#[tokio::test]
async fn test_sed_substitute() {
let result = run_sed(&["s/hello/goodbye/"], Some("hello world\nhello again"))
.await
.unwrap();
assert_eq!(result.stdout, "goodbye world\ngoodbye again\n");
}
#[tokio::test]
async fn test_sed_substitute_global() {
let result = run_sed(&["s/o/0/g"], Some("hello world")).await.unwrap();
assert_eq!(result.stdout, "hell0 w0rld\n");
}
#[tokio::test]
async fn test_sed_substitute_first_only() {
let result = run_sed(&["s/o/0/"], Some("hello world")).await.unwrap();
assert_eq!(result.stdout, "hell0 world\n");
}
#[tokio::test]
async fn test_sed_delete_line() {
let result = run_sed(&["2d"], Some("line1\nline2\nline3")).await.unwrap();
assert_eq!(result.stdout, "line1\nline3\n");
}
#[tokio::test]
async fn test_sed_print_line() {
let result = run_sed(&["-n", "2p"], Some("line1\nline2\nline3"))
.await
.unwrap();
assert_eq!(result.stdout, "line2\n");
}
#[tokio::test]
async fn test_sed_regex_groups() {
let result = run_sed(&["s/\\(hello\\) \\(world\\)/\\2 \\1/"], Some("hello world"))
.await
.unwrap();
assert_eq!(result.stdout, "world hello\n");
}
#[tokio::test]
async fn test_sed_backref_single() {
let result = run_sed(&["s/\\(hel\\)lo/\\1p/"], Some("hello"))
.await
.unwrap();
assert_eq!(result.stdout, "help\n");
}
#[tokio::test]
async fn test_sed_ampersand() {
let result = run_sed(&["s/world/[&]/"], Some("hello world"))
.await
.unwrap();
assert_eq!(result.stdout, "hello [world]\n");
}
#[tokio::test]
async fn test_sed_address_range() {
let result = run_sed(&["2,3d"], Some("line1\nline2\nline3\nline4"))
.await
.unwrap();
assert_eq!(result.stdout, "line1\nline4\n");
}
#[tokio::test]
async fn test_sed_last_line() {
let result = run_sed(&["$d"], Some("line1\nline2\nline3")).await.unwrap();
assert_eq!(result.stdout, "line1\nline2\n");
}
#[tokio::test]
async fn test_sed_case_insensitive() {
let result = run_sed(&["s/hello/hi/i"], Some("Hello World"))
.await
.unwrap();
assert_eq!(result.stdout, "hi World\n");
}
#[tokio::test]
async fn test_sed_multiple_commands() {
let result = run_sed(&["s/hello/hi/; s/world/there/"], Some("hello world"))
.await
.unwrap();
assert_eq!(result.stdout, "hi there\n");
}
#[tokio::test]
async fn test_sed_append() {
let result = run_sed(&["/one/a\\inserted"], Some("one\ntwo"))
.await
.unwrap();
assert_eq!(result.stdout, "one\ninserted\ntwo\n");
}
#[tokio::test]
async fn test_sed_insert() {
let result = run_sed(&["/two/i\\inserted"], Some("one\ntwo"))
.await
.unwrap();
assert_eq!(result.stdout, "one\ninserted\ntwo\n");
}
#[tokio::test]
async fn test_sed_hold_copy() {
let result = run_sed(&["-e", "1h", "-e", "2g"], Some("first\nsecond\nthird"))
.await
.unwrap();
assert_eq!(result.stdout, "first\nfirst\nthird\n");
}
#[tokio::test]
async fn test_sed_hold_append() {
let result = run_sed(&["-e", "1h", "-e", "2H", "-e", "3G"], Some("a\nb\nc"))
.await
.unwrap();
assert_eq!(result.stdout, "a\nb\nc\na\nb\n");
}
#[tokio::test]
async fn test_sed_exchange() {
let result = run_sed(&["-e", "1h", "-e", "2x"], Some("first\nsecond\nthird"))
.await
.unwrap();
assert_eq!(result.stdout, "first\nfirst\nthird\n");
}
#[tokio::test]
async fn test_sed_change_command() {
let result = run_sed(&["2c\\replaced"], Some("line1\nline2\nline3"))
.await
.unwrap();
assert_eq!(result.stdout, "line1\nreplaced\nline3\n");
}
#[tokio::test]
async fn test_sed_regex_range() {
let result = run_sed(
&["/start/,/end/d"],
Some("before\nstart\nmiddle\nend\nafter"),
)
.await
.unwrap();
assert_eq!(result.stdout, "before\nafter\n");
}
#[tokio::test]
async fn test_sed_regex_range_substitute() {
let result = run_sed(
&["/begin/,/end/s/x/y/g"],
Some("ax\nbeginx\nmiddlex\nendx\nax"),
)
.await
.unwrap();
assert_eq!(result.stdout, "ax\nbeginy\nmiddley\nendy\nax\n");
}
#[tokio::test]
async fn test_sed_branch_loop_limit_emits_warning() {
let result = run_sed(&[":loop; s/a/aa/; /a\\{2000\\}/!b loop"], Some("a"))
.await
.unwrap();
assert!(
result.stderr.contains("loop limit"),
"expected warning on stderr, got: {}",
result.stderr
);
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_sed_normal_branch_no_warning() {
let result = run_sed(&["s/hello/world/"], Some("hello")).await.unwrap();
assert!(result.stderr.is_empty());
assert_eq!(result.stdout, "world\n");
}
}