From 433a871f953f78c754947b82ec96d502e77c518a Mon Sep 17 00:00:00 2001 From: Adam Israel Date: Mon, 30 Mar 2026 00:35:09 -0400 Subject: [PATCH] chore: modern refactor of echo - Wire up clap macro to provide some common interfacing, a proper `--help`, and feature parity with GNU's echo. --- README.md | 2 +- echo/Cargo.toml | 4 +- echo/src/main.rs | 145 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 138 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 33da00c..2f397d0 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ This began as, and continues to be, a learning exercise to better understand the | dircolors | :white_large_square: | | dirname | :white_check_mark: | | du | :white_large_square: | -| echo | :white_large_square: | +| echo | :white_check_mark: | | env | :white_large_square: | | expand | :white_large_square: | | expr | :white_large_square: | diff --git a/echo/Cargo.toml b/echo/Cargo.toml index 38b6d86..21dc26a 100644 --- a/echo/Cargo.toml +++ b/echo/Cargo.toml @@ -1,8 +1,10 @@ [package] name = "echo" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +clap = { workspace = true } +coreutils = { workspace = true } diff --git a/echo/src/main.rs b/echo/src/main.rs index 6bd61e1..5f1402a 100644 --- a/echo/src/main.rs +++ b/echo/src/main.rs @@ -1,19 +1,142 @@ -use std::env; +use clap::{Arg, ArgAction}; + +use coreutils::{clap_args, clap_base_command}; + +clap_args!(Args { + flag no_newline: bool, + flag escape: bool, + multi args: Vec, +}); /// Echo the arguments fn main() { - let args: Vec = env::args().collect(); - let mut iter = args.iter(); - let mut sep = ""; + let matches = clap_base_command() + .arg( + Arg::new("no_newline") + .action(ArgAction::SetTrue) + .help("Do not print the trailing newline character") + .short('n'), + ) + .arg( + Arg::new("escape") + .action(ArgAction::SetTrue) + .help("Enable interpretation of backslash escapes") + .short('e'), + ) + .arg( + Arg::new("args") + .action(ArgAction::Append) + .help("Arguments to echo to stdout"), + ) + .mut_args(|a| { + // Hide the base --output argument, since it doesn't make sense for `echo`. + if a.get_id() == "output" { + a.hide(true) + } else { + a + } + }) + .get_matches(); + let args = Args::from_matches(&matches); + + let output = args.args.join(" "); - // Throw out the name of the binary - iter.next(); + if args.escape { + print!("{}", interpret_escapes(&output)); + } else { + print!("{output}"); + } + if !args.no_newline { + println!(); + } +} - for argument in iter { - print!("{sep}{argument}"); - if sep.is_empty() { - sep = " "; +fn interpret_escapes(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('\\') => result.push('\\'), + Some('a') => result.push('\x07'), + Some('b') => result.push('\x08'), + Some('c') => break, + Some('f') => result.push('\x0C'), + Some('n') => result.push('\n'), + Some('r') => result.push('\r'), + Some('t') => result.push('\t'), + Some('v') => result.push('\x0B'), + Some('0') => { + let mut val: u32 = 0; + for _ in 0..3 { + let mut peek = chars.clone(); + if let Some(d) = peek.next() { + if ('0'..='7').contains(&d) { + val = val * 8 + d.to_digit(8).unwrap(); + chars.next(); + } else { + break; + } + } else { + break; + } + } + result.push(char::from_u32(val).unwrap_or('\0')); + } + Some(other) => { + result.push('\\'); + result.push(other); + } + None => result.push('\\'), + } + } else { + result.push(c); } } - println!(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_interpret_escapes_newline() { + assert_eq!(interpret_escapes("hello\\nworld"), "hello\nworld"); + } + + #[test] + fn test_interpret_escapes_tab() { + assert_eq!(interpret_escapes("hello\\tworld"), "hello\tworld"); + } + + #[test] + fn test_interpret_escapes_backslash() { + assert_eq!(interpret_escapes("hello\\\\world"), "hello\\world"); + } + + #[test] + fn test_interpret_escapes_stop() { + assert_eq!(interpret_escapes("hello\\cworld"), "hello"); + } + + #[test] + fn test_interpret_escapes_octal() { + assert_eq!(interpret_escapes("\\0101"), "A"); // octal 101 = 65 = 'A' + } + + #[test] + fn test_interpret_escapes_bell() { + assert_eq!(interpret_escapes("\\a"), "\x07"); + } + + #[test] + fn test_interpret_escapes_no_escape() { + assert_eq!(interpret_escapes("hello world"), "hello world"); + } + + #[test] + fn test_interpret_escapes_unknown() { + assert_eq!(interpret_escapes("\\z"), "\\z"); + } }