agra 8c88504849 fix(lower): resolved author drives call param target typing [0102c F2]
Attempt-3 fix for the F2 review finding. After resolveBareCallee picks a
shadowed same-name author at a normal call site, the call's PARAMETER TARGET
TYPING still ran first-wins: resolveCallParamTypes' bare-identifier branch
resolved param types via resolveFuncByName(name) / fn_ast_map.get(name) — both
keyed by name, not by the resolved author. Because that runs in lowerCall
BEFORE the resolveBareCallee routing, a shadow author whose parameter TYPE
differs from the first-wins winner had its args lowered against the WINNER's
signature (no implicit address-of for a *T param typed as T), then the
correctly-resolved shadow FuncId was called with the mis-typed arg — a value
bit-cast to a pointer → segfault.

The bare-identifier branch now routes through the SAME resolveBareCallee
resolver one layer earlier and takes the param target types from the RESOLVED
author's lowered func.params (userParamTypes). Only the .func (single resolved
author) outcome reroutes; .ambiguous keeps the existing loud call-site
diagnostic and .none keeps the first-wins fallback, so single-author / local /
std / qualified resolution is byte-for-byte unchanged. Method-call / namespace /
foreign / generic branches of resolveCallParamTypes are untouched. The resolver
is idempotent (bareAuthorFuncId guards body lowering via lowered_fids) so the
extra call from param-type resolution is safe; lowerFunctionBodyInto already
saves/restores all lowering state for mid-call reentry.

Regression: examples/0728-modules-flat-same-name-paramtype — two flat file
imports each author `apply` with a divergent param type (a.sx value `s64`
winner, b.sx pointer `*s64` shadow). b.sx's from_b passes a value local to its
pointer-param author via implicit address-of (×2 → 42); a.sx's from_a (own ==
winner) is unchanged (value + 1 → 11). Fails on the pre-fix typing (segfault at
from_b); passes after.

Gate (worktree): zig build, zig build test (400/400), bash tests/run_examples.sh
(464 passed / 0 failed) all green. Matrix 0722-0727 unchanged. Guardrail: m3te
builds via the worktree binary (sx build --target ios-sim, exit 0) — single-
author / local resolution intact. Default-arg / closure / UFCS / comptime SITES
remain first-wins (fix-0102d).
2026-06-06 15:07:51 +03:00
...
2026-02-12 10:13:36 +02:00
2026-02-09 18:07:41 +02:00
sm
2026-03-02 21:00:55 +02:00
2026-02-22 17:24:04 +02:00
2026-02-09 18:07:41 +02:00
2026-02-09 18:07:41 +02:00

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 :: value for constants, name := value for 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 #foreign and #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.max9223372036854775807, u8.min0, s3.max3. 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.54), a builtin float numeric-limit accessor (f64.max - f64.max0, while f64.true_min + 0.5 errors), a float % (6.0 % 4.02, while 5.5 % 2.0 = 1.5 errors), or a float / (6.0 / 2.03, 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 / county : 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.51, 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 (arrays and slices)
for items: (val) { print("{}\n", val); }
for items: (val, idx) { print("[{}] = {}\n", idx, val); }

// 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()).

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: Allocator protocol, 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 generic T); passing a value is a compile-time error. A runtime Type value (type_of(x)) is currently accepted by type_name and type_is_unsigned only — 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 codesEX_OK (0), EX_USAGE (64, the sysexits.h command-line-usage code), EX_UNAVAILABLE (70, unsupported platform).
  • Terminatorsexit_ok() / exit_usage() end the process with the matching code; both route through the canonical process.exit(code: u8).
  • --json mode — the reserved global --json flag surfaces as parsed.json (true iff --json is 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

License

MIT

Description
No description provided
Readme MIT 65 MiB
Languages
Zig 98.9%
Shell 0.6%
C++ 0.4%