Compact reference
The whole language on one page, for a reader who already programs — tables and one-line rules instead of explanation, with the idioms that keep new code in style.
Every other page in this section explains. This one does not: it is the language written down as densely as it can be, for a reader who already programs in something and wants the rules rather than the argument. Each heading below has a full page behind it, linked, and the full page is where an edge case is settled.
Two things this page is not. It is not a tutorial — the tour is. And it is not the specification: where this page and a full page disagree, the full page is right, because that is the one whose every claim is a compiled program.
Lexical and layout
| line comment | // … |
| block comment | /* … */, and it nests |
| doc comment | /** … */ above a declaration, with @param / @return tags |
| not a comment | --. It lexes as two minus signs and the error lands further along the line |
Layout. Indenting opens a block, dedenting closes it, a newline ends a statement. No braces
around statement blocks. ; is a separator only inside a three-clause for header.
(,[and{suspend the off-side rule until they close — with two exceptions,matchand->, which open an indented block wherever they are written, brackets included.- A line ending in an operator that cannot finish an expression (
+,&&,==,<<, prefix!) continues onto the next. Excluded:=and->(they open a block instead), the postfix++,--,?, and..,..<,..., which can be complete. - A line beginning with
.namecontinues the line above it — the one way to break a call chain. end Namemay close any block-shaped declaration; it is optional, checked when written, and required only on a struct with no fields.
Literals.
| integers | 42, 0xFF, 0b1010_1010, 0o755; _ between digits; a canonical type name as a suffix |
| floats | 3.14, 2.5e3; default real (f64); a width as a suffix. No hex/binary/octal float |
char | 'a', '\n', '\u{1F600}' — one Unicode scalar value |
string | "…" · c"…" a C string as *u8 · s"…${e}…" interpolated · f"…${e}%4d…" with a specifier · raw"…" left exactly alone |
| text block | any of those tripled — """…""" — spanning lines, common indentation stripped |
| labels | 'name — told from a char by having no closing quote |
| words | true, false, null. null exists for *T and only for *T |
Forty reserved words: alignof as break const continue defer do elif else ensure enum extern false for if impl import in loop match module null offsetof override private ref require return self sizeof static struct then trait true type val var weak while.
Contextual — keywords only where the grammar expects one, ordinary names everywhere else: is,
not, end, become, opaque, derives, invariant, new, set, some, with, within,
where, async, await, and the c of a c const / c type block. Type names (int, usize, f32, string, …)
are predeclared identifiers, not reserved.
__…__ — leading and trailing __ with capitals between — is reserved to the language;
nothing may declare one. The built-ins are __FILE__, __LINE__, __COLUMN__, __FUNCTION__,
__DATE__, __TIME__, __NAME__, __VERSION__. __NAME__ and __VERSION__ are the
package.hocon name and version of the package whose file they are written in, and are refused in a
file that belongs to no package. Written as a default argument the location ones, __NAME__ and
__VERSION__ report the caller — its file, its line, its package’s name and version.
Operators, closed set, longest match, no user-defined symbols:
= += -= *= /= %= &= |= ^= <<= >>= || && !
== != < > <= >= .. ..< ... | ^ & ~
+ - * / % << >> ++ -- ( ) [ ] { } . ? .*
, :: : -> # ; @
Types
iN / uN | an open family — i5, u3, u12, i128 all exist without the compiler knowing them. Arithmetic wraps at the declared width |
| aliases | byte=u8, ushort=u16, uint=u32, ulong=u64, short=i16, int=i32, long=i64, real=f64. Pinned by definition on every target; prefer explicit widths at a C boundary |
fN | closed — f32 and f64 only |
usize / isize | pointer-width, and distinct types from every uN. Lengths, indices and sizeof are usize |
bool | nothing coerces to it |
char | one Unicode scalar value; neither u8 nor u32 |
string | validated UTF-8, immutable, three words. Indexes by byte, iterates by char |
unit / never | one value / no values. never is a subtype of everything |
[N]T | fixed array — is its elements, so copying copies them all |
[]T, []const T | slice — {owner, pointer, length}, a view of someone else’s elements |
<N>T | vector — N lanes in a register; operators work lane-wise; a lane index must be constant |
(A, B) | tuple, read t.0, t.1 |
Fn(A) -> R | callable type. f: A -> B is sugar for a type parameter (monomorphized, no box); f: &Fn(A) -> B is a boxed trait object |
*extern(A) -> R | a raw C function pointer |
type N = T | alias — a second spelling, no new type, no checking |
Every index is checked on arrays and slices, and out of bounds traps.
struct Point
x: int
y: int
enum Shape
Circle(r: real)
Rect(w: real, h: real)
area(s: Shape) -> real = s match
Circle(r) -> 3.0 * r * r
Rect(w, h) -> w * h
var a = [1, 2, 3]
var b = a
var v: []int = a[..]
b[0] = 99
v[1] = 88
print(a[0], a[1], b[0], Point(1, 2).y, area(Rect(2.0, 3.0)))
1 88 99 2 6
Option[T] and Result[T, E] are ordinary library enums with no compiler privileges.
A simple enum may fix its representation and its discriminants — enum Colour: u8 with
Red = 2 — and answers a closed set of :: attributes: T::First, T::Last, T::Pos(v),
T::Val(i), T::Succ(v), T::Pred(v), T::Image(v), T::Value("Name").
Declarations
| form | mutable | value | type |
|---|---|---|---|
var n = v | yes | optional | optional |
val n = v | written once | required | optional |
const N: T = v | no, folded while compiling | required | required |
At module level, val is read-only storage that outlives the run and const is folded into every
use — if it has to be indexed or pointed at, it is a val. In the entry file, whose top level
is a body, the module member is asked for with static val / static var.
A function is a name, a parameter list, an optional -> result, and a body — no keyword. The
body is = expr or an indented block whose trailing expression is the result; = may also open
a block. An absent result type means unit.
double(n: int) -> int = n * 2
sum(a: int, b: int) -> int
val t = a + b
t
box(w: int, h: int = 1, fill: string = "*") -> string
var s = ""
for i in 0..<w * h
s += fill
s
print(double(21), sum(40, 2), box(3), box(2, fill = "#"))
42 42 *** ##
- Defaults are full expressions evaluated at the call, read at the parameter’s declared type.
Named arguments are written
name = value. - Overloading is by parameter types. A function whose last act is a call to itself reuses the
frame;
@tailrecasserts that it did, andbecome f(…)demands it of any call. ...collects the rest of the call. Several results go back as a tuple.
A struct’s members — fields, methods, properties, invariant — go in the body in any order. The
constructor is the struct’s name applied to its fields in declaration order; a field declares no
default.
The three receivers, and the difference is what survives the call:
self | a copy. Cannot be mutated, cannot be referred back to |
*self | the caller’s address — good for the call’s length, and may not be kept |
&self | a share of the box — what the method builds may hold the receiver and outlive the call. Refused on a stack value |
A property is a method with the parameter list left off — perimeter -> int, read as
r.perimeter. A setter beside it is set f(v), whose parameter carries no type.
struct Rect
w: int
h: int
invariant w > 0 && h > 0
area(self) -> int = self.w * self.h
perimeter -> int = 2 * (self.w + self.h)
scale(*self, k: int)
self.w *= k
self.h *= k
end Rect
var r = Rect(3, 4)
print(r.area(), r.perimeter)
r.scale(2)
print(r.area())
12 14
48
Visibility is public unless said otherwise: private is this file, private[mod] is the named
enclosing module and everything under it. The bracket takes a simple name, not a path —
private[matrix], never private[sysl.math.matrix].
Expressions and statements
Expressions · statements · patterns.
- Conversions are calls and none is inferred:
u16(n),real(n),int(x),u32(c),char(u)(traps),string(c),usize(p). There is no conversion to or frombool, and none between a number and astring—str(x)renders,sysl.textparses.ptr_castmakes a pointer from an address. - Comparison chains:
0 <= i < nis one expression, each operand evaluated once. x is Patandx is not Pattest against the full pattern grammar, alternatives included, and stand in the condition of anifor awhileand nowhere else — chain them with&&, or use amatch. Under that condition’s||a test stands too, so long as it binds nothing.?is postfix and tightest — except thatawaittakes the postfix chain up to the first?, soawait f()?is(await f())?: onOk(v)/Some(v)it isv, otherwise it returns the failure from the enclosing function — which must return the same channel. It converts the error throughFrom.base with { bg = 9 }isbaseagain with those fields changed; the left side is untouched..Greenis the variant with the qualifier the context already supplies left off.- A call’s last argument may be an indented block after a
:; what the block becomes is decided by the parameter it fills, and the one value it is passed isit. sizeof(T),alignof(T),offsetof(T, f)fold while compiling.
Loops — five forms, each an expression.
while c / do … while c | test before / after |
loop | no test; something inside leaves it. Never write while true |
for x in seq | a range, an array, a slice, an Iterate, or a Walk (a container); x may be a full pattern in parentheses |
for init; cond; step | a stride, a descent, several variables |
for const i in 0..<N | unrolled while compiling — for walking a type pack; no label, no break, no else |
break expr makes the loop’s value; an else block after the body supplies the value when the loop
finishes normally, and a value-carrying break without one is refused. Loops take 'labels.
defer <statement> runs on the way out of its block by any ordinary route — falling off the end,
return, break, continue, or a ? taking its failure arm — in reverse order. A trap runs no
deferred statement.
Patterns, one table:
_ | anything, binds nothing |
else … | the catch-all arm, in tail position |
0, 'a', "hi", true | a literal, on any type with equality |
3..7, 0..<10, 'a'..'z' | a range, over the numeric types and char |
r | binds. A bare lowercase name binds; a backticked `limit` references instead |
Circle(r), .Empty | a variant, binding sub-patterns |
Point(a, b), Point{x, y}, Point{x: a} | a struct, positionally or by name |
(a, _) | a tuple |
c @ Circle(r) | matches, and binds the whole value too |
A \| B | alternatives, which may not bind |
A match on a data enum must be exhaustive or carry an unguarded else; a guarded arm never
counts toward exhaustiveness, and a failed guard falls through to a later arm. A pattern may also
stand at a binding and in a for header, where only an irrefutable one is allowed.
band(n: int) -> string
n match
1..10 if n > 5 -> "high"
1..10 -> "low"
else "out"
var xs = [1, 3, 4, 5]
val first_even = for x in xs
if x % 2 == 0 then break x
else -1
print(band(7), band(2), band(50), first_even)
high low out 4
Ownership and memory
| mode | what it is | freed by | needs an allocator |
|---|---|---|---|
T | a value — stack, register, or inline in something bigger | nobody | no |
&T | a counted reference to a heap object | the compiler, when the last one goes | to make one |
weak T | a reference that does not keep its object alive; .get() answers Option[&T] | — | no |
&sync T | &T with an atomic count — a distinct type, no conversion either way | the compiler | yes |
*T | a raw machine pointer | you | no |
The choice is per declaration, not per type, and value is the unmarked default. Assignment
copies; copying a value holding a &T retains it, so there is no Copy bound to write, ever.
References are non-null — nullable is Option. A slice’s owner word keeps its backing alive, and a
local that escapes is promoted rather than dangling.
ref t = self.tasks[i] binds a name to a place, keeping the bounds, within and invariant
checks that &self.tasks[i] would have given up.
A Drop fires when a box’s count reaches zero and for nothing else — see the tips below.
Crossing a concurrency domain copies by default; a &T may not cross, which is checked at a
@crossing parameter. &sync T makes the reference safe to share, not the object safe to mutate.
Generics and traits
[T],[T: Bound],[T: A + B];[const N: usize]stands for a value;[..A]for a list of types. Inference is bidirectional, and a type argument may be written explicitly asf[int](x).- An unbounded
Tmay be copied, assigned, passed, returned and stored, and nothing else. Every operator, method, index and field access needs a bound — and the diagnostic names the bound to write, except for a field, which no trait could supply. - Conformance is explicit: an
impl Trait for Typeblock, which may live with either. - A trait may take type parameters, declare an associated type —
type Itemin the trait, fixed by theimpl, read back asSelf::Iteminside andT::Itemfrom a bound — and require another trait. - A blanket
impl[T: Bound]is allowed only over a family the compiler closes —Integeris one;Floatis an ordinary trait, so its two widths need two blocks. - Some memberships are the compiler’s, and nothing implements them:
Integer;AnyBits, every type whose every bit pattern is a value (whole-byte integers, floats, whole-byte fixed point,*T, arrays and structs of them — notbool, enums,char,&T, strings or slices); andsysl.encoding.Plain. A trait that declares nothing is written closed byend Name.
Trait objects are fat pointers: *Trait points at the value, &Trait at the box. A trait is
erasable when it declares no associated type and every member has a receiver, mentions Self nowhere
else, takes no ..., and — for *Trait — does not take &self. That excludes the whole operator
catalogue, deliberately.
The compiler writes four, and the list is closed: derives Eq, Ord, Hash, Display after the name
of a struct or enum.
| trait | for |
|---|---|
Eq, Ord | ==, < … — and a bare enum already has Eq; writing one is refused |
Add Sub Mul Div Rem Neg | arithmetic |
BitAnd BitOr BitXor Shl Shr Not | bitwise |
Zero, One | the identities |
Display | rendering to a sink; str(x) and print go through it |
Hash | hashing |
Index, IndexSet | x[i] and x[i] = v |
Iterate | what for x in walks |
Walk | how for x in walks a container: through what its walk() answers |
From | T.from(u), and what ? converts an error through |
Drop | destruction |
Fn0 … Fn4 | calling a value |
Sequence[T] | fold any all find position count_where each map filter flat_map, on slices and Buf |
Modules and packages
A module is a directory, and every file in it declares the same module path. The module graph
is acyclic.
import sysl.math.max // one member, unqualified
import sysl.math.{max, min} // several
import sysl.math.* // every public member
import sysl.math.{max as bigger} // renamed
import sysl.math // the module: math.max(a, b)
import sysl.math as m // the module, renamed
A public member is always reachable fully qualified; an import only shortens. Resolution is the module the name is written in, then the file’s imports, then the library — innermost first.
A program starts in one place: either top-level statements in one file, or a main(args: []string), never both. That file is the entry file and its top level is a body, so its top-level
vars are locals unless written static.
Capabilities are a module property, written in the file header under module and consistently in
every file: @no_alloc (a promise this module does not allocate) and @requires(heap, os, posix) (a
demand). @needs(...) is the per-declaration form, and it is the one an extern takes.
package.hocon — a project is a directory with .sysl files in it; the manifest is optional.
package {
name = "tool"
version = "0.1.0"
sysl = "0.0.113"
}
capabilities { heap = false }
dependencies {
json { git = "github.com/edadma/sysl-json", version = "1.4.0", mount = "j" }
helper { path = "../helper" }
}
requires {
pkg_config { "yaml-0.1" = "libyaml — brew install libyaml" }
headers { lwip = "the pico-sdk carries them" }
}
A dependency is a git coordinate and a version — no registry, and https:// on the front is refused.
The major version rides in the coordinate from v2 on. Imports are transitive, so a manifest names
only what the project’s own files import. package.sysl is the oldest compiler the project builds
with; a capabilities block says what the machine cannot do, since everything is provided by
default; a requires block names what the consumer must supply a path or a .pc file for.
sysl run · build · build-lib · build-c · test · add · deps · tidy · vendor · doc · emit-llvm · emit-ast · emit-typed · emit-header · weave · tangle · targets · prove — weave, vendor and
targets not yet in this compiler, which answers each with “not a command this compiler can carry
out yet”. An option no command takes
is refused, never passed over. A workspace = [...] list in a root manifest makes several projects
one workspace: one version selection, one sysl.sum, members
depending on each other with { workspace = true }, and -p <member> to pick one.
Async and tasks
async double(n: int) -> int
await yield_now()
n * 2
async total() -> int = await double(3) + await double(4)
print(block_on(total()))
14
- Calling an
asyncfunction runs nothing — it hands back aTask[T].await truns the task to itsT, and is written only in anasyncfunction’s body: not in a closure, not in the entry file’s statements. A task where its value was wanted is refused with a hint toawaitit. block_on(t)drives a task to its end from ordinary code; an executor is written againststep(t, waker),await park(registrar)andawait yield_now(), and lives in a package, never in the standard library.- A task’s frame is on the heap, through a pair of overridable hooks; dropping a task cancels
it, running its
defers and releasing its locals. - A task crosses a concurrency domain only when everything its frame holds — parameters, result,
bindings alive across an
await, values a statement computed before itsawait, the tasks it awaits — is safe to share. A task that awaitsparknever crosses. @export,extern,interrupt, a property and an entry statement cannot beasync.
C interop
@link("m")
@include("<limits.h>")
@include("<stddef.h>")
extern "strnlen" bounded_len(s: *u8, n: usize) -> usize
extern pow(x: real, y: real) -> real
c const
BITS: u32 = "CHAR_BIT"
WIDEST: usize = "sizeof(long long)"
c type
Size = "size_t"
@export("mylib_add")
add(a: i32, b: i32) -> i32 = a + b
- An
externis a header with no body, and that is the whole difference. Externs share the one function namespace, are never generic, and one nothing calls is not emitted at all. The escape analysis assumes the worst of every argument. c constis a value the C compiler works out for the target — it may size an array and stand in an@assert, which a call never can.c typeis a width it works out, for a typedef likesize_torTickType_t.@includeand@linkare file clauses, so a module’sc constblocks belong in one file.@exportpublishes an unmangled symbol;@export("name")chooses it. A callback taking or returning an aggregate by value must be@exported, because&fon it has no C-callable address — so such a binding uses one exported trampoline and dispatches through it.opaque struct Twithholds a layout;*extern(A) -> Ris a function pointer;interruptis a definition the processor enters.- Only three shapes genuinely need a line of C: a caller-allocated opaque type, a macro (a
#define, andstdoutis one on Darwin), and a shape with no sysl spelling — an untagged union, a bitfield, astatic inlinefunction. A struct by value is not one of them, in either direction.
Contracts and attributes
Errors · attributes · verification.
half(x: int) -> int
require x >= 0, "a half of a negative is not what this means"
ensure result >= 0
x / 2
@assert(sizeof(int) == 4, "the protocol fixes this")
@test
halves()
assert(half(10) == 5, "ten")
print(half(10))
5
requireis checked on entry,ensurebefore every return. Both form one block at the top of the body, before any other statement.invariant <bool>clauses sit among a struct’s fields and are re-checked on every write.- Constrained types:
type Name = [new] Base [within lo..hi] [where predicate].newmakes a distinct type;withinandwhereconstrain; all three omitted is a plain alias. - A trap aborts — no unwinding, no
catch, no destructors, nodefer. It comes from an out-of-bounds index, an inverted or out-of-range slice, a failed checked cast, a divide by zero, a brokenrequire/ensure/invariant, orpanic.
| on a function | @test @tailrec @pure @ghost @reads @writes @crossing @export @needs @setup @teardown @setup_all @teardown_all |
| on a type or storage | @packed @align(n) @section("…") @export |
| on the file | @no_alloc @requires(…) @link("…") @include("…") @tests |
| on nothing | @assert(cond, "why") |
| a directive | #if posix / #elif / #else / #endif, read before the lexer, at column 1 |
asm takes one arm per architecture — [x86_64], [aarch64], [riscv64, riscv32], [thumb],
[x86], [wasm32], [craft] — and every architecture needs an answer, unavailable "why"
included.
Idioms, and the tips that keep code in style
Everything here is a house rule the compiler will not enforce. They are what separates sysl that reads like sysl from sysl that reads like C with different punctuation.
Say nothing the context already says.
- No literal suffix where the position gives the type.
if xs.len > 1, not1usize;b[0], notb[0usize]. Arithmetic propagates it, and amatchor aniftakes it from the sibling arm. - The one place to say it is a
varstarting from nothing:var i: usize = 0, because a bare integer literal alone infersint. Better still, reach forfor i in 0..<xs.len, which needs neither. - A suffix is load-bearing in six places, and stays: an array literal’s element type
where every element is literal (
[104u8, 105, …]— one suffix settles the rest, or annotate the binding instead), a range with both bounds literal, aniformatchwhere both sides are literals, a page whose subject is the type, a float width, and an integer outsideint‘s range. - For a byte constant write
u8(';'), never59u8— acharwill not compare to a byte, and there is nob'…'form. - Keep an annotation the compiler asks for:
var head: Buf[&Display] = buf()has nothing to infer from. The test is whether removing it still compiles.
Reach for the form the language names.
loop, neverwhile true. An unconditional loop is one whose exit is inside it, andloopsays so at the head.for x in xsandfor i in 0..<xs.lenover awhilewith a hand-rolled index.xs.map(…),.filter(…),.each(…),.find(…),.fold(…)over a hand-rolled push loop —import sysl.seq.Sequenceis what puts them on a slice or aBuf.for (k, v) in pairs— a pattern in the header, not a destructure in the body.e == Singular— a bare enum already compares.- A
matchor a closure body may be written as an argument, so thevalthat exists only to be used once on the line above the call is noise. break xwith anelse, rather than a found-flag.
end markers are for long blocks. The convention is seven lines head-to-marker inclusive: below
that the marker is noise, and on a four-line struct it is the reader’s first impression of how much
ceremony the language charges. A struct with no fields is the exception and must carry one.
Memory.
- A constructor for a type with a destructor returns
&T, or the destructor never runs. A plain value is copied rather than owned, so animpl Dropon a type nobody boxes is dead code that looks exactly like resource management. It is the type at each level, not the outermost:Buf[&Thing]and a&Thingfield run one;Buf[Thing]and aThingfield do not, and boxing the holder does not save a by-value field. On an error path no value owns the handle and no box is made, so read what you need and close it where it stands. - A method that hands C the address of caller-placed storage takes
*self; one that must outlive the call in something it built takes&self; a read-only accessor may takeself. - Use a
weak Tfor the edge back to a parent, or counting cannot collect the cycle. - A closure crossing a concurrency domain is a
&sync Fn, and what it may capture is checked: a scalar, an array, a&sync Tand a*Tyes; astring, a slice or an ordinary&Tno. A named function reads into one bare —Job(no_job), never&no_job.
Traps to know before they bite.
raw"""strips the indentation of its closing delimiter, not of its content. Indent the"""to match the body, or every line of a PEM or a JSON fixture carries four spaces.- A module-level
valwhose value needs a loop to build shuts the package out of a bare-metal target — there is no entry point and no loader to run the initializer. A scalar, aptr_cast, a struct of literals and even a large array literal all fold; something likebyte_set_of("()[]")does not. Put it in a local, or on a struct built once. A hostsysl test .cannot see this;sysl build-c <dir> --target thumb-freestanding-softfpcan. - A trait member may not collide with an inherent one, and the first diagnostic is the real one —
the ones pointing into the dependency’s own file are the wreckage of the refused
impl. - An associated type does not shadow a module type of the same name, so
type Token = Spanned[Token]is legal and means what it looks like. private[m]takes a simple name; a dotted path is refused at the first dot, with the rule and the simple name to write instead.refis reserved, so a binding ofuv_refneeds another name.- A
val-bound slice read out of a field is[]const T;vargives[]T.
Probe before believing a limit. A comment in a package is evidence about the day it was written,
and this org has shipped three separate claims about things the language could not do that it could
do all along. One program and one sysl build settle it.
Diagnostics you will meet
| it says | change |
|---|---|
'+' needs 'T: sysl.Add' | add the bound the message names to the type parameter |
'T' is a type parameter, so it has no fields to read | no bound can supply a field; take a property through a trait instead |
'?' may only be used in a function returning sysl.Result, not int | give the enclosing function the channel it early-returns into |
this loop breaks with a int but has no 'else' | add the else block that says what a normal finish is worth |
a 'for' names one thing, and a pattern is what takes it apart | for (k, v) in …, with the parentheses |
'is' tests a pattern in the condition of an 'if' or a 'while', and nowhere else | chain with && inside the condition, or write a match |
a pattern under '\|\|' cannot bind a name | write _ for the parts you do not name, chain with &&, or write a match |
already implements 'sysl.Eq' — no variant of it carries anything | delete the block; a bare enum compares already |
'&Cell' and '&sync Cell' are distinct types | allocate it as the one you need where it is constructed |
'require'/'ensure' clauses must come before any other statement | move the whole contract block to the top of the body |
a program starts in one place, and this 'main' is a second | put the top-level statements inside main |
a program starts in one place, and this function exported as 'main' is a second | the export is the program’s main: put the statements, or the declared main‘s body, inside it |
is exported and reaches … module storage an initializer fills | the bare-metal val above: make it a local or a field |
'private[…]' names one enclosing module by its simple name rather than by a path | private[b] — the bracket takes a simple name |
Undefined symbols … _stdout | a macro, not a symbol; put a line of C beside the module |
Next: back to the reference index, or the tour for the same language in the order it makes sense to learn.