Sans is a compiled programming language optimized for AI code generation. It compiles to native code via LLVM with performance comparable to C, Rust, and Go.
sans build hello.sans && ./hello
Sans compiles to native binaries via LLVM. Write a .sans file, build it, and run.
| Type | Short | Description |
|---|---|---|
Int | I | 64-bit signed integer |
Float | F | 64-bit floating point |
Bool | B | Boolean (true / false) |
String | S | UTF-8 string |
Array<T> | — | Dynamic growable array |
Map<K,V> | M | Generic hash map (default M<S,I>) |
Option<T> | O<T> | Optional value: Some or None |
Result<T> | R<T> | Success or error value |
JsonValue | J | Opaque JSON value |
HttpResponse | — | HTTP client response (opaque handle) |
HttpServer | — | HTTP server socket (opaque handle) |
HttpRequest | — | HTTP server request |
Sender<T> | — | Channel sender (opaque handle) |
Receiver<T> | — | Channel receiver (opaque handle) |
Mutex<T> | — | Mutual exclusion lock (opaque handle) |
JoinHandle | — | Thread handle (opaque handle) |
User-defined types: struct, enum, trait. Trait objects: dyn TraitName.
x = 42 // immutable (type inferred)
x := 0 // mutable
let x Int = 42 // explicit type (verbose, optional)
let mut x = 0 // verbose mutable (optional)
g counter = 0 // global mutable variable
inc() I {
counter = counter + 1
counter
}
Globals are mutable and accessible from any function. Declared at the top level with g.
// Full form
fn add(a Int, b Int) Int { a + b }
// Compact form (preferred)
add(a:I b:I) I = a + b
// Implicit return type (defaults to Int)
main() { 0 }
// Default parameters (trailing params only)
greet(name:S greeting:S="Hello") S = "{greeting} {name}!"
greet("Alice") // "Hello Alice!"
greet("Bob" "Hi") // "Hi Bob!"
// Ternary
result = x > 0 ? x * 2 : x * -1
// If-else block
if condition {
body
} else {
body
}
// While loop
while condition { body }
// For-in loop
for item in array { body }
// Loop control
while cond {
if done { break } // exit loop immediately
if skip { continue } // skip to next iteration
}
for x in arr {
if x == 0 { continue } // works in for-in too
if x < 0 { break }
}
// Match expression
match value {
EnumName::Variant1 => expr1,
EnumName::Variant2(x) => x + 1,
}
// Match with guards
match x {
n if n > 0 => "positive",
n if n < 0 => "negative",
_ => "zero",
}
// Struct destructuring in match
match pt {
Point { x, y } => x + y,
}
// Tuple destructuring in match
match pair {
(a, b) => a + b,
}
// For-loop destructuring
for (k v) in m.entries() {
p("{k} = {str(v)}")
}
Array methods return arrays, so they chain without .collect():
a.map(|x:I| I { x * 2 }).filter(|x:I| B { x > 3 })
// New methods
a.any(|x:I| B { x > 3 }) // B — true if any match
a.find(|x:I| B { x > 3 }) // first match or 0
a.enumerate() // [(I I)] index-value tuples
a.zip(b) // [(I I)] paired tuples
a.sort() // in-place sort (integers)
a.reverse() // in-place reverse
a.join(",") // "1,2,3" — join to string
a.slice(1, 3) // sub-array [start..end)
a.sum() a.min() a.max() // aggregate operations
a.flat() // flatten nested arrays
a.reduce(0, |a:I b:I| I { a + b }) // fold
a.each(|x:I| I { p(x); 0 }) // side-effect iteration
a.flat_map(|x:I| I { range(x) }) // map + flatten
// Lazy iterators (Iter<T>) — no intermediate allocations
a.iter() // It<T> — lazy iterator over array
iter(10) // It<I> — range 0..10 (no alloc)
iter(2, 5) // It<I> — range 2..5 (no alloc)
// Lazy combinators (return Iter)
a.iter().map(|x:I| I { x * 2 }) // transform
.filter(|x:I| B { x > 3 }) // keep matches
.take(5).skip(2) // first 5, skip 2
.collect() // materialize to array
// Consumers (terminal)
a.iter().find(|x:I| B { x > 3 }) // O<T> — first match
a.iter().any(|x:I| B { x > 3 }) // B — true if any match
a.iter().all(|x:I| B { x > 0 }) // B — true if all match
iter(5).reduce(|a:I b:I| I { a + b }, 0) // fold
iter(10).count() // 10
a.iter().for_each(|x:I| I { p(x); 0 }) // side effects
// For-loop with lazy iterators
for x in iter(10) { p(x) }
// String methods
s.upper() s.lower() // case conversion
s.index_of("sub") // position or -1
s.char_at(0) s.get(0) // single character as string
s.repeat(3) // "abcabcabc"
s.pad_left(5, "0") // "00042"
s.pad_right(6, ".") // "hi...."
s.bytes() // [65, 66, 67] — byte values
s.to_int() // "42".to_int() = 42
// Unicode / UTF-8
char_count(s) ccount(s) // UTF-8 codepoint count
chars(s) // split into UTF-8 char array
is_ascii(s) // 1 if all bytes < 128
utf8_valid(s) // 1 if valid UTF-8
string_reverse(s) srev(s) // UTF-8 aware reverse
42.to_str() // 42.to_str() = "42"
// Math & range
abs(-5) min(3, 7) max(3, 7)
range(5) // [0 1 2 3 4]
range(2, 5) // [2 3 4]
// Float math
floor(3.7) ceil(3.2) round(3.5) // 3.0, 4.0, 4.0
sqrt(4.0) pow(2.0, 3.0) // 2.0, 8.0
sin(0.0) cos(0.0) tan(0.0) // trig (radians)
log(1.0) exp(1.0) fabs(-5.5) // 0.0, 2.718..., 5.5
PI() E_CONST() // constants
sleep(1000) time() random(100) // system
stof("3.14") // string to float
// Date/time (all operate on unix timestamps)
t = tnow()
tfmt(t "%Y-%m-%d %H:%M:%S") // format with strftime
tyear(t) tmon(t) tday(t) // year, month 1-12, day 1-31
thour(t) tmin(t) tsec(t) // hour 0-23, min 0-59, sec 0-59
twday(t) // weekday 0=Sun..6=Sat
tadd(t 3600) // add seconds
tdiff(a b) // a - b in seconds
// Match expressions
match x { 1 => "one", 2 => "two", _ => "other" }
match cmd { "build" => 1, "run" => 2, _ => 0 }
// Tuple destructuring
let (a, b) = (10 20)
// Map entries
m.entries() // [(key, val), ...]
m.delete("key") // remove key
| Category | Operators |
|---|---|
| Arithmetic | +, -, *, /, % |
| Comparison | ==, !=, <, >, <=, >= |
| Boolean | &&, ||, ! |
| Assignment | =, :=, +=, -=, *=, /=, %= |
| Special | ?: (ternary), ? (try/propagate), ! (postfix unwrap), [] (index) |
Generic hash map. The type parameter specifies key and value types. Bare M() defaults to M<S,I> (string keys, integer values). Supported key types: S (String), I (Int). Float keys are not allowed.
m = M() // M<S I> — default
m = M<S S>() // string→string
m = M<I I>() // int→int
m.set("x" 10)
m.get("x")! // 10 (unwrap Option)
m.get("z").unwrap_or(0) // 0 (missing key)
m.has("z") // false
m.len() // 1
m.keys() // ["x"]
Breaking change (v0.7.1): m.get(key) now returns Option<V> instead of a raw value. Use !, .unwrap(), or .unwrap_or(default) to extract the value.
| Method | Signature | Description |
|---|---|---|
set(key, val) | (K, V) -> I | Set key-value pair |
get(key) | (K) -> Option<V> | Get value — Some(v) or None |
has(key) | (K) -> B | Check if key exists |
len() | () -> I | Number of entries |
keys() | () -> [K] | Array of all keys |
vals() | () -> [V] | Array of all values |
delete(key) | (K) -> I | Remove key |
entries | () -> [(K V)] | Key-value tuples |
name = "Sans"
msg = "Hello {name}!" // "Hello Sans!"
Full expressions are supported inside {}:
x = 10
"result is {x + 1}" // "result is 11"
"len is {a.len()}" // method calls
"sum is {x * 2 + 3}" // arithmetic
Slice strings with [start:end] syntax (desugars to .substring()):
s = "hello world"
s[0:5] // "hello"
s[6:] // "world" (to end)
s[:5] // "hello" (from start)
Tuples are fixed-size, ordered collections of values that can have different types. No commas — space-separated like arrays.
// Tuple literal (no commas)
t = (1 "hello" true)
// Access by index
t.0 // 1
t.1 // "hello"
t.2 // true
// Tuple type annotation
pair(a:I b:I) (I I) = (a b)
// Multi-return via tuples
p = pair(10 20)
p.0 + p.1 // 30
Single expressions in parens are grouping, not tuples: (1 + 2) evaluates to 3, not a 1-tuple.
struct Point { x I, y I }
make_point(x:I y:I) Point = Point { x: x, y: y }
main() {
pt = Point { x: 10, y: 20 }
p(str(pt.x + pt.y))
0
}
Structs can have type parameters, enabling reusable data structures:
struct Pair<A B> { first A, second B }
main() {
p = Pair<I S>{ first: 1, second: "hello" }
p(str(p.first)) // 1
p(p.second) // "hello"
0
}
// Multiple type params
struct Triple<A B C> { a A, b B, c C }
t = Triple<I S B>{ a: 42, b: "hi", c: true }
enum Shape {
Circle(I),
Rect(I, I),
}
area(s Shape) I = match s {
Shape::Circle(r) => r * r * 3,
Shape::Rect(w h) => w * h,
}
trait Describable {
fn describe(self) I
}
impl Describable for Point {
fn describe(self) I { self.x + self.y }
}
identity<T>(x T) T = x
dyn Trait)Trait objects enable dynamic dispatch through a vtable. Use dyn TraitName as a type and expr as dyn TraitName to coerce a concrete struct. Runtime layout: 16-byte heap-allocated fat pointer (data ptr + vtable ptr).
trait Valued {
fn value(self) I
}
struct Num { n I }
impl Valued for Num {
fn value(self) I { self.n }
}
x = Num{ n: 42 }
v = x as dyn Valued // coerce to trait object
v.value() // 42 — vtable dispatch
// dyn Trait as parameter type
show(v dyn Valued) I { v.value() }
show(x as dyn Valued) // 42
// Polymorphic collections
items = [x1 as dyn Valued x2 as dyn Valued]
for item in items { p(str(item.value())) }
Limitations: no trait inheritance, no default implementations, no associated types, no generic bounds.
Lambda expressions are anonymous functions. They implicitly capture variables from their enclosing scope (up to 8 captured variables per closure).
// Non-capturing lambda
f = |x:I| I { x + 10 }
f(5) // 15
// Multiple parameters
add = |a:I b:I| I { a + b }
// Used with map
nums = [1 2 3 4 5]
doubled = nums.map(|x:I| I { x * 2 })
// Implicit capture from enclosing scope
multiplier = 3
scaled = nums.map(|x:I| I { x * multiplier })
divide(a:I b:I) R<I> = b == 0 ? err("div/0") : ok(a / b)
main() {
r = divide(10 3)
r.is_ok ? r! : 0
}
err() accepts an optional integer error code as the first argument:
err("not found") // error, code defaults to 0
err(404 "not found") // error with code 404
r.code() // get error code (0 if not set)
? operator)The ? operator unwraps a Result<T> or early-returns the error. On Option<T>, it unwraps Some or early-returns none().
safe_div(a:I b:I) R<I> {
b == 0 ? err("div by zero") : ok(a / b)
}
compute(x:I) R<I> {
r = safe_div(x 2)? // unwraps ok(5), or returns err early
ok(r + 1)
}
x? desugars to: if x.is_err() { return x } followed by x! (unwrap) for Result; and to early-return none() for Option.
Chain Result operations without manual error checks:
parse(s:S) R<I> = s == "" ? err("empty") : ok(stoi(s))
parse("42").map(|n:I| I { n * 2 }) // ok(84)
parse("10").and_then(|n:I| R<I> { n > 0 ? ok(n) : err("neg") })
parse("").map_err(|e:S| S { "parse failed: {e}" })
parse("").or_else(|e:S| R<I> { ok(0) }) // ok(0) fallback
Option<T> (short: O<T>) represents an optional value. Used by Map.get and Array.find. Runtime layout: 16 bytes (tag@0, value@8).
x = some(42) // Some(42)
y = none() // None
x.is_some // true
x! // 42 (unwrap, exits on None)
x.unwrap_or(0) // 42
none().unwrap_or(99) // 99
// Map.get returns Option
m = M()
m.set("x" 10)
m.get("x")! // 10
m.get("z").unwrap_or(0) // 0 (missing)
// ? propagation
lookup(m:M<S I> k:S) O<I> {
v = m.get(k)? // returns none() early if missing
some(v * 2)
}
// math.sans
add(a:I b:I) = a + b
// main.sans
import "math"
main() {
result = math.add(3 4)
result
}
Use pub import to re-export all public symbols from another module. Downstream consumers see a single clean API.
// impl.sans
pub add(a:I b:I) I = a + b
pub sub(a:I b:I) I = a - b
helper() I = 42 // not pub — not re-exported
// facade.sans
pub import "impl" // re-exports add and sub
// main.sans
import "facade"
main() I {
p(facade.add(1 2)) // 3
p(facade.sub(5 3)) // 2
0
}
Only pub symbols from the source module are re-exported. Non-pub symbols remain private.
Sans includes a built-in package manager. Packages are git repositories fetched by version tag.
{
"name": "my-project",
"version": "0.1.0",
"deps": {
"github.com/user/repo": "v1.0.0"
}
}
| Command | Description |
|---|---|
sans pkg init | Create sans.json |
sans pkg add <url> [tag] | Add dependency |
sans pkg install | Install all dependencies |
sans pkg remove <url> | Remove dependency |
sans pkg list | List dependencies |
sans pkg update <url> [tag] | Update dependency version |
sans pkg search <query> | Search package index |
Short aliases: sans pkg i (install), sans pkg ls (list), sans pkg rm (remove).
Packages are cached at ~/.sans/packages/. Dependencies are resolved transitively via BFS.
sans lint runs static analysis (parse + type check) without building.
sans lint foo.sans // lint single file
sans lint compiler/ // lint all .sans files recursively
sans lint . // lint current directory
sans lint --error=unused-imports foo.sans // promote rule to error
sans lint --quiet foo.sans // suppress warnings
| Rule | Default | Description |
|---|---|---|
unused-imports | warn | Imported module never referenced |
unreachable-code | warn | Code after return statement |
empty-catch | warn | Result value silently discarded |
shadowed-vars | warn | Inner scope redeclares outer variable |
unnecessary-mut | warn | Variable declared := but never reassigned |
Rules can be configured in sans.json:
{
"lint": {
"unused-imports": "error",
"shadowed-vars": "off"
}
}
Valid severities: "error", "warn", "off". CLI --error=<rule> overrides config. Exit code 0 if no errors, 1 if any error-severity diagnostics.
worker(tx Sender<Int>) I {
tx.send(42)
0
}
main() {
let (tx rx) = channel<I>()
spawn worker(tx)
val = rx.recv
val
}
| Function | Alias | Signature |
|---|---|---|
print(value) | p | (String|Int|Float|Bool) -> Int |
file_read(path) | fr | (String) -> String |
file_write(path, content) | fw | (String, String) -> Int |
file_append(path, content) | fa | (String, String) -> Int |
file_exists(path) | fe | (String) -> Bool |
read_lines(path) | rl | (String) -> Array<String> |
write_lines(path, lines) | wl | (String, Array<String>) -> Int |
append_line(path, line) | al | (String, String) -> Int |
read_line(prompt) | -- | (String) -> String |
Line-oriented file I/O for common read/write patterns:
// Write lines to a file (each line terminated by \n)
wl("/tmp/data.txt", ["hello" "world" "sans"])
// Read all lines from a file
lines = rl("/tmp/data.txt")
p(lines[0]) // "hello"
// Append a single line (with trailing \n)
al("/tmp/data.txt", "extra")
// Interactive prompt (prints prompt, reads line, trims)
name = read_line("Enter name: ")
read_lines strips the trailing newline before splitting, so a file with content "a\nb\n" returns ["a", "b"]. write_lines adds a trailing newline after each line. append_line appends the line followed by a newline.
| Function | Alias | Signature |
|---|---|---|
int_to_string(n) | str | (Int) -> String |
string_to_int(s) | stoi | (String) -> Int |
int_to_float(n) | itof | (Int) -> Float |
float_to_int(f) | ftoi | (Float) -> Int |
float_to_string(f) | ftos | (Float) -> String |
| Function | Alias | Signature |
|---|---|---|
json_object() | jo | () -> JsonValue |
json_array() | ja | () -> JsonValue |
json_string(s) | js | (String) -> JsonValue |
json_int(n) | ji | (Int) -> JsonValue |
json_bool(b) | jb | (Bool) -> JsonValue |
json_null() | jn | () -> JsonValue |
json_parse(s) | jp | (String) -> Result<JsonValue> — parses int, float, string, bool, null, object, array. Returns error on invalid JSON or depth > 512. Breaking (v0.8.1): was JsonValue; add ! to unwrap. |
json_stringify(v) | jfy | (JsonValue) -> String |
| Function | Alias | Signature |
|---|---|---|
http_get(url) | hg | (String) -> HttpResponse |
http_post(url, body, ct) | hp | (String, String, String) -> HttpResponse |
| Function | Alias | Signature |
|---|---|---|
http_listen(port) | listen | (Int) -> HttpServer |
https_listen(port, cert, key) | hl_s | (Int, String, String) -> HttpServer |
serve(port, handler) | — | (Int, Fn) -> Int |
serve_tls(port, cert, key, handler) | — | (Int, String, String, Fn) -> Int |
stream_write(writer, data) | — | (Int, String) -> Int |
stream_end(writer) | — | (Int) -> Int |
signal_handler(signum) | — | (Int) -> Int |
signal_check() | — | () -> Int |
spoll(fd, timeout_ms) | — | (Int, Int) -> Int |
ws_send(ws, msg) | — | (Int, String) -> Int |
ws_recv(ws) | — | (Int) -> String |
ws_close(ws) | — | (Int) -> Int |
serve_file(req, dir) | — | (HttpRequest, String) -> Int |
url_decode(s) | — | (String) -> String |
path_segment(path, idx) | — | (String, Int) -> String |
serve_file(req, dir) serves a static file from dir matching the request path. Handles content-type detection, 404 for missing files, and directory traversal protection.
url_decode(s) decodes a URL-encoded string (e.g. %20 to space, + to space).
path_segment(path, idx) extracts the segment at index idx from a URL path. path_segment("/api/users/42" 2) returns "42".
serve(port, handler) starts a production server with auto-threading, HTTP/1.1 keep-alive, automatic gzip compression, and graceful shutdown. Uses a bounded thread pool (default 256 workers). Requests are read incrementally until headers are complete, then body is read based on Content-Length. On SIGINT/SIGTERM, the server stops accepting new connections, drains in-flight workers, and exits cleanly.
Configure the HTTP server before calling serve() or serve_tls(). All settings have sensible defaults.
| Function | Default | Description |
|---|---|---|
set_max_workers(n) | 256 | Max concurrent worker threads. 503 at capacity. |
set_read_timeout(s) | 30 | Read timeout per recv call (seconds). |
set_keepalive_timeout(s) | 60 | Timeout for next request on keep-alive (seconds). |
set_drain_timeout(s) | 5 | Shutdown drain timeout (seconds). |
set_max_body(n) | 1048576 (1MB) | Max request body bytes. 413 if exceeded. |
set_max_headers(n) | 8192 (8KB) | Max total header size bytes. 431 if exceeded. |
set_max_header_count(n) | 100 | Max number of headers. 431 if exceeded. |
set_max_url(n) | 8192 (8KB) | Max URL length bytes. 414 if exceeded. |
set_compress_min_size(bytes) | 1024 | Min response body size in bytes to trigger gzip compression. |
main() I {
set_max_workers(128)
set_read_timeout(10)
set_max_body(4096)
serve(8080 fptr("handle"))
}
req.respond_stream(status) sends chunked HTTP headers and returns a writer. Use stream_write(w, data) to send chunks and stream_end(w) to finalize.
signal_handler(signum) registers a signal handler that sets a global flag. signal_check() returns 1 if the signal was received. spoll(fd, timeout_ms) polls a file descriptor for readability with timeout, returning 1 if ready, 0 otherwise.
respond() automatically gzip-compresses response bodies when all conditions are met:
Accept-Encoding containing gzipX-No-Compress: 1 response header set by usertext/*, application/json, application/javascript, application/xml, image/svg+xml)No code changes needed — compression is transparent. Opt out with:
req.set_header("X-No-Compress" "1")
req.respond(200 large_body)
req.is_ws_upgrade() returns 1 if the request is a WebSocket upgrade request. req.upgrade_ws() performs the handshake and returns a WebSocket handle.
ws_send(ws, msg) sends a text frame. ws_recv(ws) receives the next text frame (handles ping/pong automatically, returns "" on close). ws_close(ws) sends a close frame and closes the socket.
handle(req:I) I {
req.is_ws_upgrade() ? {
ws = req.upgrade_ws()
msg := ws_recv(ws)
while slen(msg) > 0 {
ws_send(ws "echo: " + msg)
msg = ws_recv(ws)
}
ws_close(ws)
} : {
req.respond(200 "WebSocket server")
}
}
main() I {
serve(8080 fptr("handle"))
}
ws_send_binary(ws, data, len) sends a binary WebSocket frame. ws_ping(ws) sends a ping frame. stream_write_json(w, data) writes a JSON-formatted SSE chunk to a streaming response.
req.file(name) returns an uploaded file's content (multipart/form-data). req.files(name) returns all files with the given field name.
handle(req:HR) I {
data = req.file("upload")
if slen(data) == 0 { return req.respond(400 "no file") }
fw("/tmp/upload.bin" data)
req.respond(200 "saved")
}
serve_file(req, dir) serves a static file from dir matching the request path. Handles content-type detection for 24+ MIME types, 404 for missing files, and directory traversal protection.
set_index_file(name) sets the default file for directory requests (default: index.html).
| Function | Signature | Description |
|---|---|---|
serve_file(req, dir) | (HttpRequest, String) -> Int | Serve static file from directory |
set_index_file(name) | (String) -> Int | Set default index filename |
Pattern-based request routing with path parameters (:name) and wildcards (*).
| Function | Signature | Description |
|---|---|---|
router() | () -> Int | Create router |
rget(r, pattern, handler) | (Int, String, Fn) -> Int | Register GET route |
rpost(r, pattern, handler) | (Int, String, Fn) -> Int | Register POST route |
rput(r, pattern, handler) | (Int, String, Fn) -> Int | Register PUT route |
rdelete(r, pattern, handler) | (Int, String, Fn) -> Int | Register DELETE route |
route(r, method, pattern, handler) | (Int, String, String, Fn) -> Int | Register any-method route |
handle(r, req) | (Int, HttpRequest) -> Int | Dispatch request through router |
set_not_found(r, handler) | (Int, Fn) -> Int | Custom 404 handler |
serve_static(r, prefix, dir) | (Int, String, String) -> Int | Serve static files under prefix |
param(req, name) | (HttpRequest, String) -> String | Get path parameter |
g r = 0
users_get(req:HR) I {
id = param(req "id")
req.respond(200 "{\"id\":{id}}")
}
dispatch(req:HR) I { handle(r req) }
main() I {
r = router()
rget(r "/users/:id" fptr("users_get"))
serve_static(r "/" "./public")
serve(8080 fptr("dispatch"))
}
| Function | Alias | Signature | Description |
|---|---|---|---|
tcp_connect(host, port) | — | (String, Int) -> Int | Connect to host:port, returns fd or -1 |
tcp_listen(port) | tl | (Int) -> Int | Listen on port, returns server fd |
tcp_accept(fd) | ta | (Int) -> Int | Accept connection, returns client fd |
tcp_read(fd, size) | tr | (Int, Int) -> String | Read up to size bytes |
tcp_write(fd, data) | tw | (Int, String) -> Int | Write data |
tcp_close(fd) | tc | (Int) -> Int | Close fd |
tcp_set_timeout(fd, ms) | — | (Int, Int) -> Int | Set recv timeout (ms) |
| Function | Alias | Signature | Description |
|---|---|---|---|
udp_bind(port) | ub | (Int) -> Int | Bind UDP socket to port |
udp_sendto(sock, host, port, data) | — | (Int, String, Int, String) -> Int | Send datagram |
udp_recvfrom(sock, size) | — | (Int, Int) -> Int | Receive datagram |
udp_close(sock) | — | (Int) -> Int | Close socket |
| Function | Signature | Description |
|---|---|---|
cors(req, origin) | (HttpRequest, String) -> Int | Set CORS headers with given origin. Call before respond. |
cors_all(req) | (HttpRequest) -> Int | Set CORS headers with wildcard origin (*). |
srv = listen(8080)
while true {
req = srv.accept
cors_all(req)
req.respond(200 "ok")
}
| Function | Alias | Signature |
|---|---|---|
log_debug(msg) | ld | (String) -> Int |
log_info(msg) | li | (String) -> Int |
log_warn(msg) | lw | (String) -> Int |
log_error(msg) | le | (String) -> Int |
log_set_level(n) | ll | (Int) -> Int |
Log levels: 0=DEBUG, 1=INFO, 2=WARN, 3=ERROR
Built-in assertion functions for testing. Each prints a diagnostic with the source line number on failure and exits with code 1.
| Function | Signature | Description |
|---|---|---|
assert(cond) | (Bool) -> Int | Fail if cond is false (zero) |
assert_eq(a, b) | (Int, Int) -> Int | Fail if a != b, prints expected vs got |
assert_ne(a, b) | (Int, Int) -> Int | Fail if a == b, prints the equal value |
assert_ok(r) | (Result<T>) -> Int | Fail if r is an err |
assert_err(r) | (Result<T>) -> Int | Fail if r is ok |
assert_some(o) | (Option<T>) -> Int | Fail if o is none |
assert_none(o) | (Option<T>) -> Int | Fail if o is some |
assert(1 == 1)
assert_eq(42, 42)
assert_ne(1, 2)
assert_ok(ok(42))
assert_err(err("bad"))
assert_some(some(1))
assert_none(none())
| Function | Alias | Signature | Description |
|---|---|---|---|
path_join(a, b) | pjoin | (S, S) -> S | Join two path segments with /. If b is absolute, returns b. |
path_dir(p) | pdir | (S) -> S | Directory component (before last /). Returns "." if none. |
path_base(p) | pbase | (S) -> S | Filename component (after last /). |
path_ext(p) | pext | (S) -> S | File extension including .. Returns "" if none. |
path_stem(p) | pstem | (S) -> S | Filename without extension. |
path_is_abs(p) | pabs | (S) -> I | Returns 1 if path starts with /, else 0. |
path_join("foo" "bar") // "foo/bar"
path_dir("/home/user/file.sans") // "/home/user"
path_base("/home/user/file.sans") // "file.sans"
path_ext("file.sans") // ".sans"
path_stem("file.sans") // "file"
pabs("/home") // 1
| Function | Alias | Signature | Description |
|---|---|---|---|
base64_encode(s) | b64e | (S) -> S | Base64 encode a string. |
base64_decode(s) | b64d | (S) -> S | Base64 decode a string. |
url_encode(s) | urle | (S) -> S | Percent-encode for URLs. |
url_decode(s) | urld / ud | (S) -> S | Decode percent-encoded string (+ becomes space). |
hex_encode(s) | hexe | (S) -> S | Hex encode (each byte → 2 lowercase hex chars). |
hex_decode(s) | hexd | (S) -> S | Hex decode (2 hex chars → byte). |
base64_encode("Hello, World!") // "SGVsbG8sIFdvcmxkIQ=="
base64_decode("SGVsbG8sIFdvcmxkIQ==") // "Hello, World!"
url_encode("hello world&foo=bar") // "hello%20world%26foo%3Dbar"
url_decode("hello%20world") // "hello world"
hex_encode("ABC") // "414243"
hex_decode("414243") // "ABC"
Hash functions, HMAC, and cryptographic random bytes via OpenSSL. All return lowercase hex-encoded strings.
| Function | Alias | Signature | Description |
|---|---|---|---|
sha256(s) | — | (S) -> S | SHA-256 hash (64-char hex). |
sha512(s) | — | (S) -> S | SHA-512 hash (128-char hex). |
md5(s) | — | (S) -> S | MD5 hash (32-char hex). |
hmac_sha256(key, msg) | hmac256 | (S, S) -> S | HMAC-SHA256 (64-char hex). |
random_bytes(n) | randb | (I) -> S | n crypto random bytes as hex (2*n chars). |
sha256("hello") // "2cf24dba5fb0a30e..."
md5("hello") // "5d41402abc4b2a76..."
hmac_sha256("secret" "msg") // HMAC-SHA256 hex digest
hmac256("key" "data") // alias
r = random_bytes(16) // 32-char random hex string
r = randb(8) // 16-char random hex string
| Function | Alias | Signature | Description |
|---|---|---|---|
regex_match(pattern, text) | rmatch | (S, S) -> I | Returns 1 if text matches pattern, 0 otherwise. |
regex_find(pattern, text) | rfind | (S, S) -> S | First match substring, or "" if none. |
regex_replace(pattern, text, replacement) | rrepl | (S, S, S) -> S | Replace first match with replacement. |
regex_match("[0-9]+" "hello123") // 1
rmatch("[0-9]+" "hello") // 0
regex_find("[0-9]+" "hello123world") // "123"
rfind("[a-z]+" "123abc456") // "abc"
regex_replace("[0-9]+" "hello123world" "XXX") // "helloXXXworld"
rrepl("[0-9]+" "no digits" "XXX") // "no digits"
Sans strings are byte arrays. These functions interpret bytes as UTF-8 without changing the underlying representation. slen(s) returns byte length; use char_count(s) for codepoint count.
| Function | Alias | Signature | Description |
|---|---|---|---|
char_count(s) | ccount | (S) -> I | Count UTF-8 codepoints (not bytes). |
chars(s) | — | (S) -> Array<S> | Split string into array of UTF-8 characters. |
is_ascii(s) | — | (S) -> I | Returns 1 if all bytes are ASCII (< 128). |
utf8_valid(s) | — | (S) -> I | Returns 1 if string is valid UTF-8. |
string_reverse(s) | srev | (S) -> S | UTF-8 aware reverse (preserves multi-byte chars). |
char_count("hello") // 5
is_ascii("hello") // 1
utf8_valid("hello") // 1
string_reverse("hello") // "olleh"
srev("abc") // "cba"
c = chars("hi") // ["h" "i"]
| Function | Alias | Signature | Description |
|---|---|---|---|
getenv(name) | genv | (String) -> String | Read environment variable. Returns "" if not set. |
mkdir(path) | — | (String) -> Int | Create directory and parents (mkdir -p). Returns 1 on success, 0 on error. |
rmdir(path) | — | (String) -> Int | Remove empty directory. Returns 1 on success, 0 on error. |
remove(path) | rm | (String) -> Int | Delete a file. Returns 1 on success, 0 on error. |
listdir(path) | ls | (String) -> Array<String> | List directory contents. Returns empty array on error. |
is_dir(path) | — | (String) -> Bool | Check if path is a directory. |
sh(cmd) | shell | (String) -> String | Execute command and capture stdout. Returns "" on failure. |
// Environment
home = getenv("HOME")
// Filesystem
mkdir("build/output")
is_dir("build/output") // true
files = listdir("src/")
remove("old.txt")
rmdir("build/output")
// Process
output = sh("uname -s")
These enable Sans to replace its own C runtime. Pointers are stored as Int (i64).
| Function | Signature | Description |
|---|---|---|
alloc(size) | (Int) -> Int | malloc, returns pointer |
dealloc(ptr) | (Int) -> Int | free |
ralloc(ptr, size) | (Int, Int) -> Int | realloc |
mcpy(dst, src, n) | (Int, Int, Int) -> Int | memcpy |
mcmp(a, b, n) | (Int, Int, Int) -> Int | memcmp |
slen(ptr) | (Int) -> Int | strlen on raw pointer (byte length — use char_count() for UTF-8) |
load8(ptr) | (Int) -> Int | load byte (0-255) |
store8(ptr, val) | (Int, Int) -> Int | store byte |
load16(ptr) | (Int) -> Int | load 16-bit value |
store16(ptr, val) | (Int, Int) -> Int | store 16-bit value |
load32(ptr) | (Int) -> Int | load 32-bit value |
store32(ptr, val) | (Int, Int) -> Int | store 32-bit value |
load64(ptr) | (Int) -> Int | load 64-bit value |
store64(ptr, val) | (Int, Int) -> Int | store 64-bit value |
strstr(haystack, needle) | (Int, Int) -> Int | find substring, 0 if not found |
bswap16(val) | (Int) -> Int | byte-swap 16-bit (htons) |
bxor(a, b) | (Int, Int) -> Int | bitwise XOR (native 64-bit) |
band(a, b) | (Int, Int) -> Int | bitwise AND (native 64-bit) |
bor(a, b) | (Int, Int) -> Int | bitwise OR (native 64-bit) |
bshl(a, b) | (Int, Int) -> Int | bitwise shift left (native 64-bit) |
bshr(a, b) | (Int, Int) -> Int | bitwise shift right (native 64-bit) |
pmutex_init(ptr) | (Int) -> Int | Initialize a raw pthread mutex |
pmutex_lock(ptr) | (Int) -> Int | Lock a raw pthread mutex |
pmutex_unlock(ptr) | (Int) -> Int | Unlock a raw pthread mutex |
exit(code) | (Int) -> Int | exit process |
system(cmd) / sys(cmd) | (String) -> Int | run shell command, return exit code |
gzip_compress(data, len) | (Int, Int) -> Int | gzip-compress data; returns ptr to [compressed_ptr, compressed_len] |
Phase-based bump allocator. All allocations between arena_begin() and arena_end() are freed at once. Arenas nest up to 8 deep.
| Function | Signature | Description |
|---|---|---|
arena_begin() | () -> Int | Push a new arena onto the stack |
arena_alloc(size) | (Int) -> Int | Bump-allocate from the current arena (8-byte aligned) |
arena_end() | () -> Int | Pop and free all memory in the current arena |
| Function | Signature | Description |
|---|---|---|
wfd(fd, msg) | (Int, String) -> Int | write string to file descriptor |
Low-level TLS/SSL bindings. For most use cases, prefer https_listen.
| Function | Signature | Description |
|---|---|---|
ssl_ctx(cert, key) | (String, String) -> Int | Create SSL context from cert/key file paths |
ssl_accept(ctx, fd) | (Int, Int) -> Int | Perform TLS handshake on accepted socket fd |
ssl_read(ssl, buf, len) | (Int, Int, Int) -> Int | Read bytes from TLS connection |
ssl_write(ssl, buf, len) | (Int, Int, Int) -> Int | Write bytes to TLS connection |
ssl_close(ssl) | (Int) -> Int | Shut down TLS connection and free SSL object |
| Function | Signature | Description |
|---|---|---|
sock(domain, type, proto) | (Int, Int, Int) -> Int | socket() |
sbind(fd, port) | (Int, Int) -> Int | bind to port |
slisten(fd, backlog) | (Int, Int) -> Int | listen() |
saccept(fd) | (Int) -> Int | accept() |
srecv(fd, buf, len) | (Int, Int, Int) -> Int | recv() |
ssend(fd, buf, len) | (Int, Int, Int) -> Int | send() |
sclose(fd) | (Int) -> Int | close() |
rbind(fd, addr, len) | (Int, Int, Int) -> Int | raw bind() |
rsetsockopt(fd, level, opt, val, len) | (Int, Int, Int, Int, Int) -> Int | raw setsockopt() |
| Function | Signature | Description |
|---|---|---|
cinit() | () -> Int | curl_easy_init |
csets(h, opt, val) | (Int, Int, String) -> Int | setopt with string |
cseti(h, opt, val) | (Int, Int, Int) -> Int | setopt with long |
cperf(h) | (Int) -> Int | curl_easy_perform |
cclean(h) | (Int) -> Int | curl_easy_cleanup |
cinfo(h, info, buf) | (Int, Int, Int) -> Int | curl_easy_getinfo |
curl_slist_append(slist, str) | (Int, Int) -> Int | append to curl header list |
curl_slist_free(slist) | (Int) -> Int | free curl header list |
| Function | Signature | Description |
|---|---|---|
fptr("name") | (String) -> Int | get pointer to named function |
fcall(ptr, arg) | (Int, Int) -> Int | call function pointer with 1 arg |
fcall2(ptr, a, b) | (Int, Int, Int) -> Int | call function pointer with 2 args |
fcall3(ptr, a, b, c) | (Int, Int, Int, Int) -> Int | call function pointer with 3 args |
| Function | Signature | Description |
|---|---|---|
ptr(s) | (String|Map|Array) -> Int | get raw i64 pointer of string, map, or array |
char_at(s, i) | (String, Int) -> Int | read byte at index i (shorthand for load8(ptr(s) + i)) |
Explicit Map built-ins. Use these when a Map is stored as Int (e.g. from load64) and method dispatch cannot determine the correct type.
| Function | Signature | Description |
|---|---|---|
mget(map, key) | (Int, String) -> Int | get value from Map by key (0 if not found) |
mset(map, key, val) | (Int, String, Int) -> Int | set key-value pair in Map |
mhas(map, key) | (Int, String) -> Int | check if Map contains key (1=yes, 0=no) |
| Function | Signature | Description |
|---|---|---|
read_file(path) | (String) -> String | read entire file to string |
write_file(path, content) | (String, String) -> Int | write string to file |
args() | () -> Array<String> | get command-line arguments |
| Function | Signature |
|---|---|
ok(value) | (T) -> Result<T> |
err(message) | (String) -> Result<_> |
err(code, message) | (Int, String) -> Result<_> |
| Method | Signature | Notes |
|---|---|---|
push(value) | (T) -> Int | Append element |
pop | () -> T | Remove and return last |
get(index) or [index] | (Int) -> T | Read element |
set(index, value) | (Int, T) -> Int | Write element |
len | () -> Int | Length |
remove(index) | (Int) -> T | Remove at index |
contains(value) | (T) -> Bool | Check membership |
map(fn) | ((T) -> U) -> Array<U> | Transform elements |
filter(fn) | ((T) -> Bool) -> Array<T> | Filter elements |
any(fn) | ((T) -> Bool) -> Bool | True if any element matches |
find(fn) | ((T) -> Bool) -> Option<T> | First match, or None |
enumerate | () -> Array<(Int, T)> | Index-value tuples |
zip(other) | (Array<U>) -> Array<(T, U)> | Paired tuples |
| Method | Alias | Signature |
|---|---|---|
len | () -> Int | |
substring(start, end) | (Int, Int) -> String | |
trim | () -> String | |
starts_with(prefix) | sw | (String) -> Bool |
ends_with(suffix) | ew | (String) -> Bool |
contains(needle) | (String) -> Bool | |
split(delimiter) | (String) -> Array<String> | |
replace(old, new) | (String, String) -> String |
| Method | Signature |
|---|---|
get(key) | (String) -> JsonValue |
get_index(index) | (Int) -> JsonValue |
get_string | () -> String |
get_int | () -> Int |
get_bool | () -> Bool |
len | () -> Int |
type_of | () -> String |
set(key, value) | (String, JsonValue) -> Int |
push(value) | (JsonValue) -> Int |
| Method | Signature |
|---|---|
status | () -> Int |
body | () -> String |
header(name) | (String) -> String |
ok | () -> Bool |
| Type | Method | Signature | Notes |
|---|---|---|---|
| HttpServer | accept | () -> HttpRequest | |
| HttpRequest | path | () -> String | |
| HttpRequest | method | () -> String | |
| HttpRequest | body | () -> String | |
| HttpRequest | header(name) | (String) -> String | Get request header value (case-insensitive) |
| HttpRequest | set_header(name, value) | (String, String) -> Int | Add custom response header (call before respond) |
| HttpRequest | query(name) | (String) -> String | Get query parameter value by name |
| HttpRequest | path_only | () -> String | Path without query string |
| HttpRequest | content_length | () -> Int | Get Content-Length as int |
| HttpRequest | cookie(name) | (String) -> String | Get cookie value from Cookie header |
| HttpRequest | form(name) | (String) -> String | Parse form field from POST body (URL-encoded or multipart) |
| HttpRequest | file(name) | (String) -> String | Get uploaded file contents (multipart/form-data) |
| HttpRequest | files(name) | (String) -> Array<String> | Get all uploaded files for field name |
| HttpRequest | respond(status, body) | (Int, String) -> Int | Defaults to text/html content-type |
| HttpRequest | respond(status, body, content_type) | (Int, String, String) -> Int | Explicit content-type |
| HttpRequest | respond_json(status, body) | (Int, String) -> Int | JSON response (sets Content-Type: application/json) |
| HttpRequest | respond_stream(status) | (Int) -> Int | Chunked streaming response, returns writer handle |
| HttpRequest | is_ws_upgrade | () -> Int | Returns 1 if WebSocket upgrade request |
| HttpRequest | upgrade_ws | () -> Int | Perform WS handshake, return WebSocket handle |
| Method | Signature | Notes |
|---|---|---|
is_some | () -> Bool | |
is_none | () -> Bool | |
unwrap or ! | () -> T | Exits on None |
unwrap_or(default) | (T) -> T |
| Method | Signature | Notes |
|---|---|---|
is_ok | () -> Bool | |
is_err | () -> Bool | |
unwrap or ! | () -> T | Exits on error |
unwrap_or(default) | (T) -> T | |
error | () -> String | Error message |
code | () -> Int | Error code (0 if not set) |
map(fn) | ((T) -> U) -> Result<U> | Transform ok value |
and_then(fn) | ((T) -> Result<U>) -> Result<U> | Chain fallible operations |
map_err(fn) | ((String) -> String) -> Result<T> | Transform error message |
or_else(fn) | ((String) -> Result<T>) -> Result<T> | Recover from error |
| Type | Method | Signature |
|---|---|---|
| Sender<T> | send(value) | (T) -> Int |
| Receiver<T> | recv | () -> T |
| Mutex<T> | lock | () -> T |
| Mutex<T> | unlock(value) | (T) -> Int |
| JoinHandle | join | () -> Int |
Array GET (a[i]) and SET (a[i] = v) are bounds-checked at runtime. Out-of-bounds access prints an error and exits:
a = [1 2 3]
a[10] // error: index out of bounds: index 10 but length is 3
a[10] = 5 // error: index out of bounds: index 10 but length is 3
char_at() is bounds-checked at runtime:
s = "hi"
s.char_at(99) // error: string index out of bounds: index 99 but length is 2
json_parse(s) / jp(s) now returns Result<JsonValue> instead of JsonValue. On invalid input, it returns an error Result with a descriptive message instead of a null JsonValue.
// Before (v0.8.0):
j = jp("{\"name\":\"Alice\"}") // JsonValue (null on error)
// After (v0.8.1+):
j = jp("{\"name\":\"Alice\"}")! // unwrap Result to get JsonValue
// or handle the error:
r = jp(input)
if r.is_err { p("bad json") } else { r!.get("name") }
Migration: Add ! after every json_parse() / jp() call, or use ? to propagate the error.
JSON parsing enforces a maximum nesting depth of 512 levels. Inputs with deeper nesting return an error Result:
r = jp(deeply_nested_string)
// r.is_err == true, r.error() == "JSON parse error: maximum nesting depth exceeded"
Returning nested JSON values from functions no longer causes use-after-free. The scope-based garbage collector now walks JSON object and array trees, promoting all referenced memory to the caller's scope on return.
HTTP and HTTPS servers automatically ignore SIGPIPE so that client disconnects during a write do not crash the server process.
Panic recovery allows programs to catch unwrap failures (! on Err/None) and recover instead of exiting. Implemented with setjmp/longjmp; primarily intended for use in server request handlers.
buf := panic_get_buf()
rv := setjmp(buf)
if rv != 0 {
req.respond(500 "internal error")
panic_disable()
0
} else {
panic_enable()
result = risky_op()! // longjmp on Err/None instead of exit
req.respond(200 str(result))
panic_disable()
0
}
| Function | Signature | Description |
|---|---|---|
setjmp(buf) | (Int) -> Int | Set jump point. Returns 0 initially, non-zero on longjmp |
longjmp(buf, val) | (Int, Int) -> Int | Jump to setjmp point with value |
panic_enable() | () -> Int | Enable panic recovery (unwrap uses longjmp instead of exit) |
panic_disable() | () -> Int | Disable panic recovery |
panic_is_active() | () -> Int | Returns 1 if recovery is active, 0 otherwise |
panic_get_buf() | () -> Int | Get the jmp_buf pointer |
panic_fire() | () -> Int | Call longjmp to panic buf manually |
The standard library is implemented across 28 modules in runtime/: arena.sans, array_ext.sans, bitwise.sans, curl.sans, encoding.sans, fs.sans, functional.sans, http.sans (HTTP client), io.sans, iter.sans, json.sans (JSON), log.sans, map.sans, math.sans, net.sans, option.sans, path.sans, process.sans, rc.sans (scope GC), result.sans, router.sans, server.sans (HTTP server, WebSocket), sock.sans, ssl.sans, static_file.sans, string_ext.sans, unicode.sans, websocket.sans.
The compiler is 7 modules in compiler/ (~11,600 LOC): lexer, parser, typeck, constants, IR, codegen, main. Compiles to LLVM IR via llc, links with clang.
The compiler has builtin mappings for a large set of names. User-defined functions take precedence over builtins of the same name. Builtin names include:
p, print, str, stoi, itof, ftoi, ftos, fr, fw, fa, fe, rl, wl, al, read_lines, write_lines, append_line, read_line, file_read, file_write, file_exists, listen, serve, serve_file, serve_tls, alloc, dealloc, load8/16/32/64, store8/16/32/64, mcpy, slen, wfd, exit, system, ok, err, map, M, jp, jfy, jo, ja, sock, saccept, args, signal_handler, signal_check, tnow, tfmt, tyear, tmon, tday, thour, tmin, tsec, twday, tadd, tdiff, and all others listed in the reference.
The Sans compiler reports errors with source location, the offending source line, and a caret pointing to the token:
file.sans:12:5: error: undefined variable 'foo'
foo + 1
^
The compiler collects multiple errors before exiting — all errors in a file are reported in a single pass. Warnings are also emitted for common issues and do not prevent compilation.
The compiler emits warnings for:
return