examples/todo/
Keep a to-do list in a file.
- 334 lines
- 2 modules
- command line
- reads a file
- writes a file
A separate module, a file with its own functions, decides how the list changes and what text to save. These functions work only with values passed to them, so their tests can check the list without reading or writing files.
Run it
1. [x] write the corpus harness
2. [x] find a compiler defect with it
3. [ ] make the CI run on both platforms
4. [ ] re-measure heroes mutate over the enlarged corpus
2 of 4 left
exit 0 · 5 lines
The program
# A todo list kept in a file.
#
# heroes run examples/todo/main.hero -- examples/todo/sample.todo
# heroes run examples/todo/main.hero -- /tmp/mine.todo add buy milk
# heroes run examples/todo/main.hero -- /tmp/mine.todo done 2
#
# main.hero the program: arguments, the file, the one write
# list.hero the list, the format, and every change to it
#
# **The corpus's first program that writes.** `read_file` has been exercised
# since M-ffi-ladder and `write_file` had never been asked to do anything a
# reader could check; here the round trip is the program — add a task, write it,
# read it back, and the file on disk is the only place the state lives.
#
# The one write is in `save`, and everything that decides *what* to write is pure
# next door. That is what lets `list.hero`'s twenty assertions run without
# touching the disk, and it is what value semantics makes free: `complete`
# returns a new list, because there is nothing else it could return.
use list
## Error codes
#
# One `constant` per code, so a typo is `error[unknown_name]` here rather
# than a comparison that is quietly false forever (panel 034 R4).
constant ERR_NO_NUMBER: str
"no_number"
function main()
given = args()
if len(given) == 0
print("usage: todo <file> [add TEXT… | done N]")
exit(2)
match read_file(given[0])
.err _ => stop("cannot read " + given[0])
.ok text => run(text, given)
function stop(message: str)
print(message)
exit(1)
function run(text: str, given: [str])
match list.read(text)
.err e => stop(e.code + ": " + e.msg)
.ok tasks => act(tasks, given)
function act(tasks: [list.Task], given: [str])
if len(given) == 1
show(tasks)
return
command = given[1]
if command == "add"
match list.add(tasks, rest_of(given, 2))
.err e => stop(e.code + ": " + e.msg)
.ok grown => save(grown, given[0])
else if command == "done"
match number_of(rest_of(given, 2))
.err e => stop(e.code + ": " + e.msg)
.ok n => finish(tasks, n, given[0])
else
stop("no command `" + command + "` — say `add TEXT…` or `done N`")
function finish(tasks: [list.Task], n: i64, path: str)
match list.complete(tasks, n)
.err e => stop(e.code + ": " + e.msg)
.ok after => save(after, path)
# The only line in this program that writes anything.
function save(tasks: [list.Task], path: str)
match write_file(path: path, text: list.render(tasks))
.err _ => stop("cannot write " + path)
.ok _ => show(tasks)
function show(tasks: [list.Task])
for line in list.numbered(tasks)
print(line)
print(list.left_to_do(tasks), " of ", tasks.len(), " left")
# Everything from `at` on, joined by a space — so `add buy milk` is one task and
# not two arguments the shell happened to split.
function rest_of(given: [str], at: i64) -> str
words: [str] @= []
for i in range(from: at, to: len(given))
words @ words.push(given[i])
return join(words, " ")
function number_of(word: str) -> i64?
if word.len() == 0
return fail(code: ERR_NO_NUMBER, msg: "`done` needs the number of a task")
v: i64 @= 0
for i in range(from: 0, to: word.len())
if word[i] < '0' || word[i] > '9'
return fail(code: ERR_NO_NUMBER, msg: "`" + word + "` is not a number")
v @ v * 10 + to_i64(word[i] - '0').must()
return ok(v)
test "the sample lists the way the file reads"
tasks = list.read(read_file("examples/todo/sample.todo").must()).must()
assert tasks.len() == 4
assert list.left_to_do(tasks) == 2
assert list.numbered(tasks)[0] == "1. [x] write the corpus harness"
test "a task added is a task on disk, read back by a second program"
path = "build/heroes-corpus-todo.txt"
write_file(path: path, text: "[ ] one\n").must()
first = list.read(read_file(path).must()).must()
grown = list.add(first, "two").must()
write_file(path: path, text: list.render(grown)).must()
again = list.read(read_file(path).must()).must()
assert again.len() == 2
assert again[1].text == "two"
assert again[1].done == false
test "completing a task survives the round trip through the file"
path = "build/heroes-corpus-todo-done.txt"
write_file(path: path, text: "[ ] one\n[ ] two\n").must()
tasks = list.read(read_file(path).must()).must()
write_file(path: path, text: list.render(list.complete(tasks, 1).must())).must()
after = list.read(read_file(path).must()).must()
assert after[0].done == true
assert after[1].done == false
assert list.left_to_do(after) == 1
test "arguments after the command become one task"
assert rest_of(["f", "add", "buy", "milk"], 2) == "buy milk"
assert rest_of(["f", "add"], 2) == ""
assert number_of("12").must() == 12
assert number_of("x").is_err()
assert number_of("").is_err()
list.hero · 196 lines
# The list, and the file it lives in.
#
# [ ] buy milk
# [x] write the maze solver
#
# A format a person can edit in any editor, which is the only reason to store a
# list as text at all — so it is read strictly enough to catch a typo and
# loosely enough to survive one: the marker must be `[ ]` or `[x]`, and
# everything after it is the task, spaces and brackets included.
#
# **Nothing here writes anything.** Reading, rendering and changing are all pure
# — a `[Task]` in, a `[Task]` out — and the one call to `write_file` is in
# `main.hero`. That split is what lets every test below run without touching the
# disk, and it is the same split value semantics makes free: `complete` returns a
# new list rather than editing one, because there is no other thing it could do.
## Error codes
#
# One `constant` per code, so a typo is `error[unknown_name]` here rather
# than a comparison that is quietly false forever (panel 034 R4).
constant ERR_BAD_LINE: str
"bad_line"
constant ERR_BAD_MARKER: str
"bad_marker"
constant ERR_EMPTY_TASK: str
"empty_task"
constant ERR_NO_SUCH_TASK: str
"no_such_task"
record Task
done: bool
text: str
function is_space(c: u8) -> bool
return c == ' ' || c == '\t' || c == 13
function trimmed(line: str) -> str
from: i64 @= 0
while from < line.len() && is_space(line[from])
from @ from + 1
to: i64 @= line.len()
while to > from && is_space(line[to - 1])
to @ to - 1
return line.slice(from: from, to: to)
function split_lines(text: str) -> [str]
out: [str] @= []
start: i64 @= 0
i: i64 @= 0
while i < text.len()
if text[i] == '\n'
out @ out.push(text.slice(from: start, to: i))
start @ i + 1
i @ i + 1
if start < text.len()
out @ out.push(text.slice(from: start, to: text.len()))
return out
## Reading and writing
function read(text: str) -> [Task]?
out: [Task] @= []
number: i64 @= 0
for line in split_lines(text)
number @ number + 1
head = trimmed(line)
if head == ""
continue
if head.len() < 4 || head[0] != '[' || head[2] != ']' || head[3] != ' '
return fail(code: ERR_BAD_LINE, msg: "line " + number.to_str() + ": a task is `[ ] text` or `[x] text`")
marker = head[1]
if marker != ' ' && marker != 'x'
return fail(code: ERR_BAD_MARKER, msg: "line " + number.to_str() + ": the marker is a space or an `x`")
out @ out.push(Task(done: marker == 'x', text: trimmed(head.slice(from: 4, to: head.len()))))
return ok(out)
function render(tasks: [Task]) -> str
if tasks.len() == 0
return ""
lines: [str] @= []
for task in tasks
lines @ lines.push(marker_of(task) + " " + task.text)
return join(lines, "\n") + "\n"
function marker_of(task: Task) -> str
if task.done
return "[x]"
return "[ ]"
## Changing
function add(tasks: [Task], text: str) -> [Task]?
if trimmed(text) == ""
return fail(code: ERR_EMPTY_TASK, msg: "a task needs some text")
return ok(tasks.push(Task(done: false, text: trimmed(text))))
# `n` counts from 1, because the list a person reads is numbered from 1 and the
# number they type is the one they read. Converting once, here, is cheaper than
# remembering which end of the program is which.
function complete(tasks: [Task], n: i64) -> [Task]?
if n < 1 || n > tasks.len()
return fail(
code: ERR_NO_SUCH_TASK,
msg: "there is no task " + n.to_str() + " — the list has " + tasks.len().to_str()
)
out: [Task] @= []
for i in range(from: 0, to: tasks.len())
if i == n - 1
out @ out.push(Task(done: true, text: tasks[i].text))
else
out @ out.push(tasks[i])
return ok(out)
function left_to_do(tasks: [Task]) -> i64
open: i64 @= 0
for task in tasks
if !task.done
open @ open + 1
return open
# `1. [ ] buy milk`, the view a person reads and types a number back at.
function numbered(tasks: [Task]) -> [str]
out: [str] @= []
for i in range(from: 0, to: tasks.len())
out @ out.push((i + 1).to_str() + ". " + marker_of(tasks[i]) + " " + tasks[i].text)
return out
test "a file round-trips through reading and rendering"
text = "[ ] one\n[x] two\n"
assert render(read(text).must()) == text
assert read("").must() == []
assert render([]) == ""
test "blank lines are skipped and spacing is forgiven"
tasks = read("\n [ ] one \n\n[x] two\n").must()
assert tasks.len() == 2
assert tasks[0].text == "one"
assert tasks[0].done == false
assert tasks[1].done == true
test "a line that is not a task is refused by name"
assert code_of("one\n") == ERR_BAD_LINE
assert code_of("[ ]one\n") == ERR_BAD_LINE
assert code_of("[?] one\n") == ERR_BAD_MARKER
test "adding appends, and an empty task is not a task"
tasks = read("[ ] one\n").must()
grown = add(tasks, " two ").must()
assert grown.len() == 2
assert grown[1].text == "two"
assert grown[1].done == false
assert add(tasks, " ").is_err()
assert tasks.len() == 1
test "completing counts from one, and leaves every other task alone"
tasks = read("[ ] one\n[ ] two\n[ ] three\n").must()
after = complete(tasks, 2).must()
assert after[0].done == false
assert after[1].done == true
assert after[2].done == false
assert left_to_do(after) == 2
assert complete(tasks, 0).is_err()
assert complete(tasks, 4).is_err()
assert tasks[1].done == false
test "the numbered view is what a person types a number back at"
tasks = read("[ ] one\n[x] two\n").must()
assert numbered(tasks) == ["1. [ ] one", "2. [x] two"]
function code_of(text: str) -> str
return match read(text)
.ok _ => "no_error"
.err e => e.code
Input
[x] write the corpus harness
[x] find a compiler defect with it
[ ] make the CI run on both platforms
[ ] re-measure heroes mutate over the enlarged corpus
fingerprint sha256 f12dd6874055 · 5 files · index.json
Next: logs. All the examples. ← Previous: shapes
Run one yourself: the four commands. Learn the language, or read the full specification in one file.