sysl

Declarations

Bindings, functions, structs, enums and type declarations — what each form states, and what it leaves to be inferred.

A declaration is a statement, so anything that can be declared at the top of a file can also be declared inside a function body. A helper function used by one function belongs inside it, and needs no separate rule to allow it.

Bindings

Three forms, and the differences between them are about when the value is fixed rather than about where it lives.

formmutablevaluetype
var name = vyesoptional — a var may be declared and assigned lateroptional
val name = vno, written oncerequiredoptional
const Name: T = vno, and known while compilingrequiredrequired

The type is mandatory on a const and optional on a val, and that arrangement is deliberate in both directions. A const states an interface — its value is substituted where it is used, so what that value is matters less than what it is a value of. A val has its type readable off the value it was given, and a val with no value is not a declaration of anything.

The value may be an indented block

A binding’s = takes one expression, or an indented block whose trailing expression is the value — the same arrangement a function body has, and the same one an if branch has. What the block binds is the block’s own, and goes out of scope with it:

val limits =
    val raw = 7
    val capped = raw * 2

    capped

print(limits)
14

Reach for it when the value takes more than one step to work out. The alternative is a name in the enclosing scope that exists only to be read once on the next line, which says the value is available to everything below when it is not.

It composes with the branching forms rather than competing with them — an if too long for its line goes on the line under the =, which is the shape the form was built for:

pick(c: bool) -> int
    val n =
        if c then
            val a = 3

            a + 1
        else
            0

    n * 10

print(pick(true), pick(false))
40 0

A block of one expression is that expression. Moving a value to the next line to fit the margin changes nothing else about the declaration: a module val written that way is still laid into the object file, rather than becoming something the program computes before it starts.

A block that ends in something other than an expression yields unit, exactly as an if with no else does, and the complaint arrives where the value is used rather than where it was bound.

A const does not take one. A constant is folded into every use of it rather than run, so there is nowhere for the statements to happen. Its value may still sit on the next line — that is one expression rather than a block, by the rule above — and what is refused is a block that binds something:

const Limit: int =
    val base = 3

    base * 2

print(Limit)
'Limit' is a 'const', so its value is folded into every use of it

A module member states its type

Where a binding is a member of a module rather than a local, the annotation stops being optional even on a val. That is a rule about where the binding was written, so the grammar accepts either form and the analyzer applies the rule.

It is worth reading as one rule rather than two, because it is one: anything visible outside its file states its types, and a module member always could be, while a local states nothing to anyone and so infers exactly as a var does. Most of it the syntax already enforced — a parameter type and a field type are mandatory, and an absent return type means unit rather than being inferred — so a module-level binding is simply the last declaration the rule had left to reach.

The payoff is that interface extraction is parse-only. A file’s exported surface can be read off its syntax tree without resolving a name, checking a body, or having compiled anything the file imports, which is what lets the collect pass depend on nothing but parsing — and that is what a fast, parallel, eventually incremental build rests on. Scala infers types for public members and pays for it with a far heavier extraction step; this is a deliberate divergence, and a cheap one here because sysl’s signatures were already explicit for other reasons.

const Limit: int = 3
static val Scale: int = 2

show()
    var count = 0
    val doubled = Limit * Scale
    val name = "sysl"

    count += 1

    print(count, doubled, name, Limit)

show()
1 6 sysl 3

The two vals in that program are one keyword doing one thing: the module member says : int because it is visible outside its file, and the one inside show is visible to nobody and does not have to. The static is what asks for the member — this is the file the program starts in, so a plain val there would be a local of its body, and a local infers exactly as a var does.

A module-level val may hold a counted value, and never releases it. Storage that exists for the whole run is never let go of, so the one release with nowhere to go is the one at exit — and not taking it is what a static is. A &T, a weak T, a slice and a string the program builds are all admissible:

static val name: string = str(2026)

print(name)
2026

That was refused until the reason was read again, and what it cost was every shape that needs a value to outlive every frame. What the initializer is still decides where the storage gets filled — a literal‘s bytes are a constant in the object file and its owner word is null, so nothing is allocated and the storage is complete before the program starts, while a built value is stored by a prologue. That difference is why a module with no alloc may still hold a table of literals:

val greeting: string = "hello"
val names: [3]string = ["alpha", "beta", "gamma"]

var i = 1

print(greeting, names[i])
hello beta

This is what a module with no allocator uses for its messages. A const could not serve it: a constant is folded into its uses and has no address, so it cannot be indexed at a position computed while running. Anything the program had to build is a local instead, which is ordinary — that is what the first program above does with its val name.

A raw pointer may be held, because it counts nothing and so there is no release to write. What a val promises is that its own storage is written once and never again, and holding an address keeps that promise exactly as holding a number does. This is the shape that has no substitute — a device register block named at file scope, reached by every function in the driver rather than re-materialised in each:

const UART: usize = 0x1000_0000
val regs: *Uart = ptr_cast(UART)

An address that is a constant is laid into the object file rather than stored by a prologue, so a val at pointer type needs nothing ordered and is readable before the first initializer runs — which is what a freestanding program starting at a reset vector requires.

Writing to a val twice is refused:

show()
    val name = "sysl"

    name = "other"

    print(name)

show()
a 'val' is written once, so assignment has nothing to write through

Read-only means read-only at every depth. k = …, k[0] = …, k[0] += 1 and k[0]++ are all refused on a module-level val, and so is &k[0] — a *T is a licence to write, and handing one out would move the mistake one step away from where it could still be reported.

Slicing is allowed, and yields a []const T. That is what lets a table be read and passed rather than only read: the read-only property travels with the view, through a name, through a call, and through a second subscript, so every write above is refused through it too. &v[0] on such a view is a *T, the tier the memory model excludes on purpose, and is how the view reaches C.

The one module-level storage none of this reaches is an extern variable. Every rule above is a promise this program makes about storage it laid down; an extern variable is storage the linker supplies — stdout, environ, optind — so there is no such promise to keep. It is a place, it may be written, and it holds whatever the other side put there.

Several at once

var and val both take a comma list, binding several names to several values. Each part’s type is inferred from its own value.

show()
    val a, b = 1, 2
    var lo, hi = 0, 10

    print(a, b, lo, hi)

show()
1 2 0 10

Two or more names and an initializer are both required: one name is the ordinary form, and a multiple binding with nothing to take apart names nothing. The right side is produced before any name is bound, so a value there still means whatever the enclosing scope calls it — the binding does not shadow itself half way through its own right-hand side.

It is a local form. The parts carry no type annotation and there is nowhere to write one, so a multiple val at the top of a file collides with the rule above and is refused rather than becoming a quiet local of the entry point. A module member that wants the form declares its names separately.

The same spelling also takes a result list and a tuple apart, which is why a function with several results needs no special form at the call.

By pattern, when the shape matters

A comma list says how many things to bind. A pattern says the shape, and so reaches inside a tuple that holds another one — which is the whole of the difference between the two forms.

show()
    val (a, b) = (1, 2)
    val ((x, y), z) = ((3, 4), 5)
    val (first, _) = (6, 7)

    var (lo, hi) = (0, 10)

    hi = hi + 1

    print(a, b, x, y, z, first, lo, hi)

show()
1 2 3 4 5 6 0 11

A _ binds nothing and skips its part. A var pattern makes every name it binds assignable, and a val pattern makes each of them write-once, exactly as the single-name forms do.

A struct pattern stands here on the same terms, naming fields rather than positions. It may name them in any order, may leave fields out, and may rename one to a sub-pattern:

struct Point
    x: int
    y: int

struct Line
    a: Point
    b: Point

show()
    val Point{x, y} = Point(3, 4)
    val Line{a: Point{x: ax}, b} = Line(Point(1, 2), Point(5, 6))

    print(x, y, ax, b.x, b.y)

show()
3 4 1 5 6

A field the pattern does not name simply binds nothing — unlike a match arm, a binding has no exhaustiveness to discharge.

The positional spelling Point(a, b) stands here too, and takes the fields in declaration order. It differs in the one way it differs in a match: it names every field, so a struct that grows one turns each positional binding into a checked to-do rather than one that goes on binding the same names. See Patterns.

Only a pattern that cannot fail may stand at a binding — a tuple pattern, a struct pattern, a name, a wildcard, and those nested inside one another. A struct qualifies because it has exactly one shape, which is the same property that makes a tuple pattern irrefutable.

A literal, a range, or a variant is a test, and a binding has no other arm to take when the test does not match, so each is refused with that as the reason. Those belong in a match (see Patterns).

Like the comma form, this is a local form: the parts have nowhere to carry a type, so one at the top of a file is refused rather than becoming a quiet local of the entry point.

Functions

A name, a parameter list, an optional -> result, and a body. There is no keyword: the shape is what identifies it. An absent result type means unit.

The body is either = expr — whose value is the result — or an indented block, whose trailing expression is the result.

double(n: int) -> int = n * 2

sum(a: int, b: int) -> int
    var t = a + b

    t

greet(name: string)
    print("hi", name)

print(double(21), sum(40, 2))
greet("you")
42 42
hi you

= may also open an indented block, so a body does not have to change shape when it outgrows a line.

Tail calls

A function whose last act is a call to itself does not open a second frame. The call becomes a branch back to the function’s own entry, so the recursion is bounded by the arithmetic rather than by the stack:

count(n: int, acc: int) -> int =
    if n == 0 then acc else count(n - 1, acc + 1)

print(count(1000000, 0))
1000000

A million frames is not a stack any machine has. Nothing is written to ask for this — it applies wherever it applies.

In tail position is the last thing the function does: the body’s trailing expression, the operand of a return, and the arms of the if and match those reach through. Nothing may wait on the result — n + count(n - 1) is an ordinary call, because the addition happens after it comes back.

A tail call is a call, so the jump lands where a call would: every require is checked again on the arguments the jump wrote, and every old(e) is snapshotted again. A recursion that violates its own precondition at depth four stops at depth four.

Two things end a tail position instead of being optimized around:

  • a defer in scope, which runs on the way out of a scope — after the callee returns for an ordinary call, and before it is entered for a jump;
  • an ensures on the function, which is checked when a call returns, and a tail call never returns.

Either one leaves the function compiled exactly as written.

It is self-recursion only. Mutual recursion and calls through a Fn or a *extern are ordinary calls, however they are written.

@tailrec

The jump is silent, which is what you want until an edit takes it away. @tailrec asserts it is there and is refused when it is not:

@tailrec
sum(n: int) -> int =
    if n == 0 then 0 else n + sum(n - 1)

print(sum(5))
calls itself nowhere the jump can replace

It changes nothing about what is emitted — write it on the functions where losing the jump silently would be a bug, and leave it off the rest.

Several results

A signature may declare more than one result, and the trailing expression or return supplies them as a comma list.

minmax(a: int, b: int) -> int, int
    if a < b
        a, b
    else
        b, a

show()
    var lo, hi = minmax(9, 4)

    print(lo, hi)

show()
4 9

This is not a tuple. A tuple is a value with a type of its own; a result list is several values handed back at once, and the multiple binding above is what receives them.

A result list is a whole line by construction, so it cannot be the body of an inline branch: if a < b then a, b else b, a does not parse, because the comma there would have to belong to the branch rather than to whatever expression the branch is part of. The block form above is how a conditional supplies several results.

Default parameters and named arguments

A parameter may say what a call that leaves it out gets instead. The default is a full expression, so a call or a conditional may stand there — it is evaluated at the call site that omitted it, and it may not name anything local to the declaration.

An argument may be written name = value, which stands at the parameter it names rather than at the one its position would have given it.

box(w: int, h: int = 1, fill: string = "*") -> string
    var s = ""

    for i in 0..<w * h
        s += fill

    s

print(box(3), box(2, 2), box(2, fill = "#"))
*** **** ##

name = value is also a legal expression — assignment yields the value stored — so the two readings collide, and the named argument is the one taken. It applies only where the name is a bare identifier: p.x = 1 and b[i] = v are stores as they always were, since neither is a name a parameter list could have written. A store to a plain variable is still reachable in an argument by parenthesizing it.

A closure’s parameter declares no default. A call reaches a closure through the Fn traits, which carry the types and not the names, so there would be nothing at the call to fill one from.

Overloading

A name may be declared more than once, and every use of it still means exactly one declaration. Which one is decided by the arguments a use passes: how many, and what type each is.

show(x: int) -> string = s"int $x"
show(x: string) -> string = s"str $x"
show(x: int, y: int) -> string = s"pair $x $y"

print(show(1))
print(show("a"))
print(show(1, 2))
int 1
str a
pair 1 2

The result is never part of it. A pair differing only in what they return is refused where the second is written. sysl reads an expected type inwards, from the context to the expression, so a call whose meaning depended on its own result would need its context typed before it could be resolved and would need resolving before its context could be typed.

h(x: int) -> string = "s"
h(x: int) -> int = 1
function 'h' is already declared — which declaration a call means is decided by its arguments and never by what it returns, so two that differ only in the result have no call that tells them apart

That is the plain duplicate message with its sentence finished, and the wording is deliberate: the two parameter lists are the same, so this is one declaration written twice. Somebody who wrote that by accident is told so plainly; the clause is added only where the results differ, because that is the pair a reader wrote on purpose and expected to work.

The rule behind it is wider than the identical case. Each declaration takes a range of argument counts — from its parameters that have no default up to all of them — and two collide when their ranges overlap at some count and their first that-many parameters agree in type. So a difference hidden behind a default is refused too, and there the point is sharper: the default is unreachable, because no call could ever supply one argument to the longer declaration.

g(x: int) -> string = "one"
g(x: int, y: int = 0) -> string = "two"
'g' is already declared with parameters this one could not be told from — a call passing 1 argument would fit both, and which declaration a call means is decided by its arguments and never by what it returns. Two declarations of one name have to differ in a way a call site can show

Reporting that at the declaration rather than at the call is the point. The mistake is in the pair; reporting it where the name is used would report one mistake once per call site, in files whose authors did not write it.

A use that fits none of them, or several, is refused where the use is, and the message carries the roster — the reader’s question at that point is which declarations exist:

k(x: int) -> string = "a"
k(x: string) -> string = "b"

print(k(1.5))
no 'k' takes these arguments — the declarations of that name are:
    k(x: int)
    k(x: string)

Three tie-breaks decide a use that fits more than one. A candidate that needed no default fitted the call as written and beats one that did; and a candidate whose parameters are exactly the arguments’ own types beats one reached by a conversion — which is what lets a literal’s natural type choose between two widths:

width(x: int) -> string = "int"
width(x: i64) -> string = "i64"

print(width(1))
print(width(1i64))
int
i64

What is deliberately absent is any ranking between conversions. Two candidates each reached by a different one are ambiguous, and saying so beats a ladder of precedences nobody could predict from the source.

Exactness is asked of the types a candidate was fitted at, which for a generic one is what the call solved it to. g[T] below takes the []int at T = []int, as it was written; the other takes it only by giving up the ability to write. So the generic declaration is the exact one:

g(s: []const int) -> string = "const"
g[T](x: T) -> string = "generic"

var a = [1, 2, 3]
val v: []int = a[..]

print(g(v))
generic

The third tie-break is that a candidate that named its parameters beats one that was solved for them, where both are exact. f(x: int) and f[T](x: T) both fit f(0) at int, and the ordinary declaration is the one that said what it takes:

f(x: int) -> string = "plain"
f[T](x: T) -> string = "generic"

print(f(0))
plain

That ranks a declaration against a declaration, which is a different question from ranking the routes the arguments took — the thing the paragraph above refuses.

An address chooses by the type the context wants, which is the mechanism a generic function’s address already uses. With no expected type there is nothing to read, and the address is refused rather than guessed at.

add(a: int) -> int = a + 1
add(a: int, b: int) -> int = a + b

val one: *extern(int) -> int = &add
val two: *extern(int, int) -> int = &add

print(one(41), two(6, 7))
42 13

Overloading an extern

Two externs of one name are two functions exactly when they name two symbols. A C library’s naming is not sysl’s, and a family C spells _solid/_shaded/_blended is one operation with an option — which a binding may now say, without inventing a sysl name per C symbol.

extern "strlen" size(s: *u8) -> usize
extern "strnlen" size(s: *u8, cap: usize) -> usize

Two naming the same symbol are refused. That is one C function claimed at two signatures, and the symbol is what gets emitted — both calls would reach the same code with different arguments, and nothing downstream could tell which had been meant.

extern "strlen" size(s: *u8) -> usize
extern "strlen" size(s: *u8, cap: usize) -> usize
'size' is already declared as an 'extern' for the symbol 'strlen' — two declarations of one name are two functions, and one C function cannot be two. Overloads of an 'extern' are told apart by the symbol each names, so give this one a symbol of its own or take its address and 'ptr_cast' it where the other signature is wanted

An extern and a sysl function do not overload each other, in either order, for the same reason: what tells overloads of an extern apart is the symbol, and a sysl function declares none.

Type parameters

A bracketed list directly after the name declares type parameters, with optional bounds. See generics and traits.

Structs

A named product type. Fields are declared one per line; methods, properties and an invariant may follow among them, in any order.

struct Rect
    w: int
    h: int

    invariant w > 0 && h > 0

    area(self) -> int = self.w * self.h

    perimeter -> int = 2 * (self.w + self.h)

    scale(*self, k: int)
        self.w *= k
        self.h *= k
end Rect

var r = Rect(3, 4)

print(r.area(), r.perimeter)

r.scale(2)

print(r.area(), r.w, r.h)
12 14
48 6 8

Four things are on display there.

A constructor is the struct’s name applied to its fields, in declaration order. There is no separate constructor declaration to write or to keep in step.

A field declares no default. What an unwritten field gets is decided by the constructor that builds the value, not by the field — and the compiler says so rather than leaving a = v after a field to fail as whatever the grammar happened to want there.

A property is a method with the parameter list left offperimeter -> int, called as r.perimeter with no parentheses. It takes the same body forms a method does.

The receiver says how the method reaches its value, and is written as the first thing in the parameter list:

receivermeaning
selfby value — the method gets a copy
*selfby pointer — the method may write through it
&selfby reference, counted
&sync selfby reference, and safe to share across threads
(none)an associated function — no receiver, called on the type
struct Point
    x: int
    y: int

    origin() -> Point = Point(0, 0)

var p = Point.origin()

print(p.x, p.y)
0 0

invariant

A condition every value of the struct must satisfy, re-checked whenever the struct is built or one of its fields is written. Bare field names are in scope inside it. A multiple assignment re-checks it once, after every write has landed, which is what lets two fields that relate to each other be changed together.

invariant is a contextual word — an ordinary identifier everywhere else. See errors and contracts for what happens when one is broken.

A struct with no fields

A struct may declare no fields at all. Its emptiness has to be written — the end marker, optional everywhere else, is what says so — because a struct whose body the author forgot to indent looks exactly like one that has no body, and that is much the likelier mistake.

struct Stdout
end Stdout

impl Fallible for Stdout

impl Writer for Stdout
    write(*self, bytes: []const u8) = putbytes(bytes)
end Stdout

show[T: Display](x: T)
    var out = Stdout()

    x.display(&out, FormatSpec(0, -1, false))
    printc('\n')
end show

show(42)
show("through a sink of one's own")

print(sizeof(Stdout))
42
through a sink of one's own
0

What wants one is a sink: a value standing for a destination fixed at compile time — the console, a serial port — which has nothing to keep and so has no field to keep it in. Being a value rather than a global is what lets it be passed to a function, held in a struct, and chosen by a caller.

Such a type occupies no bytes, so embedding one costs the struct holding it nothing. The cost of that is that two of them have nothing to tell their storage apart, and &a == &b on two such locals may well be true. There is no state behind either address for the answer to be about.

opaque

opaque struct Name withholds the layout from every module but the one declaring it. Outside, the type is incomplete: only *Name may be said, so a value cannot be built, copied, or have a field read. This is a different axis from visibilityprivate decides who may say the name, opaque decides who may know the shape. See modules.

An opaque struct with no body at all is C’s incomplete type, struct sqlite3; — nothing in sysl lays one out, and the declaration exists so that *Session is a type a *u8 cannot be mistaken for.

Enums

Two shapes under one keyword. A simple enum is a set of named discriminants over an underlying integer type; a data enum gives its variants payloads, which makes it a sum type. Both may carry members.

enum Status: u8
    Ok = 0
    Warn = 10
    Fail = 20

    severe(self) -> bool = u8(self) >= 10

print(u8(Warn), Ok.severe(), Fail.severe())
10 false true

The : u8 pins the storage; without it the compiler picks. A variant is a bare name, a name with an explicit integer value, or a name with a payload:

enum Shape
    Circle(r: real)
    Rect(w: real, h: real)

    area(self) -> real = self match
        Circle(r)  -> 3.14159 * r * r
        Rect(w, h) -> w * h

print(Circle(1.0).area(), Rect(2.0, 3.0).area())
3.14159 6

A member is told from a variant by what follows it: a member needs a body after its header, so Circle(r: real) — a header with nothing after it — is a variant.

Option[T] and Result[T, E] are ordinary data enums declared in the standard library, with no compiler privileges.

Type declarations

type Name = Existing introduces a second spelling, interchangeable with the first. It creates no new type and no checking — an alias is for shortening a name that has grown long.

Adding new makes it a genuinely distinct type, and within and where add checked bounds:

type Meters = new f64
type Slot = new u8 within 0..<8

var d = Meters(1.5)
var s = Slot(3)

print(f64(d) * 2.0, u8(s))
3 3

new, within and where are contextual words, so a function or field may still be named where. See errors and contracts for what a bound costs and when it is checked.

Traits, impls, and externs

trait Name declares a set of requirements; impl Trait for Type supplies them. Both are covered on traits.

extern declares a function or a variable the other side of the link owns, and is covered on the foreign interface.

Visibility

A declaration is public unless it says otherwise — the unmarked case is the one that writes nothing, because it is the common one.

writtenreach
(nothing)public — anything that can see the module can see it
privatethis file only
private[mod]the named enclosing module and everything under it

A struct’s and an enum’s members and fields each take their own modifier, so a type may be public while part of its shape is not. A trait’s members and an impl block’s take none: a trait’s member is as visible as the trait, and an implementation supplies what the trait asked for.

The details — what a module is, how the reach is computed, and how visibility interacts with opaque — are on modules.

end markers

Every block-shaped declaration may be closed with end Name, naming what it closes. It is optional everywhere and checked when written, so it cannot drift from the thing it claims to close.

The one place it is required is a struct with no fields, where it is the only thing distinguishing a body that is deliberately empty from one that was meant to be there.

end is a soft word: it is an ordinary identifier everywhere except immediately before a name or a construct keyword, so end stays usable as a variable.


Next: patterns and matching.

Search

Esc
to navigate to open Esc to close