Rill v0.13 Reference

Chapter 13

The toolchain

rill build main.rill -o main    # compile (LLVM -O3, stripped)
rill build main.rill --debug    # no optimization, DWARF line info for lldb
rill build main.rill --unchecked  # no bounds test on buffers: run it checked first
rill build main.rill --shared   # a .dylib/.so whose `export fn`s a C or Python host calls
rill build main.rill --emit-ir  # print LLVM IR
rill run main.rill              # compile and run
rill run main.rill --sandbox    # refuse what reaches past the process (--sandbox=files,net,ffi,os allows some)
rill check main.rill            # type-check only, saying everything it finds
rill types main.rill            # the signature the checker worked out for each function
rill explain main.rill          # the memory decisions the generator made, by line
rill diagram exchange.rill      # the file's system and machines drawn: Mermaid, or SDL's own symbols (--svg)
rill msc trace.txt              # a run's trace (RILL_TRACE=1) as a sequence chart (--svg for a picture)
rill doc lib/text/str.rill      # a module's documentation as Markdown; `rill doc lib -o docs/lib` for a folder
rill doctest lib                # every example in the doc comments, run and held to its `# =>`
rill test tests.rill            # run every `test_...` function, each in its own process
rill fmt src/*.rill             # canonical formatting (--check, --stdout)
rill repl                       # interactive: definitions persist
rill lsp                        # a language server on stdin/stdout

Tests

A test is a function named test_... that takes nothing, written beside the code it tests or in a file that imports it. rill test file.rill builds the file once, with a main of its own that runs the test its first argument names β€” the file's main, if it has one, stands aside β€” and runs each test in a process of its own, so one that fails is one test failing and not the rest:

fn test_add() = assert_eq(add(2, 2), 4)

fn test_rev() =
  assert_eq(reverse(Cons(1, Cons(2, Nil))), Cons(2, Cons(1, Nil)))
  assert(len(reverse(Nil)) == 0)
$ rill test testing.rill
ok    test_add
FAIL  test_rev
      rill: assertion failed at line 5
        expected: Cons(2, Cons(1, Nil))
             got: Cons(1, Cons(2, Nil))
2 tests: 1 passed, 1 failed

assert(c) ends the test where c is false, saying the line; assert_eq(got, want) says both values too, as show would. Nothing is caught, because nothing has to be: a test that failed is a program that ended, and a fault or a deadlock in one is reported the way any program's is, with the test named above it and whatever it printed below. rill test file.rill test_rev runs one; --parallel builds the tests as a --parallel program would be.

When a program dies

A program that faults β€” an address that is nobody's through an extern or a Ptr, a strand that runs off the end of its stack, an instruction that is not one β€” still dies: nothing in it can be resumed from the middle of a write it did not finish, and the strand that faulted may hold a channel the others wait on. But it says what happened first, on standard error:

rill: bus error at 0x104f27ff0 - a stack overflow: the strand ran off the end of its stack
  in a strand started at lambda$0, worker 0
  in dive +119
  ... the same, 99999 more times

Which signal and where, whether the address is a stack's guard page, which strand and which worker, and the Rill functions on the stack innermost first β€” by name, in a release build as in a debug one, from a table of every function's start and name the compiler leaves in the binary (two pointers a function). A bounds check that fails, or a division by zero, says the same about where it was. Runtime frames are left out; a recursion that ran away is one line and a count. With RILL_CRASH_WAIT=1 in the environment the program then waits, saying its pid, for a debugger to attach; otherwise the fault runs again under the system's default, so a core is written if one was asked for and the exit status is the one a faulting process has always had.

A fault that is a consequence β€” a block freed under a holder, read later by whoever was handed it next β€” is found with the allocator's checks. RILL_ALLOC_CHECK=1 at run time paints every freed block, keeps the frames of its last two frees, and reports a block handed out or freed twice, or one whose count was touched after its free, naming the freer. RILL_ALLOC_CHECK=2 gives every small block a page of its own and makes it inaccessible at the free, so the stale holder faults on its own instruction β€” slow, a system call per allocation, for when the report above is not enough. RILL_RC_CHECK=1 in the environment of rill build compiles a program whose every count update first asks the runtime whether the block is out at all, and a touch of a freed block is reported in the toucher's frames with the block's history: every retain, release, hand-out and free, by worker and by frame. The checks live in the runtime's allocator, and a --parallel build under them sends every block there; a plain build pops and pushes the pool's blocks inline, and is told the checks cover only what the runtime allocates.

Where a program's memory is, as against where it went wrong: extern fn rill_mem_report() -> Int writes one line to standard error β€” blocks out, the pool's chunks and how much of them sits on free lists, the big blocks kept for reuse, the strand records and the stacks they have saved β€” and the free lists by size where one holds a megabyte or more. A server's /stat route is the place for it: the catalogue example found 195 MB on one free list that way.

rill types is the answer to the question an inferring language raises: what did it decide? It prints every function in the file as the checker sees it, including the requirements a body imposed β€” fn merge(a: List('a), b: List('a)) -> List('a) # where 'a is ordered for a function whose source says nothing about being ordered.

rill lsp is the same knowledge, kept open for an editor: diagnostics on the buffer as it stands, the type of the name under the cursor, go to definition, an outline, and formatting. editors/README.md has the three lines each that Neovim, Helix and Emacs need to start it.

Examples that are tests

A comment line indented four spaces past the # is an example, and rill doc shows it as code. After # => it says what show prints of the expression's value, and rill doctest holds it to that: every example of a file becomes a line of a program written beside the module, which imports the module by its name β€” so an example reads as a user would write it, json.parse(…) β€” with a line that has no => a statement the next lines may use, ? allowed, an import line hoisted to the program's head, and a block whose first line is # not run shown but left alone, for a server that would listen forever.

# The rest of `s` once `p` has been taken off its front, or `None` when
# `p` was not there.
#
#     str.strip_prefix("v0.13", "v")   # => Some(0.13)
#     str.strip_prefix("0.13", "v")    # => None
fn strip_prefix(s, p) = …
$ rill doctest lib/text/str.rill
ok    lib/text/str.rill  (167 examples)
167 examples in 1 file: 167 passed, 0 failed

A failure names the comment's line and shows both answers. Every function of the standard library carries one, and the test suite runs them all, so what the reference pages show is what the code does.

A sandbox

A program that runs for someone else β€” a model uploaded to an editor that is open to the world, a submission, a plugin β€” should not get to read the files of the machine it runs on, open its sockets, call into its C libraries, or write to whatever address it likes. Those are the four things a Rill program can do that the language cannot account for, and --sandbox on build, run, test and check refuses all of them; --sandbox=files,net allows the two named. The capabilities are files (reading, writing, listing, renaming), net (sockets), ffi (extern fn, asm fn, and raw memory through Ptr) and os (waiting on the process's signals). Everything else the runtime offers β€” the console, the clocks, strands and channels, random numbers, sleeping β€” stays open, since none of it reaches past the process.

The question is answered before the program is built, over the checked code: every function reachable from where the run starts β€” main and the exports, or the tests under rill test β€” is searched for a runtime or foreign call that needs a capability the sandbox withholds. A library that would need one is fine to import and fine to leave uncalled; it is the call that is refused, and the error lands on the author's own line even when the call is a library's, since a line in a library is not one the author can go and fix:

$ rill check spy.rill --sandbox
spy.rill: type error at line 12: `read_file` is not allowed here: it needs `files`,
  and this sandbox allows nothing beyond the process, reached through `read_file`
spy.rill: type error at line 20: `to_cstr` is not allowed here: it needs `ffi`,
  and this sandbox allows nothing beyond the process, reached through `run`

This is a static answer, and it is complete for what the language can express: a Rill program has no other way to reach the machine than the runtime's functions and the foreign ones, and both are visible to the checker. What it does not bound is time and memory β€” a program allowed nothing can still loop and allocate β€” which is the host's to limit, as the editor's server does with a clock.