Ingle by Firelight
A friendly, honest field guide to writing Ingle — as it actually stands today
Covering the language as built and tested in June 2026. Everything in this book was compiled and run before it was written down. Nothing here is aspirational.
The one promise this book makes
Ingle is a young language in active design, and it is growing quickly. A book about a moving target can lie to you in two directions: by describing features that don’t exist yet, or by going stale the moment something lands. This book solves the first problem ruthlessly — every single code sample was run through the compiler and produced the output shown — and it makes peace with the second by telling you, at every turn, exactly where the edges are. When something is designed but not built, you’ll find it in Chapter 23: The “Not Yet” List, clearly fenced off, and nowhere else.
If you can read one programming language already — any of them — you can learn to write working Ingle from this book. That includes you, dear reader, even if your last program ended in a stack trace you emailed to a friend.
How to read this book
You don’t have to read it cover to cover, but the early chapters genuinely build on each other, so if you’re new, start at the start. Each chapter follows the same rhythm: a plain explanation, real code you can run, the gotcha that will bite you if nobody warns you, and — because all work and no play makes for a dull manual — the occasional Fireside trivia box, where we wander off to look at something faintly ridiculous and true.
A few conventions:
-
Code you can run looks like this and has been run:
fn main() -> int { println("Hello from Ingle") return 0 } -
When the compiler says something back to you, it looks like this:
=> 0
Fireside trivia. Ingle’s reference compiler is written in C — about a dozen thousand lines of it — and has no third-party dependencies at all for its default build. It links the C standard library and nothing else. The entire toolchain (compiler, language server, property fuzzer, contract prover, JSON reader) is written in-tree. The one exception is the optional graphics build, which is kept so firmly off the main path that you can build and test the whole language on a machine with no display.
Running Ingle at all
Ingle programs end in .ig. You compile and run one with the inglec compiler:
inglec --emit=run hello.ig
That compiles the program and executes it, printing any output, then a final line showing
the value main returned:
=> 0
--emit=run is the one you’ll use most. The compiler can do a lot of other things to your
program — show you the tokens, the syntax tree, the bytecode, fuzz your contracts, prove
them, replay a run deterministically — and all of those live in
Chapter 21: The Whole Toolbox. It can also compile your program
to a standalone native binary, with no interpreter anywhere in sight — that’s
Chapter 22: Compiling to Native. For now, --emit=run is all
you need.
Exit codes, if you script things: 0 success, 64 you used the compiler wrong, 65 your
program has an error (lexing, parsing, type-checking, or a runtime fault), 66 the file
couldn’t be read.
Building Ingle from source
The samples above assume you already have an inglec. Producing one is deliberately dull:
Ingle’s compiler is written in C with no third-party dependencies, so on any machine with a
C compiler and make — macOS or Linux, on x86-64 or arm64 — a single command does it.
make
That builds the everyday compiler at build/inglec — a debuggable -O0 -g build — together with
the small runtime libraries that native binaries link against. To confirm everything actually
works, run the regression suite, which rebuilds whatever is stale and then runs every example in
this book:
make test
Those two are ninety per cent of what you’ll ever type. Everything else the Makefile can do is
below, grouped by why you’d reach for it.
Building the compiler
| Target | Produces | Notes |
|---|---|---|
make (make all) |
build/inglec + libember_rt.a, libember_rt_par.a |
The dev build: -O0 -g, quick to rebuild and debuggable. The two .a files are the runtime inglec -o links into native programs. |
make release |
build/inglec-release |
The optimized -O2 compiler — the one make install ships. |
make parallel |
build/inglec-par |
Same language, multicore runtime: spawn/nursery/channels run on real OS threads (Chapter 14). |
make mn |
build/inglec-mn |
The M:N green-thread scheduler — many fibers multiplexed onto a few OS threads, with structured cancellation-on-failure. Opt-in while it clears a wider soak (Chapter 14). |
make graphics |
build/inglec-gfx |
Links raylib + FreeType. Needs an external library (see below). |
make net |
build/inglec-net |
Links libcurl for HTTPS. Needs an external library. |
make net-graphics |
build/inglec-net-gfx |
Networking + graphics + threads at once — the build the desktop demo uses. Needs both libraries. |
make db |
build/inglec-db |
Links the vendored SQLite amalgamation so a program can import "std/sqlite" (Chapter 15). The one C file lives in-tree, so this still needs no system package. |
Finding bugs
| Target | Produces | Notes |
|---|---|---|
make asan |
build/inglec-asan |
AddressSanitizer build — running a .ig program flags use-after-free / overflow with a stack trace. |
make asan-par |
build/inglec-asan-par |
The same, exercising the cross-thread (parallel) paths. |
make asan-trace |
build/inglec-trace |
ASan plus the double-drop detector — the “memory tape” of Chapter 19. |
Testing, and the four gates
| Target | What it does |
|---|---|
make test |
The regression suite: builds everything, checks the editor grammar is in sync, runs every example. |
make test-update |
Regenerate the snapshot goldens. Review the diff before you trust it. |
make test-lsp |
Language-server regression (the editor integration). |
make test-graphics |
Graphics/UI regression. Needs raylib and a display. |
make test-parallel |
Correctness suite for programs that are only correct under the multicore runtime. |
make crucible |
The memory-ownership fuzzer — generates danger-zone programs and runs each through five oracles (Chapter 20). |
make ceilings |
The compiler-limits stress tester: pushes constants, locals, fields and the rest past the 256 boundary to prove nothing silently wraps. |
make ledger |
The resource-linearity fuzzer: generates Ptr-handle programs with a known accept/reject oracle and checks the compiler’s must-close-on-every-path verdict matches — catching both a leak that compiles and a balanced program wrongly rejected. |
make opcheck |
(new) The bytecode operand-layer gate — proves the encoder, decoder, disassembler and VM all agree on every opcode’s operand widths, so they can’t drift apart. |
Benchmarks
| Target | What it does |
|---|---|
make bench |
Build the release compiler, then run and time every program in benchmarks/. |
make parbench |
Run the concurrency suite under both the serial and parallel compilers and tabulate the speedup. |
Editors, installing, cleaning
| Target | What it does |
|---|---|
make gen-editor-assets |
Regenerate the VS Code syntax grammar from the single source of truth, include/vocab.def. |
make install |
Build release, then install inglec + the standard library to ~/.ingle (override with PREFIX=) so editors and tools find it from any folder. |
make install-vscode |
Package and install the VS Code extension globally. Needs Node/npm and VS Code. |
make clean |
Delete build/. |
The four gates, and why they exist.
crucible,ceilings,ledgerandopcheckare siblings, each guarding one recurring class of compiler bug. Crucible hunts memory-ownership mistakes; ceilings hunts narrow operands that wrap past 255; ledger hunts a leaked or wrongly-rejected linearPtrhandle (the must-close-on-every-path analysis of Chapter 16); andopcheckhunts operand-layout drift — the bug where the code that writes an instruction and the code that reads it quietly disagree on how many bytes it occupies. It works in two halves: a codec round-trip that encodes and decodes every operand kind, and a special-DEMBER_OPCHECKbuild of the VM that, after every instruction across the whole test corpus, asserts the handler consumed exactly the bytes the opcode’s spec declared. Run it after touching any opcode.
A note on dependencies. make, make test, all four gates, the sanitizers and the benchmarks
need nothing but a C compiler — that is the whole point of writing the compiler in C. Only three
targets reach outside the standard library: graphics (and test-graphics) want raylib + FreeType,
found via pkg-config; net wants libcurl, via curl-config; and net-graphics wants both.
make install-vscode additionally needs Node/npm and VS Code. The default path stays
dependency-free and display-free, so you can build and test the entire language on a headless
machine.
Index
Part I — First Light
Part II — Building Things
- Chapter 4 — Functions
- Chapter 5 — Control Flow
- Chapter 6 — Arrays and Iteration
- Chapter 7 — Structs and Methods
- Chapter 8 — Enums and Pattern Matching
Part III — Bigger Ideas
- Chapter 9 — Errors and Optionals
- Chapter 10 — Generics and Interfaces
- Chapter 11 — Functions as Values, and Closures
- Chapter 12 — Ownership Without Tears
- Chapter 13 — Memory, the Quiet Way
- Chapter 14 — Concurrency
- Chapter 15 — Modules and the Standard Library
- Chapter 16 — Talking to C
Part IV — Knowing You’re Right
- Chapter 17 — Contracts
- Chapter 18 — The Verification Loop
- Chapter 19 — The Tape, and Errors as Data
- Chapter 20 — Crucible: The Memory Fuzzer
Part V — Reference
- Chapter 21 — The Whole Toolbox
- Chapter 22 — Compiling to Native
- Chapter 23 — The “Not Yet” List
- Chapter 24 — One-Page Cheat Sheet
Part VI — Things People See
Part I — First Light
Chapter 1 — Hello, Ingle
Here is a complete, working Ingle program. Not a fragment, not “imagine the rest” — the whole thing.
fn main() {
let name = "Ingle"
let year = 2026
println("Hello from {name}, {year}.")
}
Run it:
inglec --emit=run hello.ig
and Ingle says:
Hello from Ingle, 2026.
=> 0
Let’s take that apart, because almost everything you need for the next ten chapters is hiding in those four lines.
fn main() is where it begins. A program is a collection of functions, and execution
starts at the one called main. The fn keyword introduces a function; the empty () means
it takes no arguments. We didn’t write a return type, and that’s allowed for main — it
quietly returns 0, which is the => 0 you saw at the end.
let name = "Ingle" binds a name to a value. We didn’t say name is a string; Ingle
worked it out. More on let (and its mutable sibling var) in the next chapter.
println(...) prints a line. There’s also print, which doesn’t add the newline. Both
are built into the language — you don’t import anything to get them.
"Hello from {name}, {year}." is an interpolated string. Anything inside { } is an
expression that gets computed and slotted into the text. Every string in Ingle can interpolate;
there’s no special prefix to opt in.
A function with a job
main calling println is fine, but functions earn their keep by taking input and returning
output. Here’s one that adds two integers:
fn add(a: int, b: int) -> int {
return a + b
}
fn main() -> int {
return add(20, 22)
}
=> 42
Two new things. First, function parameters must have types — a: int, b: int. Ingle will
happily infer the type of a local let, but at the boundary of a function it insists you
spell things out. That boundary is a contract between the caller and the callee, and it’s
exactly where being explicit pays off (for you, for the next person, and — as we’ll see — for
the machine). Second, -> int declares the return type. Here main returns an int too,
so its result 42 shows up as => 42.
Comments, and the comment that becomes documentation
Ordinary comments run from // to the end of the line. There are no block comments; Ingle
doesn’t have them, and after a while you won’t miss them.
// This is a comment. It explains the line below.
let tau = 6 // comments can sit at the end of a line, too
But three slashes — /// — make a doc comment, and those are special. Placed on the
line(s) just above a declaration, they document it, and Ingle can render them two ways from
the one source: the editor shows them when you hover, and inglec --emit=docs turns them into
a Markdown page. Write the explanation once; get the tooltip and the manual for free.
/// A point on the plane.
/// Copied by value.
struct Point {
/// The horizontal coordinate.
x: int
y: int
}
Four or more slashes (////) are just an ordinary comment again. Ingle reserves exactly three
for documentation, which is a pleasingly specific decision.
No semicolons
You may have noticed the lack of semicolons. This is not an oversight. Ingle has no
statement terminator; a newline ends a statement — but only when the line could sensibly
end there. If a line ends on something that obviously wants more (a +, a comma, an open
bracket, an =), Ingle knows you’re mid-thought and reads on:
let total = 1 + // the trailing '+' means "more coming" — the line continues
2 +
3
The rule of thumb that will keep you out of trouble: break after an operator, never before
it. Put the + at the end of the line, not the start of the next one. Do that and you’ll
never think about this again.
Fireside trivia. Languages disagree fiercely about semicolons. C demands them. Python banned them. Go made them optional by having the lexer secretly insert them at line breaks — and Ingle does essentially the same trick, emitting an invisible terminator only after a token that can legally end a statement. So the semicolons are still there, in a sense. They’ve just gone into hiding and taken your shift-key’s workload with them.
Chapter 2 — Values and Bindings
A binding gives a name to a value. Ingle has two keywords for it, and the entire difference between them is whether you’re allowed to change your mind later.
let name = "Ingle" // immutable: this name will always mean "Ingle"
var count = 0 // mutable: this one can change
count = count + 1 // ...like so
let is immutable. Once bound, it stays bound. var is mutable; you can reassign it
as often as you like. Two short words, one each, no modifiers to forget.
If you try to reassign a let, the compiler stops you — and, importantly, it tells you
exactly how to fix it:
let a = 5
a = 6
error: cannot assign to an immutable 'let' binding; declare it with 'var'
This is a theme you’ll see again and again: Ingle’s error messages are written to be read. They name your problem in the words of your program and, where they can, tell you the move that fixes it. The compiler is trying to be a teacher, not a bouncer.
Types are optional here (but allowed)
Locals infer their type from the value, so you rarely annotate them. When you want to be explicit — for clarity, or to pin down a specific numeric width — you can:
let year: int = 2026
let pi: float = 3.14159
A small, tidy detail: a binding’s right-hand side is worked out before the new name exists. So this does what you’d hope:
let a = 10
let a = a + 1 // the 'a' on the right is the old 'a' (10); the new 'a' is 11
The second a shadows the first. The new binding reuses the name, and the old value is
referred to one last time on the way in. This is legal and often handy — refining a value
through a short pipeline of lets without inventing a1, a2, a3.
Constants: a let at the top level
A let written at the top of a file, outside any function, is a module constant — a
named value fixed at compile time:
let WIDTH = 800
let TITLE = "Ingle"
fn main() -> int {
println(TITLE)
return WIDTH
}
Ingle
=> 800
Constants must be initialised with a literal — a number, a string, true/false, or a
negative number. A constant may carry a type annotation, and the compiler honours it: the
literal adopts the declared width — let MAX: u8 = 200 is a u8, not an int — and a value
that doesn’t fit or match the annotation is a compile error (let MAX: u8 = 300 is out of
range; let TITLE: int = "Ingle" is a type mismatch), caught at build, not left to surprise you.
You can’t yet write let x = some_function() at the top level; general
mutable globals aren’t a thing in Ingle today, and honestly they’re a misfeature you’ll not
miss. Constants cost nothing at runtime: each use is substituted with the value directly. They
also make excellent colours, key codes, size limits, and configuration knobs.
The discard: _
Sometimes you want a value’s effect but not the value itself. The underscore _ is Ingle’s
write-only discard: bind to it and the right-hand side is evaluated, then thrown away.
let _ = save(record) // run it for the side effect; ignore the status it returns
for _ in 0..3 { ring() } // do this three times; the loop index isn't needed
The same _ is the catch-all arm in a match (Chapter 8),
and it means the same thing everywhere: I don’t need this one. It is genuinely write-only — you
can’t read _ back, and you can’t name a function, struct, or enum _ either. Try, and the
compiler says so plainly: a function cannot be named '_' — it is the write-only discard, not a
usable name.
Fireside trivia. “Immutable by default” sounds like a modern fad, but it’s closer to a return to manners. The earliest functional languages treated reassignment as the unusual, slightly suspicious operation. Decades of C-family languages then made
x = 5so cheap and so default that we forgot it was a choice. Ingle is quietly siding with the old guard: you can change a value, but you have to look it in the eye and typevarfirst.
Chapter 3 — Numbers, Text, and Truth
Three kinds of value will carry most of your programs: numbers, strings, and booleans. Ingle has firm opinions about all three, and the opinions are mostly “be explicit, and don’t let anything happen behind your back.”
Numbers
For everyday work there are two number types:
int— a 64-bit signed integer. (It’s an alias fori64.)float— a 64-bit floating-point number. (An alias forf64.)
Use those by default. When you genuinely care about width — packing bytes, matching a file
format, squeezing memory — the full family is there: i8 i16 i32 i64, u8 u16 u32 u64, and
f32 f64. Each is a distinct type.
let n = 42 // int
let big: i64 = 42 // the same thing, spelled out
let small: u8 = 200 // an 8-bit unsigned byte
let x = 200u8 // or pin the width with a suffix
Two rules will save you a great deal of confusion:
1. There is no implicit coercion. Ingle will never quietly turn an i32 into an i64, or
an int into a float, to make an expression type-check. Mixing them is an error you must
resolve on purpose:
let bad = 1 + 2.0 // error: you can't add an int and a float
To cross between types you convert explicitly. Integer widths convert with a type-name call —
u8(x), i32(x), i64(x) — which is range-checked and traps if the value won’t fit.
Between integers and floats you use to_float and to_int (the latter truncates toward
zero):
let a: int = 7
let b: float = to_float(a) // 7.0
let c: int = to_int(3.9) // 3
let d: u8 = u8(200) // fine; u8(300) would trap at runtime
There’s a neat split worth knowing: an out-of-range literal is caught at compile time —
let x: u8 = 300 simply won’t build (“integer literal is out of range for its type”) — while
an out-of-range conversion like u8(300) is a runtime trap. The compiler catches what it
can see at compile time; the runtime guards the rest.
2. Overflow traps; it does not wrap. If an integer calculation overflows its width, the program stops with a clear runtime error rather than silently rolling over to a wrong answer:
fn main() -> int {
var x = 200u8
x = x + 100u8 // 300 doesn't fit in a u8
return 0
}
inglec: runtime error: integer overflow
This is a deliberate safety choice. A silent wrap is one of the great sources of catastrophic, hard-to-find bugs; Ingle would rather halt and tell you. (Floating-point follows the usual IEEE-754 rules — dividing by zero gives infinity, not a trap.)
One small display quirk worth knowing now, so it doesn’t startle you later: a float that
happens to be a whole number prints without a trailing .0. A distance of five prints as
5, not 5.0. It’s still a float under the hood; it just doesn’t show off about it.
Strings
A string is text in double quotes. The familiar escapes work — \n, \t, \r, \\, \" —
and to write a literal brace (so it isn’t read as interpolation) you escape it: \{ and \}.
That { } is interpolation — "value is {x}" splices x into the string. A hole renders a
number, a string, or a bool on its own, and any value of your own type once it carries a
fn show(self) -> string method (Chapter 7 shows how).
+ joins strings, and == / != compare them by content, not by identity — two strings
that spell the same thing are equal:
let greeting = "Hello, " + "world"
let same = ("ab" == "a" + "b") // true
Strings carry a handful of built-in methods. Ingle strings are UTF-8 — multi-byte Unicode
characters are fully supported. Most methods operate in bytes (fast, ASCII-safe), and a separate
family of cp_* functions lets you work in code points when you need Unicode-correct behaviour:
s.len()— the number of bytes (not code points; they differ for non-ASCII text).s.chars()— an array of strings, one per code point (not per byte).s.bytes()— an array ofu8values, one per byte.s.char_count()— the number of Unicode code points.s.split(sep)— split into an array of strings on a separator.s.parse_int()— try to read the string as an integer; gives backSome(n)orNone(see Chapter 9).
For Unicode-correct editing and slicing, the cp_* builtins index by code point:
cp_count(s), cp_at(s, i), cp_slice(s, start, end), cp_prefix(s, n),
cp_insert(s, idx, ins), cp_delete(s, idx).
fn main() -> int {
let parts = "12,34,56".split(",")
println("{parts.len()} parts") // 3 parts
match parts[0].parse_int() {
case Some(n) { println("first is {n}") } // first is 12
case None { println("not a number") }
}
return 0
}
There’s a fuller set of string operations — trim, to_upper, contains, replace,
join, and friends — but those live in the standard library and arrive in
Chapter 15. The four above are baked into the
language itself.
A gotcha worth the ink. Building a big string by repeatedly doing
out = out + piecein a loop is quadratic — each+copies everything so far, because strings are immutable. For a handful of pieces, who cares. For thousands, collect them in an array and join once. The standard library’s string builders do exactly this. Immutability is a lovely property right up until you fight it in a hot loop.
Truth
bool is its own type, with exactly two values, true and false. It is not a number,
and this matters more than it sounds:
fn main() -> int {
if 1 { return 1 } // nope
return 0
}
error: 'if' condition must be a bool
There is no “truthiness” in Ingle. 1 is not “truthy,” 0 is not “falsy,” the empty
string is just a string. A condition must be an actual bool. This feels strict for about a
day and then feels like a handrail for the rest of your life — a whole category of “oops, that
was zero” bugs simply cannot be written.
The logical operators are && (and), || (or), and ! (not). && and || short-circuit,
just as you’d expect: the right side isn’t evaluated if the left already decides the answer.
Working at the bit level
When you want to manipulate the actual bits of an integer — packing flags, masking, hashing,
writing a codec — Ingle has the full set of bitwise operators: & (and), | (or), ^ (xor),
~ (not), and the shifts << and >>. These are integer-only and width-aware: they refuse
floats, and they respect the type’s width, so ~ on a u8 flips eight bits, not sixty-four.
fn main() -> int {
let a: u8 = 6 // 0000 0110
let b: u8 = 3 // 0000 0011
println("and {a & b}") // 2 — bits set in both
println("or {a | b}") // 7 — bits set in either
println("xor {a ^ b}") // 5 — bits set in exactly one
println("shl {a << 1}") // 12 — shifted left one place
println("shr {a >> 1}") // 3 — shifted right one place
println("not {~b}") // 252 — every bit of a u8 flipped
return 0
}
and 2
or 7
xor 5
shl 12
shr 3
not 252
=> 0
Two details that keep shifts honest. The shift amount must land in [0, width) — shifting a
u8 by 8 or more is a runtime trap, not a quiet zero — and >> is arithmetic (sign-preserving)
on signed types but logical (zero-filling) on unsigned ones, so a negative i32 >> 1 stays
negative while a u32 >> 1 always pulls in zeros. The shipped examples/12_bits.ig puts all this
to work twice over: a complete xorshift PRNG built from nothing but <<, >>, and ^, and a set
of Unix-style permission flags packed into a single byte and toggled with masks.
A built-in safety.
&is bits and&&is logic, and the two never overlap — bitwise operators are never defined onbool— so the classic slip of writing&when you meant&&can’t quietly compile. The overflow discipline from earlier in this chapter applies here too: shifts are range-checked, and arithmetic traps rather than wrapping.
When you actually want wrap-around. Trapping on overflow is the right default, but a whole family of primitives — non-cryptographic hashes like FNV-1a and MurmurHash, multiply-mixing PRNGs like PCG — depend on integer arithmetic wrapping round at the type’s width. So Ingle gives you exactly that, explicitly, through three builtins:
wrapping_add,wrapping_sub, andwrapping_muleach take two integers of one width and compute modulo 2ʷ with no trap (there’s no wrapping/or%— overflow there isn’t a real thing). It’s the same philosophy asmove: the dangerous-looking operation is available, but you have to name it, so nobody — and no model — reaches for silent wraparound by accident. With it, a real FNV-1a hash is a six-line loop in pure Ingle; without it you’d be back in C. (Shift-and-xor designs like xorshift never overflow at all, so they need none of this —examples/12_bits.igstays library-free.)
The precedence table, once, so you never wonder
From loosest-binding to tightest, all binary operators grouping left-to-right:
| Tightness | Operators |
|---|---|
| loosest | \|\| |
&& |
|
\| (bitwise or) |
|
^ (bitwise xor) |
|
& (bitwise and) |
|
== != |
|
< <= > >= |
|
<< >> (shift) |
|
+ - |
|
| tightest | * / % |
Prefix !, unary -, and ~ (bitwise not) bind tighter than all of those; the postfix forms
(calling a function f(...), reaching into a value a.b, indexing a[i], and the ? operator)
bind tightest of all. When in doubt, parenthesise — it’s free and it’s kind to the next reader.
This mirrors C exactly, which means it inherits C’s one genuinely surprising row: the bitwise
operators bind looser than the comparisons, so a & b == c parses as a & (b == c), almost
never what you meant. The fix is the same as in C — parenthesise mixed bitwise-and-comparison
expressions — and Ingle’s strict typing tends to catch the rest, since b == c is a bool and
& wants integers.
Fireside trivia. The
%operator is “remainder,” and it insists on integer operands — no%on floats. The percent sign for “modulo” is one of computing’s odder inheritances: it has nothing to do with percentages. It was chosen in early languages mostly because it was a spare symbol on the keyboard that wasn’t doing anything else. Half of programming syntax is just whatever punctuation happened to be unemployed at the time.
Part II — Building Things
Chapter 4 — Functions
You’ve already met functions; let’s make the relationship official.
fn add(a: int, b: int) -> int {
return a + b
}
fn fib(n: int) -> int {
if n < 2 { return n }
return fib(n - 1) + fib(n - 2) // recursion is welcome
}
fn main() -> int {
return add(fib(10), 1) // => 56
}
The shape is fn name(params) -> ReturnType { body }. A few firm rules:
- Parameter types are mandatory. No inference at the boundary.
- Parameters are immutable. Inside the function, a parameter behaves like a
let— you can read it all you like, but you can’t reassign it. If you want a mutable working copy, make one withvar. - Arguments are checked for count and type, with no coercion. Call
add(fib(10), 1)and Ingle confirms you passed exactly twoints. - Order doesn’t matter.
fibcan call itself, and any function can call any other function defined anywhere in the file. Ingle reads all the signatures first, then checks the bodies, so forward references and mutual recursion Just Work — no forward declarations, no header files.
Functions that return nothing
If you leave off the -> T, you’ve written a unit function — one that runs for its effect
and produces no value:
fn greet(name: string) {
println("Hello, {name}")
return // a bare 'return' is fine; so is just falling off the end
}
fn main() {
greet("Ingle")
}
Inside a unit function, a bare return (with no value) bails out early, and reaching the
closing brace returns nothing at all. What you can’t do is return a value from one
(return 5 in a unit function is an error), and you can’t capture its non-existent result:
let x = greet("Ingle") // error: there's no value here to bind
This is the same principle as the strict bool: Ingle won’t let you pretend a nothing is a
something. main is allowed to be a unit function too — as you saw in Chapter 1, it then
quietly hands back 0.
Fireside trivia. The word “unit” comes from type theory, where the type with exactly one value is called the unit type. A function that “returns nothing” actually returns that single, contentless value — there’s nothing to choose, so there’s nothing to say. “Void” and “unit” are the same idea wearing different hats: one borrowed from C’s bookkeeping, the other from mathematics’ tidy bookkeeping.
Chapter 5 — Control Flow
This is the chapter where programs start making decisions and going round in circles, which is most of what programs do.
if / else
fn classify(n: int) -> int {
if n < 0 {
return -1
} else if n == 0 {
return 0
} else {
return 1
}
}
Braces are always required — there’s no “one-liner without braces” form to trip over. The
condition must be a bool (we covered why in Chapter 3: no truthiness, ever). else if
chains as far as you need.
loop, break, continue
Ingle’s one looping primitive is loop, which loops forever until you break out of it.
continue skips to the next turn.
fn sum_to(n: int) -> int {
var i = 0
var total = 0
loop {
if i >= n { break }
total = total + i
i = i + 1
}
return total
}
break and continue are only legal inside a loop; using them anywhere else is a compile
error. You might be looking at that loop and thinking “where’s while? where’s the C-style
for?” — and the answer is the next chapter, which has a much nicer for. For counting and
walking over data you’ll reach for that; loop is for the genuinely open-ended cases.
Blocks and scope
Every { } is a scope. A let or var declared inside one lives until that block’s closing
brace and no longer — if, else, loop, and even a bare { } you write yourself all
create a fresh scope. And, as we saw with bindings, an inner name may shadow an outer one:
fn main() -> int {
let x = 1
{
let x = 99 // a different x, just for this block
println("{x}") // 99
}
println("{x}") // 1 — the outer x was never touched
return x
}
This is exactly the block scoping you know from C-family languages, with no surprises. The
shadowing is the one thing C# doesn’t let you do (it forbids a local shadowing another local);
Ingle permits it, because it’s genuinely useful for refining a value step by step, and the
strict immutability of let keeps it from being confusing.
Chapter 6 — Arrays and Iteration
An array is a growable, in-order sequence of values that all share one type. Written [T] —
so [int] is an array of ints, [string] an array of strings.
fn main() -> int {
let xs = [10, 20, 30]
println("{xs[0]}") // 10 — index from zero
println("{xs.len()}") // 3 — how many
return xs[2] // 30
}
Indexing is bounds-checked: reach past the end and the program stops with a runtime error
rather than reading rubbish. Every element must be the same type; an empty literal [] takes
its type from context (let a: [int] = []).
Growing and shrinking
Through a mutable place (a var, or a mut parameter — Chapter 12 has the full story),
arrays grow and change in place:
fn main() -> int {
var a: [int] = []
a.append(1) // grow by one (amortised O(1))
a.append(2)
a.append(3)
a[0] = 10 // assign to an element
let last = a.remove_last() // takes the last element off and hands it back: 3
return a[0] + a.len() + last // 10 + 2 + 3 = 15
}
The core array methods are append(x) (grow by one), remove_last() (take the last element
off and hand it back — a runtime error if the array is empty), remove_at(i) (remove and
return the element at index i, shifting the rest down — a runtime error if i is out of
range), and len(). The free function len(a) works too if you prefer. Reading and indexing
don’t need a mutable place; growing, removing, and assigning do.
Walking over things: for ... in
for x in collection binds each element in turn:
fn main() -> int {
let xs = [10, 20, 30]
var sum = 0
for x in xs {
if x == 20 { continue } // skip the 20
sum = sum + x // 10 + 30
}
return sum + len(xs) // 40 + 3 = 43
}
for also walks an integer range written lo..hi. The range is exclusive of the top,
so 0..n gives you 0, 1, ..., n-1 — exactly the indices of an n-element array, which is
not a coincidence:
var sum = 0
for i in 0..10 { sum = sum + i } // 0+1+...+9 = 45
A range that’s empty or backwards (5..5, or 9..3) simply runs zero times, no drama.
And when you want both the position and the element, there’s a form for that:
let names = ["ada", "alan", "grace"]
for (i, name) in names {
println("{i}: {name}")
}
0: ada
1: alan
2: grace
That’s the whole iteration story, and it’s deliberately small: for x in a for elements,
for i in a..b for a counter, for (i, x) in a for both. Three forms, three distinct jobs,
no overlap. There’s exactly one range operator (.., exclusive) on purpose — an inclusive
version would just be a second way to write lo..hi+1, and Ingle dislikes having two ways to
say one thing.
A small performance note you can mostly ignore. The
forforms aren’t just tidier than a hand-rolledloopwith a counter — they’re faster, because each compiles down to a single fused machine step (increment, bounds-check, and for arrays the element fetch, all at once) instead of the dozen-odd instructions a manual counter spends per turn. The idiomatic form is also the quick one.
One thing you can’t do: a range only exists as something to iterate. let r = 0..5 on its
own is an error — ranges aren’t first-class values, just a way to drive a for.
Slices: a borrowed window, no copy
arr[lo..hi] is a slice — a read-only view into part of an array, from lo (inclusive) to
hi (exclusive), made with no copying at all. Its type is Slice<T>, and you treat it like
an array: index it, ask its len(), walk it with for, even slice it again.
fn sum(xs: Slice<int>) -> int {
var t = 0
for x in xs { t = t + x }
return t
}
fn main() -> int {
let data = [10, 20, 30, 40, 50]
let win = data[1..4] // a view of [20, 30, 40] — nothing allocated
let mid = win[1..2] // a slice of a slice → [30]
return sum(win) + mid[0] // 90 + 30 => 120
}
=> 120
A slice borrows the array it looks into, and the compiler keeps that honest the same way it
does everywhere else. While a slice is alive its source array is frozen — you can’t
append to it, reassign it, or move it, since that could pull the ground out from under the
view — and a slice can’t escape: it may be a parameter or a local, but never a return value,
a struct field, or an array element. When you genuinely need to keep a sub-array, reach for
the copying companion arr.slice(lo, hi), which hands back a fresh, owned [T] you can
store or return.
.clone(): an explicit deep copy
Because arrays and structs are uniquely owned (Chapter 12 has the full story), the compiler
won’t let you quietly make a second owner of one. Reading a struct straight out of an array
element to stash elsewhere is a compile error, in fact — a shallow copy would alias the
element’s heap fields and free them twice. When you genuinely want an independent copy, ask for
one out loud with .clone():
fn main() -> int {
var nums = [1, 2, 3]
var copy = nums.clone() // an independent deep copy
copy.append(4)
return nums.len() + copy.len() // 3 + 4 => 7 (nums itself is untouched)
}
=> 7
x.clone() copies recursively — array elements, struct fields, and everything they own — so
changing the clone never reaches the original, and vice versa. It works on arrays and on structs,
including the generic ones like Map<K, V> and Set<K>. The cost is always visible at the
call site; Ingle never deep-copies a value behind your back.
Fireside trivia. “Off-by-one errors are the two hardest problems in computer science.” The exclusive-end range is the industry’s hard-won answer to half of them: when the top is exclusive, the length of
0..nis justn, two adjacent ranges0..kandk..nmeet perfectly with no overlap or gap, and you almost never write<=by mistake. Dijkstra wrote an entire note in 1982 arguing for exactly this convention. Ingle simply makes it the only option and saves you the argument.
Chapter 7 — Structs and Methods
A struct bundles several named, typed fields into one value.
struct Point {
x: float
y: float
fn distance(self, other: Point) -> float {
let dx = self.x - other.x
let dy = self.y - other.y
return sqrt(dx * dx + dy * dy)
}
}
fn main() -> int {
let a = Point { x: 0.0, y: 0.0 }
let b = Point { x: 3.0, y: 4.0 }
println("{a.distance(b)}") // 5
return 0
}
You construct a struct with Type { field: value, ... }, and you must set every field
exactly once — Ingle won’t let you forget one or leave it to chance. You read a field with
value.field.
Methods and self
A struct can carry methods, written inside its braces. A method takes self as its first
parameter, explicitly — that’s the receiver, the value the method was called on. Inside, you
read its fields as self.x and call its other methods as self.whatever(). Dispatch is
static: Ingle knows the exact type at the call site, so there’s no virtual-call overhead.
Notice distance calls sqrt, which is a built-in maths function — no import needed. (Its
friends pow, abs, floor, ceil, round, and random are all built in too; the rest of
the maths lives in the standard library.)
Changing a field
You can change a field, but only through a mutable place — a var binding, or a mut
parameter (full details in Chapter 12). Through a let
or a plain parameter, fields are read-only.
struct Pt { x: int y: int }
struct Line { a: Pt b: Pt }
fn main() -> int {
var ln = Line { a: Pt { x: 1, y: 2 }, b: Pt { x: 3, y: 4 } }
ln.a.x = 10 // mutate a nested field through the var
return ln.a.x + ln.b.y // 10 + 4 = 14
}
Notice you can reach right down a nested path — ln.a.x = 10 — and Ingle writes it back where
it belongs. Try the same thing through a let and you’ll get a polite compile error pointing
you at var.
Making your own values printable: show
Interpolation renders a number, a string, or a bool on its own. To make a value of your own type
interpolate, give it a method fn show(self) -> string — that is the entire opt-in:
struct Temp {
celsius: float
fn show(self) -> string { return "{self.celsius}°C" }
}
fn main() -> int {
let t = Temp { celsius: 21.5 }
println("it is {t}")
return 0
}
it is 21.5°C
=> 0
The presence of show is the whole contract — there’s no implements to write (it’s structural,
like Go’s Stringer), and "{t}" quietly becomes "{t.show()}". A value whose type has no show
gets a clear compile error naming the missing method rather than a mystery rendering. (Writing
implements Show as well is allowed, and lets the type stand in wherever a Show value or a
T: Show bound is wanted — but it isn’t needed just to interpolate.)
Interfaces and implements
An interface is a list of method signatures — a promise about what a type can do. A struct
declares it keeps that promise with implements, and the compiler checks that it actually
does:
interface Ord {
fn compare(self, other: Self) -> int // negative, zero, or positive
}
struct Version implements Ord {
number: int
fn compare(self, other: Version) -> int { // 'Self' is Version here
return self.number - other.number
}
}
fn main() -> int {
let a = Version { number: 5 }
return a.compare(Version { number: 4 }) // => 1
}
Conformance is nominal: you have to say implements Ord, it isn’t inferred from the
shape of your methods. Name an interface you don’t satisfy — miss a method, get a signature
wrong, misspell the interface — and it’s a compile error, named clearly. Note the capital-S
Self: inside an interface it stands for “whatever type ends up implementing me,” and it
resolves to Version in the struct above. (Lower-case self is the receiver value;
capital Self is the type. One letter, two jobs — keep them straight.)
Interfaces earn their keep three ways. They check that a type provides the right methods (just
now); they bound generics so a <T: Ord> function may call compare on its parameter
(Chapter 10); and — this one’s new, and worth a section of
its own — they work as value types, which is how you get runtime polymorphism without
inheritance.
Interfaces as values: dynamic dispatch
Everything above is resolved at compile time: the receiver’s exact type is known, so a method call goes straight to the right code. But sometimes you genuinely don’t know the concrete type until the program runs — you want a list of different shapes, each computing its own area, all behind one type. For that, use the interface itself as a type:
interface Shape {
fn area(self) -> float
}
struct Circle implements Shape {
radius: float
fn area(self) -> float { return 3.14159 * self.radius * self.radius }
}
struct Rect implements Shape {
w: float
h: float
fn area(self) -> float { return self.w * self.h }
}
fn main() -> int {
let shapes: [Shape] = [Circle { radius: 2.0 }, Rect { w: 3.0, h: 4.0 }]
var total = 0.0
for s in shapes { total = total + s.area() }
println("total {total}")
return 0
}
total 24.5664
=> 0
A [Shape] holds a Circle and a Rect side by side, and s.area() calls the right one for
each. The moment you put a Circle where a Shape is expected, Ingle upcasts it: the value
becomes a small box pairing the receiver with a table of its methods. A call then looks the
method up in that table at run time. One fn report(s: Shape) serves every implementer you’ll
ever write, with no shared base class in sight. The shipped examples/13_interfaces.ig does
exactly this with three shapes and a report function.
There’s one rule on which interfaces can be used this way, and it’s a sensible one: the
interface must be object-safe, meaning no method may mention Self anywhere but the receiver.
The reason is concrete — once a value is hidden behind Shape, its real type is erased, so a method
like compare(self, other: Self) has nowhere to get a second value “of the same type.” Ingle
tells you so plainly if you try:
interface Ord {
fn compare(self, other: Self) -> int // 'other: Self' — not object-safe
}
let xs: [Ord] = [ ... ]
error: this interface can't be used as a value type: one of its methods uses 'Self' beyond the
receiver, which dynamic dispatch can't honor. Use it as a generic bound instead (e.g. fn f<T: Name>(x: T)).
That’s not a limitation so much as a signpost: an interface like Ord is perfectly usable — just
as a generic bound, where the concrete type is still known, which is the very next chapter. Use
Shape-style interfaces (methods that only ever take self) as values, and Ord-style interfaces
(methods comparing two of a kind) as bounds.
Fireside trivia. Type systems split into two camps on this. Structural typing says “if a type has the methods, it qualifies.” Nominal typing — Ingle’s choice — says “it qualifies only if it declares that it does,” which is what
implementsis for. The case for nominal is the accidental match: two types with acomparemethod that mean entirely different things by it. “It saysimplements Ordright there” is a hard line for a confused reader, human or machine, to misread.
Newtypes: a name the compiler keeps straight
That nominal instinct — a type is what it says it is — has a lighter use than a whole interface. A newtype gives an existing type a new name that the compiler then treats as genuinely distinct:
type UserId = int
type Email = string
fn main() -> int {
let id: UserId = UserId(42)
let mail: Email = Email("ada@ember.dev")
println("user {id} <{mail}>")
return 0
}
user 42 <ada@ember.dev>
=> 0
A UserId is an int at run time — the wrapper erases completely, on both the VM and the
native backend, so it costs nothing. What you get for it is a name the compiler refuses to confuse
with any other int. Pass a UserId where a different id is expected and the program simply
doesn’t build:
type UserId = int
type ProductId = int
fn main() -> int {
let u: UserId = UserId(1)
let p: ProductId = u // a UserId is not a ProductId
println("{p}")
return 0
}
error: binding annotation does not match the value's type
That’s the swapped-argument and unit-confusion bug class — transfer(to, from, amount) called
with to and from reversed, or cents handed to code counting dollars — turned into a compile
error. A newtype still inherits its base’s ==, ordering, hashing, and rendering, so it compares,
sorts, serves as a Map key, and interpolates in "{...}" directly. The one thing it won’t do
behind your back is arithmetic: a UserId is an identity, not a quantity, so adding to one is a
question Ingle makes you ask out loud —
error: arithmetic on a newtype requires unwrapping to its base first (e.g. `int(x)`), then re-wrapping the result
— unwrap with the base conversion (int(m)), compute, then re-wrap.
Refinements: a promise the type carries
A newtype over a numeric or bool base may add a where predicate over self — a refinement
type. The predicate is an ordinary bool expression, checked once, at construction:
type Percent = int where 0 <= self && self <= 100
fn main() -> int {
let p: Percent = Percent(80)
println("{p}%")
return 0
}
80%
=> 0
Construct one from a value the predicate rejects and it doesn’t quietly carry on — it traps with a structured fault that names the type and the line:
error[refinement_violation]: refinement violated constructing 'Percent' (line 4)
Once a Percent exists, every reader downstream knows it’s in range without re-checking — the
type is the proof. This is the contract machinery from Chapter 17 aimed
at a value’s construction instead of a function’s entry, and it plays by the same rules: checked in
debug builds, elided in --release. (For now the check is always a runtime one; discharging it
statically through the prover is a later addition.)
Fireside trivia. “Make illegal states unrepresentable” is an old slogan that usually costs a hand-written wrapper type with a private field and a validating constructor. A refinement is that wrapper in a single line — and because the validity lives in the type, the proof travels with the value instead of in a comment that asks the next reader to trust it.
Chapter 8 — Enums and Pattern Matching
If a struct is “all of these fields at once,” an enum is “exactly one of these possibilities.”
enum Shape {
Circle(radius: float)
Rect(width: float, height: float)
Origin // a variant with no data needs no parens
}
A Shape value is one of those three things: a circle with a radius, a rectangle with a
width and height, or the origin. Each variant can carry its own typed, named fields. You build
one by naming the variant, positionally:
let c = Circle(2.0)
let r = Rect(3.0, 4.0)
let o = Origin
Because the fields are named in the declaration, you can also build a variant by name, exactly like a struct literal — clearer when the positional order isn’t obvious, and free to reorder:
let c = Circle(radius: 2.0)
let r = Rect(width: 3.0, height: 4.0) // or Rect(height: 4.0, width: 3.0)
Both forms mean the same thing; pick whichever reads better.
match: handling every case
You take an enum apart with match, which checks the value against each variant and runs the
matching arm, binding that variant’s fields as local names:
fn area(s: Shape) -> float {
match s {
case Circle(r) { return 3.14159 * r * r }
case Rect(w, h) { return w * h }
case Origin { return 0.0 }
}
}
Three properties make match trustworthy:
- It’s exhaustive. Handle every variant or the program won’t compile. Add a fourth shape
six months from now and the compiler will march you to every
matchthat forgot about it. - There’s no fallthrough. The first matching arm wins and that’s the end of it; no
breakneeded, no accidental tumble into the next case. - It binds fields cleanly. In
case Circle(r), the nameris the circle’s radius, scoped to just that arm.
The catch-all: case _
When you genuinely want “everything else,” case _ handles every variant an earlier arm
didn’t. It keeps a match exhaustive without listing the whole world, and it must come last
(anything after it would be unreachable):
match colour {
case Red { return 1 }
case Green { return 2 }
case _ { return 0 } // any other colour
}
(Be aware that _ inside a variant pattern, like case Some(_), is just an ordinary
ignored binding — “there’s a value here but I don’t care about it” — not the catch-all.)
A couple of fine points
Within a module, variant names are distinct — two enums you can see at once can’t both have a
Red, and none may collide with the prelude’s Some/None/Ok/Err. Across different modules
they’re free to repeat, since each module sees only its own. Either way, you can name a variant bare
(Origin) or spelled out through its enum (Shape.Origin, Circle or Shape.Circle), whichever
reads better; they mean the same thing.
And one syntactic quirk you’ll meet eventually: in the header of an if, for, or match,
Ingle switches off struct-literal syntax so that the { can unambiguously start the block. If
you ever genuinely need a struct literal right there, wrap it in parentheses. In practice this
almost never comes up, but now it won’t puzzle you when it does.
Fireside trivia. Sum types — the formal name for these — are decades old (ML had them in the 1970s) and spent a long time confined to academic and functional languages while the mainstream made do with null and inheritance. Their slow conquest of industry is one of the quiet happy endings in language design: Rust, Swift, Kotlin, TypeScript, and modern C# all have them now. Ingle was never going to not have them. They were the first thing on the list.
Part III — Bigger Ideas
Chapter 9 — Errors and Optionals
Ingle has no exceptions and no null. Nothing is ever secretly absent, and nothing throws
past you up an invisible staircase of stack frames. When something can fail or be missing, that
fact is written into the type, and you deal with it where it happens.
Two enums carry this, and — this is the lovely part — they’re not special built-ins. They’re ordinary generic enums (Chapter 10) that happen to be so useful they’re provided to every program automatically:
Option<T>isSome(value)orNone— “aT, or nothing.”Result<T, E>isOk(value)orErr(error)— “aT, or an error of typeE.”
Option: maybe there’s a value
fn safe_div(a: int, b: int) -> Option<int> {
if b == 0 { return None }
return Some(a / b)
}
fn main() -> int {
match safe_div(10, 2) {
case Some(v) { return v } // v is the int, 5
case None { return 0 }
}
}
=> 5
There’s no way to “forget to check.” To get the value out, you match, and the compiler makes
you handle the None. The entire family of null-pointer bugs is structurally impossible.
Result: it worked, or here’s why it didn’t
fn checked(n: int) -> Result<int, string> {
if n < 0 { return Err("negative") }
return Ok(n)
}
Result is Option with a reason attached. When something fails, you don’t just learn that
it failed; you get an error value explaining it.
The ? operator: stop the boilerplate
Checking every Result by hand gets tedious fast — the dreaded pyramid of “if this failed,
return the failure.” The ? operator collapses it. Put ? after an expression that yields a
Result (or an Option): if it’s Ok/Some, you get the value and carry on; if it’s
Err/None, the whole function returns that failure immediately.
fn sum(a: int, b: int) -> Result<int, string> {
return Ok(checked(a)? + checked(b)?) // any Err here short-circuits the whole function
}
fn main() -> int {
match sum(10, 20) {
case Ok(v) { println("ok {v}") return v }
case Err(e) { println("err {e}") return -1 }
}
}
ok 30
=> 30
The catch — and it’s a sensible one — is that ? can only propagate a failure your function is
declared to return. Use ? on a Result<_, string> and your function must itself return a
Result with that same string error type; use it on an Option and your function must
return an Option. The failure always has somewhere legal to go.
Fireside trivia. Tony Hoare introduced the null reference in 1965 and later called it his “billion-dollar mistake,” estimating the cost of the bugs, crashes, and security holes it caused over the following decades. He was almost certainly low-balling it. Ingle is one of a growing number of languages built by people who took that confession seriously and simply declined to include the feature.
Chapter 10 — Generics and Interfaces
A generic is code written once that works for many types. You’ve been using them already —
Option<T>, Result<T, E>, and [T] are all generic. Now let’s write our own.
Generic structs and enums
Put one or more type parameters in angle brackets after the name:
struct Box<T> {
value: T
fn get(self) -> T { return self.value }
fn replaced(self, n: T) -> Box<T> { return Box<T> { value: n } }
}
struct Pair<A, B> {
first: A
second: B
}
fn main() -> int {
let b = Box<int> { value: 3 }
let p = Pair<int, int> { first: 3, second: 4 }
return b.replaced(7).get() + p.first + p.second // 7 + 3 + 4 = 14
}
=> 14
Each instantiation — Box<int>, Box<string>, Pair<int, bool> — is its own distinct
type. You write the type arguments explicitly when you construct (Box<int> { value: 3 }), and
they nest happily (Box<Box<int>>). Enums are generic the same way, which is the whole secret
behind Option<T> and Result<T, E> being library types rather than magic.
Generic functions, and inference
Functions and methods can be generic too, and here you usually don’t write the type arguments — Ingle infers them from what you pass and what you expect back:
fn identity<T>(move x: T) -> T {
return x
}
fn main() -> int {
println(identity("hi")) // T inferred as string
return identity(7) // T inferred as int
}
hi
=> 7
No “turbofish,” no identity<int>(7) ceremony at the call. The type just flows in.
The
moveyou may not have expected. Did you noticemove xup there? A plain parameter only borrows, and you can’t return a borrowed value — it would escape the function. For a concrete type you’d hit the same rule; for a genericT, which Ingle treats as an owned value by default, returning the argument means taking itmove. This is real and the compiler enforces it. The whole ownership story is the next chapter; for now, just know that “a generic function that hands its argument back out” is writtenmove.
Bounds: asking a type parameter to do something
Inside a generic body, T is opaque — you can pass it around, store it, return it, but you
can’t (say) call methods on it, because you’ve no idea what type it’ll be. A bound fixes
that by requiring T to implement an interface:
interface Ord {
fn compare(self, other: Self) -> int
}
struct Version implements Ord {
number: int
fn compare(self, other: Version) -> int { return self.number - other.number }
}
fn max<T: Ord>(move a: T, move b: T) -> T { // T must implement Ord
if a.compare(b) >= 0 { return a } // ...so now we may call compare on it
return b
}
<T: Ord> says “T, but only types that implement Ord.” Now a.compare(b) is legal,
because every possible T is guaranteed to have it. And the guarantee is enforced from the
other side too — try to use a type that doesn’t qualify and you’re stopped:
fn main() -> int { return max(1, 2) } // error: type argument does not satisfy the generic bound
int doesn’t implement Ord (or any interface — yet), so max(1, 2) is rejected at compile
time, by name. No surprises at runtime.
The Copy bound
There’s one special bound, Copy, for the opposite need: “this T can be freely duplicated,
so I don’t need move.”
fn id<T: Copy>(x: T) -> T { // no 'move' needed — Copy means it can be duplicated
return x
}
One detail here is worth knowing: Copy means every type except a struct or an array.
Numbers copy bit-for-bit; strings, enums, and closures are immutable and shared, so “copying”
one is just a cheap bump of a reference count. Only the unique-owner aggregates — structs and
arrays — are not Copy. Hand one to a Copy-bounded function and you get a crisp refusal:
struct User { name: string age: int }
let v = id(User { name: "ada", age: 36 })
error: type argument is not Copy — only scalars, strings, enums, and closures satisfy a 'Copy' bound (not a struct or array)
A type parameter can carry several bounds at once, joined with +: <K: Hash + Eq> asks for
a type that can both hash itself and compare itself for equality, and Copy composes right
alongside them (T: Ord + Copy). Bounds aren’t limited to free functions, either — a generic
struct can be bounded too, and the standard library leans on exactly that: the hash map is
declared struct Map<K: Hash + Eq, V>, which is what lets it hash and compare keys of
any qualifying type. (We’ll meet it properly in
Chapter 15.) What’s not here yet: bounds on a
generic struct’s individual methods and on generic enums, and inference stays call-site only
(no turbofish, by design). The honest list is in
Chapter 23.
Static versus dynamic, in one breath. A bound (
<T: Ord>) and an interface-as-a-value (Chapter 7) are the two halves of polymorphism, and Ingle keeps them distinct. A bound is resolved when the concrete type is known — the compiler hands the call a hidden table of the right methods (a “witness”), so there’s no runtime lookup and no boxing. An interface value is for when the type isn’t known until runtime — there the lookup happens through the value’s own method table. Same interfaces, two dispatch strategies: reach for a bound when you can, a value type when you must.
How generics are compiled, today. Because every value has a uniform representation, Ingle compiles a generic once —
Box<int>andBox<string>share one compiled body. This is the “erased” strategy, and it’s why adding generics doesn’t blow up your compile times. (The plan is for release builds to also offer monomorphization — a specialised copy per type for maximum speed — but that’s a future build mode, not something you write differently. You’ll never change your code for it.)
Fireside trivia. The angle brackets
<T>for generics are a C++ inheritance, and they’ve caused parser writers grief ever since, because<is also “less than.” Isa < b > ca comparison or a generic? Ingle resolves it with a lookahead rule: it reads a generic only when a balanced<...>, every token inside it type-legal, is immediately followed by{. That rule is total rather than a guess — no expression begins with{, so a> {can never continue a comparison, and a non-type token inside the brackets proves the<was less-than. Every language in this family has a scar in exactly this spot.
Chapter 11 — Functions as Values, and Closures
Functions in Ingle are values. You can put one in a binding, pass it to another function, and
call it later. A function type is written fn(ArgTypes) -> ReturnType.
fn double(x: int) -> int { return x * 2 }
fn apply(f: fn(int) -> int, x: int) -> int {
return f(x)
}
fn main() -> int {
let g = double // a named function, held as a value
return apply(double, 5) + g(7) // 10 + 14 = 24
}
=> 24
apply takes a function as its first argument — any fn(int) -> int will do — and calls it.
That’s the foundation of every “do this to each element” operation.
Lambdas
A lambda is a function written inline, with |params| expr (or |params| { ... } for a
block). Its parameter types are inferred from where it’s used:
fn main() -> int {
return apply(|x| x + 100, 5) // the lambda is an fn(int) -> int, inferred from apply
}
=> 105
A lambda can capture variables from around it — and it does so by value, copying them in when the lambda is created. That means it can never dangle, and there are no lifetime puzzles:
fn main() -> int {
let n = 100
let add_n: fn(int) -> int = |x| x + n // captures n (by value)
return add_n(5) // 105
}
=> 105
The one rule that will trip you up. A lambda needs to know its types, and it learns them from context — either by being passed straight into a function that expects a function type, or from an explicit annotation on the binding. So
apply(|x| x + 1, 5)is fine (the context isapply’s parameter), andlet f: fn(int) -> int = |x| x + 1is fine (the annotation). But a bare, unannotatedlet f = |x| x + 1is an error — Ingle can’t guess whatxis. The compiler says so plainly: “a lambda needs a function-typed context.” When in doubt, annotate the binding or pass the lambda directly.
Two more honest limits: capturing is read-only (a closure can’t reassign a variable it captured), and you can capture scalars, strings, and enums, but not a struct or an array (that would alias a unique owner — pass it as a parameter instead).
Higher-order functions, the standard-library way
Put functions-as-values together with generics and you get the classic trio, which live in
std/list (more on imports in Chapter 15):
import "std/list" as list
fn main() -> int {
let xs = [1, 2, 3, 4, 5]
let evens = list.filter(xs, |n| n % 2 == 0) // [2, 4]
let doubled = list.map(evens, |n| n * 2) // [4, 8]
let sum = list.reduce(doubled, 0, |acc, n| acc + n) // 12
return sum
}
=> 12
There’s a list.sort too, which takes a “is a before b?” lambda:
import "std/list" as list
fn main() -> int {
let words = ["ccc", "a", "bb"]
let sorted = list.sort(words, |a, b| a.len() < b.len()) // ["a", "bb", "ccc"]
for w in sorted { println(w) }
return sorted.len()
}
a
bb
ccc
=> 3
Chapter 12 — Ownership Without Tears
Ownership has a reputation for being where the fun stops and the fighting starts. The good news is that for ordinary, everyday code, you write nothing special at all. No annotations, no symbols, no lifetimes. You’ve written a dozen working programs in this book already and ownership never once got in your way.
The rules below are what’s quietly keeping you safe underneath. You mostly need them for the day the compiler stops you — and when it does, it explains itself.
Three ways to pass a value
When a function takes a parameter, there are three relationships it can have with the value:
- Borrow (the default). Plain
fn f(p: Point)borrows — it can look but the caller keeps ownership. This is what you want almost always. - Mutable borrow:
mut.fn f(mut p: Point)borrows and may change the value; the changes are visible to the caller afterward. - Move:
move.fn f(move p: Point)takes ownership. The caller hands the value over and can’t use it anymore.
The qualifier goes before the binding, and it reads the same for self and for named
parameters: mut self, move items. There are no & or &mut symbols, and no lifetime
annotations — Ingle infers all of that. The safe direction (borrowing) is the one you get for
free; the consequential direction (giving a value away with move) is the one you have to
type out. Hard to do the dangerous thing by accident.
struct Point { x: int y: int }
fn bump(mut p: Point) -> int { // mutable borrow: the caller sees the change
p.x = p.x + 1
return p.x
}
fn main() -> int {
var p = Point { x: 1, y: 2 } // var = a mutable place
p.x = 10 // mutate a field through the var
return bump(p) // => 11
}
Remember from Chapter 7: you can only mutate a field through a mutable place — a var
binding or a mut/move parameter. Through a let or a plain borrow, the value is read-only.
Move semantics: one owner at a time
Structs and arrays are owned values, with exactly one owner at a time. The moment you
hand ownership somewhere else — assign it to another binding, pass it to a move parameter,
return it, or store it in a field — the original name is “moved out” and can’t be used again.
Try, and the compiler stops you:
fn main() -> int {
let a = [1, 2, 3]
let b = a // ownership moves from a to b
return b[0] + a[0] // using 'a' here is the mistake
}
error: use of 'a' after it was moved
help: a move transfers ownership; pass it without `move` to borrow it instead, or make a copy before the move
note: value moved here
Three lines: what you did wrong, how to fix it, and where the value went. That’s the compiler-as-teacher principle in action. The fix here is to borrow instead of moving, or to make a copy before the move — exactly as it says.
This is why borrowing is the default for parameters. A function that just reads an array should take it plain, so the caller keeps it:
fn total(xs: [int]) -> int { // borrows xs
var s = 0
for x in xs { s = s + x }
return s
}
fn main() -> int {
let a = [1, 2, 3]
let t = total(a) // a is only borrowed...
return t + a.len() // ...so a is still ours here. => 9
}
=> 9
Returning an owned value: use move
A plain parameter is borrowed, and you can’t return a borrowed value — it would outlive the call and dangle. Ingle catches that too:
struct User { name: string age: int }
fn pick(u: User) -> User { return u } // error: cannot return a borrowed value
error: cannot return a borrowed value — it would escape the function; take the parameter as 'move'
Do as it says — take it move — and all is well, because now the function genuinely owns the
value it’s handing back:
fn pick(move u: User) -> User { return u } // owns u, may return it. Fine.
The shortcut that means you’ll rarely fight this
Here’s a detail worth knowing: small all-scalar structs held in a let are quietly copied,
not moved. A Point { x: int, y: int } bound
with let can be read through two names at once — Ingle duplicates it for you, because it’s
tiny and made entirely of numbers. The move rules above kick in fully for arrays, for structs
that contain a string or an array, and for structs held in a var.
The safe way to hold all this in your head is one sentence: write as if every struct and
array moves when you hand it off. If you do, you’ll never be surprised by an error — at worst
you’ll occasionally be allowed something the strict model would forbid, which never hurt
anyone. Lead with borrowing, reach for move when you mean to give a value away, and the
compiler will tell you on the rare occasion you’ve got it wrong.
The rest of the safety net, briefly
The same analysis quietly enforces a few more things, all in the name of “no value is ever read after it’s gone, and nothing mutable is aliased”:
- No aliased mutation. Because a move consumes the source, you can’t end up with two names for one mutable value and change it through both.
- Mutable XOR shared. In a single call you can’t pass the same value as a
mut/moveargument and also borrow it elsewhere in the same call. - Branches merge sensibly. Moving a value in one arm of an
if/matchis fine; the compiler tracks each path. Moving a value inside a loop body is rejected, because the next turn would move it again. - Scalars, strings, and enums copy freely. None of this applies to them — only to the unique-owner aggregates (structs and arrays).
Fireside trivia. Earlier in Ingle’s development, a generic function that aliased a struct argument managed to free the same memory twice at runtime — precisely the class of bug an ownership system exists to abolish, committed by the ownership system’s own compiler. It was caught, written up, and fixed by making type parameters move-by-default (the
move/Copyrules you met last chapter). The lesson stuck: the rules are load-bearing, and the language holds itself to them too.
Chapter 13 — Memory, the Quiet Way
Here’s a question you may not have thought to ask, because Ingle has carefully arranged for you not to need to: when does all this memory get cleaned up?
The usual answers are two: call free yourself and hope you got it right, or run a garbage
collector that periodically reclaims what’s unreachable, at the cost of some runtime overhead and
the occasional pause. Ingle does neither. It has no garbage collector, and you never write
free. Memory is reclaimed deterministically — at exactly known moments — and the ownership
rules from the last chapter are what make that possible.
The discipline is simple to state:
-
Structs and arrays are unique owners, and they’re freed at the end of the scope that owns them. When a
let/varholding a struct or array goes out of scope (hits the closing brace), its memory is released right then, along with anything it owns inside it. Because the move checker guarantees there’s exactly one owner, this is a plain, immediate free with no bookkeeping and no guessing. A value that was moved out isn’t freed here — its new owner will do it. A value that’s returned escapes to the caller, who now owns it. -
Strings and enums are shared and reference-counted. These are immutable, so several names can safely point at the same one. Each holds a counted reference; aliasing bumps the count, going out of scope drops it, and the value is freed when the last reference goes. Freeing a container releases what it holds — an array of strings frees its strings on the way out.
fn main() -> string {
let p = Point { x: 1, y: 2 } // a struct...
let s = "hello" // ...and a string
let t = s // t shares s (count is now 2)
return t // t escapes to the caller; at this brace, p is
} // freed and s drops its reference (one left)
The reclamation is eager — it happens at the brace, not when the program exits — so a long-running program that loops forever (say, concurrent workers pulling jobs off a channel) doesn’t slowly accumulate garbage. And here’s the property that makes reference-counting complete rather than leaky: because the only mutable things (structs and arrays) are uniquely owned, and everything shared is immutable, no reference cycle can ever form. Cycles are the one thing naive reference-counting can’t collect; Ingle’s value model makes them impossible to build in the first place. There is nothing a tracing garbage collector would catch that this misses.
When you do want sharing: rc struct and std/slotmap
Single ownership is the default precisely because it makes the two rules above hold. But some shapes — a config read from everywhere, a node with several parents, a graph — genuinely want more than one owner, and Ingle gives you two blessed tools rather than leaving you to alias by hand.
An rc struct is a shared, immutable, reference-counted struct. Prefix a struct with rc, and
assigning it is an incref rather than a move or a deep copy, so many names can point at one heap
value — safe precisely because an rc value can’t be mutated:
rc struct Config {
host: string
port: int
}
fn main() -> int {
let a = Config { host: "localhost", port: 80 }
let b = a // a second owner — an incref, not a copy
let c = a // a third; a, b, c all name one shared value
return a.port + b.port + c.port // => 240
}
For graph-shaped data where you’d otherwise hold pointers, std/slotmap is a generational
arena: the store owns the values and you hold small copyable Handles (a slot + a generation). Removing
a value bumps its slot’s generation, so every stale handle reads back as None instead of a dangling
value — the use-after-free of a recycled slot turned into a safe Option by construction. Between
them, the awkward shapes have a home, and the no-cycles guarantee above still holds. (A generic
rc struct Box<T> isn’t supported yet — a v1 restriction.)
Fireside trivia. The “billion-dollar” sibling to Hoare’s null mistake is arguably the use-after-free, the bug where you keep using memory after it’s been handed back. Whole categories of security exploit are built on it. Ingle’s one set of rules — single ownership, immutable sharing — covers three things at once: memory safety, deterministic cleanup, and data-race freedom (next chapter).
Chapter 14 — Concurrency
Doing several things at once is where sharp edges tend to appear. Ingle takes a deliberately structured approach that keeps them blunt.
The headline ideas:
- No function colouring. There’s no
async/awaitsplit dividing your code into two incompatible worlds. Any function can be run concurrently. (You’ll remember unit functions from Chapter 4 — those make natural workers.) - Tasks are scoped. Concurrency happens inside a
nurseryblock, and tasks cannot outlive it — the block does not finish until every task spawned inside it has finished. When control leaves the nursery, everything it started is done. No leaked background threads, no “did that ever complete?” - They talk over typed channels. A
Channel<T>carries values of typeTbetween tasks.
Here’s a complete producer/consumer:
fn producer(ch: Channel<int>) {
send(ch, 10)
send(ch, 20)
send(ch, 30)
close(ch) // no more values coming
}
fn consumer(ch: Channel<int>, out: Channel<int>) {
var sum = 0
loop {
match recv(ch) {
case Some(v) { sum = sum + v } // a value arrived
case None { break } // channel closed and drained
}
}
send(out, sum)
}
fn main() -> int {
let ch: Channel<int> = channel(2) // buffered, capacity 2
let out: Channel<int> = channel(1)
nursery {
spawn producer(ch)
spawn consumer(ch, out)
} // both tasks are finished here, guaranteed
match recv(out) {
case Some(v) { return v } // => 60
case None { return 0 }
}
}
=> 60
The pieces:
nursery { ... }— a task group. The block joins all its tasks before it exits.spawn f(args)— launch a call tofas a task in the enclosing nursery. (Usingspawnoutside a nursery is a compile error — there’d be nothing to scope it to.)channel(N)— make a buffered channel of capacityN. Its element type comes from the binding’s annotation (let ch: Channel<int> = channel(2)).send(ch, v)— put a value in (blocks if the buffer is full). Sending on a channel you’ve alreadyclosed is a runtime error — closing means “no more values are coming,” so close only once everysendis done.recv(ch)— take the next value, as anOption<T>:Some(v)while values flow,Noneonce the channel is closed and drained. ThatNoneis how a consumer loop knows to stop; on an open-but-empty channel it blocks until something arrives.try_recv(ch)—recvthat never blocks:Some(v)if a value is queued right now,Noneif the channel is empty (or closed and drained). It’s the primitive an event loop reaches for when it has other work to do and can’t afford to sit waiting on the channel.close(ch)— mark the channel closed. Queued values still drain; after that,recvreturnsNoneinstead of blocking forever.
Notice how naturally recv returning Option<T> falls out of Chapter 9 — “maybe there’s a
value” is exactly an Option, so the same match you already know handles the channel
draining.
Many workers, the same code
Because channels are shareable handles, you can spawn several workers all pulling from one
jobs channel, and the work fans out across whichever worker is free — a worker pool, in a few
lines. (The shipped example examples/05_concurrency.ig does exactly this: one dispatcher, four
workers counting error lines, a results channel summed at the end. It runs today.)
One source, three speeds
The same program, with no source changes, runs on any of three schedulers — only the wall-clock time
changes. The default runs your tasks as green threads cooperatively on a single OS thread: a
send or recv that can’t proceed yields, and the scheduler resumes it later. Built with
EMBER_PARALLEL=1 (and what a native binary uses), a thread-per-task runtime gives each task a
real OS thread on its own core. And make mn selects an M:N green-thread scheduler — a small
pool of OS threads multiplexing many lightweight fibers that park on a channel rather than block a
thread, so thousands of tasks cost a handful of threads. One set of words,
nursery/spawn/Channel<T>; one answer; three ways to spend your cores.
This works because of ownership. Recall: structs and arrays are uniquely owned (never aliased across tasks), and strings and enums are immutable. So there’s essentially no shared mutable state for threads to race over — the data-race class of bug is gone by construction, the same way use-after-free was. All three runtimes also detect a genuine deadlock (every task blocked on a channel that can never deliver) and report it as a clean runtime error instead of hanging.
Where it stands, honestly. The M:N scheduler is built but gated behind
make mnwhile it clears a wider soak (and grows right-sized fiber stacks for the hundred-thousand-fiber tier), so the cooperative single-thread runtime is the default for now. Structured cancellation-on-failure already rides with it — a failing task tears its group down at the next yield seam. One limit remains across all three runtimes: channel communication is child-to-child, becausemaindrives the nursery’s join rather than sitting in arecvloop during it (the pattern above — workers talk inside the nursery,mainreads results after — is the idiom). Still on the list (Chapter 23):select/timeouts, and flipping the default to M:N.
Fireside trivia. The word “nursery” for a scoped task group comes from the “structured concurrency” movement of the late 2010s — the argument that concurrency should nest like blocks do, with a clear beginning and end, rather than spawning free-floating threads that wander off. The name is a metaphor: tasks are children, the nursery looks after them, and nobody leaves until everyone’s accounted for. It’s a much nicer image than “thread pool,” and a much nicer debugging experience too.
Chapter 15 — Modules and the Standard Library
As a program grows past one file, you split it into modules. In Ingle, a file is a
module, and you pull one into another with import:
// geom.ig
struct Point { x: int y: int }
fn sum(p: Point) -> int { return p.x + p.y }
// main.ig
import "geom" as geom
fn main() -> int {
let p = geom.Point { x: 3, y: 4 } // construct an imported type, qualified
return geom.sum(p) // call an imported function, qualified
}
=> 7
Two things to notice. First, the import always has an alias (as geom), and you use
everything through it — geom.Point, geom.sum. There’s no implicit flat merging of names,
so you can always see at a glance where something comes from, and two modules can never collide.
Imported types work in every position you’d expect: annotations, construction literals
(including generic ones like box.Box<int> { value: 42 }), fields, and type arguments.
Second, privacy is by leading underscore. A top-level declaration whose name starts with _
is private to its own module; everything else is exported. It’s not a mere convention — it’s
enforced:
// in geom.ig: fn _secret() -> int { return 99 }
import "geom" as geom
fn main() -> int { return geom._secret() }
error: that function is private to its module (leading '_')
So: public is the default, and you opt into privacy with a _. It costs zero keywords and
self-documents at the call site.
The standard library
Some of what you’ve used — println, sqrt, the string methods — is built right into the
language. The rest of the standard library lives in real Ingle source files under the reserved
std/ prefix, and you import it exactly like your own modules. (std/ always resolves to the
toolchain’s library directory, wherever you run the compiler from.) This is the model for how
the library grows: written in Ingle, over a small native base, imported like anything else.
What’s there today:
std/string — the fuller text toolkit beyond the built-in methods: to_upper, to_lower,
trim, contains, index_of, starts_with, ends_with, repeat, substring, replace,
join. (All byte-wise ASCII, like the built-ins.)
import "std/string" as str
fn main() -> int {
let name = str.trim(" ember ")
println("Hello, " + str.to_upper(name) + "!") // Hello, EMBER!
return str.index_of("hello", "ll") // 2
}
Hello, EMBER!
=> 2
std/list — the generic functional toolkit over arrays you met in Chapter 11: map,
filter, reduce, sort, each taking a function value.
std/map — Map<K, V>, a generic hash map keyed by any type that is Hash + Eq.
The built-in scalars and strings all qualify, and a user struct that implements Hash, Eq is a
valid key too (no Copy needed — a struct key is deep-cloned on store, so the map owns its copy);
Map<string, int>, Map<int, bool>, Map<Point, V> all work from the same code. Build one, then
set, get (which returns an Option<V> — of course it does), has, size, and keys (which
hands back a [K] in bucket order):
import "std/map" as mp
fn main() -> int {
var counts = mp.Map<string, int> { buckets: [], count: 0 }
for w in "the cat the dog the bird".split(" ") {
match counts.get(w) {
case Some(n) { counts.set(w, n + 1) }
case None { counts.set(w, 1) }
}
}
match counts.get("the") { case Some(n) { println("the x{n}") } case None {} }
return counts.size() // 4 distinct words
}
the x3
=> 4
You spell out both type arguments at construction — Map<string, int>, Map<int, bool> — just
like any other generic. There’s no Copy requirement on the key, and this is newer than some of
the surrounding prose: a built-in key (a scalar or a string) copies cheaply, and a move-type
struct key is deep-cloned on store, so the map owns its copy and you keep yours — value-semantic
keys with no clone() ceremony. A key type need only be Hash + Eq. The shipped
examples/15_wordcount.ig is a real little tool built on this — tally the words in its command-line
arguments — and examples/06_calculator.ig leans on it too.
Fireside trivia.
std/mapis itself written in Ingle — a generic struct over an array ofOption<MapEntry<K, V>>buckets, boundedMap<K: Hash + Eq, V>, with open-addressing and linear probing, doubling when it gets 70% full. It dispatches each key’s ownhashandeqthrough witnesses it stores per instance — which means the map isn’t just using the language’s generics, arrays, enums, and methods, it’s using the brand-new bounds-on-generic-structs feature to do it. The standard library’s dictionary is written in the language it ships with, and it’s the proof that bounded generic structs work, because it’s made of one. (The underlying string hash is FNV-1a, a 1991 design beloved for being about five lines long and good enough for almost everything.)
std/set — a generic hash set, Set<K: Hash + Eq>. It is std/map’s twin: the same
open-addressing table with linear probing and doubling past a 0.7 load factor, but storing only
keys — no value. Membership, insertion, and iteration are amortised O(1)/O(n), and adding a key
that’s already present is a no-op. Like the map, it’s written in Ingle and is itself a proof that
bounded generic structs work.
std/slotmap — a generic generational-arena SlotMap<V>. The store owns the values; you hold
small copyable Handles instead of pointers, so identity is separated from ownership. Removing a value
bumps its slot’s generation, so a stale handle reads back as None rather than a dangling value — the
recycled-slot footgun made safe by construction. It’s the blessed tool for graph-shaped data
(Chapter 13).
std/sqlite — embedded SQL, backed by the vendored SQLite amalgamation (one public-domain C
file in-tree, so no server and no system package — it keeps the empty-dependency-tree rule). A
connection (Db) and a prepared statement (Stmt) are resource structs
(Chapter 16): each owns its SQLite handle and its drop closes it, so the
compiler guarantees every connection is closed and every statement finalized exactly once, on every
path — the classic leaked-connection bug is impossible, with no ceremony. open and prepare return
a Result (so ? handles failure), and the handle frees itself when its binding leaves scope:
import "std/sqlite" as sql
fn run() -> Result<int, string> {
let db = sql.open("notes.db")? // Db — auto-closes at scope exit (or any `?`)
let _ = sql.exec(db, "CREATE TABLE IF NOT EXISTS note(id INTEGER PRIMARY KEY, body TEXT)")?
let _ = sql.exec(db, "INSERT INTO note(body) VALUES('hello'), ('world')")?
let st = sql.prepare(db, "SELECT id, body FROM note ORDER BY id")? // Stmt — auto-finalizes
loop {
if !sql.step(st)? { break } // false = no more rows
println("{sql.column_int(st, 0)}: {sql.column_text(st, 1)}")
}
return Ok(0)
}
It links only under the database build (make db), the same way the UI modules need the graphics
build and std/http the networking one.
The next several modules are the answer to a fair question — can you build a real, networked, graphical program in this language? — and each is small, pure where it can be, and written in Ingle over a thin native base:
std/json — a real JSON parser and serializer. It dogfoods the language hard: a JSON value is
a recursive enum Json walked with match, and an object keeps its members in insertion order so
serialization round-trips. You build a tree (json.obj, json.arr, json.str, json.num) and
json.stringify it, or json.parse a string back into the enum — and escaping (quotes, newlines,
unicode) is the library’s job, not yours.
std/markdown — parses Markdown into a list of blocks — paragraphs, headings, bullets,
blockquotes, fenced code. The block model is, again, an enum, so a renderer is one exhaustive
match: exactly the language’s own machinery doing exactly what it’s for. Any Ingle GUI can reuse
it — a chat transcript, a docs viewer, a note app.
std/highlight — syntax highlighting as a lexer that emits coloured spans instead of
compiling. A code string becomes a flat list of Spans, each tagged with a Kind the renderer
maps to a colour. It’s a pragmatic C-family tokeniser (identifiers and keywords, numbers, strings,
line and block comments), and it pairs with std/markdown so a fenced code block comes out
highlighted.
std/http — HTTP/HTTPS as a thin layer over the C FFI (libcurl). Two shapes share one binding:
http.post(url, headers, body) makes a blocking request and hands back the whole response body as a
string; or you open a streaming pull — http.open(...) returns a handle, http.next(h) yields
body chunks as the network delivers them ("" once the transfer ends), http.status(h) is the HTTP
code, and http.close(h) frees it. The handle is a linear Ptr (Chapter 16),
so the compiler makes you close it exactly once, on every path. It links only under the networking
build (make net), the same way the UI modules need the graphics build.
std/sse — a Server-Sent Events decoder, the streaming layer that sits on top of std/http.
You feed it the raw response-body chunks http.next hands you; it returns the complete events
framed so far and buffers the trailing partial across feeds. That’s what turns a token-by-token API
stream into a clean sequence of events your loop can act on.
Then the graphics and UI stack, all resting on one immediate-mode idea — the UI is a pure function of state, redrawn fresh every frame, with no retained widget tree to keep in sync — which is precisely why it stays clean under the ownership model of Chapter 12:
std/draw — immediate-mode drawing over the native graphics backend: open a window, then each
frame clear it and issue primitives (rectangles, text, lines). Colours are packed 0xRRGGBB ints;
key codes follow the backend. There is no retained scene, so your app state is just your own Ingle
values.
std/ui — an immediate-mode widget toolkit over std/draw: buttons, labels, text fields,
checkboxes, sliders. A Ui value carries the small slice of cross-frame state immediate mode
needs — the layout cursor, this frame’s input snapshot, and which widget is hot (hovered) or
active (being pressed).
std/layout — a small flexbox solver. You build an ephemeral tree of boxes for one frame,
call solve() with the root rectangle, and read each box’s computed rect. It’s pure — ints,
arrays, and structs, no rendering — so the layout maths is testable on its own, without ever
opening a window.
std/flare — the declarative, component-style layer that ties the three above together:
components as functions, props, local state, declarative composition. It gets its own chapter —
Chapter 25.
Chapter 16 — Talking to C
Sometimes you need a function that already exists in C — a maths routine, a system call, a
library. Ingle reaches across to C through an extern "c" block, where you declare the
foreign function using ordinary Ingle syntax:
extern "c" {
fn atan2(y: f64, x: f64) -> f64
}
fn main() -> int {
return to_int(atan2(1.0, 1.0) * 1000.0) // pi/4 ≈ 0.785, scaled → 785
}
=> 785
Once declared, you call it like any other function. The first slice of the FFI targets the C
maths library (libm, which ships with every C runtime, so it adds no dependency): sin,
cos, tan, asin, acos, atan, atan2, exp, log, log2, log10, sinh, cosh,
tanh, cbrt, trunc, hypot, fmod. (You won’t need extern for sqrt, pow, abs,
floor, ceil, round — those are already built in.)
The extern declaration is the trust boundary. There’s no separate unsafe keyword in
Ingle; the signature you write is the contract the compiler type-checks against. That’s the
one place Ingle’s guarantees stop and you’re vouching for the C side — so if you declare a
signature that doesn’t match the real C function, you’ve told a lie the compiler can’t catch.
Keep your declarations honest.
Structs cross the boundary too, by value, as long as they’re all-scalar — a
struct Vec2 { x: f64 y: f64 } lines up with a C struct { double x, y; }:
struct Vec2 { x: f64 y: f64 }
extern "c" {
fn cvec2_len(v: Vec2) -> f64 // struct in, scalar out
fn cvec2_add(a: Vec2, b: Vec2) -> Vec2 // struct in AND struct out
}
There’s no marshalling library involved: Ingle flattens the struct to its scalar fields, and a
tiny C wrapper reassembles a real C struct and passes it by value — so the system C compiler
generates the platform’s exact calling convention for you. No hand-rolled register juggling, no
libffi.
Pointers, buffers, and handles
Scalars and structs are enough for a maths library, but binding real C — file I/O, string routines, anything that takes a pointer — needs three more shapes to cross the boundary. They all now do, and the trick that keeps them safe is that each one is borrowed for the duration of the call: Ingle lends C a pointer, C uses it while the call runs, and Ingle keeps ownership and frees nothing C owns.
| Ingle type | Arrives in C as | Notes |
|---|---|---|
string |
const char* |
the string’s bytes, NUL-terminated; read-only |
[u8] (any packed scalar array) |
a buffer pointer | the array’s contiguous native storage |
mut [u8] |
a writable buffer | C may fill the elements in place |
Ptr |
an opaque handle (FILE*, void*, …) |
round-trips to and from C; never dereferenced in Ingle |
A Ptr is the interesting one: it’s a handle you receive from C (say, from fopen), hand back
to C (fread, fclose), and never look inside from Ingle — its lifetime is C’s business. And the
compiler helps you not botch that lifetime: a Ptr is linear — you must use it exactly once.
At most once: it’s move-only, so an extern function that takes ownership declares it move —
fn fclose(move f: Ptr) — and that consumes it. Close the same handle twice (fclose(f);
fclose(f)) and the second is a compile error, “use of f after it was moved” — a C double-free
caught before it runs. At least once: an owned handle you never close is a compile error too —
“this Ptr is opened but not closed on this path” — and the checker holds you to it on every
branch, early return, and ?. So the two classic FFI footguns — double-close and leak — are both
gone, with no runtime cost. (A bare Ptr has no destructor of its own, so left loose it can’t
auto-close — keep it in a local and close it by hand, or return it; on its own it still can’t be
stashed in a plain struct/array/Option/Map. When you want a value to own a handle and close
it for you, you wrap it in a resource struct — the very next section. The null-handle case is
easy: fclose(NULL) is a safe no-op, so the idiom is open → use under a null-check → one
unconditional close.) Borrowing calls (fread/fwrite) leave the Ptr plain, so
you can keep using it. Put the four together and you can drive libc directly.
Here’s the heart of the shipped examples/16_ffi.ig, which writes a file and reads it straight back:
extern "c" {
fn strlen(s: string) -> i64
fn fopen(path: string, mode: string) -> Ptr
fn fwrite(buf: [u8], n: i64, f: Ptr) -> i64
fn fread(mut buf: [u8], n: i64, f: Ptr) -> i64 // C writes into the buffer
fn fclose(move f: Ptr) -> i64
}
wrote 5 bytes to /tmp/ingle_ffi_demo.bin
read back 5 bytes: EMBER
strlen("EMBER") = 5
=> 5
fopen hands back a Ptr; fwrite borrows a [u8] payload; fread takes a mut [u8] and the C
side fills it in place; strlen borrows a string as a const char*. Passing a heap value to a C
call (a string literal, a freshly built array) lends it for the call and reclaims it right after, so
nothing leaks.
One returning direction is handled now, and it’s how std/http brings a whole response body
home: a C function that returns a char* can be declared to return a string. Ingle copies the
bytes into an owned Ingle string on the way back and lets C free its own buffer — the safe “copy on
return” rule, with no ownership puzzle to get wrong. That’s exactly what
fn http_post(url: string, headers: string, body: string) -> string does inside std/http.
What’s still left out is the other shapes of returned memory: a non-string malloc‘d buffer
(a [u8] has no NUL terminator, so it needs a length channel first) and the “Ingle adopts the
raw pointer and frees it later” form for APIs that transfer ownership across. Both need an explicit
ownership rule at the boundary, so for a buffer the workaround stays the fread pattern above: hand
C a mut [u8] to write into. Arbitrary dynamic linking is likewise future work. (And a small,
honest wrinkle for Chapter 18: --emit=replay records a C
call’s scalar result but not the bytes it writes into a borrowed buffer, so a buffer-reading FFI
program replays as diverged — replay being scrupulously honest that an effect escaped its capture,
rather than faking a reproduction.)
Owning a handle: resource struct
The rules above keep a bare Ptr honest, but they leave the closing to you, and the handle can’t
live inside a value. A resource struct lifts both limits. Prefix a struct with resource and
give it a fn drop(self), and you’ve made the owned dual of the rc struct from
Chapter 13: a uniquely-owned, move-only value that owns a
resource and releases it automatically. It is the one struct allowed to hold a Ptr, and its drop
runs exactly once, on every path the value leaves scope — so the handle closes itself.
extern "c" {
fn fopen(path: string, mode: string) -> Ptr
fn fwrite(buf: [u8], n: i64, f: Ptr) -> i64
fn fclose(move f: Ptr) -> i64
}
resource struct File {
fp: Ptr
fn drop(self) { // runs automatically when a File leaves scope
let _ = fclose(self.fp) // ...closing the handle, on every path
println("file closed")
}
}
fn main() -> int {
let f = File { fp: fopen("/tmp/ember_demo.bin", "wb") }
let bytes: [u8] = [69u8, 77u8, 66u8, 69u8, 82u8]
println("wrote {fwrite(bytes, 5, f.fp)} bytes")
return 0
} // no fclose here — `drop` already ran
wrote 5 bytes
file closed
=> 0
There is no fclose in main: the File owns the handle, so leaving the brace runs drop, which
closes it — RAII, enforced by the compiler. The same rules that make this safe are strict by design:
a resource can’t be cloned, can’t be copied into a plain struct/array/Map, and can’t be passed to
a generic function (anywhere it might gain a second owner that double-frees), and a drop that fails
to release its handle is itself a compile error. This is exactly how std/sqlite’s Db and Stmt
work (Chapter 15) — a database connection that closes
itself. (Resources in collections — a connection pool — are a later phase; for now a resource lives
in a local or is returned, like the bare handle it wraps.) The ledger gate from the build chapter
fuzzes exactly this close-on-every-path analysis.
Fireside trivia.
extern "C"is one of the most quietly important incantations in systems programming. It exists because C++ “mangles” function names (encoding types into the symbol) while C does not, soextern "C"means “don’t mangle this — speak plain C.” Ingle borrows the exact spelling, partly for the C/C++/Rust muscle memory and partly because, when a tool or a model readsextern "c", it knows precisely what it’s looking at. Familiar punctuation is a feature, not a lack of imagination.
Part IV — Knowing You’re Right
This part is about a single idea: stating what your code is supposed to do in a way the machine can check, fuzz, prove, and replay. None of those four moves is new on its own — contracts, property-based testing, theorem-proving, and record-replay all have long histories — but wiring them into one loop around a single executable contract is the bet the next four chapters build up.
Chapter 17 — Contracts
A contract is a statement of what a function promises, written right next to its signature,
in ordinary Ingle. There are two halves: requires clauses (preconditions — what must be true
when you call it) and ensures clauses (postconditions — what will be true when it returns). An
ensures clause may use the special name result for the return value.
fn clamp(x: int, lo: int, hi: int) -> int
requires lo <= hi // precondition: checked on entry
ensures result >= lo // postconditions: checked before every return,
ensures result <= hi // with 'result' bound to the returned value
{
if x < lo { return lo }
if x > hi { return hi }
return x
}
That’s not a comment. It’s executable. In a normal (debug) build, requires is checked the
moment the function is entered, and every ensures is checked just before each return. If one
fails, the program stops with a message that tells you exactly which clause, in which function:
fn pos(x: int) -> int
requires x > 0
{
return x
}
fn main() -> int { return pos(-5) }
inglec: runtime error: precondition failed in 'pos' (requires, line 1)
A contract is just a bool expression, so the full language is available — including calling
your own predicate functions. You can write requires is_sorted(xs) and define is_sorted
normally. (Contracts should read their inputs, not change them.) Here’s the shipped
examples/07_contracts.ig, which specifies Euclid’s GCD precisely — the result is positive and
divides both inputs:
fn gcd(a: int, b: int) -> int
requires a > 0
requires b > 0
ensures result > 0
ensures a % result == 0
ensures b % result == 0
{
var x = a
var y = b
loop {
if y == 0 { return x }
let t = x % y
x = y
y = t
}
}
Those three ensures clauses are a real, checkable specification of “is a common divisor,” not
a vague comment hoping to stay true.
assert, for checks inside a function
For an invariant that should hold mid-function rather than at its boundary, there’s
assert(cond) (or assert(cond, "message")). It lowers to the same machinery as a contract, so
a failure is the same kind of structured event, not a bare crash.
The bit that makes it free in production
Contracts are checked in debug builds — the default. A --release build elides them
entirely: zero runtime cost. So you get correctness-checking while you develop and pay nothing
when you ship. Watch the same broken contract simply vanish under --release:
inglec --emit=run bad.ig # precondition failed in 'pos' ...
inglec --emit=run --release bad.ig # => 6 (the check is gone)
This is the proven debug_assert model: the safety net is up the whole time you’re working, and
folded away for the performance run. (Type-checking always happens, in every build — a contract
that isn’t a bool is a compile error no matter the profile. Only the runtime check is what
--release drops.)
Fireside trivia. Design by contract dates to Bertrand Meyer and the Eiffel language in the 1980s, named by analogy with business contracts: the caller promises the precondition, the function promises the postcondition, and if both keep their word the program is correct. The idea spent decades admired-but-niche. What’s new in Ingle isn’t contracts themselves — it’s fusing them with a machine-readable trace and automatic testing, which is the next chapter, and the whole reason they’re here.
Chapter 18 — The Verification Loop
A contract is a specification the machine understands. Once you have that, the compiler can do three things with it.
--emit=check: fuzz your contracts automatically
Your contract says what should be true. So the compiler can go looking for an input that makes
it false. inglec --emit=check generates many inputs that satisfy a function’s requires,
runs the function, and reports the first input that violates an ensures (or an assert) — as a
concrete counterexample.
Point it at the (correct) contracts example and everything passes:
inglec --emit=check examples/07_contracts.ig
check gcd: ok (300 cases)
check clamp: ok (300 cases)
checked 2 function(s): 2 passed, 0 failed
Now write a contract that’s a little too hopeful — claiming doubling a number always makes it bigger (false for zero and negatives) — and watch the compiler find the hole:
fn dbl(x: int) -> int
ensures result > x
{
return x + x
}
inglec --emit=check bad.ig
check dbl: FAILED
counterexample: dbl(0) => postcondition failed in 'dbl' (ensures, line 2)
checked 1 function(s): 0 passed, 1 failed
It didn’t just say “this is wrong” — it handed you dbl(0), the simplest input that breaks it.
A few things worth knowing: requires defines the valid domain (inputs that fail a precondition
are out of scope, not failures); struct and array parameters get fuzzed too; counterexamples are
shrunk toward the simplest failing case; and generation is deterministic (a fixed seed),
so you get the same counterexample every run — perfect for a regression test, or for a
colleague to reproduce.
--emit=prove: prove there’s no counterexample
Fuzzing can find a bug; it can’t certify the absence of one. For contracts in a decidable fragment — linear integer arithmetic over a function’s integer parameters — Ingle can prove the postcondition holds for all inputs, with no external solver:
fn add_nonneg(a: int, b: int) -> int
requires a >= 0
requires b >= 0
ensures result >= 0
{
return a + b
}
inglec --emit=prove add_nonneg.ig
prove add_nonneg: ensures @line 5 — PROVED
proved 1 of 1 ensures clause(s); 0 to check
The prover is sound and never optimistic: anything outside its fragment (branches,
multiplication of variables, calls, non-integer parameters) is reported as not proved and
politely handed off to --check. It will never call a false contract proved. Run it on the GCD
example, whose loop and % are well outside linear arithmetic, and it says so honestly:
prove gcd: ensures @line 17 — not proved (use --check)
So the workflow is a hierarchy: prove what’s decidable, fuzz the rest, and — next — replay anything that fails.
--emit=replay: make any run reproducible
The bane of debugging is the bug you can’t reproduce. Ingle’s nondeterminism comes from a small,
known set of sources — random(), the clock, external reads, and C calls — so the runtime can
record every nondeterministic value a run consumes and replay them to reproduce that run
exactly. --emit=replay runs your program twice (once recording, once replaying) and checks the
two runs are byte-for-byte identical:
inglec --emit=replay program.ig
replay: deterministic — 5 nondeterministic event(s) recorded (5 random, 0 clock, 0 read_line, 0 read_file, 0 ffi); both runs identical
If the two runs disagree, the program has a source of nondeterminism the runtime doesn’t yet
capture — which is itself a useful thing to discover. Concurrency comes along for free: replay
uses the deterministic serial scheduler, so even a nursery/spawn/channel program replays
identically.
Put the three together — prove, check, replay — and you have a closed correctness loop: state what the code should do, have the machine prove it or hunt counterexamples, and reproduce any failure on demand. Plenty of languages offer one or two of these; having all three hang off the same executable contract, in one toolchain, is what Ingle is reaching for.
Fireside trivia. The prover’s engine is Fourier–Motzkin elimination, a method for deciding systems of linear inequalities that Joseph Fourier sketched in the 1820s — yes, the heat-equation, the-frequencies-of-the-universe Fourier — and that Theodore Motzkin rediscovered and formalised in his 1936 thesis. It sat as a piece of pure mathematics for the better part of a century before turning out to be exactly the tool for proving little integer contracts in a programming language two hundred years later. You never know what the maths is for when it’s written down.
Chapter 19 — The Tape, and Errors as Data
The final piece of Ingle’s “knowing you’re right” story is observability: the language is built so that what your program does, and what the compiler thinks of it, are available as structured data — not just text for a human to squint at.
The execution tape
Run any program with --tape (or --emit=trace) and Ingle writes a tape: one JSON object
per executed instruction, in order, recording the function, the instruction offset, the opcode,
the source line, and a snapshot of the value stack at that moment.
inglec --tape program.ig
{"fn":"main","ip":0,"op":"CONST","line":6,"stack":[]}
{"fn":"main","ip":2,"op":"CALL","line":6,"stack":[3]}
{"fn":"dbl","ip":0,"op":"GET_LOCAL","line":4,"stack":[3]}
{"fn":"dbl","ip":2,"op":"GET_LOCAL","line":4,"stack":[3,3]}
It’s observer-only — recording a tape never changes how the program runs — and it costs
essentially nothing when you don’t ask for it. You can read it yourself to follow a run
step-by-step, or hand it to a tool (or an AI) to debug a run as data. When a contract is violated,
a structured contract_violation event lands on this same tape, so a spec failure isn’t a dead
end — it’s a record of exactly what happened, on what values.
Errors as data
Compile errors get the same treatment. By default they’re written for humans, in your program’s
own terms, with a fix suggestion — you’ve seen plenty in this book. But add --diagnostics=json
and every error comes out as a JSON object instead:
inglec --emit=run --diagnostics=json prog.ig
{"severity":"error","file":"prog.ig","line":5,"col":16,"message":"use of 'a' after it was moved","near":null,"help":"a move transfers ownership; pass it without `move` to borrow it instead, or make a copy before the move","note":{"line":4,"col":21,"message":"value moved here"}}
Same information as the friendly text version — file, line, column, message, a help fix, and a
secondary note pointing at the related location — but as a structure a program can parse and
act on without scraping strings.
A run-time fault, as data
Run-time failures get the same treatment. When an Err reaches main unhandled — or a contract
or refinement is violated — Ingle stops with a structured fault that carries the offending
value as data and pins it to a true file:line:col:
struct IoErr { code: int path: string }
fn load() -> Result<int, IoErr> {
return Err(IoErr { code: 5, path: "/etc/data" })
}
fn main() -> Result<int, IoErr> {
let bytes = load()?
return Ok(bytes)
}
error[unhandled_error]: an Err returned by main was never handled
--> prog.ig (in main)
why: a Result that reaches main must be handled (match its Err), not left to propagate out
values: error = IoErr { code: 5, path: "/etc/data" }
hint: match the Result and handle the Err arm (or have main do something with the error)
The values: line is the error’s payload, walked field by field and rendered as data — not an
opaque blob but the IoErr you actually returned, nested strings quoted. A scalar Err like
Err("disk offline") prints just as plainly. Whoever reads the fault — a person or a model — gets
the real error to act on, not a pointer to chase.
Why would a language make its errors and traces machine-readable? Because Ingle is built LLM-first — for a world where a lot of code is written and run by models — and a model works better with “here is precisely what’s wrong, as data, and here’s the fix” than with prose it has to parse. Everything in this part — contracts as specs, fuzzing into counterexamples, deterministic replay, the tape, JSON diagnostics — serves the same structured feedback loop, one a machine or a human can close on its own.
Fireside trivia. Ingle’s tape is emitted from inside the VM’s instruction dispatch, so it grows automatically with every new opcode — the trace can’t fall behind the language, because emitting it is part of how the language runs. Observability that’s built in rather than bolted on tends to look like paranoia right up until the first time it saves your afternoon.
Chapter 20 — Crucible: The Memory Fuzzer
Chapter 18 showed you a machine that hunts for a logic
bug: --emit=check invents inputs to a function until one of its contracts turns false. Crucible
is the twin of that idea, pointed at memory. Instead of inputs to one function it invents whole
programs — entire .ig files — and runs each one through a battery of detectors that watch
what the runtime does with memory: whether a value is freed twice, leaks, is read after it has
been freed, or quietly comes back wrong. Logic correctness and memory correctness are different
problems, so Ingle hunts them with two different tools.
Crucible is a build-time developer tool. It lives in tools/ (a generator, crucible.c, and a
driver, crucible.sh), it is never shipped inside inglec, and you run the whole thing with one
command:
make crucible
That builds everything it needs and sweeps a default of 150 seeds; when you want a shorter or
longer run, call the driver directly with a count — tools/crucible.sh 120. Either way, a green
run ends like this and exits 0:
crucible: 120 seeds → 120 clean, 0 distinct (0 NEW).
crucible: ✓ no new memory faults.
A run that turns up something new exits 1 and tells you exactly where the evidence is. That is
the entire contract: a clean exit means the language could not be made to mishandle memory
across the combinations Crucible knows how to build; a non-zero exit hands you a minimal program
that proves otherwise.
Why a fuzzer, and not just more tests
Every memory bug Ingle has ever had lived at a combination of features that, taken one at a
time, all worked: a value-struct double-freed when it was borrowed into a multi-slot parameter; a
string interpolation that leaked its intermediate pieces; a field assignment through an array
index; a value-struct double-freed when shared through an erased generic. Nobody sits down and
writes a test for “a struct with a string field, stored as the value of a Map, read back
inside a loop, and then interpolated” — because nobody thinks of it. Each of those bugs was
found the hard way, reactively, when a real program happened to walk into the combination and
crashed on the way out.
Crucible’s job is to walk into those combinations first, by the hundred, automatically. The
principle the source states for itself is “no knowledge lost”: every shape that has ever
bitten the language is inside the space the generator samples, so the same class of bug cannot
come back unseen. On its first run it surfaced a bug where a Map whose value is an array handed
back a corrupted, empty view — a combination no hand-written test had thought to try.
Why this needs its own tool. Property-based testing targets your logic, the way
--emit=checkdoes. Crucible targets the runtime memory model itself: it pits two backends against each other and watches the allocator for double-frees and leaks. Ingle frees memory deterministically from tracked ownership (Chapter 13), and that deterministic freedom is exactly the thing that needs a fuzzer pointed at it.
The generator: one seed, one program
The generator takes a seed and prints one valid Ingle program to standard output. Same seed, same program, every time — so every finding is perfectly reproducible. You can watch it work:
build/crucible 1 30
The 1 is the seed; the 30 is a loop trip-count the driver later scales up and down for the
leak check. Each program is built from self-contained operations — one function per danger
pattern — and every one of them folds every value it touches into a running acc:
fn op0() -> int {
var acc = 0
var m = map.Map<string, S>{ buckets: [], count: 0 }
m.set("k0", S { a: 98, s: "z" })
m.set("k1", S { a: 53, s: "q" })
m.set("k0", S { a: 31, s: "ab" })
match m.get("k0") { case Some(v) {
acc = acc + v.a + v.s.len()
} case None {} }
match m.get("k1") { case Some(v) {
acc = acc + v.a + v.s.len()
} case None {} }
return acc
}
That one stores a struct as a Map value, overwrites a key, reads two keys back, and folds the
recovered a field and the heap string’s length into acc — so the heap leaf is exercised, not
just the scalar. main then sums every opN() and prints the total. That total is the keystone
of the whole design: it is a checksum of every value the program touched. If a value is
silently dropped, duplicated, or read back wrong, the number changes — which is how Crucible
catches wrong answers, not merely crashes. Run the seed-1 program straight through the VM:
inglec --emit=run seed1.ig
3722=> 0
The 3722 is the checksum main printed; => 0 is the value it returned. (print adds no
newline, so the two land on the same line.)
What does the generator deliberately reach for? The places memory bugs live:
- Struct shapes — all-scalar, with a heap
stringfield, with a nested struct, or with both. - Erased generic containers — those structs placed into
[T],Map<string, T>,Map<string, [T]>, andOption<T>, including nested combinations, built and read back through generic helpers likefn c_pair<T>(a: T, b: T) -> [T]andfn c_keep<T>(move x: T) -> T. - Movement — passed by
moveand by borrow, returned, read back, mutated through an array index (arr[i].field = …), and interpolated ("row{i}-x{i}"), inside loops and across reassignment.
That list is not arbitrary. It is the union of every feature-combination that has ever produced a memory bug in Ingle.
The five oracles
The driver runs each generated program through five independent detectors. In fuzzing these are called oracles, because each one knows how to recognise a particular kind of wrong. The driver builds the variant compilers it needs (a drop-trace build, an AddressSanitizer build) on demand the first time you run it.
| Oracle | What it catches | How |
|---|---|---|
| Double-drop detector | A value freed twice | A compiler built with -DEMBER_DROP_TRACE stamps a sentinel after each reclaim and aborts if it ever frees that object again |
| VM fault | A runtime fault (bad index, etc.) | Runs under the VM and looks for runtime error: … |
| ASan | Use-after-free, buffer overflow, double-free | A compiler built with AddressSanitizer (make asan) |
| RSS leak | Memory that grows super-linearly | Runs the same seed at 50 and 6000 loops and compares peak resident memory |
| VM↔native differential | A wrong answer, or a native-only crash | Compiles the program to a native binary, runs it, and compares output and exit code against the VM |
There is a sixth thing it watches for, quietly: if the generator ever emits a program that doesn’t
compile, that is a bug in the generator — an over-reach past what the language allows — and
it’s reported as gen-compile-error so it can never be mistaken for a real finding.
Why five, and why these? Because each one sees something the others can’t. The runtime’s pool
allocator recycles memory instead of calling free, so a plain ASan build can read a
use-after-free as perfectly valid (recycled) memory and miss it entirely — which is precisely why
the double-drop detector exists, stamping a sentinel that survives recycling. The differential is
the only oracle that catches a silently wrong answer: a program that runs cleanly, leaks
nothing, double-frees nothing, and still prints a different checksum on the two backends. The leak
oracle is the only one that catches unbounded growth in a program that is otherwise correct.
Memory safety is not a single property, so it does not get a single detector.
Findings: signatures, shrinking, and minimal repros
When an oracle fires, the driver does three useful things rather than merely shouting.
First, it reduces the failure to a signature — a short string such as vm-fault:runtime error:
array index out of bounds, or double-drop:type_id=3, or diff:VM-ne-native. Distinct
signatures are distinct findings; the same signature seen again is the same bug, reported once.
Second, for each new signature it shrinks the program to a minimal reproducer: it greedily
deletes operations — the + opK() terms in main make this trivial — for as long as the
signature still holds, and saves the result under tools/crucible-finds/. So you are never handed
a hundred-line generated program; you get the smallest one that still fails. Here is a real one, a
differential finding shrunk from its original two operations down to a single guilty loop:
// crucible seed=3 shape=3 ops=2 loops=30 — generated; do not edit.
import "std/map" as map
struct Inner { x: int }
struct S {
a: int
s: string
inner: Inner
}
fn c_pair<T>(a: T, b: T) -> [T] { return [a, b] }
fn c_keep<T>(move x: T) -> T { return x }
fn op1() -> int {
var acc = 0
var i = 0
loop {
if i == 30 { break }
let xs = c_pair(S { a: 65, s: "hello", inner: Inner { x: 31 } }, S { a: 5, s: "longerstring", inner: Inner { x: 18 } })
acc = acc + xs[0].a + xs[1].a
i = i + 1
}
return acc
}
fn main() -> int {
var total = 0
total = total + op1()
print("{total}")
return 0
}
Third, it prints one line per distinct finding — the signature, the seed, and the repro path:
── [NEW] [diff:VM-ne-native] seed=3 → tools/crucible-finds/find2_diff_VM_ne_native.ig (minimal: 1 op)
When the oracle that fired was the double-drop detector, the underlying evidence — written by the
-DEMBER_DROP_TRACE runtime — has this shape, and the sentinel 0x5EAD5EAD is the giveaway:
*** EMBER DOUBLE-DROP obj=<addr> type=<n> ***
STRUCT type_id=<n> field_count=<n>
first drop site: <addr>
It prints both drop sites — this one and the one it stashed the first time — because for a double-free the question is never “where was it freed?” but “where were the two places that each thought they owned it?”
The baseline: failing only on what’s new
A bug you have already filed shouldn’t paint every future run red. tools/crucible-known.txt is
the baseline: one signature per line, with # comments allowed. A signature listed there is
reported as [known] and does not fail the run; only a signature that is not listed counts as
NEW and flips the exit code to 1. The discipline, stated in the file’s own header, is to
remove a line only when the bug is actually fixed and a clean Crucible run confirms it. As of
this writing the file is empty: the two erased-generic double-free bugs that once lived there are
fixed on both backends, and Crucible is green.
Working a finding
When make crucible hands you a NEW finding, the loop is short:
- Open the minimal repro under
tools/crucible-finds/. It is a real.igfile, already as small as the tool could make it. -
Reproduce it by hand under the oracle that fired, so you can watch it happen. For a memory fault, reach for the tape — build the ASan + drop-trace compiler and run the repro under it:
make asan-trace ASAN_OPTIONS=detect_leaks=0 build/inglec-trace --emit=run tools/crucible-finds/find2_diff_VM_ne_native.igFor a
diff:VM-ne-native, run both backends and compare the answers directly:inglec --emit=run repro.ig # the reference answer (the VM) inglec -o /tmp/repro repro.ig && /tmp/repro # the native answer - Fix the bug, then add a regression test under
tests/so it stays fixed — a fix without a test that exercises it isn’t finished. - Confirm green. Remove the signature from
crucible-known.txtif it was baselined, and runmake crucibleagain until it reports0 NEW.
That is the same discipline the rest of Part IV asks for — reproduce on the smallest possible evidence, fix, lock it in with a test — applied to the one category of correctness a contract cannot describe: what the runtime does with your memory.
Fireside trivia. The double-drop detector’s sentinel is the 32-bit value
0x5EAD5EAD. Squint at it:5EAD5EADreads as “dead dead” — a value freed once is dead, and a value freed twice is dead twice. The pool never touches an object’s refcount field after reclaiming it, and a genuine re-allocation clears it, so if the runtime is about to free an object and finds0x5EAD5EADstill sitting in that field, it knows for certain it’s staring at the same corpse a second time. A good sentinel is a number that could never arise by accident and that tells you what it means the moment you read it in a debugger at two in the morning.
Part V — Reference
Chapter 21 — The Whole Toolbox
Everything inglec can do to a program, in one place. The general shape is
inglec [flags] <file.ig>.
| Command | What it does |
|---|---|
inglec --emit=run file.ig |
Compile and execute. The one you’ll use most. Prints output, then => <value>. |
inglec --emit=tokens file.ig |
Show the token stream (what the lexer saw). |
inglec --emit=ast file.ig |
Show the parsed syntax tree. |
inglec --emit=bytecode file.ig |
Show the compiled bytecode, annotated with source lines. |
inglec --emit=trace file.ig |
Execute and emit the execution tape (JSON Lines). |
inglec --tape file.ig |
Alias for --emit=trace. |
inglec --emit=check file.ig |
Property-fuzz every checkable contract; report counterexamples. |
inglec --emit=prove file.ig |
Statically prove the contracts in the decidable (linear-integer) fragment. |
inglec --emit=replay file.ig |
Record-and-replay the run; verify it’s deterministic. |
inglec --emit=docs file.ig |
Render /// doc comments to a Markdown reference page. |
inglec --emit=c file.ig |
Lower to C. Print the standalone C translation unit the native backend produces. |
inglec -o <bin> file.ig |
Compile to a native binary. Emit that C and link it against the runtime into a standalone executable — no VM. |
The last two are the native backend, and they get their own chapter: Chapter 22: Compiling to Native.
Two flags combine with the above:
| Flag | Effect |
|---|---|
--release |
Elide contract and assert checks (zero runtime cost). |
--diagnostics=json |
Emit compile errors as JSON objects instead of friendly text. |
Exit codes, for scripting: 0 success · 64 you used the compiler wrong · 65 your program
has an error (lexical, syntax, type, or runtime) · 66 the source file couldn’t be read.
Chapter 22 — Compiling to Native
Every program in this book so far has run the same way: the compiler turns your .ig file into
bytecode, and a small virtual machine executes it. That VM is the beating heart of Ingle — it is
where contracts are checked, where the tape is recorded, where --emit=prove, --emit=check and
--emit=replay live. It is the language’s reference semantics: the definition of what an Ingle
program means.
But a virtual machine is not how you ship software. For that, Ingle has a second path: it can lower your program to C, hand that to the system C compiler, and produce a standalone native binary — a real executable, no interpreter, no runtime to install, nothing of Ingle left on the surface.
Two commands
The one you’ll reach for is -o:
inglec -o hello hello.ig
./hello
the answer is 42
=> 0
That is a native executable. It links against Ingle’s small runtime — allocation, the drop machinery, strings, arrays, the handful of things the language needs while it runs — and depends on nothing else. Run it, ship it, drop it in a container without packing an interpreter alongside it.
If you’re curious what Ingle handed the C compiler, ask to see it:
inglec --emit=c hello.ig
// Generated by `inglec --emit=c` from hello.ig. Do not edit.
// The bytecode VM is the reference semantics; tests/native diffs the two.
#include "ember_rt.h"
// ... your functions, lowered one-to-one ...
int main(int argc, char **argv) {
Value r = em_fn_1();
if (IS_INT(r)) printf("=> %lld\n", (long long)AS_INT(r));
rt_free_objects(&g_em);
return 0;
}
It is ordinary, readable C — your add becomes a C function, a + b becomes a + b, and main
runs your main and prints the same => <value> you’ve seen all book. --emit=c writes it to
standard output so you can read it, pipe it, or compile it by hand; -o does the whole job in one
step.
One front end, two lowerings — and a referee
Here is the decision that makes this safe. Ingle does not have two compilers. It has one front
end — one lexer, one parser, one type checker — and two lowerings hanging off the same checked
syntax tree: the one to bytecode you’ve used all along, and a new one (in src/cgen_c.c) to C. The
bytecode VM stays the canonical reference semantics; the native binary is the release build.
Two implementations of anything drift apart unless something forces them together, so Ingle runs a differential test: a corpus of programs is executed both ways — on the VM and as a compiled binary — and their output must match, byte for byte. A divergence fails the build.
If that sounds familiar, it should: it is the same bet as the whole of Part IV. Two independent implementations that are required to agree is a correctness check with real teeth — the compiler holding itself to the determinism standard it asks of your code.
What compiles
Everything the VM accepts. Not “the scalar subset,” not “everything except closures” — the whole
language you’ve learned in this book lowers to native: value structs (which become real C
structs, used by value, with no heap traffic at all), enums and match, arrays and strings,
erased generics, Option/Result and ?, closures and higher-order functions, dynamic dispatch
through interfaces, the bounded generics that Map and Set are built from, and the extern "c"
FFI (which simply binds the real libc you were already calling). Even concurrency comes across: a
program that uses spawn/nursery/channels is detected automatically and compiled against a
threaded runtime, so its tasks run on real OS threads.
The one thing it doesn’t do: check your work
A native binary is a release build, and that has a precise, deliberate consequence: contracts
and asserts are not enforced in native output. They are compiled out, exactly as --release
elides them on the VM.
Watch the difference. Here is a function whose postcondition is a lie — x is never greater than
x:
fn bad(x: int) -> int
ensures result > x
{
return x
}
fn main() -> int {
let y = bad(5)
println("y={y}")
return 0
}
Run it on the VM and the contract fires before main ever reaches the println:
inglec --emit=run bad.ig
inglec: runtime error: postcondition failed in 'bad' (ensures, line 2)
Compile the very same program to native, and it runs straight through — the spec isn’t in there anymore:
inglec -o bad bad.ig && ./bad
y=5
=> 0
This is not an oversight; it is the division of labour. Verification lives on the VM —
--emit=check, --emit=prove, --emit=replay, the tape, and runtime contract checks are all
reference-semantics capabilities, because the VM is where determinism and observability are
guaranteed. The native binary’s job is to be fast and standalone. So the workflow is a clean
two-step: prove, check and replay on the VM until you trust the program, then -o it for the
world. You verify the meaning where meaning is defined, and ship the speed where speed is wanted.
(One practical note while the toolchain is young: a freshly built inglec -o finds its runtime
next to the compiler, in the build tree, so native compilation expects to run from there for now.
Packaging it for a system-wide install is plumbing, tracked on the
Not Yet List.)
Why C, and why this matters
Ingle lowers from the typed AST, not from the bytecode, and targets C, not assembly or LLVM IR.
Both choices point the same way. C gives natural output and a compiler on every platform with no new
dependency — Ingle’s empty dependency tree survives intact. And lowering from the AST, where the
expression a + b still exists as an expression, rather than from stack-based bytecode, where it
has been flattened into pushes and pops, is what keeps the generated C readable instead of an
unrolled interpreter.
That readability matters for a concrete reason: you cannot run an operating system as a guest inside a virtual machine that itself needs an operating system to run. A language that means to reach bare metal has to be able to leave its VM behind, and emitting readable C is an early step in that direction.
Fireside trivia. Compiling a new language to C is one of the oldest tricks in the trade, and one of the most quietly successful. The first real C++ “compiler” — Bjarne Stroustrup’s cfront, at Bell Labs in the early 1980s — was not a compiler at all in the usual sense: it translated C++ into C and let the ordinary C compiler finish the job. That is how C++ reached every machine that already had a C compiler, which in 1983 was very nearly all of them. Four decades on, the move still works for the same reason: C is the closest thing computing has to a universal assembler, and a language that learns to speak it inherits the entire world C already runs on. Ingle’s
--emit=cis the newest entry in a long and respectable line.
Chapter 23 — The “Not Yet” List
This is the most important chapter for keeping you honest, and it’s the promise from the front of the book made good. Everything else in this book runs today. The things below are designed but not yet built — they appear in Ingle’s manifesto, its spec, or its examples as intentions, and writing them today will not work. They’re listed so you know where the edge is, not so you’ll use them.
Types and polymorphism
- Generic bounds on methods and enums — bounds (
<T: Ord>, including several at once like<K: Hash + Eq>) work on free functions and on generic structs now — that’s exactly what the standardMap<K, V>is built from — but not yet on a generic struct’s individual methods or on generic enums. (Dynamic dispatch and bounded generic structs both landed; see Chapter 7 and Chapter 10. The only interfaces that can’t be value types are non-object-safe ones, and that’s a permanent design rule, not a missing feature.) Move-type keys for— shipped. AMapMap/Setkey need only beHash + Eq; a move-type struct key is deep-cloned on store, so there’s noCopyrequirement and noCloneceremony (OFI-042). Closed since this book’s first draft.- Monomorphization — generics are compiled once (erased) today. The specialised per-type-for-speed release path is planned; you won’t write code differently for it.
Ownership and memory
- Inferred return lifetimes — so a borrowed parameter could be returned without
move. Today, returning an owned value needsmove. Full generic-body ownership and reclamation— shipped (June 2026). A generic body is ownership-checked like concrete code — a type parameter is a move type by default,Copyopts out — and refcounted values passing through it are reclaimed by the caller (OFI-009, and the OFI-117 tail). Earlier drafts listed this as a conservative leak-until-exit; that gap is closed.
Concurrency
- M:N scheduling as the default — the M:N green-thread scheduler is built but gated behind
make mn(structured cancellation-on-failure rides with it) while it clears a wider soak; the cooperative single-thread runtime stays the default until then. Also not yet:select/timeouts (the non-blockingtry_recvpoll is here — it’sselect-with-a-default; waiting on several channels at once, and timeouts, aren’t), and main↔child channel communication during a nursery (today it’s child-to-child;mainreads results after the nursery).
Values and text
- Top-level
varand non-literal global initialisers — top-levelletconstants (with literal values) work; general mutable globals don’t. Unicode-aware strings— shipped (June 2026). Strings are fully UTF-8;chars()yields one string per code point, and thecp_*family (cp_count,cp_at,cp_slice,cp_prefix,cp_insert,cp_delete) gives Unicode-correct editing. Closed by OFI-055.— shipped (June 2026). A full-range literal likeu64literals above 2⁶³−118446744073709551615(or with au64suffix) now writes directly; the parser reads the magnitude unsigned, and a value with the sign bit set isu64-only. Closed by OFI-123.
Foreign functions and capabilities
- FFI returning C-owned memory — Ingle passes scalars, all-scalar structs, strings
(
const char*), packed buffers ([u8]/mut [u8]), and opaquePtrhandles across to C, and a returnedchar*now comes back as a copied-instring(the mechanismstd/httpuses to return a response body). What’s left is the other returning shapes: a non-stringmalloc‘d buffer (needs a length channel) and an “Ingle adopts and frees the pointer” form for ownership-transfer APIs — plus arbitrary dynamic linking. - Capabilities — the designed model for bounding what code may do (gating the FFI, then filesystem and network) is in the manifesto, not the language yet.
Tooling and language
- Separate compilation / incremental rebuilds and metaprogramming (
comptimevs. macros — deliberately undecided) are on the roadmap.
If you stick to the sixteen chapters before this one, you will never write a line that doesn’t compile for a reason on this list. That’s the deal.
Fireside trivia. A language that keeps a careful, public list of what it can’t do yet is rarer than it should be — the temptation is always to describe the destination as if you’d arrived. Ingle keeps two such lists in its own repository: a manifesto of decisions and a dated log of every bug and design flaw found while building (the cheerfully-named “Opportunities For Improvement”). The window that couldn’t accept a click on its very first frame; the uninitialised field that corrupted a function call; the generic that freed memory twice — all written down, numbered, and mostly closed.
Chapter 24 — One-Page Cheat Sheet
Everything that runs today, compressed. (Keep the earlier chapters for the why; this is the what.)
// --- bindings ---
let x = 5 // immutable
var y = 0 // mutable; y = y + 1
let TOP = 800 // top-level let = compile-time constant (literal only)
// --- functions ---
fn add(a: int, b: int) -> int { return a + b }
fn greet(name: string) { println(name) } // no '-> T' = unit (returns nothing)
// --- numbers --- int=i64, float=f64; also i8..i64, u8..u64, f32/f64
let a: u8 = 200 // or 200u8
let f = to_float(a) // int -> float; to_int(f) truncates
let g = u8(300) // range-checked conversion; out of range -> runtime trap
// no implicit coercion; integer overflow traps; % needs integers
a & b a | b a ^ b ~a a << 1 a >> 1 // bitwise/shift: integer-only, width-aware
wrapping_add(a, b) wrapping_sub(a, b) wrapping_mul(a, b) // modular 2^width; trapping stays default
// --- strings (UTF-8) ---
let s = "hi " + name // + concatenates; == compares by content
"value is {x}" // interpolation; \{ \} for literal braces
"{p}" // a struct/enum too, when its type has fn show(self) -> string (Show)
s.len() // byte length s.char_count() // code-point count
s.chars() // [string] one per code point s.bytes() // [u8]
s.split(",") s.parse_int() // -> Option<int>
// Unicode-correct ops: cp_count(s) cp_at(s,i) cp_slice(s,a,b) cp_prefix(s,n)
// cp_insert(s,i,ins) cp_delete(s,i)
// --- bool / control flow --- (conditions MUST be bool; no truthiness)
if x < 0 { } else if x == 0 { } else { }
loop { if done { break } continue }
// --- arrays ---
var xs: [int] = []
xs.append(1) xs.remove_last() xs.remove_at(i) xs.len() xs[0] = 9 // indexing is bounds-checked
for v in xs { } // each element
for i in 0..n { } // exclusive range
for (i, v) in xs { } // index + element
let win = xs[1..4] // borrowed Slice<int> view, no copy; xs.slice(1,4) returns an owned [int]
let dup = xs.clone() // explicit deep copy — arrays, structs, Map, Set
// --- structs, methods, interfaces ---
struct Point { x: int y: int
fn shifted(self, d: int) -> Point { return Point { x: self.x + d, y: self.y } }
}
let p = Point { x: 1, y: 2 } // set every field exactly once
interface Ord { fn compare(self, other: Self) -> int } // 'other: Self' -> bound only
struct V implements Ord { n: int fn compare(self, o: V) -> int { return self.n - o.n } }
interface Shape { fn area(self) -> float } // object-safe (Self only as receiver)
let shapes: [Shape] = [Circle { ... }, Rect { ... }] // interface AS A VALUE TYPE
for s in shapes { println("{s.area()}") } // dynamic dispatch through a vtable
// --- newtypes / refinements --- (distinct nominal types; erase to the base, zero cost)
type UserId = int // UserId(7) constructs; not interchangeable with int or other newtypes
type Email = string // inherits base ==, order, hash, render; Map key + "{id}" ok
type Percent = int where 0 <= self && self <= 100 // predicate checked at construction; Percent(150) -> refinement_violation
// arithmetic needs an explicit unwrap: int(x), compute, re-wrap; refinement checks elided in --release
// --- enums + match --- (exhaustive, no fallthrough)
enum Shape { Circle(r: int) Rect(w: int, h: int) Origin }
let c = Circle(3) // construct positionally...
let d = Circle(r: 3) // ...or by field name, like a struct literal
match s {
case Circle(r) { }
case Rect(w, h) { }
case _ { } // catch-all, must be last
}
// --- errors / optionals --- (no exceptions, no null; from the prelude)
fn f(n: int) -> Result<int, string> { if n < 0 { return Err("neg") } return Ok(n) }
let v = f(3)? // unwrap Ok/Some, or return the Err/None early
// --- generics ---
struct Box<T> { value: T fn get(self) -> T { return self.value } }
fn identity<T>(move x: T) -> T { return x } // returns its arg -> move
fn id<T: Copy>(x: T) -> T { return x } // Copy = everything except struct/array
fn max<T: Ord>(move a: T, move b: T) -> T { if a.compare(b) >= 0 { return a } return b }
struct Map<K: Hash + Eq, V> { /* ... */ } // bounds on generic structs; several with +
// --- functions as values / closures ---
fn apply(f: fn(int) -> int, x: int) -> int { return f(x) }
apply(|n| n + 1, 5) // inline lambda: context gives its types
let h: fn(int) -> int = |n| n * 2 // bound lambda NEEDS the annotation
// --- ownership --- (borrow by default; mut borrows mutably; move transfers)
fn read(p: Point) -> int { return p.x } // borrow
fn bump(mut p: Point) { p.x = p.x + 1 } // mutable borrow
fn take(move p: Point) -> Point { return p } // owns it; may return it
// --- concurrency ---
let ch: Channel<int> = channel(2)
nursery {
spawn worker(ch) // tasks can't outlive the block
}
// send(ch, v) recv(ch)/try_recv(ch) -> Option<T> close(ch) // send on a closed channel traps
// --- talking to C (FFI) --- (extern block = the trust boundary; no separate 'unsafe')
extern "c" {
fn strlen(s: string) -> i64 // string -> const char*
fn fread(mut buf: [u8], n: i64, f: Ptr) -> i64 // [u8]/mut [u8] = buffer; Ptr = opaque handle
}
// --- modules / stdlib ---
import "std/string" as str // str.trim, to_upper, contains, replace, join, ...
import "std/list" as list // list.map, filter, reduce, sort
import "std/map" as mp // mp.Map<K, V> (any Hash+Eq key, incl. structs): set, get -> Option<V>, has, size, keys -> [K]
// names beginning with _ are private to their module
// --- contracts + verification ---
fn clamp(x: int, lo: int, hi: int) -> int
requires lo <= hi
ensures result >= lo
ensures result <= hi
{ if x < lo { return lo } if x > hi { return hi } return x }
assert(cond, "message") // inline check; elided in --release
// inglec --emit=run | check | prove | replay | trace(--tape) | docs | c [--release]
// inglec -o <bin> file.ig // compile to a standalone native binary (Chapter 22)
Part VI — Things People See
The chapters so far built the language up piece by piece: values, functions, structs and enums,
generics, ownership, concurrency, contracts. This last part spends all of it at once. Flare —
Ingle’s UI layer — is where structs become components, enums become the thing you match to paint
a screen, closures hang off buttons, and the render loop from
Chapter 13 grows into an application. It’s the book’s capstone,
and there’s no new syntax in it — only the old syntax pointed at a window.
Chapter 25 — Flare: Interfaces You Can Read
Flare is Ingle’s component-style UI layer. Components are functions, props are arguments, state is local, and the screen is a declarative description you re-evaluate each frame. There’s no retained widget tree, no diffing layer, no separate effect system — and the reason there isn’t is the whole design, so it’s worth a minute.
Immediate mode, not a retained tree
Ingle’s graphics backend redraws the whole frame every tick, cheaply. That’s the immediate-mode
model from Chapter 13: the UI is a pure function of your state,
drawn fresh each frame, with no retained widget tree to keep in sync. There’s nothing to diff and
nothing to reconcile, because nothing is kept between frames to fall out of date. Components, props,
local state, and declarative composition drop straight into Ingle’s loop { …describe the frame… }.
No retained tree means no graph-shaped mutable state, which means the ownership model from
Chapter 12 stays clean even while you build something live
and interactive.
The shape of a Flare program
Here’s a whole one — a window with a toolbar and a counter. It’s examples/graphics/17_flare.ig,
shipped with the compiler:
import "std/draw" as draw
import "std/flare" as flare
fn main() -> int {
draw.window(580, 400, "Flare")
var f = flare.new()
var count = 0
var dark = false
loop {
if draw.closing() { break }
draw.begin(f.bg()) // clear to the theme's background
f.begin() // start this frame's UI
// A toolbar: title on the left, actions pushed right by a flexible spacer.
f.row(flare.BETWEEN, flare.CENTER)
f.heading("Flare")
f.spacer()
if f.button("Theme") {
dark = !dark
if dark { f.use_dark() } else { f.use_light() }
}
if f.primary("Compose") { count = count + 1 }
f.end()
f.text_muted("Composed {count} times.")
f.finish() // solve the layout, paint the frame
draw.finish()
}
draw.close()
return 0
}
Build the graphics compiler and run it:
make graphics && INGLE_STD=./std build/inglec-gfx --emit=run examples/graphics/17_flare.ig
Four things in that loop are the entire model. The next four sections take them one at a time.
Events are return values
Look again at the buttons:
if f.button("Theme") { ... }
if f.primary("Compose") { count = count + 1 }
There’s no separate click handler. f.button("Theme") draws the button and returns a bool —
true on the one frame the user clicked it. So a click is just an if, and the code that reacts to a button sits
right next to the button, in the same place, read top to bottom. That’s the idea that makes
immediate-mode UI legible: no callbacks to register, no handler defined three hundred lines away, no
this to bind.
The loop owns your state
Where does state live? In the vars the loop owns. count is just a var; it survives because the
loop survives. No setter, no stale-closure surprise. You only reach for Flare’s own state
when you want a value encapsulated inside a reusable component, so the caller needn’t hold it for
you — and that’s the next section.
Components are just functions
A component is a function that takes the Flare value (by mutable borrow — see
Chapter 12) and emits some widgets. Here’s a self-contained
counter that keeps its own count:
fn Counter(mut f: flare.Flare, key: string, title: string) {
f.key(key) // give this instance its own identity
var n = f.state_int("n", 0) // read encapsulated state (default 0)
f.row(flare.START, flare.CENTER)
if f.button("-") { n = n - 1 }
f.label("{title}: {n}")
if f.button("+") { n = n + 1 }
f.end()
f.set_int("n", n) // write it back
f.key_clear()
}
Now drop two of them in, and they don’t interfere:
Counter(f, "apples", "Apples")
Counter(f, "pears", "Pears")
Both draw a "-" and a "+" with identical labels, and both keep their own separate count. The
thing that keeps them apart is key.
Identity: the one concept that makes lists work
Immediate-mode widgets are identified by a hash of their label, so two "+" buttons would collide —
same id, same state slot, same hit-test. f.key("apples") opens an id scope that is mixed into
every widget id and every piece of state beneath it, so the two counters’ buttons and their
state_int("n", …) stay distinct. This is the classic immediate-mode id-stack. Open a scope with
f.key(...) before a keyed component or a list row; close it with f.key_clear() after. For a
list, the idiom is just what you’d guess:
var i = 0
loop {
if i == rows.len() { break }
f.key("row{i}") // each row gets a distinct identity
if f.button(rows[i]) { selected = i }
f.key_clear()
i = i + 1
}
Layout is real flexbox
The toolbar used f.row(flare.BETWEEN, flare.CENTER) and f.spacer(), and that’s no metaphor:
underneath Flare sits std/layout, an actual flexbox solver. Each frame, as you declare the UI,
Flare builds an ephemeral tree of boxes, solves it (measure, then place), and paints each widget at
its solved rectangle. Nothing is retained between frames, so the immediate-mode bet still holds; the
value-returning buttons keep working because a click this frame tests against the rectangle the widget
got last frame — stable enough that you never notice.
Containers come in opener/f.end() pairs:
f.row(justify, align)/f.column(justify, align)— lay children along the main axis (a row is horizontal), sized to their content.justifypositions them along the main axis (flare.START,flare.CENTER,flare.END,flare.BETWEEN);alignpositions them on the cross axis (flare.START,flare.CENTER,flare.END,flare.STRETCH).f.row_grow(...)/f.column_grow(...)— the same, but flex to fill the parent’s main axis.f.spacer()— a flexible gap that swallows the leftover space, pushing whatever follows to the far edge (that’s how the toolbar’s actions reached the right).f.strut(w, h)— a fixed minimum size, e.g. to pin a sidebar to a width.f.panel_begin(justify, align)— a container with a painted surface, for a sidebar or a card.f.scroll_begin(key)/f.scroll_end(key)— a clipped, wheel-scrolled viewport;f.scroll_to_bottom(key)jumps it to the end.
A two-pane application skeleton — a pinned sidebar beside a growing, scrollable main pane — falls right out of those:
f.row_grow(flare.START, flare.STRETCH) // the body fills the window
f.panel_begin(flare.START, flare.START) // a painted sidebar…
f.strut(220, 0) // …pinned to 220px wide
f.heading("Notes")
if f.primary("+ New note") { make_note() }
f.text_muted("Recent")
f.end()
f.column_grow(flare.START, flare.STRETCH) // the main pane takes the rest
f.scroll_begin("body")
f.paragraph(current, 600) // wrapped prose, 600px wide
f.scroll_end("body")
f.end()
f.end() // close the body row
Animation: springs and FLIP
Motion rides the same keyed state as everything else, and it steps on a fixed timestep, so an
animation is a pure function of the frame count — deterministic, replayable, golden-testable, never
tied to the wall clock. There are three pieces, and examples/graphics/18_flare_anim.ig runs all of
them.
A spring eases a named value toward a target, one fixed step per frame. f.spring("panel_w",
target) returns the value’s current position; point it at a new target on any frame and it redirects
smoothly with its velocity intact — which is just what an interactive UI needs, since the user keeps
poking at things mid-motion. Drive a width, a scale, an offset:
var tw = 160.0
if expanded { tw = 460.0 }
let w = f.spring("panel_w", tw) // eases between the widths; retargets mid-flight
f.panel_begin(flare.START, flare.CENTER)
f.strut(to_int(w), 56)
f.label("width springs to {to_int(w)}px")
f.end()
(f.spring_with(key, target, stiffness, damping) tunes the feel if the default bounce isn’t right.)
f.at(dx, dy) { … } f.end_at() shifts the paint of everything inside it by a pixel offset
without moving it in the layout solve, so a subtree slides over its neighbours — feed it a spring
and you have a drawer or a toast.
f.animate_layout(key) { … } f.end_animate_layout() animates a widget that moved because the
layout changed — a row was inserted, a list reordered. This is the well-known FLIP trick, and Flare
gets it almost for free: it already re-solves real flexbox every frame and caches each widget’s
last-frame rectangle, so last frame’s position is the “before” and this frame’s is the “after,” and
the spring just rides the difference, at paint time. Give each item a stable key so the motion
follows the item, not the slot it happens to occupy:
var i = 0
loop {
if i == items.len() { break }
f.animate_layout("row:{items[i]}") // stable key → the animation follows this row
f.panel_begin(flare.START, flare.CENTER)
f.label("Row #{items[i]}")
f.end()
f.end_animate_layout()
i = i + 1
}
Add or remove a row at the top and the rows below slide to their new places instead of jumping. The
determinism is the quietly useful part: because motion is a function of frame count, the animation
goldens (tests/graphics/flare_spring.ig, flare_flip.ig) reproduce frame-for-frame.
Appearing and disappearing
Two more keyed-state primitives handle an element’s lifecycle, not just its motion. f.presence(key,
present) -> float springs from 0 toward 1 the first frame a key is seen — so it animates in — and
back toward 0 once you pass present = false, animating out; keep drawing the leaving element until
presence returns near 0, then drop it from your data. Pair it with f.at for a fade-and-slide:
let p = f.presence("row:{id}", !leaving)
f.at(0.0, (1.0 - p) * 16.0)
// ...draw the row...
f.end_at()
if leaving && p < 0.02 { remove(id) } // the exit has finished
f.fade_begin(amount) … f.fade_end() is the opacity bracket: everything painted between them is
composited at amount (0–1), so a whole subtree can dim, disable, or sit behind a scrim as one. At
full opacity it’s a no-op, so an un-faded screen stays byte-identical — the goldens don’t budge.
Toasts
For transient feedback — “Copied”, an error, a confirmation — f.toast(text) enqueues a notification,
and f.toast_layer() (called once per frame, after finish()) draws and ages the stack as fading
pills that dismiss themselves on a timer. A toast can carry an action: f.toast_action(text, label,
token) puts a button on the pill, and f.take_action() returns the token for one frame when it’s
pressed — which is the entire reversible-“Undo” pattern: snapshot the thing, delete it, show
“Deleted · [Undo]”, and re-insert if the token comes back.
The widget catalogue
Everything you emit between f.begin() and f.finish():
- Actions —
f.button(txt) -> bool(a secondary button),f.primary(txt) -> bool(the one headline action, in the accent colour), andf.ghost_button(txt) -> bool(borderless, no fill at rest — for toolbars and a message’s Copy/Retry). All returntrueon the frame they’re clicked, and all size to their content — a bare button doesn’t stretch to fill the column it sits in. When you want a full-width block instead — a sidebar’s “New chat”, a stacked call-to-action — reach forf.button_fill(txt)/f.primary_fill(txt), the variants that fill a stretch parent’s cross axis. - Choice —
f.segmented(key, options, selected) -> intis a single-choice strip (the chosen option filled with the accent, the rest plain) that returns the new index, so it readsmode = f.segmented("mode", opts, mode)— the natural fit for a small settings toggle. - Navigation —
f.nav_item(txt, active) -> boolis a full-width sidebar row that grows to the panel’s width and takes the accent fill whenactive; pair it in arowwith a trailingf.ghost_button("···")for per-item actions.f.avatar(glyph)is a small rounded identity badge. - Text —
f.heading(s),f.label(s),f.text_muted(s)for a quieter line, andf.divider()for a full-width hairline rule. - Prose —
f.paragraph(text, width)word-wraps plain text to a pixel width.f.rich_text(text, width)wraps prose with inline Markdown emphasis —**bold**,*italic*,`code`, and[links](url).f.markdown(text, width)goes the whole way: it parses the text withstd/markdowninto theBlockenum and renders each block with amatch— prose and bullets, size-stepped headings, blockquotes with an accent bar, pipe tables as an aligned grid, and fenced code blocks in a monospace panel syntax-highlighted bystd/highlight(whose text you can select and copy). The renderer is one exhaustivematchover an enum, which is Chapter 8 doing exactly what it’s for. - Input —
f.text_field(key, value) -> stringis a full single-line editor (caret, selection, horizontal scroll, clipboard, UTF-8-correct cursor motion) that returns the current text;f.submit() -> boolreportstrueon the frame Enter was pressed, and clears the field.f.text_area(key, value) -> stringis its multi-line sibling — it auto-grows then scrolls, has a 2-D caret, and takes Shift+Enter for a newline while plain Enter still reports throughsubmit()(the composer convention). The loop owns the string:
input = f.text_field("composer", input)
if f.submit() {
if input.len() > 0 { rows.append(input) }
input = ""
}
A handful of containers earn their place once an app grows up: f.bubble_begin() / f.bubble_end()
wrap a rounded, tinted message bubble; f.page_begin(width) / f.page_end() centre a fixed-width
readable column with flexible margins either side (CSS max-width + margin: auto, in other words);
f.scroll_begin_sticky(key) is a transcript viewport that follows the bottom as content grows but
only while you’re already there, so scrolling up to re-read leaves you put — paired with
f.scroll_fab(key) -> bool, the round “jump to latest” button that appears once you’ve scrolled away;
and f.splitter(key, size, lo, hi, vertical) -> int is a draggable handle between two panes that
returns the pane’s new size for you to store and feed back next frame.
Overlays: modals and popovers
Two floating layers sit above the main UI, and both rest on std/layout’s floating node, so they land
correctly however deep in the tree you declare them.
f.modal_begin(key, w, h) -> bool / f.modal_end() open a centred dialog over a dimmed scrim
(pass h = 0 to size to content). While it’s open the widgets behind it go inert — clicks can’t fall
through — and a press on the scrim outside the panel returns false, the caller’s cue to close it.
Build the contents as a column between the two calls; it’s the reusable basis for a settings dialog, a
confirmation, a picker:
if settings_open {
if !f.modal_begin("settings", 420, 0) { // false → pressed outside; close it
settings_open = false
}
f.heading("Settings")
theme = f.segmented("theme", ["Light", "Dark"], theme)
if f.primary("Done") { settings_open = false }
f.modal_end()
}
f.popover_begin(key, x, y) -> bool / f.popover_end() open an anchored menu at a point — no
scrim — with the same press-outside-to-close behaviour, filled with f.menu_item(txt) -> bool rows.
The cursor position is the usual anchor, which makes it the natural context menu or dropdown.
Theme, zoom, and what it all rests on
f.use_dark() and f.use_light() re-theme the entire app — the default is a warm “parchment and
clay” look — and f.bg() hands you the current background colour to pass to draw.begin.
f.set_zoom(pct) sets the app-wide text size (clamped 60–220 — pick a sensible default at startup,
e.g. f.set_zoom(80)) and f.zoom_by(delta) nudges it for accessibility. None of this is
special-cased: Flare delegates
everything to std/ui, so the theme, the per-frame UI tape (a record of input and draw commands, in
the spirit of the execution tape from
Chapter 19), and contracts all carry over unchanged.
Flare is a few hundred lines of Ingle over std/ui and std/layout — you could have written it.
Staying at sixty
The loop rebuilds every frame, so two costs have to stay flat as content grows — and Flare keeps both
flat. List virtualization — f.virtual_begin(key, count), then f.virtual_item(i) /
f.virtual_item_end() per row, then f.virtual_end() — builds and lays out only the rows whose
extent falls in the scroll viewport (plus a little overscan), with strut spacers preserving the
scrollbar, so a list of ten thousand rows costs about a screenful. And when nothing is happening the
loop stops spinning: it blocks on the OS event queue once there’s no input, nothing animating
(f.is_animating()), and no reply streaming, so a still app falls from ~99% of a CPU core to ~0% while
still waking instantly on the next event. Together they’re why a long, live chat transcript holds 60fps.
What’s not there yet
True to the book’s deal (Chapter 23), here are the edges:
checkboxandsliderexist instd/uibut aren’t wrapped into the Flare flexbox model yet — for now abuttonor asegmentedbound to avardoes the job.- Inline Markdown emphasis renders, but with faux faces.
rich_textandmarkdownnow draw**bold**and*italic*— but only one weight of the UI font is embedded, so bold and italic are synthesised (thickened and slanted) rather than drawn from real face files. It reads fine; it isn’t yet typographically honest (OFI-077, open). - Selection in
markdownis per-block. You can drag-select and copy within a single code or prose block, but not yet across blocks.
Fireside trivia. The vocabulary Flare settled on —
row,column,spacer,grow,justify,align— isn’t an accident. It’s flexbox, the layout model every front-end developer (and every model trained on their code) knows cold. Pair it with events-as-return-values, where the handler sits physically next to the widget, and you get a UI dialect with almost no hidden control flow: no callback indirection, no effect scheduling, no retained tree mutating behind your back. The whole stack, layout solver included, is itself a handful of readable Ingle files.
Appendix A — Glossary
Binding — a name attached to a value, via let (immutable) or var (mutable).
Borrow — using a value without taking ownership of it; the default for parameters.
Closure — a function value that has captured variables from where it was created (by value, in Ingle).
Contract — a requires/ensures specification attached to a function; checked in debug,
elided in release.
Dynamic dispatch — calling a method through an interface value, where the concrete type isn’t known until runtime and the right method is found via the value’s method table (vtable). Contrast with a generic bound, which dispatches statically through a witness.
Enum — a sum type: a value that is exactly one of several named variants, each possibly carrying typed fields.
Exhaustive — a match that handles every variant; required, and checked by the compiler.
Generic — code parameterised by type (Box<T>), written once and used for many types.
Interface — a named set of method signatures a type can declare it implements.
Move — transferring ownership of a value; the old name can’t be used afterward.
Nursery — a scoped block that owns concurrent tasks and joins them all before it exits.
Object-safe — an interface whose methods only ever mention Self as the receiver (never as a
parameter or return type), which is the condition for using it as a value type for dynamic
dispatch. Non-object-safe interfaces are still usable as generic bounds.
Option — Some(value) or None; Ingle’s “maybe a value,” replacing null.
Prelude — the handful of types (Option, Result) injected into every program
automatically.
Result — Ok(value) or Err(error); a success-or-reasoned-failure value.
Tape — the structured, per-instruction JSON record of an execution.
Unit function — a function with no return type; it runs for effect and yields no value.
Colophon
This book describes the Ingle language as it stood in late June 2026, early in its life and
moving fast. This edition was refreshed as several features landed in quick succession —
dynamic dispatch (interfaces as value types), the bitwise and shift operators plus the
explicit wrapping-arithmetic builtins, the generic-keyed Map<K, V> and the bounded
generic structs underneath it, the pointer/buffer/handle FFI, and — largest of all — the
native C backend that compiles a whole program to a standalone binary
(Chapter 22) — each of which had been
“not yet” only a day or two earlier. It
covers only what had been built and tested by the time of writing; the
Not Yet List marks the boundary. A later pass — this one — folded
in what had landed since: array slices and explicit .clone(), the non-blocking
try_recv, struct keys for Map/Set, a returned C char* arriving as a copied-in
string (how std/http brings a response body home), and — the largest piece — Flare’s
animation (springs + FLIP), its modal/popover overlays, and a much wider widget catalogue.
A further pass folded in the run of features that landed in late June: Show (a fn show(self) ->
string makes any value interpolate), named enum construction (Circle(radius: 2.0)), the
resource struct that lets a value own and auto-close a C handle — and std/sqlite built on
it — full-range u64 literals, the rc struct and std/slotmap sharing tools, and
Flare’s 60fps work (list virtualization, idle-CPU gating) with enter/exit animation, fades, and
toasts. The toolchain also grew a fourth gate (ledger, the resource-linearity fuzzer) and now
builds and runs on Linux alongside macOS.
A later pass began Ingle’s type-system campaign: newtypes (type UserId = int, a distinct
nominal type over a scalar or string, erased to its base at zero cost) and refinement types
(type Percent = int where 0 <= self && self <= 100, a predicate checked at construction) — both in
Chapter 7. The run-time fault also grew sharper: an unhandled
error now renders its payload as data (values: error = IoErr { code: 5 }) at a true file:line:col
(Chapter 19).
Every Ingle snippet in these pages was compiled and run with the reference compiler before being
written down, and the outputs shown are the outputs produced. Where this book and the language’s
formal specification disagreed, the book follows what the compiler actually does — and
refreshing it turned up a few places where the spec’s prose had drifted behind its own
implementation (a sentence still calling structs “immutable records,” another claiming there was
no prelude, a stale note on which generic bounds were allowed — and, this pass, a Map key still
said to need Copy and Flare’s inline Markdown still described as unrendered). Those were noted and filed as
Opportunities For Improvement rather than copied, which is exactly the bargain this book makes
with itself.
Ingle will have grown since you read this. Treat the spirit — safe by default, simple by default, fast to build, honest about its edges — as the durable part, and check the current spec and examples for the details. The fire’s only just been lit.
Written by the fireside. Mind the sparks.