Astralang
A gilded and silver celestial globe from 1579, driven by clockwork and carried on a winged horse.

Readable. Checkable. Native.

Astra's syntax borrows Python's directness and Rust's discipline: type inference keeps code terse, static checking keeps it honest, and every program compiles to a native binary.

Variables, inference, and strings

Types are inferred from initializers; annotations are optional and checked when present. Template literals interpolate with ${}.

let name = "Astra"           // inferred str
let version: float = 0.12  // explicit annotation
let checks = 55              // inferred int

print("${name} v${version}: ${checks} release checks pass")

Strings have the methods you reach for first, on both engines. A for loop over a string visits one character at a time, and < / > compare strings alphabetically (byte order).

let word = "Hello, world"
let vowels = 0
for c in word:                  // one str per character
    if "aeiou".contains(c):
        vowels += 1
print(word.starts_with("Hell"))    // true
print(word.replace("world", "Astra"))
print(word.char_at(0))             // H
print("apple" < "banana")        // true

Also split, trim, substring, index_of, ends_with, to_upper and chars(). v0.13.1

Recursion, control flow, loops

Functions, loops and recursion read like Python and compile to native code.

fn fib(n: int) -> int:
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

fn main() -> int:
    for i in 0..10:
        print("fib(${i}) = ${fib(i)}")
    return 0

while, do/while, switch, break/continue, and for-range loops all compile to native code today.

and / or / not, if as a value v0.13.8

The Python spellings work.

fn main() -> int:
    let n = 7
    let tags = {"a": 1}
    if n > 3 and "z" not in tags:
        print('big' if n > 5 else 'small')
    let port = parse_int("80").unwrap_or(8080)
    print(port)                 // 80
    return 0

and, or, not and not in follow Python's precedence. Strings may use single quotes; lists, calls and parameters accept a trailing comma. An if can be a value (let x = if c: with indented branches, or a if c else b), and a statement can continue on the next, deeper line after = or an operator. Option and Result have is_some, is_none, is_ok, is_err, unwrap_or and unwrap, which stops the program on None or Err.

elif, default arguments, sqrt v0.13.6

Chain conditions with elif, leave parameters off with defaults.

fn grade(score: int, pass_mark: int = 50) -> str:
    if score >= 90:
        return "A"
    elif score >= pass_mark:
        return "pass"
    else:
        return "fail"

print(grade(60))          // pass
print(grade(60, 70))      // fail
print(sqrt(2.0))         // 1.41421

elif works in both syntaxes. A default value is a literal, parameters with defaults come last, and a call may leave them off from the end; there are no keyword arguments yet. sqrt takes an int or a float and returns a float (nan for a negative number). The interpreter and native builds fill defaults in the same way.

Structs, enums, and pattern matching

Data is modelled with named structs and enums, and match must cover every case.

struct User:
    name: str
    age: int
    active: bool

let u = User { name: "Ada", age: 36, active: true }
print(u.name)  // typed field access, native GEP loads
let v = User { active: false, name: "Lin", age: 29 }  // fields in any order

enum Status:
    Active
    Suspended
    Deleted

let label = match status:
    Status::Active    => "ok"
    Status::Suspended => "paused"
    Status::Deleted   => "gone"

print and ${} take int, float, bool and str; the same goes for ==. Print or compare a struct's fields, and match an enum, until @derive(Show, Eq) arrives. A literal is always the struct it names, even when another struct has the same fields, and its fields can come in any order.

Variants with several values, richer patterns v0.13.10

Take values apart in match by shape, range or alternative.

enum Shape:
    Circle(float)
    Rect(float, float)

fn area(s: Shape) -> float:
    match s:
        Circle(r) => 3.14 * r * r
        Rect(w, h) => w * h

fn grade(score: int) -> str:
    match score:
        90..=100 => "A"
        80..90 => "B"
        _ => "below B"

fn where_is(p: (int, int)) -> str:
    match p:
        (0, 0) => "origin"
        (0, _) | (_, 0) => "on an axis"
        _ => "elsewhere"

print(area(Rect(2.0, 3.0)))   // 6
print(grade(85))              // B
print(where_is((0, 4)))       // on an axis

A variant can carry several values; Rect(w, _) and Triangle(..) skip some. Patterns also take structs (Point { x: 0, y, .. }), tuples, ranges (0..10, 0..=9, -5..0) and alternatives (1 | 2), with an if guard. A function's last expression is its return value, so arms can be plain expressions. Not yet: a variant pattern inside a struct or tuple pattern.

Maps and sorting v0.13.2

Count by key with a Map, then rank with a stable sort.

let counts: Map<str, int> = {}
for w in text.split():
    if w in counts:
        counts[w] += 1
    else:
        counts[w] = 1

let words = counts.keys()
words.sort_by_key(|w| { -counts[w] })      # most common first

let ports = {"http": 80, "https": 443}
match ports.get("ssh"):
    Some(p) => print("ssh on ${p}")
    None    => print("no ssh")

Keys are int or str, iteration follows insertion order, and a map is a value like a list. Lists have sort(), sort_by_key and reverse(); equal keys keep their order. Result and Option carry any value, structs and floats included.

Result<T,E>, no exceptions

Fallible operations return Result. The compiler forces the caller to handle both arms; there is no invisible control flow. The built-ins that can fail (read_file, parse_int, …) return Result or Option; throw and try/catch exist for code that prefers them, in braced syntax for now.

fn divide(a: int, b: int) -> Result<int, str>:
    if b == 0:
        return Err("division by zero")
    return Ok(a / b)

match divide(10, 2):
    Ok(value)  => print("result: ${value}")
    Err(cause) => print("failed: ${cause}")

Closures

Closures capture from the enclosing scope. The compiler lifts them into standalone functions at compile time (lambda lifting), so a closure call is exactly as fast as a plain function call.

fn main() -> int:
    let factor = 3
    let scale = |x: int| { x * factor }  // captures factor
    print(scale(5))  // 15, native call
    return 0

Generics and gradual typing

Generic functions are monomorphized, specialized per concrete type at compile time, like Rust and C++, so generics carry zero runtime cost.

fn first<T>(xs: [T]) -> T:
    return xs[0]

print(first([3, 1, 2]))       // 3: first<int>
print(first(["a", "b"]))      // a: first<str>

Comparing two values of a type parameter (a > b) needs a trait bound such as Ord; that comes with the standard traits, after @derive.

When a boundary genuinely can't be typed statically, data from an LLM, a dynamic config, dyn marks it explicitly. Checks defer to runtime at that boundary and nowhere else.

fn handle(payload: dyn) -> dyn:
    // statically compatible with every type;
    // the escape hatch is visible in the signature
    return payload

Generic structs v0.13.10

One struct and one impl for every element type.

struct Stack<T>:
    items: [T]

impl<T> Stack<T>:
    fn push(mut self, value: T):
        self.items.push(value)

    fn top(self) -> Option<T>:
        if self.items.length() == 0:
            return None
        Some(self.items[self.items.length() - 1])

let s: Stack<str> = Stack { items: [] }
s.push("open")
s.push("save")
match s.top():
    Some(last) => print(last)   // save
    None => print("empty")

Type arguments come from the field values (Pair { left: "x", right: 2.5 } is a Pair<str, float>); an empty list needs an annotation, as above. Generic structs work in lists, Option, Result and enum variants. The interpreter runs them as written; native builds make one concrete struct per type used.

Functions as values v0.13.11

Lists take closures and function names; functions take functions.

fn double(n: int) -> int:
    n * 2

fn apply_twice(f: fn(int) -> int, x: int) -> int:
    f(f(x))

let xs = [3, 8, 1, 6]
let k = 10
print(xs.map(|x| x * k).sum())          // 180
print(xs.filter(|x| x > 2).length())    // 3
print(xs.map(double)[1])                // 16
print(xs.fold(0, |acc, x| acc + x))     // 18
for (i, x) in xs.enumerate():
    print("${{i}}: ${{x}}")
print(apply_twice(|x| x + 3, 1))        // 7

Lists have map, filter, any, all, count, find (an Option), fold and sum; enumerate and zip pair elements up, also as Python's enumerate(xs) and zip(xs, ys). A closure may use the variables around it. Functions can be passed but not yet stored in a variable or returned.

Sized integers and as v0.13.11

Bytes, pixels and checksums, with every result checked.

fn checksum(data: [u8]) -> u32:
    let total: u32 = 0
    for b in data:
        total += b as u32
    total

let r: u8 = 200
let g: u8 = 100
print(r - g)                      // 100
print(r + 3)                      // 203
print(checksum([1, 2, 250]))     // 253
print(300 as u8)                  // 44
print(7.9 as int)                 // 7
print(r + g)                      // stops: overflow

u8, u16, u32, i8, i16 and i32 sit beside int (64-bit); i64 and f64 are other names for int and float. As in Rust, a result outside the type's range stops the program, on both engines (r + g above is 300: “integer overflow: 300 does not fit in u8”), and x as u8 keeps the low bits. A literal takes the other side's type and must fit (let b: u8 = 256 is a compile error), and two different integer types need an as to mix. No u64 or usize yet.

Nominal traits

A trait declares method signatures; an impl … for … block implements them for a concrete type. An impl that misses a method, or whose method signature differs from the trait, is the compile error E0020, which names it.

trait Shape:
    fn area(self) -> int

impl Shape for Circle:
    fn area(self) -> int:
        return self.r * self.r

Dispatch is static: impl Shape for Circle lowers to Circle__area, exactly like an inherent method. A trait bound (fn total<T: Shape>(s: T)) calls the trait's methods and accepts only types that implement it (E0020 at the call site). A trait method may have a body, a default that an impl can leave out or override (v0.13.8):

trait Shape:
    fn area(self) -> int
    fn describe(self) -> str:
        return "area ${self.area()}"

@derive(Eq, Show) on a struct gives it == and a printed form (v0.13.9):

@derive(Eq, Show)
struct Point:
    x: int
    y: int

print(Point { x: 1, y: 2 } == Point { x: 1, y: 2 })  // true
print(Point { x: 1, y: 2 })   // Point { x: 1, y: 2 }

Modules v0.10

Since v0.10, a program can span several files. import util; loads util.astra from the same folder (or from ASTRA_PATH, or a Mifa path dependency). Only items marked pub can be used from outside, and they are always named with their module, so it is clear where each one comes from.

util.astra
pub const LIMIT: int = 100
pub struct Point:
    x: int
    y: int
fn helper(n: int) -> int:  // private to util
    return n * n
pub fn square(n: int) -> int:
    return helper(n)
main.astra
import util

fn main() -> int:
    let p = util::Point { x: 3, y: 4 }
    print(util::square(p.x) + util::LIMIT)  // 109
    return 0

Import cycles, missing modules and private or unknown items are compile errors with code E0013, pointing at the import line, the same JSON diagnostics an agent already handles. Modules are resolved into one program before type checking, so the interpreter and the native compiler behave identically.

Calling C v0.13.3

Declare a C function with extern "c" fn and call it like any other. int, float, bool and str cross the boundary (C long long, double, bool, const char*), and a C function may return nothing. @link("m") links a library.

@link("m")
extern "c" fn cbrt(x: float) -> float

fn main() -> int:
    print(cbrt(27.0))  // 3
    return 0

A string returned by C is copied into Astra, so Astra never frees C's memory, and NULL comes back as "". Calling C is an effect (ffi): --deny ffi refuses it and a @pure function can't reach it. Only native builds can call C; astra run and the playground say to build the program instead. Lists, structs, pointers and callbacks can't cross the boundary yet.

The compiler pipeline

Astra is implemented in Rust (~15 core modules) as a classic multi-phase compiler:

source.astra
  → lexer        tokens with line + column
  → parser       recursive-descent AST
  → modules      resolve imports into one program
  → closure lift closures → top-level functions
  → typechecker  type inference + checking
  → monomorphize generic specialization
  → IR           LLVM IR generation
  → llc + link   object code + small C runtime
  → native executable

Since v0.10, astra run takes a second path after type checking: a tree-walking interpreter that starts instantly and needs no LLVM. Every feature is tested for identical output and exit code on both engines, and a fuzzer generates programs to keep them in agreement.

Diagnostics are emitted as structured JSON at every phase, which is what makes the agent repair loop possible. The direction is locked in five ratified architecture decisions: ARC memory management, gradual typing via dyn, a single LLVM backend targeting native / WASM / GPU, AI primitives as keywords, and Python interop via embedded CPython.