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:
- a rich standard library
- a code formatter
- a bundler and an Oak-to-JavaScript compiler
- a test runner
- a tool for working with documentation in source files
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
andfalse
- 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, likefn 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
if
expressionsOak 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
with
expressionBecause 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
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
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
.