Specification

A compact but complete description of mu: the whole language, on one page. Where a detail is elided, the full specification and grammar in the repository are the normative text.

Lexical structure

Source is UTF-8. // comments run to end of line, /* … */ may span lines, and a #! shebang is allowed on the first line only. Semicolons are optional where a newline or closing delimiter already separates statements.

Thirteen keywords, and that is the entire list:

fn  if  else  while  return  continue  break
import  quote  unquote  true  false  nil

Identifiers start with a letter or _, then letters, digits or _.

Literals. Integers are 64-bit signed and may be written in decimal, hex (0x2a) or binary (0b101010). Decimal literals are a digit run, a ., and at least one further digit — 3.14, 0.1 — and are exact fixed-point, not binary floating point. Strings are double-quoted and support \a \b \t \n \v \f \r \0 \\ \"; a backslash before anything else is kept verbatim, so "\q" is two characters. There are no numeric or \u escapes — build other code points with chr(n).

Values

Type Notes
INTEGER 64-bit signed. Truncating division; division by zero is an error.
BOOLEAN true / false, distinct from integers.
STRING Immutable, UTF-8, byte-indexed. + concatenates; < compares lexicographically.
LIST Ordered, zero-indexed. Out-of-bounds read yields nil.
MAP Keys may be integers, strings, booleans, or anything with __ops["key"].
BUFFER Mutable bytes from buf(n). b[i] is an integer 0–255.
CLOSURE Functions are closures. They cannot be compared for equality.
ERROR A value, not an exception.
CODE Parsed mu syntax from quote { … }. Data, not executable.
NULL nil, the absence of a value.
LIB, PTR Opaque FFI handles from dlopen / dlsym.
TASK, CHAN Concurrency handles.

A map carrying a string __type member reports that instead of "MAP", which is how type(3.14) is "Decimal".

Map traversal is insertion-ordered and stable. keys, values and inspect walk the same order, so values(m)[i] is always m[keys(m)[i]]. Reassigning a key keeps its position; deleting and re-adding moves it to the end. Since an absent key and a nil value both index to nil, use has(m, k) to tell them apart.

[!NOTE] Traversal order is a guarantee, not an implementation detail. Before this was pinned, the Go VM answered a different keys() order on every call, and keys and values disagreed with each other inside a single call.

Truthiness. Falsey: false, nil, 0, "", empty list, empty map, empty buffer. Everything else is truthy. A value with a bool entry in __ops answers for itself — which is why 0.0 is falsey despite being a non-empty map.

Expressions and operators

Precedence, loosest first; all groups associate left:

  1. ||
  2. &&
  3. == !=
  4. < <= > >=
  5. + - | ^
  6. * / % << >> &
  7. **
  8. unary ! - ~, and unquote
  9. primaries — literals, identifiers, (…), function literals, list/map literals, indexing, calls, member access

[!WARNING] Bitwise |, ^ and & bind at arithmetic level, not below comparison as in C. a & b == c parses as a & (b == c). Parenthesise when mixing them.

Statements

x := 1     // declare; shadows any outer x
x = 2      // assign to nearest existing x
x += 3     // update in place
x -= 1
x++        // increment by one
x--        // decrement by one

Assigning with = to a name that does not exist is a compile error, as is assigning to a builtin.

if is an expression: the taken block’s last value is its result, and nil if no branch runs.

status := if ready { "go" } else { "wait" }

while is the only loop, and yields no value. continue re-tests the condition; break leaves the nearest loop. Both are compile errors outside a loop.

return exits a function immediately; bare return yields nil. return at top level is illegal.

Functions and closures

fn add(a, b) { return a + b }
double := fn(x) { return x * 2 }

// variadic: rest is a list of the extras
fn sum(first, rest...) { … }

Top-level fn declarations are visible before their definition, so mutual recursion works without forward declarations.

Closures capture variables, not values. A nested function shares the binding with the function that declares it, so writes are visible in both directions:

fn totalise(items) {
  total := 0
  i := 0
  while i < len(items) {
    total = total + items[i]
    i = i + 1
  }
  return total          // the sum
}

A closure that outlives its declaring function keeps the binding alive, which is what makes a counter work, and a variable assigned after a closure is created is still seen by it — which is how a recursive lambda refers to itself:

fact := nil
fact = fn(n) {
  if n <= 1 { return 1 }
  return n * fact(n - 1)
}

[!NOTE] This was not always true. Closures used to capture the value at creation time, so the first example returned 0 and the second crashed. Variables that are captured and assigned are now shared; ones that are only read still use the cheaper by-value capture, which is indistinguishable because the value never changes.

Because the binding is shared, closures made inside a loop all see the variable’s final value — there is one variable, and while introduces no per-iteration binding. Declare inside the body if you want one per iteration:

while i < 3 {
  fs = append(fs, fn() { return i })   // every closure returns 3
  i = i + 1
}

while i < 3 {
  j := i
  fs = append(fs, fn() { return j })   // 0, 1, 2
  i = i + 1
}

Errors are values

There are no exceptions. error(msg) and builtin misuse produce a value of type "ERROR" that flows through your program until you check it:

import "io"

data := io.read_file("missing.txt")
if type(data) == "ERROR" {
  print("failed:", data)
}

inspect and print render an error as its message. Indexing with the wrong type, calling with the wrong arity, calling a non-function value, invalid operators, and I/O failures are all error values.

A smaller set of mistakes rejects or halts the program instead: using an undefined variable, assigning to one, assigning to a builtin, a macro-expansion failure, a concurrency deadlock, or an explicit panic.

The operator protocol

A map carrying an __ops table defines what operators mean for its own values:

fn add(a, b) {
  return Money(a.cents + b.cents)
}

fn show(a) {
  return "$" + str(a.cents / 100)
}

Money := fn(cents) {
  return {
    "__type": "Money",
    "cents": cents,
    "__ops": { "+": add, "str": show },
  }
}

print(str(Money(500) + Money(250)))
// $7

Entries cover arithmetic, comparison, equality, neg, str, bool and key (the scalar a value hashes as). The host consults __ops only where an operation would otherwise have failed, so ordinary arithmetic pays nothing for it. This is the only __dunder__ convention in mu, and it is opt-in.

Decimals are built entirely from this protocol — the language itself knows nothing about them. A bare / on decimals keeps 15 fractional digits and rounds half-even; decimal.ratio(a, b, scale, mode) chooses both.

Macros

quote { … } produces a CODE value; unquote x is a hole filled from the enclosing scope, and unquote xs... splices a list of code values. A macro is an ordinary function from code to code, registered with macro.define, and it runs on the build machine before bytecode exists — so macros cost nothing at runtime.

import "macro"

macro.define("unless", fn(cond, body) {
  return quote {
    if !unquote cond {
      unquote body
    }
  }
})

A quote is always block code; unwrapping happens at the splice site, where a block holding exactly one expression unwraps and anything else is an error. Identifiers written literally inside a quote are marked as quote-introduced, so a macro’s own bindings cannot capture its caller’s.

Modules

import "strings"
import "path/to/module" as mod

import is only legal at the outermost level. Each import binds exactly one name, and every export lives behind a dot: strings.split(…). Namespaces are immutable, and imports never leak into the global scope.

Resolution order: MULIB if set, then the embedded lib/ tree, then the filesystem relative to the importing file.

The µ-builtins module lives at lib/builtins.mu and supplies unqualified globals: print, input, panic, test, exit, plus bool, values, has, int, insert, is_error, must and try, which are written in mu rather than in the host. A MULIB override containing an empty builtins.mu removes them.

[!TIP] The µ-builtins module is ambient, not importable. Use print(...) or has(m, k) directly; import "builtins" is refused.

Execution model

Source is lexed and parsed to an AST, compiled to bytecode through a small IR, and executed on a stack machine. The same bytecode either runs on the VM or is lowered to machine code by the native backend; the VM is the reference for semantics.

Concurrency is cooperative: tasks yield only at blocking operations (send, recv, wait, sleep, I/O), and runnable tasks resume FIFO, so scheduling is deterministic. It runs through the Go VM, Go native backend, self-hosted VM, and self-hosted native backend on that backend’s supported targets.

Builtins

27 callable host builtins, and nothing else is privileged:

data     len     append  pop     del     keys
types    type    inspect str     error
chars    ord     chr
host     syscall buf     platform
         args    env
ffi      dlopen  dlsym   dlcall
tasks    task    wait    chan
         send    recv    poll    push
repl     help

Ambient µ-builtins come from lib/builtins.mu, not the host:

print    input   panic   test
exit     bool    values  has
int      insert  is_error must
try      // macro

A capability earns a place in the host list only when mu genuinely cannot express it — it needs the kernel, raw memory, the loader, the scheduler, the runtime type tag, or a mutation mu has no other way to perform. Everything else lives in mu, in the µ-builtins layer or in stdlib modules. help() prints the live list.

The normative documents

Next steps