Aura is a functional multitarget programming language. It means it's meant to be functional first, and easy to run in multiple platforms by transpiling to other languages.
This is still an alpha specification with no working implementation My implementation of a compiler is Aurac
There are no variables in Aura, you can bind values to names and rebind them later, but no mutability is allowed.
Aura implements several functional programming features such as: higher order functions, functions as values, closures, etc
Aura uses a type system that ensures operations validity is checked during compile time while add features so castings aren't too verbose
Aura tries to provide a consistent syntax so user created constructs looks like built in constructs.
An Aura file can represent a library or an executable
Here is where you gonna import stuff from other Aura files. This helps Aura in building the dependency tree and check what needs to be compiled and do the proper linking of symbols.
import ~/path/to/module // Import everything from a module
import (symbol1, altname = symbol2) = ~/path/to/module // Import just those symbols and even rebind them
The alias statement is wuite useful to shorten symbols (identifiers for values, types, variants, functions, etc) that are being used. Those are some default alias in every Aura program:
alias true Bool.true
alias false Bool.false
alias succ Failure.succ
alias fail Failure.fail
alias null Void.null
alias some Nullable.some
alias none Nullable.none
alias next Control.next
alias break Control.break
alias map #functor:map
alias each #iter:each
alias truthy #truthy:truthy
The type
statement defines the named types that are going to be used in the program. There are some models of types:
Just an alias for another type. This is quite useful for semantics in your code.
type Integer = I64
val i Integer = 1234i64
There is an implicit cast available for I64 to Integer but not the other way around. But the Integer type gets tagged with #into(I64)
This is the same as aliased but aliasing a compound type
type Pair(T, U) = (T, U)
val p Pair(Int, String) = (123, "abc")
The fields in compounds are indexed by an integer literal. The casting logic is the same as aliased types.
Just compounds with named fields.
type Car = struct (name String, brand String, year UInt = 2024)
val c1 Car = ("LaFerrari", "Ferrari", 2012)
val c2 Car = ("Huracan", year = 2015, brand = "Lamborghini")
val c3 Car = Car("Zonda R", "Pagani")
The final fields may have default values. There is an implicit casting from (String, String, UInt) for Car, but not the other way around (again, the tag is implemented).
Note: if the type has generics, pass them before a ;
inside the parenthesis (if needed, Aura has type inference)
type Foo(T, U) = struct (foo T, bar U)
val f Foo(Int, String) = Foo(Int, String; 123, "abc")
Also for the later fields, if they are: List
, ->
or =>
they can be passed with their identifier outside the parenthesis. Also if only the last non-defaultable field is being passed outside, the label can be omited.
type Map(T, U) = struct (value T, map T -> U)
val m Map(Int, String) = Map(10) map (value) -> { value*20 } // map (value) -> value*20 // map { it*20 }
A union type is a type that can assume values of a set of different types
type Number = union (Int, Float)
val n Number = 8
There is an implicit cast from a type T to a union type U if T is in the union of types that form U. But there is no way to cast U into T. Use a match
for this.
match(n) do {
Int => println("It's an integer ${n}"),
Float => println("It's a float ${n}")
}
A enum is a union whose variants are named.
type Number = enum (i Int, f Float, v)
If no type is provided for a variant it's Void
To build a value of a enum just specify the variant and the value.
val n Number = Number.f(8.8)
To work with enums use a match
match (n) do {
Number.i(i) => println("It's an integer ${i}"),
Number.f(f) => println("It's a float ${f}")
}
A type can have functions bound to it (called as Type:function_name
). Those are defined with fn
inside { }
after the type definition
type Number = enum (i Int, f Float) {
fn as_int(self) -> Int = match(self) do {
Self.i(i) => i,
Self.f(f) => Int:from(f),
}
fn as_float(self) -> Float = match(self) do {
Self.i(i) => Float:from(i),
Self.f(f) => f,
}
}
Notice the self
and Self
keywords. Self
means the current type (with the same generic parameters). While self
is short for self Self
it means the first parameter is of the Self
type (self
can only be used as the first parameter).
Associated functions can be defined outside the type definition by prefixing the function name with the type it's associated to.
type Foo = Int
fn Foo:bar(self) -> Self = self + 1
A type can have other types bound to it (this gets really useful for tags).
type Number = Int {
type Collection = List(Int)
fn prime_factors(self) -> Collection { ... }
}
It can be called as Type:AssocType
outside the definition scope.
As with associated functions, it can also be defined outside the definition scope
type Integer = Int
type Integer:Larger = I64
Also values can be associated to types using similar syntax
type Number = Int {
val max_num Self = 2_147_483_647
}
It can be called as Type:assoc_val
outside the definition scope. As with functions and types, it can be defined outside the definition scope
type Number = Int
val Number:max_num Number = 2_147_483_647
Statements where one can define a new tag or tag a defined type.
Using the tag
statement create a tag name (#kebab-case
) and add a set of { }
at the end where the associated members are defined.
tag #foo {
fn bar(i Int) -> String
}
Inside the definition scope one may create associated functions, values and types using nearly the same syntax as in type definition scopes. But here, the associated members can be defined without a default value so it must be provided when tagging a type. If a default value is given it cannot be overwritten.
tag #from(T) {
type Output = Self // The output type is set, cannot be overwritten
fn from(value T) -> Output // The function body isn't set, must be overwritten
}
Another #
expression can be added after the tag name to specify tags that a type must have before being tagged.
tag #text #printable { // Only #printable types can be tagged with #text
...
}
If more than one tag is required use a compound tag #(tag1, tag2, tag3)
The tag
statement is also used to tag a type. Just specify the type being tagged, the tag and then fulfill the associated members definitions.
type WeekDay = enum (sunday, monday, tuesday, wednesday, thursday, friday, saturday)
tag #from(WeekDay) = Int {
fn from(value WeekDay) -> Self = match(value) do {
WeekDay.sunday => 1,
WeekDay.monday => 2,
WeekDay.tuesday => 3,
WeekDay.wednesday => 4,
WeekDay.thursday => 5,
WeekDay.friday => 6,
WeekDay.saturday => 7,
}
}
Tags works as union types of all types that are tagged (compound tags works as set of types in the intersection of the tags being composed). When calling a tag associated function for a known type use the Type#tag:function
notation. When using the tag as a union type just call #tag:function
and give enough information so the compiler may in infer the type.
Int#from:from(WeekDay.sunday) // We know which implementation of #from:from to use
#from:from(WeekDay.sunday) $$ Int // Same thing here since we assert the output type is Int
#printable:format(12345) // Here we know it's Int#printable:format
fn sum(a #into(Int), b #into(Int)) -> Int = a:into() + b:into()
println(sum(Bool.true, 89.9)); // 1 + 89 = 90
Tags associated members can be defined outside the definition scope, but they must provide a default value.
Declaring global values can be done using val
. Just keep in mind that a val
is a global constant but it's name can't be shadowed. The type must be explicitly declared.
val hostname String = "localhost"
val port U16 = 8080
Only literals can be passed to val
(it means, no function calls) and they are evaluated in order.
The entrypoint for a executable program, is just a short-hand for fn main
. There are some different possible signatures for the entrypoint type:
- Input:
()
(can be ommited),(Int, List(String))
- Output:
-> Void
(can be ommited),Failure(Void, #failure)
main -> {} // () -> Void
main (argc Int, argv List(String)) -> {} // (Int, List(String)) -> Void
main -> Failure((), #failure) {ok(null)} // () -> Failure(Void, #failure)
main (argc Int, argv List(String)) -> Failure((), #failure) { ok(null) } // (argc Int, argv List(String)) -> Failure((), #failure)
Also if the main body is just an expression the =
can be used just like in regular functions
main = println("Hello World")
The function definition follows the pattern:
fn identifier Input -> Output = body
Functions use snake_case
names and can be prefixed with Type:
if they're associated to Type
The input uses the same struct notation: a comma separated list of identifiers with a explicit type and the last fields may have a default value after a =
sign.
Any generics the function may use are defined before a ;
inside the parenthesis
If the input is ()
it can be ommited
The resulting type of the function, if it's -> Void
both the type can be ommited (fn println(value #printable) ->
or fn println(value #printable) -> Void
).
The body is the code that is evalutated once the function is ran if it's only an expression just use a = expression
as the body. Other wise if a block of code is needed use { statement; statement; statement }
(the last ;
and the initial =
can be ommited) and the statement value will be used as the value of the function.
Functions can be used as values, there are three ways of creating functions: anonymous functions, composition and currying. And both support capture of environment (it means, they are closures).
Using (args, ...) -> expression
a function literal is created, inside the expression environment values can be captured, it means they are closures.
List(Int):filter([25, 0, -10, 45, 10], (elem) -> { elem > 10 })
[25, 0, -10, 45, 10]:filter by (elem) -> {
elem > 10
}
If a function needs a value of type A
as an argument and a function that returns A
is provided, they are composed.
fn sum(a Int, b Int) -> Int
sum(10, sum); // (a, b) -> sum(10, sum(a, b))
If more arguments are also composed the input types are put into an anonymous struct
sum(sum, sum); // (a (a Int, b Int), b (a Int, b Int)) -> sum(a |> sum, b |> sum)
In a function call, arguments assigned with _ T
are curried out, so instead of calling the function, a new closure is created with the needed values. The type T
must be specified
increment = sum(1, _ Int); // (b Int) -> sum(1, b)
alt_sum = sum(_ Int, _ Int); // (a, b) -> sum(a, b)
Currying is also supported with compounds and structs if the type is specified (both within the parenthesis or by the context)
(_ String, 123); // (a) -> (a, 123)
(_ Bool, _ Bool); // (a, b) -> (a, b)
There are two forms of calling functions: ( )
and |>
. The former can be used only if the function is bound to an identifier.
Since the arguments use the same struct notation to define the input type, we use a similar notation for the one used to build structs. You can pass the arguments in order as expected. The last arguments can be labeled with label =
so they can be specified out-of-order or if the arguments are List
, =>
or ->
they can be labeled outside the parenthesis. For ->
being passed outside if the input is ()
it can be ommited.
main {
// fn if(T; cond Bool, then () -> T, else () -> T = () -> { undefined })
if (true, () -> { println("Hello World") }, () -> { println("Good bye") }) // >> Hello World
if (5 == 6) then -> {
println("This shouldn't be printed")
} // This can't be used as a value since the type is Undefined as said by the default `else` callback
res = if (-1 > 1) then -> { "Fizz" } else -> { "Buzz" }; // if (-1 > 1) else { "Buzz" } then { "Fizz" } is also valid but not semantic in this context
List:filter([1, 2, 3, 4, 5]) by (it) -> { it % 2 == 0 }; // [2, 4]
foreach of ["Hello", "World", "Aura"] do (str) -> {
String:upper(str)
}; // ["HELLO", "WORLD", "AURA"]
foreach(["Hello", "World", "Aura"], String:upper); // ["HELLO", "WORLD", "AURA"]
}
The forward piping operator calls the function passed as the right operand with the values passed as the left operand.
"Hello World" |> println;
The cool part about this is that functions that aren't bound to a identifier can be called. Also it gives the visual effect of data transformation that is a key concept of functional programming languages.
[1, 2, 3, 4, 5, 6, 7, 8]
|> List:filter(_) by (it) -> { it % 2 == 1 } // [ 1, 3, 5, 7 ]
|> List:map(_) with (it) -> { it * 2 } // [ 2, 6, 10, 14 ]
|> List:reduce(_, 0) with (acc, elem) -> { acc + elem } // 32
I8
,I16
,Int
,I32
,I64
U8
,U16
,UInt
,U32
,U64
Float
,Double
Bool
Char
All those types have no fields, their information can only be accessed as a whole. They can be pattern matched using their literals
match (6 $$ Int) do {
1 => println("One"),
2 => println("Two"),
3 => println("Three"),
4 => println("Four"),
5 => println("Five"),
6 => println("Six"),
x => println(x),
}
List(T)
String
Types that are derived from primitives and can be accessed in parts. List
and String
are both #indexable
and #iterable
so can be transformed and accessed in many different ways. The literal for List
is a comma separated list of expressions with [ ]
while for String
is a double quoted text.
l List(Float) = [8.1, 12.4, 9.6, 4.02];
l:get(3); // Nullable.some(4.02) $$ Nullable(Float)
s String = "Hello World":map(Char:upper):reverse(); // "DLROW OLLEH"
They can be pattern matched using literals and ++
operator
(T, U, ...)
: a comma separated list of types within( )
A compound type is a type which has parts and each part can be of a different type (AKA a product type in ADT language). Its parts can be accessed with .n
operator where n
is an integer literal (non-negative).
Pattern matching for compounds is pattern matching againts each component
match (("Hello", false, 8) $$ (String, Bool, Int)) {
(text, false, 6) => ..., // Won't match
("Hello", _, 8) => ... // Matches
value => // Catch all
}
Components that aren't matchable must use catch all patterns or ignored
To create a new compound values create a comma-separated list of values delimited by parenthesis.
val origin (Int, Int) = (0, 0) // A compound value
@union (T, U, ...)
: a pipe separated list of types
The dual of a compound type, its value if of one of its variants which can only be accessed by pattern matching (AKA a sum type).
Pattern matching for unions is checking every possible variant:
match (7.5 $$ @union (Int, Float, Bool)) {
i $$ Int => ..., // Won't match
7.2 => ..., // Won't match
f $$ Float ~ f < 10.0 => ...,// Matches
false => ...,// Won't match
_ => ...,// Catch all
}
A value of type T
can automatically casted into a union that contains T
val number @union (Int, Float) = 6.28 // This is a Float, but is autocasted into a union (Int, Float)
(t T, u U, ...)
: a compound with named components
Structs are a less generic version of compounds where each component is identified with a name. Pattern match for structs is similar to pattern match for compounds except that:
- The later fields can be ignored using
...
- Before
...
fields can be matched out of order usingfield_name =
before the pattern - The first fields can be matched in-order
match ((name = "John Doe", age = 42) $$ (name String, age Int)) {
(age = 43, ...) => ,// Won't match
(n, a) ~ a < 30 => ,// Same
("John Doe", age) ~ age >= 30 => , // Matches
_ => // Catch all
}
Structs support the .
and =.
operations:
expr.field
gets the value of the fieldfield
Type.ident
gets a function that receivesType
and return the value of the fieldfield
expr.field(new_value)
produces a copy ofexpr
but replacing the value offield
tonew_value
ident=.field
gets the value of the fieldfield
and binds it toident
ident=.field(new_value)
produces a copy ofident
but replacing the value offield
tonew_value
and binds it toident
First of all, a compound value that is structurally identical to a struct can be autocasted. Remember, the order of the fields matter.
type Person = (name String, age Int)
val doe Person = ("John Doe", 37) // This works
val ipsum Person = (42, "Lorem Ipsum") // This doesnt, the order matters
A value can be created for an annonymous struct type if all the fields are labelled
main {
// A value of an annonymous struct type struct (name String, color String, value Float)
grape = (name = "Grape", color = "Purple", value = 1.5);
}
When creating a type for a non-anon struct type, we support positional fields in the begginging and labelled fields at the end (they can't be mixed)
type Car = (brand String, model String, year Int, color String)
val car Car = ("Ferrari", "Italia", color = "Red", year = 2016)
When using labelled fields we support a special syntax for List(T)
, T => U
and T -> U
where they can be labelled outside the parenthesis
type Craziness = (values List(Int), match Int => Bool, do Bool -> String)
val foo Craziness = () values [1, 2, 3, 4] match {
x ~ x % 2 == 0 => false,
_ => true
} do (b) -> {
b:format()
}
@enum (t T, u U, ...)
: a union with name variants
Enums behave similar to unions but their variants are named (this allows different variants to wrap the same type). If the variant type isn't Void the value can be pattern matched using ( )
type Number = @enum (i Int, f Float, nan)
match (Number.i(6)) {
Number.i(i) ~ i > 6 => ,// Won't match
Number.nan => , // NaN
Number.f(f) => ,// Won't match
Number.i(i) => //Matches
}
Enums support the .
and =.
operations:
Type.variant
: produces a new value with the givenvariant
expr.variant
: produces a new value with the givenvariant
Type.variant(...)
: produces a new value with the givenvariant
where it carries some data (can be used to capture data in pattern matching)expr.variant(...)
: produces a new value with the givenvariant
where it carries some data (can be used to capture data in pattern matching)ident=.variant
: if the enum is of the givenvariant
, we bind the carried data toident
[WIP]: Syntax for anonymous enums
To produce a new value of a given enum either use Type.variant(...)
or expr.variant(...)
to produce a value of said variant for the given type (the ( )
are only needed if the variant has any carry data)
type MaybeNaN = @enum (number Float, nan)
val nan = MaybeNaN.nan
val number = MaybeNaN.number(3.14) // or even nan.number(3.14) if you're too lazy to write `MaybeNaN` again
A function type can be expressed in two ways:
(T1, T2, ...) -> U
for closures(a1 T1, a2 T2, ...) -> U
forfn
functions
Basicly either a compound type or a struct type followed by an arrow and the output type. The output type can be ommited if it's void, but not the arrow.
Tags can be used as types
val a #add(Int) = 5 // A value that can be added to Int
Moreover, compound tags can be used to specify an even higher amount of tags a type must have to be accepted, the syntax is similar to a compound, but prefixed with #
, the tags within it don't need to have the #
// In fact, Int can be added to, multiplied by, subtracted by and divided by an Int
val a #(add(Int), mul(Int), sub(Int), div(Int)) = 42
Every type (and tag) can have its associated members (val
, fn
or type
), we use :
to get access to associated members. This operator can be used both with the type or with an expression. If used with the type in a associated function, a new first parameter araises if the function uses self
, otherwise self
is bound to the expression being used.
Type:TypeIdent
orexpr:TypeIdent
: gets the associated typeType:val_ident
orexpr:val_ident
: gets the associated valueType:fn_ident
: gets the associated function as a function expression with the extraself
argumentexpr:fn_ident
: gets the associated function as a function expression without the extraself
argument (binds it toexpr
)Type:fn_ident(...)
orexpr:fn_ident(...)
: calls the respective associated function
The special =:
operator can be used with both associated values and functions in the following scenarios:
ident=:val_ident
: same asident = ident:val_ident
ident=:fn_ident
: same asident = ident:fn_ident
ident=:fn_ident(...)
: same asident = ident:fn_ident(...)
In the type definition, some generic type parameters can be added within ( )
before the =
in a type
statement.
Those are not conventions nor recommendations, they are mandatory
snake_case
([a-z][a-z0-9_]*
): values (val
, binds, function parameters and pattern match captures), functions, fields (in structs), variants (in enums)PascalCase
([A-Z][a-zA-Z]*
): types#kebab-case
(#[a-z]+(-[a-z]+)*
): tags@train:case
(@[a-z]+(:[a-z]+)*:?
): macros$train:case
($[a-z]+(:[a-z]+)*:?
): atoms
import
: Imports a modulealias
: Creates an aliasmain
: Defines the current module as an executable and defines the entrypoint codefn
: Defines a functiontag
: Both defines a new tag or tags an existing typetype
: Defines a typeval
: Defines a compiletime known constant value
=
bind operator+ - * / % **
arithmetic operators=+ =- =* =/ =% =**
bind arithmetic operators&& || ! == != > < >= <=
logic operators++
concatenation operator=++
bind concat operator~:
composition operator_
currying operator[ ]
list operator{ }
scope operator::
compound join operator(A1, A2, ..., An) :: (B1, B2, ..., Bm) = (A1, A2, ..., An, B1, B2, ..., Bm)
=::
bind join operator\\
compound split operator(A1, A2, ..., An, B1, ..., Bm) \\ n = ((A1, A2, ..., An), (B1, B2, ..., Bm))
=\\
bind split operator.
property access (access a field or variant)=.
bind property operator:
associated access (access a associated member)=:
bind associated operator...
spread operator->
function operator. Used in the function type notation and closure creation=>
branch operator. Used in the branch type notation and branch maps creation.~
guard operator. Used to separate the pattern capture and the guard in a pattern|>
pipe-forward applies the lhs value as argument to the rhs function$>
type cast operator$$
type assertion operator??
hard-unwrap operator. Gets the value wrapped or crashes otherwise=??
bind unwrap operator?=
unwrap-or-default operator if the lhs value can't be unwrapped returns rhs?.
safe field access operator?>
safe piping operator
Scopes are defined by { }
A body that produces a function
// A Function with no arguments that prints "Hello World"
-> { println("Hello World"); }
// A function that sums `a` with `b` (types are infered from the context)
(a, b) -> { a + b }
A body that produces a branching expression
{
"hello" => println("Hello"),
"bye" => println("Not hello"),
_ => println("Idk")
}
A combination of function body and branch body
(a Int) -> {
0 => "zero",
a ~ a < 0 => "negative"
a ~ a % 2 == 0 => "even",
_ => "odd"
}
Creates a body for local variable definitions, may return a value, ain't a function because it is still in the scope of the calling function.
fn foo -> Int { // Function body A
a = -> { // Function body B
@return 10 // Return affects this function body B
};
a = { // Still body A
@return 20 // Return affects the function body A
}
}
Scopes may be appended a context using @context:(...)
macro. Contexts are read by macros
main -> {
not_a_promise Int = @context:($async) { // This appends the $async context to this scope
@await async_fn() // `@await` can only be called withing an async scope
};
promise Async(Int) = @async { // This appends the scope and encapsulates the output in an Async value
@await async_fn()
}
}
Scopes may also be labeled for early returning macros
main -> {
a = @label:($outer) {
@label:($inner) {
@return:($outer) 1
};
5 // Unreachable
}
}