sysl

Attributes, annotations, and compile time

:: attributes a type answers, the eight annotations a function takes, the three that lay out or place what they mark, the five a file’s header takes, @assert which stands on its own, and the #if directive that gates lines before the lexer sees them.

Three kinds of form in sysl reach into the compilation rather than into the running program. Each has a name and a spelling of its own:

writtenisread by
T::Attran attribute — a question a type’s own name answersthe analyzer, at the use
@test, @tailrec, @pure, @ghost, @export, @reads, @writes, @crossingan annotation — a fact about the free function under itthe grammar
@packed, @align(n), @section("...")an annotation — where the declaration under it is laid out, or where it landsthe grammar
@export("...") on a structan annotation — the name the type carries in a generated C headerthe grammar
@no_alloc, @requires, @link, @include, @testsan annotation — a fact about the whole file, in its headerthe grammar
@assertan annotation that describes nothing but itself — a condition settled while compilingthe analyzer, once
#ifa directive — a gate on linesa pass before the lexer

The last two are told apart by the sigil, and that is the whole rule. An annotation is @ and belongs to what it is written above — a declaration, or the file itself; a directive is # and gates lines. Nothing about indentation is involved, which matters because a declaration at the margin — a module header — has its annotation at the margin too, so a rule about columns would have had the two forms competing for one position.

A directive is still written at column 1, and for its own reason: it is gone before anything counts a column, so an indented one would look like it takes part in a block structure it has nothing to do with. That is a rule about directives, not the thing that distinguishes them.

Annotations come in groups, by what they attach to — and the last of them is the empty one, which is as much a rule as the others.

On a function there are eight, each written on its own line above the declaration. More than one may be stacked, and writing the same one twice is refused. @test and @tailrec are below; @pure, @ghost, @reads and @writes belong to the specification vocabulary and are on the verification page; @export makes the definition C-callable and is on the FFI page, beside the extern it is the mirror image of; @crossing(...) says a parameter hands a value to another concurrency domain and is on the memory page, beside the crossing rule it asks.

On a type, or on storage there are four, and three of them are about where rather than about what the declaration does. @packed and @align(n) lay out a struct — no interior padding, and the boundary the aggregate begins on — and @align(n) marks one binding’s storage as well. @section("...") marks a binding or a function, and says which linker section it lands in. All three are below. The fourth is @export, which on a struct names the type in a generated C header rather than placing it, and is on the FFI page with the other half of itself.

On the file there are five, in its header directly below module and before everything else: @no_alloc and its siblings, @requires(...), @link("..."), @include("..."), and @tests. The first two say what the whole module may do; @link says what its externs need at the linker and @include what its c const and c type blocks need at the C compiler; the last says it is scaffolding for the module’s tests. All five attach to the file rather than to any declaration in it — and writing one further down is refused with a message saying where it belongs. The first four are covered under modules and FFI, where what they mean is; @tests is below.

On nothing at all there is one: @assert, which stands where a declaration stands and describes only itself. It attaches to nothing, declares no name, and nothing can refer to one — two saying the same thing are two checks rather than a duplicate. It is below.

On a member, none of them. “A function” above means a free function: a method, a property, an associated function, a field and a variant take no annotation at all, and the refusal says so rather than complaining about the indentation of the line:

struct Counter
    n: int

    @test
    is_zero(self) -> bool = self.n == 0
an annotation marks a function, and a member is not one

So what sysl test runs is a free function that calls the member, and @crossing(...) is written on the wrapper a caller already goes through rather than on the method behind it — which is where the call a program makes goes, and so where the complaint belongs. @packed and @align(n) are not the exception they look like: they mark the struct, written above struct and not above a field. @assert inside a type’s body is refused too, and gets its own sentence, because it is not a claim about the member under it — it goes beside the type, where sizeof and offsetof still name what it is about.

#test above a member is answered by the same sentence, with the sigil named at the end of it. A directive is gone before the lexer counts a column, so an indented # never reaches the directive pass at all and arrives at the member grammar instead — and being told only that an annotation is written @ would send a reader to write @test, which a member is refused just the same.

An annotation’s name is an ordinary identifier, which is the point of writing these as annotations at all: nothing here is reserved, so a program may still call something test, link, alloc, no or requires. guide/slab‘s allocator calls its central function alloc and threads its free list through a field called link.

The @ is also read inside a pattern, where it binds a name to what a sub-pattern matched (patterns). The two never compete: an annotation’s @ is a prefix at the start of a line above a declaration, and a pattern’s is infix between a name and a pattern, in a position no declaration may stand.

:: — what a type’s own name answers

A type’s name is a type and not a value, so nothing is read from it. What it answers are attributes, written with :: rather than . so they stay out of the member namespace: Color::First cannot be confused with a variant, an associated function, or a member an impl added, and no impl can shadow one by declaring a member of that name.

Three kinds of type answer them, and each set is fixed and closed.

A simple enum

enum Color: u8
    Red = 2
    Green = 5
    Blue = 9

print(Color::Image(Color::First), Color::Image(Color::Last))
print(Color::Pos(Green), Color::Image(Color::Val(2)))
print(Color::Image(Color::Succ(Red)), Color::Image(Color::Pred(Blue)))
print(Color::Image(Color::Value("Blue")), int(Color::Value("Blue")))
Red Blue
1 Blue
Green Green
Blue 9
writtenistraps
T::First / T::Lastthe first and last variantno
T::Pos(v)a value’s 0-based position in the declarationno
T::Val(i)the value at position ion a position past the last, or below zero
T::Succ(v) / T::Pred(v)the neighbouring valueat the last / at the first
T::Image(v)the variant’s name, as a stringno
T::Value(s)the value a name stands foron a name no variant has

Position is not the discriminant, and that distinction is why Pos and Val exist. Discriminants may be explicit, non-contiguous and not zero-based — Green above is position 1 and discriminant 5 — so an ordinal has to be looked up rather than computed. Pos and Val are the two directions of that lookup, and Succ/Pred walk the declaration order rather than adding one. Going the other way, to a value’s discriminant, is the ordinary conversion int(c).

Image and Value are the printed-name pair, and they are what makes a simple enum observable. The type carries no == and no Display of its own, which is why every line above turns a value into a position or a name to look at it. A comparison is written on the discriminants, int(a) == int(b).

A constrained subtype

The other set — First, Last, Valid, Succ, Pred, Range — belongs to a within-ranged integer subtype, and is on the errors and contracts page beside the checking it is the question form of.

The two sets are spelled the same way for the same reason and overlap where the questions overlap, but they are not one set: an enum has Pos, Val, Image and Value because its values are named and unevenly spaced, and a subtype has Valid and Range because its values are a contiguous run of integers.

An integer type

The third set is two attributes wide — Min and Max, the extremes the type can hold. They belong to every member of the iN/uN family, and to the named types that are members of it.

print(u8::Min, u8::Max)
print(i8::Min, i8::Max)
print(int::Min, int::Max)
print(byte::Min, byte::Max)
0 255
-128 127
-2147483648 2147483647
0 255

The open family is why these exist rather than being written out. A program can spell 4294967295 for a u32 and lose nothing; the largest u10000 is 3,011 digits and cannot practically be written at all, so for a wide member the attribute is the only way to name a value the type obviously has. It is also why they cannot be a library table the way C’s UINT8_MAX is — there is no finite set of integer types to tabulate.

print(u3::Min, u3::Max)
print(i5::Min, i5::Max)
print(u64::Max)
0 7
-16 15
18446744073709551615

They are constants taking no argument, so they fold. That is what puts them where no call is admitted — a const initializer, an @assert condition, an array bound:

const LIMIT: u16 = u16::Max
const HALF: u32 = u32::Max / 2

print(LIMIT, HALF)
65535 2147483647

usize answers only a floor across targets. Its width is the pointer’s, so usize::Min is 0 everywhere and usize::Max is whatever the target makes it — a program that prints the latter is reading a fact about the machine rather than about the language.

Min/Max are not First/Last renamed, and asking for the wrong pair is answered by name rather than by a general refusal:

print(u8::First)
'u8' is an integer, not a declared sequence — its extremes are 'u8::Min' and 'u8::Max'. 'First' and 'Last' name the ends of an enum's variants or of a 'within' range, which is a different question

First and Last name the ends of a declared sequence — an enum’s variants, a written range — and an enum’s discriminants may be explicit and non-contiguous, so its first-declared variant need not carry the smallest value. The two questions coincide on an integer and only there. A within-ranged subtype answers both, since there they genuinely agree, and a reader who learned Min on u32 should not find it renamed on a subtype of u32.

And a subtype that narrows nothing has its base’s, because that is what it can hold. First and Last still need a range, which is the same distinction one paragraph up — a declared sequence against what the type can hold:

type Handle = new u16

print(u16(Handle::Max))
65535

That is the case a c type is always in: a measured typedef carries no range, so asking a size_t for its maximum is asking about the integer C said it is. A where predicate is the exception and is refused — it narrows the type without saying to what, so there is no extreme to read off the declaration.

Only integers have them: f64::Max and bool::Max are refused, the float because its extremes are sysl.math‘s business and the boolean because it is not that kind of type.

A type parameter answers them too, from whatever the instantiation bound it to — which is what makes the bounds usable by a library rather than only by a program. Bounded narrowing, integer parsing, saturating arithmetic and a min/max reduction’s identity all want T‘s extreme, and none of them can name it any other way:

widest[T]() -> T = T::Max

val a: u8 = widest()
val b: u16 = widest()

print(a, b)
print(widest[i8]())
255 65535
127

One body serves every width it is instantiated at, and the argument may be written at the call or inferred from what the result is used as.

The parameter carries the type that was written, so a within-ranged subtype answers its own bound rather than its base’s:

type Age = int within 0..150

widest[T]() -> T = T::Max

val a: Age = widest()

print(int(a))
150

This is the one place a transparent subtype and its base give different answers, which is why generics states the rule: everywhere a value flows the two are interchangeable, and a bound is the one thing either can produce that the other cannot hold.

Min and Max are the only two a parameter answers. The rest stay on a written type name:

first[T]() -> T = T::First

val a: u8 = first()

print(a)
'T' is a type parameter, and the only attributes one answers are 'T::Min' and 'T::Max'

The reason is the walk that checks a generic body once, with T standing for itself rather than for any particular type. That walk has to hand back a typed value, and Min and Max both answer in T — so one stand-in is right for both. Valid answers a bool, Pos a usize and Image a string, so admitting those would mean restating each attribute’s result type a second time, in a second place, where the two copies could drift apart.

Nothing is asked of T in the signature. A parameter given a type with no extremes is reported at the instantiation that gave it one, and the message names both:

struct P
    x: int

widest[T]() -> T = T::Max

val a: P = widest()

print(1)
'T::Max' needs an integer type, and 'T' is P here

That is the same deferral sizeof(T) takes, and it is what keeps a bound from being something a generic has to declare in order to measure.

What has no attributes

A data enum, because its value is a variant plus a payload, so a position, a name and a neighbour are each questions about only half of it:

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

print(Shape::First)
'Shape::First' needs a simple enum, and 'Shape' carries data

A generic enum, because its name stands for no one value set until it is applied:

enum Box[T]
    Full(v: T)
    Empty

print(Box::First)
'Box' is generic, so 'Box::First' has no single enum to read

And an attribute outside the set is answered by name, since the set is closed:

enum Color: u8
    Red
    Green

print(Color::Nonesuch)
'Color' has no attribute 'Nonesuch'

@test — a function with a caller nothing else has

A word after @ that is neither test nor tailrec is answered by name rather than as grammar. The set is closed, which is what makes a misspelling an error instead of a marker that quietly does nothing — annotations are deliberately not a general mechanism yet.

The annotation goes on its own line above an ordinary function declaration, which may still be private:

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

@test
adds_two()
    assert(add(1, 1) == 2, "one and one")

@test("an empty slice has no first element")
private first_of_empty()
    assert(true, "nothing to see")

print(add(2, 3))
5

That program prints 5 and runs neither test, which is the whole of the arrangement: sysl run builds the program, and the tests are for sysl test.

Four forms of the annotation:

writtenmeans
@testan ordinary test, named after the function
@test("a sentence")named by the sentence instead
@test(should_trap)the test passes by stopping the program
@test(should_trap: "past the end")…and the run must have printed that text

What a test may be

A test is an ordinary function with a caller nothing else has: no parameters, no result, not generic. All three are the same requirement from different sides, since the runner calls it with nothing and reads the answer off whether it returned. They are checked at the annotation, because the function is a perfectly good function and it is @test that made a promise about it:

@test
takes_one(n: int)
    assert(n > 0, "positive")
a '@test' function takes no parameters, and 'takes_one' takes one — 'sysl test' calls it with nothing, so there is nowhere for an argument to come from
@test
generic_one[T]()
    assert(true, "yes")
a '@test' function has no type parameters, and 'generic_one' declares 'T' — a generic is compiled for the arguments a caller fixes, and the runner supplies none
@test
gives_back() -> int = 1
a '@test' function returns nothing, and 'gives_back' returns 'int' — a test's result is whether it came back, so there is nothing to read a value with

A test passes by returning

That is the whole protocol, and it is what lets a test assert in the language it is testing rather than in a framework. A broken require, a bounds violation, an unwrap of a None — each ends the process, and none of them had to know it was running under a test.

should_trap inverts the reading, for a test whose subject is the check. With a string it additionally requires that the run printed it, which is what tells a trap from the right trap. A silent trap satisfies should_trap and can satisfy no string, because a compiler-inserted check raises a signal and says nothing — see what stopping looks like.

A test has one caller, and the program is not it

run_it()
    adds_two()

@test
adds_two()
    assert(true, "yes")
'adds_two' is a '@test' function, which 'sysl test' calls and nothing else does — every other build leaves it out, so this call would have no definition to reach. Work two tests share belongs in an ordinary function they both call

What is dropped, and when

sysl run, sysl build and sysl emit-llvm drop the tests — and drop them after analysis. So a @test that does not compile is an error in a build that would never have run it, and a module’s capability clause reaches its tests like any other member.

That ordering is what lets a test sit beside what it tests: a program’s tests do not run when it runs, and they do not stop being checked.

A helper only a test calls leaves with it, because it becomes unreachable and pruning notices; a helper the program also calls stays, because the program still calls it.

What a test build keeps regardless is the four definitions nothing in the program names: an interrupt handler, an @export, a @section definition (below), and a destructor. Each is entered from somewhere no walk over the program can see — the processor, a caller outside this compilation, a linker script gathering a named section, and the release hook generated from a payload type — so each is a root wherever the walk begins. A test build swaps the roots, putting the tests where the entry point was, and these four are roots there too. They have to be: leaving them out would not make them reachable from the tests instead, it would make them reachable from nothing.

That matters most to a package, whose only build is its own suite. A test build dropping a destructor could not link at all, the release hook being left to call a symbol nothing defined; one dropping an export would link, pass, and quietly go without the surface the package exists to provide.

sysl build-lib is the exception, and drops them before analysis. An artifact is the one output that outlives the compilation that made it, and analysis is not a passive reading: a test naming Buf[int] creates the whole of Buf at int, and that instantiation is an ordinary library function afterwards, with nothing in it recording which declaration asked for it. Dropping the test from the analyzed program would therefore drop the test and keep everything it caused — shipping instantiations no caller of the library ever asked for, and making the artifact’s contents a fact about its tests.

The line falls between parsing and analysis. Every source is parsed before the drop, so a syntax error in a @tests file still stops a build-lib. What such a file no longer gets is everything after the parse — name resolution, types, visibility, capabilities, the @test well-formedness rules above, generic instantiation, and the duplicate-@export check. So a library test that is well-formed text and wrong in every other way builds clean, and its real errors are reported by sysl test --std, which is where a library’s tests are run.

That last one is worth naming because it is the case where “everything after the parse” is easy to apply to only half of a build. An @export in a @tests file names a symbol this build is about to discard, so it can collide with nothing — and a check that read the tree before the drop would refuse a program over a name that was never going to be emitted.

Read the other way, sysl test is the build where such an export is a definition, and it is held to every rule on the FFI page accordingly — private, variadic, generic, a parameter C has no declaration for, two exports claiming one symbol, and an export reaching computed module storage. Each of those asks about the symbol table the build in hand emits, so which tree is read is the whole of the difference between the two commands.

@tests — a file of scaffolding

Pruning answers for a program. It does not answer for a library, which has no main to lower outwards from, so every public declaration is a potential entry and all of them are emitted. A helper only a test called would ride into the artifact and be advertised out of it, nameable by everything that links the library. Nothing about the declaration says it is scaffolding, and nothing could — it is an ordinary function, which is the point of it.

So the file says it, in its header, beside the capability clauses:

@tests

fixture() -> int = 6 * 7

@test("the fixture is the answer")
the_fixture_holds() =
    assert_eq(fixture(), 42)

It is @tests and not @test because the two say different things: @test names something the runner calls, and this names something no build but the runner’s keeps. One word for both would read as though the file were itself a test.

Two rules, and either alone would be unsound. Every build but sysl test drops everything such a file declares — and nothing outside a test may name any of it. Without the second, a program that called a helper would compile here and fail at the link, with a message about a missing symbol rather than about the line that named it.

The restriction is stated over the referring declaration, not over the file it sits in. So a @test function may name scaffolding wherever it was written — which is what keeps a test able to sit beside what it tests — and another @tests file may, and nothing else may:

@tests

fixture() -> int = 6 * 7

print(fixture())
error: 'fixture' is declared in a file that said '@tests', so it is there for the module's tests and no build but 'sysl test' keeps it — only another such file, or a '@test' function, may name it

A closure counts as the body it was written in. It is lowered to a function of its own under a name nobody wrote, so on its own terms it belongs to no file — but what decides is where it sits, not what it ends up called. A lambda inside a test may name the test file’s own scaffolding, and so may a bare function name, which is the same thing written shorter:

@tests

fixture() -> int = 6 * 7

apply(f: &Fn(int) -> int, n: int) -> int = f(n)

@test("the fixture is the answer, reached through a callback")
the_fixture_holds() =
    assert_eq(apply(v -> fixture() + v, 0), 42)

It goes when the file goes, too, which is the half that makes the naming safe: the program that ships carries no body for it.

An impl block may not sit in one. It declares no name; it fills a slot in a method table, which the rest of the program reads without naming anything. Kept in a test build and dropped everywhere else, it would mean a trait answering one way while the tests ran and another way in the program that ships. The impl belongs beside the type. A closure is the one exception the compiler makes for itself, and it is not a hole: the table it writes is dropped with the closure.

It stops at the package boundary. A package is compiled from source and a library arrives as an artifact whose test files were never encoded, so a rule that let the reference cross would compile against one and fail against the other. A test-support library meant to be imported is therefore ordinary code that ships, and is a different thing from a file of scaffolding inside a package.

The runner

sysl test <path>
sysl test <path> --filter <text>
sysl test <path> --fail-fast

One build, one process per test. The tree is compiled once, into a binary whose entry point takes a test’s name and runs that test alone — the program’s own statements and its main are not run, though its module-level vals are still filled, since a test reads a module’s storage like any other function. The runner then starts that binary once per test.

The process per test is not a cost being tolerated; it is the mechanism. A test that fails does so by ending its process, so a run that shared one would report the first failure and nothing after it. The compile is the slow half, and there is only ever one of it.

Exit status is 0 if and only if every test that ran passed. A tree with no tests, and a filter that matched none of the tests there are, both exit 0 and say which happened.

assert and panic

Both are ordinary functions in the standard module:

panic(msg: string) -> never
    print("panic:", msg)
    exit(1)

assert(cond: bool, msg: string)
    if !cond then panic(msg)

They exist because require is a promise about a call, checked on entry, and a test’s fifth statement has no contract to hang a claim on — the contract was about the arguments, four statements ago.

They stop the program the way unwrap does — a line naming what happened, then the hosted exit — rather than through the trap instruction, because a check a program makes is one the compiler cannot see, and the message is the whole point of it. The message is required rather than defaulted, because the condition’s source is not available to print and a failure saying only “assertion failed” sends its reader looking for which one.

@assert — a condition settled while compiling

const capacity: usize = 512

@assert(capacity == 512, "the protocol fixes this")

print(capacity)
512

The condition is a constant expression, folded by the machinery a const initializer already goes through — so it may name constants, sizeof, alignof, offsetof, and the arithmetic and comparisons over them, including a constant declared below it. A true one emits nothing at all. A false one stops the compilation, quoting the message:

@assert(1 == 2, "arithmetic broke")

print(1)
assertion failed: arithmetic broke

The message is optional and is the reader’s own, because they know what the number means — that a struct matches its C counterpart, that a table is the size a protocol fixes — where the expression alone says only that two numbers differ.

A failed comparison also says what each side came out as. The compiler folded both in order to decide the condition, so it has the numbers at the moment it reports that one of them is wrong, and withholding them would leave you editing the literal and rebuilding to find out. A side you wrote as a literal is not repeated back — it is on the line above the message — so sizeof(FRect) == 16 reports only the left; where both sides are computed, both are named. A condition that is not a comparison has nothing to add, since the thing that came out false is its only operand.

It is not require. A require is a runtime precondition: it is compiled, it branches, and it traps when the program reaches it. This is settled while compiling and reaches the binary as nothing. A condition it cannot settle is refused rather than deferred — and a call is the line, since a call in a constant expression would be a request for compile-time evaluation of arbitrary code:

f() -> int = 3

@assert(f() == 3)

print(1)
has to be a constant expression

Checking a C struct’s layout

This is what it was built for. sysl lays a struct out in declaration order and is C-compatible by construction, but from inside sysl that claim cannot be checked: sizeof reports what sysl laid out, not what the header says, so comparing the two is a tautology.

Where the C side is __attribute__((packed)) or was declared at a boundary, @packed and @align(n) further down this page are how the sysl side says the same thing — and the check below is what proves the two agree rather than merely look similar.

It stops being one when both sides name the same number. The C half goes in a .c beside the sysl, which is compiled with it and for the same target, so it reads the headers of the machine being built for:

#include <stddef.h>
_Static_assert(sizeof(struct pair) == 8, "struct pair size moved");
_Static_assert(offsetof(struct pair, b) == 4, "struct pair.b moved");

and the sysl half pins what sysl laid out:

struct Pair
    a: i32
    b: i32
end Pair

@assert(sizeof(Pair) == 8, "Pair must match struct pair")
@assert(offsetof(Pair, b) == 4, "struct pair.b moved")

print(sizeof(Pair), offsetof(Pair, b))
8 4

Neither half finds the other’s mistake, which is why both are written: the .c catches a header that moved, and the @assert catches a struct that was transcribed wrong.

Pin every field you read, not only the total. A size catches a field that changed width and one that was added. It says nothing about order — so two same-width fields transposed in the mirror leave the total unchanged, and the size assertion on both sides passes while every read is off by the distance between them:

struct Pair
    b: i32
    a: i32
end Pair

@assert(sizeof(Pair) == 8, "Pair must match struct pair")
@assert(offsetof(Pair, b) == 4, "struct pair.b moved")

print(1)
assertion failed: struct pair.b moved — the left side is 0

That is the failure the whole pairing exists to prevent, arriving through the half that was not checked. offsetof(T, field) closes it: it takes a type and a field name, and answers where the field starts in bytes. A field the struct does not have is refused by name rather than reported as a condition that would not fold.

Inside a generic, where it is settled once per instantiation

A generic’s interesting facts are not properties of its declaration — they are properties of each set of arguments it is compiled for. sizeof(T) has no width until something chooses a T, so an assertion about one is settled at every instantiation rather than once:

slab[T](x: T) -> usize
    @assert(sizeof(T) >= sizeof(*u8), "a free block has to hold the link through it")
    sizeof(T)

print(slab(1u64))
8

An argument the claim does not hold for stops the compilation, and the report names which one asked — the mistake is at the call that chose the type, while the sentence explaining why is at the declaration, so a message carrying only one of the two sends its reader to the wrong file:

slab[T](x: T) -> usize
    @assert(sizeof(T) >= sizeof(*u8), "a free block has to hold the link through it")
    sizeof(T)

print(slab(1u8))
assertion failed: a free block has to hold the link through it — the left side is 1 and the right side is 8 — where T = byte

This is the check require cannot make. A precondition over sizeof(T) written as a require is a runtime branch for a fact that was settled at the call: a container instantiated at a type too narrow compiles, ships, and traps the first time anybody uses it. guide/slab was written that way before this and is the worked example of the difference.

A value parameter is bound the same way and so is checked the same way, which is what lets a generic hold its caller to a bound on a length:

scratch[const N: usize](xs: [N]int) -> usize
    @assert(N <= 4, "the scratch buffer is a stack array")
    N

var small: [2]int = [1, 2]

print(scratch(small))
2

A generic nothing instantiates is not checked, and that is the same deferral an array bound already lives under: [sizeof(T)]u8 is a well-formed length nobody can name until a T is chosen, and the width is not wrong there — it is not being measured yet. The claim is settled at the first call, which is the first moment there is anything to settle. A condition that could never fold is still refused inside a generic, exactly as it is outside one.

@tailrec — an assertion that the frame is reused

A function whose last act is a call to itself compiles to a branch back to its own entry rather than a second frame, so the recursion is bounded by the arithmetic and not by the stack. That happens whether or not anything is written — see tail calls for what counts as the last act, and for the two things that end a tail position.

@tailrec asserts the jump is there:

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

print(count(100000, 0))
100000

What it buys is the refusal. The optimization is silent, so an edit that costs it is silent too — until the day the recursion is deep enough to matter:

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

print(count(5, 0))
calls itself nowhere the jump can replace

It changes nothing about what is emitted. Write it where losing the jump silently would be a bug, and leave it off everywhere else.

@packed and @align(n) — where a struct’s fields sit

By default a struct pads: each field begins on its own alignment, and the aggregate takes the widest of them. That is the layout a program wants unless something outside the program has an opinion.

struct Head
    tag: u8
    len: u32

print(sizeof(Head), alignof(Head))
8 4

Three bytes of that eight are a gap in front of len. Two attributes move a struct off the default, and they are separate axes — one removes the padding between fields, the other raises where the aggregate begins.

@packed — no interior padding

@packed
struct Head
    tag: u8
    len: u32

print(sizeof(Head), alignof(Head), sizeof([4]Head))
5 1 20

The fields are laid end to end, the aggregate needs no alignment of its own, and an array of them has no gap between elements either. A register block, and a C struct that has to match one, are what it is for.

The fields still read and write their own values — packing changes where they sit, not what they are:

@packed
struct Head
    tag: u8
    len: u32

var h = Head(7, 1000)

h.len += 1

print(h.tag, h.len)
7 1001

A packed field has no address. &s.f is refused, and so is any *T into one:

@packed
struct Head
    tag: u8
    len: u32

var h = Head(1, 2)
val p = &h.len

print(1)
'&' here makes a '*uint' into Head, which is '@packed' — its fields sit at their declared offsets, so this one need not be on a 'uint' boundary, and every use of a '*uint' is entitled to assume that it is. Read or write the field through the struct, which is where the offset is known, or take the address of the Head itself

The field sits at its declared offset, which is very often not a multiple of its own alignment — that is the point of the attribute — while a *u32 is a *u32 wherever it came from, and every use of one is entitled to assume the address is aligned. Only the escaped address loses that, and it would lose it arbitrarily far from the & that made it. The struct’s own address is untouched, being an ordinary pointer to an ordinary aggregate.

Bitfields — an iN field in exactly N bits

Inside @packed, an integer field occupies exactly its declared width. A packed struct whose fields all lower to an integer, at least one of them narrower than a byte, is one unsigned integer — and its fields are ranges of it:

@packed
struct Ctrl
    enable: u1
    mode: u3
    prescale: u4

val c = Ctrl(1, 5, 9)

print(sizeof(Ctrl), c.enable, c.mode, c.prescale)
1 1 5 9

No syntax was needed for this, which is why there is none: the open integer family already does the work C needs int x : 3 for, so a five-bit field is written u5 in a struct exactly as it is anywhere else.

Two things follow that C leaves to the implementation, and pinning them is the point of the feature rather than a detail of it — it is why portable embedded C avoids bitfields and writes the shifts out by hand. The bits fill from the least significant upward in declaration order, and a field straddles a byte boundary rather than moving off it. Both are visible in the storage:

@packed
struct Ctrl
    enable: u1
    mode: u3
    prescale: u4

var arena: [4]u8 = [0; 4]
var c: *Ctrl = ptr_cast(&arena[0])

c.enable = 1
c.mode = 5
c.prescale = 9

print(arena[0])
155

155 is 1 | 5 << 1 | 9 << 4: the first field declared is the low bits.

The rule is stated over the integer’s value and never over memory bytes. Written the other way — “the low bits of byte 0” — it would be a claim about endianness, which nothing in sysl is allowed to make. Written this way it costs nothing: the struct is an integer, so how it reaches memory is the target’s ordinary byte order for an integer of that width. A wire format’s byte order belongs to the protocol rather than to the CPU, and stays with sysl.encoding.binary‘s get_u16_le and the rest.

Every field has to be an integer. A bool is not one here — its storage is a byte and its representation a bit — and neither is a pointer, a float or an array:

@packed
struct Mixed
    p: *u8
    a: u3

val m = Mixed(null, 1)

print(m.a)
is one integer, and every field of it has to be one too

Nesting is the composition path, and it costs nothing: a bitfield struct is a leaf, and an ordinary @packed struct lays one out as a field of its size.

@packed
struct Ctrl
    enable: u1
    mode: u3
    prescale: u4

@packed
struct Block
    ctrl: Ctrl
    count: u32

var b = Block(Ctrl(1, 5, 9), 3)

b.ctrl.mode = 2

print(sizeof(Block), b.ctrl.enable, b.ctrl.mode, b.count)
5 1 2 3

A bitfield may be volatile, and it means a volatile access of the container. Reading a field is one volatile load of the whole container; writing one is a volatile load and a volatile store of it — what C does with volatile unsigned x : 3, and what lets a @packed struct describe a hardware register rather than only a data layout:

@packed
struct Reg
    enable: volatile u1
    mode: volatile u3
    prescale: volatile u4

var block: [4]u8 = [0u8; 4]
var r: *Reg = ptr_cast(&block[0])

r.enable = 1
r.prescale = 9
r.mode = 5

print(block[0], r.enable, r.mode, r.prescale)
155 1 5 9

The qualifier belongs to the container rather than to one range of it, so writing it on any field qualifies every access to the struct. Every field of a bitfield struct is bits of one word, so there is no shadow field here to stay ordinary — which is the thing per-field qualification buys in an ordinary register block. The struct itself still may not carry the qualifier, and does not need to:

@packed
struct Ctrl
    a: u3
    b: u5

var p: *volatile Ctrl = ptr_cast(4096usize)

print(p.a)
'volatile Ctrl' is not a type

A write is a read-modify-write, and nothing diagnoses what that costs. A device is entitled to one bus cycle, so writing a field of a register with clear-on-read or write-1-to-clear semantics corrupts the ranges beside it — the read that begins the sequence has already had its effect. Nothing in the language describes a register’s read semantics, so this is stated rather than refused, exactly as it is in C. A register of that kind keeps its volatile u32 and its shifts, where the single access is written out.

A field that is a set of named values is a simple enum, which is one integer and may be volatile like any other. A data enum may not — a tag beside a payload is more than the one access the qualifier promises.

A bitfield has no byte offset, so offsetof says so rather than rounding down to the byte the field begins in:

@packed
struct Ctrl
    enable: u1
    mode: u3
    prescale: u4

print(offsetof(Ctrl, mode))
is a bitfield — it starts at bit 1 of the struct and is 3 bits wide, so it has no byte offset

It has no address either, which is the packed rule above and needs no separate ruling.

Across a C boundary it travels as what it is — a packed struct of one integer — and not as a C bitfield struct. Those are different things on some machines, and it is C’s own doing: MSVC allocates unsigned a:1 into a four-byte unit where the Itanium ABI packs it into a byte, so a C bitfield struct of the same fields need not even be the same size everywhere. A sysl bitfield struct is one integer on every target.

@align(n) — where the aggregate begins

@align(64)
struct Head
    tag: u8
    len: u32

print(sizeof(Head), alignof(Head))
64 64

The size rounds up as well as the start, so an array keeps every element on the boundary. It may only raise: asking for less than the fields already need changes nothing, since lowering is what @packed is for and a type that under-promised would be unsound to pass around.

The bound is folded rather than lexed, so a program writes the name it already has for the number, and arithmetic over one works:

const CACHE_LINE: int = 64

@align(CACHE_LINE)
struct Head
    tag: u8
    len: u32

print(alignof(Head))
64

It must be a power of two — an address is aligned by having low bits clear, so a boundary of six is unsatisfiable rather than merely weak:

@align(6)
struct S
    a: int
'@align(6)' on 'S' is not an alignment — a boundary is a power of two, since an address is aligned by having low bits clear

They compose

The gaps between fields and the boundary the whole thing starts on are different questions, so a wire header that has to live in a DMA-capable buffer is both at once. The order they are written in does not matter.

@packed
@align(16)
struct Head
    tag: u8
    len: u32

print(sizeof(Head), alignof(Head))
16 16

Sub-byte fields are a separate axis and @align(n) does not touch them. Inside @packed an iN field occupies exactly N bits (bitfields above); the boundary the aggregate begins on is this attribute, and a bitfield struct takes one like any other.

@align(n) on a binding

The boundary may also be written on the storage itself — a var or a val, at the top of a module or inside a function, and on the static spelling of either.

@align(64)
var region: [128]u8

region[0] = 7u8
print(region[0], region.len)
7 128

It says nothing the struct form could not. A boundary put on a type travels with every value of it, and a named aligned type is reusable where a repeated attribute is not — so reach for the type when more than one thing needs the boundary. What this spelling saves is at the use site: a buffer wrapped in a struct is read as region.bytes[i] rather than region[i].

@packed has no meaning here, and says so, because it describes the arrangement of fields inside an aggregate and a binding has none:

@packed
var n: int = 1

print(n)
'@packed' describes how a struct's fields are laid out, so it can only mark a struct — a 'var' or a 'val' has no fields to pack, and '@align(n)' is the one of the two that may stand above one

A binding that names several things is refused for a related reason: there is no one object for a boundary to be about.

@align(16)
var a, b = 1, 2

print(a, b)
'@align(n)' is the boundary one object's storage begins on, and a binding that names several has no one object for it to be about — declare them on lines of their own

The bound is held to the same rule a struct’s is — a power of two, folded rather than lexed, so a const or arithmetic over one is what a program writes.

@section("...") — where a symbol lands

@align(n) says what boundary storage begins on. @section says where the storage is, and it is what a program reaches for when the address of a thing is part of what it is: a vector table at the address the processor fetches from, storage in .noinit that survives a warm reset, a DMA buffer in the RAM bank the engine can reach, a function copied into RAM so it can run while flash is being erased.

It marks whatever occupies an address — a module var, a module val, and a function.

@section(".noinit")
var crash_reason: u32

@section(".ramfunc")
erase_page(n: usize) -> bool = n < 64

Nothing on this page runs one of these. A section name belongs to the target’s object format — .noinit is ELF’s spelling, __DATA,__mine is Mach-O’s — so a program that places something is a program about one machine, and the machine this page’s examples run on is not the interesting one.

That is also why the compiler does not check what is in the string. A set of characters chosen by sysl would refuse a section some target requires, and the assembler that will not take one says so better than a rule here could. It is extern‘s link name and @export‘s symbol read a third time: a spelling belongs to whoever consumes it.

It composes with @align(n)

The two are different axes — one is the boundary an object begins on, the other is where it lives — and a statically placed stack is written with both. The order does not matter.

@align(4096)
@section(".noinit")
var page_table: [512]u64

A placed symbol is kept

Nothing inside the program reads a table that a linker script gathers — that is the whole point of writing one — so a placed definition is a root, exactly as an interrupt handler and an @exported function are, and a placed symbol is marked so that the optimizer does not delete an object with no reader.

Without that second half the attribute would compile, link, and place nothing. The failure would be the absence of a section, which is not a thing anybody looks for.

A placed definition a dependency supplied is kept only where your program reaches its module, which is the rule the FFI page states for every kind of root at once. It bites hardest here, because being marked is exactly what stops anything downstream undoing it: a placed definition kept for want of the rule is bytes in your image that no optimizer will remove, in the region the attribute exists to manage. A package whose placed definition you want is one you name — an import is enough.

What it may not mark

A const is folded into every use and has no storage; a type is not an object. Neither takes a section:

@section(".rodata")
const N: int = 4

print(N)
'@section("...")' places one object in a linker section, so it marks a 'var', a 'val' or a function

A local is refused for a different reason, and the message says which — its storage is the frame of whichever call is running, while a section is a region of the image, decided once at the link:

f() -> int
    @section(".noinit")
    var n: int = 1
    n

print(f())
'n' is a local, so it cannot be placed in section ".noinit"

A binding that names several things has no one object for a section to be about, which is the rule @align(n) above already follows:

@section(".data")
static var a, b = 1, 2

print(a, b)
'@section("...")' places one object, and a binding that names several has no one object for it to be about

#if — gating lines before the lexer

Everything a target decides is a fact the compiler reads about the machine. #if is the one place a program reads one, and it exists because machines genuinely differ in ways a library cannot paper over: a syscall number, a struct a header lays out two ways, a symbol one libc exports and the other does not.

#if posix
line_ending() -> string = "\n"
#else
line_ending() -> string = "\r\n"
#endif

print("a posix machine", line_ending().len)
a posix machine 1

What differs between the branches is the implementation, not the answer. That is the shape most uses of #if have: a syscall number, a struct a header lays out two ways, a symbol one libc exports and the other does not — chosen per machine so that everything above the choice can stop caring.

#elif chains, and #else catches what nothing named:

#if aarch64
machine() -> string = "aarch64"
#elif x86_64
machine() -> string = "x86_64"
#elif riscv64
machine() -> string = "riscv64"
#else
machine() -> string = "something else"
#endif

That one is quoted rather than run, because what it prints is supposed to depend on the machine — which is the whole point of the construct and exactly what a page cannot pin to one answer.

#if / #elif / #else / #endif, nesting freely, and the branches are exclusive — the first whose condition holds is the one that contributes, and a group inside a branch that was not taken contributes nothing however its own condition reads.

It gates lines, and it gates them before anything is parsed

A line in a branch this build is not for is replaced by an empty line, not removed, and so is every directive line. After the pass the file is an ordinary sysl file that happens to have some blank lines in it, and nothing downstream knows any of this happened.

Replaced rather than removed, because every line below a gate has to keep the number it was written at. Deleting them would leave the messages right and the carets somewhere else, with nothing to say so:

#if linux
say() -> string = "one"
extra() -> int = 1
another() -> int = 2
#endif

print(missing_name)
:7:7

The caret is on line 7, which is where print(missing_name) is written — not line 2, where it would be if the four gated lines had been dropped.

A directive sits at the margin, column 1. That is a rule and not a convention. sysl is indentation-sensitive, and indentation is how the language reads block structure — so a gate written in that channel would look like it takes part in a nesting it has nothing to do with, when in fact the line is gone before anything counts a column. At the margin it is visibly not part of the code’s shape, which is what it is. It is also how C is written. What keeps a declaration’s @test from ever being mistaken for one of these is the sigil, not the margin — an annotation on a module header sits at column 1 too.

Why lines and not a construct wrapping declarations. Rust spells this #[cfg], an attribute on an item, and can because Rust is brace-delimited: the attribute attaches without moving anything. Here the equivalent would have to take an indented block, so adding or removing a platform gate would reindent everything inside it — a one-line intent showing up as a whole-body diff. A flat marker disturbs nothing.

The symbols are derived from the target, and the set is closed

kindsymbols
operating systemmacos, linux, windows, freestanding
processoraarch64, x86_64, riscv64, riscv32, thumb, x86, wasm32, craft
derivedhosted (not freestanding), posix (macos or linux)

That is the whole vocabulary. There is no #define, nothing a project can add, and no dependence on a project config. A condition is a symbol, !, &&, ||, and parentheses; && binds tighter than ||.

posix is a name for the commonest disjunction rather than a replacement for writing it — #if linux || macos still says the same thing. Note that this posix is not the capability of the same name: this one asks is this a POSIX system, a fact about the machine settled by the target, where the capability asks may this module use POSIX, a permission a project grants and a no posix clause takes away. They agree today only because nothing denies anything yet.

A symbol nobody knows is an error, not false:

#if darwin
say() -> string = "one"
#else
say() -> string = "two"
#endif

print(say())
'darwin' is not something a target says about itself — sysl knows aarch64, craft, freestanding, hosted, linux, macos, posix, riscv32, riscv64, thumb, wasm32, windows, x86, x86_64

The set is closed, so a name outside it is a mistake rather than a fact this build happens not to have — and a misspelling that read as false would gate code out of the build with nothing said. Silently missing code is the one failure this feature cannot be allowed to have, and it is the one C has.

A target’s name is not a symbol — it has a - in it, which no identifier carries — and writing one is told what to write instead, because otherwise the reader is told that - is not an operator, which is true and no help:

#if aarch64-macos
say() -> string = "one"
#else
say() -> string = "two"
#endif

print(say())
'aarch64-macos' is a target's name rather than something a condition asks about — a condition asks about one fact of the machine at a time, so this is written 'aarch64 && macos'

Every condition is checked, in the branch being taken and the ones being skipped alike. So a misspelling in the Linux half is caught by a macOS build, which is where it would otherwise sit until somebody built for Linux:

#if linux
say() -> string = "one"
#elif nosuchmachine
say() -> string = "two"
#else
say() -> string = "three"
#endif

print(say())
'nosuchmachine' is not something a target says about itself

That refusal comes from a build whose #if linux was already false and whose #else is the branch being taken. The condition on the way past was still read.

What is given up, and what is not

The inactive branch is never syntax-checked. That is the price of gating text rather than trees, it is C’s price too, and a Linux branch can therefore rot while the macOS build stays green. What finds that is a build for each target — a thing to run, not a thing to design around. The conditions themselves are the part that is checked everywhere, and they are the part where a mistake would otherwise be silent.

The gate runs before anything knows what a string or a comment is, so a line that begins at the margin with a directive word is a directive even inside a text block or a block comment. Recognizing those would mean a second copy of the lexer’s rules about literals, in a place where the two could drift with nothing to notice — a worse defect than this one. The margin rule is what keeps it rare: a text block written anywhere but the top level is indented in the source, whatever its value turns out to be.

The library is subject to it too

library/sysl is sysl source, so it may gate on the machine like any other — which makes “the standard module” a question with a target in it, and the library’s source is parsed once per target accordingly.

The one thing held fixed is that a name the compiler spells for itself is declared on every target. A library that gated Option away for Windows would be a library nothing compiles against there, so that is refused in a registry-wide check rather than at the first ? somebody writes.

And an artifact records the target it was built for and is refused by a build for another, because the trees a library ships are now a per-target answer.

What is deliberately absent

absentwhy
a general annotation mechanismthe set is closed: @test, @tailrec, @pure, @ghost, @export, @reads(...), @writes(...) and @crossing(...) on a free function, @packed, @align(n) and @export("...") on a struct, @section("...") on a binding or a function, @no_<capability>, @requires, @link, @include and @tests on a file, and @assert on nothing at all. Each was designed and added on its own evidence; there is no way to write one the compiler does not already know
bitfield syntaxthere is nothing to write: inside @packed an iN field already occupies exactly N bits, so a five-bit register field is u5 and needs no : 5 beside it. The open integer family does the work C’s declarator syntax was invented for
#define, or any project-supplied symbolthe #if vocabulary is derived from the target and closed, which is what makes an unknown symbol an error rather than a false
a #if that asks about a capabilitya condition asks what the target says; what a project permits is a different question, left with the config that would define it
a test framework in the librarya test asserts in the language it is testing, and passes by returning
a release mode that drops a checksee errors and contracts — every check is in every build

That is the language. What ships beside it is the standard library, which is a section of its own — nothing in it is a language feature, and every type in it is one a program could have written.

Search

Esc
to navigate to open Esc to close