Skip to content

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 6
cat 3
happy 2

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 words
▶ Try it Yourself

It 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.

To normalize a word means to put it into one standard form. We’ll do two things:

  1. Remove punctuation from the start and end, with trim_matches.
  2. 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! -> dog
▶ Try it Yourself

trim_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.

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: 6
cat: 3
happy: 2
11 different words
▶ Try it Yourself

Let’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 a 0 there if the word is new. Either way, it gives back a mutable reference to the count.
  • * follows that reference to the number itself, and += 1 adds 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.

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: 6
cat: 3
happy: 2
is: 2
on: 2
sat: 2
dog: 1
log: 1
mat: 1
very: 1
yes: 1
▶ Try it Yourself

The sorting line is the trickiest part of this project, so let’s take it apart:

  • sort_by gives us two items, a and b, and asks “which comes first?”.
  • b.1.cmp(&a.1) compares the counts (.1 is the second part of the tuple). We wrote b before a on 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 normal a-to-b order, so ties are alphabetical.

The Sorting page has more examples of sort_by and friends.

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 6
cat 3
happy 2
is 2
on 2
▶ Try it Yourself

.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.

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:

Terminal window
cargo new word_counter
cd word_counter
echo "The cat sat on the mat. The dog sat on the log!" > story.txt

Put 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 --:

Terminal window
cargo run -- story.txt 3
12 words, 7 different
WORD COUNT
----------------
the 4
on 2
sat 2

And if you forget the file name, you get the usage message instead of a crash:

Terminal window
cargo run
Usage: 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 5
rust 3
fun 2
it 2
▶ Try it Yourself

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; // correct
counts.entry(word).or_insert(0) += 1; // error
error[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.

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 HashSet and 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 clap for arguments. Run cargo add clap --features derive and add real flags like --top 5 and --ignore-case, plus a free --help message. See the CLI Arguments page for an example.
  • Write tests. Move the functions into src/lib.rs and add a few tests for normalize and count_words.
  • split_whitespace() cuts text into words; trim_matches and to_lowercase normalize them.
  • *map.entry(key).or_insert(0) += 1 is the standard way to count things with a HashMap.
  • A HashMap has no order. Collect into a Vec and use sort_by with 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 with process::exit(1).

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