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 row — square 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 format1…format8 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.
- Integer: decimal
42,-7,0. No hex/oct/bin literals in v1. Range:[-2^63, 2^63)(signed 64-bit). Literals outside this range fire E0050; arithmetic overflow wraps modulo 2^64 (see §1 “Wrap-on-overflow”). - String:
"..."with escapes\\,\",\n,\t,\r. - Char:
'a','\n','\u{1F600}'. Heap-boxed (TAG_CHAR=0x0C, 16 bytes per allocation) Unicode scalar value in0x000000..=0x10FFFFexcluding surrogates0xD800..=0xDFFF. Literal escapes:\n,\t,\r,\\,\',\",\0, and\u{HEX}accepting 1–6 hex digits. Bare-codepoint UTF-8 literals ('é','中','😀') are decoded from source. The lexer rejects multi-codepoint bodies,\u{...}values >0x10FFFF, and surrogate-range values at parse time. Operator overloading (==,<) is not provided — use the named functionschar_eq/char_ltetc. (§3.1.1). Pattern- matching against literal Chars inmatch c { 'a' => ... }IS supported. - Byte: constructed via
byte_truncate(n: Int) -> Byte  and validated withbyte_in_range(n: Int) -> Bool ![]. - Bool:
trueandfalseare the literal forms of the builtinBooltype. Pattern-match withmatch b { true => ..., false => ... }. - Float:
3.14,1e10,2.5e-3. IEEE 754 f64, heap-boxed. Requires digits before and after the decimal point (3.0not3.or.3). Exponent form usese/Ewith optional+/-, but requires at least one digit after the marker (1e10is a float;1eis integer1followed by identifiere). Negative float literals:-3.14(unary minus is constant-folded). Float literals are not valid in pattern position. - Unit:
(). The singleton value of typeUnit. Not valid in pattern position.
§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.
- Generic params (
[A, B, …]) are optional; absent means no quantifier. - Each
paramisname: Type. - Return type is mandatory.
- Effect row is mandatory (use
![]for pure). - Body is an expression; multi-statement bodies use a block (§5).
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:
IO—print,println,read_linearms (std.io)ArithError— explicitperform ArithError.*only (the/and%operators trap directly and do not perform it); default handler exits 2Mem— marker effect (no shim handler; allowed for type-level gating)Env—args,var,vars(std.env)Fs—read_file,write_file,read_dir,exists,is_file,is_dir,file_size,mkdir,remove_file,remove_dir(std.fs)Process—run(std.process)
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):
- Equality / ordering:
char_eq/char_lt/char_le/char_gt/char_ge. Compare codepoint-numerically. There is no==/<operator overload forChar— use the named functions. - Conversion:
char_to_int(always succeeds),int_to_char(returnsOption[Char];Nonefor out-of-range or surrogate inputs),char_to_string(UTF-8 encode into a fresh String). - ASCII classifiers (return
falsefor any non-ASCII codepoint):is_ascii,is_ascii_digit,is_ascii_alpha,is_ascii_alphanumeric,is_ascii_whitespace. - ASCII case folding (non-ASCII passes through unchanged):
to_lower_ascii,to_upper_ascii. The*_asciisuffix is intentional — v2 may addis_unicode_*/to_lower_unicodeadditively without renaming.
Codepoint-aware string operations (in std.string,
documented in std.char):
string_chars : (String) -> List[Char] ![]— eager UTF-8 decode. Invalid byte sequences emitU+FFFD(replacement char) and resync to the next valid leading byte; lossy by design.string_char_at : (String, Int) -> Option[Char] ![]— codepoint index (not byte).Noneif out of bounds. O(n) decode walk.string_from_chars : (List[Char]) -> String ![]— UTF-8 encode each codepoint, concatenate.
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:
/and%trap (abort + exit 2) on a zero divisor and carry NO effect — a dividing function needs nothing on its row. For recoverable division usechecked_div/checked_mod(std.int), which returnResult[Int, String]. See §4.2.perform Effect.op(...)carries. Helper functions that perform effects need the effect in their declared row.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 writessigil: 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 orpanic— not an effect. A function that divides needs NOArithError(or any other) entry on its row;fn main() -> Int ![]may containn % 2and compiles.A trap cannot be intercepted by a
handlearm. When a zero divisor is a recoverable condition rather than a programming bug, usechecked_div/checked_modfromstd.int, which pre-check the divisor and returnResult[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
ArithErroreffect 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.Intis 63-bit signed (range[-2^62, 2^62 - 1]), so the smallestIntdivided by-1is2^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:
let name: Type = expr;— bindsnameto the value ofexpr.expr;— evaluatesexprfor effect; the value is discarded.
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:
eis a row variable, introduced by the| etail in the effect row — it is not listed in the[A, E]generic-param brackets. It stands for “whatever other effects the body performs.”body’s row![Raise[E] | e]says “Raise[E], plus the effects named bye.” The pipe (|) separates the named effect(s) from the row-tail variable.- The
handledischargesRaise[E]. The result row![| e]shows Raise[E] gone; onlyeremains. - This lets
catchbe called from any context: pure (e := []), IO-doing (e := [IO]), state-threading (e := [State[Int]]), or any combination — the row-tail unification picks up whatever effects the body declared.
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:
- A pure expression (literal, arithmetic, identifier, etc.).
- An
if/elsewhose branches each return values of the body’s return type. Either or both branches may perform their own effects. - A
matchwhose arms each return values of the body’s return type. Any arm may perform its own effects.
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:
OpRet— the operation’s declared return type (whatperform Effect.op(...)evaluates to at the perform site, and thus the type the continuation accepts as its single argument).Ret— the surrounding handler’s return type (what the wholehandle body() with { … }expression evaluates to, and thus the type the continuation produces).
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:
- Primitive type names:
Int,Bool,String,Char,Byte,Float,Int64,Unit. - Opaque container type names:
Array,MutArray,ByteArray,MutByteArray,StringBuilder. - Reserved type names:
Continuation[OpRet, Ret](reserved for the type-position continuation surface — see §8.5; the name itself is protected by E0146 against user redeclaration) andRef[T](the runtime cell type underlyingstd/state.sigil; the constructors are file-gated to that one source by E0148, but the type name is in scope everywhere for type-annotation purposes). - Compiler intrinsic functions (the prelude) — the primitive
operations the compiler emits directly (no
.sigilsource). Because Sigil has no operator overloading, these are the language’s primitive operations:int_to_string(n),float_add(a, b),char_to_int(c),array_get(xs, i),string_concat(a, b),panic(msg), and so on. Their names are globally unique, so calling them bare is unambiguous and needs noimport/use. §13.2 lists the module-less primitives, and the per-module tables in §13 list the rest (e.g.std.float’sfloat_add) — all of which are intrinsics callable bare.
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”):
- A top-level
fnredefining a prelude intrinsic name is a redefinition error (E0020) — you can no more redefineint_to_stringthan the typeInt. - A local
let/ parameter may shadow a prelude intrinsic within its scope, exactly as a local may shadow a top-levelfnof the same name (local-first lookup). This is not a violation of the no-shadowing rule, which forbids only re-binding the same name in the same scope (let x = 1; let x = 2;). - A redundant explicit
use std.int.{int_to_string};still compiles: it binds the name to the same intrinsic the prelude provides, so it is not shadowing — just redundant. Pre-prelude code that imports intrinsics keeps compiling unchanged.
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:
- Qualified path.
std.list.map(xs, f)— the resolver walks the dotted prefix against the file’simport/ alias table. - Bare via
use-binding.map(xs, f)afteruse std.list.{map};— the bare ident resolves to the(module, source_name)pair the binding records. - 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
useline for itself (the recursivemap(t, f)insidestd.list’sfn mapis 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:
import app.parser→<root>/app/parser.sigilimport app.parser.lexer→<root>/app/parser/lexer.sigiluse app.parser.{parse}→ bindsparsefrom<root>/app/parser.sigil
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:
- E0032 (missing module): names the missing module
(e.g., “module
app.parsernot found”). - E0033 (module cycle): names the cycle-closing module
(e.g., “circular import involving
app.parser”).
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
- Memory: Boehm GC, run in precise mode for Sigil-allocated
objects and Sigil thread stacks. Every heap object begins with an
8-byte header
(tag, count, bitmap, reserved); thebitmapnames which payload words hold pointers and drives precise heap marking (GC_malloc_explicitly_typed, dispatched through a compile-time shape table). Sigil thread stacks are scanned precisely via Cranelift-emitted stackmaps (the__SIGIL,__stackmapssection) walked at GC time rather than by conservative stack scanning. Conservative scanning survives only forcount == 0payloads (arrays and string-builder segments, whose element count exceeds the 6-bit headercountfield’s reach) and runtime-internal threads. - Tagged values:
Intis signed 64-bit (full i64) and travels through the runtime ABI as a 64-bit register value.Int64andFloatare heap-boxed. - Effects: dispatched through a CPS trampoline
(
sigil_run_loop); eachperformreturns aNextSteppacket that the trampoline routes to the matching arm fn or the enclosing handler frame. - Multi-shot machinery: thread-local stack handles re-entry from within multi-shot arm bodies; canonical pattern is the static-N let-chain (§8.3). Runtime-N enumeration uses first-class continuations (§8.5).
§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:
- The last expression of a function body (the body’s tail expression after any preceding statements).
- The tail expression of a
Blockwhose surrounding context is itself in tail position. - An arm body of a
matchwhose surrounding context is in tail position.if/elsedesugars tomatch, so anif-arm in tail position is a tail-position match arm. - The body of a
let x = …; tailwhose surrounding context is in tail position (i.e., thetailslot of a let-block).
A call is not in tail position when it appears as:
- An operand of
+,-,*, etc. (the surrounding operator consumes the call’s value). - A non-tail statement of a block (
let _ = recurse(…); …). - The scrutinee of a
match(the scrutinee feeds pattern tests, not the match’s value). - Inside a
handle … with { … }body (the body executes under a synchronous trampoline driver; tail-jumping out would skip the handler machinery). - Inside a
performargument (a perform’s args are non-tail by construction).
Tail-call optimization covers:
- Self-recursion (
fcallingf). - Mutual recursion (
f → g → f → …) — provided the recursive fns share an exact signature (param types, return type, calling convention). Sigil’s typechecker enforces matching return types for tail-position calls; cross-arity tail calls fall back to a non-tail call (one stack frame per call), since Cranelift’sreturn_callrejects signature mismatches at verifier time. - Cps-colored fns whose body has the chained-let-yield + tail-
match shape (e.g.,
let _ = perform Eff.op(); match { ... recurse }). Such fns lower asUserFnAbi::Cps, but the chained-let-yield Final-step’s tail expression goes through tail-position lowering. A recursive Cps→Cps call in a tail match-arm body emitsreturn_(NextStep::Call(callee, args))directly. The OUTER trampoline iterates without nestingsigil_run_loopper call — stack-bounded to the same unbounded depth as pure-Sync recursion. The recursive call forwards the surrounding chained-let-yield’s incoming(post_arm_k_closure, post_arm_k_fn)pair as the inner call’s trailing pair, preserving continuation chains across nested handlers; non-identity outer continuations route through the captured chain rather than being silently dropped. - Indirect calls (closure dispatch through
code_ptr) when the callee is a fn-typed value (let-binding, fn parameter, or expression returning a closure) whose signature matches the surrounding fn’s. These lower to Cranelift’sreturn_call_indirect. Mutual indirect tail-recursion through fn-typed bindings (e.g.,aandbeach indirectly tail-call the other through a fn-typed local) is depth-unbounded.
Tail-call optimization does not apply to:
- Sync→Cps cross-ABI calls in tail position. The surrounding Sync
fn returns the user’s value type, not
*mut NextStep; tail- jumping to a Cps callee would lose the trampoline drive that unwraps the NextStep into the user value. Such call sites use the synchronoussigil_run_loopwrapper (one nested run_loop per call), which is correct but stack-bounded.
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:
- Direct self-recursion (
f → f, singleton SCC{f}). - Mutual recursion (
f → g → f, SCC{f, g}); each member’s non-tail intra-SCC call drives its own promotion. - Larger cycles (
a → b → c → a) — same shape.
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
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:
- Single call per branch —
sum_to(n) = n + sum_to(n - 1),sum_list(c) = v + sum_list(rest). - Multiple calls per branch —
fib(n) = fib(n - 1) + fib(n - 2),sum_tree(t) = v + sum_tree(l) + sum_tree(r). Each call becomes a yield point with its own continuation; intermediate continuations thread captured state forward to the next call. - Pointer-returning recursion —
build(d) = Node(1, build(d-1), build(d-1)),build_list(n) = Cons(n, build_list(n-1)). The reconstructed value (a heap record) flows through the continuation chain; the GC roots the in-flight chain across collections. - Mutual recursion —
f → g → fwhere every leg has a non-tail call; each SCC member is promoted.
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_char → Option[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_dir → Bool. Fallible ops: read_file, write_file, read_dir, mkdir, remove_file, remove_dir, file_size → Result[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.set — set_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:
for/while: no looping syntax; recursion is the only iteration mechanism.- Multi-shot N at runtime without continuations: the static
N-let-chain (§8.3) requires N to be known at compile time. For
runtime-N iteration, use first-class continuations (§8.5) as
all_choicesdoes.
§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.
Recommended workflow
# 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+)
- DWARF-driven source-line resolution in pprof
Location.line[]. - Wall-clock / off-CPU profile (would require
perf_events/proc_pidinfo). - Continuous / streaming profiles.
- GC heap-snapshot profile.
- Effect-aware per-handler-frame attribution.
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
- Files begin with
import std.foolines, then optionaluse std.foo.{Name1, Name2};lines that bring those names into local scope. fn name(arg: T, ...) -> Ret ![<row>] { body }—Retand the effect row are mandatory; the row contains exactly the effects performed.- The body is an expression; multi-statement bodies use a block
{ stmt; stmt; tail }whose last expression is the block’s value — there is noreturnkeyword. matcharms are separated by commas; the last arm has a trailing comma.IO.printlntakesString. Convert withint_to_string,bool_to_string,char_to_string, etc., before passing./and%trap on a zero divisor and carry NO effect (see §4.2); usechecked_div/checked_modfromstd.intfor the recoverable form.- Walking a
List[T]ismatch xs { Nil => ..., Cons(head, tail) => ... }; the canonical fold is tail-recursive with an accumulator parameter (idiom F). std.listprovidesrange,map,filter,fold,length,reverse,append,list_sort_int/string/char/float. Prefer them over hand-rolled recursion when the shape fits (idiom G).std.optionprovidesunwrap_or,map,and_then;std.resultprovidesmap,map_err,and_then. Compose them instead of nestingmatch(idiom H).- Read a record field with
r.field(and chainsr.a.b);matchdestructuring is still used to read fields of sum-type variants.