Frontend Labs
⋯

14. Terminal project: a word-frequency counter

The same project as C §9.2. Compare the two: the Rust version has no hand-written hash table, no strdup, no free, and no fixed-size buffer.

// src/bin/wordfreq.rs
// usage: cargo run --bin wordfreq -- [FILE] [-n TOP]   (reads stdin with no FILE)
use std::collections::HashMap;
use std::io::{self, BufRead, BufReader, Read};

fn count_words(reader: impl BufRead) -> io::Result<HashMap<String, usize>> {
    let mut counts = HashMap::new();
    for line in reader.lines() {
        let line = line?;                       // propagate I/O and invalid-UTF-8 errors
        for word in line
            .split(|c: char| !c.is_alphanumeric() && c != '\'')
            .filter(|w| !w.is_empty())
        {
            *counts.entry(word.to_lowercase()).or_insert(0) += 1;
        }
    }
    Ok(counts)
}

fn main() -> io::Result<()> {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let top = args.iter().position(|a| a == "-n")
        .and_then(|i| args.get(i + 1))
        .and_then(|n| n.parse().ok())
        .unwrap_or(10);
    let path = args.iter().find(|a| !a.starts_with('-') && a.parse::<usize>().is_err());

    let input: Box<dyn Read> = match path {
        Some(p) => Box::new(std::fs::File::open(p)?),
        None => Box::new(io::stdin()),
    };
    let counts = count_words(BufReader::new(input))?;

    let mut ranked: Vec<(&String, &usize)> = counts.iter().collect();
    ranked.sort_unstable_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
    for (word, n) in ranked.into_iter().take(top) {
        println!("{n:>7} {word}");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn counts_case_insensitively() {
        let c = count_words("The cat. the CAT's hat!".as_bytes()).unwrap();
        assert_eq!(c["the"], 2);
        assert_eq!(c["cat's"], 1);
        assert_eq!(c.get("dog"), None);
    }
}
cargo run --release --bin wordfreq -- -n 5 /usr/share/dict/words
cargo test --bin wordfreq

Stretch goals:

  1. Avoid allocating a String per word. Count &str slices of a buffer that holds the whole file. You will meet lifetimes (Ch 19), because the map now borrows from the buffer.
  2. Process files in parallel with scoped threads (Ch 28) and merge the maps.
  3. Compare HashMap with BTreeMap timings on a 100 MB file.

Generated from real-life-rust-guide.md · 8 Oct 2026 16:36