agra 7218280bf0 docs: streamline readme into a punchy project overview
Drop experimental/Jai/Zig framing and the Acknowledgments section, trim
the verbose edge-case paragraphs (numeric limits, float narrowing,
reserved names, module visibility) to punchy summaries, and remove the
from-source build section. Describe sx as a programming language.
2026-06-26 11:20:33 +03:00
2026-06-15 13:39:39 +03:00
2026-02-09 18:07:41 +02:00
2026-06-17 09:58:43 +03:00
2026-02-09 18:07:41 +02:00

sx

A programming language with compile-time execution, generics, closures, protocols, and an LLVM backend — compiled to fast native code.

At a Glance

#import "modules/std.sx";

Point :: struct {
    x, y: i32;
    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());
}

Highlights:

  • Clean declaration syntax: name :: value for constants, name := value for variables
  • Compiles to native code via LLVM
  • Compile-time execution with #run and code generation with #insert
  • Generics via monomorphization
  • First-class closures with value capture
  • Protocol-based polymorphism (traits) with optional inline dispatch
  • Pattern matching on enums, optionals, and type categories
  • C interop via extern / export and #import c
  • Inline assembly as a first-class expression
  • Targets: macOS (ARM64, x86_64), Linux (x86_64, ARM64), Windows (x86_64), WebAssembly

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
i8..i64, u8..u64 Signed/unsigned integers (default: i64)
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

A fixed array [N]T coerces to a slice []T (its length is known); a [*]T many-pointer carries no length, so slice it explicitly with ptr[0..len].

Numeric limits. A field access on a builtin integer type folds to a compile-time constant: i64.max, u8.min, [u8.max]T (a 255-element array). Floats expose .min / .max plus .epsilon, .min_positive, .true_min, .inf, and .nan. See specs.md → Numeric Limits.

Declarations

// Constants (compile-time when possible)
PI :: 3.14159;
MAX : i32 : 100;

// Variables (mutable)
x := 42;               // inferred type
y : i32 = 0;           // explicit type
z : i32 = ---;         // uninitialized

A typed constant's initializer must be compatible with its annotation (checked at compile time for both literals and constant expressions). Mixed int+float arithmetic promotes to float in either operand order.

Aggregate constants. Array- and struct-typed :: constants are immutable globals — one storage, reads index directly, whole-value uses copy by value, unused tables are dropped from the binary. :: is the one and only const spelling:

K : [4]i64 : .[11, 22, 33, 44];   // typed array const
A :: .[1, 2, 3];                  // untyped — infers [3]i64
M :: .[1, 2.2, 3];                // numeric mix promotes — [3]f64
LIT :: Color.{ r = 255, g = 0, b = 0 };   // struct const

N :: K[0] + K[3];     // 55 — const element reads fold at compile time
D : [K.len]u8 = ---;  // .len folds in dimensions too
K[0] = 5;             // error: cannot assign through constant 'K'

Writes through a constant's name are compile errors; a local copy (k := K) stays writable.

Float → integer narrowing. A float flowing into an integer binding without a cast must be integral: an integral compile-time float folds to its integer, a non-integral one is a compile error (y : i64 = 4.04; y : i64 = 1.5 errors). This is uniform across locals, defaults, arguments, constants, and array dimensions. An explicit xx / cast(i64) is the escape hatch and always truncates.

Reserved names. Builtin type names (i32, u8, bool, string, …) can't be used bare as identifiers at value-binding or declaration sites. Member positions (struct fields, union tags, protocol methods) are exempt, as is any name after a leading .. A leading backtick escapes one into a raw identifier (`i2), usable in every position:

`i2 := 2.5;                  // identifier "i2", distinct from the i2 type
`i2 :: struct { x: i64; }    // a type named with a reserved spelling
v : `i2 = ---;               // referenced as a type
x : i2  = 3;                 // bare `i2` in type position is still the int type

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;

Set a variant by construction (s = .circle(2.0)), which writes the tag and payload together. Direct member assignment to a variant (s.circle = 2.0) is rejected; mutating a sub-field of the active variant in place (s.rect.w = 9.0) is fine.

Optionals

x: ?i32 = 42;
y: ?i32 = 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";

// Flow-sensitive narrowing: a `!= null` guard proves the value present.
n: ?i32 = maybe();
if n != null { take_i32(n); }       // `n` is i32 here

A ?T never implicitly unwraps to T in a value position — a bare take_i32(n) without a guard, !, ??, or binding is a compile error.

Generics

max :: (a: $T, b: T) -> T {
    if a > b then a else b;
}

List :: struct ($T: Type) {
    items: []T;          // a slice; items.len is the live count, so a List is
    cap: i64;            // directly iterable: `for xs.items (e) { ... }`

    append :: (self: *List(T), item: T) { ... }

    // `#get` / `#set` property accessors: read/write via field syntax
    // (`xs.len`, `xs.len = n`) rather than method calls.
    len :: (self: *List(T)) -> i64 #get => self.items.len;
    len :: (self: *List(T), v: i64) #set { self.items.len = v; }
}

Generic constraints via protocols:

are_equal :: ($T: Type/Eq, a: T, b: T) -> bool { a.eq(b); }

Closures

make_adder :: (n: i64) -> Closure(i64) -> i64 {
    closure((x: i64) -> i64 => 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 :: (self: *Self, x: i32, y: i32);   // receiver is explicit + required
}

impl Drawable for Circle {
    draw :: (self: *Circle, x: i32, y: i32) { ... }
}

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 :: (self: *Self, size: i64) -> *void;
    dealloc :: (self: *Self, 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
for 0<..<n (i) { }                                  // bound markers: 1 .. n-1
sub := items[1..=3];                                // slices take them too

// 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

libc :: #library "c";
printf :: (fmt: [:0]u8, args: ..Any) -> i32 extern libc;
write_fd :: (fd: i32, buf: [*]u8, count: u64) -> i64 extern libc "write";

extern imports a symbol defined elsewhere; export is its dual — define a function in sx and expose it under the C ABI so C can call back in. Both imply abi(.c) and take an optional [LIB] ["csym"] rename tail:

abs       :: (x: i32) -> i32 extern;             // import an external C symbol
sx_square :: (x: i32) -> i32 export { x * x }    // define + expose to C
__stdinp  : *void extern;                        // extern data global

Direct C header import:

#import c {
    #include "vendors/mylib/api.h";
    #source "vendors/mylib/impl.c";
};

Inline Assembly

asm is an expression. The body is a brace block: a template string first, then operands and an optional clobbers(.…) clause. Each operand is [name]? "constraint" <role>, where the role is -> Type (a value output) or = expr (an input). It compiles to an LLVM inline-asm call (AT&T syntax).

// one value output, two register-class inputs
add :: (a: i64, b: i64) -> i64 {
    return asm { "add %[out], %[a], %[b]", [out] "=r" -> i64, [a] "r" = a, [b] "r" = b };
}

Outputs decide the result: 0void (asm must be volatile); 1 → that type; N → a destructurable Tuple named by each operand. A top-level asm { … } block is global (module-level) assembly. See docs/inline-assembly.md for the full guide.

Modules

#import "modules/std.sx";              // flat import
math :: #import "modules/math";        // namespaced import (directory: all .sx files merged)

A flat import makes a module's top-level names bare-visible; a namespaced import binds only its alias, reached as m.name. Visibility does not chain — a flat import of a flat import is not bare-visible two hops away; qualify it or #import the module directly. Bare names that two flat imports both provide are ambiguous and must be qualified. When a module declares its own same-name symbol, that wins over any import.

A facade can re-export another module's members as its own declarations (ordinary aliases), which its direct importers then see bare:

// facade.sx
r :: #import "rich.sx";
helper :: r.helper;       // fn re-export
Thing  :: r.Thing;        // struct re-export
Box    :: r.Box;          // generic head re-export — same template

The stdlib prelude uses exactly this: std.sx is a pure re-export facade, so #import "modules/std.sx" gives every bare prelude name (print, List, Context, …) plus carried namespaces (mem, fs, process, socket, json, cli, hash, xml, log, test):

#import "modules/std.sx";

main :: () {
    gpa := mem.GPA.init();          // mem :: #import — carried from std.sx
    log.warn("count = {}", 3);
    s := xml.escape("<a & b>");
}

Implicit Context

Every program gets an implicit context with a default allocator:

// No boilerplate needed — context is auto-initialized
main :: () {
    list := List(i64).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: i64, hi: i64) -> i64 {
        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: i64, hi: i64) {
        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 : []i64 = .[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: Allocator protocol, GPA (general purpose), Arena (bump allocator)
  • Math: sqrt, sin, cos
  • Introspection: type_of, type_name, size_of, align_of, field_count, field_name, field_value, and more

Atomics (modules/std/atomic.sx)

Opt-in import. Atomic($T) is a transparent wrapper over an integer/pointer-sized T; the memory Ordering is an explicit compile-time value parameter:

#import "modules/std/atomic.sx";

counter : Atomic(i64) = .init(0);
counter.store(0, .relaxed);
n    := counter.load(.acquire);
prev := counter.fetch_add(1, .seq_cst);             // + fetch_sub/and/or/xor/min/max
old  := counter.swap(42, .acq_rel);

// compare-exchange returns ?T — null = SUCCESS; a present value is the actual
// current value on failure (for a retry loop).
got := counter.compare_exchange(old, 99, .acq_rel, .acquire);
if got == null { /* swapped */ } else { /* retry with got! */ }

fence(.seq_cst);   // standalone memory fence

Ordering = relaxed/acquire/release/acq_rel/seq_cst. Invalid combinations are compile errors. The same operations run at compile time (#run) under single-threaded semantics.

Command-line interface (modules/std/cli.sx)

std.cli builds command-line front-ends over an explicit logical argv: os_args reads the real process argv, and parse(args, commands, diag) does subcommand dispatch + --flag parsing, with named exit codes (EX_OK, EX_USAGE, EX_UNAVAILABLE) and a --json machine-output convention.

Cross-Compilation

sx build app.sx --target linux          # Linux x86_64 (glibc, dynamic)
sx build app.sx --target linux-musl     # Linux x86_64 (musl, static)
sx build app.sx --target macos-arm      # macOS ARM64
sx build app.sx --target windows        # Windows x86_64 (MSVC)
sx build app.sx --target windows-gnu    # Windows x86_64 (MinGW)
sx build app.sx --target wasm           # WebAssembly

Self-contained builds

sx can link with a bundled toolchain instead of the host's system linker — it supplies lld, the CRT, and libc (musl/glibc/mingw), so no cc/SDK needs to be installed. The default Linux output is statically-linked musl, which runs on any Linux.

sx build app.sx --target linux-musl --self-contained   # static, portable ELF
sx build app.sx --self-contained                       # host target, hermetic link
sx build app.sx --no-self-contained                    # force the system toolchain

License

MIT

Description
No description provided
Readme MIT 83 MiB
Languages
Zig 99%
Shell 0.5%
C++ 0.4%