Project: a to-do list
A command line tool with subcommands, options, a JSON file it reads and writes, and exit codes. Small enough to read in one go, shaped like the tools people actually keep.
//! A to-do list that lives in a JSON file.
//!
//! todo add "buy rope" todo list todo done 1 todo clear
//!
//! With no arguments it runs a demonstration session against a temporary
//! file. `--file PATH` chooses the file (default: todo.json in the temp dir).
import std.args
import std.fs
import std.json
struct Item { text: String, done: bool }
/// The items in the file, or none when there is no file yet.
fn load(path: []u8) -> !List(Item) {
var items = List(Item).new()
if !fs.exists(path) { return items }
let doc = try json.parse((try fs.read(path))[..])
for i in 0..json.len(&doc) {
let entry = json.at(&doc, i) orelse continue
let text = json.as_str(json.get(entry, "text") orelse continue) orelse continue
let done = json.as_bool(json.get(entry, "done") orelse continue) orelse false
items.append(Item{ .text = String.from(text), .done = done })
}
return items
}
fn save(path: []u8, items: *List(Item)) -> !void {
var doc = json.array()
for it in items.* {
var entry = json.object()
json.set(&mut entry, "text", json.string(it.text[..]))
json.set(&mut entry, "done", json.boolean(it.done))
json.push(&mut doc, entry)
}
try fs.write(path, json.pretty(&doc, 2)[..])
}
fn show(items: *List(Item)) {
if items.len == 0 { println("nothing to do", .{}); return }
for it, i in items.* {
println("{>2}. [{}] {}", .{i + 1, if it.done { "x" } else { " " }, it.text})
}
}
/// One command against the file; the exit code.
fn run(path: []u8, command: []u8, rest: [][]u8) -> !i32 {
var items = try load(path)
if command == "list" {
show(&items)
} else if command == "add" {
if rest.len == 0 { eprintln("add what?", .{}); return 2 }
var text = String.new()
for w, i in rest { if i > 0 { text.append(" ") }; text.append(w) }
items.append(Item{ .text = text, .done = false })
try save(path, &items)
println("added #{}", .{items.len})
} else if command == "done" {
if rest.len == 0 { eprintln("done which?", .{}); return 2 }
let n = rest[0].parse_int(usize) catch { eprintln("not a number: {}", .{rest[0]}); return 2 }
if n == 0 or n > items.len { eprintln("no item #{}", .{n}); return 1 }
items[n - 1].done = true
try save(path, &items)
println("done: {}", .{items[n - 1].text})
} else if command == "clear" {
fs.remove(path) catch { }
println("cleared", .{})
} else {
eprintln("usage: todo [--file PATH] add TEXT | list | done N | clear", .{})
return 2
}
return 0
}
fn main() -> !u8 {
var p = args.Parser.new(os.args())
let given = p.option("--file", "-f")
let path = if let f = given { String.from(f) } else { fs.join(fs.temp_dir()[..], "todo.json") }
let rest = p.rest()
if rest.len > 0 {
return (try run(path[..], rest[0], rest[1..])) as u8
}
// no command: a demonstration against a fresh file
fs.remove(path[..]) catch { }
let session = ["add buy rope", "add sharpen crampons", "add pack the tent", "list", "done 2", "list", "done 9", "clear", "list"]
for line in session {
println("$ todo {}", .{line})
var words = List([]u8).new()
for w in line.split(" ") { words.append(w) }
_ = try run(path[..], words[0], words[1..])
}
return 0
}
With no arguments it runs a session against a temporary file, so the book can show it working:
$ todo add buy rope
added #1
$ todo add sharpen crampons
added #2
$ todo add pack the tent
added #3
$ todo list
1. [ ] buy rope
2. [ ] sharpen crampons
3. [ ] pack the tent
$ todo done 2
done: sharpen crampons
$ todo list
1. [ ] buy rope
2. [x] sharpen crampons
3. [ ] pack the tent
$ todo done 9
$ todo clear
cleared
$ todo list
nothing to do
no item #9
no item #9 came out on standard error and is shown last for that reason; in a terminal it appears in order.
Arguments with std.args#
var p = args.Parser.new(os.args())
let given = p.option("--file", "-f")
let rest = p.rest()
args.Parser answers questions about the command line: flag for --verbose, option for --file PATH (or --file=PATH, or -f PATH), int_option for a number, and rest() for whatever was not consumed, which is where the subcommand and its words are. Ask about the options first, then take the rest.
The file#
fn load(path: []u8) -> !List(Item) {
std.fs reads the file, std.json parses it. json.parse returns a Json value, an enum much like the calculator's tokens: Null, Bool, Num, Str, Arr, Obj. The accessors json.at, json.get, json.as_str, json.as_bool each return an optional, and orelse continue skips an entry that is not shaped as expected instead of crashing on it. A file that is not JSON at all is an error from parse, which try turns into the tool's exit with error: InvalidInput.
Writing goes the other way: json.array(), json.object(), json.set, json.push build a value, json.pretty(&doc, 2) renders it with two-space indentation, fs.write replaces the file. The whole file is rewritten on every change, which is right for a list of a few hundred lines and wrong for a database; this is a to-do list.
Exit codes#
fn main() -> !u8 {
A main that returns u8 chooses the process's exit code: 0 for success, 2 for a usage mistake, 1 for "no such item". Scripts and other programs can branch on it. !u8 means it can also fail with an error, in which case the code is 1 and the name is printed.
run separates the decision about the exit code (its i32 result) from the failures it did not decide (! on the return type, from load and save). That split, a status for expected outcomes and an error for the rest, is one to copy.
Things to try#
todo edit N TEXT, andtodo done Ntoggling instead of setting.- Keep the file in the user's home directory:
os.env("HOME")(USERPROFILEon Windows), joined withfs.join. - Print dates:
std.timehasnow_local()andiso(). - Ship it: chapter 20 turns any program with a
maininto an installer.
Next: binary patterns.
Exercises#
The compiler grades these: nx topo 11 writes them into a directory, nx topo check runs them. Or do them here: the code is editable, Run runs it with the compiler in the page (it loads on the first run and never sends your code anywhere), Check grades it as nx topo check would, and Reset puts the exercise back. Ctrl+Enter runs.
fill_usagefill in the blank
run decides the exit code: 0 done, 1 no such item, 2 a usage mistake. Replace ??? so a
number that does not parse is a usage mistake rather than a crash.
fn run(words: [][]u8, items: *List(String)) -> i32 {
if words.len != 2 or words[0] != "show" { return 2 }
let n = words[1].parse_int(usize) ??? { return 2 }
if n == 0 or n > items.len { return 1 }
println("#{} {}", .{n, items[n - 1]})
return 0
}
fn main() {
var items = List(String).new()
items.append(String.from("buy rope"))
items.append(String.from("check the weather"))
println("exit {}", .{run(["show", "2"][..], &items)})
println("exit {}", .{run(["show", "9"][..], &items)})
println("exit {}", .{run(["show", "two"][..], &items)})
println("exit {}", .{run(["dance"][..], &items)})
}
The recorded output
#2 check the weather
exit 0
exit 1
exit 2
exit 2
Hints
parse_intreturns an error union; one keyword handles its error in place (chapter 6)
Solution
fn run(words: [][]u8, items: *List(String)) -> i32 {
if words.len != 2 or words[0] != "show" { return 2 }
let n = words[1].parse_int(usize) catch { return 2 }
if n == 0 or n > items.len { return 1 }
println("#{} {}", .{n, items[n - 1]})
return 0
}
fn main() {
var items = List(String).new()
items.append(String.from("buy rope"))
items.append(String.from("check the weather"))
println("exit {}", .{run(["show", "2"][..], &items)})
println("exit {}", .{run(["show", "9"][..], &items)})
println("exit {}", .{run(["show", "two"][..], &items)})
println("exit {}", .{run(["dance"][..], &items)})
}
write_item_jsonwrite it
Fill in item_json so the tests pass: an item as a JSON object with "text" and "done".
import std.json
struct Item {
text: String
done: bool
}
fn item_json(item: *Item) -> String {
???
}
fn main() {
let item = Item{ .text = String.from("buy rope"), .done = false }
println("{}", .{item_json(&item)})
}
test "an open item" {
let item = Item{ .text = String.from("buy rope"), .done = false }
expect_eq(item_json(&item)[..], "{\"text\":\"buy rope\",\"done\":false}")
}
test "a finished item" {
let item = Item{ .text = String.from("pack the tent"), .done = true }
expect_eq(item_json(&item)[..], "{\"text\":\"pack the tent\",\"done\":true}")
}
test "quotes are escaped" {
let item = Item{ .text = String.from("say \"hi\""), .done = false }
expect_eq(item_json(&item)[..], "{\"text\":\"say \\\"hi\\\"\",\"done\":false}")
}
The recorded output
{"text":"buy rope","done":false}
Hints
json.object()is an empty object;json.set(&mut doc, key, value)adds a memberjson.string(s)andjson.boolean(b)make the values;json.stringify(&doc)writes it compactly
Solution
import std.json
struct Item {
text: String
done: bool
}
fn item_json(item: *Item) -> String {
var doc = json.object()
json.set(&mut doc, "text", json.string(item.text[..]))
json.set(&mut doc, "done", json.boolean(item.done))
return json.stringify(&doc)
}
fn main() {
let item = Item{ .text = String.from("buy rope"), .done = false }
println("{}", .{item_json(&item)})
}
test "an open item" {
let item = Item{ .text = String.from("buy rope"), .done = false }
expect_eq(item_json(&item)[..], "{\"text\":\"buy rope\",\"done\":false}")
}
test "a finished item" {
let item = Item{ .text = String.from("pack the tent"), .done = true }
expect_eq(item_json(&item)[..], "{\"text\":\"pack the tent\",\"done\":true}")
}
test "quotes are escaped" {
let item = Item{ .text = String.from("say \"hi\""), .done = false }
expect_eq(item_json(&item)[..], "{\"text\":\"say \\\"hi\\\"\",\"done\":false}")
}
Quizno peeking
fn main() -> !u8: what is the exit code whenmainreturns an error?- 0
- 1, and the error's name is printed
- 2
Answer
1, and the error's name is printed. A
u8result chooses the code; an error from!is reported by name with code 1.nx check: which line does the compiler reject?fn main() -> u8 { let n = "12".parse_int(u8) return n }- it compiles
- line 2
- line 3
Answer
line 3.
parse_intreturns an error union;mainpromises a plainu8, so the error has to be handled (catch,try) before the value is returned.The compiler says:
type mismatch in return value: expected `u8` but found `!u8`- Why does
runreturn ani32status and have!on its result?- the status is the decision it made (usage mistake, no such item), the error is what it could not decide (the file failed to load)
- an
i32cannot hold an error !is required on every function that reads a file
Answer
the status is the decision it made (usage mistake, no such item), the error is what it could not decide (the file failed to load). Expected outcomes are values; failures are errors. The split keeps
mainshort.