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 stops 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. One rule reads all of it — a name opens, a stop closes. In add a. b. ., add opens a form and the last stop closes it. a. and b. follow the same rule: the variable’s name opens, and the stop glued to it closes. A literal such as 1 or "hi" is not a name, so it takes no stop. When a form sits inside a form, the inner one is closed first, by its own stop.

let total u64 add 1 2 . .
let mixed u64 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 stops already say so — the first stop closes mul, the second closes add. The principle is the same for ops you write yourself. stdio.write out. 1 "hi" ., field p. x . and mean xs. . all put the name first.

Parentheses are decoration: they never change the meaning. Use them to make an inner form stand out, as in add 1 (mul 2 3 .) .. The form inside the parentheses still closes with its own stop — parentheses do not replace it.

Q. When arithmetic gets long, don’t the stops 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#

Every open form is closed by a full stop . — a value, a statement, a clause, a declaration. That stop is called a closer. One stop closes the innermost form that is still open. A variable is written with the stop glued on, a. (the spaced a . means the same). 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, starting in the first column. 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 the forms opened and the stops that close them do not match, the compiler says so. Leaving one stop out looks like this.

examples/ch03/dots.low

module dots .
rem expect: E-DOT-MISSING

fn poly input a u64 . input b u64 . output u64 .
do
  rem ✘ one stop short --- nothing closes `add`
  return add mul a. 2 . mul b. 3 . .
end .

Output

$ lowentc --check dots.low
8:1 E-DOT-MISSING: a stop `.` must close `return` — found `end` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

The diagnostic says it reached end with nothing closing the return. An open form cannot cross a block’s end. So one missing stop is caught inside that block instead of silently swallowing the rest of the file. The other way round, a stop with nothing left to close is rejected with E-CLOSER-EXTRA.

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:60 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 0 .
  var i u64 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. And end closes the block, not the form — a form is always closed by a stop. So there are three things to know.

C has no ; after if (c) { … } but one after p = (struct point){ 1, 2 };. Lowent makes no such distinction — what was opened is closed by a stop.

while le i. n. . do  …  end .              the condition closes with its own stop, the while form with the stop after end
      └ condition ┘
└──────── while form ──────┘

let p lit point do x 1 . end . .           the first stop closes the lit value, the second the let statement
      └───── lit 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. 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"…"
raw string (no escapes)r"\d+\.\d+"
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 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 0b1010 .
  let b u64 1_000_000 .
  let c u64 0xFF_FF .
  let d u64 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 1.5e3 .
  let h f64 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 'A' .
  let k u16 u'가' .
  let s u32 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 usize . do
  let x usize len "\x41e" . .
  let w usize len u"가나" . .
  rem text TAG … TAG holds several lines as written; a \n in the body is not unescaped
  let h usize 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

An r glued before the quote makes a raw string. Its body has no escapes, so a backslash is one byte as written. The literal ends at the closing double quote — so a raw string cannot hold a double quote; when you need one, use an ordinary string’s \".

examples/ch03/raw_string.low

module raw_string .
rem run: raw_len
rem run: escaped_len
rem run: pattern_len

rem a raw string --- backslashes stay as written. `\\` is two bytes
fn raw_len output usize .
do
  return len r"\\s192kf%d" . .
end .

rem the same text as an ordinary string --- `\\` becomes one backslash
fn escaped_len output usize .
do
  return len "\\s192kf%d" . .
end .

rem text full of backslashes, like a regex, reads better raw
fn pattern_len output usize .
do
  return len r"^\d+\.\d+$" . .
end .

Output

$ lowentc --run raw_len raw_string.low
raw_len() = 10
$ lowentc --run escaped_len raw_string.low
escaped_len() = 9
$ lowentc --run pattern_len raw_string.low
pattern_len() = 10

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.num), 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 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, core 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 while for repeat range cycle next step guard match case try break continue expr keep
Valueslit true false none
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 core 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.

The same rule holds inside the island. A variable is written a., and the island that expr opened is closed by one stop: expr a. + b. * 2 ..

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
5useWhich 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 usize .
  effects io .
  requires gt len src. . 0 . .
do
  return stdio.write 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-VOCAB-REMOVED

fn twice input a u64 . output u64 .
do
  rem ✘ `//` is not a comment marker
  // double it
  return mul a. 2 . .
end .

Output

$ lowentc --check mistake_slash.low
7:3 E-VOCAB-REMOVED: `//` does not start a comment — a comment is `rem …` to the end of the line — found `/` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

Comments in this language are rem (line comment) and note WHY … WHY (multi-line), nothing else. // is not a comment, so it is rejected with E-VOCAB-REMOVED, and the diagnostic tells you to write rem. 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 · `use` · 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-CTRL-NODO

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
8:5 E-CTRL-NODO: a block `do … end` belongs here — found `return` (RFC-0142: a name opens, a stop closes; a variable is written `a.`)

Bodies are not opened by indentation as in Python. Once the condition is closed by its own stop, do must come next. Anything else there is rejected with E-CTRL-NODO (a do … end block belongs here). 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 usize .
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 — a name opens, a stop closesno precedence rules to memorise
… .a stop closes the innermost open form (value, statement, clause, declaration)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. area. .read 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 … ENDtext literal (escapes are not processed) · multi-line commentlong text goes in as written
r"\d+"raw string — no escapes, ends at the closing "backslashes are not written twice

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.