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/var bindings and compile-time const bindings must have an initializer (Compiler Diagnostics, E2015).
  • Destructuring let bindings 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 let bindings are supported:
  • let <pattern> = <expr> else { ... }; (the else block must be terminal)
  • const initializers must be compile-time evaluable (Compiler Diagnostics, E2041); in the Supported forms this is restricted to scalar expressions and calls to const fn functions (still no / or %), plus string literals / const string aliases.
  • Monomorphized generics are supported for struct/interface/impl and applied types (Name(args...)):
  • const parameters/arguments and generic functions are still rejected (E2016),
  • A small concurrency subset is implemented (Task(T) / Promise(T) plus yield/await; see Concurrency).
  • The builtin map(K, V) type form is removed; use std::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 pure code 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 for Option(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 void shorthand: fn (x: int, y: int) { ... } (implicit void)
  • 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-like void).
  • 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; supports else if let / else let chains and chained && let [mut])
  • let v = if cond { a } else { b }; (if expression)
  • loop { ... } (infinite loop; exits via break/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; or assert(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 main function takes no parameters and returns int. The front-end enforces this shape for executable builds before code generation.

Optionals & Mutability#

  • Declare optionals: let x: T? = None; or let 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:
  • new uses the active region inside with (see Regions).

Concurrency#

  • Function modifiers:

  • fn — normal.

  • async fn — await-able; calling yields Promise(T).

  • task fn — runs in parallel on a worker thread; calling yields Task(T).

  • async task fn — async + task; calling yields Promise(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 string is internally { ptr, len },

  • C side uses SilkString { char *ptr; int64_t len; } for embedding,

  • ext calls to typical C APIs may pass const char * derived from string where 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:

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 main returning int (see ``silk CLI for 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):

  1. Optional package ...; or module ...;
  2. Zero or more import ...; declarations as a contiguous block
  3. 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 (plus pure/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:

  • const initializers must be compile-time evaluable in the Supported forms (see E2041).

  • Only let mut/var bindings are assignable lvalues (see Mutability and Operators).

  • Destructuring let bindings 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:

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 (see Regular Expression Literals).
  • Disambiguation is context-sensitive (like JavaScript): the parser only recognizes a regex literal in expression-start positions where a Primary expression 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 FnBody
  • FnBody ::= Block | ';'
  • FnModifierOpt ::= FnModifier*
  • FnModifier ::= 'const' | 'pure' | 'task' | 'async'
  • FnGenericParamListOpt ::= GenericParamList
  • FnNameOpt ::= NameToken
  • FnSpecs ::= (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 GenericParam syntax (T and N: int) and does not permit mut.

  • FnNameOpt is currently permitted only for default-exported functions (export default fn (...) { ... }). For non-default functions, the fn identifier 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 Param syntax (mut x: T or x: 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 type T (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 remains T? (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 example fn 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 from string 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 .slk is 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.b matches "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 unquoted PackagePath branch of ImportFrom.

  • 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 ::= TypeDeclKind
  • TypeDeclKind ::= '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, or
  • let 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 ::= GenericParamList
  • GenericParamList ::= '(' GenericParamListInnerOpt ')'
  • GenericParamListInnerOpt ::= GenericParamListInner
  • GenericParamListInner ::= GenericParam (',' GenericParam)* ','?
  • GenericParam ::= Identifier (':' Type)? ('=' Type)?
  • StructFieldListOpt ::= StructFieldList
  • StructFieldList ::= StructField (',' StructField)* ','?
  • StructField ::= Identifier ':' Type StructFieldDefaultOpt
  • StructFieldDefaultOpt ::= ('=' Expr)

Notes:

  • Only #require directives may appear in StructSpecs in the current language subset (#assure / #theory are rejected on struct).

Enum declarations are part of the core language design. They are specified in ``enum Types.

  • EnumDecl ::= ExportModifier 'enum' Identifier GenericParamListOpt '{' EnumVariantListOpt '}'
  • EnumVariantListOpt ::= EnumVariantList
  • EnumVariantList ::= EnumVariant (',' EnumVariant)* ','?
  • EnumVariant ::= Identifier EnumVariantPayloadOpt
  • EnumVariantPayloadOpt ::= ('(' 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 an impl block, export is reserved for static members with no self receiver; instance method visibility uses public/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 as attr(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:

  • match as an expression (arms are expressions; see MatchExpr below),
  • match as a statement (arms are blocks), used for typed errors as specified in Typed Errors (error, panic, and T | 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 ::= Assign
  • ExprNoRange ::= AssignNoRange
  • Assign ::= Range (AssignOp Assign)?
  • AssignNoRange ::= Coalesce (AssignOp AssignNoRange)?
  • Range ::= Coalesce (RangeOp CoalesceOpt)? | RangeOp CoalesceOpt
  • CoalesceOpt ::= Coalesce
  • AssignOp ::= '=' | '+=' | '-=' | '*=' | '/='
  • Coalesce ::= LogicalOr ('??' CoalesceRhs)?
  • CoalesceRhs ::= Coalesce | CoalesceTerminal
  • CoalesceTerminal ::= '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 | Postfix
  • Postfix ::= Primary PostfixSuffix*
  • PostfixSuffix ::= CallSuffix | FieldSuffix | OptionalFieldSuffix | StructLiteralSuffix | IndexSuffix | SliceSuffix | CastSuffix | TrySuffix | IncDecSuffix
  • CallSuffix ::= '(' 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 declared mut and 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 to move <name> where <name> is a local binding.

  • Binding declarations may also use let move / var move to request the same ownership transfer at initialization. The mut modifier may appear before or after move, including let mut move value = source; and the redundant-but-accepted var 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 { ... }, and while 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 &Struct type context (for example let 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{ ... }; or let x = new Frame(...);) new is supported only in function bodies (not in top-level let initializers).

  • 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 '/' RegexpFlagsOpt
  • RegexpFlagsOpt ::= Identifier

Notes:

  • RegexpBody is 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 Block is treated as the block-body form only when the return type is followed immediately by { (starting the block).
  • Otherwise, fn (...) -> Expr is treated as an expression-body function expression and its result type is inferred by the checker.
  • fn (...) Block is treated as the block-body form with an implicit void result type (shorthand for fn (...) -> void Block).

Notes:

  • InferredStructLiteral has the same token-level shape as StructLiteralSuffix (used for Type{ ... }), but appears as a Primary expression 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 InferredStructLiteral when the { ... } contents look like a struct initializer list (or are {}): either the first token after { is } or it is an Identifier followed 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 }] and call(Type { field: value }) remain StructLiteralSuffix expressions 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:

  • Primary constructs Literal or Name expressions (or a parenthesized Expr),
  • unary expressions are represented as UnaryExpr with a token kind indicating the operator,
  • binary expressions are represented as BinaryExpr with a token kind indicating the operator,
  • identifiers and qualified names are stored as NameExpr with the full slice of source text (e.g. util::answer),
  • simple function calls such as helper() or util::helper(1, 2) are parsed as call expressions using the Postfix/CallSuffix productions; 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 ``silk CLI for 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 struct types, optionals (T?), &Struct references (in function parameter types and as local values produced by new / 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 iterable for loops). 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 struct and interface declarations 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 string ABI (__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), and
  • sizeof(s) (extract the string byte length as usize), and
  • p as raw u64 (extract the underlying address for p: &T), and
  • ptr as string(len) (construct a string view 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 to T? 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; use std::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).
  • f128 uses the IEEE‑754 binary128 bit pattern stored across those slots.
  • In the current backend implementation, f128 arithmetic and some as casts lower to bundled runtime helper calls:
  • on linux/x86_64, the helpers use __float128 and rely on libgcc symbols (for example __addtf3),
  • on targets where long double is an IEEE‑754 binary128 value (for example linux/aarch64), the helpers use long double,
  • on other targets, the helper calls are currently stubbed and will trap if executed.
  • Diagnostics rule: u128 / f128 are language features, so any compiler rejection is an implementation gap in a specific compiler path, not a language-contract rejection.
  • Typed errors (error, panic, and T | ErrorType...) are specified in Typed Errors (error, panic, and T | 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 in Type 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: isize is accepted as an alias for size.
  • 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 to string. Regex literals compile at compile time; runtime compilation and matching helpers live in std::regex (see std::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 / none is the canonical empty value; null may also be used when an optional type is expected and coerces to None. Use match, ?., ??.
  • None (value):
  • Examples: None / none (represented as None in code samples).
  • Notes: the distinguished empty value; typed as T?. The null literal is a distinct literal that can coerce to None when an optional type is expected (see Optional).
  • Reference (borrow): &T
  • Examples: &User.
  • Notes: reference type; in the Supported forms, &Struct may appear in parameter types and as local values when produced by new or by calls that return &Struct. Mutability follows the mut borrow contract and per-call aliasing rules described in Mutability.
  • 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, N may 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 supported regexp, supported non-opaque structs, and enums). See Structs, Impl Blocks, and Memory Layout for the current scalar-slot memory model. In the Supported forms, fixed array lengths are limited to N <= 4096. Indexing xs[i] traps when i is 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 are int values; in range literals, bound expressions must have type int (integer literals may be coerced to int). Range literal expressions support:
  • start..end (end-exclusive) and start..=end (end-inclusive),
  • start.. (open-ended),
  • ..end (implicit start 0),
  • .. (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. A range value 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 (see Concurrency); 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 { ... } then Name(...).
  • 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 to std::result::Result(int, string).
  • Type aliases may be used anywhere a type is expected (parameter/result types, local annotations, struct fields, as casts, etc.).
  • Cycles in type aliases are rejected (E2058).

Kind tags:

  • A type declaration may optionally specify a kind tag, for example type struct Name = Foo; or type pure fn Name = fn(...) -> ...;.
  • When present, the compiler MUST validate that the resolved alias target matches the declared kind (E2059).

Import/export:

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:

  1. Coercion to a nominal struct value T (by-value parameters and varargs elements) via exported static ctor-like methods.
  2. Coercion to a borrowed reference &T (read-only &T parameters) via a constructor method that initializes a compiler-generated stack temporary.
  3. 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 struct type T, the compiler may rewrite the argument to a call of an exported, static ctor-like method on T.
  • This also applies to varargs elements (...args: T).

Supported ctor-like method names (destination type opts in by defining these):

  • T.int(value: int) -> T
  • T.i128(value: i128) -> T
  • T.u64(value: u64) -> T
  • T.u128(value: u128) -> T
  • T.f64(value: f64) -> T
  • T.f128(value: f128) -> T
  • T.bool(value: bool) -> T
  • T.char(value: char) -> T
  • T.string(value: string) -> T
  • T.regexp(value: regexp) -> T
  • T.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 → bool
  • char → char
  • string → string
  • regexp → regexp
  • Region → 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 f32 and the selected ctor parameter type is f64, 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::Arg and ergonomic std::io::print/println calls without requiring explicit Arg.* 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 &T to a nominal struct type T, the compiler may create an implicit stack temporary tmp: T, initialize it by invoking tmp.constructor(...), and pass &tmp to 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& parameters).

Eligibility requirements (Supported forms):

  • The parameter must be &T (not mut &T).
  • The destination type T must provide a visible constructor overload 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 U parameter 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 a string argument by first constructing a temporary Self from string). 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 is std::strings::String, the compiler may accept that expression as satisfying the expected string.

Supported contexts include:

  • let x: string = expr
  • x = expr when x: 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 borrowed string contexts.
  • Returning a borrowed string view derived from a local owned String remains 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 owned std::strings::String instead.

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:

  • as is 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 ::= TypeArgList
  • TypeArgList ::= TypeArg (',' TypeArg)* ','?
  • TypeArg ::= Type | IntLiteral

Examples:

Foo(int, 1024)
Mutex(Account)
Result(int, string)

Notes:

  • A TypeArg may 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:

  • &Struct is supported in function parameter types and as local values when produced by heap allocation (new) or by calls that return &Struct.
  • &T where T is 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 &Struct references may also be created from stack values:
  • via the borrow operator &expr on borrowable lvalues, and
  • via implicit borrow coercions in contexts that expect &T (for example let 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 mut contract and conservative per-call aliasing rules; see Mutability.

Function Types and Closures#

Silk currently:

  • Parses function types in type positions (most notably for ext declarations).
  • 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 void shorthand: fn (x: int, y: int) { ... } (implicit void)
  • Function expressions may declare &T parameters only when T is a single-slot scalar primitive (for example &int / &bool).
  • Function expression bodies are checked under the pure rules in the current subset. Non-capturing function expressions are inferred as pure function types and are permitted in pure code:
  • they may call only pure functions,
  • 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 impl methods. When inferred, these functions/methods are treated as pure for call checking and are callable from pure code.
  • 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 pure code is rejected (capture environments allocate), but closure values are still checked under the pure rules and remain callable from pure code 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 in Memory 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 (...) -> R function values (which may carry a closure environment), and
  • c_fn (...) -> R C 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_fn value may be formed only from:
  • a top-level function name, or
  • a non-capturing fn (...) -> ... expression.
  • Capturing closures are rejected when a c_fn is required.
  • c_fn values are ABI-lowered as a single u64 code 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:

  1. Types (primitive types like int, f64, bool, string),
  2. this overview (what literal categories exist),
  3. 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 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 adopt f32/f64/f128 from context.
  • Duration literal tokens of the form <number><unit> (no whitespace) such as 500ms and 1.5s (specified in Duration Literals).
  • Lowering note (current IR backend subset): unannotated local let bindings participate only in the integer subset. Prefer explicit type annotations for bool and 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 or as casts.

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 / 0B followed by binary digits ([01]+),
  • octal prefix: 0o / 0O followed by octal digits ([0-7]+),
  • hex prefix: 0x / 0X followed by hex digits ([0-9a-fA-F]+),
  • legacy octal: 0[0-7]+ (for example 017).
  • 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.0 is a float literal.
  • 1. is not a float literal in the current lexer.
  • .5 is not a float literal; write 0.5.
  • Numeric literals must start with a digit in the current lexer.
  • The - sign is not part of the literal token:
  • -1 parses as unary - applied to the integer literal 1.
  • -1.5 parses as unary - applied to the float literal 1.5.
  • A numeric token immediately followed by a duration unit suffix (e.g. 1s, 500ms, 1.5s) is a single Duration literal 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:
  • 3in is a lexical error (write 3 in or 3 * in as intended),
  • 0b102 is a lexical error (invalid binary digit),
  • 08 is a lexical error in Silk because multi-digit literals starting with 0 are legacy octal (use 0o10 for octal 8, or write 8 for 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 (f32 or f64), 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.5f32 are not supported. Use type annotations (let x: u8 = 42;) or casts (42 as u8).
  • Using exponent notation: 1e6 is not supported yet.
  • Writing incomplete floats: write 1.0 (not 1.) and 0.5 (not .5).
  • Mixing integers and floats implicitly: use as casts (Operators) to convert explicitly when you need to combine integer and float values.
  • Duration Literals (duration literals like 5ms, 1.5s)
  • Duration` & `Instant (time types and operators)
  • Operators (unary -, arithmetic, and as casts)
  • 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\n and \r in the literal source are normalized to \n,
  • \r escapes are normalized to \n.
  • Equality and ordering comparisons (==, !=, <, <=, >, >=) over string values 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 and libsilk.a``.

Semantics#

  • The value of a string literal is a sequence of bytes.
  • By convention and by intent, string values 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 \0 byte; 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: \n is two bytes ('\' and 'n').
  • They still normalize embedded \r\n / \r in 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 \n escape runs.
  • Use quoted multiline strings when the literal also needs escape processing.
  • Use \n escapes for single newline bytes, compact generated fragments, escape-focused tests, or formats that require a literal backslash followed by n.

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 string values 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\n to \n
  • \r to \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 .slk source file, validates the file as UTF-8, and produces a string. This is equivalent to #embed("relative/path.txt", "utf8").
  • #embed("path", "utf8") validates the file as UTF-8 and produces a string containing those bytes.
  • #embed("path", "utf16") decodes UTF-16LE/UTF-16BE input, using a BOM when present, and produces a UTF-8 string.
  • #embed("path", "u8"), #embed("path", "u16"), and #embed("path", "u32") produce compiler-owned array values for array-typed bindings such as let 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[], or u32[]) 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 \0 explicitly when you need it, and prefer APIs that are length-aware.
  • Using \xNN for non-ASCII characters: \xNN inserts 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.
  • Types (primitive string and char)
  • Character Literals (shared escape spellings)
  • C99 ABI and libsilk.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 change std::regex::matches semantics)
  • i — ignore case
  • m — multiline
  • s — dotAll
  • y — sticky
  • d — indices (recorded; not surfaced by std::regex helpers)

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; regexp values are opaque and must be consumed via std::regex.
  • A literal regexp is borrowed data, not a heap-owned regex object: std::regex::RegExp.compile(...) is the owning/runtime-allocated path, while wrapping a literal in std::regex::RegExp does not transfer ownership.
  • When a foreign ABI caller supplies a malformed regexp buffer to std::regex, the runtime rejects it as invalid input before entering the bundled engine.
  • If a literal/borrowed/foreign regexp is 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 as E2104 (invalid regexp literal).

Examples#

Basic test#

import std::regex;

fn main () -> int {
  if std::regex::matches(/hello/, "hello world") {
    return 0;
  }
  return 1;
}

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 (==, !=) over char values.
  • char values are lowered as a u32 scalar 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 range 0xD800..=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 a string, not a char. 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 \xNN between char and string:
  • for char, \xNN denotes a code point value,
  • for string, \xNN denotes a raw byte (String Literals).
  • Types (primitive char and string)
  • String Literals (string literals and escape sequences)
  • Operators (as casts for int-like types, including char)

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] where N is the number of elements and T is 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 exactly N elements.
  • When an expected type is present and it is T[], the literal’s elements are type-checked against T and the resulting value has type T[].
  • 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 Types and Structs, 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), or
  • field_name (shorthand initializer, equivalent to field_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 &T from { ... }; use new for 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:

  1. All struct slots are zero-initialized.
  2. Field default expressions are evaluated for omitted fields (if present).
  3. 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 public by default; an explicitly private constructor 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:

  • true
  • false

They have type bool (Types).

Notes#

What works end-to-end today (lexer → parser → checker → lowering → codegen):

  • true / false literal tokens.
  • bool variables, parameters, and return values.
  • if / while conditions must have type bool.
  • 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: bool is a distinct type. Use comparisons to produce a bool (for example x != 0) rather than writing if x { ... }.
  • Forgetting short-circuiting: && and || may skip evaluating the right operand; do not rely on side effects in the skipped operand.
  • Types (the bool type)
  • Operators (!, &&, ||)
  • if` / `else (if statement semantics)
  • ``while Loop (while statement 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:
  • 10ns
  • 250us
  • 5ms
  • 1s
  • 1.5s
  • 2min
  • 1h

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 — nanoseconds
  • us — microseconds
  • ms — milliseconds
  • s — seconds
  • min — minutes
  • h — hours
  • d — 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 Duration type 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 / else
  • loop loops
  • while loops
  • for loops
  • match expressions
  • return
  • break
  • continue
  • 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 / else as statement forms (if` / `else)
  • loop loops (``loop Loop)
  • while loops (``while Loop)
  • for loops (ranges, builtin arrays/slices, and C-style for (init; cond; step); ``for Loop)
  • break / continue inside loops (break, continue)
  • return statements, including “all paths must return” checking for non-void functions (return)
  • match as an expression for optionals and enums (``match Expression (and Statement))
  • match as a statement for typed errors (Typed Errors (error, panic, and T | 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):

  • if as a value-producing expression form

When in doubt, consult:

Principles#

These rules help keep control flow explicit and statically checkable:

  • Conditions are boolean: if and while require a bool condition (no integer “truthiness”).
  • Bodies are blocks: flow constructs use { ... } blocks as their bodies.
  • Statements are terminated: most statement forms end with ; (for example let, 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#

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) binds v) are in scope only in the then block.
  • if let mut marks binders introduced by the pattern as mutable in that scope, so they may be reassigned like ordinary let mut locals.
  • else is optional (when omitted, a non-matching scrutinee executes no block).
  • else if let ... chains are supported and parse as nesting in the same way as else if ....
  • else let ... is supported as shorthand for else if let ...; else let mut has the same binder mutability as else if let mut.
  • if let move ..., else if let move ..., and else let move ... consume the scrutinee for ownership-tracked values. The consumed source binding is not available in the then block, the else block, or after the if.

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 the then block.
  • A let move <pattern> = <expr> clause consumes the clause scrutinee for ownership-tracked values. The moved source binding is unavailable in later clauses, the then block, and the else block.
  • A non-let clause must have type bool; false short-circuits.
  • Binders introduced by let clauses are in scope for:
  • subsequent clauses in the chain, and
  • the then block. They are not in scope in the else block, and they do not escape the if.

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. else may 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:

  • if expressions require an else branch 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 of if expressions contain a single expression (not a full statement block).

Semantics#

  • The condition expression is evaluated exactly once.
  • If the condition is true, the if block executes and the else block (if present) does not execute.
  • If the condition is false, the else block executes if present; otherwise the if statement does nothing.

Blocks create scopes:

  • Declarations inside the if body are not visible outside that body.
  • Declarations inside the else body 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 then and else branches 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> { ... } and if <expr> { ... } else { ... } statement forms.
  • if let <pattern> = <expr> { ... } statement form:
  • else if let / else let chains, and
  • && let-chains in the if let condition.
  • Boolean type-checking for conditions.
  • if expressions of the form if <cond> { <expr> } else { <expr> }.

Not implemented yet:

  • General block expressions ({ stmt* <expr> }) outside the specific if expression 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) binds v) are in scope only in the loop body.
  • while let mut marks 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 (see if` / `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 &&.
  • let clause binders are in scope for subsequent clauses and for the loop body, but they do not escape the loop.
  • let mut clauses introduce mutable binders for subsequent clauses and for that iteration's loop body.
  • let move clauses consume their scrutinee for ownership-tracked values. This is most useful when the scrutinee is a fresh expression each iteration, such as while 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:

  • break exits the nearest enclosing loop (break).
  • continue skips to the next iteration (continue).
  • return exits the function (return).
  • panic exits the function via the typed error system (Typed Errors (error, panic, and T | 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 (otherwise E2001).
  • Each #invariant expression must have type bool (otherwise E2001).
  • If present, the #variant expression must have an integer type (int or a fixed-width integer; otherwise E2001).
  • Each #monovariant expression must have an integer type (int or a fixed-width integer; otherwise E2001).

#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:

  • while loops with boolean conditions.
  • while let <pattern> = <expr> { ... } pattern-destructuring loops.
  • && let-chains in while let loop conditions.
  • break / continue inside while bodies.
  • #invariant (type-checked as bool), #variant (type-checked as an integer), and #monovariant (type-checked as an integer) attached to while.

examples:

break#

break exits the nearest enclosing loop.

Surface Syntax#

break;

Notes:

  • break is a statement, terminated by a semicolon.
  • break does not carry a value in the current language design; there is no break <expr> form.

Semantics#

When executed, break;:

  • terminates the innermost enclosing loop (loop, while, or for), 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#

  • break is only permitted inside a loop body.
  • A break outside a loop is a type-check error (Compiler Diagnostics, E2007).

Notes#

  • break; is accepted inside loops (loop, while, and for) and lowered end-to-end.
  • break; outside of a loop is rejected (E2007).

examples:

Common Pitfalls#

  • Forgetting the semicolon (break is a statement).
  • Expecting break to return a value (not supported).
  • Using break outside 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:

  • continue is 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-style for loops, 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#

  • continue is only permitted inside a loop body.
  • A continue outside a loop is a type-check error (Compiler Diagnostics, E2008).

Notes#

  • continue; is accepted inside loops (loop, while, and for) 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 (continue is a statement).
  • Expecting continue to exit the loop (it does not; use break).
  • Using continue outside 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:

  • return is only valid inside a function body (otherwise E2009).
  • In a function with non-void result type R, return must provide an expression whose type is R (otherwise E2009).
  • In a void function, return; is permitted and return <expr>; is rejected (E2009).
  • In a function with non-void result, 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-void functions, with type checking.
  • return; from void functions.
  • Missing return in a non-void function is rejected (E2010).

examples:

match Expression (and Statement)#

The match expression provides structured pattern matching.

Key ideas:

  • A match selects 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 if guards in any match form yet.
  • match is 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 match is 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)), where T is a payload type supported by the backend.
  • Patterns are restricted to:
  • None
  • Some(<name>)
  • Some(_)
  • No guards (if ...) are implemented yet.
  • Matches must be exhaustive for the optional scrutinee: there must be exactly one None arm and exactly one Some(...) 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 (false and true), and
  • a wildcard _ arm.
  • The match must be exhaustive:
  • when the scrutinee is not a known boolean literal, it must cover both false and true, 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::Cancelled or Cancelled
  • 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 E in patterns may be a type alias for the instantiation (for example type R = Result(int, string); then R::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 as Ti), or
  • _: Ti (matches and ignores the payload), where Ti is 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 extends chain, 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 binder v is in scope only within that arm and has type T (the inner payload type of the scrutinee T?).
  • For typed-binder arms v: T => ..., the binder v is in scope only within that arm and has the annotated type T; _: T checks the same type contract without introducing a binder.
  • The result type of a match expression 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, and T | 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 / true and _
  • primitive strings: string literals and _
  • enums (enum): enum variants (see note below)
  • type unions (T1 | ... | Tn): typed binders name: Ti / _: Ti
  • recoverable results: Ok(name) / Ok(_) and Err(name) / Err(_)

Exhaustiveness rules:

  • Expression match remains exhaustive.
  • Statement match is also exhaustive by default.
  • For Option(T) and recoverable Result-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, and while let when 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 name is 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 treats Error as a binder arm, while match (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 expr is an error-producing expression (its signature includes T | ErrorType...), then any arm that matches an error type must end in a terminal statement.

Implementation

  • The compiler currently implements match as 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, and T | 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) (an enum with Ok(T) and Err(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 Ok and Err.
  • Ok(...) / Err(...) patterns are shorthand for R::Ok(...) / R::Err(...) where R is 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? and err: 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 one Err(...) arm.
  • In Ok(v) => ..., the binder v has type T.
  • In Err(e) => ..., the binder e has type E.

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:

  • struct declarations define pure data layout.
  • impl blocks 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 constructor Name.
  • Outside a generic context, uses of the type must be fully applied (for example Data(u8)), not bare Data.
  • A declaration name may not be reused across different generic arities (for example struct Foo { ... } and struct 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,
  • const bindings,
  • calls to const fn,
  • struct literals, and
  • field access over compile-time values.

Current limits:

  • new is 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:
  1. all base fields (in declaration order), then
  2. 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):

  • extends is permitted only on non-opaque struct declarations.
  • The base name must resolve to a struct type in the compiled module set.
  • Cycles in extends chains are rejected.
  • A derived struct may not declare a field whose name conflicts with an inherited field name.

Notes:

  • extends does not imply implicit subtyping in Silk currently: there is no implicit coercion from Derived to Base (or &Derived to &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 &MyFFIHandle is allowed.

These rules increase safety at the language boundary:

  • Eliminates type confusion: distinct handle types such as &DatabaseHandle and &FileHandle are 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 struct definition.
  • 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:

  • sequence at offset 0 (4 bytes)
  • size at offset 4 (2 bytes)
  • flag at offset 6 (1 byte)
  • 1 byte of tail padding at offset 7 to 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 struct value is lowered into a sequence of scalar “slots” in source order, after recursively expanding certain composite field types:
  • string contributes 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 struct is 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 placeholder u64 slot 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 ext declarations), structs must be ABI-safe: after slot-flattening, all slots must be i64/u64/f64 (for example string fields are ABI-safe because they lower to (u64, i64), but bool, char, and f32 fields 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/f64 slots consume XMM argument slots (xmm0..xmm7, then the stack),
  • 1–2 slot results use rax/rdx for integer-like slots and xmm0/xmm1 for 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 in rax.

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 in impl Name(...). Any other identifier in an impl argument 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 impl block attaches methods to exactly one nominal type name (a struct or an enum).
  • Multiple impl blocks may exist for the same type name; the compiler merges their methods (subject to duplicate-name rules).
  • Methods inside an impl block are fn declarations (with bodies).
  • The receiver, when present, is the first parameter named self and must be either:
  • a borrowed reference to the impl type (self: &Type / mut self: &Type), or
  • an owned value of the impl type (self: Type / mut self: Type).
  • Within an impl block, the special type name Self may be used anywhere a type name is accepted, and is treated as an alias for the impl type. For example, self: &Self is equivalent to self: &Type, and -> Self is 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 fn marks a method as callable from outside the defining impl block.
  • private fn is permitted to make intent explicit.
  • export is reserved for static members (no self receiver) and is not permitted on instance methods; use public fn instead.
  • When an impl block 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. See Interfaces.
  • The method named constructor is treated specially:
  • it is only meaningful for struct types (it backs new Type(...)); enums do not support constructor methods in the Supported forms,
  • it is public by default,
  • when explicitly marked private, it is callable only within the defining impl { ... } block,
  • it may be declared multiple times in a single impl block (an overload set),
  • its overload set includes constructor declarations across all merged impl blocks for the type,
  • it is invoked by:
  • heap allocation (new Type(...)),
  • empty struct literals (Type{} and contextual {}) when a visible default constructor exists (see Aggregate Literals),
  • and certain call-argument coercions (see Types),
  • new Type(args...) invokes the unique overload whose receiver is mut self: &Type, whose return type is void, and whose non-receiver parameter list matches args... 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 -> &T constructor coercion for &T parameters); 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 receiver self: &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 example value.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 a mut self: &Type receiver), the compiler treats value.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: Type or mut self: Type, the call site passes the receiver by value. For ownership-tracked values (for example types with Drop), 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 example make().push(1)) are rejected.
  • Non-mut receivers may be arbitrary expressions (including calls), so chaining like url.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) and Pair(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 as A (sugar for E::A),
  • when the expected type is an enum E, tuple variants may be written as Data(7) (sugar for E::Data(7)).
  • Function signatures:
  • enums may be used in parameter lists and return types,
  • error-producing functions may return enums (-> E | ErrorType...), and call()? works when the success type is an enum.
  • match expression 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 ?? fallback treats 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.
  • match statement 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 use R::Ok(...) / R::Err(...) as constructors and patterns.
  • impl blocks on enums:
  • enums may have impl blocks (including generic impl 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 constructor method used by new Type(...) is for struct types; enums do not support constructor methods 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 and libsilk.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() and A() 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 ?? fallback yields that enum value,
  • if the first variant carries exactly one payload, value ?? fallback yields that payload,
  • if the first variant carries more than one payload element, use match instead,
  • 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, and continue are 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::Data by 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::Mode as the type name, and
  • util::Mode::Inc / util::Mode::Dec as 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:

  1. a u64 tag (variant index in declaration order, starting at 0), and
  2. 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, but E::Data is not a value in the Supported forms (error E2002).
  • Calling a unit variant: E::A is a value; E::A() is rejected (E2002).
  • Wrong binder count: E::Pair(a) does not match Pair(int, int) (E2002).
  • Non-exhaustive matches: you must list every variant (error E2002 in the Supported forms).
  • Assuming enum equality is defined: use match to inspect the tag/payload; the backend does not define ==/!= over enums yet.

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 interface declaration.
  • The struct that 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 extends another interface inherits all of the base interface’s method signatures.
  • A conformance declaration (impl T as I or module ... as I) must satisfy the full inherited interface surface.

Rules (Supported forms):

  • extends is permitted only on interface declarations.
  • Only single inheritance is permitted (at most one extends base).
  • Cycles in extends chains 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 method m whose signature matches after accounting for the receiver:
  • the interface method signature itself must omit any explicit self parameter; ordinary interface methods always use an implicit receiver,
  • the impl method’s first parameter is the receiver self: &Type (or mut 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) and std::interfaces::Parse(E, S) are receiverless static protocols. Their conformance does not use a receiver parameter:
  • impl T as std::interfaces::Deserialize(S) provides fn deserialize(value: S) -> Self (no self parameter),
  • impl T as std::interfaces::Parse(E, S) provides fn parse(value: S) -> std::result::Result(Self, E) (no self parameter),
  • calls use T.deserialize(value).
  • and Parse calls use T.parse(value).
  • Only Deserialize participates in as casts today. Parse remains an explicit method call so fallible construction stays visible in source.
  • The implemented conformance check substitutes Self recursively through nested generic result shapes, so interfaces such as Parse(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 as clause names an applied generic interface type (for example Read(u8)), all type arguments must be fully known at the conformance site, unless the conformance itself is generic and binds those type parameters (for example impl 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 function m whose 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 (...) -> R is treated as m(...) -> Promise(R),
  • export task fn m (...) -> R is treated as m(...) -> Task(R),
  • export async task fn m (...) -> R is treated as m(...) -> 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 match arms whose pattern type is an implemented interface for a concrete struct scrutinee,
  • special-case compiler hooks for specific interfaces (currently std::interfaces::Drop for deterministic cleanup; see std::interfaces and Memory 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 Object has 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 match dispatch 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 Interface values.

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 Interface itself 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 with package) including module ... as <Interface>; conformance checking.
  • Inline module declarations (module Name { ... } / export module Name { ... }) for nested namespaces.
  • A contiguous top-level import block (package imports and from "..." 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 .slk is 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 as my.dep.b match quoted paths such as "my/dep/b" by longest prefix,
  • and unquoted package-path specifiers (from ns_pkg::subpath).
  • Default exports (export default fn ... and export 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/.a inputs).

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):

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 .slk source 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 via as.
  • 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 package declaration.
  • When present, the package declaration 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 task is permitted as a ::-qualified segment so std::task is a valid package name.
  • std::strings
  • std::task
  • my_app::core
  • example
  • The standard library lives under the reserved std:: namespace, for example std::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 module declaration.
  • A source file MAY declare at most one of:
  • a package declaration, or
  • a module declaration.
  • When present, the module declaration 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 in Interfaces.

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-form module ...;, and import declarations 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{ ... }, and type Alias = User resolve User to 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 example inner_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 module declarations extend the prefix (for example outer::inner::name).
  • Imported namespace qualifiers preserve exported inline-module prefixes. For example, after import app as a;, exported members of export module users { ... } in package app are available as a::users::Name; after import lib from "./lib.slk"; binds a file namespace, the same exported inline-module member is available as lib::users::Name.

Source File Header Ordering (Mandatory)#

In each source file, top-level declarations must appear in this order:

  1. Optional package or module declaration (package ...; or module ...;).
  2. Zero or more import declarations, as a contiguous block.
  3. 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:

  • import declarations MUST appear at top level (not inside functions or blocks).
  • All import declarations in a module, if any, MUST appear after the optional package declaration (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 import path is a sequence of identifiers separated by ::, matching the package naming rules above (including the std::task special case).
  • As with expression/type qualified names, an import path MAY start with :: to explicitly name the global namespace (the unnamed package).
  • import declarations 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 let bindings with explicit type annotations visible as ordinary, unqualified names in the importing module (for example, import util; followed by answer refers to util::answer when util exports let answer: int = 42;),
  • imported exported let bindings are also reachable via qualified names of the form pkg::name (for example, util::answer after import 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 fn declarations 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 (struct declarations) from imported packages are visible in the importing module for the supported struct subset:
  • the qualified form pkg::Struct is always accepted when pkg is imported,
  • the unqualified form Struct is 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 (enum declarations) from imported packages are visible in the importing module for the supported enum subset:
  • the qualified form pkg::Enum is always accepted when pkg is imported,
  • the unqualified form Enum is 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::Variant or pkg::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 exports malloc). The prefix is valid in both expression and type positions, including:
  • values: ::malloc(...),
  • types and struct literals: ::Foo and ::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; binds strings

  • import std::runtime::mem; binds mem

  • 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 as to choose a different alias or use the fully qualified package path (for example std::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, println for import 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-form module ...; declaration (their package name is empty).
  • ::Name resolves Name from that global namespace, if a matching declaration exists in the current module set.
  • ::Outer::Inner::Name resolves Outer::Inner::Name from 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 Result and the std::interfaces interface names) as specified by std::runtime::globals. Use --nostd to 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 example import 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::core maps 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, then my_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>"; or import { Name } from package::path;
  • Default imports / namespace imports: import Name from "<specifier>"; or import 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's lib.slk source module. If the extension is omitted, .slk is appended. A bare dependency key such as from "logger" resolves to the dependency's default lib.slk source module. Dotted dependency keys map to slash prefixes, so [dependencies] my.dep.b = { path = "../dep-b" } makes from "my/dep/b" resolve to the lib.slk source 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:

  • logger is an explicit path dependency whose package may be named oro::logger; quoted imports use the local dependency key ("logger"), while unquoted package-path imports use oro::logger.
  • local.math is an explicit path dependency whose quoted import root is "local/math".
  • my.dep.a is found from the package search path as my/dep/a.
  • my.dep.b is found from the package search path as my/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 separate import declarations.
  • File specifiers should include the .slk extension 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 package declaration and before any other top-level declaration.
  • The from keyword is part of the import syntax.
  • The from specifier 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 .slk is accepted for compatibility and stripped before package lookup. For example, from "std/io" resolves package std::io.
  • If the specifier is an unquoted package path, it is interpreted as a package name (using the same ::-separated syntax as package declarations) 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.b matches "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.slk is used. The dependency key is independent of the dependency manifest's package.name; quoted dependency imports use the key, and unquoted package-path imports use package.name.
  • Binary-only dependency packages may omit implementation sources and ship [package].definitions plus a compatible native [[artifact]]. In that case, an exact root specifier such as from "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 package or 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, named export default fn, export let, and exported ext bindings, and
  • type names: struct, enum, error, and interface declarations. 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 theory declarations (importable so they can be applied via #theory Name(args);).
  • impl blocks 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.
  • as can 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 local type alias (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 is add within the module,

  • but it does not implicitly create a named export of add for other modules. To export it as a named export, write export fn add ... (or add an explicit named export form once one exists in the language).

  • The function name after fn is 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 fn or an ext function), 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/var or a non-function ext), it binds a value name. When a default import binds a namespace, it does not introduce any unqualified imported names; you must use foo::Name to 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() and foo::users::User.
  • Using a namespace import name as a callable (e.g. foo()) is an error; add an explicit export default to 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 via ui::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:

  • export is not allowed inside blocks; it applies only to module-level declarations. Inside impl blocks, public controls method visibility and export is reserved for static members.
  • The implementation supports export on:
  • functions (export fn ...), including a declaration-only prototype form (export fn name(...) -> T;) used for header-style interface modules,
  • let and const bindings (export let ..., export const ...).
  • ext declarations (export ext name = ...;),
  • Formal Silk theories (export theory Name(...) { ... }),
  • type aliases (export type Name = ...;),
  • struct declarations (export struct Name { ... }),
  • enum declarations (export enum Name { ... }),
  • error declarations (export error Name { ... }),
  • interface declarations (export interface Name { ... }),
  • static members inside impl blocks (impl T { export fn ... } with no self receiver).
  • The export modifier 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 in Compiler 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/.a produced by a C compiler).
  • Prototype declarations may include Formal Silk contract annotations (#require / #assure / contract #theory uses). 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 (theory declarations). It does not export type names.

Notes#

The current compiler front-end:

  • parses package declarations into the AST,
  • parses import declarations 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 export for static impl members and export default for 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 exported let bindings (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 fn declarations 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 import refers to a package that exists in the current module set,
  • reject cyclic package graphs (e.g. package a importing b while b imports a).

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 fn and export let declarations 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: package and import declarations end with ; (parse error, E0001).
  • Imports not at the top: imports must come immediately after the optional package declaration 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 .slk files to the build, or by file-importing them; missing packages are E1001).
  • Calling a namespace import: if import foo from "./mod.slk"; binds a namespace (because there is no default export), then foo() is invalid; use foo::Name or add export default (E2018).
  • Name collisions with named imports: when importing from multiple modules, use as to 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 for Option(T) and is the recommended form.
  • Optional values are constructed using Some(...) and None (the compiler also accepts none as an alias of None).
  • The null literal is distinct from None, but may coerce to None when 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 as T?.

  • For example, the following is valid today and type-checks successfully (note that the current compiler requires let initializers; see Compiler Diagnostics, E2015):

    fn main () -> int {
      let a: string? = None;
      let b: Option(string) = None;
      return 0;
    }
    
  • The current linux/x86_64 IR→ELF backend subset now supports a first slice of optional values for a subset of payload types:

  • construct optionals via None and Some(value),

  • access fields of optional structs via optional chaining (opt?.field, producing a FieldType? value),

  • call methods on optional structs via optional chaining (opt?.method(args), producing a ResultType? 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 match expression (see ``match Expression (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 struct subset (0+ fields of supported value types, including nested structs and optionals; see Structs, 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),
  • match over non-optional scrutinee types (and richer pattern forms beyond None/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:

  • None can be assigned to any T?; its concrete T is inferred.
  • In pattern matching and control flow, None corresponds 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) is T? (or Option(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_value has type T when opt has type T?.
  • When opt has type T??, the expression opt ?? default_value has type T? (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 ?? fallback yields the Ok(...) payload or the fallback for Err(...).
  • The same token is also used for ordinary named enums with exactly two declared variants:
  • if the first declared variant is unit, value ?? fallback yields 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, fallback is evaluated.
  • The right-hand side may also be one of the narrow terminal control-flow forms accepted only after ??:
  • value ?? return expr
  • value ?? break
  • value ?? continue
  • These forms keep the same validity rules as their statement counterparts:
  • return must be valid in the enclosing function and type-check against its result type,
  • break and continue are only valid inside loops.
  • This is still a narrow rule for coalescing. It does not make return, break, or continue general expressions elsewhere in the language.
  • The optional and recoverable-result forms are distinguished by the left-hand operand type; expression match remains 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?.email yields string?.
  • If any link in the chain is None, the result is None.
  • Optional method calls are also supported:
  • user.profile?.email_len() yields int?.
  • When the receiver is Some(v), the call evaluates as Some(v.email_len()).
  • When the receiver is None, the call evaluates as None.
  • Coalescing ??:
  • Converts an optional into a non‑optional by supplying a default.
  • Explicit checking via match:
  • Pattern‑matching on Some(...) / None to 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() -> bool
  • opt.is_none() -> bool
  • opt.map(f) -> U? where f: fn(T) -> U
  • opt.and_then(f) -> U? where f: fn(T) -> U?
  • opt.or_else(f) -> T? where f: fn() -> T?
  • opt.unwrap_or(fallback) -> T (eager; fallback is evaluated before the call)
  • opt.unwrap_or_else(f) -> T where f: fn() -> T (lazy; called only for None)

Notes:

  • ?? remains the idiomatic lazy fallback operator because the fallback is an ordinary expression and is evaluated only for None.
  • unwrap_or is eager by design; use unwrap_or_else (or ??) when the fallback is expensive.
  • map/and_then call the callback only for Some(...).

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? and Option(T) as equivalent surface forms.
  • Ensure that Some / None usage is type‑correct.
  • Track optionality in the type system and enforce checks when unwrapping.
  • Implement ?. and ?? with the short‑circuit semantics described above.
  • Support match on Option(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_64 backends (see Typed Errors (error, panic, and T | ErrorType...)).
  • assert is 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 test builds, failed assertions record a test failure and execution continues (the test process exits non-zero when failures were recorded). See Testing.

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.

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 (see Typed Errors (error, panic, and T | 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 / match can 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.a should use well-defined C types (e.g. enums or structs) documented in C99 ABI and libsilk.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 current linux/x86_64 backend this is implemented as an invalid-instruction trap.
  • In debug builds (silk build --debug / -g) on linux/x86_64, a failed assertion prints a panic header, the optional message (when present), and a stack trace to stderr when available (via glibc backtrace_symbols_fd) before aborting.

Notes:

  • Failed assertions are currently isolated by the silk test runner (each test runs in its own process). Future work may allow reporting failed assertions without process isolation (for example by lowering assert to 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 error represents an unrecoverable logic bug or contract violation.
  • A function that can panic must 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 (recoverable Result(T, E)),
  • std::url and an 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 error declaration has the same field rules as struct in the current compiler subset (scalar fields; see Structs, Impl Blocks, and Memory Layout).
  • An error type may also be used as data (returned, stored, logged) when it is not part of a T | ... error contract.

Implementation

  • The compiler treats error as a distinct nominal type category (separate from struct) 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 declared error type.
  • The list of error types in a signature is the complete contract: the implementation may not panic with 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:

  • panic constructs a value of the named error type 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:

  • panic is 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 match statement 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 the match statement 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 is Promise(int).
  • let p: Promise(int) = open_value()?; is valid inside a caller whose error contract includes OpenFailed.
  • 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 explicit match on open_value() is rejected with E2023.

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 with SIGABRT,
  • in non-debug builds on linux/x86_64, the compiler disables core dumps (prctl(PR_SET_DUMPABLE, 0, 0, 0, 0)) before calling abort() to keep abort fast,
  • on backends/targets where abort() is unavailable, it is lowered to the backend’s Trap primitive.
  • std::halt() and std::reboot() are currently lowered to Trap in 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) (an enum with Ok(T) and Err(E) variants), or
  • a “Result-like” struct with fields:
  • value: T?
  • err: E? where E is an error type,

then the checker accepts binder patterns of the form:

  • name => { ... } / _ => { ... } for the success payload (binds name as T),
  • err: E => { ... } for the error payload (binds err as E).

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 match expression also supports Ok(...) / Err(...) patterns for Result values (see ``match Expression (and Statement)).
  • Typed error handling uses the statement form of match with block arms.

Restrictions#

pure fn#

pure fn must not introduce or handle typed errors:

  • pure fn may not have a | in its return type.
  • pure fn may not contain panic statements.

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.

  • Open/variadic error sets for higher-order adapters (E...).
  • return <error> as shorthand for panic <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.
  • const bindings are always immutable (there is no const mut).
  • All function parameters are immutable (read‑only) by default.
  • A value parameter may be declared mut to allow reassignment of the parameter binding inside the callee (this does not affect the caller).
  • A borrowed reference parameter (&T) follows a two‑part mut borrow 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 mut bindings 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 ..., and for let mut ... in ... introduce mutable payload binders.
  • move is independent from mut: let move Some(v) = maybe;, let move Some(v) = maybe else { ... };, if let move ..., else if let move ..., and while let move ... consume the scrutinee for ownership-tracked values, while let move mut Some(v) = maybe else { ... }; and let mut move Some(v) = maybe else { ... }; also make v assignable. For simple mutable bindings, var move value = source;, var mut move value = source;, and var move mut value = source; consume source during initialization when source requires ownership tracking and then introduce value as assignable. The explicit mut after var is 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 declared mut.
  • 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 mut at both the declaration and call site for mutable borrows.
  • Surface clear diagnostics when mutation is attempted without proper mut markings.
  • 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 mut bindings, including assignment and numeric compound assignment.
  • mut value parameters (fn inc(mut x: int) { x = x + 1; }) as a callee-local mutable binding (no call-site mut marker is required).
  • Borrowed reference parameters for:
  • &Struct for the supported struct subset, and
  • &T where T is a single-slot scalar primitive (for example &int, &bool, &u64, &f64).
  • The two-part mut borrow 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 mut struct bindings (pair.a = 1, pair.b += 2), and
  • mut borrowed 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 &expr on borrowable lvalues (e.g. &pair, &obj.field, &x), and
  • via implicit borrow coercions in contexts that expect &T (currently implemented for &Struct; for example let 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 &Struct values that originate from heap allocation (new) or from calls that return &Struct:
  • these &Struct values are refcounted in the Supported forms,
  • copying a &Struct binding (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] where r: 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 mut for fixed arrays / structs, or an already-mutable view such as a mut borrowed reference parameter), or already a mutable slice view.
  • A function parameter of slice type may be declared mut to allow mutation through the slice view, and requires the caller to pass a mutable slice view using mut <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-site mut <expr> marker does not upgrade a read-only stored view into a mutable one; passing a field as mut requires 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 &Struct binding 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 &S to &T via as under 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:

  • new is supported for allocating non-opaque struct values on the heap and producing an &Struct reference.
  • 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 int or a small POD struct).
  • 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 example y = x;) also consume x when the value type requires ownership tracking (for example Drop types and task/promise handles). After the move, using x is rejected by the checker.
  • let move / var move are the binding-level ownership-transfer spellings. For a simple binding, let move y = x; and var move y = x; consume x under the same ownership-tracking rules as let y = move x;. mut may be combined with move in either order: let mut move y = x;, let move mut y = x;, var mut move y = x;, and var move mut y = x;. The explicit mut after var is redundant because var is 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 ..., and while 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#
  • new is supported only in function bodies (top-level let initializers cannot contain new in the Supported forms).
  • new is 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 example let x: &Frame = new Frame{ ... }; or as a call argument where the parameter type is &Struct)
  • from the new operand itself when it names a struct type (for example let x = new Frame{ ... }; or let x = new Frame(...);), which allows let bindings to infer &Frame without an explicit annotation
  • Only non-opaque struct types are supported for new.
  • Reference counting is applied only to &Struct values that originate from new (borrowed stack references are not treated as RC-managed values).
  • The silk build CLI supports --noheap to disable heap allocation for the Supported forms:
  • heap-backed new (outside a with region) is rejected with E2027,
  • async/task/await/yield and capturing closures are rejected with E2027,
  • ext bindings to libc heap primitives (malloc/calloc/realloc/free/etc) are rejected with E2027 in non-stdlib modules,
  • std::runtime::mem::{alloc,realloc,free} traps when called without an active with region (no implicit heap fallback),
  • region-backed new inside with is 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> { ... }, new allocates from the region’s backing bytes instead of calling the heap allocator.
  • On last-release, region-backed new allocations run drop (when defined), but do not free their backing storage (region memory is not reclaimed by RC).
Reference counting rules#
  • new initializes the allocation’s RC cell to 1.
  • Copying an RC-managed &Struct binding (for example let q: &T = p;) emits an RC retain (increment).
  • Assigning to an RC-managed &Struct binding (for example p = q; where p is a var) 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, and continue.
  • Passing new directly as a call argument to a &Struct parameter 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, and continue).
  • Return: on return, the compiler drops all in-scope droppable bindings except any value moved into the return result (for example return value; and return Some(value); treat value as 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 new allocations managed by compiler-inserted RC, drop is called before freeing the backing allocation when the refcount reaches zero.

Notes and limitations:

  • drop is 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> (see Borrow 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_ptr is a pointer to the closure code.
  • env_ptr is either 0 (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 passes env_ptr as 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 u64 refcount 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, and Drop types) 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 Mutability for the current implemented borrow rules (call-scoped aliasing checks for &T parameters 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] where r: range).
  • first-class borrowed &T values created from borrowable lvalues:
  • &expr (borrow operator) for borrowable lvalues, for:
  • the supported &Struct subset, and
  • &T where T is a single-slot scalar primitive (for example &int, &bool, &u64, &f64).
  • implicit borrow coercions in contexts that expect &T are currently implemented for &Struct (for example let 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 &T and T[] bindings,
  • local T? bindings whose payload is a borrowed &T or T[],
  • 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 to base.
  • &base[r] creates a slice view whose bounds are defined by the range value r (see Types).
  • When borrowing a range from an existing slice binding s: T[], the borrow’s underlying origin is s’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 &T that 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 = ...) when x is 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 &T or T[] participate in the same local mutation, lexical escape, move, and await checks as direct borrowed bindings.
  • Local named struct / enum bindings whose fields or payloads carry &T or T[] also participate in the same local mutation, lexical escape, move, and await checks 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 => ... } where r: T? and T is a borrowed &U or U[].
  • 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 monomorphized std::result::Result(T, E) instantiations, and the equivalent qualified enum-variant forms such as State::Ready(x).
  • if expressions 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.
  • match expressions 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:

  • &Handle is permitted when Handle is declared as an opaque struct 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 async call 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:

  • ext declarations may not use ordinary references or slices in parameters or results,
  • unnamed C-facing root-package top-level export fn declarations 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 (&Handle where Handle is struct 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 (&Handle where Handle is an opaque struct 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 (&Handle where Handle is 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 (for var) or permanently (for let).

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; consumes x,
  • y = x; consumes x.
  • let move / var move are the equivalent binding-level spellings for an explicit initialization-time move:
  • let move y = x; and var move y = x; consume x when x requires ownership tracking,
  • let mut move y = x;, let move mut y = x;, var mut move y = x;, and var 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 { ... }, and while 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 type bool.
  • 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 contains arch / 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 from attr(...), !, &&, ||, 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 for c_fn (...) -> ... (C ABI callback pointer types).
  • export attr(abi=c) fn ... and attr(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 task functions:
  • 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 for attr(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=c support.
  • 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 to export attr(abi=c) fn / attr(abi=c) export fn and 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 example arch="x86_64", abi=c),
  • =, <, <=, >, >= for numeric toolchain keys (for example silk_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"
  • os comparisons 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_patch
  • silk_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 package ui::model function add_i64 emits ui_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) (or attr(task="pool")), or
  • attr(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) (or attr(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 = true is equivalent to NAME (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 NAME or NAME=VALUE.
  • NAME starts with a letter or _ and may contain letters, digits, _, and -; this permits user-facing names such as security-provider.
  • When VALUE is omitted, the feature is treated as boolean true.
  • When VALUE is present:
  • true / false are parsed as booleans,
  • integer literals (including 0x... / 0b... / digit separators) are parsed as integers,
  • all other values are treated as strings.
  • attr(feature="NAME") is true when 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") is true only when the named feature exists and its value equals VALUE after parsing.

Precedence:

  • CLI --feature / -F entries 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/tui or --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,
  • volatile remains 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:

  • Relaxed performs an atomic operation without establishing synchronization.
  • Acquire prevents later memory operations from moving before the atomic operation.
  • Release prevents earlier memory operations from moving after the atomic operation.
  • AcqRel combines acquire and release behavior for read-modify-write operations.
  • SeqCst participates in the single sequentially consistent order for all sequentially consistent atomics.

Operation Rules#

Atomic operations have operation-specific ordering contracts:

  • load accepts Relaxed, Acquire, or SeqCst.
  • store accepts Relaxed, Release, or SeqCst.
  • swap, fetch_add, and fetch_sub accept any Ordering.
  • compare_exchange accepts any success ordering, but the failure ordering must not be Release or AcqRel.
  • fence accepts any Ordering; Relaxed is 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:

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 example sizeof(u8) == 8). For packed bytes suitable for OS/FFI byte APIs, use std::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) returning std::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:

std::buffer also continues to provide:

  • BufferU8: a packed, growable byte buffer for OS/FFI byte APIs (byte-addressed ptr, with len/cap in 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>] { ... }
  • Region is a primitive handle type:
  • const region name: u8[N]; binds name as a Region value,
  • Region values may be passed and stored (including in struct fields),
  • with <name> { ... } accepts any Region-typed binding (including function parameters and locals).
  • Inside a with <region> { ... } block, new allocations for non-opaque struct values 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 via std::runtime::mem::alloc allocate 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 new subset is affected (non-opaque struct allocations that produce &Struct).
  • Region-backed new allocations 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 region is 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, N may 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 Region value.

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 a Region value that has a compile-time-known backing size in the Supported forms (for example a const region declaration).
  • 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 from slice 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 new allocation performed by the compiler’s new lowering 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 an n-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:

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 to 0 on 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 with block”. Because anonymous-region cursors are reset on entry, code must treat pointers/&Struct values allocated inside with <bytes> { ... } and with <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, and async task fn / task async fn.
  • Parsing of yield <expr> and yield * <expr> (see yield below).
  • Parsing of await * <expr> as a unary await applied to a unary * operand (see await below).
  • Calling a function with a concurrency discipline produces a handle:
  • calling a task fn produces Task(T),
  • calling an async fn produces Promise(T),
  • calling an async task fn produces Promise(Task(T)), where T is the function’s declared surface result type.
  • yield is implemented with two forms:
  • send (yield <value>;) inside a task: writes one task value (convertible to the enclosing task’s T) to the task’s receiver and continues execution. This form is only permitted inside a task fn / async task fn body.
  • receive (yield <task_handle>) in value position: waits until the task produces its next value and yields T.
  • 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.
  • yield on 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 value T (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 uses attr(task=thread), and yields a collected T[] 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 collected T[] 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 yields T,
  • await Promise(Task(T)) unwraps and yields Task(T),
  • await Task(T) is rejected (use yield / yield * for task values).
  • await * <promises> unwraps a collection of promises:
  • await * Promise(T)[] yields a collected T[] by awaiting each promise,
  • await * Promise(T) is rejected (the * form requires a collection).
  • await and the structured block form are still async-context-only:
  • await is only allowed inside functions declared with async (including async task fn),
  • async { ... } and task { ... } are only allowed inside functions declared with async.
  • async loop { ... } and task loop { ... } are only allowed inside functions declared with async.
  • Conservative suspension-safety rules are enforced at async fn boundaries:
  • async fn result types must not contain ordinary borrowed views (&T or T[]), 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 await points:
  • 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.
  • yield is task-context-only:
  • yield is only allowed inside task functions (task fn / async task fn) and inside task { ... } / task loop { ... } blocks.
  • Initial task-safety rules are enforced at the task fn boundary:
  • task fn / async task fn parameter 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) and Promise(T) handles are permitted at task boundaries, but their inner T must itself satisfy the task-safety rule above. This supports patterns like Task(Promise(T)) (for tasks that produce promises) and await * yield * t for t: Task(Promise(T)).
  • std::sync::Arc(T) handles are permitted at task boundaries when T satisfies the same task-safety rule. Moving an Arc(T) into a task transfers that handle; call clone() explicitly before spawning multiple tasks that need shared ownership. Borrowed non-opaque references inside Arc(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 fn is by value. For ownership-tracked values (for example Drop types and Task(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 fn boundaries (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::sync primitives, std::atomic atomics, or by communicating through channels).
  • To share a runtime handle across tasks without transferring ownership, prefer stdlib APIs that follow the T / TBorrow pattern (for example Channel(T) + ChannelBorrow(T) and AbortSignal + 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 of T; put synchronization inside T (for example a Mutex-like handle) when mutation is required.
  • To share one atomic cell across tasks, keep the owning std::atomic value alive in the parent scope and pass AtomicU64Borrow or AtomicBoolBorrow across 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 Silicon macos/aarch64 today):
  • await is a true suspension point backed by a single-threaded executor (fibers), so awaiting a pending Promise(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 + stable std::runtime::event_loop surface (see Async 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 fn workers) may still call async fn entrypoints, but those calls run synchronously (no coroutine spawn), and await on a non-owner thread blocks the OS thread until the promise is resolved.
  • Awaiting a Task(T) is rejected by design; use yield / 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, and fn main(argc: int, argv: u64) -> void. Task-backed entrypoints such as task fn main and async task fn main are rejected by the executable runtime path; keep task work inside an ordinary or async main.
  • The runtime subset implements task execution using OS threads:
  • By default, calling a task fn schedules 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::task and std::sync for 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 async functions, std:: provides WHATWG-style abort signals via std::abort_controller (see the 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 fn is 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 yields T.
  • await Promise(Task(T)) yields Task(T) (which can then be consumed via yield / yield *).
  • await Task(T) is rejected; use yield / 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 fn bodies support await * 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 with E2023 because 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 match applies to the async call itself, so the success arm receives the Promise(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. await consumes the handle.
  • A Task(T) handle may be drained/joined at most once via yield * (and yield on 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 for Task(T),
  • let _ = async_call(); is rejected for Promise(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.t and await box.p is 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 await it a second time, or attempting to yield * 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:

  • await unwraps 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 for attr(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 ... -> T produces a Task(T) handle when called.
  • Inside the task body, yield <expr>; sends a value (convertible to T) to the task’s receiver and continues execution.
  • return <expr>; sends the final task value (of type T) 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 uses attr(task=thread). Pooled/default tasks skip the join. In value position, yield * yields a collected T[].
  • 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:

  • yield is a blocking OS-thread operation (like the rest of the current concurrency runtime).
  • yield is permitted only inside task fn / async task fn bodies and inside task { ... } / task loop { ... } blocks.
  • The statement forms (yield <value>; and yield * <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:

  • async is cooperative: there is no preemptive async scheduling.
  • task fn calls 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 &T across task fn / async task fn boundaries.
  • await Task(T) remains rejected; task values are consumed via yield / yield *.
  • Hosted async coroutines are not migrated across OS threads; parallelism is expressed via task and 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 silk CLI (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 #require attached to struct declarations.

When verification syntax is present, compilation MUST:

  1. generate VCs,
  2. prove them using Z3, and
  3. 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:

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_LIB in 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 → BV8
  • i16/u16 → BV16
  • i32/u32 → BV32
  • i64/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, and Duration) 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_*, and SILK_*,
  • and const-evaluable module/package const bindings, including exported qualified names such as std::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 ext bodies.
  • In the current verifier subset, calls are supported only to functions and methods that have Formal Silk contracts (see “Contracted calls” below). ext declarations do not have Formal Silk contracts yet, so verified code cannot call ext functions.

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:

  • #const is a statement that may appear inside function bodies (inside blocks).
  • The binding is compile-time-only and is not lowered into runtime code.
  • A #const binding is visible only inside specification expressions:
  • function specs (#require, #assure),
  • loop specs (#invariant, #variant, #monovariant).
  • Using a #const name in a runtime expression (e.g. in while conditions or normal let initializers) is a compile-time error. Use a normal let binding for runtime values, and (optionally) introduce a #const alias 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 the fn declaration (and before any export modifier).
  • 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 bool so 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{ ... } and new 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 #invariant annotations, zero or more #monovariant annotations, and at most one #variant annotation may appear immediately before the while keyword.
  • 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:

  • #invariant expressions (type bool in 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 break exits (so proofs after the loop may rely on the invariant).
  • #variant expressions as a well-founded measure that must decrease on each iteration (and be non-negative at the loop head), used for termination proofs.
  • #monovariant expressions 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:

  • #assert is 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 #assert site. If it cannot be proven, compilation fails.
  • After a #assert succeeds, 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 fn declarations and verified impl methods.
  • #assure may reference result (the return value) as a built-in formal declaration.
  • build metadata constants are available in Formal Silk expressions:
  • BUILD_KIND, BUILD_MODE, BUILD_VERSION as built-in compile-time string values,
  • and BUILD_VERSION_MAJOR / BUILD_VERSION_MINOR / BUILD_VERSION_PATCH as built-in compile-time u64 values.
  • Struct requirements (#require on struct declarations):
  • generate VCs and prove them at struct literal construction sites (Type{ ... } and new Type{ ... }),
  • include inherited requirements from base structs (struct Child extends Base { ... }).
  • #assert:
  • proves the asserted expression holds at the #assert site,
  • and then assumes it for the remainder of the block.
  • #invariant / #variant / #monovariant on while loops:
  • 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 when Name resolves 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 and method resolves 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 #require and any attached-theory #require) under the caller’s current path condition; errors report E3007,
  • after the call, the verifier assumes the callee’s postconditions (explicit #assure plus 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 #theory arguments 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-level if path 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 #require clauses after the write. Optional-field, index, nested-field, and compound assignment targets are rejected with E3005.

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 theory keyword.
  • Inline (block) theory declarations and theory use sites share the #theory token; 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 #theory use 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:
  • #require become additional function preconditions,
  • #assure and #invariant become 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 theory declaration (only #require / #assure may 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 #const formal 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, or new). 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 impl methods 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_DIR is 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, or method
  • 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 / #assure obligations
  • 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.json
  • share/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) on struct/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 in impl blocks).

Declaration syntax#

Parameter lists#

The parameter list uses parentheses:

struct Vector(T) {
  // ...
}

Rules:

  • T is 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 Name or Name() (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 for T in fn 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 like let 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 as casts 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, and type declarations may not reuse the same name.
  • Name conflicts across generic arities are rejected (for example, struct Foo and struct 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, and fn,
  • 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:

  • T is a type parameter, and
  • N: int is 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):

  • #require expressions on a struct may 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{ ... } and new Type{ ... }). If any requirement cannot be proven, compilation fails with E3006.
  • 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 at await points (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:

  • pure code may call only pure code (and cannot perform I/O or mutation outside local, non-escaping temporaries).
  • task code may call task and pure code, and must satisfy task-safety rules for captured/argument data.
  • async code may await other async operations; it may call pure code 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 fn is parsed and checked (Supported forms):
  • a pure fn may call only pure functions; ext is treated as impure,
  • the checker also supports purity inference (“auto-pure”) for ordinary fn declarations and impl methods:
  • when an unannotated function/method has an eligible signature and its body satisfies the purity rules, it is treated as pure for call checking, and may be called from pure code,
  • functions/methods with &T parameters are not eligible for inference (explicit pure fn remains supported for &T parameters in the current subset),
  • pure cannot be combined with task or async in the Supported forms,
  • a pure fn may not have mut parameters,
  • a pure fn may not declare mutable locals (var or let mut) and may not perform mutation via assignment,
  • a pure fn may not allocate (new) in the Supported forms,
  • a pure fn may not have a typed-error contract (-> T | Error...) and may not contain panic statements.
  • task fn, async fn, and async task fn are parsed and preserved in the AST.
  • Calls across disciplines are now reflected in expression types:
  • calling a task fn yields Task(T),
  • calling an async fn yields Promise(T),
  • calling an async task fn yields Promise(Task(T)),
  • yield supports 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 from t into the task’s yield stream,
  • expression forms:
  • yield t receives the next yielded value from t,
  • yield * t drains/collects remaining values from t into T[],
  • await unwraps Promise(T) and yields T (await Task(T) is rejected), and await * ps unwraps Promise(T)[] into T[].
  • await <expr> and async { ... } / task { ... } blocks are enforced as async-only constructs:
  • await is only permitted inside async functions (including async task fn),
  • async { ... } / task { ... } blocks are only permitted inside async functions.
  • yield <expr> is enforced as a task-only construct:
  • yield expression forms (yield t / yield * t) are permitted only inside task functions (task fn / async task fn) and inside task { ... } / task loop { ... } blocks.
  • yield statement forms (yield v; / yield * t;) require an enclosing task fn / async task fn.
  • Lowering/codegen implements task execution using OS threads on linux/x86_64 and implements yield/yield * for task values plus await for promises.
  • By default, each task fn call 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) so await is 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), await blocks 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_loop API; see Async 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, implicit void result).
  • Function expressions may not declare &Struct parameters; only single-slot scalar &T parameters (for example &int) are supported in the Supported forms.
  • Function expressions are eligible for purity inference (“auto-pure”):
  • when the body satisfies the pure rules, the function value is treated as pure for call checking (it may be called from pure code),
  • otherwise the function value is impure and may not be called from pure code.
  • 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 pure code is rejected (capture environments allocate),
  • capturing closures are also eligible for purity inference (a closure whose body satisfies the pure rules is callable from pure code).
  • 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 fn is simply a const fn that also opts into the pure rules (see Function Disciplines (pure, task, async)).
  • const fn is a compile-time-only function:
  • it may be called only from compile-time contexts (for example const initializers 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 struct values):

  • a non-opaque struct type,

  • 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):

  • string values (string literals are supported directly in const bindings),
  • 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 task or async,
  • must not declare a typed-error contract (-> T | ErrorType...),
  • must have a non-void return 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 panic statements,
  • must not declare const local bindings,
  • may call only other const fn declarations,
  • is restricted to a small expression subset over scalar values:
  • literals and local names (parameters and let bindings; no global const reads in the Supported forms),
  • as casts between supported scalar types,
  • unary operators: -, ~, !,
  • binary operators:
  • arithmetic: +, -, * (division/modulo are not part of the const-eval subset),
  • bitwise: &, |, ^, <<, >>,
  • comparisons: ==, !=, <, <=, >, >=,
  • if expressions (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, ... }) when T is 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 / else statements,
  • while loops with boolean conditions,
  • break / continue,
  • return statements.

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 mut in 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 to N arguments (implementation-defined, currently N = 32).

The pack is passed by value using the same “flattened scalar slot” ABI as other POD structs.

Notes:

  • args[i] performs bounds checks against len and traps on out-of-bounds.
  • Directly reading aK is not bounds-checked; when K >= len, the value is unspecified. Prefer args[i] unless you are working with the raw representation intentionally.
  • Calls supplying more than N varargs 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.

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:

  1. One-paragraph summary
  2. Implementation status (if the concept is Implemented)
  3. Surface syntax
  4. Semantics (evaluation order, scoping, control-flow behavior)
  5. Type checking rules (static requirements and diagnostics)
  6. Examples
  • minimal examples (smallest correct usage)
  • realistic examples (how the feature is used in real code)
  1. Common pitfalls
  2. 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 Spec for the exact accepted syntax,
  • Types for type-system rules and special cases,
  • Mutability for mut and borrowing rules,
  • Compiler Diagnostics for error codes,
  • the implementation status for 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 -x is 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 a Duration).
  • range supports shifting by an int offset:
  • range + int, range - int, int + range.
  • Bitwise: &, |, ^, ~, <<, >>.
  • Currently, bitwise operators are defined for integer operands (int and 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.
  • ~x performs 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 for bool operands.
  • In the backend, comparisons are also defined for Duration and Instant when both operands have the same time type.
  • In the backend, == and != are also defined for string operands, 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 supported struct subset, and nested optionals T??):
  • None == None is true,
  • Some(x) == Some(y) compares the payload values for equality (recursively for nested optionals),
  • and != is the logical negation of ==.
  • Currently, None and Some(...) can appear in equality expressions when the other operand has an optional type (for example opt == None and opt == Some(x)), using that other operand’s type to infer the optional payload type.
  • In the backend, == and != are also defined for the supported struct subset (see Structs, 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: NaN compares unequal to everything (including itself), and ordered comparisons (<, <=, >, >=) are false when either operand is NaN.
  • Logical: !, &&, ||.
  • Currently:
  • ! is supported for bool operands.
  • Member and scope: ., ::, ?..
  • Currently:
  • . and :: are supported,
  • and ?. is supported for optional chaining on the supported struct subset:
  • opt?.field yields FieldType?,
  • opt?.method(args...) yields ResultType?. See Optional.
  • Casts: as and as raw (postfix).
  • Syntax:
  • numeric/shape cast: <expr> as <Type>,
  • raw bit-cast: <expr> as raw <Type>.
  • as is an explicit, potentially lossy conversion operator intended for primitive numeric conversions (see “Casts (as)” below).
  • as raw is 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, and T | ErrorType...).
  • Ranges / varargs delimiters: .., ..=, ....
  • .. and ..= form range literals of type range (see Types).
  • ... is the varargs/rest marker (see Varargs (Variable Arguments)).
  • Other punctuation: ?, ??, ->, =>, ,, ;, (, ), {, }, [, ], _, :.
  • Currently, ?? is supported for:
  • optionals in the backend (including scalar, string, and the current struct subset, plus nested optionals in the supported payload subset; see Optional), and
  • recoverable Result-like values, where result ?? fallback yields the Ok(...) payload and evaluates fallback only for Err(...) (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 ?? fallback yields that enum value,
  • if the first variant carries exactly one payload, it yields that payload,
  • and if the value is the second variant, fallback is 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 expr
  • value ?? break
  • value ?? continue
  • return, break, and continue keep their normal statement validity rules:
  • return must match the enclosing function result type,
  • break and continue are 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()?; see Typed Errors (error, panic, and T | 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 mut binding, or
  • a struct field lvalue name.field (or nested field lvalue name.field1.field2...) where name is either:
  • a local let mut binding of a supported POD struct, or
  • a mut borrowed 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 example bool, integer scalars, and f32/f64).
  • Identifier lvalues must refer to let mut local bindings.
  • The type of expr must match the binding’s type.
  • The assignment expression has type void.

Compound assignment (+=, -=, *=, /=)#

Compound assignments are shorthand for “read-modify-write”:

  • x += y is equivalent to x = x + y (and similarly for -=/*=//=), with y evaluated 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). (isize is accepted as an alias for size.)
  • Prefix and postfix forms are equivalent in Silk (both update the binding and produce void).
  • Conceptual desugaring:
  • x++ and ++x are equivalent to x += 1;
  • x-- and --x are equivalent to x -= 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:

  • sizeof always returns usize.

Evaluation mode:

  • When the operand is a type name (a primitive type, struct/enum name, type alias, or qualified type name), sizeof is a compile-time constant.
  • When the operand is a compile-time constant value (literals and other const-evaluable expressions), sizeof is a compile-time constant.
  • When the operand is a runtime value, sizeof is evaluated at runtime.

Sized integration:

  • Implemented (partial): sizeof <string value> produces the string’s byte length (as usize). This reads the current string ABI layout ({ ptr: u64, len: i64 }) through the reserved intrinsic __silk_string_len; std::runtime::mem::string_len remains 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 value will 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 fold sizeof Type to 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 string value occupies two slots ((u64 ptr, i64 len)), so sizeof string is 16 in the Supported forms.
  • A T[] slice value occupies two slots ((u64 ptr, i64 len)), so sizeof T[] is 16 in the Supported forms.
  • A T[N] fixed array occupies N * 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), while sizeof <string value> is the content size (byte length).
  • sizeof is a byte-size operator. For logical element counts (for example a slice length or vector length), use a .len() method via std::interfaces::Len on the relevant type. The standard library does not define a generic length(...) 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 as binds at postfix precedence, bare sizeof x as T parses as sizeof (x as T). To cast the result of sizeof, write sizeof(x) as T or (sizeof x) as T.

alignof#

alignof <operand> produces the alignment of a type or value in bytes.

Result type:

  • alignof always returns usize.

Evaluation mode:

  • When the operand is a type name (a primitive type, struct/enum name, type alias, or qualified type name), alignof is a compile-time constant.
  • When the operand is a compile-time constant value (literals and other const-evaluable expressions), alignof is a compile-time constant.
  • When the operand is a runtime value, alignof is 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-void types currently have alignment 8.
  • alignof void is 1.

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 as binds at postfix precedence, bare alignof x as T parses as alignof (x as T). To cast the result of alignof, write alignof(x) as T or (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:

  • offsetof always returns usize.

Evaluation mode:

  • offsetof is always a compile-time constant.

Operands:

  • Type must name a struct or error type (including nested structs).
  • field_path is one or more field identifiers separated by . (for example b or inner.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_path traverses an optional T? 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:

  • typename always returns string.

Evaluation mode:

  • typename is 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, or std::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, and fn (...) -> ...).
  • 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:

  • is always returns bool.

Evaluation mode:

  • is is 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 nominal struct type, expr is Type is true when the expression’s static type is exactly Type or a struct that extends Type.
  • If <Type> is an interface, expr is Interface is true when the expression’s static type declares conformance (impl T as Interface) or when the operand is a module declared module Name as Interface.
  • For primitive types, enum/error types, reference types (&T), slice/array types (T[], T[N]), optionals (T?), and function types, is currently checks exact type equality (after resolving type aliases).

Notes:

  • is does not perform runtime tagging or value inspection. For runtime discrimination of union/optional values, use match and 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 int parses as a + (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 &Other parses as new (Type{ ... } as &Other).

Supported conversions#

In the compiler, as is supported for primitive scalar conversions:

  • Integer → Integer (including Instant, Duration, and char):

  • 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.CastInt in the current IR; i128/u128 follow the analogous rule over their { lo, hi } lane layout.

  • Float → Float:

  • f32/f64/f128 conversions 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 is 0.

  • 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 S to T is permitted when both S and T name non-opaque struct types and their fields match positionally:

  • same field count, and

  • for each index i, the field type of S at i is exactly the same type as the field type of T at i (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 &S to &T is permitted when S and T are 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 which drop implementation 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 u64 and permits pointer-width unsigned usize values to be used as raw addresses in these casts. For low-level byte-copy routines and runtime interop, as supports explicit conversions between raw addresses and array/slice views:

  • ptr as T[] constructs a T[] slice view where the pointer component is ptr and 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 < len bounds check.

  • Operations that require a known length (iteration, slicing, etc.) trap unless an explicit length is provided.

  • ptr as T[](/silk/docs/len) constructs a T[] slice view where the pointer component is ptr and the length component is len (element count).

  • ptr as string(len) constructs a string view where the pointer component is ptr and the length component is len (byte count). This is sugar for std::runtime::mem::string_from_ptr_len(ptr, len) (and the reserved intrinsic __silk_string_from_ptr_len).

  • slice as u64 / slice as usize extracts the pointer component of a T[] slice.

  • arr as u64 / arr as usize extracts the address of element 0 of a fixed array T[N] (for N == 0, the result is 0).

  • 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), use std::runtime::mem::{load_u8,store_u8} or std::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 via serialize()):

  • When the operand type provides a unique instance method named serialize matching the std::interfaces::Serialize(T) surface (fn serialize(self: &Type) -> T), expr as T is permitted and lowers to a call of that method.

  • The conversion is explicit (it does not introduce implicit coercions).

  • The serialize method must be infallible (no typed errors).

  • Purity rules apply: inside pure fn, the serialize method must be pure.

  • 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 via deserialize(...)):

  • When the target type provides a unique static method named deserialize matching the std::interfaces::Deserialize(S) surface (fn deserialize(value: S) -> Self), expr as Self is permitted and lowers to Self.deserialize(expr).

  • This rule is checked before struct shape casts: when both a deserialize conversion and a shape cast could apply, the deserialize conversion is used.

  • The conversion is explicit (it does not introduce implicit coercions).

  • The deserialize method must be infallible (no typed errors).

  • Purity rules apply: inside pure fn, the deserialize method must be pure.

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:

  • as does not participate in the implicit call-argument coercion mechanism described in Types (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 as Duration/Instant and char.
  • 128-bit wide primitives: i128/u128/f128 (two 8-byte lanes; f128 stores the raw IEEE-754 binary128 bit pattern).
  • as raw is not permitted for void, optionals, arrays, maps, Silk function value types (fn (...) -> R), or structs/enums.
  • Special-case: u64 as raw c_fn (...) -> R (and usize as raw c_fn (...) -> R) is permitted for dynamic symbol loading and C ABI interop. The reverse direction, c_fn (...) -> R as raw u64 (or usize), extracts the raw code pointer. This does not apply to Silk closure-carrying fn (...) -> R values.
  • Special-case: string as raw u64 (and string 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_ptr remains 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 &Struct pointers to C APIs that use void * / T * handles).
  • This does not make integer→reference casts legal: u64 as raw &T remains 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/u32 and f32).
  • For 128-bit primitives, this is lane-wise:
  • the low lane is copied as u64 bits,
  • the high lane is reinterpreted across u64/i64 as 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 u64 yields 1, while 1.0 as raw u64 yields 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:

  • Duration represents a signed time span.
  • Instant represents 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:

  • Duration is represented as a signed 64-bit integer count of nanoseconds.
  • Instant is 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:

  • Duration vs Duration

  • Instant vs Instant

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 Instant and 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::temporal in 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 ext definition, 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:

  • foo is a C function named foo with the given Silk function type.
  • bar is a C variable of type u32.

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 ext is 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 ext declaration.
  • It is a compile-time error for verified code (code whose compilation requires proofs) to call an ext function or read an ext variable.

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 ext external declarations and representing them in the AST,
  • optional external symbol aliases (ext local "extern" = ...;),
  • ext functions with fixed parameter lists (ext name = fn (T0, T1) -> R;) as callable symbols in Silk (C variadic ... is not implemented yet),
  • ext function parameters of function type (fn(...) -> R) as C-compatible function pointers:
  • at the ABI level, these are passed as a single u64 code 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 ext function-pointer parameters in the Supported forms,
  • c_fn (...) -> R types as explicit C callback pointers (Supported forms):
  • c_fn is a code-pointer-only function pointer type intended for FFI,
  • unlike fn (...) -> R function values, c_fn (...) -> R values do not carry a closure environment and are safe to store in locals/struct fields and pass through APIs,
  • a c_fn value may be formed only from:
  • a top-level function name, or
  • a non-capturing fn (...) -> ... expression,
  • capturing closures are rejected when a c_fn is required.
  • ext variables of scalar type (ext name = T; where T is a supported scalar such as int, fixed-width ints, bool, char, or f32/f64) as readable values in Silk,
  • string parameters in ext function calls are lowered as C-string pointers (const char *) in the backend; the compiler-emitted backing bytes include a trailing NUL terminator, while the Silk string length 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) in ext function parameters and results,
  • ordinary references (&T) and slices (T[]) are rejected at ext boundaries,
  • the same ordinary-borrow restriction also applies to unnamed-package C-facing export fn signatures 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 C ext/header surface.
  • lowering calls to ext functions when building:
  • silk build --kind object, and
  • silk 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.
  • ext variable 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), ext declarations map to wasm imports:
  • ext foo = fn (...) -> ...; becomes an imported wasm function env.foo,
  • ext bar = T; becomes an imported wasm global env.bar (for scalar T),
  • parameter/result types follow the compiler’s current scalar lowering (for example int → wasm i64).

Not implemented yet (documented design, future work):

  • writing to ext variables (they are read-only in the Supported forms),
  • ext variables of non-scalar types (strings, structs, optionals, arrays),
  • richer string and aggregate marshalling (for example: returning string from ext calls 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 ext parameters 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_fn values are code pointers only; they cannot capture local variables.
  • A raw dynamic symbol address can be converted explicitly with addr as raw c_fn (...) -> R or an alias of that type. This is intended for loader APIs such as std::dylib; Silk does not validate the symbol signature at runtime, so the declared c_fn type must match the foreign ABI exactly.
  • A c_fn value 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 u64 that is a void * 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 ext calls or ext variable reads, the compiler emits a dynamically-linked ELF64 executable (PIE-style ET_DYN with PT_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 string and regexp:

  • conceptually: struct string { ptr: ptr, len: i64 } where ptr is a UTF‑8 pointer.

  • conceptually: struct regexp { ptr: ptr, len: i64 } where ptr is 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 as string in Silk ext declarations, with the compiler extracting the ptr.

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_t
  • i16, u16 → int16_t, uint16_t
  • i32, u32 → int32_t, uint32_t
  • i64, u64 → int64_t, uint64_t
  • i128 → SilkI128 (see C99 ABI and libsilk.a``; { lo, hi } lanes)
  • u128 → SilkU128 (see C99 ABI and libsilk.a``; { lo, hi } lanes)
  • int → int64_t (current linux/x86_64 baseline; do not assume C int)
  • f32 → float
  • f64 → double
  • f128 → SilkF128 (see C99 ABI and libsilk.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), prefer i32/u32 in your ext declarations rather than int.
  • The stable C99 ABI does not use compiler-specific __int128 or __float128 types 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 string is represented internally as a { ptr, len } pair.
  • For ext calls to typical C APIs:
  • the compiler can extract ptr and pass it as a const char *,
  • the data is guaranteed to be null‑terminated so standard C string functions are safe.

For regex bytecode values (regexp):

  • Silk’s regexp is represented internally as a { ptr, len } pair with the same slot layout as string, but the bytes are not text and are not required to be null‑terminated.
  • At ABI boundaries, regexp uses the same C shape as SilkString, 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 regexp payloads 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/foreign regexp views 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 Silk string values.
  • Functions exported by libsilk.a will use SilkString in 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:

  • ext function 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 ext declarations 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/rdx and/or xmm0/xmm1 accordingly, 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-safe struct (i.e. after slot-flattening, all scalar slots are i64/u64/f64),
  • construction via None and Some(value),
  • unwrapping via ?? with short-circuit fallback evaluation,
  • and nested optionals (T??) for the same supported payload subset, including unwrapping T?? to T? 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) for string?,
  • (tag, slot0, slot1, ...) for struct? 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 ext declarations 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 and libsilk.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: GNU as in Intel syntax (.intel_syntax noprefix)
  • macos/aarch64: Apple as in its native arm64 syntax

The assembly may contain multiple instructions, for example:

fn main () -> int {
  asm "mov rax, rax\nnop";
  return 0;
}

Restrictions#

  • asm is currently implemented only for the native linux/x86_64 and macos/aarch64 backends.
  • The assembled output must not require relocations. As a result, inline asm may not refer to external symbols (for example call foo where foo is 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#

  • asm is target-dependent by nature. The current implementation is supported only for the native linux/x86_64 and macos/aarch64 backends.
  • Using asm can 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 E2116 when 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_64 IR→ELF backend,
  • emits the assembled bytes in the temporary macos/aarch64 host 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 asm support on non-linux/x86_64 targets/backends. except for the current macos/aarch64 host 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),
  • let and let mut (and var as an alias for let mut),
  • let move and var move for initialization-time ownership transfer, including combined modifier forms such as let mut move and var mut move, plus direct destructuring forms such as let move (a, b) = pair; and let move Some(value) = maybe;.
  • Specification-only declarations: #const (Formal Silk; not usable in runtime expressions).
  • Structured blocks: async { ... } / task { ... } (see Concurrency).
  • Expression statements: limited to calls, assignments, and increment/decrement in the Supported forms (Expression Statements).
  • Flow control:
  • if / else statements (including if let pattern destructuring),
  • while loops,
  • break, continue,
  • return,
  • assert,
  • panic (typed errors),
  • match statement (typed errors; see Typed Errors (error, panic, and T | 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/var are 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; and let _: 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) and Promise(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 with yield *, await, or await *.

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 struct value 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) = ...; or let { 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] requires T[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), or
  • E::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 let binding).
  • let mut <pattern> = ... else { ... }; introduces mutable pattern binders.
  • let move <pattern> = ...; and let move <pattern> = ... else { ... }; consume the scrutinee for ownership-tracked values before either branch continues. The else block cannot use the moved source binding, and the continuation receives the payload binders as moved values.
  • If the pattern does not match, the else block executes.
  • The else block 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 else block.

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 const binding 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 struct types whose fields are compile-time scalar value types and that do not require Drop, and

  • compile-time evaluable expressions composed of:

  • literals,

  • other const bindings,

  • calls to const fn functions 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

  • as casts between supported scalar types, and

  • a small operator subset (notably +, -, *, bitwise ops, shifts; / and % are currently rejected for const).

  • string bindings whose initializer is either:

  • a string literal ("..." or `...`), or

  • another const string 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/var bindings and nested blocks.
  • Formal Silk #const declarations (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 --version reports 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 in include/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);
  • CLI output: the silk CLI and silk(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: string
  • BUILD_MODE: string
  • BUILD_VERSION: string
  • BUILD_VERSION_MAJOR: u64
  • BUILD_VERSION_MINOR: u64
  • BUILD_VERSION_PATCH: u64

These behave like normal const string values:

  • They do not require an import.
  • They may be used anywhere a string expression 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:

BUILD_VERSION#

The semantic version of the current package when building from a manifest.

  • When building from a package manifest (silk.toml), BUILD_VERSION is the manifest version.
  • When not building from a manifest, BUILD_VERSION is "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.patch prefix of BUILD_VERSION.
  • Any trailing -prerelease or +build suffix 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:

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:

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: string
  • OS_ARCH: string
  • OS_IS_UNIX: bool
  • OS_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, and linux-aarch64-musl:
  • OS_PLATFORM == "linux"
  • macos-x86_64 and macos-aarch64:
  • OS_PLATFORM == "macos"
  • ios-aarch64, ios-simulator-aarch64, and ios-simulator-x86_64:
  • OS_PLATFORM == "ios"
  • android-aarch64:
  • OS_PLATFORM == "android"
  • windows-x86_64 and windows-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, and windows-x86_64:
  • OS_ARCH == "x86_64"
  • linux-aarch64, linux-aarch64-musl, macos-aarch64, ios-aarch64, ios-simulator-aarch64, android-aarch64, and windows-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 canonical OS_ARCH value remains "aarch64".
  • wasm32-unknown-unknown and wasm32-wasi:
  • OS_ARCH == "wasm32"

OS_IS_UNIX#

Whether the compilation target is a UNIX family target.

Current compiler target set:

  • linux-x86_64: true
  • linux-x86_64-musl: true
  • linux-aarch64: true
  • linux-aarch64-musl: true
  • macos-x86_64: true
  • macos-aarch64: true
  • ios-aarch64: true
  • ios-simulator-aarch64: true
  • ios-simulator-x86_64: true
  • android-aarch64: true
  • windows-x86_64: false
  • windows-aarch64: false
  • wasm32-unknown-unknown: false
  • wasm32-wasi: false

OS_IS_POSIX#

Whether the compilation target is a POSIX target.

Current compiler target set:

  • linux-x86_64: true
  • linux-x86_64-musl: true
  • linux-aarch64: true
  • linux-aarch64-musl: true
  • macos-x86_64: true
  • macos-aarch64: true
  • ios-aarch64: true
  • ios-simulator-aarch64: true
  • ios-simulator-x86_64: true
  • android-aarch64: true
  • windows-x86_64: false
  • windows-aarch64: false
  • wasm32-unknown-unknown: false
  • wasm32-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:

  • test declarations MAY appear:
  • at top level (like fn and let), and
  • nested inside another test block (scoped subtests).
  • A test block introduces its own scope (like a function body).
  • Nested test blocks are executed inline, in source order, as part of the enclosing test’s execution. They may be used for hierarchical grouping and shared setup.
  • test blocks may use let, var, control flow, and call functions/methods using the same expression subset as normal code.
  • Top-level test blocks may use await. When a test body contains await, silk test runs that generated test wrapper as async and awaits it from the generated runner.
  • return; is allowed inside a test block (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 a test declaration 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 test declarations in the module set, and
  • executes them, emitting TAP output.

TAP output#

The initial runner uses TAP version 13 formatting:

  • TAP version 13
  • 1..N
  • ok <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/b
  • not 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 assert records 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; uses value != 123).
  • Failed assertions also emit a one-line detail message to stderr so failures are visible in silk test output without requiring --debug, formatted like:
  • assertion failed: <message> when not inside any test block, or
  • assertion failed [test: a]: <message> when inside a test block, or
  • assertion failed [test: a/b]: <message> when inside nested test blocks.

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 test declarations and silk test runner with TAP output.
  • std::test helpers 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 the fn keyword.
  • 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 example silk man <label>). It should be stable and globally unique within a package (recommendation: use a pkg::topic label).
  • The optional <summary...> provides a one-line description for the manpage NAME section. 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),
  • @param entries as a “Parameters” list,
  • @returns as a “Returns” section,
  • @throws as a “Throws” section,
  • @requires, @assures, @asserts, and @theory as dedicated sections (one bullet per tag instance),
  • @example blocks 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; where p: Promise(void).
  • await * ps; where ps: Promise(T)[] (the collected results are discarded).
  • yield ...; statement forms inside task fn as described in Concurrency.

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 as if 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; see Expression Statements).

Semantics#

General rules:

  • The iterable expression is evaluated once to produce an iteration source.
  • The loop body executes once per produced element.
  • break exits the loop; continue advances 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..end iterates start, start+1, ..., end-1 (end-exclusive).
  • start..=end iterates start, start+1, ..., end (end-inclusive).

Notes:

  • The range bounds are evaluated once, left-to-right (start then end).
  • 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.
  • continue advances 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 let binding 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.
  • break exits the loop; continue advances 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.
  • break and continue keep their ordinary for meaning.

supported patterns are the same refutable subset already implemented by if let / while let, including:

  • Some(x) / None
  • Ok(x) / Err(x)
  • typed binders such as v: T where 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 to value (a copy) for that iteration and the body executes.
  • continue advances by calling next() again; break exits 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 is false, the loop exits.
  • The loop body executes once per iteration when <condition> is true.
  • 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 to var) within the loop.
  • const init 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 for loop syntax.
  • Resolve iteration targets (ranges, collections) according to the language’s iteration model.
  • Lower for into explicit control flow, with correct semantics for break and continue.

Compiler requirements:

  • Recognize for loop 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 loop and task loop are still loop statements: they do not end with ;.
  • In Silk currently, async loop / task loop follow the same async-context restriction as async { ... } / task { ... }: they are only allowed inside functions declared with async (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 / continue are 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 { ... }, and task loop { ... } parse, type-check, and lower with correct break / continue semantics.

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, and T | ErrorType...) (unparenthesized | in function signatures is reserved for typed-error contracts),
  • ``enum Types (general tagged unions with named variants),
  • ``match Expression (and Statement) (match over 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 } (isize is accepted as an alias for size), and/or
  • Nominal POD structs (including error types) 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) | C is the same union as A | 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) where Type is 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 / type aliases),
  • functions (fn and ext function 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 using and public using are 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-scope using alias.
  • 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 corresponding fn name(...); signature from Other.
  • using Other::name as alias; imports it under the new name alias.
  • Name conflicts (including conflicts with inherited extends members) 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 fn method produces a public method 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, using does not accept public / private modifiers yet (imported methods inherit the source method’s visibility).
  • Constructor reuse (constructor) via using is not supported yet.

Compiler Diagnostics#

This document specifies the human-readable diagnostic format emitted by the Silk toolchain, including:

  • the silk CLI (silk check, silk build),
  • the embedding ABI (libsilk.a via silk_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 / f128 are language features, so E2114 / E2115 describe missing implementation work in some compiler paths rather than forbidden types.
  • monomorphized generics are language features, so E2016 is 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.column behavior).
  • 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_COLOR is not set,
  • TERM is not dumb.

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 importing std::...,
  • 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 a silk guide <code> follow-up only when the installed guide catalog actually links that diagnostic code.
  • silk error --list and silk error -l print every stable compiler error code and its short description in deterministic order.
  • <code> accepts copied forms such as E2028, 2028, diag:E2028, and error[E2028].
  • Examples are syntax-highlighted when stdout is a color-capable TTY; piped output, NO_COLOR, and TERM=dumb remain 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.field must be rewritten as opt?.field or preceded by an unwrap,
  • yield <task_handle>; in statement position should explain that statement yield is the send form and that receiving from a task handle requires value position (let x = yield h) or yield * h; for drain/forward.
  • E2003 — unknown imported name.
  • E2004 — duplicate imported name.
  • E2005 — invalid assignment.
  • E2006 — invalid borrow.
  • E2007 — invalid break.
  • E2008 — invalid continue.
  • E2009 — invalid return.
  • E2010 — missing return.
  • 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 / generic impl methods).
  • E2017 — builtin map(K, V) type form was removed (use std::map::{HashMap, TreeMap} instead).
  • E2018 — namespace import is not callable.
  • E2019 — duplicate default export in a module.
  • E2020 — invalid panic statement.
  • E2021 — unknown error type.
  • E2022 — error not declared in function signature.
  • E2023 — error-producing call must be handled with match or ?.
  • 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 (new outside with, libc allocator ext, capturing closures, and concurrency use that declares/forms Task(...) / Promise(...) handles; imported stdlib async declarations alone do not trigger it).
  • E2028 — unknown name.
  • E2029 — ambiguous implicit coercion.
  • E2030 — await requires an async function.
  • E2031 — async { ... } / task { ... } requires an async function.
  • E2032 — ambiguous constructor call.
  • E2033 — await requires a Promise operand.
  • E2034 — cannot copy a Task/Promise handle.
  • E2035 — Task/Promise handle used after await/yield *.
  • E2036 — cannot consume an outer Task/Promise handle inside a loop.
  • E2037 — task fn uses 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 — const initializer is not compile-time evaluable.
  • E2042 — pure fn may not have a typed-error contract (| in return type).
  • E2043 — pure fn may not contain panic statements.
  • E2044 — pure fn may not have mut parameters.
  • E2045 — pure fn may not declare mutable locals (var or let mut).
  • E2046 — pure fn may not perform mutation via assignment.
  • E2047 — pure fn may not allocate (new).
  • E2048 — pure fn may not call impure functions.
  • E2049 — pure fn may not be combined with task or async.
  • 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 &T parameters.
  • E2057 — duplicate type alias name.
  • E2058 — type alias cycle.
  • E2059 — type alias kind mismatch.
  • E2060 — unknown extends base.
  • E2061 — invalid extends base.
  • E2062 — cyclic extends chain.
  • E2063 — derived struct redeclares an inherited field name.
  • E2064 — derived interface redeclares an inherited method name.
  • E2065 — opaque structs may not use extends.
  • E2066 — prototype and implementation signatures do not match.
  • E2067 — capturing closure is not allowed in pure code.
  • E2068 — capturing closure uses a capture type that is not implemented yet.
  • E2069 — capturing closure may not capture a mutable binding yet.
  • E2070 — yield requires a task context.
  • E2071 — yield in value position requires a Task operand.
  • E2072 — yield * requires a Task operand.
  • E2073 — yield as 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 — invalid region declaration.
  • E2078 — with requires a region binding.
  • E2079 — invalid with ... from region slice.
  • E2080 — reserved (previously: indexing a slice cast from u64 required an explicit length).
  • E2081 — cast-length suffix requires a u64/usize pointer operand and a slice/string target.
  • E2082 — const fn may not be task or async.
  • E2083 — const fn may not have a typed-error contract (| in return type).
  • E2084 — const fn parameter types must be compile-time value types.
  • E2085 — const fn result type must be a compile-time value type.
  • E2086 — const fn may not allocate (new).
  • E2087 — const fn may not call a non-const fn.
  • E2088 — const fn may not contain panic statements.
  • E2089 — unsupported construct in a const fn body (outside the current const-eval subset).
  • E2090 — const fn may 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 — move requires a local binding name.
  • E2094 — slice borrow escapes its lexical scope.
  • E2095 — reference borrow escapes its lexical scope.
  • E2096 — unknown using target.
  • E2097 — using alias conflicts with an existing name.
  • E2098 — using target is ambiguous.
  • E2099 — using cannot import constructor yet.
  • E2100 — using cannot import methods that require mutable Self borrows yet.
  • E2101 — using method 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 its impl block (not visible from the call site).
  • E2106 — interface-required methods may not be declared private.
  • 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 — u128 is not implemented yet in all compiler paths.
  • E2115 — f128 is 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 the else block to end with a terminal statement.
  • E2118 — borrowed-view type may not appear in an async fn result.
  • E2119 — borrowed-view type may not cross an ext / unnamed C-facing export fn boundary.
  • E2120 — local borrow may not remain live across await.
  • 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 spell self: &Self in the interface declaration.
  • E2127 — invalid atomic memory ordering; for example, load may not use Release / AcqRel, store may not use Acquire / AcqRel, and compare_exchange failure ordering may not use Release / 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 #require checks). 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 --debug or silk test --debug is 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, E4001 should name the blocked runtime stage directly (for example ``unsupported executable runtime support: debug panic runtime support``` ) instead of falling back to a misleading unsupported function: main`.
  • When lowering cannot isolate a narrower statement / expression site, E4001 falls 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-lsp should map the compiler’s primary source span to the LSP diagnostic range directly.
  • silk-lsp should preserve structured compiler guidance in the published LSP payload:
  • keep the primary message short and stable,
  • surface compiler detail, notes, and helps as 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 SilkInterface object,
  • 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#

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:

  • SilkString mirroring the internal Silk string layout:
  • Note: SilkString is also the C ABI shape for Silk regexp values (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;
  • SilkBytes for owned binary buffers returned by in-memory build APIs:

    typedef struct SilkBytes {
        uint8_t *ptr;
        int64_t  len;
    } SilkBytes;
    
  • SilkRange mirroring the Silk range primitive:

    typedef struct SilkRange {
        int64_t  start;
        int64_t  end;
        uint64_t flags;
    } SilkRange;
    

Notes:

  • The current linux/x86_64 backend subset passes and returns range values as three 8-byte scalar slots (start, end, flags).

  • flags is a bitfield:

  • bit 0: has_end (when unset, end is ignored),

  • bit 1: inclusive (only valid when has_end is 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:

  • SilkF128 stores the IEEE‑754 binary128 bit pattern. It is not C long double.

  • These types are passed and returned as two integer-like 8-byte slots in the current linux/x86_64 backend 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 common x86_64-*-linux-gnu triples such as x86_64-linux-gnu and x86_64-unknown-linux-gnu,
  • linux-x86_64-musl, and common x86_64-*-linux-musl triples such as x86_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 other wasm32 triples containing wasi).

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 under env.foo for wasm32-unknown-unknown, analogous to extern symbols in C.
  • String and other constant data are emitted into wasm data segments.

Entrypoint conventions:

  • wasm32-unknown-unknown:
  • when a valid executable main exists, it is exported as main for embedder use,
  • when no main exists, an export-only module is emitted that exports each supported export fn from the root package.
  • wasm32-wasi:
  • requires fn main () -> int (the main(argc, argv) form is not supported yet for WASI),
  • programs that need process arguments must read them from WASI inside main() (for example via std::args::{argc,argv,current}),
  • emits an exported _start () -> void wrapper that calls main and then imports/calls WASI proc_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_bytes fills *out_bytes with a pointer and length describing the produced artifact, and returns true.
  • The returned out_bytes->ptr is owned by libsilk.a and must be freed by calling silk_bytes_free(&bytes). Callers must not free the pointer with free() (or any other allocator).
  • silk_bytes_free is a no-op when passed NULL or when bytes->ptr is NULL; 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, and ios-simulator-x86_64 executable 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(...) and silk_compiler_build_to_bytes(...) now support that same iOS host-backed subset on Apple Silicon macOS,
  • the remaining explicit E4001 iOS 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_build always performs full front‑end validation for all modules added via silk_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:

  • import declarations must refer to packages that exist in the current module set (otherwise a resolver error is reported, such as "unknown imported package"),

  • exported let bindings 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; and export let answer: int = 42; in util allows let x: int = answer; in app),

  • imported exported functions (export fn) are callable across packages for the current scalar subset (both unqualified foo() and qualified pkg::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 referenced std::... 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_root when set, otherwise

  • SILK_STD_ROOT (environment variable) when set, otherwise

  • a std/ directory in the current working directory (development default), otherwise

  • ../share/silk/std relative to the current executable (installed default).

  • package-to-path mapping is deterministic:

  • std::foo::bar resolves to the file <std_root>/foo/bar.slk,

  • if the embedder explicitly provides a std::... module via silk_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 via make 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_LIB when set, otherwise

  • build/lib/silk/std/libsilk_std.a when using the in-repo std/ root, otherwise

  • ../lib/silk/std/libsilk_std.a relative to the current executable, otherwise

  • ../lib/libsilk_std.a relative 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, or lib/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/return in the wrong context, or other semantic violations), the call returns false and silk_compiler_last_error/silk_error_format provide 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 int and u64,

  • otherwise silk_compiler_build fails 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):

  • main is optional, but when more than one valid executable main exists in the module set, silk_compiler_build fails 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 their export declarations are treated as internal for that output.

  • within the current linux/x86_64 IR subset, string and regexp values are supported at ABI boundaries in a C-friendly SilkString { ptr, len } layout:

  • string/regexp parameters lower to two integer-like scalars in order (u64 pointer, then i64 byte length) and consume the normal integer argument locations (registers then stack),

  • string/regexp results return as two integer-like scalars in rax/rdx,

  • regexp values 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 regexp views are not caller-owned heap objects; only std::regex::RegExp.compile(...) produces runtime-owned regex bytecode,

  • when std::regex executes a foreign ABI-supplied regexp, the bundled runtime first validates the bytecode header/control-flow shape and reports malformed inputs as EXEC_ERR_INVALID_INPUT instead of entering the engine blindly,

  • when Silk code later frees or drops a regexp through 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 by silk_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/regexp expression subset:

  • string: string literals, let bindings of string, return of a string value, direct calls to string-returning helpers, and ==/!=/</<=/>/>= comparisons over string values (producing bool),

  • regexp: regex literals (/pattern/flags), let bindings of regexp, return of a regexp value, and direct calls between helpers that accept/return regexp,

  • other string operations (concatenation, indexing, etc.) are not implemented yet; higher-level regex matching lives in std::regex and is routed through ext calls.

  • within the current linux/x86_64 IR subset, i128/u128/f128 values are supported at ABI boundaries using the stable C99 { lo, hi } struct shapes:

  • parameters lower to two integer-like scalars (u64 lo, then u64/i64 hi) and consume integer argument locations,

  • results return as two integer-like scalars in rax/rdx,

  • f128 values are transported as raw IEEE binary128 bits in the two lanes (not via SSE registers).

  • within the current linux/x86_64 IR subset, a limited struct subset is supported at ABI boundaries:

  • within function bodies and internal helper calls, struct declarations 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 ext declarations and unnamed C-facing root-package export fn signatures; only opaque handle references (&Handle where Handle is struct 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 (u64 pointer, then i64 element 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_64 IR 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 Bool tag followed by the payload scalar slots: (tag, payload0, payload1, ...) with tag=0 for None and tag=1 for Some(...),

  • nested optionals (T??) lower by treating the payload slots as the full inner optional representation (for example int?? 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 tag as 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 limited string subset) and supported exported constants (export let/export const; scalar exports require an explicit type annotation and a literal initializer, and string exports may omit : string when the initializer is a string literal), and marking export fn declarations, supported exported constants, and a valid executable main (when present) as global symbols,

  • when the module set contains no supported globally-visible symbols (no supported export fn, no supported export let constants, and no valid executable main), silk_compiler_build still succeeds and writes a valid relocatable object with no globally-visible symbols,

  • for programs outside that subset (or on unsupported targets), silk_compiler_build returns false with an E4001 / E4002 formatted diagnostic (via silk_compiler_last_error / silk_error_format) and does not write an output file.

  • when lowering cannot isolate a narrower statement / expression span, that E4001 diagnostic 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 limited string subset) and supported exported constants (export let/export const; scalar exports require an explicit type annotation and a literal initializer, and string exports may omit : string when the initializer is a string literal), and marking export fn declarations, supported exported constants, and a valid executable main (when present) as global symbols,

  • when the module set contains no supported globally-visible symbols (no supported export fn, no supported export let constants, and no valid executable main), silk_compiler_build still 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_build returns false with an E4001 / E4002 formatted diagnostic (via silk_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 .so filename) for the supported IR subset, emitting supported functions (scalar-returning, void-returning, and a limited string subset) and supported exported constants (export let/export const; scalar exports require an explicit type annotation and a literal initializer, and string exports may omit : string when the initializer is a string literal), and marking export fn declarations, supported exported constants, and a valid executable main (when present) as dynamic global symbols,

  • when the module set contains no supported globally-visible symbols (no supported export fn, no supported export let constants, and no valid executable main), silk_compiler_build still succeeds and writes a valid shared library with an empty export set,

  • for programs outside that subset (or on unsupported targets), silk_compiler_build returns false with an E4001 / E4002 formatted diagnostic (via silk_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 main must be one of the following shapes:

  • zero or more let statements whose initializers are constant integer expressions, followed by exactly one return statement that returns a constant integer expression built only from:

  • integer literals,

  • the arithmetic operators +, -, *, /, and %,

  • and references to immutable let bindings (top‑level or local to main, 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 as export let or export const with the shape export <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 int when unannotated),

  • arguments at each call site are drawn from the same scalar expression subset as <expr> (including bool, char, Instant, Duration, fixed-width integers, and f32/f64 on linux/x86_64), with optionals (T?) supported for scalar payloads, string?, and optionals of the POD struct subset via None / 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 contains main (both foo() and pkg::foo() call forms are accepted initially for imported exports),

  • the helper body either:

  • consists only of scalar let bindings and a final return, or

  • ends in a simple if / else of 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 if statement whose condition is a boolean expression:

  • for the purely constant subset, the condition is a compile‑time boolean literal (true or false) and each branch body itself satisfies the same “constant lets + return constant integer expression” rule, and

  • on linux/x86_64, a slightly richer branching main shape 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 while loops that appear before the final return, each of which has:

  • a condition that is a compile‑time boolean literal (true or false),

  • for while false { ... }, a body that is ignored by the constant backend, and

  • for while true { ... }, a body consisting of zero or more constant let statements followed by a break;, 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_path names 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 main value:

  • ELF64 for linux-x86_64, linux-x86_64-musl, linux-aarch64, linux-aarch64-musl, and android-aarch64,

  • Mach-O 64-bit for macos-x86_64, macos-aarch64, ios-aarch64, ios-simulator-aarch64, and ios-simulator-x86_64,

  • PE32+ for windows-x86_64 and windows-aarch64,

  • returns true on success with no last error recorded.

  • when the program is front‑end valid but outside this subset (e.g. main contains 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 returns false and records either an E4001 / E4002 diagnostic (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_format returns 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. SilkString whose 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.

Source on GitHub · View Markdown