63. Capstone 1: a word-frequency report
Time to build something whole. This one is deliberately unglamorous — read text, count words, print a ranked report — because it uses almost everything from the first half of the course: strings and runes, maps, slices, structs, sorting, and clean error handling.
The specification
Given a block of text, produce a report showing:
- total word count and unique word count
- the top N words by frequency, ties broken alphabetically
- words shorter than a minimum length excluded ("stop words")
Step 1: tokenising
Splitting on spaces isn't enough — real text has punctuation, mixed case and newlines.
package main
import (
"fmt"
"strings"
"unicode"
)
func tokenize(text string) []string {
fields := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '\''
})
return fields
}
func main() {
text := `The quick brown fox jumps over the lazy dog.
The dog barks; the fox doesn't care!`
words := tokenize(text)
fmt.Println(len(words), "words")
fmt.Println(words[:6])
fmt.Println(words[len(words)-4:])
}
strings.FieldsFunc splits wherever your function says a rune is a
separator. We keep letters, digits and apostrophes (so doesn't stays one
word) and treat everything else as a boundary. Lower-casing first means
The and the count together.
That's unicode.IsLetter, not r >= 'a' && r <= 'z' — the difference
matters the moment somebody pastes text with an accent in it.
Step 2: counting
package main
import (
"fmt"
"strings"
"unicode"
)
func tokenize(text string) []string {
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '\''
})
}
func count(words []string, minLen int) map[string]int {
counts := make(map[string]int)
for _, w := range words {
if len(w) < minLen {
continue
}
counts[w]++
}
return counts
}
func main() {
text := "the quick brown fox the lazy dog the fox"
counts := count(tokenize(text), 3)
fmt.Println("unique words of 3+ letters:", len(counts))
fmt.Println("the:", counts["the"], "fox:", counts["fox"])
fmt.Println("never seen:", counts["elephant"])
}
counts[w]++ on an absent key gives 1 — module 4's payoff. And reading a
word that was never counted returns 0 rather than an error, so the report
needs no special case for missing words.
Step 3: ranking
A map has no order, so we build a sortable slice of structs:
package main
import (
"fmt"
"sort"
"strings"
"unicode"
)
type WordCount struct {
Word string
Count int
}
func tokenize(text string) []string {
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '\''
})
}
func rank(counts map[string]int) []WordCount {
out := make([]WordCount, 0, len(counts))
for w, n := range counts {
out = append(out, WordCount{Word: w, Count: n})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Word < out[j].Word
})
return out
}
func main() {
text := "go is fast go is simple go is fun rust is fast"
counts := make(map[string]int)
for _, w := range tokenize(text) {
counts[w]++
}
for i, wc := range rank(counts) {
fmt.Printf("%d. %-6s %d\n", i+1, wc.Word, wc.Count)
}
}
The comparator is the important line: count descending, then word ascending. Without that second clause, words with equal counts would come out in the map's random order and the report would differ between runs.
Step 4: the whole thing
Now assemble it with a config struct, an error path, and formatted output:
package main
import (
"errors"
"fmt"
"sort"
"strings"
"unicode"
)
type WordCount struct {
Word string
Count int
}
type Report struct {
TotalWords int
UniqueWords int
Top []WordCount
}
type Options struct {
TopN int
MinLen int
}
var ErrEmptyInput = errors.New("input contains no countable words")
func Analyze(text string, opts Options) (*Report, error) {
if opts.TopN <= 0 {
opts.TopN = 5
}
if opts.MinLen < 1 {
opts.MinLen = 1
}
words := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '\''
})
counts := make(map[string]int)
total := 0
for _, w := range words {
if len(w) < opts.MinLen {
continue
}
total++
counts[w]++
}
if total == 0 {
return nil, fmt.Errorf("analyzing %d characters: %w", len(text), ErrEmptyInput)
}
ranked := make([]WordCount, 0, len(counts))
for w, n := range counts {
ranked = append(ranked, WordCount{Word: w, Count: n})
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].Count != ranked[j].Count {
return ranked[i].Count > ranked[j].Count
}
return ranked[i].Word < ranked[j].Word
})
if len(ranked) > opts.TopN {
ranked = ranked[:opts.TopN]
}
return &Report{TotalWords: total, UniqueWords: len(counts), Top: ranked}, nil
}
func (r *Report) String() string {
var b strings.Builder
fmt.Fprintf(&b, "%d words, %d unique\n", r.TotalWords, r.UniqueWords)
for i, wc := range r.Top {
bar := strings.Repeat("#", wc.Count)
fmt.Fprintf(&b, "%d. %-12s %-6s %d\n", i+1, wc.Word, bar, wc.Count)
}
return b.String()
}
func main() {
text := `Go is expressive, concise, clean, and efficient. Its concurrency
mechanisms make it easy to write programs that get the most out of
multicore machines, while its novel type system enables flexible and
modular program construction. Go compiles quickly to machine code yet
has the convenience of garbage collection and the power of run-time
reflection. It is a fast, statically typed, compiled language that
feels like a dynamically typed, interpreted language.`
report, err := Analyze(text, Options{TopN: 6, MinLen: 4})
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Print(report)
if _, err := Analyze("!!! ... ???", Options{}); err != nil {
fmt.Println("\nempty input ->", err)
fmt.Println("is ErrEmptyInput:", errors.Is(err, ErrEmptyInput))
}
}
Walk through what's in there, because it's the course so far in one file:
Optionswith defaults filled in — the zero value is usable, soAnalyze(text, Options{})works (module 5).- A sentinel error wrapped with context —
%wpluserrors.Isat the call site (module 7). - A pointer result and
nilon the error path — no typed-nil trap (module 6). String()on*Reportsofmt.Print(report)just works (module 6), built with astrings.Builderrather than+=(module 4).fmt.Fprintfto a writer rather than printing directly (module 6).
Making it a real CLI
The one piece the browser can't run — reading a file and flags:
package main
import (
"flag"
"fmt"
"os"
)
func main() {
topN := flag.Int("top", 10, "how many words to show")
minLen := flag.Int("min", 3, "ignore words shorter than this")
flag.Parse()
if flag.NArg() < 1 {
fmt.Fprintln(os.Stderr, "usage: wordfreq [-top N] [-min N] <file>")
os.Exit(2)
}
data, err := os.ReadFile(flag.Arg(0))
if err != nil {
fmt.Fprintf(os.Stderr, "reading input: %v\n", err)
os.Exit(1)
}
report, err := Analyze(string(data), Options{TopN: *topN, MinLen: *minLen})
if err != nil {
fmt.Fprintf(os.Stderr, "analyzing: %v\n", err)
os.Exit(1)
}
fmt.Print(report)
}
Three conventions in there worth keeping: errors go to os.Stderr,
a failing program exits non-zero (2 for usage, 1 for runtime
failure), and flag gives you -h for free.
Because Analyze takes a string and returns a value rather than printing,
it's testable without any of this — the CLI is a thin shell around a library.
That separation is the single most useful structural habit in Go programs.
Your turn
Complete topWords: count the words, then return the top 3 as
"word:count" strings, sorted by count descending and word ascending:
[go:3 is:2 fast:1]
package main
import (
"fmt"
"sort"
"strings"
)
func topWords(text string, n int) []string {
// count the fields, rank them by count desc then word asc,
// and return the top n as "word:count"
}
func main() {
fmt.Println(topWords("go is fast go is go", 3))
}
package main
import (
"fmt"
"sort"
"strings"
)
func topWords(text string, n int) []string {
counts := make(map[string]int)
for _, w := range strings.Fields(text) {
counts[w]++
}
words := make([]string, 0, len(counts))
for w := range counts {
words = append(words, w)
}
sort.Slice(words, func(i, j int) bool {
if counts[words[i]] != counts[words[j]] {
return counts[words[i]] > counts[words[j]]
}
return words[i] < words[j]
})
if len(words) > n {
words = words[:n]
}
out := make([]string, 0, len(words))
for _, w := range words {
out = append(out, fmt.Sprintf("%s:%d", w, counts[w]))
}
return out
}
func main() {
fmt.Println(topWords("go is fast go is go", 3))
}
Next capstone: doing many things at once, correctly.