Oak

Oak

An expressive, simple, dynamic programming language.

Try Oak →

* Oak is still under construction! Official documentation for Oak will be available when the language is more finished.

std := import('std')
fmt := import('fmt')
http := import('http')

server := http.Server()
with server.route('/hello/:name') fn(params) {
    fn(req, end) if req.method {
        'GET' -> end({
            status: 200
            body: fmt.format('Hello, {{ 0 }}!'
                std.default(params.name, 'World'))
        })
        _ -> end(http.MethodNotAllowed)
    }
}
server.start(9999)

Oak is a dynamically typed, general-purpose programming language with a focus on simplicity and asynchrony, designed for (my) hobby projects. Idiomatic Oak code borrows often from the functional programming paradigm.

Oak code is easy to read and write, and Oak programs are practically capable — common tasks like running simple web servers, working with files, and rendering a web UI are straightforward. Oak has good support for concurrency and asynchronous programming with easy fall-backs to synchronous execution when it's simpler.

It borrows basic syntax and semantics from Lua and JavaScript: Oak has lists and objects, the null value, and first-class functions. But unlike those languages (at least, until recently), Oak has distinct 64-bit integer and floating-point types, as well as atoms (also called keywords).

The oak command-line tool ships an interpreter, but also includes:

As a result, all of Oak ships as a single executable file, and Oak programs can be deployed as a single source "bundle". This makes development and deployment easy.

Since late 2021, Oak has been my main programming language for simple scripts, side projects, and personal knowledge tools. You can also browse open-source Oak projects on GitHub using the oaklang tag.

How Oak came to be

Once upon a time, there was Ink. Ink was a toy programming language, and I used it to build everything from personal productivity apps to compilers to ray tracers. Ink was wonderful, but had a handful of shortcomings and misfeatures that became more and more obvious over time as I used it.

Oak is a sequel to Ink that tries to correct many of these early mistakes, while being easier to read and use. Oak is intentionally not "Ink 2.0" — it's not a simple upgrade or bug fix, but a different language with many well-considered updates in the details.

I've been building Oak since early July 2021. The core language and basic standard libraries are complete, but still feel too unstable to call it ready, and there's a lot missing that I'd like to add to Oak's tooling, like built-in utilities for testing, documentation generation, and perhaps even deployment. This is also why this website is very bare today — I haven't had time to stabilize and document much of anything yet!

Try Oak

I currently build and test release versions of Oak for Linux and macOS on x86, but other builds are also available from package repositories. To install, follow the instructions below for your operating system.

macOS

Oak is available on Homebrew. To install, just run

brew install oak

If you don't have Homebrew or don't want to use it for some reason, you may be able to run the following commands instead to install Oak to your $PATH.

# Download the released executable
curl -L https://github.com/thesephist/oak/releases/latest/download/oak-darwin > /usr/local/bin/oak

# Mark the binary as executable
chmod +x /usr/local/bin/oak

Linux

On Linux systems, you may be able to run the following commands to install Oak to your $PATH. You may need to become a superuser (sudo -s) to run these commands.

curl -L https://github.com/thesephist/oak/releases/latest/download/oak-linux > /usr/local/bin/oak
chmod +x /usr/local/bin/oak

Other platforms

You can download Oak from the GitHub releases page if there's a build for your platform. If not, you can clone the repository and build from source with Go by running

go build -o ./oak

Once you download the executable for your platform or build the binary, mark it as an executable file if necessary, and try running oak version.

$ ./oak version
Oak v0.2

If you see a version number like above, you’re all set. If you want to run Oak without the preceding ./, add it to your $PATH environment variable.

An introduction to Oak

What follows here is a work-in-progress — it'll probably evolve as Oak itself grows.

The REPL

The easiest way to run Oak code is through the REPL, which you can start by simply running oak at your terminal. Try running the program 1 + 2 + 3.

$ oak
> 1 + 2 + 3
6
>

The REPL will evaluate the expression typed in, print its value, and wait for your next input. At the REPL, all standard libraries are exposed by default through their names. For example, we can see the current time using the datetime library.

> time() |> datetime.format()
'2022-01-27T07:56:11.680098056793213Z'
>

At the REPL, we can type out the classic Hello World program. Here, the program prints Hello, World!, and returns the value 14, which is the number of bytes printed (which we usually ignore).

> std.println('Hello, World!')
Hello, World!
14
>

You can hit Ctrl-C or Ctrl-D to exit the REPL and return to your shell.

Writing Oak programs

Oak programs are written in files ending with .oak. You can pass other files to the oak CLI to run them, but when import(...)-ing files, Oak will only look for .oak files.

We can run Oak programs by simply passing it to oak.

$ cat hello.oak
std := import('std')
std.println('My first Oak program')

$ oak hello.oak
My first Oak program

Here's a more realistic Oak program.

std := import('std')

fn fizzbuzz(n) if [n % 3, n % 5] {
    [0, 0] -> 'FizzBuzz'
    [0, _] -> 'Fizz'
    [_, 0] -> 'Buzz'
    _ -> string(n)
}

std.range(1, 101) |> std.each(fn(n) {
    std.println(fizzbuzz(n))
})

To get a sense of how Oak programs look, you can check out the standard library source.

Values, types, functions, and modules

Oak has 10 different types of values. Oak is strongly typed, so moving a value between any of these types requires explicit conversion via a function, like int(). These 10 types are:

  • The null value, written ?
  • The empty value, written _, which is equal to all values, and useful for pattern-matching
  • Booleans, true and false
  • Integers like 10, -42, 1000000
  • Floating-point numbers like 0.0, 3.141592, -123456.789
  • Strings, mutable sequences of bytes, always written with single quotes like 'Hi'
  • Atoms or "keywords", written like :name,which are immutable strings used like enums or tags
  • Lists, written like [1, 2, 3], which can contain any other Oak value
  • Objects, written like { name: 'Linus' }, which are unordered dictionaries from string keys to any other Oak value
  • Functions, which are defined with the fn keyword, like fn double(n) 2 * n

Most values in Oak behave the way you'd expect, so here, let me draw your attention to the quirks unique to Oak.

  • There is no implicit type casting between any types, except during arithmetic operations when ints may be cast up to floats.
  • Both ints and floats are full 64-bit precision values.
  • Strings are mutable byte arrays, also used for arbitrary data storage in memory, like in Lua. For immutable strings, use atoms.
  • For lists and objects, equality (the = operator) is defined as deep equality. There is no identity equality in Oak.

We define a function in Oak with the fn keyword. A name is optional, and if given, will define that function in that scope. If there are no arguments, the () may be omitted.

fn double(n) 2 * n
fn speak {
    println('Hello!')
}

In broad strokes, Oak is a conventional imperative, expression-based, scripting language. Rather than re-introduce the whole language, here are the pieces that might set Oak apart from other languages you're familiar with.

Naming things

Oak variables and functions can begin with letters and the "_", "!", "?" characters, and can also contain numbers after the first character. ProductionEnv?, invalidate!, num_attendees, and ___runtime_gc are all valid Oak identifiers.

There are two loosely enforced naming conventions in Oak. First, global constants begin with uppercase letters, but are not written ALL_CAPS. Second, names starting with an underscore, like _bootstrapServer, are considered private to the file and shouldn't be imported into other modules. (However, this isn't currently enforced.)

Everything is an expression

In Oak, everything is an expression. Function definitions return the defined function; variable declarations and assignments return the new value; conditional expressions evaluate to their final value. Multiple expressions may be grouped into a single "block" with (...) or {...}, which are themselves expressions that evaluate to the last expression in the block.

Recursion: it's recursion

Oak doesn't have language constructs for looping like for or while, but has robust support for recursion and optimized tail calls, which is the preferred way of expressing loops — after one iteration, the function calls itself again to run the next iteration. For example, the each standard library function is implemented with a recursive sub-function fn sub:

fn each(xs, f) {
    fn sub(i) if i {
        len(xs) -> ?
        _ -> {
            f(xs.(i), i)
            sub(i + 1)
        }
    }
    sub(0)
}

In practice, we rarely write tail-recursive functions by hand for loops, and instead depend on standard library functions like range, map, each, filter, and reduce to express iteration.

if expressions

Oak uses one construct for control flow -- the if expression. Unlike a traditional if expression, which can only test for truthy and falsy values, Oak's if acts like a sophisticated switch-case, checking the target value in each "branch" until one that deeply equals the original value is reached.

fn pluralize(word, count) if count {
    1 -> word
    2 -> 'a pair of ' + word + 's'
    _ -> word + 's'
}

The true value of Oak's if expressions is its ability to match against complex shapes of data. For example, you might find this snippet in some server code:

if resp {
    { status: :ok, body: _ } -> handleResponse(resp)
    { status: :error, body: 'unknown' } -> handleUnknownError(resp)
    { status: :error, body: _ } -> handleGenericError(resp)
    _ -> pass(resp)
}

The if expression has a few different shorthands. The following pairs of expressions are equivalent — they're syntactic sugar for each other.

if show? -> showDialog()
if show? {
    true -> showDialog()
}

if {
    first?, last? -> counts.push(count)
    _ -> counts.shuffle()
}
if true {
    first? -> counts.push(count)
    last? -> counts.push(count)
    _ -> counts.shuffle()
}

Lists and objects

Oak uses lists and objects to organize state and data in programs.

A list is an ordered sequence of values. We can access and update the value of a list anywhere using an integer index, and append to the end of a list using the << operator. However, we can't remove values from the middle of a list — to do so, we'll need to create a new list that doesn't contain the values we don't want.

fruits := [:apple, :orange]
fruits << :pear
fruits.1 := :grapefruit

// fruits = [:apple, :grapefruit, :pear]

An object is an unordered dictionary relating string keys to arbitrary values. We can access and update values, add new values, and delete values by keys, but the values inserted into an object do not remember the order in which they were added — the order in which Oak loops through items in an object may change randomly.

profile := {
    name: 'Linus Lee'
    age: ?
    work: 'Thought & Craft'
    languages: ['Oak', 'Ink', 'JavaScript', 'Go']
}

// access and update values
profile.age := 23
profile.('work') := 'Writer'

// delete values by assigning _
profile.age := _

// get all keys of the object
keys(profile) // ['name', 'work', 'languages']

Accessing an index that is out of bounds in a list, or accessing an object with a key that doesn't exist on that object, will evaluate to ?, the null value.

Unorthodox operators

Besides the normal set of binary and arithmetic operators, Oak has a few strange operators.

The assignment operator := binds values on the right side to names on the left, potentially by destructuring an object or list. For example:

a := 1              // a is 1
[b, c] := [2, 3]    // b is 2, c is 3
d := double(a)      // d is 2

The nonlocal assignment operator <- binds values on the right side to names on the left, but only when those variables already exist. If the variable doesn't exist in the current scope, the operator ascends up parent scopes until it reaches the global scope to find the last scope where that name was bound.

n := 10
m := 20
{
    n <- 30
    m := 40
}
n // 30
m // 20

The push operator << pushes values onto the end of a string or a list, mutating it, and returns the changed string or list.

str := 'Hello '
str << 'World!' // 'Hello World!'

list := [1, 2, 3]
list << 4
list << 5 << 6 // [1, 2, 3, 4, 5, 6]

The pipe operator |> takes a value on the left and makes it the first argument to a function call on the right.

// print 2n for every prime n in range [0, 10)
range(10) |> filter(prime?) |>
    each(double) |> each(println)

// adding numbers
fn add(a, b) a + b
10 |> add(20) |> add(3) // 33

Importing libraries

Oak code is organized into "modules". A module is a single source file, described by its name or relative path. To use functions and variables defined in one file from another file, use the import() function.

std := import('std')
std.println('Hello!')

import('./path/to/module') returns an object whose keys are the names of all variables defined at the module's top-level scope, and whose values are the values of those variables. If you're familiar with JavaScript's require() module system, import() works very similarly, except that there is no explicit module.exports variable.

Standard libraries like std, str, and others can be referenced by their name directly in import. Other modules imported from your file system must be referenced by a relative path, beginning with ./, as in utils := import('./utils').

Callbacks and the with expression

Because callback-based asynchronous concurrency is common in Oak, there's special syntax sugar, the with expression, to help. The with syntax lets you place the last argument to a function (usually a callback function) after the function call itself, like this.

with readFile('./path') fn(file) {
    println(file)
}

// desugars to
readFile('./path', fn(file) {
    println(file)
})

This syntax makes common callback-based patterns easier to read. For example, to read a file:

std := import('std')
fs := import('fs')

with fs.readFile('./file.txt') fn(file) if file {
    ? -> std.println('Could not read file!')
    _ -> print(file)
}

Consistent style with oak fmt

Inspired by inkfmt, as well as the many code formatters before it like clang-format, gofmt, and prettier, Oak has a canonical code formatter called oak fmt. It ships with the Oak CLI, and you can invoke it on any file using oak fmt .

By default, oak fmt will take Oak source files, format them, and print the result. But most of the time, what you want is oak fmt <files> --fix, which will modify the files in-place with the correct formatting. Another helpful flag, --changes, will run git diff to only format Oak source files that have uncommitted changes.

For more information on oak fmt, see oak help fmt.

Bundling and compiling with oak build

While the Oak interpreter can run programs and modules directly from source code on the file system, Oak also offers a build tool, oak build, which can bundle an Oak program distributed across many files into a single "bundle" source file. oak build can also cross-compile Oak bundles into JavaScript bundles, to run in the browser or in JavaScript environments like Node.js and Deno. This allows Oak programs to be deployed and distributed as single-file programs, both on the server and in the browser.

To build a new bundle, we can simply pass an "entrypoint" to the program.

oak build --entry src/main.oak --output dist/bundle.oak

Compiling to JavaScript works similarly, but with the --web flag, which turns on JavaScript cross-compilation.

oak build --entry src/app.js.oak --output dist/bundle.js --web

The bundler and compiler are built on top of my past work with the September toolchain for Ink, but slightly re-architected to support bundling and multiple compilation targets. In the future, the goal of oak build is to become an optimizing compiler and potentially help yield an oak compile command that could package the interpreter and an Oak bundle into a single executable binary.

For more information on oak build, see oak help build.