Call it nexwc — a distant cousin of Unix's wc, with word frequency added. Given a file, it reports how many lines and words it contains, and its most common words. A programmer could write a version of this in twenty minutes without thinking about contracts at all. The point of walking through it carefully is to notice what changes when you do think about them — the design gets stated before the code, boundaries gets real checks instead of an assumption, and testing is simplified because the library underneath is separated from the command line wrapped around it.
Problem
nexwc takes a file path and an optional flag:
nexwc <file> -- line/word counts, top 5 words
nexwc <file> --top <n> -- top n words instead of top 5
Everything about its behavior at the boundary has to be decided before any code: what happens when the file does not exist, what happens when --top is given a non-positive number or no number at all, and what the process's exit code means to whatever invoked it. A CLI's real interface isn't its function signatures. It's the shape of its arguments, the text of its errors, and the number it hands back to the shell. Decide that first, and the implementation underneath has very little room to be wrong in a way that matters.
The first function we write for this program is for handling abnormal conditions it may face while on duty:
function fail(message: String, code: Integer) do
print("error: " + message)
exit(code)
end
Every error path in nexwc goes through fail, and nowhere else. That is worth noticing before anything else in this chapter: the message and the exit are printed and enforced from exactly one place, not scattered through the program wherever an error might occur. fail is named once, does one thing, and every caller trusts it to actually stop execution rather than falling through — which is exactly the kind of promise a contract is good at stating, even here, where the promise is about control flow rather than a value.
Design as Contract
The counting logic has nothing to do with files, flags, or exit codes, so it does not live anywhere near them. Word_Stats is a small, pure class.
class Word_Stats
create
make(text: String) do
this.text := text
end
feature
text: String
line_count(): Integer
do
if text = "" then
result := 0
else
result := text.split("\n").length
end
ensure
non_negative: result >= 0
end
word_count(): Integer
do
result := words().length
ensure
non_negative: result >= 0
end
...
end
These postconditions look almost too obvious to write — of course a count is non-negative. That is the point of writing them anyway. A postcondition earns its keep the moment the implementation changes and stops being obviously correct; non_negative costs one line today and stands guard against every future edit to line_count's body, including ones nobody has thought of yet. The interesting contract in this file is top_words, the one method with real work to do:
-- The n most frequent words, most frequent first; ties broken
-- alphabetically so the output is deterministic.
top_words(n: Integer): Array[Count_Entry]
require
positive: n > 0
do
...
ensure
bounded: result.length <= n
end
require positive: n > 0 is the contract's half of a promise that the CLI layer has to keep: whatever calls this method has already made sure n is a positive count. That single line is why --top's validation belongs on the command-line side of this program rather than inside the library — the library states what it needs at its boundary and trusts the caller to have gotten there, rather than re-checking a condition its caller already checked. ensure bounded closes the other end: whatever the frequency table looks like, you never get back more entries than you asked for, even from a two-word input.
Build
The complete, current code for this project is at examples/contracts_at_work/01_text_stats on GitHub.
Word splitting uses a compiled regular expression rather than hand-rolled character scanning — this is what a CLI tool's arithmetic actually looks like once the interesting part is a library method away:
intern text/Regex
words(): Array[String] do
let rx: Regex := create Regex.compile_with_flags("[a-z']+", "i")
result := rx.find_all(text)
end
Frequency counting builds a Map[String, Integer], converts it to an array of a small result type, and sorts by count with a case-insensitive alphabetical tiebreaker, using an inline comparison function passed straight to sort:
let sorted: Array[Count_Entry] := entries.sort(fn(a: Count_Entry, b: Count_Entry): Integer do
if a.count /= b.count then
result := b.count - a.count
elseif a.word < b.word then
result := -1
elseif a.word > b.word then
result := 1
else
result := 0
end
end)
The tiebreak matters more than it looks like it should: without it, two words with equal frequency could print in either order depending on Map iteration order, and a test asserting an exact top-N list would be flaky through no fault of its own. Deterministic output is a design decision, not an accident, and it is cheaper to build in here than to chase down later as an intermittent test failure.
The CLI wrapper, nexwc.nex, is where argument parsing lives — and where the postcondition on parse_top_count mirrors top_words's precondition on the other side of the same promise:
function parse_top_count(args: Array[String]): Integer
do
result := 5
let i: Integer := 0
from
i := 0
until
i = args.length
do
if args.get(i) = "--top" then
if i + 1 >= args.length then
fail("--top requires a number", 2)
end
let n: Integer := args.get(i + 1).to_integer()
if n <= 0 then
fail("--top must be a positive integer, got " + args.get(i + 1), 2)
end
result := n
end
i := i + 1
end
ensure
positive: result > 0
end
Notice the shape of this: parse_top_count never returns without either a positive number or having already called fail, which does not return at all. The ensure positive: result > 0 is not defensive filler — it is a machine-checked statement that this function's two exit paths (a good number, or no return) are the only two that exist, which is exactly the guarantee top_words's precondition needs from its caller. This is "parse, don't validate" in miniature: args is parsed into a value that is positive by construction the moment it exists, so nothing downstream ever needs to ask again.
The one thing this project needed that the design above did not anticipate: a bare relative path such as nexwc.nex sample.txt resolves against the nex CLI launcher's own installation directory, not wherever the program was actually invoked from — the launcher changes into $NEX_HOME before starting the JVM. This is documented behavior in the language, not a bug, and the fix is a few lines that read the caller's real working directory back out of an environment variable the launcher sets beforehand:
function resolve_path(proc: Process, raw: String): String do
if raw.starts_with("/") then
result := raw
else
result := proc.getenv("NEX_USER_DIR") + "/" + raw
end
end
Every project in this book that touches a file by a relative path — Chapters 2, 5, and 9 among them — uses this exact pattern.
Test
Word_Stats is pure and host-free, so its check suite is eight direct assertions with nothing to set up and nothing to tear down — construct it with a string, ask it questions, compare the answers:
let sample: String := "the quick brown fox\njumps over the lazy dog\nthe fox runs"
let stats: Word_Stats := create Word_Stats.make(sample)
c.check("line_count", "3", "" + stats.line_count())
c.check("word_count", "12", "" + stats.word_count())
let top2: Array[Count_Entry] := stats.top_words(2)
c.check("top_words[0]", "the: 3", top2.get(0).to_string())
c.check("top_words[1]", "fox: 2", top2.get(1).to_string())
nexwc.nex itself has no automated check suite, and that is a deliberate choice rather than an omission. Once the counting logic is fully covered as a library, what is left in the entry-point file is argument parsing, an existence check, and a print loop — thin enough that the cost of scripting a subprocess harness to assert on stdout and exit codes would outweigh what it catches. It was still verified directly, by hand, before being trusted: run against the sample file with no flag, with --top 3, against a missing file, and against a malformed --top value, checking in each case that the output and exit code matched what the original problem promised. In short, often the right way for testing a CLI tool built like this is: exhaustive, automated coverage on the pure library beneath it, and a short, deliberate manual pass on the thin host-facing shell that calls it.
Takeaways
Split a program's logic from the shell that reads arguments and prints output, and testing stops being a chore: the logic gets exhaustive, automated coverage with nothing to set up, and the thin shell left around it is small enough that a short manual pass covers it completely. A postcondition that looks too obvious to write — a count can't be negative — is still worth writing, because it costs one line today and stands guard against every future edit to that method's body, including the ones nobody has thought of yet. And "parse, don't validate" applies as much to a CLI's own arguments as to anything else: once parse_top_count hands back a value, nothing downstream ever needs to ask whether it's positive again.
Chapter 2 asks a harder version of the same question this project answered quietly: what does a contract owe a piece of state that has to survive being written to disk and read back?