Lowent Manual←↑→

3 The surface — full stops, blocks and clause order

What to know first

chapter 1, What Lowent sets out to do · one meaning, one spelling
chapter 2, A first program · --check diagnostics and --fmt

Looking 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

Lowent code looks strange at first. There are more full stops than parentheses, and words come before operands instead of operators between them. Memorising that strangeness as a list of exceptions takes a long time. But the rules of the surface are few, and all of them follow from chapter 1′s “one meaning, one spelling”. Every later chapter is written on this surface, so it is gathered here in one place before values and flow (Part II).

By the end of this chapter

You will learn that a free-standing full stop closes a form while a newline closes nothing, that notation is prefix with the name first, and where the boundaries of the 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

  1. When arithmetic gets long, don’t the parentheses pile up and become hard to read?
  2. 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 once did. When the processor met ; 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 .
end

do … 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.

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#

KindExamples
Integers (decimal, hex, binary)42 · 0xFF · 0b1010 · 1_000_000
Floating point3.14 · 6e-3 · 0x1.8p3
Strings (bytes)"hello\n"
Strings (UTF-16 · code points)u"…" · U"…"
Booleans · absencetrue · 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).

WrittenMeaningWrittenMeaning
\\backslash\vvertical tab
\"double quote\0zero byte
\'single quote\xNNtwo hex digits — any single byte
\aalert\uXXXXcode point, four digits
\bbackspace\UXXXXXXXXcode point, eight digits
\fform feed\nnewline
\rcarriage return\thorizontal 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

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.

GroupWords
Declarationsmodule use type newtype struct enum trait contract actor state
Opsfn proc export unsafe extern
Locals and flowlet var set return if else for while guard match case try break continue expr
Valuesmake true false none be
Othersspawn 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.

OrderClauseWhy here
1satisfies · lowdocSays what the op is first
2vector · priorityThe character of the op as a whole
3comptime inputsLater input and output types use these names
4Capability and region inputs (cap … · region …)Who allowed it is visible before what it receives
5usingWhich allocator the following data uses
6Data inputsThe values the op receives
7outputThe output type may use the inputs’ type parameters
8effectsWhat it does with the capabilities received above
9link · variadic · asmPlaces that connect to the outside (C, machine code)
10access · parallel · reduceHow it runs split up
11requires → ensures → errors → testsInput 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 .
end

Written 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

This order was not settled in one go. For a while the order put 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#

ShapeMeaningWhy
add a (mul b c)prefix notation — name first, inner calls in parenthesesno precedence rules to memorise
… .a detached stop closes a form (statement or clause)newlines never change meaning — break lines anywhere
do … endevery blockonly one way to open a block
rem … · note WHY … WHYline comment · multi-line commentno symbol comments (//, #) — they open with a word
42 · 0x2A · 0b101010 · 1_000integer literals (the position decides the type)no octal — 0755 is 755
"hi\n" · 'a'a string (several bytes) · one characterthe quote itself is the type difference
true · false · nonebooleans · no valuevalues are words too
field p x · method s arearead a field · call a methodthe dot is kept for name paths only
allocs.bump_bytes · color.reda name inside a module · a variant nameone dot means “that name inside this name”
expr a + b * can infix island — arithmetic, comparisons, and/or onlylong arithmetic reads easily; same meaning as prefix
Clause order in an op headTable 3.4each clause comes before the ones that use it
0b1010 · 1_000_000 · 0x1.8p1 · u'가' · U"…"binary · digit separator · hex float · prefixed char and stringno octal — 0755 is 755
text DOC … DOC · note END … ENDmulti-line string (escapes not unescaped) · multi-line commentlong text goes in as written

Table 3.5 — Surface rules — shape · meaning · why it looks this way

Recap

A free-standing full stop closes a form, and a newline is whitespace. Expressions are prefix with the name first, and infix is written only inside 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.