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:
- Giving beginner programmers a gentle introduction to writing code
- 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:
-
Use the
use
keyword with theexcept
keyword 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.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");
}