The Lit Programming Language

Lit is a simple scripting language.

fn factorial_of { |n|
  if n <= 1 do return 1

  n * factorial_of(n - 1)
}

if let n = readln().to_i!() {
  println("The factorial of {n} is {factorial_of(n)}")
} else {
  println("Not a valid number")
}

I build it for fun while reading Crafting Interpreters. This site is more or less my vision for the language. Nothing is set in stone yet, and not all features are implemented.

Getting Started

To use Lit, you’ll have to build it from source. It is build using Crystal, so you’ll need to have that installed first.

  1. Install Crystal
  2. Build Lit
    1. Clone the repository git clone https://github.com/lit-lang/lit
    2. Change into the directory cd lit
    3. Build the project tools/build. This will create an executable at bin/lit
  3. Run a script with bin/lit hello.lit or bin/lit to enter the REPL.

Syntax

Comments

Comments can appear at the end of a line or span multiple lines.

# Single-line comments

#= Multi-line comments
  #=
    can be nested
  =# still a comment
=# "not here"

Literals

Lit has a few basic literal types: integers, floats, strings, booleans, functions, arrays, and maps.

Numbers

1         # integer
1.0       # float
1_000_000 # underscores are allowed
0.000_1   # even on the decimal side

Strings

# double-quoted strings can contain interpolation
"Hello, {"world"}"

# double-quoted strings can contain escape sequences
"I can\tescape\ncharacters"

# single-quoted strings are raw
'Hello, {"world"}'
'I will not escape \n characters'

# short-hand for single-quoted strings
:hey == 'hey' # => true

Strings support these escape codes:

Booleans

In Lit, only nil, false and errors are falsey. Everything else is truthy.

println(true and 0 and "" and fn {} and [] and {:} and "Truthy") # "Truthy"
println(false or "Falsey") # "Falsey"

Arrays

Arrays are ordered lists of values.

let arr = [1, 2, 3,] # trailing comma is allowed

# You can access elements in an array using the `[]` operator
arr[0] # => 1

# Use `[]=` to set an element in an array:
arr[0] = 4
println(arr) # => [4, 2, 3]

Maps

Maps are key-value pairs (separated by a colon), also known as dictionaries or hashes in other languages.

# Create a map
let user = {
  "name" : "Yukihiro Matsumoto",
  "role" : "creator", # trailing comma is allowed
}

# Access a value in a map
user["name"] # => "Yukihiro Matsumoto"

# Set a value in a map
user["name"] = "Matz"
println(user) # => {"name" : "Matz", "role" : "creator"}

# An empty map
let empty_map = {:}

{x: 0, y: 0} # same as {"x" : 0, "y" : 0}

Functions

# Function definition
fn greet { |name| println("Hello, {name}") }

# Function call
greet("Matz") # outputs "Hello, Matz"

# Named functions **aren't** closures
let name = "Matz"
fn greet { println("Hello, {name}") } # error: 'name' is not defined

# Anonymous functions are closures
let name = "Matz"
let greet = fn { println("Hello, {name}") } # outputs "Hello, Matz"

# single-expression functions can use the `do` syntax
fn greet do |name| println("Hello, {name}")

# They also support the `it` default parameter:
fn greet do println("Hello, {it}")
greet("Matz") # outputs "Hello, Matz"

# Blocks return their last expression
fn fun {
  false
  "something"
  1
}
fun() # => 1

# you can use `return` to return early
fn fun { |value|
  if value {
    return "truthy"
  }

  "falsey"
}
fun(true)  # => "truthy"

# you can create anonymous functions:
[1, 2, 3].map(fn { |x| x * 2 }) # => [2, 4, 6]

# use the do syntax for single-expression blocks:
[1, 2, 3].map(fn do it * 2) # => [2, 4, 6]

Variables

You can declare variables using let or var. Variables are scoped to the block they are defined in. You can use let for immutable bindings and var for mutable bindings.

let x = 1
x = 2 # error: cannot assign to immutable variable

var y = 1
y = 2 # ok

Operators

Lit supports a variety of operators for different operations.

-1           # negation
1 + 2        # addition
1 - 2        # subtraction
1 * 2        # multiplication
1 / 2        # division
1 % 2        # modulus
1 > 2        # comparison
1 < 2
1 >= 2
1 <= 2
1 == 2

These operators can be overloaded on custom types. See Operator Overloading for more details.

Augmented Assignment

Lit supports augmented assignment operators for convenience. These operators combine an operation with assignment, making code more concise:

var n = 5
n += 2  # same as n = n + 2
n -= 1  # same as n = n - 1
n *= 3  # same as n = n * 3
n /= 2  # same as n = n / 2
n %= 4  # same as n = n % 4

You can use these with any mutable variable (var). Augmented assignments work with any type that support the corresponding operator.

Boolean Operators

Lit provides the and and or operators for combining boolean expressions. These operators short-circuit.

println(true and "yes")   # => "yes"
println(false and "no")   # => false

println(nil or "default") # => "default"
println("value" or "alt") # => "value"

Blocks & Scoping

Blocks are expressions. The last expression in a block is the return value. Because of that, you can use blocks to change the order of operations.

{1 + 2} * 3
# => 9

For a single-expression body, you can use the do syntax:

fn debug do println("DEBUG: {it}")
debug("Hello, World!") # outputs "DEBUG: Hello, World!"

Control Flow

if expressions

Lit uses if as an expression, meaning it returns a value. You can use if with or without else and chain multiple conditions with else if.

let n = 5

let result = if n > 10 {
  "greater than 10"
} else if n > 5 {
  "greater than 5"
} else {
  "5 or less"
}

println(result) # outputs "5 or less"

The last expression in each block is the value returned by that branch. else is optional; if omitted and no condition matches, the result is nil.

You can also use the single-expression do syntax for concise conditions:

let status = if n % 2 == 0 do "even" else do "odd"
println(status) # outputs "odd"

match expressions

#todo

while / until expressions

Lit provides while and until expressions for looping. Both forms are expressions, so they return a value: the last expression evaluated in the loop body or the value of break if used.

You can use break to exit the loop early. break can take an optional value, which becomes the value of the loop expression.

let i = 0
let result = while i < 5 {
  if i == 3 {
    break "stopped at 3"
  }
  i += 1
}
println(result) # outputs "stopped at 3"

let j = 0
until j == 2 {
  println(j)
  j += 1
}
# outputs:
# 0
# 1

Custom Types

Lit supports custom types using the type keyword. Types are composed of one or more variants. Each variant defines a constructor with fields and optional methods. Fields and methods are public by default unless their name starts with an underscore _.

Defining a Type

To define a type with a single variant, use the following syntax:

type Point { |x, y|
  fn to_s { "({x}, {y})" }
}

let origin = Point(0, 0)
println(origin) # outputs "(0, 0)"

Multiple Variants

Types can have multiple variants, each representing a different shape or case. This is useful for enums, tagged unions, or sum types.

type Shape {
  variant Circle { |radius|
    fn area { 3.14 * radius * radius }
  }

  variant Square { |side|
    fn area { side * side }
  }

  fn kind {
    match self {
      Circle then "circle"
      Square then "square"
    }
  }
}

let s1 = Shape.Circle(5)
println(s1.kind()) # outputs "circle"
println(s1.area()) # outputs "78.5"

let s2 = Shape.Square(3)
println(s2.kind()) # outputs "square"
println(s2.area()) # outputs "9"

Field and Method Visibility

Fields are only directly accessible inside the variant block. Fields starting with _ are private and cannot be matched.

Methods starting with _ are private and cannot be called from outside the type declaration.

type User { |name, _password|
  fn check_password do it == _password # please don't use this in production
  fn _debug { println("user={name}") }
}

let user = User("alice", "secret")

match user {
  Account { |name, _password| #= body =# }        # error: _password is private
}

user.check_password("guess")   # => false
user._debug()                  # error: _debug is private

Operator Overloading

Lit supports operator overloading for custom types. You can define how operators behave for your custom types by implementing the corresponding methods. Here’s a list of the currently supported operators and the methods you need to implement in your type to overload them:

Operator Method to implement
unary - neg
+ add
- sub
* mul
/ div
% mod
== eq
!= neq
< lt
<= lte
> gt
>= gte
[] get
[]= set
stringify (i.e. println) to_s

Modules (#todo)

Imports

You can now use the import keyword to import other Lit files. They work similar to Ruby’s require_relative. There’s not module system yet, so all the files imported this way share the same global scope.

import "foo" # imports foo.lit from the same directory
import "../bar" # imports bar.lit from the parent directory
import "baz.lit" # adding the `.lit` extension is optional

Error messages include the file name and line number, to help you find the error source.

Collections & Iteration (#todo)

Error Handling