Silk Specification (2026)
This is the single-file edition of the Silk language specification for 2026.
Language Cheat Sheet#
This document summarizes the key syntax and concepts from the Silk language in a condensed form. It is meant as a quick reference; detailed semantics live in the chapters below.
Notes#
This cheat sheet includes both:
- the full language design (where some features are still evolving), and
- the currently implemented compiler subset.
For the authoritative “current implementation notes”, prefer:
the implementation status(implementation status), and- any “notes sections inside the relevant concept documents.
In particular, features such as regions (beyond the current with + new
subset), concurrency runtime (scheduler/event loop), and
dependent types are not implemented end-to-end yet. Value constraints are
expressed via Formal Silk (#require / #assure, including #require on
struct declarations).
In Silk currently:
- Runtime
let/varbindings and compile-timeconstbindings must have an initializer (Compiler Diagnostics,E2015). - Destructuring
letbindings from structs are supported: - positional:
let (id, name) = User{ ... }; - named + aliasing:
let { data as d, id as i } = Record{ ... }; - Array destructuring is supported:
- arrays/slices:
let [a, b] = xs; - Enum destructuring is supported:
- variants:
let Ok(v) = expr;,let Pair(a, b) = expr;,let E::Variant(x) = expr;(traps on non-matching variants) - Refutable
letbindings are supported: let <pattern> = <expr> else { ... };(theelseblock must be terminal)constinitializers must be compile-time evaluable (Compiler Diagnostics,E2041); in the Supported forms this is restricted to scalar expressions and calls toconst fnfunctions (still no/or%), plus string literals /conststring aliases.- Monomorphized generics are supported for
struct/interface/impland applied types (Name(args...)): - const parameters/arguments and generic functions are still rejected (
E2016), - A small concurrency subset is implemented (
Task(T)/Promise(T)plusyield/await; seeConcurrency). - The builtin
map(K, V)type form is removed; usestd::map::{HashMap, TreeMap}instead (E2017). - Function expressions are implemented as first-class function values:
- non-capturing: inferred
pure—let add = fn (x: int, y: int) -> x + y; - capturing closures: may capture immutable scalar locals/parameters by value;
forming captures inside
purecode is rejected in the Supported forms.
Types (Surface Forms)#
- Booleans:
bool—true,false. - Integers:
u8,i8,u16,i16,u32,i32,u64,i64,u128,i128,int. - Floats:
f32,f64,f128. - Char:
char. - String:
string. - Time:
Instant,Duration. - Optional:
T?(sugar forOption(T)). - References:
&T. - Arrays / slices:
T[],T[N]. - Maps / dictionaries:
std::map::{HashMap, TreeMap}(standard library). - Function types:
fn(params) -> R(discipline modifiers apply to function declarations; function types are unmodified in the Supported forms). - Function expressions (non-capturing, inferred
pure): - expression body:
fn (x: int, y: int) -> x + y - block body:
fn (x: int, y: int) -> int { return x + y; } - block body
voidshorthand:fn (x: int, y: int) { ... }(implicitvoid) - capturing closures are supported as a subset; see
Types. - Structs / enums / interfaces:
struct Name { ... },struct Name extends Base { ... }enum Name { ... }interface Name { ... },interface Name extends Base { ... }
Literals#
- Integers:
0,42, with base/suffixes as per the spec. - Floats:
3.14,1.0e-9. - Booleans:
true,false. - Chars:
'A', escape sequences. - Strings:
- single-line:
"hello", - multi-line: multi-line quoted forms.
- Durations: numeric + unit, e.g.
10ms,2s,5min. - Aggregates:
- arrays:
[1, 2, 3], - structs:
Point { x: 1, y: 2 }.
Operators#
- Arithmetic:
+,-,*,/,%. - Bitwise:
&,|,^,~,<<,>>. - Comparison:
==,!=,<,<=,>,>=. - Logical:
!,&&,||. - Assignment:
=,+=,-=,*=,/=. - Increment/decrement:
++,--(statement-likevoid). - Optional / nullability:
- optional chaining:
?., - coalescing:
??. - Member/scope:
.,::. - Ranges:
..,..=,.... - Other punctuation:
,,;,:,->,=>.
Operator precedence and associativity follow the rules in Operators.
Flow Control#
if cond { ... } else { ... }(statement form)if let [mut] <pattern> = <expr> { ... } else { ... }(refutable pattern statement form; supportselse if let/else letchains and chained&& let [mut])let v = if cond { a } else { b };(ifexpression)loop { ... }(infinite loop; exits viabreak/return).while (cond) { ... }while let [mut] <pattern> = <expr> { ... }for pattern in iterable { ... }(ordinary binder form for ranges, builtin arrays/slices, and iterators).for let [mut] pattern in iterable { ... }(pattern-filtered iteration; matching elements run the body, non-matching elements are skipped).for (init; cond; step) { ... }(C-style loop header).async loop { ... }/task loop { ... }(loop forms in async context).match value { ... }— pattern matching.return expr;assert expr;orassert(expr, "message");break;continue;- Blocks:
{ stmt* }. - Expression statements:
expr;(where allowed).
See the relevant chapters of this specification for details.
Executable entrypoint (initial rule):
-
A minimal executable module defines exactly one top-level function:
fn main() -> int { return 0; } -
This
mainfunction takes no parameters and returnsint. The front-end enforces this shape for executable builds before code generation.
Optionals & Mutability#
- Declare optionals:
let x: T? = None;orlet x: Option(T) = None;. - Create values:
None,Some(value). - Use:
user.profile?.email— optional chaining.email ?? "default@example.com"— coalescing.
Mutability:
- Parameters and references are immutable by default.
- Grant mutation via
mut: - in function definition:
fn reset(mut r: &Runner) { ... }, - at call site (syntax per spec).
Structs, Impl Blocks, Interfaces#
-
Structs:
struct Frame { seq: u32, size: u16, flag: u8 } -
pure data, well-defined layout.
-
Impl blocks:
impl Frame { fn size_bits(self: &Frame) -> u32 { ... } } -
Interfaces:
interface Element { fn onclick(event: &Event) -> void; } impl Button as Element { fn onclick(self: &Button, event: &Event) -> void { ... } }
See Structs, Impl Blocks, and Memory Layout and Interfaces for details.
Regions & Buffers#
- Regions (fixed-size allocation context):
- declare:
const region region_buf: u8[1024]; - use:
with region_buf { let p: &Frame = new Frame{ ... }; } - anonymous:
with 1024 { let p: &Frame = new Frame{ ... }; } - Buffers:
- intrinsic
Buffer(T)with(ptr, capacity), - unsafe primitive underpinning higher-level collections.
- Allocation:
newuses the active region insidewith(seeRegions).
Concurrency#
-
Function modifiers:
-
fn— normal. -
async fn—await-able; calling yieldsPromise(T). -
task fn— runs in parallel on a worker thread; calling yieldsTask(T). -
async task fn—async+task; calling yieldsPromise(Task(T)). -
Structured block:
```silk async fn get_dashboard_data() -> Dashboard { // Note: the scheduler-backed `async { ... }` semantics are still design work, // but the compiler implements `Task(T)`/`Promise(T)` handles, `yield`, and `await`. let mut user: User; let mut orders: Order[]; async { let user_promise = fetch_user_profile(123); let orders_promise = fetch_recent_orders(123); user = await user_promise; orders = await orders_promise; } return Dashboard(user, orders); } ```
To receive task values, use yield inside a task context (task { ... } or task fn):
task fn worker () -> int { return 42; }
async fn main () -> int {
let h = worker();
task {
let value: int = yield h;
return value;
}
}
See Concurrency for deeper semantics.
Formal Silk#
#const— formal Silk declarations used inside specifications (not available at runtime).#require— preconditions.#assure— postconditions.#assert— block-local proof obligations.#invariant— invariants.#variant— termination measures.#monovariant— monotonic measures.theory/#theory— reusable proof obligations.
#require / #assure appear before functions; #invariant / #variant / #monovariant appear before loops; #const and #assert appear inside blocks. See Formal Silk.
External Declarations & ABI (Quick View)#
-
Declare external bindings:
ext foo = fn (string) -> void; ext bar = u32; -
Strings:
-
Silk
stringis internally{ ptr, len }, -
C side uses
SilkString { char *ptr; int64_t len; }for embedding, -
extcalls to typical C APIs may passconst char *derived fromstringwhere appropriate.
See External Declarations (ext) and C99 ABI and libsilk.a`` for full details.
Silk Syntax Tour (Soup to Nuts)#
This document is an example-driven tour of Silk’s surface syntax, from a single-file “hello world” through modules/packages, declarations, statements, expressions, and the Formal Silk verification directives.
This guide complements (not replaces):
Formal Grammar Spec(the exact grammar the parser accepts),- the concept chapters in this specification (semantics and checker rules),
the implementation status(what works end-to-end today),- and
Compiler Diagnostics(error codes for unsupported forms).
Notes#
This specification is the canonical specification, and many documents describe both:
- the full language design, and
- Silk currently (what parses, type-checks, and code-generates today).
This tour follows the same approach:
- examples labeled “Example” are intended to compile in the Supported forms,
- examples labeled “Design” illustrate planned syntax and are not necessarily implemented.
When in doubt, prefer:
Formal Grammar Specfor syntax,the implementation statusfor current end-to-end support,examples/for working example programs.
0. Minimal Executable Module#
The smallest executable is a module with a main function:
fn main () -> int {
return 0;
}
Notes:
- Most statements end with
;. - Blocks are
{ stmt* }. - The entrypoint for an executable build is
mainreturningint(see ``silkCLIfor the CLI rules and supported targets).
1. Lexical Basics#
Whitespace and comments#
Whitespace (spaces, tabs, newlines) is generally allowed between tokens.
Comments:
// Line comment
/* Block comment (non-nesting) */
Doc comments (tooling-only; see Silkdoc (Documentation Comments)):
/// Line doc comment
/**
* Block doc comment
*
* @example silk
* fn main () -> int { return 0; }
*/
fn main () -> int {
return 0;
}
Identifiers and qualified names#
Names are often qualified with :::
package my_app::core;
import std::strings;
fn main () -> int {
let s: string = std::strings::trim(" hi ");
return 0;
}
Formal Silk directive tokens (#...)#
Formal Silk directives like #require and #invariant are not comments.
They are real tokens and are parsed as part of the language (see
Formal Silk).
#require x >= 0;
#assure result == x + 1;
fn inc (x: int) -> int {
return x + 1;
}
2. Source File Structure: package/module, import, then declarations#
Top-level ordering is enforced (see Packages, Imports, and Exports):
- Optional
package ...;ormodule ...; - Zero or more
import ...;declarations as a contiguous block - All other top-level declarations (
fn,let,struct,enum,impl, …)
package#
// app/main.slk
package app;
fn main () -> int {
return 0;
}
module (compile-time-only module values)#
// crypto/sha256.slk
module crypto::sha256;
Modules can declare interface conformance (design surface is implemented):
// drivers/uart.slk
module drivers::uart as Device;
3. Imports and Exports#
See Packages, Imports, and Exports for the full import/export model.
Package imports#
package app;
import std::strings;
fn main () -> int {
let s: string = trim(" hi "); // may be visible unqualified in the current subset
let t: string = std::strings::trim(" hi ");
return 0;
}
File imports (from "...")#
Named import:
// main.slk
import { answer as the_answer } from "./util.slk";
fn main () -> int {
return the_answer;
}
Default import (binds a default export if present, otherwise a namespace):
// module.slk
export default fn () -> int {
return 3;
}
// main.slk
import foo from "./module.slk";
fn main () -> int {
return foo();
}
Named exports and re-exports#
Export a declaration directly:
// util.slk
export let answer: int = 42;
export fn add1 (x: int) -> int {
return x + 1;
}
Re-export an in-scope name:
// api.slk
import { answer } from "./util.slk";
export { answer as the_answer };
4. Top-Level Declarations (Overview + Examples)#
This section shows the core top-level declaration forms:
- bindings:
const,let,var - functions:
fn(pluspure/async/task) - type aliases:
type - types:
struct,enum,interface,impl,error - external declarations:
ext - tests:
test - Formal Silk:
theory(and#...directives)
4.1 Bindings: const, let, let mut, var#
Supported forms (Supported forms requires initializers; see E2015):
fn main () -> int {
const answer: int = 42;
let x: int = answer;
let mut y: int = 0;
var z: int = 1; // `var` is an alias for `let mut` (current subset)
y = y + 1;
z += 2;
return x + y + z;
}
Notes:
-
constinitializers must be compile-time evaluable in the Supported forms (seeE2041). -
Only
let mut/varbindings are assignable lvalues (seeMutabilityandOperators). -
Destructuring
letbindings are supported for struct values:struct User { id: u64, name: string } let (id, name) = User{ id: 123, name: "alice" }; struct Record { id: u64, data: string } let { data as d, id as i } = Record{ id: 456, data: "other" };
Array destructuring is also supported:
let records: Record[] = [{ id: 123, data: "a" }, { id: 456, data: "b" }];
let [a, b] = records;
Enum destructuring is also supported:
import std::result;
fn main () -> int {
type R = std::result::Result(int, int);
let Ok(value) = R.ok(7);
return value;
}
4.2 Functions: fn (plus pure, async, task)#
Basic function declaration:
fn add (x: int, y: int) -> int {
return x + y;
}
pure fn (restricted subset; see Function Disciplines (pure, task, async)):
pure fn inc (x: int) -> int {
return x + 1;
}
async fn / task fn / async task fn (handles; see Concurrency):
task fn worker () -> int {
return 7;
}
async fn main () -> int {
task {
let t = worker(); // Task(int)
let value: int = yield t;
return value;
}
}
Parameters: mut, defaults, and varargs#
Mutable reference parameters require mut both in the signature and at the
call site (see Mutability):
struct Pair { a: int, b: int }
fn bump_a (mut p: &Pair) -> void {
p.a += 1;
}
fn main () -> int {
let mut p: Pair = Pair{ a: 1, b: 2 };
bump_a(mut p);
return p.a;
}
Default arguments (Supported forms restricts default expressions to a constant/literal subset):
fn add2 (x: int, y: int = 2) -> int {
return x + y;
}
Varargs (final parameter prefixed by ...; see Varargs (Variable Arguments)):
fn log (fmt: string, ...args: std::fmt::Arg) -> void {
std::io::println(fmt, args);
}
Generic function parameter split (;) (Design, parsed but rejected)#
Generic functions use ; to separate compile-time parameters from value parameters:
// Design (currently rejected with `E2016`).
fn get_first(T, N: int; xs: &T[N]) -> T {
return xs[0];
}
4.3 Function expressions (lambdas)#
Supported forms (non-capturing expression body):
fn main () -> int {
let add = fn (x: int, y: int) -> x + y;
return add(1, 2);
}
Supported forms (block body with explicit return type):
fn main () -> int {
let add = fn (x: int, y: int) -> int {
return x + y;
};
return add(1, 2);
}
Capturing closures are supported as a restricted subset; see Types
and Memory Model (Stack, Heap, and Moves).
4.4 Type aliases: type#
Basic alias:
type I = int;
Optional kind tags (validated by the checker; see Types):
type struct UserId = int;
type fn IntAdder = fn(int, int) -> int;
type pure fn PureIntAdder = fn(int, int) -> int;
4.5 Structs: struct and impl#
Struct declarations (fields use name: Type, optional default with =):
struct Point {
x: int = 0,
y: int = 0,
}
Struct literals:
fn main () -> int {
let p1: Point = Point{ x: 1, y: 2 };
let x = p1.x;
// Shorthand field init (`x` means `x: x`):
let y: int = 3;
let p2: Point = Point{ x, y };
return x + p2.y;
}
Inferred struct literals require an expected struct type context:
fn main () -> int {
let p: Point = { x: 1, y: 2 };
return p.x + p.y;
}
Heap allocation (new) produces a &Struct reference in the Supported forms:
struct Boxed { value: int }
fn main () -> int {
let b: &Boxed = new Boxed{ value: 7 };
return b.value;
}
Attach methods with impl (see Structs, Impl Blocks, and Memory Layout):
impl Point {
public fn sum (self: &Point) -> int {
return self.x + self.y;
}
}
fn main () -> int {
let p: Point = Point{ x: 1, y: 2 };
return p.sum();
}
Single inheritance (current surface is implemented; see Structs, Impl Blocks, and Memory Layout):
// Design shape (field/layout rules and current subset limits are documented).
struct Base { x: int = 0 }
struct Derived extends Base { y: int = 0 }
4.6 Enums: enum + match expression#
enum Color {
Red,
Rgb(u8, u8, u8),
}
fn to_int (c: Color) -> int {
return match c {
Color::Red => 0,
Color::Rgb(r, g, b) => (r as int) + (g as int) + (b as int),
};
}
See enum` Types` and match Expression (and Statement).
4.7 Interfaces and impl ... as ...#
interface Counter {
fn inc() -> void;
fn get() -> int;
}
struct Cell { value: int = 0 }
impl Cell as Counter {
fn inc (mut self: &Cell) -> void {
self.value += 1;
}
fn get (self: &Cell) -> int {
return self.value;
}
}
See Interfaces.
4.8 Typed errors: error, panic, T | ErrorType..., match statement, ?#
Error type declaration:
import std::arrays;
error OutOfBounds {
index: i64,
len: i64
}
Error-producing signatures use |:
fn get_at (xs: std::arrays::Slice(u8), index: i64) -> u8 | OutOfBounds {
if index < 0 || index >= xs.len() {
panic OutOfBounds { index: index, len: xs.len() };
}
return xs.get(index);
}
Handling typed errors uses the match statement form:
fn main () -> int {
let xs_arr: u8[3] = [1, 2, 3];
let xs: std::arrays::Slice(u8) = { ptr: xs_arr as u64, len: 3 };
match (get_at(xs, 10)) {
value => {
return value as int;
},
err: OutOfBounds => {
std::abort();
}
}
}
Propagating errors from calls uses postfix ?:
// Supported when `main` declares a compatible error set.
fn main () -> int | OutOfBounds {
let xs_arr: u8[3] = [1, 2, 3];
let xs: std::arrays::Slice(u8) = { ptr: xs_arr as u64, len: 3 };
let x: u8 = get_at(xs, 0)?;
return x as int;
}
See Typed Errors (error, panic, and T | ErrorType...).
4.9 External declarations: ext#
External function binding (symbol name optional; see External Declarations (ext)):
export ext puts = fn(string) -> int;
export ext c_abort "abort" = fn() -> void;
export ext errno "errno" = int;
Note: C variadics (printf-style ...) via ext are not implemented yet; see
Varargs (Variable Arguments) and External Declarations (ext).
See also: C99 ABI and libsilk.a`` (C ABI) and include/silk/silk.h.
4.10 Tests: test#
test "addition works" {
if (1 + 2) != 3 {
std::abort();
}
}
See Testing and run with silk test.
4.11 Formal Silk theories: theory#
Top-level theory (exportable/importable):
export theory nonzero (x: int) {
#require x != 0;
}
Apply a theory inside a function:
import { nonzero } from "./theories.slk";
fn main () -> int {
let x: int = 1;
#theory nonzero(x);
return 0;
}
Inline (block-local) theories use the same #theory token and are
disambiguated from theory use by { ... } (inline declaration) vs ; (use):
fn main (x: int, y: int) -> int {
#theory local_sum_nonzero (x: int, y: int) {
#const z = x + y;
#assure z != 0;
}
#theory local_sum_nonzero(x, y);
return 0;
}
See Formal Silk.
5. Types (Surface Forms)#
See Types for full details and implementation limits.
Primitive types#
bool, i8/u8, i16/u16, i32/u32, i64/u64, int, f32/f64, char, string, void, Instant, Duration
Optional types#
fn main () -> int {
let a: int? = None;
let b: int? = Some(7);
let c: int = b ?? 0;
return c;
}
Nested optionals use ?? in type position (T?? means “optional of optional”):
fn main () -> int {
let x: int?? = Some(Some(1));
let y: int? = x ?? None;
return (y ?? 0);
}
Optional match expressions are the explicit form of optional consumption:
fn main () -> int {
let x: int? = Some(7);
let y: int = match x {
None => 0,
Some(v) => v,
};
return y;
}
References#
fn sum (p: &Point) -> int {
return p.x + p.y;
}
Arrays and slices#
fn main () -> int {
let xs: int[] = [1, 2, 3];
return xs[0];
}
Fixed-length arrays use T[N]:
fn main () -> int {
let xs: int[3] = [1, 2, 3];
return xs[2];
}
Function types#
type IntBinOp = fn(int, int) -> int;
fn main () -> int {
let add: IntBinOp = fn (x: int, y: int) -> x + y;
return add(1, 2);
}
Applied types and generics#
Generic parameter lists on struct/interface/impl are implemented:
struct Box(T) { value: T }
fn main () -> int {
let b: Box(int) = { value: 1 };
return b.value;
}
See Generics (Monomorphized) for Supported forms limits (notably E2016 for
const parameters/arguments and generic functions).
6. Statements (Inside Blocks)#
The statement grammar is summarized in Formal Grammar Spec and detailed
in the relevant chapters of this specification.
if / else#
fn main () -> int {
let x: int = 1;
if x == 0 {
return 0;
} else {
return 1;
}
}
loop, while, for#
fn main () -> int {
let mut i: int = 0;
while i < 3 {
i += 1;
}
return i;
}
for over a range form (special-cased surface; see ``for Loop):
fn main () -> int {
let mut sum: int = 0;
for i in 0 .. 5 {
sum += i;
}
return sum;
}
C-style for header:
fn main () -> int {
let mut sum: int = 0;
for (let mut i: int = 0; i < 5; i += 1) {
sum += i;
}
return sum;
}
break, continue, return#
fn main () -> int {
let mut i: int = 0;
loop {
i += 1;
if i < 3 {
continue;
}
break;
}
return i;
}
assert and panic#
fn main () -> int {
assert 1 + 2 == 3;
assert(2 + 2 == 4, "math is broken");
return 0;
}
panic is used for typed errors (see Typed Errors (error, panic, and T | ErrorType...)):
panic OutOfBounds { index: 1, len: 0 };
match statement (typed errors)#
See Typed Errors (error, panic, and T | ErrorType...) for the Terminal Arm Rule and the supported
pattern forms.
async { ... } and task { ... }#
Structured blocks (implemented as lexical blocks in the Supported forms; see
Concurrency):
async fn main () -> int {
async {
// async region
}
task {
// task region
}
return 0;
}
Concurrency operators: await, await *, yield, yield *#
await unwraps Promise(T) values inside async fn:
async fn add2 (x: int) -> int {
return x + 2;
}
async fn main () -> int {
let p = add2(1); // Promise(int)
let v: int = await p;
return v;
}
await * awaits a collection of promises and yields a collected T[]:
async fn add1 (x: int) -> int {
return x + 1;
}
async fn main () -> int {
let values: int[] = await * [add1(1), add1(2), add1(3)];
return values[0] + values[1] + values[2];
}
yield / yield * interact with Task(T) values (used inside task regions in
the Supported forms):
task fn producer (n: int) -> int {
var i: int = 0;
while i < n {
yield i;
i += 1;
}
return n;
}
async fn main () -> int {
task {
let t = producer(2); // Task(int)
let values: int[] = yield * t;
return values[0] + values[1] + values[2];
}
}
7. Expressions (Precedence + Demonstrations)#
Silk expressions follow a conventional precedence hierarchy. For the exact
productions, see Formal Grammar Spec.
Literals and other primary expressions#
See the the relevant chapters of this specification concept documents for precise rules.
fn main () -> int {
// Booleans.
let b: bool = true;
// Integers and floats.
let i: int = 42;
let u: u8 = 0xFF;
let f: f64 = 3.14;
// Characters and strings.
let ch: char = 'A';
let s1: string = "hello";
let s2: string = `raw \n no escapes`;
// Durations.
let d: Duration = 10ms;
// Optionals.
let opt: int? = Some(i);
let x: int = opt ?? 0;
// Arrays.
let xs: int[] = [1, 2, 3];
assert b;
assert x == 42;
assert xs[0] == 1;
assert u == 0xFF;
assert s1 == "hello";
assert s2 == `raw \n no escapes`;
assert ch == 'A';
assert (f as int) == 3;
assert (d as int) == (d as int);
// `d` exists to demonstrate duration literal syntax. See `Duration Literals`.
return 0;
}
Postfix forms: calls, fields, indexing, casts, ?, ++/--#
struct Point { x: int, y: int }
fn main () -> int {
let xs: int[] = [10, 20, 30];
let a: int = xs[0];
let b: int = (a + 1) as int;
let c: int = Point{ x: 1, y: 2 }.x;
return b + c;
}
as and as raw#
as performs explicit numeric/shape casts and as raw performs raw bit casts
for scalar types (see Operators).
fn main () -> int {
let bits: u64 = (1.0 as f32) as raw u64;
let f: f32 = bits as raw f32;
return f as int;
}
Unary forms: !, ~, -, new, await, yield, mut, ++/--#
fn main () -> int {
let mut x: int = 0;
++x;
x++;
if !(x == 2) {
return 1;
}
return 0;
}
mut <expr> is permitted only where a mutable borrow is required (most
commonly, in call arguments and method receivers):
struct Pair { a: int, b: int }
fn bump (mut p: &Pair) -> void {
p.a += 1;
}
fn main () -> int {
let mut p: Pair = Pair{ a: 0, b: 0 };
bump(mut p);
return p.a;
}
Arithmetic, bitwise, comparisons, and boolean operators#
fn main () -> int {
let a: int = 1 + 2 * 3;
let b: int = (a << 1) | 1;
if (b >= 0) && (b != 0) {
return b;
}
return 0;
}
Optional operators: ?. and ??#
struct User { email: string }
fn main () -> int {
let user: User? = Some(User{ email: "a@b.c" });
let email: string = user?.email ?? "unknown";
if email == "a@b.c" {
return 0;
}
return 1;
}
Typed error propagation: postfix ? on calls#
fn main () -> int | OutOfBounds {
let x: u8 = get_at([1, 2, 3], 0)?;
return x as int;
}
8. Formal Silk (Verification) Syntax#
Formal Silk is Silk’s compile-time verification surface (Z3-backed). It uses directive tokens that attach to functions and loops:
- function contracts:
#require,#assure,#theory - loop contracts:
#invariant,#variant,#monovariant - formal Silk declarations:
#const - block-local proof obligations:
#assert - reusable proof bundles:
theory/#theory
See Formal Silk for the exact verifier model and
current restrictions.
Contracts on functions#
#require x >= 0;
#assure result == x + 1;
fn inc (x: int) -> int {
return x + 1;
}
Loop invariants, variants, and monovariants#
fn main () -> int {
let limit: int = 3;
#const original_limit = limit;
let mut i: int = 0;
#invariant i >= 0;
#invariant i <= original_limit;
#variant original_limit - i;
#monovariant i;
while i < limit {
i += 1;
}
return 0;
}
Theories (theory / #theory)#
export theory add_commutes (x: int, y: int) {
#assure (x + y) == (y + x);
}
#theory add_commutes(x, y);
fn add (x: int, y: int) -> int {
return x + y;
}
9. Next References#
If you want more detail on a specific construct, jump to:
- Syntax:
Formal Grammar Spec - Types:
Types,Generics (Monomorphized) - Operators:
Operators - Flow control:
Flow Control Overviewand the relevant chapters of this specification - Modules/imports/exports:
Packages, Imports, and Exports - Optionals:
Optional - Typed errors:
Typed Errors (error,panic, andT | ErrorType...) - Concurrency:
Concurrency - Formal verification:
Formal Silk
Formal Grammar Spec#
This document contains the formal grammar and lexical specification for Silk as used by the compiler implementation.
Lexical Structure (Overview)#
The lexer operates over:
- Whitespace and comments (trivia):
- spaces, tabs, newlines;
- line comments starting with
//and continuing to the end of the line; doc line comments start with///and follow the same lexical rules; - block comments starting with
/*and ending with the next*/(non‑nesting); doc block comments start with/**and also end at the next*/.
Both doc-comment forms are still trivia for the parser (they do not
affect the syntax tree), but tooling may preserve and parse their text for
documentation generation as specified in Silkdoc (Documentation Comments).
- Identifiers:
- sequences of Unicode letters, digits, and
_, with language-specific rules for leading characters. - Keywords:
- packages and imports:
package,module,import, file imports:from, - control flow:
if,else,loop,while,for,in,match,return,panic,break,continue,assert,await,yield, - attributes and conditional compilation:
attr, - declarations and mutability:
export,public,private,default,const,let,var,mut,fn,test,theory,type, - types and declarations:
struct,enum,error,interface,impl,as,raw,extends,pure,task,async, - memory and regions:
move,region,with,new, - other operators:
sizeof,alignof,offsetof,typename, - optionals and literals:
None/none,Some,true,false,null, - verification, compile-time embedding, and external declarations:
ext, Formal Silk directives#const,#require,#assure,#assert,#invariant,#variant,#monovariant,#theory, and the compile-time file embed expression#embed, - other keywords as listed in the spec.
Keywords are lexed as distinct tokens, but in name positions (for example
qualified-name segments like std::test, function/method names, and member
access like value.test) the parser accepts keywords anywhere an
identifier is expected.
The #require / #assure / #assert / #invariant / #variant /
#monovariant / #const / #theory / #embed forms are not comments;
they are first-class lexical tokens that participate in the normal grammar.
Formal Silk directives are handled by the verifier; #embed is an ordinary
expression that embeds a file at compile time. A directive token begins with
# followed by optional horizontal whitespace and the directive name (so
#require and # require are equivalent spellings). When these sequences
appear inside a // line comment or anywhere inside a /* ... */ block
comment, the surrounding comment is treated as trivia and the directive
spellings are ignored by the lexer.
- Literals:
- numeric, duration, boolean, character, string, regexp, aggregate (see
*literals-*.md). - String literals have two surface forms:
"..."(escape sequences),`...`(raw/no-escape).- Regular expression literals have a JavaScript-style surface form:
/pattern/flags(seeRegular Expression Literals).- Disambiguation is context-sensitive (like JavaScript): the parser only
recognizes a regex literal in expression-start positions where a
Primaryexpression may begin; otherwise/is parsed as the division operator. - Because
//starts a line comment, an empty regex literal//is not a valid token sequence; use/(?:)/for an explicit empty pattern. - Operators and delimiters:
- as listed in
Operators(assignment, arithmetic, logical, bitwise, ranges,?.,??,::,->,=>, etc.).
The lexer must implement longest-token rules for ambiguous prefixes (e.g. ... vs ..= vs ..).
Lexical Grammar for Whitespace and Comments#
For the purposes of the grammar, whitespace and comments are treated as trivia that may appear between any two tokens and are ignored by the parser:
Trivia ::= (Whitespace | LineComment | BlockComment)+Whitespace ::= ' ' | '\t' | '\r' | '\n'LineComment ::= '//' LineCommentChar* Newline?LineCommentChar ::= any character except '\n'Newline ::= '\n'BlockComment ::= '/*' BlockCommentChar* '*/'BlockCommentChar ::= any character except the end of input
Block comments are not nesting: a /* sequence inside an existing
BlockComment has no special meaning and is treated as part of the comment
text until the first following */. Doc-style comments such as /** ... */
are just syntactic sugar for BlockComment at the lexical level.
High-Level Grammar Skeleton#
At a high level, the language can be structured as:
-
Compilation unit:
-
Module ::= (PackageDecl | ModuleDecl)? ImportDecl* TopLevelDecl* -
Top-level declarations:
-
AttrAnnot ::= 'attr' '(' AttrItemListOpt ')' -
AttrItemListOpt ::= AttrItemList -
AttrItemList ::= AttrItem (',' AttrItem)* ','? -
AttrItem ::= NameToken ( AttrOp AttrValue )? -
AttrOp ::= '=' | '<' | '<=' | '>' | '>=' -
AttrValue ::= 'true' | 'false' | IntLiteral | StringLiteral | NameToken -
TopLevelDecl ::= AttrAnnot* (PackageDecl | ModuleDecl | ImportDecl | InlineModuleDecl | UsingDecl | ReExportDecl | DefaultExportDecl | ExportableDecl | TestDecl) -
ExportableDecl ::= FnDecl | LetDecl | TypeDecl | StructDecl | EnumDecl | InterfaceDecl | ImplDecl | ExtDecl | ... -
Packages and imports:
The surface syntax for packages, imports, and exports is specified in detail in
Packages, Imports, and Exports. The grammar in this file mirrors
the currently Supported forms:
-
PackageDecl ::= 'package' PackagePath ';' -
ModuleDecl ::= 'module' PackagePath ModuleAsOpt ';' -
InlineModuleDecl ::= ExportModifier 'module' NameToken '{' InlineModuleItem* '}' -
InlineModuleItem ::= InlineModuleDecl | UsingDecl | ExportableDecl -
NameToken ::= Identifier | Keyword -
PackagePath ::= NameToken ('::' NameToken)* -
ModuleAsOpt ::= ('as' QualifiedName TypeArgListOpt) -
ImportDecl ::= 'import' ImportSpec ';' -
ImportSpec ::= ImportPath | FileImportSpec | FileDefaultImportSpec | AmbientFileImportSpec -
ImportPath ::= ('::')? NameToken ('::' NameToken)* -
ImportFrom ::= StringLiteral | PackagePath -
AmbientFileImportSpec ::= StringLiteral -
FileImportSpec ::= '{' ImportBindingListOpt '}' 'from' ImportFrom -
FileDefaultImportSpec ::= NameToken 'from' ImportFrom -
ImportBindingListOpt ::= ImportBindingList -
ImportBindingList ::= ImportBinding (',' ImportBinding)* ','? -
ImportBinding ::= NameToken ('as' NameToken)? -
ReExportDecl ::= 'export' '{' ReExportBindingListOpt '}' ';' -
ReExportBindingListOpt ::= ReExportBindingList -
ReExportBindingList ::= ReExportBinding (',' ReExportBinding)* ','? -
ReExportBinding ::= NameToken ('as' NameToken)? -
DefaultExportDecl ::= 'export' 'default' NameToken ';' -
UsingDecl ::= 'using' Identifier '=' QualifiedName ';' | 'using' QualifiedName UsingAsOpt ';' -
UsingAsOpt ::= ('as' Identifier) -
ExportModifier ::= 'export'? -
FnExportModifier ::= ('export' AttrAnnot* ('default')?)?
For top-level declarations that can be exported, the ExportModifier
appears before the declaration keyword (currently fn, let, ext, type,
struct, enum, theory, error, and interface):
FnDecl ::= FnSpecs FnExportModifier FnModifierOpt 'fn' FnGenericParamListOpt FnNameOpt FnSignature FnBodyFnBody ::= Block | ';'FnModifierOpt ::= FnModifier*FnModifier ::= 'const' | 'pure' | 'task' | 'async'FnGenericParamListOpt ::= GenericParamListFnNameOpt ::= NameTokenFnSpecs ::= (FnPrecondition | FnPostcondition | FnContractTheory)*FnPrecondition ::= '#require' Expr ';'FnPostcondition ::= '#assure' Expr ';'FnContractTheory ::= '#theory' QualifiedName '(' ArgListOpt ')' ';'
Declaration attributes may also appear immediately after export /
public before the exported declaration keyword. This supports forms such
as export attr(abi=c) fn add_i64 (...) -> i64 { ... }; it is equivalent to
the prefix annotation form attr(abi=c) export fn add_i64 (...) -> i64 { ... }.
Formal Silk theories may be declared either at top level (exportable) or inline inside blocks (non-exportable):
-
TheoryDecl ::= TheorySpecs ExportModifier 'theory' Identifier '(' TheoryParamsOpt ')' '{' TheoryBodyItem* '}' -
TheorySpecs ::= (TheoryPrecondition | TheoryPostcondition)* -
TheoryPrecondition ::= '#require' Expr ';' -
TheoryPostcondition ::= '#assure' Expr ';' -
TheoryParamsOpt ::= TheoryParams -
TheoryParams ::= TheoryParam (',' TheoryParam)* ','? -
TheoryParam ::= Identifier ':' Type -
TheoryBodyItem ::= SpecConstStmt | SpecTheoryStmt | TheoryRequires | TheoryInvariant | TheoryEnsures | TheoryVariant -
TheoryRequires ::= '#require' Expr ';' -
TheoryInvariant ::= '#invariant' Expr ';' -
TheoryEnsures ::= '#assure' Expr ';' -
TheoryVariant ::= '#variant' Expr ';' -
FnSignature ::= '(' FnParamsOpt ')' ResultTypeOpt -
FnParamsOpt ::= FnParams -
FnParams ::= GenericParamSectionOpt ';' ParamListOpt | ParamList -
GenericParamSectionOpt ::= GenericParamSection -
GenericParamSection ::= GenericParam (',' GenericParam)* ','? -
ParamListOpt ::= ParamList -
ParamList ::= Param (',' Param)* -
Param ::= VarArgsOpt MutOpt Identifier TypeAnnotationOpt DefaultArgOpt -
VarArgsOpt ::= '...' -
DefaultArgOpt ::= ('=' Expr) -
ResultTypeOpt ::= ('->' TypeNoPipe TypedErrorTypesOpt)? -
TypedErrorTypesOpt ::= ('|' TypeNoPipe)+
Notes:
-
When a top-level
;appears inside the function parameter list, it splits compile-time parameters (type/const parameters) from run-time value parameters. -
The compile-time side uses
GenericParamsyntax (TandN: int) and does not permitmut. -
FnNameOptis currently permitted only for default-exported functions (export default fn (...) { ... }). For non-default functions, thefnidentifier is required. -
Default export statements (
export default Name;) always name an existing symbol; they do not permit anonymous exports. -
The run-time side uses ordinary
Paramsyntax (mut x: Torx: T, with the type annotation optional in the Supported forms). -
If there is no
;, the entire list is treated as run-time parameters. -
Default arguments are supported in function parameter lists:
-
any parameter may provide a default expression (
x: int = 1), -
defaulted parameters must be trailing (once a parameter has a default, all subsequent parameters must also have defaults) because call syntax is positional-only in the current language subset,
-
and in the initial subset, default expressions are restricted to a constant/literal expression subset (no name references), so they can be inlined at call sites during lowering.
-
a parameter declared as
T?with a default has two effective behaviors in the initial subset: -
if the default expression has type
T, the parameter has effective typeT(the argument may be omitted at call sites, but the callee sees a non‑optional value), -
if the default expression is
None/Null, the parameter remainsT?(the argument may be omitted, and callers may still pass optional values explicitly). -
Varargs parameters are supported:
-
a varargs parameter is declared by prefixing the final parameter with
...(for examplefn f(x: int, ...rest: int) { ... }), -
only one varargs parameter is permitted per function and it must be final,
-
in the Supported forms, varargs parameters:
-
must have an explicit type annotation,
-
may not be
mut, -
and may not have a default expression.
-
The
fromstring literal is a module specifier (either"..."or`...`): -
strings starting with
./or../are treated as file specifiers and resolve to a module by file path, -
strings starting with
std/are treated as std package specifiers and resolve through package lookup after/is normalized to::(a trailing.slkis stripped for compatibility), -
other strings are dependency-rooted POSIX module paths matched against
[dependencies]keys. Dot-separated keys match slash-separated path prefixes (my.dep.bmatches"my/dep/b"), the longest matching key selects the dependency root, and the remainder is resolved under that dependency's source-module directory, -
quoted strings must not contain
::; package namespace specifiers use the unquotedPackagePathbranch ofImportFrom. -
BindingDecl ::= LetIntroducer Identifier TypeAnnotationOpt InitializerOpt ';' -
LetIntroducer ::= 'const' | 'let' LetModifierOpt | 'var' LetModifierOpt -
LetModifierOpt ::= LetModifier* -
LetModifier ::= 'mut' | 'move' -
LetDecl ::= ExportModifier BindingDecl -
TypeAnnotationOpt ::= (':' Type)? -
InitializerOpt ::= ('=' Expr)?
Type aliases are supported:
TypeDecl ::= ExportModifier 'type' TypeDeclKindOpt Identifier '=' Type ';'TypeDeclKindOpt ::= TypeDeclKindTypeDeclKind ::= 'struct' | 'enum' | 'error' | 'interface' | 'fn' | 'pure' 'fn'
test declarations are supported as Zig-inspired top-level test blocks:
TestDecl ::= 'test' StringLiteral? Block
FFI declarations are also part of the language grammar:
ExtDecl ::= ExportModifier 'ext' NameToken ExtExternNameOpt '=' Type ';'ExtExternNameOpt ::= StringLiteral
When ExtExternNameOpt is present, it sets the linked external symbol name.
This allows Silk code to bind a local name that differs from the C/FFI symbol
name (for example to avoid name collisions in wrapper modules).
The current compiler implementation supports external declarations (ext) whose type
is either:
- a
FunctionType(external functions, callable from Silk), or - a supported scalar type (external variables, readable as values in Silk).
Silk currently requires an initializer for runtime bindings
(let/var) and for compile-time constant bindings (const). Uninitialized
declarations like let x: int; / const x: int; are parsed but rejected by
the checker (see Compiler Diagnostics, E2015).
Additionally, const initializers must be compile-time evaluable; otherwise
the compiler reports an error (see Compiler Diagnostics, E2041).
In practice, prefer:
let x: int = 0;for a zero value, orlet x: T? = None;for an “empty” optional.
Struct declarations are also accepted by the current parser:
StructDecl ::= StructSpecs ExportModifier 'struct' Identifier GenericParamListOpt StructExtendsOpt ';' | StructSpecs ExportModifier 'struct' Identifier GenericParamListOpt StructExtendsOpt '{' StructFieldListOpt '}'StructSpecs ::= StructRequirement*StructRequirement ::= '#require' Expr ';'StructExtendsOpt ::= ('extends' QualifiedName)GenericParamListOpt ::= GenericParamListGenericParamList ::= '(' GenericParamListInnerOpt ')'GenericParamListInnerOpt ::= GenericParamListInnerGenericParamListInner ::= GenericParam (',' GenericParam)* ','?GenericParam ::= Identifier (':' Type)? ('=' Type)?StructFieldListOpt ::= StructFieldListStructFieldList ::= StructField (',' StructField)* ','?StructField ::= Identifier ':' Type StructFieldDefaultOptStructFieldDefaultOpt ::= ('=' Expr)
Notes:
- Only
#requiredirectives may appear inStructSpecsin the current language subset (#assure/#theoryare rejected onstruct).
Enum declarations are part of the core language design. They are specified in
``enum Types.
EnumDecl ::= ExportModifier 'enum' Identifier GenericParamListOpt '{' EnumVariantListOpt '}'EnumVariantListOpt ::= EnumVariantListEnumVariantList ::= EnumVariant (',' EnumVariant)* ','?EnumVariant ::= Identifier EnumVariantPayloadOptEnumVariantPayloadOpt ::= ('(' TypeListOpt ')')
Interface and impl declarations are part of the language design and are parsed by the front-end as the syntax is implemented:
-
InterfaceDecl ::= ExportModifier 'interface' Identifier GenericParamListOpt InterfaceExtendsOpt '{' InterfaceItem* '}' -
InterfaceExtendsOpt ::= ('extends' QualifiedName) -
InterfaceMethodDecl ::= 'fn' NameToken FnSignature ';' -
InterfaceItem ::= InterfaceMethodDecl | UsingDecl -
ImplDecl ::= 'impl' QualifiedName GenericParamListOpt ImplAsOpt '{' ImplMemberDecl* '}' -
ImplAsOpt ::= ('as' QualifiedName TypeArgListOpt) -
ImplMemberDecl ::= FnDecl | UsingDecl(within animplblock,exportis reserved for static members with noselfreceiver; instance method visibility usespublic/private)
Note: const-parameter-style generics (N: int parameters and integer literal
type arguments like Foo(u8, 1024)) remain tracked work; the front-end parses
these surface forms but the compiler currently focuses on type parameters
and monomorphization for type arguments.
Exception: the nominal optional form Option(T) is recognized as sugar for
T? and is accepted in the Supported forms.
-
Types (Supported forms):
-
Type ::= UnionType -
UnionType ::= TypeNoPipe ('|' TypeNoPipe)* -
TypeNoPipe ::= BaseType TypeSuffix -
TypeSuffix ::= TypeSuffixElem TypeSuffix -
TypeSuffixElem ::= OptionalTypeSuffix | ArrayTypeSuffix -
OptionalTypeSuffix ::= '?' | '??' -
ArrayTypeSuffix ::= '[' ']' | '[' ArrayLen ']' -
ArrayLen ::= IntLiteral | Identifier -
BaseType ::= ReferenceType | AttrFunctionType | FunctionType | CFunctionType | '(' Type ')' | SimpleType -
ReferenceType ::= '&' BaseType -
FunctionType ::= 'fn' '(' TypeListOpt ')' ResultTypeOpt -
CFunctionType ::= 'c_fn' '(' TypeListOpt ')' ResultTypeOpt -
AttrFunctionType ::= AttrAnnot FunctionType(in the Supported forms, this is accepted as sugar for selecting ABI variants such asattr(abi=c) fn (...) -> ...) -
TypeListOpt ::= TypeList -
TypeList ::= Type (',' Type)* -
SimpleType ::= PrimitiveType | NamedType -
PrimitiveType ::= 'bool' | 'i8' | 'u8' | 'i16' | 'u16' | 'i32' | 'u32' | 'i64' | 'u64' | 'i128' | 'u128' | 'int' | 'f32' | 'f64' | 'f128' | 'char' | 'string' | 'void' | 'Instant' | 'Duration' -
NamedType ::= QualifiedName TypeArgListOpt -
TypeArgListOpt ::= ('(' TypeArgListInnerOpt ')') -
TypeArgListInnerOpt ::= TypeArgListInner -
TypeArgListInner ::= TypeArg (',' TypeArg)* ','? -
TypeArg ::= Type | IntLiteral
This means that type annotations such as string? or int?? are parsed
into nested optional types. For simple nominal optionals, the parser also
recognizes Option(T) and desugars it to the same internal representation
as T?. Borrowed reference types (&T) are now parsed in type annotations.
Array/slice types (T[], T[N]) are
parsed and type-checked in the Supported forms (with element-type
restrictions), and are part of the implemented expression grammar via array
literals ([a, b, c]) and indexing (xs[i]). Function types
(fn (T, ...) -> R) are parsed as part of the Type grammar, and function
values are supported in the current lowering subset (including capturing
closures as a restricted scalar-only subset; see Types and
Memory Model (Stack, Heap, and Moves)).
-
Statements (Supported forms):
-
Stmt ::= AttrAnnot* (LetStmt | LetElseStmt | SpecConstStmt | SpecAssertStmt | SpecTheoryDeclStmt | SpecTheoryStmt | AsyncBlockStmt | TaskBlockStmt | ExprStmt | IfStmt | LoopStmt | WhileStmt | ForStmt | MatchStmt | ReturnStmt | PanicStmt | AssertStmt | BreakStmt | ContinueStmt) -
LetStmt ::= LetIntroducer LetBinder TypeAnnotationOpt InitializerOpt ';' -
LetElseStmt ::= ('let' LetModifierOpt | 'var' LetModifierOpt) MatchExprPattern TypeAnnotationOpt '=' Expr 'else' Block ';' -
LetBinder ::= Identifier | '_' | LetTupleBinder | LetStructBinder | LetArrayBinder | LetEnumBinder -
LetTupleBinder ::= '(' LetTupleBinderItemsOpt ')' -
LetTupleBinderItemsOpt ::= LetTupleBinderItem (',' LetTupleBinderItem)* ','? -
LetTupleBinderItem ::= Identifier | '_' -
LetStructBinder ::= '{' LetStructBinderItemsOpt '}' -
LetStructBinderItemsOpt ::= LetStructBinderItem (',' LetStructBinderItem)* ','? -
LetStructBinderItem ::= Identifier ('as' (Identifier | '_'))? -
LetArrayBinder ::= '[' LetArrayBinderItemsOpt ']' -
LetArrayBinderItemsOpt ::= LetArrayBinderItem (',' LetArrayBinderItem)* ','? -
LetArrayBinderItem ::= Identifier | '_' -
LetEnumBinder ::= QualifiedName '(' LetEnumBinderItemsOpt ')' -
LetEnumBinderItemsOpt ::= LetEnumBinderItem (',' LetEnumBinderItem)* ','? -
LetEnumBinderItem ::= Identifier | '_' -
SpecConstStmt ::= '#const' Identifier '=' Expr ';' -
SpecAssertStmt ::= '#assert' Expr ';' -
SpecTheoryDeclStmt ::= '#theory' Identifier '(' TheoryParamsOpt ')' '{' TheoryBodyItem* '}' -
SpecTheoryStmt ::= '#theory' QualifiedName '(' ArgListOpt ')' ';' -
AsyncBlockStmt ::= 'async' Block -
TaskBlockStmt ::= 'task' Block -
MutOpt ::= 'mut'? -
ExprStmt ::= Expr ';' -
IfStmt ::= 'if' IfCondition Block ('else' (IfStmt | Block))? -
IfCondition ::= Expr | IfLetCondition -
IfLetCondition ::= 'let' LetModifierOpt MatchExprPattern '=' Expr LetChainOpt -
LetChainOpt ::= ('&&' LetChainClause)* -
LetChainClause ::= 'let' LetModifierOpt MatchExprPattern '=' Expr | Expr -
LoopStmt ::= LoopPrefixOpt 'loop' Block -
LoopPrefixOpt ::= 'async' | 'task' -
WhileStmt ::= WhileSpecs 'while' WhileCondition Block -
WhileCondition ::= Expr | WhileLetCondition -
WhileLetCondition ::= 'let' LetModifierOpt MatchExprPattern '=' Expr LetChainOpt -
WhileSpecs ::= (LoopInvariant | LoopVariant | LoopMonovariant)* -
LoopInvariant ::= '#invariant' Expr ';' -
LoopVariant ::= '#variant' Expr ';' -
LoopMonovariant ::= '#monovariant' Expr ';' -
ForStmt ::= ForInStmt | ForCStmt -
ForInStmt ::= 'for' ForHead 'in' ExprNoRange (RangeOp ExprNoRange)? Block -
ForCStmt ::= 'for' '(' ForInit ';' Expr ';' Expr ')' Block -
ForInit ::= LetIntroducer Identifier TypeAnnotationOpt '=' Expr -
ForHead ::= ForBinder | 'let' MutOpt MatchExprPattern -
ForBinder ::= Identifier | '_' -
RangeOp ::= '..' | '..=' -
BlockStmt ::= Block -
Block ::= '{' Stmt* '}' -
ReturnStmt ::= 'return' ExprOpt ';' -
ExprOpt ::= Expr? -
PanicStmt ::= 'panic' QualifiedName StructLiteralSuffixOpt ';' -
AssertStmt ::= 'assert' Expr ';' | 'assert' '(' Expr (',' Expr)? ')' ';' -
BreakStmt ::= 'break' ';' -
ContinueStmt ::= 'continue' ';'
LetModifierOpt accepts at most one mut and at most one move, in either
order. mut makes the introduced binders assignable. move consumes the
initializer/scrutinee for ownership-tracked values. var bindings are always
mutable; an explicit mut after var is accepted for symmetry, so
var move name = value;, var mut move name = value;, and
var move mut name = value; are the mutable-binding forms of
initialization-time ownership transfer. In pattern forms the consuming
modifier is written as let move Some(value) = maybe, if let move ...,
else if let move ..., chained && let move ..., or while let move ....
-
WithStmt ::= 'with' Identifier Block | 'with' WithBytes Block | 'with' WithBytes 'from' Identifier WithFromSliceOpt Block -
WithBytes ::= IntLiteral | '(' IntLiteral ')' -
WithFromSliceOpt ::= '[' IntLiteral '..' IntLiteralOpt ']' -
IntLiteralOpt ::= IntLiteral -
MatchStmt ::= 'match' Expr '{' MatchStmtArmListOpt '}' -
MatchStmtArmListOpt ::= MatchStmtArmList -
MatchStmtArmList ::= MatchStmtArm (',' MatchStmtArm)* ','? -
MatchStmtArm ::= MatchStmtPattern '=>' Block -
OptionalPattern ::= 'None' | 'Some' '(' (Identifier | '_') ')' -
MatchStmtPattern ::= OptionalPattern | '_' | Identifier | (Identifier | '_') ':' QualifiedName -
StructLiteralSuffixOpt ::= StructLiteralSuffix
Region declarations and with blocks are specified in Regions.
match is implemented in two separate forms:
matchas an expression (arms are expressions; seeMatchExprbelow),matchas a statement (arms are blocks), used for typed errors as specified inTyped Errors (error,panic, andT | ErrorType...).
In the Supported forms, the match statement form is restricted to a
call-expression scrutinee and the patterns listed above.
- Expressions (Supported forms):
Expressions follow a conventional precedence hierarchy, as implemented in
the implementation:
Expr ::= AssignExprNoRange ::= AssignNoRangeAssign ::= Range (AssignOp Assign)?AssignNoRange ::= Coalesce (AssignOp AssignNoRange)?Range ::= Coalesce (RangeOp CoalesceOpt)? | RangeOp CoalesceOptCoalesceOpt ::= CoalesceAssignOp ::= '=' | '+=' | '-=' | '*=' | '/='Coalesce ::= LogicalOr ('??' CoalesceRhs)?CoalesceRhs ::= Coalesce | CoalesceTerminalCoalesceTerminal ::= 'return' ExprOpt | 'break' | 'continue'LogicalOr ::= LogicalAnd ('||' LogicalAnd)*LogicalAnd ::= BitOr ('&&' BitOr)*BitOr ::= BitXor ('|' BitXor)*BitXor ::= BitAnd ('^' BitAnd)*BitAnd ::= Equality ('&' Equality)*Equality ::= TypeTest (('==' | '!=') TypeTest)*TypeTest ::= Relational ('is' Type)?Relational ::= Shift (('<' | '<=' | '>' | '>=') Shift)*Shift ::= AddSub (('<<' | '>>') AddSub)*AddSub ::= MulDiv (('+' | '-') MulDiv)*MulDiv ::= Unary (('*' | '/' | '%') Unary)*Unary ::= ('!' | '~' | '-' | 'mut' | 'move' | 'new' | 'await' | 'yield' | 'sizeof' | 'alignof' | 'offsetof' | 'typename' | '&' | '*' | '++' | '--') Unary | PostfixPostfix ::= Primary PostfixSuffix*PostfixSuffix ::= CallSuffix | FieldSuffix | OptionalFieldSuffix | StructLiteralSuffix | IndexSuffix | SliceSuffix | CastSuffix | TrySuffix | IncDecSuffixCallSuffix ::= '(' CallArgsOpt ')'FieldSuffix ::= '.' NameToken
CoalesceTerminal is deliberately narrow. return, break, and
continue remain statements in the general language grammar and are only
admitted here as the immediate right-hand side of ??.
-
OptionalFieldSuffix ::= '?.' NameToken -
StructLiteralSuffix ::= '{' StructInitListOpt '}' -
IndexSuffix ::= '[' ExprNoRange ']' -
SliceSuffix ::= '[' SliceBoundOpt '..' SliceBoundOpt ']' -
SliceBoundOpt ::= ExprNoRange -
CastSuffix ::= 'as' RawOpt Type CastSliceLenOpt -
CastSliceLenOpt ::= '(' Expr ')' -
RawOpt ::= 'raw' -
TrySuffix ::= '?' -
IncDecSuffix ::= '++' | '--' -
StructInitListOpt ::= StructInitList -
StructInitList ::= StructInit (',' StructInit)* ','? -
StructInit ::= NameToken (':' Expr)? -
CallArgsOpt ::= CallArgs -
CallArgs ::= GenericArgListOpt ';' ArgListOpt | ArgList -
GenericArgListOpt ::= GenericArgList -
GenericArgList ::= GenericArg (',' GenericArg)* ','? -
GenericArg ::= Type | IntLiteral -
ArgListOpt ::= ArgList -
ArgList ::= Expr (',' Expr)*
Note: the parser treats mut <expr>, new <expr>, await <expr>, await * <expr>,
yield <expr>, yield * <expr>, sizeof <expr>, alignof <expr>, offsetof(Type, field_path),
typename <expr>, and prefix ++<expr> / --<expr> as unary expressions.
Note: CastSliceLenOpt is permitted only when Type is a slice type (T[])
or string, and RawOpt. It is used by unsafe pointer view casts
like ptr as u8[](/silk/docs/len) (slice view) and ptr as string(len) (string view).
-
The type checker currently permits
mut <expr>only in call arguments (and method receivers) when the corresponding parameter is declaredmutand is: -
a borrowed reference type (
mut r: &T), or -
a slice type (
mut s: T[]). -
The
move <expr>unary form is used for explicit ownership transfer; in the Supported forms it is restricted tomove <name>where<name>is a local binding. -
Binding declarations may also use
let move/var moveto request the same ownership transfer at initialization. Themutmodifier may appear before or aftermove, includinglet mut move value = source;and the redundant-but-acceptedvar mut move value = source;. This is especially useful for destructuring:let move (a, b) = pair;,let move Some(value) = maybe;,let move Some(value) = maybe else { ... };,if let move Some(value) = maybe { ... },else if let move Some(value) = maybe { ... }, andwhile let move Some(value) = next() { ... }. -
The type checker currently permits
new <expr>only when it can determine a concrete reference result type of the form&Struct. In the current implementation this happens either: -
from an expected
&Structtype context (for examplelet x: &Frame = new Frame{ ... };or as a call argument whose parameter type is&Struct) -
from the operand itself when it names the struct type (for example
let x = new Frame{ ... };orlet x = new Frame(...);)newis supported only in function bodies (not in top-levelletinitializers). -
Primary ::= IntegerLiteral | DurationLiteral | FloatLiteral | StringLiteral | RegexpLiteral | CharLiteral | 'true' | 'false' | 'None' | 'null' | 'Some' '(' Expr ')' | ArrayLiteral | IfExpr | MatchExpr | FnExpr | AttrQueryExpr | AsmExpr | EmbedExpr | '(' Expr ')' | InferredStructLiteral | QualifiedName -
AttrQueryExpr ::= 'attr' '(' AttrItemListOpt ')' -
EmbedExpr ::= '#embed' '(' StringLiteral EmbedEncodingOpt ')' -
EmbedEncodingOpt ::= ',' EmbedEncoding -
EmbedEncoding ::= StringLiteral
#embed paths are resolved relative to the containing Silk source file.
Absolute paths are accepted directly. If the parser has no source-file path
because it is parsing an in-memory buffer, relative paths are resolved from
the current working directory. The compiler rejects empty, unreachable, or
unreadable paths. #embed("file") and #embed("file", "utf8") validate
UTF-8 input and produce a string; omitting the encoding is equivalent to
"utf8". #embed("file", "utf16") decodes UTF-16 into a UTF-8 string;
#embed("file", "u8"), #embed("file", "u16"), and
#embed("file", "u32") produce compiler-owned array values for
array-typed use sites such as
let bytes: u8[] = #embed("./data.bin", "u8");. Multi-byte integer
encodings read file bytes as little-endian element values; the compiler
carries the file bytes as embed metadata instead of expanding them into
source-level integer literal nodes. Without an expected array type, a raw
integer embed infers a dynamic slice of the requested element type. The
encoding literal value must be one of "utf8", "utf16", "u8", "u16",
or "u32".
RegexpLiteral ::= '/' RegexpBody '/' RegexpFlagsOptRegexpFlagsOpt ::= Identifier
Notes:
-
RegexpBodyis scanned by the parser (not the lexer): it is the byte span between the opening and closing/, where the closing delimiter is the first unescaped/that is not inside a character class ([...]). -
ArrayLiteral ::= '[' ExprListOpt ']' -
ExprListOpt ::= ExprList -
ExprList ::= Expr (',' Expr)* ','? -
QualifiedName ::= GlobalPrefixOpt NameToken ('::' NameToken)* -
GlobalPrefixOpt ::= '::' -
InferredStructLiteral ::= '{' StructInitListOpt '}' -
FnExpr ::= 'fn' '(' LambdaParamListOpt ')' ( '->' LambdaBody | Block ) -
LambdaParamListOpt ::= LambdaParamList -
LambdaParamList ::= LambdaParam (',' LambdaParam)* ','? -
LambdaParam ::= Identifier ':' Type -
LambdaBody ::= Type Block | Expr
Disambiguation rule (current parser):
fn (...) -> Type Blockis treated as the block-body form only when the return type is followed immediately by{(starting the block).- Otherwise,
fn (...) -> Expris treated as an expression-body function expression and its result type is inferred by the checker. fn (...) Blockis treated as the block-body form with an implicitvoidresult type (shorthand forfn (...) -> void Block).
Notes:
-
InferredStructLiteralhas the same token-level shape asStructLiteralSuffix(used forType{ ... }), but appears as aPrimaryexpression with no explicit type name. The type checker requires an expected struct type context to resolve the literal’s target type. -
To avoid ambiguity with statement blocks, the parser only recognizes
InferredStructLiteralwhen the{ ... }contents look like a struct initializer list (or are{}): either the first token after{is}or it is anIdentifierfollowed by:(explicit initializer) or followed by,/}(shorthand initializer). -
Non-adjacent explicit struct literals (
Type { ... }) are suppressed only at the immediate expression-before-block boundary used by block-bearing constructs. Delimited subexpressions restore ordinary expression parsing, so forms such as[Type { field: value }]andcall(Type { field: value })remainStructLiteralSuffixexpressions even when the surrounding expression is followed by a block. -
MatchExpr ::= 'match' Expr '{' MatchArmListOpt '}' -
MatchArmListOpt ::= MatchArmList -
MatchArmList ::= MatchArm (',' MatchArm)* ','? -
MatchArm ::= MatchExprPattern '=>' Expr -
MatchExprPattern ::= OptionalPattern | EnumVariantPattern | ResultPattern | TypedBinderPattern -
IfExpr ::= 'if' Expr IfExprBlock 'else' (IfExpr | IfExprBlock) -
IfExprBlock ::= '{' Expr '}' -
AsmExpr ::= 'asm' StringLiteral -
ResultPattern ::= ('Ok' | 'Err') '(' (Identifier | '_') ')' -
EnumVariantPattern ::= QualifiedName EnumVariantBinderListOpt -
EnumVariantBinderListOpt ::= ('(' EnumVariantBinderListInnerOpt ')') -
EnumVariantBinderListInnerOpt ::= EnumVariantBinderList -
EnumVariantBinderList ::= EnumVariantBinder (',' EnumVariantBinder)* ','? -
EnumVariantBinder ::= Identifier | '_' -
TypedBinderPattern ::= (Identifier | '_') ':' TypeNoPipe -
Declarations (Supported forms additions):
-
Decl ::= ... | ErrorDecl -
ErrorDecl ::= ExportModifier 'error' Identifier '{' StructFieldListOpt '}'
This matches the current AST and checker:
PrimaryconstructsLiteralorNameexpressions (or a parenthesizedExpr),- unary expressions are represented as
UnaryExprwith a token kind indicating the operator, - binary expressions are represented as
BinaryExprwith a token kind indicating the operator, - identifiers and qualified names are stored as
NameExprwith the full slice of source text (e.g.util::answer), - simple function calls such as
helper()orutil::helper(1, 2)are parsed as call expressions using thePostfix/CallSuffixproductions; the compiler supports calls to named functions, but the type checker and back-end currently restrict which value types can appear at call boundaries; see ``silkCLIfor the exact supported subset.
Further expression forms (ranges, etc.) are described in other
language concept documents and in Operators. The current
parser now accepts ?. optional field access (opt?.field) and the initial
match expression form as part of the implemented optional subset, but other
expression forms will be added here as they are implemented.
Role of This File#
This document serves as the reference for:
- lexer implementation (token categories and reserved words),
- parser implementation (production rules and precedence),
- pretty-printer or formatter behavior.
As the parser and lexer are implemented, this file must be updated with:
- the exact grammar that the compiler accepts (including any temporary limitations),
- clarifications or corrections discovered during implementation (recorded here so this file remains canonical),
- notes about desugaring and how surface constructs map into the internal AST,
- clear indication of which productions are implemented today vs. planned future work, so that downstream users can see both the full language design and the currently supported subset.
Types#
This document specifies the Silk type system used by the compiler front-end and type checker.
- Supported end-to-end: primitives, nominal
structtypes, optionals (T?),&Structreferences (in function parameter types and as local values produced bynew/ calls that return&Struct), and array/slice types (T[N],T[]) over element types that lower to a fixed scalar-slot sequence in the backend (including array literals, indexing reads, and iterableforloops). Indexed assignment targets (xs[i] = v) are supported for these element types; compound index ops require numeric scalar element types in the Supported forms. - Parameterized nominal types (monomorphized): generic
structandinterfacedeclarations with type parameters, plus applied types in type positions (Name(u8),Name(string)) for those declarations. - Reserved intrinsics: the compiler currently exposes reserved, stdlib
bring-up intrinsics for working with the
stringABI (__silk_string_ptr,__silk_string_len, and__silk_string_from_ptr_len). User code should generally prefer the language sugar: s as raw u64(extract the underlying byte pointer), andsizeof(s)(extract the string byte length asusize), andp as raw u64(extract the underlying address forp: &T), andptr as string(len)(construct astringview from a raw pointer plus an explicit byte length) over calling these helpers directly. The intrinsic names remain reserved and are not a stable user API.- Special-case: the nominal optional form
Option(T)is accepted and desugared toT?in type annotations (it is not a general generics feature). - Parsed but rejected by the current checker: const parameters and integer
literal type arguments (
Foo(N: int),Foo(u8, 1024)) (Compiler Diagnostics,E2016). - Removed builtin map type form:
map(K, V)(E2017; usestd::map::{HashMap, TreeMap}instead). - Defined in the native backend subset: 128-bit scalar primitives
(
i128,u128,f128). - In the current scalar-slot model (
Structs, Impl Blocks, and Memory Layout), these primitives lower to two 8-byte slots (lo: u64,hi: u64). f128uses the IEEE‑754 binary128 bit pattern stored across those slots.- In the current backend implementation,
f128arithmetic and someascasts lower to bundled runtime helper calls: - on
linux/x86_64, the helpers use__float128and rely on libgcc symbols (for example__addtf3), - on targets where
long doubleis an IEEE‑754 binary128 value (for examplelinux/aarch64), the helpers uselong double, - on other targets, the helper calls are currently stubbed and will trap if executed.
- Diagnostics rule:
u128/f128are language features, so any compiler rejection is an implementation gap in a specific compiler path, not a language-contract rejection. - Typed errors (
error,panic, andT | ErrorType...) are specified inTyped Errors (error,panic, andT | ErrorType...). The current compiler models typed error contracts as an effect on function return types and expressions. - Separately, type unions (
T1 | T2 | ...) are supported in type annotations as described inType Unions (T1 | T2 | ...). In function declaration return types, union returns must be parenthesized (-> (A | B)) because unparenthesized|after->is reserved for typed-error contracts.
Quick Reference#
The core categories are:
- Booleans:
bool - Examples:
true,false. - Notes: logical values.
- Integers (fixed width):
u8,i8,u16,i16,u32,i32,u64,i64,u128,i128 - Examples:
let n: i32 = 42;. - Notes: signed/unsigned bit-widths.
- Integer (platform):
int - Examples:
let n: int = 1;. - Notes: implementation/default integer.
- Pointer-width integers:
usize,size - Examples:
let n: usize = 1;,let n: size = -1;. - Notes: unsigned/signed integer types whose width matches the target
architecture pointer width (for example 64-bit on
linux/x86_64). - Compatibility:
isizeis accepted as an alias forsize. - Floats:
f32,f64,f128 - Examples:
let x: f64 = 3.14;. - Notes: IEEE‑754.
- Char:
char - Examples:
'A'. - Notes: Unicode scalar.
- String:
string - Examples:
"hello". - Notes: immutable text; multi‑line strings supported.
- Regexp:
regexp - Examples:
/hello/i. - Notes: compiled regular expression bytecode; a non-owning
{ ptr, len }view analogous tostring. Regex literals compile at compile time; runtime compilation and matching helpers live instd::regex(seestd::regex). - Region handle:
Region - Examples:
fn f (r: Region) -> int { with r { ... } }. - Notes: a first-class region allocation context handle; see
Regions. - Void / Unit:
void - Examples:
fn foo () -> void {}. - Notes: functions that return nothing.
- Time Types:
Instant,Duration - Examples:
let i: Instant = std::now();. - Notes: specialized
i64-based types for time measurement. - Optional:
T? - Examples:
User?,i32?. - Notes:
None/noneis the canonical empty value;nullmay also be used when an optional type is expected and coerces toNone. Usematch,?.,??. - None (value):
- Examples:
None/none(represented asNonein code samples). - Notes: the distinguished empty value; typed as
T?. Thenullliteral is a distinct literal that can coerce toNonewhen an optional type is expected (seeOptional). - Reference (borrow):
&T - Examples:
&User. - Notes: reference type; in the Supported forms,
&Structmay appear in parameter types and as local values when produced bynewor by calls that return&Struct. Mutability follows themutborrow contract and per-call aliasing rules described inMutability. - Arrays / Slices:
T[],T[N] - Examples:
i32[],byte[32],u8[1024 * 1024]. - Notes: dynamic slice vs fixed length (compile‑time
N). In the current parser subset,Nmay be: - an integer literal,
- a literal-only integer arithmetic expression using
+,-,*,/,%, and parentheses, - or a single const parameter name in generic type positions.
In the compiler, arrays/slices are supported only
when the element type lowers to a fixed scalar slot sequence in the current
scalar-slot memory model (for example primitive scalars,
string, and supportedregexp, supported non-opaque structs, and enums). SeeStructs, Impl Blocks, and Memory Layoutfor the current scalar-slot memory model. In the Supported forms, fixed array lengths are limited toN <= 4096. Indexingxs[i]traps wheniis out of bounds in the Supported forms. - Range:
range - Examples:
let r: range = 0..4;,let r2: range = (1..) + 2;. - Notes: an
int-indexed range value used for slicing and other index-based operations. Range literal bounds areintvalues; in range literals, bound expressions must have typeint(integer literals may be coerced toint). Range literal expressions support: start..end(end-exclusive) andstart..=end(end-inclusive),start..(open-ended),..end(implicit start0),..(full range). The inclusive (..=) form requires an explicit end bound. In index/slice contexts, open-ended ranges use the container’s length as the effective end bound. Arangevalue can be used for slicing via&xs[r]/mut &xs[r], enabling composed ranges (including inclusive ranges) to drive slicing.- Function Types:
fn(params) -> R - Examples:
fn(i32) -> i32. - Notes: function types are part of the type grammar and function-typed
values are supported as function values (including capturing closures) in
Silk currently.
Concurrency disciplines (
task/async) are implemented on function declarations (seeConcurrency); function types in type positions do not currently include discipline modifiers. - Capturing Closures:
- Notes: capturing closures are supported as function values with an environment; see “Function Types and Closures” below for Supported forms restrictions.
- Structs (nominal):
- Surface:
struct Name { ... }thenName(...). - Examples:
Point,Option(T). - Notes: user‑defined records; may be parameterized.
- Enums (sum types):
- Surface:
enum Name { ... }. - Notes: nominal tagged unions as described in the spec.
- Type unions:
- Surface:
T1 | T2 | ...(type annotations). - Notes: a tagged “one-of-these-types” type for a small, explicitly defined
subset; see
Type Unions (T1 | T2 | ...).
The compiler must represent these types faithfully in its internal type system and in the C99 ABI mappings, and it must follow the exact surface syntaxes indicated above when parsing and printing types.
Type Aliases (type)#
Silk supports compile-time-only type aliases via type declarations.
Syntax examples:
type Int32 = i32;
type struct Bar = Foo;
type fn IntAdder = fn(int, int) -> int;
type pure fn PureIntAdder = fn(int, int) -> int;
type ResultOf(T) = std::result::Result(T, string);
export type struct PublicBar = Foo;
Semantics :
- A type alias introduces a new name for an existing type; it does not introduce a distinct nominal type.
- The type checker MUST treat uses of the alias name as equivalent to the alias target type (the alias is transparent).
- A type alias may declare generic parameters using the same parameter-list
syntax as generic structs, enums, interfaces, impls, and functions. Applying
the alias in a type position substitutes the supplied type arguments into the
transparent target, for example
ResultOf(int)is equivalent tostd::result::Result(int, string). - Type aliases may be used anywhere a type is expected (parameter/result types,
local annotations, struct fields,
ascasts, etc.). - Cycles in type aliases are rejected (
E2058).
Kind tags:
- A
typedeclaration may optionally specify a kind tag, for exampletype struct Name = Foo;ortype pure fn Name = fn(...) -> ...;. - When present, the compiler MUST validate that the resolved alias target
matches the declared kind (
E2059).
Import/export:
typealiases may be exported (export type ...;) and imported as type names via named file imports (seePackages, Imports, and Exports).
Implicit Call-Argument Coercions#
In Silk currently, Silk supports a small, opt-in implicit coercion mechanism for function call arguments. This exists to keep the current standard library ergonomic while generics and richer overload systems are still evolving.
There are three related mechanisms:
- Coercion to a nominal
structvalueT(by-value parameters and varargs elements) via exported static ctor-like methods. - Coercion to a borrowed reference
&T(read-only&Tparameters) via aconstructormethod that initializes a compiler-generated stack temporary. - Contextual string compatibility for values implementing
std::interfaces::Serialize(string).
1) Coercion to T via exported static ctor-like methods#
Rule (informal):
- When a function call argument type does not match a parameter type, and the
parameter is a nominal
structtypeT, the compiler may rewrite the argument to a call of an exported, static ctor-like method onT. - This also applies to varargs elements (
...args: T).
Supported ctor-like method names (destination type opts in by defining these):
T.int(value: int) -> TT.i128(value: i128) -> TT.u64(value: u64) -> TT.u128(value: u128) -> TT.f64(value: f64) -> TT.f128(value: f128) -> TT.bool(value: bool) -> TT.char(value: char) -> TT.string(value: string) -> TT.regexp(value: regexp) -> TT.Region(value: Region) -> T
Selection (source type → constructor):
- Signed integer primitives (
i8/i16/i32/i64/int/size/isize/Instant/Duration) →int - Signed wide integer primitive (
i128) →i128 - Unsigned integer primitives (
u8/u16/u32/u64/usize) →u64 - Unsigned wide integer primitive (
u128) →u128 - Float primitives (
f32/f64) →f64 - Wide float primitive (
f128) →f128 bool→boolchar→charstring→stringregexp→regexpRegion→Region
Integer width:
- When the source argument is a fixed-width integer, the compiler inserts an implicit integer cast to match the ctor parameter type before calling the ctor.
- When the source argument is
f32and the selected ctor parameter type isf64, the compiler inserts an implicit float cast (f32 -> f64) before calling the ctor.
Example:
struct Counter {
value: int,
}
impl Counter {
public fn int (value: int) -> Counter {
return Counter{ value: value };
}
}
fn takes (c: Counter) -> int {
return c.value;
}
fn main () -> int {
let x: i32 = 7;
return takes(x); // coerces via `Counter.int`
}
Notes:
- Coercions are only attempted when the destination type provides the matching exported static ctor method.
- Today this primarily exists to support
std::fmt::Argand ergonomicstd::io::print/printlncalls without requiring explicitArg.*wrappers everywhere.
2) Coercion to &T via constructor (stack temporary)#
Rule (informal):
- When a call argument does not match a parameter type, and the parameter is a
read-only borrowed reference
&Tto a nominalstructtypeT, the compiler may create an implicit stack temporarytmp: T, initialize it by invokingtmp.constructor(...), and pass&tmpto the callee.
This is intentionally a stack construction mechanism:
- it does not allocate on the heap,
- it is compatible with
silk build --noheap, - and the temporary’s lifetime is the duration of the call (similar to how C++
binds temporaries to
const¶meters).
Eligibility requirements (Supported forms):
- The parameter must be
&T(notmut &T). - The destination type
Tmust provide a visibleconstructoroverload with: - receiver
mut self: &T, - exactly one non-receiver parameter (
value: U), - return type
void. - The call argument type must match the selected overload’s
Uparameter type. - If multiple overloads are viable for a given argument, the coercion is ambiguous and rejected (the call must be written with an explicit construction).
Example:
struct User {
name: string,
}
impl User {
fn constructor (mut self: &Self, name: string) -> void {
self.name = name;
}
}
fn print_user (user: &User) -> void {
std::io::println("user.name = {}", user.name);
}
fn main () -> int {
// Implicitly constructs a temporary `User` from a `string` for this call.
print_user("alice");
return 0;
}
Notes:
- This is an opt-in mechanism: types must provide the matching
constructor. - If the coercion is ambiguous (multiple viable conversion paths), the compiler rejects the call and requires an explicit construction.
- Because this coercion participates in ordinary call argument checking, it can
make additional overloads applicable (for example a copy-constructor
constructor(mut self: &Self, other: &Self)can accept astringargument by first constructing a temporarySelffromstring). Overload resolution prefers overloads that match without requiring such coercions.
3) Contextual std::strings::String compatibility for plain string#
Rule (informal):
- When an expression is checked in a context that explicitly expects
string, and the expression’s type isstd::strings::String, the compiler may accept that expression as satisfying the expectedstring.
Supported contexts include:
let x: string = exprx = exprwhenx: string- ordinary function arguments where the parameter type is
string
Notes:
- This is contextual; it does not change the expression’s nominal type outside
a
string-expecting site. - This rule is currently specialized to
std::strings::String. String.serialize()/String.as_string()still describe the borrowed view that is being exposed, so explicit.as_string()is no longer required solely to satisfy these ordinary borrowedstringcontexts.- Returning a borrowed
stringview derived from a local ownedStringremains a lifetime-sensitive case and is rejected by the compiler; use.as_string()explicitly when you need to spell that borrow locally, or return an ownedstd::strings::Stringinstead.
Explicit Casts (as)#
Silk supports explicit casts using the postfix as operator:
let x: f64 = 3.14;
let n: int = x as int;
This operator is intended for explicit, potentially lossy primitive numeric
conversions. In the Supported forms it also supports explicit conversions via
std::interfaces::Serialize(T) by lowering expr as T to expr.serialize()
when the operand type provides a matching serialize method.
For structured conversions, it also supports std::interfaces::Deserialize(S)
by lowering expr as T to T.deserialize(expr) when the target type provides
a matching static deserialize method.
The supported conversions and semantics for Silk currently are
specified in Operators (“Casts (as)”).
Notes:
asis explicit. It does not introduce new implicit coercions.- For call-argument ergonomics, see the separate opt-in coercion mechanism described above (“Implicit Call-Argument Coercions”).
Nominal & Parameterized Types#
Nominal types are introduced by declarations (e.g. struct, enum, interface) and are equal only to themselves. Parameterized types are constructed by applying a type constructor to type arguments.
The compiler must:
- Treat nominal types as distinct even if their field layout is identical.
- In the full language design, support parameterized types in all contexts
where the spec permits them. In Silk currently, type-parameter
generics are supported for nominal declarations (
struct/interface) and for applied types in type positions (Name(u8)). - Const parameters and integer-literal type arguments (
Name(N: int),Name(u8, 1024)) remain tracked work and are rejected (E2016). - The
Option(T)optional sugar described above remains supported for the Supported forms.
Parameterized type syntax#
The initial surface syntax for applying type arguments is:
TypeApply ::= TypeName '(' TypeArgListOpt ')'TypeName ::= Identifier ('::' Identifier)*TypeArgListOpt ::= TypeArgListTypeArgList ::= TypeArg (',' TypeArg)* ','?TypeArg ::= Type | IntLiteral
Examples:
Foo(int, 1024)
Mutex(Account)
Result(int, string)
Notes:
- A
TypeArgmay be a type (e.g.int,&Foo,Option(string)) or a compile-time integer literal for const-parameter-style arguments. - The full semantics (declaring generic parameters, constraint checking, and monomorphization) are still evolving; the key requirement is that the compiler preserves the argument structure in the AST/type system so later stages can enforce and lower it.
Reference Types#
Reference types describe access to values rather than owning them (e.g. references, borrowed views, or other non-owning handles as specified in this document and related language docs).
Key requirements:
- Distinguish owning vs. non-owning types in the type system.
- Preserve aliasing and lifetime constraints so that regions, buffers, and FFI safety rules can be enforced.
Current implementation notes:
&Structis supported in function parameter types and as local values when produced by heap allocation (new) or by calls that return&Struct.&TwhereTis a single-slot scalar primitive (for example&bool,&int,&u64,&f64) is supported in function parameter types and as local values when produced by the borrow operator&expr.- Borrowed
&Structreferences may also be created from stack values: - via the borrow operator
&expron borrowable lvalues, and - via implicit borrow coercions in contexts that expect
&T(for examplelet r: &Pair = pair;). These borrows are checked with conservative lexical lifetime rules (they may not escape the scope of the borrowed stack storage). - Mutable reference parameters use the two-part
mutcontract and conservative per-call aliasing rules; seeMutability.
Function Types and Closures#
Silk currently:
- Parses function types in type positions (most notably for
extdeclarations). - Implements function expressions (lambdas) in expression positions:
- expression body form:
fn (x: int, y: int) -> x + y - block body form:
fn (x: int, y: int) -> int { return x + y; } - block body
voidshorthand:fn (x: int, y: int) { ... }(implicitvoid) - Function expressions may declare
&Tparameters only whenTis a single-slot scalar primitive (for example&int/&bool). - Function expression bodies are checked under the
purerules in the current subset. Non-capturing function expressions are inferred aspurefunction types and are permitted inpurecode: - they may call only
purefunctions, - they may not mutate (
let mut/var, assignment), - they may not allocate (
new), - they may not use typed error contracts or
panic. - The checker also supports purity inference (“auto-pure”) for ordinary function
declarations and
implmethods. When inferred, these functions/methods are treated aspurefor call checking and are callable frompurecode. - Capturing closures are implemented as a subset of function values:
- a function expression body may reference immutable locals/parameters from an enclosing scope; those values are captured by value into a heap environment,
- in the Supported forms, only scalar captures are supported (
int, fixed width ints,bool,char,f32,f64,Instant,Duration), - forming captures inside
purecode is rejected (capture environments allocate), but closure values are still checked under thepurerules and remain callable frompurecode once constructed. - Function values are supported end-to-end for this subset (non-capturing and capturing):
- they may be passed as arguments, returned from functions, stored in structs/arrays, and called indirectly.
- the runtime representation is a pair
{ func_ptr, env_ptr }as specified inMemory Model (Stack, Heap, and Moves). - Discipline modifiers for function declarations (
pure/task/async) are implemented. Function types in type positions do not currently include discipline modifiers.
C Function Pointers (c_fn)#
Silk distinguishes between:
fn (...) -> Rfunction values (which may carry a closure environment), andc_fn (...) -> RC callback pointers (code pointers only; no environment).
c_fn is intended for FFI: it is a safe, storable representation for passing
callbacks to foreign code.
Rules (Supported forms):
- A
c_fnvalue may be formed only from: - a top-level function name, or
- a non-capturing
fn (...) -> ...expression. - Capturing closures are rejected when a
c_fnis required. c_fnvalues are ABI-lowered as a singleu64code pointer.
Literals Overview#
This document provides a high-level overview of literals in Silk, with details split into dedicated documents for each category.
For first-time readers, a good path is:
Types(primitive types likeint,f64,bool,string),- this overview (what literal categories exist),
- the specific literal docs below (syntax, semantics, and current implementation notes).
Returning readers typically want the notes near the top of each literal concept doc, plus the “Tests” links for runnable examples.
Literal Categories#
Silk includes the following literal categories:
- Numeric literals
- Duration literals
- Boolean literals
- Character literals
- String literals
- Regular expression literals
- Aggregate literals (arrays, structs)
Each literal form has well-defined syntax and type inference rules that the compiler must implement.
See:
Numeric LiteralsDuration LiteralsBoolean LiteralsCharacter LiteralsString LiteralsRegular Expression LiteralsAggregate Literals
Numeric Literals#
Numeric literals produce integer (int, u8, i128, …) and floating-point
(f32, f64, f128) values.
In Silk, the sign is an operator: -1 is a unary - expression applied to the
integer literal token 1, not a distinct “negative literal” token.
Notes#
What works end-to-end today (lexer → parser → checker → lowering → codegen):
- Decimal integer literals:
0,42,255. - Digit separators (
_) within numeric literal digits:1_000_000,0b0000_1111_0000,0xFFFF_FFFF,1_000.25,1_000ms. - Integer base prefixes:
- binary:
0b1010/0B1010, - octal:
0o17/0O17, - hex:
0xFF/0Xff, - legacy octal:
017(value 15). - Decimal float literals with a fractional part:
0.0,1.5,10.25. - Unary
-over numeric literals:-1,-1.5. - Contextual typing:
- integer literals default to
int, but adopt an expected integer type (u8,i128, …) or time type (Duration,Instant) when a context provides one, - float literals default to
f64, but adoptf32/f64/f128from context. - Duration literal tokens of the form
<number><unit>(no whitespace) such as500msand1.5s(specified inDuration Literals). - Lowering note (current IR backend subset): unannotated local
letbindings participate only in the integer subset. Prefer explicit type annotations forbooland float locals when you intend to build an executable/library.
Not implemented yet:
- Exponent notation (
1e6,1.0e-3). - Numeric type suffixes (
42u8,1.5f32). - Numeric suffixes for 128-bit types (
1u128,1.0f128) are not implemented; use annotations orascasts.
Quick Reference#
fn main () -> int {
let a = 42; // int
let b: u8 = 42; // u8 (typed by context)
let x: f64 = 1.5; // f64
let y: f32 = 1.5; // f32 (typed by context)
let d: Duration = 5ms;
let t0: Instant = 0;
return 0;
}
Surface Syntax#
Numeric literal tokens are recognized as:
- Integer literal:
- decimal digits (
[0-9]+), - binary prefix:
0b/0Bfollowed by binary digits ([01]+), - octal prefix:
0o/0Ofollowed by octal digits ([0-7]+), - hex prefix:
0x/0Xfollowed by hex digits ([0-9a-fA-F]+), - legacy octal:
0[0-7]+(for example017). - Float literal: digits,
., digits ([0-9]+ '.' [0-9]+).
Notes:
- Integer and float literals may use
_as a digit separator. Separators are ignored when parsing the numeric value, but must appear between digits. For example: - valid:
1_000,0xFFFF_FFFF,0b0000_1111_0000,1_000.2_5, - invalid:
_1,1_,1__0,0x_FF. - A float literal must have digits on both sides of the
.: 1.0is a float literal.1.is not a float literal in the current lexer..5is not a float literal; write0.5.- Numeric literals must start with a digit in the current lexer.
- The
-sign is not part of the literal token: -1parses as unary-applied to the integer literal1.-1.5parses as unary-applied to the float literal1.5.- A numeric token immediately followed by a duration unit suffix (e.g.
1s,500ms,1.5s) is a singleDurationliteral token, not a number token followed by an identifier. - A numeric literal token may not be immediately followed by an identifier start character or an ASCII digit (unless the identifier characters are part of a duration unit suffix). For example:
3inis a lexical error (write3 inor3 * inas intended),0b102is a lexical error (invalid binary digit),08is a lexical error in Silk because multi-digit literals starting with0are legacy octal (use0o10for octal 8, or write8for decimal).
Type Rules#
See Types for the primitive type names used below.
Integer literals#
- Without an expected type, an integer literal has type
int. - When a context provides an expected type that is:
- an integer type (
u8,i64,int, …), or - a time type (
Duration,Instant), then the integer literal adopts that expected type.
Example: parameter context and “adopt the expected type”
fn id_u8 (x: u8) -> u8 {
return x;
}
fn main () -> int {
// `255` is contextually typed as `u8` because `id_u8` expects `u8`.
let v: u8 = id_u8(255);
if v != 255 {
return 1;
}
return 0;
}
Example: time types share an i64-based representation in the Supported forms
(Duration` & `Instant), so integer literals can be used as
Instant/Duration values via context:
fn main () -> int {
let t0: Instant = 0;
let d: Duration = 1s;
let t1: Instant = t0 + d;
let diff: Duration = t1 - t0;
if diff != d {
return 1;
}
return 0;
}
Float literals#
- Without an expected type, a float literal has type
f64. - When a context provides an expected float type (
f32orf64), the literal adopts that expected type.
fn id_f32 (x: f32) -> f32 {
return x;
}
fn main () -> int {
// `1.5` is contextually typed as `f32` because `id_f32` expects `f32`.
let v: f32 = id_f32(1.5);
if v != 1.5 {
return 1;
}
return 0;
}
Common Pitfalls#
- Trying to use suffixes:
42u8/1.5f32are not supported. Use type annotations (let x: u8 = 42;) or casts (42 as u8). - Using exponent notation:
1e6is not supported yet. - Writing incomplete floats: write
1.0(not1.) and0.5(not.5). - Mixing integers and floats implicitly: use
ascasts (Operators) to convert explicitly when you need to combine integer and float values.
Related Documents#
Duration Literals(duration literals like5ms,1.5s)Duration` & `Instant(time types and operators)Operators(unary-, arithmetic, andascasts)Types(primitive numeric type names)
String Literals#
String literals represent string values: immutable, length-tracked sequences of
bytes that are typically interpreted as UTF-8 text.
Use strings for:
- filenames and paths,
- user-visible messages,
- structured formats (JSON, CSV, etc),
- and general “text” data.
If you need a single Unicode scalar value, use char literals
(Character Literals).
Notes#
What works end-to-end today (lexer → parser → checker → lowering → codegen):
- Double-quote delimited string literals:
"hello". - Backtick-delimited raw string literals:
`hello`. - Multi-line string literals: newlines may appear inside
"..."and become part of the string value. - Multi-line raw string literals: newlines may appear inside
`...`and become part of the string value. - Escape sequences:
\\,\",\'\n,\r,\t,\0\xNN(exactly two hex digits, inserts a single byte)\u{...}(1–6 hex digits, inserts UTF-8 bytes for a Unicode scalar)- Line ending normalization:
- embedded
\r\nand\rin the literal source are normalized to\n, \rescapes are normalized to\n.- Equality and ordering comparisons (
==,!=,<,<=,>,>=) overstringvalues in the Supported forms. - Compile-time file embedding with
#embed("path"), text encodings#embed("path", "utf8")/#embed("path", "utf16"), and integer array encodings#embed("path", "u8")/#embed("path", "u16")/#embed("path", "u32").
Not implemented yet (or not specified as stable):
- A stable, fully-specified string ABI story across the C boundary beyond what
is documented in
C99 ABI andlibsilk.a``.
Semantics#
- The value of a string literal is a sequence of bytes.
- By convention and by intent,
stringvalues represent UTF-8 text, but some escape forms (notably\xNN) can construct byte sequences that are not valid UTF-8. Avoid this unless you are intentionally working with raw bytes. - String literals are immutable.
- Unless otherwise specified for a particular FFI surface, string literals do
not implicitly include a trailing
\0byte; length is carried explicitly.
Single-Line Strings#
Single-line string literals:
- Use standard quote-delimited syntax.
- Support escape sequences as described below.
Raw Strings (Backtick)#
Raw string literals are delimited by backticks:
`...`- They may include newlines directly.
- They do not process escape sequences:
\nis two bytes ('\'and'n'). - They still normalize embedded
\r\n/\rin the source text to\n.
Style guidance:
- Prefer raw multiline backtick strings for static multiline text that does not
need escape processing. This keeps the source text visually aligned with the
produced bytes and avoids dense
\nescape runs. - Use quoted multiline strings when the literal also needs escape processing.
- Use
\nescapes for single newline bytes, compact generated fragments, escape-focused tests, or formats that require a literal backslash followed byn.
Escape Sequences#
Double-quoted string literals support the same escape spellings as character literals:
\\(backslash)\"(double quote)\'(single quote)\n(newline, U+000A)\r(carriage return, U+000D)\t(tab, U+0009)\0(NUL byte, U+0000)\xNN(byte escape, two hex digits)\u{...}(Unicode scalar value escape, 1–6 hex digits)
When decoding \u{...} escapes, the compiler must reject non-scalar Unicode
values (for example surrogate code points).
Multi-Line Strings#
Multi-line strings:
- Allow embedding newlines directly in the literal.
- Must be represented and encoded identically to
stringvalues produced at runtime. - Should be written as raw multiline backtick strings when the text is static and does not need escape processing.
Line Ending Normalization#
When decoding string literals, the compiler must normalize:
\r\nto\n\rto\n
This applies both to embedded newlines in multi-line literals and to escaped
forms such as \r.
Note: a sequence of two escapes like "\r\n" is still two escapes. In Silk,
\r escapes become \n, so "\r\n" produces two line
feed bytes ("\n\n").
Compile-Time File Embedding#
#embed(filepath[, encoding]) reads a file during parsing and embeds its
contents into the compiled program.
#embed("relative/path.txt")resolves the path relative to the containing.slksource file, validates the file as UTF-8, and produces astring. This is equivalent to#embed("relative/path.txt", "utf8").#embed("path", "utf8")validates the file as UTF-8 and produces astringcontaining those bytes.#embed("path", "utf16")decodes UTF-16LE/UTF-16BE input, using a BOM when present, and produces a UTF-8string.#embed("path", "u8"),#embed("path", "u16"), and#embed("path", "u32")produce compiler-owned array values for array-typed bindings such aslet bytes: u8[] = #embed("./data.bin", "u8");. Multi-byte integer encodings read little-endian element values; the frontend carries file bytes as embed metadata instead of expanding the payload into source-level integer literal nodes.- If no expected array type is present, a raw integer embed infers a dynamic
slice (
u8[],u16[], oru32[]) backed by compiler-owned read-only data.
The compiler rejects empty paths, unreadable paths, invalid UTF-8 text embeds,
malformed UTF-16 text embeds, and integer encodings whose file byte length is
not divisible by the requested element width. Embedded strings do not receive
an implicit NUL terminator; sizeof(value) reports the embedded byte length.
Use the u8 encoding for raw byte payloads that are not valid UTF-8 text.
Examples#
Basic string literal#
fn main () -> int {
let s: string = "hello";
if s == "hello" {
return 0;
}
return 1;
}
Escapes and byte escapes#
fn main () -> int {
// Quote and backslash escapes.
if "\"" != "\x22" { return 1; }
if "\\" != "\u{005C}" { return 2; }
// Control escapes.
if "\t" != "\x09" { return 3; }
if "\n" != "\x0A" { return 4; }
if "\r" != "\n" { return 5; } // `\r` is normalized to `\n` in the current subset.
// NUL bytes are permitted; strings are length-tracked (not NUL-terminated).
if "\0" != "\x00" { return 6; }
// Unicode escapes insert UTF-8 bytes for that scalar.
if "é" != "\u{00E9}" { return 7; }
return 0;
}
Raw multiline string literal (preferred for static multiline text)#
fn main () -> int {
let multi: string = `a
b`;
// Equivalent to using a `\n` escape.
if multi != "a\nb" {
return 1;
}
return 0;
}
Quoted multi-line string literal (when escapes are needed)#
fn main () -> int {
let multi: string = "a
b";
if multi != "a\nb" {
return 1;
}
// Backslashes are literal bytes in raw strings, so escape-focused code may
// still need quoted strings for comparison.
if `a\nb` != "a\\nb" { return 2; }
return 0;
}
Embedded adjacent file#
let shader_source: string = #embed("shader.metal");
fn main () -> int {
if sizeof(shader_source) > 0 {
return 0;
}
return 1;
}
Common Pitfalls#
- Expecting NUL termination:
"hi"does not include an implicit\0. Use\0explicitly when you need it, and prefer APIs that are length-aware. - Using
\xNNfor non-ASCII characters:\xNNinserts a raw byte, not a Unicode scalar. Use\u{...}for text. - Assuming multi-line indentation stripping: multi-line strings include all bytes between the quotes, including indentation spaces.
Related Documents#
Types(primitivestringandchar)Character Literals(shared escape spellings)C99 ABI andlibsilk.a`` (C ABI string representation)
Regular Expression Literals#
Regular expression literals represent regexp values: compiled regular
expression bytecode that can be used by std::regex helpers.
The regex literal syntax is modeled after JavaScript:
/pattern/flags
Notes#
What is intended to work end-to-end (lexer → parser → checker → lowering → codegen):
- Regex literal parsing in expression-start positions:
/pattern/flags. - Compile-time compilation during type checking:
- invalid patterns are rejected during type checking,
- invalid or duplicate flags are rejected during type checking,
- overly deep regexp nesting is rejected within a conservative compile stack budget instead of recursing without a bound in the embedder,
- successful literals embed compiled bytecode into the output.
- The literal’s type is
regexp.
Syntax#
Delimiters and scanning#
Regex literals are scanned by the parser (not the lexer):
- the opening delimiter is a single
/, - the closing delimiter is the first unescaped
/that is not inside a character class ([...]), - after the closing delimiter, the parser consumes ASCII letters as flags.
The parser does not interpret regex escapes: backslash sequences are preserved as bytes for the regex engine.
Empty patterns and //#
Because // introduces a line comment, an empty regex literal // is not a
valid token sequence. Use an explicit empty pattern, for example /(?:)/.
Flags#
The supported flag set is intentionally small in the Supported forms:
g— global (recorded; does not changestd::regex::matchessemantics)i— ignore casem— multilines— dotAlly— stickyd— indices (recorded; not surfaced bystd::regexhelpers)
The type checker rejects:
- unknown flags,
- duplicate flags (for example
/a/ii).
Semantics#
- A regex literal’s value is a non-owning
{ ptr, len }view (regexp) into compiled bytecode embedded in read-only data. - The bytecode format is owned by the runtime regex engine;
regexpvalues are opaque and must be consumed viastd::regex. - A literal
regexpis borrowed data, not a heap-owned regex object:std::regex::RegExp.compile(...)is the owning/runtime-allocated path, while wrapping a literal instd::regex::RegExpdoes not transfer ownership. - When a foreign ABI caller supplies a malformed
regexpbuffer tostd::regex, the runtime rejects it as invalid input before entering the bundled engine. - If a literal/borrowed/foreign
regexpis later passed to the low-level regex free path, the runtime ignores it safely instead of freeing arbitrary pointers. - In the Supported forms, matching is defined over the raw bytes of the input
string, and match indices are byte offsets. - Literal compilation uses the same conservative regexp compile stack budget as
runtime
std::regex::RegExp.compile(...); excessively deep patterns are rejected asE2104(invalid regexp literal).
Examples#
Basic test#
import std::regex;
fn main () -> int {
if std::regex::matches(/hello/, "hello world") {
return 0;
}
return 1;
}
Related Documents#
Types(regexp)Formal Grammar Spec(regexp literal grammar)std::regex(runtime regex API)
Character Literals#
Character literals represent Unicode scalar values (code points) and have type
char (Types).
Use char for:
- single-character markers and delimiters (e.g.
',',':'), - working with code points when interfacing with parsing/lexing logic,
- representing control characters (
'\n','\t','\0').
If you need multiple characters, use string literals (String Literals).
Notes#
What works end-to-end today (lexer → parser → checker → lowering → codegen):
- UTF-8 character literals like
'x','é', and'😀'(exactly one Unicode scalar, encoded in UTF-8 in the source file). - Escape sequences:
\n,\r,\t,\0\\,\',\"\xNN(exactly two hex digits)\u{...}(1–6 hex digits)- Equality and inequality comparisons (
==,!=) overcharvalues. charvalues are lowered as au32scalar in the current IR backend subset.
Not implemented yet (or not specified as stable):
- A dedicated diagnostic for invalid character literal spellings (most invalid forms currently surface as generic “unsupported expression” errors in the Supported forms).
Surface Syntax#
Character literals are delimited by single quotes:
let a: char = 'x';
Rules:
- The contents must represent exactly one Unicode scalar value.
- A character literal must not span multiple lines.
- The source file is interpreted as UTF-8.
Escapes#
Inside a character literal, \ introduces an escape sequence.
Supported escapes:
\n— U+000A (line feed)\r— U+000D (carriage return)\t— U+0009 (tab)\0— U+0000 (NUL)\\— backslash\'— single quote\"— double quote\xNN— a code point given as exactly two hex digits\u{...}— a code point given as 1–6 hex digits
Unicode rules:
- The decoded code point must be a Unicode scalar value:
- range
0x0000..=0x10FFFF, excluding the surrogate range0xD800..=0xDFFF. - For
\u{...}, values outside that range are rejected.
Semantics#
Evaluating a character literal produces a char value whose numeric value is
the decoded Unicode code point.
In the backend, that code point is carried as a u32 scalar.
This is an implementation detail; the language-level rule is “a char is a
Unicode scalar value”.
Examples#
ASCII and punctuation#
fn main () -> int {
let comma: char = ',';
if comma == ',' {
return 0;
}
return 1;
}
Unicode: literal UTF-8 vs \u{...}#
fn main () -> int {
let a: char = 'é';
let b: char = '\u{00E9}';
if a == b {
return 0;
}
return 1;
}
Escape sequences#
fn main () -> int {
if '\n' != '\x0A' { return 1; }
if '\r' != '\x0D' { return 2; }
if '\t' != '\x09' { return 3; }
if '\0' != '\x00' { return 4; }
if '\\' != '\u{005C}' { return 5; }
if '\'' != '\x27' { return 6; }
if '\"' != '"' { return 7; }
return 0;
}
Common Pitfalls#
- Using double quotes:
"x"is astring, not achar. Use'x'. - Writing more than one character:
'ab'is invalid; use"ab". - Source encoding surprises: prefer
\u{...}for non-ASCII characters when you want the source spelling to be stable across editors/fonts. - Confusing
\xNNbetweencharandstring: - for
char,\xNNdenotes a code point value, - for
string,\xNNdenotes a raw byte (String Literals).
Related Documents#
Types(primitivecharandstring)String Literals(string literals and escape sequences)Operators(ascasts for int-like types, includingchar)
Aggregate Literals#
Aggregate literals cover arrays and structs.
Array Literals#
Array literals construct fixed-size array values from a list of elements.
Surface Syntax#
An array literal is written using square brackets:
let xs = [1, 2, 3];
let ys = [1, 2, 3,]; // trailing comma allowed
Empty array literals are permitted only when an expected array type is available from context (so the compiler knows the element type and, for fixed-size arrays, the required length):
let empty: i32[0] = [];
let empty_slice: i32[] = [];
Typing#
- A non-empty array literal has type
T[N]whereNis the number of elements andTis inferred from the elements (or from an expected type when present). - When an expected type is present and it is
T[N], the literal must contain exactlyNelements. - When an expected type is present and it is
T[], the literal’s elements are type-checked againstTand the resulting value has typeT[]. - In Silk currently, this slice form is lowered as a non-owning view over a compiler-generated backing array.
- Lifetime rules are not yet enforced for such slices; do not allow a slice derived from a stack-backed array literal to outlive the scope where it was created.
Compiler requirements:
- Infer element type when possible, or require explicit annotation where ambiguity exists.
- Validate that all elements are convertible to the target element type.
- Enforce current-subset restrictions on which element types are supported for
array lowering/codegen (see
TypesandStructs, Impl Blocks, and Memory Layout).
Struct Literals#
Struct literals construct values of struct types by specifying field names and values.
Surface Syntax#
A struct literal may be written in two forms:
- An explicit struct literal begins with a struct type name followed by a brace-enclosed field initializer list.
- A contextual (inferred) struct literal omits the type name and consists only of the brace-enclosed field initializer list. This form is only valid when an expected struct type is available from context (for example a function argument position or an explicit type annotation).
An explicit struct literal looks like:
struct Pair {
a: int,
b: int,
}
fn make () -> Pair {
return Pair { a: 1, b: 2 };
}
Whitespace may appear between the type name and { in ordinary expression
contexts. The parser suppresses that non-adjacent form only at the immediate
boundary where an expression is followed by a statement block (for example the
condition or iterable expression before if, while, for, or match block
braces). Inside delimited subexpressions such as array literals, call
arguments, indexes, parenthesized expressions, and accepted struct initializer
values, Type { ... } remains an explicit struct literal because the brace
cannot be the following statement block.
An inferred struct literal looks like:
struct User {
name: string,
}
fn print_user (user: User) -> void {
std::println("user.name = {}", user.name);
}
fn main () -> int {
// Equivalent to: `print_user(User{ name: "user name" });`
print_user({ name: "user name" });
return 0;
}
Initializers are written as either:
field_name: <expr>(explicit initializer), orfield_name(shorthand initializer, equivalent tofield_name: field_name).
Initializers are separated by commas and an optional trailing comma is permitted.
Example (shorthand):
struct User {
name: string,
}
fn main () -> int {
let name: string = "alice";
let user = User{ name }; // equivalent to `User{ name: name }`
if (user.name != "alice") { return 1; }
return 0;
}
Field defaults (struct declarations)#
A struct field declaration may include a default value expression:
struct Beep {
value: string = "boop",
}
When a struct literal omits a field, the compiler uses the field default expression when present; otherwise it falls back to zero-initialization in the current backend subset. This means the empty literal form is useful when all fields have defaults:
let b = Beep {};
Important notes:
- Inferred struct literals are a value construction mechanism. They do not
imply heap allocation. The compiler will not infer
&Tfrom{ ... }; usenewfor heap allocation explicitly. - The parser only treats
{ ... }as an inferred struct literal when it contains a struct-style initializer list (or is{}); blocks ({ Stmt* }) remain statement syntax (there is no general “block expression” in the current subset).
Default constructors (empty struct literals)#
If a struct defines a default constructor method with the signature:
fn constructor (mut self: &Self) -> void { ... }
then an empty struct literal invokes it as part of value construction:
Type{}(explicit empty literal){}when a struct type is expected from context (inferred empty literal)
Construction order:
- All struct slots are zero-initialized.
- Field default expressions are evaluated for omitted fields (if present).
- The default constructor is invoked, allowing it to mutate
self.
Visibility rule:
- The default constructor is invoked only when it is visible from the current
package (constructors are
publicby default; an explicitlyprivateconstructor is not invoked implicitly).
Supported forms note:
- Non-empty struct literals (for example
Type{ x: 1 }) do not invoke constructors implicitly.
Compiler requirements:
- Enforce that field names are valid and that each field is initialized at most once.
- Define the behavior for omitted fields (in the Supported forms, omitted fields are default-initialized).
- Respect struct lowering/layout rules from
Structs, Impl Blocks, and Memory Layout.
Notes#
The current compiler implementation supports struct literals only for the
limited struct subset described in Structs, Impl Blocks, and Memory Layout:
- structs with 0+ fields of supported value types (scalar primitives,
string, nested structs, and supported optionals), - literals may omit fields:
- omitted fields that have a field default (
field: T = <expr>) use that default expression, - otherwise, omitted fields are zero-initialized in the current backend subset,
- no duplicate field initializers are permitted,
- field order is not semantically significant.
Boolean Literals#
Boolean literals are the two built-in logical values:
truefalse
They have type bool (Types).
Notes#
What works end-to-end today (lexer → parser → checker → lowering → codegen):
true/falseliteral tokens.boolvariables, parameters, and return values.if/whileconditions must have typebool.- Boolean operators:
- unary
!, - short-circuit
&&and||(left-to-right, skip evaluation of the right operand when the result is already determined).
Examples#
Basic control flow#
fn main () -> int {
let ready: bool = true;
if ready {
return 0;
} else {
return 1;
}
}
Short-circuit evaluation#
fn returns_false () -> bool {
return false;
}
fn main () -> int {
// Because the left operand is `true`, the right operand is evaluated.
let a: bool = true && returns_false();
if a {
return 1;
}
// Because the left operand is `false`, the right operand is not evaluated.
let b: bool = false && returns_false();
if b {
return 2;
}
return 0;
}
Common Pitfalls#
- Assuming “truthy” values:
boolis a distinct type. Use comparisons to produce abool(for examplex != 0) rather than writingif x { ... }. - Forgetting short-circuiting:
&&and||may skip evaluating the right operand; do not rely on side effects in the skipped operand.
Related Documents#
Types(thebooltype)Operators(!,&&,||)if` / `else(ifstatement semantics)- ``while
Loop(whilestatement semantics)
Duration Literals#
The Duration and Instant types have specialized literal support.
Syntax#
Duration literals are written as a decimal integer or decimal float immediately followed by a unit suffix:
- Examples:
10ns250us5ms1s1.5s2min1h
The unit suffix is part of the literal token; the lexer must not split it into an integer token followed by an identifier.
Units#
Recognized suffixes:
ns— nanosecondsus— microsecondsms— millisecondss— secondsmin— minutesh— hoursd— days
Semantics#
Duration literals evaluate to a Duration value represented as an i64
nanosecond count.
- For integer forms (e.g.
5ms), the value is scaled exactly. - For floating-point forms (e.g.
1.5s), the value is scaled and then rounded toward zero to an integral nanosecond count.
If the scaled value does not fit in i64, compilation fails.
Compiler requirements:
- Implement lexing rules that distinguish unit suffixes from identifiers.
- Map duration literals to the
Durationtype with correct unit scaling. - Ensure constant-evaluation behavior (rounding, overflow) matches the spec.
Flow Control Overview#
Flow control describes how Silk programs sequence work, branch, loop, and exit. This concept spans several surface constructs and their static rules (typing, scoping, and diagnostics).
Core Constructs#
if/elselooploopswhileloopsforloopsmatchexpressionsreturnbreakcontinue- blocks and statement composition
- expression statements
Each construct has defined syntax, typing, and evaluation semantics which the compiler must implement.
Notes#
Implemented end-to-end in the current compiler:
if/elseas statement forms (if` / `else)looploops (``loopLoop)whileloops (``whileLoop)forloops (ranges, builtin arrays/slices, and C-stylefor (init; cond; step); ``forLoop)break/continueinside loops (break,continue)returnstatements, including “all paths must return” checking for non-voidfunctions (return)matchas an expression for optionals and enums (``matchExpression (and Statement))matchas a statement for typed errors (Typed Errors (error,panic, andT | ErrorType...))- Expression statements for calls and assignments only
(
Expression Statements)
Not implemented yet (design exists, but the current parser/checker do not accept these end-to-end):
ifas a value-producing expression form
When in doubt, consult:
the implementation status(implementation snapshot)Compiler Diagnostics(error codes)examples/(working examples)
Principles#
These rules help keep control flow explicit and statically checkable:
- Conditions are boolean:
ifandwhilerequire aboolcondition (no integer “truthiness”). - Bodies are blocks: flow constructs use
{ ... }blocks as their bodies. - Statements are terminated: most statement forms end with
;(for examplelet,return,break,continue,panic,assert, and expression statements).
Quick Examples#
Branching:
fn main () -> int {
let x: int = 1;
if x == 0 {
return 0;
} else {
return 1;
}
}
Looping:
fn main () -> int {
let mut i: int = 0;
while i < 3 {
i += 1;
}
return 0;
}
Matching:
fn main () -> int {
let x: int? = Some(7);
let y: int = match x {
None => 0,
Some(v) => v,
};
return y;
}
See the dedicated documents:
if` / `else- ``loop
Loop - ``while
Loop - ``for
Loop - ``match
Expression (and Statement) returnbreakcontinueBlocks and Statement CompositionExpression Statements
if / else#
The if / else construct provides branching based on a boolean condition.
In Silk currently, if is a statement that selects which
block of statements executes. The broader language design also includes
expression-oriented forms; those are documented as planned where relevant.
Surface Syntax#
Minimal form:
if <condition> {
...
}
With an else:
if <condition> {
...
} else {
...
}
if let (Pattern-Destructuring Statement Form)#
Silk also supports an if let statement form for refutable pattern matching
without introducing a separate match expression:
if let <pattern> = <scrutinee> {
...
} else {
...
}
if let mut <pattern> = <scrutinee> {
...
}
Notes:
- The scrutinee expression is evaluated exactly once.
- The pattern binders (for example
Some(v)bindsv) are in scope only in thethenblock. if let mutmarks binders introduced by the pattern as mutable in that scope, so they may be reassigned like ordinarylet mutlocals.elseis optional (when omitted, a non-matching scrutinee executes no block).else if let ...chains are supported and parse as nesting in the same way aselse if ....else let ...is supported as shorthand forelse if let ...;else let muthas the same binder mutability aselse if let mut.if let move ...,else if let move ..., andelse let move ...consume the scrutinee for ownership-tracked values. The consumed source binding is not available in thethenblock, theelseblock, or after theif.
if let chains (&& let)#
The if let statement form supports a short-circuiting && chain that mixes
refutable let clauses and ordinary boolean clauses:
if let Some(x) = get_x() &&
x > 0 &&
let mut Ok(v) = get_value(x) {
// `x` and `v` are in scope here.
v = v + 1;
return v;
} else {
// `x` and `v` are NOT in scope here.
return 0;
}
Semantics:
- Clauses are evaluated left-to-right and short-circuit like
&&. - A
let <pattern> = <expr>clause evaluates<expr>exactly once: - if the pattern matches, its binders are introduced and evaluation continues,
- otherwise the entire condition is
false. - A
let mut <pattern> = <expr>clause introduces mutable binders for the remaining clauses and thethenblock. - A
let move <pattern> = <expr>clause consumes the clause scrutinee for ownership-tracked values. The moved source binding is unavailable in later clauses, thethenblock, and theelseblock. - A non-
letclause must have typebool;falseshort-circuits. - Binders introduced by
letclauses are in scope for: - subsequent clauses in the chain, and
- the
thenblock. They are not in scope in theelseblock, and they do not escape theif.
Parsing note (Supported forms):
&&at the top level is parsed as a clause separator. Use parentheses if a clause needs its own&&/||/??expression at the top level.
Example (else let shorthand):
fn main () -> int {
let a: int? = None;
let b: int? = Some(3);
if let Some(v) = a {
return v;
} else let Some(v) = b {
return v;
} else {
return 0;
}
}
Supported patterns in the Supported forms (same as match expressions; see
``match Expression (and Statement)):
- optionals:
None,Some(name),Some(_) - recoverable results:
Ok(name),Err(name)(and_binders) - enums:
Variant(...)/E::Variant(...)/ qualified variants - type unions:
name: Type/_: Type
Example (optional):
fn main () -> int {
let maybe: int? = Some(7);
if let Some(v) = maybe {
return v;
}
return 0;
}
Example (recoverable Result):
import std::result;
fn main () -> int {
let r: std::result::Result(int, string) = Ok(42);
if let Ok(v) = r {
return v;
}
return 0;
}
Notes:
<condition>is an expression; parentheses are optional because the normal expression grammar already includes parenthesized expressions.- Bodies are blocks.
elsemay be followed by either: - a block (
else { ... }), or - another
if(else if ... { ... }) to form an “else-if” chain.
Surface Syntax (Expression Form)#
Silk also supports if / else as an expression form that yields a value:
let v: int = if cond { 123 } else { 456 };
Notes:
-
ifexpressions require anelsebranch so the expression yields a value on all paths. -
The
else if ...chain form is supported in expression position:let v: int = if a { 1 } else if b { 2 } else { 3 }; -
Restriction: the
{ ... }bodies ofifexpressions contain a single expression (not a full statement block).
Semantics#
- The condition expression is evaluated exactly once.
- If the condition is
true, theifblock executes and theelseblock (if present) does not execute. - If the condition is
false, theelseblock executes if present; otherwise theifstatement does nothing.
Blocks create scopes:
- Declarations inside the
ifbody are not visible outside that body. - Declarations inside the
elsebody are not visible outside that body.
Type Checking Rules#
- The condition must have type
bool. If it does not, the checker reports a type mismatch (Compiler Diagnostics,E2001).
For if expressions:
- The
thenandelsebranches must produce compatible value types. - The expression’s result type is the shared branch type (or the expected type when the expression is type-directed).
else if Chains#
The language supports chained conditions (“else-if chains”). The compiler
parses else if as sugar for nesting an if inside the else block:
fn main () -> int {
let x: int = 1;
if x == 0 {
return 0;
} else {
if x == 1 {
return 1;
} else {
return 2;
}
}
}
The equivalent direct surface form is:
fn main () -> int {
let x: int = 1;
if x == 0 {
return 0;
} else if x == 1 {
return 1;
} else {
return 2;
}
}
Examples#
Minimal if / else#
fn main () -> int {
if true {
return 0;
} else {
return 1;
}
}
Boolean expressions in conditions#
fn main () -> int {
let x: int = 1;
let y: int = 2;
if x < y && y < 10 {
return 3;
} else {
return 4;
}
}
Control flow inside branches#
fn main () -> int {
let x: int = 1;
let y: int = 2;
if x < y {
while false {
continue;
}
return 3;
} else {
return 4;
}
}
Notes#
Implemented end-to-end:
if <expr> { ... }andif <expr> { ... } else { ... }statement forms.if let <pattern> = <expr> { ... }statement form:else if let/else letchains, and&&let-chains in theif letcondition.- Boolean type-checking for conditions.
ifexpressions of the formif <cond> { <expr> } else { <expr> }.
Not implemented yet:
- General block expressions (
{ stmt* <expr> }) outside the specificifexpression form.
examples:
while Loop#
The while loop repeatedly executes a block while a boolean condition holds.
Surface Syntax#
Minimal form:
while <condition> {
// body
}
<condition> is an expression. Parentheses are optional because the condition
is parsed using the normal expression grammar:
while (x < y && y < 10) {
...
}
while let (Pattern-Destructuring Loop Form)#
Silk supports a while let loop form for iterating while a refutable pattern
matches:
while let <pattern> = <scrutinee> {
...
}
while let mut <pattern> = <scrutinee> {
...
}
Notes:
- The scrutinee expression is evaluated once per iteration.
- The pattern binders (for example
Some(v)bindsv) are in scope only in the loop body. while let mutmarks binders introduced by the pattern as mutable for that iteration's loop body.- The loop exits when the scrutinee does not match the pattern.
- Supported patterns are the same as
if let(seeif` / `else).
while let chains (&& let)#
The while let loop form supports the same short-circuiting && chain syntax
as if let, mixing refutable let clauses and ordinary boolean clauses:
fn main () -> int {
var x: int? = Some(3);
var sum: int = 0;
while let mut Some(v) = x && v > 0 {
let original = v;
v = v + 1;
sum = sum + v;
x = if original <= 1 { None } else { Some(original - 1) };
}
return sum;
}
Semantics:
- Clauses are evaluated left-to-right and short-circuit like
&&. letclause binders are in scope for subsequent clauses and for the loop body, but they do not escape the loop.let mutclauses introduce mutable binders for subsequent clauses and for that iteration's loop body.let moveclauses consume their scrutinee for ownership-tracked values. This is most useful when the scrutinee is a fresh expression each iteration, such aswhile let move Some(value) = next() { ... }; a moved local source binding is unavailable to later clauses, the loop body, and code after the loop.- The loop exits when any clause fails (pattern mismatch or boolean
false).
Parsing note (Supported forms):
&&at the top level is parsed as a clause separator. Use parentheses if a clause needs its own&&/||/??expression at the top level.
Example (optional countdown):
fn main () -> int {
var x: int? = Some(3);
var sum: int = 0;
while let Some(v) = x {
sum = sum + v;
if v <= 1 {
x = None;
} else {
x = Some(v - 1);
}
}
// 3 + 2 + 1 = 6
return sum;
}
Loop Specifications (#invariant / #variant / #monovariant)#
The language supports attaching loop specifications immediately before a
while. This is part of Formal Silk (see Formal Silk).
When Formal Silk syntax is present, the compiler proves these obligations with
Z3 at compile time.
#invariant <expr>;
#variant <expr>;
#monovariant <expr>;
while <condition> {
...
}
Semantics#
Evaluation rules:
- The condition is evaluated before each iteration.
- If the condition evaluates to
true, the body block executes. - After the body completes normally, control returns to the condition.
- If the condition evaluates to
false, the loop terminates and execution continues after the loop statement.
Control-flow statements inside the body follow their own definitions:
breakexits the nearest enclosing loop (break).continueskips to the next iteration (continue).returnexits the function (return).panicexits the function via the typed error system (Typed Errors (error,panic, andT | ErrorType...)).
Blocks create scopes. A let declared inside the body is not visible outside
the loop’s body block.
Type Checking Rules#
The checker enforces:
- The loop condition must have type
bool(otherwiseE2001). - Each
#invariantexpression must have typebool(otherwiseE2001). - If present, the
#variantexpression must have an integer type (intor a fixed-width integer; otherwiseE2001). - Each
#monovariantexpression must have an integer type (intor a fixed-width integer; otherwiseE2001).
#invariant, #variant, and #monovariant expressions are compile-time-only
(erased from runtime code). When Formal Silk verification is enabled by syntax,
they are proved with Z3 during compilation.
Examples#
Minimal loop with break#
fn main () -> int {
while true {
break;
}
return 0;
}
Loop with invariants and a variant#
fn main () -> int {
let limit: int = 3;
#const original_limit = limit;
let mut i: int = 0;
#invariant i >= 0;
#invariant i <= original_limit;
#variant original_limit - i;
while i < limit {
i = i + 1;
}
return 0;
}
Notes#
Implemented end-to-end:
whileloops with boolean conditions.while let <pattern> = <expr> { ... }pattern-destructuring loops.&&let-chains inwhile letloop conditions.break/continueinsidewhilebodies.#invariant(type-checked asbool),#variant(type-checked as an integer), and#monovariant(type-checked as an integer) attached towhile.
examples:
break#
break exits the nearest enclosing loop.
Surface Syntax#
break;
Notes:
breakis a statement, terminated by a semicolon.breakdoes not carry a value in the current language design; there is nobreak <expr>form.
Semantics#
When executed, break;:
- terminates the innermost enclosing loop (
loop,while, orfor), and - continues execution at the statement immediately following that loop.
In nested loops, break only exits the nearest loop:
fn main () -> int {
while true {
while true {
break; // exits the inner loop only
}
break; // exits the outer loop
}
return 0;
}
break does not exit the current function. Use return for that.
Type Checking Rules#
breakis only permitted inside a loop body.- A
breakoutside a loop is a type-check error (Compiler Diagnostics,E2007).
Notes#
break;is accepted inside loops (loop,while, andfor) and lowered end-to-end.break;outside of a loop is rejected (E2007).
examples:
Common Pitfalls#
- Forgetting the semicolon (
breakis a statement). - Expecting
breakto return a value (not supported). - Using
breakoutside a loop (rejected,E2007).
continue#
continue skips the remainder of the current loop iteration and jumps to the
next iteration of the nearest enclosing loop.
Surface Syntax#
continue;
Notes:
continueis a statement, terminated by a semicolon.
Semantics#
When executed inside a loop body, continue;:
- stops executing the remainder of the current iteration’s body, and
- transfers control to the loop’s “next iteration” point:
- for
loop, this means jumping to the start of the loop body. - for
while, this means re-evaluating the loop condition. - for
for, this means advancing to the next iteration (and for C-styleforloops, executing the loop step before re-checking the loop condition).
Example:
fn main () -> int {
let mut i: int = 0;
while i < 10 {
i += 1;
if i == 5 {
continue; // skips the return below for i == 5
}
// More work could happen here.
}
return 0;
}
In nested loops, continue applies to the nearest loop:
fn main () -> int {
while true {
while true {
continue; // continues the inner loop
}
}
return 0;
}
Type Checking Rules#
continueis only permitted inside a loop body.- A
continueoutside a loop is a type-check error (Compiler Diagnostics,E2008).
Notes#
continue;is accepted inside loops (loop,while, andfor) and lowered end-to-end.continue;outside a loop is rejected (E2008).
Example that uses continue in the Supported forms:
Common Pitfalls#
- Forgetting the semicolon (
continueis a statement). - Expecting
continueto exit the loop (it does not; usebreak). - Using
continueoutside a loop (rejected,E2008).
return#
The return statement exits a function, optionally with a value.
Surface Syntax#
Return a value:
return <expr>;
Return from a void function:
return;
Semantics#
When a return statement executes:
- the current function terminates immediately, and
- control transfers back to the caller,
- carrying a return value if the function’s result type is non-
void.
No statements after a return in the same control-flow path are executed.
Type Checking Rules#
The checker enforces:
returnis only valid inside a function body (otherwiseE2009).- In a function with non-
voidresult typeR,returnmust provide an expression whose type isR(otherwiseE2009). - In a
voidfunction,return;is permitted andreturn <expr>;is rejected (E2009). - In a function with non-
voidresult, falling off the end of the function body is a compile-time error (Compiler Diagnostics,E2010).
Examples#
Returning from main#
fn main () -> int {
return 0;
}
Early return#
fn main () -> int {
let x: int = 1;
if x == 0 {
return 0;
}
return 1;
}
return; in a void function#
struct Counter {
value: int,
}
impl Counter {
fn inc (mut self: &Counter) -> void {
self.value += 1;
return;
}
}
Notes#
Implemented end-to-end:
return <expr>;from non-voidfunctions, with type checking.return;fromvoidfunctions.- Missing return in a non-
voidfunction is rejected (E2010).
examples:
examples/(wrong type, rejected)examples/(missing return, rejected)examples/(usesreturn;in a-> voidmethod)
match Expression (and Statement)#
The match expression provides structured pattern matching.
Key ideas:
- A
matchselects one of several branches based on a scrutinee expression. - The full language design includes richer patterns and arm guards, but the
current shipped subset documented here does not implement
ifguards in anymatchform yet. matchis an expression; all arms must be compatible in type.
The compiler must:
- Enforce exhaustiveness rules (where specified).
- Type check each arm and compute a consistent result type.
Surface Syntax#
The full language design includes rich pattern matching, guards, and matching over many scrutinee types. The current compiler implementation supports only a narrow, explicitly documented subset so we can validate end-to-end lowering and code generation.
In the initial subset, match is accepted as an expression of the form:
match <scrutinee> {
<pattern> => <expr>,
<pattern> => <expr>,
}
Notes:
- Arms are separated by commas; a trailing comma is permitted.
- In the initial subset, arm bodies are expressions (not blocks).
- In the current compiler, expression-form
matchis implemented for: - optionals,
- primitive integers,
- primitive booleans,
- primitive strings,
- enums,
- type unions,
- and recoverable
Result-style values. - Guard clauses of the form
pattern if cond => ...are currently unsupported across all of those subsets.
Optional Matching (T?)#
For optionals, the Supported forms is:
- The scrutinee expression must have optional type
T?(Option(T)), whereTis a payload type supported by the backend. - Patterns are restricted to:
NoneSome(<name>)Some(_)- No guards (
if ...) are implemented yet. - Matches must be exhaustive for the optional scrutinee: there must be exactly
one
Nonearm and exactly oneSome(...)arm (order is not significant).
Example:
fn main () -> int {
let x: int? = Some(7);
let y: int = match x {
None => 5,
Some(v) => v,
};
return y;
}
Integer Matching (Primitive Integers)#
The compiler also supports a small match subset for integer-like primitive
scrutinees.
Notes:
- The scrutinee expression must have a primitive integer type in the current
backend subset (
int,i64,u64, and the fixed-width integer primitives). - Patterns are restricted to:
- integer literals (
0,1,123,0xFF, ...), and - a wildcard
_arm. - The match must be exhaustive:
- when the scrutinee is not a known integer literal, there must be exactly one
wildcard
_arm, and that arm must be final, - when the scrutinee is a known integer literal, the wildcard may be omitted only if one integer-literal arm covers that exact value, and
- literal arms must not repeat the same value.
- No guards (
if ...) are implemented yet.
Example:
fn main () -> int {
let x: int = 0;
let y: int = match (x) {
0 => 1,
_ => 2,
};
return y;
}
Literal scrutinees may be exhaustively matched by the literal arms alone:
fn main () -> int {
let y: int = match 2 {
1 => 10,
2 => 20,
};
return y;
}
Boolean Matching (Primitive bool)#
The compiler also supports match over primitive bool scrutinees.
Supported forms:
- The scrutinee expression must have type
bool. - Patterns are restricted to:
- boolean literals (
falseandtrue), and - a wildcard
_arm. - The match must be exhaustive:
- when the scrutinee is not a known boolean literal, it must cover both
falseandtrue, or have exactly one final wildcard_arm, - when the scrutinee is a known boolean literal, the wildcard may be omitted only if one boolean-literal arm covers that exact value, and
- literal arms must not repeat the same boolean value.
- No guards (
if ...) are implemented yet.
Example:
fn main () -> int {
let b: bool = false;
let y: int = match b {
false => 1,
true => 2,
};
return y;
}
String Matching (Primitive string)#
The compiler also supports match over primitive string scrutinees.
Supported forms:
- The scrutinee expression must have type
string. - Patterns are restricted to:
- string literals (
"..."and raw backtick strings), and - a wildcard
_arm. - Literal comparison uses the decoded byte sequence of each string literal.
- The match must be exhaustive:
- when the scrutinee is not a known string literal, it must have exactly one
final wildcard
_arm, - when the scrutinee is a known string literal, the wildcard may be omitted only if one string-literal arm covers that exact decoded byte sequence, and
- literal arms must not repeat the same decoded byte sequence.
- No guards (
if ...) are implemented yet.
Example:
fn main () -> int {
let s: string = "foo";
let y: int = match s {
"foo" => 1,
_ => 2,
};
return y;
}
Literal scrutinees may be exhaustively matched by the literal arms alone:
fn main () -> int {
let y: int = match "foo" {
"foo" => 1,
"bar" => 2,
};
return y;
}
Enum Matching (enum)#
The language design supports matching over user-defined enum types
(``enum Types).
Notes:
- The scrutinee expression must have an enum type
E(including an instantiated generic enum in module-set builds). - Patterns are restricted to enum variants:
- unit variants:
E::CancelledorCancelled - tuple variants:
E::Msg(x)/Msg(x)/E::Pair(a, b)/Pair(a, b)(binders may be identifiers or_) - For instantiated generic enums, the qualifier
Ein patterns may be a type alias for the instantiation (for exampletype R = Result(int, string);thenR::Ok(v)/R::Err(e)), or patterns may omit the qualifier and use the variant name directly. - No guards (
if ...) are implemented yet. - In expression form, enum matches must still be exhaustive:
- either there is exactly one explicit arm for each enum variant,
- or a final wildcard
_arm covers every remaining unmatched variant, - and if
_is used, it may appear at most once and must be the final arm.
Type Union and Concrete Typed-Binder Matching#
The language supports matching over type unions (Type Unions (T1 | T2 | ...)).
The same typed-binder arm syntax is also accepted for concrete struct values in
the backend when the checker can select exactly one applicable
arm statically.
Notes:
- For a union scrutinee, the scrutinee expression must have a union type
T1 | ... | Tn. - For a concrete struct scrutinee, the scrutinee expression must have a known concrete struct type, and exactly one typed-binder arm must accept that type.
- Patterns are restricted to typed binders:
name: Ti(binds the payload asTi), or_: Ti(matches and ignores the payload), whereTiis one of the union member types for union scrutinees, or an accepting nominal type for concrete scrutinees.- For concrete struct scrutinees, an arm type accepts the scrutinee when it is:
- the exact concrete type or an alias of it,
- a valid base type in the struct
extendschain, or - an interface implemented by the concrete type.
- No guards (
if ...) are implemented yet. - Union matches must be exhaustive: there must be exactly one arm per union member type (order is not significant).
- Concrete typed-binder matches are statically selected: there must be exactly one accepting arm. Zero accepting arms are rejected as a missing arm, and more than one accepting arm is rejected as an ambiguous typed-binder match.
Example:
interface Object {}
struct A { x: int }
struct B { x: int }
impl A as Object {}
fn main () -> int {
let a = A { x: 1 };
let y = match a {
v: A => v.x,
_: B => 0,
};
let z = match a {
_: Object => 2,
};
return y + z;
}
Semantics#
- The scrutinee expression is evaluated exactly once.
- The selected arm is chosen based on the scrutinee value; non-selected arms are not evaluated.
- For
Some(v) => ..., the bindervis in scope only within that arm and has typeT(the inner payload type of the scrutineeT?). - For typed-binder arms
v: T => ..., the bindervis in scope only within that arm and has the annotated typeT;_: Tchecks the same type contract without introducing a binder. - The result type of a
matchexpression is the common type of its arms; all arms must type-check to the same result type in the initial subset.
match Statement (Block Arms)#
Silk also supports a statement form of match whose arms are blocks. This is
the ergonomic counterpart to the expression form when an arm must perform
multiple statements (printing, early returns, mutation, etc).
Surface form:
match (<scrutinee>) {
<pattern> => { ... },
<pattern> => { ... },
}
The statement form may also use a single-expression arm body without braces:
match (x) {
_ => do_work(),
}
An optional trailing semicolon is permitted after the closing brace:
match (x) { _ => { } };
In Silk currently, the statement form is supported for:
- ordinary value matching (no typed-error contract), and
- typed error handling (
Typed Errors (error,panic, andT | ErrorType...)).
Ordinary value matching#
When the scrutinee expression is an ordinary value (it does not have a typed
error contract), the statement form supports the same scrutinee + pattern
subsets as the match expression form in this document:
- optionals (
T?):None/Some(name)/Some(_) - primitive integers: integer literals and
_ - primitive booleans:
false/trueand_ - primitive strings: string literals and
_ - enums (
enum): enum variants (see note below) - type unions (
T1 | ... | Tn): typed bindersname: Ti/_: Ti - recoverable results:
Ok(name)/Ok(_)andErr(name)/Err(_)
Exhaustiveness rules:
- Expression
matchremains exhaustive. - Statement
matchis also exhaustive by default. - For
Option(T)and recoverableResult-like values only, the statement form may omit one side of the split: match (opt) { Some(v) => { ... } }match (opt) { None => { ... } }match (res) { Ok(v) => { ... } }match (res) { Err(e) => { ... } }- In that one-arm statement form, the unhandled case is an implicit no-op.
- This partial form does not apply to expression
match, integer matches, general enums, type unions, or typed-error matches.
Notes:
- The preferred single-branch control-flow forms remain
if let,let ... else, andwhile letwhen they fit naturally. - For ordinary enum matches, both expression and statement form now support one
final wildcard
_arm end to end. - That wildcard arm:
- may appear at most once,
- must be the final arm,
- and must cover at least one still-unmatched variant.
Enum variant pattern note (statement form):
- In the statement form, a bare identifier pattern
nameis reserved for a catch-all binder arm (used by typed error matches), so enum variant patterns must be written in qualified form:E::Variant(...)(including::pkg::E::Variant(...)). match (stream) { Error => { ... } }therefore treatsErroras a binder arm, whilematch (stream) { IOStream::Error => { ... } }matches the unit enum variant.- Ordinary enum statement matches now also allow one final
_catch-all arm, with the same final-arm and non-redundancy rules as expression-form enum matches.
Typed error matching (Terminal Arm Rule)#
The language design also includes a statement form of match used for
typed errors (Typed Errors (error, panic, and T | ErrorType...)).
Surface form:
match (expr) {
pattern => { ... },
err: SomeError => { std::abort(); }
}
Key semantic rule (Terminal Arm Rule):
- If
expris an error-producing expression (its signature includesT | ErrorType...), then any arm that matches anerrortype must end in a terminal statement.
Implementation
- The compiler currently implements
matchas an expression for the documented Supported forms: - optionals (
T?), - primitive integers,
- type unions,
- recoverable
Result-style values, - and exhaustive enum matches.
- No match arm guards (
pattern if cond => ...) are implemented yet in either expression or statement form. - The statement form is implemented for:
- ordinary values in the supported subset (block arms), and
- typed errors as part of the typed errors feature work (
Typed Errors (error,panic, andT | ErrorType...)).
Note: the compiler also allows the match statement form to destructure
recoverable Result-style values. This form does not trigger the Terminal Arm
Rule because it is not a T | ... typed-error expression.
Result Matching (Ok(...) / Err(...))#
The match expression also supports a small subset for
recoverable “success or error” values. In the initial subset, this includes:
std::result::Result(T, E)(anenumwithOk(T)andErr(E)variants), and- “Result-like” structs of the form
{ value: T?, err: E? }.
For the struct form, the runtime invariant is: exactly one of value and err
is Some(...). If this invariant is broken at runtime, execution traps.
Patterns:
Ok(name)/Ok(_)Err(name)/Err(_)
Rules (Supported forms):
- Enum form:
- The scrutinee expression must have an enum type with variants
OkandErr. Ok(...)/Err(...)patterns are shorthand forR::Ok(...)/R::Err(...)whereRis the scrutinee enum type, and may appear alongside other enum variant patterns.- In expression form, exhaustiveness follows the enum rules: there must be exactly one arm per enum variant.
- Struct form:
- The scrutinee expression must have a nominal struct type that contains
value: T?anderr: E?. - Matches must be exhaustive:
- for enum scrutinees in expression form, follow the enum rules (one arm per variant, or a single final wildcard arm covers the remaining unmatched variant),
- for struct scrutinees, there must be exactly one
Ok(...)arm and exactly oneErr(...)arm. - In
Ok(v) => ..., the bindervhas typeT. - In
Err(e) => ..., the binderehas typeE.
Example:
import std::result;
import std::strings::String;
fn main () -> int {
let s: String = match String.from_string("hello") {
Ok(v) => v,
Err(_) => String.empty(),
};
return s.len as int;
}
One-arm statement examples:
match (parse_port(input)) {
Ok(port) => {
use_port(port);
},
}
match (std::env::get("HOME")) {
None => {
std::io::println("HOME is not set");
},
}
Structs, Impl Blocks, and Memory Layout#
Structs and impl blocks are separated:
structdeclarations define pure data layout.implblocks attach behavior to types without changing their layout.
struct Declarations#
Structs define a composite data type made of named fields:
struct Frame {
sequence: u32,
size: u16,
flag: u8,
}
Key rules:
- Structs contain only data members.
- Memory layout and padding are well-defined so that FFI and ABI rules can rely on them.
- Stack vs heap allocation is specified in
Memory Model (Stack, Heap, and Moves).
Generic structs#
Structs may declare type parameters:
struct Data(T) {
value: T,
}
Rules:
- A generic
struct Name(T, ...)introduces a type constructorName. - Outside a generic context, uses of the type must be fully applied (for
example
Data(u8)), not bareData. - A declaration name may not be reused across different generic arities (for
example
struct Foo { ... }andstruct Foo(T) { ... }cannot both exist in the same namespace).
Field Default Initializers#
Struct fields may include an optional default initializer expression:
struct Point {
x: int = 0,
y: int = 0,
}
When a struct literal omits a field, the compiler initializes the field from its default expression.
In Silk currently, a field default must be a compile-time evaluable expression. This is broader than the current default-function-argument rule.
Supported forms currently include:
- literals,
constbindings,- calls to
const fn, - struct literals, and
- field access over compile-time values.
Current limits:
newis still rejected,- ordinary runtime helper calls are rejected, and
- the expression still has to type-check against the field type.
Example:
const DEFAULT_Y: int = 0;
struct Point {
x: int = 0,
y: int = DEFAULT_Y,
}
fn main () -> int {
let p = Point{ x: 5 };
return p.y; // defaults to 0
}
Single Inheritance (extends)#
Silk supports single inheritance for struct declarations via extends.
Surface syntax:
struct Base {
x: int,
y: int = 0,
}
struct Derived extends Base {
z: int,
}
Semantics (Supported forms):
- A derived struct inherits all fields of its base struct.
- The derived struct’s field sequence is:
- all base fields (in declaration order), then
- all derived fields (in declaration order).
- Field access on the derived struct can refer to inherited base fields
directly (
d.x,d.y). - Default field initializers are inherited:
- a
Derived{ ... }literal may omit inherited fields that have defaults in the base struct.
Type checking rules (Supported forms):
extendsis permitted only on non-opaquestructdeclarations.- The base name must resolve to a
structtype in the compiled module set. - Cycles in
extendschains are rejected. - A derived struct may not declare a field whose name conflicts with an inherited field name.
Notes:
extendsdoes not imply implicit subtyping in Silk currently: there is no implicit coercion fromDerivedtoBase(or&Derivedto&Base) yet.
Opaque Structs (FFI Handles)#
Opaque structs are a special form of struct declaration intended for safely
representing foreign pointers/handles from C APIs.
Syntax:
// Declares an opaque handle type.
struct MyFFIHandle;
An opaque struct has no fields and no Silk-defined layout. It exists only as a nominal handle type that can be passed around safely.
Rules:
- Opaque structs cannot be instantiated (no struct literals).
- Opaque structs do not support field/member access (
./?.). - Opaque structs must not be used by value in type positions (locals,
parameters, results). Only the reference form
&MyFFIHandleis allowed.
These rules increase safety at the language boundary:
- Eliminates type confusion: distinct handle types such as
&DatabaseHandleand&FileHandleare not interchangeable. - Prevents invalid operations in Silk: Silk code cannot read/write fields or assume a size/layout for the foreign type.
Safety and Undefined Behavior (UB)#
Opaque handles do not carry lifetime information. You are responsible for calling the corresponding destruction/free function provided by the foreign library.
Using an opaque handle after it has been destroyed is undefined behavior. The compiler does not currently enforce this at compile time.
ABI and Lowering#
In the backend, an &Opaque value is lowered as a single pointer
scalar (u64 on the current linux/x86_64 target), rather than as a
struct-of-pointers like &struct borrows.
Memory Layout (Intended Contract)#
The long-term Silk design is for struct layout to match conventional C layout
rules for the corresponding field types on the target:
- Sequential layout: fields appear in memory in the exact order they are
declared in the
structdefinition. - Alignment and padding: each field is placed at an offset that is a multiple of the field type’s required alignment. The compiler inserts padding bytes where necessary.
- Final padding: the overall struct size is padded to a multiple of the struct’s alignment (typically the maximum alignment of its fields), so arrays of the struct keep each element correctly aligned.
Example (typical C layout on linux/x86_64):
struct Frame {
sequence: u32, // 4 bytes
size: u16, // 2 bytes
flag: u8, // 1 byte
}
Conceptually, this layout would be:
sequenceat offset0(4 bytes)sizeat offset4(2 bytes)flagat offset6(1 byte)- 1 byte of tail padding at offset
7to make the total size a multiple of 4
Total size: 8 bytes (alignment 4).
Memory Layout#
the compiler does not implement packed C-like struct layout yet. Instead, it uses a scalar slot model:
- A
structvalue is lowered into a sequence of scalar “slots” in source order, after recursively expanding certain composite field types: stringcontributes two slots:(u64 ptr, i64 len).- nested non-opaque structs contribute their slot sequence.
- optionals contribute
(bool tag, payload slots...), where payload slots follow the lowering of the underlying non-optional type. - When a
structis stored in memory (stack locals and heap boxes), each slot is stored in a separate 8-byte cell. - This means sub-64-bit fields (
bool,i8/u8,i32/u32,f32,char, etc.) are not packed yet. - Values are still typed as their declared scalar kinds (the checker and IR track widths/sign), but the physical in-memory representation is widened to one 8-byte slot per scalar.
This design keeps lowering/codegen simple and lets the compiler support nested aggregates without committing to a final packed layout. The trade-off is that the in-memory representation is not ABI-compatible with a C struct unless the struct is restricted to ABI-safe 64-bit slots.
Example : the Frame above is lowered as 3 scalar
slots and occupies 24 bytes when stored in memory (3 × 8-byte cells), even
though the intended C-like packed layout would be 8 bytes.
ABI and Code Generation#
The Silk language design includes full support for user-defined structs, nested aggregates, and FFI-safe ABI mapping. The current compiler/backend implementation supports only a narrow, explicitly documented subset:
- Only "plain" structs with 0+ fields are supported by codegen.
- Empty structs (
struct Empty {}) are currently represented as a single placeholderu64slot in the scalar-slot model. - Fields may be:
- scalar primitive types (
bool, fixed-width integers,int,char,f32/f64,Instant,Duration), string(lowered as{ ptr: u64, len: i64 }),- nested (non-opaque) structs,
- and optionals (
T?) of supported payload types. - At ABI boundaries (exported functions and
extdeclarations), structs must be ABI-safe: after slot-flattening, all slots must bei64/u64/f64(for examplestringfields are ABI-safe because they lower to(u64, i64), butbool,char, andf32fields are not). - Such structs are passed and returned by value by lowering them to their scalar slots in order and following the System V AMD64 ABI rules for those scalar slots:
- integer-like slots consume general-purpose argument slots (
rdi,rsi,rdx,rcx,r8,r9, then the stack), f32/f64slots consume XMM argument slots (xmm0..xmm7, then the stack),- 1–2 slot results use
rax/rdxfor integer-like slots andxmm0/xmm1for float slots, with mixed aggregates using both, - 3+ slot results return indirectly via a hidden sret pointer passed in
rdi(caller-allocated return buffer), with the callee storing each scalar slot sequentially and returning the pointer inrax.
Note: at the C ABI surface, exported functions accept ABI-safe structs by flattening parameters to their scalar slots in order. For 1–2 slot structs this is ABI-compatible with passing an equivalent by-value C struct parameter, while for 3+ slot structs downstream C callers should declare separate scalar parameters for the slots. Struct returns with 3+ slots use sret and are ABI-compatible with returning an equivalent C struct by value.
This subset is intentionally small so that we can validate the end-to-end type pipeline (parsing → checking → lowering → IR→ELF codegen) while keeping ABI behavior consistent with C for the supported cases.
impl Blocks#
impl blocks attach functions and methods to existing types without affecting
memory layout.
The intent is to provide “high-level” APIs without baking behavior into struct
layout. In the implementation, impl blocks are syntax and
type-checking structure; code generation treats methods as ordinary functions
that follow the same calling conventions as other Silk functions.
Generic impl blocks#
If a type is declared with type parameters (struct or enum), its impl blocks must provide a full type-argument list of the same arity.
Each position in the impl Name(...) argument list may be:
- a type parameter name (a generic impl), or
- a concrete primitive type name (an impl specialization for that argument).
struct Data(T) { value: T }
// Generic impl (applies to all specializations of Data(T)).
impl Data(T) {
fn get(self: &Self) -> T { return self.value; }
}
// Specialized impl (applies only to Data(u8)).
impl Data(u8) {
fn is_zero(self: &Self) -> bool { return self.value == 0; }
}
Specialized impl blocks are merged with any other applicable impl blocks for the same type specialization, subject to the usual duplicate method-name rules.
Supported forms limitation:
- Only primitive type names (for example
u64,string,bool) are recognized as concrete specialization arguments inimpl Name(...). Any other identifier in animplargument position is treated as a type parameter name.
Syntax#
impl List {
// Ordinary static method (no receiver).
fn init (cap: i64) -> List { ... }
// Heap constructor used by `new List(...)` (special name, receiver + `void`).
fn constructor (mut self: &Self, cap: i64) -> void { ... }
// Instance method (receiver as first parameter).
public fn len (self: &List) -> i64 { ... }
// Mutating instance method (mutable receiver).
public fn push (mut self: &List, value: u8) -> void { ... }
}
Rules:
- An
implblock attaches methods to exactly one nominal type name (astructor anenum). - Multiple
implblocks may exist for the same type name; the compiler merges their methods (subject to duplicate-name rules). - Methods inside an
implblock arefndeclarations (with bodies). - The receiver, when present, is the first parameter named
selfand must be either: - a borrowed reference to the
impltype (self: &Type/mut self: &Type), or - an owned value of the
impltype (self: Type/mut self: Type). - Within an
implblock, the special type nameSelfmay be used anywhere a type name is accepted, and is treated as an alias for theimpltype. For example,self: &Selfis equivalent toself: &Type, and-> Selfis equivalent to-> Type. - Static methods omit the receiver parameter.
- Method visibility:
- Methods are private by default: a method declared without an explicit
visibility modifier is callable only within the defining
impl { ... }block. public fnmarks a method as callable from outside the definingimplblock.private fnis permitted to make intent explicit.exportis reserved for static members (noselfreceiver) and is not permitted on instance methods; usepublic fninstead.- When an
implblock declares conformance to an interface (impl T as I), the interface’s required methods are public by definition: - the corresponding impl methods may omit
public, but - they may not be explicitly marked
private. SeeInterfaces. - The method named
constructoris treated specially: - it is only meaningful for
structtypes (it backsnew Type(...)); enums do not supportconstructormethods in the Supported forms, - it is
publicby default, - when explicitly marked
private, it is callable only within the definingimpl { ... }block, - it may be declared multiple times in a single
implblock (an overload set), - its overload set includes
constructordeclarations across all mergedimplblocks for the type, - it is invoked by:
- heap allocation (
new Type(...)), - empty struct literals (
Type{}and contextual{}) when a visible default constructor exists (seeAggregate Literals), - and certain call-argument coercions (see
Types), new Type(args...)invokes the unique overload whose receiver ismut self: &Type, whose return type isvoid, and whose non-receiver parameter list matchesargs...after applying the normal call-argument type-checking rules,- if multiple overloads are applicable, the compiler prefers overloads that do
not rely on implicit call-argument coercions (notably the
U -> &Tconstructor coercion for&Tparameters); if multiple overloads remain tied, the call is rejected as ambiguous.
Call syntax#
The surface call syntax uses field-access + call:
- Instance method call:
value.method(arg0, arg1, ...) - Static method call:
Type.method(arg0, arg1, ...)
Semantically, method calls behave like ordinary function calls where the receiver is passed as an explicit first argument.
Static-method receiver sugar (Supported forms):
-
If
value.method(...)does not resolve to an instance method (a method whose first parameter is a receiverself: &Type/mut self: &Type), the compiler may resolve it as a call to a visible static method of the receiver type by inserting the receiver as the first argument:Type.method(value, ...). -
This supports fluent chaining for value-consuming helper APIs like
std::result::Result.unwrap_or:let r: R = /* ... */; let x: int = r.unwrap_or(0); // sugar for `R.unwrap_or(r, 0)`
Mutability rule (Supported forms):
- If the method receiver is
self: &Type, the call site passes a read-only borrow of the receiver (for examplevalue.method(...)). - If the method receiver is
mut self: &Type, the call site must pass a mutable borrow of the receiver. - When the receiver is a name binding that is mutable (
let mut value = ...) or a mutable reference binding (for example amut self: &Typereceiver), the compiler treatsvalue.method(...)as a mutable receiver call (no(mut value)wrapper required). - The explicit
value.method(...)form is permitted but is no longer required for name receivers. - If the method receiver is
self: Typeormut self: Type, the call site passes the receiver by value. For ownership-tracked values (for example types withDrop), this consumes the receiver binding (use after move is rejected); for plain scalars and POD structs it behaves like a copy.
Supported forms limitations:
- Mutable borrow receiver calls (
mut self: &Type) must use a name receiver; mutable borrows from non-name receiver expressions (for examplemake().push(1)) are rejected. - Non-
mutreceivers may be arbitrary expressions (including calls), so chaining likeurl.href().as_string()is permitted.
Compiler requirements:
- Keep data layout and behavior separate in the IR.
- Preserve struct layout exactly for ABI and FFI.
- Enforce rules for opaque structs and UB as described in this document and the ABI spec.
enum Types#
An enum defines a tagged union type: a value that is exactly one of several
named variants, optionally carrying a payload.
Use enums to model:
- finite state machines (connection state, parser state),
- protocol messages and events,
- and any API where “exactly one of these cases” is the core invariant.
If your goal is “a function can fail with one of several error shapes”, prefer
typed errors (Typed Errors (error, panic, and T | ErrorType...)) over enums.
Notes#
What works end-to-end today (parser → checker → lowering → codegen):
- Enum declarations with:
- unit variants (
A), - tuple variants (
Data(int)andPair(int, int)), - and an optional trailing comma after the last variant.
- Construction:
- unit variants as values:
E::A, - tuple variants as calls:
E::Data(7). - type-directed shorthand:
- when the expected type is an enum
E, unit variants may be written asA(sugar forE::A), - when the expected type is an enum
E, tuple variants may be written asData(7)(sugar forE::Data(7)). - Function signatures:
- enums may be used in parameter lists and return types,
- error-producing functions may return enums (
-> E | ErrorType...), andcall()?works when the success type is an enum. matchexpression over enums:- patterns are restricted to enum variants (
E::A,E::Data(x)) and may use shorthand (A,Data(x)) when the scrutinee type is the enum, - binders may be names or
_, - no guards (
if ...) yet, - and expression-form matches must still be exhaustive in the current subset:
- either by explicit arm coverage for every variant,
- or by a single final wildcard
_arm that covers all remaining unmatched variants. ??over ordinary two-variant enums:- for a named enum with exactly two declared variants,
value ?? fallbacktreats the first declared variant as the “success” arm, - if that first variant is unit, the expression yields that enum value,
- if that first variant carries exactly one payload, the expression yields that payload,
- and if the enum value is the second declared variant, the fallback expression is evaluated.
matchstatement over ordinary enum values:- enum variant arms must use the qualified form
E::Variant(...), - bare identifiers remain reserved for binder-style arms,
- and both expression-form and statement-form ordinary enum matches now allow
one final
_catch-all arm, which must cover at least one remaining variant. - Generic enums (monomorphized):
enum Name(T, ...) { ... }declarations are supported in module-set builds that run monomorphization,- instantiated enums behave like ordinary enums once referenced (including
construction and
match), - callers typically introduce a local type alias for the instantiated enum
(for example
type R = Result(int, string);) and then useR::Ok(...)/R::Err(...)as constructors and patterns. implblocks on enums:- enums may have
implblocks (including genericimpl EnumName(T, ...)), - static methods are callable as
EnumName.method(...)(including through type aliases for instantiated generic enums), - instance methods are callable as
value.method(...)when the first parameter is a receiver (self: &EnumName/mut self: &EnumName), - the special
constructormethod used bynew Type(...)is forstructtypes; enums do not supportconstructormethods in the Supported forms.
Not implemented yet (or not yet stable/documented):
- Guards in enum match arms (
E::A if cond => ...). - A stable ABI story for passing/returning enums across the C99 boundary (do
not assume an enum layout until it is specified in
C99 ABI andlibsilk.a``).
When the compiler rejects an enum construct in the Supported forms, the most
common error is E2002 (“unsupported expression in the Supported forms”). Type
mismatches inside enum constructors or match arms are E2001 (“type mismatch”).
Surface Syntax#
Enum declarations introduce a nominal type and its variants:
enum RecvJob {
Msg(Job),
Cancelled,
Timeout,
}
Rules:
- Variant names are identifiers and must be unique within the enum.
- Variant names may not be the reserved optional constructors
Some/None. - An enum must declare at least one variant.
- A variant is either:
- a unit variant (no payload):
Cancelled, - or a tuple variant with one or more payload element types:
Msg(Job),Pair(int, int). - A trailing comma after the last variant is permitted.
Construction#
Unit variants#
Unit variants are constructed as values using Enum::Variant (or, in
type-directed contexts, just Variant):
enum E {
A,
B,
}
fn main () -> int {
let x: E = E::A;
let y: E = A;
return 0;
}
Notes:
E::A()andA()are invalid in the Supported forms (unit variants are not callable).
Two-Variant ?? Shortcut#
When a named enum declares exactly two variants, the ?? operator may be used
as a compact success/fallback form.
Rules in the Supported forms:
- declaration order matters: the first declared variant is the “success” case, and the second declared variant triggers the fallback,
- if the first variant is unit,
value ?? fallbackyields that enum value, - if the first variant carries exactly one payload,
value ?? fallbackyields that payload, - if the first variant carries more than one payload element, use
matchinstead, - the second variant may be unit or may carry payloads; its payload is ignored
by the
??form and the fallback expression runs. - the right-hand side is still an expression in the current grammar; terminal
statements such as
return,break, andcontinueare not accepted there yet.
Examples:
enum Ready {
Go,
Stop,
}
fn require_ready (r: Ready) -> Ready {
return r ?? Ready::Go;
}
enum ParsedPort {
Port(int),
Invalid,
}
fn port_or_default (p: ParsedPort) -> int {
return p ?? 80;
}
Tuple variants#
Tuple variants are constructed using Enum::Variant(<args...>) where the
argument count and types match the variant’s declared payload element types:
enum E {
Data(int),
Pair(int, int),
Empty,
}
fn main () -> int {
let a: E = E::Data(7);
let b: E = Data(7);
let b: E = E::Pair(1, 2);
let c: E = E::Empty;
return 0;
}
Notes:
E::Databy itself is not a value in the Supported forms (tuple variants must be constructed with(...)).- If a tuple-variant constructor argument has the wrong type, you get
E2001. - If the argument count does not match the variant definition, the compiler
currently rejects the construct with
E2002.
Generic enums (instantiation via alias)#
When an enum is generic, callers typically alias an instantiation and then use that alias as the qualifier for constructors:
enum Result(T, E) {
Ok(T),
Err(E),
}
type R = Result(int, int);
fn main () -> int {
let x: R = R::Ok(123);
return match x {
R::Ok(v) => v,
R::Err(_) => 0,
};
}
Namespaced enums (packages)#
Across packages, enums and variants may be referenced with :: qualification.
For example, if util defines enum Mode { Inc, Dec }, an importer can write:
util::Modeas the type name, andutil::Mode::Inc/util::Mode::Decas the constructors and patterns.
See Packages, Imports, and Exports for module-set rules and for how
package imports seed qualified type names.
Matching#
Enum values are typically consumed via match expressions. The match
expression rules are defined in ``match Expression (and Statement); this section
focuses on the enum-specific subset.
Patterns#
Enum patterns are variant patterns:
- Unit variant:
E::A - Tuple variant:
E::Data(x),E::Pair(a, b) - Tuple binder omission:
E::Pair(_, b)(underscore binder ignores that element)
Shorthand:
- When the scrutinee type is the enum
E, the qualifier may be omitted: - Unit variant:
A - Tuple variant:
Data(x),Pair(a, b)
For instantiated generic enums, the qualifier E may be a type alias (for
example type R = Result(int, string); then R::Ok(v) / R::Err(e)).
Binders:
- introduce a name scoped to that arm only, and
- shadow outer bindings of the same name (because they create a new binding in the arm’s environment).
Exhaustiveness#
In the Supported forms, enum exhaustiveness is split by match form:
- Expression form:
- either there is exactly one arm per enum variant,
- or a single final wildcard arm (
_ => ...) covers the remaining unmatched variants, - explicit variant arms must not repeat the same variant,
- and if
_is used, it may appear at most once and must be the final arm. - Statement form over ordinary enum values:
- either there is exactly one arm per enum variant,
- or a single final wildcard arm (
_ => ...) covers the remaining unmatched variants, - explicit variant arms must not repeat the same variant,
- and if
_is used, it may appear at most once and must be the final arm.
If a match is not exhaustive:
- expression form currently reports
E2002, and - statement form without full coverage uses the checker’s missing-arm path when it reaches that analysis.
Example: unit enum match#
enum E {
A,
B,
}
fn main () -> int {
let v: E = E::A;
let x: int = match v {
E::A => 10,
E::B => 20,
};
if x != 10 {
return 1;
}
return 0;
}
Example: unit enum match with final wildcard#
enum State {
Ready,
Busy,
Closed,
}
fn score (s: State) -> int {
return match s {
Ready => 10,
_ => 20,
};
}
Example: tuple enum match (payload binders)#
enum E {
Pair(int, int),
Empty,
}
fn main () -> int {
let v: E = E::Pair(1, 2);
let x: int = match v {
E::Pair(a, _) => a,
E::Empty => 0,
};
if x != 1 {
return 1;
}
return 0;
}
Example: struct payload enum match#
struct Job {
id: int,
}
enum RecvJob {
Msg(Job),
Cancelled,
}
fn main () -> int {
let j: Job = Job{ id: 5 };
let evt: RecvJob = RecvJob::Msg(j);
let rc: int = match evt {
RecvJob::Msg(job) => job.id,
RecvJob::Cancelled => 0,
};
if rc != 5 {
return 1;
}
return 0;
}
Representation#
Enums are values. In the current IR-backed lowering, an enum value is lowered to scalar slots as:
- a
u64tag (variant index in declaration order, starting at0), and - a payload region that includes a distinct slot range for each variant’s payload elements, in variant declaration order.
Conceptually:
(u64 tag,
payload slots for variant 0,
payload slots for variant 1,
...)
Only the active variant’s payload region is meaningful for a given value; other payload regions are unspecified.
This representation is an implementation detail and is expected to evolve (for example, toward a tag + max-payload “union-style” layout) as the compiler and ABI mature.
Common Pitfalls#
- Forgetting parentheses:
E::Data(7)is valid, butE::Datais not a value in the Supported forms (errorE2002). - Calling a unit variant:
E::Ais a value;E::A()is rejected (E2002). - Wrong binder count:
E::Pair(a)does not matchPair(int, int)(E2002). - Non-exhaustive matches: you must list every variant (error
E2002in the Supported forms). - Assuming enum equality is defined: use
matchto inspect the tag/payload; the backend does not define==/!=over enums yet.
Related Documents#
- ``match
Expression (and Statement)(match expression rules) Structs, Impl Blocks, and Memory Layout(struct payloads)Types(nominal types and type annotations)Packages, Imports, and Exports(namespaces and imports)Typed Errors (error,panic, andT | ErrorType...)(typed errors, not enums)
Interfaces#
Interfaces allow types to declare that they implement a particular contract. They are the foundation for standard-library “protocols” such as readers, writers, iterators, and allocators.
When the standard library is enabled (the default), the compiler provides a
small implicit std prelude (see Packages, Imports, and Exports).
In particular, the interface names from std::interfaces are available without
an explicit import std::interfaces;. This prelude is specified by the stdlib
module std::runtime::globals.
Key components:
- The
interfacedeclaration. - The
structthat implements the interface. - The
impl ... as ...declaration that ties them together. - A
module ... as ...declaration for module-level conformance.
Interface declarations#
An interface declares a set of required method signatures.
Syntax:
interface Element {
fn onclick(event: &Event) -> void;
}
Rules:
- Interface members are method declarations introduced with
fn. - Interface methods have no body and end with
;. - Parameter types in interface methods should be explicitly annotated (the compiler should not rely on type inference for interface contracts).
- Interface method parameter lists use the same trailing-varargs marker as
ordinary functions, so a required method may end with
...args: T. - Interface methods are part of a public contract:
- interfaces do not have private members, and
- interface method declarations do not accept visibility modifiers.
Generic interfaces#
Interfaces may declare type parameters:
interface Channel(T) {
fn send(value: T) -> bool;
fn recv() -> T?;
}
Rules:
- Generic parameter lists use the same syntax as structs (
(T, ...)). - Type parameters may provide default type arguments (
T = Type). When defaults are present, use sites may omit trailing arguments that have defaults. - The interface name is a type constructor and must be applied with the
correct number of type arguments where a concrete interface type is required
(for example in
impl ... as ...declarations).
Self in interface signatures#
Within an interface method signature, the special type name Self refers to
the concrete implementing type when checking impl Type as Interface { ... }
conformance.
Interface inheritance (extends)#
Interfaces may use extends for single inheritance:
interface BaseLogger {
fn log(msg: string) -> void;
}
interface FancyLogger extends BaseLogger {
fn warn(msg: string) -> void;
}
Semantics (Supported forms):
- An interface that
extendsanother interface inherits all of the base interface’s method signatures. - A conformance declaration (
impl T as Iormodule ... as I) must satisfy the full inherited interface surface.
Rules (Supported forms):
extendsis permitted only oninterfacedeclarations.- Only single inheritance is permitted (at most one
extendsbase). - Cycles in
extendschains are rejected. - A derived interface may not redeclare a method with the same name as an inherited base method.
Implementations (impl ... as ...)#
An implementation block declares that a concrete type implements an interface and provides method bodies.
Example:
interface Element {
fn onclick(event: &Event) -> void;
}
struct Button {
handle: i64;
}
impl Button as Element {
fn constructor(...) -> Button { ... }
fn onclick(self: &Button, event: &Event) -> void { ... }
}
Applied interface types:
interface Read(T) {
fn read() -> T;
}
struct ByteSource { /* ... */ }
impl ByteSource as Read(u8) {
fn read(self: &ByteSource) -> u8 { /* ... */ }
}
Compiler requirements:
- Represent interface types and
impl ... as ...relationships. - Enforce that all required interface methods are implemented with compatible signatures.
- Treat required interface methods as public by definition:
- impl methods that satisfy an interface requirement may omit
public, but - they may not be explicitly marked
private.
Conformance rules (implementation):
- For an
interface I { fn m(p0: T0, ...) -> R; }, the corresponding impl must provide a methodmwhose signature matches after accounting for the receiver: - the interface method signature itself must omit any explicit
selfparameter; ordinary interface methods always use an implicit receiver, - the impl method’s first parameter is the receiver
self: &Type(ormut self: &Type), and - the remaining parameters, including whether the final parameter is varargs, and the result type must match the interface method.
- Exception (static protocol, Supported forms):
std::interfaces::Deserialize(S)andstd::interfaces::Parse(E, S)are receiverless static protocols. Their conformance does not use a receiver parameter:impl T as std::interfaces::Deserialize(S)providesfn deserialize(value: S) -> Self(noselfparameter),impl T as std::interfaces::Parse(E, S)providesfn parse(value: S) -> std::result::Result(Self, E)(noselfparameter),- calls use
T.deserialize(value). - and
Parsecalls useT.parse(value). - Only
Deserializeparticipates inascasts today.Parseremains an explicit method call so fallible construction stays visible in source. - The implemented conformance check substitutes
Selfrecursively through nested generic result shapes, so interfaces such asParse(E) { fn parse(value: string) -> Result(Self, E); }can be satisfied by impl methods whose concrete result type is a monomorphized specialization of that generic result.
Invalid ordinary interface declaration example:
interface Object {
fn as_string(self: &Self) -> string; // invalid
}
Correct form:
interface Object {
fn as_string() -> string;
}
Generic interface conformance rule:
- When the
asclause names an applied generic interface type (for exampleRead(u8)), all type arguments must be fully known at the conformance site, unless the conformance itself is generic and binds those type parameters (for exampleimpl Data(T) as DataInterface(T)).
Module conformance (module ... as ...)#
A module declaration may declare conformance to an interface:
interface Logger {
fn log(msg: string) -> void;
}
module my_app::logger as Logger;
export fn log (msg: string) -> void {
// ...
}
Name resolution:
-
The interface name in
module ... as Interface;is resolved after the module’s import block is processed, so it may refer to an interface imported later in the file’s import section. -
This allows an unqualified, ergonomic module header form like:
module hello::build as Builder; // Optional: only needed with `--nostd` or when the active stdlib prelude // does not include `Builder`. import { Builder } from "std/interfaces";
Conformance rules:
- For an
interface I { fn m(p0: T0, ...) -> R; }, the corresponding module must provide a functionmwhose signature matches exactly: - there is no receiver parameter for module conformance, and
- the parameter list, including whether the final parameter is varargs, and the result type must match the interface method.
- Conformance compares the call result type of the exported function:
export async fn m (...) -> Ris treated asm(...) -> Promise(R),export task fn m (...) -> Ris treated asm(...) -> Task(R),export async task fn m (...) -> Ris treated asm(...) -> Promise(Task(R)). This allows module interfaces to express async/task entrypoints by writing the appropriate handle type in the interface method result.- In Silk currently, module conformance is checked against the
module’s exported functions (written as
export fn ...), since those are the module members that are visible across module boundaries.
Generic module conformance:
- A module may declare conformance to an applied generic interface type
(for example
module my_app::bytes as Read(u8);). - All interface type arguments must be fully specified (modules do not bind their own type parameters).
Dispatch model (status)#
The current compiler now supports interface-typed runtime values without a separate boxed/vtable runtime. Instead, the native compiler resolves an interface value type to a closed-world union of all known concrete conformers in the current compilation set.
Example:
interface Object {
fn as_string () -> string;
}
let xs: Object[] = [
Foo {},
Bar {},
];
In the current implementation, Object[] is lowered as an array whose element
type is the union of the known Object conformers (Foo | Bar in this
example). A method call like value.as_string() on an interface-typed runtime
value is rewritten by the checker into an ordinary match dispatch over that
union.
Typed-binder match patterns may also name an interface. When the scrutinee has
a concrete struct type and that type implements the named interface, an arm such
as value: Object => ... accepts the scrutinee. This concrete form is selected
statically and therefore also covers single-conformer cases without requiring a
preexisting interface-typed union value.
This means the current runtime interface subset supports:
- interface-typed local bindings, function parameters, borrowed function
parameters (
&Interface), and array elements, - interfaces with empty method sets (
interface I {}) in those same runtime positions when the conformer set is known, - heterogeneous arrays/slices whose declared element type is an interface and whose values come from known conforming concrete types,
- method calls on those interface-typed values when the selected method is part of the shared conforming surface,
- typed-binder
matcharms whose pattern type is an implemented interface for a concrete struct scrutinee, - special-case compiler hooks for specific interfaces (currently
std::interfaces::Dropfor deterministic cleanup; seestd::interfacesandMemory Model (Stack, Heap, and Moves)).
Current limits:
- This is a closed-world compilation strategy, not an open-world trait-object ABI. The conformer set is computed from the known program/package being compiled.
- The runtime representation of an interface-typed value therefore depends on that conformer set. If the set of known conformers changes, the lowered runtime shape can change too.
- The current implementation does not introduce a general boxed interface object or vtable layout.
- There is no language-level guarantee today that an ordinary interface type
such as
Objecthas one stable binary layout that can be passed unchanged across separately compiled libraries, plugin boundaries, or the C embedding surface. - In practical terms, a separately compiled artifact cannot add a new conformer to an already-built interface-typed runtime boundary without recompiling the consumer too, because the consumer’s lowered union shape was chosen from the closed world it already knew about.
- Runtime interface values currently depend on the existing union-value backend, so they are supported where the resulting conformer union is representable by Silk currently.
Closed-world compilation versus open-world ABI#
The current compiler strategy is:
- source-level interface declarations and
impl ... as ...conformance, - compile-time discovery of the conformers that are present in the current build,
- lowering runtime interface values to a concrete union over those conformers,
- and lowering interface method calls to ordinary
matchdispatch over that union.
The current compiler does not promise:
- a heap-boxed trait object,
- a stable
(data pointer, vtable pointer)object model, - a public binary layout for arbitrary interface values,
- or open-world dynamic dispatch where unknown future conformers can be linked
in later without recompiling code that stores or passes
Interfacevalues.
This distinction matters at package and ABI boundaries:
- Within one closed-world native build, interface-typed locals, parameters, borrows, and arrays can work because the compiler can see the conformers.
- Across separately compiled binary boundaries, you must not assume that
Interfaceitself is a stable interchange type. - For C ABI boundaries, exported library interfaces, plugins, or other open-world extension points, use an explicit concrete ABI:
- an enum/union chosen by the API author,
- a concrete struct carrying tagged data,
- or an explicit function-table struct if you need manually designed dynamic dispatch.
Treat ordinary Silk interfaces today as a language-level conformance and closed-world compilation feature, not as a general-purpose open-world trait object ABI.
Packages, Imports, and Exports#
This document specifies the initial surface syntax for packages, imports, and exports in Silk. The semantics are intentionally minimal for now and will be extended as the compiler’s resolver and linker mature.
Notes#
package <path>;declarations (with the module ordering rules below).module <path>;declarations (mutually exclusive withpackage) includingmodule ... as <Interface>;conformance checking.- Inline module declarations (
module Name { ... }/export module Name { ... }) for nested namespaces. - A contiguous top-level
importblock (package imports andfrom "..."module specifier imports). - Named re-exports:
export { Name, Other as Alias };(exports an in-scope value name so other modules may import it). - Package imports (
import std::strings;) that make a package’s exported values available for use in the importing source file. - Package-import aliasing (
import std::window;,import std::strings as str;) that binds a namespace alias for qualified access (window::is_supported,str::eq). - Qualified symbol imports (
import std::strings::Builder;,import std::io::println;,import ::malloc;) that bring a single symbol into scope without importing the entire package namespace. - Module-specifier imports (
import { Name } from "...";,import ns from "...";) including: - relative file imports (
from "./file.slk"), - std package specifier imports (
from "std/strings"; a trailing.slkis accepted for compatibility and stripped before package lookup), - dependency-rooted POSIX module specifiers (
from "logger/lib") resolved through[dependencies]in the importing package manifest; dotted dependency keys such asmy.dep.bmatch quoted paths such as"my/dep/b"by longest prefix, - and unquoted package-path specifiers (
from ns_pkg::subpath). - Default exports (
export default fn ...andexport default Name;) and default imports that bind either: - the default-exported symbol, or
- the module namespace when no default export exists.
- Declaration-only exported function prototypes (
export fn name(...) -> T;) for header-style “prototype modules” that describe an exported surface without providing a body (satisfied by link-time definitions from other Silk sources and/or.o/.ainputs).
Not implemented yet:
- Bulk re-exports (“export from ...”) and forwarding of export surfaces.
- A stable, fully specified “package build” system outside the current CLI/module-set
model (see
Package Manifests (silk.toml)for current manifest support).
Working examples (recommended to read alongside this doc):
- Package imports:
examples/,examples/,examples/ - File imports (named + default):
examples/,examples/,examples/ - Package-path imports (
from pkg::name):examples/,examples/
When an import fails, the relevant error codes live in Compiler Diagnostics
(notably E1001–E1006, plus E2003/E2004 for invalid imported names).
Terminology#
- Source file: a single
.slksource file. - Package: a named collection of source files that share a namespace (declared via
package ...;). - Module declaration: a
module ...;header that declares a namespace and a compile-time-only module value, and may declare interface conformance viaas. - Module set: the set of source files the compiler is compiling together for a given command. Package imports can only resolve to packages that exist in this module set.
- Named import:
import { A, B as C } from "...";(introduces unqualified names). - Default import:
import X from "...";(binds either a default export symbol or a namespace, depending on what is imported). - Namespace import: a default import that binds a module or package namespace; you
access its members as
X::Name.
Packages#
A Silk program is organized into packages and source files.
Each source file may declare the package it belongs to using a package
declaration at the top of the file:
package my_app::core;
Rules:
- Each module MAY declare at most one
packagedeclaration. - When present, the
packagedeclaration MUST appear before all other top-level declarations in the module; it is the first declaration in the file. - Package names are sequences of identifiers separated by
::. - As a special case, the keyword
taskis permitted as a::-qualified segment sostd::taskis a valid package name. std::stringsstd::taskmy_app::coreexample- The standard library lives under the reserved
std::namespace, for examplestd::strings,std::memory, etc.
If a source file omits a package declaration, it is treated as belonging to an
implementation-defined default package (for example, the “main” package for
an executable). The exact rules for default packages will be specified as
multi-module builds are implemented.
In the current silk CLI implementation, when building a package via a package
manifest (silk.toml), source files that omit package default to the
manifest’s package.name. See Package Manifests (silk.toml).
Modules (module)#
module declares a named module namespace and a compile-time-only module
value.
Syntax:
module my_app::core;
module my_app::core as SomeInterface;
Rules:
- A source file MAY declare at most one
moduledeclaration. - A source file MAY declare at most one of:
- a
packagedeclaration, or - a
moduledeclaration. - When present, the
moduledeclaration MUST appear before all other top-level declarations in the source file; it is the first declaration in the file. - Module names follow the same
::-qualified naming rules as packages. - Modules are compile-time-only values: there is no runtime representation for a module value.
- If a module declares
as <Interface>, the compiler MUST validate that the module satisfies the interface surface as specified inInterfaces.
Inline modules (module Name { ... })#
In addition to the source file header form (module ...;), Silk supports
inline modules as a nested-namespace mechanism inside a file:
package my_package;
export module inner_module {
export fn hello () -> string {
return "hello world";
}
}
Rules :
- Inline modules MUST appear at top level (not inside function blocks).
- The inline module name is a single identifier.
- The body is a brace-delimited list of top-level declarations; inline modules may be nested.
package, header-formmodule ...;, andimportdeclarations are not permitted inside an inline module body.- Declarations inside an inline module are referenced from outside using
::qualification (inner_module::hello()). - Within an inline module body, direct nominal declarations in that inline
module (
type,struct,enum,error,interface) are available through unqualified type lookup: impl User { ... },new User(),User{ ... }, andtype Alias = UserresolveUserto the current inline module before falling back outward.- other inline-module declarations still require explicit
::qualification in Silk currently. export module Name { ... }exports the namespace:- exported declarations inside it become part of the containing package’s
export surface with their names prefixed by
Name::(for exampleinner_module::hello), - exported type aliases keep the same prefixed type name surface (for
example
users::UserId) and may appear in exported function parameter and result types, - nested
export moduledeclarations extend the prefix (for exampleouter::inner::name). - Imported namespace qualifiers preserve exported inline-module prefixes. For
example, after
import app as a;, exported members ofexport module users { ... }in packageappare available asa::users::Name; afterimport lib from "./lib.slk";binds a file namespace, the same exported inline-module member is available aslib::users::Name.
Source File Header Ordering (Mandatory)#
In each source file, top-level declarations must appear in this order:
- Optional
packageormoduledeclaration (package ...;ormodule ...;). - Zero or more
importdeclarations, as a contiguous block. - All other top-level declarations.
This ordering is enforced by the parser/resolver and keeps dependency structure easy to understand and tooling-friendly.
Imports#
Source files may refer to other packages or modules via import declarations:
package my_app::core;
import std::strings;
fn main () -> int {
return 0;
}
Rules:
importdeclarations MUST appear at top level (not inside functions or blocks).- All
importdeclarations in a module, if any, MUST appear after the optionalpackagedeclaration (if present) and before any other kind of top-level declaration. In other words, imports form a contiguous block at the beginning of the module immediately following the optional package. - An
importpath is a sequence of identifiers separated by::, matching the package naming rules above (including thestd::taskspecial case). - As with expression/type qualified names, an import path MAY start with
::to explicitly name the global namespace (the unnamed package). importdeclarations identify dependencies and bring exported symbols from the imported package into scope in the importing module, subject to the visibility rules below.- Currently:
- importing a package makes its exported
letbindings with explicit type annotations visible as ordinary, unqualified names in the importing module (for example,import util;followed byanswerrefers toutil::answerwhenutilexportslet answer: int = 42;), - imported exported
letbindings are also reachable via qualified names of the formpkg::name(for example,util::answerafterimport util;); both unqualified (answer) and qualified (util::answer) forms are accepted for now, but the qualified form reflects the intended package-namespaced style, - exported functions (
export fn) are callable across packages for the compiler’s current backend subset: - within a package, top-level functions form a shared namespace across all modules in that package (so functions in one module may call functions defined in another module of the same package),
- when a module imports a package, that package’s
export fndeclarations become callable from the importing module, - both unqualified (
foo()) and qualified (util::foo()) call forms are accepted initially for imported exports, matching the current constant-import behavior, though the qualified form reflects the intended package-namespaced style, - this callable subset is limited to the compiler’s current
code generation subset (supported parameters/results, direct calls, and
structured control flow supported by the IR→ELF backend on
linux/x86_64), - struct type names (
structdeclarations) from imported packages are visible in the importing module for the supportedstructsubset: - the qualified form
pkg::Structis always accepted whenpkgis imported, - the unqualified form
Structis accepted when it is unambiguous across the module’s imports and does not conflict with a locally defined struct name, - when multiple imported packages define the same struct name, the unqualified form is rejected as ambiguous and the qualified form must be used,
- enum type names (
enumdeclarations) from imported packages are visible in the importing module for the supported enum subset: - the qualified form
pkg::Enumis always accepted whenpkgis imported, - the unqualified form
Enumis accepted when it is unambiguous across the module’s imports and does not conflict with a locally defined type name, - enum variants are referenced relative to the enum name (
Enum::Variantorpkg::Enum::Variant), - unqualified type names are introduced only by:
- local declarations in the current package, and
- explicit imports (package imports and module-specifier named imports). The checker does not resolve an unqualified type name by scanning every package in the module set for a “unique match”.
- if an imported package does not exist, resolution fails before type-checking (see the resolver).
- a leading
::on a qualified name forces lookup in the global namespace (the unnamed package), bypassing any same-named declarations in the current package or imported packages. This is intended as an explicit escape hatch for shadowing (for example, calling::malloc(...)when the current module also defines or exportsmalloc). The prefix is valid in both expression and type positions, including: - values:
::malloc(...), - types and struct literals:
::Fooand::Foo{...}, - enum variant paths/patterns:
::E::Variant.
Package import aliasing (import pkg; / import pkg as alias;)#
In addition to importing exported symbols, a package import also binds a namespace alias that can be used for qualified access:
import std::window;
fn main () -> int {
if window::is_supported() {
return 0;
}
return 1;
}
Rules :
-
The default alias is the last segment of the imported package path:
-
import std::strings;bindsstrings -
import std::runtime::mem;bindsmem -
You may override the alias with
as:import std::strings as str; -
If the alias would conflict with an existing in-scope name, the import is a compile-time error; use
asto choose a different alias or use the fully qualified package path (for examplestd::strings::eq).
Qualified Symbol Imports#
In addition to importing whole packages, a module may import a single symbol by fully qualifying it:
import std::io::println;
import std::url::URL;
import ::malloc;
Semantics:
- If the import path matches a package name present in the module set, it is a
package import (
import std::io;). - Otherwise, it is treated as a qualified symbol import:
- the compiler finds the longest package-name prefix of the path,
- the remaining suffix is the symbol name within that package (it may contain
::due to exported inline modules), - the symbol is introduced into the importing module under its final path
segment (for example,
printlnforimport std::io::println;). - When the import path begins with
::, the symbol is resolved from the global namespace (the unnamed package) and is not subject to package export gating.
import { Name } from "..."; remains the preferred form when you need to rename
imports (as) or import from a file path.
Global namespace (::name) rules :
- The global namespace is the package formed by modules that have no
package ...;or header-formmodule ...;declaration (their package name is empty). ::NameresolvesNamefrom that global namespace, if a matching declaration exists in the current module set.::Outer::Inner::NameresolvesOuter::Inner::Namefrom that same global namespace (for example, names nested under inline modules in a global module).- Global names are only accessible via the explicit
::prefix; there is no implicit prelude import of the unnamed package. - Separately, when the standard library is enabled (the default), the compiler
provides a small implicit std prelude of selected standard symbols (for
example
Resultand thestd::interfacesinterface names) as specified bystd::runtime::globals. Use--nostdto disable this behavior.
Future extensions may introduce bulk re-exports and more fine-grained import forms beyond the current package/file-path import surface. Such features will be documented here before they are implemented.
Example: a two-module package program#
Two modules can share a package name and export symbols for other packages to use.
// util.slk
package util;
export let answer: int = 41;
export fn add1 (x: int) -> int {
return x + 1;
}
// app.slk
package app;
import util;
fn main () -> int {
// Currently, both unqualified and qualified access are
// accepted after importing a package. Prefer the qualified form to make the
// origin explicit.
if util::add1(util::answer) != 42 {
return 1;
}
return 0;
}
Package imports resolve against the module set#
A package import resolves only if the package exists in the current module set.
This matters most when you use package-path specifiers (from ns_pkg) or when
you expect a package import to find a package that is not otherwise present.
Tooling note (the silk CLI):
- The language semantics are still “imports resolve against the module set”. The CLI grows the module set by loading additional source files.
- In addition to auto-loading
std::...packages from the stdlib root, the CLI MAY load non-std::packages from a package search path when an unquoted package path is imported (for exampleimport api from my_api;). - The package search path is configured via
SILK_PACKAGE_PATH(PATH-like: roots separated by:on POSIX). - A package name like
my_api::coremaps to the filesystem candidate<root>/my_api/core/silk.toml. The first matching manifest in search order is used. - Qualified imports that include extra
::segments (e.g.my_api::core::Thing) are treated as qualified symbol imports: the CLI resolves the longest package prefix that exists (my_api::core, thenmy_api) and loads that package into the module set.
Example: bringing a package into the module set via a file import, then importing the package namespace:
// main.slk
import { answer as ignored } from "./support_pkg_ns_pkg.slk"; // declares `package ns_pkg;`
import pkg from ns_pkg; // now resolves because `ns_pkg` exists in the module set
fn main () -> int {
return pkg::add1(pkg::answer);
}
If you omit the file import (or otherwise fail to include a module that declares
package ns_pkg;), the package import fails with E1001 (“unknown imported package”).
From the CLI, the usual fix is to ensure the missing package’s module(s) are
part of the command’s module set (for example by passing their .slk files to
silk check / silk build, or by adding a file import). See
``silk CLI and CLI Usage Examples.
Import Specifier Imports (JS-style)#
In addition to import pkg::name; package imports, Silk supports JS-style
import forms that use from with either a quoted module path or an unquoted
package path.
The current JS-style forms are:
- Named imports:
import { Name } from "<specifier>";orimport { Name } from package::path; - Default imports / namespace imports:
import Name from "<specifier>";orimport Name from package::path; - Ambient imports:
import "<specifier>";
An import specifier string is interpreted in one of three ways:
- File specifier: the string begins with
./or../. These imports resolve to a module by file path. - Std package specifier: the string begins with
std/. These imports normalize/to::and resolve against the linked std package surface, not by direct file path lookup. - Dependency module specifier: any other string is a POSIX-style module path
rooted at a dependency key in the importing package's
silk.toml. For example, with[dependencies] logger = { path = "../logger" },from "logger/lib"resolves to that dependency'slib.slksource module. If the extension is omitted,.slkis appended. A bare dependency key such asfrom "logger"resolves to the dependency's defaultlib.slksource module. Dotted dependency keys map to slash prefixes, so[dependencies] my.dep.b = { path = "../dep-b" }makesfrom "my/dep/b"resolve to thelib.slksource module in../dep-b.
When a dependency entry omits path, lookup is contextual to the importing
package. Relative SILK_PACKAGE_PATH entries, and the default packages/
directory when SILK_PACKAGE_PATH is unset, are resolved from the importing
package root and then from parent package roots up to the root package of the
current graph. This allows dependency manifests to declare their own nested
dependencies without depending on the command's current working directory.
Quoted import specifiers MUST NOT contain ::. Use an unquoted package path
such as from oro::logger when importing by package namespace.
This mirrors the common JS convention that relative file imports must start
with ./ or ../. Silk additionally reserves the std/ prefix for stdlib
package imports backed by the linked stdlib.
Examples (namespace-style imports):
import ui from oro::ui; // package namespace
import helpers from "./helpers.slk"; // file module namespace (if no default export)
import logger from "logger"; // dependency-key root: dependency the implementation
fn main () -> void {
let opts: &ui::WindowOptions = new ui::WindowOptions();
helpers::do_something();
logger::info();
}
Example with dependency-key mapping:
[dependencies]
logger = { path = "../libs/logger", version = "^0.1.0" }
local.math = { path = "../libs/local-math", version = "0.3.0" }
my.dep.a = { version = "^1.2.0" }
my.dep.b = { version = "^2.0.0" }
import logger_file from "logger"; // dependency default source module
import logger_pkg from oro::logger; // package.name from logger's manifest
import { add } from "local/math"; // local.math -> ../libs/local-math
import my::dep::a::transform; // package-search package namespace
import my::dep::b::some_function; // binary package definition + artifact
import { some_function as some_from_defs } from "my/dep/b";
fn main () -> int {
return logger_file::info()
+ logger_pkg::info()
+ add(20, 1)
+ transform(10)
+ some_function()
+ some_from_defs();
}
In this example:
loggeris an explicit path dependency whose package may be namedoro::logger; quoted imports use the local dependency key ("logger"), while unquoted package-path imports useoro::logger.local.mathis an explicit path dependency whose quoted import root is"local/math".my.dep.ais found from the package search path asmy/dep/a.my.dep.bis found from the package search path asmy/dep/b; if it ships one definition file and a compatible native[[artifact]], the unquoted symbol import and exact quoted named import can both be satisfied by that package.
Ambient imports#
An ambient import loads a module into the module set without introducing any imported names into local scope:
import "./my_api.slk";
import "std/io";
Notes:
- Ambient imports use the same specifier interpretation rules as other specifier-based imports:
.//../paths are resolved as file imports,std/<path>is normalized to a std package name,- other strings are dependency-rooted POSIX module paths matched against
[dependencies]keys. Dotted keys match slash-separated path prefixes, and the longest matching key wins. - Ambient imports do not bind a namespace or import any symbols. If you need to call a function or reference a type from the imported module, use a named import, a default import (namespace import), or a package import.
- Ambient imports are useful for declaring dependencies that exist only to:
- satisfy prototype/definition conformance rules (see below), or
- ensure a module is present in the module set so its types and methods are available for type checking and monomorphization.
Named imports#
Named imports import selected exported names directly into the importing module:
import { StringBuilder, write_u8 as writeByte } from "./runtime.slk";
Notes:
- There is no combined
import foo, { bar } from "...";form in the current grammar. Use separateimportdeclarations. - File specifiers should include the
.slkextension explicitly.std/...strings are package specifiers rather than file paths.
Rules:
- File imports MUST appear in the same import-declaration block as package
imports: after the optional
packagedeclaration and before any other top-level declaration. - The
fromkeyword is part of the import syntax. - The
fromspecifier may be either: - a string literal (
from "./file.slk",from "std/io",from "logger/lib"), or - a package path (
from std::io;,from ns_pkg::sub;). - String literal specifiers MUST NOT contain
::;::belongs to package path syntax. - If the specifier is a file specifier, it is resolved relative to the
importing file’s directory.
./and../path segments are permitted. Absolute paths and backslash-separated paths are rejected in source imports. - If the specifier starts with
std/("std/<path>"or"std/<path>.slk"), it is a std package specifier./is normalized to::, and a trailing.slkis accepted for compatibility and stripped before package lookup. For example,from "std/io"resolves packagestd::io. - If the specifier is an unquoted package path, it is interpreted as a
package name (using the same
::-separated syntax aspackagedeclarations) and is resolved via the package graph. - If the specifier is a quoted dependency module specifier, it MUST match a
dependency key from the importing package manifest. Dot-separated dependency
keys match slash-separated import prefixes (
my.dep.bmatches"my/dep/b"), and the longest matching key selects the dependency root. The remaining path names a module under that dependency's source-module directory; when there is no remaining path,lib.slkis used. The dependency key is independent of the dependency manifest'spackage.name; quoted dependency imports use the key, and unquoted package-path imports usepackage.name. - Binary-only dependency packages may omit implementation sources and ship
[package].definitionsplus a compatible native[[artifact]]. In that case, an exact root specifier such asfrom "my/dep/b"can bind named imports from the package's single definition file when the default source module is absent; the package artifact is then linked automatically by package builds for supported targets. - The imported module MAY declare a
packageor omit it. File specifiers refer to the target module by file path, not by package name.
Exported names for named imports:
- Named imports can import:
- exported values:
export fn, namedexport default fn,export let, and exportedextbindings, and - type names:
struct,enum,error, andinterfacedeclarations. In Silk currently, type exports are recorded but not fully enforced for all type declarations; loading a module into the module set makes its type declarations available for type checking when that module’s package is imported (and for some fully-qualified uses in monomorphized declarations). - exported type aliases:
export type ...;, and - exported Formal Silk theories:
export theorydeclarations (importable so they can be applied via#theory Name(args);). implblocks do not introduce importable names directly, but loading the imported module makes its methods available for method-call checking on the corresponding types.
Name binding rules:
- Each entry in the
{ ... }list names one imported symbol. ascan be used to rename an imported symbol (Name as Alias).- For values (
fn/let/ext), this introduces a value alias. - For type names (
struct/enum/error/interface) and exported type aliases (export type), this introduces a localtypealias (transparent: it does not create a new type identity). - For Formal Silk theories (
export theory), this introduces a theory alias. - Imported names are introduced into the importing module as unqualified names (matching the existing behavior for package imports).
- Importing an unknown name from a file is an error.
- Importing the same value name from multiple file imports without aliasing is an error.
- Importing a value name that is already visible in the module (for example via same-package scope or a package import) is treated as a no-op unless it conflicts with a local declaration in the importing module.
- Importing a type name that is already visible in the module is treated as a no-op.
Default imports and namespace imports#
A module may declare a single default export and importing modules may bind that default export with a JS-style default import:
// module.slk
package module;
export default fn () -> int {
return 1 + 2;
}
// main.slk
import foo from "./module.slk";
fn main () -> int {
let value = foo();
if (value != 3) {
return 1;
}
return 0;
}
Rules:
-
Default exports are module-level and are consumed by default imports (
import Name from "<specifier>";). -
A default export may be declared in either of two ways:
-
a default-exported function declaration:
-
export default fn ...(the function name is optional only in this form), -
or a default-export statement:
-
export default Name;(names an in-scope symbol in the current module). -
Default exports may target any top-level symbol kind that can be referenced by name:
-
functions (
fn), -
top-level bindings (
let/const/var), -
external bindings (
ext), -
type aliases (
type), -
nominal types (
struct,enum,error,interface), -
Formal Silk theories (
theory). -
Each module MAY declare at most one default export.
-
A default export is distinct from named exports:
-
export default fn add () -> int { ... }declares a default export whose internal name isaddwithin the module, -
but it does not implicitly create a named export of
addfor other modules. To export it as a named export, writeexport fn add ...(or add an explicit named export form once one exists in the language). -
The function name after
fnis optional only for default exports. When the name is omitted (export default fn () -> ...), the function is anonymous in the surface language and can only be referenced by importing it via a default file import. -
Default imports have two behaviors depending on whether a default export exists:
-
If the imported module declares
export default, the local name binds to that default-exported symbol. -
If the imported module does not declare a default export, the default import becomes a namespace import: the local name refers to the imported module’s namespace and its exported names are accessed via
foo::Name.
In other words: if there is no explicit default export, the module’s namespace is treated as the default export.
- When a default import binds a default export, it introduces a single unqualified name into the importing module:
- if the default export is callable (a
fnor anextfunction), it binds a callable value name (foo()), - if the default export is a type (
struct/enum/error/interface/type), it binds a type name usable in type positions (and as the head of struct literals), - if the default export is a Formal Silk theory, it binds a theory name that
may be applied via
#theory foo(args...);, - if the default export is a non-callable value (
let/const/varor a non-functionext), it binds a value name. When a default import binds a namespace, it does not introduce any unqualified imported names; you must usefoo::Nameto access exported names. - Namespace imports also expose exported inline-module members by keeping the
inline-module prefix after the namespace name, such as
foo::users::make()andfoo::users::User. - Using a namespace import name as a callable (e.g.
foo()) is an error; add an explicitexport defaultto the imported module or use a named import.
Package namespace imports:
- For an unquoted package path (for example
import ui from ui;), the default import binds the package’s default export when the package declares one. Otherwise, it binds a namespace and exported names are accessed viaui::Name.
Exports#
Top-level declarations can be marked as exported using the export
modifier:
package my_app::core;
export fn main () -> int {
return 0;
}
export let answer: int = 42;
Rules:
exportis not allowed inside blocks; it applies only to module-level declarations. Insideimplblocks,publiccontrols method visibility andexportis reserved for static members.- The implementation supports
exporton: - functions (
export fn ...), including a declaration-only prototype form (export fn name(...) -> T;) used for header-style interface modules, letandconstbindings (export let ...,export const ...).extdeclarations (export ext name = ...;),- Formal Silk theories (
export theory Name(...) { ... }), typealiases (export type Name = ...;),structdeclarations (export struct Name { ... }),enumdeclarations (export enum Name { ... }),errordeclarations (export error Name { ... }),interfacedeclarations (export interface Name { ... }),- static members inside
implblocks (impl T { export fn ... }with noselfreceiver). - The
exportmodifier marks a declaration as part of the package’s externally visible surface. The exact visibility rules across packages (including how exports appear in the resolver and back-end symbol tables) will be specified and implemented alongside the package graph inCompiler Architecture.
Currently, most type names are treated as visible across
module boundaries once the relevant module(s) are loaded into the module set.
The export modifier is still recorded on type declarations so the
package/export model can be tightened later without changing source.
The checker does not treat “globally unique” unqualified type names as implicitly imported: if a type name is not introduced by a local declaration or an explicit import, it is unknown (even if some other package in the module set defines a type with that base name).
Prototype exports (export fn ...;)#
In addition to ordinary function definitions (export fn ... { ... }), a module
may declare a prototype (a declaration without a body) by terminating the
signature with ;:
module bar;
export fn foo (value: string) -> int;
This is the Silk analogue of a C/C++ header prototype or a TypeScript *.d.ts
declaration file:
- Other modules may import the prototype (named import or namespace import) and type-check calls against its signature.
- The prototype itself does not provide an implementation. The symbol must be provided at link time by:
- another Silk source file in the same package that defines
export fn foo ... { ... }, and/or - an object/archive input that defines the symbol (for example a
.o/.aproduced by a C compiler). - Prototype declarations may include Formal Silk contract annotations (
#require/#assure/ contract#theoryuses). This is the visible contract surface for callers; when the implementation is precompiled and the function body is not available in the module set, callers still type-check and may verify call sites against the prototype’s contract surface.
When both a prototype declaration and a source-level implementation are present in the same build/module set, the compiler enforces:
- the signatures match, and
- the implementation package explicitly imports the prototype module (via a file import) so the relationship is declared in source.
Example (consumer imports the prototype):
import { foo } from "./ibar.slk";
export fn main () -> int {
return foo("hello");
}
Example (implementation imports the prototype and provides the body):
module bar;
import "./ibar.slk"; // ambient import; used for conformance only
export fn foo (value: string) -> int {
return 0;
}
This pattern is equivalent in intent to describing the export surface as an
interface and declaring module conformance (module ... as ...), but it is
file-based and designed to support separate compilation + link-style workflows.
Re-export declarations (export { ... };)#
In addition to export fn ... and export let ..., Silk supports exporting an
already in-scope name via a re-export declaration:
import { my_function } from "./module.slk";
export { my_function };
This is the idiomatic way to build “barrel” modules that forward selected exports from other modules.
Rules :
- A re-export declaration must appear at top level and ends with
;. - Each entry in the
{ ... }list names a local in-scope symbol. - The entry may rename the exported name:
export { localName as ExportedName };. - Re-exported names are part of the module/package export surface, so other
modules may import them via
import { Name } from "./barrel.slk";. - Currently,
export { ... }supports values and exported Formal Silk theories (theorydeclarations). It does not export type names.
Notes#
The current compiler front-end:
- parses
packagedeclarations into the AST, - parses
importdeclarations into the AST, - records whether top-level declarations are marked
export: - values (
fn,let,ext), - Formal Silk theories (
theory), - type aliases (
type), - type declarations where supported (
error,interface), - and similarly tracks
exportfor staticimplmembers andexport defaultfor top-level functions.
The type checker partially respects package, import, and export today:
- a multi-module helper (
the module-set import helper) seeds each module’s top-level environment with exportedletbindings (with explicit type annotations) from any packages it imports, making those constants visible as unqualified names in the importing module, - within a module set, function calls are type-checked against:
- all top-level functions in the current package (across all modules of that package), and
export fndeclarations from any imported packages, while still rejecting calls to non-exported functions across package boundaries.
A package-level resolver now exists in the implementation and is used by the
ABI build path (silk_compiler_build) to:
- group modules into packages (including an implementation-defined default
package for modules that omit
package), - ensure that every
importrefers to a package that exists in the current module set, - reject cyclic package graphs (e.g.
package aimportingbwhilebimportsa).
Resolver errors are surfaced through libsilk.a as human-readable
errors (for example, "unknown imported package" or
"cyclic package imports"), and are covered by both Zig tests and C99
tests under the C ABI test harness.
In addition to the package graph, the resolver also builds per-package export tables:
- for each package, all
export fnandexport letdeclarations are collected into a symbol list, - duplicate exported names within the same package are rejected, except for the
prototype/definition pairing described above (
export fn name(...) -> T;+export fn name(...) -> T { ... }), which is accepted only when the signatures match, - these export tables are currently used only for consistency checks; the type checker does not yet use them for cross-package name resolution.
Future work (tracked in the implementation plan) will:
- extend the resolver and checker to:
- map imports to concrete modules and exported symbols,
- ensure only exported symbols are visible across package boundaries,
- propagate package and export information into the IR and back-end so that symbol visibility and linkage match these rules.
Common Pitfalls#
- Forgetting semicolons:
packageandimportdeclarations end with;(parse error,E0001). - Imports not at the top: imports must come immediately after the optional
packagedeclaration and before any other top-level declaration (E0001). - Assuming package imports find code automatically: a package import can only
resolve if the package exists in the module set (fix by adding the relevant
.slkfiles to the build, or by file-importing them; missing packages areE1001). - Calling a namespace import: if
import foo from "./mod.slk";binds a namespace (because there is no default export), thenfoo()is invalid; usefoo::Nameor addexport default(E2018). - Name collisions with named imports: when importing from multiple modules, use
asto rename one binding (E2004).
Optional#
The Optional type provides a safe way to represent values that may or may not
be present, instead of relying on sentinel values such as null.
- The nominal type constructor is
Option(T). - The shorthand
T?is sugar forOption(T)and is the recommended form. - Optional values are constructed using
Some(...)andNone(the compiler also acceptsnoneas an alias ofNone). - The
nullliteral is distinct fromNone, but may coerce toNonewhen an optional type is expected. - Use
match,?.(optional chaining), and??(coalescing) to consume optionals.
Declaring Optional Types#
You can declare variables or fields as optional using either:
T?(idiomatic suffix form),Option(T)(nominal form).
The language design treats these as equivalent.
Implementation
-
The type system (
the implementation) models optional types, and the parser now accepts both: -
the suffix form
T?in type annotations, and -
the nominal form
Option(T)for simple cases (a single type argument), which is desugared into the same internal optional representation asT?. -
For example, the following is valid today and type-checks successfully (note that the current compiler requires
letinitializers; seeCompiler Diagnostics,E2015):fn main () -> int { let a: string? = None; let b: Option(string) = None; return 0; } -
The current
linux/x86_64IR→ELF backend subset now supports a first slice of optional values for a subset of payload types: -
construct optionals via
NoneandSome(value), -
access fields of optional structs via optional chaining (
opt?.field, producing aFieldType?value), -
call methods on optional structs via optional chaining (
opt?.method(args), producing aResultType?value), -
use nested optionals (
T??) for a subset of payloads in the current backend (see below), -
compare supported optionals via
==/!=(tag + payload equality; nested optionals compare recursively), -
unwrap optionals via
??(coalescing) with short-circuit evaluation of the fallback expression, -
explicitly branch on optionals via the
matchexpression (see ``matchExpression (and Statement)), -
and pass/return such optionals between helpers in the supported IR subset.
Supported optional payloads in this backend subset include:
- scalars (
bool,char,f32,f64,int, and fixed-width integers), string(lowered as{ ptr: u64, len: i64 }),- enums (tagged unions) in the current enum backend subset (lowered as
(u64 tag, payload_0, payload_1, ...)), - and the supported
structsubset (0+ fields of supported value types, including nested structs and optionals; seeStructs, Impl Blocks, and Memory Layout).
In this subset, optionals are represented at IR boundaries as a Bool tag
followed by the payload scalars: (Bool tag, payload0, payload1, ...) where
tag=0 means None and tag=1 means Some(...). The payload scalar slots
follow the same lowering rules as the underlying non-optional type (1 scalar
for scalar payloads, 2 scalars for string, N scalars for the current struct
subset, and N scalars for enums (including the enum’s own u64 tag slot).
Nested optionals (T??) are supported in this backend subset for the same
payload subset (scalars, string, enums, and the supported struct subset).
In this subset, T?? is represented as an outer optional whose payload is
the full inner optional representation: for example int?? lowers as
(Bool tag0, Bool tag1, i64 payload).
- Not yet implemented:
- optional chaining beyond the current optional-struct field access and optional method call subsets (for example chaining through optional fields and optional indexing),
matchover non-optional scrutinee types (and richer pattern forms beyondNone/Some(...)),- and richer optional forms beyond the backend.
Note: optional payload equality (== / !=) is still limited in the current
backend subset; comparisons against None are supported broadly, but full
payload equality for all optional payload kinds (notably optional-of-enum) is
still evolving.
For the current C ABI mapping of optionals in exported function signatures
within the supported backend subset, see C99 ABI and libsilk.a`` and
External Declarations (ext).
Creating Optional Values#
An optional can be:
None— the empty state.Some(value)— the value‑holding state.
Examples from the spec:
let age: u32? = None;let age: u32? = Some(30);struct User = { profile: None };profile: Some({ email: "some@example.com", age: Some(30) })
The compiler infers the optional’s element type from context when possible.
Equality comparisons provide optional type
context for None / Some(...) operands, so forms like opt == None and
opt == Some(value) type-check when opt has type T?.
None: The Empty State#
None represents the absence of a value.
Spelling note: None may also be written as none (alias). The null literal
is a distinct literal that can coerce to None in optional contexts.
Key points:
Nonecan be assigned to anyT?; its concreteTis inferred.- In pattern matching and control flow,
Nonecorresponds to the empty branch.
Some(value): The Value-Holding State#
Some(value) wraps a concrete value in an Option(T).
Key points:
- The type of
Some(value)isT?(orOption(T)). - Nested optionals are allowed (e.g. a struct containing fields that are
T?).
Optional-Coalescing Operator ??#
The ?? operator unwraps an optional by providing a fallback value if it is None.
From the spec:
- It “coalesces” the optional’s value and the default into a single, non‑optional result.
- The expression
opt ?? default_valuehas typeTwhenopthas typeT?. - When
opthas typeT??, the expressionopt ?? default_valuehas typeT?(it unwraps one optional layer). - It composes naturally with optional chaining.
Example:
let email_address: string = user2.profile?.email ?? "no-email-provided@domain.com";
Scope note:
??is primarily the optional-coalescing operator.- The same token is also used for recoverable
Result-like values:result ?? fallbackyields theOk(...)payload or the fallback forErr(...). - The same token is also used for ordinary named enums with exactly two declared variants:
- if the first declared variant is unit,
value ?? fallbackyields that enum value, - if the first declared variant carries exactly one payload, it yields that payload,
- and if the value is the second declared variant,
fallbackis evaluated. - The right-hand side may also be one of the narrow terminal control-flow
forms accepted only after
??: value ?? return exprvalue ?? breakvalue ?? continue- These forms keep the same validity rules as their statement counterparts:
returnmust be valid in the enclosing function and type-check against its result type,breakandcontinueare only valid inside loops.- This is still a narrow rule for coalescing. It does not make
return,break, orcontinuegeneral expressions elsewhere in the language. - The optional and recoverable-result forms are distinguished by the left-hand
operand type; expression
matchremains the more general payload-aware tool when you need explicit names, multiple payload elements, or more than two states.
Examples:
fn read_port () -> int {
let port: int = maybe_port() ?? return 80;
return port;
}
fn drain () -> int {
let mut seen: int = 0;
loop {
let value: int = next_value() ?? break;
seen = value;
}
return seen;
}
fn scan (values: int?[]) -> int {
let mut found: int = 0;
for item in values {
let value: int = item ?? continue;
found = value;
}
return found;
}
Using Optional Values#
The spec provides several mechanisms for working with optionals:
- Optional chaining
?.: user.profile?.emailyieldsstring?.- If any link in the chain is
None, the result isNone. - Optional method calls are also supported:
user.profile?.email_len()yieldsint?.- When the receiver is
Some(v), the call evaluates asSome(v.email_len()). - When the receiver is
None, the call evaluates asNone. - Coalescing
??: - Converts an optional into a non‑optional by supplying a default.
- Explicit checking via
match: - Pattern‑matching on
Some(...)/Noneto handle both cases explicitly.
Optional combinators (methods)#
In addition to match, ?., and ??, the compiler provides a small set of
combinator methods on optional values (T?). These are designed to feel
familiar to Rust developers while preserving Silk’s explicit move/cleanup rules
(Memory Model (Stack, Heap, and Moves)).
Supported methods (Supported forms):
opt.is_some() -> boolopt.is_none() -> boolopt.map(f) -> U?wheref: fn(T) -> Uopt.and_then(f) -> U?wheref: fn(T) -> U?opt.or_else(f) -> T?wheref: fn() -> T?opt.unwrap_or(fallback) -> T(eager;fallbackis evaluated before the call)opt.unwrap_or_else(f) -> Twheref: fn() -> T(lazy; called only forNone)
Notes:
??remains the idiomatic lazy fallback operator because the fallback is an ordinary expression and is evaluated only forNone.unwrap_oris eager by design; useunwrap_or_else(or??) when the fallback is expensive.map/and_thencall the callback only forSome(...).
Example (map + and_then):
import std::result;
fn parse_port (s: string) -> std::result::Result(int, int) {
return Ok(123);
}
fn main () -> int {
let maybe: string? = Some("8080");
let port_opt: int? = maybe.and_then(fn (s: string) {
return parse_port(s).ok_value();
});
return port_opt.unwrap_or(0);
}
Compiler Requirements#
The compiler must:
- Support
T?andOption(T)as equivalent surface forms. - Ensure that
Some/Noneusage is type‑correct. - Track optionality in the type system and enforce checks when unwrapping.
- Implement
?.and??with the short‑circuit semantics described above. - Support
matchonOption(T)and integrate optionals with flow control and error reporting.
Errors#
This document summarizes the Silk error-handling model at a level suitable for compiler implementation. It is based on the language design captured in this specification (optionals, verification, ext, ABI).
For unrecoverable logic bugs and contract violations, Silk uses typed
errors (error, panic, and T | ErrorType...), specified in
Typed Errors (error, panic, and T | ErrorType...).
Notes#
- Typed errors are implemented end-to-end for the current front-end and the
linux/x86_64backends (seeTyped Errors (error,panic, andT | ErrorType...)). assertis implemented:- in release builds, a failed assertion traps immediately,
- in debug builds on
linux/x86_64(silk build --debug/-g), a failed assertion prints a panic header, an optional message, and a stack trace before aborting. - In
silk testbuilds, failed assertions record a test failure and execution continues (the test process exits non-zero when failures were recorded). SeeTesting.
Design Goals#
- Error signaling is explicit and typed (no hidden global error state).
- Error paths are part of normal control flow, not out-of-band exceptions.
- The verifier can reason about both success and error paths symmetrically.
- The C99 ABI must be able to represent error outcomes in a stable, documented way.
Recoverable Errors (Recommended Pattern)#
Silk distinguishes between:
- Recoverable errors (invalid user input, I/O failures, parse failures): model
these as normal values, typically using
std::result::Result(T, E)or an optional (T?). - Typed errors (
T | ErrorType...+panic): reserved for unrecoverable contract violations and logic bugs that should not be silently ignored (seeTyped Errors (error,panic, andT | ErrorType...)).
Example: Recovering from URL parse errors#
std::url exposes a recoverable parsing API (std::url::parse) that returns a
tagged result (std::url::URLResult), so callers can report an error and keep
going without aborting.
A runnable example that wraps URLResult into std::result::Result and parses
all command-line arguments is in:
an example program
Error Representation#
From the overall language design:
- Silk favors explicit types such as:
- optionals (
T?/Option(T)) for “may be present / may be absent” values. - domain-specific error types (enums or structs) for richer error reporting.
- Functions that can fail should surface that in their type signatures:
- either by returning a value that encodes both success and error (e.g. an optional or a nominal error-aware type),
- or by returning an error-only type where success is absence of error.
The naming and shapes of error-carrying types are defined by this language spec and by standard library APIs, but the compiler must:
- treat them as regular, first-class types,
- enforce that callers handle them appropriately (e.g. via pattern matching, explicit checks).
Interaction with Control Flow#
Error-aware types integrate with control flow constructs:
if/matchcan be used to branch on error vs. success cases.- Pattern matching can destructure enum-based error types, exposing error codes or payloads.
- Optionals (
T?) can be used where “absence” is a common error shape; they compose with?.and??to keep code concise while still explicit.
The compiler must:
- ensure that branches that depend on error conditions are type-checked,
- support exhaustiveness checks when matching on error enums/types.
Verification and Errors#
Formal Silk constructs (#require, #assure, #assert, #invariant, #variant, #monovariant) apply equally to:
- success paths (e.g. postconditions describing the returned value),
- error paths (e.g. guarantees about when and how certain errors can occur).
The verifier should be able to:
- treat error-carrying types as ordinary values with invariants,
- prove that certain errors cannot happen given preconditions,
- or, conversely, require explicit handling of error cases when the proof cannot eliminate them.
ABI and FFI Considerations#
On the C99 side:
- Error values exposed through
libsilk.ashould use well-defined C types (e.g. enums or structs) documented inC99 ABI andlibsilk.a``. - For external functions declared via
ext, any error behavior must be captured in the Silk-side function type and corresponding C signature (e.g. error-return codes, nullable pointers, or explicit error structs).
The compiler must:
- preserve error-related information across the FFI boundary,
- avoid implicit, hidden error channels (such as untracked global error codes) in favor of explicit parameters or return values.
Assertions (assert)#
assert is a debugging/safety construct intended to catch programmer mistakes.
It is not part of Silk’s typed error model and is not a replacement for
returning optionals or Result(...).
Syntax (initial):
assert <Expr>;assert (<Expr>, <message>?);
Rules:
- The condition expression must type-check as
bool. - The optional message, when present, must type-check as
string.
Runtime behavior :
- By default (release builds), if the condition evaluates to
false, execution traps immediately (a panic-like abort). In the currentlinux/x86_64backend this is implemented as an invalid-instruction trap. - In debug builds (
silk build --debug/-g) onlinux/x86_64, a failed assertion prints a panic header, the optional message (when present), and a stack trace to stderr when available (via glibcbacktrace_symbols_fd) before aborting.
Notes:
- Failed assertions are currently isolated by the
silk testrunner (each test runs in its own process). Future work may allow reporting failed assertions without process isolation (for example by loweringassertto a typed error in test contexts). - See also:
Testing.
Typed Errors (error, panic, and T | ErrorType...)#
Silk’s typed error system exists to eliminate the “trust gap” between a function’s signature and its real behavior. There are no hidden exceptions and no implicit panic channel: if a function can terminate due to a logic bug / contract violation, it must say so in its signature, and the compiler must enforce it.
This document specifies the surface syntax and checker rules for typed errors.
The compiler supports error declarations, panic statements, error-aware
return types (T | ErrorType...), and the match statement form for handling
typed errors (including the Terminal Arm Rule), plus the postfix ?
propagation operator for error-producing calls.
Overview#
- An
errorrepresents an unrecoverable logic bug or contract violation. - A function that can
panicmust declare that in its return type using|: fn get_at(xs: u8[], index: int) -> u8 | OutOfBounds;- A typed error is triggered with
panic, which terminates the current function and propagates the error to the caller. - Typed errors are handled explicitly via
match(statement form), and any arm that handles an error must end in a terminal statement.
This model is intentionally closer to “typed, explicit non-local errors” than to try/catch exceptions or an implicit panic mechanism.
Recoverable errors are values (not typed errors)#
Typed errors are intentionally not the primary mechanism for routine runtime failures such as:
- invalid user input,
- parsing failures,
- I/O failures.
Those should typically be modeled as ordinary values using std::result::Result
or optionals (T?) so callers can handle them and continue normal execution.
See:
Errors(overview),std::result(recoverableResult(T, E)),std::urlandan example program(recoverable URL parsing example).
For ergonomic one-branch recovery over recoverable values, prefer if let,
let ... else, while let, or the recoverable ?? operator on optionals,
Result values, and other two-variant success/fallback enums rather than
is_err() / is_none() plus a second extraction step.
Declaring Error Types (error)#
Syntax:
error OutOfBounds {
index: int,
len: int
}
Rules:
error Name { ... }declares a nominal, struct-like type that represents an unrecoverable logic bug / contract violation.- An
errordeclaration has the same field rules asstructin the current compiler subset (scalar fields; seeStructs, Impl Blocks, and Memory Layout). - An
errortype may also be used as data (returned, stored, logged) when it is not part of aT | ...error contract.
Implementation
- The compiler treats
erroras a distinct nominal type category (separate fromstruct) but reuses the same field/layout rules in the Supported forms.
Error-Producing Function Signatures (T | ErrorType...)#
A function declares that it may panic by adding one or more error types after
its success type using |.
Examples:
fn get_at(xs: u8[], index: int) -> u8 | OutOfBounds { ... }
fn parse() -> Frame? | FrameTooLarge { ... }
fn init() -> void | InitFailure { ... }
Note on | disambiguation:
- In function declarations, an unparenthesized
|sequence after->is always parsed as a typed-error contract. - To return a union type from a function, parenthesize the union:
fn f () -> (A | B);fn g () -> (A | B) | SomeError;
See Type Unions (T1 | T2 | ...) for union types.
Rules:
- The leftmost type is the single success type.
- Each type on the right side of
|must name a declarederrortype. - The list of error types in a signature is the complete contract: the
implementation may not
panicwith any other error type.
Implementation notes:
- The current compiler models typed errors as a distinct “error set” attached to
the function signature and to expressions that may
panic. - The success type is still a normal Silk type (including optionals).
Triggering a Typed Error (panic)#
Syntax:
panic OutOfBounds {
index: index,
len: len
};
In a real implementation, the len value typically comes from the relevant
container/view (for example a .len() method via std::interfaces::Len).
Rules:
panicconstructs a value of the namederrortype and immediately terminates the current function, propagating the error to the caller.- A
panic X { ... };statement is only legal inside a function whose signature includes| X(directly or indirectly via propagation).
Implementation notes:
panicis a statement (not an expression) in Silk currently.
Propagating Typed Errors (?)#
The postfix ? operator propagates a typed error from an error-producing call
expression to the caller without requiring an explicit match at every call
site.
Syntax:
let value: T = error_call(...)?;
Semantics:
- If the call succeeds,
call()?evaluates to the call’s success value. - If the call panics with a declared error type,
call()?immediately returns from the current function, propagating the same error to the caller.
Rules:
?is only legal inside a function that declares an error contract (-> SuccessType | ErrorType...).- The callee’s error set must be a subset of the enclosing function’s error set.
Otherwise the call must be handled explicitly with a
matchstatement that maps the error into the caller’s contract. ?is only meaningful on an error-producing call expression (a call whose signature includes| ErrorType...). Applying?to an infallible call is a type-check error.
Implementation notes:
- In the current compiler,
call()?is lowered as “call + tag dispatch; on error return the appropriate error payload; on success yield the value”, using the same encoding as thematchstatement lowering.
Async Calls and await#
Typed-error propagation already composes with async calls in the current
subset, but the fallible expression is still the async call rather than the
await operator.
For example:
error OpenFailed {
code: int,
}
async fn open_value () -> int | OpenFailed {
return 1;
}
In the Supported forms:
open_value()is treated as an error-producing call whose success value isPromise(int).let p: Promise(int) = open_value()?;is valid inside a caller whose error contract includesOpenFailed.let v: int = await open_value()?;is valid and is the supported shorthand when the caller wants the resolved success value directly.await open_value()without?or an explicitmatchonopen_value()is rejected withE2023.
If you need explicit handling, match on the async call and await the promise in the success arm:
match (open_value()) {
p => {
let v: int = await p;
return v;
},
err: OpenFailed => {
panic OpenFailed { code: err.code };
}
}
Handling Typed Errors (match statement + Terminal Arm Rule)#
When the scrutinee expression of a match statement may panic (i.e. its
signature includes |), the compiler activates a special rule for error arms.
Match statement form#
match (create_frame(user_size)) {
Some(frame) => {
io::println("ok");
},
None => {
io::println("no frame");
},
err: FrameTooLarge => {
log::critical("invalid frame size requested", err);
std::abort();
}
}
Terminal Arm Rule#
If the scrutinee expression has an error contract (T | ErrorType...), then
for any arm that matches an error type, the arm’s block must end with a
terminal statement.
Terminal statements are:
panic <ErrorType> { ... };(propagate or map to another error)std::abort();std::halt();std::reboot();
Implementation notes:
std::abort()is lowered as a terminal action:- in the native backend subset, this is routed through the platform
abort()so the process terminates withSIGABRT, - in non-debug builds on
linux/x86_64, the compiler disables core dumps (prctl(PR_SET_DUMPABLE, 0, 0, 0, 0)) before callingabort()to keep abort fast, - on backends/targets where
abort()is unavailable, it is lowered to the backend’sTrapprimitive. std::halt()andstd::reboot()are currently lowered toTrapin the native backend subset.
This rule is intentionally context-dependent: it is triggered by the error
contract of the scrutinee expression, not by the fact that a type is declared
with error.
Error types as data (no Terminal Arm Rule)#
If a function returns an error type as a normal value (no | in its
signature), the special rule does not apply:
fn inspect_issues() -> FrameTooLarge;
match (inspect_issues()) {
err: FrameTooLarge => {
log::warn("non-critical issue", err);
// Allowed to complete normally because the scrutinee is not a `T | ...`.
}
}
match statements over Result-like values (recoverable)#
The match statement form can also be used to destructure common
recoverable result shapes such as std::result::Result(T, E).
When the scrutinee expression is a call expression whose result type is either:
std::result::Result(T, E)(anenumwithOk(T)andErr(E)variants), or- a “Result-like” struct with fields:
value: T?err: E?whereEis anerrortype,
then the checker accepts binder patterns of the form:
name => { ... }/_ => { ... }for the success payload (bindsnameasT),err: E => { ... }for the error payload (bindserrasE).
The Terminal Arm Rule does not apply in this form because the scrutinee is
not a T | ErrorType... typed-error expression; the error is a normal returned
value.
Runtime invariant (struct form, current backend): exactly one of value and
err must be Some(...). If the invariant is broken, execution traps.
Implementation notes:
- The current compiler supports a match subset for optionals as an
expression (
match x { None => expr, Some(v) => expr }). - The
matchexpression also supportsOk(...)/Err(...)patterns forResultvalues (see ``matchExpression (and Statement)). - Typed error handling uses the statement form of
matchwith block arms.
Restrictions#
pure fn#
pure fn must not introduce or handle typed errors:
pure fnmay not have a|in its return type.pure fnmay not containpanicstatements.
The checker enforces these rules in Silk currently (see
Compiler Diagnostics).
ext boundary#
Typed errors must not cross the external boundary. External shims must translate typed errors into:
- explicit error return codes,
- nullable pointers / optionals,
- explicit error structs/enums,
- or a terminal action appropriate for the platform.
the current compiler rejects ext declarations whose function types use
| (and rejects exported C ABI surfaces with |) in the current
implementation.
Related proposals#
- Open/variadic error sets for higher-order adapters (
E...). return <error>as shorthand forpanic <error>(AP131).
Mutability#
Mutability in Silk is “safe by default”: values are immutable unless explicitly marked mutable under clear rules using the mut keyword.
- All local bindings are immutable (read‑only) by default.
constbindings are always immutable (there is noconst mut).- All function parameters are immutable (read‑only) by default.
- A value parameter may be declared
mutto allow reassignment of the parameter binding inside the callee (this does not affect the caller). - A borrowed reference parameter (
&T) follows a two‑partmutborrow contract: - the parameter is declared
mut, and - the call site uses
mut <expr>to explicitly create a mutable borrow. - A slice parameter (
T[]) is a non-owning view; when the callee intends to mutate through a slice view, it also follows a two-part contract: - the parameter is declared
mut, and - the call site uses
mut <expr>to explicitly pass a mutable slice view.
This two‑part system makes mutation explicit and intentional.
Local Mutability (let mut)#
Local bindings introduced with const and let are immutable by default. To
allow a local binding to be updated, it must be declared with let mut (or
var, which is an alias for let mut):
fn main () -> int {
let mut x: int = 0;
x = 1;
x += 2;
return x;
}
Key rules:
- Only
let mutbindings may appear on the left-hand side of an assignment. - Pattern binders follow the same rule. Refutable forms such as
let mut Some(v) = maybe else { ... };,if let mut Some(v) = maybe { ... },while let mut Some(v) = maybe { ... }, chained&& let mut ..., andfor let mut ... in ...introduce mutable payload binders. moveis independent frommut:let move Some(v) = maybe;,let move Some(v) = maybe else { ... };,if let move ...,else if let move ..., andwhile let move ...consume the scrutinee for ownership-tracked values, whilelet move mut Some(v) = maybe else { ... };andlet mut move Some(v) = maybe else { ... };also makevassignable. For simple mutable bindings,var move value = source;,var mut move value = source;, andvar move mut value = source;consumesourceduring initialization whensourcerequires ownership tracking and then introducevalueas assignable. The explicitmutaftervaris redundant, but accepted for modifier-order symmetry.- The left-hand side must refer to an existing binding (an lvalue).
- The type checker enforces that the assigned value’s type matches the binding’s type.
The Principle: Safe by Default#
Example from the spec:
fn read_runner(r: &Runner) {
// This is OK:
io::print("Points: {}", r.point);
// This would be a compile-time error:
// r.point = 5;
}
Key points:
- Borrowed references (
&T) are read‑only unless explicitly declaredmut. - Attempts to mutate through a non‑mutable reference are compile‑time errors.
Granting Permission to Mutate#
To make mutation possible through a borrowed reference, mut is used both:
-
In the function definition, to declare that the function intends to mutate:
fn reset_runner(mut r: &Runner) { r.point = 0; } -
At the call site, to explicitly pass a mutable argument, acknowledging that the callee is allowed to modify it (syntax defined in the language reference).
The compiler uses this to:
- encode a clear contract that the function may modify its argument,
- ensure callers are consciously opting into mutation.
Compiler Requirements#
The compiler must:
- Enforce immutability by default for parameters and references.
- Require
mutat both the declaration and call site for mutable borrows. - Surface clear diagnostics when mutation is attempted without proper
mutmarkings. - Integrate mutability rules with regions, buffers, and concurrency:
- disallow patterns that would lead to data races,
- ensure that aliasing and lifetime rules are respected when mutation is allowed.
Current Implementation Restrictions#
Silk currently implements:
- Local
let mutbindings, including assignment and numeric compound assignment. mutvalue parameters (fn inc(mut x: int) { x = x + 1; }) as a callee-local mutable binding (no call-sitemutmarker is required).- Borrowed reference parameters for:
&Structfor the supportedstructsubset, and&TwhereTis a single-slot scalar primitive (for example&int,&bool,&u64,&f64).- The two-part
mutborrow contract for mutable reference parameters: - parameter declared
mut(e.g.fn bump(mut p: &Pair)), and - call site uses
mut <expr>(e.g.bump(mut pair)). - Field updates through both:
- local
let mutstruct bindings (pair.a = 1,pair.b += 2), and mutborrowed reference parameters (p.a = 1,p.b += 2). Nested field updates (cfg.theme.status_bg = ...) are supported for scalar leaf fields in the backend.- Local borrowed references (
&T) as first-class values: - via the borrow operator
&expron borrowable lvalues (e.g.&pair,&obj.field,&x), and - via implicit borrow coercions in contexts that expect
&T(currently implemented for&Struct; for examplelet r: &Pair = pair;). These borrows are checked with conservative lexical lifetime rules (they may not escape the scope of the borrowed stack storage). - Local bindings of
&Structvalues that originate from heap allocation (new) or from calls that return&Struct: - these
&Structvalues are refcounted in the Supported forms, - copying a
&Structbinding (e.g.let g: &File = f;) creates an alias to the same underlying heap allocation and increments the refcount.
Borrow Safety Rules#
Borrowed references (&T) in Silk currently are safe-by-default
and, for now, use conservative lexical lifetime checks:
- Borrowed references can be created and stored as local values (see above).
- The callee can mutate a borrowed reference only when:
- the parameter is declared
mut, and - the caller uses
mut <expr>at the call site. - Mutable borrows must be explicit and must originate from a borrowable lvalue:
- borrowing a local binding requires a writable base (
let mut) or an already-mutable view, and - field borrows follow the same rule (the base must be writable).
Slice views (T[]) are also call-scoped and safe-by-default:
- A slice value is a non-owning view (pointer + length) and may alias other slice views into the same underlying storage.
- Slice range borrows are created via:
&a[start..end]&a[..end]&a[start..]&a[r]wherer: range(including..=inclusive ranges)- A mutable slice view is created via
mut &a[...]and is restricted: - the base must be a borrowable lvalue (a name or a field-access chain rooted at a name), and
- the base storage must be writable (
let mutfor fixed arrays / structs, or an already-mutable view such as amutborrowed reference parameter), or already a mutable slice view. - A function parameter of slice type may be declared
mutto allow mutation through the slice view, and requires the caller to pass a mutable slice view usingmut <expr>. - When a slice value is stored in a struct field (
xs: T[]), the stored view’s mutability is tracked: - storing
&a[...]stores a read-only view, and - storing
mut &a[...]stores a mutable view. A call-sitemut <expr>marker does not upgrade a read-only stored view into a mutable one; passing a field asmutrequires that the field already holds a mutable view.
Aliasing Restrictions (Per Call)#
Within a single call expression, the compiler enforces conservative aliasing rules to avoid creating multiple mutable views of the same storage:
- A given binding may be mutably borrowed at most once in a single call.
- A binding may not be both mutably and immutably borrowed in the same call.
- Multiple immutable borrows of the same binding are permitted.
For slice parameters (T[]), these same per-call aliasing restrictions apply.
Additionally, when both borrows are slice range borrows of the same base with
integer-literal bounds, the checker permits multiple mutable borrows in the same
call when it can prove the two ranges are disjoint (including when the slices
are first bound to locals and then passed by name).
When borrowing a range from an existing slice binding (for example s: T[]),
the checker interprets &s[start..end] as a subrange of the underlying base
(offset by s’s known bounds) for the purposes of overlap checks. This
disjointness reasoning is currently limited to integer-literal bounds and to
slice bindings whose own bounds are known.
For &Struct reference-typed local bindings and slice-typed (T[]) local
bindings, the compiler also tracks obvious aliasing introduced by copying and
ref “shape casts”:
- Copying a
&Structbinding produces an alias (it refers to the same storage). - Copying a slice binding (
T[]) produces an alias (it refers to the same underlying storage). - Casting
&Sto&Tviaasunder the shape-cast rules produces an alias (it is a retyped view of the same storage). - The per-call aliasing restrictions apply across aliases: within a single call expression, you may not take multiple mutable borrows (or both mutable and immutable borrows) of the same underlying reference, even if they are held under different local names.
Example (rejected):
fn swap(mut a: &Pair, mut b: &Pair) {
// ...
}
fn main () -> int {
let mut p: Pair = Pair{ a: 1, b: 2 };
swap(mut p, mut p); // error: two mutable borrows of `p` in one call
return 0;
}
Example (allowed, immutable):
fn sum2(a: &Pair, b: &Pair) -> int {
return a.a + a.b + b.a + b.b;
}
fn main () -> int {
let p: Pair = Pair{ a: 1, b: 2 };
return sum2(p, p); // OK: multiple immutable borrows
}
ABI Notes (External Boundaries)#
At external declaration boundaries (ext), borrowed-view types are restricted:
- opaque handle references (
&Opaque/mut &Opaque) are allowed, - ordinary references (
&T) are rejected, - and slices (
T[]) are rejected.
This keeps exported signatures ABI-stable and prevents Silk borrows from escaping into foreign code. See the ABI and struct layout docs for the current rules.
Memory Model (Stack, Heap, and Moves)#
This document specifies Silk’s intended memory model: how values are allocated, passed, and how (future) heap-managed values interact with the type system.
Silk currently implements a
minimal heap model for new on linux/x86_64 and a small lexical
move/cleanup model for droppable values:
newis supported for allocating non-opaquestructvalues on the heap and producing an&Structreference.- These heap allocations are managed via reference counting (RC) inserted by the compiler during lowering.
Regions and a richer move/borrow model remain design-in-progress. See
Regions, Borrow Checking (Static Alias and Lifetime Safety), and the implementation status
for current scope.
Goals#
- Make allocation behavior explicit and predictable.
- Prefer stack allocation for most local data.
- Prevent unsafe implicit lifetime extension (for example implicitly “moving” stack data into a longer-lived heap allocation).
- Keep borrow safety a compile-time property (no runtime borrow errors in the safe subset).
Stack vs Heap#
Stack allocation (default)#
Rule: values created without new are stack values by default.
- Locals hold their data directly (for example an
intor a small PODstruct). - Passing to functions is by value. For ownership-tracked values, this is a move (the source binding is consumed); for plain scalars it behaves like a copy.
- Initializing a new binding from a name (for example
let y = x;) and assignment from a name (for exampley = x;) also consumexwhen the value type requires ownership tracking (for exampleDroptypes and task/promise handles). After the move, usingxis rejected by the checker. let move/var moveare the binding-level ownership-transfer spellings. For a simple binding,let move y = x;andvar move y = x;consumexunder the same ownership-tracking rules aslet y = move x;.mutmay be combined withmovein either order:let mut move y = x;,let move mut y = x;,var mut move y = x;, andvar move mut y = x;. The explicitmutaftervaris redundant becausevaris already mutable, but it is accepted for consistency. Copyable existing sources remain independent copies. For destructuring,let move (a, b) = pair;,let move Some(value) = maybe;,let move Some(value) = maybe else { ... };,if let move ...,else if let move ..., chained&& let move ..., andwhile let move ...request consuming pattern binding.- Lifetime is lexical (ends when the scope ends).
This aligns with Silk currently, which is value-oriented and does not implement a general heap allocation model.
Heap allocation (new) and boxed values#
Rule: values created with new live on the
heap and are represented as an &Struct reference in user code.
- The reference value is passed by value (copying the reference representation).
- The underlying allocation’s lifetime is managed by compiler-inserted reference
counting (RC) for values originating from
new.
Important: this is currently an internal Silk-managed heap for Silk code, not an
FFI pointer model. The compiler does not permit &Struct for
non-opaque structs in ext signatures; only &Opaque handles may cross the
FFI boundary (see Structs, Impl Blocks, and Memory Layout and
External Declarations (ext)).
Thread safety and task boundaries#
new produces an &Struct reference whose lifetime is managed by
compiler-inserted reference counting (RC). In the Supported forms, RC retain and
release operations are not atomic, so sharing such references across OS threads
is unsafe.
Because task concurrency runs on OS threads, the checker rejects non-opaque
reference types (&T) at task fn / async task fn boundaries (E2037). This
includes &Struct values produced by new (and any other non-opaque
references).
For async fn results, the checker also rejects borrowed-view types that could
outlive the caller across suspension:
- non-opaque references (
&T), - and slices (
T[]).
Opaque handle references (&Handle where Handle is declared as struct Name;)
remain permitted because they are treated as external handles rather than as
borrowed views into ordinary Silk-managed storage.
To share state across tasks, transfer ownership by value, share explicit
atomic/synchronized handles (std::sync or std::atomic), pass non-owning
*Borrow handle views across tasks, or use std::sync::Arc(T) when task-safe
shared ownership is required.
Atomic operations do not change the new reference model. std::atomic
provides atomic cells for their own storage; it does not make ordinary
compiler-managed references or their RC operations atomic.
std::sync::Arc(T) is a separate shared-ownership tool. Its retain/release
operations are atomic and its final release owns payload destruction, but it
does not upgrade ordinary compiler-managed new RC to atomic RC.
Notes#
newis supported only in function bodies (top-levelletinitializers cannot containnewin the Supported forms).newis supported only when the checker can determine a concrete reference result type of the form&Struct. In the Supported forms, this happens in two ways:- from an expected type context
&Struct(for examplelet x: &Frame = new Frame{ ... };or as a call argument where the parameter type is&Struct) - from the
newoperand itself when it names a struct type (for examplelet x = new Frame{ ... };orlet x = new Frame(...);), which allowsletbindings to infer&Framewithout an explicit annotation - Only non-opaque
structtypes are supported fornew. - Reference counting is applied only to
&Structvalues that originate fromnew(borrowed stack references are not treated as RC-managed values). - The
silk buildCLI supports--noheapto disable heap allocation for the Supported forms: - heap-backed
new(outside awithregion) is rejected withE2027, async/task/await/yieldand capturing closures are rejected withE2027,extbindings to libc heap primitives (malloc/calloc/realloc/free/etc) are rejected withE2027in non-stdlib modules,std::runtime::mem::{alloc,realloc,free}traps when called without an activewithregion (no implicit heap fallback),- region-backed
newinsidewithis still permitted.
Region-backed allocation (with + region)#
In the Supported forms, new may also allocate from a region when an active
region context is established with with (see Regions).
- Inside
with <region> { ... },newallocates from the region’s backing bytes instead of calling the heap allocator. - On last-release, region-backed
newallocations rundrop(when defined), but do not free their backing storage (region memory is not reclaimed by RC).
Reference counting rules#
newinitializes the allocation’s RC cell to1.- Copying an RC-managed
&Structbinding (for examplelet q: &T = p;) emits an RC retain (increment). - Assigning to an RC-managed
&Structbinding (for examplep = q;wherepis avar) releases the previous value; when the RHS is an RC-managed binding, a retain is emitted before the release to keep self-assignment safe. - Exiting a scope emits RC releases (decrement) for RC-managed bindings declared
in that scope, including on fallthrough,
return,break, andcontinue. - Passing
newdirectly as a call argument to a&Structparameter allocates a temporary and releases it after the call completes. - When an RC release decrements the count to
0, the allocation is freed.
Destructors (Drop)#
In Silk currently, Silk supports deterministic cleanup for
resource-owning struct values via std::interfaces::Drop.
A struct type is considered “droppable” when it provides a method with this
surface signature (usually via an interface impl):
import std::interfaces;
impl T as std::interfaces::Drop {
public fn drop (mut self: &T) -> void { ... }
}
Automatic invocation :
- Scope exit: values are dropped when they go out of scope (including via
fallthrough,
break, andcontinue). - Return: on
return, the compiler drops all in-scope droppable bindings except any value moved into the return result (for examplereturn value;andreturn Some(value);treatvalueas moved in the Supported forms). - Overwrite: assigning to an existing value drops the old value before the new value is copied in.
- Heap last-release: for
newallocations managed by compiler-inserted RC,dropis called before freeing the backing allocation when the refcount reaches zero.
Notes and limitations:
dropis resolved statically (no dynamic dispatch).- Values that require deterministic cleanup should be treated as ownership-tracked:
- consuming a binding moves it and suppresses scope-exit cleanup for that binding,
- using a moved binding is rejected by the checker,
- explicit ownership transfer may be written as
move <name>(seeBorrow Checking (Static Alias and Lifetime Safety)).
No Implicit Heap Promotion#
Planned rule: stack values cannot be implicitly promoted to heap-managed storage. Any promotion must be explicit and must perform a copy.
This avoids accidental lifetime extension and makes performance characteristics obvious.
The precise syntax for “heap-copy this value” is still under design; any
proposed surface form must be written down in Formal Grammar Spec before
it is implemented.
Closure Captures#
Silk supports capturing closures as a subset of function values.
Representation:
- A function-typed value is a small pair:
{ func_ptr, env_ptr }. func_ptris a pointer to the closure code.env_ptris either0(non-capturing) or a pointer to a heap-allocated environment box that stores captured values.
Calling convention:
- When
env_ptr == 0, an indirect call behaves like a normal function-pointer call:func_ptr(user_args...). - When
env_ptr != 0, the backend passesenv_ptras a hidden first argument to the closure function:func_ptr(env_ptr, user_args...).
Environment allocation and lifetime (Supported forms):
- Captures are by-value copies of scalar locals/parameters (
int, fixed width ints,bool,char,f32,f64,Instant,Duration). - The environment box begins with a
u64refcount header, followed by the captured scalar fields in a stable order. - Copying a closure value retains the environment (increments refcount) when
env_ptr != 0. - Dropping a closure value releases the environment (decrements refcount) when
env_ptr != 0; when the refcount reaches zero the environment box is freed.
Limitations:
- Capturing non-scalar values (including
string, structs, arrays/slices, optionals, andDroptypes) is rejected in the Supported forms. - Captures are immutable snapshots; the Supported forms does not support capturing by reference or mutating captured state.
Relationship to Borrowing and Mutability#
- Borrow checking is intended to be a compile-time property in the safe subset: invalid borrows should be rejected statically.
- See
Mutabilityfor the current implemented borrow rules (call-scoped aliasing checks for&Tparameters in the Supported forms). - See
Borrow Checking (Static Alias and Lifetime Safety)for the broader planned borrow checker.
Borrow Checking (Static Alias and Lifetime Safety)#
This document specifies Silk’s intended borrow-checking model for references.
Silk currently implements:
- call-scoped alias checks for mutable borrows (including slice range borrows),
- lexical lifetime checks for slice and reference borrows (no escaping borrows of stack locals),
- and a small explicit ownership-transfer form (
move) used by the checker and lowering to prevent accidental double-drops in the safe subset.
Goals#
- Prevent use-after-free and data races in safe code.
- Make mutation explicit and intentional.
- Reject invalid borrows at compile time (no runtime borrow errors required for safe code).
- Keep diagnostics actionable (highlight the borrow origin, conflicting use, and suggest a fix).
Notes#
Today, the language subset implemented by the compiler supports only:
- call-scoped borrow alias checks for:
- borrowed reference parameters (
&T,mut p: &T), and - slice parameters (
T[],mut s: T[]) and slice range borrows (&base[start..end],mut &base[start..end], and&base[r]/mut &base[r]wherer: range). - first-class borrowed
&Tvalues created from borrowable lvalues: &expr(borrow operator) for borrowable lvalues, for:- the supported
&Structsubset, and &TwhereTis a single-slot scalar primitive (for example&int,&bool,&u64,&f64).- implicit borrow coercions in contexts that expect
&Tare currently implemented for&Struct(for examplelet r: &Pair = pair;).
Additionally, the subset implements lexical lifetime checks for both slice
borrows and borrowed &T values so obvious use-after-scope cases are rejected
(for example returning a slice borrowed from a local fixed array, or returning
&T borrowed from a local struct binding).
The currently shipped subset therefore already includes borrowed views in the positions that matter most for day-to-day code:
- local
&TandT[]bindings, - local
T?bindings whose payload is a borrowed&TorT[], - struct fields and enum payloads that carry borrowed views,
- assignments through fields and mutable reference parameters,
- and whole-value returns / assignments that are checked against lexical escape rules even when the borrowed view is carried through an aggregate.
Lexical Lifetimes#
Slices (T[]) are non-owning views. Slice range borrows create slices that
point into existing storage:
&base[start..end]creates a slice view whose lifetime is tied tobase.&base[r]creates a slice view whose bounds are defined by therangevaluer(seeTypes).- When borrowing a range from an existing slice binding
s: T[], the borrow’s underlying origin iss’s origin (sub-slicing does not extend lifetime).
Lexical lifetime rules enforced by Silk currently:
- A slice value that ultimately borrows from a local fixed array binding
(
T[N]) may not escape that binding’s lexical scope. - Returning such a slice from a function is rejected.
- Assigning such a slice into outer-scope storage is rejected (including via field assignment and via mutable reference parameters).
- The same rule also applies when that borrowed slice is wrapped in
T?(Some(&xs[...])does not allow the local borrow to escape). - The same rule also applies when that borrowed slice is carried inside a struct field or enum payload; returning or assigning the aggregate does not allow the local borrow to escape.
- Returning a slice is permitted when the returned slice ultimately borrows
from a function parameter (for example returning a sub-slice of a
T[]parameter).
These rules are intentionally conservative, but they are the complete lexical lifetime model for the currently supported language subset.
Lexical Reference Lifetimes#
Borrowed &T values that ultimately reference stack storage may not escape
that storage’s lexical scope. This includes:
- returning a borrowed
&Tthat points to a local stack binding (struct or single-slot scalar), - returning such a borrow wrapped in
T?, - returning such a borrow carried inside a struct field or enum payload,
- and assigning such a borrowed reference into outer-scope storage.
Returning a reference is permitted when the returned &T ultimately refers to
an input reference parameter (that is, storage owned by the caller), and not to
stack locals.
When multiple input references or slices are in scope, no explicit lifetime label syntax is required in the current language. A returned borrowed view may refer to any caller-owned input borrow that reaches the return expression through the supported control-flow forms. If any path introduces a local stack or fixed-array origin, the lexical escape check still rejects the return.
Local Mutation While Borrowed#
Silk currently also rejects direct mutation of ordinary local storage while a borrow of that same storage remains live.
This applies to:
- whole-binding assignment (
x = ...) whenxis a local stack value or local fixed array, - field assignment (
x.f = ...) into a local aggregate that is still borrowed, - and index assignment (
xs[i] = ...) into a local fixed array that still has a live borrowed slice.
In other words, an ordinary local borrow freezes the borrowed local storage against direct mutation until that borrow ends.
Writes performed through the unique mutable borrow itself remain allowed. For
example, mutation through mut r: &T is permitted when it is not competing
with a separate live borrow of the same local storage.
This rule is intentionally local-storage-specific. Borrowed access to caller-owned or external-handle storage is governed by the existing boundary rules instead.
Borrow-Carrying Wrappers and Conservative Control Flow#
The current checker also preserves borrow identity through a small set of wrapper and control-flow forms:
Some(<borrow>)preserves the underlying borrow identity.- Local
T?bindings whose payload type is&TorT[]participate in the same local mutation, lexical escape, move, andawaitchecks as direct borrowed bindings. - Local named struct / enum bindings whose fields or payloads carry
&TorT[]also participate in the same local mutation, lexical escape, move, andawaitchecks as direct borrowed bindings. - Refutable-pattern binders also preserve borrow identity when the scrutinee already proves a single local borrow origin. In the Supported forms, this includes:
if let Some(x) = r { ... }let Some(x) = r else { ... };while let Some(x) = r { ... }- statement
match (r) { Some(x) => ..., None => ... }wherer: T?andTis a borrowed&UorU[]. if let Ok(x) = r { ... }/if let Err(x) = r { ... }let Ok(x) = r else { ... };/let Err(x) = r else { ... };while let Ok(x) = r { ... }/while let Err(x) = r { ... }- statement
match (r) { Ok(x) => ..., Err(y) => ... }for supported result-shaped enums whose payload type carries a borrow, including monomorphizedstd::result::Result(T, E)instantiations, and the equivalent qualified enum-variant forms such asState::Ready(x). ifexpressions preserve borrow identity when:- every borrowing branch resolves to the same local origin,
- or one branch is non-borrowing (
None, for example) and the other carries the borrow, - or all borrowing branches are caller-owned inputs.
matchexpressions preserve borrow identity under the same conservative rule:- every borrowing arm must resolve to the same local origin,
- or one or more arms are non-borrowing while the remaining borrowing arms resolve to that same origin,
- or all borrowing arms are caller-owned inputs.
When a borrowed control-flow expression could refer to multiple distinct local
origins, the Supported forms rejects it with E2122 instead of guessing.
Boundary Safety#
The current compiler also enforces conservative rules at boundaries where a borrowed view could outlive the storage it refers to.
async fn boundaries#
At an async fn boundary, the result type may not contain ordinary
borrowed-view types:
- non-opaque references (
&T), - and slices (
T[]).
This includes such types nested inside structs, enums, optionals, and function
types. The reason is suspension: an async fn call returns a Promise(T), so
the eventual result may outlive the stack frame that originally produced the
borrowed view.
Opaque handle references are allowed:
&Handleis permitted whenHandleis declared as an opaquestruct Name;.
These are treated as external handles rather than borrow-checked views into ordinary Silk storage.
Borrowed parameters are permitted in the Supported forms, but the checker also enforces a conservative async call-site rule:
- an ordinary reference or slice that still resolves to function-local stack
storage or a local fixed array may not be passed into an
asynccall unless that call is awaited immediately in the same expression, - opaque handle references remain allowed because they are not borrow-checked views into ordinary Silk storage.
This is the borrow model for the current async subset. Additional async surface area must define equivalent suspension and escape rules before it lands.
External ABI boundaries#
At top-level external ABI boundaries, ordinary borrowed views are also rejected:
extdeclarations may not use ordinary references or slices in parameters or results,- unnamed C-facing root-package top-level
export fndeclarations are subject to the same rule because they define the compiler’s C-facing symbol surface, - named-package Silk object exports may accept slice parameters in the compiler-owned package ABI; that does not make slices part of the external C ABI surface,
- only opaque handle references (
&HandlewhereHandleisstruct Name;) may cross that boundary.
This rule does not apply to ordinary impl/public methods inside Silk modules; those remain normal intra-Silk calls.
await suspension points#
At a concrete await / await * suspension point inside an async function,
the checker also rejects live borrowed views that still resolve to ordinary
function-local storage:
- a borrowed reference (
&T) that still points at a local stack value, - a slice (
T[]) that still points at a local fixed array, - and either of the above when the borrowed view is stored in a local struct field instead of a standalone local binding.
This rule is intentionally conservative. It applies only to borrows rooted in ordinary local Silk storage. The following remain allowed:
- borrowed views rooted in caller-owned storage that have already passed the boundary rules,
- and opaque handle references (
&HandlewhereHandleis an opaquestruct Name;).
The practical rule is: if an await may suspend, end any live borrow of local
stack / fixed-array storage before the suspension point.
External ABI boundaries#
At ext boundaries, the same borrowed-view restriction applies:
- ordinary references and slices may not cross the boundary,
- and only opaque handle references (
&HandlewhereHandleis opaque) are permitted by reference.
This keeps Silk’s borrow rules out of the C ABI and avoids exposing non-stable borrowed layouts to foreign code.
Ownership Transfer (move)#
Silk’s safe subset includes a small explicit ownership-transfer form:
move <name>
This expression:
- consumes the binding
<name>when its type requires ownership tracking (for example values that are dropped on scope exit), - and makes
<name>unavailable for further use until it is reinitialized (forvar) or permanently (forlet).
This enables moving values into other values (for example as call arguments or
as the payload of Some(...)) without accidentally copying a resource-owning
value and dropping it twice.
In the Supported forms, ownership transfer is intentionally conservative:
- A binding may not be moved while it has any live borrows (reference or slice views) in scope, including borrows stored in struct fields.
- A by-value call argument that requires ownership tracking is treated as a move, and is rejected when the same binding is also borrowed in that call.
- When a value type requires ownership tracking, binding initialization and assignment from a name are also treated as moves:
let y = x;consumesx,y = x;consumesx.let move/var moveare the equivalent binding-level spellings for an explicit initialization-time move:let move y = x;andvar move y = x;consumexwhenxrequires ownership tracking,let mut move y = x;,let move mut y = x;,var mut move y = x;, andvar move mut y = x;are accepted combined modifier forms,- copyable existing sources still copy, so both bindings remain independent,
let move Some(value) = maybe;,let move Some(value) = maybe else { ... };,if let move Some(value) = maybe { ... },else if let move Some(value) = maybe { ... }, andwhile let move Some(value) = next() { ... }consume the pattern scrutinee under the same move and borrow checks.
Completeness#
The borrow checker is complete for the currently documented and
regression-tested Silk language subset, including the wrapper and control-flow
forms described above. New language features may still require new borrow
rules, but those are not treated as pre-declared borrow-checker roadmap items.
Any such extension must be specified in Formal Grammar Spec and in this
document before implementation lands, and must be reflected in diagnostics
(Compiler Diagnostics) and tests.
Attributes (attr(...))#
Silk supports first-class attributes that can annotate declarations and can also be queried at compile time for conditional compilation.
Attributes come in two forms:
- Tags:
attr(one, two, three) - Key/value pairs:
attr(arch="x86_64", feature="tui")
Values may be:
- booleans (
true/false) - integers (numeric literals)
- strings (
"..."or raw string literals) - identifiers (treated as a string value, e.g.
abi=c)
Notes#
Defined in Silk currently:
attr(...)as a prefix annotation on declarations and statements.attr(...)as a compile-time query expression of typebool.- Comparison operators in
attr(...)items for numeric toolchain keys: - examples:
attr(silk_major>=0),attr(silk_minor>=2),attr(silk_patch=0) - and:
attr(silk_abi_major>=0),attr(silk_abi_minor>=2),attr(silk_abi_patch=0)where<op>is one of=,<,<=,>,>=and<n>is an integer literal. - Declaration gating:
- when an
attr(...)annotation containsarch/os/target/feature, the annotated declaration is included only when the key/value constraints match the current build target. - Conditional compilation:
if <cond> { ... } else { ... }prunes branches at compile time when<cond>is an attribute-query boolean expression (built fromattr(...),!,&&,||, and parentheses).- The pruned branch is not type-checked and is not lowered/code-generated.
attr(abi=c) fn (...) -> ...in type positions is accepted as a synonym forc_fn (...) -> ...(C ABI callback pointer types).export attr(abi=c) fn ...andattr(abi=c) export fn ...select the C-facing object symbol spelling for an exported function while preserving normal Silk package import/export semantics.- Task scheduling hints on
taskfunctions: attr(task=pool)/attr(task="pool")schedules the task on the global task pool (see “Task scheduling” below),attr(task_pool)is accepted as a tag-form synonym forattr(task=pool).attr(task=thread)/attr(task="thread")forces a dedicated OS thread for each call instead of the default task-pool schedule.
Not yet fully implemented:
- Objective-C / FFM / WASI-component / other ABI selectors beyond the initial
abi=csupport. - Arbitrary declaration attributes that override the C-visible symbol name,
such as a future
attr(c_name="...")design. The implemented declaration-level C ABI spelling is limited toexport attr(abi=c) fn/attr(abi=c) export fnand derives the C symbol from the Silk namespace.
Syntax#
Attribute list#
attr(one, two, debug=false, arch="x86_64", abi=c)
attr(silk_minor>=2, arch="x86_64")
Items are comma-separated. A trailing comma is permitted.
Attribute operators#
An attribute item may be either:
- a tag:
attr(one), or - a key/value item:
attr(arch="x86_64").
In the Supported forms, key/value items use one of:
=for string/identifier/bool keys (for examplearch="x86_64",abi=c),=,<,<=,>,>=for numeric toolchain keys (for examplesilk_minor>=2).
Annotation form (prefix)#
Attributes may prefix most declarations:
attr(one) fn hello () -> int { return 0; }
attr(feature="tui") struct TTY { /* ... */ }
attr(arch="x86_64", os="linux") interface Builder { /* ... */ }
Attributes may also prefix statements inside blocks:
fn main () -> int {
attr(one, two) let x: int = 1;
return x;
}
Notes:
- Statement-level attributes are metadata only; use
if attr(...) { ... }for compile-time selection inside blocks.
Query form (expression)#
attr(...) may be used as a boolean expression:
if attr(arch="x86_64") {
// compiled only when the target arch is x86_64
} else {
// compiled otherwise
}
Compound expressions are supported:
if attr(os="linux") && (attr(arch="x86_64") || attr(arch="wasm32")) {
// ...
}
attr(...) queries are compile-time only; they are evaluated by the compiler
and do not exist as runtime calls.
Built-in attribute keys#
Silk currently recognizes the following keys in queries and conditional compilation contexts:
arch:"x86_64","aarch64", or"wasm32"- The ARM64 family accepts
"aarch64"as the canonical spelling, plus the aliases"arm64"and"aarch"in any letter case. os:"linux","macos","ios","android","windows","wasi", or"unknown"oscomparisons accept those names in any letter case.target:"linux-x86_64","linux-x86_64-musl","linux-aarch64", or"linux-aarch64-musl""macos-x86_64"or"macos-aarch64""ios-aarch64","ios-simulator-aarch64", or"ios-simulator-x86_64""android-aarch64""windows-x86_64"or"windows-aarch64""wasm32-unknown-unknown"or"wasm32-wasi"feature: an enabled feature name (see “Features” below)- Toolchain version keys (numeric; compare against an integer literal using
=,<,<=,>,>=): silk_major,silk_minor,silk_patchsilk_abi_major,silk_abi_minor,silk_abi_patch
ABI selection (abi=c) and c_fn#
In type positions, attr(abi=c) fn (...) -> R is equivalent to c_fn (...) -> R.
This is intended for C callback pointer types:
type InfoCb = attr(abi=c) fn (u64, u64) -> void;
type InfoCb2 = c_fn (u64, u64) -> void; // equivalent
On exported function declarations, attr(abi=c) selects a C-facing object
symbol spelling:
export attr(abi=c) fn add_i64 (a: i64, b: i64) -> i64 {
return a + b;
}
attr(abi=c) export fn ... is accepted as the equivalent prefix form. The
function remains a normal Silk export, so Silk code imports and calls it by its
package-qualified Silk name. The attribute changes only the emitted object
symbol used by C, Objective-C, Swift, linkers, and dynamic loaders.
Symbol names are derived as follows:
- in the global package, the object symbol is the function name exactly, for
example
add_i64; - in a package or module namespace, Silk namespace separators are collapsed to
one
_and the function name is separated from that namespace by one_, for example packageui::modelfunctionadd_i64emitsui_model_add_i64.
Because this spelling is intentionally clean and C-like, different Silk package
and function names can normalize to the same object symbol. The compiler
rejects attr(abi=c) export symbols that collide with another C ABI export or
with any other function symbol emitted for the selected output before object or
library emission. Library outputs validate the root package's exported C ABI
symbols against the dependency functions as they are actually emitted into that
output, including dependency functions that become internal raw symbols rather
than public package-qualified exports.
Declaration-level attr(abi=c) currently applies only to top-level exported
functions. A top-level package or module declaration participates in the
namespace-derived C symbol spelling above, but functions nested inside
module Name { ... } inline module blocks are rejected until inline-module C
ABI symbol export is implemented end to end.
The C ABI selection does not relax the supported exported-function ABI rules. C-facing signatures must still use types that the selected target backend can marshal at a C call boundary.
Task scheduling (task=pool / task=thread)#
In the current hosted subset, task fn execution is implemented on OS threads.
By default, calling a task fn schedules that task on the global task pool.
When a task fn (or async task fn) is annotated with:
attr(task=pool)(orattr(task="pool")), orattr(task_pool)(tag-form synonym),
the compiler keeps the default global task pool schedule for that task.
When a task fn (or async task fn) is annotated with:
attr(task=thread)(orattr(task="thread")),
the compiler spawns a dedicated OS thread for each call instead of using the global task pool.
The task pool is:
- created lazily on the first pooled task submission,
- backed by OS worker threads,
- Designed as a shared queue-based worker pool (see
the implementation).
Configuration#
On hosted targets, the worker count defaults to the detected CPU count (clamped to a small fixed maximum).
You may override it by setting:
SILK_TASK_POOL_THREADS=<n>
to request n worker threads (values <= 0 are treated as 1; non-numeric
values are ignored and the default is used).
You may also bound queued work by setting:
SILK_TASK_POOL_MAX_QUEUED=<n>
to request at most n queued tasks beyond the worker set (0 or missing means
unbounded). When the queue is full, non-worker submitters block until space is
available; worker threads fall back to inline execution for that submission so
the pool does not deadlock itself.
Features#
Features are named build-time toggles intended for conditional compilation.
In Silk currently, features may be enabled from:
- the CLI (
--feature/-F), and - package manifests (
silk.toml): - the root package via
[build].features, and - dependency packages via
[dependencies].<dep>.features.
In silk.toml, [build].features may be either:
- an array of strings (
["NAME", "NAME=VALUE", ...]), or - an inline table (
{ NAME = <bool|int|string>, ... }). NAME = trueis equivalent toNAME(boolean enabled),- any other value is equivalent to
NAME=VALUE.
Use attr(feature="name") in queries and conditional compilation:
if attr(feature="tui") {
// code compiled when the build enables the "tui" feature
}
Feature scoping (package builds)#
When building a package graph (via silk build/check/test --package ...),
features are scoped per package:
attr(feature="...")queries observe only the enabled features for the current module’s package.- Root package features do not implicitly affect dependency packages.
Dependency-scoped features are enabled via the root package manifest’s dependency entries:
[dependencies]
ui = { path = "../ui", sha256 = "sha256:...", features = ["tui"] }
Feature values#
Features may optionally carry values. Use attr(feature="name=value") to
require a specific value:
if attr(feature="MY_FEATURE=123") {
// compiled only when MY_FEATURE is set to 123
}
if attr(feature=enable_this_feature) {
// compiled only when enable_this_feature is enabled
}
Rules (Supported forms):
- Feature specs are of the form
NAMEorNAME=VALUE. NAMEstarts with a letter or_and may contain letters, digits,_, and-; this permits user-facing names such assecurity-provider.- When
VALUEis omitted, the feature is treated as booleantrue. - When
VALUEis present: true/falseare parsed as booleans,- integer literals (including
0x.../0b.../ digit separators) are parsed as integers, - all other values are treated as strings.
attr(feature="NAME")istruewhen the feature is enabled:- boolean features are enabled only when they are
true, - non-boolean-valued features are enabled when present.
attr(feature="NAME=VALUE")istrueonly when the named feature exists and its value equalsVALUEafter parsing.
Precedence:
-
CLI
--feature/-Fentries override manifest-provided feature values of the same name. -
For package builds, unscoped
--feature NAME[=VALUE]entries target the root package. -
You may target a specific package with a namespaced spec:
--feature <package>/<spec>(for example--feature ui/tuior--feature ui/tui=false). -
Namespaced feature specs are accepted only for package builds (those that use
--package). -
For package builds, multiple manifests in the package graph may request features for the same dependency package. If the same feature name is assigned multiple different values for a single package, the build fails unless a CLI
--feature <package>/<spec>entry overrides it.
Atomics#
Silk atomics are compiler-backed operations for low-level thread
synchronization. They are distinct from ordinary loads/stores and from
volatile memory access:
- ordinary loads/stores are not synchronization operations,
- atomics synchronize between OS threads according to an explicit memory ordering,
volatileremains for externally observed memory such as MMIO and must not be used as a replacement for atomics.
For ordinary application code, prefer std::sync primitives such as mutexes,
condition variables, channels, and cancellation tokens. Atomics are intended
for small low-level coordination patterns such as counters, readiness flags,
once-style state, and cheap cancellation flags.
Ordering#
The public ordering enum is std::atomic::Ordering:
export enum Ordering {
Relaxed,
Acquire,
Release,
AcqRel,
SeqCst,
}
Meaning:
Relaxedperforms an atomic operation without establishing synchronization.Acquireprevents later memory operations from moving before the atomic operation.Releaseprevents earlier memory operations from moving after the atomic operation.AcqRelcombines acquire and release behavior for read-modify-write operations.SeqCstparticipates in the single sequentially consistent order for all sequentially consistent atomics.
Operation Rules#
Atomic operations have operation-specific ordering contracts:
loadacceptsRelaxed,Acquire, orSeqCst.storeacceptsRelaxed,Release, orSeqCst.swap,fetch_add, andfetch_subaccept anyOrdering.compare_exchangeaccepts any success ordering, but the failure ordering must not beReleaseorAcqRel.fenceaccepts anyOrdering;Relaxedis a no-op fence.
Invalid statically visible orderings are rejected by the checker with E2127.
import std::atomic;
fn main () -> int {
let mut value = std::atomic::AtomicU64.init(1);
// error[E2127]: atomic loads cannot use Release or AcqRel
let current = value.load(std::atomic::Ordering::Release);
return current as int;
}
Thread Safety#
Atomic fields are task-safe when the containing type is otherwise task-safe.
This means a struct containing AtomicBool or AtomicU64 can cross a task
boundary by value under the same task-safety rules as other structs composed of
task-safe fields.
Copying an atomic value by value copies the atomic storage. It does not create shared ownership. To share one atomic cell across tasks, keep the owning value alive in the parent scope and pass the module’s non-owning borrow view across the task boundary.
new references remain non-atomic. The reference counting used for ordinary
new allocations is not made thread-safe by this feature. Thread-safe shared
ownership remains a separate future type, such as Arc(T).
Notes#
The current hosted/native subset exposes:
std::atomic::Ordering,std::atomic::AtomicBool,std::atomic::AtomicBoolBorrow,std::atomic::AtomicU64,std::atomic::AtomicU64Borrow,std::atomic::fence.
Lowering routes these operations through runtime symbols backed by native compiler atomic builtins on the hosted POSIX path. They are not lowered as ordinary Silk field loads or stores.
Buffers#
Buffer(T) provides low-level access to a contiguous block of memory. It is
intentionally unsafe and used as a foundation for higher-level collections and
strings.
Key points:
Buffer(T)is a “fat pointer” with:- a raw pointer to the start of the memory block,
- a capacity (number of elements that can be stored).
Buffer(T)does not track the number of initialized elements (length).Buffer(T)uses the current compiler’s scalar-slot layout (for examplesizeof(u8) == 8). For packed bytes suitable for OS/FFI byte APIs, usestd::buffer::BufferU8.- The current API includes operations such as:
- allocation:
std::buffer::Buffer(T).init(cap)/std::buffer::alloc(T; cap) - reads/writes:
buf.read(i)/buf.write(i, v)and module-level wrappers - views:
buf.view(len)/buf.slice(start, end)returningstd::arrays::Slice(T)
Safety model (layered):
- Layer 1: unsafe
Buffer(T)primitive (ptr + cap, no tracked initialization). - Layer 2: verifier checks (borrow/ownership rules in the language subset).
- Layer 3: Formal Silk proofs (contracts, invariants, and struct requirements).
Notes#
The shipped stdlib provides std::buffer::Buffer(T) as an owning, fixed-capacity
buffer for scalar-slot T values, backed by std::runtime::mem::{alloc,free}.
The buffer surface is written so it can be used in verified code:
- structural invariants are captured in
std::formal::buffer_well_formed(ptr, cap), - bounds checks are expressed via
std::formal::bounds_i64/slice_range_i64, - and higher-level containers can layer length tracking and element lifecycle rules on top.
std::buffer also continues to provide:
BufferU8: a packed, growable byte buffer for OS/FFI byte APIs (byte-addressedptr, withlen/capin bytes), and- width-oriented aliases backed by
std::vector::Vector(T)for convenience.
Regions#
Regions provide a fixed-size, statically allocated block of memory that can
be used as an allocation context for new.
Regions are represented at runtime as a first-class Region handle value. A
Region value may be passed to functions, stored in structs, and exported.
Notes#
Supported forms:
- Parsing and type-checking of:
const region <name>: u8[N];with <name> { ... }with <bytes> { ... }/with(<bytes>) { ... }(anonymous region for the block)with <bytes> from <region> { ... }with <bytes> from <region>[<start>..] { ... }with <bytes> from <region>[<start>..<end>] { ... }Regionis a primitive handle type:const region name: u8[N];bindsnameas aRegionvalue,Regionvalues may be passed and stored (including in struct fields),with <name> { ... }accepts anyRegion-typed binding (including function parameters and locals).- Inside a
with <region> { ... }block,newallocations for non-opaquestructvalues allocate from the active region instead of the heap. - Within the dynamic extent of a
with <region> { ... }block (including calls performed while the block is active), raw allocations viastd::runtime::mem::allocallocate from the active region (8-byte aligned). - Region allocation overflow traps at runtime.
Limitations (Supported forms):
- The region backing store is currently restricted to
u8[N](a fixed-size byte array type annotation). - Only the existing
newsubset is affected (non-opaquestructallocations that produce&Struct). - Region-backed
newallocations are still reference-counted: - last-release runs
drop(when defined), - but the backing bytes are not freed (region memory is reclaimed only by reusing the region cursor, as described below).
Syntax#
Region handle type#
Region is a primitive value type representing a region allocation context.
Conceptually, a Region value contains:
- a base pointer to the backing bytes,
- a pointer to a mutable cursor cell (shared by copies of the handle), and
- a byte limit used for overflow checking.
Copying a Region value copies the handle; copies refer to the same backing
store and cursor.
Declaring a region#
A region declaration has the surface form:
const region region_buf: u8[1024];
The backing-size expression may also use literal arithmetic that folds at parse time:
const region region_buf: u8[1024 * 1024];
Rules:
const regionis a declaration form (it is not a type).- A region declaration has no initializer.
- The type annotation specifies the region backing size and must be a fixed
byte array type:
u8[N]. - In the current parser subset,
Nmay be an integer literal or a literal-only integer arithmetic expression using+,-,*,/,%, and parentheses. - In the current region subset, that expression must fold to a concrete byte count at parse/type-check time.
- The declared name is bound as a
Regionvalue.
Using a region: with#
with establishes a region allocation context for the enclosed block.
1) Bind an existing region#
with <region> { ... } activates a named region binding:
struct Frame { x: int }
fn main () -> int {
const region region_buf: u8[1024];
with region_buf {
let p: &Frame = new Frame{ x: 1 };
// ...
}
return 0;
}
The <region> name may refer to any Region-typed binding, including a region
parameter passed to a function:
struct Frame { x: int }
fn alloc_in (r: Region) -> int {
with r {
let p: &Frame = new Frame{ x: 1 };
return p.x;
}
}
2) Use an anonymous region with an explicit byte budget#
with <bytes> { ... } (or with(<bytes>) { ... }) creates an anonymous region
backed by <bytes> writable bytes and activates it for the block:
struct Frame { x: int }
fn main () -> int {
with 1024 {
let p: &Frame = new Frame{ x: 1 };
// ...
}
return 0;
}
Rules (Supported forms):
<bytes>must be a positive integer literal.
3) Use a slice of an existing region (from)#
with <bytes> from <region> { ... } creates an anonymous region backed by the
first <bytes> bytes of <region>:
struct Frame { x: int }
fn main () -> int {
const region region_buf: u8[2048];
with 1024 from region_buf {
let p: &Frame = new Frame{ x: 1 };
// ...
}
return 0;
}
You may also specify a byte slice of the source region:
with 1024 from region_buf[64..] {
// uses bytes 64..(64 + 1024) of `region_buf`
}
with 1024 from region_buf[64..1088] {
// uses bytes 64..1088 of `region_buf`
}
Rules (Supported forms):
<bytes>must be a positive integer literal.<region>must name aRegionvalue that has a compile-time-known backing size in the Supported forms (for example aconst regiondeclaration).- Slice bounds use byte offsets (the region backing store is
u8[N]). <start>/<end>must be non-negative integer literals.- When an explicit
<end>is present, it is exclusive ([start..end]). - The
fromslice must contain at least<bytes>writable bytes: with <bytes> from r { ... }requires<bytes> <= sizeof(r).with <bytes> from r[start..end] { ... }requires<bytes> <= end - start.with <bytes> from r[start..] { ... }requires<bytes> <= sizeof(r) - start.
Semantics#
Region-backed new#
Within a with <region> { ... } block:
- any
newallocation performed by the compiler’snewlowering uses the active region as its backing store, - allocations are 8-byte aligned in the Supported forms,
- if the region does not have enough remaining space, the program traps.
Outside of a with block, new uses the current heap model described in
Memory Model (Stack, Heap, and Moves).
Region-backed raw allocation (std::runtime::mem::alloc)#
Within the dynamic extent of a with <region> { ... } block (including calls
performed while the block is active):
std::runtime::mem::alloc(n)allocates ann-byte payload from the active region (8-byte aligned) and reserves an additional 8-byte header immediately before the returned pointer (used by the runtime to distinguish region-backed and heap-backed pointers and to record the allocation size),- if the region does not have enough remaining space, the program traps.
Implication for with <bytes> limits: each alloc(n) consumes at least
n + 8 bytes of region capacity (plus any alignment padding from 8-byte
alignment).
Region-backed raw allocations are bump-allocated. In the current runtime model:
std::runtime::mem::freeis a no-op for region-backed pointers,std::runtime::mem::reallocreallocates by allocating a new region block and copying bytes (it never calls libcreallocon a region-backed pointer).
Nested with#
Nested with blocks use the innermost active region:
with a {
with b {
// `new` uses region `b` here.
}
}
Reclaiming Region Memory#
Regions are bump allocators: each allocation advances a cursor within the backing byte buffer.
Because region-backed new allocations are still RC-managed in the current
subset and do not free backing bytes on last-release, reclaiming region memory
requires resetting the region cursor so the backing bytes can be reused.
Current behavior:
with <region> { ... }activates the region but does not reset its cursor.- allocations across multiple
with <region>blocks accumulate and can eventually overflow and trap. with <bytes> { ... }creates an anonymous region and resets its cursor to0on entry so repeated execution of the block starts from an empty region.with <bytes> from <region>[...] { ... }creates an anonymous region backed by a subrange of<region>and resets its cursor to the slice start on entry.
Important limitation:
- The compiler does not yet enforce “region allocations must not escape the
withblock”. Because anonymous-region cursors are reset on entry, code must treat pointers/&Structvalues allocated insidewith <bytes> { ... }andwith <bytes> from ... { ... }as block-scoped.
Exports#
Region declarations may be exported and imported like other top-level bindings:
export const region global_region_buf: u8[4096];
Exporting a region exports a Region handle that refers to the same backing
bytes and cursor cell. Importing a region binds a Region value that may be
used with with like a locally declared region.
Concurrency#
Concurrency in Silk is built around two orthogonal function modifiers:
async— marks a function as pausable/awaitable (concurrency),task— marks a function as safe to execute on a worker pool (parallelism),
plus structured concurrency blocks (async { ... } and task { ... })
intended to provide structured concurrency.
- The runtime can manage a thread pool to execute tasks.
- The compiler is intended to enforce task-safety rules when values cross task boundaries (Send/Sync-like constraints).
Notes#
This document describes the language design for concurrency and the subset implemented by the compiler/runtime today.
Notes#
- Parsing of
task fn,async fn, andasync task fn/task async fn. - Parsing of
yield <expr>andyield * <expr>(seeyieldbelow). - Parsing of
await * <expr>as a unaryawaitapplied to a unary*operand (seeawaitbelow). - Calling a function with a concurrency discipline produces a handle:
- calling a
task fnproducesTask(T), - calling an
async fnproducesPromise(T), - calling an
async task fnproducesPromise(Task(T)), whereTis the function’s declared surface result type. yieldis implemented with two forms:- send (
yield <value>;) inside a task: writes one task value (convertible to the enclosing task’sT) to the task’s receiver and continues execution. This form is only permitted inside atask fn/async task fnbody. - receive (
yield <task_handle>) in value position: waits until the task produces its next value and yieldsT. - under the hosted async executor, this wait suspends the current coroutine instead of blocking the executor owner OS thread,
- outside an executor (or on non-owner threads), it blocks the current OS thread until the task produces a value.
yieldon a temporary task handle is eager in the Supported forms:yield <task_expr>where<task_expr>is not a named handle drains the task (joining dedicated-thread tasks only) and yields its final valueT(so the temporary handle does not leak).yield * <task_handle>in value position drains a task:yield * Task(T)receives all remaining values from the task, joins the dedicated worker thread when the task usesattr(task=thread), and yields a collectedT[]result (with the task’s final return value as the last element). Pooled/default tasks skip the join.yield *also accepts fixed task arrays and returns one concatenated collectedT[]in source order, including:- named bindings such as
yield * tasks, - direct fixed-array expressions,
- struct-field carriers such as
yield * box.tasks, - nested field expressions such as
yield * make_box().tasks. yield * <task_handle>;as a statement inside a task function forwards values:- drains the right-hand task and forwards all remaining values to the enclosing task’s receiver, then joins/cleans up the drained task.
- the same forwarding sugar accepts fixed task arrays and drains them in source order, including field-carried fixed arrays.
await <expr>is implemented as a Promise unwrap operation:await Promise(T)unwraps and yieldsT,await Promise(Task(T))unwraps and yieldsTask(T),await Task(T)is rejected (useyield/yield *for task values).await * <promises>unwraps a collection of promises:await * Promise(T)[]yields a collectedT[]by awaiting each promise,await * Promise(T)is rejected (the*form requires a collection).awaitand the structured block form are still async-context-only:awaitis only allowed inside functions declared withasync(includingasync task fn),async { ... }andtask { ... }are only allowed inside functions declared withasync.async loop { ... }andtask loop { ... }are only allowed inside functions declared withasync.- Conservative suspension-safety rules are enforced at
async fnboundaries: async fnresult types must not contain ordinary borrowed views (&TorT[]), including when nested inside structs, enums, optionals, or function types,- references to opaque structs (
struct Name;) remain permitted in async results because they are treated as external handles rather than borrow-checked views into Silk storage, - borrowed async parameters are permitted, but an ordinary borrow of function-local stack storage or a local fixed array may not be passed into an async call unless that call is awaited immediately in the same expression.
- Conservative suspension-safety rules are also enforced at concrete
awaitpoints: await/await *reject a live borrowed reference that still points at a local stack value,await/await *reject a live slice that still points at a local fixed array,- and the same rule applies when the borrowed view is stored in a local struct field.
async { ... }/task { ... }are accepted as structured concurrency surface forms and establish lexical scopes with deterministic runtime-backed cleanup:- live
Promise(T)bindings are awaited/destroyed on scope exit, - live
Task(T)bindings are drained/destroyed on scope exit, - and the same cleanup runs for lowered early-exit paths such as
return. - these blocks do not create nested executors or inject implicit cancellation tokens in the Supported forms.
yieldis task-context-only:yieldis only allowed insidetaskfunctions (task fn/async task fn) and insidetask { ... }/task loop { ... }blocks.- Initial task-safety rules are enforced at the
task fnboundary: task fn/async task fnparameter and result types must not contain non-opaque reference types (&T), including within structs and optionals.- references to opaque structs (types declared as
struct Name;) are permitted (opaque structs are handle types and cannot be dereferenced or field-accessed in Silk). Task(T)andPromise(T)handles are permitted at task boundaries, but their innerTmust itself satisfy the task-safety rule above. This supports patterns likeTask(Promise(T))(for tasks that produce promises) andawait * yield * tfort: Task(Promise(T)).std::sync::Arc(T)handles are permitted at task boundaries whenTsatisfies the same task-safety rule. Moving anArc(T)into a task transfers that handle; callclone()explicitly before spawning multiple tasks that need shared ownership. Borrowed non-opaque references insideArc(T)are rejected at the task boundary.
Thread Safety and Sharing#
task concurrency runs on OS threads. Crossing a task boundary is therefore a
thread-crossing operation.
In Silk currently:
- Passing values into a
task fnis by value. For ownership-tracked values (for exampleDroptypes andTask(T)/Promise(T)handles), this is a move: ownership transfers into the task and there is no implicit sharing. - The checker enforces a conservative task-safety rule at
task fn/async task fnboundaries (E2037): - non-opaque references (
&T) are rejected (including nested inside structs and optionals), - references to opaque structs (types declared as
struct Name;) are permitted (opaque structs cannot be dereferenced or field-accessed in Silk), - task boundary types are otherwise restricted to primitives, optionals, and structs/enums composed of task-safe members.
- Shared mutable state must be synchronized explicitly (for example via
std::syncprimitives,std::atomicatomics, or by communicating through channels). - To share a runtime handle across tasks without transferring ownership, prefer
stdlib APIs that follow the
T/TBorrowpattern (for exampleChannel(T)+ChannelBorrow(T)andAbortSignal+AbortSignalBorrow). - To share ownership of immutable or internally synchronized state across
tasks, use
std::sync::Arc(T)and clone the handle explicitly.Arc(T)does not permit unsynchronized mutation ofT; put synchronization insideT(for example aMutex-like handle) when mutation is required. - To share one atomic cell across tasks, keep the owning
std::atomicvalue alive in the parent scope and passAtomicU64BorroworAtomicBoolBorrowacross the task boundary.
Note: this includes &Struct values produced by new. The compiler-inserted
reference counting (RC) used for new is non-atomic in the Supported forms and
is not safe to share across OS threads.
These rules prevent common “accidentally share a borrowed view across threads” bugs in the Supported forms. They do not prevent data races in programs that explicitly share memory through FFI or other low-level mechanisms; such sharing must be synchronized by the program.
Important Limitations#
- Hosted async runtime bring-up exists on supported hosted POSIX targets
(
linux/*and Apple Siliconmacos/aarch64today): awaitis a true suspension point backed by a single-threaded executor (fibers), so awaiting a pendingPromise(T)can park and resume without blocking the OS thread.- The current implementation uses stackful coroutines in
libsilk_rt(the implementation) rather than a compiler state-machine coroutine transform. The long-term design remains a compiler transform + stablestd::runtime::event_loopsurface (seeAsync Runtime (Hosted)). - The shipped executor is thread-affine:
- only the thread that created the executor may spawn and drive stackful coroutines (stackful coroutines are never migrated across OS threads),
std::runtime::event_loop::{poll,deinit}must be called on that same thread,- other OS threads (including
task fnworkers) may still callasync fnentrypoints, but those calls run synchronously (no coroutine spawn), andawaiton a non-owner thread blocks the OS thread until the promise is resolved. - Awaiting a
Task(T)is rejected by design; useyield/yield *for task values. - Executable entrypoints currently support
fn main (...) -> int,fn main (...) -> void,async fn main (...) -> int,async fn main (...) -> void,fn main(argc: int, argv: u64) -> int, andfn main(argc: int, argv: u64) -> void. Task-backed entrypoints such astask fn mainandasync task fn mainare rejected by the executable runtime path; keep task work inside an ordinary or asyncmain. - The runtime subset implements
taskexecution using OS threads: - By default, calling a
task fnschedules that task on the global task pool (a shared queue-based worker pool). The pool is created lazily. attr(task=thread)forces a dedicated OS thread per call.- The pool worker count is configurable via
SILK_TASK_POOL_THREADS. - The queued backlog is configurable via
SILK_TASK_POOL_MAX_QUEUED. - Full Send/Sync-style checking (beyond the conservative boundary restriction described above) is not implemented yet. In particular, the compiler does not attempt to prove absence of data races for shared state; programs must use explicit synchronization for any shared mutation.
- A small initial set of standard-library primitives exists now under
std::taskandstd::syncfor supported hosted POSIX targets. Some OS-facing std modules already integrate with the async executor/event loop for timers, fd readiness, I/O, and TCP connect/accept; wider cancellation and platform parity remains follow-up work. - For cooperative cancellation across tasks and
asyncfunctions,std::provides WHATWG-style abort signals viastd::abort_controller(seethe standard library).
Core Keywords: async and task#
async#
- Marks a function as awaitable (pausable).
- Primary domain (design): I/O-bound concurrency on an event loop/executor.
task#
- Marks a function as task-safe and eligible to be executed as a parallel task on a worker pool.
- Primary domain (design): CPU-bound parallelism and offloading blocking work.
- In the intended design, calling a
task fnis non-blocking and produces a task handle.
await#
await <expr> is the surface syntax for unwrapping a Promise(T) handle.
In Silk currently:
await Promise(T)unwraps the completed promise and yieldsT.await Promise(Task(T))yieldsTask(T)(which can then be consumed viayield/yield *).await Task(T)is rejected; useyield/yield *for task values.await/await *also reject live borrows of ordinary local stack or fixed-array storage at the suspension point; end such borrows before awaiting.- Ordinary non-entrypoint
async fnbodies supportawait * Promise(T)[]fan-in, including named fixed-array locals such as: let promises = [reader(), writer()];await * promises;The compiler/runtime path drains those promise handles and treats the bound fixed array as consumed so scope cleanup does not attempt to drop the same handles twice.
Typed Errors Across Async Calls#
Typed-error handling composes with async calls in the Supported forms, but the
fallible operation remains the async call site rather than the await
itself.
For an async function like:
error OpenFailed {
code: int,
}
async fn open_value () -> int | OpenFailed {
return 1;
}
the current checker behavior is:
await open_value()is rejected withE2023because the fallible async call has not been handled yet.let p: Promise(int) = open_value()?;is accepted inside a matching error contract.let v: int = await open_value()?;is accepted and is the supported propagation form in the Supported forms.- Explicit handling with
matchapplies to the async call itself, so the success arm receives thePromise(T)handle:
match (open_value()) {
p => {
let v: int = await p;
return v;
},
err: OpenFailed => {
panic OpenFailed { code: err.code };
}
}
Task/Promise Handle Ownership#
In Silk currently, Task(T) and Promise(T) are single-use
handles:
- A
Promise(T)handle may be awaited at most once.awaitconsumes the handle. - A
Task(T)handle may be drained/joined at most once viayield *(andyieldon a temporary task expression drains as well, joining thread-per-call tasks). - Handles are non-copyable: you may not copy a handle into another binding or use it as a normal value expression.
- Discard bindings may not consume handles:
let _ = task_call();is rejected forTask(T),let _ = async_call();is rejected forPromise(T), because_performs end-of-statement cleanup rather than structured scope-exit cleanup.- Handles may be moved into ordinary bindings, reassigned after consumption, passed through consuming call positions, and moved into collections that accept move-only element values.
- Direct
Task(T)/Promise(T)storage in struct and error fields is part of the Supported forms: struct Box { t: Task(int) }is accepted,struct Box { p: Promise(int) }is accepted,- consuming field access such as
yield * box.tandawait box.pis tracked with the same single-use rule as local handle bindings, - whole-value initialization/copy/reassignment refreshes the stored field handle state for the destination aggregate.
- A consumed handle may not be used again (including attempting to
awaitit a second time, or attempting toyield *it a second time). - Consuming a handle that was created outside the current loop body is rejected in the Supported forms (a loop may iterate multiple times).
These rules are enforced at compile time and exist to prevent double-free and
use-after-free bugs in the current runtime lowering, where await frees the
underlying handle storage after join/unwrap.
Handle Lifetime and Cleanup#
In Silk currently, Task(T) and Promise(T) handles are stored in
heap-allocated handle memory:
awaitunwraps a promise and then frees the promise handle storage.yield *drains a task and then frees the task handle storage (joining dedicated-thread tasks).yield *over a fixed task array consumes each contained handle exactly once and marks the named array binding moved so cleanup does not attempt to free the same handles again.- If a handle is not consumed (
await/yield *), the compiler inserts automatic cleanup when the handle binding is overwritten or goes out of scope: Task(T)cleanup joins the worker thread forattr(task=thread)tasks and then frees the handle storage. Pooled/default tasks skip the join since there is no per-call worker thread to join.Promise(T)cleanup frees the handle storage.
Because tasks are implemented using OS threads in the Supported forms, this automatic cleanup can block the current OS thread when it joins a task. Promise cleanup uses the hosted async runtime’s destroy helper and may suspend the current coroutine while waiting for a pending promise to resolve when running under an executor.
yield#
yield is the task-side counterpart to await.
In the intended model for tasks:
- A
task fn ... -> Tproduces aTask(T)handle when called. - Inside the task body,
yield <expr>;sends a value (convertible toT) to the task’s receiver and continues execution. return <expr>;sends the final task value (of typeT) and terminates the task.- Outside the task,
yield <task_handle>blocks until the task produces its next value and yields it. - The receive form is a value-position expression, not a statement form:
let value = yield task_handle;receives one value,yield task_handle;is parsed as the statement/send form and is therefore not the right way to wait on another task handle.yield * <task_handle>drains all remaining task values and then joins the worker thread for cleanup when the task usesattr(task=thread). Pooled/default tasks skip the join. In value position,yield *yields a collectedT[].yield * <task_handle>;as a statement forwards all remaining values from the right-hand task to the enclosing task’s receiver and then joins/cleans up the drained task.
In Silk currently:
yieldis a blocking OS-thread operation (like the rest of the current concurrency runtime).yieldis permitted only insidetask fn/async task fnbodies and insidetask { ... }/task loop { ... }blocks.- The statement forms (
yield <value>;andyield * <task_handle>;forwarding) require an enclosing task function (task fn/async task fn), since they send values to the task’s receiver.
Collected Array Ownership#
In the Supported forms, yield * and await * produce a heap-allocated
collection of values (T[]) for convenience. This is a current behavior:
- the compiler inserts deterministic cleanup for these collections when their bindings are overwritten or go out of scope,
- the returned
T[]value must not be copied, and must not escape its defining scope until a stable owning collection type is specified.
Structured Concurrency Blocks and Loops#
async { ... }, task { ... }, async loop { ... }, and task loop { ... }
introduce surface syntax for structured regions.
In Silk currently, these forms remain lexical scopes, but they are runtime-backed for live-handle cleanup:
- live
Promise(T)bindings are awaited/destroyed on scope exit, - live
Task(T)bindings are drained/destroyed on scope exit, - task waits performed during that cleanup use the hosted async fd-wait path when running under the executor, so cleanup inside async code suspends the current coroutine rather than blocking the executor owner thread,
- and no implicit nested scheduler or abort-controller injection occurs.
Current Runtime Boundaries#
This language document describes the shipped concurrency subset and its current
boundaries. Longer-term runtime architecture notes are tracked in
Async Runtime (Hosted); the runtime backlog items for the current
hosted subset are now implemented.
Current boundaries and non-goals:
asyncis cooperative: there is no preemptive async scheduling.task fncalls default to the global task pool;attr(task=thread)is the explicit dedicated-thread opt-out.yield/yield *waits inside executor-driven async code suspend the current coroutine, but the same operations still block when no executor is active or when run from non-owner threads.- structured blocks/loops guarantee deterministic live-handle cleanup on scope exit and early exit, but they do not inject implicit cancellation tokens or nested executors.
- Task-boundary safety still uses the conservative current rule that rejects
ordinary non-opaque
&Tacrosstask fn/async task fnboundaries. await Task(T)remains rejected; task values are consumed viayield/yield *.- Hosted async coroutines are not migrated across OS threads; parallelism is
expressed via
taskand explicit synchronization.
Formal Silk#
Formal Silk is Silk’s compile-time formal verification language. It is written using syntax that does not exist at runtime and is discharged at compile time using the Z3 SMT solver.
When Formal Silk syntax is present, compilation generates verification conditions (VCs), proves them with Z3, and fails the build if any VC cannot be proven. This behavior applies to:
- the
silkCLI (silk check,silk test,silk build), and - the C ABI build entrypoints (
silk_compiler_build,silk_compiler_build_to_bytes).
Proof requirements are opt-in by syntax#
Silk requires proofs only when verification syntax is present in the compiled module set:
- any use of
#...directives (#require,#assure,#assert,#invariant,#variant,#monovariant,#const) — including#requireattached tostructdeclarations.
When verification syntax is present, compilation MUST:
- generate VCs,
- prove them using Z3, and
- fail compilation with clear diagnostics if any VC cannot be proven.
When verification syntax is not present, compilation does not require proofs.
Z3 linkage and overrides#
On supported native hosts, Silk links the built-in Z3 static library and its
headers (vendor/include) directly into the compiler when the host archive is
present:
linux/x86_64->vendor/lib/x64-linux/libz3.amacos/aarch64->vendor/lib/aarch64-macos/libz3.a(optional and staged when present)
If no static host archive is present, the compiler still builds, but Formal Silk verification reports Z3 as unavailable unless a dynamic library override is provided.
To override the Z3 library at runtime (for example to test against a different Z3 build), provide a dynamic library path:
- CLI: pass
--z3-lib <path>, or - CLI/ABI: set
SILK_Z3_LIBin the environment.
When --z3-lib is provided, it overrides SILK_Z3_LIB.
Debugging proofs with Z3 (--debug)#
When a verification condition fails, the compiler reports a normal diagnostic at the failing annotation site.
When --debug is passed to silk build or silk test, the verifier also emits
additional Z3 debugging output to stderr and writes an SMT-LIB2 reproduction
script under .silk/z3/ in the current working directory (or $SILK_WORK_DIR/z3):
.silk/z3/silk_z3_m<module>_<n>.smt2
You can replay the query with an external Z3 binary:
z3 -smt2 .silk/z3/silk_z3_m0_0.smt2
Successful silk build runs that compile exported Formal Silk surface also emit
a distributable success-path bundle under .silk/formal/ (or
$SILK_WORK_DIR/formal/) keyed by the output artifact identity. See
“Distribution and export bundles” below.
Z3 model#
The current Formal Silk verifier maps Silk constructs directly to Z3:
bool→ Z3 Bool.string→ Z3 String (Supported forms: literals and equality/inequality comparisons).- integer primitives → fixed-width Z3 bitvectors:
i8/u8→ BV8i16/u16→ BV16i32/u32→ BV32i64/u64/int→ BV64
Arithmetic is modular 2^N (wraparound). Ordered comparisons and >> use
signed semantics for signed integers (i*/int) and unsigned semantics for
unsigned integers (u*).
- other primitive/runtime values that do not currently have dedicated numeric
reasoning support (for example
char, floating-point primitives,Range,Instant, andDuration) are modeled as opaque uninterpreted values. - non-primitive runtime values passed through contracts
(
&T, named values, optionals, arrays, function values, and applied types) are modeled as opaque uninterpreted values: - equality/inequality works when both sides have the same Silk type,
- the verifier does not infer field layout or numeric ordering from these values,
- this is enough for exported method receivers and distributable contracts that only need identity-style reasoning over non-primitive parameters.
Supported operators in specification expressions (Supported forms):
- boolean:
!,&&,||,==,!= - string:
==,!= - integer:
- unary:
-,~ - arithmetic:
+,-,*,/,% - bitwise:
&,|,^,<<,>> - comparisons:
<,<=,>,>=,==,!= - size/layout queries:
sizeof,alignof,offsetof(type operands and other statically-sized operands in the Supported forms)
Supported name-resolution sources in specification expressions (Supported forms):
- in-scope runtime/formal bindings,
- compiler-provided metadata constants such as
BUILD_*,OS_*, andSILK_*, - and const-evaluable module/package
constbindings, including exported qualified names such asstd::limits::I64_MAX.
Other operators and expression forms are currently rejected in verified code (see the notes below).
The ext boundary#
External declarations (ext) have no body available to the verifier.
Therefore:
- The verifier cannot generate VCs about the behavior of
extbodies. - In the current verifier subset, calls are supported only to functions and
methods that have Formal Silk contracts (see “Contracted calls” below).
extdeclarations do not have Formal Silk contracts yet, so verified code cannot callextfunctions.
See External Declarations (ext) for the external-declaration rules.
The main constructs are:
#const— formal Silk declarations used inside specifications.#require— precondition.#assure— postcondition.#assert— block-local proof obligation.#invariant— loop or state invariant.#variant— well-founded termination measure (ranking function).#monovariant— monotonic measure (non-decreasing or non-increasing).theory/#theory— reusable, parameterized proof obligations.
Key properties:
- These annotations appear before the function or loop they describe.
- They are used by the verifier only and incur no runtime cost.
Formal Silk declarations (#const)#
Formal Silk declarations let you name intermediate values for use in specifications.
Syntax:
#const name = <Expr>;
Rules:
#constis a statement that may appear inside function bodies (inside blocks).- The binding is compile-time-only and is not lowered into runtime code.
- A
#constbinding is visible only inside specification expressions: - function specs (
#require,#assure), - loop specs (
#invariant,#variant,#monovariant). - Using a
#constname in a runtime expression (e.g. inwhileconditions or normalletinitializers) is a compile-time error. Use a normalletbinding for runtime values, and (optionally) introduce a#constalias for specifications.
Example:
fn main () -> int {
let limit: int = 3;
#const original_limit = limit;
let mut i: int = 0;
#invariant i >= 0;
#invariant i <= original_limit;
#variant original_limit - i;
while i < limit {
i = i + 1;
}
return 0;
}
Function annotations#
For functions, the initial surface syntax is:
#require <Expr>;
#require <Expr2>;
#assure <Expr3>;
#theory TheoryName(args...);
fn name (params) -> ResultType {
...
}
- One or more
#require,#assure, and contract-theory attachments (#theory Name(args...);) may appear, in any order, immediately before thefndeclaration (and before anyexportmodifier). - Each annotation is terminated by a semicolon.
- The compiler front-end:
- lexes these annotations as dedicated tokens,
- parses the annotation expressions using the normal expression grammar,
- type-checks each annotation expression as
boolso obvious mistakes are rejected early (specifications are still compile-time-only metadata), - attaches them to the corresponding function in the AST as lists of preconditions, postconditions, and contract theories.
Struct requirements (#require on struct)#
Struct declarations may be preceded by one or more #require directives:
#require <Expr>;
struct Name {
field: int,
}
These #require expressions are struct requirements: properties that must
hold for all values constructed for that struct type.
Rules (Supported forms):
- Struct requirement expressions may reference the struct's fields by name.
- When Formal Silk syntax is present, the verifier proves all requirements at
struct literal construction sites (
Name{ ... }andnew Name{ ... }), using the literal's field initializers and default initialization for any omitted fields. - When a struct extends a base struct, the derived struct inherits the base struct's requirements (all requirements must be proven at construction).
- If the verifier cannot prove a requirement, compilation fails with
E3006. The diagnostic names the failed predicate and, when the predicate references fields initialized by the literal or by defaults, reports those field values.
Loop specifications (#invariant, #variant, #monovariant) follow a similar
pattern for loops.
Loop annotations#
For while loops, the initial surface syntax is:
#invariant <Expr>;
#variant <Expr2>;
#monovariant <Expr3>;
while condition {
...
}
Rules:
- One or more
#invariantannotations, zero or more#monovariantannotations, and at most one#variantannotation may appear immediately before thewhilekeyword. - Each annotation is terminated by a semicolon.
- The compiler front-end:
- lexes these annotations as directive tokens,
- parses the annotation expressions using the normal expression grammar,
- attaches them to the corresponding loop in the AST as invariants, monovariants, and a (single) variant expression.
The verifier will interpret:
#invariantexpressions (typeboolin the Supported forms) as properties that must hold:- before entering the loop,
- after each iteration (assuming the body and condition do not diverge),
- and at
breakexits (so proofs after the loop may rely on the invariant). #variantexpressions as a well-founded measure that must decrease on each iteration (and be non-negative at the loop head), used for termination proofs.#monovariantexpressions as measures that must be monotonic on each iteration (either non-decreasing or non-increasing, proved consistently across all continuation paths).
Compiler requirements:
- Parse and represent these annotations in the AST.
- Integrate with the verifier to check specifications.
- Ensure that, if verification fails, compilation fails with clear diagnostics.
Block assertions (#assert)#
Formal Silk also supports block-local proof obligations:
#assert <Expr>;
Rules:
#assertis a statement that may appear inside function/test bodies (inside blocks).- It is compile-time-only metadata and is not lowered into runtime code.
- The verifier must prove the assertion holds in the current symbolic state at
the
#assertsite. If it cannot be proven, compilation fails. - After a
#assertsucceeds, the asserted expression is assumed to hold for the remainder of the block (so later proofs may rely on it).
Notes#
Implemented end-to-end (Z3-backed, Supported forms):
- The verifier runs only when Formal Silk syntax is present.
#require/#assure:- generate VCs and prove them for verified
fndeclarations and verifiedimplmethods. #assuremay referenceresult(the return value) as a built-in formal declaration.- build metadata constants are available in Formal Silk expressions:
BUILD_KIND,BUILD_MODE,BUILD_VERSIONas built-in compile-timestringvalues,- and
BUILD_VERSION_MAJOR/BUILD_VERSION_MINOR/BUILD_VERSION_PATCHas built-in compile-timeu64values. - Struct requirements (
#requireonstructdeclarations): - generate VCs and prove them at struct literal construction sites (
Type{ ... }andnew Type{ ... }), - include inherited requirements from base structs (
struct Child extends Base { ... }). #assert:- proves the asserted expression holds at the
#assertsite, - and then assumes it for the remainder of the block.
#invariant/#variant/#monovariantonwhileloops:- prove invariants at entry and preservation across one iteration,
- prove variants are non-negative and decrease across one iteration.
- prove monovariants are monotonic across one iteration (either non-decreasing or non-increasing, consistent across all continuation paths).
- Formal Silk declarations via
#const: - may be referenced only by specification expressions,
- are rejected in runtime expressions (
E2014). theory(reusable assertions, initial subset):theory Name(params) { ... }defines a reusable set of proof obligations (exportable/importable at top level),#theory Name(args);applies it in a function body as compile-time-only assertions,#theory Name(params) { ... }may also declare an inline (non-exportable) local theory inside a block.- Contracted calls:
- direct calls of the form
Name(args...)are checked whenNameresolves to a function with a Formal Silk contract, - receiver calls of the form
expr.method(args...)are checked when the receiver resolves to a concrete method owner andmethodresolves to a method with a Formal Silk contract, - at every checked call site, including ordinary callers that have no Formal
Silk annotations of their own, the verifier proves the callee’s
preconditions (explicit
#requireand any attached-theory#require) under the caller’s current path condition; errors reportE3007, - after the call, the verifier assumes the callee’s postconditions (explicit
#assureplus attached-theory#assure/#invariant) into verified callers' symbolic state so subsequent proofs can use them, - if the callee has a source-visible body, the Supported forms requires that body to be a single return expression (no runtime statements); the verifier inlines that return expression in the caller’s symbolic state,
- if the callee has no body (a declaration-only prototype, typically used when linking against a precompiled implementation), the verifier treats the call as opaque:
- it proves the preconditions at the call site,
- introduces an uninterpreted symbolic value for the return,
- and assumes postconditions about that return value,
- if the callee has no Formal Silk contract, verified code may still call it in the Supported forms:
- if the source-visible body is a single return expression, the verifier may inline that return expression into the caller’s symbolic state,
- otherwise the verifier treats the return value as opaque and assumes no additional facts about it,
- unused expression-statement calls are permitted under the same rule,
- and the verifier does not infer side-effect facts from these contractless calls,
- recursion is not supported yet.
- Stdlib modules are verified when they use Formal Silk syntax, subject to the same current-subset restrictions described here.
Not implemented yet :
- Counterexample models (the verifier reports errors, but does not yet print a model).
- Verified local bindings are still limited to primitive/string-like symbolic
types plus opaque parameter-style values. Field projections from typed
parameters/results/receivers used in contracts and
#theoryarguments are supported through uninterpreted projections. Direct field assignment through a named aggregate or receiver (name.field = expr) is modeled by creating a fresh aggregate value, constraining the assigned field, preserving the other fields, and rechecking the aggregate's struct requirements. Verified function/method entry also assumes struct requirements for non-optional typed aggregate parameters, so direct field writes can prove requirements over untouched fields from the aggregate's starting invariant. Fully nested field-sensitive named-struct local-state reasoning is not yet supported in verified blocks. - Verification of the full expression language and full statement language
(
match, nested loops, indirect calls, and many operators are not supported yet in verified code). Statement-levelifpath splitting is implemented in the Supported forms. - Verified assignment statements currently support local names and direct field
writes through named aggregate or receiver values (
name.field = expr). Direct field writes re-prove the target aggregate's struct#requireclauses after the write. Optional-field, index, nested-field, and compound assignment targets are rejected withE3005.
Theories (theory / #theory)#
A theory is a reusable, parameterized block of Formal Silk directives that can be applied at points inside a function body to assert properties about the current symbolic state.
Syntax#
Declaration form (top-level):
export theory a_custom_theory (x: int, y: int) {
#const z = x + y;
#invariant x != 0 && y != 0;
#invariant z > 1;
}
An inline theory declaration may also appear as a statement inside a function/test block:
fn main (x: int, y: int) -> int {
#theory local_sum_nonzero (x: int, y: int) {
#const z = x + y;
#assure z != 0;
}
#theory local_sum_nonzero(x, y);
return 0;
}
Use form (statement inside a function body, or inside another theory body):
fn main (x: int, y: int) -> int {
#theory a_custom_theory(x, y);
return 0;
}
Theories may apply other theories:
export theory nonzero (x: int) {
#require x != 0;
}
export theory nonzero_sum (x: int, y: int) {
#theory nonzero(x);
#theory nonzero(y);
#assure (x + y) != 0;
}
Notes:
- Top-level theory declarations use the
theorykeyword. - Inline (block) theory declarations and theory use sites share the
#theorytoken; the parser disambiguates by the token that follows the argument/parameter list: { ... }starts an inline theory declaration,;terminates a theory use.- A top-level theory declaration may be exported (
export theory ...). Exported theories may be imported from other modules and reused. - Inline theory declarations inside a block are not exportable/importable; they
exist only in the containing block and may be applied via
#theory Name(...);after they are declared. - A theory body may contain
#theory Name(args);statements. These are compile-time-only theory applications; they are checked by the verifier in the current symbolic state at the point they appear in the theory body. - Theory recursion is rejected (direct or indirect cycles).
- Theories are not runtime functions. They can only be applied via
#theoryuse statements; calling a theory with normal call syntax (Name(...)) is a compile-time error.
Prefix #require / #assure on theories#
For ergonomics, a theory declaration may be preceded by #require and/or
#assure directives:
#require x >= 0;
export theory ensure_nonnegative_x (x: int) {
#assure x >= 0;
}
These prefix directives are treated as if they were written at the beginning of the theory body.
Theories as function contracts#
A function may attach one or more theories as part of its Formal Silk contract
surface by placing #theory Name(args...); directives in the function-spec
prelude:
import { bounded_nonneg_add } from "./theories.slk";
#theory bounded_nonneg_add(x, y);
export fn add (x: int, y: int) -> int {
return x + y;
}
Contract-theory attachments:
- are compile-time-only metadata (not runtime statements),
- contribute additional preconditions/postconditions to the function contract:
#requirebecome additional function preconditions,#assureand#invariantbecome additional function postconditions,- are used by the verifier to check contracted call preconditions and to enable contracted calls in verified code (see “Contracted calls” above),
- are not permitted before a top-level
theorydeclaration (only#require/#assuremay prefix a theory declaration).
Importing theories#
Exported theories may be imported via JS-style named imports and then applied
via #theory use statements.
Example:
// theories.slk
export theory ensure_positive_x (x: int) {
#assure x > 0;
}
// main.slk
import { ensure_positive_x as pos_x } from "./theories.slk";
fn main () -> int {
let x: int = 1;
#theory pos_x(x);
return 0;
}
Rules:
- Only exported theories may be imported.
- A theory use (
#theory Name(args);or#theory pkg::Name(args);) resolves the theory name as either: - a local theory declared in the same module, or
- an imported theory name from
import { ... } from "<specifier>";, or - an exported theory addressed by its package-qualified name.
Semantics#
When a theory is applied (#theory Name(args);):
- its parameters are bound to the provided argument expressions (as specification expressions),
- its
#constformal declarations are evaluated and are visible only within the theory during checking, - each
#require <Expr>;,#invariant <Expr>;, and#assure <Expr>;directive in the theory body is treated as a compile-time proof obligation that must hold in the current symbolic state at the use site, - each
#variant <Expr>;directive in the theory body is treated as a non-negativity obligation (Expr >= 0) at the use site (the theory form does not model decrease across iterations).
Theory bodies are verifier-only:
- Theory argument expressions and theory directive expressions are Formal Silk specification expressions evaluated by the verifier.
- In the Supported forms, specification expressions do not support function
calls or value construction (for example
foo(x),Type{...}, arrays, ornew). Such expressions are rejected as unsupported Formal Silk.
The theory form is compile-time-only and has no runtime semantics.
Distribution and export bundles#
When a successful silk build compiles a module set that exposes reusable
Formal Silk surface, the compiler emits a machine-readable export bundle.
Exported surface that participates today:
export theory Name(...) { ... }- exported top-level functions whose contract surface is non-empty
(
#require,#assure, or contract#theory) - exported/public
implmethods whose contract surface is non-empty
The bundle is written under the compiler work directory:
- direct builds:
.silk/formal/<output-identity>/manifest.json.silk/formal/<output-identity>/bundle.smt2- when
SILK_WORK_DIRis set, the same layout is rooted there instead of.silk/
The manifest records, for each entry:
- a stable entry id
- whether it is a
theory,function, ormethod - the originating module path
- the package name
- the exported symbol name
- the owner type for methods
- the normalized signature string
- the payload section id inside
bundle.smt2 - the count of exported
#require/#assureobligations - and the attached theory ids for contracted functions/methods
The payload contract is intentionally source-oriented and portable:
payload_solver = "z3"payload_format = "smt2"payload_encoding = "source"
That is, Silk currently distributes normalized SMT-LIB2 source, not a
solver-private binary snapshot. This keeps the artifact inspectable, stable
across hosts, and suitable for replay with an external z3 -smt2 ... tool.
Installed packages copy the same bundle under the package root:
share/silk/formal/<artifact-relative-path>/manifest.jsonshare/silk/formal/<artifact-relative-path>/bundle.smt2
This makes Formal Silk metadata distributable alongside definitions, headers,
and native artifacts, while keeping source-visible theory/prototype declarations
as the authoritative import-time verification surface. Installed package
loading and silk package inspect discover these bundle paths so downstream
tooling can locate the exported Formal Silk payload directly.
Generics (Monomorphized)#
This document specifies Silk’s parameterized type and declaration syntax.
Silk’s generics are compile-time features: parameterized declarations are monomorphized into concrete, fully specified types and functions at build time (there are no runtime type parameters).
Diagnostics rule:
- Generics are part of the Silk language design, not an out-of-language extension.
- When the current compiler rejects a generic form with
E2016, that means the implementation is incomplete for that generic form, not that the language forbids generics.
Note: Option(T) is a special-case surface form that is treated as sugar for
T? in the Supported forms (see Optional). This is still
accepted in the Supported forms even as general-purpose type-parameter generics are
implemented.
Overview#
Silk supports parameterized declarations by allowing a parameter list on
struct, interface, enum, impl, and fn declarations.
In Silk currently:
- Supported: type parameters (
T) and const parameters (N: usize) onstruct/interface/enum/impl, type application in type positions (Name(args...)), and generic functions using a compile-time parameter section separated by;in the signature (fn id(T; x: T) -> T) (including generic methods inimplblocks).
Declaration syntax#
Parameter lists#
The parameter list uses parentheses:
struct Vector(T) {
// ...
}
Rules:
Tis a type parameter.- Type parameters may provide a default type argument using
=: interface Serialize(S = string) { ... }- defaults must be trailing (once a parameter has a default, all subsequent parameters must also have defaults).
- Const parameters are written with an explicit type annotation:
N: usize- const parameters are compile-time integer values and may be used in type
positions such as array lengths (
T[N]) and type applications. - The parameter list may be empty (though it is uncommon):
struct Foo() { ... }.
Supported declaration forms:
struct Name(T, ...) { ... }interface Name(T, ...) { ... }enum Name(T, ...) { ... }type Alias(T, ...) = Target(T, ...);impl Name(T, ...) { ... }impl Name(T, ...) as InterfaceName(T, ...) { ... }
Applying parameters in types#
Parameterized types are referenced using the same call-like syntax in type positions:
Vector(int)
Mutex(Account)
Result(int, string)
Applied Generic Type Qualifiers#
Applied generic type names may be used directly as static member qualifiers after their type arguments are fully known:
import { Buffer } from "std/buffer";
fn main () -> int {
let b = Buffer(u8).empty();
return b.capacity() as int;
}
This form is equivalent to introducing an explicit alias for the instantiation and then calling the member through that alias:
type BufferOfU8 = Buffer(u8);
let b = BufferOfU8.empty();
The qualifier applies the same type-argument rules as type positions: type
arguments must resolve to known types, const arguments must be integer
compile-time arguments, and named imports of generic templates participate in
the same lookup rules as imported non-generic types. A generic type whose
parameters all have defaults may use Name().member(...) or the bare
Name.member(...) qualifier form; both forms instantiate the defaults before
member lookup.
Generic enums (tagged unions)#
Enums may be parameterized and are monomorphized like generic structs.
For enum constructors and match arms, callers typically introduce a local alias for an instantiation and then use that alias to construct and match variants:
enum Result(T, E) {
Ok(T),
Err(E),
}
type R = Result(int, string);
fn main () -> int {
let x: R = R::Ok(123);
return match x {
R::Ok(v) => v,
R::Err(_) => 0,
};
}
Default type arguments:
- When a parameterized declaration provides default type arguments, a use site may omit trailing arguments that have defaults.
- If all parameters have defaults, the type may be referenced as
NameorName()(both are equivalent to applying the defaults).
Type arguments may be:
- types (e.g.
int,&Foo,Option(string)). - integer literals for const parameters (e.g.
Vector(u8, 1024)).
Const arguments are compile-time integer literals and participate in monomorphization identity.
Interfaces and applied interface types#
Interfaces may be generic:
interface Channel(T) {
fn send(value: T) -> bool;
fn recv() -> T?;
}
An impl ... as ... conformance may apply type arguments to the interface:
struct QueueU8 {
// ...
}
impl QueueU8 as Channel(u8) {
// ...
}
Rule: when a generic interface is referenced in a concrete impl X as I(...),
all interface type arguments must be fully known at that conformance site. The
only exception is when the conformance itself is generic (type parameters are
in scope), for example:
struct Data(T) { /* ... */ }
interface DataInterface(T) { /* ... */ }
impl Data(T) as DataInterface(T) {
// ...
}
Impl blocks for generic structs#
If a struct is declared with type parameters, its impl blocks must also declare those parameters:
struct Data(T) { /* ... */ }
// OK:
impl Data(T) { /* ... */ }
// Error:
// impl Data { /* ... */ }
This rule keeps method receiver typing unambiguous and makes monomorphization explicit.
Functions#
Generic functions require a way to declare type/const parameters distinct from value parameters. The initial parsed surface form is:
fn get_first(T, N: usize; v: &T[N]) -> T {
// ...
}
Where the ; separates generic parameters from value parameters inside the
function’s parameter list.
Alternate (Go-like) function declaration syntax#
Silk also supports a Go-like generic header form:
fn (T, N: usize) get_first (v: &T[N]) -> T {
// ...
}
This is sugar for the ; form above; the compiler records the same generic
parameter list (T, N: usize) either way.
Rules:
- At most one generic parameter list may be provided:
- either
fn name(T; ...), - or
fn (T) name (...).
Call syntax for generic functions#
Calls mirror the signature split:
let x: int = get_first(int, 4; &xs);
Rules:
- the
;separates compile-time arguments from runtime value arguments, - compile-time arguments are a comma-separated list of:
- type arguments (
int,&Foo,Option(string)), - and integer literals for const arguments,
- runtime arguments are ordinary expressions.
- the compile-time argument list may be empty when defaults supply all generic
parameters, for example
id_default(; 1)uses the default type argument forTinfn id_default(T = int; x: T) -> T.
Call-site type inference (omitting ;)#
When a call does not include the generic separator (;), the compiler may
infer type and const arguments from runtime arguments:
fn (X, Y) add (x: X, y: Y) -> X {
return x + y as X;
}
let a = add(1.123, 2); // infers X = f64, Y = int
Rules:
- Both type parameters (
T) and const parameters (N: usize) may be inferred. - Inference is driven by the runtime argument expressions and any types that are known at the call site:
- literals (
123,1.0,"hi",'a',true), - struct literals (
Point { ... }), - explicit casts (
expr as Type), - and name expressions (
x) when the binding’s type is known (from an annotation likelet x: T = ...or from a simple initializer like a literal/struct literal). - Const parameters are inferred only from type structure:
- array lengths (
T[N]), - and const arguments in applied types (
Buffer(T, N)), when the corresponding runtime argument type provides a concrete value. - When inference cannot determine a type argument, compilation fails with an actionable diagnostic. Disambiguate by either:
- inserting
ascasts on runtime arguments, or - using the explicit
;form (add(f64, int; 1.123, 2)). When inference cannot determine a const argument, disambiguate by using the explicit;form (take_buf(4; buf)).
Implementation notes#
- Monomorphization produces a concrete instance for each referenced
instantiation
Name(args...). - Type names share one namespace within a
package:struct,interface,enum,error, andtypedeclarations may not reuse the same name. - Name conflicts across generic arities are rejected (for example,
struct Fooandstruct Foo(T)cannot both exist in the same package namespace). - Const parameters are currently restricted to integer primitive types; const
values are usable in type positions (for example
T[N]) but are not yet exposed as runtime values.
Dependent Types (Const Parameters and Type-Level Computation)#
This document specifies Silk’s intended support for “dependent-type-like” patterns where types mention compile-time values (most notably integers).
The front-end can parse and preserve:
- declaration parameter lists on
struct,impl, andfn, - type application in type positions (for example
VectorN(int, 1024)),
but the compiler does not yet implement constraint checking, inference, or
monomorphization/code generation for parameterized declarations. In the current
compiler subset, generic parameter lists and applied types are rejected during
type checking (Compiler Diagnostics, E2016). See
Generics (Monomorphized).
Const Parameters#
Const parameters are compile-time values that appear in parameter lists with a type annotation:
struct VectorN(T, N: int) { /* ... */ }
Where:
Tis a type parameter, andN: intis a const parameter whose value must be known at compile time.
The initial supported const-argument form in type application is integer
literals (for example VectorN(int, 1024)).
Type-Level Computation#
The language intends to allow certain expressions over const parameters in type positions (design-only):
fn concat(T, M: int, N: int; a: VectorN(T, M), b: VectorN(T, N)) -> VectorN(T, M + N) {
// ...
}
This requires:
- a notion of const expressions at the type level,
- evaluation rules (and overflow behavior) for those expressions,
- and a compilation strategy (typically monomorphization) that produces concrete layouts and code for each instantiated type.
Function Parameter Lists (CT/RT Split)#
Generic functions use a single parameter list split by a top-level ; inside
the parentheses:
fn id(T; x: T) -> T { return x; }
fn g(T;) -> T { /* CT-only, rare */ }
fn h(x: int) -> int { return x; } // RT-only
This split is parsed and preserved by the front-end, but generic functions are
rejected by the current checker until monomorphization is implemented
(Compiler Diagnostics, E2016).
Relationship to Arrays and Collections#
Const parameters are intended to power:
- fixed-size arrays (
T[N]), - dependent-length collections (for example
VectorN(T, N)), - and compile-time-checked indexing/slicing APIs.
These features require additional language and runtime support beyond the current implementation.
Struct Requirements (#require)#
Use #require on a struct to state requirements that must hold for all
values constructed for that struct type.
Example:
#require id > 0;
struct User {
id: int,
}
#assure result > 0;
fn get_id () -> int {
return 1;
}
fn main () -> int {
// This fails verification:
// let bad = User{ id: 0 };
let user = User{ id: get_id() };
return user.id;
}
Rules (Supported forms):
#requireexpressions on astructmay reference that struct's fields by name.- When Formal Silk syntax is present in the compiled module set, the verifier
proves these requirements at struct literal construction sites (
Type{ ... }andnew Type{ ... }). If any requirement cannot be proven, compilation fails withE3006. - Failed struct-requirement diagnostics include the rejected predicate and the referenced field initializers/defaults that were used for the construction proof.
See Formal Silk.
Function Disciplines (pure, task, async)#
This document specifies Silk’s intended “function discipline” system: how functions declare whether they are pure, asynchronous, or safe to run as parallel tasks.
Const functions (const fn) are specified separately in
Const Functions (const fn). The const modifier is orthogonal to the
discipline system described here (a const fn may also be declared pure).
, but Silk currently now implements
pure fn parsing and a strict purity checker. Concurrency disciplines (task /
async) are parsed and Task(T) / Promise(T) handles plus yield (task
values) and await (promise values) are implemented in the Supported forms
(await Task(T) is rejected). On the hosted linux/x86_64 target, the compiler
now ships a bring-up async runtime (single-threaded executor + stackful
coroutines in libsilk_rt) so await can suspend and resume without blocking
the OS thread. A compiler state-machine coroutine transform, structured
concurrency scope semantics, and task-safety (Send/Sync)-like rules remain
future work. See Concurrency for the concurrency model and
implementation status.
Overview#
The language design distinguishes:
fn— normal function (may perform effects; blocking).pure fn— function with no observable side effects (referentially transparent).task fn— function safe to execute on a worker pool as a parallel task.async fn— function that may suspend atawaitpoints (returns an awaitable).async task fn— async function executed as a separate task (self-contained worker).
Intended Call Rules (Design)#
The checker is expected to enforce:
purecode may call onlypurecode (and cannot perform I/O or mutation outside local, non-escaping temporaries).taskcode may calltaskandpurecode, and must satisfy task-safety rules for captured/argument data.asynccode mayawaitother async operations; it may callpurecode and may offload blocking work via explicit adapters .
Crossing discipline boundaries is intended to be explicit and diagnostic-driven (for example suggesting the correct adapter/intrinsic).
Standard Intrinsics#
The standard library is expected to provide typed adapters to cross boundaries safely (names and exact signatures are design work):
- lifting sync work onto a task pool,
- presenting a task as an async operation,
- running blocking work from async without stalling the event loop,
- structured spawn/join primitives.
These APIs are not yet present in the in-tree std/ implementation.
Implementation Notes#
Today:
pure fnis parsed and checked (Supported forms):- a
pure fnmay call onlypurefunctions;extis treated as impure, - the checker also supports purity inference (“auto-pure”) for ordinary
fndeclarations andimplmethods: - when an unannotated function/method has an eligible signature and its
body satisfies the purity rules, it is treated as
purefor call checking, and may be called frompurecode, - functions/methods with
&Tparameters are not eligible for inference (explicitpure fnremains supported for&Tparameters in the current subset), purecannot be combined withtaskorasyncin the Supported forms,- a
pure fnmay not havemutparameters, - a
pure fnmay not declare mutable locals (varorlet mut) and may not perform mutation via assignment, - a
pure fnmay not allocate (new) in the Supported forms, - a
pure fnmay not have a typed-error contract (-> T | Error...) and may not containpanicstatements. task fn,async fn, andasync task fnare parsed and preserved in the AST.- Calls across disciplines are now reflected in expression types:
- calling a
task fnyieldsTask(T), - calling an
async fnyieldsPromise(T), - calling an
async task fnyieldsPromise(Task(T)), yieldsupports both statement and expression forms:- statement forms (only inside an enclosing
task fn/async task fn): yield v;sends a value to the task’s yield stream,yield * t;forwards all values fromtinto the task’s yield stream,- expression forms:
yield treceives the next yielded value fromt,yield * tdrains/collects remaining values fromtintoT[],awaitunwrapsPromise(T)and yieldsT(await Task(T)is rejected), andawait * psunwrapsPromise(T)[]intoT[].await <expr>andasync { ... }/task { ... }blocks are enforced as async-only constructs:awaitis only permitted insideasyncfunctions (includingasync task fn),async { ... }/task { ... }blocks are only permitted insideasyncfunctions.yield <expr>is enforced as a task-only construct:yieldexpression forms (yield t/yield * t) are permitted only insidetaskfunctions (task fn/async task fn) and insidetask { ... }/task loop { ... }blocks.yieldstatement forms (yield v;/yield * t;) require an enclosingtask fn/async task fn.- Lowering/codegen implements
taskexecution using OS threads onlinux/x86_64and implementsyield/yield *for task values plusawaitfor promises. - By default, each
task fncall is scheduled on the global task pool. attr(task=thread)forces a dedicated OS thread per call.- On hosted
linux/x86_64, the compiler ships a bring-up async runtime (the implementation) soawaitis a true suspension point: - awaiting a pending
Promise(T)parks the current fiber and allows other runnable fibers to execute (it does not block the OS thread), - outside the executor owner thread (including when no executor is active),
awaitblocks the OS thread until the promise resolves. yield/yield *waits on task output use the same hosted fd-wait runtime path, so waiting for task values from executor-driven async code suspends the current coroutine instead of blocking the executor owner thread.- the long-term design remains a compiler coroutine transform plus a stable
std::runtime::event_loopAPI; seeAsync Runtime (Hosted). async { ... }/task { ... }blocks remain lexical scopes, but scope exit is now runtime-backed for live handle cleanup:- live
Promise(T)bindings are awaited/destroyed, - live
Task(T)bindings are drained/destroyed (joining dedicated-thread tasks; pooled/default tasks skip the join), - and the same cleanup runs on overwrite and early scope exit.
- Function types are parsed in type positions (notably for
ext). - Function expressions are implemented as first-class function values:
fn (x: int) -> x + 1(expression body),fn (x: int) -> int { return x + 1; }(block body).fn (x: int) { ... }(block body, implicitvoidresult).- Function expressions may not declare
&Structparameters; only single-slot scalar&Tparameters (for example&int) are supported in the Supported forms. - Function expressions are eligible for purity inference (“auto-pure”):
- when the body satisfies the
purerules, the function value is treated aspurefor call checking (it may be called frompurecode), - otherwise the function value is impure and may not be called from
purecode. - Capturing closures are supported as a subset:
- a function expression may reference immutable locals/parameters from an enclosing scope,
- captures are by-value copies into a heap environment (scalar-only in the Supported forms),
- forming captures inside
purecode is rejected (capture environments allocate), - capturing closures are also eligible for purity inference (a closure
whose body satisfies the
purerules is callable frompurecode). - Function values (both non-capturing and capturing) are supported end-to-end: they may be passed, returned, stored, and called indirectly.
Const Functions (const fn)#
Notes#
- Parser: implemented
- Checker rules: implemented (Supported forms)
- Compile-time evaluation: implemented (Supported forms)
This document defines the surface syntax and semantics for compile-time functions.
In Silk currently, const fn (and const pure fn) can be called
from const binding initializers when all arguments and the result are
compile-time values (scalar values and eligible POD struct values).
Summary#
Silk supports compile-time evaluation of certain expressions to produce
compile-time constants. const fn (and const pure fn) declarations opt a
function into this compile-time evaluation system so that it can be called from
compile-time contexts (for example, a const binding initializer).
Syntax#
const is a function modifier:
const fn add (a: int, b: int) -> int {
return a + b;
}
const pure fn add2 (a: int, b: int) -> int {
return a + b;
}
Notes:
const pure fnis simply aconst fnthat also opts into thepurerules (seeFunction Disciplines (pure,task,async)).const fnis a compile-time-only function:- it may be called only from compile-time contexts (for example
constinitializers and Formal Silk specifications), - it is not emitted as a runtime/linkable symbol in executable, object, or library outputs.
Compile-Time Values#
In this document, a “compile-time value” is a value that the compiler can produce and manipulate during compile-time evaluation.
Supported forms (Implementation target):
-
scalar primitives:
-
bool -
fixed-width integers (
i8,u8,i16,u16,i32,u32,i64,u64) -
int -
f32,f64 -
char -
Instant,Duration -
compile-time structures (POD
structvalues): -
a non-opaque
structtype, -
with 1+ fields,
-
where every field type is a compile-time scalar value type, and
-
the struct does not require ownership tracking (
Drop).
These values are lowered as a flattened sequence of scalar slots in
declaration order. They may be returned from and passed to const fn, and
used in const initializers.
Planned (not yet supported for const fn in the Supported forms):
stringvalues (string literals are supported directly inconstbindings),- aggregate values beyond compile-time POD structs (enum/optional/slice/array),
- function values as compile-time values (for higher-order const evaluation).
Rules#
The Supported forms defines a deliberately small “const-eval VM” surface. A
const fn must fit within this surface.
Signature rules#
In the Supported forms, a const fn:
- must not be
taskorasync, - must not declare a typed-error contract (
-> T | ErrorType...), - must have a non-
voidreturn type that is a compile-time value type, - must have parameters whose types are compile-time value types.
Body rules#
In the Supported forms, a const fn:
- must not allocate (
new) and must not use regions/with, - must not contain
panicstatements, - must not declare
constlocal bindings, - may call only other
const fndeclarations, - is restricted to a small expression subset over scalar values:
- literals and local names (parameters and
letbindings; no globalconstreads in the Supported forms), ascasts between supported scalar types,- unary operators:
-,~,!, - binary operators:
- arithmetic:
+,-,*(division/modulo are not part of the const-eval subset), - bitwise:
&,|,^,<<,>>, - comparisons:
==,!=,<,<=,>,>=, ifexpressions (if cond { a } else { b }).- assignments to local names:
=,+=,-=,*=, plus++/--.
Additionally, const fn bodies may construct and use compile-time POD struct
values:
- struct literals (
T{ field: expr, ... }) whenTis a compile-time structure and every field expression is compile-time evaluable, - field access (
value.field) on compile-time structures, and - assignment to local struct-typed names (copies the flattened scalar slots).
Control flow is limited to:
if/elsestatements,whileloops with boolean conditions,break/continue,returnstatements.
Calling Const Functions#
The initial intended compile-time use site is const bindings:
const fn add (a: int, b: int) -> int {
return a + b;
}
const answer: int = add(20, 22);
fn main () -> int {
return answer;
}
Const functions may also be imported/exported across modules/packages like
runtime declarations, but they are still compile-time-only: importing a const fn does not make it callable from runtime code.
“No Static Storage” Rule#
Const functions do not create new static storage. In particular:
- compile-time execution may compute scalar values and fold them into constants,
- compile-time execution must not allocate heap memory,
- compile-time execution must not synthesize new global read-only data (for example, it cannot build a new string at compile time in the Supported forms).
String literals are still backed by read-only static storage, but they are
introduced by the literal syntax itself (see String Literals), not by the
const fn evaluator.
Evaluation Limits#
Compile-time evaluation must terminate. The compiler enforces an
instruction budget and a call-depth budget when executing const fn bodies at
compile time; evaluation that exceeds these budgets is rejected as not
compile-time evaluable.
Varargs (Variable Arguments)#
Silk supports declaring functions that accept a variable number of trailing
arguments (“varargs”). This is used heavily by std::io::print /
std::io::println for formatted output.
Syntax#
Varargs are declared by prefixing the final parameter with ...:
fn log (fmt: string, ...args: std::fmt::Arg) -> void {
std::io::println(fmt, args);
}
Rules:
- A function may declare at most one varargs parameter.
- The varargs parameter must be the final parameter in the list.
- The varargs parameter must have an explicit type annotation.
- Varargs parameters are not permitted to be
mutin the Supported forms. - Varargs parameters may not have a default expression (
= ...) in the current subset. - The same trailing-varargs form may be used in interface method signatures,
and
impl ... as .../module ... as ...conformance compares the varargs marker as part of the required signature.
Call Semantics#
At call sites:
- All non-varargs parameters are matched positionally as usual.
- Any additional arguments are collected into the varargs parameter.
Example:
std::io::println("hello {s} answer={d}", "world", 42);
Here "world" and 42 become varargs elements.
Forwarding#
Because Silk does not yet have a general “spread” operator for calls, the compiler supports forwarding a varargs pack when you pass a varargs binding as the final argument.
fn log (fmt: string, ...args: std::fmt::Arg) -> void {
// `args` is forwarded as-is to `println`.
std::io::println(fmt, args);
}
This is primarily intended for building wrappers that preserve the caller’s argument list without repacking.
Indexing and Iteration#
Varargs packs expose a len: int field and support array-style indexing.
fn first_or_none (...args: string) -> string? {
if args.len <= 0 {
return None;
}
return Some(args[0]);
}
Indexing args[i] traps when i is out of bounds (i < 0 or i >= args.len),
matching slice/array indexing rules in the backend.
To iterate, use len + indexing:
var i: int = 0;
while i < args.len {
let v = args[i];
i = i + 1;
}
Representation#
In the compiler, a varargs parameter is lowered as a fixed-size pack value with:
len: int— the number of provided varargs arguments.a0 .. a(N-1)— storage for up toNarguments (implementation-defined, currentlyN = 32).
The pack is passed by value using the same “flattened scalar slot” ABI as other POD structs.
Notes:
args[i]performs bounds checks againstlenand traps on out-of-bounds.- Directly reading
aKis not bounds-checked; whenK >= len, the value is unspecified. Preferargs[i]unless you are working with the raw representation intentionally. - Calls supplying more than
Nvarargs arguments are rejected.
FFI (C Variadics)#
This document is about Silk varargs. C variadic functions declared via ext
(printf-style ...) are a separate concern and are not implemented yet
in the Supported forms.
Language Spec Conventions#
This document defines conventions used across this specification. It exists to keep the language specification consistent and easy to navigate for both:
- first-time readers learning Silk, and
- returning readers looking up precise rules.
See also: Silk Language Guide (Index) for recommended reading paths.
Document Structure (Recommended)#
Concept documents should be structured so readers can answer, quickly:
- “What is this feature for?”
- “What syntax does the compiler accept?”
- “What are the rules and edge cases?”
- “What works in the current compiler today?”
Recommended sections:
- One-paragraph summary
- Implementation status (if the concept is Implemented)
- Surface syntax
- Semantics (evaluation order, scoping, control-flow behavior)
- Type checking rules (static requirements and diagnostics)
- Examples
- minimal examples (smallest correct usage)
- realistic examples (how the feature is used in real code)
- Common pitfalls
- Related documents
Not every concept needs every section, but the goal is that a reader should never have to infer critical rules from examples.
“Implementation status” Format#
When a feature is not fully implemented end-to-end, the concept doc should include an explicit “Implementation status” section near the top.
Use concrete statements, not vague language. Prefer describing support in these layers:
- Parser: which surface forms are accepted.
- Checker: which typing/validation rules are enforced.
- Lowering/backends: which forms code-generate end-to-end on supported targets.
- C ABI / FFI: whether the feature is permitted at exported boundaries.
When something is rejected in the Supported forms, include the diagnostic code
from Compiler Diagnostics when one exists.
Examples#
Examples in language docs should follow these rules:
-
Use 2-space indentation and spaces only.
-
Prefer complete, runnable snippets when possible:
fn main () -> int { return 0; } -
When an example requires multiple files, label them with comments, e.g.:
// app/main.slk package app; -
When an example is intentionally invalid (to show a rule), label it and mention the expected diagnostic.
Terminology#
These terms are used consistently across the spec:
- Expression: a construct that produces a value and has a type.
- Statement: a construct evaluated for its effects and sequencing.
- Block:
{ stmt* }, a scope boundary and the unit of structured control flow. (Whether blocks are also expressions depends on the concept; docs must be explicit.) - current implementation: the set of features that parse, type-check, and code-generate end-to-end today.
Cross-References#
When describing a rule, link to the most relevant concept doc rather than restating it everywhere. Common cross-links include:
Formal Grammar Specfor the exact accepted syntax,Typesfor type-system rules and special cases,Mutabilityformutand borrowing rules,Compiler Diagnosticsfor error codes,the implementation statusfor a high-level implementation snapshot.
Operators#
This document summarizes the operator set and precedence for Silk.
Operator Set#
The language includes the following operators and delimiters:
- Assignment and compound assignment:
=,+=,-=,*=,/=. - Increment/decrement:
++,--(prefix and postfix). - Arithmetic:
+,-,*,/,%. - Currently:
- integer operands support
+,-,*,/, and%, - floating-point operands (
f32/f64) support+,-,*, and/(no%). - unary
-xis supported for both integer and floating-point operands. - time types support a small arithmetic subset:
Duration + Duration,Duration - Duration, and unary-Duration,Instant + Duration,Duration + Instant,Instant - Duration,- and
Instant - Instant(producing aDuration). rangesupports shifting by anintoffset:range + int,range - int,int + range.- Bitwise:
&,|,^,~,<<,>>. - Currently, bitwise operators are defined for
integer operands (
intand the fixed-width integer types): &,|,^perform bitwise AND/OR/XOR on two integer values of the same type and produce a result of that same type.~xperforms bitwise NOT on an integer value and produces a result of that same type.<<,>>shift the left-hand integer operand by an integer shift amount of the same type;>>uses an arithmetic right shift for signed integers (i*/int) and a logical right shift for unsigned integers (u*).- Comparison:
==,!=,<,<=,>,>=. - Currently, comparisons are defined for both integer operands and floating-point operands of the same type.
- In the backend,
==and!=are also defined forbooloperands. - In the backend, comparisons are also defined for
DurationandInstantwhen both operands have the same time type. - In the backend,
==and!=are also defined forstringoperands, comparing the underlying UTF-8 byte sequences for equality (length check + bytewise compare). - In the backend, ordered comparisons over
string(<,<=,>,>=) are defined as bytewise lexicographic ordering over the underlying UTF-8 byte sequences (unsigned byte comparison, with shorter-prefix ordering when one string is a prefix of the other). - In the backend,
==and!=are also defined for supported optional values (T?,string?, optionals of the supportedstructsubset, and nested optionalsT??): None == Noneis true,Some(x) == Some(y)compares the payload values for equality (recursively for nested optionals),- and
!=is the logical negation of==. - Currently,
NoneandSome(...)can appear in equality expressions when the other operand has an optional type (for exampleopt == Noneandopt == Some(x)), using that other operand’s type to infer the optional payload type. - In the backend,
==and!=are also defined for the supportedstructsubset (seeStructs, Impl Blocks, and Memory Layout), performing slot-wise equality over the lowered scalar slots (including embedded strings, nested structs, and optionals); float slots use IEEE-754 equality semantics. Ordered struct comparisons are not implemented. - Float comparisons follow IEEE-754 semantics:
NaNcompares unequal to everything (including itself), and ordered comparisons (<,<=,>,>=) are false when either operand isNaN. - Logical:
!,&&,||. - Currently:
!is supported forbooloperands.- Member and scope:
.,::,?.. - Currently:
.and::are supported,- and
?.is supported for optional chaining on the supportedstructsubset: opt?.fieldyieldsFieldType?,opt?.method(args...)yieldsResultType?. SeeOptional.- Casts:
asandas raw(postfix). - Syntax:
- numeric/shape cast:
<expr> as <Type>, - raw bit-cast:
<expr> as raw <Type>. asis an explicit, potentially lossy conversion operator intended for primitive numeric conversions (see “Casts (as)” below).as rawis an explicit bit reinterpretation operator intended for generic storage/marshalling of scalar values (see “Raw casts (as raw)” below).- Typed error propagation:
?(postfix). - Syntax:
<call_expr>?. - This propagates typed errors from an error-producing call to the enclosing
function; see
Typed Errors (error,panic, andT | ErrorType...). - Ranges / varargs delimiters:
..,..=,.... ..and..=form range literals of typerange(seeTypes)....is the varargs/rest marker (seeVarargs (Variable Arguments)).- Other punctuation:
?,??,->,=>,,,;,(,),{,},[,],_,:. - Currently,
??is supported for: - optionals in the backend (including scalar,
string, and the currentstructsubset, plus nested optionals in the supported payload subset; seeOptional), and - recoverable
Result-like values, whereresult ?? fallbackyields theOk(...)payload and evaluatesfallbackonly forErr(...)(std::result), and - ordinary named enums with exactly two declared variants, where the first declared variant is the “success” arm:
- if the first variant is unit,
value ?? fallbackyields that enum value, - if the first variant carries exactly one payload, it yields that payload,
- and if the value is the second variant,
fallbackis evaluated. - The right-hand side may be either:
- an ordinary fallback expression, or
- one of the narrow terminal control-flow forms accepted only after
??: value ?? return exprvalue ?? breakvalue ?? continuereturn,break, andcontinuekeep their normal statement validity rules:returnmust match the enclosing function result type,breakandcontinueare only valid inside loops.- This does not make those control-flow forms general expressions
elsewhere; the grammar extension is specific to the right-hand side of
??. The?token is used both in type annotations (T?) and as the postfix typed error propagation operator for error-producing calls (call()?; seeTyped Errors (error,panic, andT | ErrorType...)).
The lexer and parser must recognize these tokens exactly as specified, and precedence/associativity must match the formal grammar.
Assignment#
Assignment updates an existing binding (an lvalue). Assignment is “statement-like”:
it is parsed as an expression but has type void and is intended to appear as an
expression statement.
=
x = expr evaluates expr and stores the resulting value into x.
Rules:
- The left-hand side must be an assignable lvalue. In the Supported forms, it may be:
- an identifier that refers to a local
let mutbinding, or - a struct field lvalue
name.field(or nested field lvaluename.field1.field2...) wherenameis either: - a local
let mutbinding of a supported PODstruct, or - a
mutborrowed reference parameter (mut name: &Struct). In the backend, nested field assignment is supported only when the leaf field lowers to a single scalar slot (for examplebool, integer scalars, andf32/f64). - Identifier lvalues must refer to
let mutlocal bindings. - The type of
exprmust match the binding’s type. - The assignment expression has type
void.
Compound assignment (+=, -=, *=, /=)#
Compound assignments are shorthand for “read-modify-write”:
x += yis equivalent tox = x + y(and similarly for-=/*=//=), withyevaluated exactly once.
Rules:
- The left-hand side must be an assignable lvalue (as described above for
=). - In the Supported forms, compound assignments are supported only for numeric
scalar types (integers and
f32/f64), including numeric struct fields. - The compound assignment expression has type
void.
Increment and Decrement (++ / --)#
++x, x++, --x, and x-- increment or decrement an existing binding by
1.
In Silk, increment/decrement expressions are “statement-like”: they have type
void and are intended to appear only as expression statements.
Rules:
- The operand must be an assignable lvalue (the same lvalue rules as
=). - The operand type must be an integer scalar type (
int,i8,u8,i16,u16,i32,u32,i64,u64,size,usize). (isizeis accepted as an alias forsize.) - Prefix and postfix forms are equivalent in Silk (both update the binding and
produce
void). - Conceptual desugaring:
x++and++xare equivalent tox += 1;x--and--xare equivalent tox -= 1;
sizeof#
sizeof <operand> produces the size of a type or value in bytes.
For string values, sizeof(value) is the canonical way to read the UTF-8 byte
length for FFI pointer/length pairs:
let title_ptr = title as raw u64;
let title_len = sizeof(title);
The operand may be a direct name, a field access, or another expression that
evaluates to string, for example sizeof(options.title) or
sizeof(make_title()).
Use an explicit cast only when calling an API whose contract is intentionally
signed or narrower than usize.
Result type:
sizeofalways returnsusize.
Evaluation mode:
- When the operand is a type name (a primitive type,
struct/enumname, type alias, or qualified type name),sizeofis a compile-time constant. - When the operand is a compile-time constant value (literals and other
const-evaluable expressions),
sizeofis a compile-time constant. - When the operand is a runtime value,
sizeofis evaluated at runtime.
Sized integration:
- Implemented (partial):
sizeof <string value>produces the string’s byte length (asusize). This reads the current string ABI layout ({ ptr: u64, len: i64 }) through the reserved intrinsic__silk_string_len;std::runtime::mem::string_lenremains available only as a compatibility and target-shim helper. - Planned (general): for other runtime values, if the operand type provides an
instance method matching
std::interfaces::Sized(fn size(self: &Self) -> usize),sizeof valuewill lower to a call of that method. - For type operands, if the operand type provides a static, pure method
pure fn size() -> usize, the compiler may foldsizeof Typeto that value when the method body is const-evaluable; otherwise it falls back to the compiler’s built-in size model.
Built-in size model :
- Sizes reflect the current scalar-slot lowering model (
Structs, Impl Blocks, and Memory Layout): each lowered scalar occupies one 8-byte slot. - A
stringvalue occupies two slots ((u64 ptr, i64 len)), sosizeof stringis16in the Supported forms. - A
T[]slice value occupies two slots ((u64 ptr, i64 len)), sosizeof T[]is16in the Supported forms. - A
T[N]fixed array occupiesN * sizeof(T)bytes in the Supported forms, using the element’s scalar-slot size.
Notes:
sizeof string(type operand) is the representation size (currently 16 bytes in the scalar-slot model), whilesizeof <string value>is the content size (byte length).sizeofis a byte-size operator. For logical element counts (for example a slice length or vector length), use a.len()method viastd::interfaces::Lenon the relevant type. The standard library does not define a genericlength(...)helper.
Parsing note:
- Because
Name[expr]is also indexing syntax, fixed array type operands should be parenthesized:sizeof (u8[4]). Without parentheses,sizeof u8[4]is parsed as an index expression. - Because
asbinds at postfix precedence, baresizeof x as Tparses assizeof (x as T). To cast the result ofsizeof, writesizeof(x) as Tor(sizeof x) as T.
alignof#
alignof <operand> produces the alignment of a type or value in bytes.
Result type:
alignofalways returnsusize.
Evaluation mode:
- When the operand is a type name (a primitive type,
struct/enumname, type alias, or qualified type name),alignofis a compile-time constant. - When the operand is a compile-time constant value (literals and other
const-evaluable expressions),
alignofis a compile-time constant. - When the operand is a runtime value,
alignofis evaluated at runtime.
Built-in alignment model :
- Alignments reflect the current scalar-slot lowering model
(
Structs, Impl Blocks, and Memory Layout): values are stored as 8-byte slots. - All non-
voidtypes currently have alignment8. alignof voidis1.
Parsing notes:
- As with
sizeof, fixed array type operands should be parenthesized:alignof (u8[4]). Without parentheses,alignof u8[4]is parsed as an index expression. - Because
asbinds at postfix precedence, barealignof x as Tparses asalignof (x as T). To cast the result ofalignof, writealignof(x) as Tor(alignof x) as T.
offsetof#
offsetof(Type, field_path) produces the byte offset of a struct-like field
within Type in the current memory layout model.
Result type:
offsetofalways returnsusize.
Evaluation mode:
offsetofis always a compile-time constant.
Operands:
Typemust name astructorerrortype (including nested structs).field_pathis one or more field identifiers separated by.(for exampleborinner.header.len).
Built-in offset model :
- Offsets reflect the current scalar-slot lowering model
(
Structs, Impl Blocks, and Memory Layout): each lowered slot is stored in an 8-byte cell, and composite fields (nested structs, optionals, strings, etc.) are expanded into their slot sequences in source order. offsetof(Type, field)returns the offset of the first slot of that field’s lowered representation, in bytes.- When
field_pathtraverses an optionalT?field, it refers to the payload layout (the path implicitly skips the tag slot).
typename#
typename <expr> and typename(<expr>) produce a string naming the static
type of <expr>.
Result type:
typenamealways returnsstring.
Evaluation mode:
typenameis always a compile-time constant string.
Operand notes (Supported forms):
- When the operand is a bare name that does not resolve to an in-scope
runtime binding (for example
int,User, orstd::wasm::Module), the compiler interprets it as a type name and returns that type’s name. - Formatting uses the compiler’s normal type formatting (for example
T[],&T, andfn (...) -> ...). - For monomorphized generic instantiations, the string is the human-readable
display name (not an internal
__silk_mono__...symbol).
is#
<expr> is <Type> checks whether the static type of <expr> conforms to
<Type>.
Result type:
isalways returnsbool.
Evaluation mode:
isis always a compile-time constant boolean.
Rules (Supported forms):
- The right-hand side must be a type (primitive, nominal
struct/enum/error,interface, a function type, or a type alias for one of those). - If
<Type>is a nominalstructtype,expr is Typeis true when the expression’s static type is exactlyTypeor astructthatextendsType. - If
<Type>is aninterface,expr is Interfaceis true when the expression’s static type declares conformance (impl T as Interface) or when the operand is a module declaredmodule Name as Interface. - For primitive types, enum/error types, reference types (
&T), slice/array types (T[],T[N]), optionals (T?), and function types,iscurrently checks exact type equality (after resolving type aliases).
Notes:
isdoes not perform runtime tagging or value inspection. For runtime discrimination of union/optional values, usematchand the relevant pattern forms.
Examples:
type Adder = fn (x: int, y: int) -> int;
fn my_adder (x: int, y: int) -> int { return x + y; }
if my_adder is Adder { /* ... */ }
struct User { id: u64 = 0 }
struct Beep extends User { boop: string = "" }
let beep = Beep{ boop: "boop" };
if beep is User { /* ... */ }
let n = 123;
if n is int { /* ... */ }
interface Logger { fn log (value: string) -> void; }
module my_logger as Logger {
export log (value: string) { /* ... */ }
}
if my_logger is Logger { /* ... */ }
Wrapping and Overflow#
The spec notes “Arithmetic Wraps” for certain operators. The checker and code generator must:
- Implement deterministic wrapping behavior for integer arithmetic where required.
- Clearly separate wrapping operations from checked or saturating variants (if exposed in the standard library).
Casts (as)#
as is a postfix operator that performs an explicit conversion to a target
type.
Precedence#
as binds at postfix precedence (like calls, field access, and ?). For example:
a + b as intparses asa + (b as int).- To cast the result of
new, use parentheses so the cast applies to the heap reference:(new Type{ ... }) as &Other. Without parentheses,new Type{ ... } as &Otherparses asnew (Type{ ... } as &Other).
Supported conversions#
In the compiler, as is supported for primitive scalar
conversions:
-
Integer → Integer (including
Instant,Duration, andchar): -
The conversion is deterministic and may be lossy. It is performed by canonicalizing the underlying bits to the destination integer type (width truncation + sign/zero extension as appropriate). For scalar widths up to 64 bits this matches the behavior of
ir.CastIntin the current IR;i128/u128follow the analogous rule over their{ lo, hi }lane layout. -
Float → Float:
-
f32/f64/f128conversions using standard IEEE-754 conversion and rounding. -
Integer → Float:
-
Converts the integer value to the destination float type (IEEE-754), with rounding when the integer cannot be represented exactly.
-
Float → Integer:
-
Converts by truncating toward zero.
-
If the source value is
NaN, the result is0. -
If the source value is outside the destination integer’s representable range, the result saturates to the nearest bound (min/max for signed,
0/max for unsigned). -
Struct → Struct (safe “shape cast”):
-
A cast from
StoTis permitted when bothSandTname non-opaque struct types and their fields match positionally: -
same field count, and
-
for each index
i, the field type ofSatiis exactly the same type as the field type ofTati(field names may differ). -
This is intended for “newtype-like” wrappers and schema evolution where two structs have the same shape but different field names.
-
Semantics: the cast produces a value copy of the underlying struct slots, retyped as
T. The operation does not reorder fields. -
&Struct→&Struct(safe “shape cast” for references): -
A cast from
&Sto&Tis permitted whenSandTare compatible under the same Struct → Struct “shape cast” rules above. -
Semantics: the cast produces a retyped view of the same referenced storage. It does not allocate and does not copy the underlying struct slots.
-
For refcounted heap references created by
new, the cast is still a view only: it must not change whichdropimplementation runs when the refcount reaches zero. The allocation’s concrete type (tracked through the value, not the view type) determines Drop behavior at the last release. -
This means the two references alias: reading fields through the cast view observes updates made through the original reference (and vice versa).
-
Because the two references alias, the compiler’s per-call mutable-borrow restrictions treat aliases as the same storage: a single call expression may not take multiple mutable borrows (or both mutable and immutable borrows) of the same underlying reference, even if the aliases are held under different local names. See
Mutability. -
This cast is intentionally conservative: it is permitted only when the compiler can prove the two referenced struct layouts are identical at the type level (same field types in the same order). It does not permit arbitrary “reinterpret pointer” casts.
-
u64/usize↔T[]/T[N](unsafe pointer/slice view cast): -
Silk’s Supported forms represents raw addresses as
u64and permits pointer-width unsignedusizevalues to be used as raw addresses in these casts. For low-level byte-copy routines and runtime interop,assupports explicit conversions between raw addresses and array/slice views: -
ptr as T[]constructs aT[]slice view where the pointer component isptrand the length component is a dedicated unknown-length sentinel (currently,i64.min). The compiler does not validate the pointer value. -
Indexing and assignment through an unknown-length slice are permitted but unchecked: the runtime performs no
index < lenbounds check. -
Operations that require a known length (iteration, slicing, etc.) trap unless an explicit length is provided.
-
ptr as T[](/silk/docs/len)constructs aT[]slice view where the pointer component isptrand the length component islen(element count). -
ptr as string(len)constructs astringview where the pointer component isptrand the length component islen(byte count). This is sugar forstd::runtime::mem::string_from_ptr_len(ptr, len)(and the reserved intrinsic__silk_string_from_ptr_len). -
slice as u64/slice as usizeextracts the pointer component of aT[]slice. -
arr as u64/arr as usizeextracts the address of element0of a fixed arrayT[N](forN == 0, the result is0). -
These casts remain unsafe:
-
the compiler does not validate pointer provenance (whether the address is valid for the claimed element type).
-
in the current scalar-slot subset,
T[]/T[N]indexing assumes the pointed-to memory is laid out in Silk’s scalar-slot representation. This is not a packed-byte view. For packed byte access (for example string storage), usestd::runtime::mem::{load_u8,store_u8}orstd::arrays::ByteSlice. -
In the scalar-slot backend, indexed accesses through arrays/slices trap when:
-
the pointer is
0, -
the pointer is not 8-byte aligned,
-
the explicit length is negative (when provided),
-
the index is out of bounds (
index < len) when the slice/array has a known (non-unknown) length. -
Serialize(T)-backed casts (explicit conversion viaserialize()): -
When the operand type provides a unique instance method named
serializematching thestd::interfaces::Serialize(T)surface (fn serialize(self: &Type) -> T),expr as Tis permitted and lowers to a call of that method. -
The conversion is explicit (it does not introduce implicit coercions).
-
The
serializemethod must be infallible (no typed errors). -
Purity rules apply: inside
pure fn, theserializemethod must bepure. -
Supported forms limitation: the compiler must be able to resolve the receiver’s nominal type at the cast site so it can lower the implicit
serialize()call. This includes name expressions, field accesses, calls, and array/slice indexing (arr[i] as T) in the Supported forms. -
Deserialize(S)-backed casts (explicit conversion viadeserialize(...)): -
When the target type provides a unique static method named
deserializematching thestd::interfaces::Deserialize(S)surface (fn deserialize(value: S) -> Self),expr as Selfis permitted and lowers toSelf.deserialize(expr). -
This rule is checked before struct shape casts: when both a
deserializeconversion and a shape cast could apply, thedeserializeconversion is used. -
The conversion is explicit (it does not introduce implicit coercions).
-
The
deserializemethod must be infallible (no typed errors). -
Purity rules apply: inside
pure fn, thedeserializemethod must bepure.
Examples (Supported forms):
struct Data {
value: string,
}
struct User {
name: string,
}
fn main () -> int {
let data = Data{ value: "hello" };
let user = data as User;
assert data.value == user.name;
return 0;
}
struct A {
value: string,
}
struct B {
value: string,
}
fn set_value (mut b: &B, value: string) -> void {
b.value = value;
}
fn main () -> int {
let a: &A = new A{ value: "hello" };
var b = a as &B;
set_value(mut b, "world");
assert a.value == "world";
assert b.value == "world";
return 0;
}
Notes:
asdoes not participate in the implicit call-argument coercion mechanism described inTypes(that mechanism is opt-in per destination struct and is used primarily for stdlib ergonomics).
Raw casts (as raw)#
as raw is a postfix operator that reinterprets the raw bits of a scalar
value as another scalar type. It is intended for use in generic collections
and low-level marshalling where numeric conversion would be incorrect (notably
when storing f32/f64 values in integer-backed storage).
Syntax:
<expr> as raw <Type>
Rules (Supported forms):
- Both the operand and the target type must be numeric primitive types supported by the backend:
- 64-bit-slot scalars:
i8/u8/i16/u16/i32/u32/i64/u64/int,f32/f64, plus int-like primitives lowered to those scalars such asDuration/Instantandchar. - 128-bit wide primitives:
i128/u128/f128(two 8-byte lanes;f128stores the raw IEEE-754 binary128 bit pattern). as rawis not permitted forvoid, optionals, arrays, maps, Silk function value types (fn (...) -> R), or structs/enums.- Special-case:
u64 as raw c_fn (...) -> R(andusize as raw c_fn (...) -> R) is permitted for dynamic symbol loading and C ABI interop. The reverse direction,c_fn (...) -> R as raw u64(orusize), extracts the raw code pointer. This does not apply to Silk closure-carryingfn (...) -> Rvalues. - Special-case:
string as raw u64(andstring as raw usize) is permitted and extracts the string’s underlying byte pointer through the reserved intrinsic__silk_string_ptr. Prefer this direct syntax in application code, examples, and ordinary stdlib facades.std::runtime::mem::string_ptrremains available only as a compatibility and low-level target-shim helper. - Special-case:
&T as raw u64(and&T as raw usize) is permitted and extracts the reference’s underlying address as an integer. This is intended for low-level interop (for example passing&Structpointers to C APIs that usevoid */T *handles). - This does not make integer→reference casts legal:
u64 as raw &Tremains rejected in the Supported forms. - Semantics:
- The operand’s current canonical scalar bits are reinterpreted as the target
type’s canonical scalar bits (bit-level truncation/masking for narrower
target widths such as
u8/u16/u32andf32). - For 128-bit primitives, this is lane-wise:
- the low lane is copied as
u64bits, - the high lane is reinterpreted across
u64/i64as needed, - when casting a 128-bit value to a <=64-bit target, the low lane is used,
- when casting a <=64-bit integer value to
i128/u128, the high lane is sign-extended (i128) or zero-extended (u128) in the current subset. - No numeric conversion is performed. For example,
1.0 as u64yields1, while1.0 as raw u64yields the IEEE-754 bit pattern.
Examples:
let bits: u64 = (1.0 as f32) as raw u64;
let f: f32 = bits as raw f32;
// Pointer + length extraction for low-level interop.
let s: string = "hello";
let ptr: u64 = s as raw u64;
let len: usize = sizeof s;
Duration & Instant#
Duration and Instant are time-related types with special literal and operator support.
Key ideas:
Durationrepresents a signed time span.Instantrepresents a signed point-in-time on a monotonic timeline (an opaque epoch chosen by the runtime).- Duration literals represent time spans with unit suffixes and are converted into integral ticks.
- Operators cover arithmetic, comparisons, and construction from scalars.
Representation#
In the compiler:
Durationis represented as a signed 64-bit integer count of nanoseconds.Instantis represented as a signed 64-bit integer count of nanoseconds since a monotonic, runtime-defined origin.
These are distinct Silk types in the type system, but share the same underlying
scalar representation (i64) at the IR and native ABI layers.
Operators#
Supported operator subset:
-
Duration + Duration -> Duration -
Duration - Duration -> Duration -
-Duration -> Duration -
Instant + Duration -> Instant -
Duration + Instant -> Instant -
Instant - Duration -> Instant -
Instant - Instant -> Duration -
Comparisons (
==,!=,<,<=,>,>=) are supported for: -
DurationvsDuration -
InstantvsInstant
Other arithmetic (*, /, %) and bitwise operators are not defined for time
types in the Supported forms.
Overflow#
Arithmetic uses the same deterministic wrapping behavior as the underlying
i64 operations in the current back-end subset (two’s complement wraparound).
Notes#
At maturity, this document will be expanded to fully specify:
- duration/instant division semantics and rounding rules,
- checked/saturating variants exposed by the standard library,
- the precise relationship between
Instantand the platform clock APIs, - and FFI-safe conversions and APIs in
std::temporal.
Compiler requirements:
- Implement type-checking and lowering for the operator subset above.
- Implement duration literal parsing as specified in
Duration Literals. - Integrate with
std::temporalin the standard library.
External Declarations (ext)#
Silk’s external declaration feature lets Silk code call foreign functions and access foreign variables.
- The core construct is the
extdefinition, which declares: - external C functions and their Silk function types, or
- external C variables and their Silk types.
- The compiler and runtime perform marshalling between Silk’s internal representations and the C ABI, following a documented mapping.
Declaring an External Binding#
Example:
ext foo = fn (string) -> void;
ext bar = u32;
Here:
foois a C function namedfoowith the given Silk function type.baris a C variable of typeu32.
Binding a Different External Symbol Name#
Sometimes you want the Silk binding name to differ from the linked external symbol name (for example, when writing wrapper modules that want to expose stable public API names without colliding with imported libc names).
Syntax:
// The binding name is `c_malloc`, but the linked symbol is `malloc`.
ext c_malloc "malloc" = fn (i64) -> u64;
ext c_free "free" = fn (u64) -> void;
Rules:
- The identifier after
extis the Silk binding name (used for imports and calls from Silk code). - The optional string literal is the external symbol name used for linking (native) or as the import name (wasm).
- If the string literal is omitted, the external symbol name is the same as the binding name.
Avoiding Shadowing (Global ::...)#
If an ext binding is declared in the global namespace (a module with no
package ...; or header-form module ...; declaration) and a local declaration
shadows it (for example, a wrapper function named malloc), use the global-name
prefix to force lookup of the global binding:
return ::malloc(bytes);
The global-name prefix is not limited to ext: it also applies to type names
and enum variant paths in expression and type positions (for example, ::Foo,
::Foo{...}, or ::E::Variant), always forcing resolution in the global
(unnamed) package.
Verification and ext (Silk rule)#
External declarations have no body available to the verifier.
Therefore:
- It is a compile-time error to attempt to verify an
extdeclaration. - It is a compile-time error for verified code (code whose compilation requires
proofs) to call an
extfunction or read anextvariable.
This intentionally limits verification across the ext boundary.
Notes#
Silk currently implements this feature under the ext
keyword. The docs treat ext as canonical.
Currently supported:
- parsing
extexternal declarations and representing them in the AST, - optional external symbol aliases (
ext local "extern" = ...;), extfunctions with fixed parameter lists (ext name = fn (T0, T1) -> R;) as callable symbols in Silk (C variadic...is not implemented yet),extfunction parameters of function type (fn(...) -> R) as C-compatible function pointers:- at the ABI level, these are passed as a single
u64code pointer (no closure environment), - arguments must be either:
- a top-level function name, or
- a non-capturing
fn (...) -> ...expression, - capturing closures (and arbitrary function-typed locals) are rejected for
extfunction-pointer parameters in the Supported forms, c_fn (...) -> Rtypes as explicit C callback pointers (Supported forms):c_fnis a code-pointer-only function pointer type intended for FFI,- unlike
fn (...) -> Rfunction values,c_fn (...) -> Rvalues do not carry a closure environment and are safe to store in locals/struct fields and pass through APIs, - a
c_fnvalue may be formed only from: - a top-level function name, or
- a non-capturing
fn (...) -> ...expression, - capturing closures are rejected when a
c_fnis required. extvariables of scalar type (ext name = T;whereTis a supported scalar such asint, fixed-width ints,bool,char, orf32/f64) as readable values in Silk,stringparameters inextfunction calls are lowered as C-string pointers (const char *) in the backend; the compiler-emitted backing bytes include a trailing NUL terminator, while the Silkstringlength excludes it.- borrowed-view types are restricted at the external boundary:
- opaque handle types declared via
struct Name;may be used behind a reference (&Name) inextfunction parameters and results, - ordinary references (
&T) and slices (T[]) are rejected atextboundaries, - the same ordinary-borrow restriction also applies to unnamed-package
C-facing
export fnsignatures because they participate in the external ABI surface, - named-package Silk object exports may use slice parameters inside the
compiler-owned package ABI; those parameters lower as
{ ptr: u64, len: i64 }and are not part of the Cext/header surface. - lowering calls to
extfunctions when building: silk build --kind object, andsilk build --kind static,silk build --kind shared,silk build --kind executable, producing relocations against undefined external symbols in the generated.o/.a, dynamic imports in the generated.so, or dynamic imports in the generated dynamically-linked executable (linux/x86_64).- for shared libraries and dynamically-linked executables, external calls are routed through a GOT slot that is filled by the dynamic loader.
extvariable reads are supported for the same outputs, producing relocations against undefined external data symbols (.o/.a) or dynamic imports (.so/ dynamically-linked executable) routed through the GOT.- for wasm targets (
wasm32-unknown-unknown,wasm32-wasi),extdeclarations map to wasm imports: ext foo = fn (...) -> ...;becomes an imported wasm functionenv.foo,ext bar = T;becomes an imported wasm globalenv.bar(for scalarT),- parameter/result types follow the compiler’s current scalar lowering (for example
int→ wasmi64).
Not implemented yet (documented design, future work):
- writing to
extvariables (they are read-only in the Supported forms), extvariables of non-scalar types (strings, structs, optionals, arrays),- richer string and aggregate marshalling (for example: returning
stringfromextcalls as an owned Silk value, passing/returning user-defined structs by value beyond the current ABI-safe POD subset, and C-facing array/slice bridging). - calling back into Silk from foreign code with capturing closures or richer
closure environments (only plain non-capturing function pointers are
supported as
extparameters in the Supported forms).
Passing Callbacks to C (c_fn)#
Use c_fn (...) -> R to model C callback pointers you want to store and pass
to foreign code.
Example:
type I64BinOp = c_fn (i64, i64) -> i64;
// C provides: int64_t call_i64_binop(int64_t (*cb)(int64_t, int64_t), int64_t a, int64_t b);
ext call_i64_binop = fn (I64BinOp, i64, i64) -> i64;
fn add (a: i64, b: i64) -> i64 {
return a + b;
}
fn main () -> int {
let cb: I64BinOp = add;
let out: i64 = call_i64_binop(cb, 40, 2);
if out != 42 { return 1; }
return 0;
}
Notes:
c_fnvalues are code pointers only; they cannot capture local variables.- A raw dynamic symbol address can be converted explicitly with
addr as raw c_fn (...) -> Ror an alias of that type. This is intended for loader APIs such asstd::dylib; Silk does not validate the symbol signature at runtime, so the declaredc_fntype must match the foreign ABI exactly. - A
c_fnvalue can be called directly from Silk using the same call syntax as ordinary function values. The current value is invoked as a plain C code pointer with no closure environment. - If a C API needs context, pass an explicit context pointer (e.g. a
u64that is avoid *in C) alongside the callback and include that context parameter in the callback signature.
Opaque Struct Handles#
Opaque structs are intended for representing foreign pointers/handles safely. They strengthen type safety at the language boundary by preventing accidental mixups between different handle types and by disallowing invalid operations in Silk.
Declare an opaque handle type with a fieldless struct declaration:
// runtime.slk
struct StringBuilder;
ext sb_new = fn () -> &StringBuilder;
ext sb_append = fn (&StringBuilder, string) -> void;
ext sb_destroy = fn (&StringBuilder) -> void;
Use the handle by importing the type name and the ext functions:
import { StringBuilder, sb_new, sb_append, sb_destroy } from "./runtime.slk";
fn main () -> int {
let sb: &StringBuilder = sb_new();
sb_append(sb, "hello");
sb_destroy(sb);
// Using `sb` after destroy is UNDEFINED BEHAVIOR (dangling foreign pointer).
return 0;
}
Rules:
- The handle type must be used behind
&(&StringBuilder), not by value. - Opaque structs cannot be instantiated and do not support member access.
Safety:
- You are responsible for managing the lifetime of foreign handles. Most C APIs provide explicit create/destroy functions; always call the destruction function when you are done.
- Using a handle after destruction is undefined behavior; the compiler does not currently enforce this at compile time.
Notes on executable ext calls (current linux/x86_64 implementation):
- When an executable uses
extcalls orextvariable reads, the compiler emits a dynamically-linked ELF64 executable (PIE-styleET_DYNwithPT_INTERP,.dynamic,.rela.dyn, and a.got). - External symbols are resolved by the platform dynamic loader. Dependencies
can be declared via the CLI (
silk build --needed <soname> ...) or via the C99 embedding API (silk_compiler_add_needed_library), and runtime search paths can be declared via--runpath/silk_compiler_add_runpath.
ABI Contract (Overview)#
The language defines two closely related views of the ABI:
-
A “fat pointer” internal representation for
stringandregexp: -
conceptually:
struct string { ptr: ptr, len: i64 }whereptris a UTF‑8 pointer. -
conceptually:
struct regexp { ptr: ptr, len: i64 }whereptris an engine-owned bytecode pointer. -
A C ABI contract (e.g. via
silk/silk.h) using an explicit struct:typedef struct { char *ptr; int64_t len; } SilkString; -
A mapping to an LLVM type used internally by the compiler:
%silk.string = type { i8*, i64 }
When calling conventional C APIs, the compiler may pass a const char * derived from this structure, with the guarantee that the underlying data is null‑terminated. This distinction is important:
- Internal/runtime ABI: operates on
{ ptr, len }structs (SilkString). - Compatibility calls to typical C libraries: may expose
const char *for parameters declared asstringin Silkextdeclarations, with the compiler extracting theptr.
Our embedding ABI for libsilk.a will treat SilkString as the canonical C representation; details are further specified in C99 ABI and libsilk.a``.
Primitive Type Mapping#
The spec includes a table mapping Silk primitive types to C types, for example:
i8,u8→int8_t,uint8_ti16,u16→int16_t,uint16_ti32,u32→int32_t,uint32_ti64,u64→int64_t,uint64_ti128→SilkI128(seeC99 ABI andlibsilk.a``;{ lo, hi }lanes)u128→SilkU128(seeC99 ABI andlibsilk.a``;{ lo, hi }lanes)int→int64_t(currentlinux/x86_64baseline; do not assume Cint)f32→floatf64→doublef128→SilkF128(seeC99 ABI andlibsilk.a``; IEEE binary128 bits in{ lo, hi })bool→bool(or_Bool)char→uint32_t(UTF‑32)string→SilkString({ char *ptr; int64_t len; })regexp→SilkString({ char *ptr; int64_t len; }, opaque bytecode view)void→void
Notes:
- For FFI with APIs that use a C
int(for example many POSIX syscalls), preferi32/u32in yourextdeclarations rather thanint. - The stable C99 ABI does not use compiler-specific
__int128or__float128types for these primitives; it uses explicit{ lo, hi }structs so the ABI is portable and can be expressed in strict C99.
These mappings must be reflected exactly in the C99 ABI.
Strings and Passing Convention#
For strings, the spec makes the following points:
- Silk’s
stringis represented internally as a{ ptr, len }pair. - For
extcalls to typical C APIs: - the compiler can extract
ptrand pass it as aconst char *, - the data is guaranteed to be null‑terminated so standard C string functions are safe.
For regex bytecode values (regexp):
- Silk’s
regexpis represented internally as a{ ptr, len }pair with the same slot layout asstring, but the bytes are not text and are not required to be null‑terminated. - At ABI boundaries,
regexpuses the same C shape asSilkString, but C code must treat it as an opaque(ptr, len)byte span (not a C string). - Runtime regex helpers validate malformed or undersized foreign
regexppayloads before execution and report them as invalid input, but C code must still not fabricate regex bytecode as if it were a stable public format. - Runtime regex helpers also track which bytecode buffers they actually
allocated: only
std::regex::RegExp.compile(...)produces an owned regex allocation, while borrowed/literal/foreignregexpviews are ignored by the regex free/drop path instead of being freed as if they were runtime-owned.
For the embedding ABI (libsilk.a):
- We treat
SilkString({ char *ptr; int64_t len; }) as the primary C representation of Silkstringvalues. - Functions exported by
libsilk.awill useSilkStringin their signatures wherever strings cross the boundary.
This layered design allows:
- idiomatic FFI to existing C libraries using
const char *, - a precise, length‑carrying ABI (
SilkString) for embedding the compiler/runtime.
Safety & Ownership#
The external interface rules must ensure:
- No C code can violate Silk’s invariants about ownership and lifetimes.
- Any shared data representation (strings, structs, arrays, closures) is documented and stable.
Typed Errors and the ext boundary#
Typed errors (error, panic, and T | ErrorType...) must not cross the ext
boundary.
Rules:
extfunction types must not use|in their return types.- Silk-to-C ABI surfaces must not expose
|in exported function signatures. Shims should convert typed errors into explicit error codes, optionals, or domain-specific error types, or terminate in a platform-appropriate way.
Implementation
- The current compiler rejects
extdeclarations that include|, and rejects exporting error-producing functions to C ABI outputs.
The spec also includes a “Structs, Arrays, and Closures (Complex Types)” subsection for FFI. As the implementation proceeds, this document must be extended to:
- describe how user‑defined structs map to C structs (respecting the layout rules in
Structs, Impl Blocks, and Memory Layout), - define how arrays and slices are represented across the boundary,
- document any stable closure representation, if exposed in the C ABI.
Structs#
The full language design includes rich user-defined structs and nested aggregates. The current compiler implementation supports only a small subset of structs in code generation:
- structs with 0+ fields of supported value types (scalar primitives,
string, nested structs, and supported optionals) in function bodies and internal helper calls, - on
linux/x86_64, passing and returning these structs by value at ABI boundaries using a scalar-slot lowering model: - a struct value lowers to N scalar “eightbyte” slots in field order, and
each slot is classified as INTEGER (integer-like scalars such as
int, fixed-width integers,bool,char,Instant,Duration) or SSE (f32/f64), - exported function parameters accept these slots as separate parameters; for 1–2 slot structs this is ABI-compatible with a by-value C struct parameter for the 8-byte-field subset, while for packed structs with smaller fields ABI compatibility with an equivalent C struct layout is not yet implemented/validated; for 3+ slot structs downstream C callers should declare separate parameters for the slots,
- exported function returns support 1+ slot structs; 1–2 slot results
return in
rax/rdxand/orxmm0/xmm1accordingly, while 3+ slot results return indirectly via a hidden sret pointer.
This subset is intended as a stepping stone toward fully general struct layout
and SysV ABI classification (including packed layout for smaller fields such
as f32 and small integers, nested structs, and larger aggregates returned via
hidden sret pointers).
Optionals#
The full language design includes rich optional patterns (?., match, nested
optionals, etc.). The current compiler implementation supports only a limited
optional subset in code generation:
- optionals whose payload type is a supported scalar,
string, or a supported ABI-safestruct(i.e. after slot-flattening, all scalar slots arei64/u64/f64), - construction via
NoneandSome(value), - unwrapping via
??with short-circuit fallback evaluation, - and nested optionals (
T??) for the same supported payload subset, including unwrappingT??toT?via??.
At ABI boundaries in the current linux/x86_64 subset, optionals are lowered
as a Bool tag followed by the payload scalar slots in order:
(tag, payload)for scalar payload optionals,(tag, ptr, len)forstring?,(tag, slot0, slot1, ...)forstruct?where the payload lowers to N scalar slots.
For nested optionals (T??) in this subset, the payload slots are the full
inner optional representation (for example int?? lowers as
(tag0, tag1, i64 payload)).
For exported functions, these slots consume the normal scalar argument and result locations (registers then stack), and 3+ scalar results return via a hidden sret pointer.
Compiler requirements:
- Implement
extdeclarations as specified. - Map Silk types to C types per the ABI document.
- Enforce the documented passing conventions and ownership rules for external-call strings and other bridged types.
- Keep this document and
C99 ABI andlibsilk.a`` in sync with the actual codegen strategy.
Inline Assembly (asm)#
Silk provides an asm keyword for embedding inline assembly in a way that is
explicit in source code and assembled at compile time.
Inline assembly is inherently low-level and target-dependent. Use asm when
you need precise control over emitted instructions that cannot be expressed
with the standard library or ext bindings.
Syntax#
asm is an expression that takes a single string literal:
fn spin_pause () -> void {
asm "pause";
}
The expression has type void and is intended to appear as an expression
statement.
Semantics#
asm "<text>";emits the machine instructions assembled from<text>.- The assembly text must be a string literal; it is not computed at runtime.
- Inline asm produces no values (type
void). - Inline asm is treated as an explicit side-effecting operation (it is not elided).
Assembly dialect#
In the current implementation, <text> is assembled by the system assembler
(as) for the active native host backend:
linux/x86_64: GNUasin Intel syntax (.intel_syntax noprefix)macos/aarch64: Appleasin its native arm64 syntax
The assembly may contain multiple instructions, for example:
fn main () -> int {
asm "mov rax, rax\nnop";
return 0;
}
Restrictions#
asmis currently implemented only for the nativelinux/x86_64andmacos/aarch64backends.- The assembled output must not require relocations. As a result, inline
asm may not refer to external symbols (for example
call foowherefoois not defined within the asm text). - Operands (inputs/outputs), clobbers, and options are not yet modeled in the type system. Inline asm is therefore not suitable for expressing constraints like “reads memory” / “clobbers rax”; it is raw instruction emission.
Portability and safety notes#
asmis target-dependent by nature. The current implementation is supported only for the nativelinux/x86_64andmacos/aarch64backends.- Using
asmcan make programs non-portable. Prefer standard library facilities and compiler-provided intrinsics when possible.
Notes#
- Parser: accepts
asm "<string literal>"as an expression. - Type checker:
- requires a string literal operand,
- assembles the text for the native backend and reports
E2116when the asm fails to assemble or uses unsupported features (such as relocations), - assigns the expression type
void. - Code generation:
- emits the assembled bytes in the
linux/x86_64IR→ELF backend, - emits the assembled bytes in the temporary
macos/aarch64host Mach-O backend. - Tests:
- end-to-end coverage via:
examples/examples/examples/examples/
Not yet implemented:
- inline asm with operands (inputs/outputs), clobbers, or options,
- any
asmsupport on non-linux/x86_64targets/backends. except for the currentmacos/aarch64host Mach-O subset.
Blocks and Statement Composition#
Blocks group statements, establish lexical scopes, and provide the “body” form
for structured control-flow constructs like if, while, and the match
statement used for typed errors.
Surface Syntax#
A block is a sequence of zero or more statements delimited by braces:
{
stmt0;
stmt1;
...
}
The empty block {} is permitted.
Statements#
Silk currently supports these statement forms (see
Formal Grammar Spec for exact syntax):
- Local bindings:
const(compile-time constant binding; initializer must be const-evaluable),letandlet mut(andvaras an alias forlet mut),let moveandvar movefor initialization-time ownership transfer, including combined modifier forms such aslet mut moveandvar mut move, plus direct destructuring forms such aslet move (a, b) = pair;andlet move Some(value) = maybe;.- Specification-only declarations:
#const(Formal Silk; not usable in runtime expressions). - Structured blocks:
async { ... }/task { ... }(seeConcurrency). - Expression statements: limited to calls, assignments, and increment/decrement
in the Supported forms (
Expression Statements). - Flow control:
if/elsestatements (includingif letpattern destructuring),whileloops,break,continue,return,assert,panic(typed errors),matchstatement (typed errors; seeTyped Errors (error,panic, andT | ErrorType...)).
Semantics#
Sequencing#
Statements in a block execute in source order. If a statement transfers control
out of the current block (return, panic, break/continue inside loops),
the remainder of the block is not executed on that path.
Scope#
A block introduces a lexical scope:
- Names declared by
const/let/varare visible only after their declaration within the same block, and within any nested blocks. - Inner blocks may shadow outer bindings by reusing a name (this is a normal lexical-shadowing rule; the checker should reject only when a specific feature imposes stricter rules).
- The special name
_is a discard binding: let _ = expr;andlet _: T = expr;evaluate the initializer but do not introduce a binding into scope._may be used repeatedly in the same scope without conflicts.- Any produced runtime value is cleaned up at end-of-statement (not at scope exit).
Task(T)andPromise(T)handles are rejected in discard bindings: bind the handle to a real name if you want structured scope-exit cleanup, or consume it explicitly withyield *,await, orawait *.
Destructuring let bindings (Supported forms) bind multiple locals from a
single struct value:
-
Positional (field order):
struct User { id: u64, name: string } let (id, name) = User{ id: 123, name: "alice" }; -
Named (by field name, order-independent), with aliasing:
struct Record { id: u64, data: string } let { data, id } = Record{ id: 123, data: "a record" }; let { data as d, id as i } = Record{ id: 456, data: "other record" };
Array destructuring binds multiple locals from a single array/slice value:
struct Record { id: u64, data: string }
let records: Record[] = [{ id: 123, data: "a" }, { id: 456, data: "b" }];
let [a, b] = records;
Rules (Supported forms):
- Only flat patterns are supported (no nested destructuring).
- The initializer is required.
- The initializer must have a non-opaque
structvalue type. - The pattern must account for every field exactly once:
- positional patterns must have exactly one binder per declared field (in field order),
- named patterns must list each field exactly once (in any order),
- use
_to discard a field (let (_, name) = ...;orlet { data as _ } = ...;).
For array/slice destructuring:
- The initializer must have an array type (
T[N]) or slice type (T[]). - Each binder is positional (index order).
- The pattern binds exactly the number of listed binders:
- fixed arrays require an exact arity match (
[a, b]requiresT[2]), - slices trap at runtime if too short (as if indexing each element).
Enum destructuring binds payload elements from a single enum variant:
import std::result;
error Oops { code: int }
fn foo (oops: bool) -> std::result::Result(int, Oops) {
if (oops) {
return Err(Oops{ code: 123 });
}
return Ok(7);
}
fn main () -> int {
// Destructure `Ok(...)` and bind its payload.
// If the value is `Err(...)`, the program traps.
let Ok(value) = foo(false);
return value;
}
Rules (Supported forms):
- The initializer is required.
- The initializer must have an enum type
E(including a monomorphized generic enum). - The initializer value is consumed (moved); the original binding may not be used after destructuring.
- The pattern must be an enum variant pattern:
Variant(...)(shorthand), orE::Variant(...)/pkg::E::Variant(...)/::pkg::E::Variant(...).- Binder arity must match the variant payload arity (use
_to discard payload elements). - If the runtime value is not the matched variant, execution traps.
Refutable bindings: let ... else { ... };#
For refutable patterns where you want explicit control-flow on mismatch (instead
of trapping), Silk provides a let ... else statement form:
let <pattern> = <expr> else {
// must end with a terminal statement
};
Semantics (Supported forms):
- The initializer expression is evaluated exactly once.
- If the pattern matches, the pattern binders are introduced into the current
scope for the remainder of the block (like a normal
letbinding). let mut <pattern> = ... else { ... };introduces mutable pattern binders.let move <pattern> = ...;andlet move <pattern> = ... else { ... };consume the scrutinee for ownership-tracked values before either branch continues. Theelseblock cannot use the moved source binding, and the continuation receives the payload binders as moved values.- If the pattern does not match, the
elseblock executes. - The
elseblock must be terminal (it must not fall through), so the binders are always available after the statement on any path that continues. - The binders are not in scope inside the
elseblock.
Examples:
fn main () -> int {
let maybe: int? = Some(7);
let Some(v) = maybe else { return 0; };
return v;
}
import std::result;
error Oops { code: int }
fn foo (ok: bool) -> std::result::Result(int, Oops) {
if ok { return Ok(7); }
return Err(Oops{ code: 123 });
}
fn main () -> int {
let Ok(v) = foo(true) else { return 1; };
return v;
}
const bindings are compile-time constants:
- their initializer expression must be compile-time evaluable (otherwise the compiler reports an error),
- the binding is immutable (there is no
const mut), - a
constbinding is a normal runtime value (unlike#const), but its value is computed by the compiler at compile time and does not incur runtime computation cost in Silk currently.
In Silk currently, compile-time evaluation for runtime const
bindings is restricted to:
-
scalar primitive types (
bool, integer/float scalars,char,Instant,Duration), -
compile-time POD
structtypes whose fields are compile-time scalar value types and that do not requireDrop, and -
compile-time evaluable expressions composed of:
-
literals,
-
other
constbindings, -
calls to
const fnfunctions where all arguments are themselves compile-time evaluable, and -
struct literals and field access when the struct type is a supported compile-time POD
struct, and -
ascasts between supported scalar types, and -
a small operator subset (notably
+,-,*, bitwise ops, shifts;/and%are currently rejected forconst). -
stringbindings whose initializer is either: -
a string literal (
"..."or`...`), or -
another
conststring binding.
Example:
struct Point { x: int, y: int }
const origin: Point = Point{ x: 0, y: 0 };
const ox: int = origin.x;
Formal Silk declarations (#const) are compile-time-only names intended for specifications
(#require, #assure, #assert, #invariant, #variant, #monovariant). They must not be referenced
in runtime expressions (see Formal Silk and
Compiler Diagnostics, E2014).
Blocks as Expressions#
The broader language design includes expression-oriented flow constructs (for
example match expressions today and if expressions).
In Silk currently:
- a block is not an expression and does not produce a value; it is purely a statement list used as the body of constructs.
The if expression form is a special-case expression-oriented construct; it
does not make { ... } a general expression form.
If/when general block expressions are introduced, the spec will define:
- which contexts accept them (and how ambiguity with
{ ... }struct literals is resolved), and - how their result values are computed.
Examples#
Nested scope#
fn main () -> int {
let x: int = 1;
{
let y: int = 2;
if x < y {
return 0;
}
}
return 1;
}
Formal Silk declarations for loop specifications#
fn main () -> int {
let limit: int = 3;
#const original_limit = limit;
let mut i: int = 0;
#invariant i >= 0;
#invariant i <= original_limit;
#variant original_limit - i;
while i < limit {
i = i + 1;
}
return 0;
}
Notes#
Implemented end-to-end:
- Block scoping for runtime
let/varbindings and nested blocks. - Formal Silk
#constdeclarations (parsed, type-checked, and rejected if used at runtime).
examples:
Toolchain Metadata (SILK_VERSION, SILK_ABI_VERSION, SILK_GIT_COMMIT)#
Silk exposes a small set of compiler-provided toolchain metadata values to:
- runtime Silk code (as built-in compile-time constants embedded into the output),
- and Formal Silk directives (
#require,#assure, theories, etc).
These values let downstream code:
- report the exact toolchain used to build an artifact,
- gate behavior on the toolchain version,
- and express minimum-version requirements in Formal Silk.
Notes#
- the toolchain metadata constants listed below are available as built-in compile-time constants in every module.
silk --versionreports the same toolchain version, ABI version, and git commit.
Built-In Constants#
The compiler provides the following built-in constants in every module:
-
SILK_VERSION: string -
SILK_VERSION_MAJOR: u64 -
SILK_VERSION_MINOR: u64 -
SILK_VERSION_PATCH: u64 -
SILK_ABI_VERSION: string -
SILK_ABI_VERSION_MAJOR: u64 -
SILK_ABI_VERSION_MINOR: u64 -
SILK_ABI_VERSION_PATCH: u64 -
SILK_GIT_COMMIT: string
These behave like normal const values:
- They do not require an import.
- They may be used anywhere an expression of the corresponding type is allowed.
- They are compile-time constants (their values are fixed at compile time and are embedded into the output artifact).
SILK_VERSION#
The Silk toolchain semantic version string for the compiler that is
compiling the current module (SemVer core major.minor.patch).
SILK_VERSION_MAJOR / SILK_VERSION_MINOR / SILK_VERSION_PATCH#
The SemVer core triplet (major.minor.patch) of SILK_VERSION exposed as
u64 values for convenient comparisons (especially in Formal Silk).
SILK_ABI_VERSION#
The semantic version string of the embedding ABI exposed by libsilk.a.
This must match:
- the
SILK_ABI_VERSION_*macros ininclude/silk/silk.h, and - the values reported by
silk_abi_get_version(...).
SILK_ABI_VERSION_MAJOR / SILK_ABI_VERSION_MINOR / SILK_ABI_VERSION_PATCH#
The SemVer core components of SILK_ABI_VERSION exposed as u64 values.
SILK_GIT_COMMIT#
The git commit hash of the Silk toolchain used to compile the current module.
Rules:
- When the toolchain build can determine a git commit, this is set to a stable hash string.
- When the toolchain build cannot determine a commit (for example when building
from a source snapshot without git metadata), this is set to
"unknown".
Examples#
Printing toolchain information at runtime#
import { println } from "std/io";
fn main () -> int {
println("silk={}, abi={}, commit={}", SILK_VERSION, SILK_ABI_VERSION, SILK_GIT_COMMIT);
return 0;
}
Formal Silk: minimum toolchain requirement#
#require SILK_VERSION_MAJOR > 0 || (SILK_VERSION_MAJOR == 0 && SILK_VERSION_MINOR >= 2);
Related#
- CLI output: the
silkCLI andsilk(1)(silk --version) - Conditional compilation:
Attributes (attr(...))(if attr(...) { ... })
Build Metadata (BUILD_KIND, BUILD_MODE, BUILD_VERSION)#
Silk exposes a small set of compiler-provided build metadata values to both runtime code and Formal Silk (compile-time verification) so programs can adapt to build configuration and so theories can express “this code is only valid in test builds”, “this feature requires a minimum version”, and similar policies.
Notes#
- build metadata is available to runtime code via
std::runtime::build(std::runtime). - build metadata is available as built-in compile-time constants:
BUILD_KIND,BUILD_MODE,BUILD_VERSION.
Built-In Constants#
The compiler provides the following built-in constants in every module:
BUILD_KIND: stringBUILD_MODE: stringBUILD_VERSION: stringBUILD_VERSION_MAJOR: u64BUILD_VERSION_MINOR: u64BUILD_VERSION_PATCH: u64
These behave like normal const string values:
- They do not require an import.
- They may be used anywhere a
stringexpression is allowed. - They are compile-time constants (their values are fixed at compile time and are embedded into the output artifact).
BUILD_KIND#
The output kind currently being built:
"executable""object""static""shared"
BUILD_MODE#
The build mode currently being built:
"debug""release""test"
Notes:
"test"is the mode used bysilk test.- Debug stack traces and debug assertion behavior are controlled separately by
std::runtime::build::is_debug()(seestd::runtime).
BUILD_VERSION#
The semantic version of the current package when building from a manifest.
- When building from a package manifest (
silk.toml),BUILD_VERSIONis the manifestversion. - When not building from a manifest,
BUILD_VERSIONis"0.0.0".
BUILD_VERSION_MAJOR / BUILD_VERSION_MINOR / BUILD_VERSION_PATCH#
The SemVer core triplet (major.minor.patch) of BUILD_VERSION exposed as
u64 values for convenient comparisons (especially in Formal Silk).
Rules:
- These parse the
major.minor.patchprefix ofBUILD_VERSION. - Any trailing
-prereleaseor+buildsuffix is ignored. - On parse failure, all three values default to
0.
Relationship to std::runtime::build#
The standard library provides std::runtime::build functions that return the
same metadata:
std::runtime::build::kind() -> stringstd::runtime::build::mode() -> stringstd::runtime::build::version() -> string
Use std::runtime::build when you prefer explicit namespacing or when writing
code intended to run under alternate stdlib roots.
The same module also owns the reusable Formal Silk vocabulary for build metadata:
std::runtime::build::build_kind_is(...)std::runtime::build::build_mode_is(...)std::runtime::build::{requires_debug_mode,requires_release_mode,requires_executable_kind,requires_object_kind,requires_static_kind,requires_shared_kind}std::runtime::build::build_version_at_least(...)
Examples#
Build-mode gated behavior#
fn main () -> int {
if BUILD_MODE == "test" {
// Test-only behavior.
return 0;
}
return 0;
}
Version-gated behavior#
For semver parsing and comparison, use std::semver at runtime.
For Formal Silk version gating against build metadata, reuse
std::runtime::build::build_version_at_least(...).
Target Metadata (OS_PLATFORM, OS_ARCH, OS_IS_UNIX, OS_IS_POSIX)#
Silk exposes a small set of compiler-provided target metadata values to both runtime code and Formal Silk (compile-time verification).
These values let programs adapt to the compilation target (platform/OS and CPU architecture) without requiring environment-specific runtime queries.
Notes#
- target metadata is available as built-in compile-time constants in every module:
OS_PLATFORM,OS_ARCH,OS_IS_UNIX,OS_IS_POSIX.- the standard library re-exports these via
std::os(the standard library).
Built-In Constants#
The compiler provides the following built-in constants in every module:
OS_PLATFORM: stringOS_ARCH: stringOS_IS_UNIX: boolOS_IS_POSIX: bool
These behave like normal const values:
- They do not require an import.
- They may be used anywhere an expression of the corresponding type is allowed.
- They are compile-time constants (their values are fixed at compile time and are embedded into the output artifact).
OS_PLATFORM#
A canonical target platform/OS name string.
Current compiler target set and values:
linux-x86_64,linux-x86_64-musl,linux-aarch64, andlinux-aarch64-musl:OS_PLATFORM == "linux"macos-x86_64andmacos-aarch64:OS_PLATFORM == "macos"ios-aarch64,ios-simulator-aarch64, andios-simulator-x86_64:OS_PLATFORM == "ios"android-aarch64:OS_PLATFORM == "android"windows-x86_64andwindows-aarch64:OS_PLATFORM == "windows"wasm32-unknown-unknown:OS_PLATFORM == "unknown"wasm32-wasi:OS_PLATFORM == "wasi"
OS_ARCH#
A canonical target CPU architecture name string.
Current compiler target set and values:
linux-x86_64,linux-x86_64-musl,macos-x86_64,ios-simulator-x86_64, andwindows-x86_64:OS_ARCH == "x86_64"linux-aarch64,linux-aarch64-musl,macos-aarch64,ios-aarch64,ios-simulator-aarch64,android-aarch64, andwindows-aarch64:OS_ARCH == "aarch64"- Formal Silk comparisons also accept the ARM64 aliases
"arm64"and"aarch"in any letter case, including through compile-time string constants, even though the canonicalOS_ARCHvalue remains"aarch64". wasm32-unknown-unknownandwasm32-wasi:OS_ARCH == "wasm32"
OS_IS_UNIX#
Whether the compilation target is a UNIX family target.
Current compiler target set:
linux-x86_64:truelinux-x86_64-musl:truelinux-aarch64:truelinux-aarch64-musl:truemacos-x86_64:truemacos-aarch64:trueios-aarch64:trueios-simulator-aarch64:trueios-simulator-x86_64:trueandroid-aarch64:truewindows-x86_64:falsewindows-aarch64:falsewasm32-unknown-unknown:falsewasm32-wasi:false
OS_IS_POSIX#
Whether the compilation target is a POSIX target.
Current compiler target set:
linux-x86_64:truelinux-x86_64-musl:truelinux-aarch64:truelinux-aarch64-musl:truemacos-x86_64:truemacos-aarch64:trueios-aarch64:trueios-simulator-aarch64:trueios-simulator-x86_64:trueandroid-aarch64:truewindows-x86_64:falsewindows-aarch64:falsewasm32-unknown-unknown:falsewasm32-wasi:false
Relationship to std::os#
The standard library provides std::os helpers that expose the same metadata
in a namespaced form and additionally map these strings into enums for use with
match (see the standard library; targets not covered by the current enum set map
to Unknown).
Examples#
Target-gated behavior#
import std::os;
import { println } from "std/io";
fn main () -> int {
if OS_IS_POSIX {
println("posix");
}
match (std::os::platform()) {
std::os::Platform::Linux => println("linux"),
std::os::Platform::WASI => println("wasi"),
std::os::Platform::Unknown => println("unknown"),
};
return 0;
}
Formal Silk requirements#
#require OS_IS_POSIX;
Testing#
This document specifies the initial language-level testing surface for Silk.
The goal is a Zig-like authoring experience (tests live next to the code they exercise) with a simple CLI runner that emits modern TAP output for downstream consumption.
test declarations#
A test declaration is a top-level block of statements that the compiler can
compile and execute under silk test.
Syntax:
test "name" {
// statements...
}
The string name is optional:
test {
// statements...
}
Rules:
testdeclarations MAY appear:- at top level (like
fnandlet), and - nested inside another
testblock (scoped subtests). - A
testblock introduces its own scope (like a function body). - Nested
testblocks are executed inline, in source order, as part of the enclosing test’s execution. They may be used for hierarchical grouping and shared setup. testblocks may uselet,var, control flow, and call functions/methods using the same expression subset as normal code.- Top-level
testblocks may useawait. When a test body containsawait,silk testruns that generated test wrapper as async and awaits it from the generated runner. return;is allowed inside atestblock (equivalent to ending the test early).return <expr>;is not allowed.
silk test executable runners use the native host target when Silk has a
host-backed executable backend for it, and otherwise fall back to
linux-x86_64 (or linux-x86_64-musl on musl x86_64 Linux hosts). Formal
Silk target metadata in silk test reflects that selected execution target.
Doc comments:
- Doc comments (
/** ... */and/// ...) attach to atestdeclaration the same way they attach to other top-level declarations.
Running tests (silk test)#
The silk test command:
- loads a module set (like
silk check/silk build), - discovers all
testdeclarations in the module set, and - executes them, emitting TAP output.
TAP output#
The initial runner uses TAP version 13 formatting:
TAP version 131..Nok <n> - <name>not ok <n> - <name>
Nested test progress output#
When a test block contains nested test blocks, the runner emits subtest
progress lines to stderr as each nested test completes:
ok - a/bnot ok - a/b
The a/b path reflects the active nested test name stack (including the
outermost test name) joined with /. This keeps TAP output on stdout stable
while making long nested suites easier to follow in an interactive terminal.
Assertions inside tests#
In silk test builds, failed assertions do not abort the process. Instead:
- A failed
assertrecords a test failure and execution continues. - If the assertion has no explicit message, the compiler uses the assertion
condition text as the message (e.g.
assert value != 123;usesvalue != 123). - Failed assertions also emit a one-line detail message to stderr so failures are
visible in
silk testoutput without requiring--debug, formatted like: assertion failed: <message>when not inside anytestblock, orassertion failed [test: a]: <message>when inside atestblock, orassertion failed [test: a/b]: <message>when inside nestedtestblocks.
The a/b path reflects the active nested test name stack (including the
outermost test declaration name).
- The test executable exits non-zero if any failures were recorded so TAP output reflects failures.
The current runner still isolates top-level tests in separate processes, but a single test case may now accumulate multiple failures.
std::test (standard test helpers)#
The standard library provides std::test helpers for test-only assertions that
record failures without aborting:
expect(ok: bool, message: string? = None);expect_equal(expected: X, actual: Y) -> bool;expect_error(err: E?) -> bool;
See std::test for the detailed API.
Note: std::test helpers carry a Formal Silk contract requiring
BUILD_MODE == "test" via std::test::requires_test_mode() so downstream
verification can model them as test-only APIs.
Notes#
- parsing of
testdeclarations andsilk testrunner with TAP output. std::testhelpers and non-aborting assertions in test builds.
Silkdoc (Documentation Comments)#
This document specifies Silkdoc, Silk’s documentation-comment format. Silkdoc comments are intended for tools (documentation generators, editors, and the language server). They do not affect program semantics.
The goal is a familiar JSdoc feel with Silk/TypeScript-style type annotations.
Comment Forms#
Two doc-comment forms are recognized:
- Block doc comments:
/** ... */ - Line doc comments: one or more consecutive lines starting with
///
In both forms, doc comments attach to the next declaration when they appear immediately before it with only whitespace/comments between them.
implementation scope:
- Doc comments attach to top-level declarations (
package,module,import,fn,theory,let,struct,ext,interface,impl). - Doc comments also attach to:
- methods inside
impl Type { ... }blocks, and - method signatures inside
interface Name { ... }blocks. - For function declarations, doc comments attach even when one or more formal
verification annotations (
#require/#assure) appear between the doc comment and thefnkeyword. - Doc comments inside function bodies are treated as ordinary comments (not attached to anything).
- Attaching doc comments to struct fields, parameters, and locals is future work.
Content Model#
A doc comment contains:
- free-form text (Markdown-friendly) describing the declaration, and
- optional tags starting with
@.
The free-form text is everything before the first tag line.
Any non-tag lines that appear after the first tag line are ignored unless they
are part of a multi-line tag body (for example @example or @remarks).
Leading * convention#
For block doc comments, the conventional leading * is ignored:
/**
* Hello
* world
*/
Tools strip the leading * (and one following space when present) before
parsing.
Tags#
Tags begin at the start of the logical line after stripping comment prefixes.
@param#
Declare a parameter description.
Syntax:
@param <name>: <Type> <description...>
@param <name> <description...>
The <Type> uses Silk type syntax as defined in Types.
Example:
/**
* Appends one byte to the vector, growing as needed.
*
* @param self: &std::vector::Vector(u8) The receiver.
* @param value: u8 The byte to append.
*/
@returns#
Describe the return value.
Syntax:
@returns <Type> <description...>
@returns <description...>
@throws#
Describe an error/exception-like condition.
Syntax:
@throws <Type> <description...>
@throws <description...>
Note: the language does not yet have a stable error type; @throws is
documentation-only until Result(T, E) and error conventions are fully
implemented.
@external#
Indicate that a declaration is an external FFI binding (its implementation is provided outside Silk).
This tag is typically used to document ext function declarations.
Syntax:
@external
@example#
Provide an example snippet. The tag may optionally declare a language for Markdown fenced code blocks.
Syntax:
@example
<one or more lines of example text>
@example silk
<one or more lines of code>
The example body continues until the next tag line or the end of the doc comment.
Other tags#
The initial toolchain may also recognize:
@since <text...>@deprecated <text...>@remarks <text...>(may span multiple lines like@example)@see <text...>(repeatable)
Additional tags must be documented here before they are relied on by tooling.
Formal Silkdoc tags#
Silkdoc can document Formal Silk constructs without affecting verification. These tags are documentation-only (they do not prove anything and do not introduce Formal Silk obligations).
@requires#
Document one precondition for a declaration (typically mirroring #require on a
function or a theory).
Syntax:
@requires <Expr...>
This tag is repeatable.
@assures#
Document one postcondition for a declaration (typically mirroring #assure on
a function or a theory).
Syntax:
@assures <Expr...>
This tag is repeatable.
@asserts#
Document one internal proof obligation (typically mirroring a #assert inside a
function or theory body).
Syntax:
@asserts <Expr...>
This tag is repeatable.
@theory#
Document one theory attachment or use (typically mirroring #theory Name(args...);).
Syntax:
@theory <Name(args...)...>
This tag is repeatable.
Manpage-oriented tags#
The toolchain uses a small set of optional doc tags to generate man(7) pages
from source comments (silk doc --man and silk man).
These tags are documentation-only and do not affect program semantics.
@misc#
Declare a conceptual documentation block intended for man section 7.
Syntax:
@misc <label> <summary...>
@misc <label>
Notes:
- The
<label>is an opaque identifier used by tooling for discovery (for examplesilk man <label>). It should be stable and globally unique within a package (recommendation: use apkg::topiclabel). - The optional
<summary...>provides a one-line description for the manpageNAMEsection. When omitted, tools may derive a summary from the first line of the free-form description text.
@cli#
Declare that a doc comment describes a command-line interface, intended for man section 1.
Syntax:
@cli <name>
@cli
When <name> is omitted, tools derive the command name from context (for
example the module name or executable name provided by the build system).
@synopsis#
Provide one or more synopsis lines for a CLI manpage.
Syntax:
@synopsis
<one or more lines of synopsis text>
The synopsis body continues until the next tag line or the end of the doc comment.
@option#
Declare a command-line option for a CLI manpage. This tag is repeatable.
Syntax:
@option <prototype...>
@option `<prototype...>` <description...>
Examples:
@option `-h, --help` Show help and exit.
@option `--out <path>` Write output to <path>.
@command#
Declare a subcommand for a CLI manpage. This tag is repeatable.
Syntax:
@command <name> <description...>
Markdown Rendering#
The documentation generator renders doc comments to Markdown using:
- the free-form text as the leading description (paragraphs preserved),
@paramentries as a “Parameters” list,@returnsas a “Returns” section,@throwsas a “Throws” section,@requires,@assures,@asserts, and@theoryas dedicated sections (one bullet per tag instance),@exampleblocks as fenced code blocks.
The generator must keep formatting stable (deterministic output) so that documentation diffs are meaningful.
Expression Statements#
Expression statements allow expressions to be used for their side effects.
Syntax#
An expression statement is an expression followed by a semicolon:
expr;
Semantics#
- The expression is evaluated exactly once.
- The result value (if any) is discarded.
Current implementation restrictions#
For Silk currently, an expression statement is only valid when the expression is either:
- a call expression (a function call), or
- an assignment / compound assignment expression.
- an increment/decrement expression (
++x,x++,--x,x--). await p;wherep: Promise(void).await * ps;whereps: Promise(T)[](the collected results are discarded).yield ...;statement forms insidetask fnas described inConcurrency.
All other expression statements are rejected.
This restriction will be relaxed as more of the expression language is lowered and code-generated.
Examples (accepted in the Supported forms):
fn main () -> int {
std::io::println("hello");
let mut x: int = 0;
x = 1;
x += 2;
x++;
return 0;
}
async fn pause () -> void {}
async fn main () -> int {
await pause();
await * [pause()];
return 0;
}
Examples (rejected in the Supported forms):
fn main () -> int {
1 + 2; // rejected: non-call/non-assignment expression statement (E2002)
return 0;
}
Guidance#
If you computed a value and you want to keep it, bind it:
fn main () -> int {
let x: int = 1 + 2;
return x;
}
If you want a value for control flow, prefer an expression form that produces a
value (for example match expressions; see ``match Expression (and Statement)).
Compiler requirements#
The compiler must:
- Distinguish between expressions that can appear as statements and those that cannot (if the spec imposes restrictions).
- Preserve evaluation order consistent with the language’s semantics.
for Loop#
The for loop iterates over a range or iterable and executes a block once per
element.
: integer range iteration (start..end and
start..=end), array/slice iteration (for x in xs { ... } for T[N] and
T[]), iterator iteration (for x in it { ... } when it.next() -> T?), and
C-style for (init; condition; step) { ... } loops.
Goals#
- Provide a readable, structured loop construct for iteration.
- Avoid “off-by-one” patterns by making range boundaries explicit.
- Integrate with
break/continue. - Integrate with future iteration protocols (interfaces/generics) without introducing hidden allocation.
Surface Syntax#
Supported surface forms:
for <pattern> in <iterable> {
...
}
for let <pattern> in <iterable> {
...
}
for (<init>; <condition>; <step>) {
...
}
Notes:
for <pattern> in <iterable> { ... }currently accepts a single identifier binder (and_) as the ordinary element-binding form.for let <pattern> in <iterable> { ... }is the refutable pattern form.for let mut <pattern> in <iterable> { ... }marks pattern binders mutable for the current loop iteration. It uses the same currently supported refutable match-pattern subset asif let/while let.<iterable>is an expression.<init>is a local binding (let/var/const) with an initializer.<condition>is a boolean expression.<step>is a statement-like expression (the same restricted subset as expression statements; seeExpression Statements).
Semantics#
General rules:
- The iterable expression is evaluated once to produce an iteration source.
- The loop body executes once per produced element.
breakexits the loop;continueadvances to the next element.
Range iteration#
When the iterable is a range expression (for example start..end or
start..=end), the loop iterates over integer values.
Design intent:
start..enditeratesstart, start+1, ..., end-1(end-exclusive).start..=enditeratesstart, start+1, ..., end(end-inclusive).
Notes:
- The range bounds are evaluated once, left-to-right (
startthenend). - If the start bound is greater than or equal to the end bound (
start >= end) for an end-exclusive range, the loop executes zero times. - If the start bound is greater than the end bound (
start > end) for an end-inclusive range, the loop executes zero times. continueadvances to the next element (it performs the increment step, then re-checks the range condition).- The loop binder is in scope only inside the loop body block.
- The binder is immutable in the Supported forms (it behaves like a
letbinding that is updated by the loop machinery; user code cannot assign to it).
Type checking (Supported forms):
- Both range bounds must have integer type (
int,i8/u8,i16/u16,i32/u32,i64/u64). - The two bound types must match, except that an integer literal bound may be
coerced to the other bound’s integer type (for example
for i in 0..n_u32). - The loop binder (when not
_) has the bound’s integer type.
Example:
fn main () -> int {
let mut sum: int = 0;
for i in 0..3 {
// i takes values 0, 1, 2
sum += i;
}
// 0 + 1 + 2 = 3
return sum;
}
Array and slice iteration#
In Silk currently, for also supports iterating over builtin
array and slice types:
- fixed arrays
T[N], - slices
T[].
Semantics (Supported forms):
- The iterable expression is evaluated once.
- The loop executes in increasing index order, starting at index
0. - The loop binder (when not
_) is bound to the element value (a copy) for the current iteration. - The binder is in scope only inside the loop body block.
breakexits the loop;continueadvances to the next element.
Pattern-filtered iteration with for let#
Silk also supports a filtered iteration form:
for let <pattern> in <iterable> {
...
}
Semantics (Supported forms):
- The iterable expression is evaluated once.
- The loop still advances over every produced element in order.
- Each produced element is matched against
<pattern>. - When the pattern matches, any binders introduced by the pattern are in scope only for that iteration’s loop body, and the body executes once.
- When the pattern form uses
let mut, those binders may be reassigned inside that iteration's body. - When the pattern does not match, the current element is skipped and iteration continues with the next element.
breakandcontinuekeep their ordinaryformeaning.
supported patterns are the same refutable subset already implemented by
if let / while let, including:
Some(x)/NoneOk(x)/Err(x)- typed binders such as
v: Twhere that pattern form is already supported - enum variant payload patterns such as
Enum::Variant(x, _)
Example:
import std::result;
type R = std::result::Result(int, int);
fn main () -> int {
let xs: R[4] = [R.ok(2), R.err(7), R.ok(3), R.err(9)];
let mut sum: int = 0;
for let Ok(v) in xs {
sum += v;
}
return if sum == 5 { 0 } else { 1 };
}
Limitations:
- Element types are limited to the currently-supported array/slice element
subset (types that lower to a fixed scalar slot sequence in the current
back-end, such as primitive scalars,
string, and supported non-opaque structs). - Iteration is by value; to mutate an element, use indexing (
xs[i] = ...).
Example:
fn main () -> int {
let xs: int[3] = [1, 2, 3];
let mut sum: int = 0;
for x in xs {
sum += x;
}
return sum;
}
Iterator protocol#
In addition to builtin arrays and slices, for supports iterating over a
stateful iterator value.
An expression it is treated as an iterator when it has a next() -> T?
instance method (typically by implementing std::interfaces::Iterator(T)).
Semantics (Supported forms):
- The iterable expression is evaluated once to produce the iterator value.
- The loop repeatedly calls
it.next(). - When the result is
None, the loop exits. - When the result is
Some(value), the binder (when not_) is bound tovalue(a copy) for that iteration and the body executes. continueadvances by callingnext()again;breakexits the loop.
C-style for loops#
Silk also supports the traditional “C-style” for loop:
fn main () -> int {
let len: int = 10;
let mut sum: int = 0;
for (let i = 0; i < len; ++i) {
sum += i;
}
return sum;
}
Semantics (Supported forms):
<init>executes exactly once before the first condition check.<condition>is checked before each iteration; if it isfalse, the loop exits.- The loop body executes once per iteration when
<condition>istrue. - After the body executes normally,
<step>executes, then the loop re-checks<condition>. continue;skips the remainder of the loop body and jumps to<step>(then re-checks<condition>).break;exits the loop immediately without executing<step>for that iteration.- The init binding’s name is in scope within the entire loop (condition, step, and body) but is not visible after the loop.
Init binding mutability (Supported forms):
- For ergonomics,
for (let i = 0; ...; ++i)is accepted and the init binding is treated as mutable (equivalent tovar) within the loop. constinit bindings remain immutable.
Guidance#
In Silk currently, for supports integer ranges and builtin
array/slice iteration. To write other loops today, use while:
fn main () -> int {
let mut i: int = 0;
while i < 3 {
std::io::println("i = {}", i);
i += 1;
}
return 0;
}
Compiler Requirements#
- Recognize
forloop syntax. - Resolve iteration targets (ranges, collections) according to the language’s iteration model.
- Lower
forinto explicit control flow, with correct semantics forbreakandcontinue.
Compiler requirements:
- Recognize
forloop syntax. - Resolve iteration targets (ranges, collections) according to the language’s iteration model.
loop Loop#
The loop statement executes a block repeatedly until it is terminated by a
break or return.
: loop { ... }, plus async loop { ... } and
task loop { ... }.
Surface Syntax#
loop {
// ...
}
async loop {
// ...
}
task loop {
// ...
}
Notes:
async loopandtask loopare still loop statements: they do not end with;.- In Silk currently,
async loop/task loopfollow the same async-context restriction asasync { ... }/task { ... }: they are only allowed inside functions declared withasync(Compiler Diagnostics,E2031).
Semantics#
- The body block executes repeatedly.
break;exits the nearest enclosing loop and continues execution at the statement immediately following the loop (break).continue;skips the remainder of the current iteration’s body and begins the next iteration (continue).return;exits the current function (return).
Type Checking Rules#
- The loop body is checked in a loop context so
break/continueare valid. break;outside a loop is rejected (Compiler Diagnostics,E2007).continue;outside a loop is rejected (Compiler Diagnostics,E2008).
Notes#
Implemented end-to-end:
loop { ... },async loop { ... }, andtask loop { ... }parse, type-check, and lower with correctbreak/continuesemantics.
Type Unions (T1 | T2 | ...)#
Silk supports type unions in type annotations. A union type represents a value that is exactly one of several member types.
This feature exists to model small, explicit “one-of-these-types” outcomes
without requiring a dedicated nominal enum declaration for every case.
See also:
Typed Errors (error,panic, andT | ErrorType...)(unparenthesized|in function signatures is reserved for typed-error contracts),- ``enum
Types(general tagged unions with named variants), - ``match
Expression (and Statement)(matchover union values).
Surface Syntax#
Union types use | between member types:
let x: Foo | Bar;
struct S { v: u8 | bool }
type U = Foo | Bar | Baz;
Return types (important disambiguation)#
In function declarations, unparenthesized | after -> is reserved for
typed errors (SuccessType | ErrorType...). To write a union as a function’s
return type, the union must be parenthesized:
fn f () -> (Foo | Bar);
fn g () -> (Foo | Bar) | SomeTypedError;
This disambiguation is required so the parser and checker can treat typed-error contracts as authoritative.
Rules#
The implementation intentionally supports only unions whose member types have a safe, well-defined representation in the current compiler/backend subset.
A union type T1 | T2 | ... | Tn is permitted when all member types are in the
supported union-member set:
- Primitive scalar types in
{ bool, char, i8, u8, i16, u16, i32, u32, i64, u64, int, usize, size, Instant, Duration }(isizeis accepted as an alias forsize), and/or - Nominal POD structs (including
errortypes) and nominal POD enums that lower to a scalar-slot representation in the backend (no opaque structs).
Unions may freely mix primitive and nominal members in this subset.
For primitive members, the current native backend requires that each member
type be distinguishable at injection sites. In practice, that means a union
may not contain two primitive types that lower to the same backend scalar
representation (for example int | i64, usize | u64, char | u32,
Duration | i64). This restriction is specific to the backend
and may be relaxed once union injection uses full type identity rather than a
backend-scalar heuristic.
Notes:
- Nested unions are flattened:
(A | B) | Cis the same union asA | B | C. - Duplicate member types are rejected.
Semantics#
A value of a union type is a tagged value:
- It stores a runtime tag identifying which member type is active.
- It stores the payload value in a uniform representation compatible with all members in the backend.
Representation#
In the current native backend subset, unions are lowered as:
(u64 tag, u64 payload_0, ..., u64 payload_(N-1))
where N is the maximum scalar-slot count across the union’s member types
(primitive members contribute 1).
Member payload values are stored/loaded via raw-bit casts (cast_raw) to and
from the u64 payload slots. Unused payload slots are zero-filled on
injection and on widening coercions.
Union-to-union coercions (supersets)#
When a context expects a union type U_sup, a value of a union type U_sub
may be used if U_sub’s member set is a subset of U_sup’s member set. The
compiler remaps the runtime tag to the destination union’s tag numbering when
needed so pattern matches on the destination union remain correct.
If U_sup’s payload is larger than U_sub’s payload (because U_sup contains a
member with a larger scalar-slot representation), the payload is widened by
copying existing payload slots and zero-filling the newly-added slots.
Construction (injection)#
When a context expects a union type, a value whose type is one of the union’s member types may be used directly and is injected into the union.
Examples:
struct A { x: int }
struct B { x: int }
type U = A | B;
fn main () -> int {
let a: A = A{ x: 1 };
let u: U = a; // inject `A` into `U`
return 0;
}
Matching (match)#
Union values are consumed via match expressions using typed binder
patterns:
type U = A | B;
let out: int = match u {
a: A => a.x,
b: B => b.x,
};
Rules (Supported forms):
- When the scrutinee has a union type, patterns are restricted to
name: Type(or_: Type) whereTypeis one of the union member types. - Union matches must be exhaustive: exactly one arm per member type (order does not matter).
- The same typed-binder syntax may also be used with a concrete struct
scrutinee before the value is injected into a union. In that form, exactly one
typed arm must accept the concrete scrutinee type. Accepted arm types are the
exact concrete type, a valid base type through
extends, or an interface that the concrete type implements.
using (Aliases and Method Reuse)#
using introduces a local alias to an existing symbol, and (in interface /
impl bodies) can import method signatures/implementations under a new name.
This feature is intended to make large module trees ergonomic (short local names) and to enable explicit, audited method reuse across types.
Syntax#
At any supported scope, using has two surface forms:
using Alias = QualifiedName;
using QualifiedName;
using QualifiedName as Alias;
Where QualifiedName uses the normal ::-separated name syntax (including the
global-prefix form ::name).
Module / Package Scope#
At module scope, using introduces a local alias for an in-scope symbol:
- types (
struct/enum/error/interface/typealiases), - functions (
fnandextfunction bindings), - Formal Silk theories (
theory).
For type aliases, the target may be a package-qualified generic type
declaration made visible through a namespace/package import, for example
using Result = std::result::Result;. This is how a stdlib prelude can expose
canonical type constructors while keeping the stdlib source on namespace
imports.
The alias is transparent: using Alias is equivalent to using the target
symbol directly.
Name conflicts are errors, except when the alias already refers to the same
symbol as the target (a redundant alias). In that case the using declaration
is accepted as a no-op.
Module-scope aliases may also be exported:
export using Alias = QualifiedName;
public using Alias = QualifiedName;
export usingandpublic usingare equivalent at module scope.- Exported aliases participate in the module/package surface just like other exported declarations:
- file imports may name the alias directly,
- package imports may name the alias directly,
- package-qualified access may use the alias name,
- and
export default Alias;may target a module-scopeusingalias. - Exported type aliases remain transparent at import sites: importing the alias introduces the alias name as a real type name in the importing module.
interface Scope#
Inside an interface { ... } body, using may import method signatures
from another interface:
interface Read {
fn read() -> u8;
}
interface ReadAndPeek {
using Read::read;
fn peek() -> u8;
}
using Other::name;is equivalent to copying the correspondingfn name(...);signature fromOther.using Other::name as alias;imports it under the new namealias.- Name conflicts (including conflicts with inherited
extendsmembers) are errors.
Note: interface method signatures omit the receiver parameter. The receiver is
introduced only in impl method declarations (see Interfaces).
impl Scope#
Inside an impl Type { ... } body, using may import a method implementation
from another impl:
impl Foo {
fn id(self: &Foo) -> int { return 1; }
}
impl Bar {
using Foo::id;
}
This makes the imported method available as if it were declared in the target impl, including as a candidate for interface conformance checking.
Visibility#
Imported methods inherit the source method’s visibility:
- importing a
public fnmethod produces apublicmethod in the target impl, - importing a private method produces a private method in the target impl.
Since using does not accept visibility modifiers in the Supported forms, this
inheritance rule is the only way to control whether an imported method is
callable outside the target impl { ... } block.
Self and Layout Compatibility#
When the imported method’s signature depends on Self (for example
self: &Self, parameters of type Self, or returning Self), importing it
across distinct struct types requires that the underlying layouts are
compatible.
In Silk currently, a pair of non-opaque, non-error structs are
considered compatible when they have the same number of fields and the same
field types in the same order (field names do not matter).
If the source and target struct layouts are not compatible, the using
declaration is rejected.
This layout rule applies equally to immutable and mutable borrows: importing
methods with mut self: &Self (or other mut &Self parameters) is permitted
when the source and target layouts are compatible.
Supported forms Limitations#
- Outside module scope,
usingdoes not acceptpublic/privatemodifiers yet (imported methods inherit the source method’s visibility). - Constructor reuse (
constructor) viausingis not supported yet.
Compiler Diagnostics#
This document specifies the human-readable diagnostic format emitted by the Silk toolchain, including:
- the
silkCLI (silk check,silk build), - the embedding ABI (
libsilk.aviasilk_compiler_last_error/silk_error_format), - and tooling that reuses the front-end (for example
silk-lsp).
The goal is to provide diagnostics that are:
- precise (file + line + column + source span),
- stable (consistent wording and stable error codes for known error kinds),
- consumable by humans (caret snippets, notes/help where appropriate),
- easy to test (deterministic formatting; the canonical text contains no ANSI escapes).
Diagnostic Policy#
The compiler should diagnose the actual class of failure, not hide it behind bring-up terminology.
User-facing diagnostics should distinguish at least these categories:
- Spec/type-check failure: the program violates the documented Silk language or standard-library contract.
- Unimplemented language feature: the program uses a language feature that is not yet implemented in the compiler and is not part of the shipped documented language contract.
- Backend/target limitation: the program parses and type-checks, but a target-specific lowering/codegen path cannot yet emit the requested output.
- Stdlib/module availability gap: the program refers to a standard-library or package surface that is absent or unavailable for the current build/host.
- Internal compiler error: the compiler lost required context or reached an unexpected internal failure.
The term subset should not be the primary explanation in new diagnostics. It may still appear in historical notes or implementation-status prose, but user-facing errors should say what is actually wrong: for example "unimplemented language feature", "unsupported backend target path", or a specific contract violation.
Rule for feature-vs-rejection wording:
- If a construct is part of the documented language/spec surface, a compiler rejection must be described as an implementation gap, not as a language-level rejection.
- In particular:
u128/f128are language features, soE2114/E2115describe missing implementation work in some compiler paths rather than forbidden types.- monomorphized generics are language features, so
E2016is for generic forms the current compiler has not implemented yet, not for generic syntax that is outside the language.
Terminology#
- Source span: a byte range in the UTF‑8 source buffer (
offset,length). - Displayed line and column numbers are 1-based.
- Columns are measured in UTF‑8 bytes (matching the lexer’s current
Token.columnbehavior). - Primary label: the main span where the error is reported (single span in the implementation).
- Note / Help: supplemental lines that explain context or suggest a fix.
Text Format (CLI and ABI)#
The standard human-readable diagnostic format is:
error[E<code>]: <message>
--> <path>:<line>:<column>
|
<line> | <source line text>
| <caret underline>
= note: <note text> (optional, repeatable)
= help: <help text> (optional, repeatable)
Rules:
- The
error[...]line always appears for known error kinds;<code>is stable for that error kind. - For diagnostics with no usable location, the
--> ...and snippet block may be omitted. - The snippet block uses the 1-based line number and includes the full line text as it appears in the source.
- The caret underline is placed under the primary span:
- for a zero-length span, print a single
^, - otherwise print
^repeated for the span length, clipped to the line end if needed. - The canonical text format contains no ANSI color escapes.
Manifest and Config Errors#
The CLI uses the same caret diagnostic format for errors in tooling/config inputs,
including the package manifest silk.toml and build-module-generated manifests.
These diagnostics may not yet have stable error codes.
Example (missing = in silk.toml):
error: invalid TOML in package manifest
--> silk.toml:2:6
|
2 | name "app"
| ^ expected `=`
ANSI Color (CLI)#
The silk CLI may decorate the canonical diagnostic format with ANSI SGR escape codes
when writing to a terminal. The visible text (after stripping ANSI escapes) must still
match the canonical format.
Color is enabled only when:
- stderr is a TTY that supports ANSI escapes,
NO_COLORis not set,TERMis notdumb.
Color is never used for the embedding ABI (silk_error_format / silk_compiler_last_error),
and is not used when stderr is not a TTY (for example when piping diagnostics to a file).
Suggestions and Help Text#
Diagnostics may include one or more = help: lines that suggest concrete fixes.
These are heuristic and may be omitted when the compiler cannot compute a safe
suggestion.
Examples of help/suggestion content the compiler may emit:
- for unknown imports, a
"did you mean ...?"suggestion based on nearby names, - for file imports, a note about the resolved import path,
- reminders about enabling or configuring the standard library (
--nostd,--std-root,SILK_STD_ROOT) when importingstd::..., - guidance to include additional modules in the build/module set when an import refers to a package or file that is not present.
Diagnostic Lookup Command#
silk error is the terminal lookup surface for stable compiler diagnostics:
silk error <code>prints the canonical code, category, short description, documentation references, any bundled example for that diagnostic, and asilk guide <code>follow-up only when the installed guide catalog actually links that diagnostic code.silk error --listandsilk error -lprint every stable compiler error code and its short description in deterministic order.<code>accepts copied forms such asE2028,2028,diag:E2028, anderror[E2028].- Examples are syntax-highlighted when stdout is a color-capable TTY; piped
output,
NO_COLOR, andTERM=dumbremain plain text.
The command is backed by compiler-owned diagnostic metadata rather than scraped
documentation. Compiler Diagnostics remains the normative prose
catalog for error-code meanings and policy.
silk-lsp publishes these stable codes in LSP diagnostics as well. Structured
resolve/type-check diagnostics include a silk error <code> help item, and
parse diagnostics publish E0001 with a silk error E0001 lookup hint in the
diagnostic data payload.
Error Codes#
The compiler assigns a stable code to each currently supported error kind.
Parsing#
E0001— unexpected token / invalid top-level ordering.
Import and Package Resolution#
E1001— unknown imported package.E1002— cyclic package imports.E1003— unknown imported file.E1004— cyclic file imports.E1005— duplicate exported symbol within a package.E1006— file imports require a module file path.
Type Checking#
E2001— type mismatch.- The primary message stays stable, but the diagnostic detail should explain the exact failed contract when available, for example:
in IntFlag.usage param fs: expected ..., found ...,while initializing binding count: expected ..., found ...,in assignment to queue.reader: expected ..., found ...,in return statement: expected ..., found ....E2002— language feature is not implemented yet.- The diagnostic detail should identify the exact rejected construct (statement / expression / declaration / type) and why it failed.
- The public wording should describe an unimplemented feature, not a vague "subset" category.
- Common examples of the required detail quality:
- field access on an optional value should explain that
opt.fieldmust be rewritten asopt?.fieldor preceded by an unwrap, yield <task_handle>;in statement position should explain that statementyieldis the send form and that receiving from a task handle requires value position (let x = yield h) oryield * h;for drain/forward.E2003— unknown imported name.E2004— duplicate imported name.E2005— invalid assignment.E2006— invalid borrow.E2007— invalidbreak.E2008— invalidcontinue.E2009— invalidreturn.E2010— missingreturn.E2011— opaque struct used by value.E2012— cannot instantiate opaque struct.E2013— cannot access fields on opaque struct.E2014— formal Silk declaration used in runtime expression.E2015— binding requires an initializer.E2016— generic form is not implemented yet (for example const parameters / const type arguments / genericimplmethods).E2017— builtinmap(K, V)type form was removed (usestd::map::{HashMap, TreeMap}instead).E2018— namespace import is not callable.E2019— duplicate default export in a module.E2020— invalidpanicstatement.E2021— unknown error type.E2022— error not declared in function signature.E2023— error-producing call must be handled withmatchor?.E2024— match scrutinee is not an error-producing call.E2025— match is missing an arm.E2026— typed error-handling match arm must end with a terminal statement.E2027— heap allocation is disabled (--noheap) and heap-backed allocation is rejected (newoutsidewith, libc allocatorext, capturing closures, and concurrency use that declares/formsTask(...)/Promise(...)handles; imported stdlib async declarations alone do not trigger it).E2028— unknown name.E2029— ambiguous implicit coercion.E2030—awaitrequires anasyncfunction.E2031—async { ... }/task { ... }requires anasyncfunction.E2032— ambiguous constructor call.E2033—awaitrequires a Promise operand.E2034— cannot copy a Task/Promise handle.E2035— Task/Promise handle used afterawait/yield *.E2036— cannot consume an outer Task/Promise handle inside a loop.E2037—task fnuses a non-task-safe type at a task boundary.E2038—?requires an error contract (-> T | ErrorType...).E2039—?requires a fallible call operand.E2040— propagated error is not declared in the function signature.E2041—constinitializer is not compile-time evaluable.E2042—pure fnmay not have a typed-error contract (|in return type).E2043—pure fnmay not containpanicstatements.E2044—pure fnmay not havemutparameters.E2045—pure fnmay not declare mutable locals (varorlet mut).E2046—pure fnmay not perform mutation via assignment.E2047—pure fnmay not allocate (new).E2048—pure fnmay not call impure functions.E2049—pure fnmay not be combined withtaskorasync.E2050— theories are not callable as runtime functions (use#theory Name(...);).E2051— module does not satisfy the declared interface (missing exported function).E2052— module does not satisfy the declared interface (signature mismatch).E2053— unknown re-export name.E2054— duplicate exported name.E2055— prototype implementation is missing required import of its prototype module.E2056— function expression may not have non-scalar&Tparameters.E2057— duplicate type alias name.E2058— type alias cycle.E2059— type alias kind mismatch.E2060— unknownextendsbase.E2061— invalidextendsbase.E2062— cyclicextendschain.E2063— derived struct redeclares an inherited field name.E2064— derived interface redeclares an inherited method name.E2065— opaque structs may not useextends.E2066— prototype and implementation signatures do not match.E2067— capturing closure is not allowed inpurecode.E2068— capturing closure uses a capture type that is not implemented yet.E2069— capturing closure may not capture a mutable binding yet.E2070—yieldrequires ataskcontext.E2071—yieldin value position requires a Task operand.E2072—yield *requires a Task operand.E2073—yieldas a statement requires an enclosing task function.yield <value>;is the send form.- Receiving from a
Task(T)handle is a value-position form:let x = yield h. E2074—await *requires a Promise-array operand.E2075— duplicate type name.E2076— generic type arguments must be fully specified at the use site (missing a required, non-default type argument).E2077— invalidregiondeclaration.E2078—withrequires a region binding.E2079— invalidwith ... fromregion slice.E2080— reserved (previously: indexing a slice cast fromu64required an explicit length).E2081— cast-length suffix requires au64/usizepointer operand and a slice/string target.E2082—const fnmay not betaskorasync.E2083—const fnmay not have a typed-error contract (|in return type).E2084—const fnparameter types must be compile-time value types.E2085—const fnresult type must be a compile-time value type.E2086—const fnmay not allocate (new).E2087—const fnmay not call a non-const fn.E2088—const fnmay not containpanicstatements.E2089— unsupported construct in aconst fnbody (outside the current const-eval subset).E2090—const fnmay be called only from compile-time contexts.E2091— generic function call type arguments could not be inferred at the call site.E2092— use of moved value.E2093—moverequires a local binding name.E2094— slice borrow escapes its lexical scope.E2095— reference borrow escapes its lexical scope.E2096— unknownusingtarget.E2097—usingalias conflicts with an existing name.E2098—usingtarget is ambiguous.E2099—usingcannot importconstructoryet.E2100—usingcannot import methods that require mutableSelfborrows yet.E2101—usingmethod reuse requires compatible struct layouts.E2102— cannot move value while it is borrowed.E2103— invalid regexp flags (unknown or duplicate).E2104— invalid regexp literal (pattern compile failed).E2105— method is private to itsimplblock (not visible from the call site).E2106— interface-required methods may not be declaredprivate.E2107— destructuring requires a non-opaque struct value.E2108— cannot destructure opaque struct.E2109— destructuring pattern does not match the struct type (wrong arity, unknown field, or duplicate binder/field).E2110— array destructuring requires an array/slice value.E2111— array destructuring pattern does not match the array type (wrong arity for fixed arrays, or duplicate binder).E2112— enum destructuring requires an enum value.E2113— enum destructuring pattern does not match the enum type (unknown variant or wrong arity).E2114—u128is not implemented yet in all compiler paths.E2115—f128is not implemented yet in all compiler paths.E2116— invalid inline assembly (inline asm failed to assemble, or uses unsupported features in the current implementation).E2117—let ... else { ... };requires theelseblock to end with a terminal statement.E2118— borrowed-view type may not appear in anasync fnresult.E2119— borrowed-view type may not cross anext/ unnamed C-facingexport fnboundary.E2120— local borrow may not remain live acrossawait.E2121— cannot mutate local storage while it is borrowed.E2122— borrowed control-flow expression is ambiguous.E2123— local borrow may not escape through an async call.E2124— type does not satisfy the declared interface (missing required method).E2125— type does not satisfy the declared interface (signature mismatch).E2126— interface method must omit an explicit receiver parameter; ordinary interface methods already have an implicit receiver and must not spellself: &Selfin the interface declaration.E2127— invalid atomic memory ordering; for example,loadmay not useRelease/AcqRel,storemay not useAcquire/AcqRel, andcompare_exchangefailure ordering may not useRelease/AcqRel.
Formal Silk Verification#
E3001— loop invariant may not hold.E3002— loop variant may be negative.E3003— loop variant may not decrease.E3004— postcondition may not hold.E3005— Formal Silk verification failed to initialize or encountered an unsupported construct. Unsupported verified-code diagnostics should name the exact construct, for example an optional-field, index, nested-field, or compound assignment target in a verified method.E3006— assertion or struct requirement may not hold (#assert, theory assertions, and struct#requirechecks). Struct requirement failures include the rejected predicate plus referenced construction/default field values, and direct verified field writes recheck struct requirements after the write.E3007— call precondition may not hold. Contracted function/method preconditions are checked at ordinary call sites as well as inside explicitly verified code.E3008— loop monovariant may not be monotonic.
Notes:
- When
silk build --debugorsilk test --debugis used, failed Formal Silk checks emit additional Z3 debug output and write an SMT-LIB2 reproduction script under.silk/z3/in the current working directory (or$SILK_WORK_DIR/z3).
Code Generation / Backend Lowering#
E4001— backend/target limitation prevented code generation for the requested program/output.E4002— code generation failed in the backend (unexpected backend error).
Notes:
- This error is reported when a program successfully parses and type-checks, but IR lowering or native code generation cannot yet handle a construct.
- The detail should identify the actual backend-stage blocker:
- rejected statement/expression/function shape,
- missing target/output support,
- or a specific collector/layout/codegen stage failure.
- The diagnostic detail names the rejected construct kind (statement / expression / function / declaration) and its surface form tag when available.
- Executable entrypoint shape failures should name the rejected form directly (for example ``unsupported executable entrypoint form: `async task fn main``` ) and say which executable entrypoint forms are currently supported.
- When executable lowering fails during runtime-support setup before ordinary
function-body lowering begins,
E4001should name the blocked runtime stage directly (for example ``unsupported executable runtime support:debug panic runtime support``` ) instead of falling back to a misleadingunsupported function: main`. - When lowering cannot isolate a narrower statement / expression site,
E4001falls back to the offending function or declaration collector stage and names that function / declaration directly. - For declaration-stage layout collection failures, the note should carry the
collector context and, when available, the rejected field or payload type
shape so users do not have to infer it from a generic carrier such as
Result.
Tooling Integration Notes#
silk-lspshould map the compiler’s primary source span to the LSP diagnostic range directly.silk-lspshould preserve structured compiler guidance in the published LSP payload:- keep the primary
messageshort and stable, - surface compiler
detail,notes, andhelpsas structured diagnostic metadata, - and avoid collapsing all follow-up guidance into one opaque message blob when the protocol surface can carry structured fields.
- When the compiler grows multi-span diagnostics (labels and secondary spans), the LSP implementation must be updated to surface them.
C99 ABI and libsilk.a#
This document defines the C99 ABI and the interface of the libsilk.a static library.
Goals#
- Provide a stable C ABI for embedders.
- Mirror the external-declaration semantics described in
External Declarations (ext). - Keep the ABI small, explicit, and well-documented.
No open-world interface-object ABI#
Silk’s ordinary interface feature is a language-level conformance mechanism.
In the current compiler, runtime interface values are implemented only through
the closed-world compilation strategy documented in
Interfaces:
- the compiler discovers the conformers visible in the current build,
- lowers an interface-typed runtime value to a concrete union of those conformers,
- and rewrites interface method calls into ordinary dispatch over that union.
This document does not define a generic C ABI for arbitrary Silk interface
values. In particular, libsilk.a does not currently promise:
- a public
SilkInterfaceobject, - a stable
(data pointer, vtable pointer)trait-object layout, - or an open-world ABI where separately compiled libraries can exchange unknown future conformers through one stable interface-object representation.
Practical consequence:
- embedders must treat ordinary interface values as an internal compiler lowering choice, not as a stable cross-language interchange format,
- exported and imported ABI surfaces should use concrete structs, enums/unions, scalars, strings, ranges, handles, and other explicitly documented ABI shapes,
- and if an embedding boundary needs dynamic dispatch, that dispatch contract must be designed explicitly in the ABI itself, for example as a concrete function-table struct chosen by the API author.
Library & Headers#
- Static library:
libsilk.a. - Primary header:
include/silk/silk.h. - Legacy compatibility shim:
include/silk.h.
Embedders should prefer #include <silk/silk.h>. The flat include/silk.h
wrapper remains available for compatibility during the transition.
Linking When Static Z3 Is Bundled#
When the host-native vendor/lib/<host-layout>/libz3.a archive is present,
libsilk.a includes built-in Z3 to support Formal Silk verification without requiring a
runtime Z3 dynamic library. The built-in Z3 static library is built as C++,
so downstream embedders linking against libsilk.a MUST also link the system
C++ runtime and any required system libraries:
cc -std=c99 -Wall -Wextra \
-I/path/to/include your_app.c \
-L/path/to/lib -lsilk \
-lstdc++ -lpthread -lm
The silk cc wrapper adds these flags automatically when linking on
linux/x86_64.
If the static host archive is absent, libsilk.a still builds. Formal Silk
verification then requires a dynamic Z3 override via
silk_compiler_set_z3_lib or SILK_Z3_LIB.
The header must define:
- Core bridged types (e.g.
SilkString, and any other structs or enums used by the ABI). - Opaque handle types (
SilkCompiler,SilkModule,SilkError) and their lifetime rules. - Entry points for:
- initializing and shutting down compiler/runtime state,
- configuring compilation (target triple, stdlib name, optimization level),
- adding source buffers,
- compiling Silk source to executables, libraries, or object files,
- interacting with diagnostics and error reporting.
Initial C Header Shape (include/silk/silk.h)#
The initial C header provided in the Silk compiler repository defines:
SilkStringmirroring the internal Silkstringlayout:- Note:
SilkStringis also the C ABI shape for Silkregexpvalues (bytecode-backed{ ptr, len }), but the bytes are opaque and not required to be null-terminated.
typedef struct SilkString {
char *ptr;
int64_t len;
} SilkString;
-
SilkBytesfor owned binary buffers returned by in-memory build APIs:typedef struct SilkBytes { uint8_t *ptr; int64_t len; } SilkBytes; -
SilkRangemirroring the Silkrangeprimitive:typedef struct SilkRange { int64_t start; int64_t end; uint64_t flags; } SilkRange;
Notes:
-
The current
linux/x86_64backend subset passes and returnsrangevalues as three 8-byte scalar slots (start,end,flags). -
flagsis a bitfield: -
bit 0:
has_end(when unset,endis ignored), -
bit 1:
inclusive(only valid whenhas_endis set). -
128-bit scalar primitives (
i128/u128/f128) used by generated C headers for exported Silk interfaces:typedef struct SilkU128 { uint64_t lo; uint64_t hi; } SilkU128; typedef struct SilkI128 { uint64_t lo; int64_t hi; } SilkI128; typedef struct SilkF128 { uint64_t lo; uint64_t hi; } SilkF128;
Notes:
-
SilkF128stores the IEEE‑754 binary128 bit pattern. It is not Clong double. -
These types are passed and returned as two integer-like 8-byte slots in the current
linux/x86_64backend subset. -
Opaque handles:
typedef struct SilkCompiler SilkCompiler; typedef struct SilkModule SilkModule; typedef struct SilkError SilkError; -
An output-kind enum:
typedef enum SilkOutputKind { SILK_OUTPUT_EXECUTABLE = 0, SILK_OUTPUT_STATIC_LIBRARY = 1, SILK_OUTPUT_SHARED_LIBRARY = 2, SILK_OUTPUT_OBJECT = 3, } SilkOutputKind; -
ABI version query:
void silk_abi_get_version(int *out_major, int *out_minor, int *out_patch); -
Compiler lifecycle:
SilkCompiler *silk_compiler_create(void); void silk_compiler_destroy(SilkCompiler *compiler); -
Configuration:
bool silk_compiler_set_stdlib(SilkCompiler *compiler, SilkString stdlib_name); bool silk_compiler_set_std_root(SilkCompiler *compiler, SilkString std_root); bool silk_compiler_set_nostd(SilkCompiler *compiler, bool nostd); bool silk_compiler_set_debug(SilkCompiler *compiler, bool debug); bool silk_compiler_set_noheap(SilkCompiler *compiler, bool noheap); bool silk_compiler_set_target(SilkCompiler *compiler, SilkString target_triple); bool silk_compiler_set_z3_lib(SilkCompiler *compiler, SilkString path); bool silk_compiler_set_std_archive(SilkCompiler *compiler, SilkString path); bool silk_compiler_add_needed_library(SilkCompiler *compiler, SilkString soname); bool silk_compiler_add_runpath(SilkCompiler *compiler, SilkString path); bool silk_compiler_set_soname(SilkCompiler *compiler, SilkString soname); bool silk_compiler_set_optimization_level(SilkCompiler *compiler, int level); bool silk_compiler_set_c_header(SilkCompiler *compiler, SilkString path);
silk_compiler_set_std_root configures the filesystem stdlib root directory used
to auto-load std::... packages when modules contain import std::...;. The
std_root string is copied. When set, it overrides SILK_STD_ROOT and the
working-directory/default search behavior described below.
silk_compiler_set_nostd disables this stdlib auto-loading behavior when set
to true. When nostd is enabled, import std::...; declarations must be
satisfied by explicitly adding the corresponding std sources as modules (for
example via silk_compiler_add_source_buffer); the compiler will not consult
SILK_STD_ROOT or the filesystem std root search paths.
silk_compiler_set_debug enables the same debug build mode as the CLI
(silk --debug): debug-mode lowering for supported native outputs, and
additional Z3 debug output plus .smt2 reproduction scripts on failing Formal
Silk obligations (written under .silk/z3/ or $SILK_WORK_DIR/z3).
silk_compiler_set_noheap enables the same no-heap mode as the CLI
(silk --noheap): heap-backed allocation is disabled for the supported
subset. --noheap is currently incompatible with --debug; the ABI rejects
configurations that enable both.
silk_compiler_set_optimization_level selects the optimization level (0-3),
matching the CLI -O flag. The default is level 0 unless overridden. Level
1+ enables lowering-time pruning of unused
extern symbols before code generation. For IR-backed native executable
builds, it also prunes unreachable functions from the executable entrypoint
(function-level dead-code elimination), typically reducing output size and
over-linking when using the prebuilt libsilk_std.a archive to satisfy
auto-loaded import std::...; modules.
The CLI also exposes silk build --strip-unused to force analogous
reachability-based pruning at -O0 for executable/static/shared outputs; the
current C ABI does not yet expose a separate setter for that flag.
silk_compiler_set_target selects the code generation target. The
target_triple string is copied. The implementation recognizes the
same targets as the CLI (silk build --list-targets), including:
linux-x86_64(default), and commonx86_64-*-linux-gnutriples such asx86_64-linux-gnuandx86_64-unknown-linux-gnu,linux-x86_64-musl, and commonx86_64-*-linux-musltriples such asx86_64-unknown-linux-musl,linux-aarch64,linux-aarch64-musl,android-aarch64,macos-x86_64,macos-aarch64,ios-aarch64,ios-simulator-aarch64,ios-simulator-x86_64,windows-x86_64,windows-aarch64,wasm32-unknown-unknown,wasm32-wasi(and otherwasm32triples containingwasi).
For wasm32 targets, only SILK_OUTPUT_EXECUTABLE is supported. The output
bytes are a final WebAssembly module (.wasm) produced by the IR-backed wasm
backend (the implementation), with a smaller constant-only fallback for
programs that fit the constant subset.
The wasm backend is still early-stage, but it is no longer limited to single-module constant programs:
- Multi-module builds (packages + file imports) are supported.
ext foo = fn (...) -> ...;declarations become imported functions underenv.fooforwasm32-unknown-unknown, analogous toexternsymbols in C.- String and other constant data are emitted into wasm data segments.
Entrypoint conventions:
wasm32-unknown-unknown:- when a valid executable
mainexists, it is exported asmainfor embedder use, - when no
mainexists, an export-only module is emitted that exports each supportedexport fnfrom the root package. wasm32-wasi:- requires
fn main () -> int(themain(argc, argv)form is not supported yet for WASI), - programs that need process arguments must read them from WASI inside
main()(for example viastd::args::{argc,argv,current}), - emits an exported
_start () -> voidwrapper that callsmainand then imports/calls WASIproc_exit, - export-only modules are supported for embedding (export-only modules do
not include
_start).
silk_compiler_add_needed_library records a dynamic loader dependency for
executable and shared library outputs (emitted as DT_NEEDED). The soname
string is copied; the function may be called multiple times (duplicates are
ignored). For static library and object outputs, the value is ignored.
DT_NEEDED entries starting with libsilk_rt are rejected: bundled runtime
helpers are linked statically from libsilk_rt.a / libsilk_rt_noheap.a and
must not become runtime loader dependencies.
On linux/x86_64, when an executable or shared library imports any external
symbols, the compiler automatically adds the selected libc as a DT_NEEDED
dependency (libc.so.6 for glibc, libc.so for musl), so embedders do not
need to manually add libc when using hosted std:: modules like std::io
and std::fs. Additional non-libc dependencies must still be declared via
silk_compiler_add_needed_library.
silk_compiler_add_runpath records a dynamic loader search path element for
executable and shared library outputs (emitted as DT_RUNPATH). The path
string is copied; the function may be called multiple times (duplicates are
ignored) and the final DT_RUNPATH string is formed by joining all entries
with ':'.
silk_compiler_set_soname configures the shared library soname recorded as
DT_SONAME for shared library outputs. The soname string is copied; passing
an empty string clears the configured soname (no DT_SONAME entry). For
executable, static library, and object outputs, the value is ignored.
silk_compiler_set_z3_lib configures a Z3 dynamic library override for Formal
Silk verification (equivalent to the CLI --z3-lib <path>). Passing an empty
string clears the override and returns to the normal Z3 selection rules
(including honoring SILK_Z3_LIB).
silk_compiler_set_std_archive configures a stdlib archive override
(equivalent to the CLI --std-lib <path>). Passing an empty string clears
the override and returns to the normal stdlib archive selection rules
(including honoring SILK_STD_LIB).
silk_compiler_set_c_header configures C header generation for non-executable
outputs (equivalent to the CLI --c-header <path>). The header is written
when silk_compiler_build succeeds for SILK_OUTPUT_OBJECT,
SILK_OUTPUT_STATIC_LIBRARY, or SILK_OUTPUT_SHARED_LIBRARY. C header
generation is not supported for silk_compiler_build_to_bytes.
-
Source management:
SilkModule *silk_compiler_add_source_buffer(SilkCompiler *compiler, SilkString name, SilkString contents); -
Building artifacts:
bool silk_compiler_build(SilkCompiler *compiler, SilkOutputKind kind, SilkString output_path);
For embedders that need filesystem-free compilation (for example sandboxed hosts or WASM-like environments), the ABI also provides an in-memory build API that returns an owned byte buffer:
bool silk_compiler_build_to_bytes(SilkCompiler *compiler,
SilkOutputKind kind,
SilkBytes *out_bytes);
void silk_bytes_free(SilkBytes *bytes);
The returned bytes are target-specific: for example an ELF64 binary on
linux-x86_64, or a .wasm module on wasm32 targets.
Ownership rules:
- On success,
silk_compiler_build_to_bytesfills*out_byteswith a pointer and length describing the produced artifact, and returnstrue. - The returned
out_bytes->ptris owned bylibsilk.aand must be freed by callingsilk_bytes_free(&bytes). Callers must not free the pointer withfree()(or any other allocator). silk_bytes_freeis a no-op when passedNULLor whenbytes->ptrisNULL; it always clears the struct to{ NULL, 0 }.
Note: the compiler may still consult the filesystem to auto-load std::...
modules unless silk_compiler_set_nostd(compiler, true) has been set.
Current Apple host-backed note:
- the CLI / driver now supports non-const
ios-aarch64,ios-simulator-aarch64, andios-simulator-x86_64executable builds on Apple Silicon macOS for the current pure-Silk scalar subset, including reachable float-to-int lowering and portable bundled runtime helper families (number / regex / unicode / filesystem / dns / process / signal / term / pty / readline / task-pool / async), silk_compiler_build(...)andsilk_compiler_build_to_bytes(...)now support that same iOS host-backed subset on Apple Silicon macOS,- the remaining explicit
E4001iOS limitation is narrower: it now applies only to narrower unsupported bundled runtime-internal helper families, while portable bundled helpers, hosted async/task linkage, and float-to-int now link on this path.
At the current stage of implementation:
-
silk_compiler_buildalways performs full front‑end validation for all modules added viasilk_compiler_add_source_buffer: -
it lexes and parses each module into an internal representation,
-
it then type‑checks the set of modules as a unit, taking into account package/import relationships and exported constants, according to the language grammar and semantics documented under this specification,
-
if Formal Silk syntax is present (for example
#require,#assure,#assert,#invariant,#variant,#monovariant,#const), it also runs the Z3-backed verifier and fails the build if verification fails (E3001..E3008), -
the verifier is currently skipped for stdlib modules (
std::...), -
when the host-native archive is present, Z3 is linked from the built-in static archive
vendor/lib/<host-layout>/libz3.a, -
the verifier honors
SILK_Z3_LIB(environment variable) to override the Z3 dynamic library at runtime, -
it fails fast on the first front‑end error.
-
when packages/imports are present:
-
importdeclarations must refer to packages that exist in the current module set (otherwise a resolver error is reported, such as"unknown imported package"), -
exported
letbindings with explicit type annotations in an imported package are treated as ordinary, unqualified names in the importing modules for type‑checking purposes (for example,import util;andexport let answer: int = 42;inutilallowslet x: int = answer;inapp), -
imported exported functions (
export fn) are callable across packages for the current scalar subset (both unqualifiedfoo()and qualifiedpkg::foo()call forms are accepted initially), and functions in the same package share a call namespace across modules in the same module set, -
duplicate exported names within a single package are reported as a resolver error (
"duplicate exported symbol"). -
standard library import resolution (first slice):
-
when a module contains
import std::...;, the compiler will attempt to auto-load the referencedstd::...package modules from a configured stdlib root so embedders do not need to provide std sources explicitly in the common case, -
the stdlib root is selected via:
-
silk_compiler_set_std_rootwhen set, otherwise -
SILK_STD_ROOT(environment variable) when set, otherwise -
a
std/directory in the current working directory (development default), otherwise -
../share/silk/stdrelative to the current executable (installed default). -
package-to-path mapping is deterministic:
-
std::foo::barresolves to the file<std_root>/foo/bar.slk, -
if the embedder explicitly provides a
std::...module viasilk_compiler_add_source_buffer, that module is treated as authoritative for its package (auto-loading does not replace already-provided packages). -
standard library archive linking (
linux/x86_64, current archive layout): -
the toolchain can build a target-specific stdlib static archive (
libsilk_std.a) containing one ELF object per std module (for example viamake stdlib), -
for supported executable builds, the compiler treats auto-loaded
std::...modules as external during code generation and resolves their exported functions from the archive when available (while still type-checking the std sources as part of the module set), -
archive discovery (in order):
-
SILK_STD_LIBwhen set, otherwise -
build/lib/silk/std/libsilk_std.awhen using the in-repostd/root, otherwise -
../lib/silk/std/libsilk_std.arelative to the current executable, otherwise -
../lib/libsilk_std.arelative to the current executable (legacy installed layout), otherwise -
common installed-layout heuristics derived from the selected stdlib root,
-
walk up from the current working directory to find
libsilk_std.a,lib/libsilk_std.a, orlib/silk/std/libsilk_std.a, -
when no suitable archive is found (or on unsupported targets), the compiler falls back to compiling the reachable std sources into the build as part of module-set code generation.
-
When a front‑end error occurs (e.g. parse error, type mismatch, invalid control‑flow such as
break/continue/returnin the wrong context, or other semantic violations), the call returnsfalseandsilk_compiler_last_error/silk_error_formatprovide a human‑readable description (such as"unexpected token while parsing module","type mismatch","invalid break statement","invalid return statement","missing return statement", etc.). -
For executable outputs (
kind == SILK_OUTPUT_EXECUTABLE), the compiler also enforces an entrypoint precondition on the front‑end: -
there MUST be exactly one top‑level function with one of the forms
fn main() -> int { ... } fn main(argc: int, argv: u64) -> int { ... }
with a declared result type of int, and either:
-
no parameters, or
-
exactly two parameters whose types are
intandu64, -
otherwise
silk_compiler_buildfails with an error message such as"no valid main function for executable output"or"multiple main functions for executable output". -
When all modules pass front‑end validation (including the executable entrypoint requirement, where applicable), code generation behavior depends on
kind: -
for non-executable outputs (
SILK_OUTPUT_OBJECT,SILK_OUTPUT_STATIC_LIBRARY,SILK_OUTPUT_SHARED_LIBRARY): -
mainis optional, but when more than one valid executablemainexists in the module set,silk_compiler_buildfails with"multiple main functions for non-executable output", -
when multiple packages are present in the module set, only exports from the root package (the package of the first module added to the compiler via
silk_compiler_add_source_buffer) are emitted as globally-visible symbols for that output; other packages are compiled as dependencies and theirexportdeclarations are treated as internal for that output. -
within the current
linux/x86_64IR subset,stringandregexpvalues are supported at ABI boundaries in a C-friendlySilkString { ptr, len }layout: -
string/regexpparameters lower to two integer-like scalars in order (u64pointer, theni64byte length) and consume the normal integer argument locations (registers then stack), -
string/regexpresults return as two integer-like scalars inrax/rdx, -
regexpvalues remain opaque runtime-engine bytecode views: downstream C code may forward them, but must not construct them as if the byte layout were a stable public format, -
regex literals and other borrowed
regexpviews are not caller-owned heap objects; onlystd::regex::RegExp.compile(...)produces runtime-owned regex bytecode, -
when
std::regexexecutes a foreign ABI-suppliedregexp, the bundled runtime first validates the bytecode header/control-flow shape and reports malformed inputs asEXEC_ERR_INVALID_INPUTinstead of entering the engine blindly, -
when Silk code later frees or drops a
regexpthrough the regex runtime, only those runtime-owned compiled values are released; borrowed/literal/foreign views are ignored safely, -
the bundled runtime allocator override used by runtime regex compilation is process-global but internally synchronized; concurrent
silk_rt_set_allocator(...)calls can affect which hook future runtime allocations use, but any individual allocation returned bysilk_rt_malloc_bytes(...)keeps the realloc/free hooks that created it for its full lifetime, and foreign, forged, stale pre-realloc, or already-freed helper pointers that do not correspond to a live bundled-runtime allocation are ignored instead of steering helper realloc/free calls, -
within function bodies, the compiler supports a small
string/regexpexpression subset: -
string: string literals,letbindings ofstring,returnof astringvalue, direct calls tostring-returning helpers, and==/!=/</<=/>/>=comparisons overstringvalues (producingbool), -
regexp: regex literals (/pattern/flags),letbindings ofregexp,returnof aregexpvalue, and direct calls between helpers that accept/returnregexp, -
other string operations (concatenation, indexing, etc.) are not implemented yet; higher-level regex matching lives in
std::regexand is routed throughextcalls. -
within the current
linux/x86_64IR subset,i128/u128/f128values are supported at ABI boundaries using the stable C99{ lo, hi }struct shapes: -
parameters lower to two integer-like scalars (
u64 lo, thenu64/i64 hi) and consume integer argument locations, -
results return as two integer-like scalars in
rax/rdx, -
f128values are transported as raw IEEE binary128 bits in the two lanes (not via SSE registers). -
within the current
linux/x86_64IR subset, a limitedstructsubset is supported at ABI boundaries: -
within function bodies and internal helper calls,
structdeclarations with 0+ fields of supported value types are supported (scalar primitives,string, nested structs, and supported optionals), -
at ABI boundaries for exported/FFI functions, only ABI-safe structs are currently supported: after slot-flattening, all scalar slots must be
i64/u64/f64(until packed ABI mapping for smaller fields is implemented), -
ordinary borrowed references/slices are rejected up front on
extdeclarations and unnamed C-facing root-packageexport fnsignatures; only opaque handle references (&HandlewhereHandleisstruct Name;) may cross the external ABI boundary, -
named-package Silk object exports may accept slice parameters (
T[]) in the compiler-owned package ABI; these lower to two integer-like scalars (u64pointer, theni64element count) and are not emitted through C header generation, -
at the C ABI surface, exported function parameters support 1+ slot ABI-safe structs by lowering the struct to its scalar slots in order; downstream C callers should declare separate parameters for 3+ slot structs (by-value C struct parameters are ABI-compatible only for the 1–2 slot cases), while exported function returns support 1+ slot ABI-safe structs (3+ slot returns use the native backend’s sret return path and are ABI-compatible with returning an equivalent C struct by value),
-
in all cases, the compiler lowers a struct value into N scalar slots in field order and assigns argument/result locations according to System V AMD64 integer/SSE classification for those slots.
-
within the current
linux/x86_64IR subset, optionals (T?) are supported at ABI boundaries for the supported payload subset (scalar payloads,string?, and optionals of ABI-safe structs): -
an optional lowers to a
Booltag followed by the payload scalar slots:(tag, payload0, payload1, ...)withtag=0forNoneandtag=1forSome(...), -
nested optionals (
T??) lower by treating the payload slots as the full inner optional representation (for exampleint??lowers as(tag0, tag1, i64 payload)), -
optional parameters are passed as these scalar slots in order (so downstream C callers should declare separate parameters, treating
tagas an integer-like 0/1 value), -
optional results return as the same scalar slots (1–2 slots in registers; 3+ slots via a hidden sret pointer as described above).
-
for object outputs (
SILK_OUTPUT_OBJECT): -
on
linux/x86_64, the compiler can emit an ELF64 relocatable object (ET_REL) for the supported IR subset, emitting supported functions (scalar-returning,void-returning, and a limitedstringsubset) and supported exported constants (export let/export const; scalar exports require an explicit type annotation and a literal initializer, and string exports may omit: stringwhen the initializer is a string literal), and markingexport fndeclarations, supported exported constants, and a valid executablemain(when present) as global symbols, -
when the module set contains no supported globally-visible symbols (no supported
export fn, no supportedexport letconstants, and no valid executablemain),silk_compiler_buildstill succeeds and writes a valid relocatable object with no globally-visible symbols, -
for programs outside that subset (or on unsupported targets),
silk_compiler_buildreturnsfalsewith anE4001/E4002formatted diagnostic (viasilk_compiler_last_error/silk_error_format) and does not write an output file. -
when lowering cannot isolate a narrower statement / expression span, that
E4001diagnostic falls back to the offending function declaration and names that function directly. -
for static library outputs (
SILK_OUTPUT_STATIC_LIBRARY): -
on
linux/x86_64, the compiler can emit a static library archive (.a) containing an object file for the supported IR subset, emitting supported functions (scalar-returning,void-returning, and a limitedstringsubset) and supported exported constants (export let/export const; scalar exports require an explicit type annotation and a literal initializer, and string exports may omit: stringwhen the initializer is a string literal), and markingexport fndeclarations, supported exported constants, and a valid executablemain(when present) as global symbols, -
when the module set contains no supported globally-visible symbols (no supported
export fn, no supportedexport letconstants, and no valid executablemain),silk_compiler_buildstill succeeds and writes a valid archive containing an object file with no globally-visible symbols, -
for programs outside that subset (or on unsupported targets),
silk_compiler_buildreturnsfalsewith anE4001/E4002formatted diagnostic (viasilk_compiler_last_error/silk_error_format) and does not write an output file. -
for shared library outputs (
SILK_OUTPUT_SHARED_LIBRARY): -
on
linux/x86_64, the compiler can emit an ELF64 shared library (ET_DYN, typically with a.sofilename) for the supported IR subset, emitting supported functions (scalar-returning,void-returning, and a limitedstringsubset) and supported exported constants (export let/export const; scalar exports require an explicit type annotation and a literal initializer, and string exports may omit: stringwhen the initializer is a string literal), and markingexport fndeclarations, supported exported constants, and a valid executablemain(when present) as dynamic global symbols, -
when the module set contains no supported globally-visible symbols (no supported
export fn, no supportedexport letconstants, and no valid executablemain),silk_compiler_buildstill succeeds and writes a valid shared library with an empty export set, -
for programs outside that subset (or on unsupported targets),
silk_compiler_buildreturnsfalsewith anE4001/E4002formatted diagnostic (viasilk_compiler_last_error/silk_error_format) and does not write an output file. -
for executable outputs (
SILK_OUTPUT_EXECUTABLE): -
the implementation supports a minimal constant‑expression backend:
-
the program must satisfy the entrypoint rule above,
-
the body of
mainmust be one of the following shapes: -
zero or more
letstatements whose initializers are constant integer expressions, followed by exactly onereturnstatement that returns a constant integer expression built only from: -
integer literals,
-
the arithmetic operators
+,-,*,/, and%, -
and references to immutable
letbindings (top‑level or local tomain, or imported exported scalar constants from imported packages) whose initializers are themselves constant integer expressions in this same sense (no side‑effecting operations); imported exported constants must be declared asexport letorexport constwith the shapeexport <binding> name: <scalar> = <literal>;(explicit scalar type and literal initializer), -
on
linux/x86_64, direct calls to simple helper functions of the form```silk fn helper (x, y) -> int { [let ...;] return <expr>; } ```
where:
-
parameters may be annotated as scalar types (defaulting to
intwhen unannotated), -
arguments at each call site are drawn from the same scalar expression subset as
<expr>(includingbool,char,Instant,Duration, fixed-width integers, andf32/f64onlinux/x86_64), with optionals (T?) supported for scalar payloads,string?, and optionals of the PODstructsubset viaNone/Some(...)and??coalescing, and -
in module-set builds, helper calls may target:
-
functions defined in the same package (across multiple modules), and
-
imported exported functions (
export fn) from any packages imported by the module that containsmain(bothfoo()andpkg::foo()call forms are accepted initially for imported exports), -
the helper body either:
-
consists only of scalar
letbindings and a finalreturn, or -
ends in a simple
if/elseof the form:```silk if <cond> { [let ...;] return <expr>; } else { [let ...;] return <expr>; } ```
where <cond> is a boolean expression built from comparisons
over scalar expressions and boolean literals, and both
branches end in return;
such calls are lowered to IR Call instructions and compiled
to native code together with main, using the System V AMD64
scalar calling convention on linux/x86_64 (integer-like
scalars in rdi..r9, f32/f64 in xmm0..xmm7, with
additional arguments spilled to the stack); helpers may have
more than six integer parameters, and this path is exercised
in both Zig tests and C tests (see the C ABI test harness), or
-
a final
ifstatement whose condition is a boolean expression: -
for the purely constant subset, the condition is a compile‑time boolean literal (
trueorfalse) and each branch body itself satisfies the same “constant lets +returnconstant integer expression” rule, and -
on
linux/x86_64, a slightly richer branchingmainshape is also supported in which the body is exactly:```silk fn main () -> int { if <cond> { [let ...;] return <expr>; } else { [let ...;] return <expr>; } } ```
where <cond> is built from integer comparisons (==, !=,
<, <=, >, >=) over integer expressions from the same
constant subset; this shape is lowered to IR using BrCond and
compiled to native code by the IR→ELF backend so that the
condition is evaluated at runtime, or
-
one or more trivial constant
whileloops that appear before the finalreturn, each of which has: -
a condition that is a compile‑time boolean literal (
trueorfalse), -
for
while false { ... }, a body that is ignored by the constant backend, and -
for
while true { ... }, a body consisting of zero or more constantletstatements followed by abreak;, with no other control‑flow; loop invariants (#invariant) and variants (#variant) may be present but are treated as metadata and do not affect constant evaluation, -
examples of supported forms include:
```silk fn main() -> int { return 0; } fn main() -> int { return 1; } fn main() -> int { return 1 + 2 * 3; } let answer: int = 21 * 2; fn main() -> int { return answer; } // Two-module imported constant example (module-set builds only): // // util.slk package util; export let answer: int = 42; // // app.slk package app; import util; fn main () -> int { return answer; } // Two-module imported function example (module-set builds only): // // util.slk package util; export fn add (x: int, y: int) -> int { return x + y; } // // app.slk package app; import util; fn main () -> int { return add(40, 2); } fn main () -> int { let a: int = 21; let b: int = a * 2; return b; } fn main () -> int { if true { return 0; } else { return 1; } } fn main () -> int { while true { break; } return 0; } ``` -
when these conditions hold and
output_pathnames a valid path,silk_compiler_build: -
evaluates the constant integer expression in the body of
main, -
emits a tiny native executable image directly using a Silk‑owned backend (no C stub, no external C compiler),
-
currently this backend writes a minimal target-specific executable that terminates the process with the evaluated
mainvalue: -
ELF64 for
linux-x86_64,linux-x86_64-musl,linux-aarch64,linux-aarch64-musl, andandroid-aarch64, -
Mach-O 64-bit for
macos-x86_64,macos-aarch64,ios-aarch64,ios-simulator-aarch64, andios-simulator-x86_64, -
PE32+ for
windows-x86_64andwindows-aarch64, -
returns
trueon success with no last error recorded. -
when the program is front‑end valid but outside this subset (e.g.
maincontains non‑constant expressions, references to non‑constant values, or calls that fall outside the simple helper‑call subset described above), or when the backend cannot produce an executable for the current platform or output path, the call returnsfalseand records either anE4001/E4002diagnostic (for unsupported constructs or backend failures) or a descriptive string for I/O/argument errors as the last error. -
Error reporting:
SilkError *silk_compiler_last_error(SilkCompiler *compiler); size_t silk_error_format(const SilkError *error, char *buffer, size_t buffer_len); -
silk_error_formatreturns a human-readable diagnostic message. When the compiler can associate the error with a source span, the formatted message includes the module name/path plus line/column and a caret snippet. -
The text format and initial stable error code set are specified in
Compiler Diagnostics. Embedders should treat the formatted message as user-facing text (not a stable machine-readable protocol).
Ownership, lifetime, and thread-safety guarantees for these APIs must be clearly documented and kept in sync with the implementation.
ABI rules:
- All exposed functions must be C99-compatible.
- Data layouts must be stable and match the Silk side.
- Ownership and lifetime of any pointers passed across the boundary must be explicitly documented.
In addition, the embedding ABI must clearly distinguish:
- functions that consume Silk‑owned values (e.g.
SilkStringwhose storage is owned by the runtime) versus - functions that take ownership of data supplied by the embedder (and are responsible for freeing it via documented APIs).
Any deviation from the mappings documented in External Declarations (ext) must be justified here and reflected in tests.
See Also#
libsilk(7)— C99 ABI manpage for embedders.silk/silk.h— canonical public C header shipped with the library.