19. Worked example: a Todo CLI
The full v0.1-runnable form is in examples/v0_1/todo/. hanki run examples/v0_1/todo/ drives the REPL, and hanki test examples/v0_1/todo/ runs the parse-layer assertions. The condensed listings below mirror those files. One v0.1 detail needs flagging:
main!receives its argv asList<string>from whatever trails an explicit path,hanki run path arg1 arg2 …, and a.hkfile made executable with a#!/usr/bin/env -S hanki runshebang therefore takes arguments the obvious way, which no--spelling can express: the kernel appends the script's own arguments, leaving nowhere to put a separator. A trailing argument may not start with a hyphen, which is an error naming the remedy in place of swallowing a flag meant forhanki run.hanki run path -- arg1 arg2 …still works and is the way to pass argv that does begin with a hyphen. The Todo CLI accepts the parameter for spec conformance and does not read it. The program's own path is not in argv and never will be prepended: it comes back through a door of its own,sys.program_path!() -> Option<string>, effect[env], giving underhanki runthe canonical entry.hkpath, the$0a shell script had, for an AOT-compiled binary the resolved executable itself, andNonewhere no single program owns the process, in the REPL and in ahanki testrun. It is for finding files relative to the program in place of relative to wherever the user stands.
# === types.hk ===
@derive(Eq)
struct Todo
id: i32
text: string
done: bool
end
@derive(Eq)
type Command
Add(string)
Done(i32)
List
Quit
end
@derive(Eq)
type CommandError
NotFound(i32)
Parse(string)
end
impl Display<Todo>
def to_string(self) -> string
marker = if self.done then "[x]" else "[ ]"
"#{marker} ##{self.id} #{self.text}"
end
end
impl Display<CommandError>
def to_string(self) -> string
match self
NotFound(id) -> "todo ##{id} not found"
Parse(msg) -> "parse error: #{msg}"
end
end
end
# === parse.hk ===
open result
open types
def parse_command!(line: string) -> Command [throws CommandError]
if line == "quit"
Quit
elif line == "list"
List
elif line.starts_with?("add ")
Add(line.slice_from(4))
elif line.starts_with?("done ")
match i32.parse(line.slice_from(5))
Ok(id) -> Done(id)
Err(_) -> throw Parse(line)
end
else
throw Parse(line)
end
end
# === todos.hk ===
# `open types` claims bare `List` for the `Command::List` variant (a bare
# use through a second open exporting it would be ambiguous, H0306), so
# the container type is reached qualified as `list.List` - which, list
# being core, needs no import at all.
open option
open types
actor Todos
state items: list.List<Todo> = []
state next_id: i32 = 1i32
on add(text: string) -> ()
items = items.append(Todo(id=next_id, text=text, done=false))
next_id = next_id + 1i32
end
on mark_done(id: i32) -> () [throws CommandError]
found = match items.find(|t| t.id == id)
Some(_) -> true
None -> false
end
if not found
throw NotFound(id)
end
items = items.map |t|
if t.id == id
Todo(id=t.id, text=t.text, done=true)
else
t
end
end
end
on list_all() -> list.List<Todo>
items
end
end
# === main.hk ===
use parse
use todos
open io
# `open` collision note: both `list` and `types` export a bare `List`
# (parametric collection vs the Command::List variant). Opening both and
# using the name bare would be ambiguous (H0306); here only `types` is
# opened, so it claims `List` for the variant, and the `list.List<string>`
# type for argv is reached qualified - core, so no import needed.
open types
def main!(args: list.List<string>) -> () [io, throws actor.SendFailed]
todos_actor = spawn todos.Todos
loop print!("> ")
match read_line!()
Eof -> break
Line(line) ->
try
match parse.parse_command!(line)
Quit -> break
Add(t) -> todos_actor.add(t)
Done(id) -> todos_actor.mark_done(id)
List ->
todos_actor.list_all().each! |t|
print!("#{t}\n")
end
end
catch e: CommandError
print!("#{e}\n")
end
end
end
end