Common Programming Concepts
This chapter covers the pieces almost every language has: variables, basic types, functions, control flow and strings. You've seen most of them in the guessing game; here we look at how they work in Tessera, and why.
Variables and mutability
Variables are immutable by default. Once a value is bound to a name, the name keeps that value:
fn main(root: Root) {
let x = 5
root.stdout.print("x is {x}")
x = 6
root.stdout.print("x is {x}")
}$ tessera run main.tsr
error[E0205]: cannot modify `x`, it is not declared `mut`
--> main.tsr:4:5
|
4 | x = 6
| ^
= help: declare it as `let mut x`
1 error
The error says what went wrong, where, and how to fix it. Every diagnostic has a code, here E0205, and tessera explain E0205 describes it at length. Many errors also come with a fix that your editor can apply for you.
Why immutable by default? Because most variables never change, and when you read code it helps a lot to know which ones can. A let without mut is a promise that the name means the same thing for the rest of the block. When you do need to change a variable, say so:
fn main(root: Root) {
let mut x = 5
root.stdout.print("x is {x}")
x = 6
root.stdout.print("x is {x}")
// a new variable with the same name: the old one is hidden from here on
let spaces = " "
let spaces = spaces.len()
root.stdout.print("{spaces} spaces")
}mut allows assignment; a second let makes a new variable examples/ch03-02-mut/main.tsr$ tessera run main.tsr
x is 5
x is 6
3 spaces
The second half of the listing is different. let spaces = spaces.len() does not change the first spaces; it declares a new variable with the same name, which hides the old one from then on. That's handy when a value goes through steps (text, then its length) and the old form is no longer needed. Unlike assignment, the new variable can have a different type.
Types
Every value has a type, known when the program is compiled. Inside a function the compiler works types out on its own, so you write them only on function parameters and results, and occasionally on a let where there isn't enough information otherwise (like an empty list: let xs: List[Int] = []).
The basic types are:
| Type | Values |
|---|---|
Int | 64-bit signed integers: 42, -7, 1_000_000 |
Float | 64-bit floating point: 2.5, 1e9 |
Bool | true and false |
Str | text, as UTF-8: "hello" |
() | the unit value, also written (), which is what a function returns when it returns nothing useful |
Lists, maps, tuples and the rest come in later chapters.
fn main(root: Root) {
let out = root.stdout
let count: Int = 7
let price = 2.5
let open = true
let name = "Tessera"
let nothing = ()
out.print("{count} {price} {open} {name} {nothing}")
out.print("7 / 2 = {7 / 2}, 7 % 2 = {7 % 2}")
out.print("7.0 / 2.0 = {7.0 / 2.0}")
out.print("total: {count.to_float() * price}")
out.print("rounded down: {(count.to_float() * price).to_int()}")
let biggest = 9_223_372_036_854_775_807
out.print("biggest: {biggest}, plus one: {biggest + 1}")
out.print("one third: {1.0 / 3.0}, to two places: {(1.0 / 3.0).to_fixed(2)}")
}$ tessera run main.tsr
7 2.5 true Tessera ()
7 / 2 = 3, 7 % 2 = 1
7.0 / 2.0 = 3.5
total: 17.5
rounded down: 17
biggest: 9223372036854775807, plus one: -9223372036854775808
one third: 0.333333333333333, to two places: 0.33
A few things to notice:
- Integer division truncates, so
7 / 2is 3, and%is the remainder. - Integers wrap around when they overflow, instead of crashing. The largest
Intplus one is the smallest. This is the same in the interpreter and in native code, so a program never behaves differently depending on how you run it. - Floats print with as many digits as they need, up to 15.
to_fixed(2)gives exactly two decimals. IntandFloatnever mix on their own. To multiply a count by a price, you convert one of them withto_float()orto_int().
That last rule surprises people, so here is what happens if you forget:
fn main(root: Root) {
let count = 7
let price = 2.5
root.stdout.print("total: {count * price}")
}$ tessera check main.tsr
error[E0201]: expected `Int`, found `Float`
--> main.tsr:4:40
|
4 | root.stdout.print("total: {count * price}")
| ^^^^^
1 error
There are no implicit conversions anywhere in Tessera. It costs a few characters now and then, and in exchange you never wonder whether count * price rounded something on the way.
Working with bits
An Int is 64 bits, and sometimes those bits are the point: a set of flags packed into one number, a checksum, a value in a binary file format. The bitwise operators work on Int and give an Int:
| Operator | Meaning |
|---|---|
a & b | and: the bits set in both |
a | b | or: the bits set in either |
a ^ b | exclusive or: the bits set in one but not the other |
~a | every bit flipped |
a << n | shift left by n bits |
a >> n | shift right by n bits, keeping the sign |
use format
enum Perm {
Read,
Write,
Run,
}
// Each permission is one bit of an Int.
fn bit(p: Perm) -> Int {
match p {
Read => 1 << 0,
Write => 1 << 1,
Run => 1 << 2,
}
}
fn has(set: Int, p: Perm) -> Bool {
set & bit(p) != 0
}
// Adler-32: two running sums, packed into one number.
fn adler32(text: Str) -> Int {
let mut a = 1
let mut b = 0
for byte in text.bytes() {
a = (a + byte) % 65521
b = (b + a) % 65521
}
b << 16 | a
}
fn main(root: Root) {
let out = root.stdout
let mut perms = bit(Read) | bit(Write)
out.print("read: {has(perms, Read)}, run: {has(perms, Run)}")
perms = perms & ~bit(Write)
perms = perms ^ bit(Run)
out.print("now {format.base(perms, 2)}: write {has(perms, Write)}, run {has(perms, Run)}")
out.print("adler32(Wikipedia) = {format.hex(adler32("Wikipedia"))}")
out.print("1 << 62 = {1 << 62}, 1 << 64 = {1 << 64}")
out.print("-16 >> 2 = {-16 >> 2}, -16 >> 99 = {-16 >> 99}")
out.print("-16 shifted right unsigned by 60 = {(-16).shift_right_unsigned(60)}")
}$ tessera run main.tsr
read: true, run: false
now 101: write false, run true
adler32(Wikipedia) = 11e60398
1 << 62 = 4611686018427387904, 1 << 64 = 0
-16 >> 2 = -4, -16 >> 99 = -1
-16 shifted right unsigned by 60 = 15
Perm gives each permission one bit, 1 << 0, 1 << 1 and 1 << 2, so a set of them fits in one Int. | adds a permission to the set, & ~bit(Write) takes one out (and with everything except that bit), and ^ toggles one. format.base(perms, 2) from the format module prints the number in binary, so you can see the bits.
adler32 is the Adler-32 checksum. It keeps two running sums below 65,521, then packs them into one number: b << 16 moves b into the upper half, and | a puts a in the lower half.
Notice set & bit(p) != 0 in has, without parentheses. & binds as tightly as *, and | and ^ as tightly as +, so the comparison applies to the result of &, which is almost always what you mean when testing a bit. In the same way, b << 16 | a shifts first and then combines. When an expression mixes these with arithmetic and you have to stop and think, parentheses cost nothing.
Shifts have no surprises at the edges. >> keeps the sign, so a negative number stays negative: -16 >> 2 is -4. To shift in zeros instead, as when treating the bits as an unsigned number, use the method shift_right_unsigned(n). A shift by 64 or more moves every bit out, giving 0 (or -1 for >> of a negative number), and a negative shift count panics. These rules are the same in the interpreter and in native code.
Functions
Functions are declared with fn, a name, typed parameters and an optional result type after ->. A function without -> returns ().
fn main(root: Root) {
root.stdout.print("{square(12)}")
root.stdout.print(greeting("Ada", 3))
root.stdout.print(box_size(width: 3, height: 4, depth: 5))
}
fn square(x: Int) -> Int {
x * x
}
fn greeting(name: Str, times: Int) -> Str {
if times <= 0 {
return "..."
}
let mut parts: List[Str] = []
for _ in 0..<times {
parts.push("hello")
}
"{parts.join(", ")}, {name}"
}
fn box_size(width: Int, height: Int, depth: Int) -> Str {
"{width} x {height} x {depth} = {width * height * depth}"
}$ tessera run main.tsr
144
hello, hello, hello, Ada
3 x 4 x 5 = 60
The body of a function is a block, and a block's value is its last expression, written without anything after it. square is just x * x. To leave early, use return, as greeting does when there is nothing to greet.
Functions can be declared in any order; main can call square even though square comes later in the file.
When a call has several arguments of the same type, it's easy to mix them up. You can name them at the call site, as in box_size(width: 3, height: 4, depth: 5). Named arguments come after positional ones, can be in any order, and must use the parameter names the function declares. There are no default values, so every argument is still required; the names only make the call easier to read.
for _ in 0..<times counts from 0 up to times, not including it. _ is a name for something you don't need.
Control flow
fn main(root: Root) {
let out = root.stdout
let n = 7
let kind = if n % 2 == 0 { "even" } else { "odd" }
out.print("{n} is {kind}")
let mut countdown = 3
while countdown > 0 {
out.print("{countdown}...")
countdown = countdown - 1
}
out.print("liftoff")
let mut line: List[Str] = []
for i in 1..<16 {
line.push(fizzbuzz(i))
}
out.print(line.join(" "))
// the first number above 100 that both 7 and 9 divide
let mut found = 0
for i in 101..<1000 {
if i % 7 != 0 {
continue
}
if i % 9 == 0 {
found = i
break
}
}
out.print("found {found}")
}
fn fizzbuzz(i: Int) -> Str {
if i % 15 == 0 {
"FizzBuzz"
} else if i % 3 == 0 {
"Fizz"
} else if i % 5 == 0 {
"Buzz"
} else {
"{i}"
}
}$ tessera run main.tsr
7 is odd
3...
2...
1...
liftoff
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz
found 126
if is an expression: let kind = if n % 2 == 0 { "even" } else { "odd" } picks a value. Both branches must have the same type, and an if used for its value needs an else.
while repeats while its condition holds. for goes through the items of a list, or the numbers of a range: lo..<hi is every integer from lo up to but not including hi. There is only this one range form, spelled so that it can't be mistaken for an inclusive one; for an inclusive end, write 0..<n + 1.
break leaves the innermost loop and continue skips to its next round. The last loop in the listing uses both: it skips numbers 7 doesn't divide, and stops at the first one 9 divides too.
There is also match, which you saw in chapter 2. It gets its own chapter, chapter 6, once we have enums to match on.
Strings
A Str is text in UTF-8. Strings are immutable: every method that "changes" a string returns a new one.
fn main(root: Root) {
let out = root.stdout
let city = "Zaragoza"
let year = 2026
out.print("{city} in {year}, {city.len()} letters")
out.print("upper: {city.to_upper()}, first three: {city.slice(0, 3)}")
out.print("joined: " + city + "!")
out.print("braces need a backslash: \{ and \}")
let line = " apples, pears , plums "
let fruits = line.split(",").map(|f| f.trim())
out.print("{fruits}")
out.print("{fruits.len()} fruits, contains pears: {fruits.contains("pears")}")
let word = "año"
out.print("{word.len()} bytes, {word.chars().len()} characters: {word.chars().join(" | ")}")
match "42".parse_int() {
Some(n) => out.print("parsed {n + 1}"),
None => out.print("not a number"),
}
}$ tessera run main.tsr
Zaragoza in 2026, 8 letters
upper: ZARAGOZA, first three: Zar
joined: Zaragoza!
braces need a backslash: { and }
["apples", "pears", "plums"]
3 fruits, contains pears: true
4 bytes, 3 characters: a | ñ | o
parsed 43
Interpolation runs any expression inside {...}, method calls and nested strings included, and formats the result. Values of every type can be interpolated, which is also how you print them; you'll see structs and lists printed this way throughout the book. Because braces mean interpolation, a literal brace is written \{ or \}.
+ joins two strings. split cuts a string into a list of pieces and trim removes surrounding spaces. map(|f| f.trim()) runs a small function on each piece; that |f| ... is a closure, covered in chapter 9.
There is no separate character type. len() counts bytes, which is what you usually want for storage and protocols; chars() splits a string into its characters, each a one-character Str, so "año" is 4 bytes but 3 characters.
parse_int() and parse_float() read numbers out of text and return an Option, since not all text is a number.
Comments
Comments start with // and run to the end of the line. There are no block comments. A comment directly above a function, struct or field documents it, and tessera doc collects those comments into documentation (appendix B).
Summary
You've seen immutable and mut variables, the basic types and the rule that they never convert on their own, functions with named arguments, if as an expression, loops and ranges, and strings with interpolation. Next comes the chapter that is most particular to Tessera: how values behave when you pass them around.