ezc
A stack-based programming language with arbitrary-precision integers, 16 numeric types, and first-class editor tooling. Code is a flat sequence of values and operators in reverse Polish notation.
# sum of squares from 1 to 10
10 iota (sq) map sum
No ceremony. No types to declare. Just push values, apply operators.
Why ezc?
- Big integers by default —
intis arbitrary-precision. Fixed-width types (u8–u256,i8–i256,f16–f64) are opt-in. - Mathematical focus — clean operator algebra, type-family promotion, no surprises with overflow.
- First-class tooling — debugger (DAP), language server (LSP), and extensions for VS Code, Neovim, Zed, and Helix.
- Runs in the browser — every code block on this site is runnable.
Where to start
- Getting started — install and run your first program
- Stack basics — learn the language from the ground up
- Operators — the full reference
- Playground — try it now, no install needed
Getting started
Install
ezc is a Rust project. Build from source:
git clone https://github.com/cadebrown/ezc
cd ezc
cargo install --path crates/ezc-cli
This installs the ezc binary. Verify:
ezc -e "3 4 +"
# → 7
Your first program
Create hello.ezc:
"Hello, ezc!" wl
Run it:
ezc hello.ezc
wl writes a string with a newline. The string is pushed onto the stack,
then wl pops and prints it.
You can also evaluate a string directly:
ezc -e "3 4 +"
# → 7
Or check for syntax errors without running:
ezc -c hello.ezc
The REPL
Run ezc with no arguments to start the interactive REPL:
ezc
You'll see the stack update after each line:
> 3 4 +
[7]
> 2 *
[14]
Press Ctrl+D to exit. Use --no-tui for a plain reedline interface
without the boxed UI.
Editor support
ezc ships with first-class support for VS Code, Neovim, Zed, and Helix. See the editor setup page for installation instructions.
Next steps
- Stack basics — push, pop, operators
- Variables —
@bindand$recall - Functions — blocks,
!, defining your own
Stack basics
ezc has a single, global stack. Every value you write is pushed onto it. Every operator pops its arguments off and pushes its result back.
Pushing values
3
That program pushes the integer 3. Running it leaves a stack of [3].
Multiple values stack up:
1 2 3
The stack is [1 2 3] — 3 is on top.
Operators
+ pops two numbers and pushes their sum:
3 4 +
Step by step:
3→ stack[3]4→ stack[3 4]+pops3and4, pushes7→ stack[7]
This is reverse Polish notation. There are no parentheses for grouping arithmetic, and no operator precedence — the order is determined entirely by the stack.
3 4 + 2 *
That's (3 + 4) * 2 = 14. Try it.
All the arithmetic operators
10 3 + # add → 13
10 3 - # subtract → 7
10 3 * # multiply → 30
10 3 / # divide → 3 (integer division)
10 3 % # modulo → 1
2 10 ^ # power → 1024
Comparisons push 0 or 1
There is no separate boolean type. Comparisons push 1 for true, 0
for false:
3 4 < # → 1
4 3 < # → 0
3 3 == # → 1
Stack manipulation
Sometimes you want to reorder, duplicate, or drop values:
| op | effect | description |
|---|---|---|
, | a → a a | dup: copy the top |
; | a → | drop: discard the top |
~ | a b → b a | swap: exchange the top two |
_ | a b → a b a | over: copy the second to the top |
Try it:
5 , # → [5 5]
5 ; # → []
1 2 ~ # → [2 1]
1 2 _ # → [1 2 1]
Comments
Anything after # on a line is a comment:
3 4 + # this is a comment, the result is 7
What's next
- Variables and scopes — naming values
Variables and scopes
Binding with @
@name pops the top of the stack and binds it to a name:
42 @x
After this, the stack is empty and x holds 42.
Recalling with $
$name pushes the value of a name onto the stack:
42 @x
$x $x +
That's 42 + 42, leaving [84].
Rebinding is allowed
Each @x rebinds. The new value replaces the old:
1 @x
$x # → 1
2 @x
$x # → 2
Scopes
{ ... } introduces a local scope. Bindings made inside the braces
disappear when the scope ends:
10 @x
{ 99 @x $x } # local x = 99
$x # outer x is still 10
The block above leaves [99 10] on the stack — first the 99 recalled
inside the scope, then the outer x.
Bare names
A bare identifier (no sigil) is treated like a recall, but if the value is a block it executes. We'll see that in the next chapter.
Why both @ and $?
The sigils make data flow obvious. Reading @x you immediately know
something is being stored; reading $x you know something is being
fetched. There's no ambiguity about which way the value moves.
What's next
- Blocks and functions — defining reusable code
Blocks and functions
A block is a piece of deferred code wrapped in parentheses. Blocks are values — you can push them, name them, and execute them on demand.
Pushing a block
(3 4 +)
This pushes a block onto the stack. The body 3 4 + is not executed
yet. The stack contains a single block value.
Executing a block with !
! pops a block and runs it:
(3 4 +) !
Step by step:
(3 4 +)→ stack[(...)]!pops the block and runs3 4 +→ stack[7]
Functions are named blocks
A function in ezc is just a block bound to a name:
(, *) @square
5 $square !
Reading from left to right:
(, *)— push a block. Its body uses,(dup) then*.@square— bind the block tosquare.5— push 5.$square— recall the block onto the stack.!— execute it.,makes the stack[5 5], then*gives[25].
Bare-word call
Pushing-then-executing with $square ! is common, so ezc lets you write
just the name:
(, *) @square
5 square # same as: 5 $square !
When a bare identifier resolves to a block, it auto-executes.
Multiple arguments
A function in ezc is just a block — the body operates on whatever is on the stack. Whoever calls it puts the arguments there.
A two-argument adder:
(+) @add
3 4 add # → 7
A function that doubles its input, adds 1, then squares:
(2 * 1 + sq) @f
3 f # 3 → 6 → 7 → 49
Try writing a cube function. It needs to leave n³ on the stack:
# Define cube here, then test with: 3 cube
(One answer: (, , * *) @cube — dup twice, then multiply twice.)
Higher-order
Blocks can be passed to operators like &! (map). More on that in the
next chapter on lists.
What's next
- Lists and higher-order ops — map, filter, fold
Lists and higher-order ops
[...] creates a list. Unlike blocks, list contents are evaluated
eagerly — each value is pushed onto a temporary stack and gathered into
a single list when the ] is reached.
[1 2 3]
That leaves [[1 2 3]] — a stack with one item, which is a list of
three integers.
Building lists
[1 2 + 3 4 +] # → [[3 7]]
Expressions inside [...] execute, and the leftover values become the
list elements.
range and iota
For numeric sequences:
0 5 range # → [[0 1 2 3 4]]
5 iota # → [[0 1 2 3 4]] (prelude: 0..n-1)
Map (&!)
&! applies a block to every element of a list:
[1 2 3 4] (sq) &!
sq is a prelude function ((, *)), squaring each element. Result:
[[1 4 9 16]].
The named alias map does the same:
[1 2 3 4] (sq) map
Filter (&?)
&? keeps elements where the block evaluates to a truthy value:
[1 2 3 4 5 6] (even) &?
Result: [[2 4 6]].
Fold (&/)
&/ reduces a list with a block, given an initial accumulator:
[1 2 3 4] 0 (+) &/
That's ((((0 + 1) + 2) + 3) + 4) = 10.
The prelude has sum and prod for the common cases:
[1 2 3 4] sum # → [10]
[1 2 3 4] prod # → [24]
Sum of squares
Combine map and sum:
10 iota (sq) map sum
That's 0² + 1² + 2² + ... + 9² = 285.
List utilities
[1 2 3] len # → 3
[1 2 3] 0 nth # → 1 (zero-indexed)
[1 2 3] rev # → [[3 2 1]]
[3 1 2] srt # → [[1 2 3]]
[1 2 3] [4 5 6] zip # → [[[1 4] [2 5] [3 6]]]
[1 2 3 4 5] 2 take # → [[1 2]]
[1 2 3 4 5] 2 skip # → [[3 4 5]]
[[1 2] [3 4]] flat # → [[1 2 3 4]]
What's next
- Numeric types — int, u8…u256, i8…i256, f16/f32/f64
Numeric types
ezc has 16 numeric types organized into three families. The default, unsuffixed integer type is arbitrary-precision — no silent overflow.
The default: int
A bare integer literal is int — backed by BigInt:
2 100 ^ # → 2¹⁰⁰ (a 31-digit number, exact)
Try the same with a fixed-width type and you'll see overflow:
2u64 100u64 ^ # error: overflow
Three families
| Family | Types |
|---|---|
| Integer | int (BigInt — default) |
| Unsigned | u8, u16, u32, u64, u128, u256 |
| Signed | i8, i16, i32, i64, i128, i256 |
| Float | f16, f32, f64 |
Typed literals
Suffix a literal with a type name:
42u8 # u8
42i64 # i64
3.14f32 # f32
0xFFu16 # hex literal as u16
Type constructors
Type names are also conversion functions. 42 f32 means "push 42, then
convert it to f32":
42 f32 # → 42.0f32
3.7 int # → 3 (truncates float)
42 str # → "42" (number to decimal string)
Arithmetic promotion
Operations within the same family promote to the wider type:
3u8 4u32 + # → 7u32 (u8 promoted to u32, both unsigned)
1i16 2i64 + # → 3i64 (i16 promoted to i64, both signed)
1.0f32 2.0f64 + # → 3.0f64 (f32 promoted to f64, both float)
Cross-family arithmetic is an error — int, unsigned, signed, and float are separate families. You must convert explicitly:
3 4u32 + # error: int and u32 are different families
3 u32 4u32 + # → 7u32 (convert 3 to u32 first)
3 4.0 + # error: int and f64
3 f64 4.0 + # → 7.0f64
typeof
42 typeof # → "int"
42u8 typeof # → "u8"
3.14 typeof # → "f64"
"hello" typeof # → "str"
[1 2] typeof # → "list"
(+) typeof # → "block"
What's next
- I/O and modules — read, write, import
I/O and modules
Writing output
Three operators write to stdout:
| op | type effect | description |
|---|---|---|
: | a → | print top of stack with newline |
wl | a → | named alias of : |
wb | int → | write a single byte |
"Hello!" : # prints: Hello!
42 : # prints: 42
Reading input
| op | effect | description |
|---|---|---|
. | → str | read a line from stdin |
rl | → str | named alias of . |
rb | → int | read a single byte |
"What's your name? " :
. @name
"Hello, " $name | :
| (compose) concatenates strings, lists, or blocks.
Imports
import loads another .ezc file (or an embedded standard module):
"std/math.ezc" import
5 fact # → 120
The path is a string. The first lookup is in the embedded modules
shipped with the compiler (std/math.ezc, std/str.ezc); if not found,
ezc reads from disk relative to the current directory.
Imports are guarded — importing the same path twice is a no-op.
Putting it together
A complete program that prints the first ten factorials:
"std/math.ezc" import
1 11 range
(@n
$n str " factorial is " | $n fact str | :
) each
Note the str calls — | (compose) only joins values of the same kind
(string with string, list with list, block with block). To stitch
numbers into a sentence, convert them to strings first.
Errors
Errors include the source span, file, line, and column:
error: stack underflow
╭─[example.ezc:3:5]
3 │ +
│ ┬
│ ╰── operator + needs 2 values, found 1
╯
The DAP debugger lets you step through programs and inspect the stack at every point — see the editor setup page.
You're done with the tutorial
You've covered the whole language. Browse the reference for details:
Operators
Every operator in ezc, organized by category. The "stack effect" column
uses the convention before → after, listing the items closest to the
top of the stack rightmost.
Arithmetic
| op | stack effect | description |
|---|---|---|
+ | a b → (a+b) | add (within numeric family, promotes) |
- | a b → (a-b) | subtract |
* | a b → (a*b) | multiply |
/ | a b → (a/b) | divide (integer for ints, float for floats) |
% | a b → (a%b) | modulo |
^ | a b → (a^b) | power |
3 4 +
Comparison
All push 1 for true, 0 for false. There is no separate boolean type.
| op | stack effect | description |
|---|---|---|
== | a b → bool | equal |
!= | a b → bool | not equal |
< | a b → bool | less than |
> | a b → bool | greater than |
<= | a b → bool | less than or equal |
>= | a b → bool | greater than or equal |
Stack manipulation
| op | stack effect | name |
|---|---|---|
, | a → a a | dup |
; | a → | drop |
~ | a b → b a | swap |
_ | a b → a b a | over |
I/O
| op | stack effect | description |
|---|---|---|
: | a → | write line (any type) |
. | → str | read line |
wl | a → | write line (alias of :) |
rl | → str | read line (alias of .) |
wb | int → | write a single byte |
rb | → int | read a single byte |
Control flow
| op | stack effect | description |
|---|---|---|
! | varies | execute: pops a block/list/string and runs/splats/evals |
? | cond block → | conditional execute (only runs block if cond truthy) |
?? | cond then else → result | ternary: pick one branch by truthiness |
& | cond-block body-block → | while loop |
&! | list block → list | map |
&? | list block → list | filter |
&/ | list init block → acc | fold (left-to-right) |
1 (10 :) ? # prints 10 because cond was truthy
0 (10 :) ? # prints nothing
1 (10) (20) ?? # → 10
Compose
| op | stack effect | description |
|---|---|---|
| | a b → (a|b) | concatenate strings, lists, or blocks |
"foo" "bar" | # → "foobar"
[1 2] [3 4] | # → [1 2 3 4]
Variables
| form | description |
|---|---|
@name | bind: pop top of stack, store in name |
$name | recall: push value of name |
name | bare: recall, but auto-execute if value is a block |
Containers
| syntax | description |
|---|---|
(...) | block — deferred code, executed by ! |
[...] | list — eager evaluation, gathers leftovers |
{...} | scope — evaluates immediately with local bindings |
Stack introspection
| op | stack effect | description |
|---|---|---|
depth | → int | current stack height |
clear | varies | drop everything on the stack |
words | → list | list all currently-defined names |
Builtins
Built-in named operations, beyond the symbolic operators.
Collection
| name | stack effect | description |
|---|---|---|
len | coll → int | length of list / string / binary |
nth | coll i → elem | zero-indexed access |
tl | list → list | tail (drop first) |
rev | list → list / str → str | reverse |
srt | list → list | sort |
take | coll n → coll | first n elements |
skip | coll n → coll | drop first n elements |
zip | lhs rhs → list | pair-wise zip into list of pairs |
range | lo hi → list | integer range [lo .. hi-1] |
cut | str delim → list | split string by delimiter |
cat | list delim → str | join list with delimiter |
flat | list → list | flatten one level (prelude) |
Higher-order (named aliases)
| name | symbol | description |
|---|---|---|
each | — | iterate over a list or count |
map | &! | apply block to each element |
fil | &? | filter |
red | &/ | fold/reduce |
loop | & | while loop |
if | ? | conditional |
ifel | ?? | ternary |
Reflection
| name | stack effect | description |
|---|---|---|
typeof | a → str | type name string |
words | → list | all currently-defined names |
depth | → int | current stack height |
clear | → | drop all stack values |
Type constructors
Every type name is a constructor. They convert the top-of-stack value to the given type, or fail with a useful error.
int str bin
u8 u16 u32 u64 u128 u256
i8 i16 i32 i64 i128 i256
f16 f32 f64
42 f64 # → 42.0
3.7 int # → 3 (truncates)
42 str # → "42" (number to decimal string)
Modules
| name | stack effect | description |
|---|---|---|
import | path → | load and evaluate a file |
Type system
ezc is dynamically typed. Every value carries its type at runtime and operators check types when popping.
Value types
| type | description |
|---|---|
int | arbitrary-precision integer (BigInt) |
u8–u256 | fixed-width unsigned (8/16/32/64/128/256-bit) |
i8–i256 | fixed-width signed |
f16/f32/f64 | IEEE-754 floating point |
str | immutable, interned UTF-8 string |
bin | immutable, interned byte buffer |
list | heterogeneous sequence of values |
block | deferred code (sequence of expressions) |
Numeric families
Arithmetic operators only work between values in the same family:
- Integer family —
int - Unsigned family —
u8,u16, ...,u256 - Signed family —
i8,i16, ...,i256 - Float family —
f16,f32,f64
Within a family, operations promote to the wider type:
3u8 4u32 + # → 7u32 (u8 promoted)
Cross-family is an error — int, unsigned, signed, and float are
separate families. Convert explicitly:
3 4u8 + # error: int and u8 are different families
3 u8 4u8 + # → 7u8 (convert 3 to u8 first)
int (BigInt) is its own family. Within int, all ops are exact:
2 100 ^ # 2^100 as int (no overflow)
Fixed-width types overflow (and error) when results exceed their range:
2u8 7u8 ^ # 128u8 — fits
2u8 8u8 ^ # error: overflow in u8
Truthiness
Used by ?, ??, & and the higher-order ops:
| type | falsy when |
|---|---|
| number | zero |
str | empty |
bin | empty |
list | empty |
block | never (always truthy) |
Interning
str, bin, and int (BigInt) values are interned per engine instance.
Equality is constant-time pointer comparison; copying is shared via
Arc. This makes large strings and integers cheap to pass around.
Spans
Every value on the stack carries a source span — the byte range of the expression that pushed it. The DAP debugger and ariadne error reports use these spans to highlight the exact token responsible for a mismatch, not just a line number.
Standard library
The standard library lives in std/. The prelude is loaded
automatically before every program. Other modules are loaded with
import.
Prelude (auto-loaded)
Stack combinators
| name | stack effect | description |
|---|---|---|
dup | a → a a | named alias of , |
drop | a → | named alias of ; |
swap | a b → b a | named alias of ~ |
over | a b → a b a | named alias of _ |
nip | a b → b | drop second |
tuck | a b → b a b | dup top, hide under second |
dup2 | a b → a b a b | dup pair |
rot | a b c → b c a | rotate three |
id | a → a | identity (no-op) |
Predicates
| name | stack effect | description |
|---|---|---|
zero | a → bool | is zero |
even | a → bool | is even |
odd | a → bool | is odd |
ltz | a → bool | less than zero |
gtz | a → bool | greater than zero |
dvb | a b → bool | a divisible by b |
Logic
| name | stack effect | description |
|---|---|---|
not | a → bool | logical negation |
and | a b → bool | logical and |
or | a b → bool | logical or |
Math
| name | stack effect | description |
|---|---|---|
inc | a → a+1 | increment |
dec | a → a-1 | decrement |
sq | a → a*a | square |
neg | a → -a | negate |
abs | `a → | a |
sum | [list] → n | sum of a list |
prod | [list] → n | product of a list |
iota | n → list | [0..n-1] |
min | a b → m | minimum |
max | a b → m | maximum |
Collections
| name | stack effect | description |
|---|---|---|
hd | [list] → first | head |
flat | [[..]] → [..] | flatten one level |
apply | [args] (block) → | splat args, then execute block |
Misc
| name | description |
|---|---|
dfl | val fallback dfl → val if truthy, fallback otherwise |
peek | a → a (also prints a) — debugging aid |
std/math.ezc
"std/math.ezc" import
| name | stack effect | description |
|---|---|---|
fact | n → n! | factorial |
fib | n → fib(n) | nth Fibonacci |
gcd | a b → g | Euclidean GCD |
pow2 | b e → b^e | integer power for any width |
clamp | v lo hi → v' | clamp to range |
sgn | n → -1|0|1 | signum |
std/str.ezc
"std/str.ezc" import
| name | stack effect | description |
|---|---|---|
rep | s n → s' | repeat string n times |
padl | s w c → s' | pad-left to width with char |
has | s sub → bool | contains substring |
starts | s pre → bool | starts with prefix |
ends | s suf → bool | ends with suffix |
Editor setup
ezc ships with first-class plugins for four editors. All connect to the
same ezc-lsp (language server) and ezc-dap (debugger) binaries, so
you get the same features everywhere: diagnostics, hover, completions,
go-to-definition, rename, references, code lens, inlay hints, code
actions, formatting, folding, document symbols, signature help, and
full step-debugging.
Build the binaries first:
cargo install --path crates/ezc-cli
cargo install --path crates/ezc-lsp
cargo install --path crates/ezc-dap
VS Code
The extension lives at editors/vscode-ezc/. It includes a TextMate
grammar, snippets, and configuration for both the LSP client and a DAP
launch profile.
cd editors/vscode-ezc
npm install
npm run compile
# then: F5 in VS Code to launch a development host
# or: vsce package and install the .vsix
Configurable settings:
ezc.lsp.path,ezc.dap.path— override binary lookupezc.inlayHints.stackState— show post-line stack state inlineezc.inlayHints.references— show "N references" markers
Neovim
Plugin at editors/neovim-ezc/. Install with lazy.nvim:
{
dir = "/path/to/ezc/editors/neovim-ezc",
ft = "ezc",
dependencies = { "neovim/nvim-lspconfig", "mfussenegger/nvim-dap" },
config = function()
require("ezc").setup({})
require("ezc").set_dap_keymaps() -- F5/F10/F11/F12 for debugging
end,
}
Or copy the plugin to ~/.local/share/nvim/site/pack/ezc/start/ and
call require("ezc").setup() in your config.
Zed
Extension at editors/zed-ezc/. Symlink it into your Zed extensions
directory:
ln -s "$PWD/editors/zed-ezc" ~/.config/zed/extensions/ezc
Then restart Zed. Both LSP and DAP work natively.
Helix
Config at editors/helix-ezc/. Merge languages.toml into your Helix
config:
cat editors/helix-ezc/languages.toml >> ~/.config/helix/languages.toml
For tree-sitter highlighting, copy the grammar:
cp -r editors/tree-sitter-ezc ~/.config/helix/runtime/grammars/sources/ezc/
hx --grammar build
Tree-sitter grammar
Standalone tree-sitter grammar at editors/tree-sitter-ezc/. Used by
Neovim, Zed, and Helix; can also be embedded in any other tree-sitter
host.
Examples
A gallery of small programs. Every block is runnable — click Run ▶.
FizzBuzz
1 16 range
(
, 15 % 0 ==
( ; "FizzBuzz" : )
(
, 3 % 0 ==
( ; "Fizz" : )
(
, 5 % 0 ==
( ; "Buzz" : )
( : )
??
)
??
)
??
) each
Sum of squares
10 iota (sq) map sum
Factorial
"std/math.ezc" import
20 fact
That's 2,432,902,008,176,640,000 — exact, because int is BigInt by
default.
Fibonacci
"std/math.ezc" import
0 30 range (fib) map
Pythagorean triples up to 20
1 21 range # a in 1..20
(@a
1 21 range # b in 1..20
(@b
1 21 range # c in 1..20
(@c
$a sq $b sq + $c sq ==
([$a $b $c] :) ?
) each
) each
) each
Primes under 20
For each candidate n, count how many integers in [2..n-1] divide it.
If none do, n is prime.
2 20 range
(@n
2 $n range
(@d $n $d % 0 == ) &?
len 0 ==
($n :) ?
) each
Mean of a list
[42 7 19 88 3]
, sum ~ len /
Reversing a string
"hello" rev :
Counting lines (REPL)
0 @count
( . , "" != ) # while line not empty
( ; $count 1 + @count ) &
"Lines: " $count str | :
(That one needs stdin, so run it in the terminal with ezc run.)
Playground
Type ezc, hit Run (or Ctrl+Enter). Everything runs in your browser
via WebAssembly — no installation, no network calls.
Tips
- Run executes the program and shows the resulting stack.
- Trace evaluates each line independently and shows the stack after each step — handy for understanding how the stack evolves.
- Share copies a permalink that includes your code in the URL hash.
- Clear wipes the editor and output panes.
The full WASM API surface is in crates/ezc-web
if you want to embed ezc in your own page.