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.

topo/code/todo.nx
//! 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:

topo/code/todo.expected
$ 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#

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.

fill_usage.nx
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_int returns 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".

write_item_json.nx
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 member
  • json.string(s) and json.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

  1. fn main() -> !u8: what is the exit code when main returns an error?
    • 0
    • 1, and the error's name is printed
    • 2
    Answer

    1, and the error's name is printed. A u8 result chooses the code; an error from ! is reported by name with code 1.

  2. 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_int returns an error union; main promises a plain u8, 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`
  3. Why does run return an i32 status 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 i32 cannot 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 main short.