Split language manual into topic pages with cross-references

Break docs/manual.md (3074 lines) into 14 focused topic pages under
docs/manual/, add inter-page cross-references, and remove duplicated
sections (? operator, iterator protocols, unsafe blocks examples).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 18:36:08 +02:00
parent fe6263d575
commit 04edece055
16 changed files with 3043 additions and 3068 deletions

View File

@@ -128,7 +128,7 @@ async fn parallelSum(a: string, b: string): int64 {
## Documentation
- **[Language Manual](docs/manual.md)** -- complete reference for implemented features
- **[Language Manual](docs/manual/index.md)** -- complete reference for implemented features
- **[Compiler Architecture](docs/architecture.md)** -- end-to-end pipeline documentation
- **[Grammar](docs/grammar.md)** -- formal grammar specification
- **[Bootstrap TODO](docs/bootstrap-todo.md)** -- roadmap toward self-hosting

File diff suppressed because it is too large Load Diff

250
docs/manual/basics.md Normal file
View File

@@ -0,0 +1,250 @@
# Basics
[Back to Manual](index.md)
## Hello, world
```
print("Hello, world!");
```
All programs implicitly `import std` to load Peon's standard library.
## Variable declarations
Peon has three declaration keywords with distinct semantics:
| Keyword | Mutable? | Initialization | Evaluation |
|---------|----------|----------------|------------|
| `var` | Yes | Optional | Runtime |
| `let` | No | Optional | Runtime |
| `const` | No | Required | Compile time |
- **`var`** declares a mutable variable. Its value can be reassigned after declaration.
A `var` may be declared without an initializer if a type annotation is provided;
the compiler tracks the first assignment and prevents use before initialization
rather than zero-initializing the storage.
- **`let`** declares an immutable binding. Once assigned, the value cannot be changed.
Like `var`, a `let` may be declared without an initializer if a type annotation is
provided. The compiler ensures the binding is assigned exactly once before use and
prevents any subsequent reassignment.
- **`const`** declares a compile-time constant. The initializer must be evaluable at
compile time, and the resulting value is inlined at every use site. `const` values
do not occupy runtime storage. See [Compile-Time Features](compile-time.md) for more
on compile-time evaluation.
In function parameter position, `const` has a different meaning: `fn f(x: const int64)`
declares a static compile-time parameter rather than a `const` variable declaration
(see [Generics](generics.md#static-compile-time-parameters)).
```
var x = 5; # Inferred type is int64
var y = 3'u16; # Type suffix specifies uint16
x = 6; # Works: type matches
x = 3.0; # Error: cannot assign float64 to x
var x = 3.14; # Error: cannot re-declare x
const z = 6.28; # Constant: evaluated at compile time
let a = "hi!"; # Immutable: cannot be reassigned
var b: int32 = 5; # Explicit type annotation
var c: int64; # No initializer: use before first assignment is an error
let d: string; # No initializer: must be assigned exactly once before use
d = "hello"; # First assignment is OK
# d = "world"; # Error: cannot reassign a let binding
```
All three forms support optional type annotations (`var x: int32 = 5`). When the
annotation is omitted, the type is inferred from the initializer. For `var` and `let`
declarations without an initializer, a type annotation is required.
__Note__: Peon supports [name stropping](https://en.wikipedia.org/wiki/Stropping_(syntax)), meaning
that almost any ASCII sequence of characters can be used as an identifier, including language
keywords, but stropped names need to be enclosed by matching pairs of backticks (`` ` ``).
## Comments
```
# This is a single-line comment
# Peon has no specific syntax for multi-line comments.
fn id[T](x: T): T {
## Documentation comments start
## with two hashes. They are currently
## parsed but ignored by the compiler.
return x;
}
```
## Strings
String literals are enclosed in double quotes. The following escape sequences are
supported:
| Escape | Meaning |
|--------|---------|
| `\\` | Backslash |
| `\"` | Double quote |
| `\'` | Single quote |
| `\n` | Newline |
| `\t` | Tab |
| `\r` | Carriage return |
| `\a` | Bell |
| `\b` | Backspace |
| `\e` | Escape (0x1B) |
| `\f` | Form feed |
| `\v` | Vertical tab |
| `\xHH` | Hex byte (value <= 255) |
| `\NNN` | Octal byte (up to 3 digits, value <= 255) |
## Primitive types
The standard library (`std`) provides these built-in types:
| Type | Aliases | Description |
|------|---------|-------------|
| `int64` | `int`, `i64` | 64-bit signed integer |
| `int32` | `i32` | 32-bit signed integer |
| `int16` | `i16` | 16-bit signed integer |
| `int8` | `i8` | 8-bit signed integer |
| `uint64` | `u64` | 64-bit unsigned integer |
| `uint32` | `u32` | 32-bit unsigned integer |
| `uint16` | `u16` | 16-bit unsigned integer |
| `uint8` | `u8` | 8-bit unsigned integer |
| `float64` | `float`, `f64` | 64-bit floating point |
| `float32` | `f32` | 32-bit floating point |
| `bool` | | Boolean |
| `string` | | String |
Type union aliases are also provided for use in [generic constraints](generics.md#type-union-constraints):
- `SignedInteger` = `int64 | int32 | int16 | int8`
- `UnsignedInteger` = `uint64 | uint32 | uint16 | uint8`
- `Integer` = `SignedInteger | UnsignedInteger`
- `Number` = `Integer | float64 | float32`
Integer literals can use type suffixes: `42'i32`, `255'u8`, `1'i64`.
Number literals support several bases:
```
42 # Decimal
0xFF # Hexadecimal
0b1010 # Binary
0o755 # Octal
3.14 # Float
1.0e-10 # Float with exponent
```
The standard library also exposes several built-in type constructors and handle forms:
| Form | Description |
|------|-------------|
| `array[N, T]` | Fixed-size value array with length `N` and element type `T` |
| `ref T` | Managed owning reference to heap-allocated `T` |
| `lent T` | Borrowed read-only view of `T` |
| `mut lent T` | Borrowed mutable view of `T` |
| `pointer` | Opaque raw pointer type with no pointee information |
| `ptr T` | Typed raw pointer to plain storage `T` |
| `UncheckedArray[T]` | Unsized contiguous storage type used behind raw pointers |
Use `pointer` when the pointee type is intentionally erased. Use `ptr T` when you want a
typed pointer to one plain-storage value. Converting between `pointer` and `ptr T`
always requires an explicit `cast[...]`. Built-in indexing is reserved for
`ptr UncheckedArray[T]`; plain `ptr T` values are not indexable. Raw pointers cannot
point to managed `ref` values or `lent` borrows.
For details on managed references and borrows, see [Memory Model](memory-model.md).
For raw pointers and unmanaged memory, see [Unsafe](unsafe.md#raw-pointers).
### No implicit conversions
Peon never performs automatic type conversions. Assigning an `int32` to an
`int64` variable, or passing a `float32` where `float64` is expected, is a
type error. All conversions must be explicit:
```peon
var x: int32 = 42;
# var y: int64 = x; # Error: type mismatch
var y: int64 = int64(x); # OK: explicit converter call
```
The one exception is **compile-time literal inference**: an untyped numeric
or string literal can be inferred to the expected type at the call site when
the constant value fits:
```peon
let x: int8 = 42; # OK: literal 42 fits in int8
# let y: int8 = 200; # Error: literal 200 does not fit in int8
```
This is not a conversion -- the literal is typed directly as the target type.
It only applies to compile-time constants, never to variables or expressions.
Custom converter functions can be defined with the `converter` pragma
(see [Operators -- Defining custom converters](operators.md#defining-custom-converters)).
## Built-in functions
The standard library provides these built-in functions:
| Function | Description |
|----------|-------------|
| `print(x)` | Print a value to stdout followed by a newline |
| `panic()` | Abort the program with a runtime panic |
| `panic(msg)` | Abort with a message |
| `borrow(x)` or `&x` | Create a non-owning borrow (`lent`) of `x` |
| `move(src, dst)` | Explicitly move `src` into `dst` |
| `clone(x)` | Create an independent copy of `x` |
| `cast[T](x)` | Reinterpret `x` as `T` for low-level raw-pointer or same-size scalar bitcasts. Requires `unsafe`. |
| `new(T)` | Heap-allocate a new value of type `T` |
| `low(T)` | Return the minimum value of a numeric type (`-inf` for floats) |
| `high(T)` | Return the maximum value of a numeric type (`inf` for floats) |
| `typeof(x)` | Return the compile-time type witness for the value `x` |
| `sizeof(T)` | Return the compile-time size in bytes of a sized type |
| `sizeof(x)` | Return the compile-time size in bytes of the type of `x` |
| `needsDestroy(T)` | Return whether values of type `T` carry owned cleanup work |
| `low(x)` | Return the first valid index of a string, array, or `seq` |
| `high(x)` | Return the last valid index of a string, array, or `seq` |
| `len(x)` | Return the length of a collection |
| `alloc(T, n)` | Allocate raw storage for `n` values of type `T` (uninitialized). Requires `unsafe`. |
| `create(T, n)` | Allocate zero-initialized raw storage for `n` values of type `T`. Requires `unsafe`. |
| `realloc(ptr, n)` | Resize a raw allocation to `n` elements. Requires `unsafe`. |
| `dealloc(ptr)` | Free a raw pointer allocation. Requires `unsafe`. |
| `copyMem(dst, src, n)` | Copy `n` bytes from `src` to `dst` (well-defined even for overlapping regions). Requires `unsafe`. |
| `addr(x)` | Take the raw address of a raw-pointer-backed value, returning `ptr T`. |
| `append(s, x)` | Append element `x` to `mut lent seq[T]` |
| `start(g)` | Start a generator, returning `GeneratorState[Y, R]` |
| `resume(g, v)` | Resume a generator with send value `v`, returning `GeneratorState[Y, R]` |
| `next(it)` | Advance an iterator or generator, returning `Option[Y]` |
For collections, valid indices are `low(x) .. high(x)` when the collection is not
empty. For strings, arrays, and `seq`s, `low(x)` is always `0` and `high(x)` is the
last valid index (`-1` for empty values).
`typeof(x)` returns a compile-time type witness, which can be passed to helpers like
`sizeof`. Both `sizeof(T)` and `sizeof(x)` are compile-time constants. They are useful
for raw allocation helpers and FFI-style layout checks:
```peon
type Pair = object {
left: int64;
right: int64;
}
print(sizeof(int64)); # 8
print(sizeof(array[3, int64])); # 24
print(sizeof(Pair)); # 16
let pair = Pair(left = 1, right = 2);
print(sizeof(pair)); # 16
```
String indexing uses the same bounds as `low(text)` and `high(text)`, but because
strings are immutable it returns an owned `char` value rather than a borrow:
```peon
let text = "abc";
let middle: char = text[1];
print(middle); # b
print(low(text)); # 0
print(high(text)); # 2
```

224
docs/manual/c-interop.md Normal file
View File

@@ -0,0 +1,224 @@
# C Interop
[Back to Manual](index.md)
Peon provides [pragmas](compile-time.md#pragmas) and types for calling C functions from
Peon and exposing Peon functions to C.
## C-compatible types
The `cinterop` module provides types that map directly to their
C equivalents:
| Peon type | C type |
|-----------|--------|
| `cchar` | `char` |
| `cschar` | `signed char` |
| `cuchar` | `unsigned char` |
| `cshort` | `short` |
| `cushort` | `unsigned short` |
| `cint` | `int` |
| `cuint` | `unsigned int` |
| `clong` | `long` |
| `culong` | `unsigned long` |
| `clonglong` | `long long` |
| `culonglong` | `unsigned long long` |
| `cfloat` | `float` |
| `cdouble` | `double` |
| `cbool` | `_Bool` |
| `csize` | `size_t` |
| `cptrdiff` | `ptrdiff_t` |
| `cstring` | `char *` |
| `cvoid` | `void` (pointer position only) |
These types support arithmetic, bitwise, comparison, and logical operators as appropriate
for their C counterparts.
Type union aliases are provided for [generic constraints](generics.md#type-union-constraints):
- `CInteropInteger` -- all C integer types
- `CInteropNumeric` -- all C integer and float types
- `CInteropSignedInteger` / `CInteropUnsignedInteger` -- signed/unsigned subsets
## Type converters
The `cinterop` module provides converter functions for converting between Peon and C
types. Peon has [no implicit conversions](basics.md#no-implicit-conversions) -- all
conversions must be written explicitly. However, literals are inferred to the expected
type, so a numeric or string literal passed where a C type is expected needs no
conversion:
```peon
import cinterop;
fn puts(msg: cstring): cint; #pragma[importc, header: "<stdio.h>"]
unsafe { puts("hello"); } # Literal inferred as cstring -- no conversion needed
var s = "hello"; # s is a Peon string
# puts(s); # Error: string is not cstring
unsafe { puts(cstring(s)); } # Explicit conversion required for non-literals
let n: cint = 42; # Literal inferred as cint
var x: int64 = 42;
let m = cint(x); # Explicit conversion from int64 to cint
```
For defining your own converter functions, see
[Operators -- Defining custom converters](operators.md#defining-custom-converters).
## Importing C functions (`importc`)
`importc` marks a top-level forward declaration as an external C function:
```peon
fn strlen(s: cstring): csize; #pragma[importc, header: "<string.h>"]
let len = unsafe { strlen("hello") };
```
- The pragma takes an optional string argument for the C symbol name. When omitted, the
Peon identifier spelling is used: `#pragma[importc: "my_strlen"]`.
- `header` specifies the C header to `#include`. The string is emitted verbatim, so both
`"<stdio.h>"` and `"mylib.h"` work.
- `noDecl` suppresses the generated C prototype when the header already provides it:
`#pragma[importc, header: "<math.h>", noDecl]`.
- Imported C functions are treated as [`unsafe fn`](unsafe.md#unsafe-functions): they must
be called from `unsafe { ... }`.
`importc` is valid on:
- top-level forward function declarations (no body)
- top-level `const` declarations without initializers
- top-level non-`ref`, non-interface type declarations
It cannot be combined with `exportc`.
## Importing C constants (`importc` on `const`)
Imported C constants use top-level `const` declarations with no initializer and an
explicit `cinterop` type:
```peon
import cinterop;
const CLOCKS_PER_SEC: clong; #pragma[importc, header: "<time.h>"]
let value = CLOCKS_PER_SEC;
```
Rules:
- the declaration must be a top-level `const`
- it must not provide an initializer
- it must use an explicit `cinterop` type such as `cint`, `clong`, `csize`, `cdouble`,
and so on
- `header` and `noDecl` follow the same meaning as for imported functions
- despite the `const` spelling, imported C constants are runtime-only bindings, not Peon
compile-time constants in the general [CTFE](compile-time.md#ctfe-limitations) sense
This means they are available in ordinary runtime expressions and native C lowering, but
they are rejected by compile-time evaluation machinery such as `const` initializers,
`when` conditions, and static generic arguments.
One exception exists for [value-enum](types.md#value-enums) discriminants: imported
integral constants may be used there so stdlib and user code can map Peon enum variants
onto C numeric constants.
## Importing C structs (`importc` on types)
Plain object types can be imported from C headers:
```peon
type timespec = object {
#pragma[importc: "struct timespec", header: "<time.h>"]
tv_sec: clong;
tv_nsec: clong;
}
```
For plain object and enum declarations, the `importc` pragma lives inside the
declaration body as the first C interop slice. The imported type maps to the named C
struct. Fields must match the C layout. Tuple type declarations are not supported by
`importc`.
Imported and exported plain object fields may include fixed arrays
(`array[N, T]`) when `T` itself is C-ABI-safe. This allows direct mirrors of
C fields such as `char name[32]` or `unsigned char addr[128]`.
## Importing C typedef aliases
Type aliases can import named C typedefs, including scalar and pointer aliases:
```peon
type clock_t = clong; #pragma[importc, header: "<time.h>"]
fn clock(): clock_t; #pragma[importc, header: "<time.h>", noDecl]
```
This keeps the typedef name available to the C backend instead of lowering the alias to
its raw Peon spelling everywhere.
## Importing C enums
Value enums can map to C enums:
```peon
type ClockId = enum {
#pragma[importc: "ClockId", header: "<time.h>"]
CLOCK_REALTIME,
CLOCK_MONOTONIC
}
fn clock_gettime(id: ClockId, tp: ptr timespec): cint; #pragma[importc, header: "<time.h>"]
let id: ClockId = CLOCK_MONOTONIC;
let other: ClockId = ClockId.CLOCK_REALTIME;
```
As with imported object declarations, the `importc` pragma belongs inside the enum body.
Imported enums must be value enums, which in practice means:
- no payload fields
- optional explicit integral discriminants
- variant values lower to the matching C enum constants
Their Peon variant names should therefore match the C identifiers.
## Exporting Peon functions (`exportc`)
`exportc` exposes a top-level Peon function with a plain C ABI:
```peon
fn add(a: cint, b: cint): cint {
#pragma[exportc]
return a + b;
}
fn peonAdd(a: cint, b: cint): cint {
#pragma[exportc: "peon_add"] # Custom C symbol name
return a + b;
}
```
The compiler generates a C wrapper that marshals arguments. `exportc` cannot have
mutable reference parameters and cannot be combined with `importc`.
## Exporting Peon structs (`exportc` on types)
Plain object types can be exported so they are visible as C structs:
```peon
type Point = object {
#pragma[exportc]
x: cint;
y: cint;
}
```
`exportc` on types is only valid for top-level, non-generic, plain `object` declarations
(not `ref object`, not enums, not interfaces, not tuples, and not type aliases).
As with `importc`, exported plain object fields may use fixed arrays of C-ABI-safe
element types.

237
docs/manual/compile-time.md Normal file
View File

@@ -0,0 +1,237 @@
# Compile-Time Features
[Back to Manual](index.md)
## Compile-time conditionals (`when`)
`when` is a compile-time `if`. The condition must be evaluable at compile time, and only
the selected branch is compiled. Names declared in the chosen branch are visible in the
enclosing scope (no new scope is introduced):
```
when 1 < 2 {
const chosen = 41;
} else {
let chosen: bool = 1; # Would be a type error, but never compiled
}
print(chosen + 1); # 42
```
Inside a function with [static parameters](generics.md#static-compile-time-parameters),
`when` conditions may depend on those parameters. The static arguments are treated like
const-generic values, so each distinct compile-time value produces its own specialization:
```
fn select(flag: const bool): int64 {
when flag {
return 1;
} else {
return 2;
}
}
print(select(true)); # specializes `select` for `true`
print(select(false)); # specializes `select` for `false`
```
The condition is not limited to a bare identifier. Any expression is allowed as long as
the compiler's current compile-time evaluator can evaluate it under the static bindings.
That includes nested control-flow expressions and compile-time-safe helper calls:
```
fn isOdd(n: int64): bool;
fn isEven(n: int64): bool {
if n == 0 {
return true;
}
return isOdd(n - 1);
}
fn isOdd(n: int64): bool {
if n == 0 {
return false;
}
return isEven(n - 1);
}
fn classify(start: const int64): int64 {
when isEven(start) {
return 1;
} else {
return 2;
}
}
```
If a `when` condition is not compile-time evaluable, it is still rejected. Static
parameters do not make runtime-dependent expressions legal.
`when` can also be used as an expression -- see
[Control Flow -- Control flow expressions](control-flow.md#control-flow-expressions).
## CTFE limitations
Compile-time evaluation runs on the bytecode VM, but only over the CTFE-supported
subset of the language. In particular, compile-time evaluation cannot allocate runtime
heap-backed structures such as `seq` storage and cannot mutate module or global state.
Imported C constants declared with `#pragma[importc]` are also runtime-only bindings:
they may be read in ordinary runtime code, but they are rejected in `const`
initializers, `when` conditions, static [generic](generics.md) arguments, and other
compile-time evaluation contexts.
## Config symbols
The built-in `defined("name")` can be used in `when` conditions to query compiler
configuration symbols and user defines:
```
when defined("checks") {
print("safety checks enabled");
}
```
The compiler currently guarantees these canonical config symbols:
- `checks`
- `boundChecks`
- `overflowChecks`
- `floatChecks`
- `debug`
- `release`
- `danger`
- `lineTrace`
- `stackTrace`
Default behavior:
- `debug` is on by default.
- `release` and `danger` are off by default.
- `lineTrace` and `stackTrace` default to on in `debug` and off otherwise.
- `checks` defaults to on, except in `danger`, where it defaults to off.
- `boundChecks`, `overflowChecks`, and `floatChecks` default to the value of `checks`
unless explicitly overridden.
## Pragmas
Peon supports both declaration pragmas and standalone config pragmas.
### Declaration pragmas
Declaration pragmas attach metadata or compiler directives to [functions](functions.md),
[types](types.md), and [variables](basics.md#variable-declarations):
- `#pragma[magic: "..."]` marks a compiler intrinsic type or builtin function
implementation.
- `#pragma[pure]` marks a function or lambda as pure. Pure callables cannot call impure
functions, cannot take mutable reference parameters, and cannot mutate non-local
state. `print(...)` is the one allowed side effect so pure code can still emit debug
output.
- `#pragma[inline]` asks the C backend to emit `inline`. It is only valid on named
functions, not on lambdas.
- `#pragma[constructor]` marks a top-level named function as a constructor candidate for
`T(...)` syntax. See [Operators -- Defining custom constructors](operators.md#defining-custom-constructors).
- `#pragma[noreturn]` marks a named function as not returning.
- `#pragma[noinit]` skips managed-construction initialization paths on a type declaration
or variable declaration.
- `#pragma[define]` binds a constant declaration to the compile-time define table, so a
CLI or user define can override its initializer.
- `#pragma[booldefine]` does the same for boolean constants.
- `#pragma[warn: "message"]` and `#pragma[warning: "message"]` emit a `UserWarning`
when the annotated declaration is used.
- `#pragma[error: "message"]` raises a compile-time error when the annotated declaration
is used.
- `#pragma[deprecated]` is shorthand for a use-site warning saying the function or
object is deprecated.
- `#pragma[lentFrom: param]` annotates a named `lent`-returning function with the
parameter that owns the returned borrow. See
[Concurrency -- Borrow safety in generators](concurrency.md#borrow-safety-in-generators).
- `#pragma[used]` marks a variable, function, or type as intentionally used. It suppresses
unused-declaration warnings for that declaration.
Delayed use-site pragmas fire when the declaration is referenced, called, or constructed,
not when it is declared. For example:
```peon
fn oldApi() {
return;
} #pragma[deprecated]
fn caller() {
oldApi(); # Emits a warning here
}
```
### Scoped config pragmas
Standalone pragmas override canonical compiler config in a scoped way:
```peon
#pragma[push]
#pragma[checks: off]
fn uncheckedRead(values: array[3, int64], i: int64): int64 {
return values[i];
}
#pragma[pop]
```
`#pragma[push]` snapshots the current canonical configuration and `#pragma[pop]` restores
it. These config scopes are intentionally not nestable: one `push` must be matched by one
`pop` before another `push` is allowed.
Inside a pushed scope, canonical boolean options can be rebound with standalone pragmas
such as:
- `#pragma[checks: off]`
- `#pragma[boundChecks: on]`
- `#pragma[overflowChecks: off]`
- `#pragma[floatChecks: off]`
- `#pragma[lineTrace: off]`
- `#pragma[stackTrace: on]`
Canonical build mode can be switched for the same scoped context with:
- `#pragma[debug]`
- `#pragma[release]`
- `#pragma[danger]`
Warnings can also be disabled for the current config scope with:
- `#pragma[noWarn: UserWarning]`
- `#pragma[noWarn: [UserWarning, RawPointerLeak]]`
The currently supported warning kinds are:
- `UserWarning`
- `RawPointerLeak`
- `UnusedLocalVariable`
- `UnusedGlobalVariable`
- `UnusedTypeDeclaration`
- `UnusedImport`
- `UnusedFunction`
### Function-body pragmas
Inside a function body, a standalone config pragma applies to the rest of that function
body automatically, so `push`/`pop` is not needed for local tweaks:
```peon
fn wrapping_add[T: Integer](a, b: T): T {
#pragma[overflowChecks: off]
return a + b;
}
```
The same rule applies to `noWarn`:
```peon
fn caller() {
#pragma[noWarn: UserWarning]
oldApi();
}
```
This function-body form is preferred when the change is meant to affect just one function,
because the active config stays visible at the point where it matters.

250
docs/manual/concurrency.md Normal file
View File

@@ -0,0 +1,250 @@
# Structured Concurrency
[Back to Manual](index.md)
Peon implements **structured concurrency** end to end on the native C backend:
```peon
async fn parallelSum(a: string, b: string): int64 {
nursery tasks {
let left = spawn fetchCount(a);
let right = spawn fetchCount(b);
return await left + await right;
}
}
```
Current status:
- synchronous code enters async execution with `runAsync(...)`, which returns `AsyncCompletion[T]`
- `async fn` bodies may use `await`, and async roots run through the native C backend
- `spawn` starts child work inside a `nursery`, and `await` accepts either direct async calls or `SpawnHandle[Return]`
- async calls must be explicitly `await`ed or `spawn`ed
- the nursery is currently the runtime unit of concurrent work: child tasks are created inside a nursery and drained when that nursery exits
- nursery exit joins outstanding child tasks before the scope completes
- nursery capabilities (`nursery`) and child task handles (`SpawnHandle[Return]`) are restricted compiler-known capability types
- cancellation and timing helpers are available through `cancel`, `cancelAfter`, `cancelAt`, `clearDeadline`, `extendDeadline`, `deadline`, `checkpoint`, `sleep`, `sleepUntil`, `waitReadable`, and `waitWritable`
- `shield { ... }` defers outer cancellation long enough to finish a small cleanup section, but direct cancellation of the current nursery still pierces the shield
- compile-time evaluation rejects async with an error by design
- the stdlib already uses this runtime for operations such as `net.getAddrInfo`
Peon also accepts `coroutine fn` as a compatibility spelling for `async fn`, but
`async fn` is the preferred syntax in documentation.
For async function declaration syntax, see [Functions -- Async functions](functions.md#async-functions).
For design background and older planning notes around the coroutine/task model, see
[structured-concurrency-plan.md](../structured-concurrency-plan.md).
For design notes covering typed generators, coroutine lowering, and non-escaping frame placement,
see [generators-and-coroutines-plan.md](../generators-and-coroutines-plan.md).
## The problem with unstructured concurrency
Most languages today offer some form of "fire and forget" concurrency -- `go` in Go,
`spawn` in Erlang, `threading.Thread` in Python, `Task.Run` in C#, or even raw
`pthread_create`. These primitives all share the same fundamental flaw: they allow a
function to launch background work that outlives the function itself. When a function
returns, there is no guarantee that all the concurrent work it started has finished.
This breaks the basic abstraction boundary of a function call. If `foo()` spawns a
background task, callers of `foo` cannot reason about when `foo` is truly done, what
resources it still holds, or whether errors in its background work will ever be observed.
The spawned task floats free in the program's control flow graph, invisible to the caller,
unaccounted for by the type system, and disconnected from the surrounding control flow.
The blog post [Notes on structured concurrency, or: Go statement considered harmful](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/)
by Nathaniel J. Smith draws an analogy to the historical transition from `goto` to
structured programming. Unrestricted `goto` allowed control to jump anywhere, making it
impossible to reason locally about what a piece of code does. Languages eliminated `goto`
in favor of structured control flow (`if`, `while`, functions) that guarantees control
always enters a block at the top and leaves at the bottom. The same argument applies to
concurrency: an unrestricted spawn is a concurrent `goto`. It allows control flow to fork
without any guarantee that the branches will rejoin.
## What structured concurrency provides
Structured concurrency imposes a simple discipline: **concurrent tasks form a tree that
mirrors the lexical structure of the code**.
The key rules are:
1. **Child tasks are scoped to a block.** Concurrent work is started inside a dedicated
scope (often called a *nursery* or *task group*). The scope cannot exit until all tasks
started within it have completed.
2. **Tasks form a tree.** Every task has a parent. The root task is the program's entry
point. Child tasks can start their own children, forming a tree. No task can outlive
its parent.
3. **Failures propagate upward.** If a child task fails, that failure is delivered to the
parent scope instead of being silently dropped.
4. **Cancellation propagates downward.** If a parent scope is cancelled (e.g. because one
child failed and the parent is unwinding), all remaining children in that scope are
cancelled before the parent finishes cleaning up.
The result is that a function call remains a black box: when `foo()` returns, all
concurrent work it started has finished, all errors have been reported, and all resources
have been released. Callers do not need to know or care whether `foo` used concurrency
internally.
## Current rules
The currently implemented compiler and runtime enforce these rules:
- `await` is only valid inside `async fn`
- `spawn` is only valid inside `async fn`
- `shield` is only valid inside `async fn`
- bare `spawn` must appear inside an active `nursery`
- `spawn call(...) in tasks` may target an explicitly passed nursery capability
- `await` accepts either a direct async call or a `SpawnHandle[Return]`
- `runAsync(...)` is the sync-to-async entry point for a root task and is rejected inside `async fn`
- async calls in expression position are rejected unless they are immediately awaited or spawned
- nursery capabilities and `SpawnHandle[...]` values cannot be returned from a function
Nursery capabilities are intentionally restricted. They may be bound by `nursery name { ... }`,
passed to helper functions as parameters of type `nursery`, and used as explicit `spawn ...
in name` targets, but they are not ordinary first-class runtime objects.
Similarly, a `SpawnHandle[Return]` is only meant to be awaited inside the nursery subtree
that created it. Today it is essentially a convenience wrapper for "something you may
`await` later" rather than a user-facing general-purpose `Task[T]` object with an API of
its own.
Peon also does not expose first-class coroutine objects for ordinary async calls. Calling
an async function does not manufacture a heap object that can be stored, passed around,
and maybe forgotten. The language only accepts async calls in compiler-known async
positions such as `await f(...)`, `spawn f(...)`, or `runAsync(f(...))`.
This restriction is deliberate:
- in most cases, the useful information is just the function plus its arguments, not an
inert coroutine object
- allowing first-class coroutine objects would reintroduce the old "did I forget to
await this?" failure mode when an unstarted coroutine is dropped
- `await` exists to make suspension explicit in the control flow, not to require async
calls to behave like ordinary value-producing expressions everywhere
## Why this matters for Peon
Peon's existing design already values lexical scoping and deterministic resource
management. The [generational reference model](memory-model.md) gives each allocation a
precise owner and a deterministic destruction point. Structured concurrency extends the
same philosophy to concurrent work:
- **Resource safety.** Because child tasks cannot outlive their parent scope, scope-based
cleanup remains correct even in the presence of concurrency. A resource opened before a
concurrent scope is guaranteed to still be valid for all tasks within it, and cleanup
after the scope is guaranteed to happen after all tasks finish.
- **Failure reporting.** Unstructured concurrency silently drops failures from background
tasks. Structured concurrency ensures every failure has a place to go: up the task tree
to the enclosing scope.
- **Cancellation.** Cancelling a scope means cancelling a well-defined subtree of work.
There are no orphaned tasks left running after the thing that cared about their result
has moved on.
- **Local reasoning.** A programmer reading a Peon function can understand its concurrent
behavior from its lexical structure alone, without tracing transitive spawns through
the entire program.
- **Composability.** A function that internally uses a concurrent scope looks identical to
a sequential function from the outside. This means concurrent building blocks compose
the same way sequential ones do.
## What Peon will not do
Peon will not provide unstructured primitives like a bare `spawn` or `go` that starts a
task with an unbounded lifetime. All concurrent work will be lexically scoped and
tree-structured. This is a deliberate restriction: the loss of expressiveness is minimal
(any pattern expressible with unstructured concurrency can be restructured into a
tree-shaped scope), while the gains in safety and composability are substantial.
## Generators
Generators are suspendable functions that produce a sequence of values lazily.
A generator is declared with the `generator` keyword before `fn` and must return
`Generator[Yield, Send, Return]`:
```peon
generator fn range(start: int64, stop: int64): Generator[int64, void, void] {
var i = start;
while i < stop {
yield i;
i = i + 1;
}
}
```
`Yield` is the type of values produced by `yield`. `Send` is the type that
callers can send back into the generator on each resumption (use `void` when
no value is sent). `Return` is the final return type when the generator
completes (use `void` for generators that simply exhaust).
The generator itself is not executed when called. Calling a generator function
returns a `Generator` value. Advancing it is done via the `start` and `resume`
builtins, which return `GeneratorState[Yield, Return]`:
```peon
type GeneratorState[Yield, Return] = enum {
Yielded { value: Yield; },
Complete { value: Return; }
}
```
For the common case, the `next` builtin wraps start/resume into a single call
that returns `Option[Yield]`:
```peon
var g = range(0, 3);
match next(g) {
case Some(v) { print(v); } # 0
case None { }
}
```
Generators are the basis for [`for` loop](control-flow.md#for-loops) iteration.
### Yield from (delegation)
A generator can delegate to another generator with `yield from`:
```peon
generator fn chain(a: Generator[int64, void, void],
b: Generator[int64, void, void]): Generator[int64, void, void] {
yield from a;
yield from b;
}
```
`yield from` yields all values from the delegated generator, forwarding send
values through, and evaluates to the delegated generator's return value when
it completes.
### Borrow safety in generators
Generators that yield borrowed values (`lent T` or `mut lent T`) must yield
from stable storage owners -- values behind a `Buffer[T]` or `seq[T]`, not
from raw locals or arrays whose backing memory can move. This ensures that
yielded borrows remain valid across suspension points.
The compiler can usually infer borrowed return provenance through common
wrapper chains, including owner-cell projections such as `value.storage[].data[index]`.
When inference still cannot prove which parameter owns the returned borrow,
Peon provides `#pragma[lentFrom: param]` as an explicit fallback:
```peon
fn first[T](value: lent Wrapper[T]): lent T {
#pragma[lentFrom: value]
return helper(value);
}
```
This is similar in spirit to a small lifetime annotation: it tells the
[borrow checker](memory-model.md#escape-analysis) that the returned `lent T` is
tied to `value`. It does not change runtime behavior or code generation; it only
refines compile-time escape analysis. In normal code you should prefer inference
and only use `lentFrom` when the compiler reports unknown borrowed provenance for
a wrapper API.

270
docs/manual/control-flow.md Normal file
View File

@@ -0,0 +1,270 @@
# Control Flow
[Back to Manual](index.md)
## `if`/`else`
```
if x > 0 {
print("positive");
} else {
print("non-positive");
}
```
## `while` loops
```
var i = 0;
while i < 10 {
print(i);
i = i + 1;
}
```
## Named blocks
Named blocks provide labeled control flow for breaking out of nested constructs:
```
block outer {
block inner {
print("Yup");
break outer; # Jumps past the outer block
}
print("Never printed.");
}
print("And we land here");
```
`continue` on a named block jumps back to its beginning, which can be used to create
loops:
```
var i = 5;
block loop {
if i > 0 {
i = i - 1;
continue loop;
}
}
print(i == 0); # true
```
In loops, `break` and `continue` without a label apply to the innermost `while` loop.
Inside named blocks, a label is required -- bare `break` inside a named block is an error.
## Pattern matching
`match` expressions destructure values, especially useful with
[enums](types.md#enumeration-types):
```
var value = some(42);
match value {
case Some(v) {
print(v); # 42
}
case None {
print("nope");
}
}
```
Match can destructure named fields:
```
type Result = enum {
Ok { value: int64; },
Err { code: int64; }
}
fn unwrap(result: Result): int64 {
match result {
case Ok(value = value) {
return value;
}
case Err(code = code) {
return code;
}
}
}
```
Value enums match by variant name without payload bindings:
```peon
type SocketFamily = enum {
AnyFamily = 0,
Unix = 1,
Inet = 2
}
match family {
case Unix { print("unix"); }
case Inet { print("inet"); }
else { print("other"); }
}
```
Match also works on plain values with an `else` fallback:
```
match x {
case 1 { print("one"); }
else { print("other"); }
}
```
Multiple patterns can share one arm with `|`:
```
type Result = enum {
Ok { value: int64; },
Err { code: int64; }
}
fn unwrap(result: Result): int64 {
match result {
case Ok(value) | Err(value) {
return value;
}
}
}
```
As in Rust, every alternative in the same arm must bind the same names with the same
types. This is valid:
```
case Ok(value) | Err(value) { ... }
```
This is rejected because the bindings differ:
```
case Ok(value) | Err(code) { ... }
```
Bare `|` in a `case` label is parsed as a pattern separator, not as a bitwise-or
expression. If you want to match against the value of an ordinary `|` expression, wrap it
in parentheses:
```
match x {
case 1 | 2 { print("small"); } # Two alternative patterns
case (4 | 8) { print("twelve"); } # One expression pattern
else { print("other"); }
}
```
Non-void functions must have exhaustive matches: the compiler rejects enum matches that
don't cover all variants, and plain-value matches without an `else` branch are flagged as
not covering all control-flow paths.
### Borrow matching
Use `match &x` when you want to destructure through a borrow without moving payload fields
out of `x`. In that form, payload bindings are borrowed views rather than owned values:
```
type Result = enum {
Ok { value: int64; },
Err { code: int64; }
}
fn read(result: Result): int64 {
match &result {
case Ok(value) {
return value[];
}
case Err(code) {
return code[];
}
}
}
```
## `for` loops
`for`/`in` loops iterate over generators (see [Concurrency -- Generators](concurrency.md#generators)),
built-in collections such as arrays and sequences, direct iterator values, or
types implementing the [`Iterable` interface](interfaces.md#iterator-and-iterable-protocols):
```peon
for x in range(0, 5) {
print(x);
}
```
Arrays and sequences provide built-in `items` generators:
```peon
let arr = [10, 20, 30];
for x in arr {
print(x[]); # 10, 20, 30 (x is lent int64, deref to get value)
}
var s = @[1, 2, 3];
for x in s {
print(x[]); # 1, 2, 3
}
```
Mutable iteration uses `mitems`:
```peon
var s = @[1, 2, 3];
for x in mitems(s) {
x[] = x[] + 10;
}
# s is now [11, 12, 13]
```
For collections, the `for` loop desugars into:
```
var __iter = items(collection);
while true {
match next(__iter) {
case Some(x) { <body> }
case None { break; }
}
}
```
If the loop expression is already an `Iterator` (including `BorrowIterator`,
`MutBorrowIterator`, or `Generator`), the loop consumes it directly instead of
calling `iter()`. All iterators advance through `next`.
Types can participate in `for` loops by implementing the iterator or iterable protocols.
See [Interfaces -- Iterator and Iterable protocols](interfaces.md#iterator-and-iterable-protocols)
for the full protocol definitions.
## Control flow expressions
`if`, `when`, `match`, and bare blocks can all be used as expressions. The value of the
expression is the last expression in the taken branch:
```
fn test(flag: bool): int64 {
let a = if flag { 1 } else { 2 };
let b = when true { 3 } else { 4 };
let c = { let x = 7; x }; # Block expression
let d = match a {
case 1 { c }
else { b }
};
return d;
}
```
`while` is statement-only. It cannot be used in expression position.
For compile-time `when` conditionals, see [Compile-Time Features](compile-time.md#compile-time-conditionals-when).

282
docs/manual/functions.md Normal file
View File

@@ -0,0 +1,282 @@
# Functions
[Back to Manual](index.md)
## Basic functions
```
fn fib(n: int): int {
if n < 2 {
return n;
}
return fib(n - 1) + fib(n - 2);
}
fib(30);
```
Functions with no return type default to `void`, and `void` is also a real unit
type you can use in annotations and [generics](generics.md):
```peon
fn greet() {
print("hello");
}
let done: Result[void, string] = Ok();
```
Bare `return;` is valid in void functions.
## Async functions
Async functions are declared with `async fn`:
```peon
async fn child(): int64 {
return 1;
}
```
Calling an async function is not a normal fire-and-forget operation. The call must be
used in a compiler-known async position, typically `await child()` or `spawn child()`.
The compatibility spelling `coroutine fn` is accepted, but `async fn` is preferred.
Peon intentionally does not treat an async call as an ordinary first-class coroutine
value. You cannot instantiate a coroutine object, store it for later, and rely on some
later code to remember to start it. If you need to defer work, the useful payload is
usually just the original function plus its arguments. This avoids the familiar bug class
where a coroutine object is created, never awaited, and eventually discarded without
ever running. In Peon, `await` is primarily a marker that suspension may happen at this
point in control flow; it does not imply that async calls must behave like ordinary
expression values in every context.
Native execution enters an async root with `runAsync(...)`, which returns an
`AsyncCompletion[T]`:
```peon
match runAsync(child()) {
case Completed(value) {
print(value);
}
case Cancelled {
print("cancelled");
}
}
```
Function types may also be async:
```peon
async fn run(job: async fn(): int64): int64 {
return await job();
}
```
Async code runs on the native C backend today. Compile-time evaluation still rejects
async code by design.
For the full structured concurrency model (nurseries, spawn, cancellation), see
[Structured Concurrency](concurrency.md).
## Nested functions
Functions can be defined inside other functions:
```
fn outer(): int64 {
fn inner(): int64 {
return 1;
}
return inner();
}
```
## Forward declarations
A function can be forward-declared with a semicolon and no body, then defined later in
the same module:
```
fn someF(): int64; # Forward declaration
print(someF()); # Prints 42
fn someF(): int64 {
return 42;
}
```
## Named arguments and default parameters
Function calls support positional and named (keyword) arguments. All positional arguments
must come before any keyword arguments:
```
fn greet(name: string, loud: bool) {
if loud {
print(name);
}
}
greet("Alice", true); # Positional
greet("Bob", loud = false); # Mixed
greet(loud = true, name = "Eve"); # All keyword (any order)
```
Parameters can have default values. A parameter with a default may be omitted at the call
site:
```
fn connect(host: string, port: int64 = 8080) {
print(host);
print(port);
}
connect("localhost"); # port defaults to 8080
connect("localhost", 3000); # explicit port
```
Positional parameters without defaults cannot follow parameters that have defaults.
Parameters are immutable bindings by default, even though ordinary parameters are still
passed by value. Reassigning a parameter is an error; if you want a mutable local copy,
re-declare it explicitly:
```
fn bump(x: int64): int64 {
var copy = x;
copy += 1;
return copy;
}
```
## Mutable borrow parameters
Peon does not support `var` parameter syntax. If a callee needs writable access to
caller-owned storage, spell the parameter as `mut lent T` and pass an explicit mutable
borrow in an ordinary call:
```
fn bump(x: mut lent int64) {
x = x + 1;
}
var value = 3;
bump(mut borrow(value));
print(value); # 4
```
The borrow keeps referring to the caller's storage, so writes in the callee are visible
after the call returns. See [Memory Model -- Borrows](memory-model.md#borrows-and-lent)
for the full borrowing rules.
Immutable borrows are auto-inserted whenever a stable value is used where `lent T` is
required. Mutable auto-borrows are narrower: regular `f(x)` calls still require
`mut borrow(x)` (or `mut &x`) when they introduce a mutable borrow from plain storage,
but an argument that is already `mut lent T` can be passed through regular calls
without another reborrow. [Assignment operators](operators.md#assignment-operators)
and UFCS method-style calls such as `x += 1` or `values.append(item)` still auto-borrow
mutably because the mutation is already syntactically obvious.
Mutable borrow parameters can also appear in function types:
```
fn applyTwice(f: fn(x: mut lent int64), x: mut lent int64) {
f(x);
f(x);
}
```
## Function calls and UFCS
```
foo(1, 2 + 3, 3.14, bar(baz));
```
Code like `a.b()` is desugared to `b(a)` if there exists a function `b` whose signature
is compatible with the value of `a` and `a` does not already have a field named `b`. This
is known as Uniform Function Call Syntax (UFCS).
Borrow insertion follows the same rule. Stable values are always auto-borrowed when a
call expects `lent T`. For `mut lent T`, the UFCS form `a.b()` auto-borrows mutably, and
regular call syntax accepts arguments that are already `mut lent T`; an explicit mutable
borrow is still required only when the call needs to create that borrow from plain storage.
## Lambdas and closures
Lambdas are anonymous functions:
```
let inc = lambda(x: int64): int64 {
return x + 1;
};
print(inc(4)); # 5
```
Lambdas can be immediately invoked:
```
fn run(): int64 {
return lambda(x: int64): int64 {
return x + 1;
}(4);
}
```
### Higher-order functions
Lambdas are first-class values. Function types are written `fn(params): returnType`.
If the callable itself is unsafe to call, write `unsafe fn(params): returnType`
(see [Unsafe -- Unsafe function types](unsafe.md#unsafe-function-types)):
```
fn makeInc(): fn(x: int64): int64 {
return lambda(x: int64): int64 {
return x + 1;
};
}
fn apply(f: fn(x: int64): int64, value: int64): int64 {
return f(value);
}
let inc = makeInc();
print(apply(inc, 4)); # 5
```
### Closures
Lambdas capture variables from their enclosing scope. Primitives are captured by value:
```
fn makeAdder(base: int64): fn(x: int64): int64 {
return lambda(x: int64): int64 {
return x + base;
};
}
let add2 = makeAdder(2);
print(add2(4)); # 6
```
Borrowed values can be captured in non-escaping closures (immediately invoked):
```
type Box = ref object {
value: int64;
}
fn read(view: lent Box): int64 {
return lambda(): int64 {
return view.value;
}();
}
var b = Box(value = 1);
print(read(b)); # 1
```
Capturing a borrowed value in an escaping closure (one that is returned or stored) is
rejected. Capturing mutable locals is not yet implemented.

140
docs/manual/generics.md Normal file
View File

@@ -0,0 +1,140 @@
# Generics
[Back to Manual](index.md)
Peon generics use [parametric polymorphism](https://en.wikipedia.org/wiki/Parametric_polymorphism).
Generic functions are typechecked at declaration time against their constraints, not at
each call site like C++ templates. The compiler then monomorphizes them -- generating
specialized code for each concrete type used.
```
fn genericSum[T: Number](a, b: T): T {
return a + b;
}
genericSum(1, 2);
genericSum(3.14, 0.1);
genericSum(1'u8, 250'u8);
```
An unconstrained generic cannot use operations that are not defined for all types:
```
fn add[T](a, b: T): T {
return a + b; # Error: cannot prove + is defined on all types
}
```
This is intentional. To make it compile, constrain `T` to types that support `+`:
```
fn add[T: Number](a, b: T): T {
return a + b; # OK: + is defined for all Number types
}
```
## Type union constraints
A constraint like `T: int32 | int` means `T` can be any one of the listed types.
When a generic function with a union constraint calls another function, the compiler
uses deferred dispatch: it collects all matching candidates during type checking, then
re-resolves the call during monomorphization once the concrete type is known. This means
callees can be individual non-generic overloads:
```
fn foo(x: int32): int32 {
return x;
}
fn foo(x: int): int {
return x;
}
fn identity[T: int | int32](x: T): T {
return foo(x); # OK: dispatches to the matching foo overload
}
```
The callee does not need to be generic over the same union -- any set of overloads that
covers all members of the constraint is sufficient.
## Interface constraints
[Interfaces](interfaces.md) can also be used in generic constraints. Use `&` to require
multiple interfaces at once:
```peon
fn debugCopy[T: Copy & Printable](value: T) {
print(clone(value));
}
```
This is an intersection: `T` must satisfy all listed interfaces. The same syntax works
for generic type declarations:
```peon
type Buffer[T: Copy & Printable] = object {
value: T;
}
```
## Generic types
Types can be generic:
```
type Box*[T] = object {
value: T;
}
var boxFloat = Box[float64](value = 1.0);
var boxInt = Box[int64](value = 1);
```
Generic types support const parameters for compile-time values:
```
type array[N: const int64, T] = object {
#pragma[magic: "array"]
}
```
__Note__: The `*` [visibility modifier](modules.md#visibility) must be placed __before__
the generic constraints: `fn foo*[T](a: T) {}` and `type Box*[T] = object { ... }`.
## Static compile-time parameters
Generic functions also support static compile-time parameters directly in their
parameter list:
```
fn repeat(count: const int64, value: string): string {
when count == 0 {
return "";
} else {
return value;
}
}
```
Current behavior:
- Static function parameters are supported for integer and `bool` types.
- Call arguments for static parameters must be compile-time evaluable.
- Static parameters participate in specialization like hidden const-generic parameters.
- Specialized runtime functions do not keep static parameters in their runtime ABI.
For more on compile-time evaluation and `when`, see [Compile-Time Features](compile-time.md).
## `typevar` parameters
A `typevar[T]` parameter accepts a type itself as a value, allowing generic functions to
be called with a type argument rather than a value. Several builtins use this pattern:
```
var cell = new(int64); # typevar[int64] parameter
let buf = create(int64, 4); # typevar[int64] + count
print(low(int32)); # minimum value of int32
print(high(float64)); # inf
print(sizeof(int64)); # compile-time size in bytes
```

75
docs/manual/index.md Normal file
View File

@@ -0,0 +1,75 @@
# Peon - Manual
Peon is a statically typed programming language with a native C backend and a
restricted bytecode VM used for compile-time evaluation. This manual documents the
language features that are currently implemented.
Peon ~~steals~~ borrows many ideas from Python, Nim (the language peon itself is written
in), Rust, C and many others.
## Table of contents
- [Basics](basics.md) -- variables, comments, strings, primitive types
- [Type Declarations](types.md) -- objects, tuples, ref objects, enums, standard library types
- [Functions](functions.md) -- functions, async, lambdas, closures, UFCS
- [Control Flow](control-flow.md) -- if/else, while, for, pattern matching, blocks
- [Generics](generics.md) -- parametric polymorphism, constraints, static parameters
- [Interfaces](interfaces.md) -- iface declarations, built-in interfaces, iterator protocols
- [Operators](operators.md) -- overloading, postfix, assignment, `?`/try, `is`
- [Memory Model](memory-model.md) -- ownership, generational references, borrows, lifecycle hooks
- [Unsafe](unsafe.md) -- unsafe blocks, unsafe fn, raw pointers, unmanaged memory
- [Structured Concurrency](concurrency.md) -- nurseries, spawn/await, generators
- [Compile-Time Features](compile-time.md) -- `when`/`defined()`, CTFE, pragmas
- [Modules](modules.md) -- import/export, visibility, module execution
- [C Interop](c-interop.md) -- importc, exportc, C-compatible types, converters
- [Grammar](../grammar.md)
- [Bytecode](../bytecode.md)
## Current Scope
The current implementation includes:
- Variables with `var`, `let`, and `const`
- Plain objects, structural `tuple`s, `ref` forms, enums, and constructors
- Functions, lambdas, closures, and forward declarations
- Named/keyword arguments and default parameter values
- Generics with constrained parametric polymorphism
- Static function parameters written as `name: const T`
- Interfaces with `iface`, `object with ...`, and generic interface constraints
- Operator overloading and UFCS-style call rewriting
- A memory model based on generational references
- Borrows (`lent`) and mutable borrows (`mut lent`)
- Pattern matching with `match`/`case`
- Compile-time conditionals with `when`
- Canonical compile-time config symbols via `defined(...)`
- Named blocks with labeled `break`/`continue`
- Control flow expressions (`if`, `when`, `match`, and blocks as expressions)
- Static arrays (`array[N, T]`) with bounds checking
- Standard-library sequences (`seq[T]`) with borrowed indexing
- Generators (`generator fn`) with `yield`, `yield from`, and the `next()` iterator protocol
- Async functions and structured concurrency on the native C backend (`async fn`, `await`, `nursery`, `spawn`, `shield`, `runAsync`)
- Native async execution through the C backend, including stdlib networking helpers
- `for`/`in` iteration over generators, arrays, sequences, iterables, and direct iterators
- Explicit `unsafe { ... }` blocks for low-level unchecked operations
- Raw pointers via `pointer`, `ptr T`, and `ptr UncheckedArray[T]`
- Unmanaged memory via `alloc`, `create`, `realloc`, `dealloc`, `copyMem`, and `addr`
- Modules with `import`, `from`/`import`/`as`, and `export`
- Pragmas for compiler directives (`#pragma[...]`)
- C interop via `importc`, `exportc`, `header`, and `noDecl` pragmas
- C-compatible types (`cint`, `cstring`, `cdouble`, etc.) and explicit type converters
- A native C backend
## Future Plans
Planned or desired future work includes:
- A richer import and package system
- Richer interface features such as dynamic interface values or dispatch if they become necessary
- Multithreading support
- More built-in collection types
- `assert` statements (currently parsed but not yet compiled)
- A working LSP server
- A fully self-hosted Peon compiler
- A Peon-written toolchain

119
docs/manual/interfaces.md Normal file
View File

@@ -0,0 +1,119 @@
# Interfaces
[Back to Manual](index.md)
Peon has a static **interface system** broadly similar in spirit to Rust traits.
Interfaces declare required function signatures:
```peon
iface Foo {
fn bar(lent self): int64;
fn baz(self, name: string): bool;
}
```
## Inheritance
Interfaces can inherit other interfaces with `from`:
```peon
iface Tagged from Named, Printable {
fn tag(self): string;
}
```
## Conformance
Object types declare implemented interfaces with `with`:
```peon
type Stuff = object with Foo {
}
fn bar(self: lent Stuff): int64 {
return 1;
}
fn baz(self: Stuff, name: string): bool {
return true;
}
```
An object can list multiple interfaces: `type X = object with A, B { ... }`.
Both value `object` and `ref object` declarations may list interfaces with `with`.
If implementing or inheriting an interface carries extra unchecked obligations, declare
it as `unsafe iface`. See [Unsafe -- Unsafe interfaces](unsafe.md#unsafe-interfaces) for
the full rules and examples.
## Current semantics
- Interfaces are checked statically. There is no runtime vtable or trait-object
representation.
- Requirements are satisfied by free functions in the same module as the implementing
object declaration. If a requirement writes `self`, `lent self`, or `mut self`, that
receiver is matched against the implementing concrete type. If no receiver is written,
the checker still assumes an implicit by-value `self` parameter.
- Interface declarations are signature-only. Requirements cannot define bodies or default
arguments. Receiver shorthand is only valid inside interface requirements. Interface
requirements may themselves be `unsafe fn`.
- Interfaces participate in [generic constraints](generics.md#interface-constraints),
but they are currently a conformance/constraint feature rather than a general-purpose
runtime type.
## Built-in interfaces
The stdlib exposes a few built-in interfaces with compiler-aware semantics:
- `Copy` means a value can be duplicated with `clone(x)`. User-defined value types may
satisfy it structurally, or explicitly via a `clone=`
[lifecycle hook](memory-model.md#lifecycle-hooks). `ref object` types may opt in with
`with Copy`; cloning such a value duplicates the referent into a fresh managed
allocation.
- `ImplicitCopy` extends `Copy`. Values with `ImplicitCopy` may be duplicated
automatically in by-value assignments, argument passing, and returns instead of being
moved. Built-in scalars and `string` implement `ImplicitCopy`; arrays and `seq` do not.
- `Printable` requires `toString(lent self): string` and is what the generic `print(x)`
builtin relies on.
## Iterator and Iterable protocols
The standard library exposes static iterator and iterable protocols with borrow-aware
variants. These protocols are used by [`for` loops](control-flow.md#for-loops) to
iterate over collections and custom types.
```peon
iface Iterator[T] {
fn next(mut self): Option[T];
}
iface BorrowIterator[lent T] from Iterator[T] {
# Yields borrowed elements
}
iface MutBorrowIterator[mut lent T] from Iterator[T] {
# Yields mutable borrowed elements
}
iface Iterable[T] {
fn iter(lent self): Iterator[T];
}
iface BorrowIterable[lent T] {
fn items(lent self): BorrowIterator[lent T];
}
iface MutBorrowIterable[mut lent T] {
fn mitems(mut self): MutBorrowIterator[mut lent T];
}
```
`Iterator` is the base cursor protocol. `BorrowIterator` and
`MutBorrowIterator` are subtypes for iterators that yield borrowed or
mutable-borrowed elements respectively. `Iterable`, `BorrowIterable`, and
`MutBorrowIterable` are collection-style adapters that produce the
corresponding iterator type via `iter()`, `items()`, and `mitems()`.
Types can participate in `for` loops by implementing the direct iterator
protocol (`Iterator[T]`), or by implementing one of the collection adapter
protocols.

262
docs/manual/memory-model.md Normal file
View File

@@ -0,0 +1,262 @@
# Memory Model
[Back to Manual](index.md)
Peon manages heap-allocated values using **generational references** -- a scheme that
provides memory safety without a garbage collector and without the full complexity of
Rust's borrow checker.
## Ownership and references
There are four kinds of handles to data in Peon:
- **Plain values** (`T`) -- inline value storage. Integers, floats, booleans, arrays,
and plain `object` types are plain values. Assignment and argument passing duplicate
[`ImplicitCopy`](interfaces.md#built-in-interfaces) values and otherwise move by default.
- **Managed references** (`ref T`) -- heap-allocated, owning handles. Created with
[`ref object`](types.md#ref-objects) types or with `new(T)`. The runtime tracks their liveness.
- **Borrows** (`lent T`) -- non-owning, read-only views into an existing value.
Created with `borrow(x)` or the `&` operator. Cannot outlive the value they borrow from.
- **Mutable borrows** (`mut lent T`) -- non-owning, writable views.
```
type Box = ref object {
value: int64;
}
var b = Box(value = 42); # Managed reference, heap-allocated
let view = borrow(b); # Borrow: non-owning read-only view
```
## Lifecycle hooks
User-defined value types can customize their behavior with hooks:
- `clone=` for duplication
- `move=` for destructive transfer
- `destroy=` for cleanup
`ref object` types can also define a custom `destroy=` hook. That hook runs during the
automatic destruction path for the managed reference; explicit `destroy(x)` calls are only
for value types and are rejected for managed refs.
If a user-defined value type or `ref object` type does not define the relevant hooks, the
compiler uses the default behavior. The important distinction is:
- `clone(x)` only works for `T: Copy` and always preserves the source.
- `move(src, dst)` is always destructive and leaves `src` moved-from.
- Ordinary by-value assignment, initialization, argument passing, and returns clone only
for `ImplicitCopy` types; everything else is transferred with move semantics.
[`Copy` and `ImplicitCopy`](interfaces.md#built-in-interfaces) are built-in interfaces.
`clone=` always takes one by-value source parameter and returns the hooked type:
```peon
fn `clone=`(src: Box): Box {
return defaultClone();
}
```
That same shape is used for both plain `object` types and `ref object` types, so refs may
override cloning when they need custom duplication logic for payload-managed resources.
`defaultClone()` is only valid inside a `clone=` hook and performs the compiler's
default cloning behavior for the hooked type.
`move=` uses source-first ordering and both parameters must be `mut lent`:
```peon
fn `move=`(src: mut lent Box, dst: mut lent Box) {
defaultMove();
}
```
The source is a mutable borrow because moving is destructive: the hook is allowed to
leave the source in a moved-from state. `defaultMove()` is only valid inside a `move=`
hook and performs the compiler's default move for that type. Only plain user-defined
value types may define `move=`; `ref object` handle moves are fixed language behavior
and cannot be hooked.
`destroy=` takes a single `mut lent` parameter:
```peon
fn `destroy=`(value: mut lent Box) {
defaultDestroy();
}
```
`defaultDestroy()` is only valid inside a `destroy=` hook and delegates to the
compiler's default cleanup logic.
For `ref object` hooks, the `mut lent Box` parameter borrows the owning `Box` handle
slot itself. Hooks still run in place; they do not take ownership as a by-value `Box`
argument.
Explicit `destroy(x)` is only valid for storage locations that may actually need owned
cleanup work. It is rejected for managed refs, bare borrowed handles (`lent T`,
`mut lent T`, and `const T` bindings), and plain no-op types such as integers.
Destroying projected substorage such as a field or indexed slot is still valid inside
owner cleanup code. Generic cleanup code should guard explicit destruction with
`needsDestroy(T)`:
```peon
fn `destroy=`[T](values: mut lent seq[T]) {
var i = 0;
while i < values.len() {
when needsDestroy(T) {
destroy(values[i]);
}
i += 1;
}
defaultDestroy();
}
```
This matters for containers of borrows such as `seq[lent T]`: destroying the container
still frees the container's own storage, but it must not recursively destroy the
borrowed referents.
## How generational references work
Every managed allocation is assigned a random nonzero 64-bit **generation ID** when it
is created. A reference stores three things:
1. A pointer to the payload (the actual data).
2. A pointer to the **owner cell** -- a piece of side metadata that holds the current
generation of the allocation.
3. The **remembered generation** -- the generation that was live when the reference was
formed.
A reference is valid if and only if:
```
owner.current_gen != 0 && owner.current_gen == remembered_gen
```
When an allocation is freed, its owner cell's generation is set to zero. If any stale
reference is later used, the runtime detects the mismatch and panics. If the same memory
slot is reused for a new allocation, it gets a fresh random generation, so stale
references from the old allocation will not accidentally match.
This scheme is similar to Rust's ownership model in that it prevents use-after-free at
runtime, but differs in important ways:
- There is no compile-time borrow checker that forbids aliasing. Multiple references to
the same allocation can coexist freely.
- Safety is enforced at runtime through generation checks, not at compile time through
lifetime analysis.
- The compiler does perform **escape analysis** on `lent` values to prevent returning a
borrow that refers to local storage, but it does not enforce Rust-style exclusivity
rules between mutable and immutable borrows.
## Interior references
References to fields, array elements, and slices all carry the same `(ptr, owner, gen)`
triple. The `ptr` points at the interior location, while the owner cell still belongs to
the containing object. This means a field reference becomes invalid the moment its
containing object is freed, just like a whole-object reference would.
```
type Pair = ref object {
left: int64;
right: int64;
}
var p = Pair(left = 1, right = 2);
# A reference to p.left is valid as long as p is alive.
# If p is freed, any reference to p.left will fail at runtime.
```
## Mutability
Mutability is a **compile-time** property. The runtime only tracks liveness, not whether
a handle is mutable or immutable. The surface types are:
| Handle | Meaning |
|--------|---------|
| `ref T` | Managed owning reference, read-only through this handle |
| `mut ref T` | Managed owning reference, mutable through this handle |
| `lent T` | Non-owning borrow, read-only |
| `mut lent T` | Non-owning borrow, mutable |
The compiler rejects writes through immutable handles at type-check time.
## Deterministic destruction
The compiler inserts destroy/free calls at ownership boundaries:
- End of local scope for owning values.
- Reassignment of owning locals (the old value is destroyed before overwrite).
- `return` statements destroy locals that are not being returned.
There is no garbage collector and no reference counting. Deallocation is deterministic
and happens at precisely the points the compiler identifies.
## Borrows and `lent`
A borrow creates a non-owning view of an existing value. The `borrow` builtin (or the
`&` operator) creates borrows. Borrowed values have type `lent T`:
```
fn read(view: lent Box): int64 {
return view.value;
}
var b = Box(value = 42);
print(read(b)); # 42
```
Stable values are automatically borrowed whenever a `lent T` is required, so both
`read(b)` and `let view: lent Box = b` work without an explicit `borrow(...)`.
### Escape analysis
The compiler performs escape analysis to prevent returning a borrow that refers to local
storage. When inference cannot determine provenance through wrapper functions,
`#pragma[lentFrom: param]` can be used as an explicit hint
(see [Concurrency -- Borrow safety in generators](concurrency.md#borrow-safety-in-generators)).
```
fn bad(): lent int64 {
var x = 55;
return borrow(x); # Error: cannot return borrowed value that refers to local storage
}
```
This analysis tracks provenance through function calls. Even when a borrow is passed
through a helper function, the compiler infers that the returned borrow may refer to
local storage and rejects it:
```
fn passthrough(x: lent int64): lent int64 {
return x;
}
fn bad(): lent int64 {
var x = 55;
return passthrough(x); # Error: still refers to local storage
}
```
Forwarding a borrow from a parameter (not local storage) is allowed:
```
fn passthrough(x: lent int64): lent int64 {
return x; # OK: x comes from the caller
}
```
The same rule applies to borrows derived from mutable borrow parameters:
```
type Box = object {
value: int64;
}
fn view(box: mut lent Box): lent int64 {
return box.value; # OK: the borrow is derived from caller-owned storage
}
```
For mutable borrow parameters in function signatures, see
[Functions -- Mutable borrow parameters](functions.md#mutable-borrow-parameters).

94
docs/manual/modules.md Normal file
View File

@@ -0,0 +1,94 @@
# Modules
[Back to Manual](index.md)
Peon code is organized into modules (one file per module). The standard library is
available by default, and the `import` statement brings other modules' exported names
into scope:
```
import helper; # Import another module
```
Selective imports with aliasing:
```
from helper import used as alias;
print(alias());
```
Qualified access to module members:
```
import helper;
print(helper.used());
```
## Visibility
The `*` modifier makes a name visible outside its module:
```
type Point* = object {
x: int64;
y: int64;
}
fn distance*(a, b: Point): float64 {
# ...
}
```
Without `*`, names are private to the module they are defined in. Unused private
declarations are not compiled into the final output.
The `*` modifier must be placed before [generic](generics.md) constraints:
`fn foo*[T](a: T) {}` and `type Box*[T] = object { ... }`.
## Re-exports
Modules re-export their imports with `export`:
```
import builtins/values;
export values;
```
## Module execution
When a module is imported for the first time, all of its top-level statements
are executed in order. This means that any expressions, variable
initializations, or side-effecting code at the top level of an imported module
will run once, at the point where the import is first encountered. Subsequent
imports of the same module (from other files) do not re-execute its top-level
code -- each module is loaded and executed at most once per compilation.
This is relevant for modules that perform initialization work (e.g., setting up
global state or printing diagnostics) at the top level: that work happens
exactly once, during the first import.
## `isMainModule`
The compiler automatically manages the `isMainModule` compile-time define. It
is `true` only for the module that is the compilation entry point (the file
passed to the compiler on the command line) and `false` for every imported
module. This lets a module include code that only runs when it is compiled
directly, not when it is imported as a library:
```
fn helper*(): int64 {
return 42;
}
when defined("isMainModule") {
# This block only executes when this file is the entry point,
# not when another module imports it.
print(helper());
}
```
`isMainModule` is a compiler-managed define and cannot be overridden by
`-d:isMainModule` or `#pragma[define]`.
For more on `when` and `defined()`, see
[Compile-Time Features](compile-time.md#compile-time-conditionals-when).

283
docs/manual/operators.md Normal file
View File

@@ -0,0 +1,283 @@
# Operators
[Back to Manual](index.md)
## Operator overloading
```
type Vec2 = object {
x: int64;
y: int64;
}
operator `+`(a, b: Vec2): Vec2 {
return Vec2(x = a.x + b.x, y = a.y + b.y);
}
Vec2(x = 1, y = 3) + Vec2(x = 2, y = 3); # Vec2(x = 3, y = 6)
```
Custom operators (e.g. `` `dot` ``) can also be defined. The backticks mark the name as an
identifier rather than a symbol. Even the built-in arithmetic operators are defined as peon
stubs with `#pragma[magic: ...]` and are then specialized by the compiler.
Operators can be called as regular functions:
```
`+`(1, 2) # Identical to 1 + 2
```
## Postfix operators
Operators can be declared as postfix using `#pragma[postfix]`. A postfix operator
takes exactly one parameter and is written after its operand:
```
operator `++`*(x: int32): int32 {
#pragma[postfix]
return x + 1;
}
var y = x++; # postfix: applies ++ after x
```
Postfix operators bind tighter than prefix: `-x++` means `-(x++)`.
An operator is either prefix or postfix -- declaring the same operator as both is
not currently supported.
## Assignment operators
Operators whose name ends with `=` (excluding comparison operators `==`, `!=`, `>=`,
`<=`) are *assignment operators*. They must return `void` -- they mutate their first
argument in place and are statements, not expressions. This matches Rust's `AddAssign`
semantics.
```
operator `+=`*(a: mut lent int64, b: int64) {
a = a + b;
}
var x = 1;
x += 2; # x is now 3
```
The index-assignment operator `[]=` follows the same rule:
```
operator `[]=`*[T](self: mut lent seq[T], index: int64, value: T) {
# mutates self in place, returns nothing
}
```
Attempting to declare an assignment operator with a return type is a compile-time error.
## The `?` operator and `#pragma[try]`
The `?` postfix operator provides Rust-style early-return unwrapping for enum types
annotated with `#pragma[try]`. When applied to a value:
- If the value matches the **success variant**, `?` extracts the specified field.
- If the success variant has no payload, `?` succeeds with a `void` value.
- If the value matches the **error variant**, `?` early-returns from the enclosing
function with an error value of the function's return type.
Any enum type can opt into `?` by adding `#pragma[try]` to its declaration:
```
type MyResult[T, E] = enum {
#pragma[try(ok: "Success", value: "result", err: "Failure", error: "reason")]
Success { result: T; },
Failure { reason: E; }
}
```
### Named arguments
| Argument | Required | Meaning |
|----------|----------|---------|
| `ok` | yes | Name of the success variant (as a string) |
| `value` | no | Name of the field to extract from the success variant; omit it when the specialized success variant has no payload |
| `err` | yes | Name of the error variant (as a string) |
| `error` | no | Name of the field to copy from the error variant on early return |
When `error` is omitted, the error variant is assumed to carry no payload
(like `Option`'s `None`). When it is present, the error variant's payload is
forwarded into the enclosing function's return type on early return.
The enclosing function must return a type that also has `#pragma[try]`. When the error
variant has a payload, the error field types must match between the expression's type
and the function's return type. The enclosing function cannot be `void` -- `?` needs a
return type to propagate the error into.
### Usage with standard library types
Both [`Option[T]` and `Result[T, E]`](types.md#standard-library-types) in the standard
library carry the pragma and support `?` out of the box:
```peon
fn parse(input: string): Option[int64] {
# ...
}
fn process(input: string): Option[int64] {
let n = parse(input)?; # Unwraps Some, or returns None
return some(n + 1);
}
```
```peon
fn readFile(path: string): Result[string, string] {
# ...
}
fn processFile(path: string): Result[int64, string] {
let contents = readFile(path)?; # Unwraps Ok, or returns Err
return Ok(value = len(contents));
}
```
```peon
fn divide(a: int64, b: int64): Result[int64, string] {
if b == 0 {
return Err(error = "division by zero");
}
return Ok(value = a / b);
}
fn compute(): Result[int64, string] {
var x = divide(10, 2)?; # unwraps Ok(5), or returns Err
var y = divide(x, 0)?; # returns Err("division by zero")
return Ok(value = y);
}
```
Generic try-compatible enums can still name a success field even when a concrete
specialization erases it:
```peon
fn ensureDir(path: string): Result[void, string] {
# ...
return Ok();
}
fn build(path: string): Result[int64, string] {
var pending = ensureDir(path);
var ready = pending?;
return Ok(value = 1);
}
```
### The `?` operator declaration
The `?` operator itself is declared as a postfix builtin in the standard
library:
```peon
operator `?`*[T, E](value: Result[T, E]): T {
#pragma[postfix, magic: "try"]
}
operator `?`*[T](value: Option[T]): T {
#pragma[postfix, magic: "try"]
}
```
User-defined types do not need to redeclare the operator -- the standard library
declarations are generic and work with any `#pragma[try]`-annotated enum as
long as the variant and field names match.
## The `is` operator
The `is` operator performs identity comparison. For value types it checks bitwise
identity; for managed references it checks whether two handles refer to the same
allocation:
```
var a = 1;
var b = 1;
print(a is b); # true (same value)
var x = Box(value = 1);
var y = x;
print(x is y); # true (same object)
```
## Defining custom converters
The `converter` [pragma](compile-time.md#declaration-pragmas) marks a single-parameter
function as a type converter. Defining a converter from `T` to `A` enables the syntax
`A(expr)` as a shorthand for calling the converter -- this looks like a constructor call
but dispatches to the converter function:
```peon
fn myConvert(x: MyTypeA): MyTypeB {
#pragma[converter]
# ...
}
let a: MyTypeA = ...;
let b: MyTypeB = myConvert(a); # Direct call
let c: MyTypeB = MyTypeB(a); # Equivalent: constructor-style syntax via converter
```
The [C interop module](c-interop.md) uses this mechanism, so C types can be constructed
from Peon values with constructor syntax:
```peon
var x: int64 = 42;
let n = cint(x); # Calls the int64 -> cint converter
let y = int64(n); # Calls the cint -> int64 converter
```
Converter requirements:
- Exactly one parameter (no mutable reference parameters)
- Non-void return type
## Defining custom constructors
The `constructor` [pragma](compile-time.md#declaration-pragmas) marks a named top-level
function as a constructor candidate for call syntax on its return type. This lets you
expose `T(...)` without forcing users to spell a separate helper such as `newT()`:
```peon
type Event = object {
signaled: bool;
}
fn Event(): Event {
#pragma[constructor]
return Event(signaled = false);
}
let evt = Event();
```
Constructor requirements:
- Top-level named function only
- Non-generic
- Non-void return type
- Return type must be a named type
- Function name must exactly match the return type name
Resolution rules:
- Structural object construction and enum-variant construction still work as before
- Pragma constructors join the same `T(...)` candidate set
- If multiple constructor candidates match, including a mix of structural and pragma
constructors, the call is rejected as ambiguous
This is distinct from `#pragma[converter]`: converters reinterpret `T(expr)` as a type
conversion, while pragma constructors let a user-defined function act as the constructor
for that type itself.
### Named pragma arguments
[Pragmas](compile-time.md#pragmas) support named arguments inside parenthesized argument
lists:
```
#pragma[try(ok: "Ok", value: "value", err: "Err", error: "error")]
```
Named arguments use `key: value` syntax. They can be mixed with positional arguments
in the same pragma.

366
docs/manual/types.md Normal file
View File

@@ -0,0 +1,366 @@
# Type Declarations
[Back to Manual](index.md)
## Plain objects
Plain objects are value types with inline storage and normal copy/move semantics:
```
type Point = object {
x: int64;
y: int64;
}
let point = Point(x = 3, y = 4);
print(point.x); # 3
```
Object types are nominal. If two modules both declare `type Foo = object { ... }`, those
types stay distinct even when their field sets match exactly.
Fields can be read with dot syntax and assigned on mutable values:
```
var pair = Point(x = 1, y = 2);
pair.x = 7;
print(pair.x); # 7
```
## Tuple types
Tuple types use the same field syntax as objects, but they are structural instead of
nominal. A tuple value matches a tuple type when the field names and field types match,
regardless of where the type was declared.
```peon
type Point = tuple {
x: int64;
y: int64;
}
let point: Point = (y: 4, x: 3);
print(point.x); # 3
```
Tuple literals use parentheses plus `name: value` entries. Field order does not matter:
```peon
let a = (left: 1, right: 2);
let b = (right: 2, left: 1);
```
The same parenthesized field syntax also works in type position for anonymous tuple
types:
```peon
fn project(point: (x: int64, y: int64)): int64 {
return point.x;
}
let point: (x: int64, y: int64) = (y: 4, x: 3);
```
This means tuple construction is structural: the compiler checks field names and field
types, not declaration identity. In contrast, ordinary object construction stays nominal
and uses the usual call-style constructor syntax:
```peon
type Pair = object {
left: int64;
right: int64;
}
type PairView = tuple {
left: int64;
right: int64;
}
let view: PairView = (right: 2, left: 1); # OK
let pair = Pair(left = 1, right = 2); # OK
# let bad: Pair = (left: 1, right: 2); # Error: objects are nominal
```
Peon also supports custom constructor functions through `#pragma[constructor]`
(see [Operators -- Defining custom constructors](operators.md#defining-custom-constructors)).
Tuple values must still spell out field names at construction time. Peon does not treat
`(x, y)` as shorthand for `(x: x, y: y)`, because tuple field names are part of the
structural type itself rather than just positional slots.
Like objects, tuples support dot-field access and can be declared in managed form with
`ref tuple` when you want a heap-allocated structural payload.
## Ref objects
Ref objects are heap-allocated [managed references](memory-model.md):
```
type Box = ref object {
value: int64;
}
var b = Box(value = 42);
print(b.value); # 42
```
They can also be defined as a `ref` alias to an existing plain type:
```
type BoxData = object {
value: int64;
}
type Box = ref BoxData;
let b = Box(value = 7);
```
Ref objects are dereferenced with `[]`:
```
var b = Box(value = 1);
b[] = Box(value = 7)[]; # Whole-payload dereference and assignment
```
Heap-allocated values of any type can be created with `new`:
```
var cell = new(int64);
cell[] = 42;
print(cell[]); # 42
var boxed = new(Pair);
boxed.left = 7;
```
## Type aliases
Types can be aliased:
```
type IntBox = Box[int64];
```
## Enumeration types
Peon supports two enum forms:
- Value enums: payloadless variants, optionally with explicit integral values
- Payload enums: tagged unions, where variants may carry named fields
These two forms are intentionally disjoint. A single enum declaration cannot mix
payload-bearing variants with explicit discriminants.
### Payload enums
Payload enums are tagged unions. Each variant can optionally carry fields:
```
type Result = enum {
Ok { value: int64; },
Err { message: string; }
}
let ok = Ok(value = 1);
let err = Err(message = "failed");
```
### Value enums
Value enums are closed sets of integer-backed values:
```peon
type SocketKind = enum {
Stream = 1,
Datagram,
Raw = 7
}
```
Rules:
- every variant must be payloadless
- explicit discriminants must be integral expressions
- omitted discriminants auto-increment from the previous value
- the first omitted discriminant defaults to `0`
- matching uses bare variant names such as `case Stream`
Imported integral C constants may also be used as discriminants
(see [C Interop](c-interop.md#importing-c-constants-importc-on-const)):
```peon
const TEST_STREAM: cint; #pragma[importc: "TEST_STREAM", header: "<value_enum.h>"]
type SocketKind = enum {
Stream = TEST_STREAM,
Datagram = 9
}
```
This is rejected because it mixes the two models:
```peon
type Bad = enum {
Good = 1,
Broken { value: int64; }
}
```
## Standard library types
The standard library provides `Option[T]` and `Result[T, E]`:
```peon
var some42 = some(42);
var noneInt = none(int);
let done: Result[void, string] = Ok();
match done {
case Ok { print("done"); }
case Err(error) { print(error); }
}
```
Both `Option[T]` and `Result[T, E]` support the [`?` operator](operators.md#the--operator-and-pragmatry)
for concise error propagation.
For pattern matching on enum types, see [Control Flow -- Pattern matching](control-flow.md#pattern-matching).
### Static arrays
The built-in `array[N, T]` type constructor creates fixed-size arrays. Arrays can be
spelled directly, named with a type alias, or inferred from an array literal:
```
type IntVec = array[3, int64];
var a = IntVec(1, 2, 3);
var b = [1, 2, 3, 4]; # Inferred as array[4, int64]
print(a[0]); # 1
a[0] = 9;
print(len(a)); # 3
print(a[2]); # 3
```
Array access is bounds-checked at runtime when `boundChecks` is enabled
(see [Compile-Time Features -- Config symbols](compile-time.md#config-symbols)). Out-of-bounds
access panics.
Plain arrays (non-ref) are value types. Assignment moves the array -- to avoid implicit
copies, the source becomes inaccessible after the move:
```
var a = [1, 2, 3];
var b = a; # a is moved into b
b[0] = 9;
# print(a[0]); # Error: a has been moved
print(b[0]); # 9
```
To create an independent copy, use `clone` explicitly:
```
var a = [1, 2, 3];
var b = clone(a); # Explicit copy
b[0] = 9;
print(a[0]); # 1 -- a is unchanged
```
Ref arrays are heap-allocated and can be mutably borrowed:
```
type RefVec = ref array[2, int64];
var a = RefVec(4, 5);
var b = mut borrow(a);
b[1] = 7;
print(a[1]); # 7 -- a and b share the same data
```
### Sequences (`seq[T]`)
The stdlib provides `seq[T]` as a growable collection:
```peon
type Box = object {
value: int64;
}
var boxes = @[Box(value = 1), Box(value = 2)];
let first: mut lent Box = boxes[0];
first.value = 9;
var ints = @[1, 2, 3];
ints[1] = 7;
print(ints[1][]); # Scalar reads use whole-deref
```
Important `seq` indexing semantics:
- `value[index]` returns a borrow, not an owned `T`.
- Immutable indexing yields `lent T`.
- Indexing through a mutable borrow of `seq[T]` yields `mut lent T`.
- `value[index] = newValue` returns `mut lent T`.
- `unsafe { value.get_unchecked(index) }` returns the same borrow shapes as
`value[index]`, but skips the bounds check for that one access.
- When the element is a scalar or you need an owned value, use whole-deref:
`value[index][]`.
`seq[T]` is a runtime collection. It is available in native code, but compile-time
evaluation rejects it because CTFE does not permit heap-backed sequence storage.
Field access works directly through borrows, so `boxes[0].value` is valid.
Implementation note: `seq[T]` stores its payload through an internal `buffer:
Buffer[T]`
owner cell instead of keeping only a raw pointer in the sequence header. This is
what allows element borrows and iterator yields to stay tied to the backing
storage owner. If growth moves the raw payload, the buffer can swap to a new
owner so old element borrows go stale cleanly instead of silently dangling into
moved memory.
Sequences support `append` and standard queries:
```peon
var s = @[1, 2, 3];
s.append(4);
print(len(s)); # 4
print(s[3][]); # 4
```
The `@` prefix operator converts a static array to a `seq`:
```peon
var arr = [1, 2, 3];
var s = @arr; # seq[int64] with the same elements
```
### Networking (`net`)
The standard library also provides networking in `net`:
```peon
import net;
let pair = socketPair(Unix, Stream, 0).unwrap();
let addrs = runAsync(getAddrInfo("localhost", 80, Inet, Stream, 0)).unwrap();
```
The public socket-related value types are real enums:
- `SocketFamily`: `AnyFamily`, `Unix`, `Inet`, `Inet6`
- `SocketKind`: `AnyKind`, `Stream`, `Datagram`
The public address-resolution surface is:
- `getAddrInfo(host, port)`
- `getAddrInfo(host, port, family, kind, protocol)`
`host` is a `string`, `port` and `protocol` are `int64`, and `family` / `kind`
use those enums instead of raw C constants.

174
docs/manual/unsafe.md Normal file
View File

@@ -0,0 +1,174 @@
# Unsafe
[Back to Manual](index.md)
Peon's type system and borrow checker enforce safety by default. The `unsafe` keyword
is the explicit opt-out: it marks code that the compiler cannot fully verify and that
the programmer takes responsibility for. `unsafe` is intentionally granular -- it appears
in four distinct positions, each with different meaning.
## Unsafe blocks
`unsafe { ... }` is a block expression that permits specific unchecked operations inside
its body. It does **not** disable ordinary typing, purity, or borrow rules -- only the
operations listed below are unlocked:
- Calling an `unsafe fn`, including [imported C functions](c-interop.md#importing-c-functions-importc)
- Raw pointer dereference and indexing (`ptr[]`, `ptr[i]`)
- Raw allocation and deallocation via `alloc`, `create`, `realloc`, `dealloc`, and `copyMem`
- Reinterpret casts via `cast[T](x)`
Safe wrappers can use tiny internal `unsafe` blocks and expose ordinary safe APIs.
That is the intended style for abstractions such as `Buffer[T]` and `seq[T]`.
```peon
let values = @[1, 2, 3];
let second = unsafe { values.get_unchecked(1)[] };
```
## Unsafe functions
`unsafe fn` declares a function that is unsafe **to call**. The caller must wrap every
call site in `unsafe { ... }` because the function exposes preconditions the type system
does not prove.
This is separate from implementation unsafety. Even inside an `unsafe fn`, unchecked
operations still belong in explicit `unsafe { ... }` blocks:
```peon
unsafe fn get_unchecked(self: lent seq[int64], index: int64): lent int64 {
# Still need unsafe here for the raw pointer access
return unsafe { self.data[index] };
}
```
## Unsafe function types
When a first-class function value is unsafe to call, its type is written
`unsafe fn(params): returnType`:
```peon
fn apply_unsafe(f: unsafe fn(x: int64): int64, value: int64): int64 {
return unsafe { f(value) };
}
```
See also [Functions -- Higher-order functions](functions.md#higher-order-functions).
## Unsafe interfaces
If implementing or inheriting an [interface](interfaces.md) carries extra unchecked
obligations -- for example, semantic contracts the compiler cannot verify -- declare it
as `unsafe iface`. Types and child interfaces must then acknowledge the unsafety
explicitly:
```peon
unsafe iface Sendable {
unsafe fn transfer(self);
}
type Channel = object with unsafe Sendable {
}
unsafe fn transfer(self: Channel) {
}
```
`unsafe iface` propagates through inheritance and conformance:
- `unsafe iface Child from unsafe Parent { ... }` -- the `unsafe` marker is required
when inheriting from an unsafe parent.
- `type X = object with unsafe Parent { ... }` -- the `unsafe` marker is required
when conforming to an unsafe interface.
- Interface requirements may themselves be `unsafe fn`.
## Imported C functions
All functions imported with `#pragma[importc]` are treated as `unsafe fn`. Calls to
them must appear inside `unsafe { ... }`. See [C Interop](c-interop.md) for details.
```peon
fn strlen(s: cstring): csize; #pragma[importc, header: "<string.h>"]
let len = unsafe { strlen("hello") };
```
## Raw pointers
Peon supports low-level raw pointers. `pointer` is the erased raw-pointer type,
while `ptr T` is a typed raw pointer to plain storage `T`:
```
var opaque: pointer;
let values: ptr int64 = unsafe { alloc(int64, 1) };
unsafe {
values[] = 4;
dealloc(values);
}
```
If you intentionally erase the pointee type, use explicit casts in both directions:
```
unsafe {
let values = create(int64, 2);
let opaque = cast[pointer](values);
let base = cast[ptr int64](opaque);
let view = cast[ptr UncheckedArray[int64]](base);
print(view[0]);
dealloc(values);
}
```
`alloc(T, n)` returns a `ptr T` without zero-initializing the storage. `create(T, n)`
returns a zero-initialized `ptr T`. These operations require `unsafe`:
```
unsafe {
let values = create(int64, 3); # ptr int64
let view = cast[ptr UncheckedArray[int64]](values);
print(view[0]); # 0
dealloc(values);
}
```
`ptr UncheckedArray[T]` is useful for functions that consume a contiguous buffer with no
statically known length, or when you need unchecked-style pointer indexing:
```
fn sum(values: ptr UncheckedArray[int64]): int64 {
return unsafe { values[0] + values[1] };
}
```
Unlike `ref T`, raw pointers are not managed by the
[generational-reference runtime](memory-model.md#how-generational-references-work). They
do not carry liveness checks and are intended for manual allocation and low-level
interop-style code.
## Unmanaged memory operations
The full set of raw memory operations:
| Function | Description |
|----------|-------------|
| `alloc(T, n)` | Allocate raw storage for `n` values of type `T` (uninitialized). Returns `ptr T`. Requires `unsafe`. |
| `create(T, n)` | Allocate zero-initialized raw storage for `n` values of type `T`. Returns `ptr T`. Requires `unsafe`. |
| `realloc(p, n)` | Resize the allocation at `p` to hold `n` elements. Returns `ptr T`. Requires `unsafe`. |
| `dealloc(p)` | Free a raw pointer allocation. Requires `unsafe`. |
| `copyMem(dst, src, n)` | Copy `n` bytes from `src` to `dst`. Well-defined even when the source and destination regions overlap (it behaves like C's `memmove`, not `memcpy`). Requires `unsafe`. |
| `addr(x)` | Take the raw address of a raw-pointer-backed value, returning `ptr T`. |
| `cast[T](x)` | Reinterpret the bits of `x` as type `T`. Source and target must have the same size. Requires `unsafe`. |
Raw pointers cannot point to managed `ref` types or `lent` types. This is enforced
at compile time.
```peon
unsafe {
let buf = alloc(int64, 4);
let src = create(int64, 4);
copyMem(buf, src, 4 * sizeof(int64));
dealloc(src);
dealloc(buf);
}
```