Heroes / Esempi / todo

examples/todo/

Una lista di cose da fare salvata in un file.

Il programma separa la gestione della lista dalla lettura e scrittura del file. Le funzioni che decidono cosa salvare lavorano solo sui dati ricevuti e restituiscono un risultato. I test della lista possono quindi essere eseguiti senza accedere al disco.

Eseguilo

$ 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 righe

Il programma

# 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 righe
list.hero · 196 righe
# 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 righe

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 righe

impronta sha256 f12dd6874055 · 5 file · index.json

Successivo: logs. Tutti gli esempi. ← Precedente: shapes

Eseguine uno: i quattro comandi. Impara il linguaggio, oppure leggi la specifica completa in un solo file.