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

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!

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

SizeUnsignedSigned
8 bitsu8i8
16 bitsu16i16
32 bitsu32i32
64 bitsu64i64
Archuintint

     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

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

TODO: Explain the purpose of patterns, their distinction from expressions, and how pattern matching is a core tool of functional programming.

Pattern Matching with match

// 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), can also be written as 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

Module System

Generics & Traits

Pipes (dot calls)

Attributes

Pointer Arithmetics

Calling C functions via FFI

Updating Nested Records

Available Targets

IO and the file system

List & its Sub-types