Attributes and Tests
Attributes are annotations prefixed with # that attach to the following declaration. They appear on their own line(s) immediately before the declaration:
#testtest_copy_basic() -> unit 0
#inline#deprecated("use foo2")foo() -> int = 1- Flag:
#name - With arguments:
#name(arg1, arg2, ...)— arguments are literals (string, int, bool), bare identifiers, orkey: valuepairs
Multiple attributes stack on separate preceding lines. Unknown attribute names are stored as-is (no error), so new attributes can be introduced incrementally.
#if / #else / #endif (conditional compilation) use # but are not attributes — they work differently.
#test — unit tests
Section titled “#test — unit tests”Functions marked #test are unit tests. Requirements:
- zero parameters
- returns
unit(or no return type) - not generic
- not a method
A test passes iff it does not panic. A panic (panic("msg"), abort(), or any runtime trap) fails the test.
#testtest_trivial() -> unit assert(1 + 1 == 2, "math is broken")
#test("descriptive name shown in output")test_with_display_name() -> unit 0should_panic — tests that must panic
Section titled “should_panic — tests that must panic”#test(should_panic)test_guard() -> unit panic("this must fire")
#test(should_panic: "out of range")test_bounds() -> unit // substring match: panic message must contain "out of range" panic("index 42 is out of range")#test functions are excluded from non-test builds — sysl compile and sysl run strip them, so they don’t contaminate normal execution and aren’t emitted to .asm / .tof / .ll output.
sysl test — running tests
Section titled “sysl test — running tests”sysl test <path> # file or directory (recursive)sysl test --filter <pattern> <path> # substring match on test / display namesysl test --backend interpreter|trisc|all <path>sysl test --fail-fast <path>sysl test --verbose <path>Output groups tests by source file with pass / fail markers and timings:
running 6 testsstd/mem/mem.lsysl ✓ test_copy_basic (0.2ms) ✗ test_cmp_prefix (0.1ms) panic: expected -1, got 1 ✓ test_index_byte_found (0.1ms)...5 passed, 1 failed, 0 skipped — 0.6msExit code is 0 iff all tests pass. Failing tests print the source file and line of the #test attribute (at file:line).
Builtins useful in tests
Section titled “Builtins useful in tests”panic(msg: string) -> unit— halts with the given message. Primary failure signal.assert(cond: bool, msg: string) -> unit— panics withmsgifcondis false.expect(actual: i64, expected: i64, msg: string) -> unit— panics with"msg: expected N, got M". Better diagnostics thanassert(a == b, ...).
Test output capture. Any output from print, println, puts, or puti inside a test is captured and displayed below the failure message if the test fails — useful for debugging intermediate values.
#pure — side-effect-free functions
Section titled “#pure — side-effect-free functions”A function marked #pure is checked at compile time to have no observable side effects. Pure functions are a discipline-enforcement tool: any violation is an analysis error, never a warning.
#puresquare(x: int) -> int = x * x
#purefact(n: int) -> int if n <= 1 then return 1 return n * fact(n - 1)
#puregcd(a: int, b: int) -> int if b == 0 then return a return gcd(b, a % b)Allowed inside #pure:
- Read parameters and module-level
consts. - Declare and mutate local variables (they cannot escape).
- Call other
#purefunctions, including recursion and mutual recursion. - Arithmetic, comparison, casts, and all control flow (
if/while/for/match/break/continue). - Call
assert(cond, msg)— termination is the only effect, consistent with Ada’spragma Assertpolicy.
Forbidden inside #pure:
- Calling any non-
#pureuser function (cross-function purity does not transit through impure callees). - Calling IO builtins:
puts,print,println,putchar,puti. - Calling allocation builtins:
malloc,free,calloc,realloc,sbrk. - Calling side-effecting traps with observable output:
panic,abort,expect. - Writing to a module-level
var. - Writing through a pointer (
*p = v), an indexed slot (arr[i] = v), or a struct field (p.f = v) — the caller might see the write. - Increment / decrement of struct fields (
p.f++). - Heap allocation (
new),appendto a slice, closure construction. - Indirect (function-pointer) calls and interface-dispatch calls.
asmblocks.
var counter = 0
#purebad() -> int puts("hi") // ERROR: IO inside #pure counter = counter + 1 // ERROR: writes module-level var return counterCross-module propagation. #pure is carried through .smeta files (wire format: FUNCP / DEFFUNCP for pure, FUNC / DEFFUNC for impure; old smeta files without the P suffix round-trip as impure — the safe default for untrusted input). A #pure function in module A can freely call a #pure function in module B. Imported functions without #pure are still impure, so annotate library functions you intend to call from pure code.
Interaction with --no-contracts. #pure is a static check and always runs. The --no-contracts flag only elides runtime contract verification — it has no effect on #pure enforcement.
Future work. Allow #pure calls inside const initializers and as default-parameter expressions, so const TABLE = build_table(16) becomes legal at compile time.
#deprecated — warn on use
Section titled “#deprecated — warn on use”Marks a function as deprecated. Calls emit a warning to stderr during analysis (once per callee per compilation):
#deprecated("use foo2 instead")foo() -> int = 1
#deprecatedold_api() -> int = 2Warnings:
warning: 'foo' is deprecated: use foo2 insteadwarning: 'old_api' is deprecatedCalls still compile and run normally — #deprecated only reports usage.