Project: Word Counter
In this project you’ll build a small but real tool: a word counter. You give it some text, and it tells you which words appear most often. Writers use tools like this to spot words they repeat too much. Search engines use the same idea to understand what a page is about.
We’ll build it in small steps. Every step is a complete program that runs, so you can stop at any point, play with it, and then continue. Along the way you’ll use strings, HashMaps, sorting, formatted output, and command-line arguments.
At the end, your program will print something like this:
WORD COUNT----------------the 6cat 3happy 2Step 1: Split text into words
Section titled “Step 1: Split text into words”The first job is to cut the text into words. split_whitespace() splits a string on spaces, tabs, and new lines, and skips the empty bits between them:
fn main() { let text = "The cat sat on the mat. The dog sat on the log!";
let words: Vec<&str> = text.split_whitespace().collect();
println!("{:?}", words); println!("{} words", words.len());}Output:
["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]12 wordsIt works, but look closely. "The" and "the" are treated as different words, and "mat." still has its full stop. If we counted now, our results would be wrong. Let’s fix that first.
Step 2: Normalize each word
Section titled “Step 2: Normalize each word”To normalize a word means to put it into one standard form. We’ll do two things:
- Remove punctuation from the start and end, with
trim_matches. - Make it lowercase, with
to_lowercase.
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn main() { let text = "The cat sat on the mat. Don't stop -- said the DOG!";
for word in text.split_whitespace() { let clean = normalize(word); if clean.is_empty() { println!("{:>6} -> (skipped)", word); } else { println!("{:>6} -> {}", word, clean); } }}Output:
The -> the cat -> cat sat -> sat on -> on the -> the mat. -> mat Don't -> don't stop -> stop -- -> (skipped) said -> said the -> the DOG! -> dogtrim_matches takes a closure that decides which characters to remove. Here it removes anything that is not a letter or digit, but only at the ends. That’s why the apostrophe in don't survives. A “word” made only of punctuation, like --, becomes an empty string, so we skip it.
Step 3: Count with a HashMap
Section titled “Step 3: Count with a HashMap”Now the main idea. A HashMap maps each word to how many times we’ve seen it. The entry API makes counting short:
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn main() { let text = "The cat sat on the mat. The dog sat on the log! \ Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } }
println!("the: {}", counts["the"]); println!("cat: {}", counts["cat"]); println!("happy: {}", counts["happy"]); println!("{} different words", counts.len());}Output:
the: 6cat: 3happy: 211 different wordsLet’s read *counts.entry(clean).or_insert(0) += 1; slowly:
counts.entry(clean)finds the place in the map for this word..or_insert(0)puts a0there if the word is new. Either way, it gives back a mutable reference to the count.*follows that reference to the number itself, and+= 1adds one.
The \ at the end of the first text line just continues the string on the next line. And counts["the"] looks up a key directly. It panics if the key is missing, which is fine here because we know these words are in the text. For keys that might be missing, use .get().
Why didn’t we print the whole map with a loop? A HashMap has no fixed order. If you loop over it, the order can change every time you run the program. To get a stable, useful order, we need to sort.
Step 4: Sort by count, then by word
Section titled “Step 4: Sort by count, then by word”We move the map’s pairs into a Vec, then sort it. We want the highest count first. When two words have the same count, we want them in alphabetical order, so the output is always the same:
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn main() { let text = "The cat sat on the mat. The dog sat on the log! \ Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } }
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for (word, count) in &pairs { println!("{word}: {count}"); }}Output:
the: 6cat: 3happy: 2is: 2on: 2sat: 2dog: 1log: 1mat: 1very: 1yes: 1The sorting line is the trickiest part of this project, so let’s take it apart:
sort_bygives us two items,aandb, and asks “which comes first?”.b.1.cmp(&a.1)compares the counts (.1is the second part of the tuple). We wrotebbeforeaon purpose: that reverses the order, so big counts come first..then(a.0.cmp(&b.0))is only used when the counts are equal. It compares the words in normala-to-border, so ties are alphabetical.
The Sorting page has more examples of sort_by and friends.
Step 5: Print a top-N table
Section titled “Step 5: Print a top-N table”A long list is hard to read. Let’s show only the top few words, in neat columns. Format specifiers do the alignment for us:
{:<10}means “left-align in a space 10 characters wide”.{:>5}means “right-align in a space 5 characters wide”. Numbers usually look best right-aligned.
We’ll also move the code into functions, so main reads like a short story:
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn count_words(text: &str) -> HashMap<String, usize> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } } counts}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> { let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs}
fn print_table(pairs: &[(String, usize)], top_n: usize) { println!("{:<10} {:>5}", "WORD", "COUNT"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { let text = "The cat sat on the mat. The dog sat on the log! \ Is the cat happy? Yes, the cat is very happy.";
let pairs = sorted_counts(count_words(text)); print_table(&pairs, 5);}Output:
WORD COUNT----------------the 6cat 3happy 2is 2on 2.take(top_n) stops after top_n items. If there are fewer words than that, it simply stops early, so you never have to worry about going past the end.
Notice that print_table takes &[(String, usize)], a slice. That means it only borrows the list, and it works with any Vec of pairs.
Step 6: The final version, reading a file
Section titled “Step 6: The final version, reading a file”A real word counter should read a file. We’ll take the file path from the command line, and an optional second argument for how many rows to show. If something goes wrong, we print a friendly message with eprintln! (which writes to the error output) and exit with code 1.
Create the project and a text file to test with:
cargo new word_countercd word_counterecho "The cat sat on the mat. The dog sat on the log!" > story.txtPut this in src/main.rs. This version is for your own machine, because the Playground can’t read your files:
use std::collections::HashMap;use std::env;use std::fs;use std::process;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn count_words(text: &str) -> HashMap<String, usize> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } } counts}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> { let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs}
fn print_table(pairs: &[(String, usize)], top_n: usize) { println!("{:<10} {:>5}", "WORD", "COUNT"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { let args: Vec<String> = env::args().collect();
// args[0] is the program name, args[1] is the file path let Some(path) = args.get(1) else { eprintln!("Usage: word_counter <file> [top_n]"); process::exit(1); };
// the second argument is optional; use 10 if it is missing or not a number let top_n: usize = match args.get(2) { Some(n) => n.parse().unwrap_or(10), None => 10, };
let text = match fs::read_to_string(path) { Ok(text) => text, Err(e) => { eprintln!("Could not read {path}: {e}"); process::exit(1); } };
let pairs = sorted_counts(count_words(&text)); let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} words, {} different\n", total, pairs.len()); print_table(&pairs, top_n);}Run it. Remember that arguments for your program go after --:
cargo run -- story.txt 312 words, 7 different
WORD COUNT----------------the 4on 2sat 2And if you forget the file name, you get the usage message instead of a crash:
cargo runUsage: word_counter <file> [top_n]let Some(path) = args.get(1) else { ... }; is called let-else. It means “if this pattern matches, keep going; otherwise run the else block, which must stop (here, by exiting)”. The File I/O page explains read_to_string and its errors in more detail.
Here is the same program with the text built in, so you can run it in the Playground. Only main is different:
use std::collections::HashMap;
fn normalize(word: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()) .to_lowercase()}
fn count_words(text: &str) -> HashMap<String, usize> { let mut counts = HashMap::new(); for word in text.split_whitespace() { let clean = normalize(word); if !clean.is_empty() { *counts.entry(clean).or_insert(0) += 1; } } counts}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> { let mut pairs: Vec<(String, usize)> = counts.into_iter().collect(); pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); pairs}
fn print_table(pairs: &[(String, usize)], top_n: usize) { println!("{:<10} {:>5}", "WORD", "COUNT"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { // pretend this came from a file let text = "Rust is fast. Rust is safe. Rust is fun! Is it hard? It takes practice, and practice is fun too."; let top_n = 4;
let pairs = sorted_counts(count_words(text)); let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} words, {} different\n", total, pairs.len()); print_table(&pairs, top_n);}Output:
20 words, 11 different
WORD COUNT----------------is 5rust 3fun 2it 2Common mistakes
Section titled “Common mistakes”Forgetting the * when counting. or_insert gives you a reference to the number, not the number itself. Without *, Rust refuses to add to it:
*counts.entry(word).or_insert(0) += 1; // correctcounts.entry(word).or_insert(0) += 1; // errorerror[E0368]: binary assignment operation `+=` cannot be applied to type `&mut {integer}` --> src/main.rs:6:9 |6 | counts.entry(word).or_insert(0) += 1; | -------------------------------^^^^^ | | | cannot use `+=` on type `&mut {integer}` |help: `+=` can be used on `{integer}` if you dereference the left-hand side |6 | *counts.entry(word).or_insert(0) += 1; | +The compiler even shows you the fix: add the *.
Printing the HashMap directly. println!("{:?}", counts) works, but the order is random and can change between runs. Always sort into a Vec first when order matters.
Sorting only by count. If you sort with just b.1.cmp(&a.1), words with the same count can show up in any order. Add the .then(...) tie-breaker so the output is the same every time.
Ideas to extend it
Section titled “Ideas to extend it”You have a working tool. Here are some ways to make it your own, from easiest to hardest:
- Ignore stop words. Words like “the”, “a”, “is”, and “and” are everywhere and not very interesting. Put them in a
HashSetand skip any word that is in it. - Show percentages. Next to each count, print what share of all words it is, like
the 6 28.6%. Use{:.1}to show one decimal place. - Read from stdin when no file is given. Use
std::io::read_to_string(std::io::stdin())so you can pipe text in:cat story.txt | cargo run. - Count several files. Loop over every path in
args[1..]and add all their words to one map. - Use
clapfor arguments. Runcargo add clap --features deriveand add real flags like--top 5and--ignore-case, plus a free--helpmessage. See the CLI Arguments page for an example. - Write tests. Move the functions into
src/lib.rsand add a few tests fornormalizeandcount_words.
split_whitespace()cuts text into words;trim_matchesandto_lowercasenormalize them.*map.entry(key).or_insert(0) += 1is the standard way to count things with a HashMap.- A HashMap has no order. Collect into a
Vecand usesort_bywith a tie-breaker for stable output. {:<10}and{:>5}align text in columns;.take(n)limits how many rows you print.- Read the file path from
env::args(), handle errors with a friendly message, and exit withprocess::exit(1).
Quick check
Section titled “Quick check”Quick check
1. What does "Hello!".trim_matches(|c: char| !c.is_alphanumeric()) return?
2. Why do we collect the HashMap into a Vec before printing?
3. In pairs.sort_by(|a, b| b.1.cmp(&a.1)), why is b written before a?
4. What does {:>5} do in println!?
Score: 0 / 4
Read more
Section titled “Read more”- Storing Keys with Associated Values in Hash Maps — The Rust Book: includes a word-counting example with
entryandor_insert, very close to Step 3. - An I/O Project: Building a Command Line Program — The Rust Book: builds a small
grepclone that reads arguments and files, the same skills as Step 6, in more depth. HashMap::entry— std docs: returns anEntry, whose methods includeor_insert,or_default, andand_modify.slice::sort_by— std docs: how comparison closures work, and why the sort is stable (equal items keep their order).- Width and alignment —
std::fmtdocs: every padding and alignment option, like{:<10}and{:>6}, used in the tables on this page.