The host
Luce computes. It has no way to reach the world on its own: no system calls,
no libc, no ambient file handles, no way to write a byte anywhere.
Every effect a program can have is a service the host hands it,
and loom is the host that hands over all of them.
That is not a sandbox bolted on afterwards. It is the shape of the language. A Luce program handed no host is a program that computes and touches nothing — not because it was stopped, but because there was nothing there to call.
The whole surface
This is all of it. There is no second mechanism and no escape hatch.
Console
| Builtin | Answers | What loom does |
|---|---|---|
print(text) | A line on standard output | |
print_error(text) | A line on standard error, sanitized | |
read_line(prompt) | string? | Writes
the prompt, reads a line; none at end of input |
Files
| Builtin | Answers | What loom does |
|---|---|---|
file_read(path) | string! | The whole file, as text |
file_write(path, content) | ! | Truncate
and write, then sync |
file_append(path, content) | ! | Add to the end, creating the file if needed |
file_delete(path) | ! | Remove it |
file_rename(from, to) | ! | Move it |
file_exists(path) | bool | A question, never a guard |
dir_list(path) | list(string)! | The
names in a directory, unsorted, without . and
.. |
Paths are relative to the directory loom was started in. The file operations
answer ! — the fallible shape — because the world decides and no
non-racy check stands in for the result: a caller writes try to pass
the failure on or catch to handle it, and ignoring one is a compile
error rather than a silently dropped boolean. file_exists is a
question you may ask; it is not a way to make the next call safe.
Two host policies live here and are worth knowing. A file must be
valid UTF-8, because its bytes become a Luce string and the
language promises that slicing is checked against character boundaries — a
half-read JPEG would make that a lie. And there is a size cap of
64 MB, because file_read reads a whole file into one
buffer, and without a cap a program could be handed the machine's memory by
naming a path. Either way the read fails honestly rather than half-succeeding.
Data is not otherwise touched: no BOM is stripped and no line ending is
rewritten, so a program that reads a file and writes it back gets the same bytes
out.
The terminal
| Builtin | Answers | What loom does |
|---|---|---|
term_rows() term_cols() | long | The screen size now |
term_clear() | Begin a frame | |
term_move(row, col) | Position the cursor, 0-based | |
term_style(fg, bg, bold) | 256-colour;
-1 means the terminal's default | |
term_write(text) | Draw text — sanitized | |
term_flush() | Present the frame | |
key_read() | string? | Block for one
key; none at end of input |
key_text() | string | The payload of
the last "text" key |
The host owns raw mode, the alternate screen, frame buffering, and
every escape byte. A program says "draw this at that position in that
colour"; loom decides what bytes that is. Raw mode engages lazily on the first
terminal builtin, so a program that never draws never touches the terminal's
settings — and it is always restored before a trap is reported, and before a
read_line, because a line read and a raw key loop are two ways to
own one standard input and only one of them can be live.
Both key_read and sleep_ms present the pending frame
before they block, so a draw loop needs no explicit flush and a program that
draws and then waits is never a program whose last frame is stuck in a
buffer.
Key names
key_read() answers a name from a closed set, so a program branches
on words rather than on escape sequences:
| Name | Meaning |
|---|---|
"text" | A printable character; key_text()
carries it |
"enter" "tab" "backspace"
"delete" "escape" | As named |
"up" "down" "left"
"right" | Arrows |
"home" "end" "page_up"
"page_down" | Navigation |
"ctrl_a" … "ctrl_z" | A control key |
Running out of input is not one more name in that set. It is
none — the whole reason key_read answers an optional —
because an "eof" among "enter" and "up"
would be a value a program can fall past without the compiler saying so, and a
draw loop that falls past it asks for the next key forever. That is not
hypothetical: loom edit with standard input on
/dev/null once never returned, at 97% of a core.
# A whole terminal program: draw a frame, wait for a key, draw again.
# The host owns raw mode, the alternate screen and every escape byte.
func main():
var last = "press a key"
while true:
term_clear()
term_move(0, 0)
term_style(214, -1, true)
term_write("loom")
term_style(-1, -1, false)
term_write(" " + string(term_cols()) + "x" + string(term_rows()))
term_move(2, 0)
term_write(last)
term_move(4, 0)
term_write("Ctrl-Q quits")
let name = key_read()
if name == none:
return
elif name == "ctrl_q":
return
elif name == "text":
last = "text: " + key_text()
else:
last = "key: " + name$ luce check keys.luc keys.luc: ok $ loom luce keys.luc
The clock, the environment, the machine, and stopping
| Builtin | Answers | What loom does |
|---|---|---|
clock_ms() | long | A monotonic reading — only differences mean anything |
sleep_ms(ms) | Wait at least that long | |
env(name) | string? | One
environment variable; none when unset |
os_total_memory() | long | Bytes the machine has |
os_available_memory() | long | Bytes it could still hand out |
os_cpu_count() | long | Logical processors |
exit(status) | never returns | End the run with that number |
clock_ms is monotonic and not a calendar: a clock an administrator
can move backwards would break even the promise that differences mean something.
There is no wall clock, because dates are a library that does not exist yet.
sleep_ms of a duration that has already elapsed — zero, or the
negative one deadline - clock_ms() produces on a slow frame — is not
a failure; there is no time left to wait, so the call returns. A frame-pacing
loop that trapped only on a slow machine would be a correct program made flaky by
its host.
The three os_* facts are plain numbers, and a host that cannot
tell refuses rather than answering zero. Inventing a number is
the one thing this boundary will not do.
The command line is not a service
A program's arguments are main's parameter, not something it asks
for:
func main(args: list(string)):
print("hello, loom")
for name in args:
print(name)The list is owned by main's scope and freed when it returns. A
program that ignores its arguments writes func main(): and says
nothing false. Because the arguments are handed to a program rather than
asked by it, the host gate below does not cover them, and a host with
nothing to offer supplies an empty list instead of refusing.
Fail-closed
Every service in the table above is optional in the boundary's design, and
a service the host does not offer traps host_unavailable
rather than touching anything. There is no default implementation, no
best-effort fallback, and no silent success.
loom withholds nothing: it fills every slot, because its whole purpose is
running programs against a real machine. But the mechanism is what makes the
language's promise true. A caller that provides a host with only
print gets a program that prints and cannot open a file — the same
program, unmodified, with the difference made at the boundary rather than by
inspecting the program.
There is a second gate, one step earlier. Host builtins are gated at
compile time: a program compiled without allow_host that
names print is refused with a luce.sema.host diagnostic
rather than compiled into something that will trap later. Both loom and
luce compile with the gate open, because both exist to run programs
on a real machine; an embedder that wants pure computation compiles with it
shut.
Program text cannot forge the terminal
Anywhere program text reaches a channel loom owns, it is rewritten first:
term_write, a read_line prompt, print_error,
and the message of a trap or an error. Control bytes become ?. A
program can never clear your screen, move your cursor, set your window title or
forge the terminal's state through a channel it does not own.
Standard output is deliberately not sanitized. It is the program's own channel and may be a pipe or a file, where an escape sequence is just bytes and rewriting them would corrupt data. The rule is about channels loom shares, not about censoring programs.
That rule reaches into failure reporting too, which is the last place anyone
looks: a trap message is whatever the program said, and it is printed at the
moment the screen has just been restored. chr(27) + "[2J" in a trap
message once cleared the terminal. Frame names go the same way — a function name
is an identifier and cannot carry one, but a source name is a path the
world chose.
The same boundary everywhere
The services are a table of function pointers an artifact is handed
(LuceHost, the published host ABI). Its fields are append-only and
never reordered, and any change to one bumps a version that an artifact carries —
which is why an artifact built against an older boundary is
refused by name rather than called with the wrong
table.
The consequence worth stating: a program's behaviour does not depend on who started it. Under loom, as a standalone binary, or under the test suite's oracle, it meets the same services, reports a failure in the same words, and exits with the same number.