Skip to the content.

For LLM ingestion: this page is the rendered version of the spec. The same content as raw markdown — no HTML wrap, no theme, link-rewritten for direct ingestion — is at /language.raw.md. Drop it into a system prompt and ask the model to author Sigil.

Sigil — Language Specification (v1)

Sigil is a compiled, statically-typed programming language designed to be reliably authored by large language models. Programs are parsed by a strict recursive-descent parser, type-checked by a Hindley–Milner checker extended with effect rows, lowered to Cranelift IR, and linked against a small Boehm-GC’d runtime.

This document is examples-first: fourteen worked examples (E1–E14) introduce the language by progressive elaboration. The reference sections after the examples are intended as lookup, not linear reading.

Authoring contract. This spec is the LLM’s only context for Sigil’s surface syntax and semantics. Code generated against this spec should compile first try at ≥ 70 % of the validation prompt bank (spec/validation-prompts.md) and ≥ 90 % after a single error-feedback edit. If a generated program fails to parse against this spec but works against the actual compiler, that’s a spec gap — file an issue.


Worked examples (E1–E17)

E1 — Hello, world

import std.io
use std.io.{IO};

fn main() -> Int ![IO] {
  perform IO.println("hello, world");
  0
}

Every Sigil program reaches stdlib symbols via two declarations: import std.io makes the module addressable; use std.io.{IO}; opts the IO name into this file’s bare namespace so the ![IO] row entry can name the effect without a std.io. prefix. See §10 for the full module-and-imports model.

Every function declares an effect row in ![ … ]. IO is a builtin effect with multiple sub-operations (print, println, read_line, read_file, write_file — see §13); these examples use only IO.println. perform is the syntax for invoking an effect; the result of perform IO.println(...) is Unit (Sigil’s no-information type), discarded by the ;.

fn main must return Int. A non-zero return becomes the process exit code; this program returns 0 (success).

E2 — Arithmetic and the pure effect row

import std.int
import std.io
use std.int.{int_to_string};
use std.io.{IO};

fn square(n: Int) -> Int ![] {
  n * n
}

fn main() -> Int ![IO] {
  perform IO.println(int_to_string(square(7)));
  0
}

![] is the closed empty effect rowsquare performs no effects. The compiler rejects any function with an ![] row that contains perform or that calls a function with a non-empty row.

int_to_string(n: Int) -> String ![] is a compiler intrinsic in the prelude (§13.2) — it is in scope everywhere, no import / use needed (the example above imports it explicitly, which still works but is redundant). String literals use "..." with C-style backslash escapes (\\, \", \n, \t).

E3 — Recursion and exhaustive match

import std.int
import std.io
use std.int.{int_to_string};
use std.io.{IO};

fn fib(n: Int) -> Int ![] {
  match n {
    0 => 0,
    1 => 1,
    _ => fib(n - 1) + fib(n - 2),
  }
}

fn main() -> Int ![IO] {
  perform IO.println(int_to_string(fib(10)));
  0
}

match is exhaustive. Omitting either the 0, the 1, or the _ arm fires E0066: \match` on `Int` is not exhaustive at compile time. Patterns include integer literals, sum-type constructors, record patterns, tuple patterns, identifier patterns (name binds), and the wildcard _`. Patterns are matched in source order; the first matching arm wins.

E4 — Sum types and pattern matching

import std.int
import std.io
import std.option
use std.int.{int_to_string};
use std.io.{IO};
use std.option.{None, Option, Some};

fn safe_div(num: Int, den: Int) -> Option[Int] ![] {
  match den {
    0 => None,
    _ => Some(num / den),
  }
}

fn main() -> Int ![IO] {
  match safe_div(10, 0) {
    Some(v) => perform IO.println(int_to_string(v)),
    None => perform IO.println("zero divisor"),
  };
  0
}

Sum types are declared with type T = | Variant1(Args) | Variant2(Args) | … (see §6). Option[A] is shipped in std/option.sigil; Some(x) and None are its constructors. Constructor names start with an uppercase letter by convention (the parser does not enforce this in v1, but every stdlib type follows it).

E5 — Higher-order functions and lambdas

import std.int
import std.io
import std.list
use std.int.{int_to_string};
use std.io.{IO};
use std.list.{List, length, map, range};

fn add_one(n: Int) -> Int ![] { n + 1 }

fn main() -> Int ![IO] {
  let xs: List[Int] = range(1, 5);                    // [1, 2, 3, 4]
  let ys: List[Int] = map(xs, add_one);               // [2, 3, 4, 5]
  let zs: List[Int] = map(ys, fn (n: Int) -> Int ![] => n * 10);
  perform IO.println(int_to_string(length(zs)));
  0
}

map, range, and length are in std/list.sigil. Lambdas have the syntax fn (params) -> Ret ![Effects] => body — parameter types, return type, and effect row are all required, just like top-level fn.

E6 — Generic functions

import std.int
import std.io
use std.int.{int_to_string};
use std.io.{IO};

fn identity[A](x: A) -> A ![] { x }

fn main() -> Int ![IO] {
  let n: Int = identity(42);
  let s: String = identity("hello");
  perform IO.println(int_to_string(n));
  perform IO.println(s);
  0
}

Generic parameters are introduced in [A, B, …] after the function name. Each call site instantiates fresh type variables; inference finds the unique satisfying assignment via Hindley–Milner.

The same syntax extends to generic types:

type Box[A] = | Wrap(A)

fn unwrap[A](b: Box[A]) -> A ![] {
  match b { Wrap(x) => x }
}

E7 — Records

import std.int
import std.io
use std.int.{int_to_string};
use std.io.{IO};

type Point = { x: Int, y: Int }

fn manhattan(a: Point, b: Point) -> Int ![] {
  let ax: Int = match a { Point { x, y: _ } => x };
  let ay: Int = match a { Point { x: _, y } => y };
  let bx: Int = match b { Point { x, y: _ } => x };
  let by: Int = match b { Point { x: _, y } => y };
  abs(ax - bx) + abs(ay - by)
}

fn abs(n: Int) -> Int ![] {
  match n < 0 {
    true => 0 - n,
    false => n,
  }
}

fn main() -> Int ![IO] {
  let p: Point = Point { x: 1, y: 2 };
  let q: Point = Point { x: 4, y: 6 };
  perform IO.println(int_to_string(manhattan(p, q)));   // 7
  0
}

Record fields are declared name: Type in the type, constructed with Name { name: value, … }, and destructured via match with Name { name: binding, … } (field-pun name is shorthand for name: name). Record fields are read with binding.field (field access), where the head of the chain is a value of a single-variant record type; chains read through nested records (node.inner.value). Field access is read-only — there is no field-update syntax. match destructuring remains available and is the only way to read fields of a multi-variant sum type (the variant, and hence the field set, is not known statically). Field access on a non-identifier head (e.g. make(x).field) is not yet supported — bind it to a let first. Records are nominal — two records with the same fields but different declared names do not unify.

E8 — Effects: Raise for exceptions

import std.int
import std.io
import std.raise
import std.result
use std.int.{int_to_string};
use std.io.{IO};
use std.raise.{Raise, catch, raise};
use std.result.{Err, Ok, Result};

fn parse_pos(n: Int) -> Int ![Raise[String]] {
  match n {
    0 => raise("expected positive"),
    _ => n,
  }
}

fn main() -> Int ![IO] {
  let result: Result[Int, String] = catch(fn () -> Int ![Raise[String]] => parse_pos(0));
  match result {
    Ok(v) => perform IO.println(int_to_string(v)),
    Err(m) => perform IO.println(m),
  };
  0
}

std.raise ships a generic effect:

effect Raise[E] { fail[A]: (E) -> A }

Calling raise(s) performs Raise.fail(s); under catch’s discharging handler, the call short-circuits to an Err result. Raise[E] is generic over the error type — Raise[String] raises string errors, Raise[Int] raises integer error codes, etc.

catch is row-polymorphic: catch[A, E](body: () -> A ![Raise[E] | e]) -> Result[A, E] ![| e] — it discharges the Raise[E] effect and passes any other effects in the row through to the caller.

E9 — Effects: State[S] for threaded state

import std.int
import std.io
import std.pair
import std.state
use std.int.{int_to_string};
use std.io.{IO};
use std.pair.{fst};
use std.state.{State, run_state};

fn comp() -> Int ![State[Int]] {
  let _: Int = perform State.set(10);
  let v: Int = perform State.get();
  v + 1
}

fn main() -> Int ![IO] {
  let result: (Int, Int) = run_state(5, comp);   // (11, 10)
  perform IO.println(int_to_string(fst(result))); // 11
  0
}

State[S] is parametric over the state type S. run_state[A, S] (initial, body) discharges the State[S] effect by threading initial through every perform State.get/set site in body’s call tree, returning (A, S) — the body’s result paired with the final state. The discharger is defined in pure Sigil over a runtime cell primitive (see std/state.sigil). Both type parameters are inferred from the call site (e.g. run_state(5, comp) instantiates A = Int, S = Int).

State[S] composes with Raise[E] in either nesting order: catch(run_state(...)) and run_state(catch(...)) both work. A foreign raise inside a run_state body propagates through the State handle as Discharge(effect=Raise, value=Err), reaching the enclosing catch cleanly — the State arm bodies resume k(...) directly (rather than returning a state-fn closure), so the foreign discharge passes through the existing CPS infrastructure without the Sync-ABI gap that would otherwise mask the discharge tag.

E10 — Multi-effect rows

import std.byte_array
import std.int
import std.io
import std.ordering
import std.raise
import std.result
import std.string
use std.byte_array.{string_length};
use std.int.{int_to_string};
use std.io.{IO};
use std.ordering.{Equal, string_compare};
use std.raise.{Raise, catch, raise};
use std.result.{Err, Ok, Result};
use std.string.{string_concat};

fn pipeline(s: String) -> Int ![IO, Raise[String]] {
  perform IO.println(string_concat("processing: ", s));
  match string_compare(s, "") {
    Equal => raise("empty input"),
    _ => string_length(s),
  }
}

fn main() -> Int ![IO] {
  let r: Result[Int, String] = catch(fn () -> Int ![IO, Raise[String]] => pipeline("hello"));
  match r {
    Ok(n) => perform IO.println(int_to_string(n)),
    Err(m) => perform IO.println(string_concat("error: ", m)),
  };
  0
}

Effect rows are unordered sets of effect names. ![IO, Raise[String]] and ![Raise[String], IO] are the same row. A function with row ![Raise[String]] may be called from any row that contains Raise[String]; ![IO, Raise[String]] calls ![Raise[String]] callees freely.

catch is row-polymorphic — it accepts bodies with extra effects beyond Raise[E] and passes them through. The | e row variable in catch’s signature captures the residual row.

E11 — Mutable state via the Mem effect

import std.byte_array
import std.int
import std.io
import std.mem
import std.mut_byte_array
use std.byte_array.{byte_to_int, byte_truncate};
use std.int.{int_to_string};
use std.io.{IO};
use std.mem.{Mem};
use std.mut_byte_array.{MutByteArray, mut_byte_array_get, mut_byte_array_new, mut_byte_array_set};

fn main() -> Int ![IO, Mem] {
  let zero: Byte = byte_truncate(0);
  let buf: MutByteArray = mut_byte_array_new(4, zero);
  mut_byte_array_set(buf, 0, byte_truncate(72));   // 'H'
  mut_byte_array_set(buf, 1, byte_truncate(105));  // 'i'
  let h: Byte = mut_byte_array_get(buf, 0);
  let i: Byte = mut_byte_array_get(buf, 1);
  perform IO.println(int_to_string(byte_to_int(h)));
  perform IO.println(int_to_string(byte_to_int(i)));
  0
}

Mem is a marker effect (zero ops, no perform-dispatch); it gates mutation: mut_byte_array_set requires Mem in the row. Pure functions (![]) cannot mutate. See §9.

StringBuilder (std/string_builder.sigil) is the canonical incremental-string surface under Mem — see E12.

E12 — Building a JSON document with StringBuilder

import std.int
import std.io
import std.mem
import std.string_builder
use std.int.{int_to_string};
use std.io.{IO};
use std.mem.{Mem};
use std.string_builder.{sb_append, sb_finalize, sb_new};

fn render() -> String ![Mem] {
  let sb: StringBuilder = sb_new();
  sb_append(sb, "{\"name\": \"ada\", \"count\": ");
  sb_append(sb, int_to_string(36));
  sb_append(sb, "}");
  sb_finalize(sb)
}

fn main() -> Int ![IO, Mem] {
  perform IO.println(render());                                // {"name": "ada", "count": 36}
  0
}

sb_new() -> StringBuilder ![Mem] allocates a fresh segmented rope; sb_append writes into the tail segment (allocating new 4 KiB segments on overflow); sb_finalize packs everything into a single String. Avoids the O(n²) cost of repeated string_concat.

For a fuller example see examples/json.sigil.

E13 — Tuples and pair destructuring

import std.int
import std.io
import std.pair
use std.int.{int_to_string};
use std.io.{IO};
use std.pair.{fst, snd};

fn swap(p: (Int, String)) -> (String, Int) ![] {
  match p { (a, b) => (b, a) }
}

fn main() -> Int ![IO] {
  let pair: (Int, String) = (42, "hello");
  perform IO.println(int_to_string(fst(pair)));      // 42
  perform IO.println(snd(pair));                      // hello
  let swapped: (String, Int) = swap(pair);
  perform IO.println(fst(swapped));                   // hello
  0
}

Tuple types are written (T1, T2, ...) and tuple values as (e1, e2, ...). Tuples of any arity are supported. Binary tuples can use fst[A, B] and snd[A, B] from std.pair; all tuples support destructuring in match patterns with (p1, p2, ...).

E14 — Nondeterminism with Choose

import std.choose
import std.int
import std.io
import std.list
use std.choose.{Choose, all_choices};
use std.int.{int_to_string};
use std.io.{IO};
use std.list.{List, length};

fn pick_pair() -> Int ![Choose] {
  let a: Int = perform Choose.choose(3);
  let b: Int = perform Choose.choose(3);
  a * 10 + b
}

fn main() -> Int ![IO] {
  let results: List[Int] = all_choices(pick_pair);
  perform IO.println(int_to_string(length(results)));   // 9
  0
}

Choose is a multi-shot effect (resumes: many): the handler can invoke the continuation multiple times per perform. all_choices enumerates every branch by resuming k(0), k(1), …, k(n-1) and collecting results into a list. first_choice returns the first non-failing branch as Option[A].

E15 — CLI: argv + Fs.read_dir + Fs.read_file

import std.env
import std.fs
import std.io
import std.list
import std.result
use std.env.{Env, env_args};
use std.fs.{Fs, FsError, NotFound, read_dir};
use std.io.{IO};
use std.list.{Cons, List, Nil};
use std.result.{Err, Ok};

fn dump_dir(path: String) -> Int ![IO, Fs] {
  match read_dir(path) {
    Ok(entries) => dump_each(entries),
    Err(NotFound) => fail_with("(directory missing)"),
    Err(_) => fail_with("(error reading directory)"),
  }
}

fn dump_each(xs: List[String]) -> Int ![IO] {
  match xs {
    Nil => 0,
    Cons(name, rest) => dump_each_step(name, rest),
  }
}

// Helper: print one entry then recurse on the rest. Sigil v1's
// match arm body must be a single expression, so the
// print-and-recurse sequence lives in a fn body (which IS a block).
fn dump_each_step(name: String, rest: List[String]) -> Int ![IO] {
  perform IO.println(name);
  dump_each(rest)
}

fn fail_with(msg: String) -> Int ![IO] {
  perform IO.println(msg);
  1
}

fn main() -> Int ![IO, Env, Fs] {
  // First arg after argv[0] is the directory to list; default to "."
  // if not provided.
  let argv: List[String] = env_args();
  let path: String = match argv {
    Nil => ".",
    Cons(_prog, Nil) => ".",
    Cons(_prog, Cons(p, _)) => p,
  };
  dump_dir(path)
}

This is the CLI-tool baseline: env_args() gives the argv list (POSIX convention — env_args()[0] is the program name); read_dir(path) returns Result[List[String], FsError] with entry names (no path joining); pattern-match handles each FsError variant; output prints via IO.println. Replace read_dir with read_file(p) to read a file; replace with run("cmd", argv) to spawn a subprocess.

E16 — Word-frequency counter with Map[Char, Int]

import std.char
import std.int
import std.io
import std.list
import std.map
import std.option
import std.string
use std.char.{char_to_string, string_chars};
use std.int.{int_to_string};
use std.io.{IO};
use std.list.{Cons, List, Nil};
use std.map.{Map, map_char_keys, map_get, map_insert, map_to_list};
use std.option.{None, Some};
use std.string.{string_concat};

fn count_chars(cs: List[Char], m: Map[Char, Int]) -> Map[Char, Int] ![] {
  match cs {
    Nil => m,
    Cons(c, rest) => {
      let next: Int = match map_get(m, c) {
        Some(n) => n + 1,
        None => 1,
      };
      count_chars(rest, map_insert(m, c, next))
    },
  }
}

fn print_pairs(xs: List[(Char, Int)]) -> Int ![IO] {
  match xs {
    Nil => 0,
    Cons(p, rest) => match p {
      (c, n) => {
        perform IO.println(string_concat(char_to_string(c),
          string_concat(": ", int_to_string(n))));
        print_pairs(rest)
      },
    },
  }
}

fn main() -> Int ![IO] {
  let cs: List[Char] = string_chars("banana");
  let counts: Map[Char, Int] = count_chars(cs, map_char_keys());
  print_pairs(map_to_list(counts))
}

Map[Char, Int] keys each unique codepoint to its running count. map_get + map_insert is the canonical histogram-update pattern; because the persistent map carries its comparator (char_compare, threaded through map_char_keys) every lookup is O(log n) without the caller threading an equality predicate. map_to_list returns the entries sorted ascending by key, so the output is deterministic across runs.

E17 — Format-string log-line builder

import std.format
import std.io
use std.format.{AInt, AString, format3};
use std.io.{IO};

fn log_line(level: String, request_id: Int, message: String) -> String ![] {
  format3("[{}] req={} msg={}", AString(level), AInt(request_id), AString(message))
}

fn main() -> Int ![IO] {
  perform IO.println(log_line("INFO", 42, "ok"));
  perform IO.println(log_line("WARN", 43, "slow query"));
  0
}

format(template, args) walks template and substitutes each {} placeholder with the next FormatArg from args. `` escape to literal braces; mismatched arity is forgiving (unfilled {} emits the literal marker {?}, extra args drop). The arity helpers format1format8 build the args list mechanically; the per-type wrappers (format_int, format_string, …) save the constructor ceremony for the common single-arg case. See §13.

E18 — Partial application via a returned closure

import std.int
import std.io
use std.int.{int_to_string};
use std.io.{IO};

fn make_adder(x: Int) -> (Int) -> Int ![] ![] {
  fn (y: Int) -> Int ![] => x + y
}

fn main() -> Int ![IO] {
  let add3: (Int) -> Int ![] = make_adder(3);
  perform IO.println(int_to_string(add3(4)));
  0
}

make_adder’s return type is (Int) -> Int ![] — a pure unary fn type — and make_adder itself is pure, hence the trailing ![] on the fn declaration. The two ![] rows are independent: the inner one belongs to the returned fn type; the outer one to make_adder. See §3.2.1 for the rule.

The lambda fn (y: Int) -> Int ![] => x + y captures x from make_adder’s parameter list. At the call site, make_adder(3) produces a closure that adds 3 to its argument; add3(4) invokes that closure and yields 7.


Reference

§1 — Lexical structure

Sigil source is UTF-8. The parser reads tokens line by line; line endings are not significant beyond delimiting line / column for diagnostics.

Comments. Line comments start with // and run to end of line. There are no block comments in v1.

Identifiers. [A-Za-z_][A-Za-z0-9_]*. Identifiers are case-sensitive. Constructor / type names conventionally start with uppercase; variable / function names with lowercase. The parser enforces no case rule in v1, but the stdlib follows it.

Keywords (reserved). fn, let, match, if, else, true, false, effect, perform, handle, with, return, import, type, as, resumes. Reserved keywords cannot be used as identifiers.

Literals.

§2 — Top-level items

A program is a sequence of top-level items, in any order:

import std.io                                   // import
use std.io.{IO};                                // use (per-symbol opt-in)
type Color = | Red | Green | Blue              // type declaration
effect Counter { tick: () -> Int }              // effect declaration
fn main() -> Int ![IO] { 0 }                   // function

import and use. import std.<name> makes a module addressable from this file (so you can write std.io.IO or std.list.map(...) qualified) but binds no symbols. use mod.{n1, n2, ...}; opts each listed name into the file’s bare namespace. The two declarations are independent: importing without use keeps every reference qualified; a use without the matching import is rejected. See §10 for the full module system, including aliases and conflict resolution.

fn syntax. fn name[generics](params) -> RetType ![Effects] body.

fn main constraints. fn main must take no parameters and return Int. Its effect row may only contain effects discharged by the top-level shim:

Other effects (Random, Clock, Raise[E], State[S], Choose, user-defined) must be handled inside main’s body via handle ... with { ... } or stdlib helpers like run_pseudo_random / run_state / catch. A main-row entry referencing an effect without a top-level handler frame is rejected at typecheck (E0041).

§3 — Type system

§3.1 — Built-in types

Type Description
Int Signed 64-bit integer.
Bool true / false.
String Immutable UTF-8 byte sequence.
Char Boxed Unicode codepoint (TAG_CHAR=0x0C, 21-bit codepoint payload).
Byte 1-byte unsigned integer (0..255).
Unit The single-value type. Literal: ().
Array[A] Immutable indexed collection.
MutArray[A] Mutable indexed collection (Mem-gated).
ByteArray Immutable flat byte buffer.
MutByteArray Mutable flat byte buffer (Mem-gated).
Float Boxed IEEE 754 f64.
Int64 Boxed 64-bit signed integer.
StringBuilder Segmented-rope string accumulator (Mem-gated).
(T1, T2, …) Tuple types of arbitrary arity. Binary tuples have fst/snd accessors in std.pair.
Continuation[OpRet, Ret] First-class single-shot or multi-shot continuation captured from a handler arm’s k. Dynamic-extent enforcement via scope IDs.

User-declared sum types and records form the rest of the type universe (§6).

§3.1.1 — Char and codepoint string operations

Char is Sigil’s first-class Unicode codepoint type — boxed, single Unicode scalar value in 0x000000..=0x10FFFF excluding surrogates 0xD800..=0xDFFF. Literal syntax (§1, expanded):

Form Example Notes
Bare ASCII 'a', '5', ' ' Single ASCII codepoint
Bare multi-byte UTF-8 'é', '中', '😀' Source decoded as UTF-8
Escape '\n', '\t', '\r', '\\', '\'', '\"', '\0' Standard C escapes
Unicode escape '\u{41}', '\u{1F600}' 1–6 hex digits; out-of-range / surrogate rejected at parse

The lexer rejects multi-codepoint bodies ('ab', 'a\u{301}') with a “Char literal must be a single codepoint” diagnostic. Char is exactly one codepoint, never a grapheme cluster.

Operations on Char (all ![]-pure, registered in std.char):

Codepoint-aware string operations (in std.string, documented in std.char):

The byte-indexed (string_byte_at, string_substring, string_index_of) and codepoint-indexed surfaces coexist — choose based on whether the program reasons in terms of bytes or codepoints.

Worked example — count digits
import std.char
import std.list
use std.char.{is_ascii_digit, string_chars};
use std.list.{Cons, List, Nil};

fn count_digits(s: String) -> Int ![] {
  __count_digits(string_chars(s))
}

fn __count_digits(cs: List[Char]) -> Int ![] {
  match cs {
    Nil => 0,
    Cons(c, rest) => match is_ascii_digit(c) {
      true => 1 + __count_digits(rest),
      false => __count_digits(rest),
    },
  }
}

§3.2 — Type expressions

type-expr := identifier
           | identifier "[" type-expr ("," type-expr)* "]"   -- generic instantiation
           | "(" type-expr ("," type-expr)* ")" "->" type-expr "![" effects "]"
                                                              -- function type
           | "(" type-expr ("," type-expr)* ")"              -- tuple type

Function types carry effect rows just like declarations: (Int) -> Int ![] is the type of a pure unary integer function; (String) -> Int ![Raise[String]] is a fallible parser.

Tuple types are written (T1, T2) — parentheses with comma-separated element types. Arity 1 is not a tuple (it’s just a parenthesized type); arity 2+ creates a distinct tuple type.

§3.2.1 — Fn types that return fn types

A fn type that returns another fn type carries two effect rows: the inner returned fn-type’s row, and the outer fn’s own row. The doubled-![..] ![..] form is required because Sigil’s per-arrow effect discipline gives every fn-type its own row.

fn make_adder(x: Int) -> (Int) -> Int ![] ![] {
  //                              ↑     ↑
  //                              │     │ make_adder's own row (outer)
  //                              │ inner returned (Int) -> Int's row
  fn (y: Int) -> Int ![] => x + y
}

Both ![] slots are required — read right-to-left, the trailing ![..] always binds to the nearest preceding -> (the outer fn’s own arrow), and the next ![..] binds to the inner arrow.

A common pattern is to drop the outer row (writing (Int) -> Int ![]); this hits E0010 because the trailing ![..] parses as the inner fn-type’s row, leaving the outer fn without one. Always include both rows when the return type is itself a fn type.

The same rule applies recursively for fn-returning-fn-returning-fn (three rows) and beyond, but those shapes are rare in practice; v1 worked examples and stdlib stay at one level of return-type fn nesting. See E18 below for a complete program.

§3.3 — Effect rows

An effect row is a comma-separated set of effect names enclosed in ![ … ]:

![]                            -- pure
![IO]                          -- can do IO
![IO, Raise[String], Mem]      -- can do all three

Rows are unordered. Two rows are equivalent iff they list the same name set (modulo type arguments for generic effects like Raise[E]).

Row variables. Functions may include a row variable | e in their effect row to express row polymorphism:

fn catch[A, E](body: () -> A ![Raise[E] | e]) -> Result[A, E] ![| e]

fn with_io[A](body: () -> A ![IO | e]) -> A ![IO | e] {
  perform IO.println("start");
  let result: A = body();
  perform IO.println("end");
  result
}

The row variable e captures whatever additional effects are not explicitly listed. Effects performed by the body that aren’t in the explicit list are absorbed by the row variable and resolved at call sites via row unification. Row variables work both in fn-typed parameter positions and in a function’s own declared effect row.

When a fn returns a fn type, both fn types carry their own row — see §3.2.1 for the doubled-row form.

Operator effects to remember. Several built-in operators carry non-empty effect rows that propagate to their enclosing function:

The general rule: if your function body uses anything that carries effects, those effects must appear in your function’s declared row. Sigil has no automatic row inference for declared functions — every effect is explicit.

§3.4 — Inference rules (overview)

Sigil uses Hindley–Milner with explicit annotations. Every let binding requires an explicit type; the inference engine then unifies the body’s type against the annotation. Generic parameters ([A] on functions or types) introduce universally-quantified type variables that instantiate fresh at each call site.

The full inference algorithm follows the standard HM presentation (Damas–Milner with effect rows). Specific diagnostics (E0044, E0042, etc.) point at unification failures with their location and suggested fix.

§4 — Expressions

§4.1 — Expression forms

Form Example
Integer literal 42
Float literal 3.14, 1e10
String literal "hello"
Char literal 'A'
Bool literal true, false
Unit literal ()
Identifier x, length
Function call f(x, y)
Lambda fn (x: Int) -> Int ![] => x + 1
Binary op a + b, a == b, a && b
Unary op -n, !b
If/else if cond { … } else { … }
Match match scrut { p1 => e1, p2 => e2 }
Block { stmt1; stmt2; tail }
Record literal Point { x: 1, y: 2 }
Sum constructor Some(42), Cons(1, Nil)
Tuple literal (1, "hello")
Perform perform Effect.op(args)
Handle handle expr with { return(v) => …, Effect.op(args, k) => … }

§4.2 — Operators

Category Operators Type
Arithmetic +, -, * (Int, Int) -> Int ![]
Arithmetic (traps on zero) /, % (Int, Int) -> Int ![]
Comparison ==, !=, <, <=, >, >= (Int, Int) -> Bool ![]
Logic &&, \|\|, ! (Bool, Bool) -> Bool ![]
String compare string_compare(a, b) (from std.ordering) (String, String) -> Ordering ![]

/ and % trap on a zero divisor — they carry no effect. Division and modulo abort the process on a zero divisor: the runtime writes sigil: arithmetic error: division by zero (resp. remainder by zero) to stderr and exits with status 2. This is a trap, like an out-of-bounds array access or panic — not an effect. A function that divides needs NO ArithError (or any other) entry on its row; fn main() -> Int ![] may contain n % 2 and compiles.

A trap cannot be intercepted by a handle arm. When a zero divisor is a recoverable condition rather than a programming bug, use checked_div / checked_mod from std.int, which pre-check the divisor and return Result[Int, String]:

import std.int
use std.int.{checked_div};
use std.result.{Err, Ok, Result};
fn main() -> Int ![] {
  let r: Result[Int, String] = checked_div(10, 0);  // Err("division by zero")
  0
}

The ArithError effect still exists and can be performed and handled explicitly (perform ArithError.div_by_zero() inside a ![ArithError] row), but the / and % operators no longer perform it.

What about INT_MIN / -1? Not a concern in Sigil. Int is 63-bit signed (range [-2^62, 2^62 - 1]), so the smallest Int divided by -1 is 2^62, which fits in the backing i64 and never trips the hardware overflow trap. Zero divisor is the only arith trap.

§4.3 — Match patterns

pattern := "_"                                          -- wildcard
         | identifier                                    -- binding (matches anything; binds name)
         | integer-literal                               -- exact match
         | bool-literal
         | char-literal
         | constructor-name "(" pattern ("," pattern)* ")"   -- positional constructor
         | constructor-name "{" field-pats "}"           -- record constructor
         | constructor-name                              -- nullary constructor
         | "(" pattern ("," pattern)* ")"                -- tuple destructure

Patterns are matched in source order. Bindings introduced by patterns are scoped to the arm body. Exhaustiveness is checked at compile time (E0066).

§5 — Statements and blocks

A block is { stmt1; stmt2; …; tail }. Statements end in ;; the final expression (no trailing ;) is the block’s value.

{
  let x: Int = 1;
  let y: Int = 2;
  x + y                 // value of the block
}

Two statement forms exist in v1:

There is no shadowing: let x = 1; let x = 2; is a compile error (E0020 — “redefinition of x”).

_ is the discard pattern. Like Rust and Python, _ is a name that binds and immediately discards the value. Multiple let _: T = expr; in the same scope are NOT shadowing — each is an independent discard. The bare-statement form expr; is also available for the same purpose; use whichever reads clearer in context. Lambda params and pattern positions also accept _ as a non-binding wildcard (fn(_, _) -> Int { 0 }, Pair(_, _)).

There is no return statement; the block’s value flows out naturally.

§6 — Sum types, records, and tuples

type Option[A] = | Some(A) | None
type Result[A, E] = | Ok(A) | Err(E)
type Tree[A] = | Leaf | Node(Tree[A], A, Tree[A])
type Point = { x: Int, y: Int }
type Person = { name: String, age: Int }

Sum types. Each | introduces a constructor. Constructors take zero or more positional arguments; nullary constructors omit the parens. Constructors of generic types receive type arguments at the use site (inferred from constructor argument types).

Records. Field declarations are unordered; field access and construction are nominal (the record’s name in the type declaration matters for equivalence).

Tuples. Tuple types are built-in — no type declaration needed. (Int, String) is a binary tuple; (Bool, Int, String) is a ternary tuple. Tuple values are constructed with (e1, e2, ...) and destructured via match:

let pair: (Int, String) = (42, "hello");
match pair { (n, s) => perform IO.println(s) };

Binary tuples have fst[A, B] and snd[A, B] accessors in std.pair. Larger tuples use match destructuring.

Tuple arity is limited to 31 elements (architectural: the heap header carries a 32-bit pointer bitmap, with one bit reserved). Use records or nested tuples for wider structures.

§7 — Pattern matching

See E3, E4 for examples. The match expression evaluates the scrutinee once and dispatches to the first arm whose pattern matches:

match expr {
  pattern1 => arm_body1,
  pattern2 => arm_body2,
  _ => fallback,
}

Each arm body has the same type (unified by the checker). The match’s overall type is that unified arm-body type.

§8 — Algebraic effects and handlers

§8.1 — Declaring effects

effect Raise[E] {
  fail[A]: (E) -> A,
}

effect State[S] resumes: many {
  get: () -> S,
  set: (S) -> S,
}

effect Logger {
  log: (String) -> Unit,
}

Each effect declares zero or more operations; each op is a typed function declaration without a body. Effects may be generic (Raise[E], State[S]); type parameters follow the effect name in […] brackets.

The optional resumes: many annotation marks a multi-shot effect (the op’s continuation k may be invoked more than once per arm activation). Default is single-shot.

In v1 only the builtin Mem effect has zero ops (it’s a marker).

Generic effect declarations

Effects may take type parameters bound at the effect-decl level. The parameter binds across every op signature in the effect:

effect Raise[E] {
  fail[A]: (E) -> A,
}

Here E is the effect-decl parameter — the error type — and is substituted at the row site (![Raise[String]], ![Raise[ParseError]], etc.). Row-arity mismatches at the row site fire E0143 (see §11).

The canonical example is std/raise.sigil. The full file shape:

effect Raise[E] {
  fail[A]: (E) -> A,
}

fn raise[A, E](e: E) -> A ![Raise[E]] {
  perform Raise.fail(e)
}
Per-op generic parameters

An op may carry its own generic parameters in op_name[…]: … form. These are bound at the op’s scheme and instantiated fresh at each perform site — the canonical “never returns” idiom:

fail[A]: (E) -> A,

A here is unconstrained; it unifies with the surrounding context’s expected type at each perform Raise.fail(...) call. At runtime the discharging handler arm discards the continuation, so fail never returns — the per-op A is a typing convenience that lets the perform site appear in any return-type position.

Per-op generic parameters must not shadow the enclosing effect-decl’s parameters; doing so fires E0144 (see §11).

Reserved effect names

The following effect names are reserved by the standard library; declaring effect <name> { … } for any of them is a compile-time error (E0136 — duplicate effect declaration):

ArithError, IO, Mem, Env, Fs, Process, Random, Clock, Raise[E], State[S], Choose.

The first six (ArithError, IO, Mem, Env, Fs, Process) are builtin effects — synthesized at typecheck pre-pass with fixed effect IDs (ArithError = 0, IO = 1, Mem = 2, Env = 3, Fs = 4, Process = 5). They appear in every program’s effect- id table whether the program uses them or not. The remaining names are user-stdlib effects defined in std/<name>.sigil; redeclaring them collides at typecheck unless the user code shadows the import.

User effects with novel names (Cfg, Network, Audit, etc.) remain free.

§8.2 — Performing effects

perform Effect.op(args)

The result is the value the active handler resumes with (for single-shot ops) or short-circuits to (for discharging arms).

§8.3 — Handling effects

handle body with {
  return(v) => …,                  -- optional return arm
  Effect.op(args, k) => …,          -- op arm; k is the continuation
}

The body runs to completion or until a matching perform. For each op arm, args are the perform’s arguments; k is a first-class single-argument continuation that, when called, resumes the body from the perform site.

In a single-shot handler, k is invoked at most once per arm activation (typically exactly once for resumption, or zero times for discard / short-circuit).

In a multi-shot handler (effect E resumes: many), k may be invoked multiple times per arm — but in v1 the arm body must follow a static N-let-chain shape:

Effect.op(arg, k) => {
  let r1: T = k(arg1);
  let r2: T = k(arg2);
  …
  let rN: T = k(argN);
  combine(r1, r2, …, rN)            -- pure tail
}

N is fixed at compile time; runtime-N variations use first-class continuations (see §8.5).

Per-resume execution semantics. Each k(arg_i) invocation runs the body’s post-perform tail independently with the let-binding of the perform site bound to arg_i. Observable side effects in the body’s post-perform tail fire in resume order, once per k(arg_i) call. The pure return value produced by the body’s tail under substitution let-binding := arg_i is bound to r_i. After all N resumes complete, the arm’s combine(r_1, …, r_N) runs once with the per-resume r_i values in scope.

That is: for a body of shape let x: T = perform E.op(); rest_using_x, each k(arg_i) is observationally equivalent to running rest_using_x with x = arg_i. If rest_using_x performs further effects, those effects fire per resume in their original source order.

Worked example — per-resume IO ordering
import std.int
import std.io
use std.int.{int_to_string};
use std.io.{IO};

effect Choose resumes: many { choose: (Int) -> Int }

fn helper(seed: Int) -> Int ![Choose, IO] {
  let x: Int = perform Choose.choose(seed);
  perform IO.println(int_to_string(x));   -- post-perform observable
  x * 1000
}

fn main() -> Int ![IO] {
  let total: Int = handle helper(5) with {
    Choose.choose(arg, k) => {
      let r1: Int = k(7);
      let r2: Int = k(11);
      r1 * 100 + r2
    },
  };
  perform IO.println(int_to_string(total));
  0
}

Stdout:

7
11
711000

Trace: k(7) runs the body’s post-perform tail with x = 7 — prints 7, returns 7 * 1000 = 7000 so r1 = 7000. k(11) runs the same tail with x = 11 — prints 11, returns 11 * 1000 = 11000 so r2 = 11000. The arm’s combine r1 * 100 + r2 = 700000 + 11000 = 711000 runs once. The outer IO.println(int_to_string(total)) prints 711000.

This shape generalizes to any resumes: many effect. std.choose’s all_choices (see §13) is layered on top of this primitive — when the bodies passed to all_choices contain side effects, the per-resume IO ordering specified here is what the caller observes.

Per-resume execution applies regardless of whether k is invoked unconditionally on every iteration of the let-chain or conditionally inside an if/match (see the Conditional k-call subsection below). Branches that don’t invoke k contribute no per-resume side effects.

Row-polymorphic handlers

A discharging handler may be row-polymorphic in the body’s residual effects — the handler discharges the named effect and forwards everything else through to its caller’s row. The canonical example is std/raise.sigil’s catch:

fn catch[A, E](
  body: () -> A ![Raise[E] | e]
) -> Result[A, E] ![| e] {
  handle body() with {
    return(v) => Ok(v),
    Raise.fail(err, k) => Err(err),
  }
}

The signature reads:

A row variable referenced anywhere in a function’s signature (![Effect | e], ![| e], or in a fn-typed parameter’s row) must be introduced by the same | <name> tail somewhere in that function’s signature. An unreferenced row variable is rejected at typecheck with a fix-suggestion pointing at the missing declaration.

Eligible body shapes for v1. The compiler classifies the helper fn’s body into one of several supported Cps-ABI shapes that implement per-resume execution. The chained-let-yield shape covers let x_0 = perform p_0; ...; let x_N = perform p_N; tail_expr and its mid-body-discard variant let x_0 = perform p_0; perform p_1; …; tail_expr (mid-body Stmt::Perform is normalized to a discarded let by the codegen pass; the compiler also inlines elaborator-lifted ANF intermediates so impure-but-non-yielding perform args like int_to_string(x*10+b) don’t prevent classification).

tail_expr is the block’s final expression (no trailing ;). It may be:

Per-resume execution is preserved through the branched expression when the if/match is in tail position OR when it appears as a statement immediately before a non-perform tail (e.g., let a = perform p; match cond { true => perform q, false => () }; 0). The codegen automatically lifts the trailing branched statement into tail position, wrapping each arm body to evaluate as a statement and yield the original tail value. Both shapes compose through the chain machinery identically.

A tail_expr may also be a call to a separate Cps-colored function (e.g., let a = perform p; let b = perform p; helper(a, b) where helper itself performs effects). Per-resume execution composes correctly: each resume re-invokes helper with that resume’s arguments, and the helper’s effects fire per resume in order.

Conditional k-call. Handler arm bodies may use if/else and match to conditionally invoke k:

Effect.op(arg, k) => {
  if arg > 0 {
    k(arg)
  } else {
    0    -- discard k, short-circuit
  }
}

§8.4 — Effect row inference

When a function calls another function whose row contains effect E, the caller’s row must contain E (or discharge it via a handle). Row inference is structural — the checker computes the union of effects performed by the body and unifies it against the declared row. Mismatches fire E0042.

§8.5 — First-class continuations

The continuation k in a handler arm can be bound to a variable of type Continuation[OpRet, Ret]:

effect Step resumes: many {
  step: (Int) -> Int,
}

handle body() with {
  Step.step(n, k) => {
    let f: Continuation[Int, Int] = k;
    f(n + 1)
  },
}
Type parameters

Continuation[OpRet, Ret] is parameterized by:

For effect Step resumes: many { step: (Int) -> Int } handled by an arm whose body returns Int, the continuation is Continuation[Int, Int].

Syntactic sugar — desugared at codegen

The let f: Continuation[OpRet, Ret] = k; … f(arg) … annotation is a typing convenience. The compiler does not allocate a separate continuation object: the annotation passes type-checking, and the codegen pass desugars f to direct references to k in the arm body. There is no runtime indirection through f.

This means an arm body’s reference count to k is the sum of all references through any aliases. Multi-shot accounting (and the single-shot E0220 invocation check) sees both f(...) and k(...) as the same continuation invocation.

Dynamic-extent enforcement (E0145)

Continuations cannot be invoked after their handler frame exits. Returning k from an arm body, storing it in a persistent data structure that outlives the handle, or otherwise letting the continuation reference escape the dynamic extent of its handler fires E0145 (see §11).

The escape barrier is enforced statically by the typecheck pass; the diagnostic points at the escape site (the return, field-store, or let outside the handler) and references the handler’s handle keyword as the lifetime boundary.

This rules out call/cc-style first-class continuations that survive their original handler. Within the dynamic extent, however, k is fully first-class: it can be passed to helper functions, captured in arm-internal closures, and (for multi-shot effects) invoked multiple times — see all_choices in std/choose.sigil for the canonical recursive-helper-driven runtime-N enumeration pattern.

Lambda-of-state (Plotkin-style) handler encoding. Handler arms can return closures that capture k without calling it immediately. The standard library’s std/state.sigil provides the canonical cell-backed run_state encoding. An alternative is the Plotkin-style lambda-of-state encoding, which threads state through closures:

effect State resumes: many { get: () -> Int, set: (Int) -> Int }

fn run_state(initial: Int, comp: () -> Int ![State]) -> Int ![] {
  let runner: (Int) -> Int ![] = handle comp() with {
    return(v) => fn (s: Int) -> Int ![] => s,
    State.get(k) => fn (s: Int) -> Int ![] => k(s)(s),
    State.set(s2, k) => fn (_s: Int) -> Int ![] => k(s2)(s2),
  };
  runner(initial)
}

Each handler arm returns fn (s: Int) -> ... — a closure that receives the current state and threads it through k. The get arm passes s as both the resume value and the next state; the set arm replaces the state with s2. This variant’s return arm returns final state s (discarding the body value v); the canonical Plotkin encoding returns (v, s) or just v depending on context. run_state(0, comp) applies the resulting state-threaded closure to the initial state 0.

This encoding works with sum-type match bodies where the pattern dispatch contains multiple perform sites per arm. The example above mirrors the e2e test lambda_of_state_sum_type_state_threading_- returns_5 in compiler/tests/e2e.rs.

§9 — Mem and mutation

Mem is the builtin marker effect that gates all in-place mutation. Operations:

Op Type
mut_array_* MutArray[A] constructors / accessors / setters.
mut_byte_array_* MutByteArray constructors / accessors / setters.
sb_new / sb_append / sb_finalize StringBuilder.

A function declaring ![Mem] may mutate. A pure function (![]) cannot. There is no per-region or per-value isolation in v1; Mem is a single global capability.

§10 — Modules and imports

Sigil programs may span a single file or multiple .sigil files. Sigil’s stdlib lives in std/. Both stdlib and user-written modules are reached via the same two declarations: an import line that makes a module addressable, and a use line that opts specific symbols into the file’s bare namespace.

import std.option              // make `std.option` addressable
import std.list                // make `std.list` addressable
use std.option.{None, Option, Some};
use std.list.{Cons, List, Nil, map, range};

import std.<name> makes the module addressable from this file but binds no symbols — by itself it lets you write std.option.map(opt, f) (qualified) but not map(opt, f) (bare). use mod.{n1, n2, ...}; brings each listed name into the file’s bare namespace, so Some(7) and map(opt, f) work without a prefix.

Imports are strict by design — Plan F1 (2026-05-14) removed the old auto-prelude of stdlib types. The globally-available names are:

The compiler maintains the authoritative registrations of the builtin type names and the intrinsic-function surface; the bullets above mirror them.

Intrinsics vs. stdlib source. The prelude covers compiler intrinsics only. Stdlib source functions — written in .sigil, living in modules, and able to share names across modules (map in std.list vs std.option) — are NOT in the prelude. Every such name — Option, Result, Some, None, Ok, Err, List, Cons, Nil, and source fns like map, unwrap_or, read_dir, the safe string_to_int/string_to_float parse wrappers, etc. — must come from an import + use line, or be qualified at the call site. The line is exactly intrinsic-vs-source: a name with one possible meaning is global; a name you pick a module for needs the use.

The prelude obeys the no-shadowing rule (see §9’s “There is no shadowing”):

Two intrinsic categories are intentionally NOT preluded and still require a use: the low-level *_validate / *_parse / *_alloc parsing-and-allocation halves (use the safe Result-returning wrappers in std.string, std.float, and std.byte_array instead) and the internal Ref cell ops (use the std.state API).

Module aliases. import accepts an as alias for shorter qualified-call paths:

import std.option as O;
// ...
let m: O.Option[Int] = O.map(O.Some(7), fn (x: Int) -> Int ![] => x + 1);

The alias O only applies to qualified-path call sites (O.map(opt, f), O.Some(7)); it is not itself a binding and cannot appear on a use line. Module aliases work for both stdlib and user modules. To use names bare in this file, add use std.option.{Option, Some, map}; alongside the aliased import.

Aliasing in use. Two modules may export the same bare name (e.g., map is in std.list, std.option, and std.result). Listing both in use lines is rejected with E0147 — fix by aliasing one:

use std.list.{map};
use std.option.{map as option_map};

Wildcards rejected. use mod.*; fires E0034 — wildcards would re-introduce the cross-module bare-name ambiguity (E0147) that the qualified-imports design closes. Listing names explicitly keeps each file self-describing: a reader sees in one place which symbols are in scope.

Empty binding lists rejected. use mod.{}; fires E0035 — an empty list is dead code with no effect (no symbols opted in, no diagnostic value, almost certainly a typo for the intended binding list). Either delete the use line or fill in the symbols it should bring bare.

Documentation-only modules. Six stdlib modules ship as header-only files — every name they export is registered as a compiler builtin, with no source-level fn declarations: std.io, std.mem, std.int64, std.string_builder, std.char, std.panic. Their import line is a no-op at the resolver (kept for documentary clarity); the matching use line still opts the names in (use std.io.{IO};, etc.).

Several other modules — std.int, std.float, std.array, std.mut_array, std.byte_array, std.mut_byte_array — are mixed: they ship some compiler-intrinsic entries alongside source-level fn declarations. The two categories now differ from the user’s perspective: the intrinsic entries are in the prelude (callable bare, no import / use), while the source-level fns require a use like any other stdlib symbol. Importing the module and use-ing a name always works for both; the intrinsics simply also work without it.

Resolver semantics. Three resolution paths cover every Ident that names a top-level fn or type:

  1. Qualified path. std.list.map(xs, f) — the resolver walks the dotted prefix against the file’s import / alias table.
  2. Bare via use-binding. map(xs, f) after use std.list.{map}; — the bare ident resolves to the (module, source_name) pair the binding records.
  3. Intra-file fall-through. A bare reference inside a fn’s body to another fn declared in the same file resolves to that fn’s canonical key without needing a use line for itself (the recursive map(t, f) inside std.list’s fn map is the archetype).

A bare reference that matches none of the three is rejected with E0046 (unknown identifier). When the missing name is in the stdlib, the diagnostic attaches a hint pointing at the import std.<module> line that brings the declaring module into scope. v1’s hint generator points at the import only; if the file already has the import, the user still needs to add a matching use std.<module>.{name}; line — that step isn’t suggested by the hint.

Migration. The migration script scripts/migrate-to-qualified-imports.mjs walks every .sigil file in a project and adds the use lines the strict resolver expects, derived from the file’s bare-name references. It also processes inline-Sigil string literals inside Rust test sources.

It is idempotent on standalone .sigil sources. Inline fragments interpolated into Rust format!() macros via {placeholder} (the helper-fragment pattern in compiler/tests/e2e.rs) are a known carve-out: such fragments get hand-added use lines on the host test source to cover the fragment’s bare names, and re-running the script on those host sources will rewrite (re-strip + re-emit) those manual use lines. PR #173’s N1 finding documents the specific case (int_list_print_helper and its 5 host tests).

User-module imports

A program may be split across multiple .sigil files. User modules use the same import / use syntax as the standard library:

import app.parser        // make user module addressable
import app.lexer         // another user module
use app.parser.{parse};  // opt parse into bare namespace
use app.lexer.{scan};    // opt scan into bare namespace

fn main() -> Int ![] {
  let tokens = scan(input);
  let ast = parse(tokens);
  0
}

Root-anchored location-independent resolution. The root is the directory containing the entry file handed to the compiler (the file defining main). Every user import resolves against that fixed root, with dotted segments mapping to path segments:

The rule is identical from every file in the program — a given import means the same file regardless of which file writes it. The importing file’s location never affects resolution. (The std. prefix is unchanged: it continues to resolve from the stdlib embedded in the compiler, and always wins for that prefix.)

Filesystem is the module tree. There is no manifest and no module-declaration step. A module exists iff its .sigil file exists at the resolved path. Authors add a module by creating a file; nothing registers it.

Qualified vs. bare names. Qualified calls (app.parser.parse(...)) always work and are the recommended form — they are self-documenting (the origin is in the call site) and avoid collision. Bare names (parse(...) after use app.parser.{parse};) are ergonomic and valid as long as the name is unambiguous across the file’s imports. When a bare name is ambiguous — e.g., both app.parser and helper.parser export a parse function — the second use line is rejected with E0147 (duplicate bare name). Fix by qualifying the call or by aliasing one of the imports:

use app.parser.{parse as app_parse};
use helper.parser.{parse as helper_parse};
// ... now app_parse() and helper_parse() are unambiguous

Error diagnostics. User-module errors are designed to close the edit loop:

Third-party libraries via vendoring. Third-party code requires zero additional language machinery — it is vendored source that lives in the project tree and imports through the identical root-anchored rule. There is deliberately no package manager, registry, version resolver, lockfile, or network fetch.

By convention, vendored third-party source lives under a deps.- prefixed root:

import deps.json5.parse      // → <root>/deps/json5/parse.sigil
import deps.http.client      // → <root>/deps/http/client.sigil
use deps.json5.{parse};
use deps.http.client.{get};

The deps. prefix is a recommended layout convention, not a special language construct — it resolves by the ordinary root-anchored rule. Provenance-in-the-name tells an LLM author at the call site that the code is external, the same way qualified calls put origin in the call. This keeps programs self-contained, byte-reproducible, and offline.

Generics across modules. A generic function defined in one user module and instantiated from another monomorphizes into the single compilation unit, exactly as cross-module stdlib generics do today. There are no separate or precompiled module artifacts.

§11 — Diagnostics

Compiler errors are emitted as JSONL on stderr by default:

{"level":"error","code":"E0044","file":"x.sigil","line":3,"column":12,
 "end_line":3,"end_column":18,"message":"…","hint":"…"}

--human-errors switches to human-readable text. Each error code (E0001+) has a stable catalog entry accessible via:

sigil explain E0042

Common codes:

Code Meaning
E0010 parser syntax error
E0032 missing user module
E0033 module cycle detected
E0042 effect not in row
E0044 type mismatch
E0066 non-exhaustive match
E0113 duplicate type declaration
E0147 duplicate bare name across imports (qualification required)

Recent additions (Plan D + state-cell):

Code Meaning Plan
E0117 pattern shape does not match scrutinee type Plan D Task 113 (tuples)
E0143 row-site effect-arg arity does not match the effect-decl’s generic-param count Plan D Task 114
E0144 per-op generic parameter shadows an effect-decl generic parameter Plan D Task 115
E0145 continuation k cannot escape its handle’s arm body Plan D Task 117
E0148 runtime cell op called outside std/state.sigil State-cell
E0149 perform inside statement position would silently miscompile in multi-shot context PR #127 follow-up
E0220 one-shot continuation used more than once on a code path Plan B Task 54

Full catalog: see compiler/src/errors/catalog.rs.

§12 — Runtime model

§12.1 — Tail-call optimization

Every direct user-fn call in tail position with a Cranelift signature exactly matching the surrounding fn’s signature is lowered to Cranelift’s return_call instruction — a native tail-jump that deallocates the current stack frame before transferring control to the callee. Programs may rely on this for unbounded recursion in the shapes listed below; tail calls whose signatures don’t match (cross-arity, cross-return- type) fall back to a non-tail call (one stack frame per call) and are depth-bounded by the host thread’s stack size.

A call is in tail position when it appears as:

A call is not in tail position when it appears as:

Tail-call optimization covers:

Tail-call optimization does not apply to:

Regression tests in compiler/tests/e2e.rs pin the guarantee at depth 10,000,000 for all covered shapes (Sync self, Sync mutual, let-block tail, if-arm tail, match-arm tail with literal-pattern arms, Mem-effect-row body, Cps-colored chained-let-yield with tail recursion, Cps→Cps under nested non-identity-k handler, and indirect-call mutual tail-recursion through fn-typed bindings). See done/2026-05-07-01-sigil-tco-verify.md for the diagnostic-first plan and the [DEVIATION Task TCO-4 ...] entries in PLAN_C_DEVIATIONS.md for the architectural walk through all three TCO mechanisms (Sync return_call, Cps→Cps NextStep::Call return, and indirect-call return_call_indirect).

§12.2 — Auto-CPS-promotion of non-tail recursion

Tail-position recursion is depth-unbounded via §12.1’s return_call TCO. The non-tail case (e.g., sum_to(n) = n + sum_to(n - 1), or mutual f → g → f where every leg has a non-tail call) has no native tail-call lowering to apply — the surrounding operator consumes the call’s value, so each recursive call leaves a host stack frame behind. Left as Sync, such a fn segfaults at depth ~100k–1M depending on local-frame size.

Sigil auto-promotes any user function with at least one non-tail call to another fn in its strongly-connected component to Color::Cps at color-analysis time. Same-SCC membership covers uniformly:

The CPS trampoline (sigil_run_loop) then handles recursion depth without growing the host stack, at the cost of ~5–10× per-call overhead vs the Sync calling convention.

The promotion fires automatically — no annotation needed — and emits an info-level diagnostic at each promoted function’s declaration:

info[W0001]: function `sum_to` was auto-promoted to CPS due to
non-tail self-recursion at sum_to.sigil:2:21.
  --> sum_to.sigil:2:1
  = hint: rewrite as tail-recursive (accumulator pattern) or as an
          iterative loop to recover Sync performance; the CPS
          trampoline gives this function unbounded recursion depth
          at ~5-10× per-call overhead.

For mutual recursion, the message reads non-tail mutually-recursive call to `` and the diagnostic fires once per member. `sigil explain W0001` prints the long-form rationale + the canonical accumulator-pattern fix example.

Performance escape. To recover Sync performance, rewrite the recursion as tail-recursive (accumulator pattern). The promotion checks for any non-tail intra-SCC call after color analysis runs, so a fn whose entire recursive surface is in tail position falls back to Native:

// Auto-promoted to CPS (sum_to has a non-tail self-call):
fn sum_to(n: Int) -> Int ![] {
  if n <= 0 { 0 } else { n + sum_to(n - 1) }
}

// Stays Native (accumulator pattern, tail-recursive):
fn sum_to_acc(n: Int, acc: Int) -> Int ![] {
  if n <= 0 { acc } else { sum_to_acc(n - 1, acc + n) }
}

For mutual recursion the same fix shape applies — thread an accumulator through both legs so every recursive call lands in tail position. §12.1’s cross-fn return_call TCO then handles unbounded depth at zero per-call overhead.

The diagnostic is info, not warning — the auto-promotion is correct by default; the program now works at arbitrary depth. Surfacing the transformation lets the author opt for the tail-recursive rewrite only when the per-call overhead matters.

Coverage. Auto-promotion handles arbitrary non-tail recursive shapes via chained continuations:

The one shape that stays Sync: a recursive arm containing a genuine non-recursive function call (a real foo(x) call, as opposed to a constructor application or a recursive call), since the residual evaluator can only lower arithmetic, comparisons, identifiers, and constructor applications. Such a function keeps the Sync calling convention and remains depth-bounded by the host stack.

§13 — Stdlib reference

Each module is documented in its own std/<name>.sigil source file with // @example blocks demonstrating idiomatic use. The files are the authoritative API reference.

Module Surface
std.option Option[A], Some(A), None + helpers map, and_then, unwrap_or. Add import std.option + use std.option.{None, Option, Some} (plus any helpers used).
std.result Result[A, E], Ok(A), Err(E) + helpers map, map_err, and_then. Add import std.result + use std.result.{Err, Ok, Result} (plus any helpers used).
std.list List[A], length, map, filter, fold, reverse, append, range, list_sort (stable functional merge sort, comparator-driven, (T, T) -> Ordering), per-type wrappers list_sort_int, list_sort_string, list_sort_char, list_sort_float.
std.ordering Ordering = \| Less \| Equal \| Greater plus per-type comparators int_compare, string_compare, char_compare, bool_compare, float_compare, int64_compare. string_compare is the canonical string comparator (returns Ordering) — the legacy Int-returning builtin was retired in this addendum. float_compare uses total-order NaN semantics: NaN == NaN, NaN < non-NaN, non-NaN > NaN.
std.map Persistent ordered Map[K, V] (AA tree, O(log n) lookup / insert / remove). map_empty(cmp), map_size, map_is_empty, map_get, map_contains, map_insert, map_remove, map_keys, map_values, map_to_list, map_from_list, map_fold, map_map, map_filter. Convenience constructors map_int_keys, map_string_keys, map_char_keys thread the matching std.ordering comparator. Iteration order is sorted ascending by key.
std.set Persistent ordered Set[T] layered over Map[T, Unit] (same AA-tree O(log n) lookup / insert / remove). set_empty(cmp), set_size, set_is_empty, set_contains, set_insert, set_remove, set_to_list, set_from_list, set_fold, set_filter. Set-theoretic operations (set_union, set_intersect, set_difference, set_subset, set_eq) use the left operand’s comparator — when a and b were built with semantically-different comparators, the result is well-defined (carries a’s ordering) but may surprise. Convenience constructors set_int, set_string, set_char. Iteration order is sorted ascending. Persistent semantics match Map: every op returns a fresh Set[T]; inputs are unchanged.
std.array Array[A], array_alloc, array_empty, array_length. Panic-on-OOB accessors: array_get(arr, i) -> A, array_set(arr, i, val) -> Array[A] (returns fresh). Pure-Sigil canonical safe accessors: array_get_opt(arr, i) -> Option[A], array_set_opt(arr, i, val) -> Option[Array[A]].
std.mut_array MutArray[A] (Mem-gated). Builtins: mut_array_new, mut_array_length, mut_array_get (panic-on-OOB), mut_array_set (panic-on-OOB). Pure-Sigil canonical safe accessors: mut_array_get_opt(arr, i) -> Option[A] ![Mem], mut_array_set_opt(arr, i, v) -> Option[Unit] ![Mem].
std.byte_array ByteArray + core builtins (byte_array_alloc, byte_array_empty, byte_array_length, byte_array_get, byte_array_concat, byte_array_slice, string_to_bytes, string_from_bytes_validate, string_from_bytes_alloc, byte_in_range, byte_truncate, byte_to_int). Pure-Sigil canonical wrappers: string_from_bytes(ba) -> Option[String], byte_from_int(n) -> Option[Byte], byte_array_get_opt(ba, i) -> Option[Byte], byte_array_slice_opt(ba, s, e) -> Option[ByteArray]. The low-level validate/_parse and panic-on-OOB primitives remain available; prefer the Option-returning wrappers as the canonical surface.
std.mut_byte_array MutByteArray (Mem-gated). Builtins: mut_byte_array_new, mut_byte_array_length, mut_byte_array_get (panic-on-OOB), mut_byte_array_set (panic-on-OOB). Pure-Sigil canonical safe accessors: mut_byte_array_get_opt(ba, i) -> Option[Byte] ![Mem], mut_byte_array_set_opt(ba, i, v) -> Option[Unit] ![Mem].
std.string Byte-indexed: string_concat, string_substring, string_byte_at, string_starts_with, string_ends_with, string_contains, string_index_of, string_trim, string_length. Codepoint-indexed: string_chars, string_char_at, string_from_chars. Pure-Sigil helpers: string_split, string_replace. Safe accessors: string_byte_at_opt(s, i) -> Option[Byte], string_substring_opt(s, st, e) -> Option[String]. Decimal-integer parse: string_to_int(s) -> Result[Int, ParseError] (ParseError = \| Empty \| NonDecimal \| Overflow); low-level string_to_int_validate / string_to_int_parse remain. Lexicographic compare is string_compare from std.ordering.
std.char Boxed Char (TAG_CHAR): equality / ordering (char_eq/lt/le/gt/ge), conversion (char_to_int, int_to_charOption[Char], char_to_string), ASCII classifiers (is_ascii, is_ascii_digit, is_ascii_alpha, is_ascii_alphanumeric, is_ascii_whitespace), ASCII case (to_lower_ascii, to_upper_ascii). See §3.1.1.
std.float Boxed Float (IEEE 754 f64): arithmetic (float_add/sub/mul/div/neg), comparison (float_eq/lt/le/gt/ge; NaN≠NaN), math (float_abs/floor/ceil/sqrt), conversion (float_from_int/float_to_int/float_to_string). Decimal-float parse: string_to_float(s) -> Option[Float] is the canonical surface (single failure mode — IEEE 754 represents overflow as ±Inf, so Result+ParseError would be over-engineered); low-level string_to_float_validate (Int code) / string_to_float_parse builtins remain. float_to_string always includes .0 for whole numbers; inf/NaN unchanged.
std.int64 Boxed Int64 with arithmetic, comparison, conversion, stringify.
std.string_builder StringBuilder rope (Mem-gated).
std.format Format-string output. FormatArg = \| AInt \| AInt64 \| AFloat \| AString \| ABool \| AChar. General entry format(template, args: List[FormatArg]) -> String ![]; arity helpers format1..format8 build the args list mechanically; per-type wrappers (format_int, format_int64, format_string, format_float, format_bool, format_char) save the AInt(...) ceremony for single-arg cases. Template syntax: {} is a positional placeholder, `` escape literal braces. Mismatched arity is forgiving (unfilled {} emits the literal marker {?}, extra args drop). The walker is mutually tail-recursive (TCO’d per §12.1), so stack depth is O(1) regardless of template length. The accumulator is a concat chain (format is ![], so StringBuilder is unavailable — sb_* ops gate on Mem); each concat allocates a fresh String, so total work is O(L²) in output length L. Suitable for short-to-medium strings (log lines, error messages, ≤ a few KB); for large outputs (rendering a 100 KB document via repeated format) prefer StringBuilder directly under ![Mem]. No format specifiers, named args, or positional indices in v1 — see §14.1.
std.pair fst[A, B], snd[A, B] accessors for binary tuples (A, B).
std.io IO effect: print, println, read_line. (File ops moved to std.fs.)
std.env Env effect: env_args() -> List[String], env_var(name) -> Option[String], env_vars() -> List[(String, String)]. The effect-prefixed naming matches random_int / clock_now and avoids shadowing the very common parameter name args.
std.fs Fs effect + FsError sum type. Predicates: exists, is_file, is_dirBool. Fallible ops: read_file, write_file, read_dir, mkdir, remove_file, remove_dir, file_sizeResult[T, FsError]. FsError = \| NotFound \| PermissionDenied \| AlreadyExists \| NotADirectory \| IsADirectory \| InvalidUtf8 \| Other(String).
std.process Process effect + ProcessError sum type. run(cmd, args: Array[String]) -> Result[(Int, String, String), ProcessError] — direct exec (no shell), captures stdout / stderr after wait. run_list(cmd, args: List[String]) — same surface with the more idiomatic List[String] argv shape; converts internally and forwards to run. ProcessError = \| NotFound \| PermissionDenied \| Other(String).
std.mem Mem marker effect.
std.random Random effect + run_pseudo_random (process-global xorshift64) + run_seeded_random (deterministic xorshift64 from an Int64 seed). Not cryptographically secure.
std.clock Clock effect + run_os_clock (wall-clock nanos) + run_frozen_clock (fixed Int64 timestamp for test determinism).
std.raise Raise[E] effect (generic over error type) + raise[A, E](e: E) -> A ![Raise[E]] + catch[A, E](body) -> Result[A, E] ![| e] (row-polymorphic residual).
std.state State[S] effect (generic over state type) + run_state[A, S](initial, body) -> (A, S) ![]. Backed by a runtime mutable cell (Ref[S]) — run_state allocates a cell on entry, threads State arms’ get / set resumes through it, and reads the final state out at exit. The cell-backed encoding composes cleanly with Raise[E] in either nesting order; the prior Plotkin lambda-encoding had a Sync-ABI gap that surfaced as SIGSEGV on catch(run_state(... raise ...)). Ref[T] is internal scaffolding: the typechecker rejects calls to sigil_ref_alloc / sigil_ref_deref / sigil_ref_set from outside std/state.sigil (E0148).
std.choose Choose resumes: many effect + all_choices[A](body) -> List[A] (enumerate all branches) + first_choice[A](body) -> Option[A] (find first non-failing branch). Both use first-class continuations for runtime-N enumeration. Per §8.3 per-resume semantics: when body performs observable effects (e.g., IO) after a Choose.choose perform, those effects fire once per branch in resume order.
std.panic Doc-only header for the panic / assert builtins (see §13.2.1). Importing it is a no-op — both names are available without import.

§13.1 — Comparator-mixing in Set operations

The binary set-theoretic operations on std.setset_union, set_intersect, set_difference, set_subset, set_eq — use the left operand’s comparator for the result, but the right operand’s comparator for membership tests inside the predicate (via set_contains(b, x)). When a and b were built with the same comparator, the asymmetry is invisible. When the comparators differ on the same T, results are still well-defined but depend on which side performs which role.

Concrete example: case-sensitive vs case-insensitive string sets.

import std.int
import std.io
import std.set
use std.int.{int_to_string};
use std.io.{IO};
use std.set.{Set, set_empty, set_insert, set_intersect, set_size, set_string, set_subset};

// `string_compare_ci(x, y)` would be a case-insensitive variant
// (not shipped in v1; user-defined). For the purposes of the
// example, treat it as comparing "foo" and "Foo" as equal.

fn main() -> Int ![IO] {
  let cs: Set[String] = set_string();                 // case-sensitive
  let ci: Set[String] = set_empty(string_compare_ci); // case-insensitive
  let a: Set[String] = set_insert(set_insert(cs, "foo"), "Foo");
  let b: Set[String] = set_insert(ci, "foo");

  // set_intersect(a, b): keeps every a-element that b "contains".
  // Under b's case-insensitive comparator, b contains both "foo"
  // and "Foo". Result: {"foo", "Foo"} ordered by a's case-sensitive
  // comparator. Size 2.
  perform IO.println(int_to_string(set_size(set_intersect(a, b))));

  // set_subset(a, b): every a-element matches in b under b's
  // comparator. "foo" → match. "Foo" → match (case-insensitive).
  // Result: true, even though `a` has "more" elements under its
  // own comparator.
  perform IO.println(match set_subset(a, b) { true => "yes", false => "no" });
  0
}

The two-step semantics is consistent: the result’s downstream lookups use a’s comparator (the result’s stored comparator), but the construction-time membership decision used b’s. For LLM-authored code, the safe rule is always pass sets built with the same comparator; mix only when you have a specific reason and have walked through the asymmetry above.

§13.2 — Builtin primitives (not in stdlib modules)

These functions are compiler intrinsics in the prelude — available without any import (see §10’s prelude rules). This table covers the primitives with no conceptual stdlib module; the numeric/char/string/ array intrinsics live under their conceptual module in §13’s tables and are equally prelude-callable.

Function Type Description
int_to_string(n) (Int) -> String ![] Decimal string from Int.
int_xor(a, b) (Int, Int) -> Int ![] Bitwise XOR.
int_shl(a, b) (Int, Int) -> Int ![] Left shift. b masked to 6 bits.
int_shr(a, b) (Int, Int) -> Int ![] Arithmetic right shift. b masked to 6 bits. Sign-extends.
int_abs(n) (Int) -> Int ![] Absolute value. int_abs(i64::MIN) wraps to i64::MIN.
byte_truncate(n) (Int) -> Byte ![] Truncate to low 8 bits.
byte_in_range(n) (Int) -> Bool ![] Range check: 0 <= n < 256.
byte_to_int(b) (Byte) -> Int ![] Widen byte to integer.
random_pseudo_int() () -> Int ![] Process-global xorshift64. Not cryptographic.

§13.2.1 — Diagnostics: panic and assert

These builtins close the recurring “bail out with a clear message” gap without polluting effect rows with Raise[String]. Both are available without any import.

Function Type Description
panic[A](msg) (String) -> A ![] Aborts the program. Writes msg to stderr followed by \n; exits with status 1. The per-call generic A instantiates fresh at each call site (same idiom as Raise.fail[A]), so panic("oops") typechecks anywhere any expression typechecks.
assert(cond, msg) (Bool, String) -> Unit ![] Sugar over if cond { unit } else { panic(msg) }. assert(true, _) is a no-op; assert(false, msg) calls panic(msg).

panic is a hard abort. It is not catchable: there is no catch_panic, no try, no way to observe the abort from sigil code. Raise[E] (see §13 / std.raise) is the catchable error mechanism — use it when the caller may want to recover; use panic when the program’s invariants have been violated and continuing would be unsafe or nonsensical.

panic carries effect row ![] — aborting is not an effect users handle.

assert is shipped as a top-level builtin because LLMs reach for it specifically; the if !cond { panic(msg) } form one inversion away has the same semantics, but assert is the prior every LLM has.

§13.3 — Quick reference: most-used stdlib fns by module

This table lists the import std.<module> functions LLM authors most often reach for. It is intentionally non-exhaustive — full per-module documentation lives in std/<module>.sigil. The point is to settle two recurring failure modes: (a) the model invents APIs that don’t exist (xs.max(), HashMap::new(), xs.length); (b) the model writes manual recursion when a stdlib combinator would do it cleanly. None of the listed functions are themselves solutions to common problems — std.list deliberately has no max/min/sum, no list literals, and no methods. Reductions go through fold; sorts go through list_sort_*; everything else is built from the primitives below.

Effect rows: every fn listed has row ![] (pure) unless marked ![Mem] (mutable-array ops). IO.println/IO.print/IO.read_line are effect ops (not regular fns) — see §7.

Module Function Type Purpose
std.int int_to_string(n) (Int) -> String format Int as decimal — needed before any IO.println(int_value)
std.int int_max() / int_min() () -> Int Int range bounds (±2^62)
std.int int_add_safe(a,b) / int_sub_safe(a,b) (Int, Int) -> Option[Int] overflow-safe arithmetic
std.int checked_div(a,b) / checked_mod(a,b) (Int, Int) -> Result[Int, String] div/mod without trap on zero divisor (see §4.2)
std.string string_to_int(s) (String) -> Result[Int, ParseError] parse decimal Int; ParseError = \| Empty \| NonDecimal \| Overflow
std.string string_split(s, sep) (String, String) -> List[String] split on separator
std.string string_replace(s, find, repl) (String, String, String) -> String substring replace
std.string string_byte_at_opt(s, i) (String, Int) -> Option[Byte] safe byte indexing
std.string string_substring_opt(s, start, end) (String, Int, Int) -> Option[String] safe substring [start, end)
std.path path_join(a, b) (String, String) -> String join two path segments — note: an absolute b resets (path_join("a","/b") == "/b"), matching posixpath
std.path path_basename(p) / path_dirname(p) (String) -> String final component / everything before it
std.path path_split(p) (String) -> (String, String) (dirname, basename) pair
std.path path_splitext(p) (String) -> (String, String) (root, ext); ext includes the dot, "" if none; a leading-dot dotfile has no ext. (No standalone “extension” fn — use snd(path_splitext(p)).)
std.path path_normalize(p) (String) -> String collapse . / .. / duplicate slashes (posixpath normpath)
std.path path_is_absolute(p) (String) -> Bool does p start with /
std.list length(xs) (List[A]) -> Int element count
std.list range(start, end) (Int, Int) -> List[Int] build [start, end)
std.list map(xs, f) (List[A], (A) -> B ![]) -> List[B] transform each
std.list filter(xs, pred) (List[A], (A) -> Bool ![]) -> List[A] keep matching
std.list fold(xs, init, f) (List[A], B, (B, A) -> B ![]) -> B reduce — sum, max, count, find, etc. all build on fold
std.list reverse(xs), append(xs, ys) (List[A]) -> List[A], (List[A], List[A]) -> List[A] basic ops
std.list list_sort_int(xs) / list_sort_string(xs) / list_sort_char(xs) / list_sort_float(xs) (List[T]) -> List[T] type-specialised stable sort (no comparator arg)
std.list list_sort(xs, cmp) (List[T], (T, T) -> Ordering ![]) -> List[T] generic stable sort with comparator
std.option unwrap_or(opt, default) (Option[A], A) -> A collapse Option[T] with fallback
std.option map(opt, f) (Option[A], (A) -> B ![]) -> Option[B] transform Some payload
std.option and_then(opt, f) (Option[A], (A) -> Option[B] ![]) -> Option[B] chain Option-returning fns
std.result map(r, f) / map_err(r, f) / and_then(r, f) mirrors std.option shape compose Result-returning fns
std.float string_to_float(s) (String) -> Option[Float] parse Float
std.array array_get_opt(arr, i) (Array[A], Int) -> Option[A] safe indexed read (returns None on out-of-bounds)
std.array array_set_opt(arr, i, v) (Array[A], Int, A) -> Option[Array[A]] safe indexed write returning a new Array
std.mut_array mut_array_get_opt(arr, i) / mut_array_set_opt(arr, i, v) (MutArray[A], Int) -> Option[A] ![Mem] / (MutArray[A], Int, A) -> Option[Unit] ![Mem] mutable-array equivalents under ![Mem]
std.map map_int_keys() / map_string_keys() / map_char_keys() () -> Map[K, V] construct an empty Map[K, V] with built-in comparator
std.map map_empty(cmp) ((K, K) -> Ordering ![]) -> Map[K, V] generic empty Map with explicit comparator
std.map map_get(m, k) (Map[K, V], K) -> Option[V] lookup (returns None if absent)
std.map map_contains(m, k), map_insert(m, k, v), map_remove(m, k), map_size(m), map_is_empty(m) basic ops
std.map map_keys(m), map_values(m), map_to_list(m), map_from_list(xs, cmp) conversion to/from List[K] / List[V] / List[(K, V)]  
std.map map_fold(m, init, f), map_map(m, f), map_filter(m, pred) higher-order over Maps  
std.set set_int() / set_string() / set_char() () -> Set[T] construct an empty Set[T] with built-in comparator
std.set set_empty(cmp) ((T, T) -> Ordering ![]) -> Set[T] generic empty Set
std.set set_contains, set_insert, set_remove, set_size, set_is_empty basic ops
std.set set_to_list(s), set_from_list(xs, cmp), set_fold(s, init, f), set_filter(s, pred) conversion + higher-order  
std.set set_union, set_intersect, set_difference, set_subset, set_eq set algebra  
std.pair fst(p) / snd(p) ((A, B)) -> A / ((A, B)) -> B tuple component access (works on built-in tuples too)
std.ordering int_compare / string_compare / char_compare / bool_compare / float_compare (T, T) -> Ordering comparators for list_sort / map_empty / set_empty

§14 — v1 limits

The following limits are permanent v1 design choices:

§14.1 — Deferred to follow-up plans

Capability Closure path
Codepoint-aware string_split / string_replace Future string-codepoint-helpers plan (depends on stdlib namespace qualification, not on Char).
Unicode-aware is_unicode_* / to_lower_unicode / to_upper_unicode / case folding / normalization Future std/unicode.sigil plan (ships general-category + case-folding tables as embedded data + dispatchers). The v1 *_ascii suffix lets the Unicode set ship additively without renaming.
Strict-UTF-8 string_chars_strict : (String) -> Result[List[Char], Utf8Error] v1 ships only the lossy string_chars; v2 may add the strict variant additively.
Network effects (Net: TCP, TLS, DNS, sockets) Future plan once the LLM-first thesis closes and Sigil expands beyond demo programs. Not in v1 scope.
Timer effects (sleep, monotonic time, deadlines) Future plan. Clock.now (wall-clock) ships in v1 via std.clock; deadline / sleep semantics defer.
Process stdin piping Future v2 follow-up of the Process effect. v1’s Process.run runs with stdin closed.
Process stdout / stderr streaming Future v2 follow-up. v1 captures full stdout / stderr after the child exits via Command::output().
Effect ops returning user-defined sum types directly (e.g., Fs.read_file: (String) -> Result[String, FsError] as a perform-direct surface) Path 1 architecture from the CLI-effects plan; deferred. v1 ships path 4 (raw-shape effect ops + stdlib Sigil wrappers — match read_file(p) { Ok(s) => ..., Err(NotFound) => ... } as a stdlib fn call). Closure path = future BuiltinEffectArmSynth codegen-arm-fn architecture. See [DEVIATION Task EE] in PLAN_C_DEVIATIONS.md.
Recursive mkdir -p and recursive rm -rf Future stdlib helpers layered on top of mkdir / remove_dir / read_dir. v1 Fs.mkdir / remove_dir are single-level.
Symlink-aware ops (is_symlink, read_link, create_symlink) Future v2 work. v1 follows symlinks transparently; no symlink-specific surface.
MutMap, range queries on Map (map_range, prefix scans), set operations (map_union, map_intersect, map_difference), map_for_each, map_eq Future map-extensions plan. v1 ships only the persistent immutable Map[K, V] plus the closed-row pure-helper surface (map_get / map_insert / map_remove / map_keys / map_to_list / map_fold / map_map / map_filter etc.).
MutSet, range queries on Set (set_range, prefix scans), min / max queries (set_min, set_max), set_for_each Future set-extensions plan. v1 ships only the persistent immutable Set[T] plus the closed-row pure-helper surface (set_contains / set_insert / set_remove / set_to_list / set_fold / set_filter / set-theoretic ops).
mut_array_sort (in-place sort over MutArray[A]) Future plan. v1 ships only the pure functional list_sort; an in-place sort would force ![Mem] onto every call site, which the LLM-default surface chose to avoid.
Format specifiers ({:.2}, {:>10}, {:#x}) — width, precision, alignment, fill, base prefix Future format-specifiers plan. v1 ships only positional {} (each placeholder consumes the next FormatArg); width / precision / alignment / fill / base would extend the placeholder grammar and the per-FormatArg-variant render path.
Named args ({name}) and positional indices ({0}, {1}) Future format-specifiers plan. v1’s {} is strictly positional — each placeholder consumes the next FormatArg.
Compiler-level f-string syntax (f"x = {x}") Future plan. v1 ships only the runtime format family in std.format; a compile-time f-string surface would lower to format calls but requires lexer + parser changes.
Stack traces on panic Future plan. v1’s panic prints only the user-supplied msg and exits — caller-context information has to be encoded into msg itself (or built via format(...) + panic(...)). The v1 stackmap content this would build on now ships (the precise-GC rework, §12), but the symbolizing unwinder that would turn it into a panic backtrace is not yet implemented. Still a future plan.

§15 — Build and run

# Linux
sudo apt-get install -y libgc-dev pkg-config

# macOS
brew install bdw-gc pkg-config

# Build
cargo build --release

# Compile
./target/release/sigil my_program.sigil -o my_program

# Run
./my_program

The compiler produces a self-contained native binary; no runtime installation is needed beyond the Boehm GC system library.

§16 — Profiling and instrumentation

Sigil ships a runtime profile-data surface (v2 prerequisite work) that emits CPU and allocation samples to an on-disk profile, consumable by standard tools (pprof, flamegraph.pl, speedscope, perfetto). The surface is zero-overhead when disabled: the env-var gates short-circuit before any allocation, syscall, or signal-handler install, and the alloc-profile hot path on sigil_alloc is a single relaxed atomic load + branch.

Compiler flag

Flag Behaviour
--emit-symbol-table Writes <output>.symtab next to the compiled executable. The sidecar is a tab-separated map from text-section offsets to demangled function names, sorted ascending by offset: <text_offset_hex>\t<size_hex>\t<demangled_name>. The runtime profiler reads it at flush time to resolve sampled PCs. Without this flag, profile output records raw 0x<hex> addresses.

Environment variables

Variable Effect Default
SIGIL_CPU_PROFILE=<path> Install a SIGPROF handler at SIGIL_CPU_PROFILE_HZ, capture stack traces, write the profile to <path> at process exit. unset (no CPU profile)
SIGIL_CPU_PROFILE_HZ=<N> Sampling frequency in Hz. Range [1, 10000]; values outside fall back to default. 99
SIGIL_ALLOC_PROFILE=<path> Sample every SIGIL_ALLOC_SAMPLE_RATE-th sigil_alloc call, capture stack traces, write the profile to <path> at process exit. The recorded value is requested_bytes * rate, so the unsampled total renders as “bytes allocated”. unset (no allocation profile)
SIGIL_ALLOC_SAMPLE_RATE=<N> Sample every Nth allocation. N >= 1. 512
SIGIL_PROFILE_FORMAT=pprof\|folded Override the output format. Case-insensitive. Falls back to extension detection on any other value. extension-based

Output format auto-detection

Path ends in Format Renders with
.txt folded stacks flamegraph.pl < out.txt > flame.svg
anything else (.pb, .proto, no extension) pprof pprof -http=:0 out.pb, speedscope out.pb, perfetto trace_to_text out.pb

Stack-walking mechanism

Stack traces are captured via the saved-frame-pointer chain — every emitted function (compiler-generated user code and the runtime crate) preserves the %rbp (x86_64) / x29 (aarch64) prologue save, and the walker follows [fp + 0] -> prev_fp while reading return addresses from [fp + 8]. The walk is signal-safe (no allocation, no libc, bounded MAX_DEPTH = 128).

Tradeoff: tail-called functions don’t appear in profiles. The compiler aggressively TCO’s tail-recursive sigil functions (return_call IR at CallConv::Tail); the caller’s frame is replaced by the callee’s, so a sample inside the callee shows the callee’s caller, not the intermediate callees. This is the intentional cost of Sigil’s “recursion is the only loop” model.

aarch64 pointer-authentication: Apple Silicon may sign return addresses; the walker strips PAC bits with a 48-bit canonical-VA mask before recording.

# 1. Compile with the symbol sidecar.
sigil examples/json.sigil -o json --emit-symbol-table

# 2. Run under profile collection.
SIGIL_CPU_PROFILE=/tmp/cpu.pb ./json < input.json

# 3. Render with your preferred tool.
pprof -http=:0 /tmp/cpu.pb              # interactive flame graph in browser
speedscope /tmp/cpu.pb                  # standalone viewer
flamegraph.pl < /tmp/cpu.txt > /tmp/flame.svg  # if exported as folded stacks

For allocation profiling:

SIGIL_ALLOC_PROFILE=/tmp/alloc.pb SIGIL_ALLOC_SAMPLE_RATE=32 ./json < input.json
pprof -alloc_space /tmp/alloc.pb        # bytes-allocated heatmap

Out of scope (v3+)


Appendix — Idiomatic patterns

Sigil’s surface differs from common languages (Python, JS, Go, Rust) in five places where authors most often trip up:

If you’d write (other languages) In Sigil, write
for i in 1..=n: total += i / while recursive helper with an accumulator param
var x = 0; x += y nothing mutates — bind a new let each step
if a: ... elif b: ... else: ... nested if/else, OR match on conditions
return value last expression of the block IS the value
print(x) where x: Int perform IO.println(int_to_string(x))
class Color: RED, GREEN, BLUE type Color = \| Red \| Green \| Blue
try/except or null sentinels return Option[T] or Result[T, E] + match

The next five examples are the canonical translations. Each compiles and runs as-is; together they cover the surface syntax most prompts need (imports, use lines, fn main() -> Int ![<row>], recursion, exhaustive match, sum types, and the stdlib Option type).

A — Iteration → recursion with an accumulator

import std.int
import std.io
use std.int.{int_to_string};
use std.io.{IO};

// `current` walks from 1 to n; `acc` accumulates the running sum.
// No mutable variable — each recursive call rebinds `current` and `acc`.
fn count_up_step(n: Int, current: Int, acc: Int) -> Int ![] {
  if current > n {
    acc
  } else {
    count_up_step(n, current + 1, acc + current)
  }
}

fn main() -> Int ![IO] {
  perform IO.println(int_to_string(count_up_step(10, 1, 0)));
  0
}

B — Multi-way branching with match (Sigil has no else if)

There is no else if. An else must be followed by a brace block { ... }. Chaining else if — the C / Python / JS / Rust habit — is a parse error: E0010, “expected { opening block”, reported at the if that follows else.

// ✗ NOT valid Sigil — `else if` does not exist:
if n < 0 { "neg" } else if n == 0 { "zero" } else { "pos" }

Write it one of two ways. For a simple two-way split, nest the if/else so every else opens its own brace block:

import std.io
use std.io.{IO};

// ✓ Nested if/else — each `else` is followed by `{ ... }`:
fn sign(n: Int) -> String ![] {
  if n < 0 {
    "negative"
  } else {
    if n == 0 { "zero" } else { "positive" }
  }
}

fn main() -> Int ![IO] {
  perform IO.println(sign(0 - 4));
  perform IO.println(sign(0));
  perform IO.println(sign(7));
  0
}

For three or more conditions the nesting gets deep — prefer a match on a tuple of the conditions, the canonical if/elif/else replacement:

import std.int
import std.io
use std.int.{int_to_string};
use std.io.{IO};

// `match` on a tuple of conditions is the canonical replacement for
// `if/elif/else` chains. Each arm pattern picks the first matching
// shape; `_` is the wildcard. The arm bodies are expressions, not
// statements — every branch produces the same type.
fn classify(n: Int) -> String ![] {
  match (n < 0, n == 0) {
    (true, _)  => "negative",
    (_, true)  => "zero",
    (_, _)     => int_to_string(n),
  }
}

fn main() -> Int ![IO] {
  perform IO.println(classify(0 - 4));
  perform IO.println(classify(0));
  perform IO.println(classify(7));
  0
}

C — Pure helpers + IO main (composing effect rows)

import std.int
import std.io
use std.int.{int_to_string};
use std.io.{IO};

// `double` performs no effects, so its row is `![]`. `print_doubled`
// uses `IO`, so its row is `![IO]`. `main` calls `print_doubled` and
// inherits `![IO]`. Effect rows are mandatory and must exactly match
// the effects actually performed in the body.
fn double(n: Int) -> Int ![] { n + n }

fn print_doubled(n: Int) -> Int ![IO] {
  perform IO.println(int_to_string(double(n)));
  0
}

fn main() -> Int ![IO] {
  print_doubled(21)
}

D — User-defined sum types with exhaustive match

import std.io
use std.io.{IO};

// Sum types replace enum-like classes / constant tags. Variants
// after `|` can be nullary (no payload) or carry positional fields.
// `match` on a sum type is checked for exhaustiveness — leaving an
// arm out is a compile error (E0066).
type Mood = | Happy | Sad | Neutral

fn greet(m: Mood) -> String ![] {
  match m {
    Happy   => "yay",
    Sad     => "ouch",
    Neutral => "ok",
  }
}

fn main() -> Int ![IO] {
  perform IO.println(greet(Happy));
  0
}

E — Option[T] for fallible operations (instead of sentinels or exceptions)

import std.int
import std.io
import std.option
use std.int.{int_to_string};
use std.io.{IO};
use std.option.{None, Option, Some};

// Fallible operations return `Option[T]` (no value) or `Result[T, E]`
// (typed error). Callers `match` on the result; there is no
// try/except, no null, no sentinel.
fn nonzero(n: Int) -> Option[Int] ![] {
  if n == 0 { None } else { Some(n) }
}

fn main() -> Int ![IO] {
  match nonzero(7) {
    Some(v) => perform IO.println(int_to_string(v)),
    None    => perform IO.println("zero"),
  };
  0
}

F — List recursion via match Cons(head, tail) with an accumulator

import std.int
import std.io
import std.list
use std.int.{int_to_string};
use std.io.{IO};
use std.list.{Cons, List, Nil};

// Walk a `List` by matching on `Nil` / `Cons(head, tail)`. The
// tail-recursive form (accumulator parameter, recursive call at the
// tail) is the canonical pattern for any per-element fold: count,
// sum, search, transform, reduce. Every list traversal in Sigil
// follows this shape.
fn count_pos_step(xs: List[Int], acc: Int) -> Int ![] {
  match xs {
    Nil => acc,
    Cons(head, tail) => if head > 0 {
      count_pos_step(tail, acc + 1)
    } else {
      count_pos_step(tail, acc)
    },
  }
}

fn count_pos(xs: List[Int]) -> Int ![] {
  count_pos_step(xs, 0)
}

fn main() -> Int ![IO] {
  let xs: List[Int] = Cons(0 - 1, Cons(2, Cons(0, Cons(5, Nil))));
  perform IO.println(int_to_string(count_pos(xs)));
  0
}

G — Higher-order stdlib (std.list.range + map + fold)

import std.int
import std.io
import std.list
use std.int.{int_to_string};
use std.io.{IO};
use std.list.{List, fold, map, range};

// `std.list.range(start, end)` produces `[start, end)`. `map`
// transforms each element; `fold` reduces with a `(acc, elem)`
// combiner. Together they replace manual recursion for any
// transform-then-reduce shape — concise and idiomatic.
fn sum_sq(n: Int) -> Int ![] {
  let xs: List[Int] = range(1, n);
  let squares: List[Int] = map(xs, fn (k: Int) -> Int ![] => k * k);
  fold(squares, 0, fn (acc: Int, sq: Int) -> Int ![] => acc + sq)
}

fn main() -> Int ![IO] {
  perform IO.println(int_to_string(sum_sq(5)));
  0
}

H — Option combinators (unwrap_or, map, and_then)

import std.int
import std.io
import std.option
use std.int.{int_to_string};
use std.io.{IO};
use std.option.{None, Option, Some, unwrap_or};

// `unwrap_or(opt, default)` collapses `Option[T]` to `T` by
// returning the wrapped value or a fallback — cleaner than `match`
// when you just want a default. `std.option` also provides `map`
// (transform Some payload, pass through None) and `and_then`
// (chain another `Option`-returning step); both let you compose
// fallible operations without nested `match`.
fn lookup_or_default(found: Option[Int], default: Int) -> Int ![] {
  unwrap_or(found, default)
}

fn main() -> Int ![IO] {
  perform IO.println(int_to_string(lookup_or_default(Some(42), 0)));
  perform IO.println(int_to_string(lookup_or_default(None, 99)));
  0
}

Surface-syntax reminders the eight idioms collectively demonstrate