Implementing echo in Rust

Overview

My wife and I are learning Rust and cryptography together. Implementing the popular unix program echo was our hello world since Rust makes it way too damn easy to implement hello world, but more on that later.

Truncated man page entry for echo

ECHO(1)                   BSD General Commands Manual                  ECHO(1)

NAME
     echo -- write arguments to the standard output

SYNOPSIS
     echo [-n] [string ...]

DESCRIPTION
     The echo utility writes any specified operands, separated by single blank
     (` ') characters and followed by a newline (`\n') character, to the standard
     output.

     The following option is available:

     -n    Do not print the trailing newline character.  This may also be
           achieved by appending `\c' to the end of the string, as is done by
           iBCS2 compatible systems.  Note that this option as well as the effect
           of `\c' are implementation-defined in IEEE Std 1003.1-2001
           (``POSIX.1'') as amended by Cor. 1-2002.  Applications aiming for max-
           imum portability are strongly encouraged to use printf(1) to suppress
           the newline character.

We’ll need to implement a few things:

  • reading from stdin,
  • outputting to stdout, and
  • respecting the -n flag for not printing a trailing newline.

Preamble on Cargo

Cargo is Rust’s package manager, including some useful built-in scripts for managing Rust projects. It’ll be our primary tool for interacting with the project. To create a new project, run the following:

cargo new rust-echo

This produces a git directory (with a .gitignore that ignores the output directory for debugging logs and binaries, /target), a Cargo.toml for meta-data on the project, and a src directory for the project’s source code.

If you’ve worked in the JavaScript ecostystem, the Cargo.toml will feel similar to a package.json. There’s a block for describing the project (e.g., version, author, name, and so on) and another for listing dependencies. In a new project, there are no dependencies.

In src there’s but one humble file: main.rs, and in it:

fn main() {
    println!("Hello, world!");
}

This is why it’s so damn easy to implement hello world in Rust—it comes out of the box for free!

Reading from stdin

We have a main.rs with hello world. Clear the body of the main function:

fn main() {
}

To use the standard library’s input/output library, add this line to the top of the file:

use std::io;

fn main() {
}

And to read from stdin, add this:

use std::io;

fn main() {
    let mut input = String::new();

    io::stdin()
        .read_line(&mut input)
        .expect("Something went wrong with the input.");
}

There’s a lot going on in these few lines. First, notice the mut keyword. That’s telling Rust that our variable, input, is mutable. Rust’s variables are immutable by default!

I’m sure you can guess what String::new() does, but there’s something interesting behind it. Strings are growable chunks of heap-allocated memory. You can add more characters to them. But here’s the interesting part: it’s not the only string type in Rust. There are string slices with different rules of memory management (we’ll cover this in another post). String slices are either substrings of someone else’s string (this will make more sense after reading the post on memory management in Rust) or string literals that are declared within the program. String literals are compiled with the rest of the program and are part of the memory required to run it. Here’s an example of a string literal:

use std::io;

fn main() {
    let mut input = String::new();
    let error_message = "Something went wrong with the input.";
    io::stdin()
        .read_line(&mut input)
        .expect(error_message);
}

The variable error_message is a string literal that gets compiled with the program and gets shipped with the binary. It also points to the next interesting thing in the chunk of text above: .expect(). (We’re going to skip that & on .read_line()’s argument for now because it deals with memory management.)

We’re passing error_message to .expect(), priming you to think it has something to do with error handling. That’s right! If you’re coming from the JavaScript ecosystem, it functions like a catch block. What makes it interesting, though, is that it handles a particular kind of type: Result.

enum Result<T, E> {
   Ok(T),
   Err(E),
}

There are two variants to Result, both Ok and Error. If .read_line() errors, .expect() will take the Err variant of Result and print whatever you gave to .expect() as an argument. In our case, that’s error_message. If everything goes well, .expect() will unwrap the Ok variant and return the wrapped data. Result is so common you’ll come across an .unwrap() method often that unwraps the wrapped data.

Outputing to stdout

This is an easy one. We’ll use a macro (we’ll talk more about macros later, but don’t confuse them with functions—and if you do, don’t worry, the compiler won’t): println!().

use std::io;

fn main() {
    let mut input = String::new();
    let error_message = "Something went wrong with the input.";
    io::stdin()
        .read_line(&mut input)
        .expect(error_message);

    println!("{}", input);
}

The format specifier "{}" tells Rust to plop in the argument passed to println!() at that spot. If we had multiple arguments, we’d just add multiple format specifiers.

Respecting the -n flag for not printing a trailing newline

We’re not quite done. If you run our program using cargo run, you’ll still get a newline printed. That’s because the return character is what stdin uses to delimit input.

Compiling echo v0.1.0 (/Users/aaronarinder/rust-book/own-lessons/echo)
 Finished dev [unoptimized + debuginfo] target(s) in 3.44s
  Running `target/debug/echo`

 howdy  <--- input
 howdy  <--- output
        <--- disgusting, offensive newline

Let’s fix that. Add the following to your program:

use std::io;

fn main() {
    let mut input = String::new();
    let error_message = "Something went wrong with the input.";
    io::stdin()
        .read_line(&mut input)
        .expect(error_message);

    let input: Vec<&str> = input.split("-n").collect();

    if input[0] == "-n" {
        println!("{}", input[1].trim());
    } else {
        println!("{}", input[0]);
    }
}

There’s a bit going on here. The Vec<&str> is a vector (i.e., a growable list) of string slices. (If this feels familiar, it’s because String is just a Vector of 8 unsigned bytes, Vec<u8>.) We’ve split our input on -n to capture the -n flag. We’ve collected the result. We collected the result because Rust liberally employs iterators. Iterators are just what they sound like: a way to iterate over collections, where collections are anything your heart desires (you’ll fine iter() and into_iter() spread across the desolate wastes of StackOverflow as a testament to iterators’ popularity for idiomatic Rust code).

Finally, if we have -n, we print the trimmed input. The .trim() method trims both whitespace and newlines. Otherwise, we print the input without any modification.

Run the program, double-checking that it works as expected. You might notice a bug or two (e.g., what if you input an API key like DFJe123ifuj-nIKdifjd*jdfu_7efj). What happens if you try to use an shell variable?

A more advanced echo

Here’s a more advanced version of echo:

use shellexpand;
use std::env;
fn main() {
    // Skip execution path, the first arg
    let mut args: Vec<String> = env::args().skip(1).collect();

    if args[0] != "-n" {
        args.push(String::from("\n"));
    }

    println!(
        "{}",
        shellexpand::env(&args.join(" ").replace("-n", "")).expect("Unexpected input.")
    );
}

Can you explain both the functional and programmatic differences between this more advanced version and its humbler sibling?

Note, and a hint: your Cargo.toml will need to be updated like so:

[dependencies]
shellexpand = "2.1.0"

Aaron Arinder is a software engineer trying to die well. GitHub