Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

seq-compiler seq-repl seq-lsp

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

Documentation

Naming guide: The GitHub repository is patch-seq. On crates.io, the packages are published as seq-compiler, seq-repl, and seq-lsp. Once installed, the binaries are seqc, seqr, and seq-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

Prerequisitesclang 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:

CrateBinaryDescription
seq-compilerseqcCompiler (.seq to native executable)
seq-replseqrInteractive REPL
seq-lspseq-lspLanguage 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:

ModulePurpose
std:jsonJSON parsing and serialization
std:yamlYAML parsing and serialization
std:httpHTTP request/response utilities
std:mathMathematical functions
std:stack-utilsStack 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:

VariableDefaultDescription
SEQ_STACK_SIZE131072 (128KB)Coroutine stack size in bytes
SEQ_YIELD_INTERVAL0 (disabled)Yield to scheduler every N tail calls
SEQ_WATCHDOG_SECS0 (disabled)Detect strands running longer than N seconds
SEQ_REPORTunset (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:

FeatureForthFactorSeq
Word definition: name ... ;:: name ( ) ... ;: name ( ) ... ;
Stack effects( a -- b ) comment( a -- b ) checked( a -- b ) checked
Quotations' word execute[ ... ][ ... ]
Conditionalsif else thenif else thenif 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, and receive enable 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:

TypeExamplesNotes
Int42, -1, 0xFF, 0b101064-bit signed, hex/binary literals
Float3.14, -0.564-bit IEEE 754
Booltrue, 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:

WordEffectDescription
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:

WordEffectDescription
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.

FeatureC++ std::variantRust enumSeq union
Multiple fields per variantNo (single type)YesYes (max 12)
Named fieldsNoYesYes
Exhaustive matchingstd::visitmatchmatch

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:

WordStack EffectDescription
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

WordStack EffectDescription
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 pair
  • car = “contents of address register” = first element
  • cdr = “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

GuaranteeWhat It Prevents
No nullNullPointerException, segfaults from nil access
Exhaustive pattern matchingForgetting to handle error cases or union variants
Stack effect verificationStack underflow, type mismatches, arity errors
Explicit numeric typesSilent precision loss, integer overflow surprises
No shared mutable stateData 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 CheckedWhy
Array boundsLists are dynamically sized; bounds checked at runtime
Integer overflowWraps silently (like C, unlike Rust debug builds)
Resource exhaustionStack overflow from non-tail recursion, OOM
Logic errorsThe 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:

  1. Values are immutable - you don’t mutate values, you create new ones
  2. Sharing via reference counting - complex types use Arc internally for O(1) copying

Copying Behavior by Type

TypeOn dupNotes
Int, Float, BoolBitwise copyTrue value types
StringDeep copyNew allocation, independent string
VariantShallow copyArc refcount increment, data shared
MapDeep copyNew HashMap with cloned entries
ChannelShallow copyArc increment, shares sender/receiver
QuotationBitwise copyFunction pointers, no heap data
ClosureShallow copyArc 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

LanguageModelSeq Equivalent
JavaPrimitives by value, objects by reference (shared mutable)Primitives copy, collections share via Arc (immutable)
RustOwnership + borrowing, explicit movesEverything copies, Arc handles sharing
C++Value types with copy/move constructorsEverything copies, no move optimization
ClojurePersistent immutable data structuresSimilar - 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:

CategoryExampleSignature
Parsingstring->int( String -- Int Bool )
File I/Ofile.slurp( String -- String Bool )
Environmentos.getenv( String -- String Bool )
Collectionsmap.get( Map Key -- Value Bool )
Encodingencoding.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

WordEffectDescription
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 Int is 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/shr results that would fall outside the range return 0 rather than silently truncating bit 62 in the tagger.

WordEffectDescription
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
;
WordEffectDescription
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/ if XDG_CACHE_HOME is 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 CaseRecommendation
Quick testingScript mode
Development iterationScript mode
Production deploymentseqc build with -O3 (default)
Performance-criticalseqc 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

WordEffectDescription
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

WordEffectDescription
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:

DelimiterUsageExample
. (dot)Module/namespace prefixio.write-line, net.tcp.listen, string.concat
- (hyphen)Compound words within nameshome-dir, field-at, write-line
-> (arrow)Type conversionsint->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:

PrefixDomainExamples
io.Console I/Oio.write-line, io.read-line
file.File operationsfile.slurp, file.spit, file.exists?
dir.Directory operationsdir.list, dir.make, dir.exists?
string.String manipulationstring.concat, string.trim
list.List operationslist.map, list.filter
map.Hash mapsmap.make, map.get, map.set
chan.Channelschan.make, chan.send, chan.receive
net.tcp. / net.udp. / net.http.Networkingnet.tcp.listen, net.udp.bind, net.http.get
os.Operating systemos.getenv, os.home-dir
args.Command-line argsargs.count, args.at
variant.Variant introspectionvariant.tag, variant.field-at
i.Integer operationsi.+, i.-, i.*, i./, i.=, i.<
f.Float operationsf.+, 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.

KindFormMeaning
Concrete typeInt, Float, Bool, String, Symbol, Channel, Socket, Variant, or a registered union nameA 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 variableTwo dots and a lowercase name: ..a, ..rest, ..bA 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

SuffixMeaningExample
?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" + 3 yields "53" but "5" - 3 yields 2
  • 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:

  1. Discoverability - Related operations share a prefix. Wondering what you can do with strings? Look for string.*
  2. No collisions - length could mean string length, list length, or map size. string.length, list.length, and map.size are unambiguous
  3. Clean primitives - Core stack operations like dup and swap appear in nearly every word; namespacing them would add noise
  4. 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

TypeSON FormatExample
Intliteral42, -123
Floatliteral3.14, 42.0
Boolliteraltrue, false
Stringquoted"hello", "line\nbreak"
Symbolcolon prefix:my-symbol, :None
Listbuilder patternlist-of 1 lv 2 lv 3 lv
Mapbuilder patternmap-of "key" "value" kv
Variantwrap-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.

WordEffectDescription
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:

WordEffectDescription
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:

ConceptWhat It IsExample
Stack EffectA word’s declared transformation( Int Int -- Int )
Stack TypeThe 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:

ComponentMeaning
(...)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

ErrorCauseFix
Expected Int, got FloatWrong numeric typeUse f.divide for floats
Expected String, got IntNeed conversionUse int->string
stack underflowNot enough valuesCheck stack effect, add values
cannot unify T with UType variables don’t matchEnsure 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. Run just gen-docs to 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 (like instanceof)
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
  1. Command-line arguments (arg-count, arg) ✓

    • arg-count returns number of arguments (including program name)
    • arg takes an index and returns the argument string
    • Example: ./json_tree '[42]' now works!
  2. File I/O (file-slurp, file-exists?) ✓

    • file-slurp reads entire file contents as a string
    • file-exists? checks if a file exists (returns 1 or 0)
    • Example: ./json_tree config.json now works!
  3. Multi-element arrays (up to 2 elements)

    • [1], [1, 2], ["a", "b"], [42, "mixed"]
    • Strings, numbers, booleans all work inside arrays
  4. Strings at any position

    • Strings now parse correctly whether top-level or inside arrays
    • "hello", ["hello"], ["a", "b"] all work
  5. Multi-element arrays

    • Arrays with any number of elements: [1, 2, 3, ...]
    • Nested arrays: [[1, 2], [3, 4]]
    • Mixed content: [1, "hello", true, null]
  6. Multi-pair objects

    • Objects with any number of key-value pairs
    • Nested objects: {"person": {"name": "John", "age": 30}}
    • Complex structures: [{"name": "John"}, {"name": "Jane"}]
  7. Functional collection builders

    • array-with: ( arr val -- arr' ) - append to array
    • obj-with: ( obj key val -- obj' ) - add key-value pair
    • variant-append: low-level primitive for building variants
High Priority
  1. Write without newline (write vs write_line)
    • Would allow proper indentation output
    • Currently can only output complete lines
Medium Priority
  1. Pattern matching / case statement
    • Would simplify tag-based dispatch
    • Currently requires nested if/else chains
Nice to Have
  1. String escape sequences (\", \\, \n)
  2. Pretty-print with indentation levels
  3. 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:

  1. Loops - No for i in 0..count construct
  2. Tail-call optimization - Recursion would blow the stack for large collections
  3. Variant fold/map - No way to iterate over variant fields from Seq

Possible solutions:

  • Add a variant-fold runtime 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.seqnet.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.seqnet.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.seqnet.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.seqnet.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.seqnet.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.seqtcp/client.seqtcp/server.seqtls/client.seqhttp/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:

FilePurpose
sexpr.seqS-expression data types (ADTs)
tokenizer.seqLexical analysis
parser.seqParsing tokens to AST
eval.seqEvaluation with environments
test_*.seqTest files for each component

Supported features:

  • Numbers and symbols
  • Arithmetic: +, -, *, /
  • let bindings
  • if conditionals
  • lambda with 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:

FileAlgorithm
01-rightmost-bits.seqIsolate, clear, and propagate rightmost bits
02-power-of-two.seqCheck and round to powers of two
03-counting-bits.seqPopulation count, leading/trailing zeros
04-branchless.seqBranchless min, max, abs, sign
05-swap-reverse.seqBit 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 managementpick/roll patterns for 4+ item stacks
  • Cryptographic randomnesscrypto.random-int for 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

FileTopic
01-rightmost-bits.seqRightmost bit manipulation (turn off, isolate, propagate)
02-power-of-two.seqPower of 2 detection, next power, log2
03-counting-bits.seqPopcount algorithms, parity, leading/trailing zeros
04-branchless.seqBranchless abs, sign, min, max
05-swap-reverse.seqXOR 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 AND
  • bor - bitwise OR
  • bxor - bitwise XOR
  • bnot - bitwise NOT
  • shl - shift left
  • shr - logical shift right
  • popcount - count 1-bits
  • clz - count leading zeros
  • ctz - count trailing zeros
  • int-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

FilePurpose
osc.seqOSC 1.0 encoder, written in Seq. Library, no main.
test_osc.seqByte-exact unit tests for the encoder.
test_osc_loopback.seqEnd-to-end UDP round-trip tests (no audio).
live.csdCsound orchestra: OSC listener on port 7770 + kick instrument.
tone.seqOne-shot driver — sends a single /kick 220.0 message.
live.seq8-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

  1. Create a TOML manifest defining the C functions
  2. Use include ffi:name to load the bindings
  3. 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:

  1. The compiler allocates local storage
  2. Passes a pointer to that storage to the C function
  3. 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

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:

  1. Test files: Files named test-*.seq are discovered automatically.
  2. Test functions: Words named test-* are run as tests only when their declared stack effect is exactly ( -- ). A test-* word with a different signature (e.g. a test-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

WordEffectDescription
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:

  1. Call test.init with a descriptive name
  2. Run assertions
  3. 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

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

Standard Library Modules


I/O Operations

WordStack EffectDescription
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

WordStack EffectDescription
args.count( -- Int )Get number of command-line arguments
args.at( Int -- String )Get argument at index N

File Operations

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
and( Bool Bool -- Bool )Logical AND
or( Bool Bool -- Bool )Logical OR
not( Bool -- Bool )Logical NOT

Bitwise Operations

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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.

WordStack EffectDescription
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-port and Socket id aliasing. Listeners and connected streams live in separate registries that each start their id sequence at 0, so the same Socket integer can refer to different resources depending on which registry holds it. Dispatch for local-port (and for close) is streams-first, then listeners. The practical implication: if you intend to read a listener’s local port, call net.tcp.local-port on 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 treat Socket ids 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.

WordStack EffectDescription
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.

WordStack EffectDescription
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.

WordStack EffectDescription
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.close on a TLS socket is a hard close. The underlying TCP stream is dropped without first sending the TLS close_notify alert (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_from parses "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.1 if 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:

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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.

WordStack EffectDescription
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, or 0 on 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 in 200..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 Location available 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 with compress.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 with SEQ_HTTP_REQUEST_TIMEOUT_MS. The deadline is per-IO, not total: a peer that drips one byte every timeout − ε keeps each read under budget and so isn’t caught by this bound — practically, the residual exposure is capped at MAX_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-Length are sent. Content-Type rejects 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).

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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)

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
f.pi( -- Float )π
f.e( -- Float )e (Euler’s number)
f.tau( -- Float )τ = 2π

Test Framework

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
son.dump( T -- String )Serialize value to SON format (compact)
son.dump-pretty( T -- String )Serialize value to SON format (pretty)

Stack Introspection

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
array-with( JsonArray JsonValue -- JsonArray )Append element to array
obj-with( JsonObject JsonString JsonValue -- JsonObject )Add key-value pair

Type Predicates

WordStack EffectDescription
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

WordStack EffectDescription
json-unwrap-bool( JsonValue -- Bool )Extract boolean
json-unwrap-number( JsonValue -- Float )Extract number
json-unwrap-string( JsonValue -- String )Extract string

Parsing & Serialization

WordStack EffectDescription
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.

WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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]
WordStack EffectDescription
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
WordStack EffectDescription
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
WordStack EffectDescription
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

WordStack EffectDescription
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
WordStack EffectDescription
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

WordStack EffectDescription
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

WordStack EffectDescription
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)

WordDescription
signal.SIGINTInterrupt (Ctrl+C)
signal.SIGTERMTermination request
signal.SIGHUPHangup
signal.SIGPIPEBroken pipe
signal.SIGUSR1User-defined 1
signal.SIGUSR2User-defined 2

Convenience Words

WordStack EffectDescription
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
WordStack EffectDescription
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, ..rest when 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:

FeatureTraditional GenericsRow Polymorphism
Abstraction unitSingle type (T)Sequence of types (..a)
Fixed arityFunction has fixed param countStack depth is variable
CompositionExplicit argument passingImplicit stack threading
Use caseCollections, containersStack 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 true and false. Comparison and logical operations produce Bool; if requires Bool.

Type Variables

  • A single uppercase letterT, 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:

  1. Starts with declared input stack
  2. Processes each statement, tracking stack changes
  3. 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 ):

  1. Unify effect input with current stack:

    • Effect input: ..a Int Int
    • Current stack: Int Int Int
    • Unification: ..a = Int (row variable binds to Int)
  2. Apply substitution to effect output:

    • Effect output: ..a Int
    • Substitute ..a = Int: Int Int
    • Result stack: ( Int Int )

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 terminal
  • UPPERCASE - lexical tokens
  • lowercase - 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 names Int, 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

DelimiterUsageExample
. (dot)Module/namespace prefixio.write-line, net.tcp.listen
- (hyphen)Compound wordshome-dir, write-line
-> (arrow)Type conversionsint->string, float->int
? (question)Predicateslist.empty?, map.has?

For each union definition, the compiler auto-generates helper words by convention. Given union Shape { Circle { radius: Int } … }:

Generated wordShapeExample
Make-<Variant>constructor5 Make-Circle
is-<Variant>?predicateshape is-Circle?
<Variant>-<field>field accessorcircle 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 a Closure[ … ] 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

  1. std: prefix - References stdlib bundled with compiler

    • Compiler knows where stdlib lives (not user’s concern)
    • Example: include std:http loads http.seq from stdlib
  2. ffi: prefix - References FFI bindings for C libraries

    • Some bindings ship with compiler (e.g., ffi:libedit)
    • Others require --ffi-manifest flag with a TOML manifest
    • Example: include ffi:libedit loads readline-style functions
    • See FFI_GUIDE.md for full documentation
  3. 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
  4. Extension omitted - Compiler adds .seq automatically

  5. 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

  1. 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
  2. Merge programs - Combine all WordDefs into single Program

  3. Check collisions - Before type checking:

    • Build map of word name -> definition location
    • Error if any word has multiple definitions
  4. Continue normally - Type check and codegen as before

Stdlib Location

The compiler locates the stdlib in this order:

  1. SEQ_STDLIB environment variable (if set to a valid directory)
  2. stdlib/ directory relative to the compiler binary (for installed builds)
  3. Embedded stdlib compiled into the binary (fallback)

Path Validation

Include paths are validated:

  1. Absolute Path Rejection - Absolute paths are rejected; all includes must be relative
  2. Empty Path Validation - Empty include paths are rejected
  3. Canonicalization - Paths are canonicalized to resolve symlinks and normalize .. segments
  4. File Must Exist - The target .seq file 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

TermDescription
WeaveA suspended computation that yields values and receives resume values
HandleA WeaveCtx value used to resume and communicate with a weave
YieldPause execution, send a value to the caller, wait for a resume value
ResumeSend a value into a paused weave, receive its next yielded value

Core Operations

WordEffectDescription
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:

  1. Resume to completion - Keep resuming until has_more is false
  2. Cancel explicitly - Call strand.weave-cancel to 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

FeatureSeq WeavesPython GeneratorsJavaScript Generators
BidirectionalYes (yield/resume)Yes (send())Yes (next(value))
ContextExplicit stack threadingImplicitImplicit
ConcurrencyBuilt on strands/channelsSingle-threadedSingle-threaded
Cancellationstrand.weave-cancelClose iteratorreturn()
Type systemYield effect trackedUntypedUntyped

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 CaseMechanism
Independent concurrent tasksstrand.spawn
Producer/consumer with backpressureWeaves
Request/response patternsWeaves
Fire-and-forget parallelismstrand.spawn
Lazy sequencesWeaves
Stream transformationsWeaves

See Also

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

  1. Concatenative languages favor recursion - Without built-in loop constructs, recursion is the natural way to express iteration in Seq

  2. SeqLisp and embedded languages - Languages implemented in Seq (like SeqLisp) have recursive interpreters. Without TCO, both the interpreter recursion AND user program recursion compound

  3. Coroutine stack limits - Strands have fixed stacks (128KB by default, configurable via SEQ_STACK_SIZE). TCO reduces pressure on these stacks

  4. 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

  1. 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)
  2. No @tailrec annotation

    • 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 TypeC TypeSeq TypeNotes
intint/longInt64-bit on Seq side
stringchar*StringNull-terminated
ptrvoid*IntRaw pointer as integer
voidvoid(nothing)No return value

Pass Modes

The pass field controls how arguments are passed to C:

ModeDescription
c_stringConvert Seq String to null-terminated char*
ptrPass raw pointer value (Int on stack)
intPass as C integer
by_refAllocate storage, pass pointer (for out params)

Memory Ownership

The ownership field on returns controls memory management:

ModeDescriptionCodegen
caller_freesC malloc’d it, we must freeGenerates free() call
staticLibrary owns memory, don’t freeJust copy, no free
borrowedOnly valid during callCopy 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:

  1. Compiler allocates local storage
  2. Passes pointer to that storage to C function
  3. 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:

  1. Opt-in boundary: Using include ffi:* is the explicit safety boundary
  2. Stack effects enforced: Type checker validates declared effects
  3. Memory managed by codegen: Ownership annotations prevent leaks
  4. 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:

WordStack EffectDescription
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_ref out parameters for database handles
  • Fixed null values 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

ToolTriggerOverheadWhat You Get
SIGQUIT dumpkill -3 <pid>Zero until triggeredLive snapshot: strands, memory, registry
WatchdogSEQ_WATCHDOG_SECS=NNear-zero (periodic scan)Alerts when strands run too long
At-exit reportSEQ_REPORT=1Near-zeroWall clock, strands, memory, channels
Instrumentation--instrument + SEQ_REPORT=words~1 atomic per word callPer-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

VariableDefaultDescription
SEQ_WATCHDOG_SECS0 (disabled)Threshold in seconds for “stuck” strand
SEQ_WATCHDOG_INTERVAL5How often to check (seconds)
SEQ_WATCHDOG_ACTIONwarnWhat 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

ValueFormatDestination
unset or 0No report (zero cost)
1Human-readablestderr
jsonJSONstderr
json:/path/to/fileJSONFile
wordsHuman-readable + word countsstderr (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

MetricDescription
Wall clockTotal time from scheduler init to program exit
Strands spawnedTotal number of strands created
Strands doneTotal number of strands that completed
Peak strandsMaximum concurrent strands at any point
Worker threadsNumber of OS threads with active arenas
Arena currentCurrent arena memory across all threads
Arena peakPeak arena memory across all threads
Messages sentTotal channel send operations
Messages recvTotal channel receive operations
Word countsPer-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:

  1. The compiler emits a global array of i64 counters (one per word)
  2. Each word’s entry point gets a single atomicrmw add monotonic instruction
  3. At program startup, the counter array and word name table are registered with the runtime
  4. At exit, SEQ_REPORT reads 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 -3 has no effect (no signal handler installed)
  • Watchdog is not compiled
  • SEQ_REPORT still 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

VariableDefaultDescription
SEQ_REPORTunset (disabled)At-exit KPI report format and destination
SEQ_WATCHDOG_SECS0 (disabled)Stuck-strand detection threshold (seconds)
SEQ_WATCHDOG_INTERVAL5Watchdog check frequency (seconds)
SEQ_WATCHDOG_ACTIONwarnWatchdog action: warn or exit

See Also

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:

PlatformRustSeqGoMeasured
Linux x86_64305 KB732 KB1.50 MB2026-05-26 · rustc 1.95.0 · seqc 7.5.6 · go 1.25.9
Darwin arm64303 KB1.38 MB1.66 MB2026-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_REPORT KPI 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

  1. 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.

  2. Functional style - Operations produce new values rather than mutating. list.push returns a new list, it doesn’t modify the original.

  3. Static typing with inference - Stack effects are checked at compile time. Row polymorphism (..rest) allows generic stack-polymorphic functions.

  4. Concatenative composition - Functions compose by juxtaposition. f g means “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 top
  • pop(stack) -> (stack', value) - Remove and return top
  • dup, 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:

  1. Assigns fresh type variables to unknowns
  2. Collects constraints from operations
  3. Unifies constraints to solve for types
  4. 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:

  1. No async coloring - With may, a Seq strand.spawn creates a fiber that can call blocking operations (channel send/receive, I/O) and implicitly yield. No async/await syntax pollution spreading through the call stack.

  2. Erlang/Go mental model - Fits Seq’s concatenative style naturally. [ code ] strand.spawn creates 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.

  3. Simpler FFI - LLVM-generated code calls synchronous Rust functions. No async runtime ceremony or Future plumbing required.

  4. 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:

ModelMappingProsCons
Green threads (early Java)M:1Cheap, fast switchSingle CPU only
Native OS threads1:1Multi-CPUExpensive (~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:

VariableDefaultDescription
SEQ_STACK_SIZE131072 (128KB)Coroutine stack size in bytes
SEQ_POOL_CAPACITY10000Cached coroutine pool size
SEQ_WATCHDOG_SECS0 (disabled)Threshold for “stuck strand” detection
SEQ_WATCHDOG_INTERVAL5Watchdog check frequency (seconds)
SEQ_WATCHDOG_ACTIONwarnAction on stuck strand: warn or exit
SEQ_REPORTunset (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_REPORT env var dumps KPIs (wall clock, strands, memory, channels) when the program exits. Compile with seqc build --instrument to 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

  1. Parse - Tokenize and build AST (parser.rs)
  2. Type Check - Infer and verify stack effects (typechecker.rs)
  3. Codegen - Emit LLVM IR (codegen.rs)
  4. 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

  1. No loop keywords - Use recursion with TCO (tail call optimization is guaranteed)
  2. Serialization size limits - Arrays > 3 elements, objects > 2 pairs show as [...]/{...}
  3. roll type checking - 3 roll works 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 by rustup for local development
  • .forgejo/workflows/*.yml — every workflow that calls dtolnay/rust-toolchain@master declares the same explicit toolchain: 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:

  1. Sends a value to whoever called strand.resume
  2. Pauses execution at exactly that point
  3. Waits for the next strand.resume to 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

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:

FeatureDetails
Naming conventionsDot for namespaces, hyphen for compounds, arrow for conversions
OS moduleos.getenv, os.home-dir, os.path-*, args.count, args.at, os.exit, os.name, os.arch
FFIManifest-based C bindings, string marshalling, out parameters. Examples: libedit, SQLite
Runtime observabilitySIGQUIT diagnostics, watchdog timer, strand/channel/memory stats
Yield safety valveAutomatic yields in tight loops to prevent strand starvation
LSP serverLanguage server with completions, hover, diagnostics. Powers TUI and editor integrations
TUI REPLDefault REPL with split-pane IR visualization, Vi editing, tab completion
Union types & matchunion definitions, match expressions, exhaustiveness checking, named bindings
ClosuresLexical capture with Arc-shared environments, TCO-compatible
Channels as valuesFirst-class Channel type on the stack (no ID-based lookup)
WeavesGenerator/coroutine pattern with bidirectional communication
Lists & MapsFirst-class collection types with list.map, list.filter, list.fold, map.*
Symbols:foo syntax for lightweight identifiers, used for variant tags
Lint toolseqc 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:

  1. Inferred variant types - Compiler tracks that Make-Ok produces a specific union type
  2. Flow typing through combinators - If result-bind receives IntResult, infer the quotation expects Int
  3. Structural typing for conventions - Recognize Result-like patterns at compile time
  4. Constructor argument refinement - 42 Make-Ok infers IntResult from the Int argument

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:

CapabilityRust CrateQuality
Crypto hashingsha2RustCrypto, audited
HMAChmacRustCrypto, audited
Encryptionaes-gcmRustCrypto, audited
Signaturesed25519-dalekAudited, widely used
HTTP clienthand-rolled over may + rustlsStrand-yielding HTTP/1.1, no ureq dependency
TLSrustlsMemory-safe, modern
RegexregexFastest in class
Compressionflate2, zstdFast, well-maintained
RandomrandIndustry standard
UUIDuuidComplete implementation
DatabaserusqliteMature, stable (not yet integrated)

Pattern Already Proven

This isn’t theoretical - Seq already uses this pattern successfully:

Existing BuiltinRust Foundation
TCP networkingmay (coroutine-aware)
File I/Ostd::fs
Channelscrossbeam
Timestd::time
String opsstd::string
Base64/Hex encodingbase64, hex
Crypto (SHA-256, HMAC, AES-GCM, PBKDF2, Ed25519, Random, UUID)sha2, hmac, aes-gcm, pbkdf2, ed25519-dalek, rand, uuid
Regular expressionsregex
Compressionflate2, zstd
Arena allocatorCustom, 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

  1. Security: Audited Rust crates vs. hand-rolled crypto
  2. Speed: Zero-cost abstractions, no interpreter overhead
  3. Reliability: Rust’s type system catches bugs at compile time
  4. Velocity: Days to add features, not months
  5. Maintenance: Crate updates flow through automatically
  6. 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 bypass seqc and depend on the runtime directly. These are a Cargo-build internal — they are never surfaced through seqc build and have no bearing on what a Seq source program can reference.


Current State

Runtime Builtins (Rust FFI)

CategoryCapabilities
CoreStack ops, arithmetic, booleans, bitwise
TypesInt, Float, Bool, String, Symbol, List, Map, Variant
StringsConcat, split, trim, case conversion, JSON escape
I/Ostdin/stdout, file read/write, path operations
ConcurrencyChannels, strands (green threads), weave (coroutines)
NetworkingTCP (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
TimeUnix timestamp, high-res time, sleep
TestingAssertions, test runner, pass/fail counts
SerializationSON format (Seq Object Notation)
EncodingBase64 (standard + URL-safe), Hex
CryptoSHA-256, HMAC-SHA256, AES-256-GCM, PBKDF2, Ed25519 signatures, secure random, UUID v4
HTTP ClientGET, POST, PUT, DELETE with TLS support
Regexmatch, find, find-all, replace, captures, split, valid?
Compressiongzip, gunzip, zstd, unzstd with compression levels
OSArgs, env vars, path operations, exec, exit

Standard Library (Pure Seq)

Located in crates/compiler/stdlib/ (~2900 lines):

ModuleLinesDescription
json.seq1166Full JSON parser and encoder
yaml.seq752YAML parser
zipper.seq328Functional list zipper
http.seq190HTTP response building, request parsing
imath.seq145Integer math utilities (abs, min, max, clamp)
fmath.seq109Float math utilities
signal.seq66Signal handling
son.seq57SON serialization helpers
list.seq55List helpers
stack-utils.seq46Stack manipulation utilities
map.seq30Map 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”

CategoryStatusPriority
HTTP clientCompleteHigh
RegexCompleteMedium
TLS/HTTPSComplete (via rustls)Medium
TemplatesNot startedMedium
seq fmtNot startedMedium
seq.tomlNot startedMedium
LoggingNot startedLow
CompressionCompleteLow
DatabaseNot startedFuture
HTML parsingNot startedFuture

Already Mature

CategoryStatus
JSONComplete (1166 lines)
YAMLComplete (752 lines)
HTTP serverWorking (helpers + example)
HTTP clientComplete (builtin) - GET, POST, PUT, DELETE with TLS
RegexComplete (builtin) - match, find, replace, captures, split
CompressionComplete (builtin) - gzip, zstd with levels
LSPComplete (2200+ lines) - diagnostics, completions
REPLComplete - with LSP integration, vim keybindings
TestingComplete (builtin)
Result/OptionConvention-based (value Bool) pattern, no stdlib module
Base64/HexComplete (builtin) - standard, URL-safe, hex
Crypto Phase 1Complete (builtin) - SHA-256, HMAC, random, UUID
Crypto Phase 2Complete (builtin) - AES-256-GCM encryption, PBKDF2 key derivation
Crypto Phase 3Complete (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 / rustls TLS
  • TLS: Built-in via rustls with ring crypto 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 via Connection: 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 operations
  • examples/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

APIRust CrateUse Cases
crypto.sha256sha2Checksums, content addressing, password hashing input
crypto.hmac-sha256hmac + sha2Webhook verification, JWT signing, API auth
crypto.random-bytesrandTokens, nonces, salts, session IDs
crypto.uuid4uuidUnique identifiers
crypto.constant-time-eqcustomTiming-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

APIRust CrateUse Cases
crypto.aes-gcm-encryptaes-gcmEncrypting data at rest, secure storage
crypto.aes-gcm-decryptaes-gcmDecrypting data
crypto.pbkdf2-sha256pbkdf2Password-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

APIRust CrateUse Cases
crypto.ed25519-keypaired25519-dalekGenerate signing keys
crypto.ed25519-signed25519-dalekDigital signatures
crypto.ed25519-verifyed25519-dalekSignature 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

AlgorithmBest ForLevel Range
gzipWeb content, HTTP compression, broad compatibility1-9
zstdLarge data, better ratio, modern systems1-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

PatternExampleMeaning
noun.verbnet.http.get, json-serializeAction on type
noun?list.empty?, map.has?Predicate
->string->int, int->floatConversion

Comparison: Seq vs Go

AspectGoSeq
ParadigmImperative, structuralStack-based, functional
ConcurrencyGoroutines + channelsStrands + channels
Error handlingerror return valueResult types on stack
GenericsType parametersRow polymorphism
Buildgo buildseqc build
PackagesModule pathinclude std:module
Std library~150 packages~15 modules (focused)

References

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_yieldmusttail 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/else with self-call in one branch) before handling complex control flow.

Approach

Pattern Detection (in codegen, not parser)

When emitting a word body, check:

  1. Word has exactly one if/else/then at the top level
  2. One branch contains a self-tail-call as the last statement
  3. 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

  1. Phase 1: Detect simplest pattern (single if/else, self-call in one branch). Emit loop. Keep musttail as fallback for everything else. Gate behind --loop-opt flag.
  2. Phase 2: Handle match expressions with self-call in one arm.
  3. 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 recursionping/pong patterns stay as musttail.
  • Collection iteration overheadlist.map calls 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

  1. fib(40) under 500ms (currently 2200ms) — fibonacci is the classic self-recursive benchmark
  2. sum_squares under 10ms (currently 48ms) — tight arithmetic loop
  3. primes under 20ms (currently 84ms) — nested loops with modulo
  4. leibniz_pi under 500ms (currently 2900ms) — 4-value state loop
  5. --loop-opt flag — opt-in initially, default later after validation
  6. All existing tests pass — no regressions