Introduction
The Lumina Programming Language
Lumina is an eager-by-default natively compiled functional programming language with the core goals of readibility, practicality, compiler-driven development and simplicity.
It aims to be a high-level general-purpose language, but also support systems-level functionality such as raw pointer arithmetics to allow for high-level abstractions to be built on top of low-level high performance Lumina code.
Installation
Compiling from scratch
You need to have the Rust compiler installed.
Clone the repository
$ git clone https://github.com/luminalang/lumina.git
Compile and install the compiler
$ cd lumina/
$ cargo build --release
$ sudo mv target/release/lumina /usr/bin/
Copy the luminapath
directory containing the Lumina libraries to a suitable runtime folder and point the $LUMINAPATH
environment variable to it.
$ cp -r luminapath/ $HOME/.local/share/lumina
# If you're using bash
$ echo "export LUMINAPATH=$HOME/.local/share/lumina/" >> $HOME/.bashrc
Compiling & Running Programs
The Lumina repository contains an example folder.
To run one of the examples
$ lumina run examples/hello-world
Hello World!
Or to compile to a binary
$ lumina build -o hello-world examples/hello-world
$ ./hello-world
Hello World!
Creating a Lumina Project
To create a new Lumina project, use
$ lumina init my-awesome-project/
$ cd my-awesome-project/
$ lumina run
Hello World!
Lumina also ships with a code formatter.
$ lumina fmt --overwrite my-awersome-project/
It's currently experimental so don't use it for code you don't have a backup off.
Functions
Declaring Functions
Functions are defined using the fn
keyword.
fn add x y as int int -> int =
x + y
A function which takes two parameters, x and y of type int, then adds them together to return a single int
The type annotation is optional.
fn add x y = x + y
Although remember that type annotations often help as a form of documentation.
Calling Functions
White-space is used to separate the arguments to a function
fn main =
add 1 2
Parenthesis can also be used to 'group' expressions.
fn main =
std:io:println (add (add 1 2) (add 3 4))
A function adding numbers then printing them to the terminal
Pattern Parameters
The parameters in a function declaration may be any infallible pattern, not just plain identifiers.
fn add_pairs xy ab as (int, int) (int, int) -> (int, int) =
...
// can instead be written as
fn add_pairs (x, y) (a, b) as (int, int) (int, int) -> (int, int) =
(x + a, y + b)
fn main =
std:io:println (add_pairs (1, 2) (3, 4))
A function with two tuple parameters being pattern matched in the function declaration
Read more about patterns in the Pattern Matching & Conditionals chapter
Where-Bindings
A function may also be defined inside another function, of which it'll have access to its parent function's parameters.
fn main =
std:io:println (add 5 6)
where
fn add x y = x + y
A function declared inside another function as a where-binding
Operators
New operators can also be defined similarly to functions.
fn +++ left right as int int -> int =
left + right + right + right
Types
Integer Types
Size | Unsigned | Signed |
---|---|---|
8 bits | u8 | i8 |
16 bits | u16 | i16 |
32 bits | u32 | i32 |
64 bits | u64 | i64 |
Arch | uint | int |
Arch
refers to that the size depends on the CPU architecture
Decimal Types
float
is supported as a double-precision 64-bit floating point.
Bools
The bool
type has either the value true
or false
Arrays
Arrays are planned but currently not exposed to the user.
Prelude
The following types aren't builtins but are available in prelude by default.
type string // utf-8 encoded bytes
type List a // (or [a]) an efficient dynamic list
type Maybe a
type Result a err
type nothing
trait Num
trait Compare
trait ToString
string
is incomplete and not yet validated as utf-8
list
is definitely not efficient at its current state
Record types
Record types are types that contain other types as named fields.
Sometimes also called struct
or product
types.
type User {
name string
age int
}
// Construct a new record of type `User`
fn main =
{ User | name = "Jonas", age = 25 }
// Construct a new record of type `User`
//
// this time the type is inferred from the field names in scope
// instead of explicitly annotated.
fn main =
{ name = "Jonas", age = 25 }
Defining and constructing a record
Modifying Records
Records can also be constructed by changing some fields of an existing record.
But remember! Lumina is an immutable language, so this creates a new record while keeping the previous record the same.
fn rename user new as User string -> User =
{ user ~ name = new }
// Is equivalent to:
fn rename user new as User string -> User =
{ User | name = new, age = user.age }
Function returning a copy of a user with its name substituted
Sum Types
Sum types are types whose value is one out of a set of possible variants.
Sometimes called enum
types.
// Define a sum-type whose value can be one of `Admin`, `Client` or `Guest`
type UserKind = Admin | Client | Guest
// Construct a new sum-type of type `UserKind`
fn main =
Admin
Variants of sum types can also have parameters of other types.
type UserKind
= Admin Department
| Client Company
| Guest
type Department = Accounting | Support
type Company {
name string
}
fn main =
Admin Accounting
// or perhaps ...
fn main =
Client { Company | name = "luminalang" }
Trait Types
TODO: explain traits, and pretend dynamically dispatched objects are normal, we can explain trait constraints later.
Generic Type Parameters
TODO: figure out how best to explain the practical use-cases and importance of generic type parameters.
Declared types can take generic type parameters.
type User item {
name string
age int
held item
}
fn main =
// Here we supple the type `string` as type parameter for `User`
// which replaces the `item` generic.
//
// Meaning that the value assigned to the field `held` should be of type `string`.
{ User string | name = "Jonas", age = 25, held = "Laptop" }
// A type which is either "just" a value or "nothing".
type Maybe value
= Nothing
| Just value
// Here we supply the type `int` as type parameter for `Maybe`
// which replaces the `value` generic.
//
// Meaning that the parameter to `Just` should be of type `int`.
fn just_five as Maybe int = Just 5
Two examples of defining a type with type parameters and then instantiating it.
*Since Maybe
is known to be very useful, it's already defined in the Lumina standard library.
Evaluating Multiple Expressions
Let bindings
As a function grows larger, complex, or more and more nested. It can become difficult to read.
fn main =
add (add 1 2) (add 3 4)
A useful way to mitigate this is to seperate out the expressions into a sequence of steps.
let
allows you to bind the result of an expression to a pattern (often an identifier).
fn main =
let x = add 1 2 in
let y = add 3 4 in
add x y
This also comes with the benefit of allowing you to name the result of an expression, which is important for readability.
The left side of a let binding can be any pattern, and not just an identifier.
fn main =
let (x, y) = (add 1 2, add 3 4)
in add x y
A let-binding with a tuple pattern binding to a tuple expression
Do expressions
Sometimes it's useful to run an expression for its side effects without using its return type.
In those cases, do...then
notation works similarly to let
except it always discards the value instead of binding it to a pattern.
fn main =
// Run an expression
do io:println "Starting Program..." then
0 // Then do something else to generate a value
// ... Is a clearer way of writing
fn main =
// Run an expression
let _ = io:println "Starting Program..." in
0 // Then do something else to generate a value
Pattern Matching & Conditionals
Pattern Matching with match
Lumina provides pattern matching via the match
keyword. A match
expression takes one expression as input and then a set of branches which checks for a pattern to run a specified expression.
A pattern is in some ways the inverse of an expression. Expressions construct and create data, while patterns destruct data. Destructing data with a pattern will allow you to check for whether the data looks a particular way, while also binding specific bits of data to new identifiers.
Pattern matching is a core component of Lumina and will be the primary part of most functions.
// Matching integers
fn check_integer n as int -> string =
match n
| 0 -> "zero"
| 1 -> "one"
| 2..9 -> "digit"
| _ -> "large number"
// Matching sum types
fn or default m as int (Maybe int) -> int =
match m
| Nothing -> default
| Just n -> n
// Matching records
fn encode_user user as User -> string =
match user
| { kind = Admin, name, .. } -> "admin_" <++> name
| { kind = Guest, name, .. } -> "guest_" <++> name
// Type annotation is optional
| { User | kind = Guest, name, .. } -> "guest_" <++> name
// Matching strings
fn parse_user str as string -> (int, string) =
match str
| "id:" id " username:" username ->
(to_digit id, username)
| _ ->
crash ("parsing error: " <++> str)
// Matching lists
fn first_three list as [int] -> Maybe (int, int, int) =
match list
| [x, y, z : _rest] -> Just (x, y, z)
| _ -> Nothing
// Matching tuples
fn far_away position as (int, int) -> bool =
match position
| (100.., _) -> true
| (_, 100..) -> true
| (_, _) -> false
TODO: Should we show and explain string extractors here or under advanced features? Should probably be after partial application
If Expressions
fn can_drive age =
if age > 18
then "This person can own a drivers license"
else "This person can not own a drivers license"
Partial Application
A large part of why functional programming is so powerful is being able to conveniently pass around functions as if they're values.
This lets you create functions whose behavior is more generalised as a portion of the behavior can be passed down as a parameter.
The Closure Type
Among types such as int
or (float, float)
, functions-as-values also have types in the form of closures.
(fn int int -> int)
The type of a closure which expects two int
parameters and returns an int
So for example;
fn change_if_just f m as (fn int -> int) (Maybe int) -> Maybe int =
match m
| Nothing -> Nothing
| Just n -> Just (f n)
A function which runs a function to change a value if it exists
The Magic #
Unary Operator
To treat a function as if it's a value, the #
symbol is used.
fn add_five_if_just m as Maybe int -> Maybe int =
change_if_just #add_five m
fn add_five x as int -> int =
x + 5
A function which adds 5 to an integer if it exists
The #
symbol is a general-purpose way to pass various expressions as closures and can be used in a couple of different ways.
fn add x y as int int -> int = x + y
// ... //
// turns the function into a closure of type
// (fn int int -> int)
#add
// partially applys the function to create a closure with one less parameter
// (fn int -> int)
#(add 5)
// partially applys an operator to create a closure with a single parameter
// (fn int -> int)
#(+ 5)
// turn a literal value into a closure
// (fn -> int)
#5
With this in mind, we can rewrite the previous example as:
fn add_five_if_just m as Maybe int -> Maybe int =
change_if_just #(+ 5) m
A function which adds 5 to an integer if it exists
Anonymous Functions with Lambdas
If a function doesn't seem important enough to give it a name, we can inline it with a lambda.
fn main =
(\n -> n + 1) 5
Runs the inline function with the parameter 5
to create 6
Lambdas can also be passed as closures the same way as named functions.
fn add_five_if_just m as Maybe int -> Maybe int =
change_if_just #(\n -> n + 5) m
Partially Applicating Where-Bindings
TODO: Is it overly complicated and confusing to explain this here? Maybe we should have a separate design patterns chapter
A common design pattern is to partially apply where-bindings
fn add_five_if_just m as Maybe int -> Maybe int =
change_if_just #(add 5)
where
fn add x y = x + y
fn change_if_just f as fn(int -> int) -> Maybe int =
match m
| Nothing -> Nothing
| Just n -> Just (f n)
Lists & Strings
Declaring Global Values
Global values can be declared with the val
keyword.
This is sometimes useful to provide clarity as opposed to defining functions for constants.
val min_members = 1
val max_members = 20
All initializations for global values occur before the main
function is ran.
If you're writing low-level code then it might be useful to receieve the raw pointer of a global value and mutate it at runtime.
use std:io
use std:ptr
val count = 0
fn main =
let ptr = builtin:val_to_ref count in
do ptr:write ptr 5
then io:println count
Module System
A Lumina project is structured like this
project-name
├── config.lm
└── src
├── main.lm
├── other_dir
│ ├── file.lm
│ └── lib.lm
└── other_file.lm
When importing modules inside of Lumina source code, the relative filepath corresponds directly to the path in the use item.
So from main.lm
the other modules would be imported as.
use other_dir:file
use other_dir
use other_file
lib.lm
and main.lm
are magic filenames which are put under the namespace of the folder they're in.
So to import other_dir/lib.lm
in main.lm
then instead of use other_dir:lib
, you'd use use other_dir
When importing a module, you can also import items from that module directly.
use other_file:file [Direction [Right, Down]]
// ^ Item
// ^ Members under Item
As opposed to using items through the module name.
fn down = file:Direction:Down
Generics & Traits
Generics are used for making types and functions more flexible.
Imagine a scenario such as
fn select cond x y as bool string string -> string =
if cond
then x
else y
fn main =
select (1 > 2) "this" "that"
. io:println
Here we're using the type string
. However; the function select
doesn't actually care which type its used with.
Instead; we use a generic type.
fn select cond x y as bool a a -> a =
...
When defining functions, any lowercase single-letter type will be treated as a generic type.
Generics can also be used for type declarations to grant type parameters for a declared type.
type WithId v {
id int
value v
}
fn create_user as string -> WithId User =
...
Traits
If type T {...}
defines what a type has, and type T = A | B
defines what a type can have, then trait
defines what a type should be able to do.
trait Animal
fn make_noise as string
fn move as (int, int) -> (int, int)
Traits can be used in one of two ways. Either as a type by itself, which we call a trait object. Or as a constraint for generic types.
// Used as trait object
fn add as Animal Zoo -> Zoo =
...
// Used as trait constraint
when
a can Animal
fn add as a Zoo -> Zoo =
...
To implement a trait for a type, use the impl
keyword.
type Cat
impl Animal for Cat
fn make_noise = "meow"
fn move (x, y) = (x + 5, y + 2)
TODO: Most of this only makes sense if you're used to Rust or some degree Haskell. It should be explained more fundamentally.
Pipes (dot calls)
As functional programming often devolves to a tree of many nested function calls, Lumina provides numerous ways to split out nesting to make the code more readable.
The builtin pipe
construct serves as an alternative way of chaining function calls in a much more intuitive way.
forEach #io:println (filter #(> 6) (map #(+ 4) [1, 2, 3]))
// May instead be written as
[1, 2, 3]
. map #(+ 4)
. filter #(> 6)
. forEach #io:println
However; .
provides extra convenience by resolving functions from the module of where the type from the left-hand-side expression is defined.
Instead of
use std:list [forEach]
use std:io
fn main = [1, 2, 3] . forEach #io:println
You may write
use std:io
// `[_]` is defined in `std:list`. Thus; `forEach` is resolved from the module `std:list` automatically.
fn main = [1, 2, 3] . forEach #io:println
Attributes
Attributes serve as settings for the compiler and may be placed above any item.
@[platform "linux"]
fn platform_name = "linux"
@[platform "windows"]
fn platform_name = "windows"
@[precedence 2500]
fn & as int int -> int = ...
Pointer Arithmetics
Calling C functions via FFI
Updating Nested Records
// Setting a field that's deeply nested in records
fn set_id id e as int Entity -> Entity =
{ e ~ information.tracking.item.id = id }
// Modifying a field that's deeply nested in records
fn inc_id e as Entity -> Entity =
{ e ~ information.tracking.item.id @ id = id + 1 }