Skip to content

Latest commit

 

History

History
493 lines (367 loc) · 15 KB

File metadata and controls

493 lines (367 loc) · 15 KB

An Overview of Silicon

A tour of the Silicon language, in the spirit of the Go Tour and the Odin Overview. It moves from "hello world" through types, control flow, data, generics, error handling, memory, the standard library, platforms, and Silicon's metaprogramming system, strata.

Silicon compiles to WebAssembly (and, via QBE, to native binaries). Everything below runs with the sgl compiler — install it from Getting Started.

Convention: a value binding is bare (name := value, immutable); @mut marks a mutable binding, @fn a function, and @type a type. Calls are parenthesised (print(x)), including built-ins like @if(…)/@loop(…). A \\ name (Types) -> Ret line above a definition is its signature line.


1. Hello, world

@use 'io';

@fn main := {
    print('Hello, Silicon!');
    0
};
main();
sgl run hello.si        # Hello, Silicon!

@use 'io' pulls in the standard-library I/O module. print writes a line to stdout. The top-level main(); is the program's entry point.

The toolchain:

sgl init my-project     # scaffold sgl.toml + src/main.si
sgl run                 # compile to wasm + execute (wasmtime)
sgl build               # produce a .wasm
sgl build --native      # produce a native binary (via QBE)
sgl check               # type-check only, with caret diagnostics

2. Comments

# Line comments start with '#'.
## Doc comments start with '##' and attach to the following definition.

3. Values and basic types

Type Description Literals
Int target-sized signed integer (i32 on wasm32) 0, 42, 0 - 7
Int64 64-bit signed integer @toInt64(42)
Float 32-bit float (f32) 3.14, 0.0
Bool boolean @true, @false
String length-prefixed UTF-8 'hello'

Conversions are explicit — there is no implicit numeric coercion:

n := @toInt(3.75);        # Float -> Int (truncates) = 3
f := @toFloat(10);        # Int -> Float = 10.0
big := @toInt64(1);       # Int -> Int64

4. Variables and bindings

Silicon has two value bindings, distinguished by mutability:

PI := 3.14159;               # immutable — a module constant (cannot be reassigned)
@mut count := 0;             # mutable
count = count + 1;           # reassign — only @mut can be reassigned

A bare name := value is an immutable binding — a module constant at the top level, an immutable local inside a function. @mut name := value is mutable (a module variable at the top level, a mutable local inside a function). Binding types are always inferred — there is no type annotation on a variable. To pin types, annotate the function with a \\ signature line:

\\ area (Int, Int) -> Int
@fn area w, h := { w * h };

5. Operators

+  -  *  /  %                    # arithmetic (Int and Float)
== != < <= > >=                  # comparison -> Bool
||                               # logical OR
++                               # string concatenation

Operators dispatch on operand type: + on two Floats emits f32.add, on two Ints i32.add. Both operands must be the same type. Logical AND is written as nested @if (there is no && operator).

msg := 'foo' ++ '-' ++ int_to_str(99);     # "foo-99"

6. Control flow

@if is an expression-or-statement: @if(cond, { then }, { else }).

@if(x == 0, { print('zero') }, { print('nonzero') });

@loop is the one loop keyword; it dispatches on the number of operands before the { body } block — a bare block loops forever, one operand is a while condition, and two or three iterate over a lo..hi range or a Vec (@break() exits early):

@mut i := 0;
@loop(i < 10, { print_int(i); i = i + 1 });   # while

@loop(v, 0..10, { print_int(v) });            # half-open range (10 excluded)
@loop(i, v, xs, { … });                       # Vec: index + element

See Conditions & loops and ADR 0016 for every form and the v1 scope.

@return returns early from a function:

\\ safe_div (Int, Int) -> Int
@fn safe_div x, y := {
    @if(y == 0, { @return(0) }, {});
    x / y
};

@match destructures sum types (see §9).


7. Functions

\\ add (Int, Int) -> Int
@fn add a, b := { a + b };

s := add(2, 3);                  # call with parens, args comma-separated

A single-expression body needs no braces:

\\ square (Int) -> Int
@fn square n := n * n;

Export functions to the host with @export, and import host functions with @extern:

@export add;

\\ @extern puts (String) -> Int;
puts('from C');

First-class functions and closures

A top-level @fn can be taken as a value with @fnref and called indirectly with @call_indirect:

\\ apply (Int, Int) -> Int
@fn apply f, x := { @call_indirect(f, x) };   # f is a function-table index
apply(@fnref(square), 7);                      # 49

A closure captures surrounding values by value. @closure(body_fn, …caps) binds the leading parameters of body_fn to the captures; @call_closure invokes it with the remaining arguments:

\\ scale (Int, Int) -> Int
@fn scale factor, x := { factor * x };          # leading param `factor` = the capture
\\ make_scaler (Int) -> Int
@fn make_scaler factor := {
    s := @closure(scale, factor);               # capture `factor` by value
    @call_closure(s, 5)                          # = scale(factor, 5)
};

Closures can be passed to higher-order Silicon functions, and — wrapped in @export_callback — handed to the host as a callback it stores and invokes later, with the captured environment intact (the basis for events like addEventListener/setTimeout). Under --target=wasm-gc the closure environment is engine-garbage-collected. Capture is by-value/immutable in v1.0.


8. Structs

@type Point := { x Int, y Int };

@fn main := {
    p := Point(3, 4);            # construct
    print_int(p.x + p.y);        # field access -> 7
    0
};

9. Sum types, enums, and pattern matching

A sum type has variants, each marked with $ and an optional payload:

@type Shape := $Circle r Int | $Square s Int;

\\ size (Shape) -> Int
@fn size sh := {
    @match(sh,
        $Circle r, { r },
        $Square s, { s })
};

size(Circle(10));               # constructors are Circle / Square

An enum is a set of payload-free variants:

@type Color := $Red | $Green | $Blue;

\\ code (Color) -> Int
@fn code c := {
    @match(c,
        $Red,   { 1 },
        $Green, { 2 },
        $Blue,  { 3 })
};

@match is an ordinary call: each arm is a pattern argument followed by a { … } block body. Pattern alternation shares a body across variants: $Red | $Green, { 1 }.


10. Generics

Silicon uses HM-lite — Hindley–Milner inference restricted to declared polymorphism (@fn[T], @type[T]). Call sites infer the type arguments; no explicit [Int] needed.

\\ id[T] (T) -> T
@fn id x := x;

id(99);                          # T inferred as Int
id('hi');                        # T inferred as String

Parametric sum types power Option and Result:

@type Option[T] := $Some value T | $None;

11. Error handling — Option and Result

No exceptions. Absence is Option[T]; fallible results are Result[T, E].

@use 'option';
@use 'result';

picked := option_unwrap_or(Some(42), 0);     # 42
fallen := option_unwrap_or(None(), 7);       # 7

r := Ok(42);
v := result_unwrap_or(r, 0);                  # 42

Helpers: option_is_some, option_is_none, result_is_ok, result_is_err.


12. Memory

The default allocator is a bump allocator — fast, never frees. Fine for run-and-exit programs. For long-running loops (servers, REPLs), wrap per-iteration work in @with_arena({ … }) so each iteration's allocations are reclaimed, and use @move_to_parent_arena(value) to keep a value the parent scope needs:

response := @with_arena({
    body := build_response(req);
    @move_to_parent_arena(body)
});

Rc[T] (reference counting) is available via @use 'rc'. See memory.md for the full model.


13. The standard library

The stdlib wraps low-level WASI and intrinsics behind ergonomic snake_case functions, so basic programs read like a high-level language. Full reference: stdlib.md.

@use 'io';      # print, println, print_int/float/bool, eprint, read_line, exit
@use 'num';     # int_to_str, str_to_int, int_abs/min/max/clamp/pow, float_*
@use 'str';     # str_eq, str_contains, str_slice, str_repeat, str_index_of, …
@use 'mem';     # align_up, mem_fill, mem_eq  (portable; also wasm-gc)
@use 'heap';    # heap_align  (bump-pointer alignment; wasm-mvp only)

A taste:

@use 'io';
@use 'str';

@fn main := {
    name := read_line();                             # input
    print('Hello, ' ++ name ++ '!');
    print_int(str_byte_len(name));
    @if(str_contains(name, 'a'), { print('has an a') }, {});
    0
};
main();

Data structures: vec (Vec[Int]), hashmap, slice, plus option / result.


14. Platforms

A platform is the host a program runs on — orthogonal to the wasm memory-model --target (wasm-mvp vs wasm-gc). Choose it with --platform or sgl.toml [build] platform.

Platform How Output Strings
native / WASI (default) sgl run (wasmtime) / sgl build --native (QBE) @use 'io'print, read_line linear-memory String
bun sgl run --platform=bun console::log, web::console_log_f JSString + String bridge
web sgl run --platform=web (browser) web::canvas_*, web::set_html JSString + String

The pure stdlib modules (mem, num, str) compile on all platforms; only I/O differs. On the JS host, JavaScript strings are the JSString type (WASM JS String Builtins) with a StringJSString bridge — see js-string-builtins.md. The bun/web platforms also reach the host's object, async, and event APIs — see §16, Calling host APIs.

# bun platform — the portable stdlib + the JS console
@use 'num';
web::console_log_str(int_to_str(int_pow(2, 16)));     # 65536

15. Strata — Silicon's metaprogramming

Silicon's grammar is intentionally tiny and stable. Almost every "keyword" and "operator" — @if, @loop, @mut, +, ==, ++ — is not baked into the grammar. They are defined as strata: data-driven Silicon declarations, loaded into the compiler, that say how a construct lowers.

# The '++' string-concat operator is a stratum:
@stratum Concat := {
    Compiler::register::operator('++');
    Compiler::on::lower('++', Concat_lower);
};

This means you can add your own keywords and operators without touching the grammar or the compiler's TypeScript — you write a stratum that calls Compiler::*(…) to register and lower the new construct. The built-in language is itself a library of strata under src/strata/. See the Strata authoring guide and strata.md.

Why this matters: it keeps the core language small and bootstrappable while letting the surface syntax grow as a library — the same philosophy as the standard library wrapping WASI.


16. Calling host APIs (FFI)

On the bun/web platforms a Silicon program can reach the host's modern API surface. @extern imports a host function; the import lives in module env by default, or in a named host module with mod::field:

\\ @extern dom::get_element_by_id (JSString) -> JSValue;   # imports "dom"."get_element_by_id"

Object handles. Two opaque externref handle types let host objects cross the boundary without copying:

  • JSString — a JavaScript string (distinct from Silicon's linear-memory UTF-8 String), backed by the WASM JS String Builtins.
  • JSValueany host object (a Response, a Uint8Array, a DOM node, a parsed-JSON value). It is opaque to the guest and engine-garbage-collected.

Handles thread between functions but are never introspected by the guest — they go back to the host. (externref needs a JS host, so these are --platform=web|bun only; a --native build rejects them at compile time.)

Generated built-in modules. A growing set of host APIs is generated from their real specs (Web IDL, @types/node, bun-types) and ships as built-in modules, callable as module::fn:

os::platform();                                  # "linux"  (Node node:os, Tier-0)
u := url::create('https://a.b/p?x=1');           # construct a URL object (handle)
q := url_search_params::get(url::search_params(u), 'x');   # "1" — handles thread between modules
obj := json::parse(text);                        # JSValue handle; round-trips back out
json::stringify(obj);

Tiers: Tier-0 marshals linear String and runs on any host — the os/path string/scalar functions (platform/basename/…) are Tier-0 portable; Tier-1 (bun strings) and Tier-2 (json, the url/headers/text_encoder/ text_decoder constructed interfaces, and the os/path object readers such as path::parse / os::cpus) cross as zero-copy JSString/JSValue handles (web/bun only). os/path are thus mixed tier — a program calling only their string functions stays portable; calling an object reader needs a JS host (gated by E0010).

Async (@async / @await / @suspending). A Promise-returning host import is marked @suspending; a function that awaits one is @async; @await is the suspension point. The code reads straight-line:

\\ @suspending @extern bun::resolve (JSString, JSString) -> JSString;
\\ @async resolve_mod (JSString) -> JSString
@fn resolve_mod id := { @await(bun::resolve(id, id)) };

@await is only legal inside an @async body (diagnostic E0016 otherwise). At run time sgl run drives the suspension through a reactor — using the engine's JSPI where available (Bun 1.3+, V8) or a portable Asyncify fallback — so the same source runs on every engine.

Callbacks/events use closures: pass @export_callback(@closure(handler, …)) to a callback-taking host API (§7); the host calls it back on the event.


Where to go next