Solveig cheatsheet
Everything the language answers, on one page, for when you know what you want and not what it is called. REFERENCE.md is the full account of each of these — this is the index to your own memory.
Every built-in message appears below, and a test in tests/test_compile.c fails if one is added without being listed here. The examples in the fenced blocks are run on every build and their answers checked, the same as everywhere else in this repository.
How the comments in this file are read
A ; comment after a line that prints says what it printed, and this repository
runs every one of them. Two conventions are worth knowing before reading the
rest, because both cost an outside reader time on 2026-09-01:
- **Prose after the value goes behind ` –
.**; #50 – sends chain left to rightclaims#50` and explains itself. Everything before the dash is the claim and is checked exactly. - One comment may describe several lines.
#3:repeat({ "tick":display })is written; tick tick tickand prints three lines, because a comment cannot hold a line break. Where a comment shows values separated by spaces and the code prints in a loop, read it as one line each.
Both print and display end the line. Nothing here writes without a newline
except system:write.
Syntax, all of it
a := #45. ; -- ':=' binds, '.' ends a statement
a:print. ; #45 -- ':' sends a message
a:add(#5):print. ; #50 -- sends chain left to right
xs := [#1, #2, #3]. ; -- array literal
xs:at(#2):print. ; #2
double := { x | x:mul(#2) }. ; -- a block: { params | body }
double:value(#21):print. ; #42
counted := { | n | n := #0. n:inc }. ; -- { | temps | body }
counted:value:print. ; #1
point := object:new. ; -- an object is made from another object
point:x := #3. ; -- a slot holds data
point:show := { self:x }. ; -- a slot holding a block is a method
point:show:print. ; #3
| Written | Is |
|---|---|
#45 |
an integer |
$FF08 %10101100 |
the same, in hexadecimal or binary — no #, no sign |
45 4.5 4e2 |
a float — the unmarked number is the float here |
"text" |
a string, bytes, with \n \t \" \\ escapes |
'name |
a symbol, interned, compared by pointer |
[a, b] |
an array — sugar for array:of(a, b) |
#[k = v] |
a dictionary — sugar for dictionary:of(k, v); #[ is one token |
{ x \| body } |
a block; { x, y \| \| t \| body } takes two and has a temp |
; to end of line |
a comment |
@include "file.sol". |
splices another file in, once, before compiling |
@expr( a^2 + b/2 ) |
infix operators — sugar for the sends they read as |
@expr( sqrt(x) ) |
inside a region, f(x) is x:f — one argument, any name |
@expr( a < b & ~c ) |
= <> < > <= >=, then ~, then & and \| |
@expr{ a < b } |
the same region over a block — a loop’s condition or body |
nil true false infinity nan |
the value globals |
object integer float string symbol array dictionary boolean block time error system |
the class globals |
Six rules that bite
| No implicit conversion, anywhere | #1:add(1.0) is an error, not 2.0. #1:lessThan(1.0) is an error. equals is the one exception and answers false across types. |
| Indices are one-based | xs:at(#1) is the first, and copyFrom(#a, #b) includes both ends. |
ifTrue answers the block’s value |
Not a boolean. Never chain ifTrue(...):ifFalse(...) — use ifElse(t, f). |
| Integers trap, floats do not | Overflow is an error; float division by zero is infinity. |
| A capturing block cannot outlive its frame | Reading an enclosing local is fine while that frame is alive, reported after (3.1). |
No ^, no early return |
A block answers its last expression; a loop is left by its condition or by error:raise (3.13). |
Control flow is message sending
There is no if, no while, no for. These are ordinary messages taking
blocks, and the compiler inlines them to jumps when written literally.
| Message | On | Answers |
|---|---|---|
ifTrue(block) ifFalse(block) |
boolean | the block’s answer, or nil |
ifElse(then, else) |
boolean | the chosen block’s answer |
and(block) or(block) |
boolean | short-circuit; the block runs only if needed |
not |
boolean | a boolean |
whileTrue(body) |
block | nil, having run body while the receiver answers true |
doUntil(condition) |
block | nil; body first, so always at least once |
repeat(#n) |
block | nil, having run the receiver n times |
#n:repeat(block) |
integer | nil, having run the block n times |
loop(block) |
array | [#a, #b] counts up to #b inclusive, block given each |
loop(block) |
array | [#a, #b, #step] the same by #step; negative counts down |
do(block) |
array, dictionary | the receiver, block per element |
Where the table says block, it means one — and it is checked when the
message is sent, not when the block would have run. So false:and(#45),
true:ifElse({ #1 }, #45) and []:collect(#45) are all errors, though none of
them would ever have reached the argument. A block held in a name counts; only
the literal form is inlined to jumps.
#3:greaterThan(#2):ifTrue({ "yes":display }). ; yes
n := #5:greaterThan(#9):ifElse({ "big" }, { "small" }).
n:display. ; small
i := #0.
{ i:lessThan(#3) }:whileTrue({ i := i:inc }).
i:print. ; #3
ticks := #0.
#3:repeat({ ticks := ticks:inc }).
ticks:print. ; #3
seen := "".
[#1,#7,#3]:loop({ n | seen := seen:concat(n:asString) }).
seen:display. ; 147
Every type answers these
| Message | Answers |
|---|---|
print |
the receiver, having written its literal form and a newline |
display |
the receiver, having written it and a newline, without quotes on a string |
asString |
its text |
equals(v) notEquals(v) |
a boolean; value for numbers and text, identity for containers |
isNil notNil |
a boolean |
isKindOf(class) |
a boolean, searching the prototype chain |
respondsTo('name) |
a boolean — whether a send would find something |
perform('name, ...) |
sends the message that symbol names |
slots |
an array of symbols naming the receiver’s own slots |
slotAt('name) |
the value in that slot, searching the chain like a send |
exports exports([names]) |
the object’s external surface, or nil; or draws one |
new |
on a class: a new instance; on a value type: refused, there is nothing to make |
#45:respondsTo('add):print. ; true
#45:perform('add, #5):print. ; #50
"x":isKindOf(string):print. ; true
nil:isNil:print. ; true
Reflection reads and never writes. There is no slotAtPut, no way to remove
a slot, and no re-parenting.
integer
Arithmetic traps on overflow rather than wrapping — which is why the textbook random generator cannot be written here at all.
| Message | Answers |
|---|---|
add(n) sub(n) mul(n) |
an integer; traps on overflow |
div(n) mod(n) |
floored; traps on zero |
inc dec |
one more, one less |
negated abs |
an integer; traps on the most negative |
lessThan(n) greaterThan(n) |
a boolean |
lessOrEqual(n) greaterOrEqual(n) |
a boolean |
bitAnd(n) bitOr(n) bitXor(n) bitNot |
an integer, bit by bit |
shiftLeft(#n) shiftRight(#n) |
an integer; #0 to #63 |
asFloat |
a float; loses precision above 2^53 |
asString |
the digits, without the # |
asBase(#n) |
the digits in base n, 2 to 36 |
asCharacter |
the one-byte string that byte spells; #0 to #255 |
repeat(block) on integer, loop(block) on [#a, #b] or [#a, #b, #s] |
loops — see above |
#7:div(#2):print. ; #3
#-7:div(#2):print. ; #-4 -- floored, not truncated
#7:mod(#3):print. ; #1
#255:asBase(#16):display. ; ff
#65:asCharacter:display. ; A
#5:asFloat:print. ; 5
float
The unmarked number. Everything integer has except asFloat, asBase and the
overflow traps, plus:
| Message | Answers |
|---|---|
floor ceiling rounded truncated |
an integer; errors on infinity, nan, out of range |
sqrt |
a float; nan for a negative |
sqrt |
a float; nan for a negative |
pow(other) |
self raised to other |
exp log |
e to the self; the natural logarithm |
sin cos tan |
radians |
asin acos atan |
radians; nan outside the domain |
float:pi |
3.141592653589793 — on the class |
float:atan2(y, x) |
the angle to a point, all four quadrants — on the class |
There is no asInteger: narrowing names its direction. Dividing by zero answers
infinity rather than erring. The mathematics is float only and radians only;
degrees are a multiplication.
2.7:floor:print. ; #2
-2.7:truncated:print. ; #-2 -- floor goes down, truncate to zero
9.0:sqrt:print. ; 3
2.0:pow(10.0):print. ; 1024
1.0:exp:print. ; 2.718281828459045
float:pi:div(2.0):sin:print. ; 1
float:atan2(1.0, 1.0):print. ; 0.7853981633974483
1:div(3):print. ; 0.3333333333333333
3.14159:asString("0.2"):display. ; 3.14
"[":concat(1234.5:asString(",10.2")):concat("]"):display. ; [ 1,234.50]
string
Bytes, not characters: "café":size is 5.
| Message | Answers |
|---|---|
size |
an integer |
at(#i) |
a one-character string; one-based |
concat(s) |
a new string; strict about its argument |
split(s) |
an array of the pieces between occurrences of s |
indexOf(s) indexOf(s, #from) |
where s first appears, one-based, or nil — from the start or from #from |
copyFrom(#a, #b) |
the characters #a to #b, both ends included |
fill([...]) |
the blanks {} filled in from the array |
trim |
the same text without the space around it |
asUppercase asLowercase |
a new string; ASCII letters only |
asInteger asFloat |
strict: the whole string must be a number |
asInteger(#n) |
reads base n, 2 to 36 |
asByte |
the number of the one byte in it |
asSymbol |
the interned symbol for these characters |
asTime asTime(format) |
an instant; ISO-8601, or strptime format |
asString(spec) |
padded text |
lessThan(s) greaterThan(s) lessOrEqual(s) greaterOrEqual(s) |
a boolean, comparing bytes |
There is no replace: split(a):join(b) is the same operation in two words.
"a,b,c":split(","):print. ; ["a", "b", "c"]
"hello":copyFrom(#2, #4):display. ; ell
"{} of {}":fill([#3, #10]):display. ; 3 of 10
" x ":trim:display. ; x
"a-b":split("-"):join("+"):display. ; a+b
"left":asString("<8"):concat("|"):display. ; left |
symbol
Interned and compared by pointer, which is what makes perform cheap.
| Message | Answers |
|---|---|
size |
an integer |
asString |
the name, as a string |
lessThan(s) greaterThan(s) lessOrEqual(s) greaterOrEqual(s) |
a boolean, comparing the text |
'add:asString:display. ; add
'add:equals('add):print. ; true
array
One-based, and add answers the array so it chains.
| Message | Answers |
|---|---|
new / of(...) |
an empty array / one of the arguments |
size |
an integer |
at(#i) |
the element; out of range is an error |
atPut(#i, v) |
the value stored |
add(v) |
the array, so it chains |
removeLast |
the last element, taken off; an error when empty |
indexOf(v) |
where v first is, or nil |
copyFrom(#a, #b) |
a new array, both ends included |
first(#n) last(#n) |
a new array of up to n; clamps |
do(block) |
the array, block per element |
collect(block) |
a new array of the block’s answers |
select(block) |
a new array of the elements the block accepted |
inject(start, block) |
one value, folded left to right |
sorted sorted(block) |
a new array, ascending or by the block |
join(s) |
the strings with s between them; strict |
xs := [#4, #1, #3].
xs:sorted:print. ; [#1, #3, #4]
xs:collect({ x | x:mul(#2) }):print. ; [#8, #2, #6]
xs:select({ x | x:greaterThan(#2) }):print. ; [#4, #3]
xs:inject(#0, { a, b | a:add(b) }):print. ; #8
xs:add(#9):size:print. ; #4 -- add answers the array
dictionary
| Message | Answers |
|---|---|
new |
an empty dictionary |
size |
an integer |
at(key) |
the value; an error when the key is not there |
at(key, default) |
the value, or default |
atPut(key, value) |
the value stored, so it chains |
includes(key) |
a boolean |
remove(key) |
the value removed; an error when absent |
keys values |
an array, in no order worth relying on |
do(block) |
the dictionary, block once per value |
keysAndValuesDo(block) |
the same, block taking a key and a value |
dictionary:of(...) |
key then value, alternating; an odd count is an error |
d := dictionary:new.
d:atPut("port", #8080).
d:at("port"):print. ; #8080
d:at("host", "any"):display. ; any
d:includes("port"):print. ; true
d:size:print. ; #1
dictionary:of("port", #8080, "host", "any"):size:print. ; #2
block
| Message | Answers |
|---|---|
value(...) |
the block’s answer; the count must match its parameters |
boundTo(receiver) |
a new block over the same code, with self set |
whileTrue(body) doUntil(condition) repeat(#n) |
loops — see above |
onError(handler) |
the block’s answer, or the handler’s if it failed |
ensure(cleanUp) |
the block’s answer, having run cleanUp either way |
timeToRun timeToRun(#n) |
seconds one run, or n runs, took, as a float |
{ error:raise("no") }:onError({ e | e:message }):display. ; no
{ #1:div(#0) }:onError({ e | "caught" }):display. ; caught
cleaned := false.
r := { #2:add(#2) }:ensure({ cleaned := true }).
r:print. ; #4
cleaned:print. ; true
object, and errors
| Message | Answers |
|---|---|
new |
a fresh object delegating to the receiver |
via(ancestor) |
a delegating view: lookup starts there, self stays |
parent |
the prototype, or nil at the root; read-only |
error:raise(text) |
never — it unwinds to the nearest onError |
e:message |
the text an error was raised with |
via is what super is elsewhere, except that it names what it overrides.
animal := object:new.
animal:speak := { "..." }.
dog := animal:new.
dog:speak := { self:via(animal):speak:concat("woof") }.
dog:speak:display. ; ...woof
dog:parent:equals(animal):print. ; true
time
An instant, held as nanoseconds since 1970. A value: two are equal when they name the same instant.
| Message | Answers |
|---|---|
time:fromSeconds(f) |
an instant, from seconds since the epoch (on the class) |
asSeconds |
seconds since the epoch, as a float |
secondsSince(other) |
a float; negative when other is later |
plusSeconds(f) |
another instant, f seconds along |
year month day |
integers; January is #1 |
hour minute second |
integers |
weekday |
an integer; Monday is #1, Sunday #7 |
asString |
ISO-8601 in UTC |
asString(format) |
the format handed to strftime |
lessThan(t) greaterThan(t) lessOrEqual(t) greaterOrEqual(t) |
a boolean |
t := time:fromSeconds(946684800.0).
t:asString:display. ; 2000-01-01T00:00:00Z
t:year:print. ; #2000
t:weekday:print. ; #6 -- a Saturday
t:plusSeconds(86400.0):day:print. ; #2
"2000-01-01T00:00:00Z":asTime:equals(t):print. ; true
system
The process, rather than any value. One object with slots, not a class.
| Message | Answers |
|---|---|
arguments |
an array of strings; empty when there were none |
exit(#status) |
nothing — the program stops, #0 to #255 |
clock |
monotonic seconds as a float; only differences mean anything |
time |
the current instant |
write(text) |
writes a string to standard output, adding nothing — no newline |
writeError(text) |
the same, to standard error — the only way to reach it |
readLine readKey |
one line, or one byte, of standard input; nil at the end |
readUpTo(#n) |
up to n bytes of it, as they were sent; nil at the end |
isTerminal(which) |
whether 'input, 'output or 'error is a terminal |
terminalSize |
a dictionary of "rows" and "columns", or nil off a terminal |
keyWaiting(seconds) |
whether a byte is there to read, waiting up to that long |
sleep(seconds) |
waits that long and answers nil; a float, like every duration here |
load(path) |
true having run a compiled .sob here, false if already loaded |
readFile(path) |
the whole file as a string; "/dev/stdin" reads a pipe too |
readFile(path, #from, #count) |
count bytes from the one-based position from; short at the end, "" past it |
writeFile(path, text) appendFile(path, text) |
nil, having written |
fileExists(path) isDirectory(path) |
a boolean |
fileSize(path) |
an integer, without reading the file; nil if nothing is there |
fileId(path) |
which file is at that path, as a string; nil if nothing is there |
filesIn(path) |
an array of the names in a directory |
makeDirectory(path) |
true if it made one, false if it was there |
remove(path) |
nil, having deleted a file or an empty directory |
rename(from, to) |
nil, having moved it; replaces an existing to |
modifiedAt(path) setModifiedAt(path, t) |
when a file was last written; nil if nothing is there |
modeOf(path) setMode(path, #mode) |
the permission bits, #0 to #4095 |
environment(name) |
the variable, or nil when unset |
run(argv) run(argv, streams) |
the exit status; argv is an array, never a command line |
capture(argv) capture(argv, streams) |
a dictionary of "output" and "status" |
run and capture take an array so that nothing in it is ever read as syntax.
#127 is the status for no such command.
streams is an array of alternating name and value — "stdin", "stdout" or
"stderr", then 'share, 'discard, 'merge (stderr only, and it follows
stdout) or a path as a string. capture refuses "stdout", which is the
one it keeps.
system:writeFile("note.txt", "hello").
system:readFile("note.txt"):display. ; hello
system:fileExists("note.txt"):print. ; true
system:fileSize("note.txt"):print. ; #5
system:capture(["echo", "hi"]):at("output"):trim:display. ; hi
system:capture(["echo", "hi"], ["stderr", 'discard]):at("status"):print. ; #0
system:remove("note.txt").
random
A generator you make: random:new is seeded by the machine, random:new(#seed)
repeats.
| Message | Answers |
|---|---|
new / new(#seed) |
a generator — by the machine, or by you and repeatable |
seed |
the integer it was made with; a slot, so slots shows it |
upTo(#n) |
an integer #1 to #n, both included — the range an array is indexed by |
between(#a, #b) |
an integer #a to #b, both included |
fraction |
a float, at least 0.0 and less than 1.0 |
r := random:new(#20260824).
r:upTo(#6):print. ; #3
r:between(#10, #20):print. ; #13
r:seed:print. ; #20260824
random:new(#7):upTo(#100):equals(random:new(#7):upTo(#100)):print. ; true
The prototype answers none of these — random:upTo(#6) is an error, because a
generator everything shares is what new exists to avoid. State on system
would have given a VM a history; in an object, a program that never says
random:new runs the same twice.
The library
Shipped .sol files on the search path — @include "name.sol". finds them
without being told where they live.
| File | Binds | For |
|---|---|---|
| control.sol | integer:timesCollect(block), array:ifElseIf |
n results gathered; a chain of alternatives written flat |
| math.sol | min max between on numbers; min max on arrays |
the comparisons written out by hand too often |
| text.sol | integer:asUtf8 |
a code point as the bytes UTF-8 spells it |
string:startsWith string:endsWith |
a boolean; not indexOf(x):equals(#1), which searches the whole string to fail |
|
| shell.sol | shell:run capture read line |
when the shell’s pipes and globs are the point |
| json.sol | json:read json:write value:asJson |
JSON in and out |
| html.sol | html:read, a tree with find, text, attribute |
HTML that recovers from bad markup |
| scan.sol | scan:on, a cursor: peek next takeWhile since |
reading text a character at a time |
| re.sol | re:on or re:ere, then find findLast matches replaceAllIn group |
regular expressions and substitution, POSIX basic and extended |
@include "math.sol".
#3:min(#7):print. ; #3
[4.0, 1.0, 9.0]:max:print. ; 9
#5:between(#1, #10):print. ; true
@include "json.sol".
v := json:read("{\"port\": 8080}").
v:at("port"):print. ; #8080
v:asJson:display. ; {"port":8080}
Running it
solas prog.sol # compile to prog.sob
solas prog.sol -o out.sob # somewhere else
solvm prog.sob # run it
solvm prog.sob a b # with arguments, seen as system:arguments
solvm --trace prog.sob # write the call tree
solvm --steps N --memory N # bound it
solis # the REPL
solid prog.sob # the debugger: step, next, break, print
solid --exports lib.sob # what it binds, and what may be sent to it
solid --exports --extension=x.so
# the same for a C extension
What is deliberately not in the language is in ROADMAP.md section 3. Why any of it is shaped this way is in design.md, and lineage.md places it against Smalltalk, Self, Io, Lua and Ruby.