Learning Cryn
This tutorial teaches the whole Cryn language from zero. It is written for people who have never programmed before. If you have programmed before, you can skim, but read the first chapters anyway: Cryn works differently from most languages, and the early mental model matters.
Every example in this tutorial is a complete program. You can type it into a file and run it. How to run programs is explained right after the first example.
1. The idea behind Cryn
Most languages give you symbols: +, {, ;, ->. Cryn gives you none of those. Instead, a Cryn program is a list of short English sentences. Each sentence tells a small machine to do one thing.
The machine has four parts, and everything in this tutorial builds on them:
- Cells. A cell is a box that holds one value. A cell has a type, like a number box or a letter box. Cells live in a frame (one per transformation call) or in the world (shared by the whole program).
- Focus. The focus is a finger pointing at one cell (or at an element inside a cell). Commands like
readandwriteact on whatever the focus points at. - Working value. This is the one value you are currently holding in your hand. Arithmetic and comparisons work on it. At any point in the program it has exactly one type.
- Value stack. This is a pile where you can put values aside for later. You can only reach the top of the pile.
A Cryn program is a sequence of sentences that move values between these four places. There are no variables in the usual sense, no expressions, and no operator precedence to memorize. If you can follow a recipe of plain instructions, you can follow Cryn.
One more comfort: whitespace means nothing. Newlines are just spaces. Every program could be written on a single line if you wanted. Blocks are marked with words (define ... end), not with braces or indentation.
2. First program
Make a file called hello.cryn with this content:
define main
make string "Hello, world!\n" greeting
address greeting
output text
return
end
Run it from a terminal:
cryn run hello.cryn
You should see:
Hello, world!
What happened, sentence by sentence:
define main ... endwraps the program. Every program needs a transformation calledmain. Execution starts there.make string "Hello, world!\n" greetingcreates a cell namedgreetingthat holds the given text. The\nat the end is a newline character.address greetingputs the address ofgreetinginto the working value. Think of an address as a note saying where something lives.output textprints the text at that address, up to the first zero byte, to the output stream (the terminal by default).returnendsmain.
The same program in Python:
print("Hello, world!")
Exercise: make it print your name instead, keeping the newline.
Useful compiler commands (you will use the first two a lot):
cryn run hello.cryn # compile and run
cryn hello.cryn # compile to an executable called "hello"
cryn build hello.cryn -o out # compile to an executable called "out"
cryn check hello.cryn # type check only, do not build
cryn emit-c hello.cryn -o out.c # look at the generated C code
cryn emit-ir hello.cryn # look at the compiler's intermediate code
3. Comments
A comment is text the compiler ignores. Cryn has exactly one comment form. It starts with [# and ends with #]. It can span lines.
[# this is a comment #]
[# this comment
covers two lines #]
define main
set 1 [# comments can sit next to code #]
output
return
end
Comments cannot nest. The first #] closes the comment.
4. The working value and set
The working value is what you are holding. The set command puts a literal value into it.
define main
set 42
output
return
end
This prints 42.
In Python you would write x = 42 and then print(x). Cryn has no x. The working value is the one thing in hand, and set puts a value into it. The named places come later, as cells (chapter 9).
Literals you can set:
| Example literal | Meaning |
|---|---|
set 42 | an integer |
set -7 | a negative integer |
set 0x1F | an integer in hexadecimal |
set 2.5 | a float |
set 1.5e3 | a float with an exponent, 1500 |
set 'A' | one character, value 65 |
set '\n' | the newline character |
set true | a boolean, printed as true |
set false | a boolean, printed as false |
set nothing | no value at all |
output prints the working value. It never prints a space or a newline by itself. If you want a newline, print the character '\n' or a string that ends with \n.
Character escapes inside '...' and "...": \n newline, \t tab, \r carriage return, \0 zero byte, \\ backslash, \' quote, \" double quote.
set nothing empties the working value. Its type becomes nothing. You cannot output or convert nothing; it is mostly used to clear your hand.
Exercise: print true, then a tab character, then false, with nothing between them.
5. Math
Math commands change the working value. Each one takes the other operand either from a literal written after the command, or from the top of the value stack.
With a literal:
define main
set 10
add 5
output
return
end
This prints 15. In Python: print(10 + 5).
From the stack (explained fully in chapter 10, but the idea is simple: push puts the working value on a pile, and a math command without a literal takes the top of the pile as the other operand):
define main
set 10
push
set 3
subtract
output
return
end
The subtract pops the 10 and computes working value minus popped value, which is 3 - 10. This prints -7. The Python spelling is print(3 - 10).
The six math commands:
| Command | Result |
|---|---|
add | working value plus other |
subtract | working value minus other |
multiply | working value times other |
divide | working value divided by other, fraction cut off toward zero |
remainder | the remainder left over after divide |
negate | flips the sign of the working value, no operand |
Examples you can try:
define main
set 7
divide 2
output
set -7
divide 2
output
set 7
remainder 2
output
set 5
negate
output
return
end
This prints 3, then -3, then 1, then -5. Note two honest details: division cuts off toward zero (-7 / 2 is -3), and the remainder keeps the sign of the left side.
Division by zero stops the program with an error message. More on errors in chapter 25.
Exercise: compute (10 - 4) * (3 + 2) with push and print the result. Mind the order: subtract takes the popped value away from the working value, so the 4 goes on the pile before the 10 lands in hand.
6. Types
Every value has one type, and the compiler checks types before the program runs. The full type list lives in the Language Reference. The ones you meet first:
| Type | Holds |
|---|---|
int8 int16 int32 int64 | signed whole numbers of 8 to 64 bits |
uint8 uint16 uint32 uint64 | unsigned whole numbers of 8 to 64 bits |
float32 float64 | decimal numbers, single or double precision |
char | one byte, 0 to 255, with character intent |
bool | true or false |
address | a location in memory |
nothing | no value |
A plain literal like set 5 has the special type "integer literal". It turns into a concrete type the moment you combine it with one, for example when you store it into a typed cell. If two plain literals meet, they become int64.
convert changes the type of the working value on purpose:
define main
set 65
convert uint8
output
return
end
This prints A, because 65 is the code for the letter A and uint8 values print as single bytes.
Integer conversions keep only the low bits, which is called wrapping:
define main
set 65535
convert uint16
add 2
output
return
end
65535 plus 2 is 65537, which does not fit in 16 bits. It wraps to 1. This prints 1. Wrapping is defined behavior in Cryn, not an accident.
Float to integer conversion saturates instead of wrapping. It clamps to the nearest value that fits:
define main
set 300.7
convert int8
output
return
end
This prints 127, the largest int8. A value below the range would print -128.
Python has one integer type and it never overflows. Cryn fixes the width in the type and defines overflow as wrapping, so 127 plus 1 in an int8 cell is -128, not an error.
Some conversion rules worth knowing:
- float to integer saturates (just shown)
- anything between integer types wraps
- integers to floats converts the value
addressconverts toint64/uint64and back by reusing the bits- floats and addresses never convert directly into each other
- arrays cannot be converted at all
Exercise: predict what set 3.9, convert int64, output prints, then run it. Repeat with -3.9. Then predict set 300.9, convert int8, output.
7. Choices: compare and jump when
Cryn has no if. You compare two values, and then you can jump to a named spot only when the comparison had a certain outcome.
compare compares the working value with either a literal or the top of the value stack. The answer is remembered. Then jump <name> when <condition> jumps to a label only when the remembered answer matches the condition.
The six conditions:
| Condition | True when the working value was |
|---|---|
less | smaller than the other value |
equal | equal to the other value |
greater | larger than the other value |
different | not equal to the other value |
less-or-equal | smaller or equal |
greater-or-equal | larger or equal |
A label is made with mark <name> and is visible in the whole transformation. jump <name> without when always jumps.
define main
set 10
compare 10
jump same when equal
set 0
stop
mark same
set 1
output
return
end
The compare says "working value 10 equals 10", so the jump is taken. This prints 1. If you change set 10 to set 11, the jump is skipped, the program runs set 0 and stop, prints nothing, and exits with code 0.
The same shape in Python is an if:
if 10 == 10:
print(1)
Cryn has no if. The compare leaves an answer, the jump reads it, and those are always two separate steps.
Two rules to remember:
- A conditional jump must follow a
compareon every path that reaches it. The compiler rejects awhenjump that could arrive without a comparison. - Comparisons are only on numbers, chars, booleans against booleans, and addresses against addresses.
Exercise: print 1 when 3 is less than 4, and 0 when it is not. Two paths through one mark.
8. Loops
A loop is a label plus a jump back to it. Here is a countdown:
define main
make int64 i
set 5
move i
write
mark again
move i
read
compare 0
jump done when equal
move i
read
output
move i
read
subtract 1
move i
write
set 0
jump again
mark done
return
end
This prints 54321.
In Python:
i = 5
while i != 0:
print(i, end="")
i = i - 1
The mark again plays the while and jump again is what loops back.
New commands here: make, move, read, write. They get their full chapter next. For now: make int64 i creates a cell, move i points the focus at it, write stores the working value into it, read loads it back into the working value.
Notice set 0 right before jump again. This is a small habit that keeps loops simple: it makes the working value the same on every arrival at the label. Without it, the first arrival holds a literal 5 and later arrivals hold other things. The compiler would widen the working value to int64, which works here, but there are loops where the types would clash. Getting into the habit of normalizing the working value before jumping back saves you confusing errors. Chapter 28 lists the common ones.
Exercise: change the countdown to print 12345 instead. The order of the compare, the print, and the subtract is the whole puzzle.
9. Cells, focus, read and write
Cells are the boxes of the machine. Every cell has a name and a type and starts at zero.
Making cells:
make int64 score [# one cell, zeroed #]
make float32 rate
make array 10 of int64 ns [# ten int64 cells in a row #]
make string "hi\n" s [# a cell holding bytes plus a zero byte #]
make 8 of char buf [# a heap block of 8 chars, address below #]
Inside a transformation, make ... name creates a frame cell for that call. At the top of the file, make creates a world cell shared by the whole program (chapter 13).
The focus is your finger. You point it at a cell with move:
define main
make int64 score
set 5
move score
write
move score
read
add 1
move score
write
move score
read
output
return
end
This prints 6. The pattern move score / write stores, move score / read loads.
In Python:
score = 5
score = score + 1
print(score)
A cell is the variable. The focus is which variable you mean, and move is how you point at it.
Focus movement commands:
| Command | Focus after |
|---|---|
move name | points at the named cell, and resets the focus history |
move 3 | steps into element 3 of the array in focus |
move index | steps into the element given by the working value |
move at | steps to the address held in the working value |
back | undoes one step of move 3, move index, move at |
home | back to the frame root, history cleared |
The focus history is how back works. Only the stepping moves push onto the history. move name resets the history, because a named move is a fresh anchor: after move score, back takes you to the frame root, and another back is an error.
A small walk through an array:
define main
make array 3 of int64 nums
set 7
move nums
move 1
write
move nums
move 1
read
output
home
return
end
This prints 7. move nums points at the array, move 1 steps into element 1, write stores there.
Rules about read and write:
- the focus must be on a single cell, not on a whole array, and not on the frame root
writestores the working value if the types match. An integer literal widens to the cell type if it fits. Anything else needsconvertfirstreadmakes the working value a copy of the cell, with the cell's type
Exercise: make two cells holding 3 and 8, and print the bigger one using a third cell. Chapter 7 does the deciding.
10. The value stack: push, pop, swap
The value stack is a pile. push puts the working value on top. pop takes the top off and makes it the working value. swap exchanges the working value with the top of the pile.
define main
set 2
push
set 3
push
set 4
add
multiply
output
return
end
Walk through it: push 2, push 3, hold 4. add pops 3 and computes 4 + 3 = 7. multiply pops 2 and computes 7 * 2 = 14. This prints 14.
The expression 2 * (3 + 4) is the same computation. Python evaluates the parentheses first and hides the pile. Cryn makes you lay the operands out in the order the machine eats them.
The pile is the honest replacement for expressions. To compute 2 * (3 + 4) you set up the numbers in the order the machine consumes them.
swap is handy when you want to keep a value but work on another one:
define main
set 9
push
set 4
swap
output
pop
output
return
end
swap exchanges 4 (in hand) with 9 (on the pile). Now the working value is 9 and the pile holds 4. This prints 9 then 4.
Exercise: compute (10 - 4) * (3 + 2) and print it. Six commands plus output, and the pile holds the answer's factors along the way.
11. Transformations: define, call, return
A transformation is Cryn's word for a function. You define one with define, you run it with call, and it finishes with return or by reaching end.
define square int64 x
move x
read
push
move x
read
multiply
return
end
define main
set 9
push
call square
output
return
end
This prints 81.
In Python:
def square(x):
return x * x
print(square(9))
The result type is inferred from the return, like Python infers the function's return. Recursive transformations must announce it up front with result <type>, because the body calls itself before the end is in sight.
How parameters work:
- parameters are written as
type namepairs after the transformation name, sodefine square int64 xdeclares one parameterxof typeint64 - to call, push the arguments in order (first parameter goes first, so it ends up lowest), then
call square callpops the arguments, runs the transformation, and puts its result into the working value- if the result type is
nothing, the working value is left unchanged
A transformation's result type is inferred from the type of the working value at its return. All returns in one transformation must agree. Recursive transformations must state the type up front with result <type>:
define fact int64 n result int64
move n
read
compare 1
jump base when less-or-equal
move n
read
push
move n
read
subtract 1
push
call fact
multiply
return
mark base
set 1
return
end
define main
set 10
push
call fact
output
return
end
This prints 3628800, which is 10 factorial.
Mutual recursion works the same way. Both partners need result:
define iseven int64 n result bool
move n
read
compare 0
jump yes when equal
move n
read
subtract 1
push
call isodd
return
mark yes
set true
return
end
define isodd int64 n result bool
move n
read
compare 0
jump no when equal
move n
read
subtract 1
push
call iseven
return
mark no
set false
return
end
define main
set 10
push
call iseven
output
return
end
This prints true.
Rules worth knowing:
- the value stack must be empty when a transformation returns. Pushed values that were not consumed are an error
mainmust exist and take no parameters- every cell name in one transformation is unique, and parameters are cells too
- arrays cannot be parameters or results. Pass an
addressinstead (chapter 14)
Exercise: write define biggest int64 a int64 b result int64 that returns the larger parameter. A compare and one jump decide it. Chapter 22 shows one shape of the answer.
12. stop: ending the program early
stop ends the whole program. If the working value is a whole number, it becomes the exit status code. Nothing else runs after stop.
define main
set 3
stop
end
This program prints nothing and exits with code 3. On most systems only the lowest 8 bits of the code survive, so use 0 to 255. Reaching the end of main normally exits with code 0.
In Python: sys.exit(3).
Exercise: exit with code 7 and print nothing. Check yourself with echo $? in the shell.
13. World cells
A make at the top of the file, outside any define, creates a world cell. World cells exist the whole run and every thread sees them. They are how independent parts of a program share state.
make int64 total
define bump int64 n
move n
read
push
move total
read
add
move total
write
set 0
return
end
define main
set 5
push
call bump
set 7
push
call bump
move total
read
output
return
end
This prints 12.
These are module level variables in Python:
total = 0
def bump(n):
global total
total = total + n
Every thread sees the same cells. That is the point, and the danger.
World cells can hold values, arrays, and strings:
make int64 count
make array 100 of float64 samples
make string "untitled\n" title
A world cell name must not collide with a transformation name.
Exercise: add a second world cell that counts how many times bump ran, raise it inside bump, and print both cells at the end.
14. Addresses and memory
An address is the location of a cell or block. Cryn exposes addresses as plain values you can hold, pass, and follow.
Three ways to get an address:
| Command | Working value becomes |
|---|---|
address | the address of the focused cell |
address name | the address of a named cell (or a transform reference, chapter 15) |
make <size> of <type> [name] | the address of a fresh heap block |
And one way to follow one:
| Command | Effect |
|---|---|
move at | focus moves to the cell at the working value address |
A heap block is memory that lives until you free it:
define main
make 8 of int64 block
move block
read
move at
move 3
set 42
write
move block
read
move at
move 3
read
output
move block
read
free
return
end
One detail to slow down for: in make 8 of int64 block, the word block is a cell that receives the block's address. So move block / read puts the address into the working value, and move at follows it, putting the focus on the block itself. Now move 3 steps to element 3 and write stores there. The example stores 42 into element 3, reads it back, prints 42, and frees the block.
The size in make <size> of can also be a cell name holding the count, computed at run time:
make int64 n
...
make n of float64 data
You can do arithmetic on addresses. add 1 on an address moves it forward by one element of its pointee type, not by one byte. Subtracting two addresses of the same type gives the number of elements between them.
define main
make int64 a
make int64 b
set 7
move a
write
set 9
move b
write
address b
move at
read
output
return
end
This prints 9. The address of b was taken, then followed with move at, then read.
Python has nothing like this. The nearest familiar ground is C: b is the cell, &b is the address, and *(&b) is the move at / read pair. Cryn addresses are values like any other, which is what makes linked structures possible.
Rules that keep memory honest:
freeonly accepts blocks that came frommake <size> of <type>or anonymousmake <type>without a name. Anything else aborts with a message- indexing is bounds checked. An index outside the array or block stops the program with an error saying what index was attempted and what the size was. For heap blocks the check works from the block's base address, so index into the block itself, not into an address you have shifted with address arithmetic
- freeing or indexing through a stale address (a block you already freed, or an address into the middle of one) also aborts with a clear message. The runtime tracks live blocks in a table, so these cases are defined behavior, not crashes
- anonymous
make int64(no name) allocates one zeroed cell on the heap and puts its address in the working value
Linked structures are cells inside blocks holding addresses:
make 2 of int64 node [# node 0: value, node 1: address of next #]
The examples folder of the compiler repository has a full linked list and a free-list allocator written this way.
Exercise: allocate make 4 of int64, store 42 into element 3, print it back, and free the block.
15. Transform references
A transformation can itself be held in a cell of type transform. You get one with address <transformation name>, and you run it with call when the working value holds the reference.
define greet
make string "hi\n" s
address s
output text
set 0
return
end
define main
make transform f
address greet
move f
write
move f
read
call
return
end
This prints hi.
In Python this is a function object: f = greet, then f() runs it. The transform cell is the variable holding the function, and call with no name is the f().
Two honest limits:
- indirect calls only work on transformations that take no parameters. The program stops with an error otherwise
- the callee's result is discarded and the working value (the reference) is kept. Send and receive data through world cells or addresses
This is enough for callbacks: a transformation can receive, via a world cell, a reference to work that should happen later.
Exercise: make two transformations that print different words, store each into the same transform cell in turn, and call both through the cell.
16. Input from the keyboard
input reads one byte from the current input stream, which is the keyboard by default. The working value becomes the byte value, or -1 when the input has ended.
define main
set 0
mark next
input
compare -1
jump done when equal
convert uint8
output
jump next
mark done
return
end
This program copies its input to its output, like a tiny cat. Try it:
printf 'hello\n' | cryn run copy.cryn
The convert uint8 turns the -1-or-byte number into a byte so output prints it as a character instead of a number.
The Python shape is ch = sys.stdin.buffer.read(1), empty bytes at the end. Cryn uses -1 for the end instead, because the working value is always a number.
Exercise: count the bytes until the end of the input and print the count. Six bytes piped in, 6 out.
17. Files
Cryn gives files to you as first-class handles. A handle is a plain integer that stands for one open file. Many files can be open at the same time, and you choose which one the streams point at.
The five file commands:
| Command | What it does |
|---|---|
open read | opens the path in the working value for reading, handle goes into the working value |
open write | opens for writing, existing content is discarded |
open both | opens for reading and writing, content discarded |
open append | opens for writing at the end, existing content kept |
use | points the streams through the handle in the working value |
use 0 | points the streams back at the keyboard and terminal |
flush | pushes written bytes out of the buffer to the file |
seek n | positions the handle's streams at byte n from the start |
close | closes the handle in the working value |
Details:
- before
open, the working value must be an address to the path text. The usual way isaddress somepathwheresomepathis a string cell openputs a handle into the working value: a number 1 or greater on success, or -1 on failure (the file was missing, or too many files are open). Check withcompare 0andjump fail when lessusepoints the input stream at the file's readable side and the output stream at its writable side. A file openedreadonly changes the input stream. A file openedwrite,append, orbothonly changes the output stream (bothchanges both). Whatever stream is not changed stays where it was. That is why "read from a file, print to the screen" just works- handle numbers are reused after
close. Using a handle that is not open stops the program with an error, so keep handles in cells - each context (main, and every spawned thread) has its own 256 handles. Handle 0 is always the standard keyboard and terminal
seek n(orseekwith the offset in the working value) moves the file position to a byte offset from the start. On abothfile both streams are the same file and move together. The working value becomes 0 on success or -1 on failure.seekneeds a handle routed withusefirst; the keyboard and terminal cannot be positioned- switching between reading and writing on one
bothfile needs aseek(or aflush) in between: position yourself, then change direction
A full write-then-read program:
define main
make string "notes.txt" name
make string "cryn can write files\n" note
make int64 h
address name
open write
move h
write
move h
read
use
home
address note
output text
flush
home
move h
read
close
address name
open read
move h
write
move h
read
use
home
set 0
mark next
input
compare -1
jump done when equal
convert uint8
output
jump next
mark done
return
end
This prints cryn can write files. It writes the note into notes.txt, closes it, opens it again for reading, routes the input stream through it, and echoes the bytes to the terminal. (The cell is called note and not text because text is part of the language, see output text, and words of the language cannot be names.)
Two files at once is the point of handles. Keep each handle in its own cell, and use the one you want before each read or write. The repository test tests/28_handles.cryn shows exactly this, including append.
seek is what one file open both is for: reading and writing the same file, back and forth. In Python terms: open is open(), seek n is f.seek(n) measured from the start, and use is choosing which open file the next read or write touches. This program writes a row of letters, then rewrites one letter in the middle without rewriting anything else:
define main
make string "row.txt" name
make int64 h
address name
open both
move h
write
push
use
home
make string "ABCDE" row
address row
output text
seek 3
set 'X'
output
flush
pop
close
address name
open read
move h
write
push
use
home
set 0
mark next
input
compare -1
jump done when equal
convert uint8
output
jump next
mark done
pop
close
return
end
This prints ABCXE. The open both handle lets the program write the row, then seek 3 moves the position to the fourth byte and set 'X' plus output replaces just that byte. close puts the streams back at the terminal, the file is opened again for reading, and the echo loop prints it. (The repository test tests/31_seek.cryn checks each step's result, and keeps reading through the both handle with more seek calls.)
Paths are passed to the operating system unchanged. There is no folder listing, no rename, and no delete command. If you need those, the next chapter has an escape hatch.
Exercise: open one file in append mode three times, writing one line each open, then read the whole file back and count the lines.
18. system: running a shell command
system hands a command to the system shell. The working value must be an address to the command text. The working value becomes the command's exit status, as the host reports it.
define main
make string "echo shell works" cmd
address cmd
system
output
return
end
This prints the line shell works (the shell prints it, not Cryn) and then 0 (the status, printed by output). Use it sparingly. It makes programs depend on the operating system.
In Python: subprocess.run(cmd, shell=True).returncode.
Exercise: run the command true, print its status, run false, print that status too. Two numbers, 0 and 1.
19. Threads: spawn and wait
spawn <name> starts a new thread that runs the given transformation. The transformation must take no parameters. wait pauses until every context spawned by this one has finished. The working value becomes 0.
Threads talk through world cells or shared heap addresses.
make int64 winner
define setter
set 7
move winner
write
return
end
define main
spawn setter
spawn setter
wait
move winner
read
output
return
end
This prints 7. Both threads wrote the same value, so the answer is certain.
In Python:
import threading
def setter():
global winner
winner = 7
ts = [threading.Thread(target=setter) for _ in range(2)]
for t in ts:
t.start()
for t in ts:
t.join()
wait is the two join lines.
What happens when threads race on the same cell? Cryn promises exactly this:
- a read or write of a single cell of at most 8 bytes (numbers, floats, bool, char, address) that races with writes yields some value of that cell's type. It never yields a torn mix of two values, because the compiler emits every such access as an atomic operation
- cells of 16 bytes (transform references) have no protection. Do not race on those
- there is no automatic ordering. Work done in a thread is only guaranteed visible to the spawner after
wait
So a plain counter increments can lose updates, because read, add 1, write is three steps and threads interleave. When counting exactly matters, use the next chapter.
Exercise: spawn two workers that each add 100 to one int64 cell with plain read, add 1, write, wait, and print the cell. Run the program many times. Sometimes the answer is 200, sometimes less. The next chapter is the fix.
20. exchange: atomics without mystery
exchange swaps the working value with the focused cell in one indivisible step. You hand a value over and receive whatever the cell held, and no other thread can slip a step in between.
define main
make int64 probe
set 5
move probe
write
set 9
move probe
exchange
output
move probe
read
output
return
end
After the exchange the cell holds 9 and the working value holds 5. This prints 5 then 9.
In Python the atomic step is hidden inside threading.Lock. Here it is the whole tool: one cell, exchange, and the ordering promise written in the rules. There is no lock object.
Rules:
- the focus must be a single cell of at most 8 bytes, and the working value must already have exactly the cell's type. Integer literals and
float64tofloat32are converted for you, everything else needsconvertfirst exchangenever tears, and it is the one synchronization tool besideswait: when one context stores a value withexchangeand another loads that value withexchange, the memory work around those two points is ordered
That last property is what makes locks possible. A lock is a cell that holds 0 for free and 1 for taken:
make int64 counter
make int64 lock
define worker
make int64 k
set 0
move k
write
mark outer
move k
read
compare 100
jump done when greater-or-equal
mark spin
set 1
move lock
exchange
compare 0
jump spin when different
move counter
read
add 1
move counter
write
set 0
move lock
exchange
set 0
move k
read
add 1
move k
write
set 0
jump outer
mark done
return
end
define main
spawn worker
spawn worker
wait
move counter
read
output
return
end
Two workers each add 100 under the lock. The answer is always 200, never less. The trick: trying to take the lock is set 1 / move lock / exchange. If the old value came back 0, the lock was free and is now yours. If it came back 1, someone else has it, so spin and try again. Releasing is storing 0 with another exchange.
Exercise: delete the release exchange near the end of worker and predict what happens before you run it.
21. Putting it together: reversing a string
Chapters 1 to 20 introduced the words one at a time. This chapter uses them together on one task: measure a string, then print it backwards. The program is complete and runs as written.
make string "stressed" s
make string "\n" nl
define newline result nothing
address nl
output text
set nothing
return
end
define main
make int64 len
make int64 k
[# measure the string: len is both counter and index #]
set 0
move len
write
home
mark measure
move len
read
move s
move index
read
compare 0
jump measured when equal
move len
read
add 1
move len
write
home
jump measure
mark measured
set 0
home
[# print backwards: k from len-1 down to 0 #]
move len
read
subtract 1
move k
write
home
mark printLoop
move k
read
compare 0
jump finish when less
move k
read
move s
move index
read
output
move k
read
subtract 1
move k
write
home
jump printLoop
mark finish
set 0
call newline
return
end
This prints desserts, which is stressed backwards.
Look at the measure loop closely, because it has every moving part at once:
mark measure
move len
read
move s
move index
read
compare 0
jump measured when equal
len is both the counter and the index. Each pass puts the current count into the working value, focuses the string cell s, and steps into element number len with move index. read brings that byte into the working value, and compare 0 tests for the zero byte that ends every make string cell. When the loop finds it, len holds the length: 8.
The loop tail counts one up and normalizes the working value before the label:
move len
read
add 1
move len
write
home
jump measure
After write the working value is an int64, and the first arrival at mark measure holds a literal 0 from set 0. The two agree, so the paths join cleanly. Chapter 8 promised this habit would matter. Here it does.
The print loop is the same walk with the index running down instead of up. k starts at len - 1, and each pass prints element k as one raw byte, because read on a string cell gives bytes and output prints bytes as they are. When k drops below 0, the program jumps to finish, prints the newline, and returns.
One more detail worth noticing: move index on a string cell is bounds checked at run time against the cell's real size, 9 here (8 letters plus the zero byte). A walk like this cannot leave the string by accident.
In Python the whole program is print("stressed"[::-1]). Cryn makes you be the slice: find the end yourself, then walk back one byte at a time.
Exercise: print the length after the reversed string, with a space between. The length is already sitting in a cell.
22. Patterns you will reuse
Four shapes come up again and again. Each one below is a complete program you can run.
Swap two cells:
define main
make int64 a
make int64 b
make int64 t
set 3
move a
write
set 8
move b
write
move a
read
move t
write
move b
read
move a
write
move t
read
move b
write
move a
read
output
move b
read
output
return
end
This prints 83. The cell t holds one value while the other one moves. There is no word for swapping two cells, and swap works on the value stack, so a temp cell is the tool. Python spells it a, b = b, a and hides the temp; C does it exactly this way.
Biggest of two:
define main
make int64 x
make int64 y
make int64 big
set 4
move x
write
set 9
move y
write
set 0
home
move x
read
move big
write
home
move x
read
push
move y
read
compare
home
jump done when less-or-equal
move y
read
move big
write
home
mark done
move big
read
output
return
end
This prints 9. big starts as x, then the compare puts y in hand and x on the pile, so the condition less-or-equal asks one question: is y <= x? When yes, x already wins and the jump skips the copy. When no, y overwrites big. Note the home between compare and the jump: both paths must arrive at mark done with the focus in the same place, and the focus sits on y at the compare. Sending it home before the branch makes the two arrivals agree.
Sum an array:
define main
make array 5 of int64 ns
make int64 i
make int64 sum
set 7
move ns
move 0
write
set 30
move ns
move 1
write
set 5
move ns
move 4
write
set 0
move i
write
home
mark addLoop
move i
read
compare 5
jump done when greater-or-equal
move i
read
move ns
move index
read
push
move sum
read
add
move sum
write
home
move i
read
add 1
move i
write
home
jump addLoop
mark done
move sum
read
output
return
end
This prints 42 (7 + 30 + 5, and the untouched elements count as 0). The middle of the loop shows a stack trick you will use constantly: the element lands in the working value, gets pushed, then move sum / read puts the running total in hand, and add pops the element back. Every move and read replaces the working value, so anything you still need goes on the pile first.
Pass a block to a transformation:
define getelem address array of int64 blk int64 idx result int64
move idx
read
push
move blk
read
move at
pop
move index
read
return
end
define main
make 4 of int64 a
move a
read
move at
move 1
set 41
write
home
move a
read
push
set 1
push
call getelem
output
return
end
This prints 41. A cell made with make 4 of int64 holds the address of a run time sized block, and that is exactly the type address array of int64 names, so blocks pass to transformations as plain values. Inside getelem the same push and pop dance happens: the index goes on the pile, the block is focused with move at, the index comes back, move index steps in, read loads the element.
Strings are the one thing this pattern does not fit. A string cell is a fixed array, and its address carries the fixed size in its type (address array 9 of uint8 for a 9 byte string), which does not match address array of uint8. Converting the address with convert address array of uint8 compiles, but indexing through it aborts at run time, because run time bounds checking works from the base of a live heap block. Walk strings in place with move name and move index, the way chapter 21 does, and pass heap blocks to transformations.
Exercise: write setelem, the twin of getelem above, that stores a value into a block slot instead of loading one. Three parameters, one write.
23. Output details
What output prints for each type:
| Working value type | Printed as |
|---|---|
| signed integers | minus sign and digits, like -5 |
| unsigned integers | digits, like 18446744073709551615 |
float32, float64 | the shortest digits that read back exactly, like 2.75 |
bool | true or false |
char, uint8 | one raw byte |
address | 0x followed by the address in hexadecimal |
| plain integer literal | as signed if it fits int64, otherwise as unsigned |
output text prints the bytes at the working value address until the first zero byte. Strings made with make string always end with a zero byte.
output writes to the current output stream, which is the terminal unless a file opened for writing has been routed with use. Nothing is ever printed automatically. No spaces, no newlines.
A printing helper you will reuse constantly:
make string "\n" nl
...
address nl
output text
24. Program shape
A complete program is one file. The file contains world cells and transformations in any order:
make string "1.0\n" version
define helper ...
define main ...
Order does not matter. Names are case sensitive. Names use letters, digits, and underscores, and cannot start with a digit. Every word of the language (see the table in the Language Reference) is reserved and cannot be a name. In particular you cannot name a cell set, end, or when.
The whole program compiles in one pass into one C file, then into a normal executable. There is no separate compilation yet, one .cryn file is one program.
25. Errors
Two kinds exist and they look different.
Compile time errors stop the build. They name the file, line, and column:
tests/errors/mixed_sign.cryn:9:5: error: arithmetic requires matching
numeric types (uint64 and int64); use 'convert'
Run cryn check file.cryn to test for these without building. Some examples of things the compiler catches:
- type mismatches, like storing a float into an integer cell
- popping an empty value stack, or returning with values still on it
- a conditional jump that can be reached without a
compare - working value or stack types that disagree where a loop rejoins
- a
moveto a name that no cell has - arrays used as parameters or results
- duplicate cells, duplicate labels, unreachable labels
Runtime errors stop the compiled program, print one line, and exit with code 1:
cryn: runtime error: division by zero
cryn: runtime error: index 9 out of bounds (size 4)
cryn: runtime error: free of an address that is not a heap block ...
cryn: runtime error: use of a handle that is not open in this context ...
These are considered defined behavior: the program stops at a known point with a clear reason. Deliberate crashes are how Cryn avoids silently corrupt results.
26. Reading what the compiler tells you
Chapter 25 shows what the messages look like. This chapter shows what to do about them.
Three commands do the looking:
| Command | What it shows |
|---|---|
cryn check file.cryn | type checks without building anything |
cryn emit-ir file.cryn | the program as the checker understood it, one step per line |
cryn emit-c file.cryn -o out.c | the generated C, for comparing against something familiar |
A worked example. This loop counts a cell down to 0 and prints nothing, and it does not compile:
define main
make int64 i
set 3
move i
write
home
set true
mark again
move i
read
compare 0
jump done when equal
set 0
home
move i
read
subtract 1
move i
write
home
jump again
mark done
set 0
return
end
The compiler answers:
count.cryn:21:13: error: control flow joins at this label: working value
type differs between paths (bool vs int64); normalize it with 'set'
before entering the loop or branch
Read it in pieces. File, line 21, column 13, which is the jump again. The label where the paths disagree is again. The first arrival holds true, a bool. The jump back at the end of the loop holds the new count, an int64. The fix is one word: set 0 instead of set true before the label.
The same disagreement happens with the focus:
error: control flow joins at this label: focus depth differs between
paths (2 vs 1)
One path arrives at the label still sitting inside an array, the other at the frame root. A home on the deeper path, before the branch, fixes it.
Runtime errors stop the program instead of the build:
cryn: runtime error: index 9 out of bounds (size 3)
This one names the index tried and the real size, so the guilty move index is easy to find. One line, exit code 1, nothing else runs.
When a program compiles, runs, and still does the wrong thing, emit-ir is the next stop. It prints every step with its type:
transformation getelem(blk:address array of int64 idx:int64) result int64
0 move-local idx [int64]
1 load [int64]
2 push [int64]
3 move-local blk [address array of int64]
4 load [address array of int64]
5 move-at [array of int64]
6 pop [int64]
7 move-index [int64]
8 load [int64]
9 return
Reading it top to bottom usually exposes the step you did not write: a missing push, or a read that replaced a value still needed.
Exercise: take the countdown from chapter 8, change the set 0 before the loop to set true, and read the diagnostic the same way. Then fix it and confirm the program runs.
27. Limits
Fixed limits in this version, all enforced with clear messages:
| Limit | Value |
|---|---|
| focus history entries per context | 1024 |
| labels per transformation | 4096 |
| cells per transformation | 4096 |
| value stack depth the compiler proves | 4096 |
| open files per context | 256 |
| outstanding spawned contexts per context | 256 |
one make <size> of allocation | 16 TiB of data, checked in bytes |
| distinct types in one program | bounded only by memory |
28. Common mistakes and how to fix them
Loop joins with different working value types. The place where a loop rejoins must see the same type of working value from every direction. Fix: set 0 (or a value of your chosen type) right before the label or before jumping back.
Returning with a full pile. push without a matching pop or call leaves values on the value stack, and return refuses. Fix: balance them.
A conditional jump after a label that some path reaches without a compare. The comparison state must be valid on every arrival. Fix: compare again after the label before the when jump.
Storing a float into an integer cell. The checker sends you to convert and means it. Convert first, then write.
back at the frame root. The history is empty there. Fix: home is what you wanted.
output of nothing. set nothing clears your hand, it does not print. Fix: print a value or use output text with a string.
Handling handles loosely. Keep each handle in its own cell the moment open gives it, check open results with compare 0, and never use or close a handle after it was closed. Handles are plain numbers, and a closed handle's number can be handed out again by the next open.
29. Cheat sheet
[# comment ... #] comment, may span lines
set <literal> working value = literal
add/subtract/multiply/divide/remainder [literal]
negate working value = -working value
convert <type> change working value type
compare [literal] compare working value with other
push / pop / swap value stack: put / take / exchange
make <type> [name] cell (heap cell if no name)
make array <n> of <type> [name] array cell
make string "..." [name] string cell
make <size> of <type> [name] heap block, address into working value
free free heap block at working value
move <name> / move <n> / move index / move at
back / home focus movement
read / write copy between focus and working value
address [name] working value = address
jump <label> [when <condition>] conditions: less equal greater
different less-or-equal greater-or-equal
mark <label> define a label
call <name> / call run transformation (or reference)
return / stop end transformation / end program
input read one byte, -1 at end
output / output text print working value / text at address
open read|write|both|append open path at address, handle out
use [0] route streams through handle
flush flush output stream
seek [n] position handle streams at byte n
close close handle in working value
system run shell command at address
spawn <name> run zero-parameter transformation in a thread
wait join all spawned
exchange atomic swap of working value and focus
30. Where to go next
- The Language Reference page has the exact rules for every word in one large table, plus the type list, the limits, and the defined behavior.
- The Examples page walks through the complete programs in the repository's
examples/folder: fibonacci, fizzbuzz, sorting, a word counter, a linked list, and a free-list allocator. - The Architecture page explains how the compiler turns these sentences into a native executable, and how to add a word of your own without breaking the rules.
- The
tests/folder is full of small, checked programs, including every error the compiler is expected to catch.