neuron-explorer
Qwen3.5‑4B 전용 조향형 뉴런 탐색기, 모든 MLP 뉴런을 밀어 결과를 보관·탐색
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기A statically typed language for server-side and network programming. spawn is M:N — green tasks on a worker pool, not a thread per connection. Accept, recv, and send park. Memory is a precise, non-moving, stop-the-world mark-sweep collector (runtime/sl_gc.c); cycles are collected.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A statically typed language built primarily for server-side and
network programming.
spawn is M:N — green tasks on a worker pool, not a thread per
connection. Accept, recv, and send park. Memory is a precise,
non-moving, stop-the-world mark-sweep collector (runtime/sl_gc.c);
cycles are collected. slangc emits C and your system cc builds the
binary.
Built and maintained by Dolphlabs — Dolph
Tech Limited.README 전문 보기 ↗A statically typed language built primarily for server-side and
network programming.
spawn is M:N — green tasks on a worker pool, not a thread per
connection. Accept, recv, and send park. Memory is a precise,
non-moving, stop-the-world mark-sweep collector (runtime/sl_gc.c);
cycles are collected. slangc emits C and your system cc builds the
binary.
Built and maintained by Dolphlabs — Dolph Tech Limited.
Full documentation site: https://slang.dolphlabs.com/
Build it locally with make docs (dependency-free Python 3) and serve
with make docs-serve. The site is generated from this repository --
these README sections, the compiler's own signature tables, and the
pub declarations in stdlib/ -- so it cannot drift from the code.
See www/README.md for the documentation convention
every slang package follows.
Reading with an agent? Start with /llms-small.txt: the whole language
on one page, about 3k tokens, with every example compiled by the test
suite. Every page has a Markdown twin at the same path, /llms.txt
indexes the site, /llms-full.txt is the whole thing as one document,
and /api.json is the machine-readable API index.
Install the compiler, then start a project:
git clone https://github.com/dolphlabs/slang && cd slang
sudo make install # /usr/local by default; PREFIX=~/.local works too
slangc new hello
cd hello
slangc main.sl --run # hello from hello
make install puts slangc in $(PREFIX)/bin and the runtime and
standard library in $(PREFIX)/lib/slang — both are needed, because
slangc splices its runtime C into every program it compiles. Remove it
all with make uninstall. make dist builds a relocatable tarball with
the same layout, which can be unpacked anywhere and run in place. Every
release has one for Linux
x86_64 and arm64 and macOS arm64 and x86_64, with their SHA-256 sums.
Platforms. CI builds and runs the full test suite on every merge to
main on Linux x86_64 (GCC), Linux arm64 (GCC) and macOS on Apple Silicon
(clang), and it is developed on macOS x86_64. Building needs a C compiler;
net over TLS, crypto and httpc over https also need OpenSSL (macOS:
brew install openssl; Debian/Ubuntu: apt install libssl-dev).
slangc finds OpenSSL itself: OPENSSL_DIR if set, then pkg-config, then
the standard Homebrew and MacPorts locations, then brew --prefix, then the
system headers. If none of those finds it and compilation fails, slangc says
so and suggests the fix, rather than leaving only the compiler's
"openssl/… file not found".
Working on the compiler itself:
make # build ./slangc against this working tree
make test # compile & run the example programs
make docs # rebuild the documentation site
Tests live in *_test.sl files and run with slangc test — covered in
the Testing section.
slangc new myapp # creates myapp/ with slang.project, main.sl, .gitignore
slangc new . # same, in the directory you are already in
slangc new writes slang.project but not slang.lock. The lock is
derived: slangc get generates it from the pkg lines in
slang.project, and a lock file for a project with no dependencies
records nothing. Cargo and Go draw the same line — cargo new writes
Cargo.toml and not Cargo.lock.
Scaffolding lives in slangc rather than a companion tool because the
compiler already owns both formats: it parses slang.project and writes
slang.lock. A separate tool would have to reimplement a grammar it
does not control.
Look up an API without leaving the terminal:
slangc doc # every package this directory can import
slangc doc http # its exported items, one line each
slangc doc builder.Str # one item: its comment, and a struct's fields and methods
slangc doc httpc client_post # the same, as two words
slangc doc http header # no such item: every item whose name contains
# "header" (any case), else whose signature or doc does
$ slangc doc builder.Str
gc struct Str {
parts: [str],
size: int,
}
methods:
// Appends, and returns the builder so writes chain.
fn write(self: Str, s: str) -> Str
...
Packages resolve exactly as import does from the current directory: a
local directory, a compiler-provided package, the standard library, then a
slang.project pin. An item's documentation is the run of // comments
directly above its pub declaration, which is also what the documentation
site shows. It exists so an agent can ask for the one signature it needs
instead of reading a page or the package's source.
Compile a slang program:
./slangc examples/hello/main.sl # produces ./main
./main # run it
Useful flags:
| Flag | Effect |
|---|---|
-o <name> | Choose the output binary name |
--emit-c | Only write the generated C file (no compilation) |
--keep-c | Keep the generated C file after compiling |
--run | Compile, then run it; exit with the program's own status |
--dev | Fast unoptimized build for edit-and-rerun (also SLANG_DEV=1) |
--json | Compile errors as JSON lines on stderr (see below) |
get | Fetch slang.project pins and write slang.lock |
Compiler errors name the file and line, the way gcc, Go and rustc do, and say what to write instead where the compiler can tell:
geometry/shapes.sl:4: error: struct 'geometry.Point' has no field 'yy' (did you mean 'y'?)
main.sl:14: error: undefined variable 'summ' (did you mean 'sum'?)
One compile reports the first error in every function, not just the
program's first, so three mistakes cost one round rather than three. With
--json, each error is one line of JSON on stderr for an editor or an
agent: {"file":"main.sl","line":14,"severity":"error","message":"..."}.
Want to see everything at once instead of one feature at a time? See
demo/ — a full server (dice game, guestbook wall, live
dashboard) exercising every tier: http/link, net.tls_*, json,
spawn/chan[T], proc graceful shutdown, local package imports,
and C interop, with a real HTML/CSS/JS frontend. cd demo && ./run.sh.
// variables with inferred types
let x = 10; // int (64-bit)
let pi = 3.14; // float (double); 1e9 and 6.02e23 are floats too
let name = "World"; // str
let ok = true; // bool
// arithmetic: + - * / % (int/int is integer division)
// bitwise: & | ^ ~ << >> (integers only; see below)
// compound assignment for every binary operator above
let mut_acc = 0;
mut_acc += 5;
mut_acc |= 1 << 3;
xs[i] *= 2;
p.count += 1;
println(x + y);
println(x / 2.0); // mixing int and float promotes to float
// strings concatenate with + ; numbers/bools convert automatically
println("Hello, " + name + "! " + x);
// string interpolation with ${expr} (any expression allowed)
println("pi doubled is ${pi * 2}");
// comparisons: == != < <= > >= (bools: == and != only) logic: && || !
if x > y && ok {
println("bigger");
} else if x == y {
println("equal");
} else {
println("smaller");
}
// loops
let i = 0;
while i < 5 {
print(i);
i = i + 1;
}
for j in 0..5 { // exclusive range: 0,1,2,3,4
print(j);
}
for k in 1..=3 { // inclusive range: 1,2,3
println("tick ${k}");
}
// switch: one arm runs, chosen by value. Labels are literals of the
// scrutinee's type (int family, bool, str, or enum); several labels
// may share an arm with commas. No fallthrough: arms never run into
// each other. `break` exits the switch, `continue` still targets an
// enclosing loop.
switch code {
case 200, 201 { println("ok"); }
case 404 { println("missing"); }
default { println("other"); } // optional, except that an enum
// switch without one must cover
// every variant
}
// switch is also an expression: every arm yields one value, all of
// the same type, and `default` is required unless an enum scrutinee
// covers every variant
let label: str = switch code {
case 200 { "ok" }
default { "other" }
};
// functions (parameters and return types are annotated)
fn add(a: int, b: int) -> int {
a + b // implicit return: last expression is the value
}
fn abs(n: int) -> int {
guard n >= 0 else {
return -n; // guard: early exit when the condition fails
}
n
}
// void functions just omit the return type
fn shout(msg: str) {
println(msg + "!!!");
}
// recursion works (functions are forward-declared automatically)
fn fib(n: int) -> int {
if n < 2 { return n; }
return fib(n - 1) + fib(n - 2);
}
print(expr) — print a value without a newlineprintln(expr) — print a value followed by a newlineBoth accept any single value of type int, any fixed-width integer,
float, f32, str, bool, or bytes (bytes are written raw, with
no escaping). A float prints as the shortest text that reads back as the
same number: 0.1 + 0.2 is 0.30000000000000004, 1.0 is 1,
500002000000.0 is 500002000000, 1e21 and up use an exponent, and
an f32 is as short as an f32 needs (0.1). to_str, inspect and
json.encode write floats the same way; json.encode writes NaN and
the infinities as null, since JSON has neither.
len(x) — length of a str, bytes, [T], or mappush(xs, v) / pop(xs) — append to / remove the last element of a listhas(m, k) — does map m contain key k?del(m, k) — remove key k (and its value) from map mto_str(x) — convert any scalar or bytes value to strinspect(x) — render any value as a str in JavaScript-console
style: strings double-quoted, lists [1, 2], maps and structs
{k: v}, opt as none / some(v), result as ok(v) / err(e),
enums as bare variant names. Nesting past eight levels prints ...,
so cyclic values terminate. Channels, mutexes, functions, and raw
pointers have no readable form and are a compile errorto_bytes(x) — copy a str or a wire into new bytesto_int(s) / to_float(s) — parse a str, returning
result[int, str] / result[float, str] (see below)to_le(n) / to_be(n) — integer to 8-byte little/big-endian bytesfrom_le(b) / from_be(b) — 8-byte little/big-endian bytes to integerexit(code) — terminate the process immediately with the given statusassert(cond) / assert(cond, msg) — fail if cond is false, with
msg (built only when the assertion fails) and the locationpanic(msg) — fail unconditionally with msg and the locationassert and panic end the current task. In a spawned task, the
message becomes the err of its join_wait, e.g.
x must be positive, got -3 at shapes.area:9; in the main task the program
exits with status 1. panic never returns, so it can be the only thing in
a function that must return a value, and it satisfies guard's requirement
that else leave the scope:
guard let user = find(id) else {
panic("user " + to_str(id) + " vanished between check and use");
}
some(v) / none / ok(v) / err(e) — construct opt/result values
(see below)bytes_ptr(b) — raw rawptr to a bytes buffer, for passing to
extern fns (see C interop below)make_chan(n) / chan_send(ch, v) / chan_recv(ch) / chan_close(ch)
— construct and use a chan[T] (see Concurrency below)make_mutex() / mutex_lock(m) / mutex_unlock(m) /
mutex_trylock(m) — construct and use a mutex (see Concurrency
below)join_wait(h) — wait for a join[T] from spawn f(...) (see
Concurrency below)| slang type | C type | Notes |
|---|---|---|
int | long long | 64-bit signed integer |
float | double | IEEE double |
str | const char * | NUL-terminated UTF-8 bytes |
bool | bool | true / false |
bytes | sl_bytes * | binary-safe byte sequence |
i8 i16 i32 i64 | int8_t .. int64_t | signed fixed-width ints |
u8 u16 u32 u64 | uint8_t .. uint64_t | unsigned fixed-width ints |
f32 | float | IEEE single precision |
[T] | sl_arr * | growable array of T |
map[K]V | sl_map * | insertion-ordered hash map |
| struct | sl_st_* | value record (copied) |
gc struct | sl_st_* * | GC'd heap record (shared) |
enum | int32_t | closed set of named constants (see below) |
opt[T] | sl_opt_* | optional value: some(v) / none, held by value (no allocation) |
result[T,E] | sl_res_* | fallible value: ok(v) / err(e), held by value (no allocation) |
duration | int64_t | nanosecond count (see time) |
rawptr | void * | opaque foreign pointer (C interop) |
i32 -> i64,
u32 -> u64, and unsigned into a wider signed type). Widening
toward floats is also implicit (i32 -> float, f32 -> float).as:
x as i8, n as u32, 3.9 as i32. Integer literals that fit the
target width may initialize/pass without a cast.(0 as u8) - (1 as u8) is 255; 300 as i8 is 44; a signed
type's minimum divided by -1 is the minimum again, remainder 0.
Float -> int casts truncate toward zero.to_int(s) and to_float(s) are the inverse of to_str, and they are
fallible, because parsing is:
let r = to_int(proc.getenv("PORT") ?? "8080");
guard let port = r else let e = err_of(r) {
log.error("PORT is not a number: " + e);
exit(2);
}
They are strict on purpose. Every one of these is an error, with a message saying which:
| input | to_int | C's atoi would give |
|---|---|---|
"8080" | 8080 | 8080 |
"abc" | err: not a base-10 integer | 0 |
"80x80" | err: not a base-10 integer | 80 |
"" | err: cannot parse an empty string as int | 0 |
" 12" | err: not a base-10 integer | 12 |
"9223372036854775808" | err: out of range for int | undefined |
Surrounding whitespace, 1_000, 0x10 and trailing characters are all
rejected. A caller who wants leniency can strings.trim first; a caller
who gets leniency they did not ask for cannot undo it. to_float
likewise rejects inf and nan, which strtod would accept and which
are almost never what a config value meant.
The error message does not echo the offending input — the caller already has it, and building that string would mean another allocation on the failure path.
Binary protocols are most of network programming, so the bit operators
are first-class: & | ^ ~ << >>, on any integer type.
// an HTTP/2 frame header, straight off the wire
let flen = (b[0] << 16) | (b[1] << 8) | b[2];
let ftype = b[3];
let flags = b[4];
let sid = ((b[5] & 0x7f) << 24) | (b[6] << 16) | (b[7] << 8) | b[8];
if flags & 0x01 != 0 { /* END_STREAM */ }
Integer literals come in decimal, hex (0xff, 0xFF) and binary
(0b1010), and _ may be used anywhere as a digit separator:
1_000_000, 0xff_ff, 0b1010_1010.
Float literals have a fraction, an exponent, or both: 3.14, 1e9,
6.02e23, 1.5E-7, 2e+3. An exponent alone makes a float, as in Go
and Rust, so 1e9 is 1000000000.0, not an int. Separators work in
floats too (1_000.5). A literal too large for a float (1e400) is a
compile error, and so is a nonzero one too small to be anything but 0
(1e-400); subnormals such as 4.9e-324 are kept.
A literal too large for i64 is a u64, not an overflowing int:
let mask = 18446744073709551615; gives a u64 holding that exact
value, and let x: int = 18446744073709551615; is a compile error
rather than a surprise. Anything past u64 is rejected at the point of
writing — integer literal does not fit in 64 bits. (Before this,
decimal literals ran through strtoll, which saturates: those two
literals and 99999999999999999999999 all silently became
9223372036854775807.)
Precedence follows C exactly, so an expression lifted from an RFC or a C reference implementation means the same thing here:
|| < && < | < ^ < & < == != < < <= > >= < << >> < + - < * / % < unary
Three things differ from C, all deliberately:
& is never ambiguous. Infix & is bitwise AND; the borrow forms
&x / &mut x are prefix-only, so the parser can always tell them apart.x & 1 == 1 footgun is a compile error. C parses that as
x & (1 == 1) and accepts it because bool is an int; slang rejects
it with "'&' requires integer operands (got int and bool)". Parenthesize
what you meant.x << n where n is negative or at least the width of x
reports shift count out of range at pkg.func:line, the same way
division by zero and an out-of-bounds index do — this matters when the
count came off the network. When the count is a constant already in
range (b[0] << 16, the normal case) the check is compiled out
entirely, so protocol code pays nothing for it.Compound assignment exists for every one of these: += -= *= /= %=
and &= |= ^= <<= >>=. x op= v means x = x op v, which evaluates the
target twice, so a side-effecting index is hoisted into a temporary
first and runs exactly once — xs[pop(q)] += 1 pops once, not twice.
Only the value being indexed has to be re-nameable: f()[0] += 1 is a
compile error, since naming f() twice would call it twice, and hoisting
it would mutate a copy for a value-type struct. Write that one out.
>> follows the operand's signedness: arithmetic (sign-preserving) on a
signed type, logical (zero-filling) on an unsigned one, exactly as in C.
& | ^ promote to the wider operand; a shift keeps the width of the
value being shifted, so x << n never silently widens a narrow x
because n happens to be an int.
let b = b"raw\x00bytes"; // binary-safe literal; \0 \xHH escapes
println(len(b)); // byte count, not strlen
println(b[0]); // indexing yields an int (0..255)
b[0] = 65; // mutable in place
let head = b[..2]; // slicing: b[a..b], b[..n], b[n..], b[..]
let both = b"ab" + b"cd"; // concatenation
if b == other { ... } // content equality via ==
for byte in b { ... } // iterate byte values
bytes values carry an explicit length and may contain NULs — safe for
network buffers and binary formats.
A non-empty literal is a fresh copy each time it is evaluated, since it can
be written through b[i] = v. An empty b"" allocates nothing: every
evaluation is the same shared empty value, which no operation can change.
[T]let xs = [10, 20, 30]; // inferred [int]
let empty: [str] = []; // an empty list needs a type from context
push(xs, 40); // grow (amortized O(1))
println(pop(xs)); // shrink from the end
xs[0] = 5; // bounds-checked index assignment
for x in xs { println(x); } // iteration
let ys = xs[0..2] + xs[1..]; // slicing + concatenation
let grid = [[1, 2], [3, 4]]; // nested lists
Indexing is bounds-checked at runtime; violations abort with a clear message.
[] has no element type of its own, so it takes one from what is
expected of it — the same way none does: an annotated let, a function
or method parameter, a struct field, a return, an assignment,
ok([])/some([]), push(grid, []), or an element of an outer list.
With nothing expected (let xs = [];) it is a compile error.
fn total(xs: [int]) -> int { return len(xs); }
total([]); // [int], from the parameter
let b = Basket { items: [] }; // from the field
map[K]Vlet scores: map[str]int = {"alice": 90, "bob": 85};
scores["carol"] = 78; // insert or overwrite
println(scores["alice"]); // lookup (missing key = runtime error)
println(len(scores)); // entry count
if has(scores, "dave") { ... } // membership test (no error)
del(scores, "bob"); // removal
let empty: map[int]str = {}; // an empty map needs a type from context
for k, v in scores { // iteration in insertion order
println(k + ": " + to_str(v));
}
Keys may be any integer type, str, bool, or enum; values may be any type,
including structs and lists. Backed by an open-addressing hash table
(FNV-1a) that keeps entries in insertion order and grows automatically
at 75% load. del moves the entries after the removed one back along
their probe run (backward-shift deletion, no tombstones), so every other
key stays reachable and the table never fills with dead slots. A key
deleted and inserted again goes to the end of the iteration order. del
takes time linear in the map's size, since it keeps that order.
println only prints scalars — a list or map passed to it is a compile
error. inspect(x) renders any value as a str in the style of a
JavaScript console, so composite data can be logged, asserted on, or
embedded in messages:
println(inspect([1, 2, 3])); // [1, 2, 3]
println(inspect({"name": "ada"})); // {"name": "ada"}
println(inspect(Point { x: 1, y: 2 })); // {x: 1, y: 2}
println(inspect(some(7))); // some(7), none for the empty case
println(inspect(Color.Red)); // Red — enums print bare
Strings render double-quoted with escapes ("a\"b"), so text never
reads as a number and emptiness is visible (""). bytes render as
quoted base64, and faults name themselves (fault("timeout")).
struct Point {
x: int,
y: int,
}
impl Point {
fn sum(self: Point) -> int {
return self.x + self.y;
}
fn moved(self: Point, dx: int, dy: int) -> Point {
return Point { x: self.x + dx, y: self.y + dy };
}
}
let p = Point { x: 3, y: 4 };
println(p.sum()); // method call; self passed implicitly
p.x = 10; // field mutation
let q = p.moved(1, 2); // methods can build and return structs
struct Rect {
tl: Point,
br: Point,
}
let r = Rect { tl: Point { x: 0, y: 0 }, br: Point { x: 4, y: 5 } };
println(r.tl.y); // nested field chains
r.br.x = 6;
let pts: [Point] = [p, q]; // structs compose with lists & maps
push(pts, r.tl);
Struct literals must supply every field exactly once, with types
checked. A struct may be declared after one that holds it by value. Methods live in top-level impl Name { ... } blocks; mark a
method pub fn to export it to importing packages. A method's name
belongs to its struct: it may match a package-level function or another
struct's method (impl Client { fn get } beside fn get), and a bare
call get(x) always means the function. Structs are
values: assignment copies, including any str / list / map /
opt / result / gc struct fields (shallow — the heap objects
are shared). Use gc struct when the record itself should be a
shared heap object.
A method can be called on any expression, not only on a variable, so calls
chain: p.moved(1, 2).sum(), make().sum(), points[0].sum(),
scores["ann"].sum(), line.tl.sum(). The receiver is evaluated exactly once,
before the arguments, which run left to right. Two things are not allowed on
such a receiver, and both say what to do instead: a method that returns a
reference (fn get(self: &Bag) -> &int) — the reference would have no owner,
so bind the receiver to a variable first — and the arena, link and trip
methods (a.alloc(..), conn.send(..), t.pull()), which are only callable on
a variable.
own T is uniquely owned: assignment and passing move, and
use-after-move is a compile error. A moved binding can be reinitialized.
own is freed when its binding goes out of scope unless it was moved.
A struct can take type parameters, written in brackets like the built-in
opt[T] and map[K]V:
struct Box[T] {
v: T,
}
struct Pair[K, V] {
k: K,
v: V,
}
gc struct Node[T] {
val: T,
next: opt[Node[T]], // a list: recursion goes through opt
}
let a = Box { v: 41 }; // T inferred from the field: Box[int]
let b: Box[str] = Box[str] { v: "hi" }; // or written out
let p = Pair { k: 1, v: Point { x: 3, y: 4 } };
let n = Box { v: Box { v: 7 } }; // Box[Box[int]]
fn unwrap(b: Box[int]) -> int { return b.v; }
Box[int] is an ordinary struct that the compiler writes out the first
time the program names it, so it costs exactly what a hand-written IntBox
does: the same layout, the same C, no boxing and no runtime type
information. Two instances of one template are two different types
(Box[int] is not Box[str]), and instances work anywhere a type does,
including inside [T], map, opt, result, chan, fn types,
json.encode / json.decode (of a struct or gc struct), and across packages
(stash.Stack[Thing], where Thing is the importing package's own type).
A literal infers its arguments from its fields, so it needs at least one
field whose value fixes each parameter. Box { v: none } or
Bag { items: [] } cannot say what T is; write Box[int] { v: none }.
Type arguments are never written at a call site or on a literal's name
unless the inference has nothing to go on.
Each instance is checked when it is made, with the arguments in place: a
parameter is unconstrained, and what a field can do with it is decided by
the type it turns out to be. An error inside a template therefore names the
instance and where it was asked for
(... (in main.Keyed[float], requested at line 9)), and a template nobody
instantiates is not checked at all.
A generic struct has methods like any other, in an impl block that
declares the parameters:
impl Box[T] {
fn get(self: Box[T]) -> T {
return self.v;
}
pub fn apply(self: Box[T], f: fn(T) -> T) -> Box[T] {
return Box[T] { v: f(self.v) };
}
fn doubled(self: Box[T]) -> int {
return self.v * 2; // only ever asked for on a Box of numbers
}
}
println(Box { v: 21 }.doubled()); // 42
println(Box { v: "hi" }.get()); // "hi" -- doubled is never checked here
The impl block may name its parameters whatever it likes (impl Pair[A, B]
for struct Pair[K, V]); they match by position. pub fn exports a method,
as it does elsewhere.
A method is checked when an instance asks for it, not when it is
declared. doubled above multiplies, which Box[str] cannot do — and that
is fine, because nothing calls doubled on a Box[str]. This is what makes
unbounded type parameters usable without interfaces, and it is why an error
in a method body names the instance and the line that asked for it:
main.sl:5: error: unsupported operand types for '*': str and int (in main.Box[str].doubled, requested at line 9)
Not yet supported, and each says so when used: lifetime parameters on a
generic struct or on one of its methods, and a generic method's own extra
parameters (fn map[U](self: Box[T]) -> Box[U]).
A plain function can take type parameters too. There is no first[int](xs)
at a call site — a type argument is only ever written in a type position —
so every one is inferred, by unifying each parameter's declared type against
the argument's actual type:
fn first[T](xs: [T]) -> T {
return xs[0];
}
fn choose_second[A, B](a: A, b: B) -> B {
return b;
}
println(first([1, 2, 3])); // 1, T = int
println(first(["a", "b", "c"])); // "a" -- a different instance, T = str
println(choose_second(1, "two")); // "two"
Unification walks through containers, not just a bare parameter, so T is
found inside [T], Box[T], opt[T] and the rest exactly where it sits:
struct Box[T] {
v: T,
}
fn unbox[T](b: Box[T]) -> T {
return b.v;
}
fn zip_first[A, B](pairs: [Box[A]], extra: B) -> A {
return pairs[0].v;
}
A type parameter that only the return type mentions cannot be found in any
argument, so it is inferred from the expected type instead, the same
mechanism none and [] already use — which means it only reaches a
return type shaped like opt[T] / result / [T] / chan / join:
fn empty_of[T]() -> [T] {
return [];
}
let xs: [int] = empty_of(); // T = int, from the let's type
A parameter only a scalar or struct return type mentions has nothing to infer it from; write a plain function that calls the generic one with a concrete type argument, and use that instead.
Each combination of argument types is its own instance, checked the first
time the program calls it that way — same rule as a generic struct's
methods, including the instantiation note on an error inside one. A generic
function can call another, including itself, and two instances of one
template used in the same expression do not share type parameters
(first([identity(1), identity(2)]) + first([identity(3)]) is two separate
identity instances and two separate first instances).
spawn takes a generic function like any other call — the arguments fix
the type parameters first, and what is spawned is the one instance they
resolve to:
fn handle[T](conn: T, id: int) { ... }
spawn handle(conn, 1); // spawns handle[Conn], not a template
A spawn has no annotated let to infer from, so a parameter only the
return type mentions cannot be resolved there, and says so.
Not yet supported: extern fn cannot be generic, and neither can it declare
lifetime parameters. A generic function is not one function until its type
is chosen, so it cannot be used as a bare value — write a plain function
that calls it with a concrete type, and use that instead. spawn does not
take a method, generic or not.
A closed, i32-backed set of named constants:
enum Status {
Pending,
Paid,
Shipped,
Delivered,
Cancelled,
}
// explicit values are allowed per variant; unwritten ones auto-increment
// from the previous one (0-based). Duplicate variant names and duplicate
// explicit values within one enum are both compile errors.
enum PgType {
Bool = 16,
Bytea = 17,
Int8 = 20,
}
let s: Status = Status.Paid; // Type.Variant access
println(s == Status.Paid); // true -- == is only defined between
// the same enum type (no <, <=, >, >=)
println(s); // "Paid" -- to_str/println/+ all render
// the exact declared variant name
println(s as i32); // 1 -- enum -> its backing i32 is a
// safe, total `as` cast (one direction
// only: not every int is a valid variant,
// so int -> enum goes through from_int
// below instead, which can fail)
// fallible, like to_int(s): a result, not a silent wrap
let r: result[Status, str] = Status.from_int(2);
guard let s2 = r else let e = err_of(r) {
log.error("bad status: " + e); // "2 is not a valid Status" (approx)
return;
}
let r2: result[Status, str] = Status.from_str("Shipped");
An enum works as a struct field and as a map[K]V key (alongside
integers, str and bool), and json.encode/json.decode support it
as a field type, on the wire as the exact variant name string:
import "json";
gc struct Order { id: int, status: Status }
let o = Order{ id: 1, status: Status.Shipped };
json.encode(o); // {"id":1,"status":"Shipped"}
let counts: map[Status]int = {};
counts[Status.Paid] = 3;
An exported enum is usable from another package, variants and all:
// orders/orders.sl
pub enum Status { Pending, Paid, Shipped }
// main.sl
import "orders";
let o = orders.Order{ id: 1, status: orders.Status.Paid };
println(o.status == orders.Status.Paid);
let r: result[orders.Status, str] = orders.Status.from_str("Shipped");
An enum without pub is private to its package, like any other type.
There is no per-variant wire label yet (to_str/JSON always use the
declared name as written), no explicit backing-width syntax
(enum Name: i64 { ... }), and no payload-carrying variants — enums
here are a closed set of names, not a tagged union. Branching on one
uses switch, which checks exhaustiveness: an enum switch without a
default arm must list every variant.
fn div10(n: int) -> opt[int] {
if n % 10 == 0 { return some(n / 10); }
return none;
}
fn parse_small(s: str) -> result[i32, str] {
if s == "big" { return err("value too large"); }
return ok(7);
}
// guard let unwraps the happy path and binds it for the rest of the
// block. The else branch must leave the scope -- return, break,
// continue, exit(..), panic(..), or a call to a function that never
// returns -- since the bound name has no value to fall back to; the
// compiler rejects one that can fall through. `else let e = err_of(r)`
// binds the error value for `result[T, E]` so failures stay visible.
fn safe_div(n: int) -> int {
guard let v = div10(n) else {
return -1;
}
return v;
}
fn load_config(path: str) -> str {
let r: result[str, str] = read_file(path);
guard let body = r else let e = err_of(r) {
log.warn("config load failed: " + e);
return "";
}
return body;
}
// if let handles both outcomes and carries on: each binding lives only
// in its own branch, and either branch may fall through
if let v = div10(40) {
println(v); // 4
} else {
println("not a multiple of ten");
}
let pr = parse_small("big");
if let n = pr {
println(n);
} else let e = err_of(pr) {
println("rejected: " + e); // rejected: value too large
}
// ?? recovers from none / err with a fallback value
println(div10(41) ?? -1); // -1 (none)
println(parse_small("big") ?? -1); // -1 (err)
// `fault` is the closed 5-kind network/runtime failure enum:
// fault_timeout / fault_reset / fault_closed / fault_io / fault_refused.
// `==` and `fault_kind` only see the kind. `fault_op` and `fault_code`
// carry context: the op name ("recv", "connect", "dial", ...) and the
// errno value (0 when none applies). `to_str` / `+` / `println` render
// the full "op detail (code N)" form, so failures stay debuggable.
let f = fault_io();
println(fault_kind(f)); // 4
println(fault_op(f)); // "" (hand-built, no op)
println(fault_code(f)); // 0
// bare 'none' / 'err(...)' need an annotated binding to infer their
// other type parameter
let nothing: opt[str] = none;
let bad: result[str, str] = err("boom");
A guard's else may end in a helper of your own, such as a die(msg) that
prints and calls exit: the compiler works out that a function never
returns when it has no return and every path ends in exit, panic, or
another such function, across packages. It only counts a plain call by
name, so a function value or method is never assumed to diverge; end
that else with an explicit return instead.
Panics (out-of-bounds index, division by zero, err_of on ok, missing
map key) carry pkg.func:line: list index out of bounds at main.foo:12. A panicking spawned task reports through stderr and its
join_wait surfaces the same string as err, so failures stay visible
across task boundaries. A crash below the language (a segfault or bus
error in C interop, or a task overflowing its stack) prints one line,
slang: fatal SIGSEGV at address 0x..., pc 0x... (image+0x...), naming a
stack overflow when that is what it was, and the process then dies of the
same signal: the exit status and any core dump are unchanged. The image
offset, printed when the fault is in the program's own code, names the
function after the fact: addr2line -fe prog 0x... on Linux, atos -o prog -l 0x100000000 <0x100000000 + offset> on macOS.
opt[T] and result[T, E] are monomorphized per distinct type
argument (one C struct per instantiation actually used). Constructing
none/err(...) without enough context to infer the missing type
parameter is a compile error.
opt vs result vs faultopt[T] — the value may legitimately be absent (none). Lookup
misses, optional config, end of a drained channel. Absence is not
failure; ?? supplies the default.result[T, E] — the operation can fail with a descriptive error
(err(e)). Parsing, validation, anything where the caller needs to
know why. E is usually str; guard let x = r else let e = err_of(r) keeps the reason visible.fault — the operation hit the environment: timeout, reset,
closed connection, refused dial, IO error. A closed 5-kind enum
(fault_timeout / fault_reset / fault_closed / fault_io /
fault_refused), comparable with == and convertible with
to_str / +. Use it when the failure is about the world, not
the data.Rule of thumb: absent data is opt, bad data is result[_, str],
bad world is result[_, fault]. Never collapse a descriptive str
error into a bare fault_io() at a boundary — that is where
debuggability goes to die (see http.read below).
A fn type holds a function. fn(A, B) -> R for one that returns a
value, fn(A) for one that returns nothing:
fn double(x: int) -> int { return x * 2; }
fn triple(x: int) -> int { return x * 3; }
let f: fn(int) -> int = double; // annotated
let g = triple; // or inferred from the function
println(f(21)); // 42
They work as parameters, return values, struct fields, list and map
elements — which is what makes a dispatch table possible instead of a
chain of string comparisons (demo/samplex/server.sl routes this way):
gc struct Route {
method: str,
path: str,
handler: fn(State, http.Request, int) -> http.Response,
}
let routes: [Route] = [
Route { method: "GET", path: "/api/tasks", handler: list_tasks },
Route { method: "POST", path: "/api/tasks", handler: create_task }
];
for i in 0..len(routes) {
if routes[i].method == req.method && routes[i].path == req.path {
return routes[i].handler(st, req, -1);
}
}
Anything holding a function value is callable directly —
routes[i].handler(...), by_name["parse"](...), pick(true)(4).
These are not closures, and that is the point. A function value
always names a top-level function; nothing is captured. There is no
environment to allocate, trace, or reason about, so a fn value is
exactly a C function pointer — it names code, never the heap, and the
collector ignores it entirely. Anything a handler needs is passed to
it, which is the same rule spawn already follows.
Two consequences worth knowing:
fn(Counter) -> int would be a
lie about its arity. Wrap it in a plain function.let scale = ...
in scope means scale refers to the binding, never to fn scale.spawn takes a function value too — spawn handlers[i](job); — see
Concurrency below.
time, net, json, proc, fs, log, crypto, sql, regex,
os, io, strings, encoding, compress and math are compiler-provided native packages — no source files, just
import "time"; / import "net"; / import "json"; / import "proc";
/ import "fs"; / import "log"; / import "crypto"; / import "sql";
/ import "regex"; / import "io"; / import "encoding"; like any other package.
http, httpc, http2, pg, flags, builder, byteutil, sort, math/stats and math/numtheory are slang-source stdlib packages under stdlib/.
import "http" / import "byteutil" resolve to a local directory first,
then a native package, then stdlib/<path> (SLANG_STDLIB or the
compiler's SLANG_STDLIB_DIR).
timeimport "time";
let t0 = time.mono(); // monotonic clock; a `duration` (int64 ns)
time.sleep(20000000); // sleep for a duration (ns)
let elapsed = time.mono() - t0; // duration arithmetic
let deadline = time.mono() + 5000000; // timeout math for net calls
let wall = time.wall(); // unix epoch time in nanoseconds
let d = time.http_date(); // "Tue, 13 Oct 2026 09:30:15 GMT", the current second
time.put_http_date(w, off) writes the same 29 bytes into a wire and
returns off + 29 (with a zero-length wire it only counts): what http
uses for Date. For dates other than now, use timez.
timezCalendar time, written in slang on top of the native time clock:
instants, dates, durations, text formats, time zones, test clocks and
cron. Import "timez".
import "timez";
let t = timez.now(); // a Time: an instant, UTC
println(t.rfc3339()); // 2026-10-13T09:30:15Z
println(t.rfc3339_milli()); // 2026-10-13T09:30:15.000Z (toISOString)
println(t.http_date()); // Tue, 13 Oct 2026 09:30:15 GMT
println(t.format("%A %e %B %Y, %H:%M")); // Tuesday 13 October 2026, 09:30
let r = timez.parse_rfc3339("2026-10-13T11:30:15+02:00"); // offsets applied
let due = t.date().add_months(1); // Jan 31 + 1 month = Feb 28/29
let ttl = timez.parse_duration("1h30m") ?? 0; // nanoseconds; "7d" works too
println(timez.relative(t.add(-3 * timez.MINUTE), t)); // 3 minutes ago
let zr = timez.load_zone("Europe/Berlin"); // the system's tz database
guard let berlin = zr else let e = err_of(zr) { println(e); exit(1); }
println(t.in_zone(berlin).format("%H:%M %Z")); // 11:30 CEST
let cr = timez.parse_cron("*/15 9-17 * * MON-FRI");
guard let cron = cr else let e = err_of(cr) { println(e); exit(1); }
let next = cron.next_after(t, berlin); // opt[Time], DST-aware
Values.
Time is Unix seconds plus nanoseconds, so it spans 0001-01-01 to
9999-12-31 and beyond. 64-bit nanoseconds stop at 1677-2262.Date is a calendar day: birthdays, due dates, report days.int nanoseconds, the unit time.sleep and deadlines
take. Constants run from NANOSECOND to HOUR and DAY (24 hours).Calendar.
year() to nanosecond(), weekday(), year_day(),
iso_week(), is_leap_year, days_in_month.add(d), sub(t), and add_days, add_months and
add_years, which clamp to the end of the month.truncate(d), round(d), and start_of_* and
end_of_* for minute, hour, day, week (Monday), month and year.before, after, equal, compare, min, max.days_until, and years_until, which counts an age by whether
the anniversary has passed.Text.
rfc3339, rfc3339_milli, rfc3339_nano and rfc3339_at(offset).parse_rfc3339 is strict: it refuses a timestamp with no offset,
because one without names no instant.parse_date.http_date, and parse_http_date for all three forms RFC 9110 has
recipients accept.rfc2822 and parse_rfc2822.format(layout): strftime directives, locale-free.parse_duration and format_duration, Go's format plus d.relative.Time zones.
load_zone(name) reads the IANA database, from $ZONEINFO
or /usr/share/zoneinfo. It follows each file's POSIX-TZ rule past its
last transition, and refuses names that could reach other files. A
missing database is an error that says how to install it, never a
silent UTC. fixed_zone(name, offset) gives a zone at a fixed offset.t.in_zone(z) gives a ZonedTime: local fields, abbrev,
is_dst, rfc3339 with the offset, and format with real %z and %Z.add_days, add_months and start_of_day work in
local wall time, so a day across a DST change is 23 or 25 hours.zoned(z, y, m, d, h, mi, s, how) converts a
local wall time to an instant. how decides the skipped and repeated
hours: COMPATIBLE moves forward through a gap and takes the earlier of
an overlap, as Temporal and java.time do. The others are EARLIER,
LATER and REJECT.Operations.
system_clock() / fixed_clock(t): a Clock to pass in state, so
tests advance and set the time instead of sleeping.start_stopwatch(), which measures on the monotonic clock.interval(a, b): half-open, with contains, overlaps and
intersection.deadline(t) gives the until that socket and channel calls take.parse_cron: five fields, names, steps and @daily macros. When
both day fields are set, a day matches either (Vixie). next_after(t, zone) evaluates in the zone's wall time: a run in a skipped hour
happens when the clock reaches it, and a repeated hour runs once.In JSON and Postgres. A timez.Time field of a struct is written by
json.encode as an RFC 3339 string and read back by json.decode (see
json). pg.arg_time(t) binds one for a timestamptz parameter and
pg.get_time(rows, r, c) reads one out; pg.timestamp_rfc3339(cell, has_tz) turns Postgres's own timestamp text into RFC 3339.
netTCP listener/dialer built on bytes and fixed-width ints; every
fallible call returns a result[_, str] unwrapped with guard let.
import "net";
let lr: result[i32, str] = net.listen(8080); // 0 = ephemeral port
guard let lfd = lr else { exit(1); }
let pr: result[i32, str] = net.port(lfd); // assigned port number
let ar: result[i32, str] = net.accept(lfd); // blocks until a peer connects
guard let cfd = ar else { exit(1); }
net.send(cfd, b"hello");
let rr: result[bytes, str] = net.recv(cfd, 4096);
let data: bytes = rr ?? b"";
net.nonblock(cfd); // switch to non-blocking mode
let wr: result[bytes, str] = net.recv(cfd, 16); // "would block" err if idle
net.close(cfd);
net.idle_alive(fd) -> bool and net.tls_idle_alive(ssl) -> bool
report whether an IDLE connection is still reusable: true only when the
peer has neither closed nor sent anything. One non-blocking MSG_PEEK,
nothing consumed; the TLS form is also false when OpenSSL holds
decrypted-but-unread bytes. They exist for connection pools, and they
are a primitive rather than a recv_until with an expired deadline
because recv_until checks its deadline before touching the socket —
it would report every dead connection as alive.
net.recv and net.send wait for as long as the peer takes, which on
a public listener is indefinitely: a client that connects and then
neither sends nor reads parks the serving task on the reactor forever,
holding its stack and its GC roots. That is slowloris, and the defence
is recv_until / send_until, which take an until — an absolute
monotonic instant, not a duration:
import "net";
import "time";
let deadline = until_of(time.mono() + 5000000000); // 5s from now
let rr = net.recv_until(cfd, 4096, deadline);
guard let data = rr else let e = err_of(rr) {
if e == "timeout" { net.close(cfd); return; } // peer went quiet
log.error("recv: " + e); // peer broke
return;
}
"timeout" is a reserved error string: it means the deadline passed,
and it is the only error text these calls invent rather than take from
the OS. Every other error is strerror/OpenSSL text as before.
One asymmetry worth knowing: a send_until that times out has
already written some bytes, and result[i32, str] has no room to
report both "timed out" and "wrote this much". A "timeout" from
send_until therefore means the stream is at an unknown offset and the
connection must be closed, not retried. For a framed protocol that is
the right contract regardless — a half-written frame is unrecoverable.
net.tls_recv_until / net.tls_send_until are the same thing over
TLS, with the same reserved string. The link API takes an until on
accept/send/recv already.
net.dial_until(host, port, deadline) bounds connecting: the DNS lookup
and the TCP connect together. A lookup still running when the deadline
passes is abandoned to the resolver thread (getaddrinfo cannot be
interrupted), so the caller gets "timeout" on time. Every address the
name resolves to is tried in turn — net.dial does the same, without a
deadline.
Atlas connection strings use DNS SRV and TXT records. net.lookup_srv_until
and net.lookup_txt_until expose those records without blocking a slang
worker; each lookup takes an absolute until deadline:
import "net";
import "time";
let deadline = until_of(time.mono() + 5000000000);
let seeds = net.lookup_srv_until("_mongodb._tcp.cluster.example", deadline);
guard let records = seeds else let e = err_of(seeds) { println(e); exit(1); }
// Each SRV item is "priority:weight:port:target".
let defaults = net.lookup_txt_until("cluster.example", deadline);
guard let options = defaults else let e = err_of(defaults) { println(e); exit(1); }
// Each TXT item is one record with its DNS character-strings concatenated.
An empty TXT list means the domain has no TXT defaults. SRV targets retain
priority and weight so a client can apply its own ordering policy. The
resolver checks the response question and each returned record owner, rejects
malformed names and oversized or truncated answers, and returns "timeout"
when the deadline expires. At most 64 SRV/TXT lookups may be outstanding per
process, bounding their answer buffers to about 4 MiB; excess calls return
"DNS resolver request limit reached". The C resolver library is
linked only by programs that call either record lookup; ordinary net
programs keep their existing link dependencies.
net.dial_unix(path, deadline) connects to a Unix-domain stream socket
and net.listen_unix(path) listens on one (accept with net.accept).
The fds work with every fd-based call — send/recv and their _until
forms, close, idle_alive. listen_unix refuses a path that already
exists rather than deleting it, since the file may belong to a server
that is still running; remove a stale one with os.remove first. A path
longer than the platform allows (104 bytes on macOS, 108 on Linux) is an
error, not truncated.
See examples/httpd/ for a minimal HTTP server on link plus the
http stdlib package.
net.tls_* adds a TLS listener/dialer on top of the plain net
primitives above, built on OpenSSL (linked automatically, and only
when a program actually calls one of these — a plain-TCP net
program stays dependency-free). A SSL_CTX-equivalent config is
created once (tls_server_ctx / tls_client_ctx) and reused across
many connections; each connection is a separate rawptr handle.
import "net";
// server: load a cert + key once, reuse the context for every connection
let sctx_r: result[rawptr, str] = net.tls_server_ctx("cert.pem", "key.pem");
guard let sctx = sctx_r else { exit(1); }
let lr: result[i32, str] = net.listen(8443);
guard let lfd = lr else { exit(1); }
let ar: result[rawptr, str] = net.tls_accept(lfd, sctx); // TCP accept + handshake
guard let sconn = ar else { exit(1); }
net.tls_send(sconn, b"hello");
net.tls_close(sconn);
// client: verify against a CA file, or "" for the system trust store
let cctx_r: result[rawptr, str] = net.tls_client_ctx("");
guard let cctx = cctx_r else { exit(1); }
let dr: result[rawptr, str] = net.tls_dial("example.com", 443, cctx);
guard let cconn = dr else { exit(1); }
let rr: result[bytes, str] = net.tls_recv(cconn, 4096);
net.tls_close(cconn);
Client verification is strict by default: tls_client_ctx enables
peer verification, and tls_dial checks the certificate against
both the CA and the hostname you asked for (SSL_set1_host — the
check that's easy to forget and, if skipped, leaves you with "TLS"
that validates a certificate chain without checking it belongs to
the host you're actually talking to). Sending/receiving is blocking,
same as plain net — call these from a spawned task if you need a
connection handled without stalling anything else.
ALPN (RFC 7301) negotiates the protocol during the handshake, which
is how HTTP/2 over TLS is selected — there is no in-band upgrade.
tls_ctx_alpn(ctx, "h2,http/1.1") sets the list on a server context (in
preference order, so the server decides) or the offer on a client one,
and tls_alpn(conn) returns what was actually negotiated, or "" if
the peer offered nothing that overlapped. The list is comma-separated,
not the length-prefixed wire form; building that by hand is an easy way
to produce a subtly broken handshake. A client offering no protocol we
support completes the handshake without ALPN rather than failing, so it
simply falls back to HTTP/1.1.
net.tls_ctx_alpn(sctx, "h2,http/1.1");
let conn = ...; // after tls_accept
if net.tls_alpn(conn) == "h2" { serve_h2(conn); } else { serve_h1(conn); }
Mutual TLS: tls_ctx_require_client(sctx, client_ca) on the server
context demands a client certificate chained to that CA
(SSL_VERIFY_FAIL_IF_NO_PEER_CERT). The client presents one with
tls_ctx_use_cert(cctx, cert, key). Extra server names on one
listener: tls_ctx_add_sni(sctx, host, cert, key) swaps in that
cert when the ClientHello SNI matches; unmatched names keep the
default tls_server_ctx cert. require_client applies to SNI
certs too, regardless of call order. TLS 1.3 can let tls_dial
return before the server has rejected a missing client certificate;
the first send or recv then fails.
STARTTLS: tls_upgrade(fd, host, ctx) runs a client handshake on a
socket from net.dial that has already spoken cleartext — how Postgres,
SMTP and IMAP switch to TLS. Verification is exactly tls_dial's,
hostname included, which is why the host is an argument: an fd does not
remember what was dialled. On failure the fd is left open for the caller
to close; on success it belongs to the returned handle and tls_close
closes it. Read no further than the server's go-ahead before upgrading:
anything a man in the middle queued behind it would otherwise be trusted
as if it had arrived encrypted (libpq's CVE-2021-23222).
tls_upgrade_until(fd, host, ctx, deadline) bounds the handshake; a
server that stops answering part way through gives "timeout".
tls_dial_until(host, port, ctx, deadline) is tls_dial with one
deadline over the lookup, the connect and the handshake: an unreachable
host gives "timeout" at the deadline instead of after the OS connect
timeout, which is over a minute on macOS. Like dial, both try every
address the name resolves to.
TLS on a link: c.tls_accept(ctx, deadline) runs the server
handshake on a link you already accepted, and from then on the link's
send/recv go through the session. Everything written against a link
-- http.read_frame, http.write, a whole server loop -- serves HTTPS
without a second code path, and a plain link pays one pointer test per
call. Run it in the connection's own task, not the accept loop, so one
slow handshake holds up nobody else:
fn serve(c: link, ctx: rawptr) {
let hr = c.tls_accept(ctx, until_of(time.mono() + 10000000000));
guard let _h = hr else let e = err_of(hr) {
return; // a scanner, a plaintext client, a timeout
}
// read_frame / write on c exactly as over plain TCP
}
It returns result[int, fault]: timeout when the deadline passes,
closed when the client hangs up during the handshake (browsers'
preconnects do), and io with OpenSSL's reason otherwise (tls http request is a plaintext client). Client certificates are checked as
tls_accept checks them. The session is freed, with a close_notify, when
the link is; a second tls_accept on the same link is an error.
Reading and writing from two tasks: a link is move-only, so one task
owns it -- and a task parked in recv cannot also send. c.split() returns
a second link on the same connection for a writer task, which is what a
server pushing to a client needs (a WebSocket broadcast, server-sent
events):
fn writer(w: link, out: chan[bytes]) {
let mut_w = w;
while true {
let m = chan_recv(out);
guard let b = m else { return; } // closed: done
let sr = mut_w.send_bytes(b, until_of(time.mono() + 10000000000));
guard let _n = sr else { return; } // the peer is gone
}
}
let sr = c.split();
guard let w = sr else { return; }
let out: chan[bytes] = make_chan(256);
spawn writer(w, out);
// this task keeps reading c; anyone holding `out` can write to the client
From then on c only reads and the returned link only writes: a send on
the reader or a recv on the writer is an io fault saying which to use,
since two senders could interleave partial writes. Ending the reader (its
binding goes out of scope) shuts the connection down for both halves, so
a writer blocked on a client that stopped reading fails at once; ending
the writer leaves the reader working. Each half holds its own fd, so a
half never writes to a number the kernel has reused. Over TLS, start TLS
first (tls_accept on a split link is refused): both halves then share
the session, one OpenSSL call at a time. A link splits once.
builderAssembling a result one piece at a time with + is quadratic: a + b
allocates and copies both sides, so the cost is the sum of every
intermediate length. Building 80 KB one byte at a time takes about two
seconds, and a megabyte would take minutes. builder collects the pieces
and copies each once, at the end.
import "builder";
let b = builder.new_str();
b.write("hello").write(", ").write(name).write_int(42).write_line("!");
let s = b.finish(); // one allocation, linear in the total
let y = builder.new_bytes();
y.write_byte(104).write(b"ello").write_str(" world");
let raw: bytes = y.finish();
Str is for text and Bytes for binary data. Both chain, report
size() in bytes without assembling anything, and offer finish() (which
can be called again after more writes), take() (finish and start over)
and reset(). The chunk is made on the first short write, so a builder
that only takes long pieces never pays for it, and take() hands back a
lone piece without copying it. Bytes.write_byte fills a 512-byte chunk in place, so a
million single-byte writes make about two thousand allocations rather than a
million: 4 million of them take 136 ms. Bytes keeps a copy of what you
write, because bytes are mutable and a later change of yours must not
rewrite what was already written.
For assembling a [bytes] you already hold, strings.join_bytes(parts, sep) is the counterpart of strings.join. bench/builder/ prints the
naive loop beside the builder.
jsonjson.decode/json.encode (de)serialize str/bytes against a
concrete slang type — the target type for decode is inferred from
the binding's annotation, the same mechanism ok()/err() already
use to infer result[T,E]. There is no dynamic "JSON value" type:
every decode is checked field-by-field against the struct shape you
asked for, and a mismatch is a result error, not a silent null or
a runtime panic.
import "json";
gc struct Address { city: str, zip: str }
gc struct Person {
name: str,
age: i32,
email: opt[str], // JSON null / missing key <-> none
tags: [str],
addr: Address, // structs nest
}
let p = Person{ name: "Ada", age: 36, email: some("[email protected]"),
tags: ["math"], addr: Address{ city: "London", zip: "SW1" } };
let s: str = json.encode(p);
let r: result[Person, str] = json.decode(s);
guard let p2 = r else { exit(1); }
Plain structs work as well as gc structs, and are cheaper to decode:
a plain struct is filled in place -- in the binding, or in a list's or
map's own slot -- so a [Item] of a plain Item is one buffer, where a
gc struct Item is one heap object per element. For a large array of
small records (a request body with thousands of line items) that halves
the allocations. Use gc struct when the record must be shared by
reference.
Supported: gc struct (a plain struct is a compile error naming it, since
the codecs read and build structs through a pointer), opt[T], [T],
map[str, V] (JSON object keys
are always strings — a map with any other key type is a compile
error), every scalar, bytes (RFC 4648 base64 strings on the
wire), and timez.Time (an RFC 3339 string: written as rfc3339_nano()
writes it, read with parse_rfc3339()'s rules, offsets applied; about
115 ns to encode and 130 ns to decode, in C, so a struct without one
compiles to exactly the codecs it did before). rawptr, chan[T], and
result[T,E] can't appear anywhere in a decode/encode target type. A
missing JSON key defaults an opt[T] field to none; for any other
field type it's a decode error. Unknown JSON keys are ignored. Every
decode error names where it happened, composed through nesting —
json.decode on {"addr":{"city":5}} against the Person shape
above fails with field 'addr': field 'city': expected a string, got a number. Malformed input is a decode error, never a crash. Nesting
is capped at 512 levels (deeper input is the error maximum nesting depth (512) exceeded), and depth costs heap, not stack: the parser
keeps its own stack of open arrays and objects rather than recursing.
Decoding into a type that contains itself (opt[Self], [Self],
map[str]Self) does recurse once per level, so for those types alone
json.decode first measures the input's depth and, only when the input
is deep, grows the task's stack to fit before decoding.
Integers decode exactly, from the number as written: a 64-bit id such
as 9007199254740993 arrives intact (it is not routed through a
double, which is exact only up to 2^53), and every value an integer
type can hold is accepted, its limits included. A whole number written
with a fraction or exponent is still an integer (1e3 is 1000, 5.0
is 5); 1.5 or 1e-1 is expected an integer, and a value outside
the target type's range is value … out of range for i8 (or int,
u64, …). Floats decode through a double, as before.
procGraceful shutdown and environment variables. proc.shutdown_requested()
turns true once the process receives SIGTERM or SIGINT; a blocked
net.accept()/net.recv()/net.dial() is interrupted the instant the
signal arrives (an err result, not a hang), so a listener loop notices
without needing select or a timeout.
Handling the signals is opt-in by use. A program that calls
proc.shutdown_requested() anywhere — in its own code or in a package it
imports — takes them over: the first SIGTERM or SIGINT only sets the
flag, and a second one ends the process the way the signal would
have, so a drain that never finishes cannot make it unkillable. A program
that never asks keeps the default action: the signal ends it, whether or
not it imports proc for anything else (proc.getenv, say).
proc.active_tasks() counts currently-running spawned tasks.
proc.wait_idle() parks until that count is zero, so a
shutting-down program can drain in-flight work without polling.
Called from a spawned task, neither counts the caller (nor, for
wait_idle, other tasks also waiting in it), so a task can drain the
rest without waiting for itself; every test under slangc test is such
a task.
import "net";
import "proc";
import "time";
fn accept_and_serve(lfd: i32) {
let ar: result[i32, str] = net.accept(lfd);
guard let cfd = ar else { return; } // interrupted, or a real error
spawn serve(cfd);
}
let lr: result[i32, str] = net.listen(8080);
guard let lfd = lr else { exit(1); }
while !proc.shutdown_requested() {
accept_and_serve(lfd);
}
proc.wait_idle();
proc.getenv(name) reads an environment variable, returning
opt[str] (none if unset). proc.args() is the process argument
list ([str]); args[0] is the executable path. To parse options out of it, see
flags. proc.cwd() is the
working directory as result[str, str].
fsPOSIX file I/O on integer fds. open is read-only; create is
write/trunc. read/write/close use the fd; pread(fd, off, n) reads
at an offset. mkdir creates one directory. Every call returns
result[_, str], except fstat(fd), which is [kind, size, mtime_sec, mtime_nsec] of an open file (kind 0 a regular file, 1 a directory, 2
anything else; [] for a closed fd). realpath(path) resolves ., ..
and every symlink to an absolute path: what a file server compares with
its root, so a link cannot lead out of it. These calls block the
worker — use them for config and small files, not the accept loop.
To read standard input use io instead: fs.read(0, n) works, but
waits by blocking a whole worker thread, and a program waiting on a person
at a keyboard would stall every other task with it.
import "fs";
let cr = fs.create("/tmp/note");
guard let fd = cr else { exit(1); }
fs.write(fd, b"hi");
fs.close(fd);
let or = fs.open("/tmp/note");
guard let in_fd = or else { exit(1); }
let rr = fs.read(in_fd, 16);
guard let data = rr else { exit(1); }
fs.close(in_fd);
osThe operating system around a program: the environment, the process,
and everything you can ask or do about a path without opening it. Pure
libc, so importing os adds no link flag.
The fs/os boundary: fs owns open file handles and their
contents; os owns paths you have not opened. fs.mkdir predates that
split and stays where it is rather than breaking existing programs.
os.setenv(k, v) / os.unsetenv(k) | result[bool, str] |
os.environ() | [str] of KEY=VALUE |
os.pid() / os.tmpdir() | int / str |
os.hostname() | result[str, str] |
os.exists(p) / os.is_dir(p) / os.is_file(p) | bool |
os.size(p) / os.mtime(p) | result[int, str] |
os.read_dir(p) | result[[str], str] |
os.remove(p) / os.rename(a, b) | result[bool, str] |
import "os";
// serve a static file, the shape this package exists for
if !os.is_file(path) {
return not_found();
}
let sr = os.size(path);
guard let n = sr else let e = err_of(sr) {
log.error("stat " + path + ": " + e); // "No such file or directory"
return server_error();
}
The three predicates are bare bool on purpose. "Does this exist" has
two useful answers: a missing path and an unreadable parent are both
"no, you cannot use it", and code branching on the difference is racing
anyway — the answer can change between the check and the use. The
accessors return a value that has to come from somewhere, so those
carry the errno text.
environ() is a list rather than a map because an environment may
legally hold a repeated key, and a map would silently drop one.
read_dir returns entry names without . and .., since forgetting
to filter those is how a directory walk becomes an infinite loop.
remove takes files and empty directories alike, so a caller need not
know which it has.
proc.getenv, proc.args and proc.cwd stay in proc; os adds
what proc has no answer for rather than duplicating it.
ioThe process's standard streams: what a command-line program needs that
print/println and proc.args() don't already give it. Pure libc, so
importing io adds no link flag.
io.read_line() | result[opt[str], str] — one line of stdin, without its newline |
io.read_all() | result[bytes, str] — everything until the end of input |
io.eprint(s) / io.eprintln(s) | s (a str or a fault) to stderr, nothing added |
io.flush() | flush stdout |
io.is_tty(fd) | bool — whether fd (0, 1, 2) is a terminal |
io.term_width() / io.term_height() | opt[int] — columns / rows of the terminal; none without one |
io.read_secret() | result[opt[str], str] — like read_line, with echo off |
io.raw_on() / io.raw_off() | result[bool, str] — key-at-a-time input on / off |
io.read_key() | result[opt[str], str] — one key press, by name |
import "io";
import "log";
print("name? "); // no io.flush() needed: reading flushes it
let r = io.read_line();
guard let maybe = r else let e = err_of(r) {
io.eprintln("cannot read input: " + e);
exit(1);
}
guard let name = maybe else { // none: the input ended (Ctrl-D, end of pipe)
exit(0);
}
println("hello, " + name);
A loop over every line is the same two guards inside while true:
let total = 0;
while true {
let r = io.read_line();
guard let maybe = r else let e = err_of(r) { io.eprintln(e); exit(1); }
guard let line = maybe else { break; } // end of input
total = total + len(line);
}
opt inside result, on purpose. The end of input is not a failure —
it is what a filter is waiting for — so it is none, the way a drained
channel is; a failed read is an err. That is the README's own rule
(absent data is opt, a bad world is result), at the cost of two guards.
Lines. "\n" or "\r\n" ends one and is not returned; an empty line is
some(""), which is not none. A final line with no terminator is still a
line. Ctrl-D on a terminal ends one read — the next read_line reads
again, as in a shell — while on a pipe or file every later call is none
too.
Waiting parks the task, not the thread. On a terminal, a pipe or a
socket, read_line and read_all wait on the same reactor as
net.recv, so other tasks keep running while a person thinks. (Reading
fd 0 with fs.read blocks a whole worker thread; with one worker that
stalls the entire program.) A regular file or /dev/null never blocks and
is read directly. Before it waits, io flushes stdout, so a prompt written
with print is on screen before the user types.
Ctrl-C. In a program that handles shutdown — one that calls
proc.shutdown_requested() (see proc) — the signal is not fatal:
a waiting read_line returns err("interrupted") and the program can
clean up. Otherwise Ctrl-C ends the process as usual.
Limits. Input is buffered in memory, so one line — or read_all's
whole input — is capped at 256 MiB; past that the call returns an err
rather than allocating without bound. stdin is one shared stream: two
tasks reading it at once are memory-safe but see interleaved chunks, so
give it to one task.
eprint/eprintln write straight to stderr with no timestamp or level —
for the messages a command prints to a person; log is for a service's
diagnostics.
import "io";
// a password: nothing is echoed while it is typed
print("password: ");
let r = io.read_secret();
guard let maybe = r else let e = err_of(r) { io.eprintln(e); exit(1); }
let password = maybe ?? "";
// wrap to the window; a pipe or a file has no width
let width = io.term_width() ?? 80;
read_secret is read_line with the terminal's echo off for the length of
the read. The Enter is not echoed either, so it writes the newline for you.
When stdin is not a terminal (a pipe, a file, a test) it is exactly
read_line: nothing to turn off. term_width and term_height ask the
terminal every time, so they follow a resized window — poll them, there is
no resize event. They look at stdout, then stderr, then stdin, and are none
when none of the three is a terminal.
Key by key. raw_on switches stdin from line-at-a-time to key-at-a-time:
no line editing, no echo, and each key press is available as it happens.
read_key names it:
let r = io.raw_on();
guard let ok = r else let e = err_of(r) { io.eprintln(e); exit(1); } // stdin is not a terminal
while true {
let k = io.read_key();
guard let maybe = k else let e = err_of(k) { break; }
guard let key = maybe else { break; } // end of input
if key == "q" || key == "esc" { break; }
if key == "up" { println("up"); }
}
io.raw_off();
| Key | Name |
|---|---|
| a printable character (a whole UTF-8 character) | itself: "a", "é", " " |
| Enter, Tab, Backspace | "enter", "tab", "backspace" |
| Escape | "esc" |
| Ctrl with a letter | "ctrl-a" … "ctrl-z" |
| Alt with a key | "alt-x" |
| cursor and editing keys | "up" "down" "left" "right" "home" "end" "insert" "delete" "pageup" "pagedown" |
| function keys | "f1" … "f12" |
| with modifiers | "ctrl-right", "shift-up", "ctrl-shift-left", "shift-tab" — always in the order ctrl, alt, shift |
Anything else is "unknown", and it is consumed whole, so a mouse report or
an unfamiliar sequence cannot leak into the next key. read_key waits like
read_line does, parking the task and not the thread, and also works on a
pipe, where it decodes the same bytes.
A lone Escape and the first byte of an escape sequence are the same byte; what follows it within 50 ms is part of the sequence, and silence means the Escape key. Over a slow link a sequence split across that gap reads as an Escape followed by its tail.
The terminal is put back. A program that dies while the terminal is raw
leaves the person's shell with no echo and no line editing, so restoring is
not left to the caller. The terminal's original settings are saved once and
restored when the last of raw_off and read_secret ends, and also:
exit(), a panic in the main task, falling off
the end of main — so a forgotten raw_off costs nothing;Raw mode leaves signals on, so Ctrl-C still works: in a program that calls
proc.shutdown_requested() it makes the next read_key return
err("interrupted"); otherwise it ends the process, and the terminal is
restored first. It also leaves output
processing on, so "\n" still starts a new line. Not covered: SIGKILL
and crashes cannot be caught, and Ctrl-Z stops the process with the terminal
still raw.
raw_on fails with "stdin is not a terminal" when it is not one, so a
program can fall back to line input when it is run from a script.
logStderr logging with a timestamp and level. Each function accepts a
str or a fault (to_str/+ already convert faults the same way),
so err_of bindings and fault values log without manual conversion.
import "log";
log.debug("cache miss for key foo");
log.info("listening on :8080");
log.warn("retrying dial after timeout");
log.error("could not load config: " + e);
log.warn(fault_timeout());
cryptoSHA-256, HMAC-SHA256, PBKDF2, MD5 and a CSPRNG over OpenSSL. Hashes and
HMAC are infallible on valid inputs and return bytes directly; rand
and pbkdf2_sha256 can fail and return result[bytes, str].
import "crypto";
let h: bytes = crypto.sha256(b"abc"); // 32 bytes
let m: bytes = crypto.hmac_sha256(key, msg); // 32 bytes
let r = crypto.rand(32);
guard let b = r else let e = err_of(r) {
log.error("rand failed: " + e);
}
let k = crypto.pbkdf2_sha256(password, salt, 600000, 32); // RFC 8018
let s1: bytes = crypto.sha1(b"abc"); // 20 bytes
let d: bytes = crypto.md5(b"abc"); // 16 bytes
pbkdf2_sha256(password, salt, iterations, length) accepts 1 to
10,000,000 iterations and a length of 1 to 1024 bytes; anything else is
an err. The iteration count is capped because it is often chosen by the
other side of a protocol (a SCRAM server sends it) and the whole
derivation runs without yielding the worker thread.
sha1 and md5 are broken for collision resistance. They exist for the
protocols that still specify them and cannot be changed -- the WebSocket
handshake (RFC 6455) hashes the client's key with a fixed GUID, Git
object ids, Postgres md5 authentication, Content-MD5,
legacy ETags -- and must not protect anything new.
The low-level argon2id_raw and argon2id_verify primitives support the
standalone argon package, which emits
standard PHC password hashes. They accept byte passwords and salts and
validated cost parameters; verification returns ok(false) for a mismatch
and compares tags in constant time. Calls require OpenSSL 3.2 or newer with
Argon2id support; on older OpenSSL builds they return an error while the
other crypto functions remain available. Use the package's bounded PHC
parser rather than parsing stored hashes in application code.
A derivation is tens of milliseconds of work that cannot be preempted. In
a program that links the reactor (anything importing net or io, so
every server), it runs on a small pool of dedicated kdf threads while the
calling task parks, the way a DNS lookup does: a burst of logins neither
pins the pool's workers nor holds up a collection. SLANG_KDF_THREADS
sets the pool size (default: one per worker). Each in-flight derivation
holds its memory_kib, so bound how many you start, as you would any
other expensive request. Without the reactor it derives on the calling
worker.
rsa_verify_sha256(n, e, msg, sig) -> result[bool, str] checks an
RSASSA-PKCS1-v1_5 SHA-256 signature (JWS RS256) against a public key
given as its raw big-endian modulus and exponent, which is what a JWKS
entry's base64url n and e decode to. ok(false) is a signature that
does not verify, including one of the wrong length; err is a key out of
bounds (a 2048- to 8192-bit modulus, a 1- to 8-byte exponent) or one
OpenSSL rejects.
import "crypto";
let r = crypto.rsa_verify_sha256(n, e, to_bytes(header_dot_payload), sig);
let valid = r ?? false;
sqlA SQLite driver (linked automatically, only when a program imports
sql). Connections and prepared statements are opaque rawptr
handles, exactly like net.tls_*; free them with sql.close /
sql.finalize. Every fallible call returns result[_, str] whose
error is SQLite's own message — no such table: users, near "SELCT": syntax error, UNIQUE constraint failed: users.id — so a
bad query stays as visible as a bad socket read (guard let … else let e = err_of(r)), never a silent null. The column getters are
infallible (SQLite coerces types; an out-of-range index is a
programming error, returning 0 / ""), so they return bare values.
| Function | Signature |
|---|---|
sql.open(path) | result[rawptr, str] — ":memory:" for in-memory |
sql.close(db) | — |
sql.exec(db, sql) | result[int, str] — runs statement(s), returns rows changed |
sql.last_insert_id(db) | int |
sql.prepare(db, sql) | result[rawptr, str] |
sql.finalize(st) | — |
sql.reset(st) | result[bool, str] — clears bindings, re-run |
sql.bind_int/bind_float/bind_text/bind_blob(st, idx, v) | result[bool, str] — idx is 1-based |
sql.bind_null(st, idx) | result[bool, str] |
sql.step(st) | result[bool, str] — true = row ready, false = done |
sql.col_count(st) | int |
sql.col_name(st, i) / col_text(st, i) | str — i is 0-based |
sql.col_int(st, i) | int |
sql.col_float(st, i) | float |
sql.col_blob(st, i) | bytes |
sql.col_is_null(st, i) | bool |
import "sql";
import "log";
let dr = sql.open("app.db");
guard let db = dr else let e = err_of(dr) {
log.error("db open: " + e);
exit(1);
}
sql.exec(db, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
let pr = sql.prepare(db, "SELECT id, name FROM users WHERE id > ?");
guard let st = pr else let e = err_of(pr) {
log.error("prepare: " + e); // e.g. "no such table: users"
exit(1);
}
sql.bind_int(st, 1, 0);
while true {
let sr = sql.step(st);
guard let more = sr else let e = err_of(sr) { log.error("step: " + e); break; }
if !more { break; }
println(to_str(sql.col_int(st, 0)) + " " + sql.col_text(st, 1));
}
sql.finalize(st);
sql.close(db);
SQLite calls block the worker — use them for real work off the accept
loop (wrap in a spawned task), the same caveat as fs. One
connection per rawptr; there is no pool and no async stepping. For
Postgres, see pg, which runs on the scheduler instead.
Query complexity is capped per connection so SQLite's recursion stays
inside the task stack: at most 50 terms in a compound SELECT
(UNION/INTERSECT/EXCEPT) and an expression depth of 400
(roughly, terms in one AND/OR chain). SQLite's stock limits of 500
and 1000 allow a single legal query to want ~325KB of C stack, which
would force a task stack far too fat to spawn per connection. Long
IN lists, wide result sets, and recursive CTEs are not affected —
they don't recurse. Exceeding a cap is a normal error through
result[_, str] (too many terms in compound SELECT), not a crash.
pgA PostgreSQL client, written in slang over net. A query waiting on
the server parks its task like any socket read, so unlike sql it does
not block a worker thread: a server can hold many database connections
on a handful of threads. Errors keep the server's own text and SQLSTATE,
through the same result[_, str] story as every other package.
import "pg";
import "time";
import "log";
let dl = until_of(time.mono() + 5000000000);
let pr = pg.new_pool("postgres://app:[email protected]/shop", 10);
guard let pool = pr else let e = err_of(pr) {
log.error("db url: " + e);
exit(1);
}
let r = pg.pool_query(pool,
"SELECT id, name, price FROM products WHERE price < $1 ORDER BY id",
[pg.arg_float(20.0)], dl);
guard let rows = r else let e = err_of(r) {
log.error("query: " + e); // ERROR: relation "products" does not exist (SQLSTATE 42P01)
exit(1);
}
let i = 0;
while i < rows.count {
println(to_str(pg.get_int(rows, i, 0)) + " " + pg.get_text(rows, i, 1));
i = i + 1;
}
Every call that talks to the server takes a deadline (until), like
httpc; until_of(0) waits indefinitely. For connect it covers the
whole connection: DNS lookup, TCP connect, TLS handshake and login.
| Function | Signature |
|---|---|
pg.connect(url, deadline) | result[Conn, str] |
pg.parse_url(url) / pg.connect_config(cfg, deadline) | result[Config, str] / result[Conn, str] |
pg.query(c, sql, args, deadline) | result[Rows, str] — one statement, $1, $2… parameters |
pg.exec(c, sql, deadline) | result[int, str] — several ;-separated statements, no parameters; rows affected by the last |
pg.close(c) | — sends Terminate and closes |
pg.usable(c) / pg.in_transaction(c) | bool |
pg.server_param(c, name) | opt[str] — server_version, TimeZone, … |
pg.sqlstate(e) | str — the SQLSTATE in an error, or "" if it did not come from the server |
Parameters travel separately from the SQL text, so a value can never
be parsed as SQL, whatever it contains. Build them with pg.arg_text(s),
arg_int(n), arg_float(x) (sent exactly, not rounded), arg_bool(b),
arg_bytes(b) (binary, for bytea), arg_time(t) (a timez.Time, for
timestamptz; Postgres keeps microseconds) and arg_null(). A query with
none takes [].
Results from query are buffered whole in a Rows (for one too big
for that, see Streaming): rows.count, rows.columns
(names), rows.affected (from the command tag, so an INSERT or
UPDATE through query reports its row count) and rows.tag. Read
cells by row and column index; pg.col(rows, "name") finds an index.
| Getter | Reads |
|---|---|
pg.get_text(rows, r, c) | any column, in Postgres's text form (dates, numeric, uuid, json) |
pg.get_int(rows, r, c) | int2, int4, int8, oid, and a whole-number numeric (what sum() of an integer column returns) |
pg.get_float(rows, r, c) | float4, float8, numeric, the integer types |
pg.get_bool(rows, r, c) | bool |
pg.get_bytes(rows, r, c) | bytea |
pg.get_time(rows, r, c) | timestamptz as a timez.Time (result: err for infinity or a BC date); a timestamp without a zone is read as UTC |
pg.is_null(rows, r, c) | whether the cell is NULL |
The getters panic on NULL, on a column of a type they do not read,
on an unknown column name and on an index out of range, with a message
naming the column (column 'email' is NULL in row 3; check pg.is_null before pg.get_text). Those are disagreements between the query and the
code reading it — bugs to see, not conditions to handle — and inventing a
0 or "" would hide them. A nullable column is checked with is_null
first. Like sqlx's get, not Go's Scan.
pg.rows_as[T](rows) -> result[[T], str] binds columns by exact field
name and decodes directly into value or gc structs, without rendering
rows as JSON. Annotate the result, for example:
struct User { id: int, email: str, nickname: opt[str] }
let converted: result[[User], str] = pg.rows_as(rows);
Extra columns are ignored. Missing optional fields and SQL NULL become
none; missing required fields and incompatible cells return an error
naming the row and field. Binary integers and booleans retain their types,
numeric values retain integer precision, and JSON/JSONB columns decode into
nested types. bytea reads into bytes (or a base64 str); timestamps
read into timez.Time or RFC 3339 strings, with a zone-free timestamp read
as UTC. Infinity and BC timestamps remain strings and cannot fit a Time.
String-keyed maps and optional row wrappers are also supported. Duplicate
names use the first column for structs and the last for maps, as JSON
decoding does. Scalar row targets are rejected; declare a row struct.
The hermetic bench/pg_rows_as/main.sl compares this with the previous
Zokor JSON adapter on the same 1,000-row fixture. Intel macOS, ABBA x4:
3.792 ms versus 0.263 ms median per conversion; 23,040 versus 3,671
allocations and 1,864,778 versus 255,780 allocated bytes per conversion.
These measure mapping alone, excluding database and request latency.
pg.new_pool(url, max_open) parses the url and connects nothing until
first use. pool_query and pool_exec take a connection, run, and give
it back, so they cannot leak one. A transaction needs several statements
on one connection, so it uses acquire and release:
let cr = pg.acquire(pool, dl);
guard let c = cr else let e = err_of(cr) { return; }
pg.exec(c, "BEGIN", dl);
let moved = pg.query(c, "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
[pg.arg_int(100), pg.arg_int(from)], dl);
guard let m = moved else {
pg.release(pool, c); // still in the transaction: closed, never reused
return;
}
pg.exec(c, "COMMIT", dl);
pg.release(pool, c);
At most max_open connections exist at once; a task that needs one while
all are in use waits, up to its deadline. Waiters are served in arrival
order: a release hands its connection straight to the oldest one, and
pool_close fails every acquire still waiting. Idle connections are probed
before reuse and closed after pool.idle_timeout (5 minutes). A
connection released while broken, closed or still inside a
transaction is closed rather than handed on — the next caller would
otherwise run its statements inside someone else's uncommitted work.
Releasing the same connection twice panics. pool.dials and
pool.reuses count what the pool did.
postgres://user:password@host:port/database?param=value, with %
escapes in any part. The port defaults to 5432 and the database to the
user name. Recognised parameters are sslmode, sslrootcert (a CA
bundle to verify against, for providers that sign with their own CA),
application_name and statement_cache_capacity (below), plus host
and port, which override the ones in the authority, as in libpq. Anything else is an error, not ignored: a
misspelt sslmdoe=require that was quietly dropped would connect in
cleartext and nothing would ever say so.
sslmode | |
|---|---|
disable | cleartext |
require, verify-full | TLS, with the certificate chain and hostname verified |
prefer, allow | refused: they fall back to cleartext when TLS fails, which is exactly what a man in the middle arranges |
verify-ca | refused: it does not check the hostname |
This differs from libpq on purpose: libpq's require encrypts without
checking who answered. Without sslmode, the default is require —
except for a loopback host (localhost, 127.x, ::1), where it is
disable, because traffic that never leaves the machine gains nothing
from TLS and local servers rarely have it set up. A server that does not
offer TLS gets a message saying to add sslmode=disable.
Unix-domain sockets are named by their directory, as libpq does:
postgres://app@%2Fvar%2Frun%2Fpostgresql/shop (the host
percent-encoded) or postgres://app@/shop?host=/var/run/postgresql. The
socket file is <dir>/.s.PGSQL.<port>. Postgres does not use TLS over a
socket, so sslmode defaults to disable there and require is an
error.
Authentication: SCRAM-SHA-256 (the default since Postgres 14), md5 and cleartext password. SCRAM checks that the server knows the password too: a server that skips its proof, or sends a wrong one, is refused.
A server-side error — a syntax error, a constraint violation — leaves the
connection usable. Anything that leaves the stream at an unknown point
breaks it: an I/O error, a protocol violation, a deadline. After a
"timeout" the server may still be running the query, so the driver asks
it to stop with a cancel request on a separate connection, from its own
task, and returns immediately. A broken connection only returns
connection is broken: <why>.
let r = pg.query(c, "INSERT INTO users (email) VALUES ($1)", [pg.arg_text(email)], dl);
guard let ok_ = r else let e = err_of(r) {
if pg.sqlstate(e) == "23505" { // unique_violation
return respond(409, "email already registered");
}
return respond(500, "database error");
}
A Conn is safe to share between tasks (calls take turns), but a pool is
the better tool for that.
pg.stream runs a query like query but hands the rows over one at a
time, so memory holds one row however large the result, and the server
is held back by TCP flow control rather than the client reading ahead:
let sr = pg.stream(c, "SELECT id, body FROM events ORDER BY id", [], dl);
guard let rows = sr else let e = err_of(sr) { return; }
while true {
let nr = pg.next_row(c, rows, dl);
guard let more = nr else let e = err_of(nr) { log.error(e); break; }
if !more { break; }
archive(pg.get_int(rows, 0, 0), pg.get_text(rows, 0, 1)); // always row 0
}
Each next_row replaces the row in rows, read at index 0; after the
last, rows.affected and rows.tag are set. A server error part way
through comes from next_row, after the rows before it. While a stream
is open the connection serves nothing else — other calls fail with
connection is busy — so read it to the end or call
pg.stream_close(c, rows, dl). That asks the server to cancel the query,
waits until the request has been delivered (so it cannot hit a later
query on the same connection), then drops whatever was already sent. A
connection released to a pool mid-stream is closed.
| Function | Signature |
|---|---|
pg.stream(c, sql, args, deadline) | result[Rows, str] |
pg.next_row(c, rows, deadline) | result[bool, str] — false after the last row |
pg.stream_close(c, rows, deadline) | result[bool, str] |
COPY … FROM STDIN bulk-loads data in COPY's text or CSV format, far
faster than one INSERT per row; COPY … TO STDOUT dumps it.
let n = pg.copy_from(c, "COPY users (id, email) FROM STDIN (FORMAT csv)",
to_bytes("1,[email protected]\n2,[email protected]\n"), dl);
let dump = pg.copy_to(c, "COPY users TO STDOUT (FORMAT csv)", dl);
| Function | Signature |
|---|---|
pg.copy_from(c, sql, data, deadline) | result[int, str] — rows loaded |
pg.copy_in_start(c, sql, deadline) | result[bool, str] |
pg.copy_in_send(c, data, deadline) | result[bool, str] — any split, not only whole rows |
pg.copy_in_end(c, deadline) | result[int, str] — rows loaded |
pg.copy_in_abort(c, reason, deadline) | result[bool, str] — the server discards everything |
pg.copy_to(c, sql, deadline) | result[bytes, str] — buffered, up to the 256 MiB limit |
pg.copy_out_start(c, sql, deadline) | result[bool, str] |
pg.copy_out_next(c, deadline) | result[opt[bytes], str] — a row's worth, none at the end |
The start/send/end and start/next forms stream, for data that should not
be held in memory at once. A bad row fails the whole COPY — the error
comes from copy_in_end (or copy_from) with its SQLSTATE, and nothing
was loaded. A COPY of the wrong direction is refused with not a COPY … FROM STDIN statement, and a COPY through query or exec fails with a
message pointing here, the connection intact either way. Only the text
and CSV formats are meant: binary COPY data passes through untouched but
this package does not build or parse it.
pg.listen(c, "jobs", dl);
while true {
let wr = pg.wait_notification(c, until_of(time.mono() + 30000000000));
guard let got = wr else let e = err_of(wr) { log.error(e); break; }
guard let note = got else { continue; } // 30s with nothing: none
run_job(note.payload);
}
| Function | Signature |
|---|---|
pg.listen(c, channel, deadline) / pg.unlisten(c, channel, deadline) | result[bool, str] — unlisten(c, "*", dl) for all |
pg.notify(c, channel, payload, deadline) | result[bool, str] |
pg.wait_notification(c, deadline) | result[opt[Notification], str] — pid, channel, payload |
Channel names are quoted as identifiers, so any name is safe to pass.
Notifications that arrive during other calls — even in the middle of a
query's result — are queued on the connection (up to 10,000;
c.notes_dropped counts any beyond that) and handed out oldest first.
Reaching the deadline in wait_notification returns none and leaves
the connection usable, unlike every other call: waiting is the point,
and a message cut off by the deadline is kept and completed by the next
wait. A server that ends the session while one waits (an administrator's
pg_terminate_backend, a restart) is an error with its SQLSTATE. Listen
on a connection of its own, not a pooled one: a pooled connection with a
notification waiting fails the pool's idle probe and is closed.
A driver trusts the server with its memory, so what it will buffer is capped: one protocol message at 256 MiB, and one result at 256 MiB of cell data, which is an error rather than an allocation. A SCRAM server may ask for at most 1,000,000 PBKDF2 iterations (Postgres uses 4096).
Measured against Postgres 16 in Docker on the development Mac: 1M rows of two columns in about 2.1s and 150MB, a single 20MB value in about 0.3s.
query prepares each SQL text once per connection and reuses the
statement after that, so Postgres parses and plans it once instead of
on every call: on a point read that is about 60% less database CPU. A
first use costs no extra round trip (the Parse travels with the
query). Each connection keeps its 256 most recently used statements;
statement_cache_capacity=N in the url changes that, and 0 turns it
off, which a pooler that cannot carry named statements between server
connections needs (PgBouncer before 1.21 in transaction mode). On cached
runs, int2, int4, int8, bool, and bytea results use PostgreSQL's
binary format; the pg.get_* functions preserve their existing values and
text representation. Other result types stay in text format. If the server
drops a statement or a schema change alters what it returns,
the driver prepares it again and retries the query once, outside a
transaction; inside one, the error is returned, because the
transaction is already aborted. DEALLOCATE ALL and DISCARD ALL are
noticed and empty the cache. exec and stream are not prepared.
Not supported: binary floats, building or parsing binary COPY data,
Kerberos/GSSAPI, SCRAM channel
binding (SCRAM-SHA-256-PLUS), multiple hosts in one url, and SASLprep
normalisation of non-ASCII passwords (an ASCII password is unaffected).
redisA Redis client, written in slang over net like pg: commands
waiting on the server park the task on the reactor instead of
blocking a worker thread. Errors keep the server's own text through
the same result[_, str] story as every other package.
The protocol core is usable now; pooling, cluster
routing and pub/sub are all here too. connect dials with a
deadline, runs AUTH and SELECT, and hands back a Conn that any
task may share -- one round trip at a time, serialized by an
internal lock. do runs one command; a reply that violates the
protocol breaks the connection for good, while a server-side command
error only fails that call. Heavier use goes through Pool
(shared connections with idle probing), Cluster (slot routing
with MOVED/ASK handling and same-slot checks), explicit MULTI
transactions on direct Conns, and pull-model pub/sub where the
caller drives sub_next (a 4-line pump feeds a chan when push
suits better).
import "redis";
import "time";
import "log";
let dl = until_of(time.mono() + 5000000000);
let cr = redis.connect("redis://:[email protected]:6380/2", dl);
guard let c = cr else let e = err_of(cr) {
log.error("connect: " + e);
exit(1);
}
// encode a command to wire bytes (binary-safe values pass through)
let wire: bytes = redis.encode([to_bytes("SET"), to_bytes("k"),
to_bytes("v")]);
// run it; at most one command is ever in flight per Conn
let r = redis.do(c, [to_bytes("PING")], dl);
// decode one reply; ok(none) means feed more bytes and retry,
// err means the bytes violate the protocol
let dr: result[opt[redis.Decoded], str] = redis.decode(wire);
// cluster hash slot of a key (0..16383), honouring {...} hash tags
let s: int = redis.slot("{user1000}.following");
// connection strings parse into Configs for pooling and clusters
let ur = redis.parse_url("redis://alice:[email protected]:6380/2");
// pooled and clustered use underneath (see the table)
let pr = redis.new_pool("redis://cache.internal:6380", 8);
redis.close(c);
| Function | Signature |
|---|---|
redis.encode(args) | bytes — one command as a RESP2 array of bulk strings |
redis.decode(buf) / redis.decode_at(buf, pos) | result[opt[Decoded], str] — one reply plus bytes consumed |
redis.slot(key) | int — cluster hash slot with {...} tag support |
redis.parse_url(url) | result[Config, str] — redis:// / rediss:// |
redis.connect(url, deadline) / redis.connect_config(cfg, deadline) | result[Conn, str] — dial, AUTH, SELECT |
redis.do(c, args, deadline) | result[Reply, str] — one command round trip |
redis.close(c) / redis.usable(c) | — shutdown / may commands still be attempted |
| strings | ping, echo, get, set, set_ex, set_nx, del_keys, exists, expire, pexpire, ttl, pttl, persist, incr, decr, incr_by, decr_by, append, strlen, mget, mset |
| hashes | hset, hget, hgetall, hdel, hexists, hkeys, hvals, hlen, hincr_by |
| lists | lpush, rpush, lpop, rpop, llen, lrange, ltrim, lindex, lrem |
| sets | sadd, smembers, srem, scard, , |
A reply is a redis.Reply: kind is one of REPLY_SIMPLE,
REPLY_ERROR, REPLY_INT, REPLY_BULK or REPLY_ARRAY, with the
payload in text, num, bulk (none for nil) or items (empty
with is_nil for a nil array).
Replies default to a 256 MiB wire-byte limit and 1,000,000 aggregate array
elements, including nested arrays. Config.max_reply_bytes and
Config.max_reply_values can be raised for larger legitimate reads; each must
remain positive, and the existing per-bulk, per-array, line, and nesting
limits still apply. An over-limit reply fails and makes its connection
unusable.
Not supported yet: consumer groups, RESP3, replica reads.
regexRegular expressions on str or bytes, matched by slang's own
Thompson NFA — no external library, so a program that matches text
stays as dependency-free as a plain TCP one. compile returns an
opaque rawptr handle (freed with regex.free, like net.tls_* and
sql), and a bad pattern comes back as a descriptive
result[rawptr, str].
Matching is linear time, always. There is no backtracking, so the
classic catastrophic pattern (a+)+$ — which makes a backtracking
engine take exponential time on a hostile input — runs in the same
microseconds here as any other pattern. That is the point of choosing
this engine for a server language: patterns and subjects both arrive
from the network. The price is the RE2/Go one, and it is not
negotiable: no backreferences and no lookaround. Both require
backtracking; (?=...) and friends are a compile error, not a silent
mis-parse.
| Function | Signature |
|---|---|
regex.compile(pat) | result[rawptr, str] |
regex.free(re) | — |
regex.groups(re) | int — number of capture groups |
regex.is_match(re, s) | bool — s is a str |
regex.is_match_bytes(re, b) | bool — b is bytes |
regex.find(re, s) / find_bytes(re, b) | [int] |
regex.find_at(re, s, from) / find_bytes_at(re, b, from) | [int] |
find returns byte offsets as [start, end, g1start, g1end, ...], or
an empty list when there is no match — so the result is GC-owned
and there is no match handle to leak. find_at starts at an offset,
which is how you walk every match.
import "regex";
let cr = regex.compile("(\\d{4})-(\\d{2})-(\\d{2})");
guard let re = cr else let e = err_of(cr) {
log.error("bad pattern: " + e); // e.g. "missing ) at offset 9"
exit(1);
}
if regex.is_match(re, "due 2026-09-09") {
let m = regex.find(re, "due 2026-09-09");
println(to_str(m[0]) + ".." + to_str(m[1])); // whole match: 4..14
println(to_str(m[2]) + ".." + to_str(m[3])); // year: 4..8
}
regex.free(re);
Supported: literals, ., classes [a-z] [^...] [[:digit:]],
escapes \d \D \w \W \s \S \b \B \A \z \xHH, quantifiers
* + ? {n} {n,} {n,m} and their lazy ? forms, groups (...) and
(?:...), alternation |, anchors ^ $. Matching is leftmost-first
(Perl-style priority), and . does not match \n.
Subjects are matched with an explicit length, so a bytes containing
NUL matches correctly rather than stopping at the NUL — and \D
matches a NUL byte like any other non-digit.
Bounds, so a hostile pattern can't exhaust memory or stack: 4096
compiled instructions, 100 nesting levels, 32 capture groups, and
{n,m} counts up to 1000. Each is a descriptive compile error, never
a crash.
A compiled regex is safe to share across tasks, and is meant to be: it carries a small pool of reusable match buffers, so concurrent matchers allocate nothing per match. Compiling is the expensive part (it is also the only part that grows the task stack) — compile once, match many times, ideally not once per request.
Where this lands on speed. Measured single-threaded on
^(GET|POST|PUT) (/[a-z0-9/_-]*) HTTP/1\.([01])$ against a 26-byte
subject, 200k iterations:
| engine | matches/sec | on (a+)+$ vs a hostile input |
|---|---|---|
slang regex | ~721k | 2µs, correct answer |
POSIX regexec | ~372k | fast here, but no limits |
| PCRE2 (interpreted) | ~2.4M | 0.2s, then gives up (MATCHLIMIT) |
| PCRE2 (JIT) | ~8.6M | same — JIT does not save it |
So: ~1.9x faster than libc's POSIX engine, and several times slower
than PCRE2 on benign input — PCRE2's interpreter and especially its
JIT are very good, and this is an honest gap. The trade is deliberate:
on adversarial input the ordering inverts completely, because linear
time is a guarantee here and a hope there. is_match is markedly
cheaper than find (it binds no capture slots at all), so prefer it
when you only need a yes/no. Matching scales with tasks — ~3.2M/sec
across 16.
Because matching never grows the task stack, regex is cheap to use per
connection: 600 concurrently-live tasks each matching and then parking
peak at 3.9MB RSS — about 17x lighter than the same shape holding
sql connections (65.9MB), which does grow every task's stack.
http2HTTP/2 framing and HPACK header compression (RFC 9113, RFC 7541), written in slang — the frame codec is what the bitwise operators were added for.
import "http2";
let f = http2.decode(buf, 0, 16384); // one frame, bounds-checked
guard let fr = f else let e = err_of(f) { return; }
let d = http2.decoder_new(4096); // per-connection HPACK state
let hr = http2.decode_block(d, fr.payload, 64);
guard let hs = hr else let e = err_of(hr) { return; }
for i in 0..len(hs) {
println(hs[i].name + ": " + hs[i].value);
}
Frame layer: decode / encode / header_bytes, the reserved bit
masked off the stream id as the RFC requires, strip_padding, and the
common control frames (settings_frame, settings_ack, ping_ack,
rst_stream, goaway, window_update).
HPACK: prefix integers, string literals, the 61-entry static table, a
dynamic table with the RFC's +32-per-entry accounting and eviction, and
a canonical Huffman decoder. Header blocks decode through
decode_block; encode_block builds one.
The encoder is deliberately stateless — every field goes out as a static-table index or a literal without indexing, and nothing is added to a dynamic table on the encode side. That is conformant and it removes a whole bug class: an encoder's dynamic table must stay in lockstep with the peer's decoder table, and any drift silently corrupts every later block on the connection.
Bounds against hostile peers: a frame longer than the advertised
SETTINGS_MAX_FRAME_SIZE is refused before allocating, decode_block
takes a max_headers cap (a small compressed block can otherwise expand
without limit), a Dynamic Table Size Update above the agreed maximum is
rejected, and NUL in a field name or value is the protocol error RFC
9113 §8.2.1 says it is. Huffman padding must be under 8 bits and all
ones, and EOS inside a string is refused.
Validated against nghttp2 — the HPACK implementation curl and the
browser stacks use — in both directions: blocks it produces decode here,
and blocks produced here inflate there. Those fixtures are baked into
tests/http2 as literals, so the suite needs no nghttp2 to run.
Connection layer, with concurrent streams. One task reads frames and
dispatches each request to its own spawned handler; every byte leaving
the connection goes through a single writer task fed by a chan[bytes].
No mutex is involved, and none is needed: each channel message is a
complete frame sequence written with one net.send, so handlers cannot
interleave inside a frame, and a HEADERS block plus its CONTINUATIONs
stays contiguous by construction (RFC 9113 §6.2) rather than by careful
ordering. Frames for different streams interleave at frame boundaries,
which is what multiplexing means.
Measured: four 500ms requests multiplexed on one connection complete in 0.53s; served one at a time they would take about 2.0s.
The connection is addressed by a Transport, not a link. link
is move-only, so spawn writer_task(c) consumes it and the reader can
no longer use it — the two-task design is impossible with that type. A
Transport is freely copyable, and one reader plus one writer in
opposite directions on a socket is safe. net.recv also returns
bytes directly, so no byte-at-a-time copy sits on the read path.
A Transport is either a plain fd or a TLS handle, and everything above
it is identical either way:
http2.transport_fd(fd) // h2c: cleartext, prior knowledge
http2.transport_tls(ssl) // h2 over TLS, from net.tls_accept
fn handle(stream: i32, path: str, wch: chan[http2.WMsg]) {
let hs: [http2.Header] = [];
// The body goes over UNFRAMED: the writer owns the peer's windows,
// so it decides how it is cut into DATA frames and when each may go.
chan_send(wch, http2.response_msg(stream as int, "200", hs,
to_bytes("hello")));
}
fn serve(fd: i32) {
let cn = http2.conn_new();
let rd = http2.reader_new();
let wch: chan[http2.WMsg] = make_chan(32);
let lim = http2.default_limits();
spawn http2.writer_task(fd, wch, lim.write);
guard let _p = http2.accept_preface(rd, fd, wch,
until_of(time.mono() + lim.handshake)) else { return; }
while true {
let rr = http2.read_request(cn, rd, fd, wch, lim);
guard let req = rr else let e = err_of(rr) {
if http2.is_timeout(e) { /* slow peer; shed it */ }
chan_close(wch);
return;
}
spawn handle(req.stream as i32, req.path, wch);
}
}
Verified against real curl --http2-prior-knowledge: GET, POST with a
body, five requests multiplexed on one connection, and a 64KB upload
that exercises DATA chunking and flow-control WINDOW_UPDATE.
Every read and every write is bounded, so a peer that connects and then
dribbles — or one that stops reading our responses — is disconnected
rather than left holding a task forever. http2.Limits carries four
separate budgets because they defend against four different peers:
| Budget | Covers |
|---|---|
handshake | connect → valid client preface |
idle | no request in flight, waiting for the next frame |
request | first HEADERS octet → END_STREAM |
write | one writer_task send |
idle is deliberately generous (2 minutes by default): an HTTP/2
connection sitting open with no streams is completely normal, and timing
it out aggressively breaks correct clients. request is the strict one
and applies to the request as a whole — it is never refreshed by
incoming frames, so dribbling DATA one octet at a time cannot extend it.
That distinction is the whole defence; a per-read timeout would never
fire against a slowloris, because every individual read makes progress.
http2.is_timeout(e) distinguishes a slow peer from a broken one, so a
server can answer the first with GOAWAY / E_ENHANCE_YOUR_CALM.
tests/http2_deadline runs all three attacker shapes — silent,
idle-after-handshake, and octet-at-a-time dribbling — against a server
with sub-second budgets and requires all three to be shed.
DATA is flow-controlled at two levels, per-stream and per-connection
(RFC 9113 §5.2), and the server may not exceed either. Both windows live
in the writer task, because they are connection-wide state that the
read side replenishes (WINDOW_UPDATE arrives there) and the
write side spends — routing both into one task is what makes the
accounting correct without a lock.
A handler therefore hands over its body unframed and moves on. If the peer's window is too small, the body waits in the writer's queue, not the handler's task — a peer advertising a tiny window costs a queue entry rather than a parked task.
SETTINGS_INITIAL_WINDOW_SIZE adjusts every open stream's window by the
delta rather than resetting it, and does not touch the connection window
(§6.9.2). A WINDOW_UPDATE that would push a window past 2³¹−1 is a
FLOW_CONTROL_ERROR and ends the connection with a GOAWAY rather than
being clamped.
tests/http2_flow drives both levels: a client advertising a 100-octet
stream window against a 5000-octet body, and a client with a large
stream window against a 100000-octet body where the default 65535
connection window is what binds. Each phase checks the exact octet the
server stops at, that it resumes for exactly the credit granted, and
that the resumed bytes carry the right content for their absolute offset
in the body.
Browsers speak HTTP/2 only over TLS, and only when ALPN negotiates it — there is no in-band upgrade in a browser. So h2c alone, however conformant, cannot serve one.
The server advertises what it can speak, and then checks what was actually chosen:
net.tls_ctx_alpn(sctx, "h2,http/1.1"); // offer both, h2 preferred
// ... net.tls_accept(lfd, sctx) -> ssl
if !http2.alpn_is_h2(net.tls_alpn(ssl)) {
// the peer picked http/1.1; serve it as HTTP/1.1 or hang up
}
let t = http2.transport_tls(ssl);
Checking is not optional politeness. A server that offers http/1.1
must expect to get it, and feeding an HTTP/1.1 client into the frame
parser produces bad connection preface — true, but a poor explanation
of what went wrong.
tests/http2_tls runs both halves over a real handshake: h2 frames
across SSL_read/SSL_write, and an http/1.1-only client being
declined rather than misparsed.
Checked against Go's golang.org/x/net/http2, which shares no
ancestry with nghttp2 (curl's stack, and where the HPACK fixtures came
from) — agreement between two implementations that share code proves
less than it appears to. It covers a GET, a 50KB POST, a 200KB response
verified byte-for-byte against its absolute offset, and six concurrent
streams on one connection. Run it with sh tests/http2_interop/run.sh;
it skips cleanly without a Go toolchain.
The connection layer cannot cap concurrency by itself: it does not spawn
the handlers, you do. (slang has function values now, so handing it a
callback would compile — but a callback would only move the same
question inside, and the gate below is the answer either way.) So the
bound is a gate — a token channel you hold.
gate_enter takes a token and blocks when none are left, gate_leave
returns one, and that blocking is the backpressure: the reader stops
pulling frames while every slot is busy.
Without it, a peer that sends 1000 requests down one connection gets
1000 concurrent handler tasks — measured, against a
SETTINGS_MAX_CONCURRENT_STREAMS of 100 that we were advertising and
not keeping. Advertising a limit you do not enforce is worse than
advertising none, because peers size their behaviour by it.
gate_drain also makes shutdown safe. Closing the writer channel while
handlers are still in flight panics them with send on closed channel,
and draining is what knows when none are left.
The one rule: gate_leave must run on every path out of a handler,
error returns included. A lost token permanently shrinks that
connection's capacity; losing all of them wedges that one connection —
bounded and visible, not a crash, but not something to leave in.
Separately, RST_STREAM is counted. A peer that opens a stream and
cancels it immediately (CVE-2023-44487, Rapid Reset) never looks
concurrent, so a cap alone never trips; after a burst of 100 free
cancellations, a peer whose resets outnumber half of what it opened
ends the connection. Cancelling is legitimate — a browser navigating
away resets its in-flight streams — so the burst and the ratio are both
needed to tell a normal client from a flood.
tests/http2_flood drives both shapes, resetting and not, and fails if
either exceeds the cap.
The same pieces, from the other end: a Transport, one writer_task,
one reader. client_start performs the handshake and returns a Client
whose own reader task routes each response to the task that asked for
it, so client_call is safe from any number of tasks on one connection:
let started = http2.client_start(http2.transport_tls(ssl), http2.default_limits());
guard let c = started else let e = err_of(started) { return; }
let none_h: [http2.Header] = [];
let r = http2.client_call(c, "GET", "https", "api.example", "/v1/users", none_h,
b"", 2000000000, 1048576); // timeout ns, max body
guard let resp = r else let e = err_of(r) { return; }
println(resp.status + " " + to_str(len(resp.body)));
http2.client_close(c);
client_call allocates stream ids (odd, increasing, in wire order),
waits for a slot under the peer's SETTINGS_MAX_CONCURRENT_STREAMS
(capped by Limits.max_concurrent), and times each call out on its own:
an expired call is reset with RST_STREAM(CANCEL) and answers
"timeout", and the late response's header block is still decoded so
HPACK state stays in step for the rest of the connection. A Response
carries status, headers, body and trailers. Calls are never
retried; a stream refused by GOAWAY says so, since only those are safe
to repeat elsewhere.
For one stream at a time without the reader task: client_preface, then
client_request_msg sent to the writer and client_read_response for
that stream. Requests are validated rather than cleaned up: header names
must be lowercase tokens, values may not contain CR or LF, and
connection-specific fields (connection, host, transfer-encoding,
upgrade, te other than trailers) are refused. Responses that RFC
9113 calls malformed are refused too: no :status, a pseudo-header in
trailers, a content-length the body does not match, PUSH_PROMISE
(the client disables push).
On the server side, response_trailers_msg builds a response that ends
in trailers (gRPC's status travels in them): the body goes out as DATA
without END_STREAM, then the trailers as a final HEADERS frame.
The client's peer SETTINGS handling also fixed one server bug:
SETTINGS_HEADER_TABLE_SIZE bounds the peer's decoder (RFC 7541
§4.2), and resizing our own decoder table to it made a peer that
advertised a small table unable to use the dynamic table it was entitled
to. And a writer that can no longer write now keeps taking messages until
its channel closes, so a producer is never left blocked behind it.
PRIORITY is validated but not acted on: it is deprecated in RFC 9113
§5.3.2, so ignoring the prioritisation is conformant, but a malformed
frame is still rejected as the connection error it is (§6.3) rather than
waved through to desync the stream. No browser has been run against the
TLS path yet — the machinery is there and tested against slang's own
client, but a real browser is different evidence.
stringsSearch, trim, case, split and join on str. The package cannot be named
str because that token is the type — the same reason Go calls its own
strings.
import "strings";
strings.find("hello world", "world"); // 6, or -1
strings.rfind("a/b/c", "/"); // 3
strings.contains("hello", "ell");
strings.has_prefix("hello", "he");
strings.has_suffix("hello", "lo");
strings.count("a,b,c", ","); // 2
strings.trim(" hi \r\n"); // "hi" (space/tab/CR/LF)
strings.trim_start(s); strings.trim_end(s);
strings.to_upper("hi"); strings.to_lower("HI"); // ASCII only
strings.slice("hello", 1, 3); // "el"
strings.slice("hello", -3, 5); // "llo" — negative counts back
strings.repeat("ab", 3); // "ababab"
strings.replace("a,b,c", ",", " | ");
strings.split("a,b,,c", ","); // ["a", "b", "", "c"]
strings.join(parts, ","); // the inverse of split
strings.join_bytes(parts, b","); // the same for [bytes]: sized once, copied once
strings.from_float(0.1 + 0.2); // "0.30000000000000004"
from_float returns the shortest text that to_float reads back as
exactly the same number, as to_str, println, inspect and
json.encode now do too (they used six significant digits, so
to_str(123456789.125) was "1.23457e+08"). The difference is the
names: from_float writes NaN and the infinities as NaN, Infinity
and -Infinity, where to_str writes nan, inf and -inf.
This is a compiler-provided native package, and it has to be: str
supports len, + and == and nothing else — it cannot be indexed or
sliced — so none of it could be written in slang without converting to
bytes and back on every call. byteutil covers the bytes side.
Three behaviours worth knowing:
str is UTF-8 bytes; doing better means shipping a Unicode table and
a normalisation policy, which is a different project. Treat these as
byte operations, because that is what they are.slice clamps rather than panics. Slicing is how you narrow a
string you just searched, and a find that returned -1 on the line
above should not turn the next line into a crash. A negative index
counts from the end.split and join are exact inverses. Adjacent separators produce
empty elements, so the result always has count(s, sep) + 1 elements
and join(split(s, sep), sep) == s for any non-empty separator. An
empty separator splits into single bytes.encodingHex, base64, base64url, percent-encoding and query strings — the ways arbitrary bytes travel through a channel that only carries text. The package also converts binary64 floats to and from little-endian bytes for binary protocols such as BSON.
import "encoding";
encoding.hex_encode(crypto.sha256(b"abc")); // "ba7816bf8f01cfea..."
encoding.base64_encode(b"aladdin:opensesame");// HTTP Basic credentials
encoding.base64url_encode(sig); // a JWT segment: -_ alphabet, no padding
encoding.url_encode("a b&c"); // "a%20b%26c"
encoding.form_encode("a b&c"); // "a+b%26c"
encoding.float64_to_le(1.5); // exact IEEE-754 bytes, low byte first
let q = "/search?q=hello+world&page=2";
guard let term = encoding.query_get(q, "q") else { return; } // "hello world"
encoding.query_keys(q); // ["q", "page"]
| Function | Signature |
|---|---|
encoding.hex_encode | (b: bytes) -> str |
encoding.hex_decode | (s: str) -> result[bytes, str] |
encoding.base64_encode | (b: bytes) -> str |
encoding.base64_decode | (s: str) -> result[bytes, str] |
encoding.base64url_encode | (b: bytes) -> str |
encoding.base64url_decode | (s: str) -> result[bytes, str] |
encoding.url_encode | (s: str) -> str |
encoding.url_decode | (s: str) -> result[str, str] |
encoding.form_encode | (s: str) -> str |
encoding.form_decode | (s: str) -> result[str, str] |
encoding.query_get | (url: str, key: str) -> opt[str] |
encoding.query_keys | (url: str) -> [str] |
encoding.float64_to_le | (value: float) -> bytes |
encoding.float64_from_le | (bytes: bytes) -> result[float, str] |
Like regex, strings and os — and unlike crypto and sql — this
is pure computation, so importing it adds no link flag.
Five things worth knowing:
Encoders are infallible; decoders are not. Any byte string has a
hex form, so hex_encode returns a bare str. Decoding takes input
the program did not produce — a query string, a header, a token — so
every decoder returns result[_, str], and the message names the byte
offset it gave up at. "invalid base64" about a 400-character token is
not a diagnosis.
url_* and form_* differ only in +, and that is exactly why
they have separate names. In application/x-www-form-urlencoded a
space is +; in a URI it is %20. Decoding a form body with
url_decode leaves literal + where every space belongs, and nothing
reports it — the failure surfaces later as a lookup that does not
match. One function with a flag would make that the default mistake.
%00 is an error, not a truncation. url_decode and
form_decode return str, which is NUL-terminated, so a decoded zero
byte would silently cut the value short. They refuse it and say so.
hex_decode and base64_decode return bytes, which carries an
explicit length, so a zero byte there is ordinary data and round-trips
exactly.
Float bytes are exact and little-endian. float64_to_le and
float64_from_le preserve the IEEE-754 binary64 bit pattern, including
signed zero and NaN payload bits. float64_from_le accepts exactly
eight bytes and returns an error for any other length.
query_get is opt, and query_keys is a list. A missing
parameter is absent data, not bad data, so it is opt[str] — the
README rule above. Keys come back as a list rather than a map because
a query may legally repeat a key and a map would have to drop one,
the same reason os.environ is a list. query_get takes the first
value; a bare ?debug is present with an empty value, not absent.
Percent-escapes are emitted uppercase (RFC 3986 §2.1) and hex digests
lowercase (what sha256sum, git and every API that returns one use).
Both decoders accept either case.
httpcAn HTTP/1.1 client — the mirror of http, which serves. Speaks
http:// and https://, follows redirects, and decodes chunked
responses.
import "httpc";
import "time";
let dl = until_of(time.mono() + 5000000000); // 5s for the whole request
let r = httpc.get("https://api.example.com/users?id=1", dl);
guard let resp = r else let e = err_of(r) {
log.error("request failed: " + e);
return;
}
println(to_str(resp.status) + " " + to_str(len(resp.body)) + " bytes");
// a POST, and a header the request owns
let req = httpc.new_request("POST", "https://api.example.com/users");
req.headers["Authorization"] = "Bearer " + token;
req.body = to_bytes(payload);
let r2 = httpc.send(req, dl);
| Function | Signature |
|---|---|
httpc.get | (url: str, deadline: until) -> result[Response, str] |
httpc.head | (url: str, deadline: until) -> result[Response, str] |
httpc.post | (url: str, content_type: str, body: bytes, deadline: until) -> result[Response, str] |
httpc.send | (req: Request, deadline: until) -> result[Response, str] |
httpc.new_request | (method: str, url: str) -> Request |
httpc.header | (r: Response, name: str) -> opt[str] |
httpc.parse_url | (url: str) -> result[Url, str] |
httpc.new_client | () -> Client |
httpc.client_get / client_head | (c: Client, url: str, deadline: until) -> result[Response, str] |
httpc.client_post | (c: Client, url: str, content_type: str, body: bytes, deadline: until) -> result[Response, str] |
httpc.client_send | (c: Client, req: Request, deadline: until) -> result[Response, str] |
httpc.idle_count | (c: Client) -> int |
httpc.close_idle | (c: Client) |
httpc.enable_cookies / clear_cookies | (c: Client) |
httpc.set_cookie | (c: Client, url: str, set_cookie_line: str) |
httpc.cookies | (c: Client, url: str) -> [Cookie] |
httpc.parse_cookie_date | (s: str) -> int (unix ns, or -1) |
Request carries method, url, headers, body, max_redirects
(default 5, 0 disables following) and ca_path ("" = the system
trust store). Response carries status, status_text, headers,
body and url — the last being the URL that actually answered, which
after a redirect is not the one you asked for.
The deadline covers the whole request, connecting included: a host that never answers the SYN fails at the deadline, over plain TCP and TLS alike.
Separate from http rather than folded into it because http imports
only byteutil, while a client necessarily imports net — and for a
TLS request that drags -lssl/-lcrypto onto the link line of every
program that merely wanted to serve HTTP.
Five things worth knowing:
A 404 is a Response, not an err. The result is about whether
the exchange happened — DNS, connect, TLS, framing. A server that
answers "no" answered. Collapsing the two would make a 404 and a
connection refusal indistinguishable at the call site, and they need
different handling.
Certificates are verified, and there is no flag to stop that.
Verified against expired, self-signed and wrong.host on
badssl.com: all three are refused, a valid one is accepted. For an
internal service signed by a private CA, set ca_path to that
bundle — the answer is a different trust anchor, never a disabled
check.
Credentials do not survive a cross-origin redirect. Authorization,
Cookie and Proxy-Authorization are dropped when the scheme, host
or port changes, because the server that sent the Location chose
where it points, and that is exactly how a token gets exfiltrated.
Same-origin redirects keep them.
Redirect method rules follow browsers, not the RFC's original
wording. 303 always becomes GET; 301 and 302 after a POST also
become GET and drop the body, which is what every browser and curl do
and therefore what servers expect. 307 and 308 exist to preserve the
method, so they do. A redirect loop stops at max_redirects and hands
back the last 3xx rather than spinning.
Everything a server can make you allocate has a ceiling — 64 KiB of headers, 32 MiB of body, and a bounded chunk-size line. A client talks to servers it does not control, so a buffer sized on their say-so is a denial of service arriving through an ordinary call.
httpc.get and friends are one-shot: a connection per request, closed
afterwards, with Connection: close sent so the server does not hold
it open. A Client keeps idle connections and reuses them — up to
max_idle_per_host per origin (default 4), for idle_timeout
nanoseconds (default 30s), and 64 across all origins.
let c = httpc.new_client(); // share one across tasks
let r = httpc.client_get(c, "https://api.example.com/v1/items", dl);
The one-shot functions ARE a client — one that keeps nothing — so framing, redirects, decompression and every security rule are one code path, and cannot drift between the two.
A Client is safe to share between tasks: the pool is behind a
mutex. Its dials and reuses fields and httpc.idle_count(c) make
its behaviour checkable rather than asserted.
net.idle_alive — one non-blocking
MSG_PEEK, no latency, nothing consumed.ca_path. A connection verified against
one trust anchor is never handed to a request that asked for another —
tested over real TLS: the second request, demanding a different CA,
fails verification instead of riding the verified connection.Connection: close, a
framed body read exactly, and nothing left over. A body delimited by
the connection closing is never reused.Client operations come in two equivalent forms: handle-first package
functions — httpc.client_get(c, url, dl), the same idiom as
sql.exec(db, ...) — and methods, c.get(url, dl). The methods are
get, head, post, send, idle_count, close_idle,
enable_cookies, clear_cookies, set_cookie and cookies on Client,
and header on Response. c.get and the one-shot httpc.get share a
name; the method is the pooled client's, the function is not.
Requests carry Accept-Encoding: gzip, deflate and responses are
decoded transparently — unless the caller set Accept-Encoding
themselves, in which case the body comes back exactly as sent. A
caller who asked for gzip wants the gzip (to proxy it, to store it), and
decompressing behind their back would hand them something else.
deflate is tried as zlib (what RFC 9110 says it means) and then as
raw DEFLATE (what a real share of servers send under that name).Content-Encoding and Content-Length are removed:
both describe the bytes that crossed the wire, not the body in hand.RFC 6265, with RFC 6265bis's rules for Secure and the __Secure- /
__Host- prefixes. Off by default — the opposite of a browser, on
purpose:
let c = httpc.new_client();
httpc.enable_cookies(c); // this Client now acts as ONE identity
A browser's jar belongs to one person. A server's Client is usually
shared, one per process, used on behalf of every user it serves, and a
jar there would store user A's session cookie and send it on user B's
request. Go's http.Client makes the same call. Turn the jar on for a
Client that represents one identity: a scraper, a test driver, an
integration with a login-based API.
302 reaches
the page it points to, which is how nearly every login works; a
redirect to another host carries that host's cookies, not the first
one's.Secure cookie from http:// (a network attacker could
otherwise overwrite an https session); a Domain the host does not
belong to; a bare single-label Domain such as com; a Domain that
is part of an IP address; __Host- without Secure, or with a
Domain, or with a path other than /; a line with no =; anything
over 4096 bytes. Secure cookies are only ever sent over https.Domain goes back to exactly that
host (127.0.0.1 and localhost are different scopes); Path=/p
matches /p and /p/x but not /pathx. Longer paths are sent first.Max-Age wins over Expires whatever order they arrive
in, and a past date or Max-Age=0 deletes. Dates are parsed with RFC
6265's lenient algorithm, since servers send RFC 1123, RFC 850 with
two-digit years and asctime alike; httpc.parse_cookie_date exposes it.Response.set_cookies holds each Set-Cookie line separately. Use
it rather than headers["set-cookie"]: repeated headers are joined
with ", ", and an Expires date contains a comma, so two joined
lines cannot be split apart again.No public-suffix list is applied, and the consequence is stated
rather than left to be discovered: a response from a.example.co.uk may
set a cookie for Domain=co.uk, and this jar will then send it to every
*.co.uk host. Nothing in slang ships a suffix list, and an embedded
copy goes stale. The bare-TLD rule stops Domain=com; it does not stop
that case.
Not done, deliberately: no HTTP/2 client, no multipart bodies, no proxy support, no public-suffix list.
compressgzip, zlib and raw DEFLATE, over zlib (-lz, added only when a program
imports compress).
import "compress";
let gr = compress.gzip(body);
guard let gz = gr else let e = err_of(gr) { log.error(e); return; }
// decompression ALWAYS takes a ceiling -- see below
let ur = compress.gunzip(payload, 4194304); // 4 MiB
guard let plain = ur else let e = err_of(ur) {
log.warn("rejected: " + e); // names the limit
return;
}
| Function | Signature |
|---|---|
compress.gzip | (b: bytes) -> result[bytes, str] |
compress.gzip_level | (b: bytes, level: int) -> result[bytes, str] |
compress.gunzip | (b: bytes, max_out: int) -> result[bytes, str] |
compress.deflate | (b: bytes) -> result[bytes, str] |
compress.inflate | (b: bytes, max_out: int) -> result[bytes, str] |
compress.deflate_raw | (b: bytes) -> result[bytes, str] |
compress.inflate_raw | (b: bytes, max_out: int) -> result[bytes, str] |
Decompression takes a mandatory output limit. max_out is a
required argument, not an optional one with a generous default. The
expansion ratio is unbounded — 65 KB of gzip holds 64 MB of output, and
that ratio goes much further — so a program that decompresses anything
it did not itself produce is one hostile input away from the OOM killer.
The ceiling is enforced before the allocation that would cross it, not
by inspecting the result afterwards: rejecting a 64 MB bomb was measured
at 3.9 MB peak RSS against a 0.86 MB baseline. A default here would be a
number nobody chose, applied at every call site that never thought about
it.
Three containers, because HTTP needs all three. They are the same
compressed bits under different headers: gzip (RFC 1952) is what
servers send; zlib (RFC 1950) is what the deflate content-coding is
supposed to mean; raw (RFC 1951, no header) is what the servers that
get it wrong send instead — which is why inflate_raw exists rather
than being a purist's omission. They are not interchangeable, and each
decoder says so rather than producing garbage.
Backed by zlib rather than written here, unlike regex. That
package was written in-house because a backtracking engine has a
catastrophic input class and being immune to it by construction was the
point. DEFLATE has no equivalent argument — what it has is thirty years
of hostile input and a reference implementation on every platform slang
targets. A hand-written inflate would be a memory-safety surface with no
upside, since every bug in one is a buffer overrun driven by
attacker-controlled input.
mathConstants, float functions, rounding, and checked integer arithmetic,
compiled to C's <math.h> and compiler builtins: math.sqrt(x) is the
instruction, not a call, and no function here is a safepoint.
import "math";
let r = math.sqrt(2.0); // 1.4142135623730951
let a = math.atan2(1.0, 1.0) * 4.0; // math.pi
let x = math.clamp(v, 0.0, 1.0);
let n = math.round_to(3.14159, 2); // 3.14
let i = math.floor_int(f) ?? 0; // none for NaN, inf, out of range
// One function for every numeric type: T is the arguments' type.
let b: u8 = 250;
let s = math.checked_add(b, 10); // opt[u8]: none, 260 does not fit
let w = math.saturating_add(b, 10); // u8 255
let m = math.min(b, 100); // u8: a literal takes b's type
let total = math.checked_mul(price, qty) ?? 0; // money: overflow is none
math.div_floor(-7, 2); // -4, where -7 / 2 is -3
math.rem_euclid(-7, 3); // 2, never negative
math.gcd(48, 18); // 6
math.isqrt(99); // 9
math.mul_div(a, b, c); // a * b / c with no overflow in between
math.popcount(b); // 6, on b's own width
math.format_fixed(math.pi, 4); // "3.1416"
Every numeric argument of a call shares one type, T, so math.abs of
an i32 is an i32, and math.checked_add of two u8s is an
opt[u8]. T is what a + b of the arguments would be, except that a
literal takes the other arguments' type (math.min(b, 100) is a u8)
and must fit it. Float functions make an integer argument float;
integer functions reject a float. One name covers all ten numeric types
because slang has no overloading.
pi, tau, e, phi, sqrt2, sqrt3, ln2, ln10,
log2e, log10e, inf, neg_inf, nan, max_float, min_normal,
min_subnormal, epsilon, f32_max, f32_min_normal, f32_epsilon,
max_int, min_int, and max_/min_ of each fixed-width integer.abs, min, max, clamp, sign.is_nan, is_inf, is_finite, is_normal, signbit,
copysign, nextafter, nextup, nextdown, ulp, frexp_mantissa,
frexp_exponent, ldexp, ilogb, float_bits/float_from_bits,
f32_bits/f32_from_bits, total_cmp (a total order, NaN included),
approx_eq(a, b, rel_tol, abs_tol).floor, ceil, trunc, round (half away from zero),
round_even (half to even), round_to(x, decimals),
round_to_multiple, fract; floor_int, ceil_int, round_int,
trunc_int give an opt[int].sqrt, cbrt, hypot, pow, powi(x, n),
fma, fmod, remainder, mod_euclid, exp, exp2, expm1, log,
log2, log10, log1p, log_base.Floats follow IEEE 754: NaN propagates (through min, max and clamp
too), -0.0 is kept and orders below 0.0, and no float function
panics. An integer operation that can fail returns an opt: overflow, a
zero divisor, MIN / -1. div_floor, div_ceil, div_euclid and
rem_euclid panic on a zero divisor as / does, isqrt on a negative
argument, and align_up/align_down on an alignment that is not a
power of two. Programs link libm (-lm) whether or not they import
math; on macOS that is libSystem.
math.mul_mod(a, b, m) is (a * b) mod m with the product held at
twice the width, so modular arithmetic near the type's limit cannot
overflow; math/numtheory is built on it.
math/statsStatistics over lists of any numeric type, and streaming accumulators
for numbers that never all exist at once. Written in slang over math.
import "math/stats";
let xs = [12.5, 9.0, 14.25, 11.0];
stats.mean(xs); // 11.6875
stats.median(xs); // 11.75
stats.stddev(xs); // sample (n - 1); pstddev is population
stats.quantile(xs, 0.9); // R type 7 / NumPy's default
stats.correlation(xs, ys); // Pearson's r; spearman for ranks
let fit = stats.linear_regression(xs, ys); // fit.slope, fit.intercept, fit.r2, fit.at(x)
let h = stats.histogram(xs, 10); // h.edges, h.counts
// Streaming, O(1) per value, mergeable across tasks:
let lat = stats.running(); // count, mean, variance, min, max
lat.add(elapsed_ms);
let p = stats.digest(); // quantiles of a stream (t-digest)
p.add(elapsed_ms);
p.quantile(0.99);
stats.normal(100.0, 15.0).cdf(130.0); // 0.9772498680518208
stats.normal(0.0, 1.0).quantile(0.975); // 1.9599639845400538
stats.poisson(3.0).pmf(5);
sum (compensated), product, mean,
weighted_mean, geometric_mean, harmonic_mean, median, mode,
min, max, spread, variance/pvariance, stddev/pstddev,
mad, zscores, skewness, kurtosis (excess).quantile, percentile, quartiles, iqr, ranks;
sorted_floats and quantile_sorted to sort once and ask many times.covariance, correlation, spearman,
linear_regression.running() (Welford), ewma(alpha) and
ewma_half_life(n), digest() (a merging t-digest: p99 and p999 stay
accurate in about 100 centroids). Each has add, and running and
digest merge.normal, log_normal, uniform, exponential
(pdf, cdf, quantile), poisson and binomial (pmf, cdf).A result with no value for its input -- the mean of no numbers, the
variance of one, a correlation with a constant list -- is NaN, which
propagates and tests true with math.is_nan; functions that return one
of the elements (min, max, mode) return an opt of the element
type. Sums are compensated (Neumaier) and variances are two-pass around
the mean, so neither drifts on long or offset data: stats.sum([0.1, 0.2, 0.3]) is 0.6.
math/numtheoryModular arithmetic, primes and factorization on int, over
math.mul_mod so a modulus near 2^62 is as safe as a small one.
import "math/numtheory";
numtheory.mod_pow(2, 100, 1000000007); // 976371285
numtheory.mod_inv(3, 7) ?? 0; // 5
numtheory.is_prime(9223372036854775783); // true: exact for every int
numtheory.factorize(360); // [{p: 2, k: 3}, {p: 3, k: 2}, {p: 5, k: 1}]
numtheory.primes_below(30); // [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
numtheory.crt([2, 3, 2], [3, 5, 7]) ?? -1; // 23
mod_pow, mod_mul, mod_inv, ext_gcd (a Bezout { g, x, y }),
is_prime (deterministic Miller-Rabin with the seven bases proven for
64 bits), next_prime, prev_prime, primes_below (a sieve over the
odd numbers), factorize (trial division, then Pollard's rho with
Brent's cycle detection), divisors, totient, crt (moduli need not
be coprime), binomial_mod (Lucas, for a prime modulus) and
fibonacci (exact through F(92)). A modulus must be positive: a
function given one <= 0 returns none, 0 or false rather than
panicking, and no result is negative.
sortSorting lists in place with Go's sort engine shape, plus binary
search over what is already sorted. ints, floats and strings
sort by native comparison; by sorts any list of anything by a key
function returning int, float or str; by_stable keeps equal
keys in their original order; the search functions binary-search a
sorted list and answer opt[int].
import "sort";
let xs = [5, 3, 9, 1, 3];
sort.ints(xs); // in place, ascending
let i = sort.search_ints(xs, 3) ?? -1;
let users = load_users();
sort.by(users, user_age); // key fn -> int, float or str
sort.by_stable(users, user_name); // equal keys keep original order
let names: [str] = [];
for u in users { push(names, u.name); }
sort.strings(names);
let fs = [3.5, 1.0, 0.0 / 0.0, 0.5];
sort.floats(fs); // NaN values sort first (Go's rule)
The unstable sorts allocate nothing; each runs an entry pass that
answers already-sorted and fully-reversed lists in one scan, and the
quicksort settles each duplicate group in one partition pass, so
four-valued million-element lists sort in tens of milliseconds.
by_stable merges with one scratch list of about n/2 elements.
byteutilSearch, trim, and split on the bytes type — no new syntax. The
package cannot be named bytes because that token is the type.
import "byteutil";
byteutil.find(b"hello", 0, 108); // 2, or -1
byteutil.has_prefix(b"hello", b"he");
byteutil.has_suffix(b"hello", b"lo");
byteutil.trim(b" hi\r\n"); // b"hi" (space/tab/CR/LF)
byteutil.split(b"a,b", 44); // [b"a", b"b"]
flagsCommand-line options, parsed out of proc.args(). A slang-source stdlib
package. Declare the flags on a Set, parse, read the values back:
import "flags";
import "proc";
let fs = flags.new("greet", "print a greeting");
fs.add_str("name", "n", "world", "who to greet");
fs.add_int("count", "c", 1, "how many times");
fs.add_bool("loud", "l", false, "shout");
fs.add_list("tag", "t", "label to attach (repeatable)");
let p = fs.parse_or_exit(proc.args());
let greeting = "hello " + p.get_str("name");
if p.get_bool("loud") { greeting = greeting + "!"; }
let i = 0;
while i < p.get_int("count") { println(greeting); i = i + 1; }
for file in p.args() { println("file: " + file); }
$ greet -n bob -c 2 -l a.txt b.txt
hello bob!
hello bob!
file: a.txt
file: b.txt
$ greet --help
Usage: greet [options] [args...]
print a greeting
Options:
-n, --name <str> who to greet (default: "world")
-c, --count <int> how many times (default: 1)
-l, --loud shout
-t, --tag <str> label to attach (repeatable)
-h, --help show this help
| Declare | Value type |
|---|---|
fs.add_str(name, short, default, usage) | str |
fs.add_int(name, short, default, usage) | int |
fs.add_float(name, short, default, usage) | float |
fs.add_bool(name, short, default, usage) | bool |
fs.add_list(name, short, usage) | [str], one entry per occurrence |
fs.require(name) | the flag must appear |
fs.set_takes("<file>...") | positional synopsis on the usage line ("" for none) |
fs.stop_at_first_arg() | flags end at the first positional (subcommands) |
| Read | |
|---|---|
p.get_str(name) / get_int / get_float / get_bool / get_list | the value, or the default |
p.is_set(name) | did the command line set it? |
p.args() | positional arguments, in order |
p.wants_help() | was -h / --help given? |
fs.parse(args) | result[Parsed, str] |
fs.parse_or_exit(args) | Parsed; on a usage error prints to stderr and exits 2, on --help prints the usage and exits 0 |
fs.usage() | the help text as a str |
What a command line may contain:
--name bob or --name=bob. Short flags: -n bob,
-nbob or -n=bob. Give "" for a flag with no short form.--loud sets true, --loud=false or
--no-loud sets false. Short bools combine, -lv is -l -v, and a
value flag may end a cluster: -lc5.-- ends the flags,
and a lone - is a positional argument (the usual "read stdin"). The
last occurrence of a flag wins, except a list flag, which collects them.-, so --offset -5 works. A positional argument that looks like a flag
needs a -- before it.parse takes the list exactly as proc.args() returns it and skips element
0, the program name. The positionals come back without it, so a subcommand
parses what its parent left with the same call:
let global = flags.new("tool", "");
global.add_bool("verbose", "v", false, "chatty output");
global.stop_at_first_arg(); // "push --force" is not ours
let g = global.parse_or_exit(proc.args()); // g.args() == ["push", "--force", "origin"]
let push = flags.new("push", "");
push.add_bool("force", "f", false, "overwrite");
let s = push.parse_or_exit(g.args()); // element 0, "push", is skipped
Two kinds of failure. What a user can type wrong (an unknown flag, a
missing or malformed value, a required flag left out) comes back as an
err from parse: bad data, so result[_, str]. What only a programmer
can get wrong (a duplicate or empty flag name, a short name longer than one
character, reading a flag that was never declared, get_int on a str flag)
panics, because no input can cause it and it should fail the first time the
program runs, not on the user's third flag.
Why declare, then read. Go's flag.String hands back a pointer that is
filled in later. slang has no references to locals, so the values live on the
Parsed result and are read by name. The declaring methods are add_str and
so on because str, int, float and bool are type keywords.
Not supported: flags that take several values in one occurrence,
--flag value1 value2; abbreviations of long names; environment-variable
fallbacks. Set a default from proc.getenv yourself when you want one.
httpHTTP/1.1 over link / wire / until / fault. Parse a request
from bytes, or read from a connection into a caller-sized wire
(the max request size). read takes the unconsumed prefix length and
returns Incoming with leftover compacted to the front of the wire,
so one connection can carry many requests. write serializes a
Response through an arena; write_head answers a HEAD request with the
head write would send -- Content-Length the length of the body GET
would carry, as RFC 9110 has it -- and no body. A request keeps its header block as it
arrived; header(req, name) looks it up case-insensitively. A body is framed by
Content-Length or by Transfer-Encoding: chunked (chunk extensions
ignored, trailers read and discarded). wants_close follows HTTP/1.1
keep-alive (and HTTP/1.0 close-by-default). A request with no header
lines at all (GET / HTTP/1.0 and a blank line) is accepted.
read is the hot path, so it allocates only what the request it returns
holds: the path, one copy of the header block, the body when there is
one, and the Request/Incoming around them. The common methods (GET,
HEAD, POST, PUT, PATCH, DELETE, OPTIONS) and both versions come
back as literals, and wants_close decides from the header block in
place. A plain GET costs 6 GC allocations; tests/run_tests.sh holds it
to that.
Framing decides where a request ENDS, so it is a security boundary: if a
proxy in front and this server frame the same bytes differently, the
leftover is read as a second request the proxy never saw (request
smuggling). read and parse therefore refuse, rather than guess at:
Transfer-Encoding together with Content-Length, or in an HTTP/1.0
request;chunked (no lists such as gzip, chunked);Content-Length or Transfer-Encoding header;Content-Length longer than 18 digits, or not all digits;After read returns an error, close the connection (the examples all
return). The error means this server could not tell where the request
ended, so it cannot tell where the next one begins either.
The writing side holds the same line. A CR or LF inside a header field
would end the line early, so what follows would become a header of its
own or the start of the body: response splitting, when a handler copies
request data into a header. write and serialize therefore write every
field (a with_header value, a raw with_headers line, location, the
content type, the status text) with CR and LF turned into spaces, the rule
Go's net/http applies. A clean field is written as it is, at no extra
cost. httpc does the same for the method and the request headers it
sends, and percent-encodes CR, LF, space and tab in the request target.
http2's header encoder does the same for HTTP/2 fields.
Every response carries Date (RFC 9110 6.6.1): the current second, kept
by the runtime for all threads and copied into the response with no clock
read and no allocation; a handler's own Date is the one sent. A 1xx, 204
or 304 response is written with no Content-Length and no body, whatever
the Response holds (8.6 forbids the header on 1xx and 204, and on a 304
unless it equals the 200's; body bytes there would be read as the start of
the next response). http2 follows the same rules. A prebuilt static
rendering (static_render) ends its header block with
static_date_line(), whose date write_static refreshes on every send;
a rendering built elsewhere gets the same by ending its headers with that
line.
import "http";
fn serve(c: link) {
let ra = arena_new(16384);
let sa = arena_new(16384);
let buf = ra.wire(8192);
let filled = 0;
while true {
let rr = http.read(&mut c, buf, filled, until_never());
guard let got = rr else { return; }
let wr = http.write(&mut c, http.ok_text(got.req.path), &mut sa,
until_never());
guard let _n = wr else { return; }
sa.reset();
if http.wants_close(got.req) { return; }
filled = got.filled;
}
}
See examples/httpd/ for a listener loop on this package.
In a program that asks proc.shutdown_requested(), every thread starts
with SIGTERM/SIGINT blocked, and one dedicated thread receives them
with sigwait — so no signal lands on a worker thread mid-request, and
whichever thread a blocked accept() is parked on, the runtime wakes it.
There's a narrow startup race inherent to this: a signal that arrives in
the brief window before main() blocks the signals gets the OS's default
disposition (immediate termination) instead of graceful handling, same as
any signal-handling program.
spawn submits a function as an sl_task on the M:N worker pool;
chan[T] is a bounded, park-aware queue. The pool has one worker per
CPU the process may run on: the online CPUs, narrowed on Linux by the
affinity mask (taskset, a container cpuset) and by a cgroup CPU quota
(docker --cpus, a Kubernetes CPU limit), rounded up and at least 2.
SLANG_WORKERS=N overrides it.
Blocking-looking code stays blocking-looking — net.accept,
net.recv, time.sleep, io.read_line, and chan_send/chan_recv park the task
and return the OS thread to the pool. There is no colored-function
split. TLS handshake and I/O park on the same reactor as TCP
(SSL_ERROR_WANT_READ/WANT_WRITE). DNS (getaddrinfo) runs on
a dedicated thread; the dialing task parks until it finishes.
fn worker(id: i32, results: chan[i32]) {
chan_send(results, (id * 10) as i32);
}
let results: chan[i32] = make_chan(3);
spawn worker(1, results);
spawn worker(2, results);
spawn worker(3, results);
let mut_sum = 0;
for i in 0..3 {
let v = chan_recv(results); // blocks until a value or close
guard let x = v else {
println("channel closed early");
exit(1);
}
mut_sum = mut_sum + x;
}
println(mut_sum); // 60
chan_close(results);
chan_recv(results) ?? -1; // none after close+drain -> -1
spawn f(args...); evaluates every argument in the spawning
context (no closures — nothing is captured implicitly) and submits
f as a growable-stack task on the striped run queues (16 hashed
stripes, plus a global doorbell for sleepers). Every worker scans the
stripes from a starting point that moves on each pop, so every stripe
is served at the same rate and no runnable task waits indefinitely.
A task woken or spawned by a running task goes to that worker's
runnext slot instead and runs there next, while the caches are warm
(a request handed to a child task and back, a channel's two ends); a
chain of such hand-offs shares one time slice, and an idle worker
steals a slot rather than sleep. A worker that runs out of work spins
for up to 20 µs first, watching the stripes and (on Linux) the
network without blocking, and up to half the workers spin at once; a
queued task wakes at most one sleeping worker, and none while one
spins. A spawn whose task's very next step
is join_wait on it (let h = spawn f(); let r = join_wait(h);, or
join_wait(spawn f())) wakes no idle worker at all: the spawner parks
at once and its own worker runs the child, then the spawner again.
A task that runs past its time slice without reaching a yield point is
preempted asynchronously: a signal redirects it into a trampoline that
saves every register, parks it, and restores them all when it resumes.
SLANG_SCHED_STAT=1 prints scheduler counts at exit, among them
async_preempts and misaligned_preempts, the preemptions whose
trampoline called C on a stack off the ABI's 16-byte alignment (always
0; tests/run_tests.sh checks it), and deadline_waits and
reactor_nudges: IO waits that carried a deadline, and those that had
to wake the reactor to recompute its sleep, and runnext_wakes, the
sleeping workers woken for a task put in a runnext slot, and
idle_wakes, every time a sleeping worker woke, and
park_lock_waits, the parks and resumes that waited for the parked
registry's lock. The reactor never sleeps
more than a second, so only a deadline closer than that nudges it.
f may be a plain top-level function, an extern fn, or a
function value (spawn w(1, out);, spawn job.run(x);) — not a
method and not a builtin. There is no spawn on net.*/time.*
calls directly; wrap the native call in a plain function and spawn
that instead.
As a statement, the result is discarded. As an expression,
let h = spawn f(...) has type join[T] when f returns .
parks until finishes; a panic
in that task is , not process death.
What this does not give you. There is no ownership/borrow checker
here — slang's answer to "many tasks, no data races" is thread
isolation plus channels for the values that need to move between
tasks, not a type system that forbids sharing mutable state. Passing
a struct, list, or map into a spawned task and mutating it from more
than one task concurrently is exactly as unsafe as it is in Go or
Java: nothing currently stops you, so don't — mutex is there when
you need it. join_wait waits for one spawned task.
proc.active_tasks() (see the proc section) is the aggregate count
of everything currently in flight,
useful for draining on shutdown but not for waiting on one task in
particular.
Tests live next to the code they test, Go-style: files named *_test.sl
hold functions named test_*, taking nothing and returning nothing, which
fail through assert or panic.
// calc_test.sl
fn test_add() {
assert(add(2, 3) == 5);
}
fn test_clamp() {
let got = clamp(15, 0, 10); // private functions are reachable:
assert(got == 10, "got " + to_str(got)); // tests are in the package
}
slangc test # the package in the current directory
slangc test path/to/pkg # another one
slangc test --run clamp # only tests whose name contains "clamp"
slangc test -v # a line for every passing test too
A passing test prints nothing; a failure prints its message and location; the last line counts them. A run where everything passes is one line:
FAIL test_clamp (30us)
got 15 at calc.test_clamp:7
FAIL: 1 of 2 failed (190us)
ok: 2 passed (190us)
*_test.sl files never reach a normal build. Test helpers can't leak
into a program, and a test file's imports can't add link flags to one.slang.project pin runs its tests like any
other (after slangc get).slangc test does not run its top-level statements: the test
runner is main. Functions, structs and methods are all there.test_ function with parameters, say). A package
with no test files exits 0 and says so.--keep keeps the generated runner and prints where it is.slang already transpiles to C and shells out to cc, so calling into
existing C libraries is a thin layer on top of that, not a new
ecosystem: declare the C function's signature, tell the linker which
library to pull in, and call it like any other function.
link "sqlite3"; // -> '-lsqlite3' on the final cc invocation
extern fn sqlite3_libversion() -> str;
println(sqlite3_libversion());
extern fn name(params) -> ret; declares a C function with no
body — it calls the real, unmangled C symbol directly. int,
i8..u64, f32, float, bool, and str already share their C
representation, so they marshal for free. bytes does not
auto-decay (it is a boxed struct internally); pass bytes_ptr(b)
and len(b) as two separate rawptr/i32 arguments instead of
inventing implicit multi-argument expansion for one type.rawptr is an opaque foreign pointer (void *) for handles a C
library owns, like sqlite3* or FILE*. It can be passed around
and compared against nullptr, nothing else — no arithmetic,
no field access, no dereference. A rawptr is never GC-owned: if a
C library allocated it, free it through another extern fn, not by
letting it go out of scope.link "name"; is a top-level directive (parsed like import)
that adds -lname to the cc invocation. Non-default search paths
go through LIBRARY_PATH/CPATH, which cc already honors — no
separate slangc flag for that.extern fn boundary: numeric types, bool, str, bytes,
rawptr, and ptr[T] of those types. GC'd containers (opt,
result, map, structs, arrays) are rejected at compile time —
their internal layout isn't something arbitrary C code should ever
see.C++ is out of scope for the compiler itself. There's no
name-mangling/ABI support planned. Wrap the C++ library in your own
extern "C" shim (catching every exception at that boundary — an
uncaught C++ exception unwinding into C is undefined behavior) and
consume the shim exactly like any other C library above.
Safety notes:
abort() from malloc, not a clean crash. The compiler-provided
packages that wrap deep libraries grow the stack up front
(sl_rt_need_stack, used by net.tls_* for OpenSSL and by sql
for SQLite); an extern fn into a comparably deep library has no
such protection, so keep C-side recursion and stack buffers small.See tests/ffi/ for a complete example: a small hand-written C
fixture library (lib.c) built as a static archive, linked and called
from a slang program exercising extern fn, link, rawptr,
bytes_ptr, and nullptr.
A package is a directory: every .sl file inside it is compiled
together into one shared namespace, as if concatenated. Import paths
resolve to a directory next to the importer, then a native package,
then stdlib/<path>, then a pin in slang.project. Only the exact
path names a native package ("json" is the built-in; "lib/json" is
a directory), and a directory shadows a native package only if it
holds .sl files, so a json/ folder of fixtures does not.
import "geometry"; // binds the name "geometry" in this file's scope
import "a/b/util"; // nested paths bind as "util"
import "geometry" as geo; // optional alias; call as geo.area(...)
println(geometry.area(3.0, 4.0)); // qualified access
println(util.format(x));
Exports are explicit. Only declarations marked with pub are
visible to importers; everything else is private to its package:
// geometry/shapes.sl
pub fn area(w: float, h: float) -> float { ... } // exported
fn scale(v: float) -> float { ... } // private
// geometry/consts.sl
pub let pi = 3.14159; // exported package constant
let secret = 42; // private package global
Rules:
pub member from outside is a compile error.area(1, 2).let becomes a package global;
its initializer must be a constant literal.slangc; its directory is
the main package, and its top-level statements run in order in
main(). Other files of the main package share its namespace.sl_<pkg>_<name>), so different
packages can safely use the same names._ becomes _, and a
leading digit gets a p_ prefix (app.v2 → app_v2, 9lib →
p_9lib). The import binding is still the path's last element,
so a directory whose name is not an identifier needs an alias:
import "lib.v2" as lib;.import "a/util"; and import "b/util" as butil; are two
packages, and two libraries can each have their own util. When
names clash, diagnostics show the later one with a suffix
(util_2); native packages and the entry package keep theirs.See examples/pkgdemo/ for a complete multi-package project.
External packages are pinned in slang.project (walked up from the
entry file). Imports stay short. slang.lock holds content hashes
and is written by slangc get, never by hand. The file needs a name and
a version line as well as its pins; slangc new writes both.
name myserver
version 0.1.0
pkg foo git https://github.com/dolphlabs/foo tag v0.1.0
pkg bar git https://github.com/dolphlabs/bar tag v0.2.0 dir src
import "foo";
import "bar";
dir <subdir> points a pin at the package inside the repository,
for a library that also ships examples, docs and its own tests: only
that directory is compiled into your program, and nothing else in the
repository can affect your build. The subdirectory is a relative path
within the clone — .., absolute paths and empty segments are refused —
and the lock hashes the package's complete included tree, not just the
selected source directory.
A package author can keep non-runtime directories out of every consumer's
cache by listing them in that package repository's own slang.project:
name foo
version 1.2.0
exclude bench
exclude examples
exclude tests
Each exclude is one literal repository-relative directory; paths with
.., absolute paths, empty segments, or glob syntax are rejected. Do not
exclude the package directory selected by a consumer's dir; slangc get
refuses that pin. A directory inside it may be excluded (test fixtures, say)
as long as the package never imports it: an import that lands in an excluded
directory is a compile error naming the exclude line, because otherwise
import "json" from a package whose own json/ was excluded would quietly
compile the standard json instead. slangc get
uses a shallow partial clone and sparse checkout so a Git server that supports
partial clones need not send excluded file contents. If it does not, slang
still omits those directories and Git metadata from the local cache, but the
first transfer may include them. The lock hash covers every retained file, so
editing included source is detected before compilation. Projects without
exclude keep the existing full-tree behavior.
slangc get clones each pkg line into $SLANG_CACHE/pkg/<name>/<hash>
(~/.cache/slang if unset), links $SLANG_CACHE/pkg/<name>/<tag> to it
(a / in the tag becomes _), and prints one line per package with
that typeable path — zokor v0.1.0: /home/you/.cache/slang/pkg/zokor/v0.1.0/src
— for reading a package's source or docs. Builds never go through the
link; they use the lock's hash. If a fetched package has its own
slang.project, those pins are fetched too and recorded only in
slang.lock. Compile does not hit the network. A missing lock, missing
cache, or hash mismatch is an error. Same short name at two git/tag
pairs is an error.
main.sl ──loader──> packages ──lexer/parser──> ASTs ──codegen──> main.gen.c ──cc──> ./main
Loader (src/loader.c) — resolves imports (local directory,
native package, stdlib, then slang.project pins), scans package directories for .sl
files (in deterministic sorted order), merges them per package, and
detects cycles via canonical paths.
Lexer (src/lexer.c) — tokenizes source into identifiers,
keywords, literals, and operators.
Parser (src/parser.c) — recursive-descent parser producing an
AST (src/ast.h).
Code generator (src/codegen/) — walks the ASTs, performs type
inference and semantic checks (including pub enforcement), and
emits readable C. The runtime in runtime/ (GC, scheduler, pool,
containers, native packages) is real C, spliced into every
generated file so the binary stays self-contained. Split by
concern: core.c, infer.c, expr.c/stmt.c, program.c,
liveness.c (GC safepoint roots), and native.c (NatSig
dispatch). internal.h holds the shared CG struct.
Native-package signatures live under src/codegen/pkg_<name>/.
Their C runtimes are runtime/sl_<name>.c. json uses
dispatch.c because decode/encode are generic over the target
type. Adding a fixed-signature package is a pkg_<name>/ directory,
a runtime/sl_<name>.c file, and one line in loader.c.
Driver (src/main.c) — glues it together and shells out to
cc. Because GCC/Clang compile the generated C, you get their full
optimizer for free.
Large stack frames. A task starts on an 8KB stack that grows only at a
safepoint, which runs after the current function's frame already exists.
How big a frame is depends on the C compiler: clang gives every call site
its own spill slot and inlines callees into their caller, so a function
with a few hundred call sites can need more than the whole stack and used
to die with SIGBUS before printing anything (gcc gave the same C a frame
of a few hundred bytes). The driver therefore asks the compiler for the
real frame of every function (-Wframe-larger-than, read from clang's and
gcc's own reports). A slang function reported over the limit is
regenerated behind a thin wrapper that grows the stack the
function is entered, and the program is compiled a second time. So is
the C slangc generates for /, and
, whose frames grow with the type: a decoder for a 200-field
struct needs more stack than a safepoint leaves free, and without a
guard the task ran into its guard page. The
guards a build ends with are kept in the cache
(, or , one small file per
program) and applied up front the next time the same program is built,
so a rebuild compiles once; every compile still checks the frames, and
one that has outgrown its guard is regenerated as before. A guard is
sized in whole 4KB pages, as the stack grows, so a frame that changes by
a few bytes costs no compile. Programs with no large frame pay nothing,
and output has no guards. The limit is 1536 bytes;
overrides it (the tests set a tiny one to put a
guard on nearly every function).
Development builds. --dev (or SLANG_DEV=1 in the environment)
compiles the generated C at -O0 without LTO, which is most of a build's
time: a 21k-line service rebuilds in about 16 s instead of 73 s. The program
runs slower (about 1.6x the CPU per request on that service), so it is for
the edit-and-rerun loop -- zokor watch builds this way -- and never for
what you deploy. Unoptimized frames are several times bigger, so a dev build
also starts tasks on a 32KB stack with a 16KB safepoint margin, guards
frames over 8KB, and is cached apart from the release build of the same
program.
Inspect what slang generates:
./slangc examples/hello/main.sl --emit-c && cat main.gen.c
src/
common.h allocation helpers, growable string buffer, file I/O
loader.h/.c package discovery, merging, cycle detection
lexer.h/.c tokenizer
ast.h AST node definitions
parser.h/.c recursive-descent parser
rtpath.h/.c locate runtime/ next to slangc
codegen.h public codegen API (codegen_program, and the frame guards)
codegen/ type checking + C emission
main.c driver: flags, invokes cc
runtime/ real C runtime spliced into generated programs
sl_core.c sl_gc.c sl_containers.c sl_sched.c sl_pool.c
sl_time.c sl_net.c sl_tls.c sl_json.c sl_proc.c sl_fs.c
stdlib/ slang-source packages (`import "http"`, `import "byteutil"`;
`import "log"` is a native package — no source files)
examples/ one directory per example program
tests/ language tests plus tests/runtime/ (no slangc)
Makefile build/test/clean
A plain struct cannot hold itself through an opt (or a result):
both are held by value, so the type would be infinitely large, and the
compiler says so. Make it a gc struct, which is a pointer -- that is
how a linked list is written (gc struct Node { v: int, next: opt[Node] }).
Block scoping: a let inside if/else/while/for is not
visible afterwards. Loop bindings (for i in ...) are scoped to
the loop. Redeclaring a name in the same scope is an error;
inner blocks may shadow. guard let still binds for the rest of
its enclosing block.
Strings are immutable; concatenation allocates. The collector reclaims unreachable strings automatically.
Package globals require constant-literal initializers.
Implicit returns only apply to the last statement of a function
body; if and {} blocks are statements, not expressions yet.
No closures. Functions are values (fn(int) -> int), but they
capture nothing — a function value always names a top-level
function, never an environment. break/continue work inside
loops.
Package-level lists are not supported yet (scalars and bytes are).
Map keys are limited to integers, str, and bool.
No data-race protection: spawn gives you real concurrency and
per-task failure isolation, not an ownership/borrow checker.
Mutating a shared struct/list/map from more than one task is on
you, same as Go or Java — mutex is available for it, but nothing
makes you reach for one.
select has no timeout arm and no way to disable an arm. A closed
channel's recv arm is ready forever (see Concurrency above), and
there is no nil channel to switch it off with; for a deadline, feed
a channel from a spawned timer task.
mutex has no scope guard: without closures or defer, an early
return between mutex_lock and mutex_unlock leaks the lock.
Mutexes are also not recursive (locking one twice from the same
task is a checked error, not a hang).
TLS: no session resumption tuning. Handshake and send/recv park;
getaddrinfo in tls_dial parks the task while a dedicated
thread resolves. mTLS ( /
) and SNI extra certs () are
supported.
Compiled programs embed a precise mark-sweep collector
(runtime/sl_gc.c). Allocations go through sl_gc_alloc. main()
registers the thread, starts the worker pool, and switches into the
main task. There is no libgc dependency.
What this means in practice:
GOGC=100): the next
collection comes after allocating as much as survived the last one,
and never before 8MB. The heap peaks near twice what is live, however
much garbage a program makes — streaming three million database rows
runs in under 20MB.SLANG_GC_STAT counts the joins (sweep_helps).bool, enums,
and value structs made only of those -- is never traced at all: a
table of a million ints costs a collection nothing to keep.SLANG_GC_STAT=1 prints collection counts and pause times at exit
(minor_pause_ns_total for the minors).
SLANG_GC_THRESHOLD_KB=n collects every n KB instead, with no pacing:
for tests, since a rooting bug only shows when a collection lands at
the one safepoint where an object is unrooted. SLANG_GC_NURSERY_KB=n
does the same for minors.let max = if a > b { a } else { b }).step(n)ccextern struct layouts, for passing C structs by value instead of
only through opaque rawptr handlesselect over multiple channelsslang is released under the MIT licence. The compiler splices its runtime into every program it produces; that runtime is covered by the same licence, and keeping the copyright notice with it is the only condition.
A program you build links OpenSSL, SQLite or zlib from your system only if it uses the package that needs it, and those libraries keep their own terms: see THIRD_PARTY.md.
Contributions are welcome: see CONTRIBUTING.md and the Code of Conduct. To report a vulnerability, read SECURITY.md; please do not open a public issue for one.
ptr[T]T * |
| typed FFI pointer |
&T | const T * | shared borrow |
&mut T | T * | unique borrow |
own T | T * | unique heap box (no drop yet) |
gc T | T * | traced heap box of a value type |
*T / *mut T | T * | raw pointer |
chan[T] | sl_chan * | bounded thread-safe queue (see Concurrency) |
join[T] | sl_join * | handle for a spawned task's result |
mutex | sl_mutex * | task-parking lock (see Concurrency) |
fn(A)->R | R (*)(A) | function value (see Function values) |
sismemberspop| sorted sets | zadd, zrange, zrange_scores, zrank, zscore, zrem, zcard, zincr_by |
| keys | key_type, rename, rename_nx, scan (KEYS omitted on purpose) |
| pool | new_pool, new_pool_config, acquire, release, pool_do, pool_close |
| cluster | new_cluster, cluster_do, cluster_refresh, cluster_close, c* typed wrappers, same-slot multi-key checks |
| transactions | multi, queue, exec, discard, watch, unwatch (direct Conns; pool and cluster routing stay out) |
| scripting | eval, evalsha with NOSCRIPT fallback |
| pub/sub | subscribe, sub_add, sub_next, sub_remove, sub_close (pull model; pump pattern in docs) |
| streams | xadd, xadd_maxlen, xrange, xrevrange, xlen, xtrim, xdel, xread, cluster cxadd/cxlen/cxread (groups deferred) |
sinatanhatan2to_radiansto_degreeswrap_anglesin_picos_pigamma, lgamma, erf, erfc, erf_inv, beta,
factorial, binomial.lerp, inverse_lerp, remap, smoothstep,
smootherstep.checked_add/sub/mul/div/rem/neg/abs/pow/shl
(an opt), saturating_add/sub/mul, wrapping_add/sub/mul/neg/pow,
div_floor, div_ceil, div_euclid, rem_euclid, abs_diff,
midpoint, isqrt, ilog2, ilog10, is_pow2, next_pow2,
align_up, align_down, gcd, lcm, mul_div, mul_hi.popcount, leading_zeros, trailing_zeros, bit_len,
rotate_left, rotate_right, swap_bytes, reverse_bits.format(x, digits) (significant digits),
format_fixed(x, decimals), format_sci(x, digits). These are the
only functions here that allocate; format_fixed rounds an exact tie
to even, as C's printf does (format_fixed(2.5, 0) is "2").Tjoin_wait(h) -> result[T, str]ferrchan[T], built with make_chan(capacity) (element type
inferred from an annotated binding, same as none): chan_send(ch, v) blocks while full, chan_recv(ch) -> opt[T] blocks while empty
and returns none once the channel is closed and drained (instead
of inventing a second return-value convention, it reuses opt[T]),
chan_close(ch) wakes every blocked sender/receiver. Sending on a
closed channel is a checked runtime error, not undefined behavior.
select waits on several channels at once and runs the arm that
becomes ready first:
while running {
select {
case let job = chan_recv(work) {
handle(job ?? 0);
}
case let q = chan_recv(quit) {
running = false;
}
default {
// optional: runs when no arm is ready, instead of blocking
}
}
}
A case let v = chan_recv(ch) arm binds v to opt[T] for that
arm's body, exactly as a plain chan_recv would — none means the
channel is closed and drained. A case chan_send(ch, v) arm is ready
when the channel has buffer space and binds nothing. Sending on a
closed channel from a send arm is the same checked runtime error as
chan_send itself.
Every arm's channel expression (and a send arm's value) is evaluated
once, before the select blocks. With no default and nothing ever
ready, select parks forever — the same as chan_recv on a channel
nobody sends to. Which arm wins when several are ready is not
specified: polling starts at a rotating offset, so a busy first
channel cannot starve the later arms.
A closed channel is permanently ready. Its recv arm fires
immediately and forever, with none. This is the same as Go, but Go
lets you disable an arm by setting its channel to nil and slang has
no nil channel — so a loop that keeps selecting on a closed channel
will spin. Structure the loop to stop instead (count the items you
expect, or take the close as the exit condition), as
tests/select/main.sl does.
mutex, built with make_mutex(): mutex_lock(m) /
mutex_unlock(m) around whatever the lock protects, and
mutex_trylock(m) -> bool when you would rather do something else
than wait. A contended lock parks the task, not the worker thread,
so a handler waiting its turn costs a queue slot rather than one of
the pool's OS threads — the same reason chan parks. A mutex is a
handle: copying the binding aliases the same lock.
Two things are checked rather than left to chance, because both otherwise present as something other than what they are:
There is no scope guard (no defer, no closures), so an early
return between lock and unlock leaks the lock. Keep the critical
section small enough to see both ends of it at once:
gc struct State { tasks: [Task], next_id: int, lock: mutex }
fn create(st: State, title: str) -> Task {
mutex_lock(st.lock);
let t = Task { id: st.next_id, title: title, done: false };
st.next_id = st.next_id + 1;
push(st.tasks, t);
mutex_unlock(st.lock);
return t; // unlock BEFORE the return, every path
}
A mutex is not always the right tool. demo/samplex/server.sl uses
one because many handlers touch one list. stdlib/http2/conn.sl
deliberately does not: its single writer task also guarantees that a
HEADERS block and its CONTINUATION frames are never split by another
frame, which a lock would not give.
Failure isolation: a runtime error (an out-of-bounds index, a
missing map key, integer division by zero, ...) inside a spawned
task ends that task — printed to stderr as task panicked: ... —
not the whole process. The same error in the main task still ends
the process, same as today; there is no isolation boundary around
top-level code. exit(code) always ends the whole process
regardless of which task calls it — it means what it always means.
json.decodejson.encodepg.rows_asinspect~/.cache/slang/frames/$SLANG_CACHE/frames/--emit-cSLANG_FRAME_LIMITtls_ctx_require_clienttls_ctx_use_certtls_ctx_add_sniJSON: no dynamic/unknown-shape decoding (every decode target is a
concrete slang type known at compile time — see the json section
above), and JSON object keys map to struct field names verbatim
(no camelCase/snake_case conversion). bytes fields are base64
strings (RFC 4648).
proc: only SIGTERM/SIGINT are handled (there's no general
signal-registration API), and only in a program that calls
proc.shutdown_requested(); a second signal always ends it. A
signal that arrives in the narrow window before main() takes them
over gets the OS's default disposition (immediate termination).
proc.wait_idle() parks until proc.active_tasks() is zero.
SLANG_GC_VERIFY_MINOR=1 checks every minor against a full mark and
reports any live young object it missed, with the kind of old
container holding it -- a store with no write barrier. It prints
missed= at exit; the suite requires 0.