Projects, 1 of 3
A command-line tool (words)
Build words, a tool that counts the words in files, with options and help, tests, and a release build.
What you’ll build
words, a command-line tool that reads the files it’s given and lists the most common words in
them. It takes options and prints help made from the same declarations, reports files it can’t
read and goes on, has tests, and ships as one executable. It uses std.os’s Args for its command
line, std.io for files, and a Map to count.
The command line
A Struct describes the command line, and attributes say what each field is: @Positional for the
arguments, @Option for an option that takes a value, and @Flag for one that doesn’t.
@Program on the Struct names the tool. Args.Parse<Options>() reads the program’s arguments
into an Options.
Import std.core
Import std.os
@Program(Name: "words", About: "Counts the words in files.", Version: "1.0.0")
Struct Options
@Positional(Help: "The files to read", Name: "file")
Field Files: String[]
@Option(Help: "How many of the most common words to list", Short: "t")
Field Top: Int = 10
@Flag(Help: "Counts Word and word as one", Short: "i")
Field IgnoreCase: Bool
End Struct
Local options = Args.Parse<Options>()
Print("files: {options.Files}")
Print("top {options.Top}, ignoring case: {options.IgnoreCase}")files: [notes.txt, todo.txt] top 3, ignoring case: True
This ran as words --top 3 -i notes.txt todo.txt. fb run passes what follows -- to the program,
so fb run main.fb -- --top 3 -i notes.txt todo.txt runs it the same way. Each option’s long name
comes from its field’s, IgnoreCase as --ignore-case, and a field’s default is the option’s.
Help and mistakes
Args.Parse also handles -h and --help, printing help made from the same attributes, and
-V and --version, and then ends the program. Add a --min-length option, to leave out short
words, and ask for help:
Import std.core
Import std.os
@Program(Name: "words", About: "Counts the words in files.", Version: "1.0.0")
Struct Options
@Positional(Help: "The files to read", Name: "file")
Field Files: String[]
@Option(Help: "How many of the most common words to list", Short: "t")
Field Top: Int = 10
@Option(Help: "Leaves out words shorter than this", Short: "m")
Field MinLength: Int = 1
@Flag(Help: "Counts Word and word as one", Short: "i")
Field IgnoreCase: Bool
End Struct
Local options = Args.Parse<Options>()
Print("files: {options.Files}")Counts the words in files. Usage: words [options] [file]... Arguments: <file> The files to read Options: -t, --top <top> How many of the most common words to list (default: 10) -m, --min-length <min-length> Leaves out words shorter than this (default: 1) -i, --ignore-case Counts Word and word as one -h, --help Prints this help -V, --version Prints the version
A value that doesn’t fit its field’s type, like --min-length x, prints an error and the usage
line, and exits with code 2, before any of the program’s own code runs:
Import std.core
Import std.os
@Program(Name: "words", About: "Counts the words in files.", Version: "1.0.0")
Struct Options
@Positional(Help: "The files to read", Name: "file")
Field Files: String[]
@Option(Help: "How many of the most common words to list", Short: "t")
Field Top: Int = 10
@Option(Help: "Leaves out words shorter than this", Short: "m")
Field MinLength: Int = 1
@Flag(Help: "Counts Word and word as one", Short: "i")
Field IgnoreCase: Bool
End Struct
Local options = Args.Parse<Options>()
Print("files: {options.Files}")error: --min-length: expected Int32, found "x" Usage: words [options] [file]... For more, try '--help'.
Counting words
File.ReadText reads a whole file as text. It throws an IOError when it can’t, so the program
catches one for each file, says so on stderr, and goes on with the rest. WordsOf splits text
into its runs of letters, and a Map<String, Int> counts each one.
The rain in the night,
the wind in the trees,
and the night in the rain.Import std.core
Import std.collections
Import std.io
Import std.os
Import std.text
@Program(Name: "words", About: "Counts the words in files.", Version: "1.0.0")
Struct Options
@Positional(Help: "The files to read", Name: "file")
Field Files: String[]
@Option(Help: "How many of the most common words to list", Short: "t")
Field Top: Int = 10
@Option(Help: "Leaves out words shorter than this", Short: "m")
Field MinLength: Int = 1
@Flag(Help: "Counts Word and word as one", Short: "i")
Field IgnoreCase: Bool
End Struct
Function WordsOf(text: String): String[]
Local words: String[] = []
Local current = StringBuilder()
For c In text.Chars
If c.IsLetter()
current.Append(c)
ElseIf current.Length > 0
words.Add(current.ToString())
current.Clear()
End If
End For
If current.Length > 0 Then words.Add(current.ToString())
Return words
End Function
Local options = Args.Parse<Options>()
Local counts: Map<String, Int> = {}
Local total = 0
For path In options.Files
Try
For word In WordsOf(Try File.ReadText(path))
If word.Length < options.MinLength Then Continue
Local key = options.IgnoreCase ? word.ToLower() : word
counts[key] = (counts.Get(key) ?? 0) + 1
total += 1
End For
Catch e As IOError
PrintError("words: can't read {path}: {e.Kind}")
End Try
End For
Local common = counts.Keys()
common.SortByDescending(Function(word: String) => counts[word])
Print("{total} words, {counts.Count} different")
For word In common.View(0, Min(options.Top, common.Count))
Print("{counts[word]:>4} {word}")
End For16 words, 7 different 6 the 3 in 2 rain words: can't read missing.txt: NOT_FOUND
The output shows what went to stderr after what went to stdout. counts.Keys() gives the words
in the order they were first counted, and SortByDescending keeps words with the same count in
that order, so the list is the same on every run. {counts[word]:>4} right-aligns the count in
four columns.
A package with tests
As the tool grows, its counting moves into a module of its own, where tests can reach it. fb new words makes the package. counting.fb holds WordsOf and a Tally Class, main.fb the command
line, and tests/ the tests, which use the package through its Public names.
[project]
name = "words"
version = "1.0.0"Import std.core
Import std.collections
Import std.text
/// The words of `text`: its runs of letters, in order.
Public Function WordsOf(text: String): String[]
Local words: String[] = []
Local current = StringBuilder()
For c In text.Chars
If c.IsLetter()
current.Append(c)
ElseIf current.Length > 0
words.Add(current.ToString())
current.Clear()
End If
End For
If current.Length > 0 Then words.Add(current.ToString())
Return words
End Function
/// How many times each word comes up, in the order each first came up.
Public Class Tally
Field counts: Map<String, Int>
Field ignoreCase: Bool
Field minLength: Int
Field total: Int
Public New(ignoreCase: Bool, minLength: Int)
counts = {}
Self.ignoreCase = ignoreCase
Self.minLength = minLength
End New
/// Counts each word of `text`.
Public Method Add(text: String)
For word In WordsOf(text)
If word.Length < minLength Then Continue
Local key = ignoreCase ? word.ToLower() : word
counts[key] = (counts.Get(key) ?? 0) + 1
total += 1
End For
End Method
/// How many words it has counted.
Public Property Total: Int
Get => total
End Property
/// How many different words it has counted.
Public Property Distinct: Int
Get => counts.Count
End Property
/// The `count` most common words, with how many times each came up, most first.
Public Method Top(count: Int): (String, Int)[]
Local words = counts.Keys()
words.SortByDescending(Function(word: String) => counts[word])
Local top: (String, Int)[] = []
For word In words.View(0, Min(count, words.Count))
top.Add((word, counts[word]))
End For
Return top
End Method
End ClassImport std.core
Import std.io
Import std.os
Import words.counting
@Program(Name: "words", About: "Counts the words in files.", Version: "1.0.0")
Struct Options
@Positional(Help: "The files to read", Name: "file")
Field Files: String[]
@Option(Help: "How many of the most common words to list", Short: "t")
Field Top: Int = 10
@Option(Help: "Leaves out words shorter than this", Short: "m")
Field MinLength: Int = 1
@Flag(Help: "Counts Word and word as one", Short: "i")
Field IgnoreCase: Bool
End Struct
Local options = Args.Parse<Options>()
Local tally = Tally(options.IgnoreCase, options.MinLength)
For path In options.Files
Try
tally.Add(Try File.ReadText(path))
Catch e As IOError
PrintError("words: can't read {path}: {e.Kind}")
End Try
End For
Print("{tally.Total} words, {tally.Distinct} different")
For entry In tally.Top(options.Top)
Local word, times = entry
Print("{times:>4} {word}")
End ForImport std.core
Import std.test
Import words.counting
@Test
Function SplitsAtWhatIsntALetter()
Expect(WordsOf("rain, wind--and night") == ["rain", "wind", "and", "night"])
Expect(WordsOf("").Count == 0)
End Function
@Test
Function CountsEachWord()
Local tally = Tally(False, 1)
tally.Add("the cat and the hat")
Expect(tally.Total == 5)
Expect(tally.Distinct == 4)
Expect(tally.Top(1) == [("the", 2)])
End Function
@Test
Function LeavesOutShortWords()
Local tally = Tally(True, 4)
tally.Add("The rain in the night")
Expect(tally.Top(5) == [("rain", 1), ("night", 1)])
End FunctionRunning 3 tests test words.tests.counting_test.CountsEachWord ... ok test words.tests.counting_test.LeavesOutShortWords ... ok test words.tests.counting_test.SplitsAtWhatIsntALetter ... ok test result: ok. 3 passed; 0 failed; 0 skipped; 0 filtered out; finished in 0.02s
The output is fb test’s. Top gives (String, Int) tuples, which Local word, times = entry
takes apart, and which == compares element by element in the tests.
Shipping it
fb build --releasebuild/words, or build\words.exe on Windows, is the whole tool: std’s code is linked in, so it
needs nothing installed beside it. Copy it to a folder on your PATH, and run it anywhere:
words -t 5 -i notes.txtWhat you learned
- A Struct with
@Positional,@Option, and@Flagdescribes a command line, andArgs.Parse<T>()reads it, with help and errors made from the same attributes. File.ReadTextthrows anIOErrorthat the program can catch per file and go on.- A
Map<String, Int>counts, andSortByDescendingorders by count, keeping ties in order. - A package’s
tests/folder tests it from outside, through its Public names. fb build --releasemakes one executable to ship.
Report an issue with this page or with FutureBlitz on GitHub.