About Abra
The primary goal of Abra is to be a user-friendly programming language.
More specifically, Abra is optimized for these use cases:
- Giving beginner programmers a gentle introduction to writing code
- Allowing experienced programmers to prototype quickly
- Easily embed code inside a video game or game engine
Abra does this through the combination of these features:
- a strong static type system with error messages that prevent the user from making mistakes
- a garbage collector to remove the mental overhead of manually freeing memory, passing around allocators, writing lifetime annotations, or worrying about reference-count cycles.
- a small stack-based virtual machine made available through a library with a simple C API for reading/writing values
- lightweight syntax and minimal boilerplate. Semicolons and commas are often optional
Abra is not the only language to have a type system and a garbage collector. Some others include:
- OCaml
- Haskell
- TypeScript
- Java
- C#
- Go
However, Abra is distinct from more mainstream choices like TypeScript, Java, and C# because it has Hindley-Milner-style type inference, and considerably less boilerplate. Abra is distinct from OCaml because it has ad-hoc polymorphism. Abra is distinct from Haskell because it is not a pure, lazy functional language with monadic IO. Abra is distinct from Go because Go does not have algebraic data types or polymorphism. Abra is distinct from Lua, a popular choice for game scripting, because Abra has a strong static type system.
Abra isn’t necessarily a better choice than these other languages. For many use cases, it would be much better to just use something like TypeScript or C#. These comparisons are meant to illustrate Abra’s unique advantages for particular projects and for novice programmers.
Abra is not suitable for these use cases and does not aim to be:
- low-level graphics programming
- operating systems
- embedded systems
Installation
To install Abra, clone the repository and run the install script.
git clone https://github.com/anandrav/abra
cd abra
./scripts/install
This installs the Abra CLI, the LSP, and the standard library (under ~/.abra/).
Editor support
The install script can also set up syntax highlighting and language support for your editor:
| Flag | Effect |
|---|---|
--vim | Install Vim syntax highlighting and ftdetect |
--vscode | Build and install the VS Code extension (requires Node) |
--intellij | Build and install the IntelliJ plugin (requires JDK 21) |
Example:
./scripts/install --vim --vscode
Using the CLI
After installing, the abra command is available on your PATH:
Usage: abra [OPTIONS] <FILE> [ARGS]...
Arguments:
<FILE> The main Abra file to compile and execute
[ARGS] Arguments for the Abra program
Options:
-h, --help Print help
-c, --check Check for errors without compiling and running
-i, --import-dir <DIRECTORY> Provide an additional import directory
Additional options:
--standard-modules <DIRECTORY> Override the default standard modules directory
-a, --assembly Print the assembly for the Abra program
--debug-log Enable internal debug logging (debug builds only)
Requirements
Cargo
Cargo, the Rust package manager, is required to run the install script. The recommended way to get Cargo is via rustup.
Node (optional)
If you plan to install the VS Code extension with --vscode, you’ll need Node.js (which provides npm).
JDK 21 (optional)
If you plan to install the IntelliJ plugin with --intellij, you’ll need JDK 21. On macOS:
brew install openjdk@21
Hello World
To make a simple hello world program, create a file called hello.abra.
Inside the file, write
print("hello world")
Then run the file using the command abra hello.abra
Built-in Types
Abra has a small set of built-in types. Most programs are written using these plus your own structs and enums.
int
A 64-bit signed integer. The usual arithmetic operators all work on it.
let a = 5
let b = 2 + 3 // 5
let c = 2 * 3 // 6
let d = 12 / 3 // 4
let e = 13 / 3 // also 4 — integer division truncates
let f = 2 ^ 8 // 256 (power)
let g = 13 % 5 // 3 (modulo)
float
A 64-bit floating-point number. The same arithmetic operators apply, but you can’t mix int and float directly — convert with float_from_int or int_from_float.
let pi = 3.14159
let area = pi * 2.0 * 2.0
let n = int_from_float(pi) // 3
bool
let shield_is_equipped = true
let sword_is_equipped = false
Combine with and, or, and not (see Operators).
string
A garbage-collected, immutable Unicode string. Use the .. operator to build new strings out of existing values:
let name = "Merlin"
let age = 73
let message = name .. " is " .. age .. " years old."
// "Merlin is 73 years old."
For slicing, splitting, searching, and parsing, see the core/strings module.
array<T>
A growable list of elements that all have the same type. Indexing is zero-based.
let arr = [1, 2, 3, 4]
arr.push(5) // [1, 2, 3, 4, 5]
arr.len() // 5
arr[2] // 3
arr[2] = 49 // [1, 2, 49, 4, 5]
arr.pop() // [1, 2, 49, 4]
You can’t mix types in one array — [1, 2.0] is an error. Use a tuple if you need different types together.
tuples
A tuple groups a fixed number of values, possibly of different types:
let pair = (1, 2)
let coordinate = (1.0, 2.0, -3.0)
let person = ("Lancelot", 19)
Tuples are useful when you want to return a couple of related things from a function, or store a few related values together without inventing a struct.
You can pull a tuple apart with destructuring (see Patterns):
let (name, age) = person
The prelude provides equality, comparison, hashing, and ToString for tuples up to size 4.
channel<T>
A typed message path used to communicate between tasks. Create a channel with channel(), write values with .write(...), and read them with .read(). See Tasks and Channels for the full model.
void
Represents nothing. Its only value is nil. Functions that don’t return anything meaningful return void:
fn greet(name: string) {
println("Hello, " .. name)
// implicitly returns nil
}
fn maybe_greet(name: string, should_greet: bool) {
if not should_greet {
return // explicit early return; shorthand for `return nil`
}
println("Hello, " .. name)
}
never
A type with zero values. It’s the return type of functions that don’t return at all — for example, panic() aborts the program, so its return type is never.
Variables
A variable holds a value. Use let for a constant, and var for a mutable variable.
let x = 5
let pi = 3.14159
let name = "Merlin"
A var can be reassigned with =:
var score = 0
score = 10
score = score + 5 // 15
Trying to reassign a let is a compile error — that’s the point. Reach for var only when you actually need mutation.
Type annotations
Most of the time you don’t need to annotate the type — Abra figures it out from the value you assign:
let x = 5 // x is int
let pi = 3.14 // pi is float
let yes = true // yes is bool
When you do want to be explicit (or when the compiler can’t tell), put the type after a colon:
let x: int = 2
let pi: float = 3.14
let p: (int, int) = (1, 2)
Operators
Arithmetic
+, -, *, /, %, ^ work on int and float. Both operands must have the same type — Abra does not auto-convert between int and float.
let x = 5 + 2 // 7
let y = 7.5 - 2.5 // 5.0
let r = 13 % 5 // 3 (modulo)
let p = 2 ^ 8 // 256 (power)
Unary - negates a number:
let n = -5
let f = -3.14
String concatenation
The .. operator concatenates two values into a string. Either operand may be any type that implements the ToString interface, so you can mix strings, numbers, and other values without converting them first:
let name = "Merlin"
let age = 73
let msg = name .. " is " .. age .. " years old."
// "Merlin is 73 years old."
+ is not for string concatenation — that’s only for numbers.
Comparison
== and != work on any type that implements Equal. <, <=, >, >= work on any type that implements Ord.
let a = 5 == 5 // true
let b = "x" != "y" // true
let c = 3 < 7 // true
let d = "apple" < "banana" // true (lexicographic)
Logical
and, or, not are keywords (not &&, ||, !):
if x > 0 and x < 10 {
println("in range")
}
if not done {
keep_going()
}
and and or short-circuit.
Assignment
Plain assignment uses =. Compound assignment operators are also available:
var x = 0
x = 10 // simple
x += 5 // x = x + 5
x -= 1 // x = x - 1
x *= 2 // x = x * 2
x /= 4 // x = x / 4
x %= 3 // x = x % 3
Only var bindings, array elements, and struct fields can be assigned to.
Indexing
arr[i] and arr[i] = value index into arrays. The same syntax works on any type that implements the Index interface, including map and JsonValue:
let arr = [10, 20, 30]
let first = arr[0] // 10
arr[1] = 99 // arr = [10, 99, 30]
use core/map
let m: map<string, int> = map.new()
m["alice"] = 100 // calls Index.index_set
let score = m["alice"] // calls Index.index_get
Unwrap (!)
Postfix ! extracts the value from an option or result, panicking if it’s none / err. Use it when you know the value is present:
let n = "42".to_int()! // panics if not a valid int
Try (?)
Postfix ? propagates none/err to the caller. The enclosing function must return a compatible option or result:
use core/fs
fn read_two(a: string, b: string) -> result<string, FsError> {
let first = read(a)? // early-return on err
let second = read(b)?
.ok(first .. second)
}
Operator precedence
From lowest to highest:
| Precedence | Operators | Description |
|---|---|---|
| 1 | and, or | logical |
| 2 | ==, != | equality |
| 3 | .. | string format/concat |
| 5 | <, <=, >, >= | comparison |
| 6 | +, - (binary or unary) | additive |
| 7 | *, / | multiplicative |
| 8 | % | modulo |
| 9 | ^ | power |
| 10 | not | prefix |
| 11 | .field | member access |
| 12 | [index] | index |
| 13 | f(args) | function call |
| 14 | ! | unwrap |
| 15 | ? | try |
Control Flow
If-else
if runs one block when a condition is true, optionally another when it’s false:
let is_arriving = true
if is_arriving {
println("hello")
} else {
println("good bye")
}
Chain conditions with else if:
let hour = 14
if hour < 12 {
println("good morning")
} else if hour < 17 {
println("good afternoon")
} else {
println("good night")
}
if is an expression — it returns a value, so you can use it on the right side of a let:
let label = if temperature > 30 { "hot" } else { "cool" }
Match
match checks a value against a series of patterns and runs the first one that fits. It’s like a more powerful if/else if chain.
let n = 2
let s = match n {
0 -> "zero"
1 -> "one"
2 -> "two"
3 -> "three"
_ -> "something else"
}
// s = "two"
The _ is a catch-all. match does much more than literal lookup — it can pull values out of enums and tuples in one step. See Patterns for the full picture.
While loops
Repeat as long as a condition holds:
var n = 3
while n > 0 {
print("hello")
n = n - 1
}
// hellohellohello
Use break to exit a loop early, and continue to skip to the next iteration:
var i = 0
while true {
if i >= 5 { break }
if i % 2 == 0 {
i += 1
continue
}
println(i)
i += 1
}
// 1
// 3
For loops
Iterate over anything that can be iterated. Most commonly, that’s an array or a count:
let arr = [1, 2, 3, 4, 5]
var sum = 0
for n in arr {
sum = sum + n
}
println(sum) // 15
Iterating over an integer counts from 0 up to (but not including) that integer:
for i in 5 {
println(i) // prints 0, 1, 2, 3, 4
}
For an arbitrary range, use range(begin, end):
for i in range(2, 6) {
println(i) // prints 2, 3, 4, 5
}
for works on any type that implements Iterable (see Interfaces), so you can write your own.
Functions
A function packages up a piece of work so you can call it by name. Declare one with fn:
fn distance(x1, y1, x2, y2) {
sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
}
Calls look like in most languages:
let d = distance(0.0, 0.0, 3.0, 4.0) // 5.0
Parameter and return types
Parameter types are usually inferred. You can annotate them when you want to be explicit, and add a return type with ->:
fn double(x: int) -> int {
x * 2
}
Recursion
Functions can call themselves:
fn fibonacci(n: int) -> int {
if n < 2 {
n
} else {
fibonacci(n - 2) + fibonacci(n - 1)
}
}
Returning values
The last expression in the body is the return value — no return keyword needed:
fn double(x: int) -> int {
x * 2
}
Use return for an early exit:
fn fibonacci(n: int) -> int {
if n <= 1 {
return n
}
fibonacci(n - 2) + fibonacci(n - 1)
}
A function with no final expression returns void (which is nil):
fn display_message() {
for n in 5 {
println(n)
}
// implicitly returns nil
}
In a void-returning function, a bare return is shorthand for return nil:
fn maybe_log(msg: string, enabled: bool) {
if not enabled {
return
}
println(msg)
}
Expression-bodied functions
When the whole function is a single expression, write it after = instead of in a block:
fn square(x: int) -> int = x * x
fn distance(x1, y1, x2, y2) = sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
Default arguments
A parameter can have a default value. Callers can leave any trailing argument off:
fn greet(name: string, greeting: string = "Hello") {
println(greeting .. ", " .. name)
}
greet("Alice") // "Hello, Alice"
greet("Bob", "Howdy") // "Howdy, Bob"
Named arguments
You can pass an argument by its parameter name. This lets you skip past parameters that have defaults:
fn greet(name: string, greeting: string = "Hello", excited: bool = false) {
let punct = if excited { "!" } else { "." }
println(greeting .. ", " .. name .. punct)
}
greet("Carol", excited = true) // "Hello, Carol!"
greet("Dave", greeting = "Hi", excited = true)
Named arguments must come after all positional arguments.
Lambdas
A lambda is an anonymous function written inline. Use lambdas to pass behavior as a value: as an argument to another function, or stored in a variable.
Single argument
When a lambda has exactly one parameter, parentheses are optional:
let double = x -> x * 2
let n = double(5) // 10
Multiple arguments
Wrap the parameter list in parentheses:
let add = (a, b) -> a + b
let s = add(3, 4) // 7
Type annotations
Parameter and return types can be annotated, but are usually inferred:
let add = (a: int, b: int) -> a + b
Block body
A lambda body can be a block instead of a single expression. The last expression in the block is the return value:
let greet = (name: string) -> {
let msg = "Hello, " .. name
println(msg)
msg
}
Lambdas as arguments
Many standard library functions take lambdas. For example, sort_by takes a comparator:
let arr = [3, 1, 4, 1, 5, 9]
arr.sort_by((a, b) -> a <= b)
Capturing values
A lambda captures the values of variables in its enclosing scope by value at the time the lambda is created. Captured values can be read inside the lambda body but cannot be reassigned.
let multiplier = 10
let scale = x -> x * multiplier
let n = scale(5) // 50
Lambda types
A lambda’s type is written (ArgType, ...) -> ReturnType:
let f: (int, int) -> int = (a, b) -> a + b
let p: (string) -> bool = s -> s.starts_with("yes")
Limitations
Lambdas do not support default argument values or named-argument calls. Those features only work on named functions declared with fn.
Tasks and Channels
Sometimes you want your program to start one piece of work and then keep going. For example, a game might listen for input while something else is loading, or a program might start a slow calculation and wait for the answer later.
In Abra, use task to start another piece of code:
task {
println("hello from the task")
}
println("hello from the main program")
The code inside the task can run alongside the code after it. Abra’s scheduler decides when each task gets a turn, so you should not write code that depends on which println happens first.
A task does not return a value to the place where it was created. If you want a task to send a value back, use a channel.
Capturing values
A task can use values from the surrounding code:
let name = "Ada"
task {
println("hello, " .. name)
}
When a task starts, Abra gives it its own copy of the values it uses. This is important for arrays, structs, strings, and other data that could otherwise be changed from two places at once.
let numbers = [1, 2, 3]
let done: channel<int> = channel()
task {
numbers.push(4)
done.write(numbers.len())
}
println(numbers.len()) // 3
println(done.read()) // 4
The task gets a copy of numbers, so changing it inside the task does not change numbers in the main program.
Channels
A channel is a way for tasks to pass messages to each other. A channel has a type, so a channel<string> can carry strings, and a channel<int> can carry integers.
let messages: channel<string> = channel()
Use .write(...) to send a value into a channel, and .read() to take a value out:
messages.write("done")
let msg = messages.read()
If a task tries to read from an empty channel, it waits until some other task writes a value.
Channels are also how you intentionally share something between tasks. Ordinary captured values are copied, but a captured channel still points to the same channel.
Sending a result back
Here is a task that doubles a number and sends the answer back:
let answers: channel<int> = channel()
task {
let n = 21
answers.write(n * 2)
}
println(answers.read()) // 42
The main program waits at answers.read() until the task writes the answer.
You can also use one channel for work going into a task, and another channel for results coming back:
let jobs: channel<int> = channel()
let results: channel<int> = channel()
task {
for _ in 10 {
let n = jobs.read()
results.write(n * 2)
}
}
for i in 10 {
jobs.write(i)
}
var sum = 0
for _ in 10 {
sum += results.read()
}
println(sum) // 90
Waiting for a task
The main program does not automatically wait for every task to finish. When the main program is done, the whole program is done.
If the main program needs to wait for a task, have the task send a message:
let done: channel<void> = channel()
task {
println("working")
done.write(nil)
}
done.read()
println("finished")
This pattern is common: start a task, let it do work, and use a channel when you need to hear back from it.
Structs
A struct groups several named fields into one value. Use them when you have a few related pieces of data that belong together — coordinates, user records, configuration, anything where naming the fields makes the code clearer.
type Person = {
first_name: string
last_name: string
age: int
}
Construct an instance by calling the type name like a function. Arguments go in field order:
let frank = Person("Frank", "Smith", 34)
Read fields with .:
let fullname = frank.first_name .. " " .. frank.last_name
// "Frank Smith"
And update them the same way:
frank.age = frank.age + 1 // 35
Default field values
A field can have a default value. When constructing the struct, callers can leave any trailing field off:
type Greeter = {
name: string
greeting: string = "Hello"
excited: bool = false
}
extend Greeter {
fn greet(self) {
let punct = if self.excited { "!" } else { "." }
println(self.greeting .. ", " .. self.name .. punct)
}
}
Greeter("Alice").greet() // "Hello, Alice."
Greeter("Bob", "Howdy").greet() // "Howdy, Bob."
Named field arguments
You can also pass a field by name, which lets you skip past fields that have defaults:
Greeter("Carol", excited = true).greet() // "Hello, Carol!"
Greeter("Dave", greeting = "Hi", excited = true).greet() // "Hi, Dave!"
Named arguments must come after all positional arguments.
Generic structs
A struct can take a type parameter, so it works with any element type:
type Ref<T> = {
value: T
}
let int_ref = Ref(42) // Ref<int>
let str_ref = Ref("hello") // Ref<string>
See Generics for more.
Adding behavior
To attach methods to a struct, use extend. To make it work with ==, sorting, printing, or other operators, implement an interface for it.
Enums
An enum is a type whose value is one of a fixed set of variants. Use them when a value can be one of a few distinct things — a color, a direction, the result of a network request, the kind of token a parser just read.
type Color =
| Red
| Green
| Blue
Construct a variant by qualifying it with the enum name:
let c = Color.Red
When Abra can infer the enum type from context — a type annotation, a function parameter, a comparison against another value of the same enum — you can drop the prefix and write just the variant with a leading dot:
let c: Color = .Red
fn paint(c: Color) { ... }
paint(.Green)
If the type can’t be inferred at the call site, the leading-dot form won’t compile and you’ll need to fully qualify the variant.
Variants with data
Each variant can carry its own data. Construct one by passing the data as arguments, the same way you’d construct a struct:
type Shape =
| Circle(float)
| Rectangle(float, float)
| Origin
let c = Shape.Circle(5.0)
let r = Shape.Rectangle(2.0, 4.0)
let o = Shape.Origin
Or with leading-dot syntax when the type is known from context:
fn area(s: Shape) -> float { ... }
area(.Circle(5.0))
area(.Rectangle(2.0, 4.0))
area(.Origin)
Named fields and defaults
A variant’s fields can be named, and named fields can have default values:
type Color =
| Rgb(red: int = 0, green: int = 0, blue: int = 0)
| Named(string)
let red = Color.Rgb(red = 255)
let yellow = Color.Rgb(red = 255, green = 255)
let teal = Color.Rgb(green = 128, blue = 128)
At a call site, named arguments may appear in any order, but must come after any positional ones. If every field has a default, you can call the constructor with no arguments at all (Color.Rgb()).
A variant’s fields must be either all named or all unnamed — you can’t mix the two within a single variant.
Handling all the variants
Use match to do something different for each case. The compiler checks that you’ve covered every variant, so you can’t forget one:
let pi = 3.14159
fn area(s: Shape) -> float {
match s {
.Circle(r) -> pi * r * r
.Rectangle(w, h) -> w * h
.Origin -> 0.0
}
}
If you later add a new variant to Shape, every match on Shape will start failing to compile until you handle it. That’s exactly what you want.
Generic enums
Enums can take type parameters too. The standard library uses this for option and result:
type option<T> =
| some(T)
| none
type result<T, E> =
| ok(T)
| err(E)
These two come up everywhere — see Error Handling.
Patterns
A pattern is a way to look at a piece of data and ask “is it shaped like this — and if so, can you pull these parts out for me?” Patterns let you match against a value and unpack it at the same time, in a single step.
You’ve already seen patterns if you’ve used a match expression. Here’s a small one:
match command {
"quit" -> exit_game()
"help" -> show_help()
_ -> println("unknown command")
}
Each line on the left side of -> is a pattern. The first pattern that matches command wins, and its right side runs.
Where patterns appear
Three places in Abra accept patterns:
matchexpressions — choose a branch based on the shape of a value.letandvarbindings — pull values apart while binding them to names.forloops — pull apart each item as you iterate.
The kinds of patterns
Match a specific value
A literal pattern matches one specific value. Useful for small lookups:
fn day_name(day: int) -> string {
match day {
1 -> "Monday"
2 -> "Tuesday"
3 -> "Wednesday"
4 -> "Thursday"
5 -> "Friday"
_ -> "weekend"
}
}
Literals can be int, float, string, bool, or nil.
Catch everything else
The wildcard _ matches any value but doesn’t bind it to a name. It’s how you write “anything else”:
match user_input {
"yes" -> proceed()
"no" -> stop()
_ -> println("please answer yes or no")
}
Bind a value to a name
A bare identifier matches anything and binds the value to that name. Use it when you want to do something with the value, not just check what it is:
match get_age() {
0 -> println("just born!")
age -> println("age is " .. age)
}
In the second arm, age is a fresh variable that holds whatever get_age() returned.
Pull data out of an enum
If a value is an enum variant, you can match the variant and bind the data it carries in one step. Use leading-dot syntax to name the variant:
type Shape =
| Circle(float)
| Rectangle(float, float)
| Origin
fn area(s: Shape) -> float {
match s {
.Circle(r) -> 3.14 * r * r
.Rectangle(w, h) -> w * h
.Origin -> 0.0
}
}
.Circle(r) says “if s is a Circle, run this branch — and let me call its inner float r”.
This is the most common use of patterns. It’s how you handle option and result:
match read("config.txt") {
.ok(text) -> println(text)
.err(e) -> println("oops: " .. e)
}
match user.try_get("nickname") {
.some(name) -> println("hi, " .. name)
.none -> println("hi, anonymous")
}
Pull tuples apart
Tuple patterns destructure tuples by writing one. You can mix wildcards, literals, and bindings:
match point {
(0, 0) -> "at the origin"
(0, y) -> "on the y-axis at height " .. y
(x, 0) -> "on the x-axis at position " .. x
(x, y) -> "somewhere at (" .. x .. ", " .. y .. ")"
}
Tuple destructuring is the most common form of pattern in let:
let (x, y) = get_position()
let (name, age) = ("Alice", 30)
let pairs = [(1, "one"), (2, "two"), (3, "three")]
for pair in pairs {
let (number, word) = pair
println(number .. " = " .. word)
}
Combine patterns inside other patterns
Patterns nest. You can put a variant pattern inside a tuple pattern, a tuple pattern inside a variant pattern, and so on. This is useful when you want to handle several pieces of state together:
// Both players have to be ready before the game starts
match (player1.status, player2.status) {
(.Ready, .Ready) -> start_game()
(.Ready, _) -> println("waiting for player 2")
(_, .Ready) -> println("waiting for player 1")
_ -> println("waiting for both players")
}
You can also peer into nested enums:
// Walk a linked list and pull out the head if it exists
match maybe_list {
.some(.cons(head, _)) -> "first element is " .. head
.some(.empty) -> "list is empty"
.none -> "no list"
}
Exhaustiveness — the compiler has your back
When you write a match, the compiler checks that you’ve covered every possible value. If you forget a variant, you get a clear error pointing at what’s missing.
type Color = Red | Green | Blue
// Compile error: missing pattern .Blue
match c {
.Red -> "stop"
.Green -> "go"
}
This is one of the best things about patterns: refactoring becomes much safer. If you add a new variant to an enum, every match on that enum will tell you it needs to be updated. Use _ as a catch-all when you genuinely want to ignore the rest, but reach for it sparingly — explicit cases are easier to maintain.
Member Functions
A member function is a function attached to a type, callable with method syntax (value.method()). Add member functions to your own types — and even to built-in ones — with extend.
type Person = {
first_name: string
last_name: string
age: int
}
extend Person {
fn fullname(self) -> string {
self.first_name .. " " .. self.last_name
}
}
let p = Person("Arthur", "Pendragon", 15)
let name = p.fullname() // "Arthur Pendragon"
The first parameter is named self and refers to the value the method is called on.
You can extend built-in types the same way. This is how array<T> gets its methods:
extend array<T> {
fn len(self) -> int {
array_length(self)
}
fn push(self, x: T) -> void {
array_push(self, x)
}
fn pop(self) -> void {
array_pop(self)
}
}
let arr = [0, 1, 2, 3, 4]
arr.push(5)
let l = arr.len() // 6
Two ways to call
When you write p.fullname(), the value before the dot is passed as the first argument. So these two calls do the same thing:
let name = p.fullname()
let name = Person.fullname(p)
Method syntax is the more common form, but the qualified form is useful when the type isn’t obvious from context.
Static methods
A member function with no self parameter is a static method — it’s called on the type, not an instance. This is the usual way to write constructors:
extend Person {
fn new(first: string, last: string) -> Person {
Person(first, last, 0)
}
}
let p = Person.new("Arthur", "Pendragon")
Interfaces
An interface is a contract: a set of methods that a type can support. Once a type implements an interface, you can use it anywhere that interface is required. If you’ve used traits in Rust or typeclasses in Haskell, this should feel familiar.
A simple example: the ToString interface says “I know how to convert myself to a string”. Lots of types implement it — int, bool, array<T>, and so on. That’s why println and the .. operator can take values of any of those types.
Implementing an interface
Define an interface with interface. Implement it for a specific type with implement.
interface ToString {
fn str(self) -> string
}
type Person = {
first_name: string
last_name: string
age: int
}
implement ToString for Person {
fn str(self) -> string {
self.first_name .. " " .. self.last_name .. ", " .. self.age
}
}
let p = Person("Arthur", "Pendragon", 15)
println(p.str()) // "Arthur Pendragon, 15"
println("Hi, " .. p) // "Hi, Arthur Pendragon, 15" — `..` uses ToString
The first parameter is self and refers to the value the method is called on. You don’t need to write its type — it’s inferred to be the type you’re implementing for.
Interfaces in the prelude
Abra’s prelude defines a handful of interfaces that the language itself relies on. Implementing them makes your types work with familiar syntax — operators, for loops, indexing, and so on.
ToString
Convert a value to a string. The .. operator and println use this:
let arr = [1, 2, 3]
println("got " .. arr) // "got [ 1, 2, 3 ]"
Equal
Compare two values for equality with == and !=. Implement it on your own type to make it comparable:
implement Equal for Point {
fn equal(a, b) {
a.x == b.x and a.y == b.y
}
}
Ord
Defines <, <=, >, >=. Required by methods like array.sort().
Num
Powers +, -, *, /, ^. Implemented for int and float. (You usually wouldn’t implement this for your own types, but you can.)
Clone
Make a deep copy of a value:
let arr = [1, 2, 3]
let arr2 = arr.clone()
arr2.pop()
arr2.pop()
arr2.pop()
// arr = [1, 2, 3]
// arr2 = []
Hash
Required for any type used as a map or set key.
Iterable and Iterator
Make your type usable in a for loop. Iterable produces an Iterator, which yields one value at a time. The built-in array<T>, range, and int all implement Iterable — that’s why you can write for x in [1, 2, 3] or for i in 5.
If you want to iterate over a custom container, implement these. See Output types below for the details.
Index
Powers x[i] and x[i] = v. Implemented for array<T>, map, and JsonValue.
Unwrap and Try
Power the ! and ? operators. Both are implemented for option and result.
Output types
Some interfaces have a method whose return type depends on the implementing type. The Iterable interface is the classic case — different containers produce different kinds of iterators. An array<T> makes an ArrayIterator<T>; a hypothetical tree<T> would make a TreeIterator<T>. They’re not the same type, but each one is a valid iterator.
Abra handles this with output types. An interface declares one or more outputtypes, and each implementation fills them in.
interface Iterable {
outputtype IterableItem
outputtype Iter impl Iterator<IteratorItem=IterableItem>
fn make_iterator(self) -> Iter
}
interface Iterator {
outputtype IteratorItem
fn next(self) -> option<IteratorItem>
}
Without output types, an interface like Iterable would have to be parameterized over both the item type and the iterator type, which would mean a lot more type annotations everywhere it’s used.
Generics
A generic function or type works with many different types. Instead of writing one identity for int and another for string, write one that works for any type:
fn identity(x: T) -> T {
x
}
let a = identity(5) // T is int
let b = identity("hello") // T is string
The T is a type variable — a placeholder that stands in for whatever real type the caller uses. Abra figures out what T should be at each call site.
By convention, type variables are a single uppercase letter, optionally followed by digits: T, U, K, V, T2. Names like Item or Key aren’t type variables — they’d be parsed as ordinary identifiers.
Generic types
Structs and enums can take type parameters too. Put them in angle brackets after the name:
type Pair<T> = {
first: T
second: T
}
type Either<A, B> =
| Left(A)
| Right(B)
let p = Pair(1, 2) // Pair<int>
let e: Either<string, int> = .Left("oops")
Constraints
A generic function can require its type variable to support certain operations. You spell that out with an interface constraint:
fn max(a: T Ord, b: T) -> T {
if a > b { a } else { b }
}
The T Ord says “any type, as long as it implements Ord”. This lets you use > on a and b inside the body.
You can list multiple constraints:
fn find_and_show(arr: array<T Equal ToString>, target: T) {
if arr.contains(target) {
println("found " .. target)
}
}
You only need to write the constraint once. Other parameters that mention the same T automatically get the same constraints.
Wildcards in type annotations
Sometimes you only want to fix part of a generic type and let the compiler figure out the rest. Use _ for the parts to infer:
use core/map
let m: map<_, string> = map.new() // key type filled in below
m.insert(1, "hello")
m.insert(2, "world")
Performance
Each combination of type arguments gets its own compiled copy of the function — there’s no runtime cost to using generics. (This is called monomorphization if you want a name for it.)
Error Handling
Error Handling
Abra uses two prelude types to represent operations that can fail: option (for “value or nothing”) and result (for “value or error”).
option
Use option<T> when the only failure mode is “no value”:
type option<T> =
| some(T)
| none
fn safe_divide(a: float, b: float) -> option<float> {
if b == 0.0 {
.none
} else {
.some(a / b)
}
}
result
Use result<T, E> when failure carries information about what went wrong:
type result<T, E> =
| ok(T)
| err(E)
Most standard library functions that can fail return a result. For example, core/fs::read returns result<string, FsError>:
use core/fs
match read("greeting.txt") {
.ok(contents) -> println(contents)
.err(e) -> println("could not read: " .. e)
}
Unwrap (!)
The unwrap operator ! extracts the value from a some or ok, panicking if it’s a none or err. Use it when you know the value is present.
let a = "10".to_int()! // panics if not a valid int
let b = "5".to_int()!
println(a + b) // prints "15"
! is syntactic sugar for the Unwrap interface, which is implemented for both option and result.
Try (?)
The try operator ? propagates a none or err to the caller, returning early. The enclosing function must return a compatible type.
use core/fs
fn concat_files(a: string, b: string) -> result<string, FsError> {
let first = read(a)? // early-return if read fails
let second = read(b)?
.ok(first .. second)
}
If read(a) returns .err(e), concat_files returns .err(e) immediately and the rest of the function does not run.
? works with option too, but the enclosing function must return an option:
fn first_two_chars(s: string) -> option<string> {
let chars = s.chars()
if chars.len() < 2 {
return .none
}
.some(chars[0] .. chars[1])
}
Converting between error types
When you want to bubble up an error but the error types don’t match, use result.map_err to convert:
use core/fs
fn run() -> result<string, string> {
let contents = read("file.txt").map_err(e -> e.str())?
.ok(contents)
}
Here read returns result<string, FsError> but run returns result<string, string>, so we convert FsError to string before propagating.
Namespaces
Each Abra file has its own namespace determined by the name of the file and its parent directories (relative to the entry point of the program).
For instance, if the entry point is main.abra and beside that main file, there is a hat.abra and a
weapons/sword.abra, like so:
- main.abra
- hat.abra
- [weapons]
- sword.abra
- spear.abra
and if the contents of those files are as follows
// hat.abra
fn wear() {
println("you are wearing the hat")
}
fn pull_out_rabbit() {
println("pulled a white rabbit out of the hat")
}
// sword.abra
fn swing() {
println("SWOOSH")
}
// halberd.abra
fn swing() {
println("SWOOSH")
}
fn stab() {
println("SWOOSH")
}
Then the fully qualified names of those functions would be hat.wear, weapons.sword.swing, and
weapons.halberd.swing respectively.
By default, the functions in hat.abra, sword.abra, and halberd.abra are not accessible to main.abra.
In order to use functions from another file, the use keyword must be used to bring them into scope.
// main.abra
use hat
use weapons/sword
wear()
swing()
By default, the use keyword makes all symbols visible.
This is a convenient default, but it leads to name clashes in large projects.
To avoid name clashes, users can either:
-
Use the
usekeyword with theexceptkeyword to exclude an unwanted symboluse weapons/sword use weapons/halberd except swing swing() // sword.swing stab() // halberd.stab -
Explicitly import individual symbols
use weapons/sword.swing use weapons/halberd.stab swing() // sword.swing stab() // halberd.stabNote: multiple symbols can be imported from a namespace like this:
use weapons/halberd.(swing,stab) swing() // halberd.swing stab() // halberd.stab -
Import symbols under a user-specified prefix
use weapons/sword as sword use weapons/halberd as polearm sword.swing() // sword.swing polearm.stab() // halberd.stab
Standard Library
Abra ships with a small collection of modules under core/ and ard/. Bring a module into scope with use:
use core/strings
use core/map
use ard/term
A few things — the most fundamental types, interfaces, and functions — are always available without use. These come from the prelude, which is implicitly imported into every Abra file.
Prelude
Built-in functions
The prelude provides basic I/O and program control:
println("hello") // print with newline (also: print, panic, assert)
let line = readline() // read a line from stdin
let args = get_args() // command-line arguments as array<string>
Built-in types
option<T> represents a value that might be missing; result<T, E> represents a value that might be an error. Both are heavily used by the standard library:
let n: option<int> = "42".to_int() // .some(42) or .none
let r: result<string, FsError> = read("file.txt")
range is a half-open numeric interval, used in for loops and slicing:
for i in range(1, 10) { println(i) } // 1 through 9
Built-in interfaces
These power most of Abra’s operators. You’ll usually use them implicitly, by writing + or ==. To make your own type work with these operators, implement the corresponding interface.
| Interface | Powers | Notes |
|---|---|---|
Num | + - * / ^ | Implemented for int and float |
Equal | == != | |
Ord | < <= > >= | |
Hash | (used by map and set keys) | |
Clone | Deep-copy a value | |
ToString | .. and print/println | Convert any value to a string |
Unwrap | ! | Pull value out of option or result |
Try | ? | Early-return on none / err |
Index | x[i] and x[i] = v | Bracket access for arrays, maps, etc. |
Iterable | for x in y | Lets a type be used in a for loop |
Array methods
Arrays come with a useful set of methods built in. Most are obvious from their names:
let xs = [3, 1, 4, 1, 5, 9, 2, 6]
xs.len() // 8
xs.contains(4) // true
xs.find(5) // .some(4) — index of first match
xs.sort() // in-place, requires Ord
xs.push(7)
xs.pop()
let zeros = array.filled(0, 5) // [0, 0, 0, 0, 0]
xs.sort_by((a, b) -> a > b) // sort descending
core/strings
String manipulation: slicing, splitting, searching, trimming, case conversion, and parsing numbers.
use core/strings
let csv = " alice, bob, carol "
let names = csv.trim().split(",")
for name in names {
println(name.trim().to_upper())
}
// ALICE
// BOB
// CAROL
let n = "42".to_int()! // parse as int
core/map
Hash map with separate chaining. Keys must implement Hash and Equal. Bracket syntax (m[k], m[k] = v) works in addition to insert/get.
use core/map
let counts: map<string, int> = map.new()
for word in ["apple", "banana", "apple", "cherry", "apple"] {
let n = match counts.try_get(word) {
.some(c) -> c
.none -> 0
}
counts[word] = n + 1
}
println(counts["apple"]) // 3
core/set
Hash set built on core/map. Useful for fast membership checks and deduplication. Element type must implement Hash and Equal.
use core/set
let seen: set<int> = set.new()
let unique = []
for n in [1, 2, 2, 3, 1, 4] {
if not seen.contains(n) {
seen.insert(n)
unique.push(n)
}
}
println(unique) // [ 1, 2, 3, 4 ]
core/math
Small grab-bag: absolute value, generic min/max, integer bounds.
use core/math
let n = (-5).abs() // 5
let m = max(10, 20) // 20
let bound = int_max() // 9223372036854775807
core/linked_list
An immutable singly-linked list with the usual functional combinators (map, filter, fold, reverse). Useful for recursive algorithms and small functional programs.
use core/linked_list
let xs = list.from_array([1, 2, 3, 4, 5])
let evens_doubled = xs.filter(x -> x % 2 == 0).map(x -> x * 2)
println(evens_doubled) // [ 4, 8 ]
core/fs
File system operations: read and write files, manage directories, query metadata. Most functions return result<T, FsError> so you can use ? to propagate failures.
use core/fs
fn copy_if_missing(src: string, dest: string) -> result<void, FsError> {
if exists(dest)? {
return .ok(nil)
}
let contents = read(src)?
write(dest, contents)
}
core/env
Read and write environment variables.
use core/env
let home = get_var("HOME")
set_var("MY_FLAG", "1")
core/exec
Run external processes. There are simple one-liners and a builder for more control (arguments with spaces, stdin, pipelines).
use core/exec
// Quick: run via the shell
let out = run_sh("ls | wc -l")!
println(out.stdout.trim())
// Builder: pipe input through a command
let out = exec("cat").stdin("a\nb\nc\n").pipe("wc", ["-l"]).run()!
println(out.stdout.trim()) // 3
core/random
Random integers and floats over half-open ranges.
use core/random
let roll = random_int(1, 7) // 1 through 6
let jitter = random_float(0.0, 1.0)
core/time
Wall-clock time, sleep, and a DateTime type with strftime-style formatting.
use core/time
let start = get_time()
sleep(0.5)
println("waited " .. elapsed(start) .. "s")
let now = now_local()
println(format(now, "%Y-%m-%d %H:%M:%S"))
println("today is " .. now.weekday())
core/json
Parse and serialize JSON. Bracket syntax indexes into objects; .try_get(key) is the safe alternative.
use core/json
let data = parse("""{"name": "Alice", "scores": [10, 20, 30]}""")!
println(data["name"].as_string()!) // Alice
println(data["scores"].at(1).as_number()!) // 20.0
// Build a JSON value
let out = JsonValue.object()
out["greeting"] = .Str("hello")
out["count"] = .Number(3.0)
println(stringify_pretty(out))
core/http
A small HTTP client built on top of core/json. Convenience functions for get/post, and a builder for full control.
use core/http
use core/json
let resp = get("https://api.example.com/items/42")!
if resp.ok() {
let item = resp.json()!
println(item["name"].as_string()!)
}
core/regex
Regular expressions: testing, finding, replacing, splitting, and extracting capture groups. Uses Rust’s regex engine.
use core/regex
let s = "Build #1234 on 2026-05-02"
let m = find(s, "\\d{4}-\\d{2}-\\d{2}")!
println(m.text) // 2026-05-02
let parts = split("foo, bar,baz , qux", "\\s*,\\s*")
println(parts) // [ foo, bar, baz, qux ]
core/colors
ANSI escape-code helpers for coloring and styling terminal output. Each function takes any value that implements ToString. Compose by nesting.
- Foreground:
black,red,green,yellow,blue,magenta,cyan,white, andbright_*versions of each. - Background:
on_black,on_red,on_green,on_yellow,on_blue,on_magenta,on_cyan,on_white. - Attributes:
bold,dim,italic,underline,reverse,strikethrough.
use core/colors
println(red("error: ") .. "could not connect")
println(bold(green("Success!")))
println(on_yellow(black(" WARNING ")))
ard/term
Build text-based interactive programs in the terminal, backed by crossterm. Provides raw mode, an alternate screen, drawing and cursor primitives, and a unified event stream covering keys, mouse, and resize. The example programs snake.abra, mandelbrot.abra, and game_of_life.abra are good starting points.
use ard/term
use core/time
enable_raw_mode()
enter_alternate_screen()
hide_cursor()
clear()
var quit = false
while not quit {
mark("press q to quit", 0, 0)
flush()
while poll_event(1) {
match get_event() {
.Key(key, _) -> match key {
.Char('q') -> quit = true
.Esc -> quit = true
_ -> {}
}
.Resize(w, h) -> {} // terminal was resized to w x h
_ -> {}
}
}
sleep(0.05)
}
show_cursor()
leave_alternate_screen()
disable_raw_mode()
flush()
Types
Event = Key(KeyCode, Modifiers) | Mouse(MouseEvent, Modifiers) | Resize(int, int) | OtherKeyCode = Char(string) | Enter | Tab | BackTab | Backspace | Delete | Insert | Esc | Left | Right | Up | Down | Home | End | PageUp | PageDown | Function(int) | Other—Function(n)represents F1 through F12.Modifiers = { ctrl: bool, alt: bool, shift: bool }MouseButton = LeftButton | RightButton | MiddleButtonMouseEvent = Down(MouseButton, int, int) | Up(MouseButton, int, int) | Drag(MouseButton, int, int) | Move(int, int) | ScrollUp(int, int) | ScrollDown(int, int)— coordinates are(x, y).
Functions
- Modes:
enable_raw_mode,disable_raw_mode,enter_alternate_screen,leave_alternate_screen,enable_mouse,disable_mouse,set_title(s),bell(). - Events:
poll_event(ms) -> bool,get_event() -> Event. - Drawing:
clear,clear_line,clear_to_end_of_line,clear_to_end_of_screen,mark(s, x, y),flush. - Cursor:
hide_cursor,show_cursor,move_to(x, y),move_up(n),move_down(n),move_left(n),move_right(n),save_cursor,restore_cursor. - Size:
get_size() -> option<(int, int)>returns(cols, rows).
Foreign Functions
Sometimes you want to do something Abra can’t do on its own — talk to the file system, send a network request, or run code that needs to be as fast as possible. Foreign functions let you call Rust from Abra.
The flow looks like this: you write the function signature in Abra, then write the actual implementation as a normal safe Rust function. A build script wires the two together.
Declaring a foreign function
In an Abra file, mark a function with #foreign and leave the body off:
// in os.abra
#foreign
fn fread(path: string) -> string
#foreign
fn fwrite(path: string, contents: string) -> void
That’s all the Abra side looks like. The compiler trusts that an implementation will exist at runtime.
Project layout
Each top-level namespace that has foreign declarations gets one Rust project, in a rust_project/ directory next to the Abra modules. So if your top-level namespace is os, the layout looks like this:
- os.abra
- os/
- abra_foreign_module.txt
- exec.abra
- rust_project/
- Cargo.toml
- build.rs
- src/
- lib.rs (one-liner — see below)
- os/
- mod.rs
- exec.rs (impl for os/exec.abra)
- os.rs (impl for os.abra)
The Rust file path mirrors the Abra file path. A foreign function in os.abra is implemented in rust_project/src/os.rs; one in os/exec.abra lives in rust_project/src/os/exec.rs.
Cargo.toml
The Rust project produces a cdylib so the Abra runtime can load it dynamically. It depends on abra_core both as a regular dependency and as a build dependency:
[package]
name = "abra_module_os"
version = "0.0.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
abra_core = { workspace = true }
[build-dependencies]
abra_core = { workspace = true }
abra_foreign_module.txt
Every foreign module has a manifest next to the native module sources. The manifest tells the Abra runtime exactly where to load the dynamic library from:
../../target/{profile}/{library_prefix}abra_module_os{library_suffix}
The library path is relative to the directory containing abra_foreign_module.txt. The runtime expands {profile} to debug or release based on the build profile of the running Abra executable, {library_prefix} to the platform dynamic-library prefix, and {library_suffix} to the platform dynamic-library suffix.
build.rs
The build script reads your .abra files, finds the #foreign declarations, and generates the unsafe glue code that bridges the VM to your safe Rust functions. You only need one line:
fn main() {
abra_core::foreign_bindings::generate_bindings_for_crate();
}
src/lib.rs
lib.rs just pulls in the generated glue:
#![allow(unused)]
fn main() {
include!(concat!(env!("OUT_DIR"), "/lib.rs"));
}
You don’t edit this file.
Writing the implementation
Write each foreign function as an ordinary safe Rust function. The name and parameter types match the Abra declaration:
#![allow(unused)]
fn main() {
// in src/os.rs
use std::fs;
pub fn fread(path: String) -> String {
fs::read_to_string(path).expect("Unable to read file")
}
pub fn fwrite(path: String, contents: String) {
fs::write(path, contents).expect("Unable to write file");
}
}
Type mapping
Here’s how Abra types correspond to Rust types in your function signatures:
| Abra type | Rust type |
|---|---|
int | abra_core::vm::AbraInt (i64) |
float | f64 |
bool | bool |
string | String |
void | () |
option<T> | Option<T> |
result<T, E> | Result<T, E> |
array<T> | Vec<T> |
(T, U) | (T, U) |
#foreign type Foo | a generated Rust struct/enum named Foo |
Foreign types
You can also share a custom type between Abra and Rust. Mark a type declaration with #foreign, and the build script generates a matching Rust enum or struct:
#foreign
type FsError =
| NotFound(string)
| PermissionDenied(string)
| Other(string)
Import it from the generated module and use it in your function signatures:
#![allow(unused)]
fn main() {
use crate::ffi::core::fs::FsError;
pub fn read(path: String) -> Result<String, FsError> {
std::fs::read_to_string(&path).map_err(|e| FsError::Other(e.to_string()))
}
}
Building
Build the native module with cargo:
cargo build --package abra_module_os
The Abra runtime loads the cdylib declared by abra_foreign_module.txt the first time your program calls one of its foreign functions.
Host functions
There’s a related attribute, #host, for functions provided directly by the runtime that’s running your Abra program (rather than loaded from a cdylib). The prelude uses this for print_string, readline, and get_args. Most users never need to write #host functions — they’re a hook for programs that embed the Abra VM as a library and want to expose their own callbacks.