Elly style

Elly style

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.

Prefer the spread-form application: f(a, b), not f a b, but use judgement

Write 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.

Prefer the pipe for match: 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.

Mind the one-chain layout rule: a newline is a comma

An expression (and each let body) is a single muon chain, so newlines are load-bearing:

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).

Symbols and keys, { key: ... }, { .5: ... }, { (5): ... }

Imports go at the top, private by default

import "./log" as Log
Codec = import "./codec"        // re-exported: callers get M.Codec

encode = &v Log.trace(Codec.encode v)

Module encapsulation: use local ( foo = ... , bar = ... ) moditems by default

Don'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 = ...

Objects: x.m(a) when x is the subject, M.m(x, a) when the module is

An 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`

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.

Module __main = ...: prefer separate named items to be available outside

The 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.