diff --git a/Cargo.lock b/Cargo.lock
index 89006652..aa3f0a3b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -44,12 +44,55 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "anstream"
+version = "0.6.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
 [[package]]
 name = "anstyle"
 version = "1.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
 
+[[package]]
+name = "anstyle-parse"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19"
+dependencies = [
+ "anstyle",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "ar"
 version = "0.9.0"
@@ -177,8 +220,10 @@ version = "4.5.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6fb8393d67ba2e7bfaf28a23458e4e2b543cc73a99595511eb207fdb8aede942"
 dependencies = [
+ "anstream",
  "anstyle",
  "clap_lex",
+ "strsim",
 ]
 
 [[package]]
@@ -208,6 +253,12 @@ dependencies = [
  "error-code",
 ]
 
+[[package]]
+name = "colorchoice"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
+
 [[package]]
 name = "core-foundation-sys"
 version = "0.8.6"
@@ -562,6 +613,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
+
 [[package]]
 name = "itoa"
 version = "1.0.11"
@@ -1021,6 +1078,15 @@ dependencies = [
  "plib",
 ]
 
+[[package]]
+name = "posixutils-edvi"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "gettext-rs",
+ "plib",
+]
+
 [[package]]
 name = "posixutils-file"
 version = "0.2.0"
@@ -1372,6 +1438,12 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
 
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
 [[package]]
 name = "syn"
 version = "2.0.71"
diff --git a/Cargo.toml b/Cargo.toml
index 38f8db69..1c1f591e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ members = [
 	"datetime",
 	"dev",
 	"display",
+	"edvi",
 	"file",
 	"fs",
 	"misc",
diff --git a/edvi/Cargo.toml b/edvi/Cargo.toml
new file mode 100644
index 00000000..af85e267
--- /dev/null
+++ b/edvi/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "posixutils-edvi"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+plib = { path = "../plib" }
+clap = { version = "4", features = ["derive"] }
+gettext-rs = { version = "0.7", features = ["gettext-system"] }
+
+[[bin]]
+name = "ed"
+path = "./ed.rs"
+
diff --git a/edvi/buffer.rs b/edvi/buffer.rs
new file mode 100644
index 00000000..8d8e8809
--- /dev/null
+++ b/edvi/buffer.rs
@@ -0,0 +1,237 @@
+//
+// Copyright (c) 2024 Jeff Garzik
+//
+// This file is part of the posixutils-rs project covered under
+// the MIT License.  For the full license text, please see the LICENSE
+// file in the root directory of this project.
+// SPDX-License-Identifier: MIT
+//
+
+use std::fs;
+use std::io::{self, Write};
+
+#[derive(Debug)]
+pub struct Buffer {
+    pub pathname: String,
+
+    pub cur_line: usize,
+
+    lines: Vec<String>,
+}
+
+impl Buffer {
+    pub fn new() -> Buffer {
+        Buffer {
+            pathname: String::new(),
+            cur_line: 0,
+            lines: Vec::new(),
+        }
+    }
+
+    pub fn last_line(&self) -> usize {
+        self.lines.len()
+    }
+
+    pub fn set_cur_line(&mut self, line: usize) {
+        assert!(line <= self.last_line());
+        self.cur_line = line;
+    }
+
+    pub fn append(&mut self, other: &mut Vec<String>) {
+        self.lines.append(other);
+
+        if self.cur_line == 0 && self.lines.len() > 0 {
+            self.cur_line = 1;
+        }
+    }
+
+    pub fn len(&self) -> usize {
+        let mut len = 0;
+        for line in &self.lines {
+            len += line.len();
+        }
+
+        len
+    }
+
+    pub fn insert(&mut self, line_no: usize, insert_before: bool, data: &mut Vec<String>) {
+        if line_no == 0 {
+            assert!(insert_before == false, "Cannot insert before line 0");
+            self.lines.splice(0..0, data.clone());
+            if self.lines.len() > 0 {
+                self.cur_line = 1;
+            }
+        } else if insert_before {
+            assert!(line_no <= self.lines.len(), "Line number out of bounds");
+            self.lines.splice(line_no - 1..line_no - 1, data.clone());
+        } else {
+            assert!(line_no <= self.lines.len(), "Line number out of bounds");
+            self.lines.splice(line_no..line_no, data.clone());
+        }
+
+        if self.cur_line == 0 && self.lines.len() > 0 {
+            self.cur_line = 1;
+        }
+    }
+
+    pub fn write_lines<W: Write>(
+        &self,
+        start_line: usize,
+        end_line: usize,
+        writer: &mut W,
+    ) -> io::Result<()> {
+        for line in &self.lines[start_line - 1..end_line] {
+            writer.write_all(line.as_bytes())?;
+        }
+        Ok(())
+    }
+
+    pub fn write_to_file(
+        &self,
+        start_line: usize,
+        end_line: usize,
+        pathname: &str,
+    ) -> io::Result<()> {
+        let mut file = fs::File::create(pathname)?;
+        self.write_lines(start_line, end_line, &mut file)
+    }
+
+    /// Deletes lines from start to end (inclusive) from the buffer.
+    pub fn delete(&mut self, start: usize, end: usize) {
+        assert!(
+            start > 0 && start <= end && end <= self.lines.len(),
+            "Invalid line range"
+        );
+        self.lines.drain(start - 1..end);
+        if self.cur_line > self.lines.len() {
+            self.cur_line = self.lines.len();
+        }
+    }
+
+    // Helper function to generate test data
+    #[cfg(test)]
+    fn generate_test_data(data: &[&str]) -> Buffer {
+        let lines: Vec<String> = data.iter().map(|&s| s.to_string()).collect();
+        Buffer {
+            pathname: String::new(),
+            cur_line: if lines.is_empty() { 0 } else { 1 },
+            lines,
+        }
+    }
+
+    // Helper function to compare Vec<String> with &[&str]
+    #[cfg(test)]
+    fn compare_lines(lines: &Vec<String>, expected: &[&str]) {
+        let expected_strings: Vec<String> = expected.iter().map(|&s| s.to_string()).collect();
+        assert_eq!(lines, &expected_strings);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_insert_at_start() {
+        let mut buffer = Buffer::new();
+        let mut data = vec!["line 1".to_string(), "line 2".to_string()];
+        buffer.insert(0, false, &mut data);
+
+        assert_eq!(
+            buffer.lines,
+            vec!["line 1".to_string(), "line 2".to_string()]
+        );
+        assert_eq!(buffer.cur_line, 1);
+    }
+
+    #[test]
+    fn test_insert_before_line() {
+        let mut buffer = Buffer::new();
+        buffer.lines = vec!["line 3".to_string(), "line 4".to_string()];
+        let mut data = vec!["line 1".to_string(), "line 2".to_string()];
+        buffer.insert(1, true, &mut data);
+
+        assert_eq!(
+            buffer.lines,
+            vec![
+                "line 1".to_string(),
+                "line 2".to_string(),
+                "line 3".to_string(),
+                "line 4".to_string()
+            ]
+        );
+    }
+
+    #[test]
+    fn test_insert_after_line() {
+        let mut buffer = Buffer::new();
+        buffer.lines = vec!["line 1".to_string(), "line 3".to_string()];
+        let mut data = vec!["line 2".to_string()];
+        buffer.insert(1, false, &mut data);
+
+        assert_eq!(
+            buffer.lines,
+            vec![
+                "line 1".to_string(),
+                "line 2".to_string(),
+                "line 3".to_string()
+            ]
+        );
+    }
+
+    #[test]
+    fn test_insert_at_end() {
+        let mut buffer = Buffer::new();
+        buffer.lines = vec!["line 1".to_string(), "line 2".to_string()];
+        let mut data = vec!["line 3".to_string(), "line 4".to_string()];
+        buffer.insert(2, false, &mut data);
+
+        assert_eq!(
+            buffer.lines,
+            vec![
+                "line 1".to_string(),
+                "line 2".to_string(),
+                "line 3".to_string(),
+                "line 4".to_string()
+            ]
+        );
+    }
+
+    #[test]
+    #[should_panic]
+    fn test_insert_invalid_line() {
+        let mut buffer = Buffer::new();
+        let mut data = vec!["line 1".to_string()];
+        buffer.insert(1, true, &mut data);
+    }
+
+    const TEST_DATA: &[&str] = &["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"];
+
+    #[test]
+    fn test_delete_single_line() {
+        let mut buffer = Buffer::generate_test_data(TEST_DATA);
+        buffer.delete(2, 2);
+        Buffer::compare_lines(&buffer.lines, &["Line 1", "Line 3", "Line 4", "Line 5"]);
+    }
+
+    #[test]
+    fn test_delete_multiple_lines() {
+        let mut buffer = Buffer::generate_test_data(TEST_DATA);
+        buffer.delete(2, 3);
+        Buffer::compare_lines(&buffer.lines, &["Line 1", "Line 4", "Line 5"]);
+    }
+
+    #[test]
+    fn test_delete_all_lines() {
+        let mut buffer = Buffer::generate_test_data(TEST_DATA);
+        buffer.delete(1, 5);
+        Buffer::compare_lines(&buffer.lines, &[]);
+    }
+
+    #[test]
+    fn test_delete_invalid_range() {
+        let mut buffer = Buffer::generate_test_data(TEST_DATA);
+        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| buffer.delete(0, 2)));
+        assert!(result.is_err());
+    }
+}
diff --git a/edvi/command.rs b/edvi/command.rs
new file mode 100644
index 00000000..a97fa960
--- /dev/null
+++ b/edvi/command.rs
@@ -0,0 +1,951 @@
+//
+// Copyright (c) 2024 Jeff Garzik
+//
+// This file is part of the posixutils-rs project covered under
+// the MIT License.  For the full license text, please see the LICENSE
+// file in the root directory of this project.
+// SPDX-License-Identifier: MIT
+//
+
+use std::iter;
+
+#[derive(Debug)]
+struct SyntaxError {
+    message: String,
+}
+
+#[derive(Debug, PartialEq)]
+enum Token {
+    CurrentLine,
+    LastLine,
+    Number(u64),
+    Mark(char),
+    RegexForward(String),
+    RegexBack(String),
+    Offset(isize),
+
+    AddressSeparator(char),
+    Command(char),
+}
+
+impl SyntaxError {
+    fn new(message: String) -> Self {
+        SyntaxError { message }
+    }
+}
+
+fn tokenizer(input: &str) -> Result<Vec<Token>, SyntaxError> {
+    let mut tokens: Vec<Token> = Vec::new();
+    let mut iter = input.chars().peekable();
+
+    while let Some(ch) = iter.next() {
+        match ch {
+            ch if ch.is_whitespace() => continue,
+            '.' => tokens.push(Token::CurrentLine),
+            '$' => tokens.push(Token::LastLine),
+            'a'..='z' | 'A'..='Z' => {
+                tokens.push(Token::Command(ch));
+            }
+            '0'..='9' => {
+                let n: u64 = iter::once(ch)
+                    .chain(iter::from_fn(|| {
+                        iter.by_ref().next_if(|s| s.is_ascii_digit())
+                    }))
+                    .collect::<String>()
+                    .parse()
+                    .unwrap();
+
+                tokens.push(Token::Number(n));
+            }
+            '\'' => match iter.next() {
+                None => return Err(SyntaxError::new(String::from("missing mark char"))),
+                Some(mark_ch) => {
+                    if mark_ch.is_ascii_alphabetic() {
+                        tokens.push(Token::Mark(mark_ch));
+                    } else {
+                        return Err(SyntaxError::new(format!(
+                            "unrecognized mark character {}",
+                            ch
+                        )));
+                    }
+                }
+            },
+            ',' | ';' => {
+                tokens.push(Token::AddressSeparator(ch));
+            }
+            '/' | '?' => {
+                let mut bre = String::new();
+                let mut escaped = false;
+                loop {
+                    let bre_ch_res = iter.next();
+                    if bre_ch_res.is_none() {
+                        return Err(SyntaxError::new(String::from("unterminated regex")));
+                    }
+
+                    let bre_ch = bre_ch_res.unwrap();
+                    match bre_ch {
+                        // regex terminator
+                        '/' | '?' => {
+                            if escaped {
+                                bre.push(bre_ch);
+                            } else {
+                                if ch == '/' {
+                                    tokens.push(Token::RegexForward(bre));
+                                } else {
+                                    tokens.push(Token::RegexBack(bre));
+                                }
+                                break;
+                            }
+                        }
+
+                        // escape char
+                        '\\' => {
+                            if escaped {
+                                bre.push(bre_ch);
+                            } else {
+                                escaped = true;
+                            }
+                        }
+
+                        // everything else
+                        _ => bre.push(bre_ch),
+                    }
+                }
+            }
+
+            '+' | '-' => {
+                let sign = ch;
+                let mut offset = String::new();
+                offset.push(sign);
+                while let Some(&next_ch) = iter.peek() {
+                    if next_ch.is_ascii_digit() {
+                        offset.push(iter.next().unwrap());
+                    } else {
+                        break;
+                    }
+                }
+
+                let n: isize = if offset.len() == 1 {
+                    // No digits followed, so it's an implicit +1 or -1
+                    if sign == '+' {
+                        1
+                    } else {
+                        -1
+                    }
+                } else {
+                    offset
+                        .parse()
+                        .map_err(|_| SyntaxError::new(String::from("invalid offset")))?
+                };
+
+                tokens.push(Token::Offset(n));
+            }
+            _ => return Err(SyntaxError::new(format!("unrecognized character {}", ch))),
+        }
+    }
+
+    Ok(tokens)
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum AddressInfo {
+    Null,
+    Current,
+    Last,
+    Line(usize),
+    Mark(char),
+    RegexForward(String),
+    RegexBack(String),
+    Offset(isize),
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Address {
+    pub info: AddressInfo,
+    pub offsets: Vec<isize>,
+}
+
+impl Address {
+    fn new() -> Self {
+        Address {
+            info: AddressInfo::Null,
+            offsets: Vec::new(),
+        }
+    }
+
+    fn add_offset(&mut self, offset: isize) {
+        self.offsets.push(offset);
+    }
+}
+
+#[derive(Copy, Clone)]
+pub enum DefaultAddressing {
+    Current,
+    Full,
+    None,
+}
+
+pub const DEFAULT_ADDRESSING: [DefaultAddressing; 256] = {
+    let mut array = [DefaultAddressing::None; 256];
+
+    array[b'd' as usize] = DefaultAddressing::Current; // 'd' -> Default to current line
+    array[b'p' as usize] = DefaultAddressing::Current; // 'p' -> Default to current line
+    array[b'w' as usize] = DefaultAddressing::Full; // 'w' -> Default to full range
+
+    array
+};
+
+impl DefaultAddressing {
+    pub fn get_range(&self) -> (AddressInfo, AddressInfo) {
+        match self {
+            DefaultAddressing::Current => (AddressInfo::Current, AddressInfo::Current),
+            DefaultAddressing::Full => (AddressInfo::Line(1), AddressInfo::Last),
+            DefaultAddressing::None => (AddressInfo::Null, AddressInfo::Null),
+        }
+    }
+}
+
+#[derive(Debug, PartialEq)]
+pub enum Command {
+    Insert(Address, bool),
+    // Change(String),
+    // Copy(usize),
+    Delete(Address, Address),
+    // Global(String, String, bool, bool, bool),
+    // GlobalNotMatched(String, Vec<Command>),
+    // InteractiveGlobalNotMatched(String, Vec<Command>),
+    // Move(isize),
+    // NoOp,
+    Print(PrintMode, Address, Address),
+    // Read(String),
+    Quit,
+    Write(Address, Address, Option<String>, bool),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum PrintMode {
+    Normal,
+    // List,
+    // Numbered,
+}
+
+enum ParseState {
+    Address,
+    SepOffCommand,
+    Command,
+}
+
+fn normalize_addrvec(
+    av: &mut Vec<Address>,
+    n_addr: usize,
+    separator: Option<char>,
+    default_addr: DefaultAddressing,
+) {
+    assert!(av.len() > 0);
+
+    // Ensure the vector has the correct number of addresses
+    while av.len() < n_addr {
+        av.push(av[0].clone());
+    }
+
+    while av.len() > n_addr {
+        av.remove(0);
+    }
+
+    // Patch Null addresses
+    if (av.len() > 1) && (av[1].info == AddressInfo::Null) {
+        if av[0].info == AddressInfo::Null {
+            av[1].info = AddressInfo::Last;
+        } else {
+            av[1] = av[0].clone();
+        }
+    }
+    if av[0].info == AddressInfo::Null {
+        match separator {
+            Some(',') => av[0].info = AddressInfo::Line(1),
+            Some(';') => av[0].info = AddressInfo::Current,
+            None => {
+                let default_range = default_addr.get_range();
+                av[0].info = default_range.0;
+                if n_addr > 1 {
+                    av[1].info = default_range.1;
+                }
+            }
+            _ => {
+                panic!("unexpected separator");
+            }
+        }
+    }
+}
+
+impl Command {
+    fn parse(mut tokens: Vec<Token>) -> Result<Command, String> {
+        let mut addr = Address::new();
+        let mut addr_dirty = false;
+        let mut addrvec: Vec<Address> = Vec::new();
+        let mut state = ParseState::Address;
+        let mut separator: Option<char> = None;
+
+        while !tokens.is_empty() {
+            let token = tokens.remove(0);
+            match state {
+                ParseState::Address => {
+                    addr_dirty = true;
+                    state = ParseState::SepOffCommand;
+                    match token {
+                        Token::CurrentLine => addr.info = AddressInfo::Current,
+                        Token::LastLine => addr.info = AddressInfo::Last,
+                        Token::Number(v) => addr.info = AddressInfo::Line(v as usize),
+                        Token::Mark(ch) => addr.info = AddressInfo::Mark(ch),
+                        Token::RegexForward(s) => addr.info = AddressInfo::RegexForward(s),
+                        Token::RegexBack(s) => addr.info = AddressInfo::RegexBack(s),
+                        Token::AddressSeparator(ch) => {
+                            addrvec.push(addr);
+                            addr = Address::new();
+                            state = ParseState::Address;
+                            separator = Some(ch);
+                        }
+                        Token::Offset(i) => addr.add_offset(i),
+                        Token::Command(_) => {
+                            tokens.insert(0, token);
+                            state = ParseState::Command;
+                            if addr_dirty {
+                                addrvec.push(addr);
+                                addr = Address::new();
+                                addr_dirty = false;
+                            }
+                        }
+                    }
+                }
+                ParseState::SepOffCommand => {
+                    match token {
+                        Token::AddressSeparator(ch) => {
+                            state = ParseState::Address;
+                            separator = Some(ch);
+                        }
+                        Token::Offset(isize) => addr.add_offset(isize),
+                        Token::Command(_) => {
+                            tokens.insert(0, token);
+                            state = ParseState::Command;
+                        }
+                        _ => return Err(String::from("unexpected token")),
+                    }
+                    if addr_dirty {
+                        addrvec.push(addr);
+                        addr = Address::new();
+                        addr_dirty = false;
+                    }
+                }
+                ParseState::Command => match token {
+                    Token::Command('a') => {
+                        normalize_addrvec(&mut addrvec, 1, separator, DefaultAddressing::Current);
+                        return Ok(Command::Insert(addrvec[0].clone(), false));
+                    }
+                    Token::Command('d') => {
+                        normalize_addrvec(&mut addrvec, 2, separator, DefaultAddressing::Current);
+                        return Ok(Command::Delete(addrvec[0].clone(), addrvec[1].clone()));
+                    }
+                    Token::Command('i') => {
+                        normalize_addrvec(&mut addrvec, 1, separator, DefaultAddressing::Current);
+                        return Ok(Command::Insert(addrvec[0].clone(), true));
+                    }
+                    Token::Command('q') => {
+                        if !addrvec.is_empty() {
+                            return Err("quit command takes no address".to_string());
+                        }
+                        return Ok(Command::Quit);
+                    }
+                    Token::Command('p') => {
+                        normalize_addrvec(&mut addrvec, 2, separator, DefaultAddressing::Current);
+                        return Ok(Command::Print(
+                            PrintMode::Normal,
+                            addrvec[0].clone(),
+                            addrvec[1].clone(),
+                        ));
+                    }
+                    Token::Command('w') => {
+                        normalize_addrvec(&mut addrvec, 2, separator, DefaultAddressing::Full);
+                        return Ok(Command::Write(
+                            addrvec[0].clone(),
+                            addrvec[1].clone(),
+                            None,
+                            false,
+                        ));
+                    }
+                    Token::Command(_) => return Err("unrecognized command".to_string()),
+                    _ => return Err(String::from("unexpected token")),
+                },
+            }
+        }
+
+        if addr_dirty {
+            addrvec.push(addr);
+        }
+
+        Err("address-and-command parse error".to_string())
+    }
+
+    pub fn from_line(line: &str) -> Result<Command, String> {
+        match tokenizer(line) {
+            Err(e) => Err(e.message),
+            Ok(tokens) => Self::parse(tokens),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn append_basic() {
+        let line = "a";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Insert(
+                Address {
+                    info: AddressInfo::Current,
+                    offsets: Vec::new()
+                },
+                false
+            )
+        );
+
+        let line = "2a";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Insert(
+                Address {
+                    info: AddressInfo::Line(2),
+                    offsets: Vec::new(),
+                },
+                false
+            )
+        );
+    }
+
+    #[test]
+    fn append_with_line_number() {
+        let line = "2a";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Insert(
+                Address {
+                    info: AddressInfo::Line(2),
+                    offsets: Vec::new(),
+                },
+                false
+            )
+        );
+    }
+
+    #[test]
+    fn append_with_current_line() {
+        let line = ".a";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Insert(
+                Address {
+                    info: AddressInfo::Current,
+                    offsets: Vec::new(),
+                },
+                false
+            )
+        );
+    }
+
+    #[test]
+    fn append_with_last_line() {
+        let line = "$a";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Insert(
+                Address {
+                    info: AddressInfo::Last,
+                    offsets: Vec::new(),
+                },
+                false
+            )
+        );
+    }
+
+    #[test]
+    fn append_with_mark() {
+        let line = "'xa";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Insert(
+                Address {
+                    info: AddressInfo::Mark('x'),
+                    offsets: Vec::new(),
+                },
+                false
+            )
+        );
+    }
+
+    #[test]
+    fn append_with_forward_regex() {
+        let line = "/pattern/a";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Insert(
+                Address {
+                    info: AddressInfo::RegexForward("pattern".to_string()),
+                    offsets: Vec::new(),
+                },
+                false
+            )
+        );
+    }
+
+    #[test]
+    fn append_with_backward_regex() {
+        let line = "?pattern?a";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Insert(
+                Address {
+                    info: AddressInfo::RegexBack("pattern".to_string()),
+                    offsets: Vec::new(),
+                },
+                false
+            )
+        );
+    }
+
+    #[test]
+    fn append_with_offset() {
+        let line = "1+a";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Insert(
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: vec![1],
+                },
+                false
+            )
+        );
+
+        let line = "1-2a";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Insert(
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: vec![-2],
+                },
+                false
+            )
+        );
+    }
+
+    #[test]
+    fn print_with_current_line() {
+        let line = ".p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Current,
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Current,
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn print_with_last_line() {
+        let line = "$p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Last,
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Last,
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn print_with_line_number() {
+        let line = "2p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Line(2),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Line(2),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn print_with_mark() {
+        let line = "'xp";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Mark('x'),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Mark('x'),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn print_with_forward_regex() {
+        let line = "/pattern/p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::RegexForward("pattern".to_string()),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::RegexForward("pattern".to_string()),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn print_with_backward_regex() {
+        let line = "?pattern?p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::RegexBack("pattern".to_string()),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::RegexBack("pattern".to_string()),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn print_with_offset() {
+        let line = "1+2p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: vec![2],
+                },
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: vec![2],
+                }
+            )
+        );
+
+        let line = "1-2p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: vec![-2],
+                },
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: vec![-2],
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn print_with_address_range() {
+        let line = "1,5p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Line(5),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+
+        let line = "1;5p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Line(5),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn print_with_many_addresses() {
+        let line = "1,2,3,4,5p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Line(4),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Line(5),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn print_whole_file() {
+        let line = ",p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Last,
+                    offsets: Vec::new(),
+                }
+            )
+        );
+
+        let line = ";p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Current,
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Last,
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn print_implicit_addresses() {
+        let line = ",222p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Line(222),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+
+        let line = ";222p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Current,
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Line(222),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+
+        let line = "222,p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Line(222),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Line(222),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+
+        let line = "222;p";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Print(
+                PrintMode::Normal,
+                Address {
+                    info: AddressInfo::Line(222),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Line(222),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn delete_1() {
+        let line = "d";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Delete(
+                Address {
+                    info: AddressInfo::Current,
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Current,
+                    offsets: Vec::new(),
+                },
+            )
+        );
+    }
+
+    #[test]
+    fn delete_1d() {
+        let line = "1d";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Delete(
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Line(1),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn delete_2() {
+        let line = "2,d";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Delete(
+                Address {
+                    info: AddressInfo::Line(2),
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Line(2),
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+
+    #[test]
+    fn delete_dotdollar() {
+        let line = ".,$d";
+        let cmd = Command::from_line(line).expect("parse error");
+        assert_eq!(
+            cmd,
+            Command::Delete(
+                Address {
+                    info: AddressInfo::Current,
+                    offsets: Vec::new(),
+                },
+                Address {
+                    info: AddressInfo::Last,
+                    offsets: Vec::new(),
+                }
+            )
+        );
+    }
+}
diff --git a/edvi/ed.rs b/edvi/ed.rs
new file mode 100644
index 00000000..0e58ad5c
--- /dev/null
+++ b/edvi/ed.rs
@@ -0,0 +1,342 @@
+//
+// Copyright (c) 2024 Jeff Garzik
+//
+// This file is part of the posixutils-rs project covered under
+// the MIT License.  For the full license text, please see the LICENSE
+// file in the root directory of this project.
+// SPDX-License-Identifier: MIT
+//
+
+extern crate clap;
+extern crate plib;
+
+mod buffer;
+mod command;
+
+use buffer::Buffer;
+use clap::Parser;
+use command::{Address, AddressInfo, Command, DEFAULT_ADDRESSING};
+use gettextrs::{bind_textdomain_codeset, textdomain};
+use plib::PROJECT_NAME;
+use std::fs;
+use std::io::{self, BufRead, BufReader};
+
+const ERR_STR: &str = "?";
+
+/// ed - edit text
+#[derive(Parser, Debug)]
+#[command(author, version, about, long_about)]
+struct Args {
+    /// Use string as the prompt string when in command mode.
+    #[arg(short, long, default_value = "")]
+    prompt: String,
+
+    /// Suppress the writing of byte counts by e, E, r, and w commands
+    #[arg(short, long)]
+    silent: bool,
+
+    /// If the file argument is given, ed shall simulate an e command on the file named by the pathname, file, before accepting commands from stdin
+    file: Option<String>,
+}
+
+#[derive(Clone, Debug)]
+enum EdOp {
+    ReadInputLines,
+    GotoLine(usize),
+    Insert(bool),
+    DeleteLines(usize, usize),
+    DisplayLines(usize, usize, command::PrintMode),
+}
+
+#[derive(Debug)]
+struct Editor {
+    in_cmd_mode: bool,
+    buf: Buffer,
+
+    inputs: Vec<String>,
+
+    exec_queue: Vec<EdOp>,
+}
+
+impl Editor {
+    fn new() -> Editor {
+        Editor {
+            in_cmd_mode: true,
+            buf: Buffer::new(),
+            inputs: Vec::new(),
+            exec_queue: Vec::new(),
+        }
+    }
+
+    fn bottom_half(&mut self) -> bool {
+        while !self.exec_queue.is_empty() {
+            let op = self.exec_queue.remove(0);
+            // eprintln!("OP={:?}", op);
+            match op {
+                EdOp::ReadInputLines => {
+                    self.input_begin();
+                    return true;
+                }
+
+                EdOp::GotoLine(line_no) => {
+                    self.buf.set_cur_line(line_no);
+                }
+
+                EdOp::DeleteLines(start_line, end_line) => {
+                    self.buf.delete(start_line, end_line);
+                }
+
+                EdOp::Insert(insert_before) => {
+                    if self.inputs.len() == 0 {
+                        continue;
+                    }
+                    self.buf
+                        .insert(self.buf.cur_line, insert_before, &mut self.inputs);
+                }
+
+                EdOp::DisplayLines(start_line, end_line, _print_mode) => {
+                    if let Err(e) = self
+                        .buf
+                        .write_lines(start_line, end_line, &mut io::stdout())
+                    {
+                        eprintln!("{}", e);
+                        println!("{}", ERR_STR);
+                    }
+                }
+            }
+        }
+
+        true
+    }
+
+    fn input_begin(&mut self) {
+        self.in_cmd_mode = false;
+        assert_eq!(self.inputs.len(), 0);
+    }
+
+    fn input_end(&mut self) -> bool {
+        self.in_cmd_mode = true;
+
+        true
+    }
+
+    fn push_input_line(&mut self, line: &str) -> bool {
+        if line.starts_with(".") && line.trim_end() == "." {
+            self.input_end()
+        } else {
+            self.inputs.push(line.to_string());
+            true
+        }
+    }
+
+    fn resolve_address(&self, addr: &Address, default_addr: AddressInfo) -> Result<usize, String> {
+        match addr.info {
+            AddressInfo::Current => Ok(self.buf.cur_line),
+            AddressInfo::Last => Ok(self.buf.last_line()),
+            AddressInfo::Line(line_no) => Ok(line_no),
+            AddressInfo::Offset(offset) => {
+                let line_no = self.buf.cur_line as isize + offset;
+                if line_no < 0 {
+                    Err("address out of range".to_string())
+                } else {
+                    Ok(line_no as usize)
+                }
+            }
+            AddressInfo::Null => self.resolve_address(
+                &Address {
+                    info: default_addr,
+                    offsets: vec![],
+                },
+                AddressInfo::Current,
+            ),
+            _ => unimplemented!(),
+        }
+    }
+
+    fn get_address_range(
+        &self,
+        cmd_char: char,
+        addr1: &Address,
+        addr2: &Address,
+    ) -> Result<(usize, usize), String> {
+        let default_addressing = DEFAULT_ADDRESSING[cmd_char as usize];
+        let default_range = default_addressing.get_range();
+
+        let start_line = self.resolve_address(addr1, default_range.0)?;
+        let end_line = self.resolve_address(addr2, default_range.1)?;
+
+        Ok((start_line, end_line))
+    }
+
+    fn push_cmd(&mut self, cmd: &Command) -> bool {
+        let mut retval = true;
+        match cmd {
+            Command::Quit => {
+                retval = false;
+            }
+
+            Command::Delete(addr1, addr2) => {
+                let (start_line, end_line) = match self.get_address_range('d', addr1, addr2) {
+                    Ok(range) => range,
+                    Err(_) => {
+                        println!("{}", ERR_STR);
+                        return true;
+                    }
+                };
+
+                self.exec_queue
+                    .push(EdOp::DeleteLines(start_line, end_line));
+            }
+
+            Command::Insert(addr, mut insert_before) => {
+                let mut bundle = Vec::new();
+                bundle.push(EdOp::ReadInputLines);
+
+                let line_no: usize = match self.resolve_address(addr, AddressInfo::Current) {
+                    Ok(line_no) => {
+                        if insert_before && line_no == 0 {
+                            1
+                        } else if !insert_before && line_no == 0 {
+                            insert_before = true;
+                            1
+                        } else {
+                            line_no
+                        }
+                    }
+                    Err(_) => {
+                        println!("{}", ERR_STR);
+                        return true;
+                    }
+                };
+                bundle.push(EdOp::GotoLine(line_no));
+                bundle.push(EdOp::Insert(insert_before));
+
+                self.exec_queue.extend_from_slice(&bundle);
+            }
+
+            Command::Print(print_mode, addr1, addr2) => {
+                let (start_line, end_line) = match self.get_address_range('p', addr1, addr2) {
+                    Ok(range) => range,
+                    Err(_) => {
+                        println!("{}", ERR_STR);
+                        return true;
+                    }
+                };
+
+                self.exec_queue
+                    .push(EdOp::DisplayLines(start_line, end_line, *print_mode));
+            }
+
+            Command::Write(addr1, addr2, opt_pathname, _silent) => {
+                let (start_line, end_line) = match self.get_address_range('w', addr1, addr2) {
+                    Ok(range) => range,
+                    Err(_) => {
+                        println!("{}", ERR_STR);
+                        return true;
+                    }
+                };
+
+                let pathname = opt_pathname.as_deref().unwrap_or(&self.buf.pathname);
+                if let Err(e) = self.buf.write_to_file(start_line, end_line, pathname) {
+                    println!("{}", ERR_STR);
+                    eprintln!("{}", e);
+                }
+            }
+        }
+
+        retval
+    }
+
+    fn push_cmd_line(&mut self, line: &str) -> bool {
+        match Command::from_line(line) {
+            Err(e) => {
+                eprintln!("{}", e);
+                true
+            }
+            Ok(cmd) => self.push_cmd(&cmd),
+        }
+    }
+
+    fn push_line(&mut self, line: &str) -> bool {
+        let cont_loop = if self.in_cmd_mode {
+            self.push_cmd_line(line.trim_end())
+        } else {
+            self.push_input_line(line)
+        };
+
+        let cont_loop_2 = if self.in_cmd_mode {
+            self.bottom_half()
+        } else {
+            true
+        };
+
+        cont_loop || cont_loop_2
+    }
+
+    fn read_file(&mut self, pathname: &str) -> io::Result<()> {
+        let file = fs::File::open(pathname)?;
+        let mut reader = BufReader::new(file);
+        let mut lines = Vec::new();
+
+        loop {
+            let mut line = String::new();
+            let rc = reader.read_line(&mut line)?;
+            if rc == 0 {
+                break;
+            }
+
+            lines.push(line);
+        }
+
+        if lines.len() > 0 {
+            self.buf.append(&mut lines);
+        }
+
+        self.buf.pathname = String::from(pathname);
+
+        Ok(())
+    }
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // parse command line arguments
+    let args = Args::parse();
+
+    textdomain(PROJECT_NAME)?;
+    bind_textdomain_codeset(PROJECT_NAME, "UTF-8")?;
+
+    let mut ed = Editor::new();
+
+    if let Some(pathname) = &args.file {
+        if let Err(e) = ed.read_file(pathname) {
+            eprintln!("{}: {}", pathname, e);
+        }
+
+        println!("{}", ed.buf.len());
+    }
+
+    loop {
+        let mut input = String::new();
+
+        if !args.prompt.is_empty() {
+            print!("{}", args.prompt);
+        }
+
+        if let Err(e) = io::stdin().read_line(&mut input) {
+            eprintln!("stdout: {}", e);
+            std::process::exit(1);
+        }
+
+        if input.is_empty() {
+            break;
+        }
+
+        // println!("LINE={}", input.trim_end());
+
+        if !ed.push_line(&input) {
+            break;
+        }
+    }
+
+    Ok(())
+}
diff --git a/edvi/tests/ed-tests.rs b/edvi/tests/ed-tests.rs
new file mode 100644
index 00000000..6e7456fa
--- /dev/null
+++ b/edvi/tests/ed-tests.rs
@@ -0,0 +1,130 @@
+//
+// Copyright (c) 2024 Hemi Labs, Inc.
+//
+// This file is part of the posixutils-rs project covered under
+// the MIT License.  For the full license text, please see the LICENSE
+// file in the root directory of this project.
+// SPDX-License-Identifier: MIT
+//
+
+use plib::{run_test, TestPlan};
+use std::fs;
+use std::path::PathBuf;
+
+fn get_test_file_path(filename: &str) -> PathBuf {
+    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+    path.push("tests");
+    path.push("ed");
+    path.push(filename);
+    path
+}
+
+fn copy_test_file(src: &str, dest: &str) {
+    let src_path = get_test_file_path(src);
+    let dest_path = get_test_file_path(dest);
+    fs::copy(src_path, dest_path).expect("Failed to copy test file");
+}
+
+struct TempFile {
+    path: PathBuf,
+}
+
+impl TempFile {
+    fn new(filename: &str) -> TempFile {
+        let path = get_test_file_path(filename);
+        TempFile { path }
+    }
+}
+
+impl Drop for TempFile {
+    fn drop(&mut self) {
+        fs::remove_file(&self.path).expect("Failed to remove temporary file");
+    }
+}
+
+fn run_ed_test(input_file: &str, stdin_file: &str, expected_output_file: &str) {
+    let stdin_data = fs::read_to_string(get_test_file_path(stdin_file))
+        .expect("Failed to read stdin input file");
+
+    let expected_output = fs::read_to_string(get_test_file_path(expected_output_file))
+        .expect("Failed to read expected output file");
+
+    run_test(TestPlan {
+        cmd: String::from("ed"),
+        args: vec![get_test_file_path(input_file).to_string_lossy().to_string()],
+        expected_out: expected_output,
+        expected_err: String::new(),
+        stdin_data,
+        expected_exit_code: 0,
+    });
+}
+
+#[test]
+fn ed_print_basic() {
+    run_ed_test("input1.txt", "print1.ed", "print1.txt");
+}
+
+#[test]
+fn ed_insert_basic() {
+    run_ed_test("input1.txt", "insert1.ed", "insert1.txt");
+}
+
+#[test]
+fn ed_write_basic() {
+    // Copy the original input file to a temporary file for the write test
+    let _temp_file = TempFile::new("temporary1.txt");
+    copy_test_file("input1.txt", "temporary1.txt");
+
+    let stdin_data = fs::read_to_string(get_test_file_path("write1.ed"))
+        .expect("Failed to read stdin input file");
+
+    // No expected output in stdout for write command, as it writes to the file
+    let expected_output = String::from("64\n");
+
+    run_test(TestPlan {
+        cmd: String::from("ed"),
+        args: vec![get_test_file_path("temporary1.txt")
+            .to_string_lossy()
+            .to_string()],
+        expected_out: expected_output,
+        expected_err: String::new(),
+        stdin_data,
+        expected_exit_code: 0,
+    });
+
+    // Compare the temporary1.txt file with the expected output file write1.txt
+    let temporary_output = fs::read_to_string(get_test_file_path("temporary1.txt"))
+        .expect("Failed to read temporary output file");
+    let expected_file_output = fs::read_to_string(get_test_file_path("write1.txt"))
+        .expect("Failed to read expected output file");
+
+    assert_eq!(
+        temporary_output, expected_file_output,
+        "The output file does not match the expected output file."
+    );
+}
+
+#[test]
+fn ed_delete_single_line() {
+    run_ed_test("input1.txt", "delete_1.ed", "delete_1.txt");
+}
+
+#[test]
+fn ed_delete_multiple_lines() {
+    run_ed_test("input1.txt", "delete_2_to_4.ed", "delete_2_to_4.txt");
+}
+
+// #[test]
+// fn ed_delete_all_lines() {
+//     run_ed_test("input1.txt", "delete_all.ed", "delete_all.txt");
+// }
+
+#[test]
+fn ed_delete_first_line() {
+    run_ed_test("input1.txt", "delete_1.ed", "delete_1.txt");
+}
+
+#[test]
+fn ed_delete_last_line() {
+    run_ed_test("input1.txt", "delete_10.ed", "delete_10.txt");
+}
diff --git a/edvi/tests/ed/delete_1.ed b/edvi/tests/ed/delete_1.ed
new file mode 100644
index 00000000..00bdc029
--- /dev/null
+++ b/edvi/tests/ed/delete_1.ed
@@ -0,0 +1,2 @@
+1d
+,p
diff --git a/edvi/tests/ed/delete_1.txt b/edvi/tests/ed/delete_1.txt
new file mode 100644
index 00000000..c8a80154
--- /dev/null
+++ b/edvi/tests/ed/delete_1.txt
@@ -0,0 +1,10 @@
+64
+banana
+charlie
+delta
+echo
+foxtrot
+golf
+hotel
+india
+juliet
diff --git a/edvi/tests/ed/delete_10.ed b/edvi/tests/ed/delete_10.ed
new file mode 100644
index 00000000..648f9960
--- /dev/null
+++ b/edvi/tests/ed/delete_10.ed
@@ -0,0 +1,2 @@
+10d
+,p
diff --git a/edvi/tests/ed/delete_10.txt b/edvi/tests/ed/delete_10.txt
new file mode 100644
index 00000000..d0070e72
--- /dev/null
+++ b/edvi/tests/ed/delete_10.txt
@@ -0,0 +1,10 @@
+64
+apple
+banana
+charlie
+delta
+echo
+foxtrot
+golf
+hotel
+india
diff --git a/edvi/tests/ed/delete_2_to_4.ed b/edvi/tests/ed/delete_2_to_4.ed
new file mode 100644
index 00000000..63be9d52
--- /dev/null
+++ b/edvi/tests/ed/delete_2_to_4.ed
@@ -0,0 +1,2 @@
+2,4d
+,p
diff --git a/edvi/tests/ed/delete_2_to_4.txt b/edvi/tests/ed/delete_2_to_4.txt
new file mode 100644
index 00000000..d5170de1
--- /dev/null
+++ b/edvi/tests/ed/delete_2_to_4.txt
@@ -0,0 +1,8 @@
+64
+apple
+echo
+foxtrot
+golf
+hotel
+india
+juliet
diff --git a/edvi/tests/ed/delete_all.ed b/edvi/tests/ed/delete_all.ed
new file mode 100644
index 00000000..3ff5e27b
--- /dev/null
+++ b/edvi/tests/ed/delete_all.ed
@@ -0,0 +1,2 @@
+1,10d
+,p
diff --git a/edvi/tests/ed/delete_all.txt b/edvi/tests/ed/delete_all.txt
new file mode 100644
index 00000000..a1e2647d
--- /dev/null
+++ b/edvi/tests/ed/delete_all.txt
@@ -0,0 +1 @@
+?
diff --git a/edvi/tests/ed/input1.txt b/edvi/tests/ed/input1.txt
new file mode 100644
index 00000000..bad4497a
--- /dev/null
+++ b/edvi/tests/ed/input1.txt
@@ -0,0 +1,10 @@
+apple
+banana
+charlie
+delta
+echo
+foxtrot
+golf
+hotel
+india
+juliet
diff --git a/edvi/tests/ed/insert1.ed b/edvi/tests/ed/insert1.ed
new file mode 100644
index 00000000..57001d22
--- /dev/null
+++ b/edvi/tests/ed/insert1.ed
@@ -0,0 +1,6 @@
+3i
+one
+two
+three
+.
+,p
diff --git a/edvi/tests/ed/insert1.txt b/edvi/tests/ed/insert1.txt
new file mode 100644
index 00000000..0c7cd305
--- /dev/null
+++ b/edvi/tests/ed/insert1.txt
@@ -0,0 +1,14 @@
+64
+apple
+banana
+one
+two
+three
+charlie
+delta
+echo
+foxtrot
+golf
+hotel
+india
+juliet
diff --git a/edvi/tests/ed/print1.ed b/edvi/tests/ed/print1.ed
new file mode 100644
index 00000000..677e85f9
--- /dev/null
+++ b/edvi/tests/ed/print1.ed
@@ -0,0 +1 @@
+,p
diff --git a/edvi/tests/ed/print1.txt b/edvi/tests/ed/print1.txt
new file mode 100644
index 00000000..024c7795
--- /dev/null
+++ b/edvi/tests/ed/print1.txt
@@ -0,0 +1,11 @@
+64
+apple
+banana
+charlie
+delta
+echo
+foxtrot
+golf
+hotel
+india
+juliet
diff --git a/edvi/tests/ed/write1.ed b/edvi/tests/ed/write1.ed
new file mode 100644
index 00000000..7babdcb3
--- /dev/null
+++ b/edvi/tests/ed/write1.ed
@@ -0,0 +1,6 @@
+3i
+one
+two
+three
+.
+w
diff --git a/edvi/tests/ed/write1.txt b/edvi/tests/ed/write1.txt
new file mode 100644
index 00000000..891eaf20
--- /dev/null
+++ b/edvi/tests/ed/write1.txt
@@ -0,0 +1,13 @@
+apple
+banana
+one
+two
+three
+charlie
+delta
+echo
+foxtrot
+golf
+hotel
+india
+juliet
