Lowent Manual←↑→

2 A first program — build, run, get rejected

What to know first

chapter 1, What Lowent sets out to do · knowing what an op can do from its head alone

Looking back

What did chapter 1 read from the head proc main input out cap io . output u8 . effects io .? And what did it read from what was not written?

A. That the op is impure (proc), receives an io capability under the name out, returns a u8, and performs the io effect. From what was not written: the op does not allocate, has no file-system capability and starts no threads. This chapter actually builds and runs a program with that head.

The need for this chapter, and its context

The first step in learning a language is knowing what the tool in your hand does for you. Lowent’s tool, lowentc, has no default mode, runs the same program two ways (VM and native), and answers wrong programs with diagnostics that carry codes. With these three in your fingers before the grammar, you can run and get rejected by every new rule in later chapters yourself. That is why this chapter comes before the description of the surface syntax (chapter 3).

By the end of this chapter

You will build lowentc and run the smallest program once on the VM and once natively. You will see why the returned value becomes the exit code, how a contract stops execution, and how to run test blocks. You will also learn how to read the diagnostics the compiler produces when it rejects a program, and how far --fmt will fix the shape for you.

The questions this chapter answers

  1. Why is there no default mode? Couldn’t lowentc hello.low just build it?
  2. Do I have to memorise diagnostic codes?

2.1 Building the tool#

The compiler is called lowentc. After getting the repository, build it in impl/. All you need is a C compiler that knows C23 (gcc or clang) and make.

cd impl
make            # build/lowentc
make check      # unit tests and standard-library checks

When make finishes there is an impl/build/lowentc. Commands in this book are written as lowentc, assuming it is on your path.

ModeWhat it does
--checkChecks only (contracts, effects, ownership, capabilities). Emits nothing
--run OP args…Runs one op on the VM. Slice arguments are given as [3,4,8]
--emit-cWrites native C source to standard output
--testRuns test blocks
--fmtPrints the canonical form
--diag-jsonWrites diagnostics as one JSON object per line (for tools and AI)

Table 2.1 — Common lowentc modes

Q. Why is there no default mode? Couldn’t lowentc hello.low just build it?

A. It could, but then what the command did would not be visible on the command line. Whether it only checked, ran, or wrote files would have to be remembered. This is chapter 1′s “one meaning, one spelling” applied to the tool. If you want something shorter, subcommand names such as lowentc check and lowentc run do the same work.

2.2 hello, entropy#

The smallest program that prints.

examples/ch01/hello.low

module hello .
rem run: main

proc main input out cap io . output u8 . effects io . do
  return narrow u8 (write_out out 1 "hello, entropy!\n") .
end

Output

$ lowentc --run main hello.low
hello, entropy!
main() = 16

The first line, module hello ., is the module declaration every file opens with. The rem on the second line is a line comment; in this book it tells the verification script what to do.

Read the head one clause at a time.

The body lies between do and end. write_out out 1 "…" takes the capability out as its first argument, writes bytes to file descriptor 1 (standard output), and returns the number of bytes written. hello, entropy!\n is 16 bytes, so it returns 16. That is narrowed to fit a u8 with narrow u8. Narrowing carries a check that stops execution if the value does not fit (chapter 4).

The last line of the output, main() = 16, is the line where the VM shows the op’s return value.

2.3 Running it two ways#

--run ran it on the virtual machine inside the compiler. To run the same program natively, emit C and build it with a C compiler.

lowentc --emit-c hello.low > hello.c
cc -O2 -o hello hello.c -lm
./hello main

The native executable takes the name of the op to run as its first argument. Running main prints the same line, and the return value 16 comes out as the process exit code (see it with echo $?). That is why main’s output is u8 — an exit code is one byte.

In practice. When the two results differ, whose fault is it?

The VM and native code start from the same intermediate representation. If they produce different output, it is a defect in the compiler, not the program. The development repository runs hundreds of ops on both back ends with boundary-value arguments and compares them byte for byte, and this book’s verification script also runs every example carrying rem run: twice and compares. One discrepancy surfaced while writing this book — arg, which reads program arguments, counts positions differently on the VM and in native code. So examples that read arguments are only checked in this edition, and their run results are not shown.

Slice arguments are given in brackets. The next op computes the mean of a byte slice.

examples/ch01/stats.low

module stats .
rem run: mean [3,4,8]

fn mean input xs slice u8 . output u64 .
  requires gt (len xs) 0 .
do
  var total u64 be 0 .
  for x xs do
    set total (add total (widen u64 x)) .
  end
  return div total (len xs) .
end

Output

$ lowentc --run mean stats.low [3,4,8]
mean([3,4,8]) = 5
  arg0 (written) = [3,4,8]

requires gt (len xs) 0 . is a contract. The caller promises not to pass an empty slice. Because of that promise, the body’s div total (len xs) need not worry about dividing by zero. The line arg0 (written) = [3,4,8] below the result is the VM showing the final state of the slice argument.

2.4 How a contract stops execution#

What happens if a contract is broken? The next op only accepts values up to 100.

examples/ch02/twice.low

module doubling .
rem run: twice 21
rem test

export fn twice input n u32 . output u32 .
  requires le n 100 .
do
  return mul n 2 .
end

test twice_works
do
  expect eq (twice 5) 10 .
  expect eq (twice 0) 0 .
end

Output

$ lowentc --run twice twice.low 21
twice(21) = 42
$ lowentc --test twice.low
  [PASS] twice_works
== tests: 1 run, 1 passed, 0 FAILED ==

Give it a value that breaks the promise, as in lowentc --run twice twice.low 200, and the VM stops before entering the body with E-VM-CONTRACT: requires violated at entry. If the argument is written as a constant in a call (twice 250), there is no need to run at all — --check rejects it with E-CONTRACT-IMPOSSIBLE. A contract is not a comment.

The test twice_works block in the same file is a test. expect is the assertion, and --test runs every test block. The last two lines of output are its result. --check does not run tests — passing the check does not mean the tests passed, so running --check on a file with tests produces a W-TEST-NOT-RUN warning to say so.

A common misconception. If --check is quiet, the program is correct

What --check guarantees stops at the promises written in the head and the body not contradicting each other. Promises that were not written cannot be checked, tests were not run, and contract violations that only show at run time only show when you run. “The check passed”, “the tests passed” and “it is correct” are three different statements.

2.5 Getting rejected#

A good part of the time spent learning Lowent is time spent being rejected by the compiler. Rejection is not punishment but conversation. Every diagnostic carries a stable code, and that code keeps its meaning across releases.

examples/ch01/twice_bad.low

module twice_bad .
rem expect: E-EFFECT-CALC

fn shout input out cap io . output u64 .
do
  return write_out out 1 "hi\n" .
end

Output

$ lowentc --check twice_bad.low
twice_bad.low:5:1 E-EFFECT-CALC: this fn is declared pure but performs `io` — make it a `proc` with `effects …`, or remove the effect

A diagnostic line has the shape file:line:column code: explanation. In this example fn shout is declared pure but its body does input/output with write_out. The compiler rejects it with E-EFFECT-CALC and names two ways to fix it — make it a proc with effects, or remove the effect.

With --diag-json the same diagnostic comes out as one line of JSON. The rule code (rule), location (span) and the name of the repair (repair) are separate fields, so an editor or an AI can tell what is wrong without picking the prose apart.

{"rule":"E-EFFECT-CALC","sev":"error","phase":"effect", … ,"repair":"R-CALC-TO-PROC"}

Q. Do I have to memorise diagnostic codes?

A. No. Codes are names to search for and refer to. The appendices of this book collect the diagnostics you will meet often, and each chapter shows in the text the codes that come out when its rules are broken.

2.6 --fmt fixes the shape#

The clauses of an op head are written in one fixed order. Inputs come before the output. A wrong order is rejected.

examples/ch02/messy.low

module messy .
rem expect: E-CLAUSE-ORDER

fn area output u64 . input w u64 . input h u64 .
do
  return mul w h .
end

Output

$ lowentc --check messy.low
messy.low:4:22 E-CLAUSE-ORDER: a data input comes after `output`. An op header has ONE order: `satisfies`/`lowdoc` · `vector`/`priority` · `comptime` inputs · capability/region inputs · `using` · data inputs · `output` · `effects` · `link`/`variadic` · `asm` · `access`/`inplace`/`invalidates`/`parallel`/`reduce` · `requires` · `ensures` · `errors` · `tests` (`--fmt` moves the non-input clauses for you; inputs are call positions, so reorder those and their call sites yourself)

The long explanation in the diagnostic is the order table itself. The whole of it is covered in chapter 3 for now remember one thing: --fmt moves clauses that are not inputs (output, effects, contracts and so on) into place. It does not reorder inputs among themselves. The order of inputs is also the order of arguments at every call site, so moving them would change every call.

The canonical form --fmt prints puts one clause per line and shows every parenthesis. Nobody has to write in that shape. The canonical form is the yardstick a machine uses to compare whether two pieces of code mean the same.

2.7 Arguments and capabilities#

Reading program arguments is a capability too. The next program greets its first argument.

examples/ch02/greet.low

module greet .
rem (argument numbering differs between native and VM, so this is only checked)

proc main input out cap io . input a cap args . output u8 . effects io .
do
  let who option slice u8 be arg a 0 .
  guard is_some who . else return narrow u8 (write_out out 1 "no name\n") .
  let n u64 be write_out out 1 "hello, " .
  let m u64 be write_out out 1 (some_value who) .
  let k u64 be write_out out 1 "\n" .
  return 0 .
end

Output

$ lowentc --check greet.low
== check: ok ==

input a cap args . receives the argument capability, and arg a 0 reads the first argument. There may be no argument, so the result is option slice u8. guard is_some who . else … leaves on the spot when there is no value, and below it you may trust that the value exists (chapter 11). Capability inputs come before data inputs, and the two capabilities take argument positions in the order written.

Note that the single line effects io . covers both capabilities. args is a capability that can be read without an effect. How capabilities pair with effects is tabulated in chapter 16.

2.8 Common mistakes#

These are the rejections and stops you meet most often in a first program. When you see one of these codes, come back here.

Counter-example. Opening do and never closing it with end

examples/ch02/mistake_noend.low

module mistake_noend .
rem expect: E-BLOCK-UNCLOSED

proc main input out cap io . output u8 . effects io .
do
  let n u64 be write_out out 1 "hi\n" .
  return narrow u8 n .
rem ✘ the `do` that opened the body has no matching `end`

Output

$ lowentc --check mistake_noend.low
5:1 E-BLOCK-UNCLOSED: missing 'end' for do-block

A body opens with do and closes with end. If the file ends before the closer, the compiler cannot tell where the body stops and reports E-BLOCK-UNCLOSED. The 5:1 in the diagnostic is the position of the do left without a partner — when bodies are nested, match the pairs starting from that line. Consistent indentation makes the pairs easy to see.

Counter-example. Printing without receiving the capability

examples/ch02/mistake_nocap.low

module mistake_nocap .
rem expect: E-EFFECT-NO-CAP

rem ✘ declares `io` but never receives the right to do it (`cap io`)
proc main output u8 . effects io .
do
  let n u64 be write_out 1 "hi\n" .
  return narrow u8 n .
end

Output

$ lowentc --check mistake_nocap.low
mistake_nocap.low:5:0 E-EFFECT-NO-CAP: this op declares the `io` effect but receives NO capability that authorizes it. `io` is a cap-effect (RFC-0007 §6.7): I/O is a RIGHT you are HANDED, not an ambient power — `input fs cap file_system .` (or another `cap …` input). An effect you declare but hold no capability for is a claim the signature cannot back

effects io . declares that the op does I/O; what actually allows it is the capability received as an input (input out cap io .). Declaring the effect without the capability is E-EFFECT-NO-CAP. I/O is not a power you can grab anywhere: it is a right handed to main when the program starts and passed on to the ops that need it (chapter 16). The fix: add input out cap io . to the head and pass out as the first argument of write_out.

Counter-example. Returning an exit code larger than 255

examples/ch02/mistake_exitcode.low

module mistake_exitcode .
rem trap: main

proc main input out cap io . output u8 . effects io .
do
  var n u64 be 0 .
  var i u64 be 0 .
  while lt i 8 . do
    set n (add n (write_out out 1 "32 bytes of text on every line.\n")) .
    set i (add i 1) .
  end
  rem ✘ 256 bytes were written --- an exit code is one byte (0 … 255), so it does not fit
  return narrow u8 n .
end

Output

$ lowentc --run main mistake_exitcode.low
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
32 bytes of text on every line.
== ir diagnostics (1) ==
0:0 E-VM-CAST: value does not fit the target width (use narrow_wrap / narrow_sat / narrow_try)

This compiles, but stops while running. The value main returns is the exit code handed to the operating system, which is one byte (u8), and 256 does not fit in narrow u8. Lowent does not quietly truncate it to 0; it stops right there, because a truncated exit code reads as “success” and hides the bug. If what you want to know is the number of bytes written, print it, and use the exit code only for success (0) or failure (non-zero).

Counter-example. Misspelling the op to run

examples/ch02/mistake_opname.low

module mistake_opname .
rem trap: mian

proc main input out cap io . output u8 . effects io .
do
  let n u64 be write_out out 1 "hi\n" .
  return narrow u8 n .
end

Output

$ lowentc --run mian mistake_opname.low
== ir diagnostics (1) ==
0:0 E-VM-UNDEF: no such op

The name after --run must match an op in the file exactly. There is no mian, so the VM stops with E-VM-UNDEF: no such op. A native executable also exits non-zero when the op named by its first argument does not exist. This is the price of keeping what runs visible on the command line — in return, you can run any op in the file on its own.

2.9 This chapter’s syntax at a glance#

ShapeMeaningWhy
module hello .the module name of this filethe name other files use in use hello .
rem …line comment (to the end of the line)a word rather than a symbol — this book also uses it for check directives
proc main input out cap io . output u8 . effects io .where the program startsrights received, exit code returned and what it does are all in the head
do … enda bodyan opener and a closer that pair up
write_out out 1 "…"write to standard output (1) and return the byte countthe capability out comes first — no right, no writing
narrow u8 nnarrow to u8 (stops if it does not fit)so values never change silently
requires c . · test t do … end · expect c .contract · test block · assertion inside a testpromises are checked; tests run separately
lowentc --check f.lowcheck onlythe command line shows what was done — there is no default mode
lowentc --run op f.low args…run one op on the VMtry any op in the file on its own
lowentc --emit-c f.low > f.cemit C for a native buildso the result can be compared with the VM
lowentc --hw auto f.lowcarry the machine’s crypto instructions and its width (AES-NI, carry-less multiply, SSE2/AVX2 lanes)the build picks what to CARRY; when it carries more than one, the program chooses once at start — the answer is the same either way
lowentc --test · --fmtrun tests · reprint in canonical formchecking, testing and layout are different jobs

Table 2.2 — The shape of a first program and the tool — shape · meaning · why it looks this way

Recap

lowentc always takes an explicit mode. --run runs the program on the VM, --emit-c plus a C compiler runs it natively, and the two must agree. main’s return value is the exit code. Contracts stop execution before running or on entry, and test blocks run only under --test. Rejections carry stable codes, and --fmt fixes clause order only for clauses that are not inputs.