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

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      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 }

Available Targets

IO and the file system

List & its Sub-types