sysl

The command line

The subcommands, the flags they share, and what each one leaves as an exit status.

Every subcommand takes a path, and a path is either a project root or a single file. That is not two modes bolted together — a module is a directory and its name is that directory’s path relative to the root, so naming a directory compiles the whole tree under it, one module per directory, and naming a file compiles that file alone. The modules reference has the rule the path is standing on.

The subcommands

what it does
sysl run <path>compile and execute
sysl build <path> -o <exe>compile to a native executable
sysl build-lib <path> -o <artifact>compile a library to a linkable artifact
sysl build-c <path> -o <archive>compile to a static archive and a C header, for a C project
sysl test <path>run the @test functions
sysl emit-llvm <path>print the generated LLVM IR
sysl emit-header <path>print the C header for what a module exports
sysl weave <path>render a literate source as an HTML document
sysl tangle <path>print the program a literate source holds
sysl targetslist the machines sysl can build for

sysl prove is an eleventh, and it has a page of its own — see verification.

A subcommand is required; sysl with none exits 2 and prints its usage.

run

sysl run hello.sysl

Four things in a row: the source is parsed and checked, textual LLVM IR is emitted, clang assembles and links it, and the binary runs. The executable goes to a temporary file and is removed afterwards — run leaves nothing behind.

Everything after a bare -- belongs to the program, not to sysl:

sysl run report.sysl -- --verbose report.txt

The split is made before sysl’s own options are parsed, which is the point: --verbose really is one of sysl’s own options, so without the -- sysl would have taken it — after it, it belongs to the program, and neither side has to know what the other’s flags are called. What arrives at main(args: []string) is the executable’s own path followed by those two words, so args.len is 3 — args[0] is the program, exactly as C’s argv[0] is.

run exits with the status the program exited with. It is running your program, so its status is your program’s — a compilation that failed is what exits 1 on sysl’s own behalf.

The program’s input is sysl’s input, so a program that reads works under run exactly as the built binary does:

printf 'one\ntwo\n' | sysl run count.sysl

What the program writes comes out as it writes it rather than all at once when it finishes, which is what makes a program that prompts usable here at all.

build

sysl build hello.sysl -o hello

The same compilation, stopping at the executable instead of running it. wrote hello goes to stderr so that stdout stays whatever the build was for.

-o is optional, and where it writes depends on what you named. Given a file, the name is that file’s, with its extension dropped, in the current directory: src/tools/fmt.sysl becomes fmt beside you.

Given a directory, the executable goes inside it, named after it. sysl build ., sysl build fmt and sysl build ../fmt are three ways of naming one project, and all three write fmt/fmt — so the answer does not depend on where you were standing when you asked. build-lib follows the same rule, writing fmt/fmt.syslib into the root it was built from.

A project whose package.hocon names dependencies gets them fetched here, if this machine has not got them already — see packages. run and test do the same; there is no separate step to remember, and a project with no dependencies does none of it.

build-lib

sysl build-lib mylib -o mylib.syslib
sysl run prog.sysl --lib mylib.syslib

A library compiled once into the two halves a program links against. See modules for what an artifact holds and why the generic half of it travels as trees rather than as object code.

A library may itself be built on another one, and --lib is what says so — it takes an artifact or a source root here exactly as it does for a compilation:

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

Unlike build, run and test, this does not fetch. A package with a dependencies block is refused rather than resolved over the network, and the message names the dependency and points at --lib. A command whose whole job is to compile one tree into an artifact for one machine should not be the thing that goes looking; the cost is that such a package writes its dependency down twice.

Building one needs an llvm-ar as well as a clang, because a .syslib is an ar archive — installation has the note about which ar and why the platform one will not do.

It compiles the package’s C, so it asks what that C needs. A package that declares its header requirements is refused here without --include-path <name>=<dir>, exactly as it is for a build — this being the command a package is published by rather than merely built by. It is asked for the package’s own manifest and nothing else: the C of a --lib source root is not compiled here, so that root’s declaration is not charged to a library built against it.

build-c

sysl build-c mylib -o libmylib.a
sysl build-c mylib -o libmylib.a --header include/mylib.h

build-lib‘s shape with a different destination: a static archive an existing C project links, and a C header declaring whatever the module marked @export. The compilation is the ordinary one rather than a library build — what is wanted is a module lowered for this target with its calls resolved — and what differs from build is that no entry point is emitted, since the C side supplies its own main.

The header goes beside the archive with .h appended unless --header names somewhere else. Both paths are announced on stderr, along with the archives the C project’s own link line will still need — an unresolved sysl symbol over there reads as a missing definition rather than as a missing archive, so it is worth being told before you meet it. --no-std-lib folds the standard library into the object and the archive then stands alone.

Like build-lib, this needs an llvm-ar as well as a clang. FFI has @export itself — what may be exported, what a symbol is named, and why a computed module val cannot be reached from one.

emit-header

sysl emit-header mylib

The same header build-c writes, on stdout and with nothing built, for a project that generates its headers as a build step.

test

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

The tree is compiled once, into a binary that runs one named test per process, and the runner starts it once per test. Attributes has @test itself — what a test may be, what every other build does with one, and why the process per test is the mechanism rather than a cost.

It takes the search-path flags too--link-path, --include-path and -D, exactly as build does, and it needs them for the same reasons. A tree whose C includes a header the toolchain does not already know about, or whose constants come from a c const block over one, is a tree whose tests have to compile that C as much as its programs do. A package binding a system library is the ordinary case rather than a corner of one, so a test that could not be given those directories would be a test most packages could not run.

--std says the tree is the standard module, which is how sysl’s own library is tested. The compiler supplies sysl to every compilation, so without it the library arrives twice — once as the tree being compiled and once as the copy handed over — and every declaration is already declared. Nothing infers it: a program with a sysl directory of its own is nearly always a mistake, and a build that guessed would turn that refusal into a collision at the link.

The report groups by the file each test was written in, keeps source order inside a file, and shows a test’s output only where it failed:

running 4 tests

clamp.sysl
  ok    clamps below the low bound   6ms
  ok    clamps above the high bound  5ms
  FAIL  leaves a value in range      5ms
        did not return — exit status 1
        at clamp.sysl:31
        > panic: clamp(4, 1, 3) should be 3
  ok    is idempotent                5ms

3 passed, 1 failed — 21ms

A failure’s own line is one of three sentences and never more: did not return — exit status n, which is what a failed assert looks like since assert prints and exits; returned, and was expected to trap; and trapped, but printed nothing holding "…", for a @test(should_trap: "…") whose run trapped without saying it. Everything the run printed follows underneath, prefixed >.

--filter keeps the tests whose name or module holds the text, and the header says how many of how many are running. --fail-fast stops the loop rather than the report: what ran is still reported and what never ran is simply absent, because “skipped” would be a third verdict for something that is not a verdict.

Exit status is 0 if and only if every test that ran passed. A tree with no tests at all, and a filter that matched none of the tests there are, both exit 0 — neither is a failure, and each says which of the two happened rather than leaving one empty report to mean both.

None of that mechanism exists on a microcontroller. A process per test, a name in argv, a verdict in an exit status: a board has none of the three, so sysl test cannot follow the code onto one. sysl.harness is the other half — a framework linked into the image, which names its tests, locates a failure and prints a tally through a writer you hand it. Use this command for everything that runs on the machine you are typing on, and that for the checks that only exist on the target.

emit-llvm

sysl emit-llvm hello.sysl

The IR to stdout, the same text run and build hand to clang. Nothing is assembled and no toolchain is needed for it.

weave

sysl weave guide/lisp/lisp.lsysl -o lisp.html
sysl weave library/sysl/regex -o documents/

A literate source rendered as an HTML document. A .lsysl file is a Markdown document whose four-column-indented part is the program, which is what makes one readable with nothing rendering it — and an indented code block carries no language, so nothing can highlight it. weave tells the renderer that an indented block is sysl, which is the whole of the transformation: the source reaches the renderer exactly as written, and prose, tables, illustrations and heading levels are its own business.

What comes out is one file that opens by itself. It carries its own styling, in a light and a dark palette; its code is coloured by the same grammar this site highlights with; and its mathematics is set by KaTeX, which the page links. That last is the one thing a woven document needs the network for — the prose and the code are markup in the file, so a document read offline loses its equations to TeX source and nothing else.

The output goes to standard output, or to what -o names. A path holding several literate sources writes one document each, and -o then names a directory: a woven document is something somebody opens, so the unit is the file that was written rather than the tree. The ordinary .sysl files alongside are passed over, and a tree with no literate source at all is refused rather than producing an empty page.

It is a source-level command: no target, no standard module, no libraries. A package’s prose is worth reading on a machine that could not build it. What it does share with a compilation is the reading, so a file the compiler would refuse — a tab in an indent, a fence that is never closed — is refused here too, with the same message.

This is not an API reference generated from declarations. sysl has no documentation comment yet, so there is nothing for such a thing to read, and doc is left unclaimed for the day there is.

tangle

sysl tangle guide/lisp/lisp.lsysl
sysl tangle guide/lisp/lisp.lsysl -o lisp.sysl

The other half of a literate system: the program, with the prose stripped. A build tangles anyway — that is how a .lsysl file compiles at all — so what this adds is a way to see it.

That is worth having when a literate file misbehaves. A block indented that should not have been, a fence that swallowed a function: the question is always what the compiler actually read, and this is how to ask. It also hands the program to anything that does not know the format — a tool, a paste, a bug report.

The prose is replaced by blank lines rather than removed, so line 100 of the output is line 100 of the source. That is what lets a diagnostic about the program point into the document it was written in, and it is why the output is not as short as the program looks.

targets

The registry, one line per machine — the name to write after --target, the LLVM triple it stands for, and, for a target sysl knows and cannot build for, why not:

aarch64-macos                arm64-apple-macosx
x86_64-macos                 x86_64-apple-macosx
aarch64-linux                aarch64-unknown-linux-gnu
x86_64-linux                 x86_64-unknown-linux-gnu
riscv64-linux                riscv64-unknown-linux-gnu
x86_64-windows               x86_64-pc-windows-msvc
aarch64-freestanding         aarch64-none-elf
x86_64-freestanding          x86_64-unknown-none-elf
riscv64-freestanding         riscv64-unknown-elf
thumb-freestanding           thumbv8m.main-none-eabihf
thumb-freestanding-softfp    thumbv8m.main-none-eabi
thumb-freestanding-soft      thumbv8m.main-none-eabi
thumbv6m-freestanding        thumbv6m-none-eabi
thumbv7m-freestanding        thumbv7m-none-eabi
thumbv7em-freestanding       thumbv7em-none-eabihf
thumbv7em-freestanding-soft  thumbv7em-none-eabi
riscv32-freestanding         riscv32-unknown-elf
wasm32-freestanding          wasm32-unknown-unknown
craft-freestanding           craft
x86-linux                    i386-unknown-linux-gnu  (no C calling convention has been measured for x86)

The line for the machine you are on is marked (this machine), and a last line repeats what that machine’s own runtime called itself. That last line is there for the case the rest of the list cannot help with: on a machine sysl has no entry for, it is the only place to read what the machine actually said.

Eight of the freestanding rows are 32-bit microcontrollers. The RP2350 — the Pico 2 — boots either a pair of Cortex-M33s or a pair of RV32IMAC cores; the RP2040, the original Pico, has a pair of Cortex-M0+; the Armv7E-M rows are ST’s parts; and Armv7-M is the Cortex-M3. All are here because a microcontroller is what freestanding is mostly for: the three 64-bit freestanding rows reach kernels and hypervisors, which is a different audience. thumb rather than arm names the Arm ones because a Cortex-M executes Thumb only, so an arm written for A32 would assemble for a machine that cannot run it.

The Cortex-M33 has three rows, because neither the float ABI nor the FPU’s presence is sysl’s to pick. thumb-freestanding passes floating-point arguments in VFP registers, which is what eabihf selects; thumb-freestanding-softfp passes them in core registers, which is what -mfloat-abi=softfp means and what pico-sdk builds by default. The two cannot be mixed — GNU ld refuses the link outright, saying one object “uses VFP register arguments” and the other “does not” — so a sysl archive joining a C build has to agree with that build, and offering only the first row meant the C had to be rebuilt to follow sysl. Pick the one your project already uses; if you do not know, softfp is the pico-sdk default.

Both of those rows use the M33’s own unit, which is single precision. f32 arithmetic is instructions and f64 arithmetic is a call into the board’s runtime — __aeabi_dmul and its family — because an fpv5-sp-d16 has no double-precision instructions to select. That is the part rather than a setting, and it is worth knowing before a f64 goes into an inner loop.

thumb-freestanding-soft is the third, and it is for a board with no unit at all. softfp is not soft: -mfloat-abi=soft means no FPU instructions whatever, while softfp uses the fpv5-sp-d16 this core has and changes only the calling convention. That distinction is not something a triple can carry — both rows are thumbv8m.main-none-eabi — so sysl says which it is on every clang command line, with the convention beside it: -mfloat-abi=soft -mfpu=none for the soft row, -mfloat-abi=softfp -mfpu=fpv5-sp-d16 for softfp, and -mfloat-abi=hard -mfpu=fpv5-sp-d16 for the first. None of that is left to the compiler’s default, because the default is not the same one twice: the same triple reports a floating-point unit under some clangs and none under others, so a row that said nothing would mean a different machine depending on which was installed. Reach for it when your board’s headers say the FPU is off, which is where the difference announces itself: CMSIS refuses the build with “Compiler generates FPU instructions for a device without an FPU (check __FPU_PRESENT)”, and every Zephyr MPS2 board is configured that way. Getting it wrong the other way is worse than a refusal — the image links, boots, and takes a fault at whatever arithmetic reached a VFP instruction first.

thumbv7em-freestanding and thumbv7em-freestanding-soft are that same pair for Armv7E-M — an STM32 F4 or H7 with the FPU on, and one with it off.

thumbv6m-freestanding and thumbv7m-freestanding are different architectures, not further conventions. The first is the RP2040’s Cortex-M0+ — Armv6-M, which came before Armv8-M rather than being a subset of its options — and the second is the Cortex-M3. Build an original Pico’s program for thumbv6m; building it for a thumb- row produces instructions the core cannot execute, and the failure is a fault at whatever ran first rather than a refusal at the link. Neither core has a floating-point unit in the architecture, so neither needs a -soft sibling: there was never a second answer to give.

Build a Cortex-M3’s program for thumbv7m rather than for thumbv6m, even though Armv6-M code runs on an M3. What does not survive the substitution is the headers: a real project reads its own configuration, so it takes CONFIG_CPU_CORTEX_M3 to mean Armv7-M and reaches for BASEPRI, while CMSIS reading an Armv6-M triple hands it an intrinsic set that has none.

One thing needs the board’s help on that target, and only one. Armv6-M has no atomic instructions, so a program using &sync — the shared counted reference — compiles to calls the toolchain does not supply, and the link fails naming __atomic_fetch_add_4. Every other program is unaffected, because those calls are emitted only for a program that holds a &sync. The pico package supplies them over one of the chip’s hardware spinlocks, which is what a dual-core part needs — masking interrupts is per-core and would leave the other core free to lose the update.

wasm32-freestanding is the odd row: 32-bit, freestanding, and not a board at all. It is WebAssembly — a browser, wasmtime, or whatever else embeds a module — and unknown-unknown in the triple is the literal truth, so it comes with no libc, no loader and nothing that runs before an exported function is called. Sysl links it with -nostdlib and names main as the module’s entry, which is what makes main reachable and exports it under that name; a program that does not print comes out as a couple of hundred bytes of .wasm that wasmtime will run, and a program that does print fails at the link naming putchar, exactly as on any other bare target.

It needs a clang with the WebAssembly back end. Apple’s has eleven back ends and this is not among them, so on a Mac sysl reaches for Homebrew’s LLVM by itself — the same fallback it already makes for the RISC-V rows — and says so if it cannot find one.

craft-freestanding is 16-bit, and it is the one row sysl will not drive a build for. It is CRAFT — a teaching ISA with eight registers, a 64 KiB virtual address space and a software-managed TLB — whose LLVM back end lives out of tree, so what exists is an llc rather than a compiler driver. The machine has no libc, no object format and no linker: its assembler reads one file and resolves every label inside it. So sysl writes the LLVM and stops, and the rest is two commands of your own:

sysl emit-llvm hello.sysl --target craft-freestanding > hello.ll
llc -march=craft hello.ll -o hello.s
craft as hello.s

Every other subcommand refuses this target and says that. It is not a target sysl cannot lower for — emit-llvm produces an ordinary module — it is one with nothing for a driver to call.

Sixteen bits is the part that shows up in your code. usize is pointer-width by definition, so a slice’s length is a u16 and the address space is the bound on everything. An int is still 32 bits and a long still 64, because a width is the language’s answer rather than the machine’s — so ordinary arithmetic here is multi-word, and the back end expands it, exactly as every 32-bit target already expands a long. Indexing with an int needs no conversion for it: an index wider than an address is checked against what an address can name and then narrowed, so for i in 0..<4 do b[i] … means what it means everywhere else.

x86-linux is listed because it cannot be built for. The limit is the compiler’s rather than the machine’s, and it is no longer the width — this page said so until the 32-bit rows above arrived. What is missing is a C calling convention measured against clang, which is the only way a target’s answers are allowed to be arrived at. A reader who names it is better told that than told the name is unknown.

Freestanding does not mean self-contained. A program built for a bare board still names C symbols its runtime has to define — putchar wherever anything prints, free wherever a reference count can reach zero, memcpy and memset for a structure assignment you never wrote — and on a 32-bit machine it names a 64-bit division helper as well, because a long is sixty-four bits everywhere and neither RP2350 core has the instruction. None of that is a sysl dependency the compiler could warn about: it is what any C compiler emits for the same code, and a real project never meets it because its SDK has linked libgcc or compiler-rt already. Meeting it looks like undefined symbol: __aeabi_ldivmod at the link, which is the one place anybody will come looking for this paragraph.

Flags every subcommand takes

--target <name>the machine to build for; defaults to this one
--lib <path>a library to compile against; may be given more than once
--std-lib <path>a prebuilt standard module
--no-std-libcompile the standard module from source rather than linking a prebuilt one
--ar <path>the llvm-ar to build a library with
--link-path <dir>where to look for a library a link directive named; may be given more than once
--include-path <dir>where to look for a header the C beside a module includes; may be given more than once
--include-path <name>=<dir>the same, and it answers the header requirement a package declared under that name
-D NAME or -D NAME=valuea macro the C beside a module is compiled with; may be given more than once
-O <level>the optimization level handed to clang
-v, --verbosereport what the build decided — the standard module, the files read, the command lines, and where build-lib staged
--explain-escapesreport every local array promoted to the heap

The standard-module flags and -O are covered in installation, including why the default is -O1 and not off.

A module that binds a C library carries C of its own, and that C has to be found, compiled and linked. The three flags answer the three ways it fails, in the order it fails them:

  • --include-path — the shim #includes the library’s header and cannot find it. This is the first failure and the one that surprises, because it happens before anything reaches a linker.
  • -D — the header is found and refuses, because a C project of any size configures its own headers with macros and has not been asked. pico-sdk’s pico/cyw43_arch.h #errors rather than guessing which architecture variant is meant, which is the shape to expect.
  • --link-path — everything compiled and the archive is not where the linker looks.

Nothing is guessed at and nothing is defaulted. sysl does not add /opt/homebrew/lib, and it does not invent a macro: a compiler that ruled on where a platform keeps its libraries, or on how a project configures its headers, would be wrong on a machine nobody here has — and being wrong there costs a build that fails somewhere its author cannot reach. What a project defines is the project’s.

A build system that already knows these will have them: reading them out of CMake is a matter of asking the target for its INCLUDE_DIRECTORIES and COMPILE_DEFINITIONS and handing each along.

A package whose C includes headers it does not carry can say so, and then the first failure above stops being a surprise: written --include-path <name>=<dir>, the flag answers a requirement the package declared, and a build that is missing one is refused by name before clang runs. See packages.

--lib takes either a source tree or an artifact

Which one a path names is read off the name: a .syslib is decoded, anything else is walked as source. That is deliberate — how a library was shipped is the shipper’s business, and a program depending on one should not have to write down which it got. build-lib is what turns the first into the second, and the only difference downstream is what the compilation cost: an artifact is a linear decode where source is a parse.

--target, and the one thing run will not do

Given no --target, a build is for the machine it is running on. If that is a machine sysl has no entry for, it says so and stops rather than guessing, because a wrong guess produces a module that looks right and is not.

run executes what it builds, and only this machine can do that, so a cross target is refused before the build rather than after it:

sysl: error: 'run' executes what it builds, and 'x86_64-linux' is not this machine — use 'sysl build --target x86_64-linux'

A name the registry does not have is answered with the names it does:

sysl: error: unknown target 'arm-linux' — sysl knows aarch64-macos, x86_64-macos, aarch64-linux, x86_64-linux, riscv64-linux, x86_64-windows, aarch64-freestanding, x86_64-freestanding, riscv64-freestanding, thumb-freestanding, thumb-freestanding-softfp, thumb-freestanding-soft, thumbv6m-freestanding, thumbv7m-freestanding, thumbv7em-freestanding, thumbv7em-freestanding-soft, riscv32-freestanding, wasm32-freestanding, craft-freestanding, x86-linux

-v, --verbose

What the build decided, on stderr — which is where wrote <exe> goes, so stdout stays whatever the build was for:

sysl: 1 source file(s) under hello
sysl:   read hello/hello.sysl
sysl: standard module linked from ~/Library/Caches/sysl/<version>-…/std.syslib
sysl: link: clang --target=arm64-apple-macosx -Wno-override-module -O1 …

Three things, and they are the three that have actually been the answer to a question: which standard module the compilation got and whether it was linked or compiled from source, the files it read, and the command lines handed to clang together with the --lib, --link-path and --include-path searches behind them. There are no phase timings: a build that is slow is diagnosed by asking what it did.

build-lib adds a fourth, because it is the one command that writes anywhere but the artifact it was asked for:

sysl: members staged in /var/folders/…/sysl-lib-1729384756

The members are archived under names of their own rather than under whatever a temporary file was called, which is what the directory is for, and it is removed whether the build succeeded or gave up partway. The line is there for the run that is interrupted in between: what is left behind is a directory nothing else would have named.

--explain-escapes

On stderr, one line per local array the compiler moved to the heap, and the view that forced the move:

$ sysl build --explain-escapes tty.sysl
tty.sysl:31:12: 'buf' is promoted to the heap, because this view of it is returned

The position is the view, not the declaration, because that is the half a reader cannot work out for themselves. Where nothing was promoted it says so, in as many words, rather than printing nothing and leaving you to wonder whether the flag took. Memory has what promotion is and when it happens instead.

-O2 is written the way clang writes it

A short option normally takes its value as the next argument, and clang’s optimization flag has been written joined since cc. So -O2 is rewritten into -O 2 before the options are parsed — only that letter, and only where something follows it, so a bare -O still takes the next argument and --optimize is untouched. Nothing in that rewrite can reach the program’s own arguments, which were already split off at the --.

Two combinations that are refused

Neither is resolved by precedence, because whichever precedence were chosen would silently discard half of what was asked for:

sysl: error: --core-lib compiles against the standard module, and 'build-lib --core' is what builds it
sysl: error: --no-core-lib and --core-lib ask for different standard modules

The first is build-lib --core --core-lib x, which cannot mean anything: the declarations being compiled are the ones the artifact holds. The second is the pair of near-identical spellings that a typo produces.

Exit statuses

0it worked — and for test, every test that ran passed
1a compiler diagnostic, a driver error, or a failing test
2the command line did not parse
the program’srun only, once the program has started

A compiler diagnostic is printed exactly as the compiler wrote it, with its location and a caret under the offending column. A driver error — something that went wrong around the compilation rather than inside it — is prefixed sysl: error:, which is why every message quoted on this page carries it and none of the ones on the language pages do.


Next: the tour, which uses run throughout.

Search

Esc
to navigate to open Esc to close