The grammar of Solum
This is syntax only. self is an ordinary identifier here and a scope error
to solas; a duplicate temporary parses and does not compile. The rules a
grammar cannot carry are in PRODUCING.md.
The whole language on one page.
solum.bnf is the same grammar in a form a
machine reads, and that sentence is checked: every production below is
compared against it character for character on each test run, and the two that
are prose rather than notation are named in the report rather than skipped
quietly. solum.bnf in turn is run against every .sol file in this repository
by check_syntax. So this page is held to the
grammar, and the grammar is held to the language.
The notation is Wirth’s, from What can we do about the unnecessary diversity of notation for syntactic definitions (1977), and it is the notation the Pascal report is written in:
a b |
a then b |
a \| b |
a or b |
[ a ] |
a, or nothing |
{ a } |
a, any number of times, including none |
( a ) |
grouping |
"a" |
those characters, exactly |
"a" .. "z" |
one character between the two |
"\\" |
a backslash, inside a literal |
. |
ends a production |
Words and numbers
identifier = letter { letter | digit } .
letter = "a" .. "z" | "A" .. "Z" | "_" .
digit = "0" .. "9" .
hexdigit = digit | "a" .. "f" | "A" .. "F" .
bindigit = "0" | "1" .
integer = "#" [ "-" ] digit { digit }
| "$" hexdigit { hexdigit }
| "%" bindigit { bindigit } .
float = digit { digit } [ "." digit { digit } ] [ exponent ] .
exponent = ( "e" | "E" ) [ "+" | "-" ] digit { digit } .
string = '"' { escape | any character but '"' } '"' .
escape = "\\" ( '"' | "\\" | "n" | "t" | "r" ) .
symbol = "'" letter { letter | digit } .
comment = ";" { any character but a newline } .
The tag is the whole difference between 45 and #45, which are the same
characters with two readings. $FF08 and %10101100 are integers too, in the
base you are thinking in, and they take no tag and no sign: they are for looking
at bits, and there is no hexadecimal float to be told apart from.
A leading - belongs to the number — outside a @expr region. There the
scanner gives the sign to the literal, and a - with no digit after it is not a
token at all. Inside a region it is always the operator, and -3 is the
operator applied to 3, which the compiler folds back to the one constant.
So this page reads -3 as the operator everywhere, which is why float
above has no sign in it. A lexical grammar has no regions to be inside of, and
the two readings agree on every value, so one grammar describes both. What it
costs is listed at the bottom with the other things the compiler refuses and
this page does not.
. continues a number only when a digit follows it, so 45. is the float 45
and then a statement separator.
Statements
program = [ statement { "." statement } [ "." ] ] .
statement = include | expression .
include = "@include" string .
. separates statements rather than terminating them — required between
two, optional after the last. That is the rule in a file, in a block and in a
group alike. An empty file is a program.
@include is the only directive, and the only statement that is not an
expression. What it does happens while compiling, and there is nowhere inside
an expression to compile a file into.
Expressions
expression = identifier ":=" expression
| disjunction .
disjunction = conjunction { "|" conjunction } .
conjunction = negation { "&" negation } .
negation = "~" negation | comparison .
comparison = sum [ ( "<=" | "<>" | ">=" | "<" | ">" | "=" ) sum ] .
sum = product { ( "+" | "-" ) product } .
product = unary { ( "*" | "/" ) unary } .
unary = "-" unary | power .
power = ( call | primary ) { send } [ "^" unary ] .
call = identifier "(" expression ")" .
send = ":" identifier [ arguments | ":=" expression ] .
arguments = "(" [ expression { "," expression } ] ")" .
primary = identifier | integer | float | string | symbol
| block | array | dictionary | group | region .
region = "@expr" ( "(" expression ")" | block ) .
Sends chain left to right. a:add(#1):print sends print to the sum, and
outside a @expr region there is no precedence to know, because there are no
operators to have any.
A region opens with either delimiter, and which one it is decides what it
answers rather than what it reads: @expr(...) answers the expression’s value
and @expr{...} answers a block whose body is infix. That is the language’s own
(group)/{block} pair, so region reaches block rather than repeating it.
The ladder runs from | at the loosest to ^ at the tightest, with ~
between the logic and the comparisons — so ~a = b is ~(a = b), which is what
the words say and what BASIC reads. C and Pascal both bind it tightest and would
have read (~a) = b. Comparison does not chain, and the
optional-rather-than-repeated tail of comparison is the whole of how that
is said: a < b < c would compare a boolean to c.
| is the one operator the language already used. A block’s parameters and
a group’s temporaries are matched before a body is, and ordered choice is what
keeps that true — so { a | b } is still a block taking a, and ( a | b ) is
a disjunction because a group’s temporaries have to come first and did not.
The ladder is written once, at the top of expression. That is not tidiness
— it is the rule. A region is lexical: it covers everything inside it, so an
argument, an array element, a group and a block body all read as infix within
one. A ladder every expression reaches is that rule with nothing duplicated.
^ groups to the right and binds tighter than the minus in front of it, so
-2^2 is -(2^2) and 2^3^2 is 2^(3^2). The other three group to the left,
the way they read.
sin(x) is x:sin — prefix application is a send to its argument, which is
the whole rule. The name is an identifier and not a word this page names, and
that is deliberate: a list of the mathematical functions would have had to be
written here as literals, and a checker that reserves every word-shaped literal
a rule mentions would then have taken sin and cos out of circulation as
ordinary names. The reserved-word count stays at nought because the rule is
general.
Exactly one argument, which is what leaves the rule with no exceptions. The
two-argument cases are the ones that would have needed them — float:atan2 is
class-side, so atan2(y, x) could never have meant y:atan2(x), and pow
already has ^ — and neither can enter a rule that has no two-argument form to
enter. float:atan2(y, x) is written out, as a term like any other.
:= binds, and it appears in exactly two places. After a bare identifier it
binds a name; after a send that took no arguments it binds a slot, which is
what makes point:x := #3 and, when the value is a block, a method. After a send
that took arguments it is a syntax error — o:at(#1) := #2 is not a way of
storing into a collection, and that is why the two possibilities are written
inside send rather than after the chain.
Blocks, arrays and groups
block = "{" [ parameters ] [ temporaries ] body "}" .
array = "[" [ expression { "," expression } ] "]" .
dictionary = "#[" [ pair { "," pair } ] "]" .
pair = sum "=" expression .
group = "(" [ temporaries ] expression { "." expression } [ "." ] ")" .
parameters = identifier { "," identifier } "|" .
temporaries = "|" identifier { "," identifier } "|" .
body = [ expression { "." expression } [ "." ] ] .
The leading | of a temporary list is what tells the two apart:
{ a | a }. ; -- one parameter
{ | a | a }. ; -- no parameters, one temporary
{ a | | t | t }. ; -- one parameter and one temporary
{ a:print }. ; -- neither: a body that happens to start with a name
It is also why parameters could not have reused the parenthesised form: { (a) }
would be both a one-parameter block and a block answering the value of a.
An array literal is sugar, and for exactly one thing: [a, b] is
array:of(a, b).
A group holds at least one expression, its value being the last one’s, and it
may open with | a, b | — which is the only way to declare a temporary at the
top level of a script.
What is not here, and why the page is this short
There are no keywords. Not a single one. nil, true, object and self
are ordinary identifiers that happen to be bound, and nothing above reserves a
word. A grammar-driven checker discovers this by having nothing to reserve.
There is no control-flow syntax, so no if, no while, no for, no
return, and no else to dangle. Those are messages taking blocks —
ifTrue:, whileTrue: — and the compiler inlines them to jumps when it can see
that is what they are. That is an optimisation and not a rule of the language:
the grammar above admits nothing about them, because there is nothing to admit.
There are no operators outside @expr. That sentence used to have no
qualifier, and losing it was the price of the one notation the language has for
arithmetic. What was bought is that a transcribed formula can be read against
the page it was copied from: a send chain runs left to right and precedence does
not, so a^2 + 3*(sin(a/2) + sqrt(b)) written as sends puts its outermost
operation in the middle of the line.
What did not change is the semantics. Every operator lowers to the send it
reads as — + to add, ^ to pow — and the region emits the bytes the chain
would have emitted, which a test compares rather than takes on trust. That is
the rule an array literal already lives under: [a, b] is array:of(a, b), and
two spellings of the same thing mean the same thing. a:add(b) is a send like
every other, and so is @expr( a + b ).
Two things are refused by the compiler rather than by the grammar, so a file may match this page and still not compile:
self outside a block |
there is no receiver at the top level of a script |
| an escape that is not one of the five | "\q" scans as a string and is then refused |
an operator, or f(x), outside @expr |
the ladder and call above are written once and reached everywhere, because a region is lexical; the compiler is what knows where one begins |