This page is the complete rulebook. If you are new, start with the
gentle introduction; if you want the highlights, the
feature overview. Lines beginning with
>> in examples show expected output; they are not part of the
program.
- Source text · Tokens and adjacency · Literals
- The statement form · Blocks · Calls
- Variables and assignment · Tests and predicates
- Expressions and operators · Evaluation context
- Branching · Loops · Branch and loop values
- Functions · Errors
- Objects and prototypes · Hashes · Types and primitives · Filters
- Input and output · Running commands · Programs and includes
- Word inventory
Source text
- A program is a sequence of logical lines. A logical line ends at the physical newline — there is no continuation syntax. A line ending in a binary operator is a parse error, with one exception: inside a condition block, a trailing word operator joins the next sibling line.
- Indentation is tabs only: one tab = one level. A space at the start of a line is a syntax error. Tabs are never significant after the first non-tab character.
- Only bracketed regions span physical lines, and a newline
inside one is structural, never plain whitespace: inside
[ ]it separates rows, inside{ }it separates entries (like,), and inside( )it is legal only when the group holds a single block-carrying expression (adoblock value, or a value-positionif/choose/loop). - A comment is a whole line:
#as the line's first non-whitespace character. A#anywhere else in code position — glued or spaced,x#commentandx # commentalike — is a hard error: trailing comments do not exist, so a stale one can never silently join a statement. Inside strings and implicit-string spans#is just text. There are no block comments.
Tokens and the adjacency rule
The tokenizer splits each line into glyph runs — maximal sequences of non-whitespace characters (string literals are scanned as units first and may contain spaces). The core rule:
A glyph standing alone (whitespace on both sides) is an
operator. A glyph glued to other characters is part of a larger token, or an affix whose
meaning is selected by which side it is glued to. Every token records whether it
was glued left and right, and the grammar constrains those flags — a-b,
a - b, and a -b are three different programs. If a glyph run
has no legal decomposition, that is a syntax error, not a best-effort split.
Identifiers
- Start: a letter or
_. Continue: letters, digits,_, and interior hyphens — a hyphen with an identifier character glued on both sides is part of the name:parse-input,top-of-stack. A leading or trailing hyphen never is:-ais unary minus. - Namespace paths are glued separator glyphs:
object>child>deeperis a single path token. The default member accessors are a glued.or>, interchangeable:list.count,i.hex,point>x. Path segments are ordinary identifiers.
Glued affixes
| Form | Meaning |
|---|---|
-x, +x | unary minus / plus (glued right only) |
\x, \[…] | reference to a variable / literal |
&x, &[…] | copy of a variable / literal |
line-final : | block introducer — the line owns the following indented block |
x?, list[3]?, a>b? | test: defined (suffix — applies to the whole chain) |
?x | test: exists; on a chain, a head guard (tests) |
x?e, x?f(name), x?t(proto), x?{rw} | attribute / flag / type / file-attribute tests |
f(…) | call with positional arguments (paren glued to the head) |
=(v, alt), =(v, t, f), … | compare-with-alternative pair / ternary (comparator glued to the paren) |
cond ?(t, f) | boolean ternary (query form) |
x[…] | index / slice |
1<, 2<, $< (statement-initial) | echo forms (I/O) |
.name (statement-initial) | function definition head |
x++, x--, ++x, --x | increment / decrement on assignable numeric chains — postfix yields the old value, prefix the new |
Literals
Numbers
- Integers: decimal
42; hex0x1f, octal0o17, binary0b1010(lowercase prefixes). Underscores group digits —1_000_000,0xdead_beef— and are legal only between digits. Leading zeros are compile errors (017is neither octal nor decimal;0itself is fine). Integers are signed 64-bit; a literal that doesn't fit is a compile error.-42is glued unary minus and composes with every form. - Floats: digits on both sides of the dot —
.5and2.are not floats — with an optional lowercase exponent:1.5e3,2e-8,6.022_140e23. Floats are IEEE 754 binary64. A.glued after a numeric literal opens a member access unless a digit follows:2.5is a float,3.power(2)a call,2.5.power(2)both.
Strings
"…"interpolates:{ }is a full escape back to the language — any expression is legal inside. An expression that needs its own block starts on a new line indented one level past the containing statement; the string resumes on the line carrying the closing}.'…'is raw — no interpolation. A raw string cannot contain a single quote; use the other form or interpolation.- No escape sequences, anywhere. A backslash is a literal
character. Control characters are named constants, interpolated:
"line one{NL}line two". Quotes and braces are smuggled as raw-string escapes:{'"'}for a literal",{'{'}for a literal{. - Named constants (bare words in expression context):
NL(platform newline),CR,LF,CRLF,TAB,NULL,TRUE,FALSE. - A list rendered inside a string spells its join explicitly —
"{list.join(', ')}"; a bare list there is a compile error suggesting it. There is no string concatenation operator: interpolation and.joincover it. - Backtick strings
`…`are command literals — one verbatim bash line (commands).
Lists
my flat ([a, b, c])
my table ([
1, 2, 3
4, 5, 6
# same value as [[1, 2, 3] [4, 5, 6]]
])
Inside [ ] a newline separates rows; nested brackets for rows are
optional sugar. Lists index from 1, and glued brackets index and slice:
list[2], list[2 .. 4]. An out-of-range index is an error
(see hashes for the lookup-style alternative).
The statement form
Every statement — keyword, declaration, function definition, or call — has the shape:
head {options} arguments (value):
block
where everything after head is optional:
- head — a keyword or an identifier (function or variable name).
- {options} — named parameters. A brace group after a head is
always options — braces never mean anything else. Spelling is positional:
after a statement head the group must be spaced
(
for {list},run ls {l, a}); in operand position it must be glued (f{opts}); each wrong way round is a compile error. Inside an options group, each entry is a miniature of the same form:name(flag),!name(required),name ()(requires a value),name (default). - arguments — comma-separated positional expressions.
- (value) — a detached paren group (space before it): assignment of the group's value to the head.
- block — an indented block on the following lines.
Statements must have effect
A pure expression statement whose value is discarded is a compile error:
# ERROR: no effect — value discarded
i > 8
# ERROR: no effect
a + b
# ok: call
f(x)
# ok: zero-arg call — bare callable reads call
f
# ok: assignment
x (5)
# ok: value consumed
say i > 8
The last expression of an in-place value form — a value-position
if/choose/loop, or each iteration of a collecting loop — is
consumed and therefore exempt. A function body is not: its value comes only from
return, so a trailing pure expression there is diagnosed as a missing
return.
Blocks
A head owns the block indented one level deeper than its own line.
Block introduction is marked: an indented block may follow only a line ending in a
glued line-final : or in a do
form (bare do, do {opts}, a named do,
also do, or a value-position (do …) — and a line ending in
: requires the block. A block after an unmarked line, a colon with nothing
under it, and do: (one marker per line) are all parse errors. A head that
owns no block takes no colon — e.g. the body-less loop in
my n (for i (1 .. 10)).
Two cases:
# head line carries an expression:
if x < 3:
# the block is the body
say "small"
# head line carries no expression:
if:
# the block is the argument (here, the
condition1 or
# condition), and the body needs do
condition2
do
stuff
Blocks as values
A block appears as a value by wrapping do in a value group. Named
do blocks label a region of code:
my handler (do
first statement
return TRUE
)
do Optional Name Here
this block is collapsible / referenceable by name
Every indented block is a scope (loops are the exception — the body and the loop's
suffix blocks share one loop scope — and try regions add no scope at all).
Blocks read their writing site's frame live: a block value invoked
after the frame that wrote it has exited is undefined behavior. A block that references
nothing outside itself (do {use ()}) is safe anywhere, as are top-level
function definitions.
Calls
Adjacency selects the role of every bracket after a name:
| Shape | Meaning |
|---|---|
f {a, b (v)} | call with named options (at a statement head) |
f(x, y) | call with positional arguments (parens glued) |
f x, y | call with positional arguments (bare) |
f {opts} x, y | options and arguments together |
f (v) | assignment of v to f (paren detached) |
f {opts} (v) | assignment with options (e.g. my {ref} f (…)) |
- Grouping parentheses are legal only where an operand is expected. To
call with a computed first argument, glue —
f(x + y)— or go bare:f x + y. - A detached
(after a name is assignment only at expression-start positions: statement head, named-argument position, and immediately afterif/or/nor/while/unless/nif,(, or,. This preserves assign-then-compare:if variable (value) < 20:. Mid-expressiona + b (5)is a syntax error. - The assignment value is one or more consecutive paren groups,
coalescing: evaluated lazily left to right, the first defined value
is assigned —
my x (maybe-a) (maybe-b) (0). After the group(s) only a statement modifier may follow:x (v) if readyis legal;x (a + b) * 2is an error — writex ((a + b) * 2). - An empty detached group is a hard error:
f ()is neither a call nor an assignment (it diagnoses the one-space typo off(), which is a legal zero-arg call). The empty-hash literal()lives in operand position —f (())— plus the declarator sugarmy h (); nesting keeps the diagnostic intact. - Within an options group or argument list,
name (value)assigns to that named parameter. Named-parameter arrays flatten with inline ones:myfunction [param1 (v), param2 (v)], param3 (v)— the array is a hash literal, and_(obj)spreads a hash expression the same way. - Passing an aggregate requires an explicit reference
\myarray/\[v1, v2]or copy&myarray/&[v1, v2]. Copies are shallow — one level; nested aggregates arrive as references. The same sigil rule applies in value groups:my a2 (\a)aliases,my a2 (&a)copies; expression results need no sigil.
Operand position
The full statement form — spaced options, bare argument list, suffix block — belongs
to the statement head alone. Where an operand is expected (inside an
expression, argument list, value group, or inline condition), a call is spelled glued:
f, f(x, y), f{opts}, f{opts}(x, y).
Bare argument lists do not nest: say f a is a parse error — write
say f(a) or say f, a. A comma inside grouping parentheses is
a syntax error; a grouping paren holds exactly one expression.
Auto-call
A bare read of a name or path bound to a callable — a dot-defined
function or a variable holding a block — calls it with no arguments.
With one shared namespace this gives uniform access: obj>size reads the
same whether size is stored or computed, and the caller cannot tell.
- Auto-call applies to name and path reads, never to values — a call whose result is callable does not cascade.
- Zero-arg auto-call of a callable with required parameters is an error.
- The read does not call under the sigils
\f(reference) and&f(copy), under the exists prefix?f, or underexists f; an assignment target is not a read. Self-reference insidefis\f— barefrecurses. Suffix tests do call.
Variables and assignment
Declaration is mandatory
my name (value)— declares in the current block.up name (value)— declares in the immediately enclosing block — exactly one level.upnever crosses a function boundary or the file top level; from a loop body it targets the block containing the loop.global name (value)— declares in the file scope and exports the name to files that include this one. Legal at file top level only.
Assigning to an undeclared name is a compile error. So is shadowing:
a declarator, parameter, or loop binder introducing a name already visible is a compile
error — the missing-my typo and the extra-my typo are equally
loud. (Built-in bindings may be shadowed, and object member names may coincide with
enclosing names.) A variable is visible from its declarator to the end of its block; a
function definition is visible throughout its enclosing block, lines above included, so
mutual recursion needs no forward declarations. A declarator takes no statement
modifier — declare unconditionally, assign conditionally.
Assignment
name (value) — a detached parenthesized value; there is no
= assignment, and = is pure equality. Full positional rules
under calls. Assignment inside a condition is
comparison-with-binding:
if variable (value) < 20:
# variable now holds value; branch on its comparison
Conditional assignment
# coalescing lvalue: assign to a if a is
# undefined, else to b
a ? b (10)
# set if currently = oldvalue
newvalue ?> variable = oldvalue
# same, mirrored
oldvalue = variable <? newvalue
# set if currently ≠ oldvalue
newvalue ?> variable <> oldvalue
The conditional-set arrow points toward the variable; the comparison rides on the far side.
Narrowing
# runs when var is not NULL
with var
# runs when var is NULL
without var
Tests and predicates
# defined?
variable?
# exists?
?variable
# attribute test: e=exists d=defined f=flag
variable?e
# zero/empty: 0 int, '' str, empty container
variable?z
# letters combine with AND: defined and zero
variable?dz
# named user flag
variable?f(name)
# is-a: proto on the prototype chain
variable?t(proto)
# brace = FILE attributes; int var → handle,
# str var → path, file object → itself:
# e exists, d directory, f file, r read-only,
# w writable, a archive, l locked
variable?{e}
# the suffix attaches to a whole glued chain —
list[3]?
# identifier, path, or index
obj>field?e
# head guard: undefined when list itself is
# missing; if list exists, a normal index
?list[3]
# safe both ways
?list[3]?e
- Head guard. The exists prefix applies only to the chain's
head — the variable itself, never an element. Glued to a chain it becomes a guard:
a missing head yields undefined without evaluating the rest; a present head
evaluates the chain normally, ordinary errors included. The undefined miss composes
with coalesce:
?config>port ? 8080. - Comparison-composability. A comparison whose left operand is a
defined-test yields the tested variable's value when the variable is
defined and the comparison holds, and undefined otherwise:
varint? > 50is77whenvarintis 77, and undefined when it is unset or too small — so?and the pair form catch the miss. In anifhead the same test is a plain boolean. There is no glued comparator form:varint?>50is a syntax error; space it. - On callables, suffix tests observe the read's result (the read
auto-calls); the prefix
?fandexists ftest the binding and never call.
Aggregate predicates
# variable exists
exists x
# defined (not null)
used x
# defined and TRUE — scalar only
set x
# junction constructors double as predicates:
any(15, 19)
# at least one / every / no item TRUE
all(\needed)
# .used maps the "defined" test per item
none(list.used)
exists/used/set are keywords;
any/all/none are built-in functions with
ordinary call grammar. In boolean position a junction collapses over its items'
boolean values — a non-boolean item is an error (no truthiness). Emptiness is spelled
explicitly: list?z or list.count > 0.
Expressions and operators
| Category | Spellings |
|---|---|
| Arithmetic | + - * / // % — / is integer division and // float division on any numerics, each coercing its operands; + - * % need same-type operands |
| Comparison | = <> < > <= >= — typed-strict: concrete mixed-type comparison is an error. eq ne — string-domain equality: numeric operands coerce to string (5 eq "5" is true; 5 = "5" is an error) |
| Range | .. (inclusive), ..< (exclusive) |
| Logic (words) | and nand or nor xor nxor not |
| Logic (glyphs) | & (and), | (or) — short-circuit, boolean-strict, expression-only: never trailing joiners or line-initial branch heads |
| Bitwise | &b |b ^b <<b >>b — glyph + glued b, integer-only |
| Coalescing | ? — left side if defined, else right (also an lvalue) |
| Conditional set | ?> <? |
All standalone operators require whitespace on both sides. Precedence, tightest first:
- Affix tests and sigils (
x?,?x,\x,&x, unary-,++/--) - Index, member, call (glued
[ ],( ),{ },.,>paths) * / // %+ -<<b>>b&b^b|b— bitwise sits above comparisons, sox &b 3 = 1masks, then compares....<- Comparisons and
eq ne(junctions distribute here) &|?,?>,<?, and the query?(…)and,nandor,nor,xor,nxor- Statement modifiers (
… if cond,… unless cond)
Junctions
# true if x is 15 or 19
x = any(15, 19)
# true if x is neither
x = none(15, 19)
# true if x equals every item of needed
x = all(\needed)
Under a comparison the junction distributes (any = or-fold,
all = and-fold, none = negated or-fold); two junctions
distribute pairwise.
Pair form and ternaries
A comparator glued to a parenthesized group — spaced from its left operand, like
every comparison — yields the left operand's value when the comparison holds, and the
second element otherwise; a third element makes it a full ternary. The query form
?(t, f) is the boolean ternary; its left operand evaluates in strict
boolean context:
# equal → status; else 'failed'
status =('ok', 'failed')
# x > 50 → x; else -1
x >(50, -1)
# success → second; failure → third
x =(15, "ready", "waiting")
list.count > 0 ?("full", "empty")
# a test is a boolean here — composes
x? ?("set", "unset")
All comparators participate (=( <>( <( >( <=( >=(),
each taking two or three elements; the query takes exactly two. Only the taken element
evaluates. Composed with the defined-test, this is the custom-false-value idiom:
varstr? =('nothing', 'else') — an undefined variable always takes the
alternative.
Evaluation context
Every expression evaluates in a context supplied by its surroundings. Context never changes parsing; it decides what a context-polymorphic value produces:
- Boolean — condition positions. Strict: a
concrete non-boolean here is an error — no truthiness. Zero/empty checks are
explicit:
num.zero,num?z,x.blank,list.count > 0. - Comparison — an operand of a typed comparison is coerced toward
the other operand's type; this is how
x? = 10evaluates the test as the tested variable's value. - String — interpolation
{ }; a list here requires.join. - List — loop sources, list-literal rows.
- Command — the argument region of
run(commands). - Void — an unconsumed expression statement; a pure one is a compile error.
Functions query the context they were called in with want — one of the
constants BOOL CMP STR LIST VOID CMD — or dispatch on it directly in a
signature (functions).
Branching
# branch when true
if <cond>
# branch when false — captures a pending exception
unless <cond>
# branch when false — lets exceptions propagate
nif <cond>
# else-if (line-initial)
or <cond>
# else-if-not
nor <cond>
# fallback
else
Multi-line conditions
An inline condition must be complete on its line. A multi-line condition uses the
expressionless form: the condition is the block, and do introduces the
body. Within a condition block, lines join with explicit trailing word operators, or
bare nesting implies them — four levels, alternating: level 1 AND, level 2 OR, level 3
AND, level 4 OR. Siblings at a level join with that level's operator, and a line with
children joins its child group with its own level's operator:
if:
a
b
c
d
e
f
do
stuff
≡ if a and (b or (c and d) or e) and f. An explicit trailing operator
overrides the implicit joiner. A condition block may contain only expressions —
statement forms there are parse errors pointing at the missing do.
Statement modifiers
say "Hello!" if cond
say "Hello!" if:
cond1 or
cond2 or
cond3
At most one modifier per statement, in tail position, if or
unless only — no stacking. A modifier with no inline expression ends the
line with : and takes the following block as its condition; the statement
itself is the body, so no do is needed.
Loops
# bare block; as a loop: first pass unconditional
do
# top-tested loop
while <cond>
# iteration
for <range|count|list>
# iterate the list itself (writes through)
foreach v (list)
Loop-control keywords are paired per loop type, so a mixed nesting can break either
level with one word: for takes next/last,
while takes continue/break.
for forms
One binder shape — for value (source) [index] — and loop modifiers are
options on for (heads shown without their : and bodies):
# 0 ..< 3, no binder
for 3
# range
for i (1 ..< 10)
# value = item; index optional
for value (list) index
# every other item
for {by (2)} value (list)
# reverse
for {by (-1)} value (list)
# step 2 starting at item 4
for {by (2), from (4)} value (list)
# bind an existing variable; its final
# value survives the loop
for value (list) use index
# use generalizes to either binder
for use value (list)
by takes any expression; negative iterates in reverse. A body-less loop
head is legal and takes no colon — it iterates for effect.
Suffix blocks
# runs at natural end (loop not broken)
then
# runs every iteration, even on next; `also do` runs even
# when the loop is broken — or unwound by an exception
also
A suffix keyword (then, also, else,
catch) binds to the statement whose head sits at the same indentation,
nearest above. There is no loop else.
Loop scope
A loop owns one scope, created at entry and reused across
iterations — binders live in it and are updated in place; the body and the loop's
then/also blocks share it (also sees the binders
per iteration, then their final values). The scope dies at loop exit, so
binders die with the loop — hoist a result with up, or bind an existing
variable with the use marker. A my in the body is one
declaration whose initializer re-runs per iteration.
foreach writes through
foreach value (list[2 .. 4]):
# modifies list
value ('New')
foreach value (my temp[] (list[2 .. 4])):
# modifies the copy only
value ('New')
Iterators
for/foreach accept an iterator wherever a list is
accepted: any object answering more (do items remain? — never advances),
take (yield the next item and advance; take past exhaustion
is a runtime error), and optionally rewind. Exhaustion is a distinct
signal, not a sentinel — items may freely be undefined or falsy. Loops consume without
resetting: after last/break the iterator stays where it
stopped and a later loop resumes there. Rewindability is ?it>rewind.
Files iterate by lines (files).
Branch and loop values
An if used as a value yields the last expression evaluated in the taken
branch (undefined if no branch ran); the branch ordinal is available as an option:
my label (if cond1:
"a"
or cond2:
"b"
else:
"c"
)
my which (if {which} cond1:
…
# 1, 2, …; 0 = else; undefined = no branch
)
Loops as values:
# last iteration's value
my v (for {last} … )
# list of every iteration's value
my l (for {list} … )
my n (for label (labels) i:
say label
then:
# then-result: the loop's own value —
result i + 1
# here, an explicit count
)
A bare loop's value is undefined — count explicitly
(list.count, or the index binder plus then: result i + 1).
The {list}/{last} modes consume iteration values — each
iteration's explicit result or implicit last expression. A
result in then sets the loop's own value; then
runs at natural end only, so a broken loop yields the mode value ({list}:
items so far), and a zero-iteration loop runs then — then-result
doubles as the empty-source default. Reading a loop's value never mutates the source.
result
result expr sets the value of the innermost enclosing
block and exits it; bare result sets undefined and exits;
result {set} expr sets the value and continues (last one wins, superseding
the implicit last-expression value).
my grade (if score >= 90:
result "A"
or score >= 80:
result "B"
else:
log "below B: {score}"
result "C"
)
my labels (for {list} n (nums):
# finish this iteration early with "big"
result "big" if n > 99
# otherwise: implicit last expression
classify n
)
In a branch arm, result sets the if-statement's value; in a loop body
it sets that iteration's value and finishes the iteration early
(also still runs); in a loop's then it sets the loop's value;
in a do block it sets the do's value. It reaches only the innermost
block. The doctrine: return is the invocation's value,
result the in-place block's — result in a function
body's top block is a compile error pointing at return. Like
throw, result is statement-only. try regions
are transparent to it.
choose
choose:
age < 18 "Too young to drink"
age < 21 "Too young to drink in the US"
default "Legal drinking age"
First matching row wins; the statement yields the row's value.
Functions
Definition
A statement whose head is a glued leading dot + name defines a function:
.name {option1, !option2 (), option3 (), option4 (value), "option5" ()} arg1, arg2 {int}, "arg3", arg4 (value):
body
option1— flag option (a bare pass bindsTRUE)!option2 ()— required named parameteroption3 ()— optional named parameter requiring a valueoption4 (value)— optional named parameter with a default"option5" ()— implicit-string option (below): the quoted name marks it, and it must take a value — a quoted flag is a compile errorarg2 {int}— required typed positional: the brace group after a parameter name is that parameter's options (the universal form recursing)"arg3"— implicit-string parameter (below)arg4 (value)— optional positional with a default
A type is any prototype name — built-in (int, str,
list, …) or user-defined (p {point}) — an is-a constraint;
i/s are accepted as aliases. A definition binding is
immutable: assigning to a dot-defined name is a compile error (so
greet (x), the typo of greet(x), is diagnosed). A
reassignable function is an ordinary variable holding a block. Definitions may nest,
and a top-level definition may carry {global} to export it
(includes).
Multiple signatures: {multi} definitions
.name {multi} i {int}, j {str}:
return "{j}{i}"
.name {multi} j {str}:
return j
A name gains multiple signatures as separate, complete definitions,
each carrying the {multi} option. Duplicates are loud: a second
.name in the same block is a compile error unless every
definition of the name carries {multi} — a mixed set is the same error. A
sole {multi} definition is legal (a set of one, open to extension), and a
set lives in one block.
First match, in textual order: the first definition whose required
arity, declared types, and (if present) want context match the call wins —
order members from specific to general. Per-signature options ride each member's
options group beside {multi}: want (CTX) dispatches on the
caller's context, returns (type) is a checked result contract, and
raises (…) promises which error types can escape — the latter two never
participate in dispatch. Set-level options must agree: {global} on every
member or none.
.matches {multi, want (LIST)} filter:
return rows(filter)
.matches {multi} filter:
return rows(filter).count
Implicit-string slots must agree across the set (a position is quoted in every member or in none; an option name is quoted in every member that declares it, or in none).
Closed functions — use and mutate
Function bodies are closed by default: a body sees its parameters
and options, its own declarations, the definitions lexically visible at its site, and
built-ins — and no mutable outer binding. Outer names are admitted by
an allowlist pair in the options group — use (…) by copy,
mutate (…) by reference:
.myfunc {use (config), mutate (counter)} test, var2:
# mutate: by reference — outside sees updates
counter (counter + 1)
# use: by copy
say "{config} {test}"
Admission is per call: each invocation reads the current outer value —
use copies it fresh (the shallow & copy), mutate
aliases the live binding; nothing snapshots at creation. Objects are the
exception: use admits them by alias — no copy preserves the
identity that =, is-a, and catch filters ride, so a copied
prototype would silently miss its own filter. The pair's distinction is binding
write-back, not object interiors; admit &obj for a detached copy.
An unlisted outer name is simply not in scope. Blocks, by
contrast, stay open — control-flow bodies, block values, and trailing
blocks see their writing site. On do, the same pair works as a
restriction:
# sees only x (copy) and y (reference)
do {use (x), mutate (y)}
…
# sees nothing outside its scope — pure
do {use ()}
…
Implicit-string parameters
A parameter whose name is quoted in the definition takes bare, unquoted text at the call site:
.greet "who", punct ('!'):
say "Hello, {who}{punct}"
greet old friend of mine
>> Hello, old friend of mine!
- The span runs from its first non-whitespace character to the next top-level
,, the call's closing), or end of line. Outer whitespace is trimmed; interior whitespace is preserved. - A glued line-final
:is the block marker, never span text — a bare description and a trailing block mix without quoting (test the parser handles empty input:). Mid-line or spaced colons stay text; quote text that must end in a line-final colon. - A span behaves as an interpolated string:
{ }is the full escape; bare words are text (NLin a span is two letters). Smuggle a comma as{','}, or quote the whole argument — an implicit parameter accepts an ordinary quoted string at any time. - An implicit-string option — declared
"named" ()or"named" (default)— takes its value the same way: inf {named (…)}the value group is one span, from after(to the first)outside an escape. Commas are text (f {msg (Hello, world)}passesHello, world); the group must close on its own line, so a missing)is a loud error. A literal)falls back to the ordinary quoted value. A bare pass or empty group is an error — neverTRUE, never the empty string. - Implicit parameters exist only on top-level dot-defined functions — not on methods or block-valued variables.
Parameter and option access
- Arguments and options both bind bare names in the body:
declare
{verbose}, readverbose. The$namespace holds only the specials$doand$err. - An option passed bare —
f {verbose}— bindsTRUE. An option not passed takes its declared default, else it is undefined; under strict booleans a defaultless flag is testedverbose?, or declaredverbose (FALSE)for bareif verbose:use. name?tests definedness after defaults;?nametests whether the caller explicitly passed the option.- A final positional
rest[]collects the remaining positional arguments into a list (empty if none). A quoted slurpy"rest"[]collects implicit-string spans, one element per comma-separated span.
.sum first, rest[]:
my t (first)
for v (rest):
t (t + v)
return t
say sum(1, 2, 3)
>> 6
The nested call glues its parens — a bare argument list belongs to the statement head alone (calls).
Return values and want
A call's value comes only from return: bare
return and falling off the end yield undefined, and a trailing pure
expression is diagnosed as a missing return. The return operand evaluates
in the caller's context. want evaluates to the immediate call's context —
one of BOOL CMP STR LIST VOID CMD — and composes with junctions:
.report:
# called for effect: skip the work
return if want = VOID
return build-summary
Block parameters
A call site may end in : and pass a trailing block; inside the body it
is $do:
.twice:
$do
$do
twice:
say "hi"
>> hi
>> hi
Reading $do runs the block (each read is one invocation, yielding its
return value); \$do captures it; ?$do tests
presence without running. A function that never mentions $do takes no
block — a caller's trailing block is then a compile error. The block sees the caller's
scope by reference.
Executed-do arguments
The inverse: a call line ending in do runs the block at the call
site and passes its value — its result, or the implicit last
expression — as the final positional argument. The marker says who runs the block:
: hands it to the callee as $do; do executes it
in place. Declarations join: my x do initializes x with the
block's result.
render header, do
my rows (build-rows)
result rows.join(NL)
Parenthesized (do …) stays the block as data, unrun — parens
pass the block, a line-final do runs it. In an implicit-string span a
line-final do stays text (prose lines end in "do" constantly, unlike the
colon); to pass an executed block to an implicit-string function, quote the
arguments.
Errors
Exceptions are ordinary objects on the built-in error prototype, with a
prototypal taxonomy: command (failed external command),
bounds (index miss), type, dispatch (no matching
signature), math (overflow, division by zero), io, and
contract. User types derive normally:
my parse-error {is (\error)}. Members: message,
file/line (the raise site); command errors add
code, command, stderr. An exception no handler
accepts aborts the program with its message and raise site, and a nonzero exit.
Raising — throw
# fresh error, message set
throw "malformed header"
# options select the prototype
throw {io} "cannot open {path}"
# optional payload hash:
# handlers read $err["path"]
throw {io} "no {path}", \["path" (path)]
# an is-a-error object, as-is
throw bad-input
# bare: rethrow $err (handlers only)
throw
Handling — catch
catch guards the immediately preceding statement — no wrapper needed.
Its options are is-a filters; successive catch blocks are tried top to
bottom, first match runs, bare catch matches everything, and an unmatched
exception propagates. $err is the exception inside a handler:
run {dir (build)} make all
catch {command}:
log "build failed ({$err>code}): {$err>message}"
catch:
log "unexpected: {$err>message}"
throw
A raise inside a guarded statement aborts it at the raise point with no partial
assignment — my x (risky) leaves x undefined,
x (risky) leaves the old value untouched.
The unless capture
Statement-position unless captures a pending exception from the
statement above, parks it, and evaluates its condition — typically a test of the value
that should now exist:
my cfg (run cat config.txt)
unless cfg?:
log "no config, using defaults: {$err>message}"
cfg ('')
Branch taken → the block runs with $err bound, and completing it
handles the exception. Branch not taken → the exception propagates at once.
nif is the same conditional but never captures, and a modifier
… unless cond never captures either.
try regions
try: guards a group of statements, and has one special property: the
region introduces no scope. Declarations inside land in the enclosing
block and survive the region — declare inside, handle, use after. (For a scoped
temporary around a guarded region, use do + catch instead.)
try:
my text (run cat config.txt)
my cfg (parse text)
catch {command}:
cfg (\defaults)
# try added no scope — cfg lives on
say "port: {cfg>port}"
An options group on try is a raise contract:
try {io, parse}: allows only the listed types to escape;
try {}: promises none do. A violating exception is replaced at the
boundary by a contract error carrying the original. The signature twin is
the raises (…) option on a function row.
Unwinding
A raise aborts enclosing expressions, statements, loop iterations, and invocations
outward to the nearest accepting handler. A loop unwound by an exception skips
then and that iteration's plain also, but also do
runs during the unwind — the loop's cleanup arm. Iterators stay where the raise left
them. An exception raised mid-unwind replaces the in-flight one.
Objects and prototypes
An object is a namespace with a prototype — a scope whose bindings persist as the object's members. A declarator that owns a block builds one: ordinary declarations and dot definitions run inside it, and the scope they build is the object:
my point:
my x (0)
my y (0)
.norm:
return (x * x + y * y).sqrt
mydeclares data members;.namedefines methods (immutable); a nested declarator-with-block nests an object. A declarator takes value group(s) or a member block, never both.- Access is by path:
point>x,point.norm— reads auto-call methods, so stored vs. computed stays invisible to the caller. - Assignment
point>x (v)requiresxdeclared somewhere on the prototype chain; reading an undeclared member is likewise an error. The dynamic exception is the hash index door (below). - Inside a method, unqualified names resolve locals → receiver's chain →
use/mutate-listed names — so a child's override is honored by inherited methods.selfis the receiver;self>xis the explicit spelling, and\selfpasses the receiver on.selfoutside a method body is a compile error. \point>normcaptures a bound method — receiver attached.- Methods may not declare implicit-string parameters.
upinside a member block declares into the block enclosing the declarator — construction temporaries live in a nesteddoblock, anduphoists results into the member scope.- There are no anonymous object values — anonymous data is the
hash literal.
=on objects is identity.&objcopies own members one level, shares methods, and keeps the prototype link.
Prototypes and is-a
my child {is (\parent)}:
…add or override members…
Reads fall back along the chain; assignment to an inherited member creates an own
member shadowing the prototype's; assignment to a name on no chain link is an error.
The is-a test is x?t(proto) — true iff proto is on
x's chain; it works on primitives too (x?t(int)), and the
same relation drives signature dispatch. object is the root prototype;
prototypes are ordinary objects.
Namespace separators
The default member separators are . and >,
interchangeable — point>x and point.x are the same read.
The habit of > toward data members and . toward methods is
style, not grammar. Additional separators are declared per object:
my obj {children (/, :)}
say obj/config:port
Legal separator glyphs are only those the language has not already claimed —
/, : are free (. and > are the
defaults, not declarable). Path segments are ordinary identifiers.
Hashes
A hash is an object addressed through index brackets;
hash is a built-in prototype:
my config ([host ('localhost'), "key with spaces" (1), {key} (2)])
# the empty hash
my h ()
h["port"] (8080)
say h["port"] ? 8080
>> 8080
- Index access is the dynamic door: keys are data, not bindings — assignment to
an absent key creates it; reading an absent key yields undefined,
composing with
?and the tests (h["k"]?). The asymmetry with lists — where an out-of-range index is an error — is deliberate: a positional miss is a bounds bug, a key miss is a lookup result. - Path access to a hash needs a declared member, as on any object.
- Literal — key pairs: an entry is
key (value)— a pair, not an assignment; nothing binds during construction. Keys are restricted implicit strings: a bare word (identifier grammar, interior hyphens:content-type), a quoted string (spaces, arbitrary text), or the{expr}escape (computed keys — the same brace escape strings use). In a key a bare word is text (NLis two letters). Pair entries never mix with plain expression elements in one bracket group; a newline separates entries. - The empty hash is
(): legal in operand position (f (())assigns one), with declarator sugarmy h ()— safe becausemy h()has no legal reading. Plainh ()stays the empty-group hard error (one space from the callh()). _(hash)spreads (_is reserved): in an argument list the entries bind as named parameters beside inline ones (f _(obj), retries (3)); in a bracket literal they merge, last wins ([_(defaults), host ('www')]); in an options group they splat as options (f{_(opts)}(x)). Literals merge, calls collide: a name arriving twice at a call is an error.- A named-parameter array is a hash literal: bare in argument position
it flattens into named parameters;
\[…]/&[…]passes the hash as one value. - Entries keep insertion order;
keep/tossstring dispatch operates on keys.
Types and primitives
The object model is the type model. The built-in prototypes — object int
float str bool list hash block iterator error file — are ordinary global
bindings, not reserved words. Signature types are is-a constraints, so dispatch covers
user types with no extra machinery.
- Mixed-type comparison is loud: two concrete, different types
under
=or an ordering is an error — no implicit str↔num coercion.eq/neare the deliberate exception: string-domain equality, numeric operands rendering to their string form. Objects compare by identity under=. - Arithmetic is typed and loud:
/is integer division (float operands truncate;7 / 2→ 3) and//is float division (7 // 2→ 3.5), on any numeric operands.+ - * %take same-type numerics; mixed concrete operands are an error — convert explicitly.%takes the dividend's sign. Overflow and division by zero raisematherrors. Exponentiation is thepowermember —3.power(2); a negative exponent on an int receiver is amatherror (use a float receiver). boolis separate fromint:TRUE = 1is a type error. The explicit bridges areb.int(0/1) andb.str; there is deliberately non.bool— spell the test (n <> 0).- An unparseable conversion yields undefined —
s.int ? 0;s.int/s.floataccept the full literal grammar (sign, prefixes, underscores, exponent).
Standard members
| Type | Members |
|---|---|
list | count, join(sep?) (bare read = empty separator), sort, reverse, push(v, …), pop, shift, unshift(v, …), sum, min, max, map (list.map[1 .. 3, 6] projects every row), used (per-item defined map). Mutators write through the receiver. |
hash | keys, values, count (insertion order) |
str | count (characters), upper, lower, trim, split(sep) → list, index(sub) → position or undefined (no −1 sentinel), replace(from, to), join(…) (the receiver is the separator: ''.join(a, b)), int, float. Strings index and slice like lists: s[2], s[2 .. 4]. |
| numeric | power, abs; float round, floor, ceil; sqrt (float result); conversions int, float, str, hex; tests zero, blank |
bool | int (0/1), str ("TRUE"/"FALSE") |
Member and index suffixes apply to literals and groups directly:
[a, b].join, ''.join(a, b), 3.power(2),
(expr).member. x.zero is the type's zero/empty
(0, '', empty container); x.blank is NULL
or the type's zero/empty.
Filters: keep, toss, grep
keep/toss are postfix keywords that edit aggregates in
place. Dispatch is on the expression's type: boolean → per-element predicate; integer →
index; string → hash key. The predicate form takes a leading comparator whose left
operand is each element implicitly:
my array ([7, 8, 9])
# keep index 2 → 8
array.keep 2
# drop elements = 9 → 7, 8
array.toss = 9
# predicate form → 8, 9
array.keep > 7
grep is the assigning/creating variant
(grep [[my] var] (list) filter); plain filtering should prefer
keep/toss. The postfix-keyword set is fixed; ordinary
operations are members, overridable by user prototypes.
Input and output
| Form | Target |
|---|---|
echo e / < e | the block's output stream |
say e | the block's output stream, with newline |
print e / 1< e | stdout, bypassing capture |
log e | stderr, with newline |
2< e | stderr |
$< e | the enclosing interpolation string |
Stream selection on the word forms is an options group:
# stderr, by number
say {2} "message"
# stderr, by name (out | err | 1 | 2 | …)
say {err} "message"
# to an open file
say {to (\f)} "message"
# prints: 2 message
say 2 "message"
The output model
Every block execution owns an output stream. echo and
say append to the stream of the block containing the statement; uncaptured
streams pass through to the parent's, and the root stream is stdout — an uncaptured
say prints, from anywhere. print and log bypass
the model entirely.
do {out} turns a do block into an
immediate execution whose value is the captured output of its dynamic extent, calls
included — the text in string context, lines in list context:
my report (do {out}
render-header
for row (rows):
say "{row.join(TAB)}"
)
$< appends to the enclosing string — legal only
inside a "{ … }" interpolation block. An interpolation block that uses
$< contributes only its $< output; the block's
expression value is discarded:
my html ("{
for item (items):
$< "<li>{item}</li>"
}")
Files
# read (default)
my f (open("log.txt"))
# write modes: write | append | rw
my g (open{append}("audit.log"))
# files are iterators, by lines
for line (f):
say line
# raw write — newline explicit
g.write("done{NL}")
# explicit; no auto-close
g.close
Members: read (rest of the file as one string), write(s),
close, path, fd. Failures raise io
errors — open misses, operations on a closed file, permission errors. The
?{…} file tests dispatch on their receiver: int → handle, str → path,
file object → itself. stdin/stdout/stderr
are built-in file objects: for line (stdin):, stdin.read;
say {to (\stderr)} … ≡ log ….
Program and environment
All built-in bindings, not keywords:
args— the argument list (the program path is not in it);program— the root file's path.env— the process environment as a hash (env["HOME"]). Writes set the process environment and are inherited byrunchildren.exit— terminate with status:exit(0) orexit 3. Immediate — no unwinding, no cleanup arms; streams flush, files close.dump— debug rendering of each argument to stderr, structure-aware, newline-terminated.
Running commands
Two spellings run external commands: run, where the language
builds the argv list — structural, so quoting and injection hazards are absent by
construction — and the backtick literal, one verbatim bash line for when you want real
shell syntax.
run
# ls -l -a /tmp /home
run ls {l, a} /tmp /home
# glued: single-letter bundle — ls -la
run ls{la} /tmp /home
# quoted elements interpolate: one
# argv element each, never re-split
run cp "{src}" "{dst}"
# glued into a word: splice
run tar{czf} backup-{stamp}.tgz
- Words. The region runs to end of line (or the closing bracket
of an enclosing group). Whitespace splits argv elements; bare words are literal
text, not variable reads. A quoted string is a single element —
"…"interpolates,'…'is raw. - Interpolation is structural.
"{src}"is exactly one argv element, never re-split on whitespace whatever the value contains; a{ }group glued into a word splices into that element. - Execution is direct — no shell process. A command word with no
path separator resolves against
PATH. - Switches. The command word takes a spaced options group: a
single-letter entry renders
-o, a multi-letter entry--option; the glued form is the single-letter bundle (ls{la}→-la). Valued entries:{o (file)}→-o file,{option (v)}→--option v. Switches render after the command word, in listed order, before positionals. - Bare brace groups are options — only there: immediately after
run(execution options) and immediately after the command word (switches); anywhere else they are a parse error suggesting the quoted spelling ("{dst}"). - A statement modifier never fires inside the region —
run deploy.sh if readyis argv text. Wrap in anifstatement instead.
Execution options
run {dir (/tmp), ok (any(0, 1))} grep{c} pattern data.txt
# rc = raw exit code; never raises
my rc (run {code} risky-tool)
# don't wait; value: process handle
run {async} sleep 60
| Option | Effect |
|---|---|
dir (path) | working directory |
env (\hash) | environment overlay |
ok (codes) | exit codes counted as success — ok (any(0, 1)) |
code | value is the raw exit code (int, any context); never raises on exit status |
async | don't wait; the value is a process handle |
in (path) / out (path) / err (path) | stream redirection |
merge | fold stderr into stdout (affects captures) |
Unknown option names are compile errors, so run {src} cp … — the
misplaced-escape typo — diagnoses instead of running cp --src.
Shell habits, handled loudly
- Shell metacharacters as bare words are compile errors:
| || & && ; < > >>, and any bare word beginning with$or a backtick. The error names the fixes — the backtick literal for real shell syntax, execution options for redirection,"{var}"for interpolation. Quoted, they are ordinary text (run grep '|' f). - Globbing is native: an unquoted word containing
*,?, or[ ]expands against the filesystem — one argv element per match, sorted. No match is a runtime error. Quote to pass a literal pattern. - Tilde is not expanded — the home directory is
"{env["HOME"]}". - Pipelines between commands are written with the backtick literal.
The backtick literal
my n (`ls -1 {dir} | wc -l`)
`make clean && make {target} 2>&1 | tee build.log`
run {dir (/tmp)} `tar czf backup.tgz . && echo done`
The content runs as one bash command line — pipes, redirection, globbing, quoting,
and expansions are bash's, untouched. { } interpolates in string context
and splices textually: no quoting is added and bash re-parses the
line — verbatim shell is the feature, so the never-resplit guarantee deliberately does
not apply here. Two escapes: ${ passes through verbatim (bash parameter
expansion needs no doubling), and other literal braces are doubled — brace expansion is
{{1..5}}, an awk body {{print $1}}. A bare backtick statement
executes for effect; to combine with execution options, the literal stands in place of
the command region.
Command values and failure
The command's value — run and backticks alike — follows evaluation
context:
| Context | Value |
|---|---|
| boolean | success: exit code in the ok set (default 0) |
| string | captured stdout, trailing newlines stripped |
| list | stdout split on newlines; interior blank lines kept |
| void | stdio inherited; nothing captured |
| comparison | per the other operand; toward int, a compile error suggesting {code} |
A non-ok exit raises a command error — carrying
code, command, and stderr where captured —
except in boolean context, which tests instead of raising. Spawn failure (missing
binary, permission denied, bad dir) always raises, in every context: a
command that never ran is not a false result. stderr inherits by default and is never
part of a capture unless merged.
Programs and includes
A program is a root file plus the closure of its
includes:
include lib/parser
include {only (trim, pad)} util
include {hide (debug-dump)} 'odd name.glue'
- A file owns its scope. What an includer gains is the included
file's export set — its
globaldeclarations and{global}definitions; everything else stays private, in both directions. - Include-once. A file initializes once per program however many
files include it; every includer sees the same bindings. Execution order is
dependencies-first; the root file's own top level runs last — it is the
program; there is no
main. Include cycles are compile errors. - Not transitive: including B does not show you what B includes — each file states its own dependencies.
- Two files may export the same name; the collision errors only in a file whose
includes see both. Filter with
{only (…)}/{hide (…)}— entries are checked against the export set, so a typo'd name is a compile error. - Inside closed function bodies, imported definitions are visible
(immutable, like local ones); an imported variable is a mutable outer
binding —
use/mutate-listed or not in scope. includeis a static statement: file top level only, before every non-include statement, with a literal path — a bare span or a quoted raw string; no interpolation, no conditional includes. Search is relative to the including file's directory, then the configured path list; an extensionless path takes the default source extension.
Word inventory
Reserved words:
my up global do if unless nif else or nor and nand xor nxor not eq ne
with without while for foreach next last break continue also then fn use
return result want self try catch throw choose keep toss grep exists used
set echo say print log run include default TRUE FALSE NULL NL CR LF CRLF
BOOL CMP STR LIST VOID CMD _
(fn is reserved but currently means nothing — definitions use the dot
head. _ is the blank head of the spread form _(…) and no
longer a legal identifier.) Reserved words are deliberately minimal. The built-in prototype names
(object int float str bool list hash block iterator error file) and the
built-in functions and values (any all none open exit dump args program env
stdin stdout stderr) are ordinary global bindings, not reserved words —
conversely, member names may not be reserved words: a reserved word in member position
is a postfix keyword (array.keep > 7).