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.
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 fires when no action flag and no -f are
given. 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
Building API interfaces and Model Context Protocol (MCP). 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. Rules and rejections
-
One action flag per invocation. Siblings are mutually exclusive and a second one is rejected at parse time.
-
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/--help. -
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.