Version: 1.0 (Comprehensive) Status: Living Document — Last updated: 2026-06-29
- Introduction
- Design Philosophy
- Lexical Structure
- Types
- Variables and Bindings
- Functions
- Control Flow
- Memory Management
- Object-Oriented Programming
- Generics
- Error Handling
- Compile-Time Execution
- Contract Programming
- Closures and Lambdas
- Properties and Operator Overloading
- Dynamic Dispatch
- Inline Assembly
- Aether OS Integration
- Multi-Target Assembler
- Universal Binaries
- Standard Library
- Build System
- Compiler Targets
- Future & Aspirational Features
- Concurrency and Fibers
- Module System and Imports
Aether is a systems programming language designed for building operating systems, kernels, bootloaders, and high-performance userland applications. It compiles directly to native code through NASM assembly — no LLVM, no interpreter, no runtime GC.
The language combines the readability of Python with the control of C and the safety of Rust, while adding unique features like compile-time execution, contract programming, and a multi-target assembler that can translate the same source to x86_64, ARM64, and RISC-V.
func main(): u64 {
print("Hello, Aether!")
return 0
}
# Compile for the host system
aether hello.ae -o hello
# Compile for Aether OS kernel
aether --target kernel kernel.ae -o kernel.elf
# Compile as a universal binary (x86_64 + ARM64)
aether --target universal hello.ae -o hello.ub
# Compile and run in one step
aether run hello.ae-
Readability first — Syntax should be immediately understandable. No sigils, no cryptic operators, no template metaprogramming.
-
Zero-cost abstractions — High-level features (classes, generics, exceptions) compile down to the same assembly you'd write by hand. You never pay for what you don't use.
-
Automatic memory management, no GC — Memory is managed at compile time through escape analysis, region inference, and deterministic scope-based deallocation. No garbage collector, no reference counting overhead.
-
No interpreter — Aether compiles directly to native code. There is no VM, no JIT, no bytecode. The compiler is written in Aether and eventually self-hosts.
-
OS-native from day one — The language is designed alongside the Aether OS. Every feature exists because the OS needs it: syscall tables, kernel modules, boot sectors, flat binaries.
-
Multi-architecture by default — The same source code can target x86_64, ARM64, and RISC-V. The multi-target assembler translates NASM instructions between architectures automatically.
- Compile-time
#runblocks execute code during compilation, enabling metaprogramming without a separate macro system - Contract programming with
pre()andpost()conditions checked in debug builds, eliminated in release - Multi-target assembler translates NASM assembly to ARM64 and RISC-V automatically
- Universal binaries contain native code for multiple architectures with a CPU detection trampoline
- Deterministic exceptions use tagged union returns — no unwinding tables, no personality functions
- Region-based allocation with
region { }blocks for O(1) arena teardown sys funcfor direct syscall page invocation in kernel spaceselfis implicit in methods — never write it in the parameter list- Properties inferred from return type — getter has return type, setter has no return type
// Line comment
/* Block comment */
/* Nested /* block */ comments */
/// Documentation comment — attached to the next declaration
/// These are used by the `aether doc` tool to generate API docs
Documentation comments (///) are attached to the declaration that follows them. They are collected by the documentation generator (aether doc). Unlike regular comments, doc comments are associated with specific declarations (functions, types, constants, modules) and can include markdown formatting.
Identifiers start with a letter or underscore, followed by letters, digits, or underscores:
my_variable // valid
_private_val // valid
camelCase // valid
PascalCase // valid
2bad // invalid
and as bool break byte
catch char class const continue
copy defer double dyn elif else
enum export extern false float
for func heap i8 i16
i32 i64 if impl import
in init int internal let
module none not or
pool post pre private protocol
public region
return self static string struct
super sys throw trait true
try type u8 u16 u32
u64 u128 unsafe var void
while yield
Note:
int,float,double, andcharare type aliases (see §4.3).vardeclares a mutable binding.letdeclares an immutable binding.copyforces pass-by-value copy.voidis a zero-sized type used for functions with no return value.noneis the null value for optional types.trueandfalseare boolean literals.selfis the implicit method receiver.initanddropare special method names for constructors and destructors. All objects are reference types with automatic memory management.
Arithmetic: + - * / %
Comparison: == != < > <= >=
Logical: && || ! and or not
Bitwise: & | ^ << >>
Assignment: = += -= *= /= %= &= |= ^= <<= >>=
Member: . ->
Scope: ::
Arrow: ->
Range: .. ..=
Array length: # (prefix operator, e.g. #arrayName)
Pattern: | ..=
Array length
#: The#prefix operator returns the length of an array (the first 8 bytes of the array's header). At codegen time, it reads[array_ptr]to get the stored count. Supported for array literals, array-typed variables, and dynamic arrays.
Short-circuit evaluation: The logical operators &&, ||, and, and or use short-circuit evaluation. If the left operand determines the result, the right operand is not evaluated:
let a = false and expensive() // expensive() never called
let b = true or expensive() // expensive() never called
This is guaranteed behavior — the compiler never evaluates the right side when the left side is sufficient. This applies to both symbolic (&&, ||) and keyword (and, or) forms.
Compound assignment operators (+=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=): These operators modify a variable in place. The codegen evaluates the left operand to get the current value, applies the operation with the right operand, and stores the result back. Supported for all numeric and bitwise operation combinations.
Prefix/postfix increment and decrement (++x, x++, --x, x--): Both prefix and postfix forms are supported. The parser creates UNARY_INC and UNARY_DEC nodes, and codegen emits inc/dec instructions. Prefix form returns the incremented value; postfix form returns the original value.
var x: u64 = 5
x += 3 // x = 8 (compound add)
x *= 2 // x = 16 (compound mul)
++x // x = 17 (prefix increment)
let y = x++ // y = 17, x = 18 (postfix increment)
Bitwise operators (&, |, ^, ~, <<, >>): Full bitwise operations are supported in the parser and codegen. The ~ prefix operator creates UNARY_BIT_NOT nodes. All operations are evaluated at compile time when operands are constants.
let flags: u64 = 0b1010
let mask: u64 = 0b1100
let and = flags & mask // 0b1000
let or = flags | mask // 0b1110
let xor = flags ^ mask // 0b0110
let not = ~flags // bitwise NOT
let shl = flags << 2 // 0b101000
let shr = flags >> 1 // 0b0101
Logical keyword operators (and, or, not): These keyword operators are synonyms for &&, ||, and !. They are treated identically in the parser and generated the same code. The tokenizer emits TOKEN_KW_AND, TOKEN_KW_OR, TOKEN_KW_NOT which the parser maps to BIN_AND, BIN_OR, and UNARY_NOT.
let a = true and false // false (same as &&)
let b = true or false // true (same as ||)
let c = not true // false (same as !)
Range expressions (.. and ..=): Range expressions are used in for loops and match range patterns. a..b creates a half-open range [a..b), while a..=b creates an inclusive range [a..b]. The parser creates BIN_RANGE and BIN_RANGE_INCLUSIVE nodes.
for i in 0..10 { } // 0 through 9
for i in 0..=10 { } // 0 through 10
match x { case 1..9 -> ... } // range pattern
Operators are evaluated in the following order, from highest to lowest precedence:
| Level | Operators | Associativity | Description |
|---|---|---|---|
| 1 (highest) | () [] . :: -> |
Left-to-right | Call, index, member access, scope, arrow |
| 2 | # ~ ! not - (unary) ++ -- (prefix) |
Right-to-left | Prefix operators |
| 3 | * / % |
Left-to-right | Multiplicative |
| 4 | + - |
Left-to-right | Additive |
| 5 | << >> |
Left-to-right | Bitwise shift |
| 6 | & |
Left-to-right | Bitwise AND |
| 7 | ^ |
Left-to-right | Bitwise XOR |
| 8 | | |
Left-to-right | Bitwise OR |
| 9 | .. ..= |
Left-to-right | Range |
| 10 | == != < > <= >= |
Left-to-right | Comparison |
| 11 | && and |
Left-to-right | Logical AND |
| 12 | || or |
Left-to-right | Logical OR |
| 13 | = += -= *= /= %= &= |= ^= <<= >>= |
Right-to-left | Assignment |
| 14 (lowest) | -> (arrow) |
Right-to-left | Expression body |
Notes:
- Postfix
++and--have the same precedence as level 1 (evaluated before prefix operators). - The
notkeyword operator has the same precedence as!. - The
and/orkeyword operators have the same precedence as&&/||. - Assignment operators are right-associative:
a = b = cparses asa = (b = c).
String indexing: Strings can be indexed with [] to access individual bytes. When s[i] is used on a string-typed expression, the codegen returns a single byte (u8) at the given position.
let s = \"hello\"
let b = s[0] // 'h' (byte 0x68)
String Concatenation with +: The + operator is overloaded at the language level. When either operand is a string, + performs string concatenation instead of numeric addition. This is detected at codegen time by the is_string_expr() helper.
let s = "hello " + "world" // "hello world" (concat)
let s = "value: " + 42 // "value: 42" (auto-converted via __aether_itoa)
let s = 42 + " is the answer" // "42 is the answer" (auto-converted)
let n = 42 + 1 // 43 (numeric addition — both operands are numbers)
The is_string_expr() helper detects:
- String literals (
NODE_LITERAL_STRING) BIN_CONCATchains (interpolation results)- Typed identifiers with
PRIM_STRINGtype - Untyped identifiers whose initializer is a string expression
When string concat is detected, the codegen:
- Checks if either operand is numeric and auto-converts via
__aether_itoa - Pushes both operands and calls
__aether_concat - Returns the new string pointer in
rax
// Integer literals
42 // decimal
0xFF // hexadecimal
0o77 // octal
0b1010 // binary
1_000_000 // digit separators (underscores ignored)
// Float literals
3.14
1.0e-5
1_000.5 // digit separators in floats
// String literals
"hello, world"
"escaped: \n \t \\ \" \x41"
// String interpolation — expressions in {} are evaluated and converted to strings
let name = "World"
let msg = "Hello {name}!" // "Hello World!"
let n: u64 = 42
let s = "count = {n}" // "count = 42" (auto-converted via __aether_itoa)
let sum = "total: {x + y}" // expressions work too
// Escaping braces in string interpolation
let s = "literal brace: \{not_interpolated\}" // \{ and \} produce literal { and }
// Multi-line strings
// Use string concatenation or interpolation instead.
// """ ... """ syntax is reserved for future implementation.
// Character literals
'a'
'\n'
'\x41' // hex escape in char
// Boolean literals
true
false
// None literal
none
Digit separators: Underscores (
_) in numeric literals are ignored by the compiler. They exist only for readability:1_000_000,0xFF_FF_FF_FF,0b1010_0101. Multiple underscores are allowed but not between the prefix (0x,0b,0o) and the first digit.
String interpolation is a first-class language feature, not a library function. When the parser encounters {expr} inside a string literal, it:
- Splits the string at the
{boundary - Parses the expression between
{and}as a sub-expression - Builds a left-associative chain of
BIN_CONCAToperations - At codegen time, each
BIN_CONCATemits acall __aether_concat
Numeric-to-string auto-conversion: If an interpolated expression evaluates to a numeric type (u8-u64, i8-i64, bool, byte), the codegen automatically inserts a call __aether_itoa before the concat. This is detected by the is_numeric_expr() helper which checks:
- Literal types (
NODE_LITERAL_INT,NODE_LITERAL_FLOAT,NODE_LITERAL_BOOL,NODE_LITERAL_CHAR) - Binary ops that produce numeric results (add, sub, mul, div, bitwise, shifts)
- Unary ops that produce numeric results (neg, bit_not, inc, dec)
- Typed identifiers (resolved to declarations with primitive numeric types)
let name = "World"
let msg = "Hello {name}!" # BIN_CONCAT("Hello ", BIN_CONCAT(name, "!"))
let n: u64 = 42
let s = "value: {n}" # BIN_CONCAT("value: ", __aether_itoa(n))
let x: u64 = 40
let y: u64 = 2
let sum = "{x + y}" # BIN_CONCAT("", __aether_itoa(x + y))
Runtime: The __aether_concat(left: u64, right: u64) -> u64 function:
- Computes strlen of both strings
- Allocates a buffer via
__aether_alloc(bump allocator) - Copies both strings into the buffer
- Returns the new string pointer
The __aether_itoa(rdi: u64) -> u64 function:
- Allocates 21 bytes (max 20 digits + null)
- Handles zero case (returns "0")
- Writes digits from end backwards using repeated div by 10
- Shifts the result to the start of the buffer
- Returns the string pointer
Note: __aether_itoa clobbers rcx (uses div rcx), so callers must save rcx before calling it.
Aether uses significant indentation (4 spaces) for block structure, similar to Python:
func example(): u64 {
if condition {
do_something()
} else {
do_other()
}
}
Braces are optional for single-statement bodies. When a control flow construct (if, elif, else, while, for, defer, try, catch, match case) has only a single statement in its body, the braces can be omitted:
if a == b return a
if a == b return a else return b
while x < 10 x = x + 1
for i in 0..10 print(i)
defer cleanup()
try risky() catch e print(e)
This also works with braces on one line:
if a == b { return a }
while x < 10 { x = x + 1 }
Aether provides a full set of primitive types. All type names are lowercase keywords:
| Type | Description | Size |
|---|---|---|
void |
Zero-sized type (no value) | 0 bytes |
bool |
Boolean (true/false) | 1 byte |
byte |
Raw byte (u8 alias) | 1 byte |
u8 |
Unsigned 8-bit integer | 1 byte |
u16 |
Unsigned 16-bit integer | 2 bytes |
u32 |
Unsigned 32-bit integer | 4 bytes |
u64 |
Unsigned 64-bit integer | 8 bytes |
i8 |
Signed 8-bit integer | 1 byte |
i16 |
Signed 16-bit integer | 2 bytes |
i32 |
Signed 32-bit integer | 4 bytes |
i64 |
Signed 64-bit integer | 8 bytes |
f32 |
IEEE 754 32-bit float (always signed) | 4 bytes |
f64 |
IEEE 754 64-bit double (always signed) | 8 bytes |
string |
Null-terminated string (pointer + length header) | 8 bytes (pointer) |
Notes:
- All integer types (u8-u64, i8-i64) support arithmetic, bitwise, and comparison operations.
- Signed integers (i8-i64) use two's complement representation.
- Floating point types (f32, f64) follow IEEE 754. There are no "unsigned" float variants — all floats have a sign bit.
boolvalues are 0 (false) or 1 (true).byteis an alias foru8.- There is no implicit
inttype — all types must be explicit in declarations.
Integer overflow semantics:
| Operation | Debug mode | Release mode |
|---|---|---|
+ - * (signed) |
||
+ - * (unsigned) |
||
/ % (division by zero) |
💥 Runtime panic | 💥 Runtime panic |
<< >> (shift >= bit width) |
Note: Aether uses wrapping arithmetic by default (like C). There is no implicit overflow check. If you need checked arithmetic, use the standard library functions (
checked_add,checked_mul, etc.) or implement your own. Division by zero always panics regardless of build mode.
let a: u8 = 255 // unsigned 8-bit
let b: u16 = 65535 // unsigned 16-bit
let c: u32 = 4294967295 // unsigned 32-bit
let d: u64 = 18446744073709551615 // unsigned 64-bit
let e: bool = true // boolean
let f: byte = 0xFF // raw byte
let g: i8 = -128 // signed 8-bit
let h: i16 = -32768 // signed 16-bit
let i: i32 = -2147483648 // signed 32-bit
let j: i64 = -9223372036854775808 // signed 64-bit
let k: f32 = 3.14 // 32-bit float
let l: f64 = 3.141592653589793 // 64-bit float
// Arrays
let arr: [u64; 4] = [1, 2, 3, 4]
let dyn_arr: [u64] // dynamic array (slice)
// Pointers are not supported — use reference types instead
let r: u64 = &value
let null_ref: u64 = none
// References (preferred over pointers)
let r: u64 = &value
var ref_r: u64 = &value
// Reference-counted
let shared: SharedData = new_ref(data)
// Optionals
let opt: u64? = some(42)
let none_val: u64? = none
// Function types
let handler: func(u64, string): bool // function pointer type
let callback: func(): void // void-returning function
// Tuple types
let pair: (u64, string) // anonymous tuple
let triple: (i32, f64, bool) // 3-element tuple
Function type syntax: func(ParamType1, ParamType2, ...): ReturnType. The parameter list is comma-separated. The return type follows the colon. For void functions, the return type is omitted or void.
Tuple type syntax: (Type1, Type2, ...). Tuples are anonymous structs. They can be destructured:
let pair = (42, "hello")
let (num, str) = pair // destructuring
Type casting with as: The as keyword performs explicit type conversion between compatible types:
let x: u64 = 42
let y: u8 = x as u8 // truncates to 8 bits
let z: f64 = 3.14
let w: f32 = z as f32 // float truncation
let b: byte = 0x41
let c: char = b as char // byte to char (same type)
Note:
ascasts are unchecked — they truncate or reinterpret bits without runtime checks. For checked conversions, use the constructor pattern:u8(x),f32(z), etc. which may perform bounds checking in debug mode.
Aether provides common type aliases for convenience. These are defined in the standard library and are just shorthand for the underlying explicit types:
| Alias | Underlying Type | Description |
|---|---|---|
int |
i64 |
Default signed integer |
float |
f32 |
Default floating-point |
double |
f64 |
Double-precision float |
char |
byte |
Single character (byte) |
Note: These are aliases, not distinct types.
intandi64are the same type. Use the explicit types (u64,i32,f64, etc.) when you need a specific size — aliases are for convenience in general-purpose code.
type Result = u64
type ErrorCode = i32
type Callback = func(u64): bool
struct Point {
x: u64
y: u64
}
let p = Point { x: 10, y: 20 }
let x = p.x
Enumerations are tagged unions — a type that can hold one of several named variants. Each variant can optionally carry payload data. Enum construction uses the :: (scope resolution) syntax.
enum Error {
NotFound
PermissionDenied
InvalidInput(string)
IoError(u64, string)
}
let err = Error::NotFound
let custom = Error::InvalidInput("bad data")
Enum construction with ::: The EnumName::Variant(args) syntax is handled by the parser. When the codegen encounters a :: expression where the left side is an enum type and the right side is a variant name, it creates the enum discriminant (data pointer or integer tag) in rax. For variants with payload, the payload is stored inline in the struct layout.
Key rules:
- Variants without payload are unit-like (like
NotFound,PermissionDenied) - Variants with payload carry data inline (
InvalidInput(string),IoError(u64, string)) - Match expressions can destructure enum variants and bind payload fields
- Enums are stored as a discriminant + payload (sum type representation)
Pattern matching on enums:
match err {
Error::NotFound -> print("not found")
Error::PermissionDenied -> print("denied")
Error::InvalidInput(msg) -> print("invalid: {msg}")
Error::IoError(code, msg) -> print("io error {code}: {msg}")
}
Classes are syntactic sugar over structs + associated functions. The compiler tracks constructors (init) and destructors (drop) and inserts calls automatically. Classes differ from structs in that they support init/drop and automatic memory management.
class Counter {
value: u64
func new(): Counter {
return Counter { value: 0 }
}
func increment() {
self.value += 1
}
func get(): u64 {
return self.value
}
}
Key rules:
initis the constructor name — called automatically on variable declarationdropis the destructor name — called automatically at scope exit- Methods defined inside the class body get implicit
self: Typeas first parameter - Fields are initialized in declaration order
- Classes are emitted as C structs with companion functions
Constructors: You can define multiple init methods with different parameters. The compiler selects the matching one based on call-site arguments.
Destructors: The drop method is called in LIFO order at scope exit. You don't need to write explicit cleanup code.
Traits define shared behavior — a contract that types can implement. They are Aether's primary mechanism for polymorphism and code organization.
trait Drawable {
func draw()
func area(): f64
}
impl Drawable for Circle {
func draw() {
print("Drawing circle")
}
func area(): f64 {
return 3.14159 * self.radius * self.radius
}
}
Key characteristics of traits:
- Behavior-only: Traits define method signatures, never fields or data
- Static dispatch by default:
Traitcompiles to monomorphized code — zero overhead - Dynamic dispatch opt-in:
dyn Traituses fat pointers (vtable + data pointer) for runtime polymorphism - Generic type params: Traits can be parameterized (
trait Comparable<T>) for type-safe generic algorithms - Type constraints: Traits can constrain generic type params (
func min<T: Comparable>(a: T, b: T): T) - No data: Traits describe what a type can do, not what a type is
When to use traits:
- Defining interfaces that multiple types can implement
- Writing generic algorithms that work with any type satisfying a contract
- Organizing code into logical capability groups (Serializable, Comparable, Iterable)
- Enabling polymorphism with zero-cost static dispatch
- Constraining generic type parameters
Protocols define hardware-level I/O interfaces — they describe how to communicate with physical devices, ports, and buses. Unlike traits, protocols carry configuration data (pin assignments, port addresses, baud rates) and their methods contain inline assembly for direct hardware interaction.
protocol Serial {
port base = 0x3F8
speed = 115200
func putc(c: byte) {
asm { mov dx, port; mov al, c; out dx, al }
}
}
Key characteristics of protocols:
- Data + behavior: Protocols declare fields (pin numbers, port addresses, speeds) and methods
- Hardware-oriented: Methods contain inline assembly for register-level I/O
- Compile-time configuration: Field values are compile-time constants, enabling the compiler to generate optimized bit-banging sequences
- No polymorphism: Protocols are not implemented by types — they are standalone hardware interface descriptions
- Code generation target: The compiler can generate complete driver code from a protocol declaration
When to use protocols:
- Defining serial bus protocols (I2C, SPI, UART)
- Configuring hardware pin assignments and port addresses
- Writing low-level device drivers with inline assembly
- Describing memory-mapped I/O regions
- Generating optimized bit-banging code from declarative specs
| Aspect | Trait | Protocol |
|---|---|---|
| Purpose | Define shared behavior/interfaces | Define hardware I/O interfaces |
| Data | No fields — method signatures only | Fields (pins, ports, speeds, addresses) |
| Implementation | impl Trait for Type { ... } |
Standalone declaration with inline asm |
| Polymorphism | Static dispatch (default), dynamic (dyn) |
None — single hardware target |
| Generics | Generic type params supported | Not applicable |
| Codegen | Monomorphization or vtable dispatch | Compiler generates driver code |
| Use case | Application-level polymorphism | Hardware-level device drivers |
| Runtime cost | Zero (static) or one deref (dynamic) | Zero — all code is compile-time generated |
| Example | trait Drawable { func draw() } |
protocol I2C { scl pin = 5; sda pin = 6 } |
Rule of thumb: If you're describing what a type can do, use a trait. If you're describing how to talk to hardware, use a protocol.
T? (optional type) represents a value that may be present or absent. It's syntactic sugar for a tagged union: some(T) | none. Optionals are the primary way to handle "no value" cases without null pointers.
let x: u64? = some(42)
let y: u64? = none
// Pattern matching with if let
if let val = x {
print("Got: {val}")
}
// Unwrap with default
let val = x or 0
Key rules:
T?is a type alias for the optional sum typesome(expr)wraps a value,nonerepresents absenceif let pattern = optional { }binds the inner value if presentoroperator provides a default:optional or default_value- Under the hood, optionals use a discriminant byte (0 = none, 1 = some) + payload
Matching on optionals:
match x {
some(val) -> print("has value: {val}")
none -> print("empty")
}
let declares an immutable binding. var declares a mutable binding. Types can be explicit or inferred from the initializer.
let x: u64 = 42 // immutable, explicit type
let y = 42 // immutable, type inferred (u64)
var z = 10 // mutable, type inferred
z = 20 // ok — mutability allowed
var a: u64 = 5 // mutable, explicit type
a = 15 // ok
const MAX = 100 // compile-time constant (must be a literal or const expression)
Note:
let(immutable) andvar(mutable) are the two variable declaration forms. All objects are reference types with automatic memory management. Usecopyto force pass-by-value when needed.
Key rules:
letbindings are immutable by default — usevarto allow reassignment- Type inference works for all expressions including function calls, arithmetic, and struct literals
constis evaluated at compile time and substituted inline — no runtime storage- Shadowing is allowed:
let x = 10; let x = x + 5;creates a new binding - Destructuring is supported:
let (a, b) = (1, 2); var (c, d) = (3, 4);
Destructuring example:
func point() -> (u64, u64) { return (1, 2) }
let (x, y) = point() // x = 1, y = 2
{
let inner = 5
// inner accessible here
}
// inner not accessible here
Variables are scoped to the block ({ ... }) they are declared in. Nested blocks can see outer variables; outer blocks cannot see inner variables.
let x = 10
let x = x + 5 // shadows previous x, now 15
Shadowing creates a new binding with the same name in the same or inner scope. The previous binding is hidden, not modified. This is different from reassignment — shadowing changes the binding, reassignment changes the value.
File-scope let declarations create global variables. They are initialized at program startup and live for the entire program lifetime:
let GLOBAL_CONFIG: string = "default"
var counter: u64 = 0
Global variables are stored in the .bss (zero-initialized) or .data (initialized) section. Mutable globals require unsafe to access from multiple functions, as the compiler cannot prove thread safety.
Aether supports recursive functions. The compiler does not perform tail-call optimization (TCO) yet:
func factorial(n: u64): u64 {
if n <= 1 { return 1 }
return n * factorial(n - 1)
}
Recursion depth is limited only by the available stack space. The default stack size is 2 MB on host targets and configurable on freestanding targets.
Loops can be labeled to allow break and continue to target an outer loop:
outer: for i in 0..10 {
for j in 0..10 {
if i * j > 50 { break outer } // breaks out of both loops
if j == 5 { continue outer } // continues the outer loop
}
}
Labels are identifiers followed by a colon (:). They can be applied to for, while, and loop constructs.
Functions are declared with the func keyword. Parameters are written as name: type pairs. The return type follows the parameter list after :. The body is a block or an expression (for expression-bodied functions).
// Basic function
func add(a: u64, b: u64): u64 {
return a + b
}
// Expression-bodied function
func add(a: u64, b: u64): u64 -> a + b
// Void return
func greet(name: string) {
print("Hello, {name}")
}
// Multiple return values via tuple
func divide(a: u64, b: u64): (u64, u64) {
return (a / b, a % b)
}
Key rules:
- Parameters are passed by reference (all objects are reference types)
- The last expression in a block is implicitly returned if no explicit
return returnis optional in expression-bodied functions (-> expr)- Void functions implicitly return
0at the end (viaxor rax, raxin assembly)
Attributes modify function behavior at compile time. They are written as @name or @name(args) before the func keyword.
// Inline hint
@force_inline func fast_path(): u64 { return 42 }
// Force inline
@force_inline func always_inline(): u64 { return 42 }
// Prevent inlining
@no_inline func debug_trace() { ... }
// Export for module loader
@export func module_init() { ... }
// Entry point at specific address
@entry(0x100000) func kernel_main() { ... }
// Test fixture attribute — marks a function as a test with expected exit code
@test(expect=0) func test_addition(): u64 {
assert(1 + 1 == 2)
return 0
}
// Module ABI version declaration
@module_abi(version=1) module my_module {
// module body
}
Standard attributes:
| Attribute | Description |
|---|---|
@force_inline |
Force the compiler to inline this function |
@no_inline |
Prevent inlining of this function |
@export |
Export function for module loader visibility |
@entry(addr) |
Set binary entry point at specific address |
@test(expect=N) |
Mark function as test fixture; binary exits with N on success |
@module_abi(version=N) |
Declare ABI version for module declarations |
@layout(start=N, max=M, file="name") |
Flat binary layout: start address, max size, output file |
@kernel_layout |
Enable kernel memory map overlap verification |
func create_window(title: string, width: u64 = 800, height: u64 = 600) {
// ...
}
create_window("Hello") // uses defaults
create_window("Wide", 1024, 768) // explicit
Default parameters are evaluated at the call site. They must be trailing parameters (all parameters after a default must also have defaults).
Variadic parameters accept zero or more arguments of the given type. The variadic parameter must be the last parameter in the function signature.
func sum(values: ...u64): u64 {
let total = 0
for v in values {
total += v
}
return total
}
let s = sum(1, 2, 3, 4, 5) // s = 15
let empty = sum() // empty call — returns 0
Inside the function, the variadic parameter behaves like an array. Iterating over it with for v in values works as expected. Calling with no arguments produces an empty array (length 0), so the loop body never executes.
extern func puts(s: string): int
extern func malloc(size: u64): u64
extern func free(ptr: u64)
Extern functions are declared without a body. They tell the compiler the function exists in another object file or shared library. The compiler emits an extern NASM directive and generates a call instruction with the correct ABI (SysV for x86_64).
Calling convention: Aether uses the System V AMD64 ABI for extern calls:
- Integer/pointer arguments:
rdi,rsi,rdx,rcx,r8,r9 - Floating-point arguments:
xmm0–xmm7 - Return value:
rax(integer/pointer),xmm0(float/double) - Stack alignment: 16 bytes before
callinstruction
extern func printf(fmt: string, ...): int
func main(): u64 {
printf("Hello from C! %d\n", 42)
return 0
}
Note: Extern functions are unsafe by nature — the compiler cannot verify the types or memory safety of external code. Use
unsafeblocks when calling extern functions that involve pointer manipulation.
The if statement evaluates a condition and executes the corresponding block. It can also be used as an expression.
if x > 0 {
print("positive")
} elif x < 0 {
print("negative")
} else {
print("zero")
}
// If as expression
let status = if x > 0 { "positive" } else { "non-positive" }
Key rules:
elifandelseare optional- As an expression, all branches must evaluate to the same type
- The condition must be a boolean expression
- Blocks are optional for single statements — if the body is a single statement, braces can be omitted:
if a == b return a if a == b return a else return b if a == b { return a } // braces on one line - If-let pattern matching still requires braces
If-let pattern matching:
if let some(val) = optional_value {
// val is bound here
print("Got: {val}")
}
while condition {
do_work()
}
// Infinite loop
while true {
process()
}
The while loop evaluates the condition before each iteration. If the condition is false initially, the body never executes.
Braces are optional for single statements:
while x < 10 x = x + 1
while x < 10 { x = x + 1 } // one line with braces
Aether supports three forms of for loops:
Range loops:
// Range loop (half-open: 0..10 means 0 through 9)
for i in 0..10 {
print(i)
}
// Inclusive range (0..=10 means 0 through 10)
for i in 0..=10 {
print(i)
}
Array iteration:
let arr = [1, 2, 3, 4, 5]
for val in arr {
print(val)
}
With index:
for i, val in arr {
print("arr[{i}] = {val}")
}
Key rules:
- Range expressions
a..banda..=bare lazy — they don't allocate an array - Iterating over an array yields elements by value (copy)
for i, valyields the index (u64) and the element value- The loop variable is scoped to the loop body
- Braces are optional for single statements:
for i in 0..10 print(i) for i in 0..10 { print(i) } // one line with braces
for i in 0..100 {
if i == 50 { break }
if i % 2 == 0 { continue }
print(i) // odd numbers 1..49
}
breakexits the innermost loopcontinueskips to the next iteration of the innermost loop- Labels can target outer loops:
break outer,continue outer
match is both a statement and an expression. It compares a value against a series of patterns and executes the first matching arm.
match value {
case 0 -> print("zero")
case _ -> print("default")
}
// Match as expression
let description = match value {
case 0 -> "zero"
case 1..9 -> "small"
case _ -> "large"
}
Pattern types:
- Literal patterns:
case 0,case "hello" - Range patterns:
case 1..9,case 1..=9 - Wildcard:
case _(matches anything, must be last) - Enum destructuring:
case Error::InvalidInput(msg)
Key rules:
- Patterns are evaluated top-to-bottom; first match wins
- The wildcard
_must be the last arm - As an expression, all arms must evaluate to the same type
- Match expressions can be used in assignments, returns, etc.
func process_file(path: string) {
let f = File::open(path)
defer { f.close() }
// f.close() is called when this scope exits,
// regardless of how it exits (return, error, break)
f.write(data)
}
defer schedules a block to run when the current scope exits — whether by normal return, error throw, break, or continue. This is the primary mechanism for resource cleanup.
Defer ordering: Deferred calls are executed in last-in-first-out (LIFO) order — the most recently deferred block runs first:
func example() {
defer { print("first") }
defer { print("second") }
// Output: second, first
}
Braces are optional for single statements:
defer cleanup()
defer { cleanup() } // equivalent with braces
Defer in try/catch: Deferred blocks inside a try body execute before the catch handler runs. This ensures resources are cleaned up before error handling:
try {
let f = File::open("/etc/config")
defer { f.close() }
// If an error occurs, f.close() runs before the catch block
} catch _ {
print("error handled after cleanup")
}
Defer in loops: Deferred blocks inside a loop body execute at the end of each iteration, not when the loop exits:
for i in 0..3 {
defer { print("cleaning up iteration") }
// defer runs at end of each iteration
}
// defer runs 3 times, once per iteration
This is the heart of the language and what makes it a true 4GL — you describe allocation semantics; the compiler generates the exact free/teardown code.
This is the heart of the language and what makes it a true 4GL — you describe allocation semantics; the compiler generates the exact free/teardown code.
All local variables are stack-allocated by default. The compiler tracks lifetimes and generates destruction at the end of scope.
func process() {
let p = Point(3, 4) // stack allocated
let items = [1, 2, 3] // fixed-size array, stack
// compiler inserts destructor calls at scope exit
}
Key rules:
- All
letbindings withoutheaporregionare stack-allocated - The compiler tracks the lifetime of each variable and emits cleanup at scope exit
- For types with
dropmethods (classes), the destructor is called in LIFO order - Stack allocation is zero-cost — just a stack pointer bump
When a reference to a stack variable could outlive its scope, the compiler automatically promotes it to heap allocation. No programmer annotation needed for simple cases.
func make_point(x: int, y: int): Point {
let p = Point(x, y)
// compiler detects: p's reference escapes this frame
// auto-promotes p to heap
return p
}
Escape analysis is conservative — if the compiler cannot prove a reference doesn't escape, it promotes to heap. This is sound but may over-allocate.
let big = heap Buffer(1024 * 1024)
let shared = heap SharedState()
The heap keyword forces heap allocation even if escape analysis would stack-allocate. Use it for:
- Large objects that would overflow the stack
- Objects that need to outlive the current function
- When you need explicit control over lifetime
Aether uses automatic memory management with escape analysis and region inference. All objects are reference types — the compiler infers whether a binding is borrowed or shared and inserts reference count operations automatically.
func consume(val: Buffer) { // borrow, compiler infers non-owning
print(val.size())
} // nothing freed, borrow ends
func share(val: Buffer) { // shared, compiler infers shared ownership
let clone = val // compiler inserts refcount increment
} // compiler inserts refcount decrement, frees if zero
Force a copy: Use copy to force pass-by-value when the compiler would otherwise infer a borrow:
func process(data: copy Data) // explicit copy on call
let x = copy original // explicit copy at binding
Key rules:
- All objects are reference types — the compiler manages lifetimes automatically
- The compiler infers borrowing vs. shared ownership through escape analysis
- Use
copyto force a pass-by-value copy when needed - Region inference groups objects that share the same lifetime
- The compiler enforces ownership at compile time where possible
For kernel modules and performance-critical code, the language supports region-based allocation that maps directly to the Aether OS capability model.
region("kernel") {
let p = Page() // allocated from kernel region
let buf = Buffer(256)
} // entire region freed at once — O(1) teardown
Regions are named allocation arenas. All allocations inside a region { } block come from that region's arena. When the block exits, the entire arena is freed in one operation — O(1) regardless of how many objects were allocated.
Use cases:
- Kernel memory pools
- Request-scoped allocations in servers
- Temporary working sets that can be bulk-freed
The type system has no null. Use T? (optional) instead.
let x: u64? = some(42)
let y: u64? = none
Why no null:
- Null pointers are the "billion dollar mistake" — they require runtime checks
T?makes absence explicit in the type system- The compiler can prove optional handling is complete (exhaustive match)
- No silent crashes from dereferencing null
Optional patterns:
// If-let
if let val = optional { ... }
// Match
match opt {
some(v) -> ...
none -> ...
}
// Default with `or`
let val = opt or default_value
### 8.7 Unsafe Blocks
`unsafe` blocks opt into operations the compiler cannot prove safe:
**What is allowed inside `unsafe` blocks:**
- Calling functions marked `unsafe`
- Inline assembly (`asm { }`)
- Direct memory manipulation
- Accessing or modifying mutable statics
**What is NOT allowed inside `unsafe` blocks:**
- Creating references with invalid lifetimes (the borrow checker still applies to `ref` types)
- Calling non-`unsafe` functions with invalid arguments (type checking still applies)
> **Safety note:** `unsafe` does not disable the compiler. Type checking, borrow checking, and lifetime analysis still apply. `unsafe` only enables operations that the compiler cannot prove safe: inline assembly, FFI calls, and direct memory access.
### 8.8 Automatic Destructor Insertion
The compiler tracks every class instance and inserts destructor calls at every scope exit point — normal return, early return, exception propagation, loop break/continue.
```aether
func read_config(): throws string {
let f = File("/etc/aether.cfg") // constructor called
let content = f.read_all() // use the file
// compiler inserts: f.drop() — even if f.read_all() threw
return content
}
Classes are syntactic sugar over structs + associated functions. The compiler tracks constructors (init) and destructors (drop) and inserts calls automatically.
class File {
fd: int
path: string
func init( path: string) throws {
self.fd = sys_open(path)
self.path = path
}
func drop() {
sys_close(self.fd)
}
public func read( buf: [u8]): int {
return sys_read(self.fd, buf)
}
public prop path(): string {
return self.path
}
}
self is never written in the parameter list. The parser auto-injects self: Type as the first parameter for methods defined inside struct/class bodies.
class Point {
x: int
y: int
// self is implicit — never write it in params
func distance(other: Point): float {
let dx = self.x - other.x
let dy = self.y - other.y
return sqrt(dx*dx + dy*dy)
}
}
Pure procedural code with flat structs and free functions is always valid:
struct Point { x: int; y: int }
func distance(p: Point): float {
return sqrt(p.x * p.x + p.y * p.y)
}
Traits define shared behavior. They compile to vtable pointers only when used dynamically; static dispatch is the default.
trait Serializable {
func serialize(): [u8]
}
impl Serializable for Point {
func serialize(): [u8] {
return to_bytes([self.x, self.y])
}
}
// Static dispatch (zero-cost)
func save_static(value: Serializable) {
let bytes = value.serialize()
}
// Dynamic dispatch (vtable)
func save_dynamic(value: dyn Serializable) {
let bytes = value.serialize()
}
class BankAccount {
public balance: u64 // accessible anywhere
private owner: string // accessible only within this class
internal pin: u64 // accessible within the same module
public func deposit(amount: u64) {
self.balance += amount
}
private func validate_pin(input: u64): bool {
return self.pin == input
}
}
Properties are methods that look like fields. The compiler infers getter/setter from the return type:
- Getter: has a return type →
obj.prop()calls the getter - Setter: has no return type →
obj.prop(value)calls the setter
Properties are defined as regular func methods inside struct/class bodies. The self parameter is auto-injected by the compiler — you never write it yourself.
struct Temperature {
celsius: f64
// Getter — has return type
func fahrenheit(): f64 {
return self.celsius * 9.0 / 5.0 + 32.0
}
// Setter — no return type
func fahrenheit(value: f64) {
self.celsius = (value - 32.0) * 5.0 / 9.0
}
}
let t: Temperature
t.celsius = 100
print(t.fahrenheit()) // calls getter → 212.0
t.fahrenheit(32) // calls setter → celsius = 0
Note:
selfis a reserved keyword referencing the enclosing object. It is never passed as a parameter — the compiler injects it automatically. You only needselfwhen a local variable or parameter name shadows a field name.
Operators can be overloaded by defining a function named op_ followed by the operator symbol. The op_ prefix is reserved — it can only be used for operator overloading. The symbol can be any sequence of characters (including unicode), making custom operators possible.
Operator overloads use explicit left/right parameters (C++ style):
struct Vector2 {
x: f64
y: f64
}
func op_+(a: Vector2, b: Vector2): Vector2 {
let result: Vector2
result.x = a.x + b.x
result.y = a.y + b.y
return result
}
func op_*(a: Vector2, scalar: f64): Vector2 {
let result: Vector2
result.x = a.x * scalar
result.y = a.y * scalar
return result
}
let a: Vector2
a.x = 1
a.y = 2
let b: Vector2
b.x = 3
b.y = 4
let c: Vector2 = a + b // calls op_+(a, b)
let d: Vector2 = a * 2.0 // calls op_*(a, 2.0)
Supported operator overloads:
| Operator | Function name | Type |
|---|---|---|
+ |
op_+ |
Binary |
- |
op_- |
Binary |
* |
op_* |
Binary |
/ |
op_/ |
Binary |
% |
op_% |
Binary |
== |
op_== |
Binary |
!= |
op_!= |
Binary |
< |
op_< |
Binary |
> |
op_> |
Binary |
<= |
op_<= |
Binary |
>= |
op_>= |
Binary |
& |
op_& |
Binary |
| |
op_| |
Binary |
^ |
op_^ |
Binary |
<< |
op_<< |
Binary |
>> |
op_>> |
Binary |
- (unary) |
op_- |
Unary |
! (unary) |
op_! |
Unary |
~ (unary) |
op_~ |
Unary |
Custom operators: Any unicode symbol can be used as a custom operator with infix syntax:
func op_⌛(a: int, b: int): int {
return a + b
}
let result = 1 ⌛ 2 // calls op_⌛(1, 2)
Generics are monomorphized (zero-cost). Type parameters use angle brackets.
func identity<T>(value: T): T {
return value
}
func swap<T>(a: T, b: T) {
let tmp = *a
*a = *b
*b = tmp
}
class Stack<T> {
data: [T]
size: int
public func push( item: T) {
self.data[self.size] = item
self.size += 1
}
public func pop(): T? {
if self.size == 0 { return none }
self.size -= 1
return self.data[self.size]
}
}
trait Comparable<T> {
func less_than( other: T): bool
}
impl<T> Comparable<T> for u64 {
func less_than( other: u64): bool {
return self < other
}
}
func min<T: Comparable>(a: T, b: T): T {
if a.less_than(b) { return a }
return b
}
Exceptions are deterministic — no unwinding tables, no personality routines, no runtime type lookups. The compiler encodes exceptions as tagged union returns (rax=value/discriminant, rdx=error tag). The happy path has zero overhead.
Every function that can throw (throws annotation) returns a 2-register result:
- rax: return value (or error discriminant on error)
- rdx: error tag (0 = success, 1 = error)
The compiler generates scope cleanup tables for every try block. Each cleanup table entry records what objects need destructor calls, what defer blocks need execution, and the scope nesting level.
throw expr
→ evaluate expr into rax (error discriminant)
→ set rdx = 1 (error tag)
→ walk the current function's cleanup table:
for each live scope (innermost first):
call destructors for all live objects in that scope
execute defer blocks in that scope
→ if inside a try block: jump to catch handler
→ if not inside a try block: return (rdx=1, rax=error)
func divide(a: u64, b: u64): u64 throws {
if b == 0 {
throw DivisionByZero()
}
return a / b
}
func compute() {
try {
let result = divide(10, 0)
print(result)
} catch DivisionByZero {
print("Can't divide by zero!")
} catch e IOException {
print("IO error: {e}")
} catch _ {
print("Unknown error caught")
} finally {
print("compute completed")
}
}
Codegen for try/catch:
; Clear error tag
xor rdx, rdx
; Emit try body with cleanup table tracking
[try body code]
; Check error tag
test rdx, rdx
jnz .catch_label
jmp .finally_label
.catch_label:
; Walk cleanup table for this try block
[cleanup code — destructors + defers]
; Match error discriminant against catch arms
push rax ; save error discriminant
[for each catch arm:
if type specified: compare rax against variant discriminant
if match: pop rax, bind error value to var, execute handler
if no match: check next arm]
[catch-all (_): always matches]
[no match: re-throw (rdx=1, rax=discriminant, return)]
.finally_label:
[finally body]
.end:
enum MathError {
DivisionByZero
Overflow
NegativeInput(i64)
}
func safe_sqrt(x: i64): i64 throws {
if x < 0 {
throw MathError::NegativeInput(x)
}
return sqrt(x)
}
A throws function that doesn't catch an error:
- Its own cleanup table is walked (destructors + defers for the function's scope)
- Returns with rdx=1, rax=error discriminant
- The caller checks rdx after the call:
- rdx=0: success, use rax as return value
- rdx=1: error, rax has discriminant, propagate or catch
func read_config(): throws Config {
let data = read_file("/etc/aether.cfg") // propagates errors
return parse_config(data)
}
func caller(): u64 throws {
let result = callee() // after call, check rdx
// if rdx=1, propagate (cleanup + return with rdx=1)
return result
}
try {
risky_operation()
} catch _ {
print("Something went wrong, but we don't care what")
}
try {
try {
inner_operation()
} catch InnerError {
print("inner failed")
}
} catch OuterError {
print("outer failed")
}
Aether transparently catches hardware faults (segmentation faults, bus errors, page faults) inside try blocks. No special syntax, no signal handlers to write — it just works.
try {
let null_ref: u64 = none
let val = null_ref # null reference dereference — caught!
} catch _ {
print("caught a segfault")
}
If a hardware fault occurs outside a try block, or if no catch arm matches, the program prints a stack trace with the exact crash location and exits:
=== AETHER HARDWARE FAULT ===
Signal: 11, Fault address: 0x0
Source: tests/fixtures/test_segfault.ae:8:12
The output shows:
- Signal — the type of fault (11 = SIGSEGV, 10 = SIGBUS)
- Fault address — the memory address that caused the fault (
0x0= null pointer) - Source — the exact file, line, and column where the crash happened
This works on all host targets (macOS, Linux) without any debug symbols or external tools. The compiler embeds a source map table directly into the binary.
For kernel/freestanding targets: The compiler generates IDT entries for page fault (interrupt 14) and general protection fault (interrupt 13). When a fault occurs inside a try block, the IDT handler unwinds to the catch handler. If no try block is active, the default fault handler prints a stack trace and halts.
Code inside #run { } executes at compile time. This enables metaprogramming without a separate macro system.
#run {
// This runs at compile time!
let result = compute_something()
emit("const TABLE_SIZE = {result}")
}
const TABLE_SIZE = 256
const MAX_CONNECTIONS = 1000
const BUFFER_SIZE = MAX_CONNECTIONS * 64
const GREETING = "Hello, World!"
#run {
// Iterate over all types in the current module
for type in Module.types() {
if type.has_trait(Serializable) {
emit("impl Serialize for {type.name} {{ ... }}")
}
}
}
Contract programming allows you to specify preconditions, postconditions, and struct/class contracts that the compiler checks at runtime (debug builds) or uses as optimization hints (release builds). Contracts are written inline between the function signature and the body.
pre(expr) declares a condition that must be true before a function executes. post(expr) declares a condition that must be true after a function returns. The old() function inside a post() condition refers to the value of an expression before the function executed.
func withdraw(account: Account, amount: u64)
pre(account.balance >= amount)
post(account.balance == old(account.balance) - amount)
{
account.balance -= amount
}
Key rules:
pre()andpost()are placed between the function signature and the opening{- Multiple conditions are allowed — each gets its own
pre()orpost()line old(expr)is only valid insidepost()conditions — it captures the value ofexprat function entry- In debug builds, violations trigger
assert()failures - In release builds, conditions are eliminated and used as optimizer hints
contract(expr) declares a condition that must hold before and after every method call on a struct or class instance. Unlike pre()/post() which are per-function, contracts are per-type — they define the type's internal consistency contract.
class Queue<T> {
data: [T]
head: u64
tail: u64
contract(self.size <= self.data.len)
func size(): u64 {
return (self.tail - self.head) % self.data.len
}
}
Key rules:
contract()is declared inside a struct/class body, alongside fields and methods- The condition is checked at every method entry and exit (in debug builds)
- Contracts are structural — they describe what makes an instance valid
- The compiler emits a
_check_contracts()function that callsassert()for each condition
contract vs assert — comparison:
| Aspect | assert(expr) |
contract(expr) |
|---|---|---|
| Scope | Inside a function body | Inside a struct/class definition |
| When checked | At that exact line of code | Before/after every method call on the type |
| Who guarantees | The function author | The type itself (all methods collectively) |
| Granularity | Point-in-time, ad-hoc | Structural, cross-method |
| Removed in release | Yes (NDEBUG) | Yes, but could be kept for safety |
| Use case | "This should never happen here" | "This type is always in a valid state" |
Rule of thumb: Use assert() when you want to check a condition at a specific point in a function. Use contract() when you want to define what "valid" means for a type, and have every method automatically verify it.
In debug builds, all contracts (pre, post, inv) are checked at runtime via assert(). In release builds, they serve as optimizer hints and are eliminated — the compiler can use the information to prove properties about the code without generating runtime checks.
// Debug: checks pre/post at runtime
// Release: eliminated, used for optimization
func fast_path(x: u64): u64
pre(x > 0)
post(result > 0)
{
return 100 / x
}
Build modes:
- Debug (
-O0or no optimization flag): All contracts checked at runtime. Violations print a message and abort. - Release (
-O2,-O3): Contracts are parsed and stored in the AST but no runtime checks are emitted. The optimizer may use contract information for dead code elimination and range analysis.
let add = |a: int, b: int| -> a + b
let result = add(3, 4) // result = 7
// Multi-line lambda
let process = |items: [int]| {
let total = 0
for item in items {
total += item
}
return total
}
func make_adder(x: int): func(int): int {
return |y| -> x + y // captures x by reference
}
let add5 = make_adder(5)
let result = add5(3) // result = 8
func map<T, U>(items: [T], f: func(T): U): [U] {
let result: [U; items.len]
for i, item in items {
result[i] = f(item)
}
return result
}
let numbers = [1, 2, 3, 4, 5]
let doubled = map(numbers, |x| -> x * 2)
Properties are methods that look like fields. The compiler infers getter/setter from the return type:
- Getter: has a return type →
obj.prop()calls the getter - Setter: has no return type →
obj.prop(value)calls the setter
Properties are defined as regular func methods inside struct/class bodies. The self parameter is auto-injected by the compiler — you never write it yourself.
struct Temperature {
celsius: f64
// Getter — has return type
func fahrenheit(): f64 {
return self.celsius * 9.0 / 5.0 + 32.0
}
// Setter — no return type
func fahrenheit(value: f64) {
self.celsius = (value - 32.0) * 5.0 / 9.0
}
}
let t: Temperature
t.celsius = 100
print(t.fahrenheit()) // calls getter → 212.0
t.fahrenheit(32) // calls setter → celsius = 0
Note:
selfis a reserved keyword referencing the enclosing object. It is never passed as a parameter — the compiler injects it automatically. You only needselfwhen a local variable or parameter name shadows a field name.
Operator overloads use explicit left/right parameters (C++ style). The op_ prefix is reserved — it can only be used for operator overloading.
struct Vector2 {
x: f64
y: f64
}
func op_+(a: Vector2, b: Vector2): Vector2 {
let result: Vector2
result.x = a.x + b.x
result.y = a.y + b.y
return result
}
func op_-(a: Vector2, b: Vector2): Vector2 {
let result: Vector2
result.x = a.x - b.x
result.y = a.y - b.y
return result
}
func op_*(a: Vector2, scalar: f64): Vector2 {
let result: Vector2
result.x = a.x * scalar
result.y = a.y * scalar
return result
}
func op_==(a: Vector2, b: Vector2): bool {
return a.x == b.x and a.y == b.y
}
Dynamic dispatch uses fat pointers (vtable pointer + data pointer). Use dyn Trait to opt in.
trait Drawable {
func draw()
func area(): f64
}
// Static dispatch (default) — monomorphized, zero-cost
func render_static(items: [Drawable]) {
for item in items {
item.draw()
}
}
// Dynamic dispatch — vtable lookup
func render_dynamic(items: [dyn Drawable]) {
for item in items {
item.draw()
}
}
- Static dispatch (default): When the concrete type is known at compile time. Zero overhead.
- Dynamic dispatch (
dyn Trait): When you need heterogeneous collections or runtime polymorphism. Adds one pointer dereference per call.
Full NASM syntax is a first-class citizen. The asm block text is emitted verbatim into the generated assembly. Aether function parameters are not automatically substituted — use SysV ABI registers directly (rdi=arg1, rsi=arg2, rdx=arg3, rcx=arg4, r8=arg5, r9=arg6).
func writePortByte(port: u16, value: byte) {
asm {
mov dx, rdi
mov al, sil
out dx, al
}
}
const declarations are emitted as NASM equ directives, making them accessible from inline asm blocks:
const COM1_DATA = 0x3F8
const LSR_THR_EMPTY = 0x20
func serial_putc(c: byte) {
asm {
mov dx, COM1_DATA
mov al, dil
out dx, al
}
}
This generates:
COM1_DATA equ 0x3F8
LSR_THR_EMPTY equ 0x20Use asm: (outputs) { body } to bind assembly results to Aether variables.
func readTimestampCounter(): u64 {
let hi: u32
let lo: u32
asm: (hi, lo) {
rdtsc
mov [hi], edx
mov [lo], eax
}
return (u64(hi) << 32) | u64(lo)
}
func spin_wait(cycles: u64) {
asm {
mov rcx, cycles
.loop:
dec rcx
jnz .loop
}
}
The same NASM syntax works on all targets via the multi-target assembler:
// Write once in NASM syntax:
func setup_gdt() {
asm {
lgdt [gdt_ptr]
mov ax, 0x10
mov ds, ax
mov ss, ax
}
}
The compiler translates this to:
- x86_64 target: Direct NASM emission (passthrough)
- ARM64 target: Translated to ARM64 equivalents
- RISC-V target: Translated to RISC-V equivalents
At file level (outside any function), asm { } emits raw assembly text directly into the output without any function wrapping. This is used for declaring BSS/data sections, global symbols, and other NASM directives that must appear at file scope.
# Top-level asm block — emits section directives at file level
asm {
section .bss
cmd_names: resq 16
cmd_handlers: resq 16
cmd_count: resq 1
section .data
help_text: db "Available commands: help, ls, echo", 0
section .text
}
Top-level asm blocks are emitted in declaration order, before any function code. They are useful for:
- Declaring BSS variables with
resb/resq/resw/resd - Declaring data tables with
db/dw/dd/dq - Defining global symbols that functions reference
- Emitting NASM directives like
align,section,global,extern
The compiler automatically hoists extern declarations from asm blocks to the top of the output file. NASM requires extern at file level, not inside function bodies.
func main(): u64 {
asm {
extern __bss_start
extern __bss_end
}
asm {
mov rdi, __bss_start
mov rcx, __bss_end
sub rcx, rdi
xor al, al
rep stosb
}
}
The compiler emits:
; Extern declarations (hoisted from asm blocks)
extern __bss_start
extern __bss_end
section .text
...
main:
mov rdi, __bss_start
mov rcx, __bss_end
sub rcx, rdi
xor al, al
rep stosbfunc get_forty_two(): u64 {
asm {
mov rax, 42
}
// No "xor rax, rax" emitted here — rax preserved from asm block
}
The compiler's primary target is x86_64-freestanding. No libc, no CRT, no OS assumptions.
aether build --target x86_64-freestanding --output kernel.elfThe compiler also outputs host-native formats so you can compile and run .ae programs directly on your development machine.
| Host OS | Format | Linker |
|---|---|---|
| macOS | Mach-O 64 (x86_64) | ld (system) or direct syscall emission |
| Linux | ELF64 | ld (system) or direct syscall emission |
| Windows | PE32+ | link.exe or direct |
The compiler knows the Aether syscall table and generates optimal call sequences:
sys func putc(c: byte) at(0)
sys func puts(s: string) at(1)
sys func open(path: string): int at(2)
sys func read(fd: int, buf: [u8]): int at(3)
sys func exit() at(7)
For kernel module development:
module serial {
@export func mod_init(): int {
reg_cmd("serial", cmd_serial)
return 0
}
@export func mod_fini() {
unreg_cmd("serial")
}
}
Aether supports file-level imports via the import keyword. Imports are resolved at compile time — the compiler reads the imported file, parses it, and merges its declarations into the current compilation unit.
// Import a library file relative to the current source
import "libaether.ae"
// Now use functions from the imported file
func main(): u64 {
puts("Hello from Aether!")
exit_bin()
return 0
}
How imports work:
- The parser creates
NODE_IMPORTAST nodes for eachimport "path.ae"statement - After parsing, the compiler scans for
NODE_IMPORTnodes and resolves them:- Builds the full path relative to the importing file's directory
- Reads the imported file from disk
- Parses it using
parser_create_with_arena()(shares the main parser's arena so AST nodes persist) - Merges the imported declarations into the main program's declaration list
- Removes the
NODE_IMPORTnode from the list
- The imported source buffer is kept alive because
StringViewfields in the AST point into it - Circular imports are detected and skipped (tracked by path)
Semantic analysis uses a two-pass approach to handle forward references across files:
- First pass: declares all top-level names (functions, consts, structs, enums, traits, classes)
- Second pass: visits function bodies and resolves identifiers
Dead Code Elimination (DCE) also uses a two-pass approach:
- First pass: adds all top-level functions to the symbol table
- Second pass: collects references from all function bodies
- Removal pass: only removes functions that are neither entry points nor referenced
Limitations:
- Only file-level imports are supported (no module-level or selective imports)
- Imported files must use relative paths (resolved from the importing file's directory)
- The
importkeyword currently requires a string literal path (not a bare module name)
@entry(0x2000000)
func main(args: [][]byte): int {
puts("Hello from Aether binary!\n")
return 0
}
@layout(start=0x7C00, max=512, file="stage1.bin")
func stage1_mbr() {
asm { ... 512-byte MBR ... }
}
@kernel_layout
func init_memory() {
// Compiler knows the memory map and verifies no overlap
let bitmap = reserved(0x1000, 0x1000)
let registry = reserved(0x4000, 0x1000)
let syscall = reserved(0x5000, 0x1000)
}
// Declare a memory pool for USB transfers
pool UsbDmaBuffer of size 64, count 32, alignment 256
// Use it — compiler generates pool alloc/free
func alloc_usb_buf(): UsbDmaBuffer {
return UsbDmaBuffer() // from the declared pool
} // compiler inserts: return to pool on drop
Syntax: pool <Name> of size <N>, count <M>, alignment <A>
The parser accepts pool, of, size, count, and alignment as keywords. The AST node stores name, size, count, and alignment fields. Codegen currently emits ; pool declaration (reserved) and produces no runtime code.
protocol Serial {
port base = 0x3F8
speed = 115200
func putc(c: byte) {
asm { mov dx, port; mov al, c; out dx, al }
}
}
Syntax: protocol <Name> { <field_decls> <method_decls> }
The protocol keyword is parsed at the top level. The AST node (NODE_PROTOCOL_DECL) stores a name and a list of method declarations. Codegen currently emits ; protocol declaration (interface) and produces no runtime code. This is distinct from the protocol block in §28.1 (Automatic Protocol Generation for hardware), which is a separate feature.
The multi-target assembler translates NASM syntax to target architecture assembly:
NASM Source → NASM Parser → AsmIR (intermediate representation)
→ x86_64 Backend (passthrough)
→ ARM64 Backend (instruction mapping)
→ RISC-V Backend (instruction mapping)
| NASM | ARM64 | RISC-V |
|---|---|---|
rax |
X0 |
a0 |
rbx |
X19 |
s0 |
rcx |
X1 |
a1 |
rdx |
X2 |
a2 |
rsi |
X3 |
a3 |
rdi |
X4 |
a4 |
rsp |
SP |
sp |
rbp |
X29 |
s1 |
| NASM | ARM64 | RISC-V |
|---|---|---|
mov rax, rbx |
MOV X0, X19 |
MV a0, s0 |
add rax, 5 |
ADD X0, X0, #5 |
ADDI a0, a0, 5 |
jmp label |
B label |
J label |
call func |
BL func |
JAL ra, func |
ret |
RET |
JALR zero, ra, 0 |
push rax |
STR X0, [SP, #-16]! |
ADDI sp, sp, -16; SD a0, 0(sp) |
pop rax |
LDR X0, [SP], #16 |
LD a0, 0(sp); ADDI sp, sp, 16 |
# Show ARM64 translation of NASM assembly
aether --target asm-arm64 source.ae -o output.asm
# Show RISC-V translation
aether --target asm-riscv64 source.ae -o output.asmAether supports compiling a single source file into a universal binary that runs natively on multiple architectures without an interpreter, JIT, or emulation layer.
┌─────────────────────────────────────┐
│ Universal Binary │
│ ┌───────────────────────────────┐ │
│ │ CPU Detection Trampoline │ │ ← ~30 bytes, runs first
│ │ if x86_64: jmp to .text.x86│ │
│ │ if ARM64: jmp to .text.arm│ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ .text.x86_64 │ │ ← compiled from Aether source
│ │ (NASM → x86_64 machine code)│ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ .text.arm64 │ │ ← same source, ARM64 backend
│ │ (NASM → ARM64 machine code) │ │
│ └───────────────────────────────┘ │
│ ┌───────────────────────────────┐ │
│ │ .rodata (shared) │ │ ← deduplicated, shared by both
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
// Generated automatically by --target universal
func _start() {
asm {
// x86_64 path: CPUID check
pushfq
pushfq
xor dword [rsp], 0x00200000
popfq
pushfq
pop rax
xor eax, [rsp]
and eax, 0x00200000
popfq
jnz .arm64_entry
.x86_64_entry:
jmp _start_x86_64
.arm64_entry:
jmp _start_arm64
}
}
# Build a universal binary for x86_64 + ARM64
aether build --target universal --output kernel.elf
# Build for all three architectures
aether build --target universal-all --output kernel.elfThe compiler ships a freestanding standard library:
| Module | Contents |
|---|---|
std.io |
print, println, format, read_line |
std.mem |
alloc, free, copy, zero, Pool, Arena |
std.str |
String, string_view, concat, split, trim |
std.math |
sqrt, sin, cos, abs, min, max |
std.collections |
Array, HashMap, Set, List, Queue |
std.fs |
File, Path, Directory (maps to AetherFS syscalls) |
std.serial |
COM1, putc, puts (kernel-mode serial I/O) |
std.elf |
ELF64 reader/writer (for module loader, linker) |
std.test |
assert, test_runner, benchmark |
std.asm |
NASM helper macros and common sequences |
std.arch |
Architecture detection, register definitions, multi-target helpers |
The following functions are available without any import. They are emitted directly by the compiler:
| Function | Signature | Description |
|---|---|---|
print |
print(...) |
Print values to stdout (variadic, auto-converts types) |
sizeof |
sizeof(T) -> u64 |
Returns the size of type T in bytes |
alignof |
alignof(T) -> u64 |
Returns the alignment of type T in bytes |
offsetof |
offsetof(T, field) -> u64 |
Returns the byte offset of a struct field |
typeName |
typeName(T) -> string |
Returns the string name of type T at compile time |
panic |
panic(msg: string) |
Prints a message and aborts the program |
let size = sizeof(u64) // 8
let align = alignof(u64) // 8
let offset = offsetof(Point, y) // byte offset of y field
let name = typeName(u64) // "u64"
panic("unreachable code reached") // abort with message
Note:
sizeof,alignof,offsetof, andtypeNameare compile-time evaluable — they produce constant values that can be used inconstdeclarations and#runblocks.
aether build [--target=...] # Compile source file
aether run [--target=...] # Build and run
aether asm [--target=...] # Show generated assembly
aether inspect [binary] # Inspect ELF metadata
aether init|new <name> # Scaffold new project (planned)
aether fmt [files...] # Format source (planned)
aether doc [files...] # Generate documentation (planned)
my-kernel/
aether.toml # Project manifest
src/
main.ae # Entry point
lib/
serial.ae # Library modules
asm/
stage1.ae # Assembly-heavy boot files
tests/
test_serial.ae # Unit tests
target/
debug/
release/
[package]
name = "aether-kernel"
version = "0.1.0"
[build]
target = "x86_64-freestanding"
output = "kernel.elf"
linker-script = "tools/kernel.ld"
[dependencies]
std = { path = "/lib/aether/std" }| Target | Format | Use Case |
|---|---|---|
host |
Mach-O 64 / ELF64 / PE32+ | Development and testing on dev machine |
x86_64-freestanding |
ELF64 | Aether OS kernel |
kernel |
ELF64 | Kernel binary with memory map verification |
module |
ELF64 .ko |
Loadable kernel module |
binary |
ELF64 | Userland binary at 0x2000000 |
boot |
Flat binary | Boot sector (stage1/stage2) |
asm-x86_64 |
NASM text | x86_64 assembly listing |
asm-arm64 |
ARM64 text | ARM64 assembly listing |
asm-riscv64 |
RISC-V text | RISC-V assembly listing |
universal |
Multi-arch ELF | x86_64 + ARM64 combined |
universal-all |
Multi-arch ELF | x86_64 + ARM64 + RISC-V combined |
This section describes features that make Aether genuinely different from other systems languages. Many are aspirational — they represent the long-term vision.
The compiler has baked-in knowledge of the Aether OS architecture. It knows the memory map, the syscall table layout, the module registry structure, and the boot chain requirements. This means:
// The compiler knows this is a boot sector and enforces the 512-byte limit
@layout(start=0x7C00, max=512, file="stage1.bin")
func stage1() {
asm {
// ... boot code ...
}
// Compiler error if this exceeds 512 bytes
}
// The compiler verifies memory map regions don't overlap
@kernel_layout
func verify_layout() {
// Compiler checks: 0x1000-0x1FFF (bitmap) doesn't overlap
// 0x4000-0x4FFF (module registry) doesn't overlap
// 0x5000-0x5FFF (syscall page) doesn't overlap
// 0x6000-0xAFFF (page tables) doesn't overlap
}
Instead of describing how to read a file, describe what you want:
// The compiler generates the optimal read path:
// - Boot-time: raw ATA PIO reads
// - Userspace: AetherFS syscalls
// - In-memory FS: direct pointer access
let config = from "/etc/aether.cfg" read Config
Instead of C++ templates or macros, Aether uses pattern matching on types:
impl<T> trait Hashable {
func hash(): u64 {
match T {
case u64 => self
case [u8] => hash_bytes(self)
case string => hash_str(self)
case struct { ...fields } => {
var h = 0
for field in fields { h ^= field.hash() }
h
}
}
}
}
Chain operations on collections — the compiler fuses them into a single loop with no intermediate allocations:
let active_users = db.users
.filter(|u| u.active)
.map(|u| (u.name, u.email))
.sort(|u| u.0)
.collect()
This compiles to a single fused loop. No temporary arrays, no allocation overhead.
Define hardware protocols declaratively:
protocol I2C {
scl pin = 5
sda pin = 6
speed = 100000
func start() {
asm {
// Compiler generates optimal bit-banging code
// based on pin assignments and speed
}
}
func write(byte data) {
// Compiler generates the I2C protocol sequence
}
}
// Detect hardware at compile time and generate optimized code
#run {
if target_arch() == "x86_64" {
emit("func flush_tlb() { asm { mov cr3, cr3 } }")
} elif target_arch() == "arm64" {
emit("func flush_tlb() { asm { dsb ish; tlbi vmalle1is; dsb ish; isb } }")
}
}
Aether binaries contain embedded metadata that the OS can read:
@metadata {
author = "Aether Team"
description = "Kernel main binary"
license = "MIT"
required_abi = "1.0"
}
The compiler embeds this as an ELF note section. The OS can query it without loading the binary.
// Functions declare what capabilities they need
@requires(io, mem)
func write_disk(sector: u64, data: [u8]) {
// Compiler verifies caller has io and mem capabilities
}
// Capabilities are tracked at compile time
func safe_path() {
// Error: write_disk requires io capability
// write_disk(0, data)
}
func read_config(): throws Config {
let f = File::open("/etc/aether.cfg") ? "Failed to open config"
let data = f.read_all() ? "Failed to read config"
return parse(data) ? "Failed to parse config"
}
// The `?` operator attaches context to errors.
// In debug builds: error messages are preserved.
// In release builds: only the error discriminant remains (zero-cost).
// The compiler tracks physical units at compile time
@units(meters)
func distance(v: f64, t: f64): f64 {
return v * t // m/s * s = m ✓
}
@units(meters_per_second)
func speed(d: f64, t: f64): f64 {
return d / t // m / s = m/s ✓
}
// Compile error: can't add meters to seconds
// let bad = distance(10, 5) + speed(10, 5)
// Declare interrupt handlers declaratively
interrupt timer at(0x20) {
// Compiler generates:
// 1. Correct interrupt frame save/restore
// 2. EOI signaling
// 3. Stack switching if needed
tick_count += 1
}
interrupt keyboard at(0x21) {
let scancode = readPortByte(0x60)
handle_key(scancode)
}
// Describe the boot chain declaratively
bootchain {
stage1: @layout(start=0x7C00, max=512)
stage2: @layout(start=0x7E00, max=16384)
kernel: @layout(start=0x1000000)
}
// Compiler generates:
// 1. Stage1 MBR with correct INT 13h parameters
// 2. Stage2 loader with correct sector counts
// 3. Kernel entry point at correct address
// 4. Disk image with correct layout
func worker(id: u64) {
print("Worker {id} started")
}
spawn worker(1)
spawn worker(2)
var lock: Mutex
lock.acquire()
// critical section
lock.release()
let ch: Chan<u64>
ch.send(42)
let val = ch.recv()
The compiler generates code compatible with the Aether OS fiber scheduler:
- Cooperative multitasking with explicit yield
- No preemption, no timer interrupts needed
- Per-fiber stack allocation
- Yield points at blocking I/O, timer waits, and explicit
yieldcalls
Each .ae file is a module. The filename (without extension) is the module name.
import "math.ae"
import "io/file.ae"
Imports are resolved at compile time: the compiler reads the file, parses it with a shared arena, and merges declarations. Circular imports are detected. Two-pass semantic analysis (declare all names first, then visit bodies) handles forward references across files.
For closed-source third-party library distribution, Aether uses .aelib — an archive format containing compiled code (.o files) plus a metadata section with type signatures, class layouts, and the export table. The metadata enables code completion and compile-time validation while the archive format prevents trivial reverse engineering with objdump.
File format (version 1):
+----------------------------------+
| Magic: "AELIB\0" (8 bytes) |
| Version: 0x0001 (2 bytes) |
| Flags: (2 bytes) |
| ABI version: (2 bytes) |
+----------------------------------+
| Code section offset (8 bytes) |
| Code section size (8 bytes) |
+----------------------------------+
| Metadata section offset (8) |
| Metadata section size (8) |
+----------------------------------+
| Code section (archive of .o) |
+----------------------------------+
| Metadata section (binary blob) |
+----------------------------------+
Metadata section format:
- Header: magic
"AEMETA\0", version0x0001 - Symbol table: name, kind (function/struct/class/global/const/enum), flags (public bit), namespace, type data offset/size
- Type data: function signatures (return type, param count, params with name/type/mutability), struct/class layouts (field offsets/sizes), enum variants
- String table: null-terminated strings concatenated
# All public decls from a library
import "test"
# Only public decls from a specific class/namespace
import "test" : Foo
# From multiple classes
import "test" : Foo, Bar
# Explicit .aelib file
import "test.aelib" : Foo
# Standard library (auto-resolved by compiler)
import "std/io"
For import "foo" (no extension), the compiler tries in order:
foo.ae— source file (for code you own)foo.aelib— pre-compiled libraryfoo/lib.aeorfoo/lib.aelib— package directory- Standard library paths:
$AETHER_LIB,~/.aether/lib,/usr/local/lib/aether
If none found, the compiler reports an error listing what was tried.
public func exported() { } // visible to importers
private func internal_only() { } // only within this file
internal func package_only() { } // within the same package
func default_public() { } // public by default (for simplicity)
For .aelib libraries, visibility is enforced via metadata gating — private and internal declarations are omitted from the metadata entirely, so they literally cannot be called from outside the library.
aether build --target lib test.ae -o test.aelib
The new --target lib target produces code + metadata in one .aelib file.
When importing a .aelib, the consumer has two options:
- Static linking (default for
--target kernel,--target binary): extract.ofiles from the archive, link them into the final binary. Everything in one executable — no runtime dependencies. - Dynamic linking (for
--target host): reference the.aelibas a shared library, runtime resolution via the OS dynamic linker. Smaller binaries, can update libraries without recompiling.
A .aelib can contain code for multiple architectures (x86_64, ARM64, RISC-V). The metadata is arch-agnostic — it references symbols by name. The linker picks the right arch at build time, like macOS fat dylibs.
Function-level imports create ambiguity problems (multiple overloads with different signatures — which do you import?). Class-level is unambiguous: import the whole class, use Foo.sayHello() to call methods. This is how Python modules, Java classes, and C# namespaces work. The class is the natural unit of grouping.
An ELF file with a metadata section (.aeb) is trivially reverse-engineerable with objdump -d — anyone can disassemble the code sections. An archive format (.aelib) requires writing a custom disassembler to extract the code sections AND understanding the archive layout. This matches how dylibs work on every platform (macOS, Linux, Windows) and is the natural choice for distributing compiled libraries without exposing source.
as, asm, break, case, catch, class, const, continue, copy,
defer, do, dyn, elif, else, enum, export, extern, false, for,
func, heap, if, impl, import, in, init, drop, let, match,
module, none, not, or, and, pool, post, pre, private,
protocol, public, region, return, self, static, struct,
super, sys, test, throw, trait, true, try, type, unsafe,
var, where, while, yield
| Token | Rule |
|---|---|
| Comment | // to end of line |
| Block comment | /* ... */ (nestable) |
| String | Double-quoted, escape sequences: \n, \t, \\, \", \xNN |
| Char | Single-quoted: 'a', '\n', '\x41' |
| Integer | Decimal: 42, Hex: 0xFF, Binary: 0b1010, Octal: 0o77 |
| Float | 3.14, 1e10, 0xFF.0p-3 |
| Identifier | [a-zA-Z_][a-zA-Z0-9_]* |
| Indentation | Significant: blocks are indentation-based, 4 spaces per level |
| Terminator | Newline ends simple statements; expressions continue across lines |
| Semicolons | Optional — may separate statements on the same line |
Aether supports two build profiles that affect optimization, contract checking, and error handling:
| Feature | Debug (-O0) |
Release (-O2/-O3) |
|---|---|---|
| Optimization | None (fast compile) | Full (constant folding, DCE, inlining) |
| Contract checks | pre()/post() checked at runtime |
Eliminated (optimizer hints only) |
| Error context | Error messages preserved | Error discriminants only |
| Stack traces | Full source map | Minimal |
| Debug symbols | Included | Stripped |
| Build time | Fast | Slower (optimization passes) |
# Debug build (default)
aether build source.ae -o output
# Release build
aether build -O2 source.ae -o output
# Maximum optimization
aether build -O3 source.ae -o outputThe following operations are undefined behavior — the compiler may produce any result, including crashes, incorrect output, or silent data corruption:
- Data races — Concurrent read and write to the same memory location without synchronization
- Use-after-free — Dereferencing a pointer after the memory has been freed
- Double free — Calling
free()on the same pointer twice - Null pointer dereference — Reading from address
0x0(outside a try block) - Uninitialized variable access — Reading a
letvariable before it has been assigned - Invalid pointer arithmetic — Moving a pointer beyond the bounds of its allocation
- Dangling reference — Returning a
refto a stack-local variable - Signed integer overflow —
i8,i16,i32,i64arithmetic that exceeds the representable range (wraps silently) - Shift overflow — Shifting by an amount >= the bit width of the type
- Type punning through
as— Casting between incompatible types and reading the result - Calling variadic function with wrong types — Passing arguments that don't match the expected types
- Stack overflow — Exceeding the available stack space (e.g., infinite recursion)
Note: Aether does not have Rust-level safety guarantees. The compiler trusts the programmer.
unsafeblocks explicitly opt into operations 1-7. Operations 8-9 are always wrapping (not UB in Aether, but the result may be unexpected). Use the standard library's checked arithmetic functions when you need overflow detection.
The Aether compiler pipeline:
Source (.ae)
→ Tokenizer (whitespace-aware, indent engine)
→ Parser (Pratt + recursive descent)
→ AST (50+ node types)
→ Import Resolution (read, parse, merge imported files)
→ Semantic Analysis (type checking, name resolution)
→ Code Generation (NASM text output)
→ Multi-Target Assembler (x86_64/ARM64/RISC-V)
→ Binary Output (ELF64/Mach-O/PE32+/flat binary)
Planned future pipeline phases (not yet implemented):
→ HIR (High-level IR — type-aware, for semantic passes)
→ MIR (Mid-level IR — CFG with memory annotations)
→ Optimization (constant folding, DCE, inlining)
→ LIR (Low-level IR — register allocation)
→ Code Generation (from LIR, not AST)
Comparison with other languages:
| Phase | Aether | Swift | Zig |
|---|---|---|---|
| Tokenizer | Direct | Lexer | Tokenizer |
| Parser | Recursive descent | Recursive descent | Recursive descent |
| AST | 50+ node types | AST | AST |
| Import resolution | File-level | Module system | @import |
| Semantic analysis | Two-pass | Sema | ZIR → AIR |
| High-level IR | Planned | SIL (Swift IR) | ZIR (Zig IR) |
| Mid-level IR | Planned | SIL Optimizer | AIR (Analyzed IR) |
| Optimization | Partial | SIL + LLVM passes | LLVM passes |
| Low-level IR | Planned | LLVM IR | LLVM IR |
| Code generation | NASM text | LLVM IRGen | LLVM / native |
| Assembly | Multi-target | LLVM MC | LLVM MC |
| Binary output | ELF/Mach-O/PE | LLVM | LLVM / custom |
Note: Aether's current pipeline skips intermediate IRs and goes directly from AST to NASM assembly text. This is by design for the bootstrap phase — it keeps the compiler simple and debuggable. As the language matures, HIR/MIR/LIR passes will be added for better optimization and code quality.
End of Aether Language Specification v1.0