6.10. Output actions
One typed function usually wants more than one presentation. sift scan
returns [Hit], which is what a downstream program should get, and not at all
what a person at a terminal wants to read. The obvious fix — export a second
command that formats the first one’s output — doubles the module’s surface and
puts presentation into the library.
An output action attaches a formatter to a command as a flag. The command keeps its type; the flag routes the result through a named term on the way out.
--' Print one `path:line:text` record per line
asLines :: [Hit] -> Str
--' Report the number of matches instead of the matches
countHits :: [Hit] -> U64
countHits = size
--' Search a directory tree for lines containing a pattern
--' @with -c/--count=countHits
--' @render -p/--plain=asLines
scan :: ...
The directive names the flag and the term: @with -c/--count=countHits means
"the flag -c or --count routes the result through `countHits`". The term’s
own docstring becomes the flag’s help text, which is why it is worth giving
formatters docstrings even when they are one-liners:
$ ./sift scan -h
...
General Options:
-h, --help Print help; -hh adds details and examples, -hhh adds schemas
(nexus options: -h @)
-c, --count Report the number of matches instead of the matches
-p, --plain Print one `path:line:text` record per line
...
$ ./sift scan the notes -c
4
A formatter is an ordinary typechecked function A → B where A unifies with
the command’s return payload. countHits is [Hit] → U64, so the composed
command returns a U64. size from the standard library would have done as
well; countHits exists only to carry the docstring.
Because each flag produces a different type, -h reports the return as a
table instead of a single line:
Return:
default: [Hit]
-c/--count: U64
-p/--plain: Str
...
The command’s declared return type is unchanged. Morloc code that composes
scan still sees <IO> [Hit]; the actions exist only at the interface.
@parse is the same idea on the way in: it reads an argument from a file in
another format before the command runs (see Reading files in other formats). A command can
use both; its arguments are read first, whatever output action is chosen.
6.10.1. @with keeps a value; @render produces bytes
The two directives differ in what they do with the formatter’s result.
@render treats the result as the final bytes. They are written verbatim,
without quoting or escaping, and -f no longer applies. That is how sift
declares -p, and it is why the output is readable:
$ ./sift scan the notes -p
notes/todo.txt:2:fix the parser
notes/todo.txt:3:write the manual
notes/2026/plan.txt:1:fix the build
notes/2026/plan.txt:2:ship the manual
@with keeps the result typed. It flows through -f like any other return
value, so the same Str comes out as a JSON string. Change the one word in
sift.loc —
--' @with -p/--plain=asLines
— rebuild, and the same command gives you this instead:
$ ./sift scan the notes -p
"notes\/todo.txt:2:fix the parser\nnotes\/todo.txt:3:write the manual\nnotes\/2026\/plan.txt:1:fix the build\nnotes\/2026\/plan.txt:2:ship the manual\n"
A @render handler must return Str or [U8]. Use @with when the
result is data for something else to read, and @render when it is text or
bytes for a human or a file.
A third case falls out of the same rule: a formatter that returns () is a
sink. It has already done the writing itself — printed, saved a file, sent a
request — and nothing goes on the wire. Unit in the Return: table marks
one.
The table marks the framing too: (raw bytes) after a type means the row is a
@render action, so those bytes go out as they are and -f does not apply to
them.
6.10.2. Giving a formatter arguments
A formatter may take arguments besides the value it formats. $1, $2, …
refer to the command’s own arguments by position, and @value refers to the
value being formatted. Write them as a call:
rep.py
def query(q):
return [[q + "-" + str(i), i] for i in range(3)]
def tabulate(width, rows):
for name, n in rows:
print("%-*s%d" % (width, name, n))
def as_json(rows):
import json
return json.dumps(rows)
module rep (report)
import root-py
source Py from "rep.py" ("query", "tabulate", "as_json" as asJson)
query :: Str -> <IO> [(Str, Int)]
--' Print the rows as a table, `width` columns wide
tabulate :: Int -> [(Str, Int)] -> <IO> ()
--' Render the rows as JSON text
asJson :: [(Str, Int)] -> Str
--' Run a query
--' @render -t/--table=tabulate($2) @default
--' @with -j/--json=asJson
report ::
--' the query to run
Str ->
--' output width
Int ->
<IO> [(Str, Int)]
report q _ = query q
The @default on -t is the subject of the next subsection; ignore it for the
moment and pass the flag explicitly.
$2 passes report’s second argument — the width — into `tabulate:
$ ./rep fruit 12 -t
fruit-0 0
fruit-1 1
fruit-2 2
$ ./rep fruit 20 -t
fruit-0 0
fruit-1 1
fruit-2 2
The value being formatted is appended last unless you place it yourself, so
tabulate($2) applies tabulate width rows, while tabulate(@value, $2)
would apply tabulate rows width.
6.10.3. Choosing a default
Mark one action @default and it writes to stdout when no action flag
claims stdout and no -f is given (see Several outputs in one run for actions given a
path). That is how a command gets human-readable output by default while
keeping its typed output one flag away:
--' Run a query
--' @render -t/--table=tabulate($2) @default
--' @with -j/--json=asJson
report :: ...
$ ./rep fruit 12
fruit-0 0
fruit-1 1
fruit-2 2
$ ./rep fruit 12 -j
"[[\"fruit-0\", 0], [\"fruit-1\", 1], [\"fruit-2\", 2]]"
$ ./rep -f json @ fruit 12
[["fruit-0",0],["fruit-1",1],["fruit-2",2]]
An explicit -f suppresses the default, which is what makes the typed output
reachable again. At most one action per command may be @default.
6.10.4. Media types
Bytes carry no label. A PNG and a CSV are both [U8] as far as the type system
is concerned, and a caller that receives one has no way to tell which. @mime
attaches a media type (RFC 6838 type/subtype) to a type, once:
ramp.py
import struct
import zlib
def make_png(n):
def chunk(typ, data):
c = typ + data
return (struct.pack(">I", len(data)) + c
+ struct.pack(">I", zlib.crc32(c) & 0xffffffff))
raw = b""
for _ in range(n):
raw += b"\x00" + bytes([(x * 255) // max(n - 1, 1) for x in range(n)])
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", n, n, 8, 0, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(raw))
png += chunk(b"IEND", b"")
return list(png)
def ident(bs):
return bs
module ramp (ramp)
import root-py
--' A PNG image
--' @mime image/png
type PNG = [U8]
source Py from "ramp.py" ("make_png", "ident")
make_png :: Int -> PNG
--' Write the image bytes to standard output
ident :: PNG -> PNG
--' Draw an n-by-n grayscale ramp
--' @render -w/--write=ident
ramp :: Int -> PNG
ramp = make_png
Declaring it on the type rather than on each use means it cannot disagree
between uses; a conflicting @mime along an alias chain is a compile error.
The label replaces the type name wherever the type surfaces:
$ ./ramp -h
Draw an n-by-n grayscale ramp
...
Return:
default: image/png
-w/--write: image/png (raw bytes)
A PNG image
It does more than label. The HTTP daemon returns the raw bytes with a matching
Content-Type instead of a JSON envelope, and the MCP server delivers them as
an inline image block rather than an array of numbers — see
Serving. A media-typed
return must reduce to Str or a byte array ([U8]), or a list of either;
anything else is rejected at compile time.
|
|
The |
6.10.5. Several outputs in one run
An action flag takes an optional path, attached with =. --json writes to
stdout as before; --json=rows.json writes to the file instead. Several
actions may be named at once, each with its own path, and the command runs
once to feed them all — the way to get a genome and its annotation table
from one expensive run:
$ ./rep fruit 12 --json=rows.json
fruit-0 0
fruit-1 1
fruit-2 2
$ cat rows.json
"[[\"fruit-0\", 0], [\"fruit-1\", 1], [\"fruit-2\", 2]]"
An output goes to one place: a file or stdout, never both. Stdout gets a
bare action (no =) if one is named, otherwise the @default action, unless
-f is given or the @default action itself was given a path; then stdout
gets the typed value. Above, stdout still carries the @default table.
./rep fruit 12 --table=table.txt writes the table to the file and the typed
value to stdout. --no-stdout sends nothing to stdout:
$ ./rep fruit 12 --table=table.txt --json=rows.json --no-stdout
$ cat table.txt
fruit-0 0
fruit-1 1
fruit-2 2
tabulate is a sink — it prints its rows itself — and what it prints goes
to its file. Whatever the command body prints goes to stdout, as it always
does.
Each file holds exactly what the action writes when run alone, -f and -z
included. On a streaming command every action sees the whole stream, batch by
batch as the producer wrote it (see Streaming output with @collect). Every file is
written under a temporary name beside it and moved into place only when the
whole run succeeds; if the command or any action fails, or the run is
interrupted, no requested file is created or changed. A file that is
replaced keeps its permissions, and a path through a symlink writes the
file the link names. An existing path that is not a regular file
(/dev/null, a named pipe) is written directly.
Only = attaches a path. --json rows.json is still the flag followed by a
positional argument, and a short flag (-j) always means stdout, as does
--json=-.
What makes this possible is that the run saves the command’s output, and the
actions read it back. Two things follow. An action that writes to stdout in
such a run does so after the command finishes, not as it streams. And an
action on a streaming command that can stream from more than one @collect
in a run, or from one in a where-binding, or that has a branch streaming
nothing, runs only on saved output, so the daemon and MCP servers, which run
one action per call, do not offer it. A command whose branches (guard or
match arms) each end in one @collect is not such a command: its actions
run directly.
A -f that one of the outputs cannot take — csv for a value that is not
a table, or mpk for a stream — is refused before the command runs.
6.10.6. Rules and rejections
-
At most one action writes to stdout. Two bare action flags, or a bare one with
--no-stdout, are rejected, as are one path given to two actions, a path that is also-o’s, and an empty `--act=. -
A streaming command with actions must return
(): its actions apply to what it streams. -
An action cannot refer with
$Nto a streamed@parseargument: the command consumes that stream as it reads it. -
Action directives belong in the signature preamble — the
--'lines directly abovename ::— not on argument docstrings, record fields, or type aliases. -
The command needs an explicit signature.
-
Flag names must not collide with the command’s own
@arg/@true/@falsenames, with each other, or with-h/--helpor--no-stdout. -
Two directives whose long flags collapse to the same internal name (say
--bar-bazand--bar_baz) are rejected, as is a synthesized entry name that collides with a top-level identifier in the module.