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. String
s 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"