Tau is a dynamically typed, interpreted programming language. It is small on purpose: a handful of keywords, one way to build an object, errors that are ordinary values, and concurrency that looks like Go's. The interpreter is written in Go around a virtual machine and an object representation written in C; the standard library is written in tau itself, under stdlib, which is the best place to read the language as it is actually used.
- Installing
- The tools
- A tour of the language
- The elegant bits
- Errors
- Modules
- Concurrency
- C libraries
- Shipping a program
- The standard library
- Builtin functions
You need Go and a C compiler. libffi is a submodule, so clone with it.
git clone --recurse-submodules https://github.com/NicoNex/tau
cd tau
make installmake alone builds tau, the small C runtime tau-rt and the shared objects
the standard library opens; make install copies them into a prefix. The
default prefix is $HOME/.local, which needs no root: the binary goes in
~/.local/bin and the standard library in ~/.local/lib/tau. For a system
wide install pass the prefix:
sudo make install PREFIX=/usr/localmake uninstall removes both. make test runs the Go tests and then the tau
ones. make fmt formats the tau sources in the tree. make tau-lsp builds the
language server, which speaks LSP over stdin and stdout.
A module is looked up, in order, in the directories of TAUPATH (separated
like PATH), next to the file that imports it, then in ~/.local/lib/tau,
/usr/local/lib/tau and /lib/tau. That is how a checkout runs against its
own standard library without installing anything:
TAUPATH=$PWD/stdlib ./tau myprogram.tauEverything is one binary.
tau start the REPL
tau FILE [ARGS] run a file, shorthand for tau run
tau run FILE [ARGS] the same, explicitly
tau build FILE... compile to '.tauc' bytecode
tau bundle -o app FILE compile into a standalone executable
tau test [PATH...] run the '*_test.tau' files found in PATH
tau fmt [-w|-l] PATH... format sources in the canonical style
tau version print the version
tau help COMMAND help for one command
tau version prints what git describe said when the binary was built, so a
release is a tag and nothing else, and a tree between two tags says so:
$ tau version
Tau v2.0.15-53-g5b05dc5 on Linux
A file can also carry a shebang and run on its own:
#!/usr/bin/env tau
println("hello world")The REPL is multiline: a block keeps reading until it is closed.
Tau v2.0.15-53-g5b05dc5 on Linux
>>> repeat = fn(n, func) {
... for i = 0; i < n; ++i {
... func(i)
... }
... }
...
>>> repeat(3, fn(i) { println("hello #{i}") })
hello #0
hello #1
hello #2
>>>
The types are int, float, string, bool, null, list, map,
object, bytes, closure, error and pipe. type(x) gives the name of
one as a string.
Integers can be written in base 10, 16, 2 and 8, and underscores may be used anywhere inside a number to group digits.
Two integers divide into an integer, the way they do in C and in Go: the
remainder is dropped. A float on either side makes it a float division, as it
does for +, - and *, so float(a) / b is how two integers give a
fraction. Dividing an integer by zero is an error, where a float division
gives inf.
# numbers
println(255, 0xff, 0b1111_1111, 0o377, 1_000_000)
println(2.5, 1.5e3, 7 / 2, 7 % 2, 7 / 2.0, float(7) / 2)
# strings
name = "tau"
println("hello, {name}", "the answer is {6 * 7}")
println(`a raw string: {name} and \n stay as they are`)
# lists, maps, objects
xs = [1, "two", 3.0]
kv = {"a": 1, 2: true}
o = new()
o.field = "value"
println(xs[1], kv["a"], o.field, len(xs), keys(kv))255 255 255 255 1000000
2.5 1500 3 1 3.5 3.5
hello, tau the answer is 42
a raw string: {name} and \n stay as they are
two 1 value 3 [2, a]
Anything between braces inside a double quoted string is an expression, and its
value is put in the string. To write a brace that stands for itself, double it
or escape it: "{{" and "\{" both give one. A string in backticks is raw:
braces and backslashes are the characters they look like, which is the easier
way to write anything with braces in it, JSON included. Since a nested string
closes the one it sits in, quote it: "{if hot { \"warm\" } else { \"cold\" }}".
Comments start with # and run to the end of the line. A newline ends a
statement; ; does the same in the middle of a line.
= += -= *= /= %= &= |= ^= <<= >>=
|| && !
== != < > <= >=
+ - * / %
& | ^ ~ << >>
++ --
From loosest to tightest, the levels are: assignment, ||, &&, |, ^,
&, == and !=, the comparisons, << and >>, + and -, * / %,
the prefix operators, calls, indexing, and . last. That is the order C uses,
trap included: & is looser than ==, so a & b == c is a & (b == c) and
wants parentheses. The shifts are looser than the arithmetic as well, so
1 << 2 + 3 is 1 << 5, which is 32.
== on two lists or two maps compares identity, not contents; for a structural
comparison use cmp.Equal from the standard library.
A list, a map, a call and a parameter list may end with a comma, so that a line can be added to one written over several lines without touching the line above it. It is allowed, never required.
primes = [
2,
3,
5,
]for has three shapes and no other: an empty one that never stops, one
expression that is the condition, three that are the C header.
for { break } # forever
for i < 10 { i++ } # while
for i = 0; i < 10; i++ { } # the usualbreak and continue do what they look like.
++ and -- come in both forms and behave as they do in C: written in front
of what they count they give back the new value, written after it they give
back the old one. Either way the variable ends up changed by one.
i = 5
println(i++) # 5, and i is now 6
println(++i) # 7, and i is now 7A function is a value written with fn. The last expression of a body is its
result, so return is only needed to leave early.
add = fn(x, y) { x + y }
fib = fn(n) {
if n < 2 {
return n
}
fib(n-1) + fib(n-2)
}
println(add(9, 1), fib(20))10 6765
A function reads the names of the function around it and the global ones. What it cannot do is write to them: assigning to one of those names makes a local of its own, starting from the value that was read on the right of the assignment, and shadowing the name from there on.
mk = fn() {
n = 0
return fn() { n = n + 1; return n }
}
next = mk()
println(next(), next()) # 1 1, the n outside never movesA closure can change what is inside something it captured, because the name
goes on meaning the same thing. That is what the ref module is for:
ref = import("ref")
mk = fn() {
n = ref.New(0)
return fn() { n.v = n.v + 1; return n.v }
}
next = mk()
println(next(), next()) # 1 2new() returns an empty object; a constructor is an ordinary function that
fills one in and returns it. Methods are functions stored in its fields, and
they reach the object the same way they reach anything else: by closing over
the variable that holds it.
newQueue = fn() {
q = new()
q.items = []
q.Push = fn(x) { q.items = append(q.items, x); return q }
q.Pop = fn() {
if len(q.items) == 0 { return error("empty queue") }
x = q.items[0]
q.items = slice(q.items, 1, len(q.items))
return x
}
q.Len = fn() { len(q.items) }
return q
}
q = newQueue()
q.Push("a").Push("b")
println(q.Len(), q.Pop(), q.Pop(), q.Pop())
pop = q.Pop # a method is just a value
println(type(pop), failed(pop()))2 a b empty queue
closure true
There is no self and no this because there is nothing for them to do. The
receiver is q, a normal local of the constructor, and a method mentions it
the way it mentions any other captured variable. That buys three things:
- one concept instead of two. Closures already exist; a receiver keyword would be a second, parallel way of getting at the same value.
- no implicit binding to get wrong.
pop = q.Popstill works, becauseqwas captured when the function was made, not looked up from whatever the call happened to be written against. - methods are values like any other. They can be passed, stored, wrapped, or
replaced after the fact:
q.Pop = fn() { ... }and every caller sees the new one.
The standard library is built this way throughout: newT in
stdlib/testing.tau builds the t a test case is given,
newFile in stdlib/os.tau wraps a file descriptor, New in
stdlib/rand.tau holds the state of a generator.
Both give back a value, which is what makes the error idiom below read the way it does.
n = 7
kind = if n % 2 == 0 { "even" } else { "odd" }
println(kind)
println(total = 20 + 22)odd
42
An if with no else that doesn't run gives null.
An error is a value, made with error(msg), tested with failed(x). Nothing
is thrown, nothing unwinds: a function that failed returns the error, and the
caller decides.
Because assignment is an expression, the call and the test on its result are one line, which is the shape most of the standard library is written in:
strconv = import("strconv")
parse = fn(s) {
if failed(n = strconv.Atoi(s)) {
return error("not a number: {s}")
}
return n * 2
}
println(parse("21"))
println(parse("x"))42
not a number: x
exit(err) ends the program printing the error and returning non zero.
A mistake the VM catches itself stops the program with the line it happened on:
increment = fn(n) {
return n + 1
}
increment("this will raise a runtime error")error in file errtest.tau at line 2:
return n + 1
^
unsupported operator '+' for types string and int
runtime error
import("name") loads a file once and gives back an object. There is no export
list: the names that start with a capital letter are the ones that end up in
that object, everything else stays private to the module. A path with slashes
reaches into a directory, as in import("crypto/sha256").
# greeting.tau
prefix = "hello"
Greet = fn(name) { "{prefix}, {name}" }
newGreeter = fn(prefix) {
g = new()
g.prefix = prefix
g.Greet = fn(name) { "{g.prefix}, {name}" }
return g
}
Formal = newGreeter("good evening")# main.tau
greeting = import("greeting")
println(greeting.Greet("world"))
println(greeting.Formal.Greet("world"))
println(keys(greeting))hello, world
good evening, world
[Greet, Formal]
A module being a plain object is worth saying out loud: it is the same thing
new() makes, so it can be passed to a function, kept in a list, or have its
fields read with keys. prefix and newGreeter are not in it, and neither
is the prefix field of Formal, but the exported Greet closes over both
and works anyway.
Importing a file twice gives the same module: the table is keyed on the absolute path, so two spellings of one file still run it once. A cycle is an error rather than a crash.
tau test runs every *_test.tau in the paths it is given, each in its own
process. A test file hands its cases to testing.Main:
# double_test.tau
testing = import("testing")
double = fn(x) { x * 2 }
testing.Main([
["double", fn(t) {
t.AssertEq(double(21), 42)
}],
["double of zero", fn(t) {
t.AssertEq(double(0), 1)
}]
])$ tau test double_test.tau
=== double_test.tau
--- PASS: double (0ms)
--- FAIL: double of zero (0ms)
got 0, want 1
FAIL 1 failed, 1 passed of 2 (0ms)
1 of 1 test files failed
tau f(x) runs a call in a tau-routine of its own. Values move between them
through pipes, built with pipe() and handled by send, recv and close.
Pipes can be buffered or unbuffered. On an unbuffered pipe send hands the
value directly to a receiver: the tau-routine that sends sleeps until another
one calls recv. A buffered pipe holds as many values as it was created with,
so send returns right away and only sleeps once the buffer is full. recv on
an empty pipe sleeps until something is sent. close closes the pipe, allowing
it to be garbage collected; the values already in it are still delivered, and
once there are none left recv returns null.
That last rule is what makes a receiving loop a one-liner: for val = recv(p)
assigns, tests, and stops when the pipe is drained and closed.
listen = fn(p) {
for val = recv(p) {
println(val)
}
println("bye bye...")
}
p = pipe()
tau listen(p)
send(p, "hello")
send(p, "world")
send(p, 123)
close(p)hello
world
123
bye bye...
The runtime module says how many cores the program may run on, which is the
number to size a pool of workers with:
runtime = import("runtime")
work = fn(id, jobs, out) {
for j = recv(jobs) {
send(out, j * j)
}
}
jobs = pipe(8)
out = pipe(8)
for i = 0; i < runtime.NumCPU(); i++ {
tau work(i, jobs, out)
}
for i = 1; i <= 6; i++ { send(jobs, i) }
close(jobs)
squares = []
for i = 0; i < 6; i++ { squares = append(squares, recv(out)) }
close(out)
println(squares)[1, 9, 16, 4, 25, 36]
The results come back in whatever order the workers finished in, which is a different one on every run.
There are two ways to call C, and the second is built out of the first.
dlopen(path) opens a shared object. The dot on the handle is dlsym, so
lib.name is a symbol, and lib[name] is the same lookup with a name worked
out while the program runs. dlopen(null) is the handle of the program
itself, which is where the C library it was linked against can be reached.
It takes the name of a file, and that name differs from system to system, so
ffi.Lib("m") is the one to reach for when a program has to run on more than
one: it tries the shapes the system uses -- libm.so.6, libm.dylib,
m.dll -- and says what it tried when none of them opens. A name with a slash
or an extension already in it is opened as it stands.
gcc -shared -o vec.so -fPIC vec.c// vec.c
double dot(double a, double b) { return a * b; }
const char *greet(const char *name) {
static char buf[64];
snprintf(buf, sizeof buf, "hello, %s", name);
return buf;
}
void *fill(int n) {
char *p = malloc(n);
memset(p, 'x', n);
return p;
}A symbol can be called with no declaration at all. Every argument goes as an
int64 or a double, and the result comes back as a machine word you take apart
yourself: int(x) for a number, string(x) for a char *, bytes(x, n) for
a buffer, and a pointer C gave you can be handed straight back to C.
libc = dlopen("libc.so.6")
p = libc.malloc(32)
libc.memcpy(p, "hello", 6)
println(int(libc.strlen(p)), string(p), string(bytes(p, 5)))
libc.free(p)
println(int(libc.abs(-5)), int(libc.atoi("1234")))5 hello hello
5 1234
It is three lines to try a library, and it takes your word for the types: pass
the wrong ones and you get a wrong answer, or a signal. One limit is worth
knowing before it bites, since nothing warns you about it: a function that
returns a double does not survive this. The call is prepared with a
pointer-sized result and reads the integer register, so lib.dot(1.5, 4.0)
comes back as 9.88e-324 rather than 6. Pointers and integers do survive.
The ffi module of the standard library gives a symbol a signature, and from
then on the arguments travel as those types and the result comes back as a tau
value.
A signature is a C declaration. The name of the function and the names of the arguments may be there or not, so a line copied out of a header works as it stands.
ffi = import("ffi")
lib = dlopen("./vec.so")
dot = ffi.Func(lib.dot, "double dot(double a, double b)")
greet = ffi.Func(lib.greet, "const char *greet(const char *name)")
fill = ffi.Func(lib.fill, "void *fill(int n)")
println(dot(1.5, 4), greet("tau"), string(bytes(fill(4), 4)))6 hello, tau xxxx
An integer where the signature says double is converted on the way in, so
dot(1.5, 4) needs no help.
ffi.Bind does a whole library at once, naming each function the way its
signature names it, and takes the name of a library as well as a handle -- the
same names ffi.Lib understands:
m = ffi.Bind("m", [
"double pow(double, double)",
"double sqrt(double)",
])
println(m.pow(2.0, 10.0), m.sqrt(2))1024 1.4142135623730951
Memory comes from the C library rather than from the language: ffi.Alloc and
ffi.Free are malloc and free, ffi.Write is memcpy into a pointer,
ffi.String reads a C string up to its NUL, and ffi.Read(p, n) is
bytes(p, n).
ffi.Export turns a tau function into one C can call, which is what a library
wants when it takes a handler, a comparator or a visitor:
ffi = import("ffi")
libc = dlopen("libc.so.6")
qsort = ffi.Func(libc.qsort, "void qsort(void *base, size_t n, size_t size, void *cmp)")
cmp = ffi.Export("int compare(const void *a, const void *b)", fn(a, b) {
return bytes(a, 1)[0] - bytes(b, 1)[0]
})
buf = bytes([5, 3, 9, 1, 7])
qsort(buf, 5, 1, cmp)
println(buf)[1, 3, 5, 7, 9]
What comes back is an ordinary function pointer as far as C is concerned. The call is answered by the VM of the thread that entered C, so a handler called from the loop of a library works; one called from a thread the library made for itself finds no tau there and gets a zero back. A function that fails inside a callback does not unwind through C: the failure stops there, and the C side carries on with what it was given.
The exported function lives for as long as the program does. Whoever was handed the pointer never says when they are finished with it, and freeing a trampoline C still holds is the one crash this cannot have.
The types are the ones C writes, the exact width ones of stdint.h, and the
same widths spelled the way tau spells its own:
| written | is |
|---|---|
void |
nothing, as a result, and no arguments as a parameter list |
bool, _Bool |
a boolean |
char, short, int, long, long long |
the signed integer of that width on this machine, unsigned in front for the other sign |
size_t, ssize_t, intptr_t, uintptr_t |
the width they have on this machine |
int8_t … uint64_t, int8 … uint64 |
exactly that many bits |
float, double, float32, float64 |
a float |
char * |
a tau string, NULL terminated on the way out and copied on the way back |
any other T *, T [], pointer |
an address: a bytes buffer, a string, a native value or an integer |
const, volatile and restrict are read and ignored, as is the room around
anything. A variadic signature is refused, since the call is prepared once and
a ... says nothing about what will be passed: write the types this particular
call passes, int(char *, double) rather than int(const char *, ...).
The call is prepared once, when ffi.Func is given the signature, not on every
call. It passes an error through untouched, so a failed symbol lookup says so
instead of turning into a nonsense pointer.
Underneath, ffi.Func reads the declaration and calls cfunc(sym, ret, args),
which takes the types as numbers and prepares the call. That is the whole of
the C side: reading a declaration is tau, in stdlib/ffi.tau.
A pointer that came back from C is read with bytes(ptr, n), which copies n
bytes out of it: that is how a returned buffer, or a struct, is brought into
tau. bytes(n) on its own allocates an empty buffer of n bytes, the way
make([]byte, n) does in Go. For a word whose width is known, int(x, bits)
sign-extends the right number of them.
The standard library uses all of this. stdlib/math.tau is
the shortest example and carries no C of its own: the interpreter is linked
against libm, so it declares the functions it wants off dlopen(null).
stdlib/syscall and stdlib/runtime are small
shared objects of their own, opened with dlopen and called the raw way.
tau bundle writes a program and everything it imports into a single
executable: a copy of the runtime with the program appended to it. The result
runs where tau is not installed and needs nothing else, the modules and the
shared objects it uses included.
$ tau bundle -o hello main.tau
$ ./hello
hello, worldThe modules travel compiled, so a bundled program starts without a parser and
without a compiler. The executable is built on tau-rt, a runtime written in C
that carries the VM, the objects and the bytecode decoder and nothing else, so
a bundled program weighs a few hundred kilobytes rather than a few megabytes
and starts in about two milliseconds. make install puts tau-rt next to the
standard library, in PREFIX/lib/tau, which is where tau bundle looks for
it.
An import of a path worked out at run time cannot be bundled and is refused
there: it was never in the source for the bundler to find, and there is no
compiler left to answer it.
tau build is the lighter option for machines that already have tau: it writes
a .tauc carrying the same program and its dependencies, without the runtime
in front of it.
On macOS the executable has to be signed before it will start, because appending to one invalidates the signature it came with:
codesign -s - myappAll of it is under stdlib and all of it is readable tau. Each file starts with a comment saying what it is for.
| module | |
|---|---|
buffer |
growing buffers of text and bytes |
bufio |
buffered reading and writing |
cmp |
comparison and ordering of values, Equal and Compare |
crypto/hmac |
the keyed hash of RFC 2104, over SHA-256 |
crypto/sha256 |
the hash of FIPS 180-4 |
csv |
comma separated values, the way RFC 4180 writes them |
encoding/base64 |
the encoding of RFC 4648 |
encoding/hex |
hexadecimal encoding |
errno |
the numbers errno takes |
errors |
errors as values: New, Wrap, Is, Message |
exec |
running other programs |
flag |
command line flags |
http |
HTTP/1.1 client and server, in the shape of Go's net/http |
io |
moving bytes between streams |
json |
JSON, parsed and written |
list |
operations on lists |
log |
messages with a date on them |
maps |
operations on maps and objects |
math |
the usual functions on floats |
net |
TCP and UDP sockets, in the shape of Go's net package |
os |
files, environment, working directory, os.Args |
path |
slash separated paths |
rand |
pseudo random numbers, and Crypto for the ones that matter |
ref |
a cell holding a value that a closure has to change |
regexp |
regular expressions, in the shape of Go's regexp package |
runtime |
what the program can know about the machine it runs on |
strconv |
conversions between strings and numbers |
strings |
operations on strings |
syscall |
the system calls underneath the rest |
testing |
the test runner tau test uses |
time |
clocks and pauses |
utf8 |
text as code points |
http and net have their own documents: HTTP_README.md and
NET_README.md.
A taste of a few of them:
log = import("log")
rand = import("rand")
utf8 = import("utf8")
csv = import("csv")
sha256 = import("crypto/sha256")
exec = import("exec")
log.Print("started")
r = rand.New(1) # seeded, so the sequence repeats
println(r.Intn(100), r.Intn(100))
println(utf8.RuneCount("caffè"), len("caffè"), utf8.Runes("naïve"))
println(csv.Parse("a,b\n\"c,d\",e", ","))
println(sha256.Hex("abc"))
println(exec.Output(["echo", "hi"]))2026/07/29 11:10:59 started
11 56
5 6 [110, 97, 239, 118, 101]
[[a, b], [c,d, e]]
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
hi
And the flags of a program, which read like Go's:
flag = import("flag")
strings = import("strings")
n = flag.Int("n", 1, "how many times")
name = flag.String("name", "world", "who to greet")
loud = flag.Bool("loud", false, "shout")
rest = flag.Parse()
for i = 0; i < n.Value; i++ {
msg = "hello, {name.Value}"
println(if loud.Value { strings.ToUpper(msg) } else { msg })
}
println("rest:", rest)$ tau greet.tau -n 2 -name tau -loud extra
HELLO, TAU
HELLO, TAU
rest: [extra]
These are always in scope, no import needed.
len(x)-- the length of a String, List, Map or Bytes.println(...)-- print the arguments separated by a space, with a newline.print(...)-- the same without the newline.input([prompt])-- read a line from standard input, after an optional prompt.string(x)-- convertxto a String.error(s)-- build an error carrying the messages.type(x)-- the name of the type ofx, as a String.int(x[, bits])-- convertxto an Integer; on a native valuebitssays how many of its bits are meaningful (8, 16, 32 or 64).float(x)-- convertxto a Float.exit([code | message[, code]])-- end the program, with an optional message and exit code.append(xs, ...)-- a new List with the arguments added at the end.new()-- a fresh empty object.failed(x)-- true ifxis an error.dlopen(path)-- open a C shared object, or the program itself withnull.cfunc(sym, ret, args)-- a C function with its types given as codes. Whatffi.Funcis made of, see C libraries.cexport(fn, ret, args)-- a tau function C can call, the same way round. Whatffi.Exportis made of.pipe([n])-- a new pipe, unbuffered or holdingnvalues.send(p, x)-- sendxto the pipep.recv(p)-- take the next value out of the pipep.close(p)-- close the pipep.hex(x),oct(x),bin(x)-- the base 16, 8 and 2 spellings ofx.slice(x, start, end)-- part of a String, List or Bytes.keys(x)-- the keys of a Map, or the field names of an object, as a List.delete(m, k)-- remove the keykfrom the Mapm.bytes(x)-- Bytes from a String, a List of integers, or an empty buffer ofxbytes;bytes(ptr, n)copiesnbytes from a native pointer.