Heroes / Examples / todo

examples/todo/

Keep a to-do list in 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

$ heroes run examples/todo/main.hero -- examples/todo/sample.todo
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()
examples/todo/main.hero, 138 lines
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
examples/todo/list.hero, 196 lines

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
examples/todo/sample.todo, 4 lines

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.