Skip to content

Repository files navigation

τ

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

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 install

make 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/local

make 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.tau

The tools

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

A tour of the language

Values

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.

Operators

=  +=  -=  *=  /=  %=  &=  |=  ^=  <<=  >>=
||  &&  !
==  !=  <  >  <=  >=
+  -  *  /  %
&  |  ^  ~  <<  >>
++  --

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,
]

Control flow

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 usual

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

Functions

A 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

What a function sees around it

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 moves

A 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 2

The elegant bits

Objects without self

new() 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.Pop still works, because q was 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.

Assignment is an expression, and so is if

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.

Errors

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

Modules

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.

Tests

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

Concurrency

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.

C libraries

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

The raw way

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.

With the types written down

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).

The other direction

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_tuint64_t, int8uint64 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.

Shipping a program

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, world

The 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 - myapp

The standard library

All 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]

Builtin functions

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) -- convert x to a String.
  • error(s) -- build an error carrying the message s.
  • type(x) -- the name of the type of x, as a String.
  • int(x[, bits]) -- convert x to an Integer; on a native value bits says how many of its bits are meaningful (8, 16, 32 or 64).
  • float(x) -- convert x to 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 if x is an error.
  • dlopen(path) -- open a C shared object, or the program itself with null.
  • cfunc(sym, ret, args) -- a C function with its types given as codes. What ffi.Func is made of, see C libraries.
  • cexport(fn, ret, args) -- a tau function C can call, the same way round. What ffi.Export is made of.
  • pipe([n]) -- a new pipe, unbuffered or holding n values.
  • send(p, x) -- send x to the pipe p.
  • recv(p) -- take the next value out of the pipe p.
  • close(p) -- close the pipe p.
  • hex(x), oct(x), bin(x) -- the base 16, 8 and 2 spellings of x.
  • 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 key k from the Map m.
  • bytes(x) -- Bytes from a String, a List of integers, or an empty buffer of x bytes; bytes(ptr, n) copies n bytes from a native pointer.

About

A functional interpreted programming language with a minimalistic design.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages