use anyhow::{Result, anyhow, bail};
use crate::command::{Address, Command, SubstitutionFlags};
const ERROR_CONTEXT_SIZE: usize = 30;
fn extract_context(full_text: &str, pos: usize) -> String {
let start = pos.saturating_sub(ERROR_CONTEXT_SIZE);
let end = if pos + ERROR_CONTEXT_SIZE < full_text.len() {
pos + ERROR_CONTEXT_SIZE
} else {
full_text.len()
};
let mut context = full_text[start..end].to_string();
if start > 0 {
context.insert_str(0, "...");
}
if end < full_text.len() {
context.push_str("...");
}
context
}
fn format_parse_error(
expression: &str,
error_pos: Option<usize>,
description: &str,
suggestion: Option<&str>,
) -> String {
let mut msg = format!("Parse error: {}", description);
if let Some(pos) = error_pos {
let context = extract_context(expression, pos);
msg.push_str(&format!("\n Near: \"{}\"", context));
}
if let Some(hint) = suggestion {
msg.push_str(&format!("\n Hint: {}", hint));
}
msg
}
fn fold_substitution_flags(flags: &[char]) -> SubstitutionFlags {
let mut out = SubstitutionFlags::default();
for flag in flags {
match flag {
'g' => out.global = true,
'p' => out.print = true,
'i' | 'I' => out.case_insensitive = true,
'0'..='9' => {
out.nth = Some(flag.to_digit(10).unwrap() as usize);
}
_ => {} }
}
out
}
pub fn parse_sed_expression(expr: &str) -> Result<Vec<Command>> {
let mut commands = Vec::new();
let mut current_expr = String::new();
let mut in_braces = 0;
let chars = expr.chars().peekable();
for c in chars {
match c {
'{' => {
in_braces += 1;
current_expr.push(c);
}
'}' => {
in_braces -= 1;
current_expr.push(c);
}
';' if in_braces == 0 => {
let part = current_expr.trim();
if !part.is_empty() {
commands.push(parse_single_command(part)?);
}
current_expr.clear();
}
_ => {
current_expr.push(c);
}
}
}
let part = current_expr.trim();
if !part.is_empty() {
commands.push(parse_single_command(part)?);
}
Ok(commands)
}
fn is_inside_pattern_address(cmd: &str, pos: usize) -> bool {
let bytes = cmd.as_bytes();
let limit = pos.min(bytes.len());
let mut i = 0;
let mut current_opener: Option<u8> = None;
while i < limit {
let byte = bytes[i];
match current_opener {
None => {
if byte == b'/' || byte == b'\\' {
current_opener = Some(byte);
}
}
Some(opener) => {
if byte == b'\\' && i + 1 < limit {
i += 2;
continue;
}
if byte == opener {
current_opener = None;
}
}
}
i += 1;
}
if current_opener.is_some() {
return true;
}
let has_slash_before = (0..pos)
.rev()
.any(|j| bytes[j] == b'/' && (j == 0 || bytes[j - 1] != b'\\'));
if !has_slash_before {
return false;
}
for &byte in bytes.iter().skip(pos + 1) {
if byte.is_ascii_whitespace() {
break;
}
if byte == b'/' {
return true;
}
}
false
}
fn try_parse_file_io(cmd: &str) -> Result<Option<Command>> {
let trimmed = cmd.trim();
if !(trimmed.contains('r')
|| trimmed.contains('R')
|| trimmed.contains('w')
|| trimmed.contains('W'))
{
return Ok(None);
}
let mut r_positions: Vec<usize> = trimmed.match_indices('r').map(|(i, _)| i).collect();
let mut r_upper_positions: Vec<usize> = trimmed.match_indices('R').map(|(i, _)| i).collect();
let mut w_positions: Vec<usize> = trimmed.match_indices('w').map(|(i, _)| i).collect();
let mut w_upper_positions: Vec<usize> = trimmed.match_indices('W').map(|(i, _)| i).collect();
r_positions.retain(|&pos| !is_inside_pattern_address(trimmed, pos));
r_upper_positions.retain(|&pos| !is_inside_pattern_address(trimmed, pos));
w_positions.retain(|&pos| !is_inside_pattern_address(trimmed, pos));
w_upper_positions.retain(|&pos| !is_inside_pattern_address(trimmed, pos));
let iac_pos = [
trimmed.find("i\\"),
trimmed.find("a\\"),
trimmed.find("c\\"),
]
.into_iter()
.flatten()
.min();
let keep = |&pos: &usize| -> bool {
match iac_pos {
Some(iac) => pos < iac,
None => true,
}
};
let all_positions: Vec<(usize, char)> = r_positions
.iter()
.copied()
.filter(keep)
.map(|p| (p, 'r'))
.chain(
r_upper_positions
.iter()
.copied()
.filter(keep)
.map(|p| (p, 'R')),
)
.chain(w_positions.iter().copied().filter(keep).map(|p| (p, 'w')))
.chain(
w_upper_positions
.iter()
.copied()
.filter(keep)
.map(|p| (p, 'W')),
)
.collect();
let Some(&(pos, char_at_pos)) = all_positions.iter().min_by_key(|(p, _)| p) else {
return Ok(None);
};
let rest = &trimmed[pos + 1..];
if rest.trim().is_empty() {
return Ok(None);
}
let parsed = match char_at_pos {
'r' => parse_read_file(cmd)?,
'R' => parse_read_line(cmd)?,
'w' => parse_write_file(cmd)?,
'W' => parse_write_first_line(cmd)?,
_ => unreachable!(),
};
Ok(Some(parsed))
}
fn parse_single_command(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
if cmd.contains('{') {
return parse_group(cmd);
}
if let Some(cmd_out) = try_parse_file_io(cmd)? {
return Ok(cmd_out);
}
type Handler = fn(&str) -> Result<Command>;
const CANDIDATES: &[(&str, Handler)] = &[
("i\\", parse_insert),
("a\\", parse_append),
("c\\", parse_change),
("s/", parse_substitution),
("s#", parse_substitution),
("s:", parse_substitution),
("s|", parse_substitution),
];
let earliest = CANDIDATES
.iter()
.filter_map(|(marker, handler)| cmd.find(marker).map(|pos| (pos, *handler)))
.min_by_key(|(pos, _)| *pos);
if let Some((_, handler)) = earliest {
return handler(cmd);
}
let last_char = cmd.chars().last().unwrap_or(' ');
if last_char == 'h' || last_char == 'H' {
if !cmd.starts_with('s') && cmd.chars().filter(|&c| c == 's').count() <= 1 {
return if last_char == 'H' {
parse_hold_append(cmd)
} else {
parse_hold(cmd)
};
}
}
if last_char == 'g' || last_char == 'G' {
if !cmd.starts_with('s') && cmd.chars().filter(|&c| c == 's').count() <= 1 {
return if last_char == 'G' {
parse_get_append(cmd)
} else {
parse_get(cmd)
};
}
}
if last_char == 'x' {
if !cmd.starts_with('s') && cmd.chars().filter(|&c| c == 's').count() <= 1 {
return parse_exchange(cmd);
}
}
if last_char == 'n' && !cmd.starts_with('s') {
if cmd.chars().filter(|&c| c == 's').count() <= 1 {
return parse_next(cmd);
}
}
if last_char == 'N' && !cmd.starts_with('s') {
if cmd.chars().filter(|&c| c == 's').count() <= 1 {
return parse_next_append(cmd);
}
}
if last_char == 'P' && !cmd.starts_with('s') {
if cmd.chars().filter(|&c| c == 's').count() <= 1 {
return parse_print_first_line(cmd);
}
}
if last_char == 'D' && !cmd.starts_with('s') {
if cmd.chars().filter(|&c| c == 's').count() <= 1 {
return parse_delete_first_line(cmd);
}
}
if cmd.starts_with(':') {
return parse_label(cmd);
}
let trimmed = cmd.trim();
if trimmed.contains('b') || trimmed.contains('t') || trimmed.contains('T') {
let b_pos = trimmed.find('b');
let t_pos = trimmed.find('t');
let t_upper_pos = trimmed.find('T');
let min_pos = [b_pos, t_pos, t_upper_pos].iter().filter_map(|&p| p).min();
if let Some(pos) = min_pos {
let char_at_pos = trimmed
.chars()
.nth(pos)
.ok_or_else(|| anyhow!("Invalid position {} in command: {}", pos, cmd))?;
let rest = &trimmed[pos + 1..];
if rest.trim().is_empty() || rest.starts_with(' ') {
if char_at_pos == 'b' {
return parse_branch(cmd);
} else if char_at_pos == 't' {
return parse_test(cmd);
} else {
return parse_test_false(cmd);
}
}
}
}
if trimmed.contains('=') {
if let Some(eq_pos) = trimmed.find('=') {
let rest = &trimmed[eq_pos + 1..];
if rest.trim().is_empty() {
return parse_print_line_number(cmd);
}
}
}
if trimmed.contains('F') {
if let Some(f_pos) = trimmed.find('F') {
let rest = &trimmed[f_pos + 1..];
if rest.trim().is_empty() {
return parse_print_filename(cmd);
}
}
}
if trimmed.contains('z') {
if !cmd.starts_with('s')
&& cmd.chars().filter(|&c| c == 's').count() <= 1
&& let Some(z_pos) = trimmed.find('z')
{
let rest = &trimmed[z_pos + 1..];
if rest.trim().is_empty() {
return parse_clear_pattern_space(cmd);
}
}
}
if cmd.ends_with('Q') && !cmd.starts_with('s') {
parse_quit_without_print(cmd)
} else if cmd.ends_with('q') && !cmd.starts_with('s') {
parse_quit(cmd)
} else if cmd.ends_with('d') {
parse_delete(cmd)
} else if cmd.ends_with('p') && !cmd.starts_with('s') {
parse_print(cmd)
} else {
let command_char = cmd.chars().last().ok_or_else(|| anyhow!("Empty command"))?;
match command_char {
's' => parse_substitution(cmd),
'Q' => parse_quit_without_print(cmd),
'q' => parse_quit(cmd),
'd' => parse_delete(cmd),
'p' => parse_print(cmd),
'h' => parse_hold(cmd),
'H' => parse_hold_append(cmd),
'g' => parse_get(cmd),
'G' => parse_get_append(cmd),
'x' => parse_exchange(cmd),
'n' => parse_next(cmd),
'N' => parse_next_append(cmd),
'P' => parse_print_first_line(cmd),
'D' => parse_delete_first_line(cmd),
'r' => parse_read_file(cmd),
'R' => parse_read_line(cmd),
'w' => parse_write_file(cmd),
'W' => parse_write_first_line(cmd),
'=' => parse_print_line_number(cmd),
'F' => parse_print_filename(cmd),
'z' => parse_clear_pattern_space(cmd),
_ => {
let unknown_char = command_char;
let suggestion = match unknown_char {
c if c.is_ascii_alphabetic() => {
"Did you mean:\n\
- Substitution: s/pattern/replacement/[flags]\n\
- Delete: d\n\
- Print: p\n\
- Insert (before line): 5i\\text\n\
- Append (after line): 5a\\text\n\
- Change line: 5c\\new text\n\
- Quit: q or Q\n\
See 'sedx --help' for all commands".to_string()
}
'0'..='9' => {
"Numbers alone are not commands. Use a command character after the line number (e.g., '5d' to delete line 5)".to_string()
}
_ => {
"Valid commands: s (substitute), d (delete), p (print),\n\
i (insert), a (append), c (change), q (quit),\n\
h/H (hold), g/G (get), x (exchange), n/N (next),\n\
b/t/T (branch), r/R (read file), w/W (write file),\n\
= (line number), F (filename), z (clear pattern space)".to_string()
}
};
let cmd_trimmed = cmd.trim();
Err(anyhow!(
"{}",
format_parse_error(
cmd_trimmed,
Some(cmd_trimmed.chars().count().saturating_sub(1)),
&format!("unknown command '{}'", unknown_char),
Some(&suggestion),
)
))
}
}
}
}
fn parse_substitution(cmd: &str) -> Result<Command> {
let bytes = cmd.as_bytes();
let mut s_pos = None;
for (i, &byte) in bytes.iter().enumerate() {
if byte == b's' && i + 1 < bytes.len() {
let next_byte = bytes[i + 1];
if next_byte == b'/' || next_byte == b'#' || next_byte == b':' || next_byte == b'|' {
s_pos = Some(i);
break;
}
}
}
let s_pos = s_pos.ok_or_else(|| anyhow!("{}", format_parse_error(
cmd,
None,
"'s' command not followed by a valid delimiter",
Some("Substitution format: s<delimiter>pattern<delimiter>replacement<delimiter>[flags]\nDelimiters: / (slash), # (hash), : (colon), | (pipe)\nExample: s/foo/bar/ or s#old#new#g"),
)))?;
let address_part = &cmd[..s_pos];
let rest = &cmd[s_pos + 1..];
let delimiter = rest.chars().next()
.ok_or_else(|| anyhow!("{}", format_parse_error(
cmd,
Some(s_pos + 1),
"missing delimiter after 's'",
Some("Expected format: s<delimiter>pattern<delimiter>replacement<delimiter>[flags]\nExample: s/foo/bar/ or s#old#new#g"),
)))?;
let mut delimiter_positions: Vec<usize> = Vec::new();
for (byte_pos, c) in rest.char_indices() {
if c == delimiter {
delimiter_positions.push(byte_pos);
}
}
if delimiter_positions.len() < 3 {
let (description, suggestion) = match delimiter_positions.len() {
0 => (
format!(
"no '{}' delimiter found after the opening delimiter",
delimiter
),
Some(
"Make sure to close the pattern, replacement, and optionally add flags:\n s/pattern/replacement/\n s/pattern/replacement/g",
),
),
1 => (
"missing closing delimiter for replacement".to_string(),
Some(
"You need to close the replacement with the delimiter:\n s/pattern/replacement/\n ^ (add this)",
),
),
2 => {
(
"missing final delimiter to close the substitution".to_string(),
Some(
"Add the final delimiter:\n s/pattern/replacement/\n ^ (add this)",
),
)
}
_ => unreachable!(),
};
return Err(anyhow!(
"{}",
format_parse_error(cmd, None, &description, suggestion,)
));
}
let pattern = &rest[delimiter_positions[0] + 1..delimiter_positions[1]];
let replacement = rest[delimiter_positions[1] + 1..delimiter_positions[2]].to_string();
let raw_flags: Vec<char> = if delimiter_positions[2] + 1 < rest.len() {
rest[delimiter_positions[2] + 1..].chars().collect()
} else {
Vec::new()
};
let range = if address_part.contains(',') {
let parts: Vec<&str> = address_part.splitn(2, ',').collect();
if parts.len() == 2 {
let start = parse_address(parts[0])?;
let end_str = parts[1].trim();
if end_str.starts_with('+') || end_str.starts_with('-') {
let offset_str = &end_str[1..]; let offset: isize = offset_str.parse()
.map_err(|_| anyhow!("{}", format_parse_error(
cmd,
None,
&format!("invalid relative offset '{}'", end_str),
Some("Relative offset format: start,+N or start,-N\nExample: /pattern/,+5 - 5 lines after pattern match\n 10,-3 - 3 lines before line 10"),
)))?;
let end = Address::Relative {
base: Box::new(start.clone()),
offset,
};
Some((start, end))
} else {
let end = parse_address(end_str)?;
Some((start, end))
}
} else {
None
}
} else if !address_part.trim().is_empty() {
let addr = parse_address(address_part.trim())?;
Some((addr.clone(), addr))
} else {
None
};
Ok(Command::Substitution {
pattern: pattern.to_string(),
replacement: replacement.to_string(),
flags: fold_substitution_flags(&raw_flags),
range,
})
}
fn parse_delete(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
if addr_part.trim().is_empty() {
return Ok(Command::Delete {
range: (Address::LineNumber(1), Address::LastLine),
});
}
if let Some(comma_pos) = addr_part.find(',') {
let start = &addr_part[..comma_pos];
let end = &addr_part[comma_pos + 1..];
return Ok(Command::Delete {
range: (parse_address(start)?, parse_address(end)?),
});
}
let addr = parse_address(addr_part)?;
Ok(Command::Delete {
range: (addr.clone(), addr),
})
}
fn parse_print(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
if addr_part.trim().is_empty() {
return Ok(Command::Print {
range: (Address::LineNumber(1), Address::LastLine),
});
}
if let Some(comma_pos) = addr_part.find(',') {
let start = &addr_part[..comma_pos];
let end = &addr_part[comma_pos + 1..];
return Ok(Command::Print {
range: (parse_address(start)?, parse_address(end)?),
});
}
let addr = parse_address(addr_part)?;
Ok(Command::Print {
range: (addr.clone(), addr),
})
}
fn parse_quit(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
if addr_part.trim().is_empty() {
return Ok(Command::Quit { address: None });
}
let addr = parse_address(addr_part)?;
Ok(Command::Quit {
address: Some(addr),
})
}
fn parse_quit_without_print(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
if addr_part.trim().is_empty() {
return Ok(Command::QuitWithoutPrint { address: None });
}
let addr = parse_address(addr_part)?;
Ok(Command::QuitWithoutPrint {
address: Some(addr),
})
}
fn parse_group(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let open_brace = cmd.find('{')
.ok_or_else(|| anyhow!("{}", format_parse_error(
cmd,
None,
"group command is missing opening '{'",
Some("Group format: [range] { command1; command2; ... }\nExample: {s/foo/bar/; s/baz/qux/}\n 1,10{s/^/> /}"),
)))?;
let addr_part = cmd[..open_brace].trim();
let brace_start = open_brace + 1;
let mut depth = 1;
let mut close_brace = None;
for (i, c) in cmd[brace_start..].chars().enumerate() {
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
if depth == 0 {
close_brace = Some(brace_start + i);
break;
}
}
}
let close_brace = close_brace
.ok_or_else(|| anyhow!("{}", format_parse_error(
cmd,
None,
"group command is missing closing '}'",
Some("Every opening '{' must have a matching closing '}'.\nExample: {s/foo/bar/; p}\n ^ (add closing brace here)"),
)))?;
let commands_str = &cmd[brace_start..close_brace].trim();
let range = if addr_part.is_empty() {
None
} else if addr_part.contains(',') {
let parts: Vec<&str> = addr_part.splitn(2, ',').collect();
if parts.len() == 2 {
Some((
parse_address(parts[0].trim())?,
parse_address(parts[1].trim())?,
))
} else {
None
}
} else {
let addr = parse_address(addr_part)?;
Some((addr.clone(), addr))
};
let mut commands = Vec::new();
for cmd_str in commands_str.split(';') {
let cmd_str = cmd_str.trim();
if !cmd_str.is_empty() {
commands.push(parse_single_command(cmd_str)?);
}
}
if commands.is_empty() {
return Err(anyhow!(
"{}",
format_parse_error(
cmd,
None,
"empty group: no commands inside braces",
Some(
"Add commands separated by semicolons:\n {s/foo/bar/; p} - valid\n {} - invalid (empty)"
),
)
));
}
Ok(Command::Group { range, commands })
}
fn parse_insert(cmd: &str) -> Result<Command> {
let parts: Vec<&str> = cmd.splitn(2, "i\\").collect();
if parts.len() != 2 {
let suggestion = if cmd.contains('i') && !cmd.contains("i\\") {
Some(
"Insert command requires a backslash after 'i':\n Format: [address]i\\text\n Example: 5i\\INSERTED LINE\n Example: /pattern/i\\New line before match",
)
} else {
Some("Valid insert format: [address]i\\text\nExample: 5i\\INSERTED LINE")
};
return Err(anyhow!(
"{}",
format_parse_error(cmd, None, "invalid insert command syntax", suggestion,)
));
}
let address = if !parts[0].trim().is_empty() {
parse_address(parts[0].trim())?
} else {
return Err(anyhow!(
"{}",
format_parse_error(
cmd,
None,
"insert command requires an address",
Some(
"Specify which line to insert before:\n 5i\\text - insert before line 5\n /pat/i\\text - insert before lines matching 'pat'\n $i\\text - insert before last line"
),
)
));
};
Ok(Command::Insert {
text: parts[1].strip_prefix('\n').unwrap_or(parts[1]).to_string(),
address,
})
}
fn parse_append(cmd: &str) -> Result<Command> {
let parts: Vec<&str> = cmd.splitn(2, "a\\").collect();
if parts.len() != 2 {
let suggestion = if cmd.contains('a') && !cmd.contains("a\\") {
Some(
"Append command requires a backslash after 'a':\n Format: [address]a\\text\n Example: 5a\\APPENDED LINE\n Example: /pattern/a\\New line after match",
)
} else {
Some("Valid append format: [address]a\\text\nExample: 5a\\APPENDED LINE")
};
return Err(anyhow!(
"{}",
format_parse_error(cmd, None, "invalid append command syntax", suggestion,)
));
}
let address = if !parts[0].trim().is_empty() {
parse_address(parts[0].trim())?
} else {
return Err(anyhow!(
"{}",
format_parse_error(
cmd,
None,
"append command requires an address",
Some(
"Specify which line to append after:\n 5a\\text - append after line 5\n /pat/a\\text - append after lines matching 'pat'\n $a\\text - append after last line"
),
)
));
};
Ok(Command::Append {
text: parts[1].strip_prefix('\n').unwrap_or(parts[1]).to_string(),
address,
})
}
fn parse_change(cmd: &str) -> Result<Command> {
let parts: Vec<&str> = cmd.splitn(2, "c\\").collect();
if parts.len() != 2 {
let suggestion = if cmd.contains('c') && !cmd.contains("c\\") {
Some(
"Change command requires a backslash after 'c':\n Format: [address]c\\text\n Example: 5c\\REPLACED LINE\n Example: /pattern/c\\Replacement",
)
} else {
Some("Valid change format: [address]c\\text\nExample: 5c\\REPLACED LINE")
};
return Err(anyhow!(
"{}",
format_parse_error(cmd, None, "invalid change command syntax", suggestion,)
));
}
let addr_part = parts[0].trim();
if addr_part.is_empty() {
return Err(anyhow!(
"{}",
format_parse_error(
cmd,
None,
"change command requires an address",
Some(
"Specify which line(s) to change:\n 5c\\text - change line 5\n 2,3c\\text - change lines 2-3 (collapsed)\n /pat/c\\text - change lines matching 'pat'\n $c\\text - change last line"
),
)
));
}
let text = parts[1].strip_prefix('\n').unwrap_or(parts[1]).to_string();
if let Some(comma_pos) = addr_part.find(',') {
let start = parse_address(addr_part[..comma_pos].trim())?;
let end = parse_address(addr_part[comma_pos + 1..].trim())?;
return Ok(Command::Change {
text,
range: (start, end),
});
}
let addr = parse_address(addr_part)?;
Ok(Command::Change {
text,
range: (addr.clone(), addr),
})
}
fn parse_hold(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
let range = parse_optional_range(addr_part)?;
Ok(Command::Hold { range })
}
fn parse_hold_append(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
let range = parse_optional_range(addr_part)?;
Ok(Command::HoldAppend { range })
}
fn parse_get(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
let range = parse_optional_range(addr_part)?;
Ok(Command::Get { range })
}
fn parse_get_append(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
let range = parse_optional_range(addr_part)?;
Ok(Command::GetAppend { range })
}
fn parse_exchange(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
let range = parse_optional_range(addr_part)?;
Ok(Command::Exchange { range })
}
fn parse_next(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
let range = parse_optional_range(addr_part)?;
Ok(Command::Next { range })
}
fn parse_next_append(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
let range = parse_optional_range(addr_part)?;
Ok(Command::NextAppend { range })
}
fn parse_print_first_line(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
let range = parse_optional_range(addr_part)?;
Ok(Command::PrintFirstLine { range })
}
fn parse_delete_first_line(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let addr_part = &cmd[..cmd.len() - 1];
let range = parse_optional_range(addr_part)?;
Ok(Command::DeleteFirstLine { range })
}
fn parse_optional_range(addr_part: &str) -> Result<Option<(Address, Address)>> {
let addr_part = addr_part.trim();
if addr_part.is_empty() {
return Ok(None); }
if let Some(comma_pos) = addr_part.find(',') {
let start = &addr_part[..comma_pos];
let end = &addr_part[comma_pos + 1..];
if end.starts_with('+') || end.starts_with('-') {
let start_addr = parse_address(start)?;
let offset_str = &end[1..]; let offset: isize = offset_str.parse()
.map_err(|_| anyhow!("{}", format_parse_error(
end,
None,
&format!("invalid relative offset '{}'", end),
Some("Relative offset format: start,+N or start,-N\nExample: /pattern/,+5 - 5 lines after pattern\n 10,-3 - 3 lines before line 10"),
)))?;
let end_addr = Address::Relative {
base: Box::new(start_addr.clone()),
offset,
};
return Ok(Some((start_addr, end_addr)));
}
let start_addr = parse_address(start)?;
let end_addr = parse_address(end)?;
return Ok(Some((start_addr, end_addr)));
}
let addr = parse_address(addr_part)?;
Ok(Some((addr.clone(), addr)))
}
fn parse_address(addr: &str) -> Result<Address> {
let addr = addr.trim();
if addr.is_empty() {
return Err(anyhow!("Empty address"));
}
if let Some(inner_addr) = addr.strip_suffix('!') {
let parsed = parse_address(inner_addr)?;
return Ok(Address::Negated(Box::new(parsed)));
}
if addr == "0" {
return Ok(Address::FirstLine);
}
if addr == "$" {
return Ok(Address::LastLine);
}
if let Some(tilde_pos) = addr.find('~') {
let start_str = &addr[..tilde_pos];
let step_str = &addr[tilde_pos + 1..];
let start: usize = start_str.parse()
.map_err(|_| anyhow!("{}", format_parse_error(
addr,
Some(tilde_pos),
&format!("invalid step start '{}'", start_str),
Some("Step format: start~step\nExample: 1~2 - every 2nd line starting from line 1\n 10~5 - every 5th line starting from line 10"),
)))?;
let step: usize = step_str.parse()
.map_err(|_| anyhow!("{}", format_parse_error(
addr,
Some(tilde_pos + 1),
&format!("invalid step value '{}'", step_str),
Some("Step format: start~step\nThe step value must be a positive integer.\nExample: 1~2 or 10~5"),
)))?;
if step == 0 {
bail!(
"{}",
format_parse_error(
addr,
Some(tilde_pos + 1),
"step value cannot be zero",
Some(
"Use a positive integer for the step value.\nExample: 1~1 (every line) or 1~2 (every other line)"
),
)
);
}
return Ok(Address::Step { start, step });
}
if let Ok(num) = addr.parse::<usize>() {
return Ok(Address::LineNumber(num));
}
if addr.starts_with('/') && addr.ends_with('/') {
let pattern = &addr[1..addr.len() - 1];
return Ok(Address::Pattern(pattern.to_string()));
}
if addr.starts_with('/') && !addr.ends_with('/') {
return Err(anyhow!(
"{}",
format_parse_error(
addr,
Some(addr.len()),
"pattern address is missing closing '/'",
Some(
"Pattern addresses must be enclosed in slashes:\n /pattern/\n /^hello/\n /goodbye$/"
),
)
));
}
if addr.ends_with('/') && !addr.starts_with('/') {
return Err(anyhow!(
"{}",
format_parse_error(
addr,
Some(0),
"pattern address is missing opening '/'",
Some(
"Pattern addresses must be enclosed in slashes:\n /pattern/\n /^hello/\n /goodbye$/"
),
)
));
}
Err(anyhow!(
"{}",
format_parse_error(
addr,
None,
&format!("invalid address '{}'", addr),
Some(
"Valid address formats:\n - Line number: 5, 10, 42\n - Last line: $\n - Pattern: /regex/\n - Range: 1,10 or /start/,/end/\n - Stepping: 1~2 (every 2nd line)\n - Relative: /pat/,+5 (5 lines after pattern match)"
),
)
))
}
fn parse_label(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let label_name = cmd[1..].trim();
if label_name.is_empty() {
return Err(anyhow!(
"{}",
format_parse_error(
cmd,
Some(1),
"label name cannot be empty",
Some(
"Label definition format: :labelname\nExample: :loop\n :end\n :retry\nNote: Label names are limited to 8 characters (GNU sed compatibility)"
),
)
));
}
if label_name.len() > 8 {
return Err(anyhow!(
"{}",
format_parse_error(
cmd,
None,
&format!("label name '{}' is too long (max 8 characters)", label_name),
Some(&format!(
"Shorten the label name to 8 characters or less.\nSuggestion: {} (truncated)",
&label_name[..8]
)),
)
));
}
Ok(Command::Label {
name: label_name.to_string(),
})
}
fn parse_branch(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let b_pos = cmd
.find('b')
.ok_or_else(|| anyhow!("Branch command missing 'b'"))?;
let address_part = &cmd[..b_pos];
let rest_part = &cmd[b_pos..];
let range = parse_optional_range(address_part)?;
let label_part = &rest_part[1..]; let label = if label_part.trim().is_empty() {
None
} else {
let label_name = label_part.trim();
if !label_name.is_empty() {
Some(label_name.to_string())
} else {
None
}
};
Ok(Command::Branch { label, range })
}
fn parse_test(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let t_pos = cmd
.find('t')
.ok_or_else(|| anyhow!("Test command missing 't'"))?;
let address_part = &cmd[..t_pos];
let rest_part = &cmd[t_pos..];
let range = parse_optional_range(address_part)?;
let label_part = &rest_part[1..]; let label = if label_part.trim().is_empty() {
None
} else {
let label_name = label_part.trim();
if !label_name.is_empty() {
Some(label_name.to_string())
} else {
None
}
};
Ok(Command::Test { label, range })
}
fn parse_test_false(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let t_pos = cmd
.find('T')
.ok_or_else(|| anyhow!("Test false command missing 'T'"))?;
let address_part = &cmd[..t_pos];
let rest_part = &cmd[t_pos..];
let range = parse_optional_range(address_part)?;
let label_part = &rest_part[1..]; let label = if label_part.trim().is_empty() {
None
} else {
let label_name = label_part.trim();
if !label_name.is_empty() {
Some(label_name.to_string())
} else {
None
}
};
Ok(Command::TestFalse { label, range })
}
fn parse_read_file(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let r_pos = cmd
.char_indices()
.find(|&(pos, ch)| ch == 'r' && !is_inside_pattern_address(cmd, pos))
.map(|(pos, _)| pos)
.ok_or_else(|| anyhow!("Read file command missing 'r'"))?;
let address_part = &cmd[..r_pos];
let rest_part = &cmd[r_pos..];
let range = if address_part.trim().is_empty() {
None
} else {
Some(parse_address(address_part.trim())?)
};
let filename_part = &rest_part[1..]; let filename = filename_part.trim();
if filename.is_empty() {
bail!(
"{}",
format_parse_error(
cmd,
None,
"read file command requires a filename",
Some(
"Read file format: [address]r filename\nExample: 5r header.txt - insert contents of header.txt after line 5\n /pat/r data.txt - insert contents after lines matching 'pat'"
),
)
);
}
Ok(Command::ReadFile {
filename: filename.to_string(),
range,
})
}
fn parse_write_file(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let w_pos = cmd
.char_indices()
.find(|&(pos, ch)| ch == 'w' && !is_inside_pattern_address(cmd, pos))
.map(|(pos, _)| pos)
.ok_or_else(|| anyhow!("Write file command missing 'w'"))?;
let address_part = &cmd[..w_pos];
let rest_part = &cmd[w_pos..];
let range = if address_part.trim().is_empty() {
None
} else {
Some(parse_address(address_part.trim())?)
};
let filename_part = &rest_part[1..]; let filename = filename_part.trim();
if filename.is_empty() {
bail!(
"{}",
format_parse_error(
cmd,
None,
"write file command requires a filename",
Some(
"Write file format: [address]w filename\nExample: 5w output.txt - write line 5 to output.txt\n /pat/w log.txt - write matching lines to log.txt"
),
)
);
}
Ok(Command::WriteFile {
filename: filename.to_string(),
range,
})
}
fn parse_read_line(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let r_pos = cmd
.char_indices()
.find(|&(pos, ch)| ch == 'R' && !is_inside_pattern_address(cmd, pos))
.map(|(pos, _)| pos)
.ok_or_else(|| anyhow!("Read line command missing 'R'"))?;
let address_part = &cmd[..r_pos];
let rest_part = &cmd[r_pos..];
let range = if address_part.trim().is_empty() {
None
} else {
Some(parse_address(address_part.trim())?)
};
let filename_part = &rest_part[1..]; let filename = filename_part.trim();
if filename.is_empty() {
bail!(
"{}",
format_parse_error(
cmd,
None,
"read line command requires a filename",
Some(
"Read line format: [address]R filename\nExample: 5R data.txt - append one line from data.txt after line 5\n /pat/R input.txt - append one line after matching lines"
),
)
);
}
Ok(Command::ReadLine {
filename: filename.to_string(),
range,
})
}
fn parse_write_first_line(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let w_pos = cmd
.char_indices()
.find(|&(pos, ch)| ch == 'W' && !is_inside_pattern_address(cmd, pos))
.map(|(pos, _)| pos)
.ok_or_else(|| anyhow!("Write first line command missing 'W'"))?;
let address_part = &cmd[..w_pos];
let rest_part = &cmd[w_pos..];
let range = if address_part.trim().is_empty() {
None
} else {
Some(parse_address(address_part.trim())?)
};
let filename_part = &rest_part[1..]; let filename = filename_part.trim();
if filename.is_empty() {
bail!(
"{}",
format_parse_error(
cmd,
None,
"write first line command requires a filename",
Some(
"Write first line format: [address]W filename\nExample: 5W output.txt - write first line of pattern space to output.txt\n /pat/W log.txt - write first line to log.txt for matches"
),
)
);
}
Ok(Command::WriteFirstLine {
filename: filename.to_string(),
range,
})
}
fn parse_print_line_number(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let eq_pos = cmd
.find('=')
.ok_or_else(|| anyhow!("Print line number command missing '='"))?;
let address_part = &cmd[..eq_pos];
let range = if address_part.trim().is_empty() {
None
} else {
Some(parse_address(address_part.trim())?)
};
Ok(Command::PrintLineNumber { range })
}
fn parse_print_filename(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let f_pos = cmd
.find('F')
.ok_or_else(|| anyhow!("Print filename command missing 'F'"))?;
let address_part = &cmd[..f_pos];
let range = if address_part.trim().is_empty() {
None
} else {
Some(parse_address(address_part.trim())?)
};
Ok(Command::PrintFilename { range })
}
fn parse_clear_pattern_space(cmd: &str) -> Result<Command> {
let cmd = cmd.trim();
let z_pos = cmd
.find('z')
.ok_or_else(|| anyhow!("Clear pattern space command missing 'z'"))?;
let address_part = &cmd[..z_pos];
let range = if address_part.trim().is_empty() {
None
} else {
Some(parse_address(address_part.trim())?)
};
Ok(Command::ClearPatternSpace { range })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::{Address, Command};
#[test]
fn test_parse_simple_substitution() {
let cmd = parse_single_command("s/foo/bar/g").unwrap();
match cmd {
Command::Substitution {
pattern,
replacement,
flags,
range,
} => {
assert_eq!(pattern, "foo");
assert_eq!(replacement, "bar");
assert!(flags.global);
assert!(range.is_none());
}
other => panic!("expected Substitution, got {:?}", other),
}
}
#[test]
fn test_parse_line_substitution() {
let cmd = parse_single_command("10s/foo/bar/").unwrap();
match cmd {
Command::Substitution {
pattern,
replacement,
flags,
range,
} => {
assert_eq!(pattern, "foo");
assert_eq!(replacement, "bar");
assert!(!flags.global);
assert_eq!(
range,
Some((Address::LineNumber(10), Address::LineNumber(10)))
);
}
other => panic!("expected Substitution, got {:?}", other),
}
}
#[test]
fn test_parse_range_substitution() {
let cmd = parse_single_command("1,10s/foo/bar/").unwrap();
match cmd {
Command::Substitution {
pattern,
replacement,
flags,
range,
} => {
assert_eq!(pattern, "foo");
assert_eq!(replacement, "bar");
assert!(!flags.global);
assert_eq!(
range,
Some((Address::LineNumber(1), Address::LineNumber(10)))
);
}
other => panic!("expected Substitution, got {:?}", other),
}
}
#[test]
fn test_parse_delete_line() {
let cmd = parse_single_command("10d").unwrap();
assert_eq!(
cmd,
Command::Delete {
range: (Address::LineNumber(10), Address::LineNumber(10)),
}
);
}
#[test]
fn test_parse_delete_range() {
let cmd = parse_single_command("1,10d").unwrap();
assert_eq!(
cmd,
Command::Delete {
range: (Address::LineNumber(1), Address::LineNumber(10)),
}
);
}
#[test]
fn test_parse_delete_pattern() {
let cmd = parse_single_command("/foo/d").unwrap();
assert_eq!(
cmd,
Command::Delete {
range: (
Address::Pattern("foo".to_string()),
Address::Pattern("foo".to_string())
),
}
);
}
#[test]
fn test_parse_print_line() {
let cmd = parse_single_command("10p").unwrap();
assert_eq!(
cmd,
Command::Print {
range: (Address::LineNumber(10), Address::LineNumber(10)),
}
);
}
#[test]
fn test_parse_print_range() {
let cmd = parse_single_command("1,10p").unwrap();
assert_eq!(
cmd,
Command::Print {
range: (Address::LineNumber(1), Address::LineNumber(10)),
}
);
}
#[test]
fn test_parse_simple_group() {
let cmd = parse_single_command("{s/foo/bar/}").unwrap();
match cmd {
Command::Group { range, commands } => {
assert_eq!(range, None);
assert_eq!(commands.len(), 1);
}
_ => panic!("Expected Group command"),
}
}
#[test]
fn test_parse_group_with_semicolons() {
let cmd = parse_single_command("{s/foo/bar/; s/baz/qux/}").unwrap();
match cmd {
Command::Group { range, commands } => {
assert_eq!(range, None);
assert_eq!(commands.len(), 2);
}
_ => panic!("Expected Group command"),
}
}
#[test]
fn test_parse_hold_simple() {
let cmd = parse_single_command("h").unwrap();
assert_eq!(cmd, Command::Hold { range: None });
}
#[test]
fn test_parse_hold_with_address() {
let cmd = parse_single_command("5h").unwrap();
assert_eq!(
cmd,
Command::Hold {
range: Some((Address::LineNumber(5), Address::LineNumber(5)))
}
);
}
#[test]
fn test_parse_hold_append_with_range() {
let cmd = parse_single_command("1,5H").unwrap();
assert_eq!(
cmd,
Command::HoldAppend {
range: Some((Address::LineNumber(1), Address::LineNumber(5)))
}
);
}
#[test]
fn test_parse_get_append() {
let cmd = parse_single_command("$G").unwrap();
assert_eq!(
cmd,
Command::GetAppend {
range: Some((Address::LastLine, Address::LastLine))
}
);
}
#[test]
fn test_parse_exchange_with_pattern() {
let cmd = parse_single_command("/pattern/x").unwrap();
match cmd {
Command::Exchange {
range: Some((Address::Pattern(p), _)),
} => {
assert_eq!(p, "pattern");
}
_ => panic!("Expected Exchange command with pattern"),
}
}
#[test]
fn test_parse_get_with_negation() {
let cmd = parse_single_command("/foo/!g").unwrap();
match cmd {
Command::Get {
range: Some((Address::Negated(_), _)),
} => {
}
_ => panic!("Expected Get command with negation"),
}
}
#[test]
fn test_parse_hold_range_with_patterns() {
let cmd = parse_single_command("/start/,/end/H").unwrap();
match cmd {
Command::HoldAppend {
range: Some((Address::Pattern(s), Address::Pattern(e))),
} => {
assert_eq!(s, "start");
assert_eq!(e, "end");
}
_ => panic!("Expected HoldAppend with pattern range"),
}
}
#[test]
fn test_parse_get_simple() {
let cmd = parse_single_command("g").unwrap();
assert_eq!(cmd, Command::Get { range: None });
}
#[test]
fn test_parse_exchange_simple() {
let cmd = parse_single_command("x").unwrap();
assert_eq!(cmd, Command::Exchange { range: None });
}
#[test]
fn test_parse_insert_with_text_ending_in_command_char() {
let result = parse_sed_expression("2i\\INSERTED").expect("should parse");
assert_eq!(result.len(), 1);
match &result[0] {
Command::Insert { text, address } => {
assert_eq!(text, "INSERTED");
assert!(matches!(address, Address::LineNumber(2)));
}
other => panic!("expected Insert, got {:?}", other),
}
}
#[test]
fn test_parse_append_with_text_ending_in_command_char() {
let result = parse_sed_expression("5a\\PATCHED").expect("should parse");
assert_eq!(result.len(), 1);
match &result[0] {
Command::Append { text, address } => {
assert_eq!(text, "PATCHED");
assert!(matches!(address, Address::LineNumber(5)));
}
other => panic!("expected Append, got {:?}", other),
}
}
#[test]
fn test_parse_change_pattern_addr_text_ending_h() {
let result = parse_sed_expression("/foo/c\\BAH").expect("should parse");
assert_eq!(result.len(), 1);
match &result[0] {
Command::Change { text, .. } => assert_eq!(text, "BAH"),
other => panic!("expected Change, got {:?}", other),
}
}
#[test]
fn test_parse_insert_with_text_containing_substitution_lookalike() {
let result = parse_sed_expression("2i\\s/foo/bar/g").expect("should parse");
assert_eq!(result.len(), 1);
match &result[0] {
Command::Insert { text, address } => {
assert_eq!(text, "s/foo/bar/g");
assert!(matches!(address, Address::LineNumber(2)));
}
other => panic!("expected Insert, got {:?}", other),
}
}
#[test]
fn test_parse_read_file_with_path_containing_s_slash_segment() {
let result =
parse_sed_expression("R /var/folders/tb/abc/T/.tmpXYZ/data.txt").expect("should parse");
assert_eq!(result.len(), 1);
match &result[0] {
Command::ReadLine { filename, range } => {
assert_eq!(filename, "/var/folders/tb/abc/T/.tmpXYZ/data.txt");
assert!(range.is_none());
}
other => panic!("expected ReadLine, got {:?}", other),
}
}
#[test]
fn test_parse_read_file_with_line_address_and_s_slash_in_path() {
let result = parse_sed_expression("2r /var/folders/xx/yy/extra.txt").expect("should parse");
match &result[0] {
Command::ReadFile { filename, range } => {
assert_eq!(filename, "/var/folders/xx/yy/extra.txt");
assert!(matches!(range, Some(Address::LineNumber(2))));
}
other => panic!("expected ReadFile, got {:?}", other),
}
}
#[test]
fn test_parse_write_file_with_s_slash_in_path_no_address() {
let result = parse_sed_expression("W /var/folders/xx/yy/out.txt").expect("should parse");
match &result[0] {
Command::WriteFirstLine { filename, range } => {
assert_eq!(filename, "/var/folders/xx/yy/out.txt");
assert!(range.is_none());
}
other => panic!("expected WriteFirstLine, got {:?}", other),
}
}
#[test]
fn test_parse_change_with_text_containing_substitution_lookalike() {
let result = parse_sed_expression("2c\\s/foo/bar/g").expect("should parse");
match &result[0] {
Command::Change { text, .. } => assert_eq!(text, "s/foo/bar/g"),
other => panic!("expected Change, got {:?}", other),
}
}
#[test]
fn test_parse_substitution_with_pattern_containing_iac_marker() {
let result = parse_sed_expression("s/i\\foo/bar/").expect("should parse");
assert_eq!(result.len(), 1);
match &result[0] {
Command::Substitution {
pattern,
replacement,
..
} => {
assert_eq!(pattern, "i\\foo");
assert_eq!(replacement, "bar");
}
other => panic!("expected Substitution, got {:?}", other),
}
}
#[test]
fn test_parse_substitution_with_pattern_containing_c_marker() {
let result = parse_sed_expression("s/c\\d+/NUM/").expect("should parse");
match &result[0] {
Command::Substitution { pattern, .. } => {
assert_eq!(pattern, "c\\d+");
}
other => panic!("expected Substitution, got {:?}", other),
}
}
#[test]
fn test_parse_substitution_with_address_and_iac_in_pattern() {
let result = parse_sed_expression("5s/a\\b/X/").expect("should parse");
match &result[0] {
Command::Substitution { pattern, range, .. } => {
assert_eq!(pattern, "a\\b");
assert!(matches!(range, Some((Address::LineNumber(5), _))));
}
other => panic!("expected Substitution, got {:?}", other),
}
}
#[test]
fn test_parse_insert_strips_leading_newline() {
let result = parse_sed_expression("2i\\\nINSERTED").expect("should parse");
match &result[0] {
Command::Insert { text, .. } => assert_eq!(text, "INSERTED"),
other => panic!("expected Insert, got {:?}", other),
}
}
#[test]
fn test_parse_append_strips_leading_newline() {
let result = parse_sed_expression("3a\\\nPATCHED").expect("should parse");
match &result[0] {
Command::Append { text, .. } => assert_eq!(text, "PATCHED"),
other => panic!("expected Append, got {:?}", other),
}
}
#[test]
fn test_parse_change_strips_leading_newline() {
let result = parse_sed_expression("4c\\\nREPLACED").expect("should parse");
match &result[0] {
Command::Change { text, .. } => assert_eq!(text, "REPLACED"),
other => panic!("expected Change, got {:?}", other),
}
}
#[test]
fn test_parse_insert_inline_form_unchanged() {
let result = parse_sed_expression("2i\\INSERTED").expect("should parse");
match &result[0] {
Command::Insert { text, .. } => assert_eq!(text, "INSERTED"),
other => panic!("expected Insert, got {:?}", other),
}
}
#[test]
fn test_parse_insert_empty_text_after_multiline_separator() {
let result = parse_sed_expression("2i\\\n").expect("should parse");
match &result[0] {
Command::Insert { text, .. } => assert_eq!(text, ""),
other => panic!("expected Insert, got {:?}", other),
}
}
#[test]
fn test_parse_change_with_range() {
let result = parse_sed_expression("2,3c\\REPLACED").expect("should parse");
assert_eq!(result.len(), 1);
match &result[0] {
Command::Change { text, range } => {
assert_eq!(text, "REPLACED");
assert!(matches!(range.0, Address::LineNumber(2)));
assert!(matches!(range.1, Address::LineNumber(3)));
}
other => panic!("expected Change with range, got {:?}", other),
}
}
#[test]
fn test_parse_change_single_address_uses_same_addr_for_both() {
let result = parse_sed_expression("5c\\REPLACED").expect("should parse");
match &result[0] {
Command::Change { range, .. } => {
assert!(matches!(range.0, Address::LineNumber(5)));
assert!(matches!(range.1, Address::LineNumber(5)));
}
other => panic!("expected Change, got {:?}", other),
}
}
#[test]
fn parse_pattern_addr_r_with_absolute_path() {
let commands = parse_sed_expression("/err/r /etc/hosts").unwrap();
assert_eq!(commands.len(), 1);
match &commands[0] {
Command::ReadFile { filename, range } => {
assert_eq!(filename, "/etc/hosts");
assert!(range.is_some());
}
other => panic!("expected ReadFile, got {:?}", other),
}
}
#[test]
fn parse_pattern_addr_w_with_absolute_path() {
let commands = parse_sed_expression("/err/w /tmp/out.log").unwrap();
assert_eq!(commands.len(), 1);
match &commands[0] {
Command::WriteFile { filename, range } => {
assert_eq!(filename, "/tmp/out.log");
assert!(range.is_some());
}
other => panic!("expected WriteFile, got {:?}", other),
}
}
#[test]
fn parse_pattern_addr_capital_w_with_absolute_path() {
let commands = parse_sed_expression("/err/W /var/log/first.log").unwrap();
assert_eq!(commands.len(), 1);
match &commands[0] {
Command::WriteFirstLine { filename, range } => {
assert_eq!(filename, "/var/log/first.log");
assert!(range.is_some());
}
other => panic!("expected WriteFirstLine, got {:?}", other),
}
}
#[test]
fn parse_pattern_addr_with_w_in_pattern() {
let commands = parse_sed_expression("/wrong/w /tmp/out.log").unwrap();
assert_eq!(commands.len(), 1);
match &commands[0] {
Command::WriteFile { filename, range } => {
assert_eq!(filename, "/tmp/out.log");
assert!(range.is_some());
}
other => panic!("expected WriteFile, got {:?}", other),
}
}
#[test]
fn parse_pattern_addr_with_capital_r_in_pattern() {
let commands = parse_sed_expression("/wRong/R data.txt").unwrap();
assert_eq!(commands.len(), 1);
match &commands[0] {
Command::ReadLine { filename, range } => {
assert_eq!(filename, "data.txt");
assert!(range.is_some());
}
other => panic!("expected ReadLine, got {:?}", other),
}
}
#[test]
fn parse_pattern_addr_with_capital_w_in_pattern() {
let commands = parse_sed_expression("/Wrong/W /var/log/first.log").unwrap();
assert_eq!(commands.len(), 1);
match &commands[0] {
Command::WriteFirstLine { filename, range } => {
assert_eq!(filename, "/var/log/first.log");
assert!(range.is_some());
}
other => panic!("expected WriteFirstLine, got {:?}", other),
}
}
#[test]
fn parse_pattern_addr_with_escaped_slash_in_pattern() {
let commands = parse_sed_expression("/foo\\/bar/r /tmp/out.txt").unwrap();
assert_eq!(commands.len(), 1);
match &commands[0] {
Command::ReadFile { filename, range } => {
assert_eq!(filename, "/tmp/out.txt");
assert!(range.is_some());
}
other => panic!("expected ReadFile, got {:?}", other),
}
}
}