3 The surface — full stops, blocks and clause order
What to know first
--check diagnostics and --fmtLooking back
In chapter 2, what rejected fn area output u64 . input w u64 . …, and how far does --fmt fix it?
A. E-CLAUSE-ORDER rejected it, because a data input came after output. --fmt moves clauses that are not inputs (output, effects, contracts) into place but leaves the order of inputs alone, because the order of inputs is also the order of the caller’s arguments. This chapter covers that whole order table and the smaller rules underneath it — full stops, parentheses, blocks.
The need for this chapter, and its context
By the end of this chapter
expr island that allows infix lie. You will pick up the rules for comments and literals, that names contain no dots and shadowing is forbidden, and that every block is do … end. Finally you will understand the whole clause-order table of an op head and why it is ordered as it is.The questions this chapter answers
- When arithmetic gets long, don’t the parentheses pile up and become hard to read?
- Can a module and an op share a name?
3.1 The name first, then the arguments#
Lowent expressions use prefix notation. The name of the operation comes first and its arguments follow. add a b adds a and b, and a form inside a form is wrapped in parentheses.
let total u64 be add 1 2 .
let mixed u64 be add 1 (mul 2 3) .Prefix notation has no precedence. Someone reading 1 + 2 * 3 knows that multiplication comes first because they memorised it, but in add 1 (mul 2 3) the parentheses already say so. The principle is the same for ops you write yourself. write_out out 1 "hi", field p x and mean xs all put the name first.
Q. When arithmetic gets long, don’t the parentheses pile up and become hard to read?
A. That is why there is an expr island. Inside a place starting with expr, the four arithmetic operators, comparisons, and and or may be written infix. An expression in the island is translated to exactly the same meaning as the prefix form and costs nothing at run time. The island’s boundaries appear later in this chapter and in detail in chapter 8.
3.2 A free-standing full stop closes#
The end of a statement or clause is closed by a free-standing full stop ., called a closer. A newline is not a closer — it is whitespace, exactly like a space. So wherever you break a line, the meaning stays the same.
examples/ch03/poly.low
module newline .
rem run: poly 5 4
rem run: poly_expr 5 4
note WHY
개행은 공백이다. 폼은 자기 닫개인 떨어진 마침표에서 끝난다.
그래서 줄을 어디서 나누어도 뜻이 같다.
WHY
fn poly input a u64 . input b u64 . output u64 .
do
return add (mul a 2)
(mul b 3) .
end
fn poly_expr input a u64 . input b u64 . output u64 .
do
return expr a * 2 + b * 3 .
end
Output
$ lowentc --run poly poly.low 5 4
poly(5, 4) = 22
$ lowentc --run poly_expr poly.low 5 4
poly_expr(5, 4) = 22
The return in poly spans two lines but is one form; it ends at the full stop at the end of the second line. That is why there is no line-continuation marker (C’s trailing backslash, a trailing comma, indentation rules). poly_expr in the same file writes the same computation as an expr island, and the two ops give the same answer.
note WHY … WHY is a multi-line comment; it ends at a line where the word written after note stands alone. rem is a line comment. The language has no symbolic comments like // or /* */ — comments too are opened with words.
The number of full stops works as a kind of checksum. If opened forms and closers do not match, the compiler says so. Forgetting one parenthesis looks like this.
examples/ch03/dots.low
module dots .
rem expect: E-GROUP-UNCLOSED
fn poly input a u64 . input b u64 . output u64 .
do
return add (mul a 2 (mul b 3) . .
end
Output
$ lowentc --check dots.low
7:1 E-PAREN-ESCAPE: a `(` opened inside this block was never closed — it would have ESCAPED past the `end`. It used to: the newline-continuation stayed on for the REST OF THE FILE and every op after it was swallowed into the group. A bracket may not cross a block boundary; it is closed here
6:14 E-GROUP-UNCLOSED: missing ')'
The first diagnostic means a parenthesis tried to leak past the block’s end. Parentheses cannot cross a block boundary. So a single wrongly closed parenthesis stops inside that block instead of silently swallowing the rest of the file.
A common misconception. A semicolon closes statements too
; it literally produced a full stop. Two spellings of one meaning force a reader to know both, so it was removed. Today ; is rejected with E-VOCAB-REMOVED.examples/ch03/semi.low
module semi .
rem expect: E-VOCAB-REMOVED
fn twice input a u64 . output u64 . do return mul a 2 . ; end
Output
$ lowentc --check semi.low
4:57 E-VOCAB-REMOVED: `;` was a THIRD spelling of the closer `.` (the lexer literally emitted a DOT for it) — one meaning, three spellings, and SPEC-002 §2.5 forbids synonyms. It only survived inside the old interpreter's second language. Write `.`
3.3 A block is always do … end#
Every place that groups statements opens with do and closes with end: an op body, the body of if, while and for, the arms of match, and the bodies of declarations such as struct, enum, trait and actor.
struct point do
x u64 .
y u64 .
end
fn sum_to input n u64 . output u64 .
do
var total u64 be 0 .
var i u64 be 1 .
while le i n . do
set total (add total i) .
set i (add i 1) .
end
return total .
enddo … end works like a pair of braces. end closes only its own do and leaves everything outside alone. So there are just two rules to know.
- A construct that owns a block as its body ends at
end: op declarations,struct·enum,if·while·for·match. As in the example above, no full stop follows theend— one there closes nothing and is rejected withE-DOT-STRAY. - A statement that uses a block as a value ends with its own full stop, like any statement. Building a struct value with
makeand binding it withletis the usual case:let p point be make point do x 1 . y 2 . end .— the last stop belongs to thelet. Leaving it out isE-DOT-MISSING.
In C terms: no ; after if (c) { … }, but one after p = (struct point){ 1, 2 };.
while le i n . do … end block as body: the while statement ends at end
└─── while statement ───┘
let p point be make point do x 1 . end . block as value: the block is make's, the statement ends with its own .
└──── make value ─────┘ │
└─────────── let statement ────────────┘Each statement inside a block must end with its own full stop too; end does not close an open statement for you. A bare do … end with nothing opening it is not allowed either (E-BLOCK-NOHEAD) — a block always has a head. Opening a declaration block with only a line break instead of do is rejected with E-STMT-NODO, and --fmt inserts the do. The clauses of an op header work the same way. Each ends with its own stop — input n u64 . · output u64 . — and the next clause word does not close the one before it; leaving the stop out is E-DOT-MISSING.
3.4 Comments and literals#
| Kind | Examples |
|---|---|
| Integers (decimal, hex, binary) | 42 · 0xFF · 0b1010 · 1_000_000 |
| Floating point | 3.14 · 6e-3 · 0x1.8p3 |
| Strings (bytes) | "hello\n" |
| Strings (UTF-16 · code points) | u"…" · U"…" |
| Booleans · absence | true · false · none |
Table 3.1 — Writing literals
An integer literal has no type of its own; the place where it is used gives it one. A leading 0 is not octal (0755 is 755). A literal that does not fit the type of its place is rejected rather than silently truncated.
examples/ch03/lit300.low
module lit300 .
rem expect: E-TYPE-WIDTH
fn f output u8 .
do
let x u8 be 300 .
return x .
end
Output
$ lowentc --check lit300.low
lit300.low:6:0 E-TYPE-WIDTH: value does not fit the declared type (a literal out of range, or a narrowing — use narrow / narrow_wrap / narrow_sat / round_to)
C truncates 300 to 44 when it is put in a u8. Then the value written in the source and the actual value differ. Lowent stops it at translation time. Shrinking is done by writing one of the named narrow operations (chapter 4).
The set of string escapes is closed. There are the familiar \n, \t, \\ and \", plus \xNN (exactly two digits), \uXXXX (four) and \UXXXXXXXX (eight); any other escape is rejected with E-STR-ESCAPE. C’s \x eats hex digits without end, so the meaning of "\x41e" depends on the character next to it; in Lowent \x41e is always A followed by e. A string is not a separate type but a byte slice (slice u8).
| Written | Meaning | Written | Meaning |
|---|---|---|---|
\\ | backslash | \v | vertical tab |
\" | double quote | \0 | zero byte |
\' | single quote | \xNN | two hex digits — any single byte |
\a | alert | \uXXXX | code point, four digits |
\b | backspace | \UXXXXXXXX | code point, eight digits |
\f | form feed | \n | newline |
\r | carriage return | \t | horizontal tab |
Table 3.2 — String escapes — these fourteen only
\uXXXX and \UXXXXXXXX write a code point, and the prefix decides how many units carry it — "\U0001F600" is 4 bytes, u"…" is 2 UTF-16 code units (a surrogate pair) and U"…" is 1 code point. Surrogate values (D800 … DFFF) and values above 10FFFF are rejected. There are no octal escapes — the language has no octal notation at all, so reviving it only in literals would make that the one exception.
Using every shape in the table in one file looks like this.
examples/ch03/literals.low
module literals .
rem run: ints
rem run: floats
rem run: chars
rem run: texts
note END
note 와 같은 태그 사이의 줄은 모두 주석이다.
Lines between note and the same tag are all a comment.
END
rem integers: binary, digit separator _, hexadecimal; a leading 0 is not octal
fn ints output u64 . do
let a u64 be 0b1010 .
let b u64 be 1_000_000 .
let c u64 be 0xFF_FF .
let d u64 be 0755 .
return add (add a b) (add c d) .
end
rem floating point: exponent e, hexadecimal float p (0x1.8 is 1.5, p1 multiplies by 2)
fn floats output f64 . do
let e f64 be 1.5e3 .
let h f64 be 0x1.8p1 .
return add e h .
end
rem characters: 'A' is one byte, u'가' one UTF-16 code unit, U'😀' one code point
fn chars output u64 . do
let a u8 be 'A' .
let k u16 be u'가' .
let s u32 be U'😀' .
return add (widen u64 a) (add (widen u64 k) (widen u64 s)) .
end
rem strings: \x41 takes exactly two digits so "\x41e" is two bytes; u"가나" is two code units
fn texts output u64 . do
let x u64 be len "\x41e" .
let w u64 be len u"가나" .
rem text TAG … TAG holds several lines as written; a \n in the body is not unescaped
let h u64 be len text DOC
line one
a\nb
DOC .
return add (mul x 100) (add (mul w 10) h) .
end
Output
$ lowentc --run ints literals.low
ints() = 1066300
$ lowentc --run floats literals.low
floats() = 1503.0
$ lowentc --run chars literals.low
chars() = 172609
$ lowentc --run texts literals.low
texts() = 233
intsis 10 (0b1010) + 1000000 (1_000_000) + 65535 (0xFF_FF) + 755 (0755). An underscore goes only between digits and does not change the value; it makes long numbers easier to read.0755is 755, not 493, a choice that removes a C trap.floatsis 1500 (1.5e3) + 3 (0x1.8p1— 1.5 × 2¹). Hexadecimal floats are for when the bits must be written exactly. Decimal0.1cannot be written exactly in binary, but0x1.8p1is exactly 3.charsis 65 ('A') + 44032 (u'가') + 128512 (U'😀'). The prefix decides the width of the element. Writing a character that does not fit in one byte, such as'가', without a prefix is refused withE-CHAR-WIDTH.- In
texts,"\x41e"is 2 bytes,u"가나"is 2 code units andtext DOC … DOCis 13 bytes. A multi-line string (heredoc) holds, exactly as written, everything from the tag aftertextto the line where the same tag stands alone. The\nin the body is two characters, not a newline, and no newline is added after the last line. It is there to paste long descriptions or test input without escapes. - The
note END … ENDat the top is a multi-line comment. It turns a whole block into a comment withoutremon every line.
3.5 Names#
A name starts with an ASCII letter or underscore and continues with letters, digits and underscores. Two things differ from other languages.
First, declared names contain no dots. A place with a dot attached is a path referring to a name, and it has only three meanings — a module’s name (allocs.byte_allocator), a variant’s name (node.lit), and the name of a declaration attached to a type (rect.area). Looking inside a value (reading a field, calling a method) is not a glued dot but a form: field p x · method s area.
Second, there is no shadowing. An inner name that reuses the spelling of an outer name is rejected.
examples/ch03/shadow.low
module shadow .
rem expect: E-NAME-SHADOW
fn f input n u64 . output u64 .
do
let n u64 be add n 1 .
return n .
end
Output
$ lowentc --check shadow.low
shadow.low:6:0 E-NAME-SHADOW: a local binding takes the name of a PARAMETER of the same op. The namespace is FLAT (SPEC-002 2.10: no shadowing) — from here on the same letters mean the new binding, and a reader who saw the signature will read the wrong value. Rename the local
A shadowed name makes someone who read the head think of a different value. If you had to work out on every line whether n is the parameter or the new local, that is exactly semantic entropy. No parameter, module name, builtin op name or name still alive in an outer block can be shadowed. Choose a new name instead.
Q. Can a module and an op share a name?
A. No. With an op twice inside module twice, both would be referred to as twice.twice and twice and could not be told apart, so it is rejected with E-NAME-DUP. That is why the module names in this book’s examples differ slightly from their op names.
3.6 Words are a budget#
The words the language gives meaning to (keywords) form a closed list. Adding a new word grows the language, so nothing that existing words can express gets a new one. Roughly, they fall into these groups.
| Group | Words |
|---|---|
| Declarations | module use type newtype struct enum trait contract actor state |
| Ops | fn proc export unsafe extern |
| Locals and flow | let var set return if else for while guard match case try break continue expr |
| Values | make true false none be |
| Others | spawn send drop test expect satisfies do end |
Table 3.3 — Groups of core words
add, len, neg and the like are not words but builtin ops. They cannot be used as parameter names either, but they occupy the space of names rather than grammar. Removed words (in, loop, as, to and so on) are not quietly accepted; E-VOCAB-REMOVED tells you what to use instead.
3.7 The boundaries of the expr island#
What the island allows fits in one table. * and / bind tighter than + and -, comparisons (eq, lt and so on) come below them, and and binds tighter than or. Bitwise operations, remainder, minimum and the like are written in prefix form even inside the island. The precedence of bitwise operations differs between languages, so putting them in the island would not make them easier to read — it would add something to look up.
Comparisons cannot be chained.
examples/ch03/chain.low
module chain .
rem expect: E-EXPR-CHAIN
fn between input a u32 . input b u32 . input c u32 . output bool .
do
return expr a lt b lt c .
end
Output
$ lowentc --check chain.low
chain.low:6:0 E-EXPR-CHAIN: comparisons do not chain in an `expr` island — `a lt b lt c` reads like mathematics but means `(a lt b) lt c`, which compares a bool with a number. Say what you mean: `expr (a lt b) and (b lt c)`
Mathematical a < b < c means “a is less than b and b is less than c”, but in many languages that spelling reads as (a < b) < c. A spelling that means one thing to people and another to the machine is a place where code is silently wrong. As the diagnostic suggests, write expr (a lt b) and (b lt c).
3.8 Clause order in an op head#
An op head is a name followed by clauses. Each clause is closed by a full stop, and clauses have one fixed order.
| Order | Clause | Why here |
|---|---|---|
| 1 | satisfies · lowdoc | Says what the op is first |
| 2 | vector · priority | The character of the op as a whole |
| 3 | comptime inputs | Later input and output types use these names |
| 4 | Capability and region inputs (cap … · region …) | Who allowed it is visible before what it receives |
| 5 | using | Which allocator the following data uses |
| 6 | Data inputs | The values the op receives |
| 7 | output | The output type may use the inputs’ type parameters |
| 8 | effects | What it does with the capabilities received above |
| 9 | link · variadic · asm | Places that connect to the outside (C, machine code) |
| 10 | access · parallel · reduce | How it runs split up |
| 11 | requires → ensures → errors → tests | Input conditions, output promises, failure, tests |
Table 3.4 — Clause order in an op head (front to back)
You need not memorise every clause; most ops use four or five of them. What to remember is the principle — clauses are placed so that earlier ones are used by later ones. Type parameters before inputs, capabilities before data, inputs before the output, capabilities before effects, effects before contracts.
proc copy_upper
input out cap io . rem capability input
input src slice u8 . rem data input
output u64 .
effects io .
requires gt (len src) 0 .
do
return write_out out 1 src .
endWritten on one line or one clause per line, the meaning is the same (newlines are whitespace). This book writes short heads on one line and heads with contracts one clause per line.
In practice. When the output came first
output at the very front of the head — on the grounds that what an op returns should be seen first. But in generic ops whose output type uses an input’s type parameter, that meant a name being used before it was declared, and capabilities ended up far from their effects. So the order was redrawn to follow how clauses use one another, and then fixed. The compiler enforces it with a rank table.3.9 Common mistakes#
Surface mistakes mostly come from habits carried over from other languages. Some diagnostics point somewhere unexpected, so it helps to remember the shapes too.
Counter-example. Writing comments with //
examples/ch03/mistake_slash.low
module mistake_slash .
rem expect: E-RETURN-PARTIAL
fn twice input a u64 . output u64 .
do
rem ✘ `//` is not a comment marker --- this line and the next become one form ending at a single stop
// double it
return mul a 2 .
end
Output
$ lowentc --check mistake_slash.low
mistake_slash.low:4:0 E-RETURN-PARTIAL: this op says it OUTPUTS a value, but some path through its body reaches the end without a `return`. Until now the tool quietly returned 0 there — a value that appears NOWHERE in your source (RFC-0019 G-TOTAL). Give every path a `return`, or say `output void` if it really produces nothing. An exhaustive `match` whose every arm returns counts as returning
mistake_slash.low:7:0 E-VOCAB-REMOVED: `//` is not a comment here — this language has never had it. A comment starts with `rem` (to the end of the line) or `note <tag>` … `<tag>` (several lines). Until today `//` slipped through to lowering and was reported as an unsupported FEATURE, which sent the reader looking for a missing capability instead of a wrong spelling
Comments in this language are rem (line comment) and note WHY … WHY (multi-line), nothing else. // is not a comment, so the compiler reads it as the start of a form, and a form runs until a stop appears. // double it and the next line’s return mul a 2 therefore become one form that ends at a single stop, and the return is swallowed inside it. The diagnostic says “some path does not return”, but the cause is the comment. Comments open with a word to keep symbols down — //, # and -- differ from language to language, so one word was chosen. The fix: rem double it.
Counter-example. Reading a field with a glued dot
examples/ch03/mistake_glued.low
module mistake_glued .
rem expect: E-FIELD-GLUED
struct point do
x u64 .
y u64 .
end
fn getx input p point . output u64 .
do
rem ✘ reads a field with a glued dot --- fields are read with `field p x`
return p.x .
end
Output
$ lowentc --check mistake_glued.low
mistake_glued.low:12:10 E-FIELD-GLUED: glued-dot field access is gone — write `(field <value> <name>…)` instead. One meaning gets one spelling: the dot still means module qualification (`mod.name`), a variant name (`err.too_short`) and a type-associated declaration (`fn pt.twice`), so a fourth meaning made the same letters mean four things. `field` chains: `(field o i z)`
p.x is everyday C or Python, but here it is E-FIELD-GLUED. The dot already serves as a path to a name (module allocs.bump_bytes, variant color.red); if it also looked inside values, every a.b would need working out. Read a field with field p x (chapter 10).
Counter-example. Writing the head’s clauses in any order
examples/ch03/mistake_order.low
module mistake_order .
rem expect: E-CLAUSE-ORDER
rem ✘ `output` comes before the input --- clauses have one order
fn twice output u64 . input a u64 .
do
return mul a 2 .
end
Output
$ lowentc --check mistake_order.low
mistake_order.low:5:23 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)
Clauses have exactly one order (Table 3.4). If the order were free, the same head could be written many ways and readers would have to hunt for each clause. The diagnostic says what came after what, and lowentc --fmt puts the order right.
Counter-example. Opening an if body without do
examples/ch03/mistake_ifdo.low
module mistake_ifdo .
rem expect: E-TOPLEVEL
fn pick input a u64 . output u64 .
do
rem ✘ the body of `if` was not opened with `do`
if gt a 3 .
return 1 .
end
return 0 .
end
Output
$ lowentc --check mistake_ifdo.low
mistake_ifdo.low:10:0 E-TOPLEVEL: this is a STATEMENT, and it sits outside every op body — an `end` above it closed the op earlier than you meant. The usual cause is a control head written without `do`: `if <cond> .` alone takes the ONE statement that follows as its body, so the `end` written for the `if` ends the OP instead. Write the body as `if <cond> . do … end` whenever it holds more than one statement
mistake_ifdo.low:4:0 E-RETURN-PARTIAL: this op says it OUTPUTS a value, but some path through its body reaches the end without a `return`. Until now the tool quietly returned 0 there — a value that appears NOWHERE in your source (RFC-0019 G-TOTAL). Give every path a `return`, or say `output void` if it really produces nothing. An exhaustive `match` whose every arm returns counts as returning
Bodies are not opened by indentation as in Python. Without do, the if form ends at the stop after the condition, and the end below closes the op’s body rather than the if. The remaining return 0 . then falls outside any declaration, which gives E-TOPLEVEL (something other than a declaration at the top level), and because the body closed early, the return paths go wrong too (E-RETURN-PARTIAL). When you see those two together, suspect a missing do. The fix: if gt a 3 . do.
Counter-example. Writing a string in single quotes
examples/ch03/mistake_quote.low
module mistake_quote .
rem expect: E-CHAR-WIDTH
fn size output u64 .
do
rem ✘ single quotes hold one character --- strings use double quotes, "hi"
return len 'hi' .
end
Output
$ lowentc --check mistake_quote.low
mistake_quote.low:7:0 E-CHAR-WIDTH: this character does not fit ONE unit of the prefix you chose. `u8'…'` holds one BYTE (a Hangul syllable is three in UTF-8), `u'…'` one UTF-16 code unit (a non-BMP character is a surrogate PAIR), `U'…'` one code point. Widen the prefix, or use a string — the tool will not silently keep the first unit and call it the character
Single quotes are the literal for one character ('a' is the byte 97). Two characters do not fit in one unit, so you get E-CHAR-WIDTH. Strings always use double quotes: len "hi" is 2. The two kinds of quote mean different things because “one character” and “several bytes” are different types (Table 3.1).
A common misconception. A number with a leading zero is octal
In C, 0755 is octal 493, but Lowent has no octal notation.
examples/ch03/octal.low
module octal .
rem run: perm
fn perm output u64 .
do
rem a leading 0 means nothing --- this is seven hundred fifty-five
return 0755 .
end
Output
$ lowentc --run perm octal.low
perm() = 755
It was removed because the same characters mean different numbers in different languages. For values where octal is handy, such as permission bits, write 0x1ED (hexadecimal) or use bit operations.
3.10 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
add a (mul b c) | prefix notation — name first, inner calls in parentheses | no precedence rules to memorise |
… . | a detached stop closes a form (statement or clause) | newlines never change meaning — break lines anywhere |
do … end | every block | only one way to open a block |
rem … · note WHY … WHY | line comment · multi-line comment | no symbol comments (//, #) — they open with a word |
42 · 0x2A · 0b101010 · 1_000 | integer literals (the position decides the type) | no octal — 0755 is 755 |
"hi\n" · 'a' | a string (several bytes) · one character | the quote itself is the type difference |
true · false · none | booleans · no value | values are words too |
field p x · method s area | read a field · call a method | the dot is kept for name paths only |
allocs.bump_bytes · color.red | a name inside a module · a variant name | one dot means “that name inside this name” |
expr a + b * c | an infix island — arithmetic, comparisons, and/or only | long arithmetic reads easily; same meaning as prefix |
| Clause order in an op head | Table 3.4 | each clause comes before the ones that use it |
0b1010 · 1_000_000 · 0x1.8p1 · u'가' · U"…" | binary · digit separator · hex float · prefixed char and string | no octal — 0755 is 755 |
text DOC … DOC · note END … END | multi-line string (escapes not unescaped) · multi-line comment | long text goes in as written |
Table 3.5 — Surface rules — shape · meaning · why it looks this way
Recap
expr islands. A block is always do … end. Names contain no dots, and shadowing is forbidden. A literal that does not fit its place’s type is rejected. The clauses of an op head have one order, arranged so that earlier clauses are used by later ones.