use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use regex::{Regex, RegexBuilder};
use std::path::Path;
use crate::ast::Value;
use crate::backend::PatchOp;
use crate::operation::KernelOperation;
use crate::tools::builtin::get_path_string;
use crate::tools::builtin::regex_dialect::{append_dialect_hint, bre_metas_to_ere};
use crate::interpreter::{ExecResult, OutputData};
use crate::tools::{schema_from_clap, validate_against_schema, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};
use crate::validator::{IssueCode, ValidationIssue};
pub struct Sed;
#[derive(Parser, Debug)]
#[command(name = "sed", about = "Stream editor for filtering and transforming text")]
struct SedArgs {
#[arg(short = 'n', long = "quiet")]
quiet: bool,
#[arg(short = 'e', long = "expression")]
expression: Vec<String>,
#[arg(short = 'E', short_alias = 'r', long = "regexp-extended")]
extended: bool,
#[arg(short = 'i', long = "in-place")]
in_place: bool,
#[command(flatten)]
global: GlobalFlags,
args: Vec<String>,
}
#[async_trait]
impl Tool for Sed {
fn name(&self) -> &str {
"sed"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&SedArgs::command(),
"sed",
"Stream editor for filtering and transforming text",
[
("Substitute the first match on each line", "sed 's/old/new/' file.txt"),
("Substitute every match", "sed 's/old/new/g' file.txt"),
("Replace the 2nd match only", "sed 's/x/Y/2' file.txt"),
("Match case-insensitively", "sed 's/hello/hi/gi' file.txt"),
("Delete lines matching pattern", "sed '/error/d' log.txt"),
("Print only matching lines", "sed -n '/pattern/p' file.txt"),
("Chain commands with ;", "sed 's/a/b/; s/c/d/' file.txt"),
("Chain expressions with -e", "sed -e 's/a/b/' -e 's/c/d/' file.txt"),
("Append a line after matches", "sed '/ERROR/a ---' log.txt"),
("Insert a line at the top", "sed '1i #!/bin/sh' script.sh"),
("Change matching lines", "sed '/old/c replaced' file.txt"),
("Transliterate characters", "sed 'y/abc/xyz/' file.txt"),
("Delete a line range", "sed '2,5d' file.txt"),
("Use a different delimiter", "sed 's|/usr|/opt|g' file.txt"),
("Swap two capture groups", "sed 's/(\\w+) (\\w+)/\\2 \\1/' file.txt"),
("Match either alternative", r"sed 's/cat\|dog/pet/g' file.txt"),
],
)
.with_operations([KernelOperation::FsOverwrite.as_str()])
}
fn validate(&self, args: &ToolArgs) -> Vec<ValidationIssue> {
let mut issues = validate_against_schema(args, &self.schema());
let Ok(exprs) = collect_expressions(args) else {
return issues;
};
for expr in &exprs {
if expr.contains("<dynamic>") {
return issues;
}
}
let extended =
args.has_flag("E") || args.has_flag("r") || args.has_flag("regexp-extended");
for expr in &exprs {
if let Err(msg) = parse_program_dialect(expr, extended) {
issues.push(
ValidationIssue::error(
IssueCode::InvalidSedExpr,
format!("sed: {msg}"),
)
.with_suggestion(
"commands: s/pat/rep/[gipN], y/abc/xyz/, d, p, q, a/i/c TEXT; \
chain with ; or -e; addresses: N, $, /re/, N,M; regex is ERE \
(egrep-style; GNU BRE \\| \\(…\\) \\{N,M\\} also accepted)",
),
);
}
}
issues
}
async fn execute(&self, mut args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
args.flagify_bool_named(&self.schema());
let argv = match args.to_argv() {
Ok(v) => v,
Err(e) => return ExecResult::failure(2, format!("sed: {e}")),
};
let parsed = match SedArgs::try_parse_from(
std::iter::once("sed".to_string()).chain(argv),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("sed: {e}")),
};
parsed.global.apply(ctx);
let quiet = parsed.quiet || args.has_flag("quiet") || args.has_flag("n");
let in_place = parsed.in_place || args.has_flag("i");
let extended = parsed.extended;
let expressions = match collect_expressions(&args) {
Ok(e) => e,
Err(msg) => return ExecResult::failure(2, format!("sed: {msg}")),
};
if expressions.is_empty() {
return ExecResult::failure(1, "sed: missing expression");
}
let mut parsed: Vec<SedExpression> = Vec::new();
for expr in &expressions {
match parse_program_dialect(expr, extended) {
Ok(cmds) => parsed.extend(cmds),
Err(e) => return ExecResult::failure(1, format!("sed: {}", e)),
}
}
let file_pos = if expression_from_flag(&args) { 0 } else { 1 };
if in_place {
let operands = args.positional.get(file_pos..).unwrap_or(&[]);
let files: Vec<String> = match crate::interpreter::values_to_text_sink_named(operands, "a path") {
Ok(f) => f,
Err(e) => return ExecResult::failure(1, format!("sed: {e}")),
};
if files.is_empty() {
return ExecResult::failure(
1,
"sed: -i requires file operands (cannot edit a stream in place)",
);
}
let mut hint_prefix = String::from("sed -i");
if quiet {
hint_prefix.push_str(" -n");
}
for expr in &expressions {
let escaped = expr.replace('\'', r"'\''");
hint_prefix.push_str(&format!(" -e '{escaped}'"));
}
let targets: Vec<(String, bool)> = files.iter().map(|f| (f.clone(), false)).collect();
if let Err(blocked) = ctx
.snapshot_overwrites("sed",
&targets)
.await
{
return blocked;
}
let mut errors: Vec<String> = Vec::new();
for path in &files {
let resolved = ctx.resolve_path(path);
let target = Path::new(&resolved);
let content = match ctx.backend.read(target, None).await {
Ok(data) => match String::from_utf8(data) {
Ok(s) => s,
Err(_) => {
errors.push(format!("sed: {}: invalid UTF-8", path));
continue;
}
},
Err(e) => {
errors.push(format!("sed: {}: {}", path, e));
continue;
}
};
let output = execute_sed(&content, &parsed, quiet);
let ops = vec![PatchOp::Replace {
offset: 0,
len: content.len(),
content: output,
expected: Some(content.clone()),
}];
if let Err(e) = ctx.backend.patch(target, &ops).await {
errors.push(format!("sed: {}: {}", path, e));
}
}
return if errors.is_empty() {
ExecResult::success("")
} else {
ExecResult::failure(1, errors.join("\n"))
};
}
let input = match get_path_string(&args, "path", file_pos) {
Ok(Some(path)) => {
let resolved = ctx.resolve_path(&path);
match ctx.backend.read(Path::new(&resolved), None).await {
Ok(data) => match String::from_utf8(data) {
Ok(s) => s,
Err(_) => {
return ExecResult::failure(1, format!("sed: {}: invalid UTF-8", path))
}
},
Err(e) => return ExecResult::failure(1, format!("sed: {}: {}", path, e)),
}
}
Ok(None) => match ctx.read_stdin_to_text().await {
Ok(s) => s.unwrap_or_default(),
Err(e) => return ExecResult::failure(2, format!("sed: {e}")),
},
Err(e) => return ExecResult::failure(1, format!("sed: {e}")),
};
let output = execute_sed(&input, &parsed, quiet);
ExecResult::with_output(OutputData::text(output))
}
}
fn expression_from_flag(args: &ToolArgs) -> bool {
args.named.contains_key("expression") || args.named.contains_key("e")
}
fn collect_expressions(args: &ToolArgs) -> Result<Vec<String>, String> {
let mut exprs = Vec::new();
for key in ["expression", "e"] {
match args.named.get(key) {
Some(Value::Json(serde_json::Value::Array(items))) => {
for item in items {
match item {
serde_json::Value::String(s) => exprs.push(s.clone()),
other => return Err(format!(
"-e expression must be a string, got `{other}`"
)),
}
}
}
Some(Value::String(e)) => exprs.push(e.clone()),
Some(other) => {
return Err(format!("-e expression must be a string, got `{other:?}`"));
}
None => {}
}
}
if exprs.is_empty() && let Some(Value::String(e)) = args.positional.first() {
exprs.push(e.clone());
}
Ok(exprs)
}
#[derive(Debug, Clone)]
enum Address {
All,
Line(usize),
LastLine,
Range(Box<Address>, Box<Address>),
Pattern(Regex),
}
#[derive(Debug, Clone)]
enum Command {
Substitute {
pattern: Regex,
replacement: String,
global: bool,
occurrence: usize,
print: bool,
},
Delete,
Print,
Quit,
Append(String),
Insert(String),
Change(String),
Transliterate { from: Vec<char>, to: Vec<char> },
}
#[derive(Debug, Clone)]
struct SedExpression {
address: Address,
command: Command,
}
#[cfg(test)]
fn parse_program(expr: &str) -> Result<Vec<SedExpression>, String> {
parse_program_dialect(expr, false)
}
fn parse_program_dialect(expr: &str, extended: bool) -> Result<Vec<SedExpression>, String> {
let mut out = Vec::new();
let mut rest = expr.to_string();
loop {
let next = {
let trimmed = rest.trim_start_matches([';', '\n', ' ', '\t']);
if trimmed.is_empty() {
break;
}
let (expr, remaining) = parse_one(trimmed, extended)?;
out.push(expr);
remaining
};
rest = next;
}
if out.is_empty() {
return Err("empty expression".to_string());
}
Ok(out)
}
fn parse_one(expr: &str, extended: bool) -> Result<(SedExpression, String), String> {
let (address, rest) = parse_address(expr, extended)?;
let (command, rest) = parse_command(rest.trim_start(), extended)?;
Ok((SedExpression { address, command }, rest))
}
fn parse_address(expr: &str, extended: bool) -> Result<(Address, &str), String> {
let expr = expr.trim();
if expr.is_empty() {
return Ok((Address::All, ""));
}
if expr.starts_with('/') {
let (pattern, rest) = parse_pattern_address(expr, extended)?;
if let Some(after_comma) = rest.strip_prefix(',') {
let (end_addr, final_rest) = parse_address(after_comma, extended)?;
return Ok((
Address::Range(Box::new(Address::Pattern(pattern)), Box::new(end_addr)),
final_rest,
));
}
return Ok((Address::Pattern(pattern), rest));
}
if let Some(rest) = expr.strip_prefix('$') {
if let Some(after_comma) = rest.strip_prefix(',') {
let (end_addr, final_rest) = parse_address(after_comma, extended)?;
return Ok((
Address::Range(Box::new(Address::LastLine), Box::new(end_addr)),
final_rest,
));
}
return Ok((Address::LastLine, rest));
}
if expr.chars().next().is_some_and(|c| c.is_ascii_digit()) {
let num_end = expr
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(expr.len());
let num: usize = expr[..num_end]
.parse()
.map_err(|_| "invalid line number")?;
let rest = &expr[num_end..];
if let Some(after_comma) = rest.strip_prefix(',') {
let (end_addr, final_rest) = parse_address(after_comma, extended)?;
return Ok((
Address::Range(Box::new(Address::Line(num)), Box::new(end_addr)),
final_rest,
));
}
return Ok((Address::Line(num), rest));
}
Ok((Address::All, expr))
}
fn parse_pattern_address(expr: &str, extended: bool) -> Result<(Regex, &str), String> {
debug_assert!(expr.starts_with('/'));
let chars: Vec<char> = expr.chars().collect();
let mut i = 1; let mut pattern = String::new();
while i < chars.len() {
let c = chars[i];
if c == '\\' && i + 1 < chars.len() {
let next = chars[i + 1];
if next == '/' {
pattern.push('/');
} else {
pattern.push('\\');
pattern.push(next);
}
i += 2;
} else if c == '/' {
i += 1;
break;
} else {
pattern.push(c);
i += 1;
}
}
let rewritten = if extended { pattern.clone() } else { bre_metas_to_ere(&pattern) };
let rewrote = rewritten != pattern;
let regex = compile_pattern(&rewritten, false, false)
.map_err(|e| append_dialect_hint(e, rewrote, Some("-E/-r")))?;
let consumed: usize = chars[..i].iter().map(|c| c.len_utf8()).sum();
Ok((regex, &expr[consumed..]))
}
fn parse_command(cmd: &str, extended: bool) -> Result<(Command, String), String> {
let Some(first) = cmd.chars().next() else {
return Err("missing command".to_string());
};
let after_first = &cmd[first.len_utf8()..];
match first {
's' => parse_substitute(after_first, extended),
'y' => parse_transliterate(after_first),
'd' => Ok((Command::Delete, after_first.to_string())),
'p' => Ok((Command::Print, after_first.to_string())),
'q' => Ok((Command::Quit, after_first.to_string())),
'a' => Ok((Command::Append(parse_text_arg(after_first)), String::new())),
'i' => Ok((Command::Insert(parse_text_arg(after_first)), String::new())),
'c' => Ok((Command::Change(parse_text_arg(after_first)), String::new())),
_ => Err(format!("unknown command: {}", first)),
}
}
fn parse_text_arg(s: &str) -> String {
let s = s.strip_prefix('\\').unwrap_or(s);
s.strip_prefix(' ').unwrap_or(s).to_string()
}
fn parse_substitute(expr: &str, extended: bool) -> Result<(Command, String), String> {
let chars: Vec<char> = expr.chars().collect();
if chars.is_empty() {
return Err("s command requires delimiter".to_string());
}
let delimiter = chars[0];
let (pattern_str, after_pattern) = parse_delimited(&chars[1..], delimiter)?;
let (replacement, after_replacement) = parse_delimited(after_pattern, delimiter)?;
let mut global = false;
let mut case_insensitive = false;
let mut multiline = false;
let mut print = false;
let mut digits = String::new();
let mut idx = 0;
while idx < after_replacement.len() {
match after_replacement[idx] {
'g' => global = true,
'i' | 'I' => case_insensitive = true,
'm' | 'M' => multiline = true,
'p' => print = true,
c if c.is_ascii_digit() => digits.push(c),
';' | '\n' | ' ' | '\t' => break,
other => return Err(format!("unknown s flag: {}", other)),
}
idx += 1;
}
let occurrence = if digits.is_empty() {
0
} else {
let n = digits
.parse()
.map_err(|_| "invalid s/// occurrence number")?;
if n == 0 {
return Err("number option to `s' command may not be zero".to_string());
}
n
};
let (pattern_str, rewrote) = if extended {
(pattern_str, false)
} else {
let rewritten = bre_metas_to_ere(&pattern_str);
let rewrote = rewritten != pattern_str;
(rewritten, rewrote)
};
let regex = compile_pattern(&pattern_str, case_insensitive, multiline)
.map_err(|e| append_dialect_hint(e, rewrote, Some("-E/-r")))?;
let rest: String = after_replacement[idx..].iter().collect();
Ok((
Command::Substitute {
pattern: regex,
replacement,
global,
occurrence,
print,
},
rest,
))
}
fn parse_transliterate(expr: &str) -> Result<(Command, String), String> {
let chars: Vec<char> = expr.chars().collect();
if chars.is_empty() {
return Err("y command requires delimiter".to_string());
}
let delimiter = chars[0];
let (from, after_from) = parse_delimited(&chars[1..], delimiter)?;
let (to, after_to) = parse_delimited(after_from, delimiter)?;
let from: Vec<char> = from.chars().collect();
let to: Vec<char> = to.chars().collect();
if from.len() != to.len() {
return Err("y command: 'from' and 'to' must have the same length".to_string());
}
let rest: String = after_to.iter().collect();
Ok((Command::Transliterate { from, to }, rest))
}
fn compile_pattern(pattern: &str, case_insensitive: bool, multiline: bool) -> Result<Regex, String> {
RegexBuilder::new(pattern)
.case_insensitive(case_insensitive)
.multi_line(multiline)
.build()
.map_err(|e| {
if e.to_string().contains("backreferences are not supported") {
"pattern uses a backreference (\\1-\\9); kaish sed regex is ERE on a \
linear-time engine that can't backreference in the pattern — match \
the text directly, or split the work across commands"
.to_string()
} else {
format!("invalid pattern: {e}")
}
})
}
fn parse_delimited(chars: &[char], delimiter: char) -> Result<(String, &[char]), String> {
let mut result = String::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\\' && i + 1 < chars.len() {
let next = chars[i + 1];
if next == delimiter {
result.push(delimiter);
i += 2;
} else {
result.push('\\');
result.push(next);
i += 2;
}
} else if c == delimiter {
return Ok((result, &chars[i + 1..]));
} else {
result.push(c);
i += 1;
}
}
Err("unterminated expression".to_string())
}
fn execute_sed(input: &str, expressions: &[SedExpression], quiet: bool) -> String {
let lines: Vec<&str> = input.lines().collect();
let total_lines = lines.len();
let mut output = String::new();
let mut range_active: Vec<bool> = vec![false; expressions.len()];
for (line_num, line) in lines.iter().enumerate() {
let one_indexed = line_num + 1;
let is_last = line_num + 1 == total_lines;
let mut pattern_space = line.to_string();
let mut deleted = false;
let mut printed_by_p = false;
let mut quit = false;
let mut appends: Vec<&str> = Vec::new();
for (expr_idx, expr) in expressions.iter().enumerate() {
let addr = address_matches(
&expr.address,
one_indexed,
is_last,
&pattern_space,
&mut range_active[expr_idx],
);
if !addr.matched {
continue;
}
match &expr.command {
Command::Substitute {
pattern,
replacement,
global,
occurrence,
print,
} => {
let new_text =
substitute(pattern, &pattern_space, replacement, *global, *occurrence);
let changed = new_text != pattern_space;
pattern_space = new_text;
if *print && changed {
output.push_str(&pattern_space);
output.push('\n');
printed_by_p = true;
}
}
Command::Delete => {
deleted = true;
break;
}
Command::Print => {
output.push_str(&pattern_space);
output.push('\n');
printed_by_p = true;
}
Command::Quit => {
quit = true;
break;
}
Command::Append(text) => {
appends.push(text);
}
Command::Insert(text) => {
output.push_str(text);
output.push('\n');
}
Command::Change(text) => {
deleted = true;
if addr.range_end {
output.push_str(text);
output.push('\n');
}
}
Command::Transliterate { from, to } => {
pattern_space = transliterate(&pattern_space, from, to);
}
}
}
if !deleted && !quiet && !printed_by_p {
output.push_str(&pattern_space);
output.push('\n');
}
for text in appends {
output.push_str(text);
output.push('\n');
}
if quit {
break;
}
}
output
}
fn transliterate(text: &str, from: &[char], to: &[char]) -> String {
text.chars()
.map(|c| {
from.iter()
.position(|&f| f == c)
.and_then(|i| to.get(i).copied())
.unwrap_or(c)
})
.collect()
}
struct AddressMatch {
matched: bool,
range_end: bool,
}
fn address_matches(
addr: &Address,
line_num: usize,
is_last: bool,
pattern_space: &str,
range_active: &mut bool,
) -> AddressMatch {
let single = |m: bool| AddressMatch {
matched: m,
range_end: m,
};
match addr {
Address::All => single(true),
Address::Line(n) => single(line_num == *n),
Address::LastLine => single(is_last),
Address::Pattern(regex) => single(regex.is_match(pattern_space)),
Address::Range(start, end) => {
if *range_active {
let end_matches = match end.as_ref() {
Address::Line(n) => line_num >= *n,
Address::LastLine => is_last,
Address::Pattern(regex) => regex.is_match(pattern_space),
_ => false,
};
if end_matches {
*range_active = false;
AddressMatch {
matched: true,
range_end: true,
}
} else {
AddressMatch {
matched: true,
range_end: is_last,
}
}
} else {
let start_matches = match start.as_ref() {
Address::Line(n) => line_num == *n,
Address::LastLine => is_last,
Address::Pattern(regex) => regex.is_match(pattern_space),
_ => false,
};
if !start_matches {
return AddressMatch {
matched: false,
range_end: false,
};
}
let close_same_line = matches!(end.as_ref(), Address::Line(n) if *n <= line_num);
if close_same_line {
AddressMatch {
matched: true,
range_end: true,
}
} else {
*range_active = true;
AddressMatch {
matched: true,
range_end: is_last,
}
}
}
}
}
}
fn substitute(
pattern: &Regex,
text: &str,
replacement: &str,
global: bool,
occurrence: usize,
) -> String {
let skip = occurrence.saturating_sub(1);
let mut result = String::new();
let mut last_end = 0;
let mut seen = 0;
let mut replaced = false;
for captures in pattern.captures_iter(text) {
let Some(mat) = captures.get(0) else {
continue;
};
let do_replace = seen >= skip && (global || !replaced);
seen += 1;
if do_replace {
result.push_str(&text[last_end..mat.start()]);
result.push_str(&expand_replacement(replacement, &captures));
last_end = mat.end();
replaced = true;
}
}
result.push_str(&text[last_end..]);
result
}
fn expand_replacement(replacement: &str, captures: ®ex::Captures) -> String {
let mut result = String::new();
let chars: Vec<char> = replacement.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\\' && i + 1 < chars.len() {
let next = chars[i + 1];
if next.is_ascii_digit() {
let group_num = (next as u8 - b'0') as usize;
if let Some(m) = captures.get(group_num) {
result.push_str(m.as_str());
}
i += 2;
} else {
match next {
'n' => result.push('\n'),
't' => result.push('\t'),
'\\' => result.push('\\'),
_ => {
result.push('\\');
result.push(next);
}
}
i += 2;
}
} else if c == '&' {
if let Some(m) = captures.get(0) {
result.push_str(m.as_str());
}
i += 1;
} else {
result.push(c);
i += 1;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::sync::Arc;
fn parse_expression(expr: &str) -> Result<SedExpression, String> {
let mut cmds = parse_program(expr)?;
if cmds.len() != 1 {
return Err(format!("expected 1 command, got {}", cmds.len()));
}
Ok(cmds.remove(0))
}
async fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(
Path::new("test.txt"),
b"hello world\nHELLO WORLD\nfoo bar\nbaz",
)
.await
.unwrap();
mem.write(
Path::new("lines.txt"),
b"line one\nline two\nline three\nline four\nline five",
)
.await
.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[test]
fn test_parse_basic_substitute() {
let expr = parse_expression("s/foo/bar/").unwrap();
assert!(matches!(expr.address, Address::All));
assert!(matches!(expr.command, Command::Substitute { .. }));
}
#[test]
fn test_parse_substitute_flags() {
let expr = parse_expression("s/foo/bar/gip").unwrap();
if let Command::Substitute {
global,
print,
pattern,
..
} = &expr.command
{
assert!(*global);
assert!(*print);
assert!(pattern.is_match("FOO"));
} else {
panic!("expected Substitute");
}
}
#[test]
fn test_parse_alternative_delimiter() {
let expr = parse_expression("s|/usr|/opt|").unwrap();
if let Command::Substitute {
pattern,
replacement,
..
} = &expr.command
{
assert!(pattern.is_match("/usr"));
assert_eq!(replacement, "/opt");
} else {
panic!("expected Substitute");
}
}
#[test]
fn test_parse_escaped_delimiter() {
let expr = parse_expression(r"s/foo\/bar/baz/").unwrap();
if let Command::Substitute { pattern, .. } = &expr.command {
assert!(pattern.is_match("foo/bar"));
} else {
panic!("expected Substitute");
}
}
#[test]
fn test_parse_line_address() {
let expr = parse_expression("5d").unwrap();
assert!(matches!(expr.address, Address::Line(5)));
assert!(matches!(expr.command, Command::Delete));
}
#[test]
fn test_parse_last_line_address() {
let expr = parse_expression("$d").unwrap();
assert!(matches!(expr.address, Address::LastLine));
}
#[test]
fn test_parse_range_address() {
let expr = parse_expression("1,5d").unwrap();
if let Address::Range(start, end) = &expr.address {
assert!(matches!(start.as_ref(), Address::Line(1)));
assert!(matches!(end.as_ref(), Address::Line(5)));
} else {
panic!("expected Range");
}
}
#[test]
fn test_parse_range_to_last() {
let expr = parse_expression("3,$d").unwrap();
if let Address::Range(start, end) = &expr.address {
assert!(matches!(start.as_ref(), Address::Line(3)));
assert!(matches!(end.as_ref(), Address::LastLine));
} else {
panic!("expected Range");
}
}
#[test]
fn test_parse_pattern_address() {
let expr = parse_expression("/error/d").unwrap();
assert!(matches!(expr.address, Address::Pattern(_)));
assert!(matches!(expr.command, Command::Delete));
}
#[test]
fn test_parse_pattern_range() {
let expr = parse_expression("/start/,/end/d").unwrap();
if let Address::Range(start, end) = &expr.address {
assert!(matches!(start.as_ref(), Address::Pattern(_)));
assert!(matches!(end.as_ref(), Address::Pattern(_)));
} else {
panic!("expected Range");
}
}
#[test]
fn test_parse_delete_command() {
let expr = parse_expression("d").unwrap();
assert!(matches!(expr.command, Command::Delete));
}
#[test]
fn test_parse_print_command() {
let expr = parse_expression("p").unwrap();
assert!(matches!(expr.command, Command::Print));
}
#[test]
fn test_parse_quit_command() {
let expr = parse_expression("q").unwrap();
assert!(matches!(expr.command, Command::Quit));
}
#[test]
fn test_parse_invalid_regex() {
let result = parse_expression("s/[invalid/bar/");
assert!(result.is_err());
}
#[test]
fn test_basic_substitution() {
let input = "hello world\nhello there";
let expr = parse_expression("s/hello/hi/").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "hi world\nhi there\n");
}
#[test]
fn test_global_substitution() {
let input = "foo bar foo baz foo";
let expr = parse_expression("s/foo/XXX/g").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "XXX bar XXX baz XXX\n");
}
#[test]
fn test_case_insensitive() {
let input = "Hello HELLO hello";
let expr = parse_expression("s/hello/hi/gi").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "hi hi hi\n");
}
#[test]
fn test_print_on_change() {
let input = "hello world\nfoo bar";
let expr = parse_expression("s/hello/hi/p").unwrap();
let output = execute_sed(input, &[expr], true); assert_eq!(output, "hi world\n");
}
#[test]
fn test_capture_groups() {
let input = "John Smith";
let expr = parse_expression(r"s/(\w+) (\w+)/\2, \1/").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "Smith, John\n");
}
#[test]
fn test_ampersand_expansion() {
let input = "hello world";
let expr = parse_expression("s/hello/[&]/").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "[hello] world\n");
}
#[test]
fn test_delete_command() {
let input = "keep\ndelete this\nkeep";
let expr = parse_expression("/delete/d").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "keep\nkeep\n");
}
#[test]
fn test_quiet_mode() {
let input = "line 1\npattern here\nline 3";
let expr = parse_expression("/pattern/p").unwrap();
let output = execute_sed(input, &[expr], true);
assert_eq!(output, "pattern here\n");
}
#[test]
fn test_line_number_address() {
let input = "line 1\nline 2\nline 3";
let expr = parse_expression("2s/line/LINE/").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "line 1\nLINE 2\nline 3\n");
}
#[test]
fn test_range_address() {
let input = "line 1\nline 2\nline 3\nline 4";
let expr = parse_expression("2,3d").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "line 1\nline 4\n");
}
#[test]
fn test_quit_command() {
let input = "line 1\nline 2\nline 3\nline 4";
let expr = parse_expression("2q").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "line 1\nline 2\n");
}
#[test]
fn test_multiple_expressions() {
let input = "abc 123";
let e1 = parse_expression("s/a/X/").unwrap();
let e2 = parse_expression("s/1/Y/").unwrap();
let output = execute_sed(input, &[e1, e2], false);
assert_eq!(output, "Xbc Y23\n");
}
#[test]
fn collect_expressions_reads_json_array_from_repeated_e() {
let mut args = ToolArgs::new();
args.named.insert(
"expression".to_string(),
Value::Json(serde_json::json!(["s/a/b/", "s/c/d/"])),
);
assert_eq!(
collect_expressions(&args).unwrap(),
vec!["s/a/b/".to_string(), "s/c/d/".to_string()]
);
assert!(expression_from_flag(&args));
}
#[test]
fn collect_expressions_reads_single_string_e() {
let mut args = ToolArgs::new();
args.named
.insert("expression".to_string(), Value::String("s/a/b/".into()));
assert_eq!(collect_expressions(&args).unwrap(), vec!["s/a/b/".to_string()]);
assert!(expression_from_flag(&args));
}
#[test]
fn collect_expressions_falls_back_to_positional() {
let mut args = ToolArgs::new();
args.positional.push(Value::String("s/a/b/".into()));
args.positional.push(Value::String("file.txt".into()));
assert_eq!(collect_expressions(&args).unwrap(), vec!["s/a/b/".to_string()]);
assert!(!expression_from_flag(&args));
}
#[test]
fn test_empty_replacement() {
let input = "hello world";
let expr = parse_expression("s/hello //").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "world\n");
}
#[test]
fn test_empty_input() {
let input = "";
let expr = parse_expression("s/foo/bar/").unwrap();
let output = execute_sed(input, &[expr], false);
assert!(output.is_empty());
}
#[test]
fn test_no_matches_passthrough() {
let input = "hello world";
let expr = parse_expression("s/xyz/abc/").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "hello world\n");
}
#[test]
fn test_last_line_address() {
let input = "line 1\nline 2\nline 3";
let expr = parse_expression("$s/line/LAST/").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "line 1\nline 2\nLAST 3\n");
}
#[test]
fn test_pattern_range() {
let input = "before\nSTART\nmiddle\nEND\nafter";
let expr = parse_expression("/START/,/END/d").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "before\nafter\n");
}
#[test]
fn test_escaped_backslash_replacement() {
let input = "hello";
let expr = parse_expression(r"s/hello/a\\b/").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "a\\b\n");
}
#[test]
fn test_newline_in_replacement() {
let input = "hello world";
let expr = parse_expression(r"s/ /\n/").unwrap();
let output = execute_sed(input, &[expr], false);
assert_eq!(output, "hello\nworld\n");
}
#[test]
fn semicolon_splits_into_multiple_commands() {
let prog = parse_program("s/a/X/;s/b/Y/").unwrap();
assert_eq!(prog.len(), 2);
let output = execute_sed("abc", &prog, false);
assert_eq!(output, "XYc\n");
}
#[test]
fn semicolon_with_addresses_and_whitespace() {
let prog = parse_program("/b/d ; /d/d").unwrap();
assert_eq!(prog.len(), 2);
let output = execute_sed("a\nb\nc\nd\n", &prog, false);
assert_eq!(output, "a\nc\n");
}
#[test]
fn semicolon_inside_regex_is_literal_not_a_separator() {
let prog = parse_program("s/a;b/X/").unwrap();
assert_eq!(prog.len(), 1);
assert_eq!(execute_sed("a;b", &prog, false), "X\n");
}
#[test]
fn empty_segments_from_doubled_semicolons_are_skipped() {
let prog = parse_program(";;s/a/X/;;").unwrap();
assert_eq!(prog.len(), 1);
}
#[test]
fn substitute_nth_occurrence_only() {
let expr = parse_expression("s/a/X/2").unwrap();
assert_eq!(execute_sed("aaa", &[expr], false), "aXa\n");
}
#[test]
fn substitute_nth_onward_with_g() {
let expr = parse_expression("s/a/X/2g").unwrap();
assert_eq!(execute_sed("aaaa", &[expr], false), "aXXX\n");
}
#[test]
fn substitute_default_is_first_match() {
let expr = parse_expression("s/a/X/").unwrap();
assert_eq!(execute_sed("aaa", &[expr], false), "Xaa\n");
}
#[test]
fn append_emits_text_after_the_line() {
let expr = parse_expression("/B/a ---").unwrap();
assert_eq!(execute_sed("A\nB\nC", &[expr], false), "A\nB\n---\nC\n");
}
#[test]
fn insert_emits_text_before_the_line() {
let expr = parse_expression("1i top").unwrap();
assert_eq!(execute_sed("A\nB", &[expr], false), "top\nA\nB\n");
}
#[test]
fn change_replaces_single_line() {
let expr = parse_expression("/B/c NEW").unwrap();
assert_eq!(execute_sed("A\nB\nC", &[expr], false), "A\nNEW\nC\n");
}
#[test]
fn change_replaces_whole_range_once() {
let expr = parse_expression("2,3c NEW").unwrap();
assert_eq!(execute_sed("A\nB\nC\nD", &[expr], false), "A\nNEW\nD\n");
}
#[test]
fn change_range_unclosed_at_eof_still_emits_once() {
let prog = parse_program("2,/NOPE/c NEW").unwrap();
assert_eq!(execute_sed("a\nb\nc\nd", &prog, false), "a\nNEW\n");
}
#[test]
fn change_numeric_range_past_eof_emits_once() {
let prog = parse_program("2,99c NEW").unwrap();
assert_eq!(execute_sed("a\nb\nc", &prog, false), "a\nNEW\n");
}
#[test]
fn single_line_numeric_range_matches_one_line() {
let prog = parse_program("2,2d").unwrap();
assert_eq!(execute_sed("a\nb\nc", &prog, false), "a\nc\n");
}
#[test]
fn change_single_line_numeric_range_emits_once() {
let prog = parse_program("2,2c NEW").unwrap();
assert_eq!(execute_sed("a\nb\nc", &prog, false), "a\nNEW\nc\n");
}
#[test]
fn descending_numeric_range_matches_only_start_line() {
let prog = parse_program("3,1d").unwrap();
assert_eq!(execute_sed("a\nb\nc\nd", &prog, false), "a\nb\nd\n");
}
#[test]
fn append_text_emits_even_under_quiet() {
let expr = parse_expression("/B/a ---").unwrap();
assert_eq!(execute_sed("A\nB\nC", &[expr], true), "---\n");
}
#[test]
fn parse_text_arg_accepts_backslash_space_and_glued_forms() {
assert_eq!(parse_text_arg(r"\hello"), "hello");
assert_eq!(parse_text_arg(" hello"), "hello");
assert_eq!(parse_text_arg("hello"), "hello");
assert_eq!(parse_text_arg(r"\ hello"), "hello");
}
#[test]
fn transliterate_maps_chars() {
let expr = parse_expression("y/abc/xyz/").unwrap();
assert_eq!(execute_sed("cabbage", &[expr], false), "zxyyxge\n");
}
#[test]
fn transliterate_length_mismatch_errors() {
let err = parse_program("y/abc/xy/").unwrap_err();
assert!(err.contains("same length"), "got: {err}");
}
fn parse_expression_ere(expr: &str) -> Result<SedExpression, String> {
let mut cmds = parse_program_dialect(expr, true)?;
if cmds.len() != 1 {
return Err(format!("expected 1 command, got {}", cmds.len()));
}
Ok(cmds.remove(0))
}
#[test]
fn bre_capture_groups_translate_to_ere_groups() {
let expr = parse_expression(r"s/\(a\)\(b\)/\2\1/").unwrap();
assert_eq!(execute_sed("ab", &[expr], false), "ba\n");
}
#[test]
fn bre_alternation_translates() {
let expr = parse_expression(r"s/cat\|dog/X/g").unwrap();
assert_eq!(execute_sed("cat dog", &[expr], false), "X X\n");
}
#[test]
fn bre_interval_translates() {
let expr = parse_expression(r"s/a\{2\}/X/").unwrap();
assert_eq!(execute_sed("aa", &[expr], false), "X\n");
let expr = parse_expression(r"s/a\{2,\}/X/").unwrap();
assert_eq!(execute_sed("aaaa", &[expr], false), "X\n");
}
#[test]
fn bre_plus_quantifier_translates() {
let expr = parse_expression(r"s/a\+/X/").unwrap();
assert_eq!(execute_sed("aaab", &[expr], false), "Xb\n");
}
#[test]
fn bre_idioms_work_in_addresses_too() {
let expr = parse_expression(r"/cat\|dog/d").unwrap();
assert_eq!(execute_sed("cat\nfish\ndog", &[expr], false), "fish\n");
}
#[test]
fn ere_interval_and_alternation_are_fine() {
let expr = parse_expression("s/a{2}/X/").unwrap();
assert_eq!(execute_sed("aa", &[expr], false), "X\n");
let expr = parse_expression("s/cat|dog/X/g").unwrap();
assert_eq!(execute_sed("cat dog", &[expr], false), "X X\n");
}
#[test]
fn ere_groups_with_backref_work_normally() {
let expr = parse_expression(r"s/(a)(b)/\2\1/").unwrap();
assert_eq!(execute_sed("ab", &[expr], false), "ba\n");
}
#[test]
fn ere_mode_treats_backslash_metas_as_literals() {
let expr = parse_expression_ere(r"s/cat\|dog/X/").unwrap();
assert_eq!(execute_sed("cat|dog here", &[expr], false), "X here\n");
let expr = parse_expression_ere(r"s/\(x\)/Y/").unwrap();
assert_eq!(execute_sed("(x)", &[expr], false), "Y\n");
}
#[test]
fn pattern_backreference_gives_sed_specific_error() {
let err = parse_program(r"s/(a)\1/X/").unwrap_err();
assert!(err.contains("backreference"), "should name backreference: {err}");
assert!(
!err.contains("regex parse error"),
"should not leak the raw engine error: {err}"
);
}
#[test]
fn escaped_backslash_before_pipe_is_literal_backslash_then_alternation() {
let expr = parse_expression(r"s/a\\|b/X/").unwrap();
assert_eq!(execute_sed(r"a\ b c", &[expr], false), "X b c\n");
}
#[tokio::test]
async fn test_sed_from_stdin() {
let mut ctx = make_ctx().await;
ctx.set_stdin("hello world".to_string());
let mut args = ToolArgs::new();
args.positional.push(Value::String("s/world/kaish/".into()));
let result = Sed.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(&*result.text_out(), "hi kaish\n".replace("hi", "hello"));
}
#[tokio::test]
async fn test_sed_from_file() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("s/hello/hi/".into()));
args.positional.push(Value::String("/test.txt".into()));
let result = Sed.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("hi world"));
}
#[tokio::test]
async fn test_sed_quiet_mode() {
let mut ctx = make_ctx().await;
ctx.set_stdin("line 1\npattern\nline 3".to_string());
let mut args = ToolArgs::new();
args.positional.push(Value::String("/pattern/p".into()));
args.flags.insert("n".to_string());
let result = Sed.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(&*result.text_out(), "pattern\n");
}
#[tokio::test]
async fn test_sed_missing_expression() {
let mut ctx = make_ctx().await;
let args = ToolArgs::new();
let result = Sed.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("missing expression"));
}
#[tokio::test]
async fn test_sed_invalid_expression() {
let mut ctx = make_ctx().await;
ctx.set_stdin("hello".to_string());
let mut args = ToolArgs::new();
args.positional.push(Value::String("s/[invalid/bar/".into()));
let result = Sed.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("sed:"));
}
#[tokio::test]
async fn test_sed_file_not_found() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("s/foo/bar/".into()));
args.positional.push(Value::String("/nonexistent".into()));
let result = Sed.execute(args, &mut ctx).await;
assert!(!result.ok());
}
#[tokio::test]
async fn test_sed_unicode() {
let mut ctx = make_ctx().await;
ctx.set_stdin("こんにちは 世界".to_string());
let mut args = ToolArgs::new();
args.positional.push(Value::String("s/世界/kaish/".into()));
let result = Sed.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(&*result.text_out(), "こんにちは kaish\n");
}
}