Skip to content

Commit 81ac7b8

Browse files
committed
[ed] command lists
Phase 3.5: a/i/c with Input in Global Command Lists POSIX Requirement: The a, i, and c commands can include text lines embedded in a global (g/v) command list, terminated by . (or optional if last command). Changes Made: 1. editors/ed/editor.rs: - Added GlobalCommand struct to represent a parsed command with optional embedded input lines - Added find_command_char() helper to identify command character skipping address prefix - Added parse_global_command_list() to parse multi-line command lists and extract a/i/c input - Added execute_input_command_with_lines() helper to execute a/i/c with pre-supplied input - Modified execute_global() to use the new parsing and handle embedded input - Added line offset tracking to correctly adjust line numbers when insert/append operations shift buffer lines 2. editors/tests/ed/mod.rs: - Added 6 test cases for global append/insert/change with embedded input - Tests cover: basic append/insert/change, optional . terminator, multiple input lines, mixed commands
1 parent e7f90cf commit 81ac7b8

File tree

2 files changed

+266
-56
lines changed

2 files changed

+266
-56
lines changed

editors/ed/editor.rs

Lines changed: 206 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,89 @@ use regex::Regex;
1616
use std::io::{self, BufRead, Write};
1717
use std::sync::atomic::Ordering;
1818

19+
/// Parsed command with optional embedded input for a/i/c in global commands.
20+
struct GlobalCommand {
21+
/// The command text (e.g., "a", "s/foo/bar/", "d")
22+
command: String,
23+
/// For a/i/c: the input text lines
24+
input_lines: Vec<String>,
25+
}
26+
27+
/// Find the command character in a line (skipping address prefix).
28+
fn find_command_char(line: &str) -> Option<char> {
29+
// Skip leading whitespace and address components
30+
// Look for first command letter
31+
for ch in line.chars() {
32+
if ch.is_ascii_alphabetic() {
33+
return Some(ch);
34+
}
35+
// Continue past address components
36+
if ch.is_ascii_digit()
37+
|| ch == '.'
38+
|| ch == '$'
39+
|| ch == '\''
40+
|| ch == '/'
41+
|| ch == '?'
42+
|| ch == '+'
43+
|| ch == '-'
44+
|| ch == ','
45+
|| ch == ';'
46+
|| ch.is_whitespace()
47+
{
48+
continue;
49+
}
50+
// Stop at other characters
51+
break;
52+
}
53+
None
54+
}
55+
56+
/// Parse a global command list string into individual commands.
57+
/// Handles a/i/c commands with embedded input.
58+
fn parse_global_command_list(commands: &str) -> Vec<GlobalCommand> {
59+
let lines: Vec<&str> = commands.split('\n').collect();
60+
let mut result = Vec::new();
61+
let mut i = 0;
62+
63+
while i < lines.len() {
64+
let line = lines[i];
65+
if line.is_empty() {
66+
i += 1;
67+
continue;
68+
}
69+
70+
// Check if this is an a, i, or c command (possibly with address prefix)
71+
let cmd_char = find_command_char(line);
72+
73+
if matches!(cmd_char, Some('a') | Some('i') | Some('c')) {
74+
// Collect input lines until '.' or end of list
75+
let mut input_lines = Vec::new();
76+
i += 1;
77+
while i < lines.len() {
78+
let input_line = lines[i];
79+
if input_line == "." {
80+
i += 1;
81+
break;
82+
}
83+
input_lines.push(format!("{}\n", input_line));
84+
i += 1;
85+
}
86+
result.push(GlobalCommand {
87+
command: line.to_string(),
88+
input_lines,
89+
});
90+
} else {
91+
result.push(GlobalCommand {
92+
command: line.to_string(),
93+
input_lines: Vec::new(),
94+
});
95+
i += 1;
96+
}
97+
}
98+
99+
result
100+
}
101+
19102
/// The ed editor state.
20103
pub struct Editor<R: BufRead, W: Write> {
21104
/// The text buffer
@@ -1065,6 +1148,34 @@ impl<R: BufRead, W: Write> Editor<R, W> {
10651148
Ok(())
10661149
}
10671150

1151+
/// Execute an a/i/c command with pre-supplied input lines (for global commands).
1152+
/// This is used when a/i/c commands appear in g/v command lists with embedded input.
1153+
fn execute_input_command_with_lines(
1154+
&mut self,
1155+
command: &str,
1156+
input_lines: &[String],
1157+
) -> EdResult<()> {
1158+
// Parse the command to get the address and command type
1159+
match parse(command) {
1160+
Ok(Command::Append(addr)) => {
1161+
let line_num = self.resolve_address(&addr)?;
1162+
self.buf.append(line_num, input_lines);
1163+
Ok(())
1164+
}
1165+
Ok(Command::Insert(addr)) => {
1166+
let line_num = self.resolve_address(&addr)?;
1167+
self.buf.insert(line_num, input_lines);
1168+
Ok(())
1169+
}
1170+
Ok(Command::Change(addr1, addr2)) => {
1171+
let (start, end) = self.resolve_range(&addr1, &addr2)?;
1172+
self.buf.change(start, end, input_lines)?;
1173+
Ok(())
1174+
}
1175+
_ => Err(EdError::Generic("invalid command".to_string())),
1176+
}
1177+
}
1178+
10681179
/// Execute a global command.
10691180
fn execute_global(
10701181
&mut self,
@@ -1116,96 +1227,135 @@ impl<R: BufRead, W: Write> Editor<R, W> {
11161227
let original_cur_line = self.buf.cur_line;
11171228
let mut last_successful_line = original_cur_line;
11181229

1230+
// Parse the command list into individual commands (handles a/i/c with embedded input)
1231+
let global_commands = parse_global_command_list(commands);
1232+
11191233
// For delete commands, process in reverse order to avoid line number shifts
1120-
let is_delete = commands.trim() == "d";
1121-
if is_delete {
1234+
let is_delete_only = global_commands.len() == 1 && global_commands[0].command.trim() == "d";
1235+
if is_delete_only {
11221236
matching_lines.reverse();
11231237
}
11241238

1239+
// Track line offset for insert/append operations
1240+
// When we insert N lines, subsequent matching line numbers shift by N
1241+
let mut line_offset: isize = 0;
1242+
11251243
// Execute commands on each matching line
11261244
for line_num in matching_lines {
1127-
// Adjust line_num for lines that may have been deleted
1128-
// For non-delete commands, we need to track line number changes
1129-
let adjusted_line = if is_delete {
1245+
// Adjust line_num for lines that may have been deleted or shifted
1246+
let adjusted_line = if is_delete_only {
11301247
line_num
11311248
} else {
1132-
// For other commands, check if line still exists
1133-
if line_num > self.buf.line_count() {
1249+
// Apply offset for lines that shifted due to insertions
1250+
let shifted = (line_num as isize + line_offset) as usize;
1251+
// Check if line still exists
1252+
if shifted > self.buf.line_count() || shifted == 0 {
11341253
continue;
11351254
}
1136-
line_num
1255+
shifted
11371256
};
11381257

11391258
// Set current line
11401259
if self.buf.set_cur_line(adjusted_line).is_err() {
11411260
continue; // Line may have been deleted
11421261
}
11431262

1144-
// Parse and execute the command
1145-
match commands.trim() {
1146-
"p" => {
1147-
if let Some(line) = self.buf.get_line(self.buf.cur_line) {
1148-
write!(self.writer, "{}", line)?;
1263+
// Track line count before command to detect insertions
1264+
let line_count_before = self.buf.line_count();
1265+
1266+
// Execute each command in the command list
1267+
for gc in &global_commands {
1268+
if gc.command.is_empty() {
1269+
continue;
1270+
}
1271+
1272+
let cmd = gc.command.trim();
1273+
1274+
// Check if this is a/i/c with embedded input
1275+
let cmd_char = find_command_char(cmd);
1276+
if matches!(cmd_char, Some('a') | Some('i') | Some('c'))
1277+
&& !gc.input_lines.is_empty()
1278+
{
1279+
// Execute a/i/c with embedded input directly
1280+
if let Err(e) = self.execute_input_command_with_lines(cmd, &gc.input_lines) {
1281+
self.buf.end_global();
1282+
return Err(e);
11491283
}
11501284
last_successful_line = self.buf.cur_line;
1285+
continue;
11511286
}
1152-
"d" => {
1153-
let cur = self.buf.cur_line;
1154-
if self.buf.delete(cur, cur).is_ok() {
1287+
1288+
// Handle common simple commands inline for performance
1289+
match cmd {
1290+
"p" => {
1291+
if let Some(line) = self.buf.get_line(self.buf.cur_line) {
1292+
write!(self.writer, "{}", line)?;
1293+
}
11551294
last_successful_line = self.buf.cur_line;
11561295
}
1157-
}
1158-
"n" => {
1159-
if let Some(line) = self.buf.get_line(self.buf.cur_line) {
1160-
let content = line.trim_end_matches('\n');
1161-
writeln!(self.writer, "{:6}\t{}", self.buf.cur_line, content)?;
1296+
"d" => {
1297+
let cur = self.buf.cur_line;
1298+
if self.buf.delete(cur, cur).is_ok() {
1299+
last_successful_line = self.buf.cur_line;
1300+
}
11621301
}
1163-
last_successful_line = self.buf.cur_line;
1164-
}
1165-
"l" => {
1166-
if let Some(line) = self.buf.get_line(self.buf.cur_line) {
1167-
let content = line.trim_end_matches('\n');
1168-
for ch in content.chars() {
1169-
match ch {
1170-
'\\' => write!(self.writer, "\\\\")?,
1171-
'\x07' => write!(self.writer, "\\a")?,
1172-
'\x08' => write!(self.writer, "\\b")?,
1173-
'\x0c' => write!(self.writer, "\\f")?,
1174-
'\r' => write!(self.writer, "\\r")?,
1175-
'\t' => write!(self.writer, "\\t")?,
1176-
'\x0b' => write!(self.writer, "\\v")?,
1177-
'$' => write!(self.writer, "\\$")?,
1178-
c if c.is_control() || !c.is_ascii() => {
1179-
for byte in c.to_string().as_bytes() {
1180-
write!(self.writer, "\\{:03o}", byte)?;
1302+
"n" => {
1303+
if let Some(line) = self.buf.get_line(self.buf.cur_line) {
1304+
let content = line.trim_end_matches('\n');
1305+
writeln!(self.writer, "{:6}\t{}", self.buf.cur_line, content)?;
1306+
}
1307+
last_successful_line = self.buf.cur_line;
1308+
}
1309+
"l" => {
1310+
if let Some(line) = self.buf.get_line(self.buf.cur_line) {
1311+
let content = line.trim_end_matches('\n');
1312+
for ch in content.chars() {
1313+
match ch {
1314+
'\\' => write!(self.writer, "\\\\")?,
1315+
'\x07' => write!(self.writer, "\\a")?,
1316+
'\x08' => write!(self.writer, "\\b")?,
1317+
'\x0c' => write!(self.writer, "\\f")?,
1318+
'\r' => write!(self.writer, "\\r")?,
1319+
'\t' => write!(self.writer, "\\t")?,
1320+
'\x0b' => write!(self.writer, "\\v")?,
1321+
'$' => write!(self.writer, "\\$")?,
1322+
c if c.is_control() || !c.is_ascii() => {
1323+
for byte in c.to_string().as_bytes() {
1324+
write!(self.writer, "\\{:03o}", byte)?;
1325+
}
11811326
}
1327+
c => write!(self.writer, "{}", c)?,
11821328
}
1183-
c => write!(self.writer, "{}", c)?,
11841329
}
1330+
writeln!(self.writer, "$")?;
11851331
}
1186-
writeln!(self.writer, "$")?;
1332+
last_successful_line = self.buf.cur_line;
11871333
}
1188-
last_successful_line = self.buf.cur_line;
1189-
}
1190-
cmd if !cmd.is_empty() => {
1191-
// Try to parse and execute other commands
1192-
match parse(cmd) {
1193-
Ok(parsed_cmd) => {
1194-
if let Err(e) = self.execute_command(parsed_cmd) {
1195-
// Abort global on error and propagate
1334+
_ => {
1335+
// Try to parse and execute other commands
1336+
match parse(cmd) {
1337+
Ok(parsed_cmd) => {
1338+
if let Err(e) = self.execute_command(parsed_cmd) {
1339+
// Abort global on error and propagate
1340+
self.buf.end_global();
1341+
return Err(e);
1342+
}
1343+
last_successful_line = self.buf.cur_line;
1344+
}
1345+
Err(e) => {
1346+
// Abort global on parse error
11961347
self.buf.end_global();
11971348
return Err(e);
11981349
}
1199-
last_successful_line = self.buf.cur_line;
1200-
}
1201-
Err(e) => {
1202-
// Abort global on parse error
1203-
self.buf.end_global();
1204-
return Err(e);
12051350
}
12061351
}
12071352
}
1208-
_ => {}
1353+
}
1354+
1355+
// Update line offset based on how many lines were added/removed
1356+
if !is_delete_only {
1357+
let line_count_after = self.buf.line_count();
1358+
line_offset += line_count_after as isize - line_count_before as isize;
12091359
}
12101360
}
12111361

editors/tests/ed/mod.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,3 +1332,63 @@ fn test_ed_list_long_line_folding() {
13321332
let expected = format!("{}\\\n{}$\n", "a".repeat(72), "a".repeat(8));
13331333
ed_test(&stdin, &expected);
13341334
}
1335+
1336+
// ============================================================================
1337+
// POSIX Compliance: a/i/c with Input in Global Command Lists (Phase 3.5)
1338+
// ============================================================================
1339+
1340+
#[test]
1341+
fn test_ed_global_append_with_input() {
1342+
// g/pattern/a with embedded input should append text after each match
1343+
// Input: g/foo/a\<newline>appended line\<newline>.
1344+
// This becomes: "a\nappended line\n." after line continuation processing
1345+
ed_test(
1346+
"a\nfoo\nbar\nfoo2\n.\ng/foo/a\\\nappended line\\\n.\n1,$p\nQ\n",
1347+
"foo\nappended line\nbar\nfoo2\nappended line\n",
1348+
);
1349+
}
1350+
1351+
#[test]
1352+
fn test_ed_global_insert_with_input() {
1353+
// g/pattern/i with embedded input should insert before each match
1354+
ed_test(
1355+
"a\nfoo\nbar\nfoo2\n.\ng/foo/i\\\ninserted line\\\n.\n1,$p\nQ\n",
1356+
"inserted line\nfoo\nbar\ninserted line\nfoo2\n",
1357+
);
1358+
}
1359+
1360+
#[test]
1361+
fn test_ed_global_change_with_input() {
1362+
// g/pattern/c with embedded input should replace each match
1363+
ed_test(
1364+
"a\nfoo\nbar\nfoo2\n.\ng/foo/c\\\nreplacement\\\n.\n1,$p\nQ\n",
1365+
"replacement\nbar\nreplacement\n",
1366+
);
1367+
}
1368+
1369+
#[test]
1370+
fn test_ed_global_append_no_terminator() {
1371+
// POSIX: Last command can omit the '.' terminator
1372+
ed_test(
1373+
"a\nfoo\nbar\n.\ng/foo/a\\\nappended line\n1,$p\nQ\n",
1374+
"foo\nappended line\nbar\n",
1375+
);
1376+
}
1377+
1378+
#[test]
1379+
fn test_ed_global_append_multiple_lines() {
1380+
// Multiple input lines for a single append
1381+
ed_test(
1382+
"a\nfoo\n.\ng/foo/a\\\nline1\\\nline2\\\nline3\\\n.\n1,$p\nQ\n",
1383+
"foo\nline1\nline2\nline3\n",
1384+
);
1385+
}
1386+
1387+
#[test]
1388+
fn test_ed_global_mixed_commands_with_append() {
1389+
// Mix of regular command and append with input
1390+
ed_test(
1391+
"a\nfoo\nbar\n.\ng/foo/s/foo/baz/\\\na\\\nappended\\\n.\n1,$p\nQ\n",
1392+
"baz\nappended\nbar\n",
1393+
);
1394+
}

0 commit comments

Comments
 (0)