Sessions 56–69: declarative macro! with rep/zip/literal/block and unhygienic var $name binders; partial field-move skip Drop; @[Release] polish; LSP type hierarchy; CI Nim cache + lean macOS + Windows smoke.
32 KiB
Bux Language Reference
This document describes the Bux programming language as implemented by the bootstrap compiler.
Table of Contents
- Lexical Structure
- Types
- Variables
- Functions
- Control Flow
- Structs
- Enums
- Pattern Matching
- Methods and Interfaces
- Generics
- Gradual Ownership — Checked / Release / Drop & RAII
- Error Handling
- Modules and Imports
- Async/Await
- Operator Overloading
- Operators
- Macros
Lexical Structure
Comments
// Single-line comment
/*
Multi-line comment
/* Nested comments are supported */
*/
Identifiers
Identifiers start with a letter or underscore, followed by letters, digits, or underscores.
Keywords
func, let, var, const, type, struct, enum, union, interface, extend
module, import, pub, extern, if, else, while, do, loop, for, in
break, continue, return, match, as, is, null, self, super, sizeof
async, await, spawn, defer, switch, case, default, checked
String Literals
"Hello" // String (UTF-8) — escape sequences: \n \t \r \\ \"
c8"Hello" // *char8 (C string)
c16"Hello" // *char16
c32"Hello" // *char32
`raw literal` // Raw multi-line string — no escape processing
`line 1
line 2
line 3` // Newlines preserved as-is
f"Hello, {name}" // Interpolated string — expressions inside {}
Backtick raw strings (`...`) treat all characters literally:
\nis two characters, not a newline- Actual newlines in source are preserved in the string
- No way to escape the backtick character itself (use regular strings if needed)
Interpolated strings (f"..."):
- Expressions inside
{}are evaluated and converted toString - Supported types:
int,uint,float,bool,String - Escaped braces:
\{and\}
Number Literals
42 // int
3.14 // float64
0x2A // hex
0o52 // octal
0b101010 // binary
32i8 // int8 literal
1000u64 // uint64 literal
Types
Primitive Types
| Type | Description |
|---|---|
int8, int16, int32, int64, int |
Signed integers |
uint8, uint16, uint32, uint64, uint |
Unsigned integers |
float32, float64 |
Floating-point |
bool, bool8, bool16, bool32 |
Booleans |
char8, char16, char32 |
Characters |
String |
C-compatible string (const char*) |
Composite Types
*T // Pointer to T
&T // Shared reference (read-only in checked functions)
&mut T // Mutable reference (exclusive borrow)
own T // Owned value (move semantics)
T[] // Slice (unsized)
T[N] // Fixed-size array
(T1, T2, T3) // Tuple — access fields with .0, .1, .2
func(T1) -> T2 // Function pointer type
Tuples
func Pair(a: int, b: int) -> (int, int) {
return (a, b);
}
func Main() -> int {
let t: (int, int) = Pair(10, 20);
PrintInt(t.0); // 10
PrintInt(t.1); // 20
return 0;
}
Function pointers and closures
func Apply(f: func(int) -> int, x: int) -> int {
return f(x);
}
func Double(n: int) -> int { return n * 2; }
func MakeAdder(base: int) -> func(int) -> int {
// Each call allocates its own capture environment
return |a: int| -> int { return a + base; };
}
func Main() -> int {
let g: func(int) -> int = Double; // named func → fat pointer
let a10 = MakeAdder(10);
let a20 = MakeAdder(20);
// a10 and a20 are independent instances
return Apply(g, 21) + a10(1) + a20(1); // 42 + 11 + 21
}
func(T) -> R values are fat pointers { code, env }:
- capturing closures store captures in a heap env
- capture-less closures and named functions use
env = null
Structs
struct Point {
x: int;
y: int;
}
Enums
enum Color {
Red,
Green,
Blue
}
// Algebraic enum (tagged union)
enum Result {
Ok(int),
Err(String)
}
Unions
union Bits {
asByte: uint8;
asInt: int32;
}
Variables
let x: int = 42; // Immutable
var y: int = 10; // Mutable
y = 20; // OK
const MAX: int = 100; // Compile-time constant
Functions
func Add(a: int, b: int) -> int {
return a + b;
}
// Extern C function
extern func printf(fmt: *char8, ...);
// Generic function
func Min<T>(a: T, b: T) -> T {
if a < b {
return a;
}
return b;
}
// Named and default parameters
func HttpResponse(code: int = 200, body: String = "") -> Response { ... }
let r: Response = HttpResponse(body: "hello"); // code defaults to 200
let s: Response = HttpResponse(404, body: "err"); // positional + named mixed
// Operator overloading (bootstrap only)
func Vec2_operator_add(self: *Vec2, other: Vec2) -> Vec2 { ... }
func Vec2_operator_sub(self: *Vec2, other: Vec2) -> Vec2 { ... }
func Vec2_operator_eq(self: *Vec2, other: Vec2) -> bool { ... }
func Vec2_operator_lt(self: *Vec2, other: Vec2) -> bool { ... }
func MyArray_operator_index_get(self: *MyArray, idx: int) -> int { ... }
func MyArray_operator_index_set(self: *MyArray, idx: int, value: int) { ... }
// Closures (capture-less for now)
let add: func(int, int) -> int = |a: int, b: int| -> int { return a + b; };
let sum: int = add(3, 4); // 7
// Closure passed to higher-order function
func Apply(x: int, op: func(int) -> int) -> int { return op(x); }
let doubled: int = Apply(5, |x: int| -> int { return x * 2; }); // 10
Control Flow
If / Else
if x > 0 {
PrintLine("positive");
} else if x < 0 {
PrintLine("negative");
} else {
PrintLine("zero");
}
Loops
while i < 10 {
i = i + 1;
}
do {
i = i + 1;
} while i < 10;
loop {
// Infinite loop
break;
}
for i in 0..10 {
// Range 0 to 9 (exclusive)
}
for i in 0..=10 {
// Range 0 to 10 (inclusive)
}
Break / Continue with Labels
outer: loop {
loop {
break outer;
}
}
defer
Runs an expression when the current scope exits (LIFO order).
func ReadFile(path: String) -> String {
let fd: int = Open(path);
defer Close(fd);
defer PrintLine("done");
let data: String = ReadAll(fd);
return data; // both defers run before return
}
switch / case
Desugars to an if-else chain. Supports a default case.
switch statusCode {
case 200: PrintLine("OK");
case 404: PrintLine("Not Found");
case 500: PrintLine("Server Error");
default: PrintLine("Unknown");
}
Structs
struct Rectangle {
width: int;
height: int;
}
func Main() -> int {
let rect: Rectangle = Rectangle { width: 10, height: 5 };
PrintInt(rect.width);
return 0;
}
Enums
Simple Enums
enum Color { Red, Green, Blue }
let c: Color = Color::Red;
if c == Color::Red {
PrintLine("red");
}
Algebraic Enums
enum Result {
Ok(int),
Err(String)
}
enum Pair {
Two(int, int), // multi-field → nested payload
One(int), // single-field → flat data.One_0
None
}
func Main() -> int {
let r: Result = Result { tag: Result_Ok };
r.data.Ok_0 = 42;
// Multi-field construction: data.Variant.Variant_i
var p: Pair = Pair { tag: Pair_Two };
p.data.Two.Two_0 = 3;
p.data.Two.Two_1 = 4;
if r.tag == Result_Ok {
PrintInt(r.data.Ok_0);
}
return 0;
}
Layout notes:
- Single positional field: flat union member
data.Variant_0 - Multi-field: nested payload
data.Variant.Variant_0/data.Variant.Variant_1(C typeEnum_Variant_Payload)
Pattern Matching
// Payload bindings: names in Variant(args) are bound in the arm body
func GetValue(opt: Option) -> int {
match opt {
Option::Some(value) => value,
Option::None => 0
}
}
match n {
0 => 100,
1..5 => 200,
6..=10 => 300,
_ => -1
}
Supported patterns:
- Wildcard:
_ - Literal:
42,"hello",true - Identifier catch-all:
name(binds whole subject) - Range:
1..9,1..=9 - Enum tags + payload bindings:
Option::Some(value),Pair::Two(a, b) - Nested:
Box::Val((a, b)),Shape::Dot(Point { x, y }) - Tuple patterns:
(a, b)→ bindssubject._0,subject._1 - Struct patterns:
Point { x: px, y: py }or shorthandPoint { x, y } - Guard patterns: parsed; full lowering still evolving
match pair {
(a, b) => a + b,
_ => 0
}
match p {
Point { x, y } => x * 10 + y,
_ => -1
}
match bx {
Box::Val((a, c)) => a + c,
Box::Empty => 0
}
match sh {
Shape::Dot(Point { x, y }) => x * 10 + y,
Shape::Empty => -1
}
// Multi-statement arm bodies (block expression; last expr is the value)
match n {
1 => {
let a: int = 10;
a + 1
},
_ => 0
}
// Block as expression
let r: int = {
let x: int = 5;
x + 6
};
Methods and Interfaces
struct Rectangle {
width: int;
height: int;
}
interface Drawable {
func Draw(self: Rectangle);
}
extend Rectangle for Drawable {
func Draw(self: Rectangle) {
PrintLine("Drawing rectangle");
}
}
// Or extend with standalone methods
extend Rectangle {
func Area(self: Rectangle) -> int {
return self.width * self.height;
}
}
Generics
Generic Functions
Generic functions are monomorphized at compile time. Type parameters can be specified explicitly or inferred from arguments:
func Max<T>(a: T, b: T) -> T {
if a > b { return a; }
return b;
}
func Main() -> int {
// Explicit type args
let m1: int = Max<int>(10, 20);
// Type inference — T inferred as int from arguments
let m2: int = Max(10, 20);
return 0;
}
Generic Structs
struct Box<T> {
value: T,
}
// Use extend Type<T> for methods on generic structs
extend Box<T> {
func Get(self: *Box<T>) -> T {
return self.value;
}
func Set(self: *Box<T>, value: T) {
self.value = value;
}
}
func Main() -> int {
let b: Box<int> = Box<int> { value: 42 };
PrintInt(b.Get()); // 42
b.Set(100);
PrintInt(b.Get()); // 100
return 0;
}
Note:
extend Type<T>syntax requires type parameters on the impl block. The compiler propagates them to each method automatically.
Gradual Ownership (Phase 8.2) ✅ Implemented
Bux has gradual ownership — opt-in borrow checking. Default is permissive (C-like). Turn safety on where it matters; turn it off on hot paths with zero cost.
Three tiers
| Mode | Attribute | Checks | Cost |
|---|---|---|---|
| Default | (none) | None | Zero — raw *T, free aliasing |
| Checked | @[Checked] |
Moves, exclusive &mut, shared/&mut conflicts, dangling returns, elision |
Compile-time only |
| Release | @[Release] |
Forced off (even if also @[Checked]) |
Zero — same codegen as default |
Story: write most code unchecked for speed of iteration; mark critical APIs
@[Checked]; mark micro-hotspots @[Release] (or both) when you need C-level
performance without false positives.
// Tier 1 — default: C-like, no borrow checker
func QuickSort(arr: *int, len: int) {
// free to alias, no move tracking
}
// Tier 2 — opt-in safety
@[Checked]
func Scale(val: &mut int) {
*val = *val * 2;
}
// Tier 3 — zero-cost escape (e.g. hot loop helper)
@[Release]
func HotInc(p: *int) {
*p = *p + 1; // no checks; same as default, documents intent
}
// Release wins over Checked when both are present
@[Checked]
@[Release]
func HotButDocumented(p: &mut int) {
*p = *p + 1; // no borrow checks
}
Reference types
| Type | Syntax | Description |
|---|---|---|
| Raw pointer | *T |
C-style pointer, no checks |
| Shared ref | &T |
Borrowed reference (read-only in checked functions) |
| Mutable ref | &mut T |
Exclusive mutable borrow (allows mutation) |
| Owned | own T |
Ownership type — values can be moved |
Move Semantics
own T values can be moved. After a move, the original variable is uninitialized and cannot be used until reassigned.
@[Checked]
func Process(data: own String) {
PrintLine(data);
// data is consumed here
}
@[Checked]
func Main() {
let msg: own String = "hello";
Process(msg); // move: msg is now uninitialized
// PrintLine(msg); // ERROR: use after move
msg = "reassigned"; // OK: reinitialization
PrintLine(msg);
}
Moves happen in three contexts:
- Function call argument:
Process(msg)movesmsginto the parameter - Assignment:
b = amovesaintob - Return:
return xmovesxout of the function
Rules in @[Checked] functions (not @[Release])
&Tcannot be used to mutate data (compile-time error)&mut Tallows mutation*Tpointers are unrestricted (escape hatch)&mut Tcoerces to&Tand*T- Double mutable borrow: two live
&mutof the same var (call args or let-bound)Swap(&mut x, &mut x); // ERROR let a: &mut int = &mut x; let b: &mut int = &mut x; // ERROR: exclusive mut already live - Use while mutably borrowed: assign/use of
xwhile a let-bound&mut xis live - Shared while mut: cannot form
&xwhile&mut xis live - Use after move: using a moved
own Tuntil reassigned - No dangling returns: cannot return a reference to a local
@[Checked] func Bad(p: &int) -> &int { var x: int = 1; return &x; // ERROR }
@[Release] (C.4 zero-cost path)
Use when a function must stay check-free:
- Documented hot path — same IR as unchecked, but the attribute states intent.
- Override Checked —
@[Checked] @[Release]on a method that would otherwise inherit team-wide Checked defaults.
There is no runtime cost: the attribute only disables the checker for that function body. Prefer @[Release] on the smallest possible surface; keep call boundaries @[Checked] when you still want API-level safety.
@[Checked]
func SafeApi(buf: &mut int) {
// checked here
HotPath(buf);
}
@[Release]
func HotPath(p: &mut int) {
// no move / borrow tracking — write like C
*p = *p + 1;
}
Lifetime elision (C.1)
In @[Checked] functions (and not @[Release]), most reference signatures need
no lifetime annotations. Elision applies the usual single-input rules:
- Each elided input
&T/&mut Tparameter gets a distinct lifetime. - If there is exactly one input lifetime, it is assigned to all elided outputs.
- If the first parameter is named
self/Self, that input lifetime is preferred for outputs. - Multiple input references + elided return → error (write an explicit lifetime).
// Elided — one input ref, return shares its lifetime
@[Checked]
func Identity(p: &int) -> &int {
return p; // OK
}
// Explicit — required when several inputs could be returned
@[Checked]
func Pick<'a>(a: &'a int, b: &'a int) -> &'a int {
return a;
}
// Syntax: &'a T and &mut / &'a mut T (lifetime before `mut`)
// Type parameters: func F<'a, T>(...)
Default and @[Release] functions ignore lifetime rules (C-like). Explicit 'a
is optional documentation when a single input would already elide correctly.
Drop and RAII
Bux uses static destructors (no GC): when a value goes out of scope, the
compiler may emit TypeName_Drop(&local). That is the RAII story — resources
are released at every exit path without manual defer on every return.
Declaring cleanup
Two equivalent ways to opt a type into auto-drop:
// 1) Attribute — compiler looks up TypeName_Drop
@[Drop]
struct Token {
id: int,
counter: *int,
}
func Token_Drop(self: *Token) {
// free / close / decrement …
}
// 2) Interface (stdlib `lib/Drop.bux`) — same static call, no vtable
import Drop;
extend Buffer for Drop {
func Drop(self: *Buffer) {
Mem_Free(self.data);
}
}
Stdlib collections implement Drop (Array_Drop, Map_Drop, …). Calling
Array_Drop is the same cleanup as Array_Free for Array<T>.
When auto-drop runs
Auto-drop is not gated on @[Checked]. Any function can receive injected
Type_Drop at:
| Exit | Behavior |
|---|---|
| End of block / function | Drop locals still owned |
Early return |
Drop all live locals after materializing the return value |
| Branch scope end | Only locals from the taken branch |
| Nested scopes | Drop in reverse order of declaration |
@[Drop]
struct Token { id: int, counter: *int }
func Token_Drop(self: *Token) { /* … */ }
func Early(flag: int, counter: *int) -> int {
let t: Token = Token { id: 1, counter: counter };
if flag == 0 {
return 0; // still runs Token_Drop(&t)
}
return 1; // Token_Drop(&t) here too
}
See examples/drop_early_return.bux for branch-local vs fallthrough counts.
Field-move: skip Drop of the source (critical)
Problem: a local is moved by value into a struct field (or another local).
If the compiler still auto-dropped the source, you get a double free — the
field and the original local would both run Array_Drop on the same buffer.
Rule: after a value move out of a local, that local is not dropped.
struct Box {
items: Array<int>;
}
func MakeBox() -> Box {
var items: Array<int> = Array_New<int>(4);
Array_Push<int>(&items, 10);
Array_Push<int>(&items, 20);
// Move `items` into the field — compiler skips Drop of `items`
let b: Box = Box { items: items };
return b; // also: return-by-value skips Drop of `b` (caller owns it)
}
What the C backend does for MakeBox (simplified):
Box MakeBox(void) {
Array_int items = Array_New_int(4);
Array_Push_int(&items, 10);
Array_Push_int(&items, 20);
Box b = (Box){ .items = items };
return b;
/* no Array_Drop_int(&items); — moved into b.items */
/* no Array_Drop on b; — moved to caller via return */
}
Ownership after MakeBox:
- Heap buffer lives inside
b.items(and later the caller'sBox). itemsis moved-out → skip auto-Drop.bis returned by value → skip auto-Drop at the return site; the caller (or the next owner) is responsible.
The same skip applies to:
- Struct field init —
S { field: local }(field-move) - Assignment —
a = bwhenbis moved (value types with Drop) - Call argument by value into a consuming parameter
return x— move-on-return
Live, unmoved Drop locals still clean up on error paths (e.g. early return
before the move). That is intentional: only the successful transfer path
skips Drop.
Runnable check: examples/move_field.bux (also covered by
make test-selfhost-smoke on buxc2).
Partial field moves
Moving a droppable field out of a local (return or let) also skips Drop
of the parent local:
@[Drop]
struct Bag {
items: Array<int>,
tag: int,
}
func Bag_Drop(self: *Bag) {
Array_Drop<int>(&self.items);
}
func TakeItems() -> Array<int> {
var items: Array<int> = Array_New<int>(4);
Array_Push<int>(&items, 42);
let bag: Bag = Bag { items: items, tag: 7 };
return bag.items; // Bag_Drop skipped — items ownership transferred
}
Rules:
- Applies only when the field type is droppable (
Array_*,@[Drop]types, etc.). Readingbag.tag(int) does not markbagmoved. - After
let moved = bag.items,Bag_Drop(&bag)is skipped;movedowns the array and is auto-dropped at scope end. - Avoid using other droppable fields of the parent after a partial move (they may be left in a moved-from state without per-field Drop).
Golden smoke: make test-drop-move / examples/move_field_partial.bux.
Manual Drop and non-Drop types
- Types without
@[Drop]/Dropimpl are never auto-dropped (plain C layout). - You can still call
Type_Drop(&x)or usedeferfor explicit cleanup. @[Release]/ default functions still get auto-drop for Drop types — Release only turns off the borrow checker, not RAII.
Limits (honest)
- Partial field moves mark the whole parent local as moved for Drop purposes (not per-field Drop of remaining fields).
- Nested
a.b.cpath moves and moving through pointers are limited. - Interface Drop uses a static
TypeName_Dropsymbol (zero cost), not dynamic dispatch through a vtable. - Double-free bugs in unchecked code that manually free and auto-drop are still possible if you free without invalidating the value — prefer one owner.
Compile-Time Function Execution (CTFE) ✅ Implemented
const func functions are evaluated at compile time. Their results can be used in type sizes, array lengths, or other constant contexts.
const func Factorial(n: int) -> int {
if n <= 1 {
return 1;
}
return n * Factorial(n - 1);
}
const TABLE_SIZE = Factorial(10); // 3628800 — computed at compile time
func Main() -> int {
let arr: [TABLE_SIZE]int; // Array size from compile-time value
return 0;
}
Supported in CTFE
- Integer, boolean, and string literals
- Arithmetic (
+,-,*,/,%) - Comparisons and logical operators
if/elsewith constant conditions- Calls to other
const funcfunctions (including recursion)
Limitations
- No
while/forloops (use recursion) - No
mutreferences or heap allocation - No non-const function calls
Error Handling
Result and Option Types
enum Result {
Ok(int),
Err(String)
}
enum Option {
Some(int),
None
}
The ? Operator
The ? operator automatically propagates errors:
func Divide(a: int, b: int) -> Result {
if b == 0 {
return Result_NewErr("division by zero");
}
return Result_NewOk(a / b);
}
func Compute() -> Result {
let x: int = Divide(10, 2)?; // If Err, returns immediately
let y: int = Divide(x, 5)?;
return Result_NewOk(y);
}
? can be used on Result and Option types in any expression context.
Modules and Imports
// Single import
import Std::Io::PrintLine;
// Multiple imports
import Std::Io::{PrintLine, PrintInt};
// Wildcard import
import Std::Io::*;
// Module declaration
module MyModule;
pub func PublicFunc() -> int {
return 42;
}
func PrivateFunc() -> int {
return 0;
}
Concurrency
Bux supports both async/await (stackful coroutines) and pthread-based threads with channels.
Threads and Channels
import Std::Task::{Task_Spawn, Task_Join, TaskHandle};
import Std::Channel::{Channel, Channel_New, Channel_SendInt, Channel_RecvInt, Channel_Close};
func Producer(ch: *Channel<int>) {
Channel_SendInt(ch, 42);
Channel_Close<int>(ch);
}
func Consumer(ch: *Channel<int>) -> int {
let val: int = Channel_RecvInt(ch);
return val;
}
func Main() -> int {
let ch: Channel<int> = Channel_New<int>(1);
let p: *void = spawn Producer(&ch);
let c: *void = spawn Consumer(&ch);
Task_Join(TaskHandle { handle: p });
Task_Join(TaskHandle { handle: c });
return 0;
}
spawn Func()creates a new pthread runningFuncChannel<T>is a buffered channel with mutex/condvarChannel_RecvIntreturns0when the channel is closed and empty
Async/Await
Bux supports stackful coroutines via async/await with a round-robin scheduler.
Declaring Async Functions
async func Compute() -> int {
PrintLine("step 1");
bux_async_yield();
PrintLine("step 2");
return 42;
}
Spawning Tasks
let handle = spawn Compute();
Awaiting Results
let result: int = handle.await as int;
Full Example
import Std::Io::{PrintLine, PrintInt};
async func Compute() -> int {
PrintLine("Compute: start");
bux_async_yield();
PrintLine("Compute: done");
return 42;
}
func Main() -> int {
let h = spawn Compute();
let r: int = h.await as int;
PrintInt(r);
return 0;
}
Runtime Functions
| Function | Description |
|---|---|
bux_async_yield() |
Yield control to the scheduler |
bux_async_spawn(fn) |
Create a new coroutine from a function |
bux_async_await(handle) |
Block until coroutine completes, return result |
bux_async_run() |
Run the scheduler (called implicitly from main) |
bux_async_sleep(ms) |
Sleep for ms milliseconds (non-blocking) |
bux_async_return(value, size) |
Copy return value into task result buffer |
Operator Overloading
Status: ✅ Implemented in bootstrap. Selfhost reserves syntax but has no method-table yet.
Overloadable operators use the naming convention TypeName_operator_<op>:
| Operator | Function Name | Signature Example |
|---|---|---|
+ |
operator_add |
func T_operator_add(self: *T, other: T) -> T |
- |
operator_sub |
func T_operator_sub(self: *T, other: T) -> T |
* |
operator_mul |
func T_operator_mul(self: *T, other: T) -> T |
/ |
operator_div |
func T_operator_div(self: *T, other: T) -> T |
% |
operator_mod |
func T_operator_mod(self: *T, other: T) -> T |
== |
operator_eq |
func T_operator_eq(self: *T, other: T) -> bool |
!= |
operator_ne |
func T_operator_ne(self: *T, other: T) -> bool |
< |
operator_lt |
func T_operator_lt(self: *T, other: T) -> bool |
<= |
operator_le |
func T_operator_le(self: *T, other: T) -> bool |
> |
operator_gt |
func T_operator_gt(self: *T, other: T) -> bool |
>= |
operator_ge |
func T_operator_ge(self: *T, other: T) -> bool |
[] (get) |
operator_index_get |
func T_operator_index_get(self: *T, idx: int) -> U |
[] (set) |
operator_index_set |
func T_operator_index_set(self: *T, idx: int, value: U) |
Notes:
- Short-circuit operators (
&&,||) cannot be overloaded. - Generic method instantiation is supported.
Operators
Arithmetic
+, -, *, /, %, ** (power)
Comparison
==, !=, <, <=, >, >=
Logical
&&, ||, !
Bitwise
&, |, ^, ~, <<, >>
Assignment
=, +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=
Other
as— Cast:expr as Typeis— Type test:expr is Type?— Try / error propagation:expr?&— Address-of:&var*— Dereference:*ptr::— Path separator:Module::Name..— Range (exclusive):0..10..=— Range (inclusive):0..=10sizeof— Size of type:sizeof(Type)
Macros
Bux supports declarative macros. Expansion runs after parse and before
type-checking. Expanded AST uses call-site source locations (quote hygiene).
Both bootstrap and selfhost (buxc2) expand macros.
Definition
macro! twice {
($x:expr) => {
($x) + ($x)
}
}
// Trailing repetition
macro! sum_n {
( $($x:expr),* ) => {
var acc: int = 0;
$( acc = acc + $x; )*
acc
}
}
// Compound / zip: parallel lists from interleaved args
macro! add_pairs {
( $($a:expr, $b:expr),* ) => {
var acc: int = 0;
$( acc = acc + ($a + $b); )*
acc
}
}
// Multi-rep groups: `;` separates arg groups at the call site
macro! sum_groups {
( $($x:expr),* ; $($y:expr),* ) => {
var s: int = 0;
$( s = s + $x; )*
$( s = s + $y; )*
s
}
}
// Nested template repetition (outer list → inner expands once per item)
macro! double_each_sum {
( $($x:expr),* ) => {
var t: int = 0;
$(
$( t = t + $x; )*
$( t = t + $x; )*
)*
t
}
}
// ident fragment: bare identifier at the call site
macro! call0 {
( $f:ident ) => {
$f()
}
}
// literal (alias: lit) — only int/float/string/char/bool literals
macro! only_lit {
( $x:literal ) => { $x }
}
// block — only `{ … }` block expressions
macro! wrap_block {
( $b:block ) => { $b }
}
// gensym: template locals renamed per expansion
macro! with_acc {
( $start:literal ) => {
var n: int = $start;
n = n + 1;
n
}
}
-
Introduced with the
macro!keyword. -
Each rule is
( pattern ) => { template }. -
Fragment kinds:
Kind Matches exprany expression identbare identifier ( ekIdent)tttoken-tree (MVP: same as expr)literal/litint/float/string/char/bool literal only blockblock expression { … } -
Fragment names start with
$(lexer$ident). -
Repetition:
$( $x:expr ),*/$( $x:expr )*— one or more rep fragments per pattern. -
Compound rep:
$( $a:expr, $b:expr ),*— interleaved args zip into parallel lists. -
Multi-rep: two (or more)
$(…)*in one pattern; call site uses;between groups:sum_groups!(1, 2; 10, 20, 30). -
Template
$( stmt; … )*expands once per list item (zip when multiple lists used). -
Nested
$( $(…)* )*: after outer binds list items as singles, inner expands once.
Invocation
let n = twice!(21);
let s = sum_n!(1, 2, 3); // 6
let z = sum_n!(); // 0
let p = add_pairs!(1, 10, 2, 20); // (1+10)+(2+20) = 33
let g = sum_groups!(1, 2; 10, 20, 30); // 63
let d = double_each_sum!(3, 4); // 14
call0!(SomeFunc);
let a = with_acc!(10); // 11
let b = with_acc!(20); // 21 — different gensym'd `n`
let c = only_lit!(7);
// only_lit!(1 + 2); // ERROR: no matching rule
let w = wrap_block!({ 1 + 2 }); // 3
- Syntax:
name!( arg, … )(not unwrap: unwrap isexpr!without(). - Matching: fixed-arity by count; kind constraints; rep by groups / remaining args / chunk.
Built-in quote!
let x = quote!(1 + 2); // identity expand; locations grafted to call site
Hygiene
Two layers (both bootstrap + selfhost):
-
Call-site graft — expanded AST uses the call site’s line/col/
sourceFile(so diagnostics and#linepoint at the user call, not the macro definition). -
Gensym of template binders — each expansion renames:
let/varlocals introduced by the templateforloop binders in the template- Nested scopes (if/while/for bodies, MacroRep bodies)
so two expansions of the same macro in one function do not collide under the C backend’s function-scoped locals (e.g.
__m1_nand__m2_n).
Spliced $frags in expression positions are not gensym’d — they keep
call-site names/values.
Unhygienic binders (var $name)
To introduce a binder whose name comes from the call site, use a $frag
as the binder itself. That name is not gensym’d:
macro! let_mut {
( $name:ident, $init:literal ) => {
var $name: int = $init; // unhygienic: becomes `counter`, not __m1_…
$name = $name + 1;
$name
}
}
// expands with local `counter` (and hygienic locals still unique)
let a = let_mut!(counter, 10); // 11
let b = let_mut!(other, 20); // 21
macro! double_acc {
( $start:literal ) => {
var acc: int = $start; // hygienic → __m1_acc / __m2_acc
acc = acc + acc;
acc
}
}
| Binder form | After expand | Gensym? |
|---|---|---|
var acc = … (plain name in template) |
__mN_acc |
yes |
var $name = … with $name:ident |
call-site ident | no |
for $i in … with $i:ident |
call-site ident | no |
The binder must be a :ident fragment bound to a bare identifier. A plain
template name is always hygienic.
Examples: examples/macro_hygiene.bux, examples/macro_unhygienic.bux.
Limits
- Up to two named rep lists per rule on selfhost (enough for zip + multi-rep).
- Compound chunk size currently 1 or 2.
- Nested macro calls expanded recursively (depth limit 32).
- Unhygienic binders only rename
let/var/forbinders — not full Scheme/Rust colored identifiers orstmt/pattoken trees. - Macro expansion still yields a block expression; unhygienic names are scoped to that block (not automatically injected into the caller scope).