Stylistic suggestions for writing Elly by hand or generating it
(examples, tests, benchmarks). A living document — add to it as
conventions settle. These are preferences, not spec rules; the
grammar is in docs/elly-spec.md and the Muon layer it sits
on is in docs/muon-spec.md.
f(a, b), not
f a b, but use judgementWrite f(a, b, c) rather than the bare application spine
f a b c. Inside a (…) group commas and
newlines are interchangeable, and each comma/newline-separated segment
is a full sub-expression spread as one argument to whatever
precedes the group — so f(a, b) is exactly
f a b. The spread form lets a call wrap across lines while
staying one chain:
__Int.for(0, n,
0,
&(i, acc) __Int.add acc (f i))
Write &(i, acc) rather than
&i &acc. They desugar identically (a binder group
curries left to right), but the group reads as a parameter list.
Patterns work in either position: &(i, [seed, acc])
binds i and destructures the second argument.
Mixing the two forms in one program is fine and idiomatic — reach for
the space form when a call is short (__List.get al i) and
the spread form when arguments are long or want their own lines
(__eq(k, target, &_ hit, &_ acc)).
Spread composes: __Int.add(__Int.mul(i, n), k) reads as
__Int.add (__Int.mul i n) k, because each segment is
reduced independently before being spread.
Keep &x &y (two separate &)
only when the two-stage currying is the point — e.g. a fixpoint
Z (&rec &n …), where rec is supplied
first and the result is applied to n.
The space form has one trap worth knowing: a builtin's name is a
module and a member (__Map.nil is two chain
items), so a builtin passed as an argument to a bare application keeps
the chain going — __Map.with __Map.nil .k 1 reads as
((__Map.with __Map) .nil .k) 1. Use the spread form
whenever an argument is itself a dotted name:
__Map.with(__Map.nil, .k, 1).
A […] list literal is already a
multi-line-friendly scope, so pass one directly —
__match [c0, c1, …], not __match([c0, c1, …]).
The list's own brackets carry the newlines; wrapping it in a
(…) spread just to reach one argument is redundant.
value |> __match [...]Write val |> __match [clauses] rather than
__match [clauses] val. The pipe L |> R
lowers to App(R, L), threading the left operand in as
R's final argument — so it is the same call, read
subject-first:
n |> __match [
&(< 2) n,
&_ __Int.add (rec (__Int.sub n 1)) (rec (__Int.sub n 2))
]
|> is the loosest operator in a chain, so within a
single segment it needs no parentheses — and a pipeline that is
a binder body does: write &x (x |> f), since
&x x |> f pipes the lambda itself
(f (&x x)). A lambda as a whole pipe stage needs no
parens: x |> &y body is
(&y body) x.
An expression (and each let body) is a single muon
chain, so newlines are load-bearing:
elly::parse, the playground) where a top-level
newline splits the input into multiple chains
(MultipleExpressions).let binder group and its body
orphans the body (LetMissingBody) — start the body on the
group's closing-paren line: ) __Int.add a b.
recur has the same shape and the same trap
(RecurMissingBody).(…) /
[…] / {…} scope. Even there, a newline that
splits a single abstraction body makes the &-header its
own chain (AbsWithoutBody): in
(&i &acc <body>) the whole
&i &acc <body> must be one segment.So lay multi-line code out as spread arguments or
let bindings (both tolerate newlines
between their segments), not by breaking an abstraction body across
lines. When a callback body is genuinely large, give it a name in a
let:
let (
step = &(i, [s, acc]) let (
s2 = lcg s,
v = (__Int.divrem s2 M).1
) [s2, (__Int.add acc v)]
) (__Int.for(0, n, [7, 0], step)).1
Reach for recur when the name is
recursive. A let binding cannot see itself, so a
recursive helper written as a let silently resolves its own
name to something outer, or fails to resolve it at all. Write the group
with recur instead — same layout, same newline rule — and
keep let for the sequential case:
recur (
loop = &(i, acc) (i |> __match [
&(0) acc
&(> 0) loop(__Int.sub(i, 1), __Int.add(acc, i))
])
) loop(n, 0)
A recur binding may not reuse a name that is already in
scope, so pick a fresh one rather than shadowing the enclosing
binder.
Stopgap for top-level newlines. A whole expression
that wants blank lines or top-level comments can be wrapped in one
(…) grouping — a one-element group tolerates stray newlines
and comment-only chains at its top level:
(
// a comment, and blank lines, are fine at the top of a `(…)`
let (x = 1, y = 2) __Int.add x y
)
This is only for single-expression sources; a module file already has
free top-level newlines. The (…) wrapper also happens to
sit nicely inside a Rust multiline string literal (leading/trailing
newlines are harmless).
{ key: ... }, { .5: ... },
{ (5): ... }{ k: 1 } is { .k: 1 }. Write .5
(symbol) or (5) (integer key) to disambiguate a
digit-leading key.imports as its first lines, one per
line, before the items. Nothing enforces it — an import is a declaration
wherever it sits — but a module's imports are its reachable set, and a
reader should not have to scan for them.import "spec" as Foo and
re-export deliberately. Foo = import "spec" says "callers
of this module get Foo too", which is a promise about your
interface, not a convenience.import "./util/text" as Text. The spec string is the
resolver's business; the name is your code's.__Int.add(Dep.base, 1), not
__Int.add Dep.base 1 (see Prefer the spread-call
form).import "./log" as Log
Codec = import "./codec" // re-exported: callers get M.Codec
encode = &v Log.trace(Codec.encode v)
local ( foo = ... , bar = ... ) moditems
by defaultDon't forget to default to local definitions, unless the
intent is to make them public.
// the top-levels items are public interface:
foo = &x x
// multiple local definitions (prefer to keep together and after public toplevels):
local (
one = 1
two = 2
)
// public again, but prefer to put before the first big `local` block:
baz = ...
// this is also private and can go in any order, but prefer one `local ( ... )` form:
local bar = ...
x.m(a) when x is the subject,
M.m(x, a) when the module isAn object is a payload plus a module as its prototype, and
x.m(a) and M.m(x, a) run the same
item — so the choice is about what the line is about, not about what
works. Lead with the receiver for anything that reads as behaviour of a
value (total.plus(step), n.to_hms), and with
the module for what belongs to the type as a whole — constructors above
all (Second.from_minutes(3),
Int.divrem(n, 60)).
local Self = __module // so signatures read `as Self`, not `as __module`
plus = &(__value n, other as Self) new(Int.add(n, other.total))
duration.plus(Second.zero) // ✓ receiver first; the argument is spread
duration.plus Second.zero // ✗ one chain: `duration .plus Second .zero`
.plus the module
Second, then projects .zero off the result.
This is ergonomics §9 met from a third side; the spread form is the
standing answer.__value. &(__value n) unwraps the
receiver in the binder, which is what makes a method shorter than its
module-form spelling; the payload has no other reader, and that is the
encapsulation.as Self. A
binary method (plus, minus) should say what it
takes rather than trust whatever it is handed. The test is spelled
as __module, but alias it once at the top —
local Self = __module — and the __ stays out
of every signature below it.__new is unreachable
from outside, so keep the checking constructor the single door and build
the rest on it (from_hours → from_minutes →
new).crates/elly-cli/tests/programs/Second.ly is the worked
example for all four, and Bool.ly beside it is the iota
case, where there is no payload to carry.
__main = ...: prefer separate named items to be available
outsideThe smallest Elly program is:
// the `__main` entry is special and is only available to the host runner:
__main = &io io.print "hi"
This form is fine for a hello-world script, but keep in mind that a
main might be needed outside as well (e.g. for testing), so
separate and name it explicitly:
// this entry is a regular exported moditem:
main = &io let (
_ = io.print("hello")
_ = io.print("world")
) []
// available to the host runner as well:
__main = main
Take the capability as a parameter wherever it is needed, rather than
reaching for it: shout = &(io, s) io.print(s). A
function's signature is then its permission list — the whole point of
passing IO as an argument instead of putting it in
scope.
Beware banner = io.print("hi") as an item. Item bodies
re-evaluate on every reference, so an effectful item fires per access
rather than once. Keep effects in what __main applies.