Seq - Concatenative Language
A concatenative, stack-based programming language that compiles to native executables. Seq combines the elegance of stack-based programming with a sophisticated type system, guaranteed tail call optimization, and CSP-style concurrency.
Home Code Repository is at git.navicore.tech
PRs and issues welcome at codeberg.org mirror
Naming guide: The GitHub repository is
patch-seq. On crates.io, the packages are published asseq-compiler,seq-repl, andseq-lsp. Once installed, the binaries areseqc,seqr, andseq-lsp.
: factorial ( Int -- Int )
dup 1 i.<= if
drop 1
else
dup 1 i.- factorial i.*
then
;
: main ( -- ) 10 factorial int->string io.write-line ;
Project Status
Stable as of 4.0. The language and standard library are stable and used by the creators for their own projects. That said, Seq is a niche experimental language - adopt it with eyes open. Future versions follow strict semantic versioning: major version increments indicate breaking changes to the language or standard library. Minor and patch versions add features and fixes without breaking existing code.
Why Seq?
Stack-based simplicity. No variable declarations, no argument lists - values flow through the stack. Code reads left-to-right as a pipeline of transformations.
Strongly typed with effect tracking. Stack effects aren’t just comments - they’re enforced by the compiler. The type system tracks not only what goes on and off the stack, but also side effects like yielding from generators:
: counter ( Ctx Int -- | Yield Int ) # Yields integers, takes a context
tuck yield # yield current count, receive resume value
drop swap 1 i.+ counter
;
Guaranteed tail call optimization. Recursive functions run in constant stack space via LLVM’s musttail. Write elegant recursive algorithms without stack overflow concerns.
CSP-style concurrency. Lightweight strands (green threads) communicate through channels. No shared memory, no locks - just message passing.
No implicit numeric conversions. Operations like i.+ and f.+ make types explicit. No silent coercion, no precision loss, no “wat” moments - when you need to mix types, you convert explicitly with int->float or float->int.
Installation
Prerequisites — clang is required to compile Seq programs (used to compile LLVM IR to native executables):
- macOS:
xcode-select --install - Ubuntu/Debian:
apt install clang libedit-dev - Fedora:
dnf install clang
Install from crates.io:
cargo install seq-compiler
cargo install seq-repl
cargo install seq-lsp
This installs the following binaries:
| Crate | Binary | Description |
|---|---|---|
seq-compiler | seqc | Compiler (.seq to native executable) |
seq-repl | seqr | Interactive REPL |
seq-lsp | seq-lsp | Language server for editor integration |
Build from source:
cargo build --release
Virtual Environments — Create isolated environments to manage multiple Seq versions or pin a specific version for a project:
seqc venv myenv
source myenv/bin/activate
This copies the seqc, seqr, and seq-lsp binaries into myenv/bin/, completely isolated from your system installation. Unlike Python’s venv (which uses symlinks), Seq copies binaries so your project won’t break if the system Seq is updated.
Activate/deactivate:
source myenv/bin/activate # Prepends myenv/bin to PATH, shows (myenv) in prompt
deactivate # Restores original PATH
Supports bash, zsh, fish (activate.fish), and csh/tcsh (activate.csh).
Quick Start
Compile and run a program:
seqc build examples/basics/hello-world.seq
./hello-world
Script mode (run directly):
seqc examples/basics/hello-world.seq # Compile and run in one step
Scripts can use shebangs for direct execution:
#!/usr/bin/env seqc
: main ( -- Int ) "Hello from script!" io.write-line 0 ;
chmod +x myscript.seq
./myscript.seq arg1 arg2 # Shebang invokes seqc automatically
Script mode compiles with -O0 for fast startup and caches binaries in ~/.cache/seq/ (or $XDG_CACHE_HOME/seq/). Cache keys include the source and all includes, so scripts recompile automatically when dependencies change.
Check version:
seqc --version
Run tests:
cargo test --all
Learn Seq
New to concatenative programming? Start with the Glossary - it explains concepts like stack effects, quotations, row polymorphism, and CSP in plain terms.
Learn by doing: Work through seqlings - hands-on exercises that teach the language step by step, covering stack operations, arithmetic, control flow, quotations, and more. Each exercise includes hints and automatic verification.
Interactive REPL
The seqr REPL provides an interactive environment for exploring Seq:
seqr
Stack persists across lines:
seqr> 1 2
stack: 1 2
seqr> i.+
stack: 3
seqr> 5
stack: 3 5
seqr> : square ( Int -- Int ) dup i.* ;
Defined.
seqr> square
stack: 3 25
Commands: :clear (reset), :edit (open in $EDITOR), :pop (undo), :quit (exit), :show (show file), :stack (show stack)
Editing: Vi-mode (Esc for normal, i for insert), Shift+Enter (newline), Tab (completions), F1/F2/F3 (toggle IR views)
Language Features
Stack Operations & Arithmetic:
dup drop swap over rot nip tuck pick 2dup 3drop # Stack manipulation
i.+ i.- i.* i./ i.% # Integer arithmetic
f.+ f.- f.* f./ # Float arithmetic
i.= i.< i.> i.<= i.>= i.<> # Comparisons
band bor bxor bnot shl shr popcount # Bitwise operations
Numeric literals support decimal, hex (0xFF), and binary (0b1010).
Algebraic Data Types — Define sum types with union and pattern match with match:
union Option { None, Some { value: Int } }
: unwrap-or ( Option Int -- Int )
swap match
None ->
Some { >value } -> nip
end
;
Quotations & Higher-Order Programming — Quotations are first-class anonymous functions:
[ dup i.* ] 5 swap call # Square 5 → 25
my-list [ 2 i.* ] list.map # Double each element
Concurrency — Strands (green threads) communicate through channels:
chan.make
dup [ 42 swap chan.send drop ] strand.spawn drop
chan.receive drop # Receives 42
Weaves provide generator-style coroutines with bidirectional communication:
[ my-generator ] strand.weave
initial-value strand.resume # Yields values back and forth
Standard Library — Import modules with include std:module:
| Module | Purpose |
|---|---|
std:json | JSON parsing and serialization |
std:yaml | YAML parsing and serialization |
std:http | HTTP request/response utilities |
std:math | Mathematical functions |
std:stack-utils | Stack manipulation utilities |
Sample Programs
See the Examples chapter for complete programs organized by category (basics, language features, paradigms, data formats, I/O, projects, FFI).
Editor Support
The seq-lsp language server provides IDE features in your editor.
Install: cargo install seq-lsp
Neovim: Use patch-seq.nvim with Lazy:
{ "navicore/patch-seq.nvim", ft = "seq", opts = {} }
Features: Real-time diagnostics, autocompletion for builtins/local words/modules, context-aware completions, syntax highlighting.
Configuration
Environment Variables:
| Variable | Default | Description |
|---|---|---|
SEQ_STACK_SIZE | 131072 (128KB) | Coroutine stack size in bytes |
SEQ_YIELD_INTERVAL | 0 (disabled) | Yield to scheduler every N tail calls |
SEQ_WATCHDOG_SECS | 0 (disabled) | Detect strands running longer than N seconds |
SEQ_REPORT | unset (disabled) | At-exit KPI report: 1 (human to stderr), json (JSON to stderr), json:/path (JSON to file) |
SEQ_STACK_SIZE=262144 ./my-program # 256KB stacks
SEQ_YIELD_INTERVAL=10000 ./my-program # Yield every 10K tail calls
SEQ_WATCHDOG_SECS=30 ./my-program # Warn if strand runs >30s
SEQ_REPORT=1 ./my-program # Print KPI report on exit
Compile with --instrument for per-word call counts in the report:
seqc build --instrument my-program.seq
SEQ_REPORT=1 ./my-program # Report includes word call counts
License
MIT
Seq Language Guide
A concatenative language where composition is the fundamental operation.
Why Concatenative?
If you’ve written Rust like this:
#![allow(unused)]
fn main() {
data.iter()
.map(transform)
.filter(predicate)
.fold(init, combine)
}
You’ve already experienced the appeal of concatenative thinking: data flows through a pipeline, each step consuming its input and producing output for the next. No intermediate variables, no naming - just composition.
Seq takes this idea to its logical conclusion. Where Rust uses method chaining as syntactic sugar over function application, Seq makes composition the only mechanism:
data [ transform ] list.map [ predicate ] list.filter init [ combine ] list.fold
The connection runs deeper than syntax. Rust’s FnOnce trait means “callable
once, consumes self.” Seq’s stack semantics mean “pop consumes the value.” Both
enforce linear dataflow - resources used exactly once. Rust tracks this in the
type system; Seq tracks it through the stack.
Language Heritage
Seq belongs to the concatenative language family. If you know Forth or Factor, you’ll feel at home:
| Feature | Forth | Factor | Seq |
|---|---|---|---|
| Word definition | : name ... ; | :: name ( ) ... ; | : name ( ) ... ; |
| Stack effects | ( a -- b ) comment | ( a -- b ) checked | ( a -- b ) checked |
| Quotations | ' word execute | [ ... ] | [ ... ] |
| Conditionals | if else then | if else then | if else then |
Syntactically, Seq is ~80% Forth, ~15% Factor - a Forth programmer reads Seq immediately; a Factor programmer feels at home with the quotations and type annotations.
Semantically, Seq is novel:
-
Row-polymorphic type system - Forth is untyped; Factor has optional inference. Seq statically verifies stack effects with full type checking.
-
CSP concurrency - Neither Forth nor Factor has built-in green threads with channels. Seq’s
spawn,send, andreceiveenable actor-style concurrency. -
LLVM compilation - Seq compiles to native binaries via LLVM, not threaded code or a VM.
Seq wears familiar Forth clothes while offering modern type safety and concurrency. It’s a new language built on proven concatenative foundations.
The Stack
Everything in Seq operates on an implicit stack. Literals push values; words consume and produce values:
1 2 i.+ # Push 1, push 2, add consumes both, pushes 3
The stack replaces variables. Instead of:
let x = 1
let y = 2
let z = x + y
You write:
1 2 i.+
The stack is your working memory.
Words
Words are the building blocks. A word is a named sequence of operations:
: square ( Int -- Int )
dup i.*
;
The ( Int -- Int ) is the stack effect - this word consumes one integer and
produces one integer. Stack effects are required on all word definitions - the compiler verifies that the body matches the declared effect.
Calling a word is just writing its name:
5 square # Result: 25
Quotations
Quotations are deferred code - blocks that can be passed around and executed later:
[ 2 i.* ] # Pushes a quotation onto the stack
Quotations enable higher-order programming:
5 [ 2 i.* ] call # Result: 10
They’re essential for combinators like list.map, list.filter, and control flow.
Control Flow
Conditionals use stack-based syntax:
condition if
then-branch
else
else-branch
then
The condition is popped from the stack and must be a Bool (produced by comparisons, true/false literals, or logical operations):
: abs ( Int -- Int )
dup 0 i.< if
0 swap i.- # negate: 0 - n
then
;
Values and Types
Seq has these value types:
| Type | Examples | Notes |
|---|---|---|
| Int | 42, -1, 0xFF, 0b1010 | 64-bit signed, hex/binary literals |
| Float | 3.14, -0.5 | 64-bit IEEE 754 |
| Bool | true, false | |
| String | "hello" | UTF-8 text; also carries arbitrary bytes for binary I/O |
| List | (via variant ops) | Ordered collection |
| Map | (via map ops) | Key-value dictionary |
| Quotation | [ code ] | Deferred execution |
Numeric Literals
Integers can be written in decimal, hexadecimal, or binary:
42 # Int (decimal)
-123 # Int (negative)
0xFF # Int (hexadecimal, case insensitive: 0xff, 0XFF)
0b1010 # Int (binary, case insensitive: 0B1010)
Floats use decimal notation with a decimal point:
3.14 # Float
-0.5 # Float (negative)
Stack Operations
The fundamental stack manipulators:
| Word | Effect | Description |
|---|---|---|
dup | ( ..a T -- ..a T T ) | Duplicate top |
drop | ( ..a T -- ..a ) | Discard top |
swap | ( ..a T U -- ..a U T ) | Exchange top two |
over | ( ..a T U -- ..a T U T ) | Copy second to top |
rot | ( ..a T U V -- ..a U V T ) | Rotate third to top |
nip | ( ..a T U -- ..a U ) | Drop second |
tuck | ( ..a T U -- ..a U T U ) | Copy top below second |
Master these and you can express any data flow without variables.
Composition
The key insight: in Seq, juxtaposition is composition.
: double 2 i.* ;
: square dup i.* ;
: quad double double ; # Composition by juxtaposition
Writing double double doesn’t “call double twice” in the applicative sense -
it composes two doublings into a single operation.
Since a word is just a named sequence of operations, any contiguous sequence can be extracted into a new word without changing meaning:
# Given words a, b, c, d in sequence:
a b c d
# Define a new word for "b c":
: bc b c ;
# This is equivalent:
a bc d
A concrete example:
# Four words in sequence
read parse transform write
# Extract middle two into a word
: process parse transform ;
read process write # Same behavior
Comments
Comments start with # and continue to end of line:
# Whole-line comment
5 square # Inline comment after code
I/O Operations
Basic console I/O:
| Word | Effect | Description |
|---|---|---|
io.write-line | ( String -- ) | Print string to stdout with newline |
io.read-line | ( -- String Bool ) | Read line from stdin with success flag |
Line Ending Normalization
All line-reading operations (io.read-line, file.for-each-line)
normalize line endings to \n. Windows-style \r\n is converted to \n.
This ensures Seq programs behave consistently across operating systems.
Handling EOF with io.read-line
The io.read-line word returns a success flag, making EOF handling explicit:
io.read-line # ( -- String Bool )
# Success: ( "line\n" true )
# EOF: ( "" false )
Example - reading all lines until EOF:
: process-input ( -- )
io.read-line if
string.chomp # Remove trailing newline
process-line # Your processing word
process-input # Recurse for next line
else
drop # Drop empty string at EOF
then
;
Algebraic Data Types (ADTs)
Seq provides compile-time safe algebraic data types with union definitions and match expressions.
Seq’s union is similar to Rust’s enum - each variant can carry multiple named fields. This differs from C++’s std::variant, where each alternative holds only a single type.
| Feature | C++ std::variant | Rust enum | Seq union |
|---|---|---|---|
| Multiple fields per variant | No (single type) | Yes | Yes (max 12) |
| Named fields | No | Yes | Yes |
| Exhaustive matching | std::visit | match | match |
Union Definitions
Define sum types with typed fields:
union Option { Some { value: Int }, None }
union Message {
Get { response-chan: Int }
Increment { amount: Int }
Report { op: Int, delta: Int, total: Int }
}
The compiler automatically generates typed constructors:
Make-Some: ( Int -- Option )Make-None: ( -- Option )Make-Get: ( Int -- Message )Make-Report: ( Int Int Int -- Message )
Compile-Time Safety
The compiler catches common errors:
Field type validation - Only valid types allowed:
union Bad { Foo { x: Unknown } } # Error: Unknown type 'Unknown'
Valid field types: Int, Float, Bool, String, or another defined union.
Variant arity limit - Maximum 12 fields per variant:
union TooBig { V { a: Int, b: Int, c: Int, d: Int, e: Int, f: Int,
g: Int, h: Int, i: Int, j: Int, k: Int, l: Int, m: Int } }
# Error: Variant 'V' has 13 fields, maximum is 12.
# Consider using a Map or grouping fields into nested union types.
Pattern Matching
Use match to destructure variants. The compiler requires exhaustive matching:
: describe ( Option -- String )
match
Some { >value } -> drop "has value" # drop the extracted value
None -> "empty"
end
;
Non-exhaustive matches are compile errors:
: bad ( Option -- String )
match
Some -> "has value"
# Error: Non-exhaustive match on 'Option'. Missing variants: None
end
;
Stack-Based Matching
All fields are pushed to stack in declaration order:
: handle ( Message -- )
match
Get -> # ( response-chan )
send-response
Increment -> # ( amount )
do-increment
Report -> # ( op delta total )
drop nip # extract delta
process
end
;
Named Bindings
Request specific fields by name using > prefix (indicating stack extraction, not variable binding):
: handle ( Message -- )
match
Get { >response-chan } ->
# response-chan is now on stack
send-response
Increment { >amount } ->
# amount is now on stack
do-increment
Report { >delta } -> # only 'delta' pushed to stack
process
end
;
The > prefix makes clear these are stack extractions, not local variables. Both styles compile to identical code. Mix them freely.
ADTs with Row Polymorphism
ADTs and row polymorphism are orthogonal:
union Option { Some { value: Int }, None }
# Row polymorphic - extra stack values pass through
: unwrap-or ( ..a Option Int -- ..a Int )
swap match # swap so Option is on top for match
Some { >value } -> nip # remove default, keep extracted value
None -> # keep default
end
;
"hello" 42 Make-Some 0 unwrap-or # ( "hello" 42 )
Low-Level Variants
For dynamic use cases, low-level primitives create tagged values at runtime. A variant is a value with a symbol tag (like :Some or :Nil) and zero or more fields.
Creating Variants
The variant.make-N words take N values from the stack plus a symbol tag:
| Word | Stack Effect | Description |
|---|---|---|
variant.make-0 | ( Symbol -- Variant ) | Tag only, no fields |
variant.make-1 | ( T Symbol -- Variant ) | One field + tag |
variant.make-2 | ( T U Symbol -- Variant ) | Two fields + tag |
The tag is always the last argument (top of stack):
:None variant.make-0 # Creates: (None)
42 :Some variant.make-1 # Creates: (Some 42)
"x" 10 :Point variant.make-2 # Creates: (Point "x" 10)
Inspecting Variants
| Word | Stack Effect | Description |
|---|---|---|
variant.tag | ( Variant -- Symbol ) | Get the tag symbol |
variant.field-at | ( Variant Int -- T ) | Get field by index (0-based) |
"x" 10 :Point variant.make-2 # ( Point )
dup variant.tag # ( Point :Point )
drop 0 variant.field-at # ( "x" )
Cons Lists (Lisp-Style Linked Lists)
A cons list is a classic linked list from Lisp. Each node is either:
- Nil: empty list (no fields)
- Cons: a pair of (first-element, rest-of-list)
The names come from Lisp heritage:
cons= “construct” a paircar= “contents of address register” = first elementcdr= “contents of decrement register” = rest of list
Here’s the complete pattern:
# Constructors
: nil ( -- List ) :Nil variant.make-0 ;
: cons ( T List -- List ) :Cons variant.make-2 ;
# Predicates
: nil? ( List -- Bool ) variant.tag :Nil symbol.= ;
# Accessors
: car ( List -- T ) 0 variant.field-at ;
: cdr ( List -- List ) 1 variant.field-at ;
Building a list works from right to left - start with nil, then prepend each element:
nil # () - empty list
3 swap cons # (3) - prepend 3
2 swap cons # (2 3) - prepend 2
1 swap cons # (1 2 3) - prepend 1
The swap is needed because cons expects ( T List ) but we have ( List T ).
In the REPL, the raw stack output for nested variants looks cryptic. Peek at elements without destroying the list:
dup car # 1 - first element
dup cdr car # 2 - second element
dup cdr cdr car # 3 - third element
See examples/data/cons-list.seq for a complete example with length, reverse, and printing.
Safety Philosophy
Seq aspires to Rust’s core principle: if it compiles, it tends to run correctly. The compiler statically eliminates entire categories of bugs that cause runtime failures in other languages.
What the Compiler Guarantees
| Guarantee | What It Prevents |
|---|---|
| No null | NullPointerException, segfaults from nil access |
| Exhaustive pattern matching | Forgetting to handle error cases or union variants |
| Stack effect verification | Stack underflow, type mismatches, arity errors |
| Explicit numeric types | Silent precision loss, integer overflow surprises |
| No shared mutable state | Data races between strands |
No Null
Seq has no null. There’s no implicit “absence of value” that can appear in any type.
When you need to represent optional or fallible values, use union types:
union Option { None, Some { value: Int } }
union Result { Ok { value: Int }, Err { message: String } }
If a function returns a union type, the compiler requires callers to handle all variants via exhaustive match. You cannot forget the error case:
: maybe-parse ( String -- Option ) ... ;
: use-it ( String -- Int )
maybe-parse match
None -> 0 # must handle this
Some { >value } -> # value extracted to stack, returned as result
end
;
This is opt-in. Seq doesn’t enforce a pervasive Result convention across the standard library - union types are used case by case where they make sense. The compiler’s role is to ensure that if you use a union type, callers must handle all variants.
What the Compiler Does Not Catch
Seq is not Rust. Some things remain the programmer’s responsibility:
| Not Checked | Why |
|---|---|
| Array bounds | Lists are dynamically sized; bounds checked at runtime |
| Integer overflow | Wraps silently (like C, unlike Rust debug builds) |
| Resource exhaustion | Stack overflow from non-tail recursion, OOM |
| Logic errors | The compiler verifies types, not intent |
The philosophy: eliminate the bugs that are both common and mechanically detectable. Stack effects catch most “wrong number of arguments” bugs. Exhaustive matching catches “forgot the error case.” No null catches “didn’t check for absence.” Explicit numerics catch “mixed up int and float.”
What remains are bugs that require understanding intent - and those are for tests and code review.
Value Semantics
Seq has straightforward value semantics with no ownership tracking or move semantics.
No Borrowing, No Moves
Unlike Rust, Seq has no borrow checker or ownership system. Unlike C++11+, there are no move constructors or rvalue references. Values are simply copied when needed:
5 dup # Copies the integer - both stack positions hold 5
This simplicity comes from two design choices:
- Values are immutable - you don’t mutate values, you create new ones
- Sharing via reference counting - complex types use Arc internally for O(1) copying
Copying Behavior by Type
| Type | On dup | Notes |
|---|---|---|
| Int, Float, Bool | Bitwise copy | True value types |
| String | Deep copy | New allocation, independent string |
| Variant | Shallow copy | Arc refcount increment, data shared |
| Map | Deep copy | New HashMap with cloned entries |
| Channel | Shallow copy | Arc increment, shares sender/receiver |
| Quotation | Bitwise copy | Function pointers, no heap data |
| Closure | Shallow copy | Arc increment on captured environment |
Why This Works
The lack of mutation eliminates the problems that borrowing solves. In Rust, you need the borrow checker because:
- Mutable references could alias
- Data could be freed while references exist
- Race conditions on shared mutable state
Seq sidesteps all of this:
- No mutation of values on the stack
- Reference counting handles lifetimes automatically
- Strands communicate via channels, not shared memory
Comparison to Other Languages
| Language | Model | Seq Equivalent |
|---|---|---|
| Java | Primitives by value, objects by reference (shared mutable) | Primitives copy, collections share via Arc (immutable) |
| Rust | Ownership + borrowing, explicit moves | Everything copies, Arc handles sharing |
| C++ | Value types with copy/move constructors | Everything copies, no move optimization |
| Clojure | Persistent immutable data structures | Similar - variants share, maps clone |
Seq’s model is closest to functional languages with persistent data structures. The simplicity cost is that large maps are expensive to “modify” (you clone the whole thing). The benefit is that you never think about lifetimes, borrows, or use-after-free.
Error Handling
Seq uses a simple, type-preserving pattern for fallible operations: ( value Bool ).
The Value-Bool Pattern
Operations that can fail return their result plus a Bool success flag:
"42" string->int # ( -- 42 true ) on success
"abc" string->int # ( -- 0 false ) on failure
This pattern is used consistently across the standard library:
| Category | Example | Signature |
|---|---|---|
| Parsing | string->int | ( String -- Int Bool ) |
| File I/O | file.slurp | ( String -- String Bool ) |
| Environment | os.getenv | ( String -- String Bool ) |
| Collections | map.get | ( Map Key -- Value Bool ) |
| Encoding | encoding.base64-decode | ( String -- String Bool ) |
Using the Pattern
The idiomatic way to handle fallible operations:
"42" string->int if
# Success - the Int is on the stack
2 i.* # use it
else
drop # discard the failure value
0 # provide a default
then
Chaining Fallible Operations
For multiple fallible operations, check each result:
: get-port ( -- Int Bool )
# Get PORT from environment, parse it, validate range
# Demonstrates chaining: getenv -> parse -> validate
"PORT" os.getenv if # Check env var exists
string->int if # Parse as integer
dup 1024 i.>= over 65535 i.<= and if
true # Valid port in range
else
drop 8080 false # Port out of range
then
else
drop 8080 false # PORT is not a number
then
else
drop 8080 false # PORT not set
then
;
Why Not Result/Option Types?
Seq prioritizes compile-time type safety. A generic Result<T,E> type would require either losing type information (everything becomes Variant) or generic/parametric types (not supported).
The ( value Bool ) pattern preserves types: the Int stays an Int, the String stays a String.
If you need functional composition patterns (map, bind), you can define your own concrete Result types - see examples/paradigms/functional/result.seq for an example.
String Operations
| Word | Effect | Description |
|---|---|---|
string.concat | ( String String -- String ) | Concatenate |
string.length | ( String -- Int ) | Character count |
string.empty? | ( String -- Bool ) | True if empty |
string.equal? | ( String String -- Bool ) | Compare |
string.char-at | ( String Int -- Int ) | Char code at index |
string.substring | ( String Int Int -- String ) | Extract substring |
string.split | ( String String -- List ) | Split into list |
string.chomp | ( String -- String ) | Remove trailing newline |
string.trim | ( String -- String ) | Remove whitespace |
string->int | ( String -- Int Bool ) | Parse integer (value, success flag) |
int->string | ( Int -- String ) | Format integer |
Bitwise Operations
For low-level bit manipulation.
Int is 63-bit. Seq’s
Intis a signed 63-bit integer — the low bit of the tagged stack slot is the type tag, leaving 63 bits for the value. Range:[-2^62, 2^62 - 1]=[-4611686018427387904, 4611686018427387903]. All bitwise operations report results in this 63-bit model.shl/shrresults that would fall outside the range return0rather than silently truncating bit 62 in the tagger.
| Word | Effect | Description |
|---|---|---|
band | ( Int Int -- Int ) | Bitwise AND |
bor | ( Int Int -- Int ) | Bitwise OR |
bxor | ( Int Int -- Int ) | Bitwise XOR |
bnot | ( Int -- Int ) | Bitwise NOT (one’s complement) |
shl | ( Int Int -- Int ) | Shift left, clamped to 63-bit range (out of range → 0) |
shr | ( Int Int -- Int ) | Logical shift right (zero-fill), clamped to 63-bit range |
popcount | ( Int -- Int ) | Count 1-bits in the 63-bit representation |
clz | ( Int -- Int ) | Count leading zeros (clz(0) = 63) |
ctz | ( Int -- Int ) | Count trailing zeros (ctz(0) = 63) |
int-bits | ( -- Int ) | Push 63 (bit width of Int) |
Shift Behavior
- Shift by 0 returns the original value.
- Shift by a negative count returns 0.
- Shift by 64 or more returns 0.
- Any shift result that doesn’t fit in the 63-bit Int range also returns 0.
- Right shift is logical (zero-fill), not arithmetic (sign-extending).
1 61 shl # 2305843009213693952 (= 2^61, fits in 63-bit range)
1 62 shl # 0 (would be 2^62, one past the 63-bit max)
-1 2 shr # 4611686018427387903 (= 2^62 - 1, the 63-bit max)
-1 1 shr # 0 (would be 2^63 - 1, outside the 63-bit range)
Recursion and Tail Call Optimization
Seq has no loop keywords. Iteration is recursion:
# Count down
: countdown ( Int -- )
dup 0 i.> if
dup int->string io.write-line
1 i.- countdown
else
drop
then
;
# Process a list
: sum-list ( Variant -- Int )
dup nil? if
drop 0
else
dup car swap cdr sum-list i.+
then
;
Guaranteed Tail Call Optimization
Seq guarantees TCO via LLVM’s musttail calling convention. Deeply recursive code
won’t overflow the stack - you can recurse millions of times safely.
More importantly, Seq’s TCO is branch-aware. The compiler recognizes tail position within each branch of a conditional, not just at word level. This means you can write natural recursive code without restructuring for optimization:
: process-input ( -- )
io.read-line if
string.chomp
process-line
process-input # Tail call - even inside a branch
else
drop
then
;
In many languages, you’d have to “game” the compiler - inverting conditions, using continuation-passing style, or adding explicit trampolines to get TCO. In Seq, the compiler does this analysis for you. Write readable code; get optimization automatically.
When TCO Applies
TCO works for user-defined word calls in tail position. It does not apply in:
- main - entry point uses C calling convention
- Quotations
[ ... ]- use C convention for interop - Closures - signature differs due to captured environment
For hot loops that need guaranteed TCO, use a named word rather than a quotation:
# TCO works here
: loop ( Int -- )
dup 0 i.> if
1 i.- loop
else
drop
then
;
Command Line Programs
: main ( -- )
args.count 1 i.> if
1 args.at # First argument (0 is program name)
process-file
else
"Usage: prog <file>" io.write-line
then
;
| Word | Effect | Description |
|---|---|---|
args.count | ( -- Int ) | Number of arguments |
args.at | ( Int -- String ) | Get argument by index |
Script Mode
For quick iteration and scripting, you can run .seq files directly without a separate build step:
seqc myscript.seq arg1 arg2
Script mode compiles with -O0 for fast startup and caches the binary for subsequent runs. The cache key includes the source content and all transitive includes, so scripts automatically recompile when any dependency changes.
Shebang Support
Scripts can include a shebang for direct execution:
#!/usr/bin/env seqc
: main ( -- Int ) "Hello from script!" io.write-line 0 ;
chmod +x myscript.seq
./myscript.seq arg1 arg2 # Arguments passed to main
Note that the main word in a script must return Int (the exit code), unlike compiled programs where main returns ( -- ).
Cache Location
Compiled binaries are cached in:
$XDG_CACHE_HOME/seq/ifXDG_CACHE_HOMEis set~/.cache/seq/otherwise
Cache entries are named by their SHA-256 hash. To clear the cache: rm -rf ~/.cache/seq/
When to Use Script Mode
| Use Case | Recommendation |
|---|---|
| Quick testing | Script mode |
| Development iteration | Script mode |
| Production deployment | seqc build with -O3 (default) |
| Performance-critical | seqc build with optimizations |
Script mode trades runtime optimization (-O0) for faster compilation. For production use, compile with seqc build to get full LLVM optimizations.
File Operations
| Word | Effect | Description |
|---|---|---|
file.slurp | ( String -- String Bool ) | Read entire file. Returns content and success flag |
file.spit | ( String String -- Bool ) | Write content to file. Takes content and path, returns success |
file.append | ( String String -- Bool ) | Append content to file. Takes content and path, returns success |
file.exists? | ( String -- Bool ) | Check if file exists at path |
file.delete | ( String -- Bool ) | Delete a file at path. Returns success |
file.size | ( String -- Int Bool ) | Get file size in bytes. Returns size and success |
file.for-each-line | ( String [String --] -- Bool ) | Process file line by line. Returns success |
Directory Operations
| Word | Effect | Description |
|---|---|---|
dir.exists? | ( String -- Bool ) | Check if directory exists at path |
dir.make | ( String -- Bool ) | Create a directory at path. Returns success |
dir.delete | ( String -- Bool ) | Delete an empty directory. Returns success |
dir.list | ( String -- List Bool ) | List directory contents. Returns filenames and success |
Line-by-Line File Processing
For processing files line by line, use file.for-each-line:
: process-line ( String -- )
string.chomp
# ... do something with line
;
: main ( -- )
"data.txt" [ process-line ] file.for-each-line
if
"Done!" io.write-line
else
"Error reading file" io.write-line
then
;
The quotation receives each line (including trailing newline) and must consume it.
Returns true on success (including empty files), false if the file could not be
opened or a read error occurred mid-stream.
Line endings are normalized to \n regardless of platform - Windows-style \r\n
becomes \n. This ensures consistent behavior when processing files across
different operating systems.
This is safer than slurp-and-split for large files - lines are processed one at a time rather than loading the entire file into memory.
Modules
Split code across files with include:
# main.seq
include "parser"
include "eval"
: main ( -- )
# parser.seq and eval.seq words available here
;
The include path is relative to the including file.
Naming Convention
Seq uses a consistent naming scheme for all built-in operations:
| Delimiter | Usage | Example |
|---|---|---|
. (dot) | Module/namespace prefix | io.write-line, net.tcp.listen, string.concat |
- (hyphen) | Compound words within names | home-dir, field-at, write-line |
-> (arrow) | Type conversions | int->string, float->int |
Words Are Just Names
In Seq, a word is any contiguous sequence of non-whitespace characters. There are
no operators - the . in io.write-line is part of the word’s name, not syntax
for “calling a method on an object.”
io.write-line # This is ONE word, not "io" followed by "write-line"
string.concat # This is ONE word, not a method call on a string object
If you come from object-oriented languages, this may feel strange at first. In OO,
foo.bar means “send the bar message to foo.” In Seq, io.write-line is simply
a name that includes a dot - exactly like write-line is a name that includes a
hyphen. The dot is a naming convention for grouping related operations, not a
dereferencing or method dispatch operator.
Concatenative languages work differently: there are no objects receiving messages.
There is only the stack. Words consume values from the stack and push results back.
io.write-line doesn’t operate “on” an io object - it pops a string and writes it.
Module Prefixes
Operations are grouped by functionality:
| Prefix | Domain | Examples |
|---|---|---|
io. | Console I/O | io.write-line, io.read-line |
file. | File operations | file.slurp, file.spit, file.exists? |
dir. | Directory operations | dir.list, dir.make, dir.exists? |
string. | String manipulation | string.concat, string.trim |
list. | List operations | list.map, list.filter |
map. | Hash maps | map.make, map.get, map.set |
chan. | Channels | chan.make, chan.send, chan.receive |
net.tcp. / net.udp. / net.http. | Networking | net.tcp.listen, net.udp.bind, net.http.get |
os. | Operating system | os.getenv, os.home-dir |
args. | Command-line args | args.count, args.at |
variant. | Variant introspection | variant.tag, variant.field-at |
i. | Integer operations | i.+, i.-, i.*, i./, i.=, i.< |
f. | Float operations | f.+, f.-, f.*, f./, f.=, f.< |
Names in Stack Effects
Three kinds of name appear inside ( ... -- ... ). They look similar but
behave very differently — keep the distinction in mind, especially when
writing or migrating word signatures.
| Kind | Form | Meaning |
|---|---|---|
| Concrete type | Int, Float, Bool, String, Symbol, Channel, Socket, Variant, or a registered union name | A specific type. The type checker enforces it. |
| Type variable (generic) | A single uppercase letter: T, U, V, K, M, … | A polymorphic placeholder for one type slot on the stack. dup ( ..a T -- ..a T T ) works for any single value. |
| Row variable | Two dots and a lowercase name: ..a, ..rest, ..b | A polymorphic placeholder for zero or more values below. Implicit on every stack effect — you only write it when you need to name it (e.g. so two effects share the same row). |
: dup ( ..a T -- ..a T T ) # ..a = "anything below"; T = "any one type"
: net.tcp.write ( ..a String Socket -- ..a Bool ) # all named: row + concretes
Multi-character uppercase identifiers in stack effects must be a known
concrete type or a registered union — Acc, Ctx, Handle etc. are
not type variables. See TYPE_SYSTEM_GUIDE.md
for the deeper “row polymorphism vs traditional generics” treatment, and
GLOSSARY.md / Row Variable
for one-line definitions.
Suffixes
| Suffix | Meaning | Example |
|---|---|---|
? | Predicate (returns boolean) | nil?, string.empty?, file.exists? |
Core Primitives (No Prefix)
Fundamental operations remain unnamespaced for conciseness:
- Stack:
dup,swap,over,rot,nip,tuck,drop,pick,roll - Boolean:
and,or,not - Bitwise:
band,bor,bxor,bnot,shl,shr,popcount,clz,ctz - Control:
call,spawn,cond
Type-Prefixed Arithmetic and Comparison
Integer and float operations use explicit type prefixes:
- Integer arithmetic:
i.add,i.subtract,i.multiply,i.divide(or terse:i.+,i.-,i.*,i./,i.%) - Integer comparison:
i.=,i.<,i.>,i.<=,i.>=,i.<>(or verbose:i.eq,i.lt,i.gt,i.lte,i.gte,i.neq) - Float arithmetic:
f.add,f.subtract,f.multiply,f.divide(or terse:f.+,f.-,f.*,f./) - Float comparison:
f.=,f.<,f.>,f.<=,f.>=
This is a deliberate design choice, not a limitation. Implicit type conversions are harmful.
Many languages silently convert between numeric types, leading to subtle bugs:
- JavaScript’s
"5" + 3yields"53"but"5" - 3yields2 - C silently converts between numeric types - promoting integers, truncating floats to integers, and losing precision when narrowing - without warning by default
- Python 2’s
/behaved differently for int vs float operands
Seq rejects this entirely. When you write i.+, you know both operands are integers and the result is an integer. When you need to mix types, you convert explicitly:
42 int->float 3.14 f.+ # Explicit: convert int to float, then add
The code states exactly what happens. No implicit coercion, no surprises, no “wat” moments. The few extra characters buy certainty about program behavior.
Note that explicit conversions can still lose precision - int->float loses precision for integers beyond 2^53, and float->int truncates the fractional part. The point isn’t that conversions are lossless; it’s that you asked for it, and it’s visible in the code.
Rationale
The naming convention provides:
- Discoverability - Related operations share a prefix. Wondering what you can do with strings? Look for
string.* - No collisions -
lengthcould mean string length, list length, or map size.string.length,list.length, andmap.sizeare unambiguous - Clean primitives - Core stack operations like
dupandswapappear in nearly every word; namespacing them would add noise - Familiar patterns - The
.delimiter echoes method syntax from other languages;->for conversions is intuitive
Maps
Key-value dictionaries with O(1) lookup:
map.make # ( -- Map )
"name" "Alice" map.set # ( Map K V -- Map )
"age" 30 map.set
"name" map.get # ( Map K -- V Bool )
"name" map.has? # ( Map K -- Map Bool )
map.keys # ( Map -- List )
SON (Seq Object Notation)
SON is Seq’s native data serialization format - it’s valid Seq code that reconstructs data when evaluated. This makes SON ideal for configuration files, data exchange, and debugging.
Format Overview
| Type | SON Format | Example |
|---|---|---|
| Int | literal | 42, -123 |
| Float | literal | 3.14, 42.0 |
| Bool | literal | true, false |
| String | quoted | "hello", "line\nbreak" |
| Symbol | colon prefix | :my-symbol, :None |
| List | builder pattern | list-of 1 lv 2 lv 3 lv |
| Map | builder pattern | map-of "key" "value" kv |
| Variant | wrap-N | :Point 10 20 wrap-2 |
Using SON
Include the SON module to access builder words:
include std:son
# Build a list
list-of 1 lv 2 lv 3 lv # ( -- List )
# Build a map
map-of "name" "Alice" kv # ( -- Map )
"age" 30 kv
# Build a variant (fields before tag)
:None wrap-0 # ( -- Variant ) no fields
42 :Some wrap-1 # ( -- Variant ) one field
10 20 :Point wrap-2 # ( -- Variant ) two fields
Serializing Values
Use son.dump to convert any value to its SON string representation:
include std:son
# Serialize primitives
42 son.dump # "42"
true son.dump # "true"
"hello" son.dump # "\"hello\""
# Serialize complex structures
list-of 1 lv 2 lv son.dump # "list-of 1 lv 2 lv"
# Pretty-print with indentation
list-of 1 lv 2 lv son.dump-pretty
# list-of
# 1 lv
# 2 lv
Loading SON Files
SON files define words that return data structures:
# config.son
include std:son
: config ( -- Map )
map-of
"debug" true kv
"port" 8080 kv
;
# main.seq
include std:son
include "config.son" # adds the 'config' word
: main ( -- )
config # call to get the Map
"port" map.get # get the port value
;
Stack Display
The REPL uses SON format when displaying stack contents via stack.dump:
stack: list-of 1 lv 2 lv map-of "name" "Alice" kv :None wrap-0 true 42
This makes it easy to copy values from the REPL output directly into Seq code.
Zipper: Functional List Navigation
The std:zipper module provides a zipper data structure for efficient cursor-based navigation and editing of immutable lists. A zipper maintains a focus element with left and right context, enabling O(1) movement and modification.
include std:zipper
# Create a zipper from a list
list-of 1 lv 2 lv 3 lv 4 lv 5 lv
zipper.from-list # focus at 1
# Navigate
zipper.right zipper.right # focus at 3
# Modify
99 zipper.set # replace focus with 99
# Convert back
zipper.to-list # [1, 2, 99, 4, 5]
Key operations:
- Navigation:
zipper.left,zipper.right,zipper.start,zipper.end - Query:
zipper.focus,zipper.index,zipper.length - Modification:
zipper.set,zipper.insert-left,zipper.insert-right,zipper.delete
See STDLIB_REFERENCE.md for the complete API.
Higher-Order Words
# Map over a list
my-list [ 2 i.* ] list.map
# Filter a list
my-list [ 0 i.> ] list.filter
# Fold (reduce)
my-list 0 [ i.+ ] list.fold
Concurrency
Seq supports massive concurrency through strands - lightweight green threads built on a coroutine runtime. Thousands of strands can run on a single OS thread, cooperatively yielding during I/O operations.
Strands
Spawn a quotation as a new strand:
[ "Hello from strand!" io.write-line ] strand.spawn drop # drop strand ID
Strands are cheap - spawn thousands of them. They’re ideal for:
- Handling concurrent connections
- Parallel processing pipelines
- Actor-style architectures
Channels (CSP-Style Communication)
Strands communicate through channels, following the CSP (Communicating Sequential Processes) model - similar to Go channels or Erlang message passing.
| Word | Effect | Description |
|---|---|---|
chan.make | ( -- Channel ) | Create channel |
chan.send | ( T Channel -- Bool ) | Send value. Returns false once the channel has been closed |
chan.receive | ( Channel -- T Bool ) | Receive value. Buffered values still drain after close; subsequent receives return (default false) |
chan.close | ( Channel -- ) | Mark the channel closed. Idempotent across multiple callers; wakes all blocked receivers |
Channel operations return status flags rather than panicking. Always check the boolean result:
Producer-Consumer Example
: send-messages ( Channel Int -- )
dup 0 i.> if
over "message" swap chan.send drop # send returns Bool, drop it
1 i.- send-messages
else
drop chan.close
then
;
: producer ( Channel -- )
10 send-messages
;
: consumer ( Channel -- )
dup chan.receive if
io.write-line
consumer # loop via recursion
else
drop drop # channel closed, drop message and channel
then
;
: main ( -- )
chan.make
dup [ producer ] strand.spawn drop
consumer
;
TCP Networking
Build network servers with strand-per-connection:
| Word | Effect | Description |
|---|---|---|
net.tcp.listen | ( Int -- Socket Bool ) | Listen on port, return listener socket |
net.tcp.accept | ( Socket -- Socket Bool ) | Accept connection, return client socket |
net.tcp.read | ( Socket -- String Bool ) | Read from socket |
net.tcp.write | ( String Socket -- Bool ) | Write to socket |
net.tcp.close | ( Socket -- Bool ) | Close socket |
Concurrent Server Pattern
: handle-client ( Socket -- )
dup net.tcp.read drop # read request
process-request # your logic here
over net.tcp.write drop # write response
net.tcp.close drop
;
: accept-loop ( Socket -- )
dup net.tcp.accept drop # ( listener client )
[ handle-client ] strand.spawn drop # spawn handler
accept-loop # tail call - runs forever, no stack growth
;
: main ( -- )
8080 net.tcp.listen
"Listening on :8080" io.write-line
accept-loop
;
Each connection runs in its own strand. The recursive accept-loop runs forever
without growing the stack - TCO converts the tail call into a jump. No callbacks,
no async/await, just sequential code that scales.
Why Strands?
Traditional threading has problems:
- OS threads are expensive (~1MB stack each)
- Context switching is slow
- Shared memory requires careful locking
Strands solve these:
- Lightweight (128KB coroutine stack per strand, fixed, configurable via
SEQ_STACK_SIZE) - Cooperative scheduling (fast context switch)
- Message passing via channels (no shared state)
Write code that reads sequentially, runs concurrently.
Understanding Type Errors
Seq’s type system tracks two orthogonal concepts:
| Concept | What It Is | Example |
|---|---|---|
| Stack Effect | A word’s declared transformation | ( Int Int -- Int ) |
| Stack Type | The actual stack state at a point | (..rest Float Float) |
A stack effect describes what a word does - its inputs and outputs. A stack type describes what is - the current stack contents.
Type errors occur when your stack type doesn’t satisfy a word’s input requirements:
i.divide: stack type mismatch. Expected (..a$0 Int Int), got (..rest Float Float): Type mismatch: cannot unify Int with Float
Here, i.divide has stack effect ( Int Int -- Int ). The compiler checks:
“Does the current stack type have two Int values on top?” Your stack type
(..rest Float Float) has two Float values instead - mismatch.
Reading the Error
The format (..name Type Type ...) represents a stack state:
| Component | Meaning |
|---|---|
(...) | Stack contents, left-to-right = bottom-to-top |
..a or ..rest | “The rest of the stack” (row variable) |
Int, Float, etc. | Concrete types at those positions |
a$0, a$5, etc. | Freshened variable names (the number is just a counter) |
So (..a$0 Int Int) means: “any stack with two Int values on top.”
Visual Breakdown
i.divide: stack type mismatch. Expected (..a$0 Int Int), got (..rest Float Float)
│ │ │ │ │ │
│ │ └── top │ │ └── top
│ └── 2nd │ └── 2nd
└── rest of stack └── rest of stack
Translation:
i.divide expects: ( ..a Int Int -- ..a Int ) ← two Ints in, one Int out
You provided: ( ..rest Float Float ) ← two Floats
Problem: Int ≠ Float
Row Variables Enable Polymorphism
The ..a notation (row variable) is what makes words like dup work on any
stack depth:
: dup ( ..a T -- ..a T T )
This says: “Whatever is on the stack (..a), plus some value of type T on
top, I’ll duplicate that T, leaving the rest untouched.”
Row variables let the type checker verify stack effects without knowing the full stack contents - only the parts each word actually touches.
Common Type Errors
| Error | Cause | Fix |
|---|---|---|
Expected Int, got Float | Wrong numeric type | Use f.divide for floats |
Expected String, got Int | Need conversion | Use int->string |
stack underflow | Not enough values | Check stack effect, add values |
cannot unify T with U | Type variables don’t match | Ensure consistent types |
Seq: where composition is not just a pattern, but the foundation.
Examples
Note: This file is auto-generated from README files in the
examples/directory. Runjust gen-docsto regenerate, or edit the source README files.
Basics
Getting started with Seq - the simplest programs to verify your setup.
hello-world.seq
The canonical first program:
: main ( -- Int ) "Hello, World!" io.write-line 0 ;
cond.seq
Demonstrates the cond combinator for multi-way branching - a cleaner alternative to nested if/else.
Language Features
Core Seq language concepts demonstrated through focused examples.
Stack Effects (stack-effects.seq)
Stack effect declarations and how the type checker enforces them:
: square ( Int -- Int ) dup i.* ;
Quotations (quotations.seq)
Anonymous code blocks that can be passed around and called:
: apply-twice ( Int { Int -- Int } -- Int )
dup rot swap call swap call ;
5 [ 2 i.* ] apply-twice # Result: 20
Closures (closures.seq)
Quotations that capture values from their environment:
: make-adder ( Int -- { Int -- Int } )
{ i.+ } ;
10 make-adder # Creates a closure that adds 10
5 swap call # Result: 15
Control Flow (control-flow.seq)
Conditionals, pattern matching, and loops:
: fizzbuzz ( Int -- String )
dup 15 i.modulo drop 0 i.= if drop "FizzBuzz"
else dup 3 i.modulo drop 0 i.= if drop "Fizz"
else dup 5 i.modulo drop 0 i.= if drop "Buzz"
else int->string
then then then ;
Recursion (recursion.seq)
Tail-recursive algorithms with guaranteed TCO:
: factorial-acc ( Int Int -- Int )
over 0 i.<= if nip
else swap dup rot i.* swap 1 i.- swap factorial-acc
then ;
: factorial ( Int -- Int ) 1 factorial-acc ;
Strands (strands.seq)
Lightweight concurrent execution:
[ "Hello from strand!" io.write-line ] strand.spawn
Union Types (unions.seq)
Algebraic data types (sum types) with pattern matching:
union Option {
Some { value: Int }
None
}
: unwrap-or ( Option Int -- Int )
swap match
Some { >value } -> nip # return the value
None -> # return the default
end
;
42 Make-Some 0 unwrap-or # Result: 42
Make-None 99 unwrap-or # Result: 99
Covers Option, Result, Message passing, and recursive tree structures.
Include Demo (main.seq, http_simple.seq)
Demonstrates the module include system for code organization.
Programming Paradigms
Seq is flexible enough to express multiple programming paradigms. These examples demonstrate different approaches to structuring programs.
Object-Oriented (oop/)
shapes.seq - OOP patterns using unions and pattern matching:
- Encapsulation: data bundled in union variants
- Polymorphism: pattern matching dispatches to correct implementation
- Factory functions as constructors
- Type checks via
variant.tag(likeinstanceof)
union Shape {
Circle { radius: Float }
Rectangle { width: Float, height: Float }
}
: shape.area ( Shape -- Float )
match
Circle { >radius } -> dup f.* 3.14159 f.*
Rectangle { >width >height } -> f.*
end ;
Actor Model (actor/)
actor_counters.seq - CSP/Actor demonstration with hierarchical aggregation:
Company (aggregate)
└── Region (aggregate)
└── District (aggregate)
└── Store (counter)
Features:
- Independent strands communicate via channels
- HTTP interface for queries and updates
- Request-response pattern with response channels
counter.seq - Simple generator pattern using weaves.
sensor-classifier.seq - Stream processing with structured data.
Functional (functional/)
lists.seq - Higher-order functions and list processing:
## Built-in higher-order functions
list-of 1 lv 2 lv 3 lv 4 lv 5 lv
[ 2 i.* ] list.map # (2 4 6 8 10)
[ 2 mod 0 i.= ] list.filter # keep evens
0 [ i.+ ] list.fold # sum
## Functional pipelines
list-of 1 lv 2 lv 3 lv 4 lv 5 lv 6 lv 7 lv 8 lv 9 lv 10 lv
keep-odds # filter to 1,3,5,7,9
square-each # map to 1,9,25,49,81
sum # fold to 165
Features:
- map: Transform each element with a quotation
- filter: Keep elements matching a predicate
- fold: Reduce list to single value with accumulator
- Composable operations for data pipelines
Logic (logic/)
Coming soon - Backtracking, unification patterns.
Dataflow (dataflow/)
Coming soon - Reactive and stream-based patterns.
Data Formats & Structures
Working with structured data in Seq.
JSON (json/)
json_tree.seq - Parse and traverse JSON:
include std:json
: main ( -- Int )
"{\"name\": \"Alice\", \"age\": 30}" json.parse
"name" json.get json.as-string io.write-line
0 ;
YAML (yaml/)
YAML parsing with support for:
- Multiline strings
- Nested structures
- Anchors and aliases
SON (son/)
serialize.seq - Seq Object Notation, Seq’s native serialization format optimized for stack-based data.
Zipper (zipper/)
zipper-demo.seq - Functional list navigation with O(1) cursor movement:
include std:zipper
{ 1 2 3 4 5 } list->zipper
zipper.right zipper.right # Move to element 3
100 zipper.set # Replace with 100
zipper.to-list # { 1 2 100 4 5 }
Encoding (encoding.seq)
Base64, hex, and other encoding/decoding operations.
JSON Examples
Practical examples demonstrating JSON parsing and serialization in Seq.
json_tree.seq - JSON Tree Viewer
An interactive tool that reads JSON from files, command-line, or stdin, parses it, and displays the structure.
Usage
### Build
cargo build --release
./target/release/seqc --output json_tree examples/json/json_tree.seq
### Read from a JSON file (preferred)
./json_tree config.json
./json_tree data/users.json
### Or with command-line JSON string
./json_tree '42'
./json_tree 'true'
./json_tree '"hello world"'
./json_tree '[42]'
### Or with piped input
echo '42' | ./json_tree
### Or interactive (type JSON, press Enter)
./json_tree
Example Output
$ ./json_tree '[42]'
=== JSON Tree Viewer ===
Input: [42]
Type: 4
Value:
[42]
Type codes: 0=null, 1=bool, 2=number, 3=string, 4=array, 5=object
What This Example Reveals We Need
Building this practical example highlighted several missing features that would make Seq more useful for real-world JSON processing:
Implemented
-
Command-line arguments (
arg-count,arg) ✓arg-countreturns number of arguments (including program name)argtakes an index and returns the argument string- Example:
./json_tree '[42]'now works!
-
File I/O (
file-slurp,file-exists?) ✓file-slurpreads entire file contents as a stringfile-exists?checks if a file exists (returns 1 or 0)- Example:
./json_tree config.jsonnow works!
-
Multi-element arrays (up to 2 elements) ✓
[1],[1, 2],["a", "b"],[42, "mixed"]- Strings, numbers, booleans all work inside arrays
-
Strings at any position ✓
- Strings now parse correctly whether top-level or inside arrays
"hello",["hello"],["a", "b"]all work
-
Multi-element arrays ✓
- Arrays with any number of elements:
[1, 2, 3, ...] - Nested arrays:
[[1, 2], [3, 4]] - Mixed content:
[1, "hello", true, null]
- Arrays with any number of elements:
-
Multi-pair objects ✓
- Objects with any number of key-value pairs
- Nested objects:
{"person": {"name": "John", "age": 30}} - Complex structures:
[{"name": "John"}, {"name": "Jane"}]
-
Functional collection builders ✓
array-with:( arr val -- arr' )- append to arrayobj-with:( obj key val -- obj' )- add key-value pairvariant-append: low-level primitive for building variants
High Priority
- Write without newline (
writevswrite_line)- Would allow proper indentation output
- Currently can only output complete lines
Medium Priority
- Pattern matching / case statement
- Would simplify tag-based dispatch
- Currently requires nested if/else chains
Nice to Have
- String escape sequences (
\",\\,\n) - Pretty-print with indentation levels
- JSON path queries (
$.foo.bar)
Current JSON Support
Works:
- Primitives:
null,true,false - Numbers:
42,-3.14,1e10 - Strings:
"hello","hello world"(no escapes) - Arrays:
[],[1],[1, 2],[1, 2, 3], nested arrays, any length - Objects:
{},{"a": 1},{"a": 1, "b": 2}, nested objects, any number of pairs - Complex nested structures:
[{"name": "John", "age": 30}, {"name": "Jane"}]
Serialization limits (parsing works for any size):
- Arrays: up to 3 elements display fully, 4+ show as
[...] - Objects: up to 2 pairs display fully, 3+ show as
{...}
Limitations:
- String escapes:
"say \"hi\""- not supported
Technical Notes
Why Serialization Has Size Limits
The serializer (json-serialize-array, json-serialize-object) uses nested if/else
chains to handle different sizes (0, 1, 2, 3 elements). This is because Seq currently
lacks:
- Loops - No
for i in 0..countconstruct - Tail-call optimization - Recursion would blow the stack for large collections
- Variant fold/map - No way to iterate over variant fields from Seq
Possible solutions:
- Add a
variant-foldruntime primitive:( variant init quot -- result ) - Add counted loops to the language
- Implement TCO for recursive serialization
Why Parsing Has No Size Limits
Parsing uses recursive descent with the functional builders (array-with, obj-with).
Each recursive call builds up the collection incrementally. The stack usage is
proportional to nesting depth, not collection size, so [1,2,3,...,1000] works fine
but deeply nested structures could overflow.
YAML Examples
Examples demonstrating the YAML parsing library implemented in Seq.
Overview
The YAML library (std:yaml) is written entirely in Seq, using only the
existing language primitives. This validates that the builtin/stdlib balance
allows building complex parsers without language changes.
Primitives Used
The YAML parser uses these existing primitives:
- String operations:
string-find,string-substring,string-trim,string-empty,string-length,string-char-at,string-concat,string->float - Character conversion:
char->string - Variant operations:
make-variant-0,make-variant-1,variant-tag,variant-field-at,variant-field-count,variant-append - Standard stack operations:
dup,drop,swap,over,rot - Arithmetic and comparison:
add,subtract,<,>,=,<> - Control flow:
if/else/then
No new primitives were required.
Examples
yaml_test.seq
Basic tests for single-line YAML parsing:
- Strings:
name: John - Numbers:
age: 42,price: 19.99 - Booleans:
active: true,enabled: false - Null:
data: null,empty: ~
yaml_multiline.seq
Tests for multi-line YAML documents:
- Multiple key-value pairs
- Blank lines (ignored)
- Comments (lines starting with #)
Running
cargo run --release -- examples/yaml/yaml_test.seq -o /tmp/yaml_test
/tmp/yaml_test
cargo run --release -- examples/yaml/yaml_multiline.seq -o /tmp/yaml_multi
/tmp/yaml_multi
Supported YAML Features
- Multi-line documents with multiple key-value pairs
- String values (unquoted)
- Integer and floating-point numbers
- Booleans (true/false)
- Null values (null or ~)
- Comments (# to end of line)
- Blank lines
Not Yet Supported
- Nested objects (indentation-based nesting)
- Arrays/lists (- item syntax)
- Multi-line strings (| and > block scalars)
- Quoted strings with escapes
- Anchors and aliases
Input/Output
File I/O, terminal, OS, text processing, compression. The non-network side of I/O.
For networking examples (TCP, UDP, TLS, DNS, HTTP) see
../net/.
Terminal (terminal/)
terminal-demo.seq - Terminal colors, cursor control, and formatting using ANSI escape sequences.
Operating System (os/)
os-demo.seq - Environment variables, paths, and system information.
Text Processing (text/)
log-parser.seq - Parsing structured log files with string operations.
regex-demo.seq - Regular expression matching and extraction.
Compression (compress-demo.seq)
Zstd compression and decompression for efficient data storage.
Networking
Five layers, bottom to top: DNS → TCP → UDP → TLS → HTTP. Each subfolder
has runnable example(s) using the corresponding net.* builtins.
The stack is may-aware end to end: every IO step yields the
cooperative carrier instead of blocking it. Hostnames resolve through
a dedicated worker pool so getaddrinfo runs off the carrier. See
docs/STDLIB_REFERENCE.md for the full word reference, or
docs/design/done/NONBLOCKING_NETWORKING.md for the design rationale.
Examples
dns/resolve.seq — net.dns.resolve
Resolves a hostname and prints each IP returned. Demonstrates the worker-pool-offload path; useful as a one-liner for “what does this hostname actually resolve to from inside Seq.”
seqc build dns/resolve.seq -o /tmp/dns-resolve
/tmp/dns-resolve
tcp/client.seq — net.tcp.connect
Minimal TCP client: connect to example.com:80, send an HTTP/1.0
request, read the response, close. Plain TCP — no framing helpers —
to show what net.tcp.* looks like on its own.
seqc build tcp/client.seq -o /tmp/tcp-client
/tmp/tcp-client
tcp/server.seq — plain TCP echo server
Not all networking is HTTP. Accepts a TCP connection, echoes whatever the client sent back, closes. Each connection runs in its own strand (green thread) so the server handles concurrent clients cooperatively.
seqc build tcp/server.seq -o /tmp/tcp-server
/tmp/tcp-server &
echo hello | nc localhost 9000
tcp/http-routing.seq — HTTP server on top of net.tcp.*
The companion to tcp/server.seq: same accept-loop shape, with
HTTP/1.1 request parsing and a cond-driven router on top. Doubles as
a tutorial on concatenative programming (the source is heavily
commented). Lives under tcp/ because net.http.* is client-only —
the server is hand-rolled over net.tcp.read / net.tcp.write.
seqc build tcp/http-routing.seq -o /tmp/http-routing
/tmp/http-routing &
curl http://localhost:8080/
curl http://localhost:8080/health
curl http://localhost:8080/echo
curl http://localhost:8080/invalid # 404
udp/echo.seq — net.udp.bind + net.udp.send-to + net.udp.receive-from
Single-program UDP loopback: bind two sockets, send a datagram from one to the other, receive it, print. Mirrors the round-trip pattern the integration test uses.
seqc build udp/echo.seq -o /tmp/udp-echo
/tmp/udp-echo
tls/client.seq — net.tls.client
The TCP client above with one extra step: after net.tcp.connect
returns the Socket, net.tls.client upgrades it in place to a
TLS-wrapped Socket. Subsequent net.tcp.read / net.tcp.write calls
dispatch through rustls transparently — the rest of the code looks
exactly like the plain-TCP version.
seqc build tls/client.seq -o /tmp/tls-client
/tmp/tls-client
http/client.seq — net.http.get / .post / .put / .delete
High-level HTTP/1.1 client: hand net.http.get a URL, get a response
Map back (status, body, ok, error). The client handles DNS
resolution, SSRF validation against the resolved IPs, connection
pooling keyed on (scheme, host, port), TLS for https://, and
HTTP/1.1 framing — all the layers below are still there, just
composed into one builtin. Exercises GET, POST, PUT, DELETE against
httpbin.org.
seqc build http/client.seq -o /tmp/http-client
/tmp/http-client
Reading order
For a layered tour, read top-to-bottom: dns/resolve.seq →
tcp/client.seq → tcp/server.seq → tls/client.seq →
http/client.seq. The HTTP-routing server (tcp/http-routing.seq)
is the “everything on top of TCP” deep dive once the rest clicks.
What’s not here yet
- mTLS, ALPN selection, peer-cert inspection (planned follow-ups — see issue #483 for the test anchors).
- Per-request timeouts (planned — see issue #484).
- A header-bag API for custom HTTP request headers.
Complete Projects
Larger applications demonstrating Seq’s capabilities.
Lisp Interpreter (lisp/)
A complete Lisp interpreter in Seq:
| File | Purpose |
|---|---|
sexpr.seq | S-expression data types (ADTs) |
tokenizer.seq | Lexical analysis |
parser.seq | Parsing tokens to AST |
eval.seq | Evaluation with environments |
test_*.seq | Test files for each component |
Supported features:
- Numbers and symbols
- Arithmetic:
+,-,*,/ letbindingsifconditionalslambdawith closures
This project demonstrates:
- Union types (ADTs) for the AST
- Pattern matching for dispatch
- Recursive descent parsing
- Environment passing for lexical scope
Hacker’s Delight (hackers-delight/)
Bit manipulation algorithms from the book Hacker’s Delight:
| File | Algorithm |
|---|---|
01-rightmost-bits.seq | Isolate, clear, and propagate rightmost bits |
02-power-of-two.seq | Check and round to powers of two |
03-counting-bits.seq | Population count, leading/trailing zeros |
04-branchless.seq | Branchless min, max, abs, sign |
05-swap-reverse.seq | Bit reversal and byte swapping |
Demonstrates Seq’s bitwise operations: band, bor, bxor, shl, shr, popcount, clz, ctz.
Shamir’s Secret Sharing (sss.seq)
A tutorial implementation of Shamir’s Secret Sharing over GF(256), the same finite field used by AES. A secret is split into N shares such that any K can reconstruct it, but K-1 shares reveal nothing.
Demonstrates:
- GF(256) finite field arithmetic — addition (XOR), peasant multiplication, Fermat inverse
- Polynomial evaluation via Horner’s method
- Lagrange interpolation to reconstruct secrets from share subsets
- Packed accumulators — encoding two byte values in one Int for
list.fold - Deep stack management —
pick/rollpatterns for 4+ item stacks - Cryptographic randomness —
crypto.random-intfor polynomial coefficients
Cryptography (crypto.seq)
Cryptographic operations including hashing and encoding.
Shopping Cart (shopping-cart/)
A domain modeling example showing how to structure a typical business application with Seq.
Hacker’s Delight Examples
Bit manipulation puzzles inspired by the classic techniques in low-level programming.
Files
| File | Topic |
|---|---|
01-rightmost-bits.seq | Rightmost bit manipulation (turn off, isolate, propagate) |
02-power-of-two.seq | Power of 2 detection, next power, log2 |
03-counting-bits.seq | Popcount algorithms, parity, leading/trailing zeros |
04-branchless.seq | Branchless abs, sign, min, max |
05-swap-reverse.seq | XOR swap, bit reversal, bit set/clear/toggle |
Running
seqc examples/hackers-delight/01-rightmost-bits.seq -o /tmp/demo && /tmp/demo
Bitwise Operations Used
These examples use Seq’s bitwise operations:
band- bitwise ANDbor- bitwise ORbxor- bitwise XORbnot- bitwise NOTshl- shift leftshr- logical shift rightpopcount- count 1-bitsclz- count leading zerosctz- count trailing zerosint-bits- bit width (63 — Seq Int is signed 63-bit)
Numeric Literals
Seq supports hex and binary literals for bit manipulation:
0xFF # hex: 255
0b10101010 # binary: 170
Seq → OSC → Csound (live-coding POC)
This directory is the Phase C POC from
docs/design/LIVE_CODING_CSOUND_POC.md:
prove that Seq can drive an external audio engine (Csound) over OSC well
enough to support live-coding music.
What’s here
| File | Purpose |
|---|---|
osc.seq | OSC 1.0 encoder, written in Seq. Library, no main. |
test_osc.seq | Byte-exact unit tests for the encoder. |
test_osc_loopback.seq | End-to-end UDP round-trip tests (no audio). |
live.csd | Csound orchestra: OSC listener on port 7770 + kick instrument. |
tone.seq | One-shot driver — sends a single /kick 220.0 message. |
live.seq | 8-beat metronome driver — sends 8 evenly-spaced kicks. |
Audible run
The encoder/loopback tests run in CI (just ci). The audible parts
below need a working Csound install on your machine.
1. Install Csound
macOS (Homebrew):
brew install csound
Linux (Debian/Ubuntu):
sudo apt-get install csound
Verify:
csound --version
2. Start the listener
In one terminal, from the repo root:
csound -odac examples/projects/live-coding-csound/live.csd
-odac writes audio to your default output device. You should see
Csound print its banner, list the instruments, and sit waiting (last
line will say something like SECTION 1: followed by no further
output). Leave this terminal running.
3. Send one kick (tone.seq)
In a second terminal, build and run the one-shot driver:
just build
target/examples/projects-live-coding-csound-tone
You should hear a single short percussive tone at 220 Hz. The Seq process exits immediately; Csound stays up so you can run again.
4. Run the metronome (live.seq)
target/examples/projects-live-coding-csound-live
You should hear 8 evenly-spaced kicks over roughly 4 seconds (120 BPM).
5. Live-coding loop
Edit live.seq (e.g. change bpm-ms from 500 to 250 for 240 BPM,
or beats from 8 to 16), then run just build and re-execute
the binary. Csound keeps running between Seq runs, so the iteration
cycle is just edit → build → re-run.
To stop everything: Ctrl+C in the Csound terminal.
Troubleshooting
Nothing audible after tone/live. Check the Csound terminal:
when an OSC message lands, Csound prints something like
new alloc for instr 2: and ihold: lines. If those are absent,
the message isn’t reaching Csound. Verify the port matches (Csound
listens on 7770; Seq sends to 7770).
csound: command not found. Install per step 1 above.
Address already in use from Csound. Another process holds port
7770. lsof -i :7770 to find it; kill the holder or change the port
in both live.csd and the 7770 literal in tone.seq / live.seq.
Audio cuts out / glitches. Csound’s default audio backend can be
finicky. Try csound -odac0 live.csd to force the system default
device, or pass -+rtaudio=... to pick a specific backend (CoreAudio
on macOS, ALSA/JACK on Linux).
How this fits the design doc
- Checkpoint 1 (UDP loopback) — covered by
test_osc_loopback.seq, runs in CI. - Checkpoint 2 (OSC fixture test) — covered by
test_osc.seq, runs in CI. - Checkpoint 3 (Csound responds, one tone) —
tone.seq+ this README. Manual verification. - Checkpoint 4 (a bar of music) —
live.seq+ this README. Manual verification. - Checkpoint 5 (POC writeup decides spinout question) — once you’ve run the metronome a few times and exercised the edit-build-rerun loop, the design doc gets a final block recording what worked, what surfaced as a Seq language gap, and whether the whole thing is worth spinning into its own repo.
Foreign Function Interface
Calling native C libraries from Seq.
SQLite (sqlite/)
sqlite-demo.seq - Database access through FFI:
include ffi:sqlite
: main ( -- Int )
"test.db" sqlite.open
"CREATE TABLE users (id INTEGER, name TEXT)" sqlite.exec
"INSERT INTO users VALUES (1, 'Alice')" sqlite.exec
"SELECT * FROM users" sqlite.query
sqlite.close
0 ;
Requires sqlite.toml manifest defining the FFI bindings.
Libedit (libedit-demo.seq)
Readline-style input using the libedit library for interactive command-line applications.
Creating FFI Bindings
- Create a TOML manifest defining the C functions
- Use
include ffi:nameto load the bindings - Call functions with Seq-style names (e.g.,
sqlite.open)
See the FFI Guide for complete documentation.
SQLite FFI Example
This example demonstrates using SQLite via FFI, including the by_ref pass mode
for out parameters (used by sqlite3_open to return the database handle).
Building
seqc --ffi-manifest examples/ffi/sqlite/sqlite.toml \
examples/ffi/sqlite/sqlite-demo.seq \
-o sqlite-demo
./sqlite-demo
Dependencies
- macOS: SQLite is pre-installed
- Ubuntu/Debian:
apt install libsqlite3-dev - Fedora:
dnf install sqlite-devel
FFI Features Demonstrated
by_ref Out Parameters
SQLite’s sqlite3_open returns the database handle via an out parameter:
int sqlite3_open(const char *filename, sqlite3 **ppDb);
In the FFI manifest, this is declared as:
[[library.function]]
c_name = "sqlite3_open"
seq_name = "db-open"
stack_effect = "( String -- Int Int )"
args = [
{ type = "string", pass = "c_string" },
{ type = "ptr", pass = "by_ref" }
]
[library.function.return]
type = "int"
The by_ref argument doesn’t come from the Seq stack - instead:
- The compiler allocates local storage
- Passes a pointer to that storage to the C function
- After the call, reads the value and pushes it onto the stack
Result: db-open has stack effect ( String -- Int Int ) where the first Int
is the database handle (from the out param) and the second is the return code.
Important: Ownership Semantics
The by_ref pointer value pushed onto the stack is an opaque handle owned by
the C library (SQLite in this case). You must:
- Only pass it to functions from the same library (e.g.,
db-exec,db-close) - Never attempt to free it manually
- Always close/release it using the library’s cleanup function (
db-close) - Not store it beyond its valid lifetime
The compiler treats these as integers for simplicity, but they are NOT arbitrary integers - they are pointers that must be used according to the C library’s API.
Fixed Value Arguments
For sqlite3_exec, we pass NULL for unused callback parameters:
args = [
{ type = "ptr", pass = "ptr" },
{ type = "string", pass = "c_string" },
{ type = "ptr", value = "null" }, # callback
{ type = "ptr", value = "null" }, # callback arg
{ type = "ptr", value = "null" } # error msg
]
Arguments with value don’t come from the stack - they’re compiled as constants.
See Also
- Language Guide - Core language concepts
- Weaves Guide - Generators and coroutines
- Testing Guide - Writing and running tests
- seqlings - Interactive exercises
Testing Guide
Seq includes a built-in test framework for writing and running tests. Tests are discovered automatically and run with seqc test.
Quick Start
Create a file named test-math.seq:
: test-addition ( -- )
"Addition" test.init
1 2 i.+ 3 test.assert-eq
test.finish
;
: test-multiplication ( -- )
"Multiplication" test.init
3 4 i.* 12 test.assert-eq
test.finish
;
Run tests:
seqc test
Output:
test-math.seq
test-addition ... ok
test-multiplication ... ok
2 tests passed, 0 failed
Test Discovery
The test runner uses two naming conventions plus a signature check:
- Test files: Files named
test-*.seqare discovered automatically. - Test functions: Words named
test-*are run as tests only when their declared stack effect is exactly( -- ). Atest-*word with a different signature (e.g. atest-flag ( Int Int -- Bool )helper) is skipped, not promoted to an entry point.
myproject/
src/
parser.seq
eval.seq
tests/
test-parser.seq # Discovered
test-eval.seq # Discovered
helpers.seq # NOT discovered (no test- prefix)
Run tests in a directory:
seqc test tests/ # Run all test-*.seq files in tests/
seqc test test-parser.seq # Run specific file
seqc test . # Run all tests in current directory (recursive)
If you pass an explicit file path that doesn’t match test-*.seq, the
runner errors instead of silently producing zero results:
$ seqc test tests/parser.seq
Test files must be named `test-*.seq`. Got: `tests/parser.seq`
Why test- predicates are safe
Inside a test-*.seq file, you can still define helpers whose names
start with test- — for example, predicate-style words ending in ?
or domain probes that take arguments. The signature filter excludes any
test-* word that doesn’t match ( -- ), so it never gets called with
an empty stack, and the runner prints a one-line note explaining why it
was skipped:
test-flag ... skipped — name starts with `test-` but stack effect is
( Int Int -- Bool ), not ( -- ). Rename if it's a helper; fix the
signature if it's a test.
The note distinguishes “you have a helper that happens to start with
test-” from “your test silently disappeared” — if you wrote what you
thought was a test and see this skip note, fix the signature.
Test Framework Builtins
| Word | Effect | Description |
|---|---|---|
test.init | ( String -- ) | Initialize test with a name |
test.finish | ( -- ) | Complete test and report results |
test.assert | ( Bool -- ) | Assert condition is true |
test.assert-not | ( Bool -- ) | Assert condition is false |
test.assert-eq | ( actual expected -- ) | Assert two integers are equal |
test.assert-eq-str | ( actual expected -- ) | Assert two strings are equal |
test.fail | ( String -- ) | Explicitly fail with message |
test.pass-count | ( -- Int ) | Get number of passed assertions |
test.fail-count | ( -- Int ) | Get number of failed assertions |
test.has-failures | ( -- Bool ) | Check if any assertions failed |
For test.assert-eq and test.assert-eq-str, push the actual
(computed) value first and the expected (literal) value on top. On
failure the runner prints expected <top>, got <below>.
Writing Tests
Basic Structure
Every test function should:
- Call
test.initwith a descriptive name - Run assertions
- Call
test.finish
: test-string-operations ( -- )
"String operations" test.init
# Test concatenation
"hello" " " string.concat "world" string.concat
"hello world" test.assert-eq-str
# Test length
"abc" string.length 3 test.assert-eq
# Test empty check
"" string.empty? test.assert
"x" string.empty? test.assert-not
test.finish
;
Testing with Setup
For tests needing setup, extract helpers:
: make-test-list ( -- List )
list.make
1 list.push
2 list.push
3 list.push
;
: test-list-length ( -- )
"List length" test.init
make-test-list list.length 3 test.assert-eq
test.finish
;
: test-list-sum ( -- )
"List sum" test.init
make-test-list 0 [ i.+ ] list.fold
6 test.assert-eq
test.finish
;
Testing Error Cases
Use test.fail for cases that shouldn’t be reached:
: test-option-handling ( -- )
"Option handling" test.init
Make-None match
None -> "none handled" drop
Some { >value } -> "Should not reach Some" test.fail
end
42 Make-Some match
None -> "Should not reach None" test.fail
Some { >value } -> value 42 test.assert-eq
end
test.finish
;
Testing Stack Effects
Test that operations produce expected stack results:
: test-stack-ops ( -- )
"Stack operations" test.init
# Test dup
5 dup
5 test.assert-eq # top should be 5
5 test.assert-eq # second should also be 5
# Test swap
1 2 swap
1 test.assert-eq # top should be 1
2 test.assert-eq # second should be 2
test.finish
;
Running Tests
Basic Usage
seqc test # Run all test-*.seq in current directory
seqc test tests/ # Run all tests in tests/ directory
seqc test test-parser.seq # Run specific test file
Filtering Tests
Run only tests matching a pattern:
seqc test -f parse # Run tests with "parse" in the name
seqc test -f test-add # Run tests starting with "test-add"
Verbose Output
See timing for each test:
seqc test -v
Output:
test-math.seq
test-addition ... ok (2ms)
test-multiplication ... ok (1ms)
test-division ... ok (1ms)
3 tests passed, 0 failed (4ms total)
Failure Output
When an assertion fails, the runner reports the source line, the expected value, and the actual value on the stack:
tests/test-math.seq::test-addition
test-addition ... FAILED
at line 6: expected 8, got 13
Multiple failures within a single test each get their own line.
Tests that fire many assertions (e.g. loop-like comparisons over a
list) cap the output at the first five failures and append a
+N more failures footer so the real signal isn’t buried:
tests/test-math.seq::test-many
test-many ... FAILED
at line 3: expected 1, got 99
at line 4: expected 2, got 99
at line 5: expected 3, got 99
at line 6: expected 4, got 99
at line 7: expected 5, got 99
+2 more failures
Standalone Test Files
If your test file has a main function, it runs as a standalone program instead of using the test runner:
# test-manual.seq - has main, runs standalone
: test-helper ( -- ) ... ;
: main ( -- )
# Custom test harness
"Running manual tests" io.write-line
test-helper
"All tests passed!" io.write-line
;
This is useful for tests requiring custom setup or integration tests.
Best Practices
1. One Assertion Per Concept
Group related assertions, but keep tests focused:
# Good - focused test
: test-empty-list-length ( -- )
"Empty list length" test.init
list.make list.length 0 test.assert-eq
test.finish
;
# Good - related assertions grouped
: test-list-push ( -- )
"List push" test.init
list.make
1 list.push list.length 1 test.assert-eq
2 list.push list.length 2 test.assert-eq
test.finish
;
2. Descriptive Test Names
Use names that describe what’s being tested:
: test-parser-handles-empty-input ( -- ) ... ;
: test-parser-rejects-invalid-syntax ( -- ) ... ;
: test-eval-arithmetic-precedence ( -- ) ... ;
3. Clean Up State
Tests run sequentially. Clean up any global effects:
: test-with-cleanup ( -- )
"Test with cleanup" test.init
# ... test code ...
test.finish
# Clean up any channels, files, etc.
;
4. Test Edge Cases
Cover boundaries and special cases:
: test-division-edge-cases ( -- )
"Division edge cases" test.init
0 5 i./ drop 0 test.assert-eq # 0 / n = 0
5 1 i./ drop 5 test.assert-eq # n / 1 = n
0 7 i.- 3 i./ drop 0 2 i.- test.assert-eq # negative division
test.finish
;
Example: Testing a Parser
include "parser"
: test-parse-number ( -- )
"Parse number" test.init
"42" parse match
ParseOk { >value } -> value 42 test.assert-eq
ParseErr { >msg } -> msg test.fail
end
test.finish
;
: test-parse-invalid ( -- )
"Parse invalid input" test.init
"not-a-number" parse match
ParseOk { >value } -> drop "Should have failed" test.fail
ParseErr { >msg } -> drop # Expected error
end
test.finish
;
: test-parse-empty ( -- )
"Parse empty string" test.init
"" parse match
ParseOk { >value } -> drop "Should have failed" test.fail
ParseErr { >msg } -> drop # Expected error
end
test.finish
;
See Also
- examples/ - Many examples include tests
- Standard Library - Full builtin reference
Seq Standard Library Reference
This document covers:
- Built-in Operations - primitives implemented in the runtime
- Standard Library Modules - Seq code included via
include std:<module>
Table of Contents
Built-in Operations
- I/O Operations
- Command-line Arguments
- File Operations
- Type Conversions
- Integer Arithmetic
- Integer Comparison
- Boolean Operations
- Bitwise Operations
- Stack Operations
- Control Flow
- Concurrency
- Channel Operations
- Networking — net.tcp.*
- Networking — net.udp.*
- Networking — net.dns.*
- Networking — net.tls.*
- Networking — Socket type
- OS Operations
- Terminal Operations
- String Operations
- Encoding Operations
- Crypto Operations
- Networking — net.http.* (HTTP client)
- Regular Expressions
- Compression
- Variant Operations
- List Operations
- Map Operations
- Float Arithmetic
- Float Comparison
- Float Math
- Test Framework
- Time Operations
- Serialization
- Stack Introspection
Standard Library Modules
- std:json
- std:yaml
- std:http
- std:list
- std:map
- std:imath
- std:fmath
- std:zipper
- std:signal
- std:son
- std:stack-utils
I/O Operations
| Word | Stack Effect | Description |
|---|---|---|
io.write | ( String -- ) | Write string to stdout without newline |
io.write-line | ( String -- ) | Write string to stdout with newline |
io.read-line | ( -- String Bool ) | Read line from stdin. Returns (line, success) |
io.read-n | ( Int -- String Int ) | Read N bytes from stdin. Returns (bytes, status) |
Command-line Arguments
| Word | Stack Effect | Description |
|---|---|---|
args.count | ( -- Int ) | Get number of command-line arguments |
args.at | ( Int -- String ) | Get argument at index N |
File Operations
| Word | Stack Effect | Description |
|---|---|---|
file.slurp | ( String -- String Bool ) | Read entire file. Returns content and success flag |
file.spit | ( String String -- Bool ) | Write content to file. Takes content and path, returns success |
file.append | ( String String -- Bool ) | Append content to file. Takes content and path, returns success |
file.exists? | ( String -- Bool ) | Check if file exists at path |
file.delete | ( String -- Bool ) | Delete a file at path. Returns success |
file.size | ( String -- Int Bool ) | Get file size in bytes. Returns size and success |
file.for-each-line | ( String [String --] -- Bool ) | Execute quotation for each line in file. Returns false if the file could not be opened or a read error occurred. |
Directory Operations
| Word | Stack Effect | Description |
|---|---|---|
dir.exists? | ( String -- Bool ) | Check if directory exists at path |
dir.make | ( String -- Bool ) | Create a directory at path. Returns success |
dir.delete | ( String -- Bool ) | Delete an empty directory. Returns success |
dir.list | ( String -- List Bool ) | List directory contents. Returns filenames and success |
Type Conversions
| Word | Stack Effect | Description |
|---|---|---|
int->string | ( Int -- String ) | Convert integer to string |
int->float | ( Int -- Float ) | Convert integer to float |
float->int | ( Float -- Int ) | Truncate float to integer |
float->string | ( Float -- String ) | Convert float to string |
string->int | ( String -- Int Bool ) | Parse string as integer. Returns (value, success) |
string->float | ( String -- Float Bool ) | Parse string as float. Returns (value, success) |
char->string | ( Int -- String ) | Convert Unicode codepoint to single-char string |
symbol->string | ( Symbol -- String ) | Convert symbol to string |
string->symbol | ( String -- Symbol ) | Intern string as symbol |
int.to-bytes-i32-be | ( Int -- String ) | Encode Int as 4-byte big-endian i32 (low 32 bits). For binary protocol encoders |
float.to-bytes-f32-be | ( Float -- String ) | Encode Float as 4-byte big-endian IEEE-754 f32. For binary protocol encoders |
Integer Arithmetic
| Word | Stack Effect | Description |
|---|---|---|
i.add / i.+ | ( Int Int -- Int ) | Add two integers (wrapping on overflow) |
i.subtract / i.- | ( Int Int -- Int ) | Subtract second from first (wrapping on overflow) |
i.multiply / i.* | ( Int Int -- Int ) | Multiply two integers (wrapping on overflow) |
i.divide / i./ | ( Int Int -- Int Bool ) | Integer division with success flag |
i.modulo / i.% | ( Int Int -- Int Bool ) | Integer modulo with success flag |
i.pow | ( Int Int -- Int Bool ) | Integer power base^exp with success flag (false on negative exp, exp > u32::MAX, or overflow; 0^0 = 1) |
Division and Modulo Behavior
Division and modulo operations return a result and a success flag:
- Success (
true): Operation completed normally, result is valid - Failure (
false): Division by zero, result is 0
Overflow handling: INT_MIN / -1 uses wrapping semantics and returns INT_MIN with success=true. This matches Forth/Factor behavior and avoids undefined behavior.
10 3 i./ # ( -- 3 true ) Normal division
10 0 i./ # ( -- 0 false ) Division by zero
-9223372036854775808 -1 i./ # ( -- -9223372036854775808 true ) Wrapping
Integer Comparison
| Word | Stack Effect | Description |
|---|---|---|
i.= / i.eq | ( Int Int -- Bool ) | Test equality |
i.< / i.lt | ( Int Int -- Bool ) | Test less than |
i.> / i.gt | ( Int Int -- Bool ) | Test greater than |
i.<= / i.lte | ( Int Int -- Bool ) | Test less than or equal |
i.>= / i.gte | ( Int Int -- Bool ) | Test greater than or equal |
i.<> / i.neq | ( Int Int -- Bool ) | Test not equal |
Boolean Operations
| Word | Stack Effect | Description |
|---|---|---|
and | ( Bool Bool -- Bool ) | Logical AND |
or | ( Bool Bool -- Bool ) | Logical OR |
not | ( Bool -- Bool ) | Logical NOT |
Bitwise Operations
| Word | Stack Effect | Description |
|---|---|---|
band | ( Int Int -- Int ) | Bitwise AND |
bor | ( Int Int -- Int ) | Bitwise OR |
bxor | ( Int Int -- Int ) | Bitwise XOR |
bnot | ( Int -- Int ) | Bitwise NOT (complement) |
shl | ( Int Int -- Int ) | Shift left by N bits |
shr | ( Int Int -- Int ) | Shift right by N bits (logical) |
popcount | ( Int -- Int ) | Count number of set bits |
clz | ( Int -- Int ) | Count leading zeros |
ctz | ( Int -- Int ) | Count trailing zeros |
int-bits | ( -- Int ) | Push bit width of integers (63 — see language guide for the 63-bit Int model) |
Stack Operations
| Word | Stack Effect | Description |
|---|---|---|
dup | ( T -- T T ) | Duplicate top value |
drop | ( T -- ) | Remove top value |
swap | ( T U -- U T ) | Swap top two values |
over | ( T U -- T U T ) | Copy second value to top |
rot | ( T U V -- U V T ) | Rotate third to top |
nip | ( T U -- U ) | Remove second value |
tuck | ( T U -- U T U ) | Copy top below second |
2dup | ( T U -- T U T U ) | Duplicate top two values |
3drop | ( T U V -- ) | Remove top three values |
pick | ( T Int -- T T ) | Copy value at depth N to top |
roll | ( T Int -- T ) | Rotate N+1 items, bringing depth N to top |
Control Flow
| Word | Stack Effect | Description |
|---|---|---|
call | ( Quotation -- ... ) | Call a quotation or closure |
cond | ( T [T -- T Bool] [T -- T] ... N -- T ) | Multi-way conditional: N predicate/body pairs. Each predicate receives the value and returns Bool; first match wins. Panics if no predicate matches. |
Concurrency
| Word | Stack Effect | Description |
|---|---|---|
strand.spawn | ( Quotation -- Int ) | Spawn concurrent strand. Returns strand ID |
strand.weave | ( Quotation -- handle ) | Create generator/coroutine. Returns handle |
strand.resume | ( handle T -- handle T Bool ) | Resume weave with value. Returns (handle, value, has_more) |
yield | ( ctx T -- ctx T ) | Yield value from weave and receive resume value |
strand.weave-cancel | ( handle -- ) | Cancel weave and release resources |
Channel Operations
| Word | Stack Effect | Description |
|---|---|---|
chan.make | ( -- Channel ) | Create new channel |
chan.send | ( T Channel -- Bool ) | Send value. Returns false once the channel has been closed |
chan.receive | ( Channel -- T Bool ) | Receive value. Returns (default false) once closed and drained |
chan.close | ( Channel -- ) | Mark the channel closed. Subsequent sends fail; receives drain buffered values and then return (default false) (issue #499) |
chan.yield | ( -- ) | Yield control to scheduler |
Networking — net.tcp.*
All TCP operations return a Bool success flag for error handling. The fd
slot is typed as Socket (a phantom over Int) — net.tcp.write will not
accept an arbitrary integer.
| Word | Stack Effect | Description |
|---|---|---|
net.tcp.listen | ( Int -- Socket Bool ) | Listen on port. Returns (socket, success). Pass 0 to let the OS pick. |
net.tcp.connect | ( String Int -- Socket Bool ) | Connect to host:port. Returns (socket, success) |
net.tcp.accept | ( Socket -- Socket Bool ) | Accept connection. Returns (client, success) |
net.tcp.local-port | ( Socket -- Int Bool ) | Read the OS-assigned local port. Works on both listeners (after listen 0) and connected streams. Returns (port, success). |
net.tcp.read | ( Socket -- String Bool ) | Read from socket. Returns (data, success) |
net.tcp.write | ( String Socket -- Bool ) | Write to socket. Returns success |
net.tcp.close | ( Socket -- Bool ) | Close socket. Returns success |
Note on
net.tcp.local-portand Socket id aliasing. Listeners and connected streams live in separate registries that each start their id sequence at 0, so the sameSocketinteger can refer to different resources depending on which registry holds it. Dispatch forlocal-port(and forclose) is streams-first, then listeners. The practical implication: if you intend to read a listener’s local port, callnet.tcp.local-porton it before allocating any connected streams (or stash the result eagerly), otherwise an id that aliases between the two registries will return the stream’s port. Tracked as a broader follow-up; in the meantime treatSocketids as resource-local, not globally unique.
net.tcp.connect resolves the hostname through net.dns.resolve —
the lookup runs on the DNS worker pool, not the may carrier, and tries
each returned address in order until one connects (or all fail). IP
literals work as hostnames (they round-trip through getaddrinfo
without touching DNS). Bool is false on resolution failure,
every-address-failed, invalid port (must be 1..65535), or
socket-registry exhaustion.
Connect timeout. Each connect attempt is bounded by a default
10s deadline. A peer that silently drops SYNs surfaces as a connect
failure in seconds, not the kernel’s full SYN timeout (~60–130s on
Linux). Override per-process with SEQ_TCP_CONNECT_TIMEOUT_MS (read
once on first use; setting 0 falls back to the default). The bound
applies per address — a multi-IP fallback that walks dead addresses
sees N × timeout total wall time before giving up.
Known limitation — no happy-eyeballs. Addresses are tried in resolver order. If AAAA (IPv6) points somewhere unreachable on a misconfigured network, the connect-timeout fires per address before falling back to A (IPv4). RFC 8305 happy-eyeballs would parallelise this; it’s a planned follow-up.
"example.com" 443 net.tcp.connect
[ # ( socket )
"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n" over net.tcp.write drop
dup net.tcp.read
[ io.write-line ]
[ drop "read failed" io.write-line ]
if
net.tcp.close drop
]
[ drop "connect failed" io.write-line ]
if
Networking — net.udp.*
Datagram-oriented; sockets are Socket handles. Every word ends with a
success Bool on top so callers can [ ... ] [ ... ] if.
| Word | Stack Effect | Description |
|---|---|---|
net.udp.bind | ( Int -- Socket Int Bool ) | Bind to local port. Returns (socket, bound-port, success). port=0 lets the OS pick. |
net.udp.send-to | ( String String Int Socket -- Bool ) | Send a datagram. (bytes, host, port, socket) |
net.udp.receive-from | ( Socket -- String String Int Bool ) | Receive (yields). Returns (bytes, host, port, success) |
net.udp.close | ( Socket -- Bool ) | Release the socket |
net.udp.send-to host resolution. Hostnames in the host argument
are resolved through net.dns.resolve — the
may-aware DNS worker pool — so the carrier thread does not park on a
blocking getaddrinfo. IP literals work without DNS round-trip. If
resolution returns multiple addresses (e.g. localhost →
::1, 127.0.0.1) the addresses are tried in order; the first
send_to that doesn’t error wins, which transparently handles the
case of a v4-only socket reached through a name that resolves
v6-first.
Note: for UDP, send_to returning OK means the kernel accepted the
datagram for the chosen address family — it does not confirm peer
receipt. The multi-address walk therefore catches local-side errors
(address-family mismatch, route-not-found) but cannot detect an
unreachable peer past the first successful queue.
Networking — net.dns.*
Hostname resolution offloaded to a dedicated OS-thread pool so may
carriers never park on getaddrinfo. A small TTL cache (60s, max 256
entries) collapses fanout to the same host. Inherits all
platform-correct resolution behaviour (/etc/hosts, systemd-resolved,
VPN/corp DNS, mDNS) from libc.
| Word | Stack Effect | Description |
|---|---|---|
net.dns.resolve | ( String -- V Bool ) | Resolve hostname to a list of IP-address strings. On failure (unresolvable, empty result, empty input) returns (empty-list, false). |
The list contains IP-string representations ("127.0.0.1",
"::1", …). IP literals fast-path through getaddrinfo without
touching DNS.
Worker count is configurable via SEQ_DNS_WORKERS (default 8, max 64).
SEQ_DNS_WORKERS=0 is treated as unset and falls back to the default —
disabling the pool makes no architectural sense since the syscall has
to run somewhere off the may carrier.
Fanout collapsing. Both sequential and concurrent fanout
collapse to a single getaddrinfo. The TTL cache catches the
sequential case. An in-flight map keyed by hostname catches the
concurrent case: when N strands race to resolve the same uncached
host, the first to arrive enqueues exactly one worker job and the
others attach to the in-flight entry. When the worker returns it
writes the cache and fans the result out to every attached strand.
IP-literal fast path. Callers that go through net.dns.resolve
indirectly (via net.tcp.connect, net.udp.send-to, or the HTTP
client’s SSRF validator) bypass the worker pool entirely when the
host string is an IP literal — dns::resolve_to_ips parses the
literal and returns it directly. The pool round-trip is reserved
for actual DNS work.
"api.example.com" net.dns.resolve
[ "first IP: " io.write list.first
[ io.write-line ] [ drop "no addresses" io.write-line ] if ]
[ "resolution failed" io.write-line ]
if
Networking — net.tls.*
TLS client upgrade for an already-connected Socket. The handshake
runs eagerly inside the builtin (via rustls::ClientConnection::complete_io
over the underlying may-aware TCP stream) so transport errors, bad
certificates, hostname mismatches, and any other TLS-layer failure
surface as (0, false) — matching every other fallible networking
word. After a successful upgrade, the existing net.tcp.read /
net.tcp.write / net.tcp.close operate on the returned Socket
transparently — the runtime dispatches reads and writes through
rustls. Trust roots come from webpki-roots.
| Word | Stack Effect | Description |
|---|---|---|
net.tls.client | ( Socket String -- Socket Bool ) | Upgrade a connected Socket to TLS. String is the hostname (drives SNI and certificate validation). Returns the same Socket id with its registry slot replaced in place (caller-side maps keyed on the id remain valid). On failure (handshake error, bad cert, empty hostname, etc.) returns (0, false); if the failure happened after the original stream was taken out of the registry, the slot’s id is released and the underlying socket is closed. |
Handshake timeout. Each individual read/write inside the rustls
handshake is bounded by a default 10s deadline. A peer that
accepts the TCP connection but goes silent mid-handshake surfaces as
a handshake failure within that bound, rather than parking the strand
indefinitely. Override with SEQ_TLS_HANDSHAKE_TIMEOUT_MS. (The
per-IO budget applies to each round of the handshake, not the entire
exchange — a slow but progressing handshake completes; a stalled one
fails.)
Known limitations (v1):
net.tcp.closeon a TLS socket is a hard close. The underlying TCP stream is dropped without first sending the TLSclose_notifyalert (RFC 5246 expects clients to send it). Modern servers tolerate truncation; some older stacks log it as a truncation-attack indicator. A graceful-shutdown variant is a planned follow-up.- IP-literal hostnames syntactically work but almost always fail validation.
ServerName::try_fromparses"1.2.3.4"cleanly, but cert validation against an IP requires the peer cert to carry that IP in a SubjectAltName entry — vanishingly rare for public-internet certs. Use a DNS name unless you control the peer cert. - No client-certificate authentication (mTLS). A
with_no_client_auth()config is used unconditionally. mTLS is a planned follow-up. - No caller-side ALPN selection. Whatever rustls defaults negotiate (typically
h2/http/1.1if the peer offers them) is what you get; there’s no way to ask for or reject a specific protocol. - No peer-certificate or cipher inspection from Seq. Once the handshake succeeds, only the upgraded Socket is exposed — the negotiated suite, peer cert chain, SNI accepted, etc., are not surfaced.
- No session resumption knobs. rustls’s default in-process session cache applies; there’s no Seq-level control.
"example.com" 443 net.tcp.connect
[ # ( socket )
"example.com" net.tls.client
[ # ( socket )
"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n" over net.tcp.write drop
dup net.tcp.read
[ io.write-line ]
[ drop "read failed" io.write-line ]
if
net.tcp.close drop
]
[ drop "TLS handshake failed" io.write-line ]
if
]
[ drop "connect failed" io.write-line ]
if
Networking — Socket type
Socket is a compile-time-only nominal wrapper over the Int file
descriptor; the runtime representation stays Value::Int(fd). This means
the type checker rejects 42 net.tcp.write. Two escape hatches exist
for FFI / debugging:
| Word | Stack Effect | Description |
|---|---|---|
fd->socket | ( Int -- Socket ) | Cast a raw fd to a Socket. No runtime conversion. |
socket->fd | ( Socket -- Int ) | Cast a Socket back to a raw fd. No runtime conversion. |
OS Operations
| Word | Stack Effect | Description |
|---|---|---|
os.getenv | ( String -- String Bool ) | Get env variable. Returns (value, exists) |
os.home-dir | ( -- String Bool ) | Get home directory. Returns (path, success) |
os.current-dir | ( -- String Bool ) | Get current directory. Returns (path, success) |
os.path-exists | ( String -- Bool ) | Check if path exists |
os.path-is-file | ( String -- Bool ) | Check if path is regular file |
os.path-is-dir | ( String -- Bool ) | Check if path is directory |
os.path-join | ( String String -- String ) | Join two path components |
os.path-parent | ( String -- String Bool ) | Get parent directory. Returns (path, success) |
os.path-filename | ( String -- String Bool ) | Get filename. Returns (name, success) |
os.exit | ( Int -- ) | Exit program with status code |
os.name | ( -- String ) | Get OS name (e.g., “macos”, “linux”) |
os.arch | ( -- String ) | Get CPU architecture (e.g., “aarch64”, “x86_64”) |
Terminal Operations
| Word | Stack Effect | Description |
|---|---|---|
terminal.raw-mode | ( Bool -- ) | Enable/disable raw mode. Raw: no buffering, no echo, Ctrl+C = byte 3 |
terminal.read-char | ( -- Int ) | Read single byte (blocking). Returns 0-255 or -1 on EOF |
terminal.read-char? | ( -- Int ) | Read single byte (non-blocking). Returns 0-255 or -1 if none |
terminal.width | ( -- Int ) | Get terminal width in columns. Returns 80 if unknown |
terminal.height | ( -- Int ) | Get terminal height in rows. Returns 24 if unknown |
terminal.flush | ( -- ) | Flush stdout |
String Operations
| Word | Stack Effect | Description |
|---|---|---|
string.concat | ( String String -- String ) | Concatenate two strings |
string.length | ( String -- Int ) | Get character length |
string.byte-length | ( String -- Int ) | Get byte length |
string.char-at | ( String Int -- Int ) | Get Unicode codepoint at index |
string.substring | ( String Int Int -- String ) | Extract substring (start, length) |
string.find | ( String String -- Int ) | Find substring. Returns index or -1 |
string.split | ( String String -- List ) | Split by delimiter |
string.contains | ( String String -- Bool ) | Check if contains substring |
string.starts-with | ( String String -- Bool ) | Check if starts with prefix |
string.empty? | ( String -- Bool ) | Check if empty |
string.equal? | ( String String -- Bool ) | Check equality |
string.trim | ( String -- String ) | Remove leading/trailing whitespace |
string.chomp | ( String -- String ) | Remove trailing newline |
string.to-upper | ( String -- String ) | Convert to uppercase |
string.to-lower | ( String -- String ) | Convert to lowercase |
string.json-escape | ( String -- String ) | Escape for JSON |
symbol.= | ( Symbol Symbol -- Bool ) | Check symbol equality |
Encoding Operations
| Word | Stack Effect | Description |
|---|---|---|
encoding.base64-encode | ( String -- String ) | Encode to Base64 (standard, with padding) |
encoding.base64-decode | ( String -- String Bool ) | Decode Base64. Returns (decoded, success) |
encoding.base64url-encode | ( String -- String ) | Encode to URL-safe Base64 (no padding) |
encoding.base64url-decode | ( String -- String Bool ) | Decode URL-safe Base64 |
encoding.hex-encode | ( String -- String ) | Encode to lowercase hex |
encoding.hex-decode | ( String -- String Bool ) | Decode hex string |
Crypto Operations
| Word | Stack Effect | Description |
|---|---|---|
crypto.sha256 | ( String -- String ) | SHA-256 hash. Returns 64-char hex |
crypto.hmac-sha256 | ( String String -- String ) | HMAC-SHA256. (message, key) |
crypto.constant-time-eq | ( String String -- Bool ) | Timing-safe comparison |
crypto.random-bytes | ( Int -- String ) | Generate N random bytes as hex |
crypto.random-int | ( Int Int -- Int ) | Generate random integer in range [min, max) |
crypto.uuid4 | ( -- String ) | Generate random UUID v4 |
crypto.aes-gcm-encrypt | ( String String -- String Bool ) | AES-256-GCM encrypt. (plaintext, hex-key) |
crypto.aes-gcm-decrypt | ( String String -- String Bool ) | AES-256-GCM decrypt. (ciphertext, hex-key) |
crypto.pbkdf2-sha256 | ( String String Int -- String Bool ) | Derive key. (password, salt, iterations) |
crypto.ed25519-keypair | ( -- String String ) | Generate keypair. Returns (public, private) |
crypto.ed25519-sign | ( String String -- String Bool ) | Sign message. (message, private-key) |
crypto.ed25519-verify | ( String String String -- Bool ) | Verify signature. (message, signature, public-key) |
Networking — net.http.* (HTTP client)
The HTTP client lives under net.http.*. The std:http stdlib module
provides server-side response/parsing helpers (http-ok, http-request-path,
…) and is unrelated.
The client is hand-rolled HTTP/1.1 over the may-aware TCP and TLS
primitives (see net.tcp.connect, net.tls.client).
Every IO step yields the strand cooperatively; the request never parks
the carrier thread. Hostname resolution and SSRF validation run through
the net.dns.* worker pool, so there is exactly
one getaddrinfo per request and it runs off the may carrier.
| Word | Stack Effect | Description |
|---|---|---|
net.http.get | ( String -- Map ) | GET request. Map has status, body, ok, error. |
net.http.post | ( String String String -- Map ) | POST request. (url, body, content-type). Body is byte-clean — binary payloads round-trip intact. |
net.http.put | ( String String String -- Map ) | PUT request. (url, body, content-type). |
net.http.delete | ( String -- Map ) | DELETE request. |
Response Map shape:
"status"(Int): HTTP status code, or0on connection-level error."body"(String): response body as raw bytes (byte-clean — binary downloads round-trip intact; decode as text yourself if you expected text)."ok"(Bool): true iff status is in200..300."error"(String): error message; present only on failure.
Connection pool. Keep-alive connections are pooled by (scheme, host, port)
between requests. A second net.http.get against the same origin skips
the TCP and TLS handshakes when an idle entry is available. Defaults:
- 8 idle connections per
(scheme, host, port) - 30s idle timeout
- 256 idle connections globally
- MRU checkout (freshest pooled connection used first)
- Non-blocking half-closed peek on reuse: if the peer FIN’d the socket while it sat idle, the entry is dropped and a fresh connection is dialled.
SSRF protection. Requests are blocked when the URL’s host resolves
to a loopback (127.0.0.0/8, ::1), private (10/8, 172.16/12,
192.168/16), link-local (169.254/16, including cloud metadata
endpoints), or unique-local (fc00::/7) IP. Schemes outside
http/https are rejected. The check runs against the addresses the
DNS layer already returned — no second resolution on the carrier.
v1 limitations:
- No redirect following. A 3xx response is returned to the caller as-is, with
Locationavailable in the body or via your own header parser. - No automatic decompression. The client sends
Accept-Encoding: identity. If you want gzip transfer, set up your own request and decompress withcompress.gunzip. - Per-IO request/response timeout (default 30s). Each individual read/write inside the wire layer (request send, response header read, every chunk read for chunked transfers, every read for EOF-framed bodies) is bounded by this deadline. A peer that goes silent mid-response — no bytes for
timeout— surfaces as a wire error within the bound. Override withSEQ_HTTP_REQUEST_TIMEOUT_MS. The deadline is per-IO, not total: a peer that drips one byte everytimeout − εkeeps each read under budget and so isn’t caught by this bound — practically, the residual exposure is capped atMAX_BODY_SIZE(10 MB) reads ×timeout. A total-time deadline is a planned follow-up; today the per-IO bound covers the “goes fully silent” hazard, not the slow-trickle-but-progressing one. - No custom request headers. Only
Host,User-Agent,Accept,Accept-Encoding,Connection, and (for POST/PUT)Content-Type+Content-Lengthare sent.Content-Typerejects control characters in the value to prevent header injection. A header-bag API is a planned follow-up. - No client certificate auth, ALPN selection, or peer-cert inspection. Inherited from
net.tls.client. - POST is not auto-retried on transient transport failure; GET/PUT/DELETE are (idempotent per RFC 9110). A POST that fails mid-flight surfaces as a connection error.
Regular Expressions
All regex operations return a Bool success flag (false for invalid regex).
| Word | Stack Effect | Description |
|---|---|---|
regex.match? | ( String String -- Bool ) | Check if pattern matches. (text, pattern) |
regex.find | ( String String -- String Bool ) | Find first match. Returns (match, success) |
regex.find-all | ( String String -- List Bool ) | Find all matches. Returns (matches, success) |
regex.replace | ( String String String -- String Bool ) | Replace first match. Returns (result, success) |
regex.replace-all | ( String String String -- String Bool ) | Replace all matches. Returns (result, success) |
regex.captures | ( String String -- List Bool ) | Extract capture groups. Returns (groups, success) |
regex.split | ( String String -- List Bool ) | Split by pattern. Returns (parts, success) |
regex.valid? | ( String -- Bool ) | Check if valid regex |
Compression
| Word | Stack Effect | Description |
|---|---|---|
compress.gzip | ( String -- String Bool ) | Gzip compress. Returns base64-encoded |
compress.gzip-level | ( String Int -- String Bool ) | Gzip at level 1-9 |
compress.gunzip | ( String -- String Bool ) | Gzip decompress |
compress.zstd | ( String -- String Bool ) | Zstd compress. Returns base64-encoded |
compress.zstd-level | ( String Int -- String Bool ) | Zstd at level 1-22 |
compress.unzstd | ( String -- String Bool ) | Zstd decompress |
Variant Operations
| Word | Stack Effect | Description |
|---|---|---|
variant.field-count | ( Variant -- Int ) | Get number of fields |
variant.tag | ( Variant -- Symbol ) | Get tag (constructor name) |
variant.field-at | ( Variant Int -- T ) | Get field at index |
variant.append | ( Variant T -- Variant ) | Append value to variant |
variant.first | ( Variant -- T ) | Get first field |
variant.last | ( Variant -- T ) | Get last field |
variant.init | ( Variant -- Variant ) | Get all fields except last |
variant.make-0 / wrap-0 | ( Symbol -- Variant ) | Create variant with 0 fields |
variant.make-1 / wrap-1 | ( T Symbol -- Variant ) | Create variant with 1 field |
variant.make-2 / wrap-2 | ( T T Symbol -- Variant ) | Create variant with 2 fields |
| … | … | … |
variant.make-12 / wrap-12 | ( T ... T Symbol -- Variant ) | Create variant with 12 fields |
List Operations
| Word | Stack Effect | Description |
|---|---|---|
list.make | ( -- List ) | Create empty list |
list.push | ( List T -- List ) | Push value onto list (COW: mutates in place if sole owner, else copies) |
list.get | ( List Int -- T Bool ) | Get value at index. Returns (value, success) |
list.set | ( List Int T -- List Bool ) | Set value at index. Returns (list, success) |
list.length | ( List -- Int ) | Get number of elements |
list.empty? | ( List -- Bool ) | Check if empty |
list.reverse | ( List -- List ) | Return list with elements reversed |
list.first | ( List -- T Bool ) | Get first element. Returns (value, success) — false on empty list |
list.last | ( List -- T Bool ) | Get last element. Returns (value, success) — false on empty list |
list.map | ( List [T -- U] -- List ) | Apply quotation to each element |
list.filter | ( List [T -- Bool] -- List ) | Keep elements where quotation returns true |
list.fold | ( List Acc [Acc T -- Acc] -- Acc ) | Reduce with accumulator |
list.each | ( List [T --] -- ) | Execute quotation for each element |
Map Operations
| Word | Stack Effect | Description |
|---|---|---|
map.make | ( -- Map ) | Create empty map |
map.get | ( Map K -- V Bool ) | Get value for key. Returns (value, success) |
map.set | ( Map K V -- Map ) | Set key to value |
map.has? | ( Map K -- Bool ) | Check if key exists |
map.remove | ( Map K -- Map ) | Remove key |
map.keys | ( Map -- List ) | Get all keys |
map.values | ( Map -- List ) | Get all values |
map.size | ( Map -- Int ) | Get number of entries |
map.empty? | ( Map -- Bool ) | Check if empty |
Float Arithmetic
| Word | Stack Effect | Description |
|---|---|---|
f.add / f.+ | ( Float Float -- Float ) | Add two floats |
f.subtract / f.- | ( Float Float -- Float ) | Subtract second from first |
f.multiply / f.* | ( Float Float -- Float ) | Multiply two floats |
f.divide / f./ | ( Float Float -- Float ) | Divide first by second |
Float Comparison
| Word | Stack Effect | Description |
|---|---|---|
f.= / f.eq | ( Float Float -- Bool ) | Test equality |
f.< / f.lt | ( Float Float -- Bool ) | Test less than |
f.> / f.gt | ( Float Float -- Bool ) | Test greater than |
f.<= / f.lte | ( Float Float -- Bool ) | Test less than or equal |
f.>= / f.gte | ( Float Float -- Bool ) | Test greater than or equal |
f.<> / f.neq | ( Float Float -- Bool ) | Test not equal |
Float Math
NaN/Infinity propagate per IEEE 754 — no (value Bool) flag. e.g. f.sqrt
of a negative returns NaN; f.ln 0.0 returns -Infinity.
Roots / powers
| Word | Stack Effect | Description |
|---|---|---|
f.sqrt | ( Float -- Float ) | Square root |
f.cbrt | ( Float -- Float ) | Cube root (defined for negatives) |
f.pow | ( Float Float -- Float ) | base exp pow |
Exponential / logarithmic
| Word | Stack Effect | Description |
|---|---|---|
f.exp | ( Float -- Float ) | e^x |
f.ln | ( Float -- Float ) | Natural log |
f.log10 | ( Float -- Float ) | Base-10 log |
f.log2 | ( Float -- Float ) | Base-2 log |
Trigonometric (radians)
| Word | Stack Effect | Description |
|---|---|---|
f.sin | ( Float -- Float ) | Sine |
f.cos | ( Float -- Float ) | Cosine |
f.tan | ( Float -- Float ) | Tangent |
f.asin | ( Float -- Float ) | Arcsine, range [-π/2, π/2] |
f.acos | ( Float -- Float ) | Arccosine, range [0, π] |
f.atan | ( Float -- Float ) | Arctangent, range (-π/2, π/2) |
f.atan2 | ( Float Float -- Float ) | y x atan2 — angle of (x, y) from positive x-axis |
Rounding
| Word | Stack Effect | Description |
|---|---|---|
f.floor | ( Float -- Float ) | Round toward -Infinity |
f.ceil | ( Float -- Float ) | Round toward +Infinity |
f.round | ( Float -- Float ) | Round to nearest, ties to even (banker’s, IEEE 754 default) |
f.trunc | ( Float -- Float ) | Round toward zero |
Constants
| Word | Stack Effect | Description |
|---|---|---|
f.pi | ( -- Float ) | π |
f.e | ( -- Float ) | e (Euler’s number) |
f.tau | ( -- Float ) | τ = 2π |
Test Framework
| Word | Stack Effect | Description |
|---|---|---|
test.init | ( String -- ) | Initialize with test name |
test.finish | ( -- ) | Finish and print results |
test.has-failures | ( -- Bool ) | Check if any tests failed |
test.assert | ( Bool -- ) | Assert boolean is true |
test.assert-not | ( Bool -- ) | Assert boolean is false |
test.assert-eq | ( actual expected -- ) | Assert two integers equal. Push the computed value first, the expected literal on top. |
test.assert-eq-str | ( actual expected -- ) | Assert two strings equal. Push the computed value first, the expected literal on top. |
test.fail | ( String -- ) | Mark test as failed with message |
test.pass-count | ( -- Int ) | Get passed assertion count |
test.fail-count | ( -- Int ) | Get failed assertion count |
Time Operations
| Word | Stack Effect | Description |
|---|---|---|
time.now | ( -- Int ) | Current Unix timestamp in seconds |
time.nanos | ( -- Int ) | High-resolution monotonic time in nanoseconds |
time.sleep-ms | ( Int -- ) | Sleep for N milliseconds |
Serialization
| Word | Stack Effect | Description |
|---|---|---|
son.dump | ( T -- String ) | Serialize value to SON format (compact) |
son.dump-pretty | ( T -- String ) | Serialize value to SON format (pretty) |
Stack Introspection
| Word | Stack Effect | Description |
|---|---|---|
stack.dump | ( ... -- ) | Print all stack values and clear (REPL) |
Standard Library Modules
These modules are included with include std:<module-name>.
std:json - JSON Parsing
JSON parsing and serialization implemented in Seq.
include std:json
"hello" json-string json-serialize io.write-line # "hello"
"{\"name\":\"bob\"}" json-parse drop json-serialize io.write-line
Value Constructors
| Word | Stack Effect | Description |
|---|---|---|
json-null | ( -- JsonValue ) | Create null |
json-bool | ( Bool -- JsonValue ) | Create boolean |
json-true | ( -- JsonValue ) | Create true |
json-false | ( -- JsonValue ) | Create false |
json-number | ( Float -- JsonValue ) | Create number |
json-int | ( Int -- JsonValue ) | Create number from int |
json-string | ( String -- JsonValue ) | Create string |
json-empty-array | ( -- JsonArray ) | Create empty array |
json-empty-object | ( -- JsonObject ) | Create empty object |
Builders
| Word | Stack Effect | Description |
|---|---|---|
array-with | ( JsonArray JsonValue -- JsonArray ) | Append element to array |
obj-with | ( JsonObject JsonString JsonValue -- JsonObject ) | Add key-value pair |
Type Predicates
| Word | Stack Effect | Description |
|---|---|---|
json-null? | ( JsonValue -- JsonValue Bool ) | Check if null |
json-bool? | ( JsonValue -- JsonValue Bool ) | Check if boolean |
json-number? | ( JsonValue -- JsonValue Bool ) | Check if number |
json-string? | ( JsonValue -- JsonValue Bool ) | Check if string |
json-array? | ( JsonValue -- JsonValue Bool ) | Check if array |
json-object? | ( JsonValue -- JsonValue Bool ) | Check if object |
Extractors
| Word | Stack Effect | Description |
|---|---|---|
json-unwrap-bool | ( JsonValue -- Bool ) | Extract boolean |
json-unwrap-number | ( JsonValue -- Float ) | Extract number |
json-unwrap-string | ( JsonValue -- String ) | Extract string |
Parsing & Serialization
| Word | Stack Effect | Description |
|---|---|---|
json-parse | ( String -- JsonValue Bool ) | Parse JSON string |
json-serialize | ( JsonValue -- String ) | Serialize to JSON string |
std:yaml - YAML Parsing
YAML parsing for configuration files and data.
include std:yaml
"name: hello\nport: 8080" yaml-parse drop yaml-serialize io.write-line
Supports: key-value pairs, nested objects (indentation), strings, numbers, booleans, null, comments.
| Word | Stack Effect | Description |
|---|---|---|
yaml-parse | ( String -- YamlValue Bool ) | Parse YAML string |
yaml-serialize | ( YamlValue -- String ) | Serialize to JSON-like string |
yaml-null | ( -- YamlValue ) | Create null |
yaml-bool | ( Bool -- YamlValue ) | Create boolean |
yaml-number | ( Float -- YamlValue ) | Create number |
yaml-string | ( String -- YamlValue ) | Create string |
yaml-empty-object | ( -- YamlObject ) | Create empty object |
yaml-obj-with | ( YamlObject key value -- YamlObject ) | Add key-value pair |
std:http - HTTP Response Helpers
Helper functions for building HTTP servers.
include std:http
"Hello, World!" http-ok # Returns full HTTP 200 response
request http-request-path # Extract path from request
Response Building
| Word | Stack Effect | Description |
|---|---|---|
http-ok | ( String -- String ) | Build 200 OK response |
http-not-found | ( String -- String ) | Build 404 response |
http-error | ( String -- String ) | Build 500 response |
http-response | ( Int String String -- String ) | Build custom response (code, reason, body) |
Request Parsing
| Word | Stack Effect | Description |
|---|---|---|
http-request-line | ( String -- String ) | Extract first line |
http-request-path | ( String -- String ) | Extract path |
http-request-method | ( String -- String ) | Extract method |
http-is-get | ( String -- Bool ) | Check if GET request |
http-is-post | ( String -- Bool ) | Check if POST request |
http-path-is | ( String String -- Bool ) | Check if path matches |
http-path-starts-with | ( String String -- Bool ) | Check path prefix |
http-path-suffix | ( String String -- String ) | Extract path after prefix |
std:list - List Utilities
Convenient words for building lists.
include std:list
list-of 1 lv 2 lv 3 lv # Build [1, 2, 3]
| Word | Stack Effect | Description |
|---|---|---|
list-of | ( -- List ) | Create empty list (alias for list.make) |
lv | ( List V -- List ) | Append value (alias for list.push) |
std:map - Map Utilities
No additional utilities beyond the built-in Map Operations.
std:imath - Integer Math
Common mathematical operations for integers.
include std:imath
-5 abs # 5
48 18 gcd # 6
15 0 100 clamp # 15
| Word | Stack Effect | Description |
|---|---|---|
abs | ( Int -- Int ) | Absolute value |
max | ( Int Int -- Int ) | Maximum |
min | ( Int Int -- Int ) | Minimum |
gcd | ( Int Int -- Int ) | Greatest common divisor |
sign | ( Int -- Int ) | Sign (-1, 0, or 1) |
square | ( Int -- Int ) | Square |
clamp | ( Int Int Int -- Int ) | Clamp between min and max |
For modulo and power, use the i.modulo and i.pow builtins directly (( Int Int -- Int Bool ) — see the Integer Arithmetic section above).
std:fmath - Float Math
Common mathematical operations for floats.
include std:fmath
-3.14 f.abs # 3.14
2.5 3.7 f.max # 3.7
1.5 f.square # 2.25
| Word | Stack Effect | Description |
|---|---|---|
f.abs | ( Float -- Float ) | Absolute value |
f.max | ( Float Float -- Float ) | Maximum |
f.min | ( Float Float -- Float ) | Minimum |
f.sign | ( Float -- Float ) | Sign (-1.0, 0.0, or 1.0) |
f.square | ( Float -- Float ) | Square |
f.neg | ( Float -- Float ) | Negate |
f.clamp | ( Float Float Float -- Float ) | Clamp between min and max |
For sqrt, trig, exp/log, rounding, and constants (f.pi, f.e, f.tau),
see the Float Math builtin section above — those are
always available without an include.
std:zipper - Functional List Zipper
A zipper provides O(1) cursor movement and “editing” of immutable lists by maintaining a focus element with left and right context.
include std:zipper
list-of 1 lv 2 lv 3 lv 4 lv 5 lv
zipper.from-list
zipper.right zipper.right # focus is now 3
zipper.focus # get current element (3)
10 zipper.set # replace focus with 10
zipper.to-list # [1, 2, 10, 4, 5]
Construction
| Word | Stack Effect | Description |
|---|---|---|
zipper.from-list | ( List -- Zipper ) | Create zipper from list, focus at first element |
zipper.to-list | ( Zipper -- List ) | Convert zipper back to list |
zipper.make-empty | ( -- Zipper ) | Create empty zipper |
Navigation
| Word | Stack Effect | Description |
|---|---|---|
zipper.right | ( Zipper -- Zipper ) | Move focus right (no-op at end) |
zipper.left | ( Zipper -- Zipper ) | Move focus left (no-op at start) |
zipper.start | ( Zipper -- Zipper ) | Move focus to first element |
zipper.end | ( Zipper -- Zipper ) | Move focus to last element |
Query
| Word | Stack Effect | Description |
|---|---|---|
zipper.focus | ( Zipper -- T ) | Get focused element |
zipper.empty? | ( Zipper -- Bool ) | Check if zipper is empty |
zipper.at-start? | ( Zipper -- Zipper Bool ) | Check if at first element |
zipper.at-end? | ( Zipper -- Zipper Bool ) | Check if at last element |
zipper.length | ( Zipper -- Int ) | Get total number of elements |
zipper.index | ( Zipper -- Int ) | Get current focus index (0-based) |
Modification
| Word | Stack Effect | Description |
|---|---|---|
zipper.set | ( Zipper T -- Zipper ) | Replace focused element |
zipper.insert-left | ( Zipper T -- Zipper ) | Insert element to left of focus |
zipper.insert-right | ( Zipper T -- Zipper ) | Insert element to right of focus |
zipper.delete | ( Zipper -- Zipper ) | Delete focused element, focus moves right (or left at end) |
std:signal - Signal Handling
Unix signal handling with a safe, flag-based API.
include std:signal
signal.trap-shutdown # Trap SIGINT and SIGTERM
: server-loop ( -- )
signal.shutdown-requested? if
"Shutting down..." io.write-line
else
handle-request
server-loop
then
;
Signal Constants (builtins)
| Word | Description |
|---|---|
signal.SIGINT | Interrupt (Ctrl+C) |
signal.SIGTERM | Termination request |
signal.SIGHUP | Hangup |
signal.SIGPIPE | Broken pipe |
signal.SIGUSR1 | User-defined 1 |
signal.SIGUSR2 | User-defined 2 |
Convenience Words
| Word | Stack Effect | Description |
|---|---|---|
signal.shutdown-requested? | ( -- Bool ) | Check for SIGINT or SIGTERM |
signal.trap-shutdown | ( -- ) | Trap SIGINT and SIGTERM |
signal.ignore-sigpipe | ( -- ) | Ignore SIGPIPE (for servers) |
std:son - Seq Object Notation Helpers
Convenience module that re-exports std:map and std:list, providing all the builder words needed for SON data construction.
include std:son
map-of "host" "localhost" kv "port" 8080 kv
list-of 1 lv 2 lv 3 lv
Including std:son gives you map-of, kv, list-of, and lv.
Security warning: SON files are executable Seq code. Only load SON from trusted sources.
std:stack-utils - Stack Utilities
Common stack operations built from primitives.
include std:stack-utils
1 2 3 2drop # Stack: 1
| Word | Stack Effect | Description |
|---|---|---|
2drop | ( A B -- ) | Drop top two values |
Built-in operations: 152 total. Standard library modules provide additional functionality.
Seq Type System Guide
Overview
Seq has a static type system with row polymorphism that verifies your programs at compile time. The type checker ensures:
- Stack safety: Operations receive the correct types
- No stack underflow: Operations never pop from an empty stack
- Branch compatibility: Conditionals produce consistent stack effects
- Type correctness: String operations get Strings, Int operations get Ints, etc.
All type checking happens at compile time - there’s zero runtime overhead.
Stack Effect Declarations
Basic Syntax
Words declare their stack effect - how they transform the stack:
: square ( Int -- Int )
dup i.* ;
Stack effect format: ( inputs -- outputs )
- Before
--: Types expected on stack (top on right) - After
--: Types produced on stack (top on right)
Reading Stack Effects
Stack effects are read bottom-to-top, with the rightmost type being the top of stack:
: example ( Int String -- Bool )
# Expects: Bottom [ Int String ] Top
# Produces: Bottom [ Bool ] Top
...
;
When this word is called:
- String must be on top of stack
- Int must be below the String
- After execution, a Bool will be on top
Examples
# Takes nothing, produces an Int
: forty-two ( -- Int )
42 ;
# Takes two Ints, produces one Int
: add-numbers ( Int Int -- Int )
i.+ ;
# Takes String, produces nothing (prints it)
: print ( String -- )
io.write-line ;
# Takes Int and String, produces String (e.g., "Value: 42")
: format ( Int String -- String )
swap int->string swap string.concat ;
Row Polymorphism
The Problem
How do we type dup? It should work for any type:
42 dup # Works: Int Int
"hi" dup # Works: String String
But it also needs to work with any stack depth:
# With empty stack
42 dup # ( -- Int Int )
# With existing values on stack
10 20 dup # ( Int -- Int Int Int )
"a" "b" dup # ( String -- String String String )
The Solution: Row Variables
Row variables represent “the rest of the stack”:
: dup ( ..a T -- ..a T T )
# ..a = whatever is already on the stack
# T = type on top
# Result: same stack, but top duplicated
...
;
Components:
..a- Row variable (rest of stack)T- Type variable (polymorphic over any type)- Stack effect says: “Give me a stack with some stuff (..a) and a value (T) on top, I’ll give you the same stack with that value duplicated”
Row Polymorphism in Action
All stack operations use row polymorphism:
# Duplicate top value
: dup ( ..a T -- ..a T T )
# Remove top value
: drop ( ..a T -- ..a )
# Swap top two values
: swap ( ..a T U -- ..a U T )
# Copy second value to top
: over ( ..a T U -- ..a T U T )
# Rotate three values
: rot ( ..a T U V -- ..a U V T )
Built-in operations also use row polymorphism:
# Add: works at any stack depth
: i.+ ( ..a Int Int -- ..a Int )
# Print: works at any stack depth
: io.write-line ( ..a String -- ..a )
Implicit Row Polymorphism
All stack effects in Seq are implicitly row-polymorphic. You don’t need to write ..rest explicitly - the compiler adds it automatically:
# What you write:
: double ( Int -- Int )
dup i.+ ;
# What the compiler understands:
: double ( ..rest Int -- ..rest Int )
dup i.+ ;
This means:
( -- Int )is treated as( ..rest -- ..rest Int )- pushes Int onto any stack( Int -- )is treated as( ..rest Int -- ..rest )- consumes Int from any stack( Int Int -- Int )is treated as( ..rest Int Int -- ..rest Int )- works at any depth
Why this matters: You can call double from any stack state:
# With one value on stack:
42 double # 42 → 84
# With extra values below:
10 20 30 double # 10 20 30 → 10 20 60
The values 10 and 20 are untouched—double only operates on the top. Without implicit row polymorphism, double would only work with exactly one Int on the stack—you couldn’t compose operations freely.
When to use explicit row variables:
- Use explicit
..a,..restwhen you need to name the row variable - Useful when multiple row variables must match (e.g., in quotation types)
- Example:
( ..a T -- ..a T T )makes it clear both sides share the same..a
Why This Matters
Row polymorphism enables stack operation composition:
: swap-and-add ( Int Int Int -- Int Int )
swap i.+ ;
# Type checker verifies:
# 1. swap: ( ..a Int Int -- ..a Int Int )
# With ..a = Int, we get: ( Int Int Int -- Int Int Int )
# 2. i.+: ( ..a Int Int -- ..a Int )
# With ..a = Int, we get: ( Int Int Int -- Int Int )
# Result: ( Int Int Int -- Int Int ) ✓
Refinement Inside Quotation Bodies
Row variables are not just placeholders for “whatever is below” — they
refine to expose concrete slots when an operation needs them. This
is what lets nested combinators like [ [ q ] dip ] dip type-check.
When the typechecker pops a value from a stack that’s currently a bare
row variable, it introduces a fresh type variable for the popped slot
and a fresh row variable for what’s still below, and records the
substitution ..a := ..a' T. The constraint propagates outward as the
surrounding word’s effect is inferred.
# Inside the outer quotation body, the stack starts as a fresh row
# variable. The inner `[ 50 i.+ ] dip` pops a preserved value out of
# that row, refining it to "at least one Int slot present":
[ [ 50 i.+ ] dip ]
# Body's inferred effect: ( ..r Int T -- ..r Int T )
# - ..r Int below the preserved slot (the [ 50 i.+ ] operates here)
# - T the preserved slot the inner dip exposes
The outer dip then applies this body effect to a concrete stack —
unification fills ..r, Int, and T from what the caller provided.
Refinement only applies to flexible row variables. A row variable
named ..rest in a user-declared signature (the parser’s convention
for “the caller provides whatever’s below”) is rigid — it
represents the caller’s contract, not an unknown to be refined.
Popping below it is a genuine stack underflow and reports as such:
: bad ( -- ) # ..rest is rigid: the user said "no inputs"
[ ] dip # error: dip: expected a value below the quotation:
# stack underflow
;
This boundary is what keeps the inference sound — declared inputs are honored, only inferred (unconstrained) row variables refine.
Row Polymorphism vs Traditional Generics
If you’re familiar with generics from languages like Java, Rust, or TypeScript, row polymorphism may seem similar—but it solves a different problem.
Traditional Generics parameterize over individual types:
// TypeScript: generic over one type T
function identity<T>(x: T): T {
return x;
}
// Rust: generic over type T
fn identity<T>(x: T) -> T { x }
This lets identity work with any single type. But what if you need to abstract over multiple types at once—without knowing how many?
Row Polymorphism parameterizes over sequences of types:
# Seq: polymorphic over the entire stack prefix
: dup ( ..a T -- ..a T T )
The ..a isn’t a single type—it’s zero or more types. This is essential for stack-based languages where operations must work regardless of what’s “below” them on the stack.
Comparison table:
| Feature | Traditional Generics | Row Polymorphism |
|---|---|---|
| Abstraction unit | Single type (T) | Sequence of types (..a) |
| Fixed arity | Function has fixed param count | Stack depth is variable |
| Composition | Explicit argument passing | Implicit stack threading |
| Use case | Collections, containers | Stack operations, concatenative code |
Why generics alone aren’t enough:
Consider typing swap with only traditional generics:
// TypeScript - can type swap for exactly 2 args:
function swap<T, U>(a: T, b: U): [U, T] {
return [b, a];
}
But this doesn’t let swap ignore extra values. In Seq:
: swap ( ..a T U -- ..a U T )
The ..a means “whatever else is on the stack stays unchanged.” You can’t express this with traditional generics—you’d need a separate swap2, swap3, etc. for each stack depth.
Row polymorphism is generics extended to type sequences. Where T abstracts over a single type, ..a abstracts over zero or more types in a specific order—each potentially different. The compiler tracks that ..a bound to Int, String, Float stays exactly Int, String, Float.
Types in Seq
Concrete Types
- Int: Integer numbers (64-bit signed)
- Float: Floating-point numbers (64-bit)
- String: Text strings
- Bool: A distinct type tracked by the type checker. Literals are
trueandfalse. Comparison and logical operations produceBool;ifrequiresBool.
Type Variables
- A single uppercase letter —
T,U,V,K,M, etc. — is a polymorphic type variable, abstracting over one concrete type slot. - Multi-character uppercase identifiers (
Acc,Ctx,Handle,Sokcet) are not type variables. They must be a registered concrete type or union, otherwise the type checker rejects them with a “did you mean” hint. This catches typos that previously masqueraded as polymorphism. - Example:
dup ( ..a T -- ..a T T )works for any one value type T.
Row Variables
- ..a, ..b, ..rest: Row variables (lowercase with
..prefix) - Represent “rest of stack” — zero or more values, in order
- Enable polymorphism over stack depth, not over a single type
- Implicit on every stack effect; write them explicitly only when you need two effects to share the same row variable
Type Errors Explained
Type Mismatch
: bad ( Int -- )
io.write-line ; # ERROR: io.write-line expects String, got Int
Error message:
io.write-line: stack type mismatch.
Expected (..a String), got (..a Int): Type mismatch: cannot unify String with Int
Fix: Convert Int to String first:
: good ( Int -- )
int->string io.write-line ;
Stack Underflow
: bad ( -- )
drop ; # ERROR: can't drop from empty stack
Error message:
drop: stack type mismatch.
Expected (..a T), got (): stack underflow
Fix: Ensure stack has a value first:
: good ( Int -- )
drop ;
Branch Incompatibility
: bad ( Int -- ? )
0 > if
42 # Produces: Int
else
"hello" # Produces: String - ERROR!
then ;
Error message:
if branches have incompatible stack effects:
then=(..a Int), else=(..a String): Type mismatch: cannot unify Int with String
Fix: Both branches must produce the same types:
: good ( Int -- String )
0 > if
"positive"
else
"non-positive"
then ;
Unbalanced If/Then
: bad ( Int Int -- Int )
> if
100 # Pushes Int
then ; # ERROR: else branch leaves stack unchanged
Error message:
if branches have incompatible stack effects:
then=(..a Int), else=(..a): branches must produce identical stack effects
Fix: Provide else branch OR don’t push in then:
: good ( Int Int -- Int )
> if
100
else
0
then ;
Type Checking Process
The type checker works in two passes:
Pass 1: Collect Signatures
: helper ( Int -- String ) int->string ;
: main ( -- ) 42 helper io.write-line ;
First, the checker collects all word signatures:
helper: ( Int -- String )main: ( -- )
Pass 2: Verify Bodies
For each word, the checker:
- Starts with declared input stack
- Processes each statement, tracking stack changes
- Verifies result matches declared output
Example for main:
Start: ( -- ) # Empty stack
After 42: ( Int ) # Pushed Int
After helper: ( String ) # Applied helper's effect
After io.write-line: ( ) # Applied io.write-line's effect
Result: ( ) # Matches declared output ✓
Unification
When applying an effect like add: ( ..a Int Int -- ..a Int ) to current stack ( Int Int Int ):
-
Unify effect input with current stack:
- Effect input:
..a Int Int - Current stack:
Int Int Int - Unification:
..a = Int(row variable binds to Int)
- Effect input:
-
Apply substitution to effect output:
- Effect output:
..a Int - Substitute
..a = Int:Int Int - Result stack:
( Int Int )
- Effect output:
This is how the type checker proves stack safety!
Best Practices
1. Always Declare Effects
Even though the checker can infer types, always declare effects for clarity:
# Good - clear intent
: double ( Int -- Int )
2 i.* ;
# Discouraged - unclear what it does
: double
2 i.* ;
2. Use Descriptive Row Variable Names
# Okay
: dup ( ..a T -- ..a T T ) ... ;
# Better - shows it's the rest of stack
: dup ( ..rest T -- ..rest T T ) ... ;
3. Check Both Branches
When using conditionals, ensure both branches produce the same effect:
: abs ( Int -- Int )
dup 0 < if
-1 i.* # Negate
else
# Leave unchanged - implicit "do nothing"
then ;
4. Use int->string for Conversions
: print-number ( Int -- )
int->string io.write-line ;
Examples
Simple Math
: square ( Int -- Int )
dup i.* ;
: pythagorean ( Int Int -- Int )
# a^2 + b^2
swap square # ( a b -- b a^2 )
swap square # ( b a^2 -- a^2 b^2 )
i.+ ; # ( a^2 b^2 -- sum )
String Operations
: greet ( String -- )
"Hello, " swap string.concat io.write-line ;
: print-number ( Int -- )
int->string io.write-line ;
Conditionals
: max ( Int Int -- Int )
2dup i.> if
drop # Keep first
else
nip # Keep second
then ;
: describe ( Int -- String )
0 i.> if
"positive"
else
"non-positive"
then ;
Stack Shuffling
: rot-sum ( Int Int Int -- Int )
# Sum three numbers after rotation
rot i.+ i.+ ;
: under ( Int Int Int -- Int Int )
# Like over but deeper
rot swap ;
Summary
Seq’s type system provides:
- ✅ Stack safety - no underflows, no type mismatches
- ✅ Row polymorphism - stack operations work at any depth
- ✅ Implicit row polymorphism - all effects are automatically row-polymorphic
- ✅ Zero runtime cost - all checking at compile time
- ✅ Clear error messages - tells you exactly what’s wrong
- ✅ Compile-time guarantees - if it type checks, stack operations are safe
The type system is simple but powerful - it catches bugs early without getting in your way.
Happy concatenative programming! 🎉
Seq Language Grammar
This document provides a formal EBNF grammar specification for the Seq programming language.
Notation
|- alternation[ ]- optional (0 or 1){ }- repetition (0 or more)( )- grouping"..."- literal terminalUPPERCASE- lexical tokenslowercase- grammar rules
Grammar
Top-Level Structure
program = { include | union_def | word_def } ;
include = "include" include_path ;
include_path = "std" ":" IDENT
| "ffi" ":" IDENT
| STRING ;
Union Types (Algebraic Data Types)
union_def = "union" UPPER_IDENT "{" { union_variant } "}" ;
union_variant = UPPER_IDENT [ "{" field_list "}" ] ;
field_list = [ field { "," field } [ "," ] ] ;
field = IDENT ":" type_name ;
Word Definitions
word_def = ":" IDENT [ stack_effect ] { statement } ";" ;
stack_effect = "(" type_list "--" type_list [ "|" effect_annotation { effect_annotation } ] ")" ;
effect_annotation = "Yield" type ;
type_list = [ row_var ] { type } ;
row_var = ".." ROW_VAR_NAME ;
type = base_type
| type_var
| quotation_type
| closure_type ;
base_type = "Int" | "Float" | "Bool" | "String" ;
type_var = UPPER_IDENT ;
quotation_type = "[" type_list "--" type_list "]" ;
closure_type = "Closure" "[" type_list "--" type_list "]" ;
type_var must not be the literal token Quotation: the parser rejects
it explicitly with a hint pointing at the [ .. -- .. ] syntax. The
name Closure is also reserved — it’s handled as the start of
closure_type, not as a type variable.
Statements
statement = literal
| word_call
| quotation
| if_stmt
| match_stmt ;
literal = INT_LITERAL
| FLOAT_LITERAL
| BOOL_LITERAL
| STRING
| SYMBOL_LITERAL ;
word_call = IDENT ;
quotation = "[" { statement } "]" ;
if_stmt = "if" { statement } ( "then" | "else" { statement } "then" ) ;
match_stmt = "match" { match_arm } "end" ;
match_arm = pattern "->" { statement } ;
pattern = UPPER_IDENT [ "{" { BINDING } "}" ] ;
BINDING = ">" IDENT ;
BINDING is a single lexical token: > and the field name must not be
separated by whitespace. >value is a binding; > value is two
separate tokens (the word calls > and value) and the parser reports
an error asking for the >-prefix form.
Lexical Grammar
Identifiers
IDENT = IDENT_START { IDENT_CHAR } ;
IDENT_START = LETTER | "_" | "-" | "." | ">" | "<" | "=" | "?" | "!" | "+" | "*" | "/" | "%" ;
IDENT_CHAR = IDENT_START | DIGIT ;
UPPER_IDENT = UPPER_LETTER { IDENT_CHAR } ;
LOWER_IDENT = LOWER_LETTER { IDENT_CHAR } ;
ROW_VAR_NAME = LOWER_LETTER { LETTER | DIGIT | "_" } ;
LETTER = UPPER_LETTER | LOWER_LETTER ;
UPPER_LETTER = "A" | "B" | ... | "Z" ;
LOWER_LETTER = "a" | "b" | ... | "z" ;
DIGIT = "0" | "1" | ... | "9" ;
Row-variable names (..rest) use the stricter ROW_VAR_NAME rule: they
must start with a lowercase letter and contain only letters, digits, and
underscores. The broader IDENT punctuation characters (`- . > < = ? !
-
- / %
) are rejected. The namesInt,Bool,String` are reserved even though they’re already excluded by the lowercase-start rule (the parser emits a dedicated error if you try to use them).
- / %
Literals
INT_LITERAL = DECIMAL_INT | HEX_INT | BINARY_INT ;
DECIMAL_INT = [ "-" ] DIGIT { DIGIT } ;
HEX_INT = "0" ( "x" | "X" ) HEX_DIGIT { HEX_DIGIT } ;
BINARY_INT = "0" ( "b" | "B" ) BINARY_DIGIT { BINARY_DIGIT } ;
HEX_DIGIT = DIGIT | "a" | "b" | "c" | "d" | "e" | "f"
| "A" | "B" | "C" | "D" | "E" | "F" ;
BINARY_DIGIT = "0" | "1" ;
FLOAT_LITERAL = [ "-" ] ( DIGIT { DIGIT } "." { DIGIT } [ EXPONENT ]
| DIGIT { DIGIT } EXPONENT
| "." DIGIT { DIGIT } [ EXPONENT ] ) ;
EXPONENT = ( "e" | "E" ) [ "+" | "-" ] DIGIT { DIGIT } ;
BOOL_LITERAL = "true" | "false" ;
SYMBOL_LITERAL = ":" SYMBOL_NAME ;
SYMBOL_NAME = LETTER { LETTER | DIGIT | "-" | "_" | "." | "?" | "!" } ;
(* `:` is a single-character delimiter token; whitespace after it is not
significant. Disambiguation between `word_def` and `SYMBOL_LITERAL` is
context-driven: a `:` at the top level starts a `word_def`, and a `:`
inside a word body (wherever a `statement` is expected) starts a
`SYMBOL_LITERAL`. *)
STRING = '"' { STRING_CHAR | ESCAPE_SEQ } '"' ;
STRING_CHAR = any character except '"' or '\' ;
ESCAPE_SEQ = '\' ( '"' | '\' | 'n' | 'r' | 't' )
| '\' 'x' HEX_DIGIT HEX_DIGIT ;
The \xNN escape produces the Unicode code point U+00NN. For NN in
00..7F this is a single ASCII byte (common use: \x1b for ANSI
terminal escape sequences). For NN in 80..FF the code point falls
in the Latin-1 Supplement block (U+0080..U+00FF) and the resulting
character is encoded as multi-byte UTF-8.
Comments and Whitespace
COMMENT = "#" { any character except newline } NEWLINE ;
SHEBANG = "#!" { any character except newline } NEWLINE ;
WHITESPACE = SPACE | TAB | NEWLINE ;
A SHEBANG line (typically #!/usr/bin/env seqc) is accepted anywhere a
COMMENT is, so scripts can be executed directly from the shell. The
parser treats it as an ordinary comment.
Comments matching the form # seq:allow(lint-id) are collected as lint
allowances for the word definition that follows them. The text inside
the parentheses is the lint rule id; multiple seq:allow comments
before a word stack additively.
Semantic Notes
Row Polymorphism
All stack effects are implicitly row-polymorphic. When no explicit row variable is given, an implicit ..rest is assumed:
# These are equivalent:
: dup ( T -- T T ) ... ;
: dup ( ..rest T -- ..rest T T ) ... ;
This means ( -- ) preserves the stack (it’s ( ..rest -- ..rest )), not that it requires an empty stack.
Naming Conventions
| Delimiter | Usage | Example |
|---|---|---|
. (dot) | Module/namespace prefix | io.write-line, net.tcp.listen |
- (hyphen) | Compound words | home-dir, write-line |
-> (arrow) | Type conversions | int->string, float->int |
? (question) | Predicates | list.empty?, map.has? |
For each union definition, the compiler auto-generates helper words
by convention. Given union Shape { Circle { radius: Int } … }:
| Generated word | Shape | Example |
|---|---|---|
Make-<Variant> | constructor | 5 Make-Circle |
is-<Variant>? | predicate | shape is-Circle? |
<Variant>-<field> | field accessor | circle Circle-radius |
These are ordinary word_calls at the grammar level; they’re listed
here so readers can predict the generated names.
Reserved Words
The following are reserved and cannot be used as word names:
- Control flow:
if,else,then,match,end - Definitions:
union,include - Literals:
true,false
Operator Precedence
Seq has no operator precedence - all tokens are either literals or word calls. Evaluation is strictly left-to-right with stack-based semantics.
Quotations vs Closures
A quotation (the surface syntax [ … ]) has two possible types:
quotation_type— if the body consumes only values pushed inside the quotation itself (plus an implicit row variable).closure_type— if the body references values from the enclosing stack. The compiler captures those values into an environment at the point the quotation is produced; the result is aClosure[ … ]at the type level.
There is no dedicated syntax for a closure — the parser always builds a
quotation literal, and the type checker decides whether the result is a
quotation_type or a closure_type based on what the body references.
Arithmetic Sugar
The tokens +, -, *, /, %, =, <, >, <=, >=, and <> are
ordinary identifiers at the grammar level but are resolved by the compiler
to their typed counterparts based on the inferred stack types. For example:
3 4 + # resolves to `i.+` — both operands are Int
3.0 4.0 + # resolves to `f.+` — both operands are Float
This is a compile-time rewrite, not dynamic dispatch: if the types can’t
be inferred unambiguously the program fails to type-check. Writing the
explicit form (i.+, f.<, etc.) is always valid and suppresses the
sugar resolution.
Sugar resolves only when the operand types are visible on the typechecker’s stack at the use site. Inside a quotation body the body is typed against its own fresh effect, so its stack is empty from the resolver’s perspective and sugar cannot resolve. Use the typed form inside quotations:
3 4 [ + ] call # error: + can't resolve, operands not in scope
3 4 [ i.+ ] call # idiomatic — works regardless of caller context
The typed form (i.+, f.+, string.concat, …) is the always-works
idiom; sugar is a top-level convenience that’s nice for short
expressions but should be expanded when writing words intended to be
passed to combinators like dip, keep, bi, times, or
each-integer.
Examples
Complete Program
include std:json
union Result {
Ok { value: Int }
Error { message: String }
}
: safe-divide ( Int Int -- Result )
dup 0 i.= if
drop drop "Division by zero" Make-Error
else
i.divide drop Make-Ok
then
;
: main ( -- )
10 2 safe-divide
match
Ok { >value } -> value int->string io.write-line
Error { >message } -> message io.write-line
end
;
Stack Effects
# Simple transformation
: double ( Int -- Int ) 2 i.* ;
# Multiple inputs/outputs
: divmod ( Int Int -- Int Int ) over over i./ rot rot i.% ;
# Row-polymorphic (preserves rest of stack)
: swap ( ..a T U -- ..a U T ) ... ;
# Quotation type
: apply-twice ( Int [Int -- Int] -- Int ) dup rot swap call swap call ;
# Closure type
: make-adder ( Int -- Closure[Int -- Int] ) [ i.+ ] ;
Include System Design
Overview
Seq supports a simple include system for code reuse. The design prioritizes:
- Minimal filesystem exposure
- Clear provenance (stdlib vs user code)
- Collision detection with good error messages
- Future extensibility to packages
Syntax
# Standard library (ships with compiler)
include std:http
include std:imath
# FFI bindings (C library wrappers)
include ffi:libedit
# Relative to current file
include "my-utils"
include "lib/helpers"
Rules
-
std:prefix - References stdlib bundled with compiler- Compiler knows where stdlib lives (not user’s concern)
- Example:
include std:httploadshttp.seqfrom stdlib
-
ffi:prefix - References FFI bindings for C libraries- Some bindings ship with compiler (e.g.,
ffi:libedit) - Others require
--ffi-manifestflag with a TOML manifest - Example:
include ffi:libeditloads readline-style functions - See FFI_GUIDE.md for full documentation
- Some bindings ship with compiler (e.g.,
-
Quoted string - Path relative to the including file
- No absolute paths allowed
- Paths can use
..to reference parent directories - Example:
include "lib/foo"loads./lib/foo.seq - Example:
include "../src/utils"from a tests directory - Example:
include "../../src/tokenizer"for deeper nesting
-
Extension omitted - Compiler adds
.seqautomatically -
Include once - Files are included at most once per compilation
- Duplicate includes are silently ignored
- Prevents diamond dependency issues
Collision Detection
If the same word is defined in multiple files:
Error: Word 'http-ok' is defined multiple times:
- stdlib/http.seq:45
- ./my-utils.seq:12
Hint: Rename one of the definitions to avoid collision.
All definitions are still global (no namespaces), but collisions are caught at compile time.
Implementation Notes
Compilation Pipeline
-
Resolve includes - Before parsing main file:
- Scan for include statements
- Resolve paths (stdlib vs relative)
- Load and parse included files
- Recursively process their includes
- Track included files to prevent duplicates
-
Merge programs - Combine all WordDefs into single Program
-
Check collisions - Before type checking:
- Build map of word name -> definition location
- Error if any word has multiple definitions
-
Continue normally - Type check and codegen as before
Stdlib Location
The compiler locates the stdlib in this order:
SEQ_STDLIBenvironment variable (if set to a valid directory)stdlib/directory relative to the compiler binary (for installed builds)- Embedded stdlib compiled into the binary (fallback)
Path Validation
Include paths are validated:
- Absolute Path Rejection - Absolute paths are rejected; all includes must be relative
- Empty Path Validation - Empty include paths are rejected
- Canonicalization - Paths are canonicalized to resolve symlinks and normalize
..segments - File Must Exist - The target
.seqfile must exist
Examples
Simple Program
include std:http
: main ( -- Int )
"Hello" http-ok io.write-line
0
;
With Local Utils
include std:http
include "utils"
: main ( -- Int )
get-greeting http-ok io.write-line
0
;
Where utils.seq in same directory:
: get-greeting ( -- String )
"Hello from utils!"
;
Collision Error
include std:http
include "my-http" # Also defines http-ok
Error: Word 'http-ok' is defined multiple times:
- stdlib/http.seq:45
- ./my-http.seq:3
Weaves: Generators and Coroutines
Weaves are Seq’s implementation of generators/coroutines, built on top of the CSP-style strand system. They provide bidirectional communication between a producer and consumer through structured yield/resume semantics.
Why Weaves?
Generators are useful when you need to:
- Produce values lazily (only compute when needed)
- Maintain state between iterations without explicit data structures
- Transform streams of data with backpressure
- Implement query engines or interpreters that yield results incrementally
Seq’s weaves are unique in that they’re built on the same strand/channel infrastructure as CSP concurrency. A weave is essentially a strand with a structured protocol for yield and resume.
Basic Concepts
| Term | Description |
|---|---|
| Weave | A suspended computation that yields values and receives resume values |
| Handle | A WeaveCtx value used to resume and communicate with a weave |
| Yield | Pause execution, send a value to the caller, wait for a resume value |
| Resume | Send a value into a paused weave, receive its next yielded value |
Core Operations
| Word | Effect | Description |
|---|---|---|
strand.weave | ( Quotation -- WeaveCtx ) | Create a weave from a quotation |
strand.resume | ( WeaveCtx T -- WeaveCtx T Bool ) | Resume with value, get (handle, yielded, has_more) |
yield | ( Ctx T -- Ctx T | Yield T ) | Yield value, receive resume value |
strand.weave-cancel | ( WeaveCtx -- ) | Cancel a weave and release resources |
Simple Counter Example
A counter that yields its current value and accepts an increment:
# Counter - yields current count, receives increment
: counter ( Ctx Int -- | Yield Int )
tuck # ( count Ctx count )
yield # yield count, receive increment -> ( count Ctx increment )
rot # ( Ctx increment count )
i.add # ( Ctx new_count )
counter # tail recurse
;
: main ( -- )
# Create weave
[ counter ] strand.weave # ( handle )
# Resume with initial value 10
10 strand.resume # ( handle yielded has_more )
drop # ( handle yielded )
"First: " swap int->string string.concat io.write-line
# ( handle )
# Resume with increment 5
5 strand.resume # ( handle yielded has_more )
drop
"After +5: " swap int->string string.concat io.write-line
strand.weave-cancel # Clean up (infinite generator)
;
Output:
First: 10
After +5: 15
The Ctx Threading Pattern
Critical: The weave context (Ctx) must be explicitly threaded through your code. This is different from languages where generator state is implicit.
The quotation passed to strand.weave receives (Ctx, first_resume_value) and must keep the Ctx accessible for yield calls:
# WRONG - loses the Ctx
: bad-generator ( Ctx Int -- | Yield Int )
yield # Error: Ctx is buried under Int
;
# CORRECT - Ctx is on top for yield
: good-generator ( Ctx Int -- | Yield Int )
tuck # ( Int Ctx Int )
yield # ( Int Ctx resume_value )
...
;
Handling Weave Completion
strand.resume returns ( handle value has_more ). The boolean indicates whether the weave yielded (true) or completed (false):
: finite-generator ( Ctx Int -- | Yield Int )
dup 0 i.<= if
drop drop # Done - just return, weave ends
else
tuck yield # Yield current value
rot 1 i.- # Decrement
finite-generator
then
;
: main ( -- )
[ finite-generator ] strand.weave
3 strand.resume # ( handle 3 true )
drop drop # ( handle )
0 strand.resume # ( handle 2 true )
drop drop
0 strand.resume # ( handle 1 true )
drop drop
0 strand.resume # ( handle 0 false ) - weave completed!
if
"More values" io.write-line
else
drop "Weave finished" io.write-line
then
drop # drop handle
;
Resource Management
Weaves hold resources (channels internally). You must either:
- Resume to completion - Keep resuming until
has_moreis false - Cancel explicitly - Call
strand.weave-cancelto release resources
# BAD - resource leak!
[ infinite-generator ] strand.weave
10 strand.resume drop drop drop # Weave abandoned, resources leak
# GOOD - explicit cancellation
[ infinite-generator ] strand.weave
10 strand.resume drop drop
strand.weave-cancel # Clean up
The linter will warn about immediate drops of weave handles.
Type System Integration
The Yield effect appears in stack effect annotations:
: my-generator ( Ctx Int -- | Yield Int )
# ^^^^^^^^^^^ Yield effect
...
;
This tells the type checker that the word participates in generator semantics. The effect propagates through callers.
Advanced: Structured Data
Weaves work well with union types for rich producer/consumer protocols:
union SensorReading {
Reading { temp: Int, status: String }
}
: sensor-processor ( Ctx SensorReading -- | Yield SensorReading )
# Transform the reading
dup 0 variant.field-at # Get temp
classify-temp # Classify it
swap 1 variant.field-at # Get status
Make-Reading # Create new reading
swap yield # Yield result, get next reading
sensor-processor # Process next
;
Comparison to Other Languages
| Feature | Seq Weaves | Python Generators | JavaScript Generators |
|---|---|---|---|
| Bidirectional | Yes (yield/resume) | Yes (send()) | Yes (next(value)) |
| Context | Explicit stack threading | Implicit | Implicit |
| Concurrency | Built on strands/channels | Single-threaded | Single-threaded |
| Cancellation | strand.weave-cancel | Close iterator | return() |
| Type system | Yield effect tracked | Untyped | Untyped |
Backpressure
Weaves provide backpressure through the pull model: a producer cannot advance past yield until the consumer explicitly calls strand.resume. The producer is suspended at yield, not running ahead buffering values — it is physically blocked waiting for a resume signal.
This means the consumer controls the production rate with no additional coordination needed:
# Infinite data source - but only computes on demand
: data-source ( Ctx Int -- | Yield Int )
tuck yield # Block here until consumer is ready
rot 1 i.+
data-source
;
: slow-consumer ( WeaveCtx -- )
# Only pulls the next item when ready to process it
0 strand.resume # Producer runs exactly one step
drop # Process the value
# ... do slow work ...
slow-consumer
;
This is not buffered backpressure (as in Reactive Streams or Akka Streams, where a downstream signals demand counts). It is stricter: one resume produces exactly one yielded value. The producer cannot run ahead at all.
When to Use Weaves vs Strands
| Use Case | Mechanism |
|---|---|
| Independent concurrent tasks | strand.spawn |
| Producer/consumer with backpressure | Weaves |
| Request/response patterns | Weaves |
| Fire-and-forget parallelism | strand.spawn |
| Lazy sequences | Weaves |
| Stream transformations | Weaves |
See Also
- Concurrency - Strands and channels
- examples/weave/ - Working examples
Tail Call Optimization (TCO) Guide
Overview
This guide describes tail call optimization in seqc, the Seq compiler. TCO is a critical optimization for functional and recursive programming styles, allowing recursive functions to execute in constant stack space.
Motivation
The Problem
Without TCO, recursive functions consume stack space for each call:
: factorial ( Int -- Int )
dup 1 i.<= if
drop 1
else
dup 1 i.- factorial i.* # Each call adds a stack frame
then
;
Calling 1000 factorial would create 1000 stack frames, risking stack overflow.
Why TCO Matters for Seq
-
Concatenative languages favor recursion - Without built-in loop constructs, recursion is the natural way to express iteration in Seq
-
SeqLisp and embedded languages - Languages implemented in Seq (like SeqLisp) have recursive interpreters. Without TCO, both the interpreter recursion AND user program recursion compound
-
Coroutine stack limits - Strands have fixed stacks (128KB by default, configurable via
SEQ_STACK_SIZE). TCO reduces pressure on these stacks -
Differentiating feature - Many compiled languages lack guaranteed TCO. Making it a first-class feature positions Seq for functional programming use cases
Background: How TCO Works
Traditional Call
caller:
push return_address
push arguments
jump to callee
callee:
... do work ...
pop arguments
pop return_address
jump to return_address
Tail Call (Optimized)
When a call is the last thing before returning, we can reuse the current frame:
caller:
# Don't push return address - reuse caller's
overwrite arguments in place
jump to callee # callee will return directly to our caller
LLVM Support
Seq uses LLVM’s musttail calling convention — guaranteed TCO (compiler error
if the optimization is impossible):
%result = musttail call ptr @seq_bar(ptr %stack)
ret ptr %result
All Seq word functions share a uniform ptr -> ptr signature, which is ideal
for musttail: return value is directly from the call with no cleanup between.
Tail Position in Seq
A call is in tail position when it’s the last operation before the function returns. In Seq’s word-based model:
Tail position:
: foo ( -- )
setup-stuff
bar # Last word - tail position
;
NOT tail position:
: foo ( -- Int )
something
bar # Not tail - result used by i.+
i.+
;
Conditionals
Both branches must end in tail position for the call to be a tail call:
: factorial ( Int -- Int )
dup 1 i.<= if
drop 1 # Base case - not a call, that's fine
else
dup 1 i.-
factorial # Tail position in else branch
i.* # PROBLEM: i.* comes after factorial
then
;
This common pattern is NOT tail-recursive. To enable TCO, use accumulator style:
: factorial-acc ( Int Int -- Int )
over 1 i.<= if
nip # Return accumulator
else
swap dup 1 i.- swap
rot i.* # Multiply first
factorial-acc # NOW in tail position
then
;
: factorial ( Int -- Int )
1 factorial-acc
;
Match Expressions
Match expressions work the same as conditionals - each arm body is independently checked for tail position. If a match arm’s last statement is a recursive call, it gets TCO:
: process-list ( SexprList -- Int )
match
SNil ->
0 # Base case, no recursion
SCons { >head >tail } ->
swap do-something # Not tail position (head on stack)
process-list # Last statement - gets TCO ✓
end
;
Each arm is evaluated independently, so different arms can have different tail call behavior:
: eval-expr ( Expr -- Value )
match
Literal { >value } ->
# value on stack
# No recursion needed
BinOp { >left >op >right } ->
# Stack: ( left op right )
rot eval-expr # NOT tail - result used below
swap eval-expr
apply-op
Call { >func >args } ->
# Stack: ( func args )
eval-args
eval-expr # Last statement - gets TCO ✓
end
;
The key insight: TCO applies to the last statement in each arm body, regardless of how many statements precede it.
Mutual Recursion
Mutual recursion works correctly — each tail call reuses the caller’s frame regardless of which function is called:
: even? ( Int -- Bool )
dup 0 i.= if drop true else 1 i.- odd? then
;
: odd? ( Int -- Bool )
dup 0 i.= if drop false else 1 i.- even? then
;
1000000 even? runs in constant stack space despite bouncing between two words.
Design Decisions
-
TCO is always-on
- No opt-in required — compiler applies TCO whenever possible
- Rationale: Coroutine stacks are fixed-size (128KB default), recursion is idiomatic
- Trade-off: Shorter stack traces in errors (acceptable)
-
No
@tailrecannotation- Compiler silently optimizes when possible
- No warnings for non-tail-recursive code
- Rationale: Keep language simple, avoid annotation clutter
Appendix: LLVM IR Examples
Before TCO
define ptr @seq_factorial(ptr %stack) {
entry:
; ... check n <= 1 ...
br i1 %cond, label %base, label %recurse
base:
; return 1
%result1 = call ptr @patch_seq_push_int(ptr %stack2, i64 1)
ret ptr %result1
recurse:
; recursive case
%n_minus_1 = call ptr @patch_seq_subtract(ptr %stack3)
%rec_result = call ptr @seq_factorial(ptr %n_minus_1) ; NOT tail call
%final = call ptr @patch_seq_multiply(ptr %rec_result)
ret ptr %final
}
After TCO (Accumulator Style)
define ptr @seq_factorial_acc(ptr %stack) {
entry:
; ... check n <= 1 ...
br i1 %cond, label %base, label %recurse
base:
; return accumulator (already on stack)
ret ptr %stack2
recurse:
; compute new acc, new n
%new_stack = call ptr @patch_seq_multiply(ptr %stack3)
%prepared = call ptr @patch_seq_subtract(ptr %new_stack)
%result = musttail call ptr @seq_factorial_acc(ptr %prepared)
ret ptr %result
}
References
Foreign Function Interface (FFI) Guide
Overview
Seq’s FFI system enables calling external C libraries from Seq programs. FFI is purely a compiler/linker concern - the runtime remains free of external dependencies, preserving Seq’s minimal footprint.
Quick Start
Built-in FFI: libedit
The compiler includes a BSD-licensed libedit binding for readline-style input:
include ffi:libedit
: main ( -- Int )
"prompt> " readline
"You entered: " swap string.concat io.write-line
0
;
Build with:
seqc build myprogram.seq -o myprogram
External FFI: SQLite
For libraries not bundled with the compiler, use --ffi-manifest:
include ffi:sqlite
: main ( -- Int )
":memory:" db-open drop
"CREATE TABLE test (id INT)" db-exec drop
db-close drop
0
;
Build with:
seqc --ffi-manifest sqlite.toml myprogram.seq -o myprogram
Include Syntax
FFI bindings are accessed via the ffi: prefix in include statements:
include ffi:libedit # Built-in manifest (ships with compiler)
include ffi:sqlite # Must provide --ffi-manifest sqlite.toml
The library name after ffi: must match the name field in the manifest.
Writing FFI Manifests
FFI bindings are declared in TOML files. Here’s a complete example:
[[library]]
name = "mylib"
link = "mylib" # Passed to linker as -lmylib
[[library.function]]
c_name = "my_function" # C function name
seq_name = "my-func" # Seq word name
stack_effect = "( Int String -- Int )"
args = [
{ type = "int", pass = "int" },
{ type = "string", pass = "c_string" }
]
[library.function.return]
type = "int"
Type Mappings
| Manifest Type | C Type | Seq Type | Notes |
|---|---|---|---|
int | int/long | Int | 64-bit on Seq side |
string | char* | String | Null-terminated |
ptr | void* | Int | Raw pointer as integer |
void | void | (nothing) | No return value |
Pass Modes
The pass field controls how arguments are passed to C:
| Mode | Description |
|---|---|
c_string | Convert Seq String to null-terminated char* |
ptr | Pass raw pointer value (Int on stack) |
int | Pass as C integer |
by_ref | Allocate storage, pass pointer (for out params) |
Memory Ownership
The ownership field on returns controls memory management:
| Mode | Description | Codegen |
|---|---|---|
caller_frees | C malloc’d it, we must free | Generates free() call |
static | Library owns memory, don’t free | Just copy, no free |
borrowed | Only valid during call | Copy immediately |
Advanced Features
Out Parameters (by_ref)
Some C functions return values via pointer parameters (out params). Use by_ref
pass mode:
[[library.function]]
c_name = "sqlite3_open"
seq_name = "db-open"
stack_effect = "( String -- Int Int )" # db handle + return code
args = [
{ type = "string", pass = "c_string" },
{ type = "ptr", pass = "by_ref" } # Out parameter
]
[library.function.return]
type = "int"
For by_ref arguments:
- Compiler allocates local storage
- Passes pointer to that storage to C function
- After call, reads value and pushes onto stack
Important: The by_ref value is an opaque handle owned by the C library.
You must:
- Only pass it to functions from the same library
- Never attempt to free it manually
- Always use the library’s cleanup function (e.g.,
db-close)
Fixed Value Arguments
For arguments that should always be a constant (like NULL callbacks):
args = [
{ type = "ptr", pass = "ptr" },
{ type = "string", pass = "c_string" },
{ type = "ptr", value = "null" }, # Always passes NULL
{ type = "ptr", value = "null" },
{ type = "ptr", value = "null" }
]
Fixed value arguments don’t consume stack values - they’re compiled as constants.
Supported values: null, NULL, or integer literals.
Multiple Manifests
You can load multiple FFI manifests:
seqc --ffi-manifest db.toml --ffi-manifest crypto.toml program.seq -o program
Safety Model
FFI is inherently unsafe - you’re calling into C code that can do anything. Seq’s approach:
- Opt-in boundary: Using
include ffi:*is the explicit safety boundary - Stack effects enforced: Type checker validates declared effects
- Memory managed by codegen: Ownership annotations prevent leaks
- Linker flag validation: Only safe characters allowed in link names
If you don’t use FFI, your Seq program has full memory safety.
Security Considerations
- Trust your manifests: Malicious manifests could link arbitrary libraries
- Validate external manifests: Review manifests before using
--ffi-manifest - Linker injection prevented: Link names can only contain alphanumeric, dash, underscore, and dot characters
Built-in Libraries
ffi:libedit
BSD-licensed readline alternative. Provides:
| Word | Stack Effect | Description |
|---|---|---|
readline | ( String -- String ) | Read line with prompt |
add-history | ( String -- ) | Add line to history |
read-history | ( String -- Int ) | Load history from file |
write-history | ( String -- Int ) | Save history to file |
Examples
Example: Interactive REPL
include ffi:libedit
: repl ( -- )
"seq> " readline
dup string.length 0 i.> if
dup add-history
process-input # Your processing here
repl
else
drop
then
;
: main ( -- Int )
"Welcome to Seq REPL" io.write-line
repl
0
;
Example: Persistent History
include ffi:libedit
: repl ( -- )
"seq> " readline
dup string.length 0 i.> if
dup add-history
process-input
repl
else
drop
then
;
: main ( -- Int )
# Load history at startup (ignore error if file doesn't exist)
"/tmp/.myapp_history" read-history drop
"Welcome to Seq REPL" io.write-line
repl
# Save history on exit
"/tmp/.myapp_history" write-history drop
0
;
Note: File paths are passed directly to the C library. Shell expansions
like ~ are not performed. Use os.home-dir and os.path-join to build
paths from the home directory rather than hardcoding /tmp paths.
Example: SQLite Database
See examples/ffi/sqlite/ for a complete SQLite example demonstrating:
by_refout parameters for database handles- Fixed
nullvalues for unused callbacks - Proper handle cleanup with
db-close
Troubleshooting
“Unknown word: readline”
Ensure you have include ffi:libedit at the top of your file.
“Unknown FFI library: sqlite”
You need to provide the manifest: --ffi-manifest path/to/sqlite.toml
Linker errors
Install the C library’s development package:
- macOS:
brew install <library> - Ubuntu:
apt install lib<library>-dev - Fedora:
dnf install <library>-devel
“Invalid character in linker flag”
Link names can only contain: a-z, A-Z, 0-9, -, _, .
This prevents command injection attacks via malicious manifests.
Design Notes
Callbacks (Shelved)
FFI callbacks (C functions calling back into Seq) were explored but shelved for now.
Why shelved:
- Most useful callback patterns (qsort comparators, iteration handlers) pass pointers to the callback, requiring low-level memory operations to interpret them
- Those low-level operations (
ptr-read-i64, etc.) are too invasive for Seq’s design - Many C APIs have non-callback alternatives (e.g., SQLite’s prepared statement API works without callbacks)
Callbacks may be revisited when there’s a concrete use case that justifies the complexity.
See ROADMAP.md for the full FFI roadmap.
Observability Guide
Seq provides three layers of runtime observability for compiled programs, from zero-cost live inspection to compile-time instrumentation. All features are gated behind the diagnostics Cargo feature (enabled by default).
Quick Reference
| Tool | Trigger | Overhead | What You Get |
|---|---|---|---|
| SIGQUIT dump | kill -3 <pid> | Zero until triggered | Live snapshot: strands, memory, registry |
| Watchdog | SEQ_WATCHDOG_SECS=N | Near-zero (periodic scan) | Alerts when strands run too long |
| At-exit report | SEQ_REPORT=1 | Near-zero | Wall clock, strands, memory, channels |
| Instrumentation | --instrument + SEQ_REPORT=words | ~1 atomic per word call | Per-word call counts |
SIGQUIT Diagnostic Dump
Send SIGQUIT to a running Seq process to dump runtime statistics to stderr without stopping it. This works like a JVM thread dump.
kill -3 <pid>
Output includes:
- Strand statistics — active, total spawned, total completed, peak (high-water mark)
- Active strand details — strand IDs and how long each has been running
- Memory statistics — arena bytes across all threads
- Warnings — lost strands (panic/abort), registry overflow
Example output:
=== Seq Runtime Diagnostics ===
Timestamp: SystemTime { ... }
[Strands]
Active: 3
Spawned: 150 (total)
Completed: 147 (total)
Peak: 12 (high-water mark)
[Active Strand Details]
Registry capacity: 1024 slots
3 strand(s) tracked:
[ 1] Strand #1 running for 42s
[ 2] Strand #148 running for 3s
[ 3] Strand #150 running for 0s
[Memory]
Tracked threads: 4
Arena bytes: 2.50 MB (across all threads)
=== End Diagnostics ===
The signal handler runs on a dedicated thread using signal-hook’s iterator API, so all I/O operations happen outside signal context.
Watchdog
The watchdog detects strands that run too long without yielding, helping catch infinite loops and runaway computation in production.
Configuration
| Variable | Default | Description |
|---|---|---|
SEQ_WATCHDOG_SECS | 0 (disabled) | Threshold in seconds for “stuck” strand |
SEQ_WATCHDOG_INTERVAL | 5 | How often to check (seconds) |
SEQ_WATCHDOG_ACTION | warn | What to do: warn (dump diagnostics) or exit (terminate) |
Usage
# Warn if any strand runs longer than 30 seconds
SEQ_WATCHDOG_SECS=30 ./my-program
# Check every 10 seconds instead of every 5
SEQ_WATCHDOG_SECS=30 SEQ_WATCHDOG_INTERVAL=10 ./my-program
# Exit the process if a strand is stuck (for unattended services)
SEQ_WATCHDOG_SECS=60 SEQ_WATCHDOG_ACTION=exit ./my-program
When the watchdog triggers with warn action, it dumps the same diagnostics as kill -3. With exit action, it dumps diagnostics then terminates the process.
The watchdog runs on a dedicated thread and scans the strand registry periodically. It piggybacks on existing strand tracking infrastructure, adding no overhead to the hot path.
At-Exit Report (SEQ_REPORT)
Batch programs exit before anyone can send kill -3. The at-exit report dumps KPIs automatically when the program finishes, controlled by the SEQ_REPORT environment variable.
Configuration
| Value | Format | Destination |
|---|---|---|
unset or 0 | — | No report (zero cost) |
1 | Human-readable | stderr |
json | JSON | stderr |
json:/path/to/file | JSON | File |
words | Human-readable + word counts | stderr (requires --instrument) |
Usage
# Human-readable report on stderr
SEQ_REPORT=1 ./my-program
# JSON report on stderr (pipe to jq, etc.)
SEQ_REPORT=json ./my-program 2>report.json
# JSON report written directly to a file
SEQ_REPORT=json:/tmp/report.json ./my-program
# Human report with per-word call counts (requires --instrument)
SEQ_REPORT=words ./my-program
Example Output
=== SEQ REPORT ===
Wall clock: 127 ms
Strands spawned: 42
Strands done: 42
Peak strands: 8
Worker threads: 4
Arena current: 0 bytes
Arena peak: 524288 bytes
Messages sent: 100
Messages recv: 100
==================
Metrics
| Metric | Description |
|---|---|
| Wall clock | Total time from scheduler init to program exit |
| Strands spawned | Total number of strands created |
| Strands done | Total number of strands that completed |
| Peak strands | Maximum concurrent strands at any point |
| Worker threads | Number of OS threads with active arenas |
| Arena current | Current arena memory across all threads |
| Arena peak | Peak arena memory across all threads |
| Messages sent | Total channel send operations |
| Messages recv | Total channel receive operations |
| Word counts | Per-word call counts (only with --instrument) |
Instrumentation (--instrument)
The --instrument compiler flag bakes per-word atomic counters into the binary. Each time a word is called, its counter increments. This is useful for profiling which words are hot paths.
Usage
# Compile with instrumentation
seqc build --instrument my-program.seq
# Run with word-count report
SEQ_REPORT=words ./my-program
How It Works
When --instrument is passed:
- The compiler emits a global array of
i64counters (one per word) - Each word’s entry point gets a single
atomicrmw add monotonicinstruction - At program startup, the counter array and word name table are registered with the runtime
- At exit,
SEQ_REPORTreads the counters and includes them in the report
Overhead: One atomic increment per word call (lock xadd on x86). This is the cheapest possible atomic operation. When --instrument is not passed, no counters or atomics are emitted — zero cost.
Tail recursion: A word that tail-recurses 1M times will show 1M calls. This accurately reflects work done, since each tail call re-enters the function.
Example Output
With SEQ_REPORT=words:
=== SEQ REPORT ===
Wall clock: 42 ms
Strands spawned: 1
...
--- Word Call Counts ---
main 1
process-item 1000
helper 5000
recursive-worker 1000000
==================
Word counts are sorted by call count (descending), making it easy to spot hot words.
Disabling Diagnostics
All observability features depend on the diagnostics Cargo feature. Disable it to eliminate strand registry operations, signal handler setup, and SystemTime::now() syscalls on every spawn:
# In Cargo.toml
seq-runtime = { version = "...", default-features = false }
When disabled:
kill -3has no effect (no signal handler installed)- Watchdog is not compiled
SEQ_REPORTstill works for basic metrics (wall clock, memory) but strand registry data is unavailable
In practice, benchmarking shows the diagnostics overhead is negligible compared to May’s coroutine spawn syscalls. The feature is primarily useful for production deployments where live debugging capability is needed.
Environment Variables Summary
| Variable | Default | Description |
|---|---|---|
SEQ_REPORT | unset (disabled) | At-exit KPI report format and destination |
SEQ_WATCHDOG_SECS | 0 (disabled) | Stuck-strand detection threshold (seconds) |
SEQ_WATCHDOG_INTERVAL | 5 | Watchdog check frequency (seconds) |
SEQ_WATCHDOG_ACTION | warn | Watchdog action: warn or exit |
See Also
- Architecture — runtime configuration and concurrency design
- Testing Guide — writing and running Seq tests
Binary Footprint
Compiled Seq programs include a runtime — green threads, channels, an arena allocator, signal handling, panic backtrace machinery. This document explains what’s in the binary you ship, why each piece is there, and which size choices you can adjust.
Production binary size
The size that matters is the stripped, release-optimized binary you
actually ship — for examples/basics/hello-world.seq. These figures
are regenerated by just footprint and recorded in
footprint-measurements.md:
| Platform | Rust | Seq | Go | Measured |
|---|---|---|---|---|
| Linux x86_64 | 305 KB | 732 KB | 1.50 MB | 2026-05-26 · rustc 1.95.0 · seqc 7.5.6 · go 1.25.9 |
| Darwin arm64 | 303 KB | 1.38 MB | 1.66 MB | 2026-05-26 · rustc 1.95.0 · seqc 7.5.6 · go 1.26.3 |
Seq’s shipped binary runs from a few hundred KB up to ~1.4 MB depending on platform, and it lands below Go — another runtime-bearing language — for perspective. Bare Rust is the floor: the cost of being a compiled native binary at all.
The default seqc build output is much larger (e.g. ~6.7 MB on
Linux) because it embeds DWARF debug info. That is metadata you strip
before shipping, not code — see the DWARF
tradeoff below.
Programs that don’t reference HTTP, regex, crypto, or compression do not pay for those subsystems’ code. The runtime archive contains all of them; the link removes what’s unreferenced. See Batteries Included.
What’s in the binary
Excluding DWARF and the symbol table, the stripped code splits into
four groups. The sizes below are for Linux x86_64, where the stripped
hello-world binary is ~730K; a macOS arm64 binary is larger (~1.4 MB)
because its toolchain and strip retain more, but the categories are
the same:
Backtrace symbolizer (~250K)
Rust’s std::panic machinery includes an in-process DWARF parser
(gimli, addr2line, rustc_demangle, plus a deflate decompressor
for compressed DWARF sections). When a Seq program panics, this is
what reads the binary’s own debug info to print a .seq:line trace.
A pure Rust --release binary often skips this — Seq keeps it so
panics from generated code can point back at the source.
This category disappears entirely under panic = "abort", at the
cost of useful panic backtraces.
Seq runtime essentials (~150–250K)
The always-on infrastructure that defines a Seq program:
may— green-thread scheduler- arena allocator
- channels (via
crossbeam) - signal handlers (SIGQUIT diagnostic dump, watchdog)
- the at-exit
SEQ_REPORTKPI emitter - sync primitives (
parking_lot) used by the above
These are reachable from seq_main itself, so they stay in every
binary. They are what makes a Seq program a Seq program rather than a
thin wrapper around printf.
Float formatting (~30–50K)
std::fmt’s float-to-string machinery
(core::num::flt2dec::dragon::*, the POW10_SIGNIFICANDS table).
Pulled in via std::fmt’s monomorphized formatters. Present even in
programs that don’t print floats, because the formatter trait
machinery touches it.
Rust standard library baseline (~200–300K)
The allocator, panic infrastructure (the core parts), and common
slice/string operations that any Rust binary carries. Not Seq-specific
— this is the cost of being a Rust-built program at all.
DWARF and the backtrace tradeoff
seqc build passes -g to clang on every build, embedding DWARF
debug sections. On Linux that DWARF lives inside the binary and
dominates the default artifact — about 6 MB of the ~6.7 MB hello-world
build. On macOS the debug info goes into a separate .dSYM bundle
instead, so the default binary is already small (~1.7 MB) and
stripping it barely changes the size.
The DWARF is what lets a panic from generated Seq code resolve back
to a source line in your .seq file rather than a hex address. It’s
metadata only — no runtime cost — but it is large.
If a deployment target is size-constrained and you don’t need
.seq:line resolution in panic traces, strip removes the symbol
table (and, on Linux, the embedded DWARF):
seqc build prog.seq -o prog
strip prog
A strip’d binary still runs and still panics; it just prints
addresses where it would otherwise print source locations. There is
no recommended default here — the tradeoff between debuggability and
artifact size depends on what you’re shipping.
Programs that go further and don’t want backtrace machinery at all
can configure the workspace’s release profile with
panic = "abort". That removes the ~250K backtrace symbolizer
category in addition to the DWARF data. Panics then terminate the
process without a backtrace.
Inspecting your own binaries
Standard Linux binutils are enough for a quick breakdown:
size -A ./prog | sort -k2 -rn | head -20
nm --print-size --size-sort --reverse-sort ./prog | head -30
bloaty (where available) gives a much better view, especially the
compileunits mode that buckets bytes by source file:
bloaty ./prog -d compileunits -n 25
On macOS the equivalents are size -m, nm, and bloaty (via
Homebrew). The shape of a Seq macOS binary is similar to Linux modulo
a small ring-crate residue that survives ld64’s -dead_strip —
it’s a few KB of unused AES and SHA assembly kernels and is inert at
runtime.
Seq Architecture
Seq is a concatenative (stack-based) programming language with static typing, row polymorphism, and green-thread concurrency.
Core Design Principles
-
Values are independent of stack structure - A value can be duplicated, shuffled, or stored without corruption. The stack is a contiguous array of 8-byte tagged pointer values.
-
Functional style - Operations produce new values rather than mutating.
list.pushreturns a new list, it doesn’t modify the original. -
Static typing with inference - Stack effects are checked at compile time. Row polymorphism (
..rest) allows generic stack-polymorphic functions. -
Concatenative composition - Functions compose by juxtaposition.
f gmeans “do f, then g”. No explicit argument passing.
Project Structure
Crate graph
Arrows point from a crate to the crates it depends on at compile time.
graph TD
core[seq-core<br/><i>values, stack, arena</i>]
runtime[seq-runtime<br/><i>scheduler, I/O, stdlib ops</i>]
compiler[seq-compiler<br/><i>parser, typechecker, codegen</i>]
lsp[seq-lsp<br/><i>language server</i>]
repl[seq-repl<br/><i>seqr TUI REPL</i>]
vim[vim-line<br/><i>vim-motion line editor</i>]
runtime --> core
compiler --> runtime
lsp --> compiler
repl --> compiler
repl --> vim
seq-repl launches seq-lsp as a subprocess for completions — that’s a
runtime dependency, not a compile-time one, so it doesn’t appear in the
graph above.
Layout
patch-seq/
├── crates/
│ ├── core/ # Rust - seq-core foundational types
│ │ └── src/
│ │ ├── value.rs # Value enum (Int, Float, String, Variant, Channel, etc.)
│ │ ├── tagged_stack.rs # Contiguous 8-byte tagged pointer stack
│ │ ├── arena.rs # Thread-local bump allocator (bumpalo)
│ │ ├── seqstring.rs # Reference-counted string type
│ │ └── memory_stats.rs # Memory tracking
│ ├── compiler/ # Rust - seqc compiler
│ │ ├── src/
│ │ │ ├── ast.rs # AST, union definitions, match expressions
│ │ │ ├── parser.rs # Forth-style parser
│ │ │ ├── typechecker.rs # Row-polymorphic type inference
│ │ │ ├── builtins.rs # Type signatures for builtins
│ │ │ ├── codegen/ # LLVM IR generation (inline ops, control flow, FFI)
│ │ │ ├── unification.rs # Type unification
│ │ │ ├── ffi.rs # FFI manifest parser and codegen
│ │ │ ├── lint.rs # Syntactic lint engine
│ │ │ └── capture_analysis.rs # Closure capture analysis
│ │ └── stdlib/ # Seq standard library (.seq files)
│ ├── runtime/ # Rust - libseq_runtime.a
│ │ └── src/
│ │ ├── arithmetic.rs # Math operations
│ │ ├── io.rs # I/O operations
│ │ ├── scheduler.rs # May coroutine scheduler
│ │ ├── channel.rs # CSP-style channels
│ │ ├── weave.rs # Generator/coroutine weaves
│ │ ├── closures.rs # Closure invocation
│ │ ├── string_ops.rs # String operations
│ │ ├── variant_ops.rs # Variant operations
│ │ ├── list_ops.rs # List operations
│ │ ├── map_ops.rs # Map operations
│ │ ├── file.rs # File I/O
│ │ ├── dns.rs # DNS worker pool (may-aware)
│ │ ├── tcp.rs # TCP (may-aware: listen/accept/connect/read/write)
│ │ ├── udp.rs # UDP (bind/send-to/recv-from)
│ │ ├── tls.rs # TLS client (rustls + ring)
│ │ ├── http_client.rs # HTTP/1.1 client (hand-rolled over may + rustls)
│ │ └── ... # + diagnostics, watchdog, signal, etc.
│ ├── lsp/ # Rust - seq-lsp language server
│ ├── repl/ # Rust - seqr TUI REPL (ratatui-based)
│ └── vim-line/ # Rust - vim-motion line editor library
├── examples/ # Example programs
└── docs/ # Documentation
Value Types
Values are defined in core/src/value.rs:
#![allow(unused)]
fn main() {
#[repr(C)]
pub enum Value {
Int(i64), // Discriminant 0
Float(f64), // Discriminant 1
Bool(bool), // Discriminant 2
String(SeqString), // Discriminant 3 - reference-counted
Symbol(SeqString), // Discriminant 4 - symbolic identifiers (:foo)
Variant(Arc<VariantData>), // Discriminant 5 - Arc for O(1) cloning
Map(Box<HashMap<...>>), // Discriminant 6 - key-value dictionary
Quotation { wrapper, impl_ },// Discriminant 7 - function pointers (dual calling conventions)
Closure { fn_ptr, env }, // Discriminant 8 - function + Arc-shared captured values
Channel(Arc<ChannelData>), // Discriminant 9 - MPMC sender/receiver pair
WeaveCtx { yield_chan, resume_chan }, // Discriminant 10 - generator coroutine context
}
pub struct VariantData {
pub tag: SeqString, // Symbol-based tag for dynamic variant construction
pub fields: Box<[Value]>,
}
}
The Rust Value enum is 40 bytes (used for storage and FFI), but on the stack
values are encoded as 8-byte tagged pointers (StackValue = u64). Integers are
stored inline (shifted left 1, low bit set); all other types are heap-allocated
behind 8-byte-aligned pointers with a tag in the low 3 bits.
Stack Model
The stack is a contiguous array of 8-byte tagged pointer values (StackValue),
defined in core/src/tagged_stack.rs:
#![allow(unused)]
fn main() {
pub type StackValue = u64; // 8 bytes — tagged pointer encoding
pub struct TaggedStack {
pub base: *mut StackValue, // Heap-allocated array
pub sp: usize, // Stack pointer (next free slot)
pub capacity: usize, // Current allocation size
}
}
This design enables:
- Inline LLVM IR operations - Integer arithmetic, comparisons, and boolean ops execute directly in generated code without FFI calls
- Cache-friendly layout - Contiguous memory access patterns
- O(1) stack operations - No linked-list traversal or allocation per push/pop
Key operations:
push(stack, value) -> stack'- Add value to toppop(stack) -> (stack', value)- Remove and return topdup,drop,swap,rot,over,pick,roll- Stack shuffling
Type System
Stack Effects
Every function has a stack effect: ( input -- output )
: add ( Int Int -- Int ) ... ;
: dup ( T -- T T ) ... ;
: swap ( A B -- B A ) ... ;
Row Polymorphism
The ..rest syntax captures “everything else on the stack”:
: my-dup ( ..rest T -- ..rest T T )
dup
;
This means my-dup works regardless of what’s below the top value.
Type Inference
Types are inferred at compile time. The type checker:
- Assigns fresh type variables to unknowns
- Collects constraints from operations
- Unifies constraints to solve for types
- Reports errors if unification fails
Variants (Algebraic Data Types)
Variants are tagged unions with N fields:
# Create using low-level constructors (Symbol tag + N fields)
42 "hello" :MyTag variant.make-2 # Tag :MyTag with fields [42, "hello"]
:Empty variant.make-0 # Tag :Empty with no fields
# Access
variant.tag # ( Variant -- Symbol )
variant.field-count # ( Variant -- Int )
0 variant.field-at # ( Variant Int -- Value )
# Functional append (for building dynamic collections)
value variant.append # ( Variant Value -- Variant' )
In practice, union definitions generate typed Make-VariantName constructors
and match expressions for safe, named field access. The low-level
variant.make-N API is used by the stdlib for dynamic variant construction.
JSON Tags
The JSON library (stdlib/json.seq) uses Symbol-based variant tags:
:JsonNull(0 fields):JsonBool(1 field: Int, 0 or 1):JsonNumber(1 field: Float):JsonString(1 field: String):JsonArray(N fields: elements):JsonObject(2N fields: key1 val1 key2 val2 …)
Control Flow
Conditionals
condition if
# then-branch
else
# else-branch
then
The condition must be a Bool value (produced by comparisons, logical operations, or true/false literals). The type checker enforces this — passing an Int where a Bool is expected is a compile error.
Both branches must have the same stack effect.
Recursion
Words can call themselves:
: factorial ( Int -- Int )
dup 1 i.<= if
drop 1
else
dup 1 i.- factorial i.*
then
;
Tail calls are optimized via LLVM’s musttail - deep recursion won’t overflow.
See docs/TCO_GUIDE.md for details.
Concurrency (Strands)
Seq uses May coroutines for cooperative concurrency:
# Spawn a strand (green thread)
[ ... code ... ] strand.spawn # ( Quotation -- Int ) returns strand ID
# Channels for communication
chan.make # ( -- Int ) returns channel ID
value chan-id chan.send # ( Value Int -- )
chan-id chan.receive # ( Int -- Value )
# Cooperative yield
chan.yield # Let other strands run
Note: Current implementation has known issues with heavy concurrent workloads.
Why May (Not Tokio)
Seq uses the may crate for stackful coroutines (fibers) rather than Rust’s
async/await ecosystem (Tokio, async-std). Key reasons:
-
No async coloring - With may, a Seq
strand.spawncreates a fiber that can call blocking operations (channel send/receive, I/O) and implicitly yield. Noasync/awaitsyntax pollution spreading through the call stack. -
Erlang/Go mental model - Fits Seq’s concatenative style naturally.
[ code ] strand.spawncreates a lightweight fiber. Thousands can run concurrently with message passing via channels. This matches how Go goroutines and Erlang processes work - simple synchronous-looking code that yields cooperatively. -
Simpler FFI - LLVM-generated code calls synchronous Rust functions. No async runtime ceremony or
Futureplumbing required. -
M:N scheduling - Like Tokio, may multiplexes many fibers across a small thread pool. We get lightweight concurrency without one OS thread per fiber.
M:N Threading: Best of Both Worlds
Early concurrency implementations had to choose between two models:
| Model | Mapping | Pros | Cons |
|---|---|---|---|
| Green threads (early Java) | M:1 | Cheap, fast switch | Single CPU only |
| Native OS threads | 1:1 | Multi-CPU | Expensive (~1MB stack), slow switch |
May provides M:N scheduling - many lightweight coroutines distributed across all CPU cores:
- Lightweight - Strands use a fixed 128KB stack (configurable via
SEQ_STACK_SIZE), not 1MB - Multi-core - Work-stealing scheduler spreads load across all CPUs
- Fast context switch - Cooperative yield, no kernel involvement
- No blocking - When one strand waits on I/O, others run on that core
This means Seq programs get the programming simplicity of green threads (spawn thousands of concurrent tasks cheaply) with the performance of native threads (utilizing all available CPUs). Write sequential code that scales.
Tradeoff: libc for stdout
May’s implicit yields can occur inside any function call. Rust’s stdout()
uses an internal RefCell that panics if one coroutine holds a borrow, yields,
and another coroutine on the same OS thread tries to borrow. This is because
RefCell tracks borrows per-thread, not per-coroutine.
We bypass this by calling libc::write(1, ...) directly, protected by
may::sync::Mutex (which yields the coroutine when contended rather than
blocking the OS thread). This is a small price for may’s cleaner programming
model.
See runtime/src/io.rs for the implementation.
Runtime Configuration
The scheduler can be tuned via environment variables:
| Variable | Default | Description |
|---|---|---|
SEQ_STACK_SIZE | 131072 (128KB) | Coroutine stack size in bytes |
SEQ_POOL_CAPACITY | 10000 | Cached coroutine pool size |
SEQ_WATCHDOG_SECS | 0 (disabled) | Threshold for “stuck strand” detection |
SEQ_WATCHDOG_INTERVAL | 5 | Watchdog check frequency (seconds) |
SEQ_WATCHDOG_ACTION | warn | Action on stuck strand: warn or exit |
SEQ_REPORT | unset (disabled) | At-exit KPI report: 1 (human/stderr), json (JSON/stderr), json:/path (JSON to file), words (human + per-word counts) |
Diagnostics Feature
The runtime includes optional diagnostics for production debugging:
- Strand registry - Tracks active strands with spawn timestamps
- SIGQUIT handler - Dumps runtime stats on
kill -3 <pid> - Watchdog - Detects strands running longer than threshold
- At-exit report -
SEQ_REPORTenv var dumps KPIs (wall clock, strands, memory, channels) when the program exits. Compile withseqc build --instrumentto include per-word call counts
These are controlled by the diagnostics Cargo feature (enabled by default):
# In Cargo.toml - disable for minimal overhead
seq-runtime = { version = "...", default-features = false }
When disabled, the runtime skips strand registry operations and signal handler
setup, eliminating ~O(1024) scans and SystemTime::now() syscalls per spawn.
Note: Benchmarking shows the diagnostics overhead is negligible compared to
May’s coroutine spawn syscalls. The feature is primarily useful for production
deployments where kill -3 debugging is needed.
Memory Management
The tagged pointer stack eliminates per-operation allocation overhead for integers (inline in the 8-byte slot) and provides O(1) push/pop for all types. The stack is a single contiguous array that grows/shrinks by adjusting the stack pointer. Heap types (String, Variant, Closure) use reference counting for correct cleanup.
Arena Allocation
Problem: String operations (concatenation, substring, parsing) create many short-lived intermediate strings. Reference counting each one adds overhead.
Solution: Thread-local bump allocator (via bumpalo crate).
- Allocation is a pointer bump (~5ns vs ~100ns for malloc)
- No individual deallocation - entire arena reset at once
- Reset when strand exits or when arena exceeds 10MB threshold
- 20x faster than global allocator for allocation-heavy workloads
Thread-local vs strand-local: The arena is per-OS-thread, not per-strand. If may migrates a strand between threads (rare), some memory stays in the old arena until another strand on that thread exits. This is acceptable - the common case (strand stays on one thread) is fast, and the 10MB auto-reset prevents unbounded growth in the rare migration case.
See core/src/arena.rs for implementation.
Reference Counting
SeqString uses atomic reference counting for strings that escape the arena:
- Strings passed through channels are cloned to the global allocator
- Strings stored in closures use reference counting
- Arena strings are fast for local computation; refcounted strings are safe for sharing across strands
This hybrid approach gives us arena speed for the common case (local string manipulation) and correctness for cross-strand communication.
Inline LLVM IR vs FFI
The tagged stack design enables inline code generation for performance-critical operations. Integer arithmetic, comparisons, and boolean operations execute directly in generated LLVM IR without FFI calls to the runtime:
; Example: inline integer add
%a = load i64, ptr %slot1_ptr
%b = load i64, ptr %slot1_ptr.1
%result = add i64 %a, %b
store i64 %result, ptr %slot1_ptr
Complex operations (string handling, variants, closures) still call into the Rust runtime for memory safety and code maintainability.
The main Word and Exit Codes
Every executable Seq program defines a main word with one of two
allowed signatures:
: main ( -- ) # void main: process exits with code 0
: main ( -- Int ) # int main: returned Int becomes the process exit code
The compiler rejects any other shape (extra inputs, multiple outputs, non-Int output) at type-check time.
For main ( -- Int ), the top-of-stack Int at the end of main is
written to a runtime global by the generated seq_main, and the C-level
main function returns it as the process exit code (truncated to i32).
This means Seq programs compose naturally with shell tooling: &&,
||, $?, set -e, CI gates, and test harnesses all work as
expected.
: main ( -- Int )
do-work
0 # success
;
./myprog && echo "succeeded" || echo "failed with $?"
Script mode (seqc script.seq) inherits this behavior automatically
because it just execs the compiled binary.
Compilation Pipeline
- Parse - Tokenize and build AST (
parser.rs) - Type Check - Infer and verify stack effects (
typechecker.rs) - Codegen - Emit LLVM IR (
codegen.rs) - Link - LLVM compiles IR, links with
libseq_runtime.a
# Compile a .seq file
./target/release/seqc build myprogram.seq -o myprogram
# Keep IR for inspection
./target/release/seqc build myprogram.seq -o myprogram --keep-ir
cat myprogram.ll
Standard Library
Include System
include std:json # Loads stdlib/json.seq
include foo # Loads ./foo.seq
JSON (stdlib/json.seq)
Parsing:
include std:json
"[1, 2, 3]" json-parse # ( String -- JsonValue Bool )
Serialization:
json-value json-serialize # ( JsonValue -- String )
Functional builders:
json-empty-array 1 int->float json-number array-with 2 int->float json-number array-with
# Result: [1, 2]
json-empty-object "name" json-string "John" json-string obj-with
# Result: {"name": "John"}
Current Limitations
- No loop keywords - Use recursion with TCO (tail call optimization is guaranteed)
- Serialization size limits - Arrays > 3 elements, objects > 2 pairs show as
[...]/{...} - roll type checking -
3 rollworks at runtime but type checker can’t fully verify
Building and CI
The justfile is the source of truth for all build, test, and lint
operations. Forgejo Actions calls these recipes directly — there is no
duplication between local development and CI.
just build # build runtime, compiler, LSP
just test # run all unit tests
just lint # clippy with warnings as errors
just ci # everything CI runs: fmt-check, lint, test, build,
# examples, integration tests, seq lint
Run just ci before pushing — it’s the same pipeline that runs in
Forgejo Actions and will catch formatting, clippy, test, and lint
failures locally.
Toolchain pinning
The Rust toolchain is pinned in two places that must always agree:
rust-toolchain.toml— used byrustupfor local development.forgejo/workflows/*.yml— every workflow that callsdtolnay/rust-toolchain@masterdeclares the same explicittoolchain:input (ci-linux.yml,release.yml,bench.yml,bench-calibrate.yml)
All cargo commands in the ci pipeline use --locked so a stale
Cargo.lock is a build failure rather than a silent re-resolve.
Running Programs
# Compile and run
./target/release/seqc build myprogram.seq -o /tmp/prog
/tmp/prog
# With arguments
/tmp/prog arg1 arg2
Seq Glossary
A guide to concepts in Seq and concatenative programming. Written for working programmers who may not have encountered these ideas in traditional web/enterprise development.
ADT (Algebraic Data Type)
A way to define custom types by combining simpler types. “Algebraic” because you build types using two operations:
- Sum types (“or”): A value is one of several variants. Like an enum.
- Product types (“and”): A value contains multiple fields together. Like a struct.
In Seq, you define ADTs with union:
# Sum type: a value is Either a Left OR a Right
union Either {
Left { value: Int }
Right { value: String }
}
# Option is a common pattern: either Some value or None
union Option {
None
Some { value: Int }
}
Why it matters: ADTs let you model your domain precisely. Instead of using null, magic numbers, or stringly-typed data, you define exactly what shapes your data can take. The compiler then ensures you handle all cases.
History: ADTs emerged from the ML family of languages in the 1970s (Robin Milner at Edinburgh). They became central to Haskell, OCaml, and F#. Rust’s enum and Swift’s enum with associated values are modern descendants.
In other languages: Java traditionally uses inheritance hierarchies; Java 17+ added sealed classes and pattern matching. C# has similar recent additions. Rust and Swift have ADTs as core features. In JavaScript/TypeScript, discriminated unions with type fields achieve a similar pattern.
Disambiguation: “ADT” also stands for “Abstract Data Type” (Barbara Liskov, CLU, 1974) - a different concept about encapsulation and defining types by their operations rather than their representation. Abstract data types influenced object-oriented programming. Seq uses ADT in the algebraic sense from ML, not the abstract sense from CLU.
Closure
A function bundled with the variables it captured from its surrounding scope.
: make-adder ( Int -- [ Int -- Int ] )
[ i.+ ] ; # The quotation captures the Int from the stack
5 make-adder # Returns a closure that adds 5
10 swap call # Result: 15
The quotation [ i.+ ] captures the 5 from the stack. When you call it later, it still has access to that captured value, even though make-adder has returned.
Why it matters: Closures enable functional patterns like callbacks, partial application, and higher-order functions. They’re the building block for abstracting behavior.
In other languages: JavaScript closures work similarly. Java 8+ has lambdas (with some restrictions on captured variables). Python has closures with nonlocal. The difference in Seq is that captured values come from the stack, not named variables.
Concatenative Programming
A programming paradigm where programs are built by composing functions in sequence. Each function takes its input from a stack and leaves its output on the stack.
# This program: take a number, double it, add 1, print it
dup i.+ 1 i.+ int->string io.write-line
Each word operates on whatever is on the stack. No variables, no argument lists - just a pipeline of transformations.
Why it matters: Concatenative code is highly composable. Any sequence of words can be extracted into a new word. Refactoring is trivial because there are no variable names to coordinate.
History: Concatenative programming was pioneered by Charles Moore with Forth (1970). Moore designed Forth to control radio telescopes at the National Radio Astronomy Observatory - he needed something small, fast, and interactive. Forth became popular in embedded systems, early personal computers, and even spacecraft (it powered the guidance system on several NASA missions). Other concatenative languages include PostScript (the PDF predecessor), Factor, and Joy.
In other languages: Most languages are “applicative” - you apply functions to arguments: print(add(1, double(x))). Notice how you read this inside-out. Concatenative code reads left-to-right. Unix pipes (cat file | grep pattern | wc -l) follow a similar compositional style. Elixir’s |> operator and F#’s pipeline operator provide this within applicative languages.
Coroutine
A function that can pause its execution and resume later from where it left off.
Regular functions run to completion - they start, do their work, and return. Coroutines can yield control in the middle, let other code run, then continue from exactly where they paused.
# A coroutine that yields 1, 2, 3
: counter ( Ctx Int -- Ctx | Yield Int )
1 yield drop
2 yield drop
3 yield drop
;
Why it matters: Coroutines enable cooperative multitasking, generators, and async-like patterns without the complexity of threads.
History: Coroutines were first described by Melvin Conway in 1963 - yes, the same Conway of “Conway’s Law” (organizations design systems mirroring their communication structure). The concept predates threads! Simula (1967) had coroutines, and they were central to early Lisp implementations. Modern languages rediscovered coroutines: Python added generators in 2001, C# added iterators in 2005, and JavaScript added generators in 2015.
In other languages: Python has generators with yield and async/await. JavaScript has async/await and generator functions. C# has yield return for iterators and async/await. Go’s goroutines are similar but preemptively scheduled. Kotlin has coroutines as a library feature.
See also: Generator, Yield, Strand
CSP (Communicating Sequential Processes)
A concurrency model where independent processes communicate by sending messages through channels, rather than sharing memory.
chan.make
dup [ 42 swap chan.send drop ] strand.spawn drop
chan.receive drop # Receives 42
The key insight: instead of multiple threads reading/writing shared variables (and needing locks), each process has its own state and communicates through explicit message passing.
Why it matters: CSP eliminates entire categories of concurrency bugs (race conditions, deadlocks from lock ordering). It’s easier to reason about because communication points are explicit.
History: CSP was formalized by Tony Hoare in his 1978 paper “Communicating Sequential Processes.” Hoare is one of the giants of computer science - he also invented quicksort, developed Hoare logic for program verification, and received the Turing Award in 1980. CSP influenced the Occam language (1983) for parallel computing, and Erlang’s actor model is a close relative. Go (2009) made channels and goroutines first-class features, bringing CSP to wide adoption.
In other languages: Go has goroutines and channels as core features. Erlang and Elixir use the related Actor model with message-passing processes. Rust has channels in its standard library. Java traditionally uses shared memory with locks; Java 21 added virtual threads. JavaScript is single-threaded and uses callbacks/promises for async work.
DWARF
The debugging data format embedded in compiled binaries. It maps machine-code addresses back to the original source — file, line, function, variable names, and types — so a debugger or crash handler can describe where execution is in your code rather than at a raw hex address.
Why it matters in Seq: seqc build compiles your .seq program down through LLVM to a native binary, so a runtime panic happens in generated machine code. The embedded DWARF is what lets the panic handler print a your-program.seq:42 location instead of an unhelpful address. Seq’s runtime even carries an in-process DWARF parser (gimli / addr2line) so it can read its own debug info at panic time. DWARF is metadata only — no runtime speed cost — but it is large: on Linux it accounts for the bulk of the default seqc build output, which is why stripping it is the main lever on binary size. See Binary Footprint.
History: DWARF was designed in the late 1980s alongside the ELF executable format for Unix System V Release 4. The name is a fantasy-creature pun on ELF; only later was it backronymed to “Debugging With Attributed Record Formats.” It’s maintained by the DWARF Debugging Information Format Committee, with DWARF 5 (2017) the current major version.
Where you’ll see it: Essentially every native toolchain emits DWARF on Linux and the BSDs — C, C++, Rust, Go, and Seq all produce it. Platforms differ in where it lives: Linux embeds DWARF sections directly in the binary, macOS keeps it in a separate .dSYM bundle, and Windows uses a different format entirely (PDB files). That platform split is exactly why a default Seq binary looks huge on Linux but compact on macOS.
Fiber
See Strand.
Generator (Weave)
A function that produces a sequence of values on demand, yielding one at a time rather than computing all values upfront.
In Seq, generators are called weaves:
# A generator that yields squares: 1, 4, 9, 16, ...
: squares ( Ctx Int -- Ctx | Yield Int )
dup dup i.* yield drop # yield n*n
1 i.+ squares # recurse with n+1
;
[ 1 swap squares ] strand.weave
0 strand.resume # yields 1
0 strand.resume # yields 4
0 strand.resume # yields 9
Why it matters: Generators let you work with infinite or expensive sequences lazily. You only compute values as needed. Great for streaming data, pagination, or any producer/consumer pattern.
In other languages: Python has generators with yield and send() for bidirectional communication. JavaScript has generator functions (function*) with next(value). C# has IEnumerable with yield return. Java has Stream for lazy sequences. Seq’s weaves support bidirectional communication - you can send values back to the generator with each resume.
NaN-Boxing
A memory optimization technique that encodes multiple value types within a single 64-bit word by exploiting the unused bits in IEEE 754 floating-point NaN (Not a Number) representations.
IEEE 754 doubles use 64 bits: 1 sign, 11 exponent, 52 mantissa. When the exponent is all 1s and the mantissa is non-zero, the value is NaN. The “quiet NaN” range (with the top mantissa bit set) provides ~51 bits of payload that hardware ignores - perfect for smuggling in pointers, integers, or type tags.
Normal f64: stored directly (bit pattern < 0xFFF8...)
NaN-boxed Int: 0xFFF8 | (tag << 47) | (47-bit payload)
NaN-boxed Ptr: 0xFFF8 | (tag << 47) | (pointer)
Why it matters: NaN-boxing shrinks value representation from multi-word tagged unions to a single 64-bit word. This improves cache utilization, reduces memory bandwidth, and enables faster stack operations. JavaScript engines (V8, SpiderMonkey) use variants of this technique for significant performance gains.
The tradeoff: Integers are limited to ~47-51 bits (depending on tag space), not full 64-bit. This breaks operations expecting i64::MIN/MAX (like 1 63 shl). Languages using NaN-boxing either accept this limit (JavaScript’s 53-bit integers via f64 mantissa) or fall back to heap-allocated “BigInt” for large values.
History: The technique emerged from Lisp implementations in the 1970s-80s, where tagged pointers were common. Modern popularization came from LuaJIT (Mike Pall, 2005) and JavaScript engines. WebKit’s JavaScriptCore, Mozilla’s SpiderMonkey, and early V8 all use NaN-boxing or similar “pointer tagging” schemes.
In other languages: LuaJIT uses NaN-boxing extensively. JavaScript engines use it for the number type. Ruby’s CRuby uses tagged pointers (a related technique). OCaml uses tagged integers with the low bit. Seq uses 8-byte tagged pointers — integers are stored inline (63-bit, shifted left 1 with low bit set), heap types use the low 3 bits of aligned pointers as a type tag.
Point-Free Programming
Writing functions without explicitly naming their arguments. Also called “tacit programming.”
# Point-free: arguments are implicit on the stack
: double ( Int -- Int ) dup i.+ ;
: quadruple ( Int -- Int ) double double ;
# vs. "pointed" style in other languages:
# def quadruple(x): return double(double(x))
In Seq, point-free is the natural style because values live on the stack, not in named variables.
Why it matters: Point-free code emphasizes the transformation rather than the data. It’s often more composable and can be easier to reason about once you’re fluent.
In other languages: Haskell supports point-free style using . for composition. APL and J are famously point-free. Most languages require naming arguments explicitly. In Seq, point-free is the default style since values live on the stack.
Pattern Matching
A control flow mechanism that branches based on the structure of data, often extracting values in the process.
Seq has two forms of pattern matching:
1. match - ADT Destructuring
Used with union types to branch on variants and extract fields:
union Option {
None
Some { value: Int }
}
: describe ( Option -- )
match
None -> "Nothing here" io.write-line
Some { >value } ->
int->string "Got: " swap string.concat io.write-line
end
;
The compiler verifies exhaustiveness - you must handle all variants. If you add a variant to the union, all match expressions must be updated.
2. cond - Predicate-Based Dispatch
Used for conditional branching based on boolean tests:
: classify ( Int -- String )
[ dup 0 i.< ] [ drop "negative" ]
[ dup 0 i.= ] [ drop "zero" ]
[ true ] [ drop "positive" ]
3 cond
;
Each predicate quotation produces a Bool. The body quotation of the first true predicate executes. This is closer to Lisp’s cond than structural pattern matching.
Why it matters: Pattern matching replaces chains of if/else with declarative structure. The compiler can check exhaustiveness, catching bugs when data structures change.
History: Pattern matching originated in ML (1970s) and became central to Haskell, OCaml, and Erlang. It’s now spreading widely - Rust has match, Scala has case, Swift has switch with associated values, Python added structural pattern matching in 3.10, and Java added pattern matching in recent versions.
In other languages: Rust has match with exhaustiveness checking. Haskell and OCaml have pattern matching as a core feature. Scala has case classes and match. Elixir inherits pattern matching from Erlang. Python 3.10+ has match/case. Java 21 has pattern matching for switch. JavaScript does not have pattern matching natively.
See also: ADT
Quotation
A block of code that isn’t executed immediately - it’s a value you can pass around and execute later. Also known as an anonymous function or lambda in other languages.
[ 1 i.+ ] # A quotation that adds 1
dup # Now we have two copies of it
call # Execute one copy
swap call # Execute the other
Quotations are Seq’s equivalent of lambdas/anonymous functions, but simpler - they’re just deferred code.
Why it matters: Quotations enable higher-order programming. You can pass behavior as data, store it, compose it, execute it conditionally or repeatedly.
History: The [ ] quotation syntax comes from Factor (2003, Slava Pestov), a modern concatenative language that refined many ideas from Forth. Factor demonstrated that concatenative languages could have rich type systems, garbage collection, and modern tooling. Joy (1990s, Manfred von Thun) also used quotations extensively and influenced Factor’s design.
In other languages: JavaScript has arrow functions x => x + 1. Python has lambda x: x + 1. Java 8+ has lambdas x -> x + 1. Ruby has blocks and procs. The difference is Seq quotations don’t declare parameters - they operate on whatever is on the stack.
Resume
Continuing a paused generator/weave by sending it a value.
[ my-generator ] strand.weave # Create weave, get handle
42 strand.resume # Send 42, get yielded value back
Resume is the counterpart to yield. When the generator yields, it pauses. When you resume, you send a value into the generator and it continues from where it paused.
Why it matters: Bidirectional communication between caller and generator enables powerful patterns like coroutine-based state machines, interactive protocols, and pull-based data processing.
In other languages: Python has generator.send(value). JavaScript has iterator.next(value). Lua coroutines have coroutine.resume(co, value). Some languages only support one-way generators that yield out but don’t receive values in.
Single Assignment / Immutable Bindings
A language property where variables can only be bound once - you can’t reassign them after the initial binding.
% Erlang example - single assignment
X = 5,
X = 6. % ERROR! X is already bound
This leads to a characteristic style where you thread transformed values through new names:
X1 = transform(X),
X2 = transform(X1),
X3 = transform(X2).
How Seq relates: Seq takes this further - there are no variable names at all. Values live on the stack and flow through transformations without being named. The “juggling” that Erlang programmers do with variable names (X1, X2, X3) becomes stack manipulation in Seq (dup, swap, rot, over).
# No names to juggle - values flow through the stack
transform transform transform
Both approaches enforce thinking about data flow rather than state mutation. The stack is essentially single-assignment taken to its logical conclusion: values don’t even need names, they just flow.
Why it matters: Understanding single assignment helps explain why concatenative programming feels different. You’re not mutating variables - you’re transforming values. Stack manipulation is the mechanism for managing those transformations without names.
History: Single assignment was central to Erlang (1986, Ericsson) where immutability enables reliable concurrent systems. Haskell enforces immutability at the type level. Clojure brought immutable data structures to the JVM. The concept traces back to declarative and logic programming (Prolog).
In other languages: Erlang and Elixir enforce single assignment. Haskell variables are immutable by default. Rust has immutable bindings by default (let vs let mut). JavaScript’s const and Java’s final provide opt-in immutability. Most languages allow reassignment by default.
Row Polymorphism
A type system feature that lets functions work with stacks of any depth, as long as they have the right types on top.
: add-one ( ..a Int -- ..a Int ) 1 i.+ ;
The ..a is a Row Variable representing “whatever else is on the stack.” This function works whether the stack has 1 element or 100 - it only cares about the Int on top.
Why it matters: Without row polymorphism, you’d need different versions of add-one for different stack depths, or lose type safety entirely. Row polymorphism gives you both flexibility and safety.
History: Row polymorphism was developed in the 1990s for typing extensible records (Mitchell Wand, 1989; Didier Rémy, 1994). It was adapted for stack-based languages by researchers working on typed Forth and later Joy. The key insight: a stack is just a record where fields are positions rather than names. Seq’s type system builds on this work to provide safety without sacrificing the flexibility that makes concatenative programming powerful.
In other languages: PureScript and some ML variants have row polymorphism for extensible records. TypeScript’s mapped types and excess property checks address similar problems differently. Most languages don’t need this concept because they don’t have stack-based semantics - it’s analogous to how generics let you write code that works with any type.
Row Variable
A polymorphic placeholder for zero or more stack values, in order. Written as two dots followed by a lowercase name: ..a, ..b, ..rest.
: dup ( ..a T -- ..a T T ) # ..a is "everything below"; T is the top value
Implicit on every stack effect — you only write a row variable explicitly when:
- Two effects must share the same row (e.g., a quotation type that promises to leave the stack at a specific depth)
- You want to make the polymorphism visible for documentation
Row variables abstract over a sequence of types. To abstract over a single type slot, use a Type Variable instead. The two are different tools — see Row Polymorphism for the broader machinery.
Why it matters: Row variables are what make dup, swap, drop, etc. compose into deeper words without writing one version per stack depth. Without them, dup would only work when the stack has exactly one element.
Stack Effect
A function’s type signature in Seq, describing what it takes from the stack and what it leaves.
: swap ( a b -- b a ) # Takes two values, returns them reversed
: dup ( a -- a a ) # Takes one value, returns two copies
: drop ( a -- ) # Takes one value, returns nothing
: i.+ ( Int Int -- Int ) # Takes two Ints, returns one Int
The part before -- is input (consumed from stack), after -- is output (left on stack).
The compiler verifies that when you compose words, the stack types line up:
# These compose: dup outputs match i.+'s inputs
: double ( Int -- Int ) dup i.+ ;
# ↑ Int → ↑ Int Int → ↑ Int
# This would fail:
# : broken ( Int -- Int ) dup concat ; # ERROR: concat expects strings!
Why it matters: Stack effects are the contract of a function. The compiler traces types through each operation, catching errors at compile time. This makes concatenative code both highly composable and type-safe.
In other languages: Function signatures like int add(int a, int b) in C/Java describe named parameters. Stack effects describe the stack transformation rather than named parameters. Forth uses stack effect comments by convention; Seq makes them part of the type system.
Strand (Green Thread)
A lightweight unit of concurrent execution managed by the runtime, not the operating system.
[ do-work ] strand.spawn # Start work in a new strand
Strands are much cheaper than OS threads (thousands are fine), and they cooperate by yielding at certain points rather than being preemptively interrupted.
Why it matters: You can have massive concurrency without the overhead of OS threads. Great for I/O-bound work like servers handling many connections.
In other languages:
- Go has goroutines - very similar to strands, lightweight and cooperatively scheduled
- Erlang/Elixir has processes - lightweight, isolated, message-passing
- Java has OS threads (heavy) and virtual threads (Java 21+, lightweight)
- JavaScript/Python use async/await for single-threaded concurrency via callbacks/promises
- Ruby has fibers - another name for the same concept
Seq’s strands run on top of the May coroutine library.
Tail Call Optimization (TCO)
A compiler technique that transforms recursive calls into loops, preventing stack overflow.
# Without TCO, this would overflow the stack for large n
: countdown ( Int -- )
dup 0 i.> if
dup int->string io.write-line
1 i.- countdown # Recursive call - but with TCO, no stack growth!
else
drop
then ;
1000000 countdown # Works fine - runs in constant stack space
When a function’s last action is calling another function (a “tail call”), TCO reuses the current stack frame instead of creating a new one.
Why it matters: TCO makes recursion as efficient as iteration. You can write elegant recursive algorithms without worrying about stack overflow.
History: TCO was pioneered by Guy Steele and Gerald Sussman in the development of Scheme (1975). They proved that properly tail-recursive functions are equivalent to loops, making recursion a practical tool for iteration. Scheme was the first language to require TCO in its specification. This insight influenced functional programming for decades.
In other languages: Scheme requires TCO by specification. Haskell, OCaml, and F# implement it. Scala has @tailrec annotation for verified tail recursion. JavaScript includes TCO in the ES6 spec, though Safari is currently the only major browser implementing it. Java and Python do not implement TCO. Seq guarantees TCO using LLVM’s musttail directive.
Type Variable
Within a stack effect, a polymorphic placeholder for one type slot. Written as a single uppercase letter: T, U, V, K, M, Q. The type checker freshens it on each call site and unifies it with whatever concrete type appears.
: dup ( ..a T -- ..a T T ) # T can be Int, String, Channel, ...
: swap ( ..a T U -- ..a U T ) # T and U are independent
Multi-character uppercase identifiers (Acc, Ctx, Sokcet) are not type variables under the strict v7.0 rule. They must be a registered concrete type (Int, Float, Bool, String, Symbol, Channel, Socket, Variant) or a union name. Otherwise the type checker rejects them with a “did you mean” hint — this catches typos that previously masqueraded as fresh polymorphics.
A type variable abstracts over a single type slot. To abstract over a sequence of stack values, use a Row Variable instead. The two compose: ( ..a T -- ..a T T ) is “any stack with any single value on top, duplicate the top.”
Why it matters: Type variables let one word work for many types without losing static safety. The strict single-letter rule makes typos visible — a misspelled name is no longer silently legal.
In other languages: Equivalent to Java/C# <T>, Rust/Haskell type parameters, TypeScript generics. Two distinctions in Seq: type variables are inferred (you don’t declare them with a <T> clause), and they are separate from row variables, which abstract over sequences of types rather than single types.
Union
See ADT.
Variant
A tagged value - an instance of a union type. Not to be confused with variables, records, or the loosely-typed “Variant” from COM/Visual Basic.
union Shape {
Circle { radius: Int }
Rectangle { width: Int, height: Int }
}
10 Make-Circle # Creates a Variant with tag=Circle, one field
5 10 Make-Rectangle # Creates a Variant with tag=Rectangle, two fields
A Variant carries:
- A tag identifying which union case it is (Circle vs Rectangle)
- Zero or more fields containing the associated data
You create Variants using generated constructors (Make-Circle, Make-Rectangle) and inspect them with match:
: area ( Shape -- Int )
match
Circle { >radius } -> dup i.* # πr² simplified to r²
Rectangle { >width >height } -> i.* # w × h
end
;
Why “variant”? The term comes from type theory - a “variant type” is a type that can be one of several variants. Each variant is a tagged alternative. The value “varies” in which case it represents.
Common confusion:
- Not a variable - Variables hold changing values; Variants are immutable tagged data
- Not a record/struct - Records have fixed fields; Variants are one-of-many alternatives
- Not COM Variant - COM’s Variant is a loosely-typed container; Seq’s Variants are statically typed
In other languages: Rust calls these enum values. Haskell calls them “data constructors.” OCaml calls them “variant constructors.” TypeScript’s discriminated unions achieve similar patterns. The term “variant” is common in ML-family languages.
See also: ADT, Pattern Matching
Weave
Seq’s term for a generator/coroutine that can yield values. See Generator.
The name evokes how the weave’s execution “weaves” back and forth with the caller - yielding out, resuming in, yielding out again.
Word
A named function in Seq. The term comes from Forth, where the dictionary of defined operations are called “words.”
: greet ( -- ) # Define a word
"Hello, World!" io.write-line ;
greet # Call the word
Why “word”? In concatenative languages, a program is literally a sequence of words (tokens). When you write 1 2 i.+, you’re writing three words. User-defined words are indistinguishable from built-in ones in usage.
History: The term comes from Forth, where Charles Moore conceived of programming as extending a language. In Forth, you build up a “dictionary” of words - starting with primitives and defining new words in terms of existing ones. Moore saw programming as fundamentally linguistic: you’re not writing instructions for a machine, you’re teaching the machine new vocabulary. This philosophy influenced Seq’s design.
In other languages: Equivalent to “function,” “method,” or “procedure.” Seq uses “word” to honor the Forth tradition and because it emphasizes the linguistic nature of concatenative programming - a program is a sentence of words.
Yield
Pausing a generator/weave and sending a value to the caller.
: fibonacci ( Ctx Int Int -- | Yield Int )
over yield drop # Yield current fib number
tuck i.+ fibonacci # Compute next and recurse
;
[ 0 1 fibonacci ] strand.weave
0 strand.resume # yields 0
0 strand.resume # yields 1
0 strand.resume # yields 1
0 strand.resume # yields 2
0 strand.resume # yields 3
When the generator executes yield, it:
- Sends a value to whoever called
strand.resume - Pauses execution at exactly that point
- Waits for the next
strand.resumeto continue from that exact location
The key insight: the yield point is both the pause point and the resumption point. When the generator resumes, it continues with all its local state intact - stack values, recursion depth, everything. This makes generators/coroutines inherently stateful.
Example: Game avatars. Lua coroutines are famously used in MMOs to model player characters. Each avatar is a coroutine that knows its coordinates, inventory, and capabilities. The game loop resumes each avatar, it does a tick of work (move, attack, etc.), yields back, and suspends - all its state preserved until next tick. No external state management needed; the coroutine is the state.
Why it matters: Yield enables lazy evaluation and producer/consumer patterns. The generator only does work when asked. More powerfully, the stateful nature lets you model complex behaviors (state machines, protocol handlers, simulations) as straightforward sequential code rather than callbacks or explicit state objects.
In other languages: Python has yield. JavaScript has yield in generator functions. C# has yield return. Lua has coroutine.yield(). The concept is the same across languages - pause execution and emit a value.
Further Reading
- Language Guide - Full syntax and semantics
- Type System Guide - Deep dive into Seq’s type system
- TCO Guide - How tail call optimization works
- Architecture - System design and implementation
- seqlings - Learn by doing with guided exercises
Seq Development Roadmap
Core Values
The fast path stays fast. Observability is opt-in and zero-cost when disabled. We can’t slow the system down to monitor it.
Inspired by the Tokio ecosystem (tokio-console, tracing, metrics, tower), we aspire to rich runtime visibility while respecting performance.
Current (v5.0)
Union Type Safety
Compile-time safety for union types (RFC #345). The compiler auto-generates
type-safe constructors, predicates (is-Get?), and field accessors
(Get-response_chan) for all union variants.
Error Handling Standardization (v3.0)
All fallible operations return (value Bool) instead of panicking.
Division, TCP, regex, and other operations now consistently use this pattern.
Foundation (Complete)
These features are stable and documented:
| Feature | Details |
|---|---|
| Naming conventions | Dot for namespaces, hyphen for compounds, arrow for conversions |
| OS module | os.getenv, os.home-dir, os.path-*, args.count, args.at, os.exit, os.name, os.arch |
| FFI | Manifest-based C bindings, string marshalling, out parameters. Examples: libedit, SQLite |
| Runtime observability | SIGQUIT diagnostics, watchdog timer, strand/channel/memory stats |
| Yield safety valve | Automatic yields in tight loops to prevent strand starvation |
| LSP server | Language server with completions, hover, diagnostics. Powers TUI and editor integrations |
| TUI REPL | Default REPL with split-pane IR visualization, Vi editing, tab completion |
| Union types & match | union definitions, match expressions, exhaustiveness checking, named bindings |
| Closures | Lexical capture with Arc-shared environments, TCO-compatible |
| Channels as values | First-class Channel type on the stack (no ID-based lookup) |
| Weaves | Generator/coroutine pattern with bidirectional communication |
| Lists & Maps | First-class collection types with list.map, list.filter, list.fold, map.* |
| Symbols | :foo syntax for lightweight identifiers, used for variant tags |
| Lint tool | seqc lint with TOML-based syntactic pattern matching |
Future
Strand Visibility
Strand lifecycle events (opt-in):
- Parent-child relationships for debugging actor hierarchies
- Blocked strand detection (who’s waiting on what)
- Optional compile-time flag to enable
Metrics & Tracing
Metrics export:
- Prometheus-compatible endpoint
- Strand pool utilization
- Message throughput sampling
- Configurable sampling rates to control overhead
Structured tracing:
- Integration with tracing ecosystem
- Span-based request tracking across strands
- Correlation IDs for distributed debugging
Visual Tooling
Seq console (inspired by tokio-console):
- Real-time strand visualization
- Channel flow graphs
- Actor hierarchy browser
- Historical replay for post-mortem debugging
OpenTelemetry integration:
- Distributed tracing across services
- Standard observability pipeline integration
FFI Phase 3
- Struct passing
- Platform-specific bindings
- Callback support (C -> Seq) - shelved: most useful callback patterns require low-level memory operations; many C APIs have non-callback alternatives
Type System Research
Goal: Achieve the safety benefits of generics without sacrificing point-free composability or adding syntactic overhead.
Seq’s philosophy: type safety through inference, not annotation.
Current state:
- Row-polymorphic stack effects provide implicit type threading
- Union types with nominal typing and auto-generated accessors (v4.0)
(value Bool)error handling pattern (v3.0)
Research directions:
- Inferred variant types - Compiler tracks that
Make-Okproduces a specific union type - Flow typing through combinators - If
result-bindreceivesIntResult, infer the quotation expectsInt - Structural typing for conventions - Recognize Result-like patterns at compile time
- Constructor argument refinement -
42 Make-OkinfersIntResultfrom theIntargument
Key question: How far can we push implicit typing before explicit annotations become necessary?
Constraint: Must not compromise point-free style or add syntactic noise.
Design Documents
Batteries Included: Seq Standard Library
Philosophy
Inspired by Go’s “batteries included” approach:
- Opinionated: One obvious way to do things
- Self-sufficient: Build real applications without external dependencies
- Cohesive: Consistent naming, patterns, and idioms across the stdlib
- Practical: Focus on what developers actually need, not academic completeness
The Rust Advantage
Seq’s runtime is implemented in Rust, which provides a massive architectural advantage for building a batteries-included stdlib. Instead of:
- Writing crypto from scratch (dangerous, years of work)
- Binding to C libraries like OpenSSL (complex, CVE-prone, platform headaches)
- Building HTTP/TLS stacks from first principles
- Maintaining fragile C FFI bindings
Seq can leverage Rust’s ecosystem directly:
| Capability | Rust Crate | Quality |
|---|---|---|
| Crypto hashing | sha2 | RustCrypto, audited |
| HMAC | hmac | RustCrypto, audited |
| Encryption | aes-gcm | RustCrypto, audited |
| Signatures | ed25519-dalek | Audited, widely used |
| HTTP client | hand-rolled over may + rustls | Strand-yielding HTTP/1.1, no ureq dependency |
| TLS | rustls | Memory-safe, modern |
| Regex | regex | Fastest in class |
| Compression | flate2, zstd | Fast, well-maintained |
| Random | rand | Industry standard |
| UUID | uuid | Complete implementation |
| Database | rusqlite | Mature, stable (not yet integrated) |
Pattern Already Proven
This isn’t theoretical - Seq already uses this pattern successfully:
| Existing Builtin | Rust Foundation |
|---|---|
| TCP networking | may (coroutine-aware) |
| File I/O | std::fs |
| Channels | crossbeam |
| Time | std::time |
| String ops | std::string |
| Base64/Hex encoding | base64, hex |
| Crypto (SHA-256, HMAC, AES-GCM, PBKDF2, Ed25519, Random, UUID) | sha2, hmac, aes-gcm, pbkdf2, ed25519-dalek, rand, uuid |
| Regular expressions | regex |
| Compression | flate2, zstd |
| Arena allocator | Custom, but Rust memory safety |
Each builtin is a thin FFI wrapper that exposes Rust functionality to Seq. Adding crypto, HTTP client, or regex follows the exact same pattern.
Why This Matters
- Security: Audited Rust crates vs. hand-rolled crypto
- Speed: Zero-cost abstractions, no interpreter overhead
- Reliability: Rust’s type system catches bugs at compile time
- Velocity: Days to add features, not months
- Maintenance: Crate updates flow through automatically
- Cross-platform: Rust handles platform differences
This is Seq’s unfair advantage: a concatenative language with the full power of the Rust ecosystem behind it.
Binary Contents
A seqc build binary contains exactly what the source uses. The
runtime ships with every capability built in, and the link removes
what the program does not reference.
There are no seqc flags for capability selection. There are no
source annotations. The typechecker already knows which builtins
each program touches; the linker uses the seq_main reachability
boundary to drop the rest. A hello world ships no HTTP code, no
TLS, no regex, no crypto, no compression — and pays nothing for
their presence in the runtime archive.
This means “batteries included” is honest: adding a builtin to the runtime imposes no cost on programs that do not call it.
Note: The runtime crate (
crates/runtime/Cargo.toml) still exposes Cargo features (crypto,http,regex,compression) for builds that bypassseqcand depend on the runtime directly. These are a Cargo-build internal — they are never surfaced throughseqc buildand have no bearing on what a Seq source program can reference.
Current State
Runtime Builtins (Rust FFI)
| Category | Capabilities |
|---|---|
| Core | Stack ops, arithmetic, booleans, bitwise |
| Types | Int, Float, Bool, String, Symbol, List, Map, Variant |
| Strings | Concat, split, trim, case conversion, JSON escape |
| I/O | stdin/stdout, file read/write, path operations |
| Concurrency | Channels, strands (green threads), weave (coroutines) |
| Networking | TCP (listen/accept/connect/read/write/close/local-port), UDP (bind/send-to/recv-from), TLS client upgrade, HTTP client (GET/POST/PUT/DELETE), DNS resolve |
| Time | Unix timestamp, high-res time, sleep |
| Testing | Assertions, test runner, pass/fail counts |
| Serialization | SON format (Seq Object Notation) |
| Encoding | Base64 (standard + URL-safe), Hex |
| Crypto | SHA-256, HMAC-SHA256, AES-256-GCM, PBKDF2, Ed25519 signatures, secure random, UUID v4 |
| HTTP Client | GET, POST, PUT, DELETE with TLS support |
| Regex | match, find, find-all, replace, captures, split, valid? |
| Compression | gzip, gunzip, zstd, unzstd with compression levels |
| OS | Args, env vars, path operations, exec, exit |
Standard Library (Pure Seq)
Located in crates/compiler/stdlib/ (~2900 lines):
| Module | Lines | Description |
|---|---|---|
json.seq | 1166 | Full JSON parser and encoder |
yaml.seq | 752 | YAML parser |
zipper.seq | 328 | Functional list zipper |
http.seq | 190 | HTTP response building, request parsing |
imath.seq | 145 | Integer math utilities (abs, min, max, clamp) |
fmath.seq | 109 | Float math utilities |
signal.seq | 66 | Signal handling |
son.seq | 57 | SON serialization helpers |
list.seq | 55 | List helpers |
stack-utils.seq | 46 | Stack manipulation utilities |
map.seq | 30 | Map helpers |
HTTP Server Example
examples/http/http_server.seq (18KB) demonstrates:
- Concurrent request handling with strands
- Channel-based worker dispatch
- HTTP routing with
cond - Closure capture for connection handling
# Working HTTP server pattern
8080 net.tcp.listen
[ conn-id |
conn-id net.tcp.read
http-request-path
cond
[ "/health" string.equal? ] [ drop "OK" http-ok ]
[ "/api" string.starts-with ] [ handle-api ]
[ true ] [ drop "Not Found" http-not-found ]
end
conn-id net.tcp.write
conn-id net.tcp.close
] accept-loop
Gaps for “Batteries Included”
| Category | Status | Priority |
|---|---|---|
| HTTP client | Complete | High |
| Regex | Complete | Medium |
| TLS/HTTPS | Complete (via rustls) | Medium |
| Templates | Not started | Medium |
seq fmt | Not started | Medium |
seq.toml | Not started | Medium |
| Logging | Not started | Low |
| Compression | Complete | Low |
| Database | Not started | Future |
| HTML parsing | Not started | Future |
Already Mature
| Category | Status |
|---|---|
| JSON | Complete (1166 lines) |
| YAML | Complete (752 lines) |
| HTTP server | Working (helpers + example) |
| HTTP client | Complete (builtin) - GET, POST, PUT, DELETE with TLS |
| Regex | Complete (builtin) - match, find, replace, captures, split |
| Compression | Complete (builtin) - gzip, zstd with levels |
| LSP | Complete (2200+ lines) - diagnostics, completions |
| REPL | Complete - with LSP integration, vim keybindings |
| Testing | Complete (builtin) |
| Result/Option | Convention-based (value Bool) pattern, no stdlib module |
| Base64/Hex | Complete (builtin) - standard, URL-safe, hex |
| Crypto Phase 1 | Complete (builtin) - SHA-256, HMAC, random, UUID |
| Crypto Phase 2 | Complete (builtin) - AES-256-GCM encryption, PBKDF2 key derivation |
| Crypto Phase 3 | Complete (builtin) - Ed25519 digital signatures |
Priority 1: HTTP Client
The HTTP client is a hand-rolled HTTP/1.1 implementation sitting on the may-aware TCP/TLS/DNS layers — every IO step yields the cooperative carrier instead of blocking it. It keeps its own connection pool keyed on (scheme, host, port).
API
# GET request - returns response map
"https://api.example.com/users" net.http.get
# Stack: ( Map ) where Map = { "status": 200, "body": "...", "ok": true }
# POST request with body and content-type
"https://api.example.com/users" "{\"name\":\"Alice\"}" "application/json" net.http.post
# Stack: ( Map )
# PUT request (same signature as POST)
"https://api.example.com/users/1" "{\"name\":\"Bob\"}" "application/json" net.http.put
# DELETE request
"https://api.example.com/users/1" net.http.delete
# Stack: ( Map )
Response Map
All HTTP operations return a Map with these keys:
"status"(Int): HTTP status code (200, 404, 500, etc.) or 0 on connection error"body"(String): Response body as text"ok"(Bool): true if status is 2xx, false otherwise"error"(String): Error message (only present on failure)
Example Usage
# Make a GET request and handle the response
"https://httpbin.org/get" net.http.get
dup "ok" map.get drop
if
"body" map.get drop io.write-line
else
"error" map.get drop "Error: " swap string.concat io.write-line
then
Implementation Details
- Transport: hand-rolled HTTP/1.1 over
may-aware TCP /rustlsTLS - TLS: Built-in via
rustlswithringcrypto provider (no OpenSSL dependency) - Per-IO request/response timeout: 30s default, override with
SEQ_HTTP_REQUEST_TIMEOUT_MS - TLS handshake timeout: 10s default, override with
SEQ_TLS_HANDSHAKE_TIMEOUT_MS - TCP connect timeout: 10s default, override with
SEQ_TCP_CONNECT_TIMEOUT_MS - Max body size: 10 MB
- Connection pooling: Process-wide pool keyed on
(scheme, host, port); idle entries return viaConnection: keep-alive
Security: SSRF Protection
The HTTP client includes built-in SSRF protection. The following are automatically blocked:
- Localhost:
localhost,*.localhost,127.x.x.x - Private networks:
10.x.x.x,172.16-31.x.x,192.168.x.x - Link-local/Cloud metadata:
169.254.x.x(blocks AWS/GCP/Azure metadata) - IPv6 private: loopback, link-local, unique local addresses
- Non-HTTP schemes:
file://,ftp://,gopher://, etc.
Blocked requests return an error response with ok=false.
Additional recommendations:
- Use domain allowlists for sensitive applications
- Apply network-level egress filtering
Priority 2: Regular Expressions
Regex support is implemented via the Rust regex crate (v1.11). Fast, safe, and no catastrophic backtracking.
# Check if pattern matches anywhere in string
"hello world" "wo.ld" regex.match? # ( String String -- Bool )
# Find first match
"a1 b2 c3" "[a-z][0-9]" regex.find # ( String String -- String Bool )
# Find all matches
"a1 b2 c3" "[a-z][0-9]" regex.find-all # ( String String -- List Bool )
# Replace first occurrence
"hello world" "world" "Seq" regex.replace # ( String String String -- String Bool )
# Replace all occurrences
"a1 b2 c3" "[0-9]" "X" regex.replace-all # ( String String String -- String Bool )
# Extract capture groups
"2024-01-15" "(\\d+)-(\\d+)-(\\d+)" regex.captures
# ( String String -- List Bool ) returns ["2024", "01", "15"] true
# Split by pattern
"a1b2c3" "[0-9]" regex.split # ( String String -- List Bool )
# Check if pattern is valid
"[a-z]+" regex.valid? # ( String -- Bool )
Examples:
examples/text/regex-demo.seq- Demonstrates all regex operationsexamples/text/log-parser.seq- Practical log parsing with regex
Priority 3: Cryptography
Crypto is essential for real-world applications but often requires hunting through external packages. A batteries-included approach means shipping these out of the box.
Tier 1: Essential
| API | Rust Crate | Use Cases |
|---|---|---|
crypto.sha256 | sha2 | Checksums, content addressing, password hashing input |
crypto.hmac-sha256 | hmac + sha2 | Webhook verification, JWT signing, API auth |
crypto.random-bytes | rand | Tokens, nonces, salts, session IDs |
crypto.uuid4 | uuid | Unique identifiers |
crypto.constant-time-eq | custom | Timing-safe comparison for signatures |
# Hashing
"hello world" crypto.sha256 # ( String -- String ) hex-encoded
# HMAC for API authentication
"webhook-payload" "secret-key" crypto.hmac-sha256
# ( message key -- signature )
# Verify webhook signature
received-sig computed-sig crypto.constant-time-eq
# ( String String -- Bool ) timing-safe comparison
# Generate secure random token
32 crypto.random-bytes # ( n -- String ) 32 random bytes as 64-char hex string
# Generate UUID v4
crypto.uuid4 # ( -- String ) "550e8400-e29b-41d4-a716-446655440000"
Tier 2: Encryption
| API | Rust Crate | Use Cases |
|---|---|---|
crypto.aes-gcm-encrypt | aes-gcm | Encrypting data at rest, secure storage |
crypto.aes-gcm-decrypt | aes-gcm | Decrypting data |
crypto.pbkdf2-sha256 | pbkdf2 | Password-based key derivation |
# Symmetric encryption (AES-256-GCM)
# Key must be 64 hex chars (32 bytes = 256 bits)
plaintext hex-key crypto.aes-gcm-encrypt # ( String String -- String Bool )
ciphertext hex-key crypto.aes-gcm-decrypt # ( String String -- String Bool )
# Key derivation from password
"user-password" "salt" 100000 crypto.pbkdf2-sha256
# ( password salt iterations -- hex-key Bool )
# Full example: derive key and encrypt
"user-password" "unique-salt" 100000 crypto.pbkdf2-sha256
if
"secret data" swap crypto.aes-gcm-encrypt
if
"Encrypted: " swap string.concat io.write-line
else
drop "Encryption failed" io.write-line
then
else
drop "Key derivation failed" io.write-line
then
Tier 3: Signatures
| API | Rust Crate | Use Cases |
|---|---|---|
crypto.ed25519-keypair | ed25519-dalek | Generate signing keys |
crypto.ed25519-sign | ed25519-dalek | Digital signatures |
crypto.ed25519-verify | ed25519-dalek | Signature verification |
# Generate keypair
crypto.ed25519-keypair # ( -- public-key private-key ) both as 64-char hex
# Sign a message
message private-key crypto.ed25519-sign # ( String String -- String Bool )
# Verify signature
message signature public-key crypto.ed25519-verify # ( String String String -- Bool )
# Full example
crypto.ed25519-keypair
"Important document" swap crypto.ed25519-sign
if
swap "Important document" rot rot crypto.ed25519-verify
if "Signature valid!" else "Signature invalid!" then io.write-line
else
drop drop "Signing failed" io.write-line
then
Encoding Helpers
Available as encoding.* builtins:
# Base64 (standard with padding)
"hello" encoding.base64-encode # ( String -- String ) "aGVsbG8="
"aGVsbG8=" encoding.base64-decode # ( String -- String Bool )
# URL-safe Base64 (no padding, for JWTs/URLs)
data encoding.base64url-encode # ( String -- String )
encoded encoding.base64url-decode # ( String -- String Bool )
# Hex (lowercase output, case-insensitive decode)
"hello" encoding.hex-encode # ( String -- String ) "68656c6c6f"
"68656c6c6f" encoding.hex-decode # ( String -- String Bool )
Priority 4: Compression
Data compression via gzip and Zstandard (zstd). All operations use base64 encoding for string-safe output.
API
# Gzip compression (default level 6)
"hello world" compress.gzip # ( String -- String Bool )
# Gzip with custom level (1-9, where 1=fastest, 9=best)
"hello world" 9 compress.gzip-level # ( String Int -- String Bool )
# Gzip decompression
compressed compress.gunzip # ( String -- String Bool )
# Zstandard compression (default level 3)
"hello world" compress.zstd # ( String -- String Bool )
# Zstandard with custom level (1-22, where 1=fastest, 22=best)
"hello world" 19 compress.zstd-level # ( String Int -- String Bool )
# Zstandard decompression
compressed compress.unzstd # ( String -- String Bool )
Return Values
All compression operations return ( String Bool ):
- On success:
compressed-data true(data is base64-encoded) - On failure:
error-message false
Decompression accepts base64-encoded input and returns the original string.
Example Usage
# Compress and decompress with gzip
"Hello, World!" compress.gzip
if
dup "Compressed: " swap string.concat io.write-line
compress.gunzip
if
"Decompressed: " swap string.concat io.write-line
else
drop "Decompression failed" io.write-line
then
else
drop "Compression failed" io.write-line
then
# Compare compression algorithms
"Large text data..." dup
compress.gzip if string.length else drop 0 then
swap compress.zstd if string.length else drop 0 then
# Compare sizes
When to Use Each
| Algorithm | Best For | Level Range |
|---|---|---|
| gzip | Web content, HTTP compression, broad compatibility | 1-9 |
| zstd | Large data, better ratio, modern systems | 1-22 |
- gzip: Universal compatibility, good for HTTP
Content-Encoding - zstd: Better compression ratio and speed, ideal for data storage
Implementation Details
- Crates:
flate2(gzip),zstd(Zstandard) - Output encoding: Base64 for string-safe transport
- Input/Output: String → compressed base64 String
Examples:
examples/io/compress-demo.seq- Demonstrates all compression operations
Design Principles
1. Composition Over Configuration
# Good: Composable pieces
request
auth-middleware
logging-middleware
rate-limit-middleware
handler
http.handle
# Avoid: Giant config objects
2. Stack-Friendly APIs
# Good: Works naturally on stack
users [ is-active ] list.filter
# Avoid: Deeply nested structures that fight the stack
3. Explicit Over Magic
# Good: Clear what happens
response "body" map.get drop json-parse drop
# Avoid: Hidden transformations
4. Errors as Values
# Use (value Bool) pattern
"data.txt" file.slurp # ( String -- String Bool )
if
process-content
else
drop "File not found" io.write-line
then
5. Consistent Naming
| Pattern | Example | Meaning |
|---|---|---|
noun.verb | net.http.get, json-serialize | Action on type |
noun? | list.empty?, map.has? | Predicate |
-> | string->int, int->float | Conversion |
Comparison: Seq vs Go
| Aspect | Go | Seq |
|---|---|---|
| Paradigm | Imperative, structural | Stack-based, functional |
| Concurrency | Goroutines + channels | Strands + channels |
| Error handling | error return value | Result types on stack |
| Generics | Type parameters | Row polymorphism |
| Build | go build | seqc build |
| Packages | Module path | include std:module |
| Std library | ~150 packages | ~15 modules (focused) |
References
- Go Standard Library
- HTMX - HTML-centric approach to interactivity
- Hyperscript - Stack-like scripting for HTML
- Factor - Concatenative language with rich stdlib
Loop Lowering in Codegen
Intent
Compute benchmarks show Seq 13-32x slower than Go/Rust. Every “iteration”
is a musttail call: function prologue, spill virtual registers to stack
memory, jump. A native loop is a single basic block with a phi node and
a conditional branch — no call overhead, and LLVM can vectorize/unroll.
The goal is to detect self-tail-recursive words and lower them to LLVM
loops instead of musttail calls.
Current Implementation
A word like:
: sum-to ( Int Int -- Int )
over 0 i.<= if nip
else swap dup rot i.+ swap 1 i.- swap sum-to
then ;
Compiles to:
define tailcc ptr @seq_sum_to(ptr %stack) {
entry:
; ... body code ...
br i1 %cond, label %if_then, label %if_else
if_then:
; base case — return
ret ptr %result
if_else:
; ... compute next args ...
call void @patch_seq_maybe_yield()
%r = musttail call tailcc ptr @seq_sum_to(ptr %stack_n)
ret ptr %r
}
Each iteration: spill virtual stack → call maybe_yield → musttail jump
→ reload from stack memory. LLVM can’t see across the call boundary to
optimize the loop body.
Constraints
- Only self-tail-recursion — Mutual recursion stays as
musttail. Detecting mutual loops in a call graph is a separate, harder problem. - Must preserve
maybe_yield— Tight loops need cooperative yields for strand fairness. Insert a yield check every N iterations (e.g., 1024) instead of every iteration. - Must not break non-loop tail calls — Words that tail-call other
words (not themselves) still use
musttail. - Correctness first — The loop must produce identical stack state.
Start with the simplest pattern (single
if/elsewith self-call in one branch) before handling complex control flow.
Approach
Pattern Detection (in codegen, not parser)
When emitting a word body, check:
- Word has exactly one
if/else/thenat the top level - One branch contains a self-tail-call as the last statement
- The other branch does not call self (the base case)
This covers ~90% of recursive loops in practice (factorial, countdown, sum, fold, fibonacci-acc, etc.).
Code Generation
Instead of emitting musttail, emit a loop:
define tailcc ptr @seq_sum_to(ptr %stack) {
entry:
br label %loop
loop:
%sp = phi ptr [%stack, %entry], [%sp_next, %continue]
; ... body code (condition + branch) ...
br i1 %cond, label %base, label %continue
continue:
; ... compute next iteration's stack state ...
; yield check every 1024 iterations
%iter = phi i64 [0, %loop], [%iter_next, %continue]
%iter_next = add i64 %iter, 1
%need_yield = icmp eq i64 0, (and i64 %iter_next, 1023)
br i1 %need_yield, label %do_yield, label %loop
do_yield:
call void @patch_seq_maybe_yield()
br label %loop
base:
; ... base case ...
ret ptr %result
}
Virtual Stack in Loops
The virtual stack (top 4 values in SSA registers) can stay in registers across loop iterations using phi nodes. No need to spill and reload — this is where the real speedup comes from.
loop:
%v0 = phi i64 [%init_v0, %entry], [%next_v0, %continue]
%v1 = phi i64 [%init_v1, %entry], [%next_v1, %continue]
; operate on %v0, %v1 directly — no memory loads
Incremental Rollout
- Phase 1: Detect simplest pattern (single if/else, self-call in
one branch). Emit loop. Keep
musttailas fallback for everything else. Gate behind--loop-optflag. - Phase 2: Handle match expressions with self-call in one arm.
- Phase 3: Handle multiple self-calls (e.g., both branches recurse but with different args — fibonacci pattern). This requires loop unrolling or continuation-passing and may not be worth it.
What This Does NOT Fix
- Mutual recursion —
ping/pongpatterns stay asmusttail. - Collection iteration overhead —
list.mapcalls a quotation per element; that’s a different optimization (inline expansion). - Spill cost — Stack operations move 8-byte tagged pointers through memory when the virtual stack spills.
Checkpoints
- fib(40) under 500ms (currently 2200ms) — fibonacci is the classic self-recursive benchmark
- sum_squares under 10ms (currently 48ms) — tight arithmetic loop
- primes under 20ms (currently 84ms) — nested loops with modulo
- leibniz_pi under 500ms (currently 2900ms) — 4-value state loop
--loop-optflag — opt-in initially, default later after validation- All existing tests pass — no regressions