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 defaultint is arbitrary-precision. Fixed-width types (u8u256, i8i256, f16f64) 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

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

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:

  1. 3 → stack [3]
  2. 4 → stack [3 4]
  3. + pops 3 and 4, pushes 7 → 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:

opeffectdescription
,a → a adup: copy the top
;a →drop: discard the top
~a b → b aswap: exchange the top two
_a b → a b aover: 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

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

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:

  1. (3 4 +) → stack [(...)]
  2. ! pops the block and runs 3 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:

  1. (, *) — push a block. Its body uses , (dup) then *.
  2. @square — bind the block to square.
  3. 5 — push 5.
  4. $square — recall the block onto the stack.
  5. ! — 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 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

[...] 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

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

FamilyTypes
Integerint (BigInt — default)
Unsignedu8, u16, u32, u64, u128, u256
Signedi8, i16, i32, i64, i128, i256
Floatf16, 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

Writing output

Three operators write to stdout:

optype effectdescription
:a →print top of stack with newline
wla →named alias of :
wbint →write a single byte
"Hello!" :       # prints: Hello!
42 :             # prints: 42

Reading input

opeffectdescription
.→ strread a line from stdin
rl→ strnamed alias of .
rb→ intread 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

opstack effectdescription
+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.

opstack effectdescription
==a b → boolequal
!=a b → boolnot equal
<a b → boolless than
>a b → boolgreater than
<=a b → boolless than or equal
>=a b → boolgreater than or equal

Stack manipulation

opstack effectname
,a → a adup
;a →drop
~a b → b aswap
_a b → a b aover

I/O

opstack effectdescription
:a →write line (any type)
.→ strread line
wla →write line (alias of :)
rl→ strread line (alias of .)
wbint →write a single byte
rb→ intread a single byte

Control flow

opstack effectdescription
!variesexecute: pops a block/list/string and runs/splats/evals
?cond block →conditional execute (only runs block if cond truthy)
??cond then else → resultternary: pick one branch by truthiness
&cond-block body-block →while loop
&!list block → listmap
&?list block → listfilter
&/list init block → accfold (left-to-right)
1 (10 :) ?     # prints 10 because cond was truthy
0 (10 :) ?     # prints nothing
1 (10) (20) ?? # → 10

Compose

opstack effectdescription
|a b → (a|b)concatenate strings, lists, or blocks
"foo" "bar" |          # → "foobar"
[1 2] [3 4] |          # → [1 2 3 4]

Variables

formdescription
@namebind: pop top of stack, store in name
$namerecall: push value of name
namebare: recall, but auto-execute if value is a block

Containers

syntaxdescription
(...)block — deferred code, executed by !
[...]list — eager evaluation, gathers leftovers
{...}scope — evaluates immediately with local bindings

Stack introspection

opstack effectdescription
depth→ intcurrent stack height
clearvariesdrop everything on the stack
words→ listlist all currently-defined names

Builtins

Built-in named operations, beyond the symbolic operators.

Collection

namestack effectdescription
lencoll → intlength of list / string / binary
nthcoll i → elemzero-indexed access
tllist → listtail (drop first)
revlist → list / str → strreverse
srtlist → listsort
takecoll n → collfirst n elements
skipcoll n → colldrop first n elements
ziplhs rhs → listpair-wise zip into list of pairs
rangelo hi → listinteger range [lo .. hi-1]
cutstr delim → listsplit string by delimiter
catlist delim → strjoin list with delimiter
flatlist → listflatten one level (prelude)

Higher-order (named aliases)

namesymboldescription
eachiterate over a list or count
map&!apply block to each element
fil&?filter
red&/fold/reduce
loop&while loop
if?conditional
ifel??ternary

Reflection

namestack effectdescription
typeofa → strtype name string
words→ listall currently-defined names
depth→ intcurrent stack height
cleardrop 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

namestack effectdescription
importpath →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

typedescription
intarbitrary-precision integer (BigInt)
u8u256fixed-width unsigned (8/16/32/64/128/256-bit)
i8i256fixed-width signed
f16/f32/f64IEEE-754 floating point
strimmutable, interned UTF-8 string
binimmutable, interned byte buffer
listheterogeneous sequence of values
blockdeferred code (sequence of expressions)

Numeric families

Arithmetic operators only work between values in the same family:

  • Integer familyint
  • Unsigned familyu8, u16, ..., u256
  • Signed familyi8, i16, ..., i256
  • Float familyf16, 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:

typefalsy when
numberzero
strempty
binempty
listempty
blocknever (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

namestack effectdescription
dupa → a anamed alias of ,
dropa →named alias of ;
swapa b → b anamed alias of ~
overa b → a b anamed alias of _
nipa b → bdrop second
tucka b → b a bdup top, hide under second
dup2a b → a b a bdup pair
rota b c → b c arotate three
ida → aidentity (no-op)

Predicates

namestack effectdescription
zeroa → boolis zero
evena → boolis even
odda → boolis odd
ltza → boolless than zero
gtza → boolgreater than zero
dvba b → boola divisible by b

Logic

namestack effectdescription
nota → boollogical negation
anda b → boollogical and
ora b → boollogical or

Math

namestack effectdescription
inca → a+1increment
deca → a-1decrement
sqa → a*asquare
nega → -anegate
abs`a →a
sum[list] → nsum of a list
prod[list] → nproduct of a list
iotan → list[0..n-1]
mina b → mminimum
maxa b → mmaximum

Collections

namestack effectdescription
hd[list] → firsthead
flat[[..]] → [..]flatten one level
apply[args] (block) →splat args, then execute block

Misc

namedescription
dflval fallback dfl → val if truthy, fallback otherwise
peeka → a (also prints a) — debugging aid

std/math.ezc

"std/math.ezc" import
namestack effectdescription
factn → n!factorial
fibn → fib(n)nth Fibonacci
gcda b → gEuclidean GCD
pow2b e → b^einteger power for any width
clampv lo hi → v'clamp to range
sgnn → -1|0|1signum

std/str.ezc

"std/str.ezc" import
namestack effectdescription
reps n → s'repeat string n times
padls w c → s'pad-left to width with char
hass sub → boolcontains substring
startss pre → boolstarts with prefix
endss suf → boolends 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 lookup
  • ezc.inlayHints.stackState — show post-line stack state inline
  • ezc.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.