Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

About Abra

The primary goal of Abra is to be the most user-friendly programming language.

More specifically, Abra is optimized for these use cases:

  1. Giving beginner programmers a gentle introduction to writing code
  2. Allowing experienced programmers to prototype quickly

Abra does this through the combination of these features:

  • a strong static type system with error messages that guide the user
  • a garbage collector to remove the mental overhead of manually freeing memory or passing around allocators or writing lifetime annotations

Abra is not the only language to have a type system and a garbage collector. Some others include:

  • OCaml
  • Haskell
  • TypeScript
  • Java
  • C#

However, Abra is distinct from more mainstream choices like TypeScript, Java, C# because it has powerful Hindley-Milner-style type inference. 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 not optimized for these use cases and does not aim to be:

  • graphics
  • operating systems
  • embedded systems

Installation

To install Abra, download the repository from https://github.com/anandrav/abra. Then, run the script located at /scripts/install

git clone https://github.com/anandrav/abra
cd abra
./scripts/install

After, you should be able to run Abra from the command line

% abra --help
Usage: abra [OPTIONS] <FILE> [ARGS]...

Arguments:
  <FILE>     The main Abra file to compile and execute
  [ARGS]...  Arguments to pass to the Abra program

Options:
  -m, --modules <DIRECTORY>  Override the default module directory (~/.abra/modules).
  -h, --help                 Print help
  -V, --version              Print version

Requirements

Cargo

Cargo, the Rust package manager, is required to run the install script. The best way to install Cargo is by using rustup.

Node

If you're using Visual Studio Code and want to install the Abra extension, you'll want to install Node as well before running the install script.

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

int

A 64-bit signed integer. Supports addition, subtraction, multiplication, division, and exponentiation operators.

let a = 5
let b = 2 + 2   // 4
let c = 2 * 3   // 6
let d = 5 - 2   // 3
let e = 12 / 3  // 4
let f = 13 / 3  // also 4
let g = 2 ^ 3   // 8

let n = (a + c / d) ^ 2 // 49

float

A 64-bit signed floating point decimal number. Supports addition, subtraction, multiplication, division, and exponentiation operators.

let pi = 3.14159
let e = 2.71828

boolean

let shield_is_equipped = true
let sword_is_equipped = false

string

Garbage-collected unicode text data. Strings can be concatenated together using the & operator.

let name = "Merlin"
let message = name & " is a wizard." // "Merlin is a wizard."

array<'T>

A built-in dynamic array data structure. Supports random access (zero-indexed), pushing, and popping elements. Arrays are homogenous; elements of the array must have the same type i.e. an array cannot mix int and float elements, for instance.

let arr = [1, 2, 3, 4]  // create an array with some numbers
arr.push(5)             // arr = [1, 2, 3, 4, 5]
let n = arr.len()       // 5
let n = arr[2]          // 3
arr[2] := 49
let n = arr[2]          // 49
arr.pop()               // arr = [1, 2, 3, 4]

tuples

  • ('T, 'U)
  • ('T, 'U, 'V)
  • and so on...

A tuple is 2 or more values grouped together. It is sometimes useful to group related values together. For instance, a tuple of three floats could be used to represent a coordinate in 3d space. Unlike arrays, tuples can have elements of different types. Unlike arrays, tuples cannot have elements added or removed.

let pair_of_ints = (1, 2)
let coordinate_3d = (1.0, 2.0, -3.0)
let name_and_age = ("Lancelot", 19)

void

Represents nothing and only has one value -- (). This is useful as a return type for functions that don't return anything. This is sometimes referred to as the "unit type" in other languages because it has a single value.

never

The never type has zero values. This is useful as a return type for a function which should never return. For instance, the built-in panic() function, used to terminate the program in the case of an unrecoverable error, does not return a value.

Variables

Variables can be created using the let keyword.

let x = 5
let y = 8

Mutable variables can be created using the var keyword. Their value can be updated using the assignment := operator.

var x = 5       // x = 5
x := 6          // x = 6
let y = x + x   // y = 12

Variables can be given type annotations. For convenience, they are often not required.

In some situations, type annotations are required, for instance when writing code with generic types, invoking member functions, or accessing struct fields. They also help the compiler give more tailored error messages in case you make a mistake.

let x: int = 2
let y: float = 3.14
let p: (int, int) = (1, 2)

Control Flow

If-Else

If-else expressions are used to choose between two pieces of code. A boolean expression is given as an input. If the boolean value is true, the first piece of code is executed. If the boolean value is false, then the second piece of code is executed.

// this code prints "hello"

let is_arriving = true
if is_arriving {
    println("hello")
} else {
    println("good bye")
}

If-else expressions can be chained in succession to check multiple conditions.

// this code prints "good night"

let is_morning = false
let is_afternoon = false
if is_morning {
    println("good morning")
} else if is_afternoon {
    println("good afternoon")
} else {
    println("good night")
}

If-else expressions always return a value.

let x = if true { 1 } else { 0 }    // x = 1

Match

Match expressions test an expression against a set of patterns. The first pattern that matches will have its corresponding

let n = 2
let s = match n {
    0 -> "zero"
    1 -> "one"
    2 -> "two"
    3 -> "three"
    _ -> "something else"
}
// s = "two"

While loops

// this code prints
// "hellohellohello"

var n = 3
while n > 0 {
    print("hello")
    n := n - 1
}

For loops

// this code prints "15"

let arr = [1, 2, 3, 4, 5]
var sum = 0

for n in arr {
    sum := sum + n
}

println(sum)

Functions

A function is a piece of code that takes zero or more inputs and has a single output.

fn distance(x1: float, x2: float, x2: float, y2: float) -> float {
    sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
}

Functions can be recursive.

fn fibonacci(n: int) -> int {
    if n <= 1 {
        n
    } else {
        fib(n-2) + fib(n-1)
    }
}

The last expression in the body of the function is the return value. You can also return early from a function.

fn fibonacci(n: int) -> int {
    if n <= 1 {
        return n        // early return
    }
    fib(n-2) + fib(n-1) // last expression is return value
}

A function always returns the value of its body. If the body of a function is a block of statements, then the last expression of the block is the return value of the function.

Structs

Define a struct to group together related pieces of data.

type Person = {
    first_name: string,
    last_name: string,
    age: int,
}

Create an instance of a struct by calling its constructor, which is a function that shares the same name as the struct.

let frank = Person("Frank", "Smith", 34)

Access the fields of a struct and modify them by using dot . syntax.

let fullname = frank.first_name & " " & frank.last_name
// fullname = "Frank Smith"
frank.age := frank.age + 1
// age = 35

Structs can have generic type arguments.

type Ref<'T> = {
    value: 'T
}

Enums

Enums are useful for modeling data as a set of possible variants.

type Color =
  | Red
  | Blue
  | Green

Data can be attached to each variant of an enum.

type Shape =
    | Circle(float)
    | Rectangle(float, float)
    | Triangle(float, float)
    
let circle = Shape.Circle(5.0)
let rect = Shape.Rectangle(2.0, 4.0)

Match expressions are used to handle each possible case of an enum.

let c = Shape.Circle(5.2)
let area = match c {
    .Circle(radius) -> radius * radius * pi,
    .Rectangle(width, height) -> width * height,
    .Triangle(width, height) -> width * height * 0.5,
}

Enums can have generic type arguments.

type option<'T> =
  | some('T)
  | none

Member Functions

For convenience, you can define member functions on both user-defined and built-in types.

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"
extend array<'T> {
    fn len(self: array<'T>) -> int {
        array_length(self)
    }

    fn push(self: array<'T>, x: 'T) -> void {
        array_push(self, x)
    }

    fn pop(self: array<'T>) -> void {
        array_pop(self)
    }
}

...

let arr = [0, 1, 2, 3, 4]
arr.push(5)
let l = arr.len() // 6

In the examples above, a member function is invoked on a variable using the dot . operator. The variable is passed as the first argument to the member function.

Member functions can also be invoked on the type itself. In that case, the first argument to the function is passed in the parentheses.

let p = Person("Arthur", "Pendragon", 15)
let name = Person.fullname(p)

let arr = [1, 2, 3, 4]
array.push(arr)
let l = array.len(arr) // 6

Interfaces

An interface is a set of operations supported by some type named self.

A type implements an interface if it supports all its operations. As an example, the Num interface supports the add(), subtract(), multiply(), and divide() operations. The Number interface is implemented by both the int and float built-in types.

// support conversion to string
interface ToString {
    fn str(x: self) -> string
}

type Person = {
    first_name: string,
    last_name: string,
    age: int,
}

// support conversion to string for the Person type
impl ToString for Person {
    fn str(x: Person) -> string {
        x.first_name & " " & x.last_name & ", " x.age & " years old."
    }
}

...

let p = Person("Arthur", "Pendragon", 15)
let s = p.str() // "Arthur Pendragon 15"

Standard library interfaces

ToString

The ToString interface allows you to convert some type to a string. Any type which implements the ToString interface can be passed as an argument to the & operator.

let arr = [1, 2, 3]
let arr_str = arr.str()         // arr_str = "[1, 2, 3]"
let age = 23
let name = "John"
println(name & " is " & age)    // prints "John is 23"

Clone

The clone interface allows you to create a deep copy of some data structure. For instance, cloning an array allows you to manipulate its copy without manipulating the original.

let arr = [1, 2, 3]
let arr2 = arr.clone()
arr2.pop()
arr2.pop()
arr2.pop()
// arr = [1, 2, 3] and arr = []

Equal

The Equal interface is used to compare values for equality. Types which implement the Equal interface can be compared for equality using the = operator.

Num

The Num interface is used for int and float. Types which implement the Num interface can use the arithmetic operators.

Iterator

The Iterator interface is implemented by types which traverse over some data structure's elements. The Iterator interface is used by the Iterable interface, but Iterator can also be used on its own.

Iterable

The Iterable interface is implemented for container types in order to iterate through their values. Types which implement the Iterable interface can be used in a for loop. Types which implement the Iterable interface, such as array<'T>, have an output type which implements the Iterator interface. In the case of array<'T>, that iterator type is ArrayIterator<'T>.

Output types

Output types are used when the output of an interface's operations are determined by the type of the input. For instance, when implementing the Iterable interface, different container types will output different iterator types. For example, array<'T> returns an ArrayIterator<'T>, whereas a hashmap<'K,'V> would return a HashmapIterator<'K,'V>, which is a completely different struct. Similarly, a StringIterator, when implementing the Iterator interface, would always return a maybe<char,void>, so Item = char in that case.

Without output types, interfaces like Iterable would have to be parameterized over Item and Iter, which would require much more type annotations. This would also impede language features like operator overloading and the for-in loop, which lean on interfaces in the prelude such as Iterable and Iterator.

interface Iterable {
    outputtype Item
    outputtype Iter impl Iterator<Item=Item>

    fn make_iterator: ('a Iterable) -> Iter
}

interface Iterator {
    outputtype Item

    fn next: ('a Iterator) -> maybe<Item,void>
}

Error Handling

It is recommended to use the option type defined in the standard library as a return type for functions that can fail.

type option<'T> =
  | some of 'T
  | none
fn safe_divide(a: float, b: float) -> option<float> {
    if b = 0.0 {
        .none
    } else {
        .some(a / b)
    }
}

For convenience, you can use the unwrap operator ! to forcibly get the value from the some case, or invoke panic() if it's the none case. This is useful if you know that the result is guaranteed to be a success. The ! operator is syntactic sugar for calling .unwrap() on some value.

// assume string_to_int: string -> option<int>

let a = string_to_int("10")!
let b = string_to_int("5")!
println(a + b)

// prints "15"

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:

  1. Use the use keyword with the except keyword to exclude an unwanted symbol

    use weapons/sword
    use weapons/halberd except swing
    
    swing()         // sword.swing
    stab()          // halberd.stab
    
  2. Explicitly import individual symbols

    use weapons/sword.swing
    use weapons/halberd.stab
    
    swing()         // sword.swing
    stab()          // halberd.stab
    

    Note: multiple symbols can be imported from a namespace like this:

    use weapons/halberd.(swing,stab)
    
    swing()         // halberd.swing
    stab()          // halberd.stab
    

Foreign Functions

Functions implemented in Rust can be called from Abra. Foreign functions must be declared ahead of time in an Abra file, so that the compiler knows they exist. The compiler will search for a directory with the same name as the Abra file which contains a Rust project. That Rust project should use crate-type "cdylib" so that it can be loaded at runtime. It must take abra_core as a dependency. It is recommended to take abra_core as a build-dependency as well and to invoke generate_bindings_for_crate() in the project's build.rs. This will automatically generate the necessary unsafe glue code that corresponds with the foreign function declarations. Then, you can implement those foreign functions using safe Rust.

In order to avoid creating a Rust project (and therefore a cdylib) for every Abra file with a foreign function declaration, there should be a single Rust project for every toplevel namespace that contains foreign function declarations. For example, implementing a foreign function in os.abra (os is under the root namespace) should be done in os/rust_project/src/os.rs. Implementing a foreign function in os/exec.abra, which is under the os/ namespace, should be done in os/rust_project/src/os/exec.abra -- the same Rust project.

os.abra

foreign fn fread(path: string) -> string

foreign fn fwrite(path: string, contents: string) -> string

os/rust_project/Cargo.toml

[package]
name = "abra_module_os"

[lib]
crate-type = ["cdylib"]
[dependencies]
abra_core = { workspace = true }

[build-dependencies]
abra_core = { workspace = true }

os/rust_project/build.rs

fn main() {
    abra_core::addons::generate_bindings_for_crate();
}

os/rust_project/src/lib.rs (automatically generated)

use abra_core::vm::Vm;
use std::fs;

#[no_mangle]
pub unsafe extern "C" fn fread(vm: *mut Vm) {
    let string_view = vm_view_string(vm);
    let path = string_view.to_owned();
    vm_pop(vm);

    let content = fs::read_to_string(path).expect("Unable to read from file");

    let string_view = StringView::from_string(&content);
    vm_push_string(vm, string_view);
}

#[no_mangle]
pub unsafe extern "C" fn fwrite(vm: *mut Vm) {
    let string_view = vm_view_string(vm);
    let content = string_view.to_owned();
    vm_pop(vm);

    // TODO: make a macro for this called get_string!
    let string_view = vm_view_string(vm);
    let path = string_view.to_owned();
    vm_pop(vm);

    fs::write(path, content).expect("Unable to write to file");
}

os/rust_project/src/os.rs

use std::fs::self;
use std::io::Write;

pub fn fread(path: String) -> Result<String, String> {
    fs::read_to_string(path).map_err(|e| e.to_string())
}

pub fn fwrite(path: String, content: String) {
    fs::write(path, content).expect("Unable to write to file");
}