sysl

Modules

A module is a directory — visibility, imports, the acyclic graph, where a program starts, and separate compilation.

A module is a directory of source files, and its name is that directory’s path relative to the project root, with the separators read as dots. The files under oskit/arch/ make up the module oskit.arch; those under std/fs/ make up std.fs. Every declaration in every file of the directory is a member of the one module, so splitting a growing module into more files adds no new module and changes no import.

Each file states which module it contributes to, in a header, and the compiler checks the declared name against where the file sits:

module oskit.arch

halt()
    print("halted")

That is Scala’s package and Go’s directory-package. It pays off directly for the target: an OS subsystem is a directory — an arch layer, a server, a driver — so the module is the subsystem, and the subsystem is the unit an importer depends on and the unit a capability clause narrows.

The files of one module must all name it the same way, because the module is the directory, so its name is a property of the directory rather than of any file in it. Each file is held to the name its location gives it, which is the stronger rule: the file that strayed is reported on its own line rather than as a disagreement with whichever sibling happened to be read first.

A file with no header is in the anonymous root module, whose name is the empty path. That is what lets a program be one file with no ceremony — the one-file case is not a special form, it is a module that happens to be unnamed. And because its name is empty, nothing can name it: its declarations are visible to its own files and to nothing else. That is the right way round for the place a program starts — the root reaches down into the modules it is built out of, and they do not reach back up.

A module and a type of its parent may not spell one path. A dotted reference takes the longest prefix that names a module, so a module geom.Point alongside a type Point in geom would take geom.Point.dist outright and leave the type’s member no spelling at all. The two stay distinct declarations either way — what collides is the path a program writes — so the second is refused with a diagnostic rather than settled by a silent choice.

Visibility

A top-level declaration is public by default. Two modifiers restrict it, and they are one keyword with an optional scope:

formvisible to
privatethis file
private[own_module]every file of this module
private[ancestor]the named ancestor module and its whole subtree
(unmarked)any module that imports it
module oskit.arch

exported() -> int = 42

private lookup(fd: int) -> int = fd

private[arch] reset(c: int) -> int = c

private[oskit] struct FrameHeader
    magic: int
end FrameHeader

M resolves innermost-outward. The argument is a simple name, not a path, matched against the enclosing module names from the declaring module outward, first hit winning — so private[geom] inside geom/mesh/geom/tri binds to the nearer geom. A name matching no enclosing module is an error; there is no way to name an unrelated module, so a visibility scope is always a contiguous subtree containing the declaration. Because the name is resolved where it is declared, moving a subtree elsewhere does not change what its internal annotations mean.

Why file-scoped and not module-scoped. This is a deliberate divergence from Scala, where private means the enclosing class or package. Making the bare form file-scoped costs nothing in expressiveness — module-private is exactly private[own_module], the degenerate case of the scoped form — and it buys the one level that provably never crosses a file boundary, which is the level at which a declaration can be fully inferred and LLVM internal linkage applies. The cost is honest: the everyday module-internal helper is private[arch] rather than a bare private, so the common case is the wordier one.

There is no pub keyword. Rust makes a declaration private until pub; Scala and Kotlin make it public until restricted, and that is the precedent here. The low-ceremony common case — a small module whose declarations are meant to be used — writes no modifier, and encapsulation is the deliberate act.

A restriction is about naming, not about existence

A modifier decides who may write a name; it never makes a second namespace. A file-private declaration still belongs to its module and still spends its name there, so a sibling file cannot declare something else of that name. The five declaration forms take a modifier; an impl takes none, having no name for one to restrict; and an enum’s variants carry the enum’s own, since a type nobody outside may name is not one whose variants they may construct.

A name a file may not reach is not a candidate for it. Resolution passes over one and goes on through the file’s imports rather than stopping there — a file that wrote import util.width said which width it meant, and a sibling file’s private helper of that name is not an answer to it. Where nothing else answers at all, the restriction is then reported, because at that point it is the whole story and a better one than an undefined name.

A wildcard offers only what is visible; a selector is refused where it is not. A wildcard has claimed nothing, so a name it cannot see is simply not among what it brings in, and cannot make another module’s name ambiguous either. Naming something deliberately is the opposite case, and being told at the import that it is private is more use than an undefined name at every shorter spelling it would have bound.

A declaration may not be more visible than the types it names

What a declaration says about itself has to be as nameable as the declaration is. A private struct beside a public function returning it would hand every module a value of a type none of them may name: they could hold it, pass it on, and read its fields, and the one thing they could not do is write the type down.

private struct Point
    x: int
end Point

make() -> Point = Point(1)

print(make().x)
'make' is public, but its result names 'Point'

The comparison is between two reaches, and every reach is a contiguous region because private[M] may only name an enclosing module: a type restricted to a subtree may stand in a signature restricted to that subtree or to anything inside it, and never the other way round. A bare-private declaration is exempt in every case — it is read in one file, and a type it can name at all is visible there.

“Signature” is the shorter word for it, and the rule is not about signatures. It is about everything a declaration says about itself, so it reaches the forms that have no signature at all: a field, an enum variant’s payload, a type parameter’s bound and its default, and the declarations that are a name and one type — a const, a module-level val and an extern variable. It reaches everything a caller has to be able to write: a struct’s fields and a variant’s payload, since neither has a visibility of its own; a type argument, since Box[Point] names Point as much as a bare Point does; a trait behind a memory mode; a member of a type or a trait; and a bound, since a trait a caller cannot name leaves it unable to say what is being asked of it.

The const was not always among them: a constant was held to being a scalar, and every scalar is a builtin nobody may restrict, so there was nothing to reach the question with. A transparent subtype of a scalar is a constant’s type now, and that is a declared type somebody may make private — so the rule stated in advance for a hole that did not yet exist is the rule that closes it.

An impl block is outside the rule, in both directions. Implementing a private trait for a public type adds a member nobody outside can ask for by trait; implementing a public trait for a private type makes a public promise about a type that stays unnameable. Neither leaks a name, and a private type reaching a caller through a trait’s signature is a leak in the trait, which is where it is reported.

Rust refuses this and Scala allows it; this follows Rust. The refusal is what makes private mean something a reader can rely on, and it is additive in the safe direction: forbidding it now rules out nothing a later rule would have had to keep allowing, while allowing it and tightening later would break programs.

Anything visible outside its file states its types

A declaration visible beyond the file that declares it carries explicit types. Inference is available only at the bare-private level — the one level that provably never crosses a file boundary — which is why the two rules are really one.

Most of it the syntax already enforces: parameter and field types are mandatory, and a return type is written or its absence means unit. What the rule genuinely binds is the two declarations that are a name and a type — a const and a module-level val, both below.

Why: it makes interface extraction parse-only. A file’s exported surface can be read off its syntax tree without resolving a name, checking a body, or having compiled anything the file imports. That is what a fast, parallel, and eventually incremental build rests on. Scala infers types for public members and pays for it with a far heavier extraction step; this is a deliberate divergence, and it is cheap here precisely because sysl’s signatures were already explicit for other reasons.

Imports

A module reaches another module’s members two ways, and the first needs no import at all.

import sysl.math.{max, min}

print(max(2, 7), min(2, 7), sysl.math.max(10, 3))
7 2 10

A member is always reachable fully-qualified by its module path. Nothing is required to see a public member; an import exists only to shorten the reference.

An import is a dotted path through the module tree, and how the path ends decides what you get:

import sysl.math.max              // max(a, b)      — one member, unqualified
import sysl.math.{max, min}       // several
import sysl.math.*                // every public member
import sysl.math.{max as bigger}  // a member, renamed
import sysl.math.max as bigger    // the same, unbraced
import sysl.math                  // math.max(a, b) — the module itself
import sysl.math as m             // m.max(a, b)    — the module, renamed

Those are Scala 3’s import forms unchanged. The unbraced as belongs to the bare-path form alone, where exactly one thing is being named: after a wildcard there is nothing for one word to rename, and a selector list carries its own as per name, so both are refused rather than quietly ignored. It renames whatever the path turned out to name — a member or a module — because which of the two it is is settled by the same longest-prefix rule everything else uses, and a reader wanting a shorter word should not have to know the answer first.

A module brought in by name is a prefix wherever a written path is:

import sysl.math as m

print(m.max(4, 9))
9

import sysl.math makes math.max(p), math.Float, and [T: math.Float] all work, because the leading segment of a dotted reference is read through the imports where it is not already a module. Two rules keep those from ever both applying: a module reached by the name it already has asks for what is already true and binds nothing, and an import may not be given a name that a module path already begins with. The second is a refusal rather than a precedence rule on purpose — a binding that is both would make fs.read mean one thing in a file that imported fs and another in the file beside it, and as costs one word.

Resolution

An unqualified name is looked for in the module it is written in, then among the file’s imports, then in the library, and nowhere else. A sibling module’s names are not in scope unqualified, and neither are the root module’s, which have no path to be reached by at all.

Resolution is innermost-first. A local binding shadows an imported name; the fully-qualified path is always available to break a tie or reach a name deliberately not imported.

The three steps rank a name by where it was written, not by what kind of thing it is. A function, a const, a module-level val, an extern variable and an enum variant are different kinds of declaration, and a bare name may be any of them — a program’s own answers before an import’s, and an import’s before the library’s, whichever kind each one is. The library declares a stdout(); a program that declares storage of that name reaches its own, and the library’s is still there under the path that names it.

val stdout: int = 7

sysl.stdout().write("the library's\n".bytes)
print(stdout + 1)
the library's
8
situationresult
a wildcard offers a name that is also defined locally or imported selectivelythe more specific one wins
two wildcards both offer one namean unqualified use is a compile error naming both
two selectors bind one name, or two statements doreported at the second import

A wildcard offers a name; a selector binds one. That is the whole of the difference: a wildcard neither collides with a selective import of a name it also offers, nor with a second wildcard over the same module, because it has claimed nothing.

import sysl.math.*

max(a: int, b: int) -> int = 99

print(max(1, 2))
99

Binding one name twice is a mistake, and is reported at the second import rather than at whichever use first found two answers:

import sysl.math.max
import sysl.math.max

print(max(1, 2))
'max' is already imported

The standard module counts as one of those wildcards. sysl is auto-imported into every file, so a written import a.* where a declares an Option of its own is the two-wildcard case, and an unqualified Option in that file is a compile error naming both — it does not shadow the library’s. That is deliberate: the alternative is a precedence tier that makes the library quietly lose to whatever a program imported, which is the same silent-capture problem an explicit conflict is being reported to avoid.

Every step is filtered by visibility, the library’s included. A member the library keeps to itself is not an answer to a program’s bare name, exactly as a sibling file’s private helper is not.

A dotted reference names a module by the longest prefix of it that is one. A program holding both a and a.b reads a.b.f as a.b‘s f rather than as a‘s b. Everything left of the module prefix is the ordinary form — read(…), Point(…), Shape.Circle(…) — which is why qualified access needed no second resolution path beside the unqualified one.

An import binds a name, not a kind. The same spelling may be a type in one module and a function in another, so what an import records is which path a name stands for; which of them a use meant is settled by what that position asks for. One import therefore serves a type, a function, a trait, and an enum variant without saying which it expected to be.

Where an import may stand

Imports normally sit just below the module header, but — following Scala — an import may also appear inside a block, scoped to it, for the case where a name is wanted in one function only:

widest(a: int, b: int, c: int) -> int
    import sysl.math.max

    max(max(a, b), c)

print(widest(3, 11, 7))
11

A block import lasts as long as the block’s local bindings do and shadows whatever the file imported under the same name. It takes effect where it is written: the statements above it have imported nothing.

An import is not an executable statement, whatever it looks like — it binds a name and runs nothing, so a file may import freely without becoming the one file of the program that carries its statements.

A file’s imports are not its dependency list. Because a qualified reference needs no import, a file can depend on a module without naming it in any header — the dependency appears only in a body. Two consequences follow, and they are the price of the convenience above: building the module graph requires parsing rather than a header scan, and the cycle check below needs real resolution rather than a textual match, since a local named std makes std.fs a field access and not a module reference.

Capabilities are a module property

A capability annotation narrows the module, and it is written in the file header below module, each on a line of its own:

module oskit.arch
@no_alloc

Because the module is the directory and the capability is a property of the module, a narrowing must appear consistently in every file of the module — a module whose files disagree is rejected. The redundancy buys local legibility: you can never open a file in a @no_alloc module and fail to see that it is one. A file that declares no module may still carry one, since the anonymous root module is a module like any other.

The other direction is @requires(...), which takes a list because a module often needs more than one capability at once — the POSIX regex binding is @requires(heap, posix), since a regex_t is caller-allocated and regcomp is POSIX.

The heap has two names, and they say different things. The capability is heap and the clause that gives it up is @no_alloc:

  • heap names a facility — whether the machine being built for has one. It sits beside os and posix, and it is what a project states in package.hocon, because whether there is a heap is a project engineering decision.
  • @no_alloc names conduct — a promise this module’s code does not allocate, and so does not need a heap to exist. A promise is about an action, which is why it reads as a verb.

So @no_alloc narrows away heap: I do not allocate, therefore I do not need one. Each spelling is refused where the other belongs, naming it, since somebody who wrote one meant the other. The other three capabilities need only one word, because for them giving the facility up and not using it are the same act.

What @no_alloc promises, exactly: no execution that begins in this module’s own code makes heap storage. It is a portability claim — this module can be compiled into a project that has no heap — and its worth is that the compiler holds you to it while you are developing on a machine that does have one, rather than at the far end when you first build for the board.

They are annotations rather than grammar, which is what keeps alloc, no and requires available as ordinary names; see attributes.

@link is the header’s other inhabitant and is deliberately not held to agreeing. @link("z") names a library the file’s externs need, and the files of a module may each name their own — because what is being described differs. A capability is a property of the whole module, so files that disagreed would describe different modules, while a link requirement is a property of the externs in one file.

Propagation is over the module graph. A module’s effective requirement is its own uses plus the requirements of every module it imports, transitively, and the whole graph must fit the target. A @no_alloc module importing a @requires(heap) module is an error at the import, not deep in codegen. Because the graph is acyclic, propagation is a single sweep in reverse topological order — each module’s requirement set is final before any importer of it is visited — rather than an iterated fixpoint.

Two of the capabilities are checked differently, and the difference is worth knowing:

  • heap is finer than the declaration, and the standard library is why. Inferring it per module would put the whole of sysl on one side of a line that runs through the middle of it, since print allocates nothing and from_utf8 does — so the inferred half is asked of what a module calls rather than of which modules it depends on.
  • os and posix are exactly the declaration, since they gate which modules exist rather than what the language allows. The edge the rule is stated over is the reference graph rather than the import graph, which is load-bearing: a qualified path reaches another module with no import at all, so a rule about imports would have missed the shorter of the two ways to write the mistake.

The target’s half needs no clause at all

A module’s effective set is the target’s intersected with its own narrowing, so a capability is out of reach whichever of the two removed it — and both are answered at the same edge. @no_os is the half you can see in the file. The other half is the machine: on a target whose package.hocon says os = false, every module of the program is one that may not reach sysl.fs, with nothing written anywhere. A print(exists("/tmp")) in a program with no clause at all is refused:

error: this reaches 'sysl.fs', which requires 'os', and 'aarch64-none-elf' does not provide it — a
target's capabilities are what 'package.hocon' declares, so either this reference cannot be made on
this machine or the config is understating it

The message names the config rather than a clause, because that is where the answer is. Where the module did write @no_os, that is what it hears about instead — a reader sent to the config over something they said in their own header would go and change the wrong file.

A library’s own modules are exempt, and that is what makes the rule usable. The modules this question is asked of are the ones the compilation is producing: your program’s own. The standard module’s are not, and neither are a --lib source root’s or a fetched package’s. A library holding one POSIX module is not a library a POSIX-less target cannot use — it is a library one module of which your program cannot reach, and refusing at that module’s own @requires would refuse your build over a file you did not write and cannot change. So the refusal lands at the reference, which is a line somebody chose to write, and a program that never names the module hears nothing.

A generic answers for what it wrote, not for what its caller chose

A generic has no execution until a type is chosen, and whoever chose it is usually somebody else. So the promise is asked of the generic’s body as written, and a monomorphized instance answers for nothing at all. An allocator-free library may therefore be instantiated at a type whose impl allocates: the library promised nothing about a type it never saw.

module lib
@no_alloc

trait Sink
    put(*self, s: string)

twice[S: Sink](s: *S, msg: string)
    s.put(msg)
    s.put(msg)

put is the caller’s choice, so the program below is accepted although its impl allocates on every call — and it is accepted whether or not lib carries the clause, which is the point of it being a promise about lib‘s own conduct.

import lib.*
import sysl.text.cstring

struct Loud
    n: int

impl Sink for Loud
    put(*self, s: string)
        val c = cstring(s)
        self.n += 1

main()
    var l = Loud(0)
    twice(&l, "hi")
    print(l.n)

What the generic itself constructs is unchanged by the type argument, so it is charged where it is written, at every instantiation:

@no_alloc

boxed[T](x: T) -> &T = x

print(*boxed(3))
a reference needs an allocator, and this module declared '@no_alloc'

The same rule answers the two cases that look like ways around it. A trait’s default body is written once, in the trait’s own file, so a default that allocates is that module’s conduct however the implementing type is chosen. And a generic that reaches an allocator through another generic is still its own module’s, since a call in a generic body leads to the body that was written.

Nothing is given up where the promise is load-bearing. On a target with no heap every module is allocator-free with no clause written anywhere, so the module that chose the type is itself checked, and the walk from its body goes straight through the instance to whatever the type argument dragged in. What the rule gives up is a refusal aimed at the wrong file.

Platform selection — __<os>__

A module’s implementation may be split across operating systems by a directory named __<os>__. Such a directory selects but does not name: it is not a module, contributes no segment to any name, and the files inside it belong to the directory that holds it — exactly as if they had been written there.

sysl/fs/path.sysl              module sysl.fs, on every target
sysl/fs/__linux__/dirent.c     that module's C on Linux, absent everywhere else
sysl/fs/__macos__/dirent.c     that module's C on macOS

An importer writes import sysl.fs and names its members. Which files went into it is not something they can see or have to know — the module name is unchanged by the selection, and that invariant is the point of the feature.

The vocabulary is the operating systems a target can have, and no more: __macos__, __linux__, __windows__, __freestanding__. These are the same words #if linux uses, because they are the same idea. A directory of the __x__ shape naming anything else is an error — a misspelled __linx__ read as an ordinary module directory would compile nothing on any target and be reported, much later, as a missing function.

Exactly one operating system is true of a target, so at most one of these directories is selected at any one level. There is no precedence to remember and no tie to break. Files sitting directly in a directory are compiled for every target; the folders add to them rather than replacing them, so shared code stays where it is and only the part that differs moves.

sysl/posix/time/time.sysl              the whole API, written once
sysl/posix/time/__linux__/clock.sysl   the one primitive that differs
sysl/posix/time/__macos__/clock.sysl

That layering is how to use this: put the smallest private primitive in the folder and build the public surface once, outside it. A public API duplicated per operating system is two APIs, and they will drift.

A __<os>__ directory may not be nested inside another. Two axes — an operating system and a processor, an operating system and a libc — are not what this is for: the second axis is #if inside the file, or the C preprocessor inside the .c, which is where the world already keeps that knowledge.

A directory this target did not select is not read at all — not compiled, not analyzed, not parsed. That is what lets it hold a .c including a header this machine does not have, and it is also the cost: a Linux implementation is checked by a build on Linux and by nothing else.

It exists for the C

Everything above is true of .sysl files, and they are the smaller half. A module that differs by platform can usually say so with #if in one file — but a .c cannot carry a sysl attribute, and it will not be given a sysl-shaped name. The path is the only place its selector could go.

That is what a directory buys over a filename suffix, and it is why the suffix this section used to describe was never built: a grammar over sysl filenames would have selected everything except the one kind of file the feature is for. A library may carry C has the rest — and the standard library’s own sysl.fs.entries is the worked example, four lines of C under each of two folders, reaching a struct dirent whose layout no sysl file could honestly transcribe.

The module graph is acyclic

Two modules may not depend on each other, directly or through a chain. The dependency graph is a DAG, and a cycle in it is a compile error naming the modules on the cycle. This is Go’s rule, and a deliberate divergence from Scala, where a package’s compilation units may depend on each other freely.

The graph is over references, not over imports. A member of another module is reachable by its full path with no import at all, so a file can depend on a module its header never mentions. An edge is whatever resolution found — a call, a type named in a signature or a field, a trait named as a bound or behind a memory mode, a variant, a generic instantiated from elsewhere. An import contributes an edge of its own on top of those, because a file’s imports are meant to be readable as what it needs, and a dependency that came and went with a use would not be.

The anonymous root module sits outside the graph: nothing can depend on it, having no name for another module to write.

The standard module does not sit outside it. Writing print records an edge on sysl like any other reference, and that is deliberate. For a program the edge is inert — nothing in the library can point back at a program’s module, so it can never close a cycle — but within the library it is the whole of what keeps the split honest. sysl reaches sysl.sys for the C functions its printing is built on, so sysl.sys may name nothing of sysl‘s, which is why it holds the externs and nothing else. Read as edges: sysl.sys needs nothing; sysl reaches sysl.sys; sysl.buf reaches sysl; sysl.text reaches sysl and sysl.buf; sysl.io reaches all four. Every edge runs away from the standard module and none runs back, which is what makes any of them removable from a program that never asks.

Three things follow from acyclicity:

  • Modules can be compiled in dependency order, and independent modules in parallel. A topological sort exists, so a module’s imports are all fully known before it is checked. A cyclic graph would force the whole strongly-connected component to be checked as one unit — which is the same as saying it was never really more than one module.
  • Capability propagation is a sweep, not a fixpoint.
  • A cycle is a design error, and the fix is cheap. Two directories that need each other are either one module drawn along the wrong line — merge them, which changes no import, since a module is a directory and its file count is not part of its name — or they share something that belongs in a third module both import. Neither fix costs an importer anything.

Within a module, cycles are free and carry no ceremony. All files of a directory share one scope, so mutually recursive functions and types across sibling files need no forward declaration and no ordering: the analyzer collects every signature in the module before it checks any body. The restriction is on the directory graph, never on how a module’s own files refer to one another.

Where a program starts

A top-level statement is not a declaration. A declaration is hoisted and belongs to the module as a whole; a statement runs, and running happens in an order — and a module’s files have no order at all, being one unordered scope. So one file of a program carries the statements it runs, and a second that carries any is an error naming both. That file is the program’s entry file, and its top level is a body: what it declares is local to it, which the section below is about.

A program starts in one place. Statements at the top of a file and a main are two ways of writing that place, so a program that writes both is refused:

print("initialization")

main(args: []string)
    print("then main, with", args.len, "argument")
a program starts in one place, and this 'main' is a second — whichever of the two the program means, the other belongs inside it

Whichever of the two the program means, the other belongs inside it. What a main has that statements do not is a parameter list, so a program that wants the arguments writes main and puts inside it what it would otherwise have written above:

main(args: []string)
    print("initialization")
    print("then the work, with", args.len, "argument")
initialization
then the work, with 1 argument

What main gets at that a statement cannot is the arguments. A statement has nowhere to receive them — it is not a call, so it has no parameter list, and a program’s arguments are not a module-level anything. There are exactly two signatures:

main()
    print("no arguments wanted")
no arguments wanted

args is a slice of string. What the platform hands a program is C’s pair — a count and a vector of NUL-terminated byte runs — and neither appears in a sysl signature anywhere. The library converts the pair, finding each run’s end, validating its bytes, and copying them into strings the program owns, so an argument outlives the vector it came from and holds no memory the platform is still responsible for. The zeroth element is the program’s own path, because that is what the platform passes. An argument that is not UTF-8 stops the program, with the offset of the byte that made it ill-formed.

main names one function in a program, wherever it is written — even in two different modules, where nothing else would collide, and even at the two signatures above:

main()
    print(1)

main(args: []string)
    print(2)
'main' is where a program starts, so there is one — a second declaration of it would overload the name, and a program has one beginning rather than a set of them

That is the reason C reserves the name: it is not a name the program calls, it is the name the platform calls, so two would leave which one the program is to whichever was emitted last. Otherwise it is an ordinary function, and may be called by the program too.

A main may answer with a Result[unit, E], which is what lets ? reach the top of a program:

import sysl.fs.{read_text, IoError}

main() -> Result[unit, IoError]
    val text = read_text("/nonexistent/file")?

    print(text)

    Ok(())

That program prints nothing and exits 1, having written error: no such file or directory to stderr. Without the form, every fallible call in main ends in .unwrap(), which reports the failure as a panic naming the line that gave up rather than the thing that went wrong.

Three parts of it are decided rather than incidental. The unit is not decoration — a value main answered with would have nowhere to go, since what the platform takes is a status. E must be Display, because the report is the whole point and an error nobody can render would exit non-zero having said nothing. And the status is 1 rather than something read off the error: a status is one byte and an error is a value, so mapping one onto the other is the program’s business, and exit is how a program that wants to choose says so.

Nothing else is admitted:

main() -> int = 0
'main' yields nothing or a 'Result[unit, E]', so it may not result in int — a program's exit status is not something a signature can say

A program in which no file carries a statement is a complete program that does nothing. The entry point exists, runs nothing, and succeeds. That is what a tree of pure declarations compiles to, which is what it should compile to — a library is not an error.

The entry file is a body, and what it declares is local to it

val and var at the top of the entry file are locals: initialized where they are written, in the order the statements around them run. A function declared there is a nested function (functions), so it reads and writes the bindings above it with nothing passed in:

var counter = 0

bump()
    counter += 1

bump()
bump()

print(counter)
2

That is the whole point of the arrangement, and it is what a script wants: a sequence that is a sequence. A val bound from what the statements above it produced is ordinary here, and could never be a module member — a module member is bound before any statement runs.

A helper pays nothing for this unless it uses it. Whether a function at the top of the entry file belongs to the body is settled by whether it reads one of the body’s bindings. One that reads none is an ordinary module function: generic if it says so, addressable, passable as a value, and reachable from another file. Only one that reads a binding is nested, and only that one takes the nested function’s limits.

static — asking for the module instead

A val or var in the entry file that should be the module’s says so:

static val table: [3]int = [1, 2, 3]

sum() -> int = table[0] + table[1] + table[2]

print(sum())
6

It is then hoisted, laid into the object file, visible to every file of the module, and initialized before any statement runs — which is also why its initializer may not call a helper that reads the body: at that moment there is no body yet.

A static var is the same storage, written — assignment at every depth and &, which is what the word var already means, and which a val refuses because a val promises its storage is written once:

static var ticks: int = 0

tick() = ticks += 1

tick()
tick()
print(ticks)
2

Its initializer may be absent, which a val‘s may not — the type’s zero is what it starts at, and that is the cheapest form and the one an arena wants.

It holds a counted value, and the last one it holds is never released. A string, a slice, a &T, a weak T and anything built out of them are all module storage; every assignment during the run gives back the count it replaces, and the only release with nowhere to go is the one at exit — which is the release a static is defined by not taking.

static var current: string = ""

set(s: string) = current = "[" + s + "]"

set("one")
set("two")
print(current)
[two]

That was refused until the reason behind it was read again: storage lasting the whole run has no line to write a release on, which is true and is a description of a static rather than an argument against one. What the refusal cost was the shape every callback interface needs — a C function that calls back takes an address and an opaque word, so a binding offering a sysl closure has to keep it where the trampoline finds it again, and module storage is the only storage that outlives the call.

What is refused instead is a type with no zero and no initializer, which is a narrower rule about a different thing. A &T has no zero, because there is no such thing as a reference to nothing; an enum has none either, since a zeroed tag names no variant in particular. A string and a slice both zero to the empty one and need nothing written. It is the same question a local declared with no initializer is already held to.

struct Cell
    v: int

static var cell: &Cell
'cell' needs a value: storage with no initializer starts at its type's zero, and &Cell has none — the same rule a local with no initializer is held to. A 'string' and a slice both start empty and need no value written; a reference and an enum need one

A destructor takes the same ruling and for the same reason: a value in module storage never reaches a count of zero, so its drop does not run when the program ends.

static is meaningful only in the entry file, since only that file has a body for a declaration to not belong to. In a file with a module header, or a headerless file carrying no statements, everything is the module’s already and the modifier is refused rather than ignored. A function never takes it either: settled by what it reads, the modifier would be redundant on one and impossible on the other.

So everywhere else the same storage is written plain var, and it is the same declaration — visibility, the shared value namespace, the initializer graph and the release rule all answer for it exactly as above:

module counter

var count: int = 0

bump() = count += 1

The two spellings never compete, because the modifier is needed precisely where there is a body to be asking about. Reading them side by side: static var in the file the program starts in, var anywhere else, one kind of module storage.

Visibility is the part of that worth writing out, because a module with state usually wants it — the functions that maintain the storage are public and the storage itself is nobody’s business:

module counter

private var count: int = 0

bump() = count += 1
peek() -> int = count

Both spellings take one, so private static var says the same thing in the file the program starts in.

A var does not decide which file the program starts in. That is settled by what a file runs, and declaring storage runs nothing — a file holding a counter is no more the program’s beginning than one holding a table. Where a file carries a statement that is not a binding, it is the entry file and every other file’s top-level vars are their module’s. Where nothing runs anywhere, a single headerless file of bindings is still a body, so a one-file var n = 1 means what it always did.

That word is load-bearing: a file naming a module is never the program’s beginning, and it is the sentence at the top of this section seen from the other side — a header means everything the file declares is the module’s already, so there is no body for a binding to belong to instead. A library is the shape that tests it, being files and no beginning anywhere.

const — a value

A const is a module member: hoisted, order-free, visible to the whole module and beyond it under the ordinary rules, and never running at all.

const capacity: usize = 512
const window: int = 1 << 15

type Slot = usize within 0..<capacity

var table: [capacity]u8 = [0; capacity]

print(table.len, window, capacity)
512 32768 512

Why it exists at all, when a nullary function already serves every use in an expression: an array bound is a compile-time constant and a call is not. Without const, a program carries [512]u8 next to a capacity() function and a comment asking the reader to keep the two in step.

The type is always written — not because it could not be inferred from the initializer, but because the rule that anything visible outside its file states its types is what keeps interface extraction parse-only. Writing it is also what fixes the literal’s type, so const capacity: usize = 512 needs no suffix on the 512.

And the type is a scalar, or a transparent subtype of one. The first half is the shape of what a constant expression can produce rather than a restriction anybody chose — there is no aggregate literal to fold to, and a table would be storage rather than a value. The second half follows from a transparent subtype being its base, so a constant declared at one is a literal at an integer, a float or a char — and its range is checked while compiling:

type Age = int within 0..150

const oldest: Age = 122

print(oldest)
122
type Age = int within 0..150

const oldest: Age = 200
which Age does not admit — it holds 0 to 150

That check is the one thing a constant gets that a val of the same type gets only at run time, and it is what a c const is really for: a number nobody chose — a sizeof, a config macro out of somebody else’s header — held to what this program can actually do with it.

A new type is refused, because reaching one from its base is a written conversion and a constant is the value it was written as, so there is nowhere on the line to write one. A where predicate is refused too, because a predicate is checked where a value is made and a constant is folded into its uses rather than made anywhere — admitting one would be a check the declaration claims and the program never gets.

A constant expression is a literal; a const; a conversion; a unary -, ! or ~; or a binary arithmetic, bitwise, shift, or comparison operator applied to constant expressions. Integers, floats, bool and char fold.

There are no calls, and a string constant’s initializer must be a literal — since + on strings allocates, and a compile-time concatenation would be a different operation wearing the same spelling:

size() -> int = 4

const called: int = size()

print(called)
the value of 'called' is not a constant expression

A function call in a constant expression is a request for compile-time evaluation of arbitrary code, which is a language of its own.

“A const“ above means the declaration, not a spelling of it. A constant reached by its full path is the same constant as one reached by an import, and the two fold alike in every position below — what decides is what the name resolves to, and the three steps above are the whole of how a name resolves:

import bits.byte_width

var packed: [byte_width]u8            -- imported
var padded: [bits.byte_width]u8       -- the same declaration, named through its module

It is worth saying only because the two spellings are different enough to be implemented separately, and once were. A c const is no different: a binding keeps its measured constants in a sub-module of their own, so every consumer names them qualified.

Where a constant may stand: anywhere an expression may, plus the four places a literal was previously the only thing accepted — an array bound, an enum discriminant, a pattern, and the bounds of a within range:

const Limit: int = 3

enum Op
    Halt = Limit
    Push

classify(n: int) -> string
    n match
        Limit -> "at the limit"
        else     "other"

print(classify(3), classify(1), int(Op.Halt), int(Op.Push))
at the limit other 3 4

All four go through the same fold, which is the property worth keeping: they accept the same expressions because they ask the same question, rather than several grammars agreeing by coincidence.

The pattern position is the one worth saying out loud, because it is where other languages have come to grief: a name in a pattern binds unless it resolves to something, and Rust’s rule that a lowercase const in a pattern binds instead of matching is a documented trap. Here it cannot arise — a pattern name already resolves against the enum variants in scope before it is taken as a binding, and a constant joins that same resolution rather than adding a second one.

A constant has no address. It is folded into each use and occupies no storage, which is why it needs no initialization order, why a no alloc module may hold one, and why &capacity is not a thing to write. That is also what rules it out for a table, which is indexed at a value only known while running and therefore has to be somewhere.

A cycle between constants is reported at the declaration, naming the loop:

const a: int = b
const b: int = a

print(a)
constant 'a' is defined in terms of itself: a → b → a

Two things a constant is not. Not an enumeration — a set of related named values is a simple enum, which is the type-safe replacement for a pile of constants; if a second constant would be the obvious neighbour of the first, the declaration wanted was an enum. And not a value generic — parameterizing over a length is [const N: usize], which is this declaration with the initializer left to the caller. The two are one idea in two positions, and this one had to come first: a value cannot be passed as an argument before it can be named.

val — a thing

A val is a thing, where a const is a value. As a module member it is read-only storage that exists for the whole run; as a local it is the immutable counterpart of var, in the same frame with the same lifetime. One keyword at both levels, because it is one idea at both — and which one it is follows the file it is written in: the module’s everywhere except the entry file, whose top level is a body, and where static asks for the member.

static val order: [8]usize = [3, 1, 4, 1, 5, 9, 2, 6]

at(i: usize) -> usize = order[i]

var i: usize = 0
var total: usize = 0

while i < order.len
    total += at(i)
    i += 1

print(total, order[2])
31 4

The whole difference from a const is an address. A constant is folded into every use, which is what lets it size an array and what stops it from being one. A val sits somewhere, so it may be indexed at a value only known while running, iterated, and reached into. The rule for a reader is short: if it has to be indexed or pointed at, it is a val.

Because it is read while running, a val is the one module-level name that cannot size a type or stand in a pattern:

val limit: usize = 4

var table: [limit]u8 = [0; 4]

print(table.len)
an array length must be a constant — a literal, or a 'const' naming one

Naming one in a pattern with a bare name is an error rather than a quiet binding, for the same reason — a bare name there would bind rather than match, and the diagnostic names what to write instead:

static val limit: usize = 4

check(n: usize) -> string
    n match
        limit -> "at the limit"
        else     "other"

print(check(4))
a bare name here would bind rather than match

What to write is the backticked form, which says the test was meant and compares against whatever the val holds when the match runs:

static val limit: usize = 4

check(n: usize) -> string
    n match
        `limit` -> "at the limit"
        else     "other"

print(check(4), check(9))
at the limit other

The rest of what a module-level val may hold — read-only at every depth, and the []const T a slice of one yields — is on declarations.

Separate compilation

A library is built into one file and linked against, rather than recompiled by everything that uses it:

sysl build-lib mylib -o mylib.syslib     # compile the library once
sysl run prog.sysl --lib mylib.syslib    # link a program against it

--lib takes either an artifact or a source tree, and which one is read off the name. How a library shipped is the shipper’s business; a program that depends on one should not have to write down which form it got. Given a source tree the library is simply more modules, and the rules at the top of this page do the rest.

An artifact has two halves, and the split is the whole design. A declaration with no type parameters is compiled ahead of time into object code by whoever built the library, and a program that calls it declares the symbol and links the body. A generic has nothing to compile until a caller fixes its type arguments, so it crosses as the tree it was parsed into and is monomorphized in the consuming program. Rust’s .rlib makes the same split for the same reason.

The metadata carries every declaration, not only the generic ones: a call into the precompiled half still has to be type-checked, and the tree is where the signature is. What the symbol list adds is which of those the consumer must declare rather than emit a second time.

Five consequences, each a thing a reader would otherwise have to discover:

  • An artifact is for one machine, and both halves pin it. The object half obviously does; the tree half does because a library may gate on the machine it is built for, which makes two artifacts built from one source two different sets of declarations. So an artifact records its target and is refused by a build for another — refused rather than left to the linker, which would eventually complain about object formats in a message saying nothing about which library or why.
  • A library carries no entry point. A main of its own would collide with the one belonging to whatever links it.
  • Nothing is pruned when a library is built. A program is lowered from main outwards because what it cannot reach is dead; a library has no main and every public declaration is a potential entry, so all of them are emitted and the linker discards what a given program never calls.
  • A library defines its own declarations and nobody else’s. A library that prints reaches the library’s own printing surface exactly as a program does — but emitting those would put a copy in every artifact, so two libraries that both printed could not be linked into one program. They are declared in the artifact and defined in the consuming program.
  • A library may not sit in the anonymous root module. A library is reached by naming its module, and the root module has no name, so nothing depending on it could write a path to what it declares.

A library may be built on another library, and --lib is how one gets there:

sysl build-lib sdl3 -o sdl3.syslib
sysl build-lib sdl3-ttf --lib sdl3.syslib -o sdl3-ttf.syslib

build-lib takes --lib exactly as a compilation does, and for the same reason: a library whose declarations are written in another library’s types does not compile without them. sdl3-ttf‘s Font renders to an sdl3 Surface, so a package that could not say so would have to be a module inside its dependency rather than a package of its own. Nothing about the artifact changes, because the fourth bullet above already governs it — the dependency’s compiled half is declared here and defined by whatever program links both.

What build-lib does not do is fetch. A dependencies block is a coordinate to resolve over the network, and a command whose whole job is to compile one tree into an artifact for one machine does not go looking — so a package that declares dependencies and is handed no library is refused, naming the dependency and the flag that answers it. Such a package writes its dependency down twice, once in package.hocon and once on the command line, and that is the price of a compile step that is offline by construction.

The container is an ar archive, which is what an .rlib is and for the same reason: the linker already reads one, so the compiled half needs no unwrapping and a member is pulled in only to resolve something a program actually left undefined. The metadata rides inside it wrapped in a real object file, as one private constant in a section of its own, so nothing ever gives the linker a reason to pull it in and it costs the linked program nothing.

The standard module is built the same way, and linked by default. Which library a compilation is compiled against is a parameter of it rather than an ambient fact — which is what lets two cores be handed to two compilations and compared — but the parameter has a default, and the default is found rather than named. A compilation that finds no standard module at the default path builds one, in well under a second, announcing it on stderr. That is not the silent substitution a compiler must never make: a rebuild compiles against this library, from its own source, held to the same fingerprint on the way back in. Nothing is substituted, so there is nothing to be misled about.

The standard module’s source ships with the compiler and is read off diskshare/sysl/library under the install prefix, found from the binary’s own location the way rustc finds its sysroot. Running out of a checkout, it is library/ in the tree. You can read it, and you can edit it: a changed file changes the library’s fingerprint, so the next compilation builds an artifact of its own rather than picking up a stale one. SYSL_LIB names a library root outright, which is what a broken install needs and nothing else does. A compiler that cannot find its library names every path it tried.

The default path is keyed by a fingerprint of the library, so every compilation of the same library on the machine finds the same artifact — and a rebuild therefore publishes by rename: it is assembled beside its destination and moved onto it. Two builds may run at once; a reader gets the whole of one artifact or the whole of the other, and a rebuild that fails leaves the one that was already there.

An artifact named on the command line is never rebuilt, and one that cannot be read stops the compilation — corrupt, truncated, built by another sysl, or built from other sources. Someone who wrote down which standard module to compile against is owed the truth about that one.

What is deliberately absent

absentwhy
a file as a modulethe file is a contribution, not a unit; there is no per-file namespace and no import of a file
a pub keywordpublic is the unmarked default; its absence is public
relative or wildcard-path importsan import names a module by its full dotted path from the project root, so a reference means the same thing wherever the importing file sits
auto-import beyond the standard modulea module earns visibility by being imported or fully qualified
implicits — Scala’s given/usinga term selected by searching the scope would let a change anywhere alter what resolves in a file that did not change, so a module’s interface would no longer bound its blast radius

The last is the sharpest of them. Scala pays for implicits with per-file used-name tables and API diffing, and that machinery is the cost of the feature rather than an implementation detail of it. The one search sysl does perform, for a trait impl, is bounded to two modules by the coherence rule for exactly the same reason.


Next: errors and contracts.

Search

Esc
to navigate to open Esc to close