Welcome to Aria!
This document is intended to guide you on the Aria language, from the fundamentals to advanced features. It is not meant as an introduction to computer programming.
It assumes you have Aria installed, and know how to write code in a text editor and run the Aria interpreter on your system. It does not assume that you know Rust or are interested in learning it.
Aria programs contain a main
function, defined like
func main() {
# code goes here
}
A program can contain multiple functions, but execution starts from main
. A very simple program is the canonical Hello World
func main() {
println("Hello World");
}
Save it to hello.aria
and run it as aria hello.aria
. It should print Hello World
and return. There is no need to provide a return value from main
.
To declare a variable, use val
, as in
val x = 1;
val y = "Hello World";
val z = 3.14f;
Variables are mutable. Aria does not provide an immutable value construct.
Basic arithmetic works as one would expect
println(x + 1); # prints 2
println(3 + 4 * 5 - 1); # prints 22
Integers are signed 64-bit values and they wrap around, e.g.
func main() {
val x = 0x7FFFFFFFFFFFFFFF;
println(x); # prints 9223372036854775807
println(x+1); # prints -9223372036854775808
}
Lists are a builtin data type in Aria. They are defined by a list literal, e.g.
val l = [1, "hello", 3.14f, false];
A list can contain heterogeneous elements of any Aria type. It is dynamically resizable, by appending new elements:
l.append(5); # l is now [1, "hello", 3.14f, false, 5];
The size of a list can be obtained with l.len()
and individual elements can be indexed directly from 0 to len()-1
,
println(l[0]); # prints 1
l[1] = "hi there";
println(l[1]); # prints hi there
Lists can contain other lists
val l = [1,2,3];
val ll = [l,4];
println(ll[0][0]); # prints 1
Multidimensional arrays are not provided.
List concatenation uses the +
operator, so
println([1,2]+[3,4]); # prints [1,2,3,4]
Strings can be concatenated with the +
operator
println("Hello " + 'World'); # prints Hello World
and string repetition uses the *
operator
println("Chugga " * 2 + "Choo " * 2); # prints Chugga Chugga Choo Choo
Strings literals can use either "
or '
quotes, which makes some constructs easier to write
val quote = 'He said "Tu quoque, fili mi?"';
Strings can also be indexed with square brackets, but only for reading
val x = "hello";
println(x[0]); # prints h
String offer a formatting function, that allows interploating the values of variables or expression inside text. For example
val name = "Eric";
val year = 2025;
println("My name is {0} and the year is {1}".format(name, year)); # My name is Eric and the year is 2025
Common control flow statements and operators are available in Aria.
val x = 3;
if x == 3 {
println("yes!");
} else {
println("no!");
}
Alternative branches of an if
statement are labeled elsif
, as in
val x = 3;
if x == 1 {
println("one");
} elsif x == 2 {
println("two");
} elsif x == 3 {
println("three"); # prints three
} else {
println("I don't know");
}
There is no need to parenthesize the condition of an if
clause. The else
clause is optional.
while
loops also work as expected.
val x = 1;
while x < 10 {
println(x); # will print 1,2,3...
x += 1;
}
Only boolean values and expressions, can be used in control flow, for example the following is illegal:
val x = 3;
while x {
x -= 1;
println(x);
}
for
loops are used to iterate all over a container, for example a list.
val l = [2,4,6,8];
for x in l {
println(x); # prints 2, 4, 6, 8
}
Loops can be exited with break
, or jump to the next iteration with continue
, much like in other languages in the C family.
Both for
and while
loops can optionally accept an else
clause, which is executed if the body of the loop is never taken.
for x in [] {
println(x);
} else {
println("This list is empty!"); # prints This list is empty
}
val x = 0;
while x > 0 {
println("x is positive");
x -= 1;
} else {
println("x is <= 0"); # prints x is <= 0
}
A ternary operator is provided with very similar behavior to its C counterpart:
val num = 3;
val str = num == 1 ? "one" : num == 2 ? "two" : "three";
println(str); # prints three
Functions are defined with the func
keyword, and can take zero or more arguments. They are invoked in the usual manner, with their name followed by parentheses.
func return_answer() {
return 42;
}
func add_numbers(x,y) {
return x + y;
}
func main() {
println(return_answer()); # prints 42
println(add_numbers(3,4)); # prints 7
}
Functions may not return a value if they are not used as expressions, such as
func print_answer() {
println(42);
}
func main() {
print_answer(); # prints 42
}
Functions can accept a variable number of arguments, with 0 or more fixed (required) arguments before that. Variable arguments are stored in a list provided to the function named varargs
func add_all_values(x, ...) {
for arg in varargs {
x += arg;
}
return x;
}
func main() {
println(add_all_values(1,2,4,6,8)); # prints 21
println(add_all_values(5)); # prints 5
}
Closures are defined with a |args| => { body }
. Captures (if any) are implicitly handled by the Aria VM
func double(x) {
return x + x;
}
func call_f(f, x) {
return f(x);
}
func main() {
val answer = 42;
println(call_f(double, 12)); # prints 24
println(call_f(|x| => { return x + 1; }, 3)); # prints 4
println(call_f(|x| => {return x + answer; }, 2)); # prints 44
}
Structs are defined as a set of operations, not data. For example
struct Foo {
func blah() {
println("I am a Foo");
}
}
func main() {
val f = alloc(Foo);
f.blah();
}
Instances of structs are created with alloc
, which returns a new object of the struct type.
For builtin types, alloc
returns a default value (for example, 0 for integers and โโ for strings). Some types (e.g. functions, enums, โฆ) cannot be instantiated with alloc
because there is no obvious default for such values.
Instance functions are defined with the same syntax as free functions. It is not possible to overload methods.
To access fields (data) on a struct instance, one directly reads or writes the field.
struct Foo {
func blah() {
println("I am a Foo - my value is {0}".format(this.x));
}
}
func main() {
val f = alloc(Foo);
f.x = 5;
f.blah(); # prints I am a Foo - my value is 5
}
It is possible to define methods that interact with the struct type instead of any given instance. For example
struct Foo {
type func blahblah() {
println("I am the Foo struct");
}
}
func main() {
Foo.blahblah(); # prints I am the Foo struct
}
The common use of type
methods is to write constructors for structs
struct Foo {
type func new(x) {
return alloc(This) {
.x = x
};
}
func blah() {
println("I am a Foo - my value is {0}".format(this.x));
}
}
func main() {
val f = Foo.new(5);
f.blah(); # prints I am a Foo - my value is 5
}
By convention, construtors are named new
, or new_with_X
if there are multiple possible constructors supplying different arguments.
This pattern allows writing fields into a struct before the caller can access its operation, so objects are well-formed by definition.
Structs can contain each other, but not inherit each other.
When printing objects of struct type, if a prettyprint
method is defined, format
and println
call it and expect it to return a string that represents the object.
struct Foo {
type func new(x) {
return alloc(This) {
.x = x
};
}
func prettyprint() {
return "Foo({0})".format(this.x);
}
}
func main() {
val f = Foo.new(5);
println(f); # prints Foo(5)
}
Enumerations allow describing a closed set of possible values
enum TaskStatus {
case NotStarted,
case InProgress,
case Blocked,
case Completed
}
func main() {
val ts1 = TaskStatus::Completed;
val ts2 = TaskStatus::InProgress;
}
Enumeration cases can also contain a payload. Each case can contain at most one value, which can be of any type, including a struct. It is possible to define structs inside enums, e.g.
enum TaskStatus {
struct BlockedReason {
type func new(reason: String) {
return alloc(This) {
.reason = reason,
}
};
}
case NotStarted,
case InProgress(Int),
case Blocked(TaskStatus.BlockedReason),
case Completed
}
func main() {
val ts1 = TaskStatus::Completed;
val ts2 = TaskStatus::InProgress(45);
}
To check if an enumeration value is a specific case, helper is_X
methods are provided for each case. For cases with payload, unwrap_X
methods provide the payload, if the value is of that case.
enum TaskStatus {
case NotStarted,
case InProgress(Int),
case Blocked,
case Completed
}
func main() {
val ts1 = TaskStatus::Completed;
val ts2 = TaskStatus::InProgress(45);
println(ts1.is_Blocked()); # prints false
println(ts2.is_InProgress()); # prints true
println(ts2.unwrap_InProgress()); # prints 45
}
It is a runtime error to call unwrap_X
on an enumeration value not representing that case.
One can also use the match
statement to extract case and value from an enum, for example
enum TaskStatus {
case NotStarted,
case InProgress(Int),
case Blocked,
case Completed
}
func main() {
val ts1 = TaskStatus::Completed;
val ts2 = TaskStatus::InProgress(45);
match ts2 {
isa TaskStatus and case InProgress(x) => {
println("In Progress, {0}% completed".format(x));
},
isa TaskStatus and case Blocked => {
println("Blocked");
},
# ...
} # prints In Progress, 45% completed
}
The two clauses in match
above check that ts2 is of type TaskStatus
and the case that it covers. A case
check would only match the name of the case, but not the type (multiple enums could have the same case name). For enums with payload, the payload can also be extracted in the case
clause by providing an identifier.
More broadly, a match statement can also be used to check some simple comparisons, for example
func main() {
val x = 3;
match x {
>= 5 => { println("A very large number"); },
> 3 => { println("A good number"); },
== 3 => { println("It's three!"); }, # prints It's three!
> 0 => { println("A small positive number"); }
} else {
println("not sure!");
}
}
Valid operators are ==, !=, isa, >, >=, <, >= and they can be combined with and
clauses:
func main() {
val x = 3;
match x {
isa String and == "hello world" => { println("hi there!"); },
isa Int and >= 4 => { println("Four or more"); },
isa Int and > 0 and < 5 => { println("It might be three?"); }, # prints It might be three?
}
}
Maybe
is an enum that represents a potentially missing value. It is defined as
enum Maybe {
case Some(Any),
case None,
}
As an enum, it can be used in match
or checked with is_X
helpers. APIs where a value may be returned or not, and neither condition is an error, use Maybe
to represent that fact.
Maps are provided by the Aria standard library. To import the Map data type, use import Map from aria.structures.map;
. This gives access to the Map
data type.
Values can be inserted into a map by key
import Map from aria.structures.map;
func main() {
val m = Map.new();
m[1] = "one";
m[2] = "two";
}
and retrieved by key
import Map from aria.structures.map;
func main() {
val m = Map.new();
m[1] = "one";
m[2] = "two";
println(m[1]); # prints one
}
It is a runtime error to try to retrieve a missing key with the square brackets operator. In that case, use get
, which returns Maybe
.
import Map from aria.structures.map;
func main() {
val m = Map.new();
m[1] = "one";
m[2] = "two";
println(m.get(3).is_None()); # prints true
}
Maps can be iterated with for
loops, and they return pairs of key and value
import Map from aria.structures.map;
func main() {
val m = Map.new();
m[1] = "one";
m[2] = "two";
for kvp in m {
println("key = {0} value = {1}".format(kvp.key, kvp.value)); # prints key = 1 value = one key = 2 value = two
}
}
Custom types can be used as keys in maps, as long as they define a func hash()
.
Extensions allow to add new functions to already defined types. They are introduced by the extension
keyword, and they otherwise look the same as a definition of a type
struct Counter {
type func new() {
return alloc(This) {
.x = 0,
};
}
}
extension Counter {
func add(x) {
this.x += x;
return this.x;
}
}
extension Counter {
func increment() {
return this.add(1);
}
}
func main() {
val c = Counter.new();
c.add(2);
println(c.increment()); # prints 3
}
add
and increment
operate as if they were defined within the body of Counter
โs initial definition.
Enums can also be extended the same way
enum Temperature {
case Celsius(Float),
case Fahrenheit(Float)
}
extension Temperature {
func to_f() {
match this {
case Fahrenheit => { return this; },
case Celsius(c) => {
return Temperature::Fahrenheit(c*1.8f+32);
}
}
}
func prettyprint() {
match this {
case Fahrenheit(f) => {
return "{0} F".format(f);
}, case Celsius(c) => {
return "{0} C".format(c);
}
}
}
}
func main() {
val tmp = Temperature::Celsius(32.0f);
val tmp_f = tmp.to_f();
println(tmp_f); # prints 89.6 F
}
Exceptions can be used to generate out-of-band control flow. Any Aria object can be thrown and caught. Exceptions cause unwinding of the stack until a frame catches, or there are no more frames left, at which point it is handled by the VM itself.
func main() {
try {
println(9 / 3); # prints 3
println(2 /0);
} catch e {
println(e); # prints division by zero
}
}
catch
cannot discriminate on the type of the exception or its parameters. If a catch
block is unable to resolve an exception, it can throw it again.
The error handling philosophy of Aria is generally inspired by Rust and Midori:
assert
fails by throwing a non-recoverable VM error.Aria itself defines a set of common exceptions in the RuntimeError
enum:
These values can be reused by user code, or new exception types can be created. The usual pattern for an exception is to create an ad-hoc struct
struct MyException {
type func new(msg) {
return alloc(This) {
.msg = msg,
};
}
func prettyprint() {
return "{0}".format(this.msg);
}
}
Possibly, the exception itself can craft a message based on arguments provided to it, if that makes sense in the specific case. An enumeration can be used to discriminate if an error can occur for different causes, and itโs generally a better pattern than using an integer error code.
If an exception is not handled, the VM itself dumps the exception information and a stack trace. For example:
func do_division(x,y) {
return x / y;
}
func complex_math(x,y) {
val a = x + y;
val b = x - y;
val c = x * y;
val d = do_division(x,y);
return a + b + c - d;
}
func main() {
println(complex_math(7,0));
}
will fail with an exception and print out
Error: division by zero
โญโ[/tmp/program.aria:2:12]
โ
2 โ return x / y;
โ โโโฌโโ
โ โฐโโโโ here
โ
9 โ val d = do_division(x,y);
โ โโโฌโโ
โ โฐโโโโ here
โ
15 โ println(complex_math(7,0));
โ โโโฌโโ
โ โฐโโโโ here
โโโโโฏ
Structs and enums can overload a subset of operators to provide custom behavior. The resolution rules are similar to those of Python, without the support for inheritance.
Common operators that can be overloaded are equality (op_equals
) and the arithmetic operators (op_add
, op_sub
, op_mul
, op_div
, op_rem
).
struct Integer {
type func new(n) {
alloc(This){
.n = n,
};
}
func op_rem(rhs) {
if rhs isa Integer {
Integer.new(this.n % rhs.n);
} elsif rhs isa Int {
Integer.new(this.n % rhs);
} else {
throw alloc(Unimplemented);
}
}
func prettyprint() {
return "{0}".format(this.n);
}
}
func main() {
val x = Integer.new(26);
println(x % 4); # prints 2
}
If an operator does not support a combination of operands, it can throw Unimplemented
. If the type of the second operand is different, the language will try to find and invoke op_rX
(e.g. op_rrem
) switching the order of the operands.
The square bracket operator is defined by a pair of operator functions, func read_index(n)
and func write_index(n,val)
. The value of the index does not have to be an integer (e.g. Map
), however only one index can be supported in this version of Aria.
Function call syntax (i.e. the ability to call an object as if it was a function) is defined by func op_call(<args>)
.
A guard statement is used to create a scoped block that can deallocate some managed resource on exit.
struct LoggedTask {
type func new(op) {
println("Starting {0}...".format(op));
return alloc(This) {
.op = op,
};
}
func guard_exit() {
println("{0} completed".format(this.op));
}
}
func main() {
guard op1 = LoggedTask.new("first task") { # prints Starting first task...
# do the thing
} # prints first task completed
guard op2 = LoggedTask.new("second task") { # prints Starting second task...
# do the thing
} # prints second task completed
}
In the general case, objects are deallocated transparently by the Aria VM, and there is no way to control the time and flow of the deallocation.
If an object needs to execute some custom cleanup, a guard
block is the right way to assign additional behavior independent of the general VM deallocation. Note that an object can live outside of its guard block, and itโs up to the object to respond safely to that.
Mixins can be used to insert new behavior into existing types. Aria does not provide inheritance, but some cases can instead be expressed via a mixin
.
mixin Double {
func double(x) {
return 2 * x;
}
}
struct Foo {
include Double
type func new() {
return alloc(This);
}
}
func main() {
val f = Foo.new();
println(f.double(5)); # prints 10
}
Foo
includes all the member functions of mixin Double
. Functions in a mixin can use other functions of their type, or the mixin, and can refer to this
. At runtime, mixin functions see the type of the object they are included in, not the type of the mixin.
mixin Double {
func double() {
return 2 * this.x;
}
}
struct Foo {
include Double
type func new(x) {
return alloc(This) {
.x = x,
};
}
}
func main() {
val f = Foo.new(5);
println(f.double()); # prints 10
}
mixin Double {
func double() {
return 2 * this.x;
}
}
struct Foo {
include Double
type func new(x) {
return alloc(This) {
.x = x,
};
}
}
func main() {
val f = Foo.new(5);
println(f isa Foo); # prints true
#println(f isa Double); # runtime error, the mixin is not a type
}
Mixins may have requirements of their types, for example Double
expects to be included in types that have a .x
member value of a type that can be multiplied by 2. There is no way to encode those requirements, usually they are expressed as comments in the mixin itself.
A mixin can be included in multiple types, and a type can include multiple mixins. Mixins can be included in the type definition or an extension of it.
for
loops work by leveraging iterators. An iterator is an object that has a next
method with no arguments. This method is expected to return an object with a specific layout:
done
with value true
;done
with value false
, and a field named value
, whose value is the next element in the iterationThis mechanism allows for finite or infinite sequences, for values to be pre-computed or dynamically generated.
The in
expression of a for loop is assumed to be a container of some kind, so the iterator
method is called on it, and its return is used as the actual iterator.
struct SampleIterator {
type func new() {
return alloc(This) {
.num = 0,
};
}
instance func iterator() {
return this;
}
instance func next() {
if this.num == 5 {
return Box() {.done = true};
} else {
this.num += 1;
return Box{.done = false, .value = this.num};
}
}
}
func main() {
for n in SampleIterator.new() {
println(n); # prints 1,2,3,4,5
}
}
Iterators for common types (e.g. Map, List) include the Iterable
mixin (defined at aria.iterator.mixin
). This mixin allows using common functional operators on an iterator (where
(aka filter
), map
and reduce
). Iterators can get pre-written implementations of these behaviors with the Iterator
mixin from the same module. For example
import Iterator from aria.iterator.mixin;
import Iterable from aria.iterator.mixin;
struct SampleIterable {
type func new() {
return alloc(This);
}
instance func iterator() {
return SampleIterator.new();
}
include Iterable
}
struct SampleIterator {
type func new() {
return alloc(This) {
.num = 0,
};
}
instance func next() {
if this.num == 5 {
return Box() {.done = true};
} else {
this.num += 1;
return Box{.done = false, .value = this.num};
}
}
include Iterator
}
func main() {
for n in SampleIterable.new().where(|x| => x > 2).map(|x| => x + 1) {
println(n); # prints 4,5,6
}
}
This is a slightly more complex but realistic example where the iterable and the iterator are different objects. Using the Iterable
mixin, it is possible to compose operations, so one can where
, then map
, then map
, โฆ and so on freely.
Any Aria file can be a module.
Modules are imported with the import
statement, which follows a dotted structure, e.g. import aria.rng.xorshift;
. aria
is the name of the Aria standard library module, which contains submodules defined via filesystem paths. Documentation for the Aria library is contained in stdlib.md.
Modules are located via the ARIA_LIB_DIR
environment variable, which is a list of paths, separated by a colon. Each path is scanned in order until the module is found or all are exhausted.
aria.rng.xorshift
is defined in lib/aria/rng/xorshift.aria
. Directories may contain other directories, or files, or a combination thereof. A directory is scanned by virtue of its name being used, and does not need contain any files. Directories may be arbitrarily nested.
A module can be imported more than once, but only the first import will load the module, others will act as a no-op. An import of a module brings that module into the visible set of symbols, and names inside the module can be referenced via a fully-dotted path. For example
import aria.rng.xorshift;
func main() {
val rng = aria.rng.xorshift.XorshiftRng.new();
println(rng.next()); # will print a random value
}
You can structure your own projects the same way. Consider this layout:
my_project/
โโโ main.aria
โโโ my_lib/
โโโ utils.aria
To use a function from utils.aria
inside main.aria
, you would set ARIA_LIB_DIR
to include my_project
and then write import my_lib.utils;
.
To avoid dotted notation, one can import symbols directly from a module, e.g.
import XorshiftRng from aria.rng.xorshift;
func main() {
val rng = XorshiftRng.new();
println(rng.next()); # will print a random value
}
will work exactly the same. It is substantially equivalent to
import aria.rng.xorshift;
val XorshiftRng = aria.rng.xorshift.XorshiftRng;
func main() {
val rng = XorshiftRng.new();
println(rng.next()); # will print a random value
}
i.e. the entire module is imported, and one specific symbol is lifted from it.
It is possible to import multiple names from a module at once, or in separate statements.
Extensions in a module cannot be imported by name, and are always brought in when the module is imported.