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