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> | check a library for one machine, writing nothing |
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-ast <path> | print one file’s untyped parse tree, as deterministic text |
sysl emit-typed <path> | print one module’s typed tree, as deterministic text |
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 deps <path> | print the dependency graph the project resolves to |
sysl add <coordinate> | add a dependency to the project’s manifest |
sysl vendor <path> | put what the project depends on into vendor/ |
sysl tidy [<path>] | drop the sysl.sum lines for versions the project no longer resolves |
sysl doc <path> | generate an API reference from declarations and their doc comments |
sysl targets | list the machines sysl can build for |
sysl prove <path> | discharge the contracts with Why3 — see verification |
sysl lsp | serve the Language Server Protocol to an editor, on standard input and output |
A subcommand is required; sysl with none says so on stderr, with its usage, and exits 2.
A word that is not one of these is refused on stderr, naming the commands there are, and exits 2.
Every command is sysl’s own — nothing is looked for on the PATH:
sysl: error: 'wave' is not a sysl command — the commands are run, build, build-lib, build-c, emit-header, weave, tangle, test, emit-llvm, emit-ast, emit-typed, prove, deps, add, vendor, tidy, doc, targets, lsp
What a command was run for goes to stdout, and everything it says about the run goes to stderr —
a diagnostic, a refusal, a mistake in the command line with the usage under it, wrote <exe>, and
what --verbose reports. So sysl emit-llvm hello.sysl > hello.ll holds a module or nothing at all.
sysl --help and sysl --version were asked for, so they answer on stdout with status 0.
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 second run of an unchanged program builds nothing. The executable is kept, keyed over
everything that can reach its bytes — this compiler’s version, the target, the allocator, the
optimization level, every source file the program is made of and the C beside it, every --lib
artifact, the standard module, and the search and link paths. Run it again with nothing changed and
it is the same binary; change a byte of any of those and it is a different key, so there is nothing
to go stale. What is deliberately not in the key is the program’s own arguments, which is the
point: sysl run p -- a and sysl run p -- b are one binary run twice.
What that saves is most of what run costs. In a 9,600-line module with three git dependencies, a
program whose body is print("hi") took 9.2 seconds every time — all of it compilation, none of
it the program, and invisible as such: four timings taken while benchmarking that program’s real work
read 9.6 to 10.1 seconds and appeared to say the workloads were indistinguishable, when the ratio
between the fastest and slowest was about 45x.
test gets the same treatment, which is where it is felt most often — a suite that recompiles on
every run is the loop somebody is actually sitting in. --filter picks from the list of tests rather
than deciding what is compiled, so it is not part of the key.
SYSL_NO_CACHE set to anything non-empty compiles every time. It is for working on the
compiler, where the version in the key stands still while the bytes it produces do not — the one
case a cache cannot see, and the same one the standard module’s cache has. No other subcommand
consults it: build writes a binary somebody named, and build-c and build-lib write artifacts
for somebody else’s toolchain.
SYSL_TIMINGS=1 says how long each phase of a compilation took, one line on standard error as
each ends:
sysl: phase parse std 130ms
sysl: phase parse program 0ms
sysl: phase declarations 52ms
sysl: phase bodies 23ms
sysl: phase checks 4ms
sysl: phase prune 0ms
sysl: phase std 33ms
sysl: phase codegen 2ms
sysl: phase link 90ms
The phases add up to the compilation, nothing counted twice. std is finding the standard module’s
compiled artifact, or building it the first time, which is then one line rather than a compilation’s
worth. It is the instrument for a build that is slow and does not say why.
Where a phase is slow, a sampling profiler says which functions. On a hosted target sysl keeps
the frame pointer the platform’s C keeps (Apple’s, and AArch64 Linux’s and Android’s), so sample
on macOS or perf on Linux walks a sysl program’s stacks – the compiler’s own included – and
reports callers as well as the functions the time was spent in.
The kept binaries, and the C objects every build keeps beside them, are bounded. Each directory
— ~/Library/Caches/sysl/run/ and ~/Library/Caches/sysl/cobj/ on macOS, under ~/.cache/sysl/
on Linux — holds at most 4 GB: past that the oldest entries go, though never one written in the
last hour and never one the build in hand used. Using an entry — a hit — refreshes its age, so
“the last hour” means used in the last hour and the oldest are the least recently used. Past 256 entries, anything a week older than the
newest goes too. The sweep runs once, when a build that compiled something is done, and is skipped
where another build swept the same directory in the last ten minutes. Deleting either directory
costs one rebuild and nothing else. The same bound, sweep and sparing rules hold for the compiled
roots under roots/ and for the standard module’s own entries (one directory per compiler build,
target and optimisation level, directly under sysl/), which are removed whole, oldest first, and
never the one the running compiler uses.
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.
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 --target thumbv7em-freestanding
checked mylib
A library run through the whole analysis, against the standard module, for one machine — and nothing
written. It is the only check a board package gets on a host, which is what it is for. A library is
handed to a program as the directory it is written in, never as an artifact somebody names
(modules has why), so build-lib takes no -o:
sysl: error: build-lib writes nothing anybody names — it checks a library, and what it compiles is this compiler's cache, found by the compiler alone — so it takes no -o
A library may itself be built on another one, and --lib naming the other’s directory is what
says so, exactly as it does for a compilation.
Unlike build, run and test, this does not fetch. A dependencies block is resolved as theirs
is — optional dependencies no feature turns on are pruned first, and the feature
flags choose which those are — but only over directories: a path dependency is
read where it is, and a coordinate has to be answered by a --lib root or a path dependency that
stands in for it. One that
nothing answers is refused, naming the coordinate and the directory that would. A command whose whole
job is to check one tree for one machine should not be the thing that goes looking.
--cc names the clang, where a search would not find the right one.
sysl build hello.sysl --cc /opt/homebrew/opt/llvm/bin/clang
They are the answer to a machine with more than one toolchain on it: a vendor’s clang trimmed to that vendor’s processors, an LLVM installed beside the system one, a cross toolchain that has to be reached by path. A named compiler is not searched past and not fallen back from — somebody who wrote down which compiler to use is owed an error rather than a different compiler — so naming one that cannot run is refused rather than quietly replaced.
--cc reaches every place sysl runs clang: the link, a package’s carried C, the c const probe, and
the standard module’s own rebuild. That last one is the reason it is worth saying: the standard
module is rebuilt automatically when nothing usable is at the default path, and a flag that stopped
applying at that moment would change compiler halfway through a build and blame the library.
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 archive holds native objects by default: a manifest’s lto key does not
apply to build-c, since LLVM bitcode links only under a linker with LLVM’s plugin and optimizing
across the archive is the C project’s own link’s business. A C project that links with clang and
lld can ask for that on the command line: --lto thin (or full) makes the archive LLVM bitcode,
compiled as sysl build compiles its own objects under that mode, so the host’s link optimizes
across the boundary — and build-c says on stderr that the archive must be linked with
-fuse-ld=lld.
The header goes beside the archive with .h appended unless --header names somewhere else. Both
paths are announced on stderr, along with the libraries the C project’s own link line will still need
— what @link named, and the pkg_config modules the packages in the build require, given as the
pkg-config --libs … to ask for their flags. 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 how module storage a computed initializer
fills is filled here, where there is no entry point to fill it in.
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> --file <name>
sysl test <path> --fail-fast
sysl test <path> --jobs <n>
sysl test <path> --timeout <seconds>
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 runs the tests of the tree you name, and no other. A dependency is
compiled into the binary because the tree’s code calls it, but its own @test functions are left
out: they are its author’s to run, in its own repository, and a consumer’s run reporting them would
blame the consumer for code it cannot change.
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.
It takes the feature flags too, and given none of them it enables every
feature the manifest declares rather than only default.
It also compiles each program in the root’s tests/refused/, one at a time with the package’s
modules in scope, and passes one only where the compiler refuses it with a diagnostic containing every
// error: line at its head. Those rows are named by path, counted with the rest and selected by
--filter (packages).
--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 >.
The hooks a module may write change what a
FAIL line can be about, without adding a fourth verdict. A @setup or
@setup_all fault never runs the test it was guarding, so there is no assertion inside the test to
report — the test’s own FAIL line names the setup function instead, in place of the usual sentence.
A @setup_all fault guards the whole module, so every test in it fails that way and the run reports
none of them as having started. A @teardown or @teardown_all fault does not touch the test’s own
verdict, since the test has already returned or trapped by the time its teardown runs — it is reported
as a failing entry of its own, named after the hook, alongside whatever the test it followed reported.
--filter keeps the tests whose name or module holds the text, and the header says how many of
how many are running.
--file <name> keeps the tests written in one file — the file a report heads their rows with. The
name is the file’s base name, with or without .sysl (--file clamp and --file clamp.sysl are the
same), or as much of its path as it takes to tell two files of one name apart (--file geom/clamp).
It matches whole path components, never a piece of one, so --file clamp does not also run
clamps.sysl, and a --filter keeps selecting exactly what it did: the two flags ask different
questions, and given both a test has to answer yes to each. A refused program under tests/refused/
is in a file of its own, so --file picks one of those out the same way.
The tests run several at once — as many as the machine has processors, or --jobs <n> — and
the report is the same report a run of one at a time writes: each row waits for every row above it,
so the rows still come in source order, and --jobs 1 is that run. Each test already has a process
of its own, so running several at once shares nothing between them that running them one after
another did not. What they can still share is whatever outlives a process — a file at a fixed path,
a port — and a test that needs one of those to itself makes it its own, with a temporary directory or
a port the system picks, or is named in its manifest’s
test.alone, which runs it with nothing beside
it. The manifest’s test block can also set the run’s jobs and timeout, which the flags beat. A
module’s @setup_all is in before any of its tests starts, and its
@teardown_all starts once every one of them is in.
--fail-fast stops the starting rather than the report: the tests already running finish and are
reported, what ran is still reported, and what never started is simply absent, because “skipped”
would be a third verdict for something that is not a verdict.
Every test has a time limit, so a test that never ends fails rather than hanging the run.
--timeout <seconds> sets it for each test the run starts — 300 seconds when it is not given, and
--timeout 0 for none at all — and a test’s own @test(timeout: n)
takes its place for that test. A test that outlasts its limit is stopped, together with everything it
started, and its FAIL line reads timed out after Ns, with whatever it had printed by then
underneath; the run carries on with the next test. A module’s hooks are held to the run’s limit.
A test process does not outlive its run. Each one watches the runner that started it, and if the runner goes — interrupted, killed, its terminal closed — the test ends itself and everything it started, so a stopped run leaves nothing behind it still running.
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 but for one line: the printed
module’s header is target triple alone, while the text clang compiles also carries the target’s
target datalayout, asked of that clang. Without it LLVM would read every 64-bit access the text
leaves unannotated as four-byte aligned, which on a machine with no unaligned access (riscv64) splits
each one in two. Nothing is assembled and no toolchain is needed for it.
emit-ast
sysl emit-ast hello.sysl
sysl emit-ast hello.sysl --no-spans
One file’s untyped parse tree, as deterministic text — one node per line, indented for its
children, with every field the tree carries. It is parse only: no analysis runs and no standard
module is read, so it works on a file that would otherwise fail to compile, and it needs no target’s
toolchain. --no-spans leaves out each node’s source position, for a diff that does not move when a
line does. A parse error exits non-zero and prints the ordinary diagnostic instead.
emit-typed
sysl emit-typed hello.sysl
sysl emit-typed hello.sysl --no-spans
sysl emit-typed hello.sysl --tables
One module’s typed tree, in the same text emit-ast writes — emit-ast‘s analysed counterpart.
It runs the whole front end: parsing, then analysis against the standard module exactly as
emit-llvm reads it, with every name resolved and every type solved. Nothing is lowered and nothing
is emitted — no pruning, no codegen, no clang — so it needs no target’s toolchain, the same way
prove does not. Every expression’s resolved type is printed as the compiler’s own diagnostic text
(int, a slice of u8, Point) rather than as the type’s internal structure. --no-spans leaves
out each node’s source position, exactly as it does for emit-ast. --tables prints the module’s
declaration tables instead of its tree — every struct, enum, trait implementation, extern, module
val and function, sorted by name, with every signature resolved. An analysis error, or a parse
error, exits non-zero and prints the ordinary diagnostic instead, with nothing on stdout.
weave
sysl weave library/sysl/regex/vm.lsysl -o vm.html
sysl weave library/sysl/regex -o documents/
weave [-o <path>] <path>. 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 sets exactly the lines the compiler reads as program text as sysl code, one
block through any blank lines between them, and renders every other line as Markdown: prose, tables,
illustrations and heading levels are the document’s own business.
The Markdown is sysl-lang/markdown‘s: CommonMark 0.31.2
with what GitHub renders — tables, strikethrough, bare links, task lists, footnotes, emoji
shortcodes, alerts (> [!NOTE]), mathematics as $…$ and $$…$$, and mermaid diagrams — and every
heading given GitHub’s id, so # The greeting is <h1 id="the-greeting">. The whole file is one
document, so a link reference defined anywhere in the prose reaches a link anywhere else, and a
program block between them closes whatever paragraph, list or quote was open. Raw HTML passes
through, under GitHub’s tag filter.
What comes out is one file that opens by itself. It carries its own styling, in a light and a dark
palette; its code — every block whose language is sysl, so an illustration fenced as sysl too — is
coloured by keyword, type, function, string, number, comment and punctuation, the keywords being the
lexer’s own reserved words; 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.
$ cat greeting.lsysl
# The greeting
A program of one statement.
print("hi")
$ sysl weave greeting.lsysl
<!doctype html>
…
<h1 id="the-greeting">The greeting</h1>
<p>A program of one statement.</p>
<pre><code class="language-sysl"><span class="hl-function">print</span><span class="hl-punctuation">(</span><span class="hl-string">"hi"</span><span class="hl-punctuation">)</span>
</code></pre>
…
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. That is doc, further down: sysl
has documentation comments and sysl doc reads them.
The two answer different questions — weave renders a document somebody wrote, doc renders what
the declarations say — so a tree usually wants both, and neither is a substitute for the other.
tangle
sysl tangle library/sysl/regex/vm.lsysl
sysl tangle library/sysl/regex/vm.lsysl -o vm.sysl
tangle [-o <path>] <path>. The other half of a literate system: the program, with the prose
stripped, written to standard output or to what -o names. A path holding several literate sources
gives their programs one after another; the ordinary .sysl files beside them are passed over, and a
tree with none is refused. 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.
deps
The dependency graph the project resolves to: every package it will be compiled against, the version selection settled on, and who asked for that package and at what floor.
sysl deps .
syslui-demo 0.0.1
github.com/sysl-lang/plutovg 0.2.1
github.com/sysl-lang/syslui-sdl asks for 0.2.1
github.com/sysl-lang/syslui asks for 0.2.1
github.com/sysl-lang/sdl3 0.3.1
github.com/sysl-lang/syslui-sdl asks for 0.3.1
github.com/sysl-lang/syslui 0.1.0
github.com/sysl-lang/syslui-sdl asks for 0.1.0
github.com/sysl-lang/syslui-sdl 0.1.0
syslui-demo asks for 0.1.0
That project’s manifest names one dependency, which is what makes the command worth having: imports are transitive, so three of those four packages are ones nobody typed, and nothing in the files in front of you says they are there.
A coordinate marked (raised) is one somebody asked for a lower version of.
github.com/e/buf 1.4.0 (raised)
github.com/e/a asks for 1.2.0
github.com/e/b asks for 1.4.0
That is the answer to why is this version the one I got, and it is the half a build cannot tell you: selection keeps the highest minimum and nothing else, so the claim that lost is gone by the time anything could go looking for it. The note a build prints covers only the case where your own manifest was overtaken; this covers a dependency overtaken by its sibling, which is the case where the version you are running is one no file you own mentions.
A coordinate marked (dev) is one only this project’s tests reach.
github.com/sysl-lang/quickjs 0.1.0 (dev)
It is declared in dev_dependencies, so
sysl test resolves it and nothing that depends on this project ever fetches it. The listing shows
it because what a project takes is a property of its manifests rather than of any one build — and
the mark is what says a consumer will not be paying for it.
It is a mark rather than a section of its own, because a package reached through a dev dependency is in exactly the same position without being named in either block, and a second section would have nowhere to put it.
A path dependency prints its directory where a coordinate prints a version, since it has none and the directory is the only thing that says which tree it is.
The command resolves exactly as a build does, so it fetches what this machine has not got and records
what it got in sysl.sum. It asks nothing of a machine beyond that — no target, no standard module,
no source is read — so a project that cannot be built here can still be inspected.
add
A dependency written into package.hocon, at the newest version the repository has tagged or at one
you name.
sysl add github.com/sysl-lang/sdl3
sysl add github.com/sysl-lang/sdl3@0.3.1
added sdl3 github.com/sysl-lang/sdl3 0.3.1
The version comes from git ls-remote --tags, not from a forge’s API — a coordinate here is a git
identity and the build already clones it with plain git, so this works for a self-hosted server or a
mirror as well as for GitHub. A tag that is not a version is passed over rather than refused, since
repositories carry latest and nightly and neither is something a manifest can pin.
Your manifest is rewritten one run of bytes at a time, and nothing else in it moves. Printing a parsed value back would emit the value and nothing else — every blank line, every comment explaining why a dependency is there, and your own layout would be gone the first time a tool touched the file. A new entry takes the indent its siblings have and lines up with their column where they have one.
Nothing is fetched: this records what the project takes, and the next build goes and gets it. The result is read back through the manifest reader before it is written, so a rewrite that produced something unreadable leaves the file exactly as it was.
vendor
Every package the project depends on, put in a vendor/ directory beside the manifest.
sysl vendor .
fetching github.com/sysl-lang/parsing v0.12.0
vendored 1 package into vendor/
It is the machine’s package cache moved into the project, rather than a second mechanism beside
it — the same layout, the same resolution, the same sysl.sum. A project that has a vendor/
builds with the network off, and somebody who clones it needs to know none of this: the directory
being there is the whole of what turns it on.
The graph is resolved against the machine’s cache first, so anything the machine has not got is
fetched (the fetching line, on standard error) and anything sysl.sum has no line for gets one,
exactly as a build would. Each package is then copied out of the cache, and the copy’s hash is
checked against its sysl.sum line before it is put in place: a cached package that somebody edited
after it was fetched is refused rather than frozen into your project. A repository’s .git is left
behind. Running it again copies only what is not there yet.
What goes in is the graph sysl test resolves — every feature the project declares, unless the
feature flags say otherwise, and its dev_dependencies as well as its
dependencies — so a vendored project’s own tests fetch nothing either. Inside a
workspace there is one vendor/, at the workspace root, holding
every member’s packages.
vendor/ is not part of the project’s own source, exactly as
examples/ is not, so nothing in it is compiled
as one of your modules. Commit it where you want a build that cannot be broken by an upstream
disappearing; leave it out of the repository where you would rather fetch.
A path dependency is not vendored and cannot be. It is a directory you are editing beside this one — which is why nothing keeps a sum for it — so freezing a copy is the one thing it is there not to do. The command says how many there were.
tidy
sysl.sum, cut down to the packages the project resolves to now. A build only ever adds a line to
it, so a version the project has moved off stays recorded until this runs.
sysl tidy
removed github.com/sysl-lang/skitter v0.1.0
removed github.com/sysl-lang/box2d v0.1.0
It takes the working directory where no path is given. The graph it resolves is the whole one —
every feature the manifest declares and the dev_dependencies too — because a build that pruned
would drop the lines of whichever features it had not been asked for. A line that stays is left
byte for byte where it was, a file that is already tidy is left alone with nothing printed, and a
project depending on nothing but paths ends with no sysl.sum at all.
--check writes nothing and fails where the file is not tidy, naming what it would change — the
form to put in CI. Outside a project, with no package.hocon, the command is refused.
reference/packages.md has the rule.
doc
An API reference for a tree, generated from its declarations and their doc comments — one Markdown page per module.
sysl doc library --out docs/api
sysl doc: 27 modules -> docs/api
It writes Markdown rather than a website, against what scaladoc, javadoc and rustdoc all do, and
the reason is what a package repository is for. A docs/ folder of generated Markdown is readable in
the GitHub UI with no tooling, no hosting and nothing installed; Rust needs docs.rs — an entire
hosted service — to solve exactly that for crates. It is also diffable, where generated HTML cannot
be reviewed in a commit.
The unit is a module, not a type, because a module is what you import.
| Flag | Means |
|---|---|
-o, --out <dir> | where the Markdown goes (default docs/api) |
-t, --title <text> | the index page’s title |
-n, --note <text> | a note under the index page’s title |
-V, --docversion <v> | the version being documented, shown on the pages |
--private | include file- and module-private declarations |
--site <dir> | after writing, build the juicer site rooted at <dir> |
--check | write nothing; exit non-zero if what is committed is stale |
--check is what keeps committed documentation honest. Generated files that live in a repository
go stale, so the answer is a job that regenerates and compares:
sysl doc library --out docs/api --check
sysl doc: docs/api is stale — 2 file(s) differ:
sysl-text.md
sysl-slices.md
Regenerate with 'sysl doc' and commit the result.
It exits 1 there and 0 when everything matches, so it drops straight into CI and fails a build
where somebody edited a doc comment and did not regenerate.
It reads only the syntax. Nothing is analyzed, so a package whose dependencies are missing — or which does not currently compile — still documents, which is exactly when somebody is trying to read their way out of trouble.
A literate source is tangled first, so a .lsysl file’s narrative never reaches the generator: that
prose is an essay about the program and belongs to weave. A doc comment written inside
its code blocks is read like any other. A literate module whose author explained everything in the
narrative therefore produces bare signatures here, and that is the honest answer rather than a defect.
The site is somebody else’s
sysl doc is built into the compiler, and what it writes is Markdown and nothing else. --site
hands the pages to the juicer command, found on your PATH, to build the HTML: generating a
site means a templating engine, an asset pipeline and a web server, and a systems compiler has no
business carrying any of that. Without a juicer it says so and where one comes from.
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-android aarch64-linux-android24
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
thumbv7em-freestanding-dp thumbv7em-none-eabihf
riscv32-freestanding riscv32-unknown-elf
wasm32-freestanding wasm32-unknown-unknown
wasm32-wasi wasm32-wasip1
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 says which processor
and system sysl reads this machine as — this machine reports: aarch64 / macos on an Apple Silicon
Mac. 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 sysl took it for.
aarch64-android is the one row whose triple carries a version number, and the one that needs
something set in your environment. The 24 is an Android API level — which of Bionic’s declarations
exist — and it is in the triple because clang requires it there: without a level, no __ANDROID_API__
is defined and the first system header that guards a declaration on it refuses to compile. Its C
calling convention is aarch64-linux‘s, because AAPCS64 is AAPCS64; what differs is everything above
the ABI, which is why it is a system of its own and #if android is a symbol distinct from
#if linux.
What it needs is which clang. Every other target here is served by a compiler that has the right
back end, and having the back end is not the same as having the toolchain: Android’s headers and
libraries are the NDK’s, and no clang outside it carries them. So sysl asks the environment.
ANDROID_NDK_ROOT or ANDROID_NDK_HOME names an NDK outright; otherwise ANDROID_HOME names the
SDK and the newest ndk/<version> under it is used.
export ANDROID_HOME=~/Library/Android/sdk
sysl build --target aarch64-android hello.sysl
ANDROID_SDK_ROOT is read too and means the same thing, but Android’s own documentation marks it
deprecated in favour of ANDROID_HOME — so set that one, and if you already have both, keep them
pointing at the same directory, which is what Android Studio and the Gradle plugin check.
Nothing is guessed at. An NDK sits wherever you installed it, so a compiler that went looking through your home directory would find one on the machine it was written on and the wrong one — or none — anywhere else, and building against the wrong platform headers is a failure you would not see. With nothing set, the build stops and says what to set:
sysl: error: building for Android needs the NDK's own clang, and nothing here says where it is — no clang outside the NDK carries Bionic's headers, so one picked for having the back end fails at the first '#include'. Set ANDROID_HOME to the Android SDK (the directory holding 'ndk/'), or ANDROID_NDK_ROOT to one NDK directly
That message exists because the alternative was worse: pick the host’s clang for having aarch64, and
the build dies on a missing dirent.h inside the standard library’s own C — which reads as a broken
library rather than as the wrong compiler.
Naming the NDK is the whole of what is required. Its clang resolves a sysroot from its own location,
so there is no --sysroot to pass and no include or library path to set up: the result is an ordinary
position-independent Android executable, which is what an APK loads.
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 — __aeabi_dmul and its family, which the program carries
in sysl.softfloat.binary64 — 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 with the FPU on, and one with it off.
thumbv7em-freestanding-dp is the Cortex-M7’s double-precision unit. The thumbv7em-freestanding
row names an fpv4-sp-d16, which does single precision in hardware and calls a library routine
(__aeabi_dmul and the rest) for every f64 operation. A Cortex-M7 with the FPU configured double
(an STM32 F7 or H7) has an fpv5-d16, so this row — the same triple, -mfloat-abi=hard, and
-mfpu=fpv5-d16 — lets an f64 multiply select vmul.f64. Use it only on a part that has the double unit:
on a single-precision-only M7 the first f64 instruction takes a usage fault.
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.
wasm32-wasi is the same machine with a libc under it, and it is the row to reach for unless you
know you want the bare one. WASI is a standardised set of imports — file descriptors, clocks,
randomness, arguments, exit — that a module asks its host for by name in place of syscalls, and
wasi-libc is a real libc built on them. So a program that prints links and runs, sysl.fs and
sysl.env have something underneath them, and nothing has to be written by hand before the first
build:
export WASI_SDK_PATH=~/wasi-sdk-34.0-arm64-macos
sysl build --target wasm32-wasi hello.sysl -o hello.wasm
wasmtime hello.wasm
It needs wasi-sdk, which is clang, wasi-libc and a sysroot in one download from
the project’s releases. That is the same
arrangement Android’s NDK is under and for the same reason: having the wasm32 back end is not
having the toolchain, so a clang picked for the back end alone succeeds at the search and fails at
the first #include. Nothing is guessed at, and with nothing set the build stops and says what to
set.
The version is preview1, which is what every runtime supports today and what the browser shims
(@bjorn3/browser_wasi_shim, Node’s node:wasi) implement. preview2 — the Component Model, with WIT
interfaces and resource handles — produces something that is not a core wasm module at all and that
browsers do not run natively; the standard preview1-to-preview2 adapter lifts a module into a
component, so nothing here is a dead end.
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. 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 a libc already.
What the program does carry is the arithmetic the machine has no instruction for. A long is
sixty-four bits everywhere, and no Cortex-M or 32-bit RISC-V core divides one, so dividing it — and
rendering it, which divides by ten, and so any reachable panic — is a call to compiler-rt’s
__aeabi_ldivmod or __divdi3. An f64 on a machine with no double-precision unit is a call per
operation, __aeabi_dmul or __muldf3. The standard module defines those routines
(sysl.softint, sysl.softfloat.binary64), weak, and a build for a 32-bit machine keeps them exactly
where a reached body divides a 64-bit integer or computes with an f64 — so an image links with
-nostdlib, and a libgcc that defines them too simply wins. An f32 on a core with no unit at all
— a Cortex-M0+ or M3, RISC-V with no F — is the same: __aeabi_fmul or __mulsf3, kept where a
reached body computes with one. Armv6-M lacks more: 32-bit division, a 64-bit multiply and
variable shift and a leading-zero count are all calls there, which LLVM makes from code that spells
none of them, so sysl.softint is kept in every Armv6-M build. Every Thumb core calls the
run-time ABI’s __aeabi_memcpy4 and its relatives for a large struct copy, so sysl.softint.aeabi
lays them down in every Thumb build, over C’s memcpy and memset — which is all the board still
supplies, as any freestanding target’s does.
lsp
sysl lsp is a Language Server Protocol
server on standard input and output, for an editor to start and talk to. It takes --stdio, which is
what an editor’s client library passes, and no other option. Standard output belongs to the protocol;
anything the server has to say about itself goes to standard error.
Every answer comes from the same analysis sysl build makes, run again, whole, on each change to an
open file — its text as the editor holds it, saved or not:
| request | answer |
|---|---|
textDocument/didOpen, didChange (full text) | textDocument/publishDiagnostics: the file’s syntax error, or every diagnostic the analysis reports, ranged in UTF-16 columns as the protocol counts them — for the file itself and for every other file the analysis found a mistake in, open or not; a file whose mistakes are gone gets an empty list |
textDocument/hover | the type of a local or a module val/var, or a function’s signature |
textDocument/definition | where that name is declared — in the file, another file of the package, or the standard library |
A file is analyzed with the package whose package.hocon is nearest above it, or alone where there
is none. The standard library is parsed once and kept for the life of the server, except while the
editor holds unsaved text for one of the library’s own files. A message that is not JSON is answered
with the JSON-RPC parse error (-32700); a request the server does not answer, with -32601. The
server ends at exit, with status 0 after shutdown and 1 without; input that ends or cannot be
framed ends it with status 1.
Visual Studio Code: the extension in editors/vscode of the compiler’s repository starts
sysl lsp --stdio and colours sysl with the grammar the documentation uses. Its settings are
sysl.path (the compiler, sysl on the PATH by default) and sysl.lib (passed as SYSL_LIB).
Flags every subcommand takes
--target <name> | the machine to build for; defaults to this one |
--lib <dir> | a library’s source directory to compile against; may be given more than once |
--no-std-lib | compile the standard module from source rather than linking the cached one |
--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=value | a macro the C beside a module is compiled with; may be given more than once |
-O <level> | the optimization level handed to clang |
--lto <mode> | optimize across every object at the link: thin or full |
--link <how> | link pkg_config libraries from their static archives: static, dynamic, or a comma-separated list of their names |
--profile-generate <dir> | build an instrumented program that writes its counters into this directory |
--profile-use <file> | build against a merged profile |
--no-bounds-locations | leave the file:line:column out of a failed bounds check’s report — the bounds_locations key for one build |
--general-regs-only | keep the code out of the floating-point and SIMD registers — the codegen block for one build |
--strict-align | make every access aligned — the codegen block for one build |
-p <member>, --package <member> | in a workspace, the member to build, test or run |
-v, --verbose | report what the build decided: the files read, the standard module and how it was reached, the search paths, the allocator, and the clang and linker command lines |
--explain-escapes | report every local array promoted to the heap |
An option no command takes is refused, never passed over. Each mistake is named on a line of its
own, the command’s usage under them, on standard error, and the exit status is 2 — so a misspelt
--relase is never quietly built as a debug binary, and one taken for the path is never compiled:
Error: Unknown option --relase
usage: sysl build [--target <name>] [--cc <path>] [-O<level>] [-o <exe>] …
An option that wants a value and was given none says Error: Missing value after --target the same
way. An option one command takes and another does not — -o on test — is unknown to the second.
A lone - is a path, and what follows -- in run is the program’s, options included.
The standard-module flags and -O are covered in
installation, including why the default is -O1 and not off.
--lto is the flag form of the lto manifest key,
--link of the link key, and the two profile flags are the ends of the workflow below.
Profile-guided builds
A profile-guided build is three steps and two of them are sysl’s. The optimizer is told what the program actually did — which branch is taken, which call is hot, which function is never reached — and lays it out around that, rather than around what a static reading of the code suggests.
sysl build . --lto thin --profile-generate prof -o myprog-instr
./myprog-instr <each job in the training set>
"$(clang -print-prog-name=llvm-profdata)" merge -output my.profdata prof/*.profraw
sysl build . --lto thin --profile-use my.profdata -o myprog
- Instrument.
--profile-generatereaches every clang the build drives — the module, each C file a package carries, and the link, which is where the counting runtime comes from. The resulting program is larger and slower, which is what instrumentation is. - Train. Run it over work that resembles the work it is for. Each run writes into the directory
named, under a name derived from the binary, and the runtime merges into that one file rather
than overwriting it — so twenty-three training runs leave one
.profrawholding all of them. - Merge, then build against the result.
--profile-usewants the indexed profilellvm-profdata mergewrites, not the raw counters the program wrote.
Asking for both flags at once is refused, since clang takes both and quietly instruments the build you asked it to optimize. Neither is a manifest key: a profile describes one measurement on one machine, and a path to it in a file a consumer reads is a path that is wrong for everybody but its author.
Use the llvm-profdata the compiler names, not the one on the PATH
A .profraw carries a format version, and llvm-profdata reads only the version its own LLVM
writes — so the tool has to match the compiler, not the machine. On a Mac those are routinely
different, and the failure reads as a corrupt profile rather than as the wrong tool:
warning: default_15853205779378358405_0.profraw: raw profile version mismatch: Profile uses raw
profile format version = 10; expected version = 11
error: no profile can be merged
clang -print-prog-name=llvm-profdata is the question that cannot get this wrong: it asks the driver
where its own tools are. Apple’s clang answers with the one inside Xcode’s toolchain, which is
nowhere on the PATH, while the llvm-profdata Homebrew’s LLVM puts on the PATH is from a
different LLVM entirely.
Nothing leaks into an ordinary build
A build that did not ask for instrumentation carries none of it: nm over a plain build finds no
__llvm_profile symbol at all, and the binary built with --profile-use finds none either — a
profile is read at compile time and leaves nothing behind.
What it was worth
Measured on the slate interpreter, trained over its own benchmark suite and measured on the same
suite by alternating best-of-9, on top of -O2 with thin LTO:
| geomean | binary | |
|---|---|---|
-O2, --lto thin | — | 6,013,000 bytes |
| the same, instrumented | (not for use) | 10,162,936 bytes |
the same, --profile-use | −12.5 % | 5,880,888 bytes |
Against the plain -O2 build the two levers together are about −31 %, and the profile-guided
binary is smaller than the one that never saw a profile — cold code is laid out where it does not
crowd the hot path. Every one of the 64 programs checked answered byte-identically.
Training on the work you then measure flatters the result, and the table above does exactly that. A profile is worth what it is worth for jobs the training set resembles; for a workload nothing in training looked like, expect less.
The feature flags
run, build, build-c, build-lib, test, deps and vendor take three more, which choose the root
project’s features — and so which optional dependencies are fetched
at all:
--features <a,b> | a feature of the root project to turn on, beside default; comma-separated, and may be given more than once |
--no-default-features | leave the root’s default feature off |
--all-features | turn on every feature the root’s manifest declares |
--all-features may not be combined with either of the others, and that is a third refusal of the
same kind as the two below: it says everything the manifest declares is wanted where they each say
something narrower about the same set, so no precedence could keep both answers.
sysl: error: --all-features says every feature the manifest declares is wanted, and
--features/--no-default-features each say something narrower about the same set — a compilation
cannot ask for both
sysl test given none of the three enables every feature the manifest declares, because gated
code no build compiles is gated code nobody is testing. Naming one turns that off, so a suite may be
run at exactly the configuration a consumer would get.
--link-path, --include-path and -D are three steps of one thing
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’spico/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 a source tree
A library is handed over as the directory it is written in — a
source root — and what the compiler makes from it is its own
cache, which nobody names. A path to a .syslib is refused:
sysl: error: --lib takes the directory a library's source is in, and 'mylib.syslib' is a '.syslib' — a library is handed over as the tree it is written in, and the compiler keeps its own cache of what it compiled from one, which nobody names
A source tree that is a dependency’s package overrides that dependency. Building against a working copy of something the manifest names is one flag, with the manifest left alone:
sysl build . --lib ../geom
Where ../geom/package.hocon names the package geom and the project depends on
github.com/e/geom, the checkout is what is compiled — for that dependency and for any other package
in the graph that depends on it — and the coordinate is never fetched, so a version nobody has
published yet is no obstacle. A tree that only holds a module a dependency also offers, without
being that package, is refused as a collision instead.
reference/packages.md § A source root stands in for the package it is
has the rule.
--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-android, aarch64-freestanding, x86_64-freestanding, riscv64-freestanding, thumb-freestanding, thumb-freestanding-softfp, thumb-freestanding-soft, thumbv6m-freestanding, thumbv7m-freestanding, thumbv7em-freestanding, thumbv7em-freestanding-soft, thumbv7em-freestanding-dp, riscv32-freestanding, wasm32-freestanding, wasm32-wasi, craft-freestanding, x86-linux
-v, --verbose
What the build decided, on stderr, each line led sysl: — the files it read, which standard module
it got and by which route, where it looks and which heap it allocates through, then every command
line it handed to clang:
sysl: 1 source file(s) under hello.sysl
sysl: read hello.sysl
sysl: standard module linked from ~/Library/Caches/sysl/<version>+…/std.syslib
sysl: allocator: malloc / free (the C default)
sysl: link: clang --target=arm64-apple-macosx -Wno-override-module -O1 -Wl,-dead_strip hello.ll ~/Library/Caches/sysl/<version>+…/std.syslib -o hello
wrote hello
Under --no-std-lib the third line is standard module compiled from source, not linked from an artifact, and a compile: line follows for each of the library’s C files. Each --lib,
--link-path, --include-path and -D is said too (library:, link path:, include path:,
define:), ahead of the allocator — which names who chose it where a package or the project did. A
test suite links like any other build and says the same. There are no phase timings: a build that
is slow is diagnosed by asking what it did.
--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 --.
The standard module is never named
The compiled standard module is the compiler’s cache, so no flag points a build at one. --std-lib
is refused by every command in the same words, before any of them reads its own options:
sysl: error: --std-lib names a '.syslib', and a '.syslib' is this compiler's cache, which nobody names — to compile against another standard module, set SYSL_LIB to the root its source is in
Exit statuses
| 0 | it worked — and for test, every test that ran passed |
| 1 | a compiler diagnostic, a driver error, or a failing test |
| 2 | the command line did not parse — an unknown option, a missing value, no subcommand |
| the program’s | run only, once the program has started |
A compiler diagnostic is printed on standard error, by every command, exactly as the compiler wrote it: the message, the location, the line it happened on, and what went wrong underlined beneath it. The underline is as wide as the thing being complained about — a name, a literal, or a whole expression where the expression is what is wrong — and stops at the end of the line, since only the one line is quoted.
error: 'b' of 'add' is int, but string was given
--> hello.sysl:7:14
|
7 | print(add(x, "two"))
| ^^^^^
A driver error — something that went wrong around the compilation rather than inside it — is
prefixed sysl: error: and written to standard error, which is why every message quoted on this
page carries it and none of the ones on the language pages do. A command line that did not parse is
the one exception: it is answered Error: … with the command’s usage under it, as above.
Next: the tour, which uses run throughout.