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.