The for header is now a comma-separated list of iterables with a
positional capture group and no ':' separator:
for xs (x) { } // collection
for 0..n (i) { } // range (end exclusive)
for 1..=5 (a) { } // ..= inclusive end
for xs, 0.. (x, i) { } // index idiom (replaces (x, i))
for xs, ys (x, y) { } // parallel (zip) iteration
for xs (x) => sum += x; // arrow body (full statement)
First-iterable-wins: the first iterable's length drives the loop and
must be bounded; the other positions follow by their own cursors (a
non-first range's end is not consulted or evaluated; a shorter
non-first collection is read past its length on mismatch). The old
single-iterable index capture is replaced by the trailing open range.
Capture/call disambiguation is positional: the paren group immediately
before '{' or '=>' is the capture, every earlier top-level group is a
call. 'for zip(a, b) (x, y)' calls zip; 'for f(n) { }' reads (n) as
the capture and errors with a parenthesize/add-capture hint. The old
':' form errors with a migration hint.
Lowering is unified across forms: one cursor slot per position (ranges
start at their start, collections at 0), all advanced together, the
first position's bound terminating. inline for keeps the single
bounded comptime range.
Migrated the full corpus (examples, library modules, issue repros,
in-source test strings). New coverage: examples/0050 (the full feature
surface) and examples/1149-1155 (seven diagnostic faces). specs.md For
Loop section + grammar rewritten; readme teaser updated.
19 KiB
sx
An experimental systems programming language with Jai-inspired syntax, compile-time execution, generics, closures, protocols, and an LLVM backend.
Status: Highly experimental. The language and compiler are under active development.
At a Glance
#import "modules/std.sx";
Point :: struct {
x, y: s32;
magnitude :: (self: *Point) -> f32 { sqrt(self.x * self.x + self.y * self.y); }
}
main :: () {
p := Point.{ x = 3, y = 4 };
print("point: {}, magnitude: {}\n", p, p.magnitude());
}
Key characteristics:
- Jai-inspired declaration syntax:
name :: valuefor constants,name := valuefor variables - Compiles to native code via LLVM 19
- Compile-time execution with
#run - Generics via monomorphization
- First-class closures with value capture
- Protocol-based polymorphism (traits)
- Pattern matching on enums, optionals, and type categories
- C interop via
#foreignand#import c - Targets: macOS (ARM64, x86_64), Linux (x86_64, ARM64), Windows (x86_64), WebAssembly
Building
Requires Zig 0.16+ and LLVM 19+.
zig build
On macOS with Homebrew LLVM:
# default path: /opt/homebrew/opt/llvm@19
zig build
Custom LLVM path:
zig build -Dllvm-prefix=/path/to/llvm
Usage
sx run file.sx # compile and run
sx build file.sx # compile to binary
sx build file.sx -o out # compile with output path
sx ir file.sx # emit LLVM IR
sx lsp # start language server
Options:
--target <triple> target platform (shortcuts: macos, linux, windows, wasm)
--opt <level> optimization: none, less, default, aggressive
--cpu <name> target CPU
-o <path> output path
Language Overview
Types
| Type | Description |
|---|---|
s8..s64, u8..u64 |
Signed/unsigned integers (default: s64) |
f32, f64 |
Floating point (default: f32) |
bool |
true / false |
string |
UTF-8 fat pointer {ptr, len} |
[N]T |
Fixed-size array |
[]T |
Slice (fat pointer) |
*T, [*]T |
Single / many pointer |
?T |
Optional |
struct, enum, union |
Composite types |
Closure(args) -> ret |
Closure type |
Numeric limits. A field-like access on a builtin integer type name folds to
a compile-time constant of that type: s64.max → 9223372036854775807,
u8.min → 0, s3.max → 3. It works for every width s1..s64 / u1..u64
plus usize/isize, and is usable anywhere a constant of that type is — including
array dimensions ([u8.max]T is a 255-element array). The float types f32/f64
expose .min / .max too (with .min = most-negative finite = -max, not
C's DBL_MIN) plus the float-only .epsilon (ULP of 1.0, not C#'s denormal
Epsilon), .min_positive (smallest normal = C DBL_MIN), .true_min (smallest
subnormal — beware flush-to-zero CPU modes), .inf, and .nan. A float-only
accessor on an integer (s32.epsilon), or any accessor on a non-numeric type, is
a clean compile error. The fold applies only to a bare type-name receiver: a raw
identifier that binds a value shadowing a type name (`f64 := … then
`f64.epsilon) reads the value's field, not the limit — for a local, global,
or module-constant binding alike. This stays an ordinary runtime field read
even when it flows into an integer binding or an array dimension, so it truncates
(its field value) / is a non-constant count — never the builtin limit. See
specs.md → Numeric Limits.
Declarations
// Constants (compile-time when possible)
PI :: 3.14159;
MAX : s32 : 100;
// Variables (mutable)
x := 42; // inferred type
y : s32 = 0; // explicit type
z : s32 = ---; // uninitialized
A typed constant's initializer must be compatible with its annotation — an
integer fits any integer or float, a float a float type, a string string,
null a pointer/optional. The check is type-based, so it covers a literal and a
constant expression alike: both N : string : 4 and N : string : M + 2 are a
compile-time type mismatch error, not a silently-accepted constant. Mixed
int+float arithmetic promotes to the float in either operand order (n + 0.5 and
0.5 + n are both f64), so C : s64 : M + 0.5 is rejected regardless of order
while F : f64 : M + 0.5 folds to 2.5.
Float → integer narrowing (unified rule). A float flowing into an
integer-typed binding without a cast follows the same integral-fold rule an
array dimension uses: an integral compile-time float folds to its integer, a
non-integral one is a compile error. It holds whether the value is a literal
or any compile-time-constant float expression — including one that references a
float-typed const (F : f64 : 2.5; y : s64 = F + 1.5 → 4), a builtin float
numeric-limit accessor (f64.max - f64.max → 0, while f64.true_min + 0.5
errors), a float % (6.0 % 4.0 → 2, while 5.5 % 2.0 = 1.5 errors), or a
float / (6.0 / 2.0 → 3, while 5.0 / 2.0 = 2.5 errors — a float / is
always float division, never integer truncation, even with integral operands):
the compile-time float evaluator recognises every leaf shape the integer one does, so
no constant float form escapes the rule at one site while folding at another — and
is uniform
across a typed local, a parameter default, a struct field default, a call
argument, a typed constant, and an array dimension / count — y : s64 = 4.0,
K : s64 : 4.0, y : s64 = M + 2.0, and [F + 1.5]s64 (≡ [4]s64, whether
written directly, through a const, or via a type alias) all give 4, while
y : s64 = 1.5, N : s64 : 1.5, y : s64 = M + 0.5, y : s64 = F + 0.25
(= 2.75), and [F + 0.25]s64 all error (one wording at the binding sites:
cannot implicitly narrow non-integral float …; a dimension instead reports
array dimension must be an integer, but '…' is a non-integral float, since the
cast escape does not apply in a count position). An explicit xx / cast(s64)
is the escape hatch and always truncates (y : s64 = xx 1.5 → 1,
y : s64 = xx (M + 0.5) → 2); a genuine runtime float is likewise unaffected.
Builtin type names (s2, u8, bool, string, …) are reserved and a bare
spelling can't be used as an identifier at a value-binding or declaration-name
site — a value binding (:= / typed local / parameter), a :: constant or
function declaration, an impl method definition, or a :: type declaration
(struct / enum / union / alias / protocol / …) — each is an error
(s2 :: 5 and s2 :: (n) { … } are rejected just like s2 := 5). Member-name
positions are exempt: a struct field, a union tag, and a protocol
method-signature may be a bare reserved spelling (struct { s2: s64 },
union { u8: … }, protocol { s2 :: () -> s64 }) — they are reached via obj.name,
so they never mis-lower. The bare exemption covers only the identifier-classified
reserved names (s1..s64, u1..u64, bool, string, void, usize,
isize, Any); f32 and f64 are lexer keywords, so even in a member slot they
need the backtick (struct { `f32: s64 }). A leading backtick escapes one into
a raw identifier:
`name is the literal identifier name (the backtick drops out of the text),
usable in every position — value, declaration, and type, and optional in the
exempt member positions. It is the only way handwritten sx can spell a reserved
name in a binding or declaration site.
`s2 := 2.5; // identifier "s2", distinct from the s2 type
print("{}\n", `s2); // 2.5 (or bare `s2` in value position)
`s2 :: struct { x: s64; } // declare a type named with a reserved spelling
v : `s2 = ---; // and reference it as a type — resolves to the struct
x : s2 = 3; // bare `s2` in type position is still the int type
It works in every identifier position — local, global, parameter, struct field,
union tag, function name, type/alias/import name, a top-level or struct-body
constant, and the control-flow / capture / binding forms (destructure, if/while
binding, for capture, match capture, catch/onfail tag) — and a reserved-spelled
function is bare-callable (s2(10)). A backtick name used as a type resolves to a
`name-declared type — including a parameterized template (`s2(s64)) and
under pointer/optional wrappers — else a normal unknown type error.
Foreign declarations from #import c { … } are exempt automatically: C names that
collide with reserved type names (e.g. s1, s2) import unedited, and a foreign
reserved-name function is bare-callable by its C name.
Structs
Vec3 :: struct {
x, y, z: f32;
length :: (self: *Vec3) -> f32 {
sqrt(self.x * self.x + self.y * self.y + self.z * self.z);
}
}
v := Vec3.{ x = 1, y = 2, z = 3 };
v2 := Vec3.{ 1, 2, 3 }; // positional
print("{}\n", v.length());
Structs support field defaults, #using for composition, and methods defined in the body.
Enums (Tagged Unions)
Shape :: enum {
circle: f32;
rect: struct { w, h: f32; };
none;
}
area :: (s: Shape) -> f32 {
if s == {
case .circle: (r) => 3.14159 * r * r;
case .rect: (r) => r.w * r.h;
case .none: 0;
}
}
Flag enums with power-of-2 values:
Perms :: enum flags { read; write; execute; }
rw := Perms.read | Perms.write;
Optionals
x: ?s32 = 42;
y: ?s32 = null;
val := x ?? 0; // null coalescing
forced := x!; // force unwrap (traps on null)
if v := x { // safe unwrap
print("{}\n", v);
}
// Optional chaining
node: ?Node = get_node();
name := node?.name ?? "unknown";
Generics
max :: (a: $T, b: T) -> T {
if a > b then a else b;
}
List :: struct ($T: Type) {
items: [*]T;
len: s64;
append :: (self: *List(T), item: T) { ... }
}
Generic constraints via protocols:
are_equal :: ($T: Type/Eq, a: T, b: T) -> bool { a.eq(b); }
Closures
make_adder :: (n: s64) -> Closure(s64) -> s64 {
closure((x: s64) -> s64 => x + n);
}
add5 := make_adder(5);
print("{}\n", add5(100)); // 105
Closures capture by value. Bare functions auto-promote to closures when needed.
Protocols
Drawable :: protocol {
draw :: (x: s32, y: s32);
}
impl Drawable for Circle {
draw :: (self: *Circle, x: s32, y: s32) { ... }
}
shape : Drawable = xx my_circle; // type erasure via xx
shape.draw(10, 20); // dynamic dispatch
#inline protocols store function pointers directly (no vtable indirection):
Allocator :: protocol #inline {
alloc :: (size: s64) -> *void;
dealloc :: (ptr: *void);
}
Pattern Matching
// On enums
if shape == {
case .circle: (r) => print("radius: {}\n", r);
case .rect: (r) => print("{}x{}\n", r.w, r.h);
case .none: print("nothing\n");
}
// On optionals
if opt == {
case .some: (val) => use(val);
case .none: fallback();
}
// On type categories (via Any)
if type_of(val) == {
case int: print("integer\n");
case string: print("string\n");
case struct: print("struct\n");
}
Control Flow
// Chained comparisons
if 0 <= x <= 100 { ... }
// While
while i < 10 { i += 1; }
// For — collections, ranges, and parallel iteration
for items (val) { print("{}\n", val); }
for items, 0.. (val, idx) { print("[{}] = {}\n", idx, val); }
for 1..=5, 0.. (a, b) { print("{}:{}\n", a, b); } // a: 1..5, b follows
for items (val) => total += val; // arrow body
// Defer
f := open("file.txt");
defer close(f);
// Multi-target assignment (atomic swap)
a, b = b, a;
Pipe Operator
result := data |> parse() |> transform() |> serialize();
// equivalent to: serialize(transform(parse(data)))
Compile-Time Execution
// Evaluate at compile time
FIBONACCI_10 :: #run fib(10);
// Generate code at compile time
#insert #run generate_lookup_table();
C Interop
Foreign functions:
libc :: #library "c";
printf :: (fmt: [:0]u8, args: ..Any) -> s32 #foreign libc;
write_fd :: (fd: s32, buf: [*]u8, count: u64) -> s64 #foreign libc "write";
Direct C header import:
#import c {
#include "vendors/mylib/api.h";
#source "vendors/mylib/impl.c";
};
Modules
#import "modules/std.sx"; // flat import
math :: #import "modules/math.sx"; // namespaced import
When two flat-imported modules each define a function of the same name, every
module's own code binds its OWN author — a bare call resolves to the same-name
function in the caller's module (or in its single flat import that provides it).
A bare call to a name that two or more flat imports both provide is ambiguous and
is rejected; qualify it with a namespaced import (m :: #import …; m.fn()).
A namespaced import only binds its alias: reach the module's members as
m.name. Bare-name visibility joins over flat (#import "…") imports, never over
a namespaced alias. That join is non-transitive for every bare member kind —
functions, constants, AND types alike: a flat import of a flat import is NOT
bare-visible (when A imports B and B imports C, A does not see C's
top-level names — including its types — so qualify them, or #import "C" directly
if you reference them). This holds for a parameterized type head too: a generic
struct / parameterized protocol / type-returning function used as Box(s64) is
gated exactly like a bare leaf type — the constructor head must be reachable over
your own or a direct flat import, not two hops away. A bare reference to a
namespaced-only import's member — function, module constant, or type (leaf or
generic head) — is likewise not visible and is rejected (type 'X' is not visible; #import the module that declares it); qualify it as m.name. The type gate holds
wherever a bare type name is named — a value/field annotation, a reflection /
type-arg slot (size_of(T), size_of(*T)), a typed array-literal head (T.[…]),
a parameterized head (Box(s64)), or a type-as-value / type-match arm — not just
plain annotations. Own-wins holds at every one of those sites too, exactly like
a bare call: when the querying module declares its OWN same-name type, that bare
reference resolves to ITS author — never a same-name flat import. Ambiguity is
enforced at every one of those sites as well: a bare type (including a type-returning
function head) that two or more flat imports each declare — with no own author to
win — is ambiguous and rejected (type 'X' is ambiguous: it is declared in multiple flat-imported modules; qualify the reference or remove the duplicate import) — never a silent pick of one author. Qualifying the reference is a real
escape hatch for a generic head too: ns.Box(args) selects the template
AUTHORED by ns's module, so two namespaces each declaring a same-name
Box($T) with different layouts stay distinct types (a.Box(s64) and
b.Box(s64) instantiate their own author's fields), never the global last-wins
template. (A library's own internal type references still resolve: a generic
struct / pack fn / protocol body is instantiated in the module that defines it, so
e.g. List(T).append's alloc: Allocator is visible there regardless of the call
site.)
Implicit Context
Every program gets an implicit context with a default allocator:
// No boilerplate needed — context is auto-initialized
main :: () {
list := List(s64).create(); // uses context.allocator
list.append(42);
}
// Override allocator for a scope
push Context.{ allocator = my_arena } {
do_work(); // all allocations use my_arena
}
Quick Sort Example
#import "modules/std.sx";
quick_sort :: (items: []$T) {
partition :: (items: []T, lo: s64, hi: s64) -> s64 {
pivot := items[hi];
i := lo - 1;
j := lo;
while j < hi {
if items[j] < pivot {
i += 1;
items[i], items[j] = items[j], items[i];
}
j += 1;
}
i += 1;
items[i], items[hi] = items[hi], items[i];
i;
}
sort :: (items: []T, lo: s64, hi: s64) {
if lo < hi {
pi := partition(items, lo, hi);
sort(items, lo, pi - 1);
sort(items, pi + 1, hi);
}
}
sort(items, 0, items.len - 1);
}
main :: () {
arr : []s64 = .[333, 2, 3, 5, 2, 2, 3, 4, 5, 6, 6, 1];
quick_sort(arr);
print("{}\n", arr);
// [1, 2, 2, 2, 3, 3, 4, 5, 5, 6, 6, 333]
}
Standard Library
The standard library (modules/std.sx) provides:
- I/O:
print(fmt, args...),out(str) - Collections:
List($T)(dynamic array) - Strings:
concat,substr,int_to_string,uint_to_string,float_to_string,cstring - Memory:
Allocatorprotocol,GPA(general purpose),Arena(bump allocator) - Math:
sqrt,sin,cos - Introspection:
type_of,type_name,type_is_unsigned,type_eq,field_count,field_name,field_value,size_of,align_of,is_flags— the type-only builtins (size_of,align_of,field_count,type_name,type_eq,type_is_unsigned,is_flags) require a type argument (a spelled type or a genericT); passing a value is a compile-time error. A runtimeTypevalue (type_of(x)) is currently accepted bytype_nameandtype_is_unsignedonly — the other five are compile-time-only (runtime reflection is deferred)
Command-line interface (modules/std/cli.sx)
std.cli builds command-line front-ends over an explicit logical argv
([]string): os_args(buf) reads the real process argv, and
parse(args, commands, diag) -> !Parsed does subcommand dispatch + --flag
parsing. On top of that it defines the small exit-code / --json contract
a CLI program (e.g. dist) relies on:
#import "modules/std/cli.sx";
p, e := parse(args, cmds, @diag); // (Parsed, !CliError)
if e == error.UnknownCommand {
log.err("unknown command '{}'", diag.token); // human text -> stderr
exit_usage(); // usage error -> exit 64
}
if p.json { /* emit ONLY machine output on stdout */ }
- Named exit codes —
EX_OK(0),EX_USAGE(64, the sysexits.h command-line-usage code),EX_UNAVAILABLE(70, unsupported platform). - Terminators —
exit_ok()/exit_usage()end the process with the matching code; both route through the canonicalprocess.exit(code: u8). --jsonmode — the reserved global--jsonflag surfaces asparsed.json(true iff--jsonis in the argv). Convention: in json mode stdout carries only the machine result; human diagnostics go to stderr.
Cross-Compilation
sx build app.sx --target linux # Linux x86_64
sx build app.sx --target macos-arm # macOS ARM64
sx build app.sx --target windows # Windows x86_64
sx build app.sx --target wasm # WebAssembly
Acknowledgments
- Jonathan Blow for Jai, the language that inspired this one
- Andrew Kelley for Zig, which made this compiler a joy to write
License
MIT