Tutorial

This is the whole language, in order, from nothing. Each step is a file you can save and run with tau run. If you know any C-like language you will be at home by the third section; if you know Go you are already home.

On this page

Install

You need Go and GCC. The submodules matter: the VM links against them.

$ git clone --recurse-submodules https://github.com/NicoNex/tau
$ cd tau
$ make install

That installs into ~/.local: the binary in ~/.local/bin, the standard library and the runtime bundled programs are built on in ~/.local/lib/tau. Nothing needs root. For a system wide install, PREFIX=/usr/local sudo make install.

Check it:

$ tau version
$ tau help

Hello world

Save this as hello.tau:

println("Hello, world")
$ tau run hello.tau
Hello, world

tau hello.tau is short for tau run hello.tau, and a shebang line works too, so a Tau file can be an executable script:

#!/usr/bin/env tau

println("Hello, world")

There is no main, no imports to declare, no boilerplate: a file is a program and it runs top to bottom.

Values and operators

Tau is dynamically typed. Variables come into being by assignment, and type(x) tells you what you have: int, float, string, bool, list, map, object, bytes, closure, error, null.

# Two integers divide into an integer; a float on either side makes it a
# float division.
n = 42
half = n / 2
println(n, half, type(half), n / 5, n / 5.0)

# Octal and hexadecimal literals.
perm = 0644
mask = 0x1f
println(perm, mask, oct(perm), hex(mask))

# Booleans, null and conversions.
ok = true
println(!ok, int("7") + 1, float(3), string(255))
$ tau run values.tau
42 21 float
420 31 0o644 0x1f
false 8 3 255

Two things worth pinning down. Two integers divide into an integer, the way they do in C and in Go: 7 / 2 is 3, and the remainder is dropped. A float on either side makes it a float division — 7 / 2.0 is 3.5 — 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. And integer literals can be written in octal (0644), hexadecimal (0x1f) or binary (0b1010), which is why file permissions read the way you expect.

# Arithmetic. Two integers divide into an integer, the remainder is dropped.
println(7 + 2, 7 - 2, 7 * 2, 7 / 2, 7 / 2.0, 7 % 2)

# Comparison and logic.
println(1 < 2, 2 == 2.0, "a" != "b", true && false, true || false, !true)

# Bitwise, on integers.
println(0b1010 & 0b0110, 6 | 1, 6 ^ 3, 1 << 4, 32 >> 2)

# Increment, decrement, and the compound assignments.
n = 10
++n
n += 5
n *= 2
println(n)
println(type(7 / 2), type(7 / 2.0), float(7) / 2)

The precedence is C’s, trap included: & is looser than ==, so a & b == c means a & (b == c) and wants parentheses.

Comments start with # and run to the end of the line. A newline ends a statement; a ; lets you put several on one line. A list, a map, a call and a parameter list may end with a comma, so a line can be added to one written over several lines without touching the line above it.

Strings

Strings are byte indexed: s[0] is a one byte string, len(s) is a byte count, and slice(s, start, end) cuts a piece out. Interpolation is written with braces, and any expression can go inside them. To write a literal brace, double it.

name = "Tau"
temp = 25

# Any expression can go inside the braces.
println("hello {name}, it is {if temp > 20 { \"hot\" } else { \"cold\" }}")

# A doubled brace is a literal one.
println("a block looks like {{ ... }}")

# Strings are byte indexed, and slice cuts them.
s = "gopher"
println(s[0], len(s), slice(s, 0, 2))

# Backticks make a raw string: no escapes, no interpolation.
println(`raw\n {not interpolated}`)
$ tau run strings.tau
hello Tau, it is hot
a block looks like { ... }
g 6 go
raw\n {not interpolated}

Backticks make a raw string: no escapes, no interpolation, newlines allowed. They are the right tool for regular expressions and JSON literals.

Lists, maps and objects

Three collections, no ceremony. Lists are ordered and grow with append, maps take any hashable key, objects are bags of named fields you build at runtime.

xs = [1, 2, 3]
xs = append(xs, 4)
println(xs, xs[1], len(xs))

m = {"host": "localhost", "port": 8080}
m["tls"] = true
println(m["host"], keys(m))
delete(m, "tls")
println(len(keys(m)))

# Objects are made with new() and filled field by field.
Dog = fn(name, age) {
	d = new()
	d.Name = name
	d.Age = age
	d.HumanAge = fn() { d.Age * 7 }
	return d
}

snuffles = Dog("Snuffles", 8)
println(snuffles.Name, snuffles.HumanAge())
$ tau run collections.tau
[1, 2, 3, 4] 2 4
localhost [host, tls, port]
2
Snuffles 56

new() returns an empty object, and you give it fields with the dot. A constructor is just a function that fills one in and returns it — that is the whole object system. There are no classes and no inheritance; keys(o) lists the fields an object has, including the ones holding functions.

No self, no this

A method is a function stored in a field, and it reaches the object the same way any closure reaches what it captured: the constructor’s local variable is still there, so the method uses it by name.

# No self, no this: a method is a closure that captured the object.
Queue = fn() {
	q = new()
	q.items = []

	q.Push = fn(x) { q.items = append(q.items, x) }
	q.Pop = fn() {
		if len(q.items) == 0 {
			return null
		}

		first = q.items[0]
		q.items = slice(q.items, 1, len(q.items))
		return first
	}
	q.Len = fn() { len(q.items) }

	return q
}

q = Queue()
q.Push("a")
q.Push("b")

# A method is an ordinary value: take it out of the object and it still works,
# because what it closed over is the object, not the call it was reached by.
pop = q.Pop
println(pop(), q.Len())

# And it can be replaced, without the language needing a word for it.
loud = q.Push
q.Push = fn(x) { loud(string(x) + "!") }
q.Push("c")
println(q.Pop(), q.Pop())
$ tau run methods.tau
a 1
b c!

Two things follow, and both are useful. A method is an ordinary value, so pop = q.Pop keeps working once it is out of the object — there is no receiver to lose. And a method can be replaced from the outside, with no keyword for it, because assigning to a field is all it takes.

The cost of a self keyword is not the five characters. It is a second mechanism, with its own rules about when it is bound and to what, sitting next to the closures the language already has. Tau has the closures.

Functions and closures

fn makes a function, and functions are values like any other: store them, pass them, return them. The last expression of a body is the result, so return is only needed to leave early.

# The last expression of a function is its result.
add = fn(a, b) { a + b }

# Recursion, and an early return.
fib = fn(n) {
	if n < 2 {
		return n
	}
	fib(n - 1) + fib(n - 2)
}

println(add(9, 1), fib(20))

# Functions are values: pass them around, return them.
adder = fn(n) { fn(x) { x + n } }
add10 = adder(10)
println(add10(5))

# Closures capture free variables by value: n was 1 when twice was made.
n = 1
twice = fn(x) { x * n * 2 }
n = 100
println(twice(3))

Closures capture the free variables they use by value, at the moment the closure is built. This is the one rule that surprises people coming from JavaScript, and it is the reason a loop can hand every closure its own copy:

# A closure keeps a copy of the free variables it uses, taken when the
# closure is built: later changes to the original do not reach inside.
make = fn() {
	n = 1
	f = fn() { n }
	n = 100
	return f
}
println(make()())

# Which is why every closure made in a loop gets its own value.
multipliers = fn() {
	fns = []
	for i = 0; i < 3; ++i {
		fns = append(fns, fn(x) { x * i })
	}
	return fns
}

fns = multipliers()
println(fns[0](10), fns[1](10), fns[2](10))
$ tau run closures.tau
1
0 10 20

Control flow

if is an expression: it produces the value of the branch that ran, so you can assign it. for comes in three shapes and that is all the looping there is — there is no range-for.

a = 3
b = 7

# if is an expression, it gives back a value.
min = if a < b { a } else { b }
println(min)

# The three shapes of for.
for i = 0; i < 3; ++i {
	println("i is {i}")
}

xs = [0, 1, 2, 3]
for len(xs) > 2 {
	xs = slice(xs, 1, len(xs))
}
println(xs)

i = 0
for {
	++i
	if i == 5 {
		break
	}
}
println(i)
$ tau run control.tau
3
i is 0
i is 1
i is 2
[2, 3]
5

break and continue work as expected. The for x = recv(p) { ... } form you will meet in the concurrency section is the second shape: the condition is an assignment, and the loop stops when it yields null.

Errors

An error is a value. error("...") builds one, returning it is how a function reports failure, and failed(x) says whether a value is one. There is nothing to catch, and nothing unwinds behind your back.

The idiom is to put the assignment inside the check, so the value is bound either way:

div = fn(n, d) {
	if d == 0 {
		return error("division by zero")
	}
	n / d
}

# The assignment goes inside failed(), so the value is there either way.
if failed(res = div(16, 2)) {
	println("boom: {res}")
} else {
	println("16 / 2 is {res}")
}

if failed(res = div(1, 0)) {
	println("boom: {res}")
}
$ tau run errors.tau
16 / 2 is 8
boom: division by zero

You will see this shape all through the standard library:

if failed(f = os.Open(path, null, null)) {
	return f
}

Read it as “do the call, bind the result, and if it went wrong hand the error back up”. exit(err) ends the program with that message; the errors module adds errors.Wrap(err, "context") when you want to say where it happened.

Modules

A module is a file. import("name") evaluates it once and gives you back an object holding its exported names. Names starting with a capital letter are exported; lowercase ones stay private, exactly like Go, and the rule applies to object fields too.

# Only the names starting with a capital letter leave the module.
prefix = "hello"

Greet = fn(name) { "{prefix}, {name}" }

Version = "1.0"
greet = import("greet")
strings = import("strings")

println(greet.Greet("world"))
println("greeter v{greet.Version}")
println(strings.ToUpper(greet.Greet("tau")))
$ tau run main.tau
hello, world
greeter v1.0
HELLO, TAU

Names are resolved relative to the importing file first, then against the directories in TAUPATH, then against the installed standard library. Which also means a file of yours called strings.tau will shadow the stdlib module of that name for its neighbours — name your files with that in mind.

A module is a file, or a directory holding several: the files of a directory share one scope and their capitalised names are what it exports, so a growing module splits across files without any of them having to export to the others. A path with slashes reaches into a directory — import("crypto/sha256") — and a path whose first element is a host, import("github.com/you/thing"), is a module fetched from elsewhere. That last part, with tau.mod, tau get and versioning, is its own story in Tooling.

Concurrency

Put tau in front of a call and it runs as a routine, concurrently with the rest of the program. Routines are cheap; spawn them freely.

Routines talk over pipes. pipe() makes one, send(p, x) puts a value in, recv(p) takes one out and sleeps until there is one, close(p) shuts it down. A recv on a closed pipe returns null, which is what ends a for loop over a pipe. pipe(n) makes a buffered pipe of capacity n.

# A pipe is a FIFO queue shared by routines. recv sleeps until something
# arrives, and returns null once the pipe is closed, which ends the loop.
listen = fn(p, done) {
	for val = recv(p) {
		println("got {val}")
	}
	println("pipe closed, bye")
	send(done, true)
}

p = pipe()
done = pipe()
tau listen(p, done)

send(p, "hello")
send(p, "world")
send(p, 123)
close(p)

# Wait for the routine to finish before the program ends.
recv(done)
close(done)
$ tau run pipes.tau
got hello
got world
got 123
pipe closed, bye

Notice the done pipe: when the program’s main routine reaches the end of the file, the process stops. Waiting on a pipe is how you keep it alive until the work is finished — pipes are the synchronisation primitive as well as the channel.

A worker pool is then four lines. Three routines share one job pipe, and the values come back in whatever order they finish:

time = import("time")

# A routine is an ordinary call with 'tau' in front of it.
worker = fn(id, jobs, results) {
	for job = recv(jobs) {
		time.Sleep(10)
		send(results, "worker {id} did job {job}")
	}
}

jobs = pipe()
results = pipe()

for w = 1; w <= 3; ++w {
	tau worker(w, jobs, results)
}

for j = 1; j <= 6; ++j {
	send(jobs, j)
}
close(jobs)

# recv blocks until something shows up.
for i = 0; i < 6; ++i {
	println(recv(results))
}
close(results)
$ tau run workers.tau
worker 1 did job 1
worker 3 did job 3
worker 2 did job 2
worker 1 did job 4
worker 3 did job 5
worker 2 did job 6

Files

The os module opens, reads and writes; bufio wraps a file for line by line reading and buffered writing. os.Open(path, flags, perm) takes null for the flags and permissions you do not care about, and returns an error value you check with failed.

os = import("os")
bufio = import("bufio")

path = "/tmp/tau-notes.txt"

if failed(err = os.WriteFile(path, "alpha\nbeta\ngamma\n")) {
	exit(err)
}

if failed(text = os.ReadFileString(path)) {
	exit(text)
}
println("{len(text)} bytes")

# Line by line, through a buffered scanner.
if failed(f = os.Open(path, null, null)) {
	exit(f)
}
s = bufio.NewScanner(f)
for s.Scan() {
	println("line: {s.Text()}")
}
f.Close()
os.Remove(path)
$ tau run files.tau
17 bytes
line: alpha
line: beta
line: gamma

os.Create(path) is os.Open with the usual creating flags and mode 0644. Files are objects: f.Read(n), f.ReadAll(), f.ReadString(), f.Write(data), f.Seek(offset, whence), f.Close(). Anything with a Read is a reader and anything with a Write is a writer, which is all io and bufio ask for.

Tests

Test files end in _test.tau and tau test runs them, each in its own process. A test file hands testing.Main a list of [name, function] pairs; the t it passes in carries the assertions.

Double = fn(x) { x * 2 }

Div = fn(n, d) {
	if d == 0 {
		return error("division by zero")
	}
	# float(n) and not n, or two integers would divide into an integer.
	float(n) / d
}
testing = import("testing")
mathx = import("mathx")

testing.Main([
		["Double", fn(t) {
				t.AssertEq(mathx.Double(21), 42)
			}],
		["Div", fn(t) {
				t.AssertEq(mathx.Div(9, 2), 4.5)
				t.AssertError(mathx.Div(1, 0))
			}]
	])
$ tau test .
=== mathx_test.tau
--- PASS: Double (0ms)
--- PASS: Div (0ms)
ok      2 passed of 2 (0ms)

ok      1 test files passed

t.Assert(cond, msg), t.AssertEq(got, want), t.AssertNe(got, want), t.AssertError(v), t.Error(msg), t.Fatal(msg) and t.Skip(msg) are the whole vocabulary. A failing case reports the file and line and the process exits non-zero, so it drops straight into CI.

Putting it together

Read a file, split it into words, count them, and print the three most frequent. Everything in it has appeared above.

os = import("os")
strings = import("strings")
list = import("list")

path = "/tmp/tau-wordcount.txt"
if failed(err = os.WriteFile(path, "the quick brown fox jumps over the lazy dog the end\n")) {
	exit(err)
}

if failed(text = os.ReadFileString(path)) {
	exit(text)
}

counts = {}
list.Each(strings.Fields(text), fn(w) {
		if counts[w] == null {
			counts[w] = 0
		}
		counts[w] = counts[w] + 1
	})

words = list.Sort(keys(counts), fn(a, b) {
		if counts[a] == counts[b] {
			return a < b
		}
		counts[a] > counts[b]
	})

for i = 0; i < 3; ++i {
	println("{counts[words[i]]}  {words[i]}")
}

os.Remove(path)
$ tau run wordcount.tau
3  the
1  brown
1  dog

From here the standard library is the next stop, and tooling covers building, formatting and the REPL.