Exercise 16: Ship a useful command¶
Aim: build tally, a native command that reports nonblank lines and words
in a UTF-8 text file. This is a small program with a stated contract, not a clone
of the system wc command. Stay in the project from exercise 15.
State the contract first¶
The command accepts exactly one file path. A nonblank line has content after trimming whitespace. For this exercise a word is a nonempty token separated by ASCII spaces or tabs within that line. LF and CRLF end lines. A lone CR and other Unicode separators are outside this word-separator contract. Unicode letters inside a word are supported. An empty file reports zero and zero.
Output is exactly lines=N words=N followed by a newline. Success exits 0.
Wrong argument count prints usage on stderr and exits 2. An unreadable file
or invalid UTF-8 prints a read error on stderr and exits 1.
Before looking at the implementation, write a function outline: read arguments, validate their count, read the file, calculate totals and report a result. Identify the pure calculation separately from file I/O and printing.
Type and predict¶
Put this starting implementation in src/main.fib. Explain each function using
the earlier exercises. Predict the no-argument run before reading the answer.
(ns main (:use fib.core fib.seq fib.coll)
(:require [fib.string :as text]))
(defstruct Totals (lines: i64 words: i64))
(defun words-in (line: str) -> i64
(let ((normal (text/replace line "\t" " "))
(tokens (text/split-literal normal " ")))
(count (filterv (fn (word: str) (not (text/blank? word))) tokens))))
(defun tally (input: str) -> Totals
(let ((lines (filterv (fn (line: str) (not (text/blank? line)))
(text/split-lines input))))
(Totals (count lines) (reduce + 0 (mapv words-in lines)))))
(defun report-file (path: str) -> i64
(match (read-file path)
(nil (do (eprintln (str "cannot read " path)) 1))
((some input)
(let ((totals (tally input)))
(do
(println (str "lines=" (. totals lines) " words=" (. totals words)))
0)))))
(defun main () -> i64
(let ((arguments (args)))
(if (= (count arguments) 1)
(report-file (nth arguments 0))
(do (eprintln "usage: tally FILE") 2))))
Check the no-argument run
fibc run src/main.fib prints usage: tally FILE on stderr, and prints main's
result on stdout:
That 2 is the JIT's result display. Build the executable to check the actual
process exit status. read-file returns an Option str, preserving read/UTF-8
failure rather than substituting empty text.
Make fixtures and build¶
printf 'red blue\n\n green\tgold \n' > sample.txt
printf '' > empty.txt
fibc run src/main.fib -- sample.txt
fibc build src/main.fib -o tally
./tally sample.txt
echo $?
The JIT run prints lines=2 words=4, then 0. The native command prints only
lines=2 words=4; the immediately following echo $? prints its status 0.
Spaces in file paths are fine when the shell argument is quoted.
Acceptance checks¶
Create a fixture for each applicable row. Check stdout, stderr and the native
exit status separately. Read $? immediately after the command: another command
would replace it. Do not count a usage error as a successful empty-file result.
| Input | Stdout | Stderr | Exit status |
|---|---|---|---|
sample.txt above |
lines=2 words=4 |
empty | 0 |
| empty file | lines=0 words=0 |
empty | 0 |
| spaces/tabs and blank lines only | lines=0 words=0 |
empty | 0 |
red with no final newline |
lines=1 words=1 |
empty | 0 |
red\r\nblue\r\n |
lines=2 words=2 |
empty | 0 |
café tea\n |
lines=1 words=2 |
empty | 0 |
| missing path | empty | cannot read PATH |
1 |
| directory path | empty | cannot read PATH |
1 |
file containing byte FF (invalid UTF-8) |
empty | cannot read PATH |
1 |
| no arguments | empty | usage: tally FILE |
2 |
| two arguments | empty | usage: tally FILE |
2 |
Every message in the table has a final newline. The documentation checker builds
this program and exercises these rows; readers should reproduce them themselves.
For invalid UTF-8, printf '\377' > invalid.txt writes a single invalid byte.
Change it¶
- Move
Totals,words-inandtallyintosrc/tally/counts.fib, with namespacetally.counts. Require that module from main under an alias. - Write
specs/counts-spec.fibfor the puretallyfunction, checking both fields. Include empty input, repeated spaces, tabs, CRLF and the no-final-newline case. - Repeat every native acceptance row after the module move. Behaviour specs for the pure calculation do not check argument handling or file failures.
Break and repair¶
Remove the token filter in words-in. Multiple spaces now introduce empty
tokens counted as words. Make a fixture red blue and show the wrong result
before restoring the filter. Add a regression spec for that fixture.
Separately, change the usage branch to return 0. Show the acceptance check that
detects this wrong exit status even though the message looks right. Restore 2.
Final checkpoint¶
Demonstrate a clean acceptance table, a caught planted failure and a repaired run. Explain why the missing-file case cannot count as an empty file, why one successful sample is insufficient, and why an already-built binary must be rebuilt after editing its source. Ask someone else to run it on a new fixture.
You have completed the course when you can recreate this program from its contract and the library reference, without copying the implementation. Next try concurrency, protocols and enums, or numerics. Check target and deployment support before shipping a binary to another machine.