Chapter 1 Stack and Words
SourceSilo programs are pipelines of values flowing through a stack. You push values, call words that consume them and push results, and chain the whole thing together. There are no parentheses around function arguments, no infix operators, no implicit precedence — everything is postfix and left-to-right.
This chapter introduces the stack, the literal forms, the rules for naming things, and the two pieces of syntax you'll use to bind intermediate values: pop-> and peek->.
The stack
Silo has a single data stack that every word reads from and writes to. A literal like 42 pushes itself. A word like + pops two values and pushes their sum. Reading a program is just simulating what the stack looks like as you move through the source:
42 # ⌊42⌉
3 4 + # ⌊42 7⌉
2 3 * 1 + # ⌊42 7 7⌉
(The floor-and-ceiling brackets ⌊…⌉ are how the REPL draws the stack. The deepest value is on the left; the top of the stack — the one that drop would remove — is on the right.)
The stack is typed at compile time. The checker knows the shape of the stack at every point in your source and rejects any word call whose inputs don't match what's actually on the stack. You never see the stack mentioned in a type error the way you see a line number in a Python error — it's more like a Rust type mismatch, but the "types" include their position in the pipeline.
Stack shufflers
Silo ships a small set of built-in stack shufflers for rearranging the top few values on the stack. You'll see these in short pipelines; for anything longer, reach for pop-> and peek-> (covered later in this chapter) because named bindings read better than a string of shufflers.
| Word | Stack effect | What it does |
|---|---|---|
dup |
a → a a |
Duplicate the top |
drop |
a → |
Discard the top |
swap |
a b → b a |
Swap the top two |
over |
a b → a b a |
Copy the second-from-top to top |
rot |
a b c → b c a |
Rotate three: second becomes top |
nip |
a b → b |
Drop the second-from-top |
These are all polymorphic — the stack-effect variables a, b, c stand for any type. For heap values, dup creates a new logical reference; the compiler's reference counter tracks the rest. That's covered in the lifecycle chapter.
Every shuffler has an arity variant written with a /N suffix. The variant operates on groups of N consecutive values instead of single values, so you can rearrange several values in one move instead of chaining primitives. The /2 family covers pairs:
| Word | Stack effect | What it does |
|---|---|---|
dup/2 |
a b → a b a b |
Duplicate the top pair |
drop/2 |
a b → |
Discard the top pair |
swap/2 |
a b c d → c d a b |
Swap the top two pairs |
over/2 |
a b c d → a b c d a b |
Copy the second pair to the top |
nip/2 |
a b c d → c d |
Drop the second pair |
rot/2 |
a b c d e f → c d e f a b |
Rotate three pairs |
So 3 5 dup/2 leaves 3 5 3 5 on the stack — useful when a pair needs to flow into two places. The bare dup is equivalent to dup/1; the bare forms are just the arity-1 shorthand.
Literal forms
Numbers come in four representations, split along one important axis: exact versus inexact.
# Integers — exact, arbitrary precision. No overflow.
42
-7
0xFF
0b1010
2 100 pow # a very large integer, still exact
# Fractions — exact rationals.
3/4
1/3 1/4 + # 7/12, not 0.5833...
# Decimals — exact decimal, unbounded.
19.99:D64
0.1:D128 # exactly 0.1 — no binary rounding error
# Floats — IEEE 754 binary. Inexact by construction.
3.14 # F64 by default
0.1 # NOT exactly 0.1
Mixing an inexact value with an exact one contaminates: the result is inexact. That way you always know whether a number went through a Float at any point.
The other primitive literals:
"hello world" # UTF-8 string
"tab:\there\n" # escapes: \n \r \t \\ \" \0
"\u{1F600}" # Unicode escape
'a' # a single codepoint (the Char type)
'\u{0301}' # combining accent — also a codepoint
true false # Bool
'ok # a symbol — a globally interned atom
'error # with O(1) equality
0 .. 10 # a range, 0 inclusive to 10 exclusive
5 .. # from 5 onward
.. 10 # up to 10
.. # unbounded
b "raw bytes" # a byte string
Symbols are Lisp-style atoms: two 'ok literals anywhere in the program compare equal in constant time, and they carry no payload. They're useful when you want a cheap, distinct tag without reaching for a full enum.
Identifiers
Identifiers in Silo are liberal: Unicode letters to begin, then letters or digits or a handful of punctuation characters. Hyphens are part of the identifier — my-function is one name, not a subtraction. The punctuation ?, !, ', /, <, > is all valid, as are Unicode symbols in general — math operators like ∈, ⊆, ∪, arrows like ↦, and any other symbol character your domain calls for:
my-function # hyphens
is-valid? # trailing ?
u8>int # angle brackets in a conversion name
str>bytes # another conversion
m' # trailing apostrophe
Unicode math symbols are valid word names too. Silo's set-theoretic traits use them directly, so a b ∪ really does mean set union:
a b ∪ # union
a b ∩ # intersection
x s ∈ # membership
p q ⊆ # subset
Non-Latin scripts work anywhere a Latin-script word would:
土豆 pop-> p
ču õʹnnj pop-> g
μαθηματικά pop-> m
There's nothing special about these examples — they're ordinary identifiers under Unicode's XID_Start and XID_Continue rules, plus the extra punctuation Silo allows. Use whatever reads best for the domain you're modelling.
The convention — not the rule — is:
- Lowercase starts a word (a function, a local binding).
- Uppercase starts a type or a constructor.
So Point is a type and point is a variable holding one. This is the same convention Haskell uses.
Words
A word is a function. You declare one with :fn:
:fn square ( Int -> Int )
dup *
:end
The signature ( Int -> Int ) is the stack effect. It reads "pops one Int, pushes one Int." The body is ordinary Silo code. dup duplicates the top of the stack, and * pops two values and pushes their product.
Call a word just by naming it:
5 square # 25
Stack effects can consume or produce multiple values. Here's a word that takes two integers and produces one:
:fn add ( Int Int -> Int )
+
:end
3 4 add # 7
The compiler checks every call against the signature. If you try to call square with a Str on top of the stack, the program doesn't compile.
Local bindings
Threading values through a stack works for small words but gets unreadable quickly. For anything beyond a line or two, you'll bind intermediate values to names. The two forms are:
pop-> name # pops the top of the stack into `name`
peek-> name # copies the top into `name`, leaves it on the stack
Bindings are lexically scoped to the enclosing word or quotation, and shadowing is allowed. Here's a word that computes Euclidean distance between two points:
:fn distance ( F64 F64 F64 F64 -> F64 )
pop-> y2 pop-> x2 pop-> y1 pop-> x1
x2 x1 - peek-> dx
y2 y1 - peek-> dy
dx dx * dy dy * + sqrt
:end
A couple of things to notice here:
- The
pop->bindings appear in reverse order of the signature. The signature says the arguments go on left-to-right, so the last one pushed is on top, and that's the first one popped. peek->is useful when you want both the name and the value still on the stack — heredxstays on the stack so the next operation can use it, and is also bound to the namedxfor readability.
Shadowing is fine:
:fn example ( Int -> Int )
pop-> x
x 2 * pop-> x # inner x shadows the outer x
x 1 +
:end
Key points
- A program is a stream of values flowing through a single typed stack.
- Everything is postfix. No infix operators, no precedence rules.
- Four numeric representations — Int, Fraction, Decimal, Float — with exact/inexact contamination rules that keep you honest.
- Identifiers can contain hyphens,
?,!, Unicode — sois-sorted?andu8>intare single names. - Every word carries a stack-effect signature; the compiler uses it like a type signature.
pop->moves a value off the stack into a name;peek->copies without consuming. Both are lexically scoped.
The next chapter covers control flow — conditionals, pattern matching, loops, early returns, and error short-circuiting.