sysl

Attributes, annotations, and compile time

:: attributes a type answers, the eighteen 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, @crossing, @domain, @lends, @noinline, @inline, @cold, @disjoint, @no_alloc, @no_block, @blocks, @abandonsan annotation — a fact about the free function under itthe grammar
@setup, @teardown, @setup_all, @teardown_allan annotation — a hook sysl test runs around a module’s teststhe grammar
@borrowsan annotation on a trait’s method — see @borrowsthe grammar
@needs(...)an annotation — the capabilities reaching the declaration under it requires; with @abandons, one of the two an extern takesthe grammar
@packed, @align(n), @section("...")an annotation — where the declaration under it is laid out, or where it landsthe grammar
@export("...", weak), @export(weak) on a functionan annotation — the export is a default a strong definition of the symbol replaces at the link; see a weak exportthe grammar
@export("...") on a structan annotation — the name the type carries in a generated C headerthe grammar
@export("...") on a module var or valan annotation — the C symbol the storage is published underthe 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 eighteen, 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, and so are the four hooks a module’s tests may declare — @setup, @teardown, @setup_all and @teardown_all — the three that tell the optimizer what a definition is for, @noinline, @inline and @cold, and @disjoint(...), which tells it which parameters never overlap; @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; @no_alloc, the one header word a function may also carry, holds that one definition to making no heap storage and is below; and @abandons says a call to the function never comes back while the program goes on, so the caller’s frame is given back first — it marks an extern too, and is on the declarations page beside become.

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 on a module var or val publishes the storage under a C symbol; both are on the FFI page with the other half of themselves.

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, except @no_alloc above a function or a method, which holds that one definition instead (below). 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, five: the ones that are about a PARAMETER. @crossing, @borrows, @lends, @reads and @writes each name parameters, and a method, a property or an associated function has parameters exactly as a free function does — so there was never anything for a blanket refusal to be about in their case:

struct Chan
    open: bool

    @crossing(v)
    send(*self, v: *int) -> int = v[0]

var c = Chan(true)
var n = 7

print(c.send(&n))
7

Beside them a member takes the six about the function it lowers to — @noinline, @inline, @cold, @no_alloc, @no_block and @blocks — and @relocates, which says a call may move the storage its receiver’s elements live in, so a live ref to one of them is refused the call (memory), and @views(self), which says the view a call answers is borrowed from its receiver, so the view may not outlive it (memory), and @consumes, which says a call takes everything its receiver holds, so a local it was called on may not be read again until it is assigned (below). Everything else is refused, and the line is what the annotation is about rather than where it is written. “A function” in the list above means a free function: what sysl test calls, what recurses, what a symbol names, how a type is laid out — none of which a member supplies.

struct Counter
    n: int

    @test
    is_zero(self) -> bool = self.n == 0
the only annotations a member may carry are the ones about a parameter

So what sysl test runs is a free function that calls the member. @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.

They are refused above a field and above a variant, by a sentence of their own: each names parameters, and neither a field nor a variant has any.

struct Point
    @crossing(x)
    x: int
are about a parameter

#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. The slab package’s allocator calls its central function alloc and threads its free list through a function 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.

Four 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 declarationonly on a value no variant has
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, and on a value no variant has
T::Image(v)the variant’s name, as a stringonly on a value no variant has
T::Value(s)the value a name stands foron a name no variant has

A value no variant has cannot be built — E(n) checks, and so does reading a bitfield’s enum field — so it arrives only through storage a ptr_cast or C filled. Pos, Succ, Pred and Image stop on one rather than answer for a variant it is not.

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.

A derive body’s T is not such a parameter. Its body is read once for each type that names the trait in its derives, with T that type and never standing for itself, so it answers every attribute the type does — T::Image(self) in an arm under if const T::Kind == Kind.SimpleEnum (reflection).

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.

A fixed-point type

A fixed-point type answers Min, Max, Small — one unit, 2^-F — and Bits(n), which makes a value from the integer it is stored as, and nothing else:

print(q31::Min, q31::Max, q15::Bits(16384))
-1 0.9999999995 0.5

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'

Reflection queries answer on every type

The sets above belong to one kind of type each. A second family answers on any type, because it asks what the type is made of rather than what a value of it may do, and it is answered while compiling:

writtenanswers
T::Kinda Kind variant — Struct, DataEnum, Int, Opaque…
T::Name, T::BaseNamethe type as written, with its arguments / the declared name alone
T::Fields, T::Membersthe named fields / every stored member of a struct, tuple or union
T::Variantsthe variants of an enum, simple or data
T::Make(c, p)the variant c of a data enum, built from a payload
T::Annotation[A], f::Annotation[A]an Option of the program’s own @annotation on a type or field

A data enum, which has no First or Pos, is walked through T::Variants instead. The descriptors, for const f in T::Fields, v.(f), f::Type and the rest are on reflection.

@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 — the built-in annotations are a closed list. A program’s own are declared with @annotation and read by reflection.

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.

Six 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
@test(timeout: 30)the test may run for 30 seconds, whatever the run’s own limit
@test(ignore: "why")the test is compiled and checked, and not run

They compose, in the order the table gives them — @test("a sentence", should_trap: "past the end", timeout: 30, ignore: "why") is one annotation.

Six, and @test() is not a seventh. An empty argument list would mean what bare @test means, and it is refused rather than accepted as a synonym: it is not a shorter way of saying nothing, it reads as a description that was going to be there and got lost, and a reader who saw it accepted could not tell which.

@test()
subtracts()
    assert(3 - 1 == 2, "two")
'@test' takes a description or nothing at all, and '()' is neither — drop the parentheses, and the function's own name becomes its description

Everything after the ( is answered by the annotation, so a description that is not a string and an argument list left open are told what @test wanted rather than being blamed on the function below.

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 bounds check says what failed before it traps — @test(should_trap: "out of bounds for length 3") asks for exactly that — while every other compiler-inserted check raises a signal and says nothing, so it satisfies should_trap and can satisfy no string — see what stopping looks like.

A test may be written and not run

A test for a defect that is not fixed yet is still worth writing — with the assertion that should hold, rather than one bent to match what the code does today. ignore is how it lands: the function is compiled and checked exactly like every other test, and sysl test reports it without starting it.

parse_arms(n: int) -> int = n - 1

@test(ignore: "the parser drops the second arm")
keeps_both_arms()
    assert(parse_arms(2) == 2, "both arms")

print(parse_arms(2))
1

Checked, not run, is the whole point of the form. An ignored test is held to everything a test is — no parameters, no result, a body that compiles — so it cannot quietly stop being valid code while it waits, which is what a test that was commented out or deleted does. It composes with the rest of the annotation: @test("both arms are kept", should_trap: "past the end", ignore: "…") is a trap test that is waiting, and it is reported as waiting rather than as a run that returned.

The reason is required. An ignored test that does not say what it is waiting for is the one nobody comes back to, so bare ignore — or a reason that is empty — is refused:

@test(ignore)
keeps_both_arms()
    assert(true, "both arms")
'ignore' needs the reason the test is not run, written as a string — '@test(ignore: "the parser drops the second arm")'

Ignoring is something a test is, so there is no @ignore of its own; written above a function it is answered with the spelling above.

A test may be given a limit

Every test sysl test runs has a time limit — the run’s, 300 seconds unless --timeout says otherwise — and a test that outlasts it fails, reading timed out after Ns, rather than holding up every test after it. timeout gives one test a limit of its own, in whole seconds, in place of the run’s: longer for a test that is slow on purpose, shorter for one whose whole point is that it finishes quickly, and 0 for none at all.

import sysl.time.{sleep, millis}

@test(timeout: 2)
answers_promptly()
    sleep(millis(10))

@test("a soak that is meant to take a while", timeout: 0)
soaks()
    sleep(millis(10))

print("built")
built

The test’s own limit beats the run’s in both directions, --timeout 0 included: a run that asked for no limit still stops a test that said timeout: 5, because the test is the one that knows. The limit is a whole number of seconds from 0 to 2147483, the most milliseconds an int counts, and anything else is refused where it is written:

@test(timeout: 1.5)
answers_promptly()
    assert(true, "prompt")
'timeout' needs the seconds the test may run, written as a whole number from 0 to 2147483 — '@test(timeout: 30)'; 0 is no limit at all

A test may find out that it cannot run

ignore is decided before the test starts. Some tests can only find out once they are running: a test that drives a tool which is not installed, or talks to a device that is not plugged in. Such a test calls skip, from sysl.testing, and the runner reports it as skip with the reason it gave:

import sysl.env.get
import sysl.testing.skip

needs_tool(name: string)
    if get(name).is_none() then skip(s"${name} is not set, so there is nothing to talk to")

@test
talks_to_the_board()
    needs_tool("BOARD_PORT")
    assert(false, "not reached on a machine without a board")
running 1 test

board.sysl
  skip  talks_to_the_board  skipped: BOARD_PORT is not set, so there is nothing to talk to

0 passed, 0 failed, 1 ignored — 2ms

A test that printed a note and returned would have been reported ok: a pass that checked nothing. skip makes the run say so on the line a reader looks at last.

A test may call the compiler that is running it

sysl test and sysl run start the program with SYSL in its environment: the absolute path of the compiler doing the running. It is always set, replacing a SYSL the environment already held, because one left by an outer run names the outer compiler. sysl.testing.compiler() reads it and answers "sysl" where it is absent (a built program run by hand), so a test that builds a fixture or asks for --version runs the compiler under test and not whichever sysl the search path finds:

import sysl.process.capture
import sysl.testing.compiler

@test
asks_the_running_compiler_its_version()
    val answer = capture(compiler(), ["--version"]).unwrap()
    assert(answer.status.ok(), "the compiler answers --version")
  • skip is imported, not ambient. It lives in sysl.testing rather than the root module, so a test file that also imports sysl.harness.* — which has a skip of its own, for the on-target runner — is not told the two collide.
  • skip ends the test at once. It returns never, so nothing after it runs, and a helper the test calls may make the call on the test’s behalf, as needs_tool does above.
  • It is not a pass and not a failure. The summary counts it with the ignored tests, it does not change the exit status, and --fail-fast does not stop at it. The row’s word says which kind it was: ignored is the annotation’s, skipped is the test’s own.
  • A failure before it is still a failure. An assert that failed earlier ended the process before skip was reached, so the run is reported as that failure.
  • The test’s @teardown still runs, in a process of its own, as it does after a test that trapped. A teardown that fails turns the row into that failure. A @setup that calls skip skips the test it was setting up.
  • A should_trap test that skips is reported as skipped, since it never reached the thing it was meant to trap on.

How it works: skip prints one control byte, the reason up to the end of its line, and then ends the process with status 0. The runner looks for that byte in what the run printed. Because the exit is clean, a sysl test older than skip reads the test as one that returned and reports ok, which is what such a test was before skip existed, rather than reporting a failure.

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

Every build but sysl test drops the tests after the parse and before analysis — sysl run, sysl build, sysl build-c, sysl emit-llvm and sysl build-lib alike, for a program’s own files exactly as for a library’s and a dependency’s. What goes is every @tests file whole, and every @test function and hook wherever it was written. A syntax error in a test still stops the build, since every file is parsed first; everything after the parse — names, imports, types, capabilities — is asked of a test by sysl test alone, which is the one command that runs it.

So a test’s imports are never read by the program that ships. A @tests file may import a dev dependency, which only sysl test fetches, and the program still builds:

main()
    print("built")

@test
checks_nothing_yet()
    val unused: NoSuchType = 0
built

The @test above names a type that does not exist, and sysl build says nothing about it: a broken test is reported by sysl test, the build that would run it. The module’s capability rules reach its tests there like they reach any other member — with the one difference that a @tests file states its own, since that file is what every other build drops.

This is what lets a test sit beside what it tests: a program’s tests do not run when it runs, and nothing they need is asked of it.

An ordinary helper only a test calls leaves too, because with the test gone it is 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 three definitions nothing in the program names: an interrupt handler, an @export, and a @section definition (below). Each is entered from somewhere no walk over the program can see — the processor, a caller outside this compilation, and a linker script gathering a named section — 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 three 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.

A freestanding image is the one place an @export is not a root. The compiler still emits it, but the image is linked from its entry, a function at a time, and the linker drops every function nothing the entry reaches calls — an export nobody calls included, with the thread-local storage and the rest of what only it touched. An archive keeps every export, since what links it decides. An export the image must keep though nothing in it calls it — a routine a debugger or a boot ROM jumps to by name — is given a @section (below): a placed definition is marked for the linker as well as the optimizer, and stays.

A destructor is entered from somewhere no walk sees as well — the release hook generated from a payload type — but it is kept by its type rather than as a root: wherever anything kept holds a value of the type, in a test build exactly as in a program, the destructor its release calls is kept with it.

That matters most to a package, whose only build is its own suite. A test build dropping an export would link, pass, and quietly go without the surface the package exists to provide.

Why the drop comes before analysis. 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 an analyzed program would drop the test and keep everything it caused — shipping, in an artifact, instantiations no caller of the library ever asked for. And a test’s imports would be resolved against a build that never fetched what they name.

The line falls between parsing and analysis. Every source is parsed before the drop, so a syntax error in a test still stops a build or a build-lib. What a test no longer gets there 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 test that is well-formed text and wrong in every other way builds clean, and its real errors are reported by sysl test (sysl test --std for the standard library’s), which is where tests are run. The refusals this page shows for tests are sysl test‘s. That includes an ordinary function calling a @test or naming what a @tests file declares: sysl test says why, and any other build reports a name that is not there — since, in that build, it is not.

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, and two exports claiming one symbol. 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.

The one rule on that page a test build is not held to is module storage, and for a reason that is about the build rather than about the tree: a test binary has an entry point of its own, so it fills its own storage before the first test runs. That refusal belongs to the artifact a C project links on a freestanding target, which is the one build with nowhere to fill it.

@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 sits in one only for a type that same file declares. An impl 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, an impl for a type the program has would mean a trait answering one way while the tests ran and another way in the program that ships, so it belongs beside the type. A type the @tests file declares itself has no such second answer — no other build has the type at all — so its impl may sit beside it, and that is how a test gets a stand-in to hand to code written against a trait:

@tests

trait Area
    area(self) -> int

    described(self) -> string = s"area ${self.area()}"

measured(s: &Area) -> int = s.area()

struct Square
    side: int

impl Area for Square
    area(self) -> int = self.side * self.side

@test("a stand-in answers through the trait")
a_square_measures()
    val s: &Area = Square(3)

    assert_eq(measured(s), 9)
    assert_eq(s.described(), "area 9")

Where the trait is declared does not enter into it: a trait of the module and a trait of the test file are equally fine. What is still refused is an impl for any type the file did not declare — one of the module’s, a builtin, a blanket impl[T] … for T, and a type another @tests file declares, since which files a build keeps is decided file by file:

@tests

trait Area
    area(self) -> int

impl Area for int
    area(self) -> int = self
error: an 'impl' block in a file that said '@tests' has to be for a type that file declares, and 'int' is not declared in it — a test build would keep this block and every other build would drop it, so a trait would answer one way while the tests ran and another way in the program that ships. Write the block beside the type's own declaration, or declare a type for the tests in this file

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.

Whatever the file writes goes with it, so whatever it writes may name its scaffolding. A method of a type the file declares, written inline or in that type’s impl, is as much the file’s as a free function is, and may call the file’s helpers:

@tests

helper() -> int = 41

struct Probe
    n: int

    bumped(self) -> int = helper() + self.n

impl Probe
    twice(self) -> int = helper() + helper() + self.n

@test("a stand-in's methods call the file's helper")
probe_bumps() =
    assert_eq(Probe(1).bumped(), 42)
    assert_eq(Probe(0).twice(), 82)

A @tests file of declarations and bindings is never where a program starts, even a root file with no module header: no build but sysl test keeps it, so it cannot be the body a script runs. Its vars are the module’s storage, and a test reading one is a test like any other:

@tests

var shared: int = 11

@test("a test reads the file's storage")
reads_it() =
    shared += 1
    assert_eq(shared, 12)

Every test runs or is refused by name. A @test or a hook written in the file a script starts in, reading one of that script’s bindings, would be nested into the script’s body where the runner cannot reach it — so it is refused, and marking the binding static makes it the module’s:

var n = 1
print(n)

@test("reads")
reads() =
    assert_eq(n, 1)
error: 'reads' is a '@test' that reads a binding of the body of the file this program starts in, so it would be nested inside that body where the test runner cannot reach it — mark what it reads 'static', which makes it the module's

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.

A build that depends on a package does not read that package’s @tests files at all — not analyzed, not even parsed, the header being enough to know one. One written for a newer compiler, or against a dev dependency the consumer never fetches, stops nothing but that package’s own sysl test.

The runner

sysl test <path>
sysl test <path> --filter <text>
sysl test <path> --fail-fast
sysl test <path> --jobs <n>
sysl test <path> --timeout <seconds>

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 tests are the named tree’s own. A dependency’s modules are compiled in, since the tree calls them, and its @test functions are not — a package’s tests are run in the package’s repository, by sysl test there.

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. It is also what lets the tests run several at once — as many as the machine has processors, or --jobs <n> — with nothing shared between them that a run of one at a time did not share; the report waits for each row’s turn, so it reads the same either way (the CLI).

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.

A process that does not come back in time is stopped, and the run goes on. Each test’s process is started in a process group of its own and given its limit (timeout, or the run’s --timeout, 300 seconds by default); past it, the process and everything it started are ended, the row reads timed out after Ns with what the test had printed underneath — a test’s output is written a line at a time, so a stopped test still has it — and the next test starts. In the other direction, a test process watches the runner that started it and ends itself and its group once the runner is gone, so a run stopped by hand, or killed outright, leaves nothing running behind it.

An ignored test is reported, never left out. It starts no process — not its own, and not its module’s @setup or @teardown — and its row says why in place of a time. The summary counts it, so a run that ignored something says so on the line a reader looks at last:

running 2 tests

parser.sysl
  ok    holds            1ms
  skip  keeps_both_arms  ignored: the parser drops the second arm

1 passed, 0 failed, 1 ignored — 1ms

A test that skipped itself gets the same skip row, reading skipped: where an ignored test’s reads ignored:, and the summary counts it in the same figure.

Where nothing was ignored the summary is N passed, M failed exactly as it always was. A filter selects an ignored test like any other and reports it as ignored, and an ignored test fails nothing: it does not change the exit status, and --fail-fast does not stop at it.

The hooks a module may write

Four more annotations, and none of them names a test — each names a function sysl test calls around the ones it does. @setup runs before every @test in the module and @teardown after it; @setup_all runs once, before the module’s first test, and @teardown_all once, after its last. A module may declare at most one of each. There is no way to nest them and no way for one test to opt out of a hook the rest of the module has.

@setup
opens_the_log()
    print("setup")

@setup
also_opens_it()
    print("setup, again")
a module writes at most one '@setup', and this one already has one — 'opens_the_log'

A second @setup in one module is refused, and the message names both the function already declared and the one that repeats it — the same shape as two @tests sharing a name, except that here there is only ever room for one.

The process per test, which “The runner” above already made the mechanism rather than a cost, is what shapes everything about these four. sysl test starts one process per test, so a hook that runs inside that process sees what the process saw, and a hook that runs in a process of its own sees nothing the test process held:

  • @setup runs in the test’s own process, immediately before the test. A module-level val it sets is filled by the time the test reads it, the same as any other statement that ran first would leave it.
  • @teardown runs wherever the test left off: in the test’s own process if the test returned, or in a process of its own if the test trapped — a trapped process is gone, so nothing after it can run inside it. What a @teardown can rely on releasing is therefore only what outlives a process — a file, a pid, a socket path — never a variable, because half the time there is no process left holding one.
  • @setup_all and @teardown_all are each their own process invocation, run once, and share nothing with the tests they bracket except what outlives a process — the same kind of file, pid or path a @teardown is left with.

A @setup fault fails the tests it guards without running them, and names the setup function rather than an assertion a test never reached; a @setup_all fault reaches every test in the module, so none of them run. A @teardown or @teardown_all fault is a counted failure of its own, alongside whatever verdict the test it followed already reported.

Worked example: a module whose tests share a fixture across the process boundary. @setup_all writes the fixture once, to a file; each test reads it back; @teardown_all removes it. The file is the whole of what crosses a process boundary — which is the reason a @setup_all exists at all, rather than a @setup repeating the write once per test:

import sysl.fs.*

writes_the_fixture()
    write_text("fixture.txt", "ready").unwrap()

removes_the_fixture()
    remove_file("fixture.txt").unwrap()

@setup_all
starts_the_fixture()
    writes_the_fixture()

@teardown_all
stops_the_fixture()
    removes_the_fixture()

@test
reads_the_fixture()
    assert(read_text("fixture.txt").unwrap() == "ready", "fixture ready")

writes_the_fixture()
print(read_text("fixture.txt").unwrap())
removes_the_fixture()
ready

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.

trap — the instruction without the message

trap() is the other half: it stops the program at once with the target’s trap instruction and says nothing. It answers never, so a function may end in it with no value to return; it needs no capability, allocates nothing and is legal under @no_alloc and on every freestanding target. Reach for it where a message cannot be written — a bare-metal abort, a fault handler — and for assert or panic everywhere a reader would want to be told what broke.

@no_alloc
halt() -> never = trap()

pick(n: int) -> int
    if n > 0 then return n
    halt()

print(pick(4))
4

Like a compiler-inserted check it runs no defer and flushes nothing, and a should_trap test passes by it.

@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, and call any function whose body can run while compiling (declarations.md § Calls where a type is read):

sq(n: int) -> int = n * n

@assert(sq(3) == 9, "the square of three")

print(sq(3))
9

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.

The message is any string the compiler can work out while compiling, not only a literal: an interpolation over constants, a call whose body runs while compiling, and — inside a generic or a derive body — what a type answers, T::Name or a field’s f.name:

const WIDTH: int = 3

@assert(WIDTH % 2 == 0, s"a width of $WIDTH is not even")

print(WIDTH)
assertion failed: a width of 3 is not even

It is worked out only once the condition has come out false, so a message that could not be computed costs nothing while the condition holds:

const tenth(n: int) -> string = s"${10 / n}"

@assert(1 == 1, tenth(0))

print("the message was never worked out")
the message was never worked out

Where the condition is false and the message cannot be worked out — it traps, reads storage filled at run time, or is not a string — that is what is reported, in words that say the assertion failed:

const tenth(n: int) -> string = s"${10 / n}"

@assert(1 == 2, tenth(0))

print(1)
the message of this failed '@assert' traps while it is being compiled: integer division by zero
@assert(1 == 2, 42)

print(1)
the message of this failed '@assert' is of type 'int', and a message is a 'string'

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 that reaches run-time storage, a var for one, is the line, since only a function holding what exists while compiling can run then:

var seed: int = 3

f() -> int = seed

@assert(f() == 3)

print(1)
has to be a constant expression

In a body, per instance

@assert may stand in a body as well, and there it is checked for each instance of a generic and in each unrolled copy of a for const — against the types and values that instance was given, which is the only moment sizeof(T) is a number. Inside an if const arm it is checked only where that arm is taken, which makes it a compile-time error that a generic raises on purpose:

ring[T, const N: usize]() -> usize
    if const N > 64
        @assert(sizeof(T) * N <= 4096, "a ring this large belongs on the heap")
    N

print(ring[u8, 100](), ring[u64, 10]())
print(ring[u64, 1000]())
assertion failed: a ring this large belongs on the heap — the left side is 8000 — where N = 1000

The first line compiles: u8 times 100 is 100 bytes, and ring[u64, 10] never takes the arm.

The message is worked out in the instance’s terms too, so it can name the type that was given:

struct Big
    a: i64
    b: i64

fits[T]() -> int
    @assert(sizeof(T) <= 8, s"'${T::Name}' is wider than a register")
    1

print(fits[i32]() + fits[Big]())
assertion failed: 'Big' is wider than a register — the left side is 16 — where T = Big

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

@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

@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. The slab package was written that way before this landed 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.

@no_alloc on a function — one entry point that makes no heap storage

In a file’s header @no_alloc gives the allocator up for the whole module (modules). Above one function or method it gives it up for that definition alone, checked by the same two rules through every call the body makes: what the body itself makes is refused at the outermost allocation, and a call that reaches a function that allocates is refused at the smallest piece of the body that still arrives there. It is the form for a module that has one path that must not allocate beside others that must — an interrupt entry beside the system calls that share its file:

boxed(n: int) -> &int = n

@no_alloc
on_irq(n: int) -> int = n + 1

print(on_irq(1), *boxed(2))
2 2

boxed allocates and is not refused: the promise is on_irq‘s, not the module’s. When the promised function reaches an allocation the refusal names the function that made the promise, what the far end makes, and the calls between the two — the far end is somebody else’s code, and the line to change is as often in the middle of the chain as at either end:

boxed(n: int) -> &int = n

middle(n: int) -> int = *boxed(n)

@no_alloc
on_irq(n: int) -> int = middle(n) + 1

print(on_irq(3))
which makes a reference, by the call path 'on_irq' → 'middle' → 'boxed', and 'on_irq' declared '@no_alloc'

What the body makes on its own line is refused the same way the module’s clause refuses it, naming the function instead of the module:

boxed(n: int) -> &int = n

@no_alloc
shown(n: int) -> usize = str(n).len

print(shown(42))
the string a value renders as needs an allocator, and 'shown' declared '@no_alloc'

Directly under the header the word is still the module’s clause. The header is read first, and blank lines do not end it, so a file whose first declaration is the promised function has the module’s clause instead; an import or another declaration above the function is what makes it the function’s. A function in a module that already declared @no_alloc is held to the module’s clause, which says everything the annotation would. The module’s clause binds its own files and never its @tests files (a @tests file states its own capabilities): a test may use str, f-strings and Buf, while the module’s functions stay allocator-free when a test calls them.

A method may carry it — it lowers to a function like any other and is held the same way. A function declared inside another may not: it is a closure by another spelling, and what it captures is boxed by the function around it, outside the body the promise would be written on.

outer(n: int) -> int
    @no_alloc
    inner(k: int) -> int = k + n

    inner(1)

print(outer(3))
'@no_alloc' holds a top-level function or a method, and 'inner' is declared inside another function

Above anything that is not a function — a struct, a binding, an extern — it is the module’s word written in the wrong place, and the refusal says so before naming the other reading:

f() -> int = 1

@no_alloc
struct P
    x: int

print(f())
Above a function or a method, '@no_alloc' holds that one definition instead, and this declares neither

A generic keeps the promise twice: as it was written, and at every instance. The body it was written as is held first, and what it makes whatever the type — the str(n) above — is refused once, naming the declaration, however many instances the program makes. A method of a generic struct and a method of a generic impl are held the same way:

f() -> int = 1

@no_alloc
shown[T](x: T) -> usize = str(42).len

print(shown(1), shown(true))
the string a value renders as needs an allocator, and 'shown' declared '@no_alloc'

What depends on the type is only there to see once a type is chosen: a call through a bound reaches a different implementation at each instance. Each instance is held to the promise as well, and the refusal names the instance that breaks it; the instances that keep it are not mentioned:

trait Size
    size(self) -> usize

impl Size for int
    size(self) -> usize = 8

impl Size for bool
    size(self) -> usize = str(self).len

@no_alloc
measured[T: Size](x: T) -> usize = x.size()

print(measured(1), measured(true))
this reaches 'bool.size', which makes the string a value renders as, and 'measured' declared '@no_alloc' (instance 'measured.bool')

This is the function’s own promise, not the module’s: under a module’s @no_alloc clause a generic still answers only for the body it was written as (modules), since the type an instance is made at is chosen by somebody else’s code.

@no_block and @blocks — a path that must never wait

An interrupt handler may take a completion and wake a thread, and must never sleep: sleeping there switches away from an exception frame with the interrupt still unacknowledged. A real-time audio callback is held to the same rule by a deadline rather than by a machine. @no_block is that promise, and @blocks marks what breaks it. Above a function or a method, @no_block says the definition never waits; in a file’s header it says it of every body in the module. Either way it is checked as @no_alloc is, through every call the body makes: a call that reaches a declaration marked @blocks is refused at the smallest piece of the body that still arrives there.

struct WaitQueue
    n: int

    @blocks
    sleep(*self) = self.n += 1

read_sector(q: *WaitQueue) -> int
    q.sleep()
    1

@no_block
on_irq(q: *WaitQueue) -> int = read_sector(q) + 1

var q = WaitQueue(0)
print(on_irq(&q))
this reaches 'read_sector', which blocks through 'WaitQueue.sleep', and 'on_irq' declared '@no_block'

The refusal names the call the reader wrote and, after through, the declaration it arrives at — the line to change is the first, and the reason is the second. A body that keeps the promise compiles, and a sibling beside it may wait as much as it likes:

struct WaitQueue
    n: int

    @blocks
    sleep(*self) = self.n += 1

@no_block
take(n: int) -> int = n + 1

drain(q: *WaitQueue) -> int
    q.sleep()
    q.n

var q = WaitQueue(0)
print(take(1), drain(&q))
2 1

Whether something waits is declared, not inferred. A C function’s waiting is invisible to the compiler, and a sysl loop that spins until another thread moves cannot be told from a search, so @blocks is written where the waiting is: above a function, a method, or an extern whose C call waits.

@blocks
extern "usleep" c_usleep(us: u32) -> int

@no_block
nap() -> int = c_usleep(10)

print(nap())
this reaches 'c_usleep', which blocks, and 'nap' declared '@no_block'

The standard library marks its own waiting operations: sysl.posix.time‘s sleep and nanosleep, Mutex.with and Mutex.lock_raw, Thread.join, Channel.send and Channel.receive, sysl.net‘s Socket.connect, accept, send, send_all and recv and UdpSocket.send, send_to, recv and recv_from, Child.wait, and the hooks a blocking read or write of a descriptor, a file or a socket goes through — so a promised body reaches none of them, however many calls away. block_on, which drives a task to its end on the calling thread, has no declaration to mark and counts as blocking all the same. A generic is named as it was declared, since the type argument says nothing about whether it waits:

import sysl.threads.Mutex

f() -> int = 1

@no_block
guarded(m: *Mutex[int]) -> int = *m.lock_raw()

print(f())
this reaches 'sysl.threads.Mutex.lock_raw', which blocks, and 'guarded' declared '@no_block'

A call through a trait object reaches the implementation the body erased into it, as for @no_alloc. try_with, try_lock_raw, try_send and try_receive never wait and are not marked. A spin lock is not marked either: it waits only as long as another core holds it, which is what an interrupt handler’s own locks are made of.

A generic that promises is held as @no_alloc holds one: the body it was written as, and then each instance, so a call through a bound that one type answers by waiting is refused at that instance:

trait Poll
    poll(self) -> int

impl Poll for int
    poll(self) -> int = 0

impl Poll for bool
    @blocks
    poll(self) -> int = 1

@no_block
ready[T: Poll](x: T) -> int = x.poll()

print(ready(1), ready(true))
this reaches 'bool.poll', which blocks, and 'ready' declared '@no_block' (instance 'ready.bool')

The rules for where it stands are @no_alloc‘s. Directly under the header the word is the module’s clause, and a function there answers to it; a method may promise it; a function declared inside another may not, being a closure. @blocks takes no arguments and marks a function, a method or an extern function and nothing else — an extern variable is read rather than called:

f() -> int = 1

@blocks
struct P
    x: int

print(f())
'@blocks' says a call may wait, so it marks a function, a method or an 'extern' — and this declares none of them

The two above one definition contradict each other:

f() -> int = 1

@blocks
@no_block
g() -> int = 2

print(g())
'@blocks' says this definition may wait and '@no_block' that it never does

Waiting is conduct, not a facility. No machine takes it away, so @no_block has no requires beside it the way @no_alloc has requires heap, and @requires(block) and @needs(block) are refused:

@needs(block)
f() -> int = 1

print(f())
'block' is what a module does, not something a machine has, and no machine takes it away

@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 three 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.

@consumes — a call that takes everything its receiver holds

Some members hand back what their receiver held and leave the receiver empty: Buf.freeze() moves a buffer’s elements into the frozen buffer it answers (memory). A read of that receiver afterwards can only find nothing, so @consumes above such a member makes the read a refusal: once a local has been called on, it may not be read again on any path until it is assigned a new value.

import sysl.buf.{Buf, buf}

run()
    var b: Buf[int] = buf()

    b.push(1)
    b.push(2)

    val f = b.freeze()

    print(f.len(), b.len())

run()
'b' is read here after 'b.freeze()' took everything it held — 'freeze' is '@consumes', so 'b' has nothing in it on this path until it is assigned again

Assigning it ends it, and so does binding the name afresh — a var inside a loop body is a new local every turn:

import sysl.buf.{Buf, buf}

run()
    var b: Buf[int] = buf()

    b.push(1)
    b.push(2)

    val f = b.freeze()

    b = buf()
    b.push(7)
    print(f.len(), b.len(), b[0])

    for i in 0..<2
        var c: Buf[int] = buf()

        c.push(i)
        print(c.freeze()[0])

run()
2 1 7
0
1

The check follows every path the body can take. A branch that consumed on one side leaves the local consumed after the two sides meet, and a loop is followed round, so a read on a later turn counts — here the second turn’s push:

import sysl.buf.{Buf, buf}

run()
    var b: Buf[int] = buf()

    for i in 0..<3
        b.push(i)

        if i == 1 then print(b.freeze().len())

run()
'b' is read here after 'b.freeze()' took everything it held

A branch that consumed and then left — a return, a break past the read — never reaches it, and is not refused.

Only a local is followed. A field, an element, or what a pointer or a second name reaches may be reached another way the body cannot see, so a call on one is accepted, and the member’s own emptying at run time is what a later read finds:

import sysl.buf.{Buf, buf}

struct Holder
    b: Buf[int]

val h: &Holder = Holder(buf())

h.b.push(1)

val f = h.b.freeze()

print(f.len(), h.b.len())
1 0

A call through a trait object is not followed either: which body a slot dispatches to is settled at run time, so a trait member’s @consumes says nothing about the object’s own local. A method has no second spelling the check could miss — Buf.freeze(b) is refused as every instance method named through its type is.

It marks a member that takes a receiver, in any mode, and takes no arguments. Above a free function, or a member that takes no self, it is refused:

struct S
    v: int

    @consumes
    make() -> S = S(1)
'@consumes' says a call to 'make' takes everything its receiver holds, and it has none — it takes no 'self'

@noinline, @inline and @cold — what the optimizer is told about a definition

A private function is visible only inside the file that wrote it, so the optimizer knows every call it has. With one call site it folds the body back into the caller and deletes it — which is usually what you want, and is exactly wrong for a slow path. A buffer’s grow is called from push‘s cold arm and from extend, and is almost never reached; inlined into either caller it would make that caller large enough that it stops being inlined into its own callers. Splitting the rare path out bought nothing.

@noinline says the call stays a call:

@noinline
grow(n: int) -> int = n * 2

print(grow(21))
42

@inline says the opposite, and it is the one a library reaches for more often. A member written to be absorbed by its caller — a sequence’s append, an accessor that checks one thing — sits a few units either side of the budget the optimizer is willing to spend, and which side is decided by things the member has no say in: how wide the element type is, how many counted fields it carries. @inline raises that budget for this definition, so the decision stops moving when a struct one field along grows:

@inline
push(n: int) -> int = n + 21

print(push(21))
42

Bare, it is a hint, not an instruction. The inliner is told to spend more and then decides as it decides, and a definition that is enormous is still a definition. That is the right default — an instruction would make a library’s opinion about one member override a caller’s whole code size.

@inline(always) is the instruction. Every call is replaced by the body, at every optimization level — -O0 included, where nothing else is inlined at all — so it is for the definition whose call is known to cost more than its body: a decoder’s per-symbol step, a fixed-point multiply, a register accessor on a board built for debugging:

@inline(always)
symbol(bits: int) -> int = bits & 0xff

print(symbol(298))
42

It lowers to LLVM’s alwaysinline in the place @inline‘s inlinehint takes, and a member, a generic — every instantiation — and an @exported function may carry it like the bare word; the exported symbol is still defined for C to call. A function that calls itself is not inlined, whatever it is marked — LLVM leaves such a body out of line, as clang and rustc do with theirs, and the program is still right. @tailrec turns the self-call into a jump, and a body that no longer calls itself is absorbed like any other:

@tailrec
@inline(always)
fact(n: int, acc: int) -> int = if n <= 1 then acc else fact(n - 1, acc * n)

print(fact(5, 1))
120

@cold says the definition is reached rarely. The optimizer spends nothing on it and keeps its blocks away from the hot ones — but it is a frequency, not a prohibition, and a cold function may still be inlined:

@cold
fail(n: int) -> int = n - 1

print(fail(43))
42

@noinline and @inline are one axis; @cold is another. The first two contradict, and the compiler refuses them together. @cold composes with either: a rare slow path wants @noinline and @cold — kept out of line, and known to be rare — and a rarely reached helper written to be absorbed wants @inline and @cold.

@noinline
@cold
grow(n: int) -> int = n * 2

print(grow(21))
42
@inline
@cold
report(n: int) -> int = n + 21

print(report(21))
42

What @inline guarantees across packages and codegen units

A program is not always one module. A package — a dependency, or a --lib root — is compiled once into an archive of its own that every program using it links, and a large program is cut into codegen units compiled side by side. A call from one module into another is ordinarily a wall the optimizer cannot see through. At every optimization level but -O0, an optimized build carries the body across that wall for:

  • every function marked @inline or @inline(always), whatever its length, and
  • every small function — a few statements’ worth: an accessor, a comparison, a fixed-point multiply — whether marked or not.

The calling module is given a copy of the body to inline (LLVM’s available_externally) and never emits it, so the program still links the package’s one definition, and a call the optimizer leaves out of line is the same call it always was. The copy is made from the package’s source on every build, so an edit to the package is what the next build inlines; nothing is kept that could be stale.

A package’s own archive is a calling module too. Its calls into the standard module, or into another package, carry the same copies — including a call a generic reaches through a bound. A package whose is_nonzero[T: Zero + Eq](x: T) = x != T.zero() is instantiated at real by the package’s own code compiles real.zero() to the constant 0.0 in its archive, as it would in the program, rather than calling the standard module’s real.zero out of line.

So the guarantee is the same as within one file: a call into a package, or into another unit, inlines exactly where the same call would inline if both functions sat side by side — the hint is still a hint, and the inliner still decides. What is never carried across:

  • a function marked @noinline, or @cold without @inline — the call stays a call;
  • an async function, an interrupt handler, one placed in a @section, or a variadic one;
  • a function that reaches module storage filled at run time, or @thread_local, @per_cpu or @main_thread storage, or another package’s storage, which is compiled by the program anyway (the package keeps no copy of it), and so needs nothing carried. Storage that is a constant image is not that: the package’s own, and the standard module’s, is laid down once in its own archive, and every other archive and the program name that one object — one address, one value — so a function reaching it, one that indexes a Buf or reads a lookup table say, is compiled into the package’s archive like any other;
  • a generic, which needs none of this: it is instantiated in the module that uses it, beside its callers.

sysl emit-llvm prints the program as one module and carries no copies.

They mark a member too

A method lowers to an ordinary function, and these three are about that function — so a generic container writes the word once and every element type it is used at gets it:

struct Buf[T]
    cap: int

    @noinline
    @cold
    grow(self) -> int = self.cap * 2

    @inline
    room(self) -> int = self.cap + 21

var a: Buf[int] = Buf(21)
print(a.grow())
print(a.room())
42
42

That is what sysl.buf does with its own push: the member is written so a caller can absorb the whole of it, and @inline is what keeps the element type from deciding whether it does.

They are the only three annotations a member may carry that are not about a parameter.

None of the three takes an argument but always

Whether a call stays a call is the whole of what the first says, @inline raises a budget rather than naming one, and how rare a rare path is is not a number a program has. always is the one argument there is:

@noinline(2)
grow(n: int) -> int = n * 2

print(grow(21))
'@noinline' takes no arguments
@inline(200)
grow(n: int) -> int = n * 2

print(grow(21))
'@inline' takes no argument but 'always'

The opposite request is a word of its own, so never is pointed at it:

@inline(never)
grow(n: int) -> int = n * 2

print(grow(21))
'@inline(never)' is written '@noinline'

What they may not mark

They mark a function. A struct has no definition to keep out of line, a const is folded into every use, and an extern names something this program does not define — a declare line carries no function attributes at all.

@noinline
struct S
    a: int

print(1)
an annotation marks a function

@inline(always) above an extern is the case most often tried, and it says why — the body is in the object the extern is linked from, and only that object’s own compiler can put it anywhere:

@inline(always)
extern abs(n: i32) -> i32

print(1)
An 'extern' has no body in this program to put anywhere

@ghost is the one other annotation they contradict rather than merely have nothing to do with. A ghost function is erased before anything is emitted, so there is no definition left for any of them to be about:

@ghost
@noinline
sorted(n: int) -> bool = n > 0

print(1)
'@ghost' means there is none

@noinline beside @inline

One forbids inlining and the other asks for it, so a definition carrying both is held to neither. This is the one refusal in the group that is not about a missing definition:

@noinline
@inline
grow(n: int) -> int = n * 2

print(grow(21))
they contradict above one declaration

@inline(always) beside @noinline is refused the same way, and beside a bare @inline it says one thing twice — the instruction already asks for everything the hint does:

@inline
@inline(always)
grow(n: int) -> int = n * 2

print(grow(21))
the two together say one thing twice

If what you meant was keep it out of the hot path, that is @cold, and it stands beside either of them.

They stand beside everything else. @tailrec is about this function’s call to itself becoming a jump and @noinline about the call into it from elsewhere staying a call — two different calls, so there is nothing for them to disagree about; and @test only says who calls the function.

@disjoint — parameters whose memory nothing else reaches

A kernel that writes through one view and reads through another is the loop a vectorizer exists for, and the one it is least sure of. axpy below adds a * x[i] into y[i]; if y and x could be two views of one array, offset by an element, then a store to y[i] changes what a later read of x sees, and doing four elements at once would get the wrong answer. The optimizer cannot tell from the function, so it vectorizes the loop and keeps a scalar copy, choosing between them at run time by comparing the two address ranges on every call.

@disjoint(y, x) is the function saying the comparison is never needed:

@disjoint(y, x)
axpy(y: []f64, x: []const f64, a: f64)
    for i in 0..<y.len
        y[i] = y[i] + a * x[i]

var ys: [4]f64 = [1.0, 1.0, 1.0, 1.0]
val xs: [4]f64 = [1.0, 2.0, 3.0, 4.0]

axpy(ys[0..<4], xs[0..<4], 2.0)
print(ys[3])
9

What it promises is C’s restrict. While a call runs, no memory reached through one named parameter is reached through another named parameter, or through any other pointer the body uses. The parameters are named on the function, as @lends(...) and @crossing(...) name theirs — a parameter carries no annotation of its own.

It names pointers and views: *T, &T, []T and []const T. A parameter of any other type arrives as a copy, which nothing else can reach, so naming one promises nothing and is refused:

@disjoint(n, p)
store(n: int, p: *int) = *p = n

var v = 0
store(42, &v)
print(v)
'@disjoint' names 'n', a parameter of 'store' whose type 'int' is a value

A generic is held to that at each instantiation, where its parameters have types — the views and pointers of every instance carry the promise. A name that is not a parameter, or one written twice, is refused where it is written, as @lends‘ are; so is the annotation above an async function, whose body runs after the call that made the task has returned, so that there is no call for the parameters to be kept apart during:

@disjoint(p)
async fill(p: *int) -> int = 1

print(1)
'@disjoint' marks 'fill', which is 'async'

A method names its other parameters, never self. What a named parameter reaches, nothing else the body uses reaches — the receiver included — so naming the slice a method copies from already keeps it apart from the storage the method writes into:

struct Ring
    bytes: [8]u8

impl Ring
    @disjoint(data)
    fill(*self, data: []const u8)
        for i in 0..<data.len
            self.bytes[i] = data[i]

var r = Ring([0; 8])
val src: [3]u8 = [7, 8, 9]

r.fill(src[..])
print(r.bytes[2])
9

Writing the receiver into the list is refused, and says so:

struct Ring
    bytes: [8]u8

impl Ring
    @disjoint(self, data)
    fill(*self, data: []const u8) = ()

print(1)
'@disjoint' names parameters, and 'self' is the receiver — leave it out

It is not @no_overlap: the @no_ words are the capabilities a module or a definition gives up — @no_alloc, @no_block — and every @no_<word> is read as one, so a promise about two parameters takes a word of its own.

Checked where a call shows it broken

A call is refused where the arguments it hands two named parameters are provably the same storage: the same local twice, two slices of one array or view whose constant bounds share an element, or a view and the address of one of its own elements.

@disjoint(y, x)
axpy(y: []f64, x: []const f64, a: f64)
    for i in 0..<y.len
        y[i] = y[i] + a * x[i]

var ys: [8]f64 = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]

axpy(ys[0..<4], ys[2..<6], 2.0)
'axpy' is '@disjoint' over 'y' and 'x', and this call hands them storage that overlaps — 'ys[0..<4]' and 'ys[2..<6]' overlap

Slices that only meet at a boundary are separate storage, and a call handing them over compiles:

@disjoint(y, x)
axpy(y: []f64, x: []const f64, a: f64)
    for i in 0..<y.len
        y[i] = y[i] + a * x[i]

var ys: [8]f64 = [1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 3.0, 4.0]

axpy(ys[0..<4], ys[4..<8], 2.0)
print(ys[3])
9

Where the call cannot settle it, the promise is trusted, as C trusts restrict. Two views a caller was itself handed, a bound or an index computed at run time, a pointer read out of a field — none of these is refused, and none is checked when the program runs. A call that breaks the promise anyway is undefined behaviour: the optimizer has been told the loads and stores through the two parameters never touch the same memory, and the code it makes is free to read a value before the store that should have changed it, or after.

What the back end is told

A pointer @disjoint names carries LLVM’s noalias on the function’s definition. A view is two pointers and a count in one value, and noalias attaches only to a pointer parameter, so a function naming a view is emitted as two: the function with the signature every caller already uses, taking each named view apart, and an internal, always-inlined body taking the parts, its element address noalias. Its callers, function pointers and exports are unchanged.

The body is inlined into the function at every optimization level, and LLVM’s inliner turns each noalias parameter into the scoped alias metadata it uses for an inlined restrict — the form that survives the function being inlined further, into a caller and a loop unrolled around it. axpy above, built at -O2, is a vector loop with no run-time comparison of the two ranges; the same function without the annotation keeps both copies of its loop and the comparison between them.

An @export of one is the same function behind a C entry point. An extern may not carry it — its body is C, which never reads the sysl declaration, so the promise is made on the C side, as restrict:

@disjoint(d, s)
extern "memcpy" c_memcpy(d: *u8, s: *u8, n: usize) -> *u8
an 'extern''s body is C, which never reads the sysl declaration, so the promise belongs on the C declaration as 'restrict'

@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 a struct of whole-byte fields states it with @byte_order below.

Being one integer, it goes to and from that integer with nothing in between. T::Bits(n) makes the struct from the integer it is stored as and x.bits reads that integer back — the same pair a fixed-point type has, and for the same reason: neither converts anything, both read one set of bits at another type. A system register read whole is decoded in one step:

@packed
struct Esr
    iss: u25
    il: u1
    ec: u6
    iss2: u5
    res0: u27

val e = Esr::Bits(0xF2000000)

print(e.ec, e.il, e.iss, e.bits)
print((e with { iss = 7 }).bits)
60 1 0 4060086272
4060086279

The integer is the struct’s own: unsigned, and as wide as its container — the fields’ widths added and rounded up to whole bytes, so Esr is a u64 and a struct of 3 and 21 bits a u24. A literal takes that type from where it sits; a value of any other width or signedness is refused rather than widened, narrowed or reinterpreted, since that would be a second conversion hidden inside the first:

@packed
struct Esr
    iss: u25
    il: u1
    ec: u6
    iss2: u5
    res0: u27

val low: u32 = 0x3c

print(Esr::Bits(low).ec)
'Esr::Bits' takes the ulong 'Esr' is stored as, not uint

Esr(raw) is not the spelling because it is already the constructor, which takes one value per field — and a bitfield struct of a single field would make it mean two things. Only a bitfield struct has ::Bits: an ordinary struct, and a @packed one whose fields are all whole bytes, are laid out as fields rather than as one integer, and P::Bits(n) on one is refused saying so. A field or member the struct declares named bits is what .bits reaches, ahead of the container.

Nothing is checked on the way in. The bits are taken as they are, so an enum-typed field may hold a value none of its variants has; the field is held to its variants where it is read, as one reached through a pointer the hardware filled is.

Every field has to be an integer — or a simple enum, or another bitfield struct. 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

A bitfield struct may be a field of another, and there it occupies its own fields’ widths — the bits it uses, not the whole bytes its own container rounds up to. That is how a group of flags shared by several registers is named once: a page descriptor’s ten lower attribute bits sit at bit 2 of the block, the page and the stage 2 descriptor alike. A field of the inner struct is read and written through both containers, and .bits and ::Bits of the outer one cover all of it:

@packed
struct Lower
    attr: u3
    ns: u1
    ap: u2
    sh: u2
    af: u1
    ng: u1

@packed
struct Desc
    valid: u1
    page: u1
    lower: Lower
    oa: u36
    rest: u16

var d = Desc(1, 1, Lower(5, 0, 1, 3, 1, 0), 0x12345, 0)

d.lower.ap = 2

print(sizeof(Desc), d.lower.attr, d.lower.ap, d.lower.sh, d.oa, d.lower.bits)
8 5 2 3 74565 485

A struct that is not one integer is refused there — an ordinary struct, or a @packed one whose fields are all whole bytes:

struct Pt
    x: u8
    y: u8

@packed
struct Flags
    on: u1
    at: Pt

val f = Flags(1, Pt(2, 3))

print(f.on)
Pt is laid out as fields rather than as one integer — only a '@packed' struct with a field narrower than a byte nests in another, in its own fields' width

What decides whether a @packed struct is one integer is its own integer and enum fields, so an ordinary @packed struct whose fields are whole bytes lays a bitfield struct out as a field of its size, as it always has.

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

Reading an enum field is the checked conversion E(n), made where the field is read. The bits were put there by hardware or by a cast, and a width with values no variant covers — six bits of an exception class with two dozen defined — can hold one of them. A value no variant has stops the program at the read, reporting it the way a failed bounds check does, rather than reaching Image as a wrong name or a match as no arm at all:

value 42 is not one of the enum's variants (main.sysl:12:11)

An enum whose variants cover every value of its width — two variants in u1, four in u2 — has nothing to check, and its read is the shift and nothing more.

The fallible read is E.try(s.field) — the conversion that answers instead of trapping, handed the field itself. The field’s bits are read without the check and answered for: Some of the variant they are, or None. It is how a kernel asks for “the exception class, if it is one I know” without declaring the field a plain u6:

enum Ec: u6
    Unknown = 0x00
    Brk = 0x3c

@packed
struct Esr
    low: u26
    ec: Ec
    high: u32

describe(class: Option[Ec]) -> string = class match
    Some(c) -> Ec::Image(c)
    None -> "a class this kernel does not know"

var raw: u64 = u64(0x2a) << 26
val e: *Esr = ptr_cast(&raw)

print(describe(Ec.try(e.ec)))

raw = u64(0x3c) << 26
print(describe(Ec.try(e.ec)))
a class this kernel does not know
Brk

A signed enum’s field answers by its two’s-complement bits, as its read does, and where the variants cover the width try is Some with no test at all. Only a bitfield’s enum field is taken as it is — a value of the enum read anywhere else has been held to its variants already:

enum Ec: u6
    Unknown = 0x00
    Brk = 0x3c

val c = Ec.Brk

print(Ec.try(c).is_some())
'Ec' converts an integer, but the value has type Ec — a value of the enum is already one of its variants

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. Where the point is to match a C header’s bitfields, the fields are written as C writes them — the next section.

C bitfields — name: uW in T, laid out as C lays it out

A field written name: uW in T is C’s T name : W;: W bits allocated in a unit of T, the type C declares the field with, by the target’s own C rule. A struct with any such field is an ordinary struct whose other fields sit where C puts them after a bitfield, so its size, its boundary and every offset are what the C compiler for that target gives the same declaration. There are three rules: Itanium (Linux, macOS, the BSDs, RISC-V, WebAssembly, i386 and CRAFT), AAPCS (Arm outside Apple’s platforms: an unnamed or zero-width field raises the struct’s boundary too) and MSVC (Windows: a run of bitfields shares a unit only while their declared types have one size, and no field ever straddles a unit).

struct Status
    ready: bool in u32
    mode: u3 in u32
    count: u12 in u32
    tag: u8

var s = Status(true, 5, 4000, 7)

s.mode = 6
s.count += 1
print(sizeof(Status), alignof(Status), offsetof(Status, tag), s.ready, s.mode, s.count, s.tag)
4 4 2 true 6 4001 7

The field’s value type is an integer of W bits, a simple enum whose underlying type is W bits wide, or bool (bool in bool is C’s _Bool b : 1). T is an integer type of 8, 16, 32 or 64 bits or bool, W fits in it, and the value’s signedness is T‘s — i3 in u32 is refused, because C’s read of the same field would disagree with sysl’s.

A read loads the unit, shifts the field down and cuts it to its width, so a signed field comes back with its sign; a write is a read-modify-write of the unit, so the fields beside it — in the unit, in the next unit, and the ordinary fields around them — keep what they held. Compound assignment reads the field, computes at its type and writes it back. An enum field is checked where it is read, as any enum read from storage is, and E.try(s.field) answers instead of trapping:

struct Range
    lo: i3 in i32
    hi: i3 in i32
    rest: u8

var r = Range(-4, 3, 255)

r.lo += 1
r.hi = -4
print(r.lo, r.hi, r.rest)
-3 -4 255

An unnamed field, _: uW in T, and a zero-width one, _: in T, only move the fields after them — C’s T : W; and T : 0;. As in C, nothing initializes them: a construction and a positional pattern name every field but these, and a value built field by field holds zero in their bits. A derived Eq, Ord, Hash or Display reads every named field:

struct Reg derives Eq, Display
    lo: i8 in i32
    _: u4 in u32
    hi: u8 in u32
    _: in u32
    next: u16 in u32

val r = Reg(-3, 200, 9)
val Reg(lo, hi, next) = r
val s = r with { hi = 7 }

print(sizeof(Reg), lo, hi, next, s, r == s)
8 -3 200 9 Reg(-3, 7, 9) false

A module val of one is laid down as data, its bits where C puts them, as any constant struct is.

volatile makes every access to the field’s unit exactly the declared type’s width — one load of the whole unit, and for a write one load and one store of it, never merged with a neighbour and never narrowed. That is the AAPCS rule for volatile bitfields, and what a peripheral register needs: a device may ignore, or fault on, an access narrower than its register. As with today’s bitfields, the qualifier on any field of a unit qualifies every access to that unit, and nothing diagnoses what a read-modify-write costs a register whose read clears it. Under @packed a volatile field that lies across two units of its declared type has no single access to make, and is refused.

@packed on such a struct is GNU’s packed (MSVC’s pack(1)): the fields abut bit for bit, and @align(n) raises it as it raises any struct. The two kinds of bitfield do not mix in one struct — a @packed struct with an in field may not also hold today’s narrow fields — and @byte_order is refused on one, there being no portable C spelling of a bitfield stored the other way round.

A C bitfield has no address: it is bits inside a unit and begins on no byte, so &, ref and offsetof refuse it, and the struct is reached instead:

struct Status
    ready: bool in u32
    mode: u3 in u32

var s = Status(true, 5)
val p = &s.mode

print(p[0])
'&' here would reach 'Status.mode', a C bitfield — it is bits inside a unit of its declared type and has no address of its own

What carries a count stays out: a struct with C bitfields is laid out as C lays its counterpart out, so a string, a slice or a reference among its fields is refused.

Not built in this release: passing one by value to an extern or returning one from an @export — how C passes one differs from an ordinary struct on WebAssembly and on RISC-V’s lp64d, so it crosses as a pointer — and its declaration in a generated header, which writes it as an incomplete type C reaches through a pointer:

struct Status
    ready: bool in u32
    mode: u3 in u32

extern apply(s: Status)

apply(Status(true, 5))
'Status' has C bitfields, and passing it by value to the 'extern' 'apply' is not built in this release — pass its address, as a '*Status'

@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 boundary travels with every value of the type the compiler lays down: a module var or val, a static, a local, an element of an array, a field of another struct — which begins on the boundary and raises its holder’s to match — and the payload of an enum variant carrying one. A value on the heap keeps it too. A box (&T), a buffer or a task’s frame takes its storage from the allocating pair, which promises only what malloc does — two words, sixteen bytes on a 64-bit machine and eight on a 32-bit one — so storage of a type aligned beyond that comes from the aligned pair, sysl_alloc_aligned(size, align) and sysl_free_aligned(p), and the payload is laid after the box’s header on the boundary:

@align(4096)
struct Page
    e: [512]u64

var p: Page
p.e[3] = 9

val b: &Page = p

print(usize(&*b) % 4096, b.e[3])
0 9

Every access through an address of the type claims its whole boundary. With libc’s malloc and free on a POSIX system, WASI or Windows the aligned pair needs nothing from the program; anywhere else the program answers it, as packages.md § A type aligned beyond what the pair promises says.

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.

@byte_order(big) and @byte_order(little) — the order a struct’s integers keep their bytes in

A protocol header or an on-disk record has a byte order of its own, and it is the format’s rather than the machine’s: IPv4, UDP and TCP keep the most significant byte first, on every machine that reads them. @byte_order says so once, above the struct, and every integer field of it is then stored in that order and read and written as an ordinary integer.

@packed
@byte_order(big)
struct Udp
    src: u16
    dst: u16
    len: u16
    sum: u16

var h = Udp(5353, 53, 8, 0)

h.len += 4

val raw: *u8 = ptr_cast(&h)

print(h.src, h.dst, h.len)
print(raw[0], raw[1], raw[4], raw[5])
5353 53 12
20 233 0 12

5353 is 0x14E9, and the first byte in memory is 0x14 — the order the wire wants, though the machine running this is little-endian. The struct’s storage is the bytes of the format, so a whole value is copied, cast over a frame, compared or handed to C exactly as those bytes, and the reversal happens only where a field turns into a value and back: a read, a write, a construction, a destructuring. On a machine whose own order is the one stated, nothing is reversed at all.

@byte_order(little) is the other word, for a format that keeps the least significant byte first — most file formats, and every one written by a little-endian machine. On today’s targets it reverses nothing, since every machine sysl targets is little-endian; it is written so the struct still means what it says on the first one that is not.

It is a separate axis from @packed and @align(n), and composes with both. A byte order says nothing about gaps between fields, so a header whose fields are not already on their own boundaries is @packed as well — Udp above is four u16s and would lay out the same either way.

What a field may be, inside one:

  • an integer of a whole number of bytes — u16, i32, u24 — or a fixed-point number, or a simple enum (whose discriminant is the integer that is stored), or a name for any of these: these are the fields whose bytes are reversed;
  • a byte, a bool, or an array of either — one byte has no order to keep;
  • a struct that states its own @byte_order, or that holds only bytes, which is how a format whose fields disagree is written: two structs, one a field of the other.

A field is read through the struct and nowhere else, so its address is refused — a *u16 is read in the machine’s order, and would hand every later read the bytes backwards:

@byte_order(big)
struct Hdr
    len: u16

var h = Hdr(1)
val p = &h.len

print(1)
'&' here would reach a 'ushort' inside Hdr, which is '@byte_order(big)' — the field holds its bytes most significant first whatever the machine does, and a '*ushort' is read in the machine's order. Read or write the field through the struct, which is where the order is applied, or take the address of the Hdr itself

A ref is refused in the same words, for the same reason. The address of the struct itself is untouched — it is an ordinary pointer to the bytes of the format, which is what a frame is.

Every other kind of field is refused, and the refusal says what to hold instead. A float’s bytes are put in an order through its bits:

@byte_order(big)
struct Sample
    level: f32

print(sizeof(Sample))
'Sample.level' is f32, and 'Sample' is '@byte_order(big)' — a float is put in an order through its bits, which the language does not do behind a field read. Hold it as the unsigned integer of its width and convert with '.bits()' and 'from_bits'

An array of wider integers has elements, which are read by index rather than as fields:

@byte_order(big)
struct Table
    slots: [4]u16

print(sizeof(Table))
'Table.slots' is [4]ushort, and 'Table' is '@byte_order(big)' — an element is reached by index rather than read as a field, and the order is applied where a field is read. Hold it as bytes, '[N]u8', or make the element a '@byte_order' struct of its own

A struct held as a field keeps its own order, so one that states none would be read in the machine’s:

struct Pair
    a: u16
    b: u16

@byte_order(big)
struct Outer
    p: Pair

print(sizeof(Outer))
'Outer.p' is Pair, and 'Outer' is '@byte_order(big)' — 'Pair' keeps its integers in the machine's order, so a field of it would be read in the wrong one. Mark it '@byte_order' too

An address means nothing in the memory of whatever reads the bytes:

@byte_order(big)
struct Link
    next: *u8

print(sizeof(Link))
'Link.next' is *byte, and 'Link' is '@byte_order(big)' — an address means nothing outside the memory it points into, and a struct with a byte order is laid out to be written somewhere else. Hold an offset or an index instead

A bitfield struct takes one too, and the order is its container’s. Its fields are ranges of one integer, so the integer is what is stored in the stated order — IPv4’s sixteen bits of flags and fragment offset are exactly this:

@packed
@byte_order(big)
struct Frag
    offset: u13
    more: u1
    dont: u1
    reserved: u1

@packed
@byte_order(big)
struct Ipv4Tail
    ident: u16
    frag: Frag
    ttl: u8

var t = Ipv4Tail(0x1c46, Frag(0, 0, 1, 0), 64)
val raw: *u8 = ptr_cast(&t)

print(raw[2], raw[3], t.frag.dont, t.frag.bits)
64 0 1 16384

The ranges are taken from the integer, as they are for any bitfield struct, so dont is bit 14 of 0x4000 and the wire holds 0x40 0x00. A bitfield struct with no order of its own is refused as a field of one that has an order, its integer being in the machine’s.

A union takes one too, and it orders every integer of every field — the bytes are shared, so a word written through one field reads back in network order through another, and building the union from that field stores it in the stated order. An anonymous member’s fields are the enclosing type’s, so the order above the type reaches them as well. Each field is held to the rule above:

@byte_order(big)
union Wire
    raw: [4]u8
    word: u32

var w = Wire(word = 0x01020304)

print(w.raw[0], w.raw[3])
w.raw = [0, 0, 1, 2]
print(w.word)
1 4
258

The order is one of two words, and the attribute marks a struct or a union and nothing else:

@byte_order(middle)
struct Odd
    a: u16

print(1)
'@byte_order' names the order in parentheses — '@byte_order(big)' for a network header, which keeps the most significant byte first, or '@byte_order(little)' for a format that keeps the least significant first
@byte_order(big)
var n: u16 = 1

print(n)
'@byte_order(...)' is the order a struct's integer fields keep their bytes in, so it can only mark a struct — and this declares none. A lone value is put in an order with 'to_be' and 'from_be', the members 'sysl.math.ByteOrder' gives every whole-byte integer

A value that is not in a struct is put in an order with sysl.math.ByteOrder‘s to_be and from_be, and a byte-ordered struct is read out of bytes, every bool and enum in it checked, with sysl.encoding.Plain‘s decode.

@pinned on a struct — a value C holds pointers into

Some C state points into itself. littlefs’s lfs_t keeps a pointer to its configuration and a list of open files threaded through its own storage; a USB stack’s device struct is handed to interrupt handlers by address. Copy one, and C goes on writing through pointers into the copy that is gone. @pinned above a struct says a value of it is never copied or moved once it is built.

A new value has no address yet, so it may land once — in a binding, in module storage, in a &T box, in a field of another pinned value, or as what a function hands back, where it is built in the caller’s slot. After that it is reached only through it: a field, &, or a method taking *self.

struct Config
    block: int

@pinned
struct Fs
    cfg: *Config
    open: int

    mount(*self, cfg: *Config)
        self.cfg = cfg
        self.open = 1

    size(*self) -> int = self.open * self.cfg.block

// A constructor hands back a value it has just built, and the caller's storage is where it lands.
filesystem(open: int) -> Fs = Fs(null, open)

var cfg: Config = Config(512)
var fs: Fs = filesystem(0)

main()
    fs.mount(&cfg)

    val boxed: &Fs = filesystem(2)

    boxed.mount(&cfg)
    print(fs.size(), boxed.open, fs.cfg.block)
512 1 512

Everything that would read the value whole is refused, each with what it does to the value:

@pinned
struct Fs
    open: int

var fs: Fs = Fs(0)

main()
    var b = fs
    print(b.open)
'Fs' is '@pinned' — C may hold pointers into a value of it, so a value is never copied or moved once it is built, and this binds a copy of it. Take its address ('&') or name it with a 'ref' instead, or build the value in place

The same sentence ends differently for each form:

writtenrefused because this…
var b = fs, val p: &Fs = fsbinds a copy of it / copies it into a new box
c = fs, fs = Fs(2)assigns a copy of it / assigns over a value already built, which C may still hold pointers into
take(f: Fs), take(fs)parameter ‘f’ of ‘take’ takes it by value / passes it by value
peek(self) in the structreceiver of ‘Fs.peek’ is taken as ‘(self)’, which is a copy
give() -> Fs = fsreturns it by value — only a value built right there may be handed back
() -> here.open over a localcaptures ‘here’ by value in a closure
val Fs(n) = fs, fs matchtakes it apart / matches on it, which reads it whole into a copy first
(Fs(3), 1), Option[Fs]puts it in a tuple / carries it by value in variant ‘Some’
for item in all over [2]Fscopies each element into the loop variable
Buf[Fs]makes it an element of a buffer whose storage moves when it grows

Assignment is refused even of a new value: the one already there may be what C points into. A pinned value is built once, where it lives, and changed afterwards through its own members.

A type holding one is pinned too, and says so

A struct holding a pinned value by value moves it whenever the struct moves, so it is pinned as well — and it is marked, not inferred, so the reader of the outer declaration sees that its address matters:

@pinned
struct Fs
    open: int

struct Holder
    fs: Fs

var h: Holder = Holder(Fs(1))

main()
    print(h.fs.open)
and this holds it by value in field 'fs' of 'Holder', which moves it whenever it moves. Mark 'Holder' '@pinned' too, or hold a '&Fs' or a '*Fs'

An array of pinned values is pinned the same way, and a []Fs view, a *Fs and a &Fs are all fine: none of them is the value.

A generic is held to it at the instance

A generic instantiated at a pinned type is checked as the instance, the way an instance is held to its declaration’s @no_alloc: addr[T](x: *T) -> *T is fine at Fs, and first[T](x: *T) -> T = *x is refused, the refusal naming first.Fs. A container is refused at the type when any of its members is @relocates, since that member moves its elements — which is why Buf[Fs] is.

A drop is the one member taking self that a pinned struct may have: it is handed the value as the value is destroyed. And @pinned marks a struct beside @packed, @align(n) and @export(...); above a function it is still the per-core promise of @per_cpu.

@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, a function, and an asm block written at the top of a file, which is how code that runs before there is a stack is placed (see inline assembly; such a block also takes @align(n)).

@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

@thread_local

@thread_local gives a module var one copy per thread rather than one for the whole program. Every thread reading the name reads its own storage, and &name answers the address of the copy belonging to the thread that took it.

The initializer has to be a value the compiler can write down — a literal, null, a const, the address of other module storage, or an array or struct built from those — or left off, which starts every thread at the type’s zero. That is the one rule the attribute adds, and it follows from what a per-thread copy is: the copies are made from a single image in the object file, because a thread started halfway through a run never passes through the prologue an ordinary module var is filled by. A call that has to run when the program does — one that prints, or reads a var — would fill exactly one copy and leave every other thread reading zeroes, so it is refused where it is written.

It marks a var and nothing else. A val and a const never change, so the one copy each already has is every thread’s.

import sysl.threads.*

@thread_local static var n: int = 0

count_to_five(state: *int)
    for i in 0..<5
        n = n + 1
    print(n)

var a = spawn(&count_to_five, null).unwrap()
a.join()

var b = spawn(&count_to_five, null).unwrap()
b.join()

print(n)
5
5
0

Each worker’s n starts at zero and counts up in storage no other thread can see; joining one before spawning the next is what makes the two 5s print in a fixed order rather than racing. Main’s own copy is never touched, so its print(n) reads the zero it started at — the third line is the proof that @thread_local gave three threads three different objects under one name.

It composes with @align(n) and @section("...")

All three are facts about where one object’s storage lives, so a thread-local can be aligned or placed exactly as an ordinary module var can:

@align(16)
@section(".tdata")
@thread_local var scratch: [4]u64

A &T into a thread-local needs no &sync

A reference into a @thread_local belongs to one thread by construction, so there is no second thread for its count to be torn by, and reaching it is not crossing a concurrency domain.

What it may not take as an initializer

next() -> int
    print("next")
    42

@thread_local static var n: int = next()

print(n)
'n' is '@thread_local', so its value has to be one the compiler can write down: every thread gets a copy of the initial value, and the copies are made from a single image in the object file rather than by code that runs per thread. A literal, 'null', a 'const', the address of other module storage and the arrays and structs built from them are all values; a call is not. Leave the value off to start every thread at the type's zero, and do the work in each thread instead

It may start at the address of other module storage

The address of an ordinary module var or val — or of a field of one, or of an element at a constant index — is a relocation the linker fills in once, so it is a value the image can carry, as C’s static _Thread_local Vm *vm = &main_vm; is. Every thread starts pointing at the same storage, and each can then point its own copy somewhere else without the others seeing it:

import sysl.threads.*

struct Vm
    id: int

static var main_vm: Vm = Vm(1)

@thread_local static var current: *Vm = &main_vm

worker(seen: *int)
    var mine = Vm(2)

    *seen = current.id
    current = &mine
    *seen = *seen * 10 + current.id

var seen = 0

spawn(&worker, &seen).unwrap().join()

print(seen)
print(current.id)
12
1

The worker starts at main’s Vm, repoints its own copy at a Vm of its own, and reads 2 through it; main’s copy was never touched, so it still reads 1.

The address of another @thread_local is not a value, being a different address on every thread:

@thread_local static var count: int = 0

@thread_local static var counter: *int = &count

print(*counter)
'counter' is '@thread_local', so its value has to be one the compiler can write down, and the address of 'count' is not one: 'count' is '@thread_local' too, so its address is a different one on every thread, and no single image in the object file can hold it. The address of ordinary module storage is a value. Leave the value off to start every thread at the type's zero, and do the work in each thread instead

A target with no thread-local storage refuses it by name

A freestanding target has no loader and no libc, so nothing lays a thread’s storage down and nothing writes the thread pointer an offset would be read from:

@thread_local static var n: int = 0

print(n)
'n' cannot be '@thread_local' on 'thumbv7em-freestanding': there is no loader and no libc on this target, so nothing lays a thread's storage down and nothing writes the thread pointer the offset would be read from. Declare it as a plain module 'var' and let the port's scheduler answer for which task is running, which is what the ownership runtime's reaper slot does

What a bare target uses instead is exactly this: a plain module var the port’s scheduler answers for, the same shape the ownership runtime’s reaper slot already takes.

On a bare AArch64 machine the program may own the thread pointer

A kernel is the thing that writes the thread pointer, so on aarch64-freestanding the refusal above becomes a question: which register? The manifest answers it, and from then on @thread_local compiles to the local-exec model against that register:

codegen {
  thread_pointer = "tpidr_el1"
}

The register is any of tpidr_el0, tpidrro_el0, tpidr_el1, tpidr_el2 and tpidr_el3. Every function is compiled to read it (clang’s -mtp), and so is a package’s carried C, so a _Thread_local there and a @thread_local here find one block.

What the program then owes is what a loader would have done, and the library does most of it. The linker gathers every thread-local into a .tdata image (the initial values) and a .tbss (the zeros), and on AArch64 a variable lives at the thread pointer plus 16 plus its offset in that image, the 16 bytes being reserved for the thread’s own use. Every bare image’s link names that template — __sysl_tls_data, __sysl_tls_data_end, __sysl_tls_end and __sysl_tls_align — so sysl.threads.spawn lays each thread it starts out of it and hands the block’s address to the spawn hook (library/threads.md § On a target with no C library), and the program’s part is the register: a kernel writes it on every switch. The first thread is the one the library did not start, so the program lays its block itself, sysl.threads.tls_room() bytes set aside and tls_place answering what the register should hold:

var block: [256]u8 = [0; 256]            // at least tls_room()
val tp = tls_place(block[..]).unwrap()   // copies .tdata, zeroes .tbss
write_sysreg("tpidr_el0", u64(usize(tp)))

A thread that never gets a block reads whatever the register points at, as it would through any pointer the program forgot to set. A register the machine has no use for is refused before anything is built:

@thread_local static var n: int = 0

print(n)
cannot be '@thread_local' on 'aarch64-freestanding' until the program says which register holds the thread pointer

One file for every machine: #if thread_local

A library that runs both where there are threads and on a board — a C library’s errno is the usual case — wants one variable that is per-thread wherever the build has thread-local storage and a plain module var everywhere else. The #if symbol thread_local (§ #if) is true exactly where the attribute is accepted, so the attribute line is gated by it and the declaration is written once:

import sysl.threads.*

#if thread_local
@thread_local
#endif
static var errno: int = 0

fail(state: *int)
    errno = 22
    print(errno)

var t = spawn(&fail, null).unwrap()
t.join()

print(errno)
22
0

On this hosted machine errno is one per thread, so the worker’s 22 never reaches main. On a board, and on aarch64-freestanding with no codegen.thread_pointer, the same file builds with a plain module var the scheduler answers for; on aarch64-freestanding with the register named it is a thread-local again. thread_local is the one #if symbol the target alone does not settle: it also reads the root manifest’s codegen.thread_pointer, which is why the register is the root’s to name and why it is read before any condition is.

@main_thread — storage only the main thread reaches

A body that may run on another thread — a closure held as a &sync Fn, a named function read into one — may not reach module storage whose counts are not atomic, since the rest of the program could be touching the same counts at the same moment:

struct Counter
    n: int

static var counter: &Counter = Counter(0)

val bump: &sync Fn() -> unit = () -> counter.n += 1

bump()
a closure shared between two domains may be called from either, so every count it reaches has to be atomic — but the module storage 'counter' it reaches is a '&Counter'

Some programs know something the compiler cannot see: that a callable typed &sync because it may cross is in fact only ever called on the main thread. An interpreter’s builtins are the usual case — the table is shared with worker threads, but the builtins that touch the main machine run on the main thread alone. @main_thread above a module var or val writes that promise down, and the refusal above stops:

struct Counter
    n: int

@main_thread
static var counter: &Counter = Counter(0)

val bump: &sync Fn() -> unit = () -> counter.n += 1

bump()
bump()
print(counter.n)
2

The promise is checked where it can be, at run time. Each access to @main_thread storage from a body that may run in another domain first asks whether the thread making it is the one the process began on; where it is not, the program writes 'counter' is @main_thread storage and was reached from another thread to standard error and traps, as a failed bounds check does. The question is answered without anything recorded at start-up, so an archive a C program links answers it too. The program’s own code — the entry file, a function no &sync body reaches — is not checked, and reads the storage exactly as it reads any module var. The check is kept in every build, and is there only on a target with POSIX threads.

It marks one object every thread could otherwise reach, so it is refused above a local (already one call’s), above a const, an extern or a function (no storage), and beside @thread_local or @per_cpu (two answers to who reaches the binding):

@main_thread
@thread_local
static var n: int = 0
'@main_thread' and '@thread_local' are two answers to who reaches one binding — the main thread alone, or each thread its own copy — so they cannot stand above the same one. '@thread_local' already keeps every copy to one thread; '@main_thread' is for the one object the whole program shares

A freestanding target has no threads for one to be the main one:

@main_thread
static var n: int = 0

print(n)
'n' cannot be '@main_thread' on 'thumbv7em-freestanding': the promise is that only the program's main thread reaches it, and a freestanding target has no threads for one to be the main one

@per_cpu — one copy per processor core

@per_cpu gives a module var one copy per core rather than per thread: the record a scheduler keeps for each processor, the core’s run queue, its tick count. It exists only on aarch64-freestanding, where the program owns a register to find the copy through, and the manifest names that register:

codegen {
  per_cpu_offset = "tpidr_el1"
}

Every @per_cpu binding is laid down in one section, .percpu, and that section is the image each core’s copy is made from. The register holds this core’s copy’s address minus the image’s, so an access is the binding’s address plus the register — read afresh at every access — and a core whose register is zero is using the image itself. A boot core can therefore use its per-core data before it has made any copies, provided it zeroes the register first. For the others the program copies the image once per core and writes each core’s offset:

.percpu : { __percpu_start = .; *(.percpu) __percpu_end = .; }
@per_cpu
var ticks: u64 = 0

@pinned
tick() -> u64
    ticks += 1
    ticks

Its initializer has to be one the compiler can write down, as a @thread_local‘s does, and it stands beside @align(n) and nothing else: the section is the storage class’s own.

A per-core read is right only where the core cannot change

Which copy ticks names depends on the core running, so the read is right only while the thread cannot move: a thread preempted between finding its core’s copy and using it resumes on another core holding the first core’s. Nothing in a function’s text says whether interrupts are masked, so the function says it, in one of two words:

  • @pinned — every caller already keeps this function on one core: it runs in an interrupt handler, or under a lock that masks interrupts. That is a precondition, so a call of a @pinned function is held to the same rule as a per-core read, and so is taking its address.
  • @pins — the body keeps itself on one core before it gets there (it masks interrupts first, and unmasks them after). Its callers are asked nothing.

A @per_cpu binding may be named, and a @pinned function called, only inside a body marked one or the other. The words are the program’s claim, as an @export‘s symbol is — the compiler cannot see a register being written — but they put the claim at the function, where a reader can check it against the code, and they carry it up the call tree to the place that keeps the core still.

The rule is not about storage, so it runs anywhere a @pinned function is written:

@pinned
depth() -> int = 3

@pins
probe() -> int
    // The real thing masks interrupts here and restores them after.
    depth()

print(probe())
3

A closure is a body of its own and carries neither word, since it can be called after the function that wrote it has let the core go; nor do the entry file’s statements.

@pinned
depth() -> int = 3

print(depth())
'depth' is '@pinned', so every call of it has to be made where the core cannot change, and the program's statements are neither '@pinned' nor '@pins'

@pinned says the callers hold the core still and @pins that the body does, so one function is one or the other:

@pinned
@pins
depth() -> int = 3
'@pinned' says every caller already keeps this function on one core, and '@pins' says the function does that itself before it reaches per-core storage — one body is one or the other

What it refuses

Anywhere but a bare AArch64 machine, the operating system moves a thread between cores whenever it likes, so no answer to “which core” stays true long enough to read through:

@per_cpu
static var ticks: u64 = 0

print(ticks)
cannot be '@per_cpu' on

On aarch64-freestanding, until the manifest names the register:

@per_cpu
static var ticks: u64 = 0

@pins
tick() -> u64 = ticks

print(tick())
cannot be '@per_cpu' until the program says which register holds this core's offset

A copy per thread and a copy per core are two answers to one question:

@per_cpu
@thread_local
static var ticks: u64 = 0
'@thread_local' and '@per_cpu' are two answers to how many copies one 'var' has

A section would move the binding out of the image the copies are taken from:

@per_cpu
@section(".data")
static var ticks: u64 = 0
'@per_cpu' places the binding in '.percpu', the image every core's copy is made from

A val never changes, so the one copy it has is already every core’s:

@per_cpu
static val ticks: u64 = 0
a per-core constant is a constant

A local is in one call’s frame, which no other core can see:

count() -> u64
    @per_cpu
    var n: u64 = 0
    n

print(count())
'n' is a local, and a local is already one call's

@domain(interrupt) — a function entered on top of whatever was running

An interrupt handler is a function the processor enters directly, in the frame it pushed. Most kernels do not write one: a vector table in assembly saves the interrupted registers itself and then calls an ordinary function, exactly as C would. That function is @exported, and an export is how C enters sysl at all — almost always on the program’s one thread — so nothing about it says a second domain exists.

@domain(interrupt) says so. It changes nothing about how the function is called; it makes the function a root of the interrupt domain, held to what an interrupt handler is held to: every count it reaches, however many calls down, has to be atomic (memory § Module storage reached from another domain).

import sysl.sync.Atomic

var done: Atomic[u32] = Atomic(0)

@domain(interrupt)
@export("disk_irq")
disk_irq()
    done.store(1)

@export("disk_wait")
disk_wait()
    while done.load() == 0 do ()

The handler and the program share one word, and every touch of it is one atomic instruction. A handler that reaches a counted reference instead is refused at the handler, naming what it reached:

struct Request
    sector: u64

var pending: Option[&Request] = None

@domain(interrupt)
@export("disk_irq")
disk_irq()
    pending = None
'disk_irq' is entered in the 'interrupt' domain, on top of whatever was running, so every count it reaches has to be atomic — but the module storage 'pending' it reaches holds a '&Request' in its 'Some(value)', whose count is not. Hold it as a '&sync Request' ('06')

A waker is the case a kernel meets first, and it has no atomic spelling at all — see async § A task that parks stays in its domain.

It names one domain, and interrupt is the one there is

@domain(thread)
worker() = ()
'thread' is not a domain a function is entered in — 'interrupt' is the one there is

A function the program calls itself is in the program’s own domain and says so by not writing the annotation, so there is no empty form, and a function the processor may enter in either of two is entered in the stricter, so there is no list.

It is not async

Whatever enters the function calls it directly, and calling an async function only makes its task:

@domain(interrupt)
async on_irq() = ()
a function entered in the 'interrupt' domain cannot be 'async': whatever enters it calls it directly, and calling an async function only makes its task, which nothing there can drive

#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, android, wasi
processoraarch64, x86_64, riscv64, riscv32, thumb, x86, wasm32, craft
derivedhosted (not freestanding), posix (macos, linux or android), bsd (macos)
floating pointfpu (the machine has floating-point registers), fpu_dp (they hold a double), hardfloat / softfp (the float ABI), fma (a fused multiply-add is one instruction at f32), fma_dp (and at f64)
storagethread_local (a @thread_local is accepted on this build)

That is the whole vocabulary. There is no #define and nothing a project can add, and only one symbol depends on a project config: thread_local, which also reads the root manifest’s codegen.thread_pointer (below). 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, and since android joined it is the shorter way to say all three. android is its own symbol and is not linux, though there is a Linux kernel under it: what a source file gating on a system is really asking about is the libc and the libraries, and Bionic is neither glibc’s set of -l names nor its headers. Code that wants both writes #if linux || android. 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.

bsd is narrower than posix and is about the libc rather than the machine. A BSD and a glibc system are both POSIX, and yet they disagree about a handful of things a source file has to get right: errno is reached through __error on one and __errno_location on the other, ENOTEMPTY is 66 against 39, ENAMETOOLONG is 63 against 36, and mode_t is sixteen bits against thirty-two. Every one of those is a libc fact with nothing about any particular system in it, which is why the standard library’s own gates for them say bsd and not macos.

fma and fma_dp are C’s FP_FAST_FMAF and FP_FAST_FMA: whether llvm.fma (reference/ffi.md) is a single instruction at that width, so that a fused multiply-add is no slower than a multiply and an add. They are facts about the unit, not the calling convention: AArch64 and a hosted RISC-V machine have both, every Arm unit sysl targets has fma, and only the Cortex-M7’s double-precision one (thumbv7em-freestanding-dp) has fma_dp as well. The x86 rows ask for no processor past the baseline, which has no FMA, and WebAssembly has none. Where a symbol is false the operation is still exact — it is a call rather than an instruction.

thread_local is true exactly where a @thread_local would be accepted (§ @thread_local): on every hosted machine, and on bare AArch64 when the root manifest says which register holds the thread pointer (codegen { thread_pointer = "tpidr_el0" }). It is false on a board and on bare AArch64 with no such setting. It is the one symbol the target alone does not settle, because the register is the program’s to name; the compiler reads it before the first condition, a dependency’s if included.

That distinction is worth making before it is needed rather than after. macOS is the only BSD sysl targets today, so #if macos and #if bsd select identically — and a second BSD would route into the glibc branch at every one of those sites, where exactly one of them says anything: the errno accessor fails at the link, while a wrong ENOTEMPTY merely stops matching and a mode_t at the wrong width appears to work. A symbol that means what it says costs nothing today and is the whole of the fix later.

The floating-point four are facts of the whole target, not of the processor. Seven Arm targets are all thumb, and they differ in exactly this: a Cortex-M0+ has no floating-point registers, a Cortex-M4F has single-precision ones, a Cortex-M7 double-precision ones, and a core with a unit may still be built to pass a float in an integer register. Code that saves those registers — a setjmp, a context switch — has to know, because an instruction naming s16 faults on a core with none:

targetfpufpu_dphardfloatsoftfp
every hosted target, aarch64-freestanding, x86_64-freestanding, both wasm32yesyesyesno
thumb-freestanding, thumbv7em-freestandingyesnoyesno
thumb-freestanding-softfpyesnonoyes
thumbv7em-freestanding-dpyesyesyesno
thumbv6m-, thumbv7m-, thumb-freestanding-soft, thumbv7em-freestanding-soft, both RISC-V freestanding, craft-freestandingnononono

fpu asks about the registers, whichever ABI passes a value, so a soft-float-ABI build of a core that has them still answers yes — the registers are there and a context switch must save them. A machine with registers is exactly one of hardfloat and softfp; one without is neither, and !fpu is how a condition says soft float throughout. Every hosted machine answers yes, which is what this host says:

#if fpu && fpu_dp && hardfloat
print("floating-point registers, doubles in them, and floats passed in them")
#else
print("something else")
#endif
floating-point registers, doubles in them, and floats passed in them

Inside an asm statement the arm is still chosen by processor, and what differs between the cores of one processor is gated by #if inside its arm — a setjmp saving the callee-saved single registers only where there are any:

save_fp(at: usize)
    asm
        [thumb]
#if fpu
            "vstmia {at}, {{s16-s31}}"
            in at : reg
#endif
        [x86_64, aarch64, riscv64, riscv32, craft, wasm32] unavailable "not this example"

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, android, bsd, craft, fma, fma_dp, fpu, fpu_dp, freestanding, hardfloat, hosted, linux, macos, posix, riscv32, riscv64, softfp, thread_local, thumb, wasi, 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.

Inside a text block, a condition sysl can read gates the block’s lines, and every other margin line is the block’s text. A text block carrying C — a header to compile, a template — has its #define, #if and #include at column 1, and they belong to the string; a text block a program assembles may also want some of its lines only on some machines, or only with a feature on. The two are told apart by the condition: an #if or #elif whose condition reads as sysl’s, over the symbols a condition may name here — the target’s and the package’s feature_ ones — gates exactly as it does in live code, down to its own #else and #endif. Anything else is text: #define, #include, #ifdef, #pragma and every other word, an #if whose condition is not sysl’s (#if LIMIT > 2, #if defined(X), #if NDEBUG), and that #if‘s own #elif, #else and #endif, so a C conditional stays whole with a sysl group around it or inside it.

import sysl.text.Search

val header = raw"""
    #include <stddef.h>
#if posix
    #include <unistd.h>
#else
    #include <windows.h>
#endif
    """

print(header.contains("<unistd.h>"), header.contains("<windows.h>"), header.starts_with("#include"))
true false true

The directive lines go as they do everywhere, leaving blank lines, and the block’s incidental indentation is reckoned from what is left — the gated text, not the text as written. A condition over a C macro is the block’s text whatever else is around it:

import sysl.text.Search

val header = raw"""
#define LIMIT 4
#if LIMIT > 2
#include <stdio.h>
#endif
"""

#if posix
val kind = "posix"
#else
val kind = "other"
#endif

print(kind, header.contains("#if LIMIT > 2\n#include <stdio.h>\n#endif"))
posix true

#if posix in a block of C gates, since posix is a symbol sysl knows; a C header that means its own macro of that name writes it as #ifdef posix, which is text. A feature_ name the manifest does not declare is false, as it is in live code, so a misspelled one gates its lines out rather than passing into the text. A group opened inside a text block closes inside it: one whose #endif is below the block’s closing delimiter is refused at its #if, naming the line the block ends on.

val s = raw"""
#if posix
    on a posix machine
    """
#endif
this '#if' opens a group inside the text block that ends on line 4, and the group has to close inside the block — add its '#endif' before line 4

A block comment is all text. Nothing reads a comment’s lines, so there is nothing for a condition in one to gate, and a C fragment quoted in a comment stays as written. The gate asks the lexer itself where a literal opened in live code ends, and which kind it is, with the same scans a token is read with, so there is one set of rules about strings and comments rather than two that could drift. A branch not taken is not read this way, since it need not lex at all — a text block that opens inside one does not hide the #endif that closes it.

/*
#if windows
The quoted condition is the comment's, and so is the '#endif' below it.
#endif
*/
print("a comment's lines are its own")
a comment's lines are its own

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(...), @crossing(...), @noinline, @inline, @cold, @disjoint(...), @setup, @teardown, @setup_all and @teardown_all on a free function, @packed, @align(n) and @export("...") on a struct, @export("...") on a module binding, @section("...") on a binding or a function, @section("...") and @align(n) on an asm block at the top of a file, @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
@naked, a function with no framecode that must run before there is a stack — a reset entry, a vector table — is an asm block at the top of a file, which is not a function and so has no frame to leave out
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