Language guide · github.com/nnunley/fmpl

FMPL, the language guide

A tutorial for experienced programmers, rendered for the web — expressions, pattern matching, grammars, objects, and the metaprogramming pipeline.

Source TUTORIAL.md + DEMO.md Live every block runs here Companion to the engineering tour Written by Norman Nunley

Every snippet here is transcribed from TUTORIAL.md / DEMO.md, whose blocks CI executes against the real interpreter on every push — the -- Returns: comments are asserted there, not aspirational. This page runs them again in your browser: the cursor marks the next statement — step it, or press enter.

$ fmpl  ·  live session — this terminal is real-- ▸ marks the next statement: click it, press enter, or type your own. fmpl>
§1

Quick start

The fastest path is this page: the terminal above is live, and every block below steps through the same VM — nothing to install. When you want the full toolchain on your machine:

git clone https://github.com/nnunley/fmpl.git && cd fmpl
just build              # bootstrap the FMPL-generated parser, then build
cargo run -p fmpl-cli   # the REPL
cargo run -p fmpl-web   # web UI on http://localhost:3000
cargo run -p fmpl-tui   # terminal UI

There’s also a full browser REPL for blank-slate scratch sessions (opens in a new tab) — but the guided path continues right here: every block below runs in place.

FMPL is expression-oriented: every expression produces a value, and statements don’t exist. Literals evaluate to themselves:

"Hello, World!"   -- String literals evaluate to themselves
42                -- Numbers evaluate to themselves
true              -- Booleans evaluate to themselves

One thing to internalize before anything else: FMPL is purely functional with immutable bindings. Once bound, a name cannot be reassigned. Loops are recursion; accumulation is fold. Most surprises for newcomers trace back to this.

§2

Language basics

Primitive types

-- Numbers
42
3.14
-10

-- Strings
"Hello, World!"
"Line 1\nLine 2\tTabbed"   -- Escape sequences: \n \t \r \\ \" \' \0

-- Booleans
true
false

-- Null
null

Comments

-- Single-line comments start with double dash

/*
   Multi-line comments
   are supported
*/

Arithmetic and logic

-- Arithmetic operators
1 + 2          -- 3
10 - 4         -- 6
3 * 4          -- 12
15 / 3         -- 5

-- Comparison operators
1 == 1         -- true
1 != 2         -- true
5 < 10         -- true
5 <= 5         -- true
10 > 5         -- true
10 >= 10       -- true

-- Logical operators
true && false  -- false
true || false  -- true
!true          -- false

Arithmetic is checked — overflow is a clean error, not a panic. Exponentiation (**) is planned but not yet implemented. String concatenation uses + and requires both operands to be strings: a mixed "n = " + 42 is a type mismatch and evaluates to null.

§3

Data structures

Lists

-- List literals (`;` separates consecutive expressions — a `[` opening a
-- new line would otherwise be read as indexing the previous expression)
[1, 2, 3];
["apple", "banana", "cherry"];
[1, "mixed", true];

-- Empty list
[]

-- Indexing, length, and (immutable) append
let numbers = [1, 2, 3]
numbers[0]       -- => 1
numbers.len()    -- => 3
numbers.push(4)  -- => [1, 2, 3, 4]  (a new list; `numbers` is unchanged)

Lists are immutable. The higher-order methods work — [1, 2, 3].map(\x x * 2) returns [2, 4, 6], and .fold() is available. For element access beyond indexing, use pattern matching with the @ operator or recursive functions.

Maps

-- Map literal
%{name: "Alice", age: 30, city: "NYC"}

-- Empty map
%{}

-- Map access
let person = %{name: "Bob", age: 25}
person.name              -- "Bob"
person.age               -- 25

Objects

FMPL is prototype-based, not class-based. Objects are created with object expressions — more in §8:

-- Basic object (must be named)
object counter {
  count: 0
  increment(): self.count + 1
  value(): self.count
}

Objects must be named in the current implementation. The receiver inside methods is self — there is no this. Anonymous object literals and constructors (^name) are planned features.

§4

Pattern matching with @

The @ operator is FMPL’s swiss-army knife — apply the thing on the right to the value on the left. It covers three jobs with one mechanism: applying grammars to parse text, matching patterns against values, and transforming data via pattern-directed rules.

Matching text

-- Match strings against regex-style patterns
"hello" @ {
  [a-z]+ => "word"
}
-- Returns: "word" (matches [a-z]+)

"12345" @ {
  [0-9]+ => "number"
}
-- Returns: "number"

Matching maps

Map patterns extract values via bindings. Arms are separated with ;, and the OMeta-style binding syntax is _:name:

-- Extract values using bindings, with a wildcard fallback arm
let response = %{status: 200, body: "ok"}

response @ { %{status: _:code, body: _:msg} => msg; _ => "other" }
-- Returns: "ok"

-- Nested map patterns work too
%{outer: %{inner: "value"}} @ { %{outer: %{inner: _:i}} => i }
-- Returns: "value"

To match on a specific value, bind it and guard with when (or its alias if):

%{status: 200, body: "ok"} @ {
  %{status: _:s} when s == 200 => "success";
  %{status: _:s} => "failed"
}
-- Returns: "success"

%{code: 404} @ { %{code: _:c} when c == 404 => "not_found"; _ => "found" }
-- Returns: "not_found"

Literal values directly inside map patterns (%{status: 200} => ...) are not yet supported — the compiler rejects them. Bind and guard, as above.

Matching lists

-- Match a list and extract elements
[1, 2, 3] @ { [ _:x, _:y, _:z ] => [x, y, z] }
-- Returns: [1, 2, 3]

-- Length must match: this arm does not match a 3-element list
[1, 2, 3] @ { [ _:x, _:y ] => "two"; _ => "not two" }
-- Returns: "not two"

-- Empty list pattern
[] @ { [] => "empty" }
-- Returns: "empty"

Rest patterns ([first | rest]) are planned but not yet implemented. In the REPL, write @ { ... } match blocks on a single line with ; between arms — multi-line @ { blocks are routed to the grammar engine. Multi-line works fine with the match keyword form.

match 5 { n if n > 3 => "big"; _ => "small" }
-- Returns: "big"

And when you don’t need a pattern at all, plain field access works:

let response = %{
  tool: "curl.get",
  args: %{url: "https://example.com"}
}

response.tool
-- Returns: "curl.get"

response.args.url
-- Returns: "https://example.com"
§5

Grammars and parsing

FMPL includes an OMeta-style PEG grammar system for parsing and transformation. Grammar rules match input and run semantic actions; capture matched text with a :binding suffix on a pattern element:

-- Define a grammar: capture the digits, return them from the action
let g = grammar { num = [0-9]+:d => d }

"42" @ g.num
-- Returns: "42"

-- Actions are arbitrary expressions
let shout = grammar { word = [a-z]+:w => w + "!" }
"hello" @ shout.word
-- Returns: "hello!"

A full JSON parser written this way ships with the repo — see lib/json.fmpl. The metacircular FMPL parser itself (lib/core/fmpl_parser.fmpl) is the largest grammar in the tree: the language’s canonical parser is written in the language (the tour’s §3 tells that story).

Built-in base grammar rules are available under base::parser:

-- Apply built-in base grammar rules to input
"12345" @ base::parser.integer   -- Returns: "12345"
"hello" @ base::parser.word      -- Returns: "hello"

Grammar inheritance (<: with <super.rule> overrides) is a designed feature that is deliberately deferred (DESIGN-005). Compose grammars by referencing shared rules for now.

§6

Control flow

Conditionals

FMPL uses then/else keywords, not braces — and if is an expression:

-- if-then-else
if 15 > 10 then "big" else "small"
-- Returns: "big"

-- Nested with expressions
if 150 > 100 then
  "huge"
else if 15 > 10 then
  "big"
else
  "small"
-- Returns: "huge"

-- With let bindings
let (value = 42)
  if value > 10 then "big" else "small"
-- Returns: "big"

Loops are recursion

There are no mutable variables, so there are no loop counters. Iteration is recursion:

-- Sum numbers recursively (lambda bound at top level)
let sum_range = \start, end if start > end then 0 else start + sum_range(start + 1, end)

sum_range(1, 10)
-- Returns: 55

-- Factorial via recursion
let factorial = \n if n <= 1 then 1 else n * factorial(n - 1)

factorial(5)
-- Returns: 120

Recursion works through top-level (statement-style) let bindings — the lambda body resolves the name at call time. Scoped let (f = ...) expression bindings cannot see themselves recursively yet (a known limitation; see docs/known-gaps.md).

The corollary, from the demo’s “known limitations” file: a for body cannot mutate an outer binding — sum = sum + x inside a loop creates a new sum in the inner scope and the outer one is unchanged. The idiomatic shape is map + fold:

let numbers = [1, 2, 3, 4, 5]
let doubled = numbers.map(\x x * 2)
doubled.fold(0, \acc, x acc + x)  -- => 30

Let-bindings

-- let-in expression (scoped binding)
let (x = 42) x * 2
-- Returns: 84

-- Multiple bindings
let (x = 10, y = 20) x + y
-- Returns: 30

-- Statement-style let (binds to current scope)
let x = 42
let y = x * 2
y + 10
-- Returns: 94
§7

Functions and lambdas

Functions are lambdas bound to names with let:

-- Bind a lambda to a name
let add = \a, b a + b
add(1, 2)           -- 3

-- The lambda keyword form is equivalent
let inc = lambda (n) n + 1
inc(41)             -- 42

The name(args): body definition syntax only exists inside object blocks (as method definitions) — it is not a top-level function form. Functions must be defined before they’re called.

Lambda forms

-- Lambda syntax: single param, multi-param (comma-separated), curried
\x x + 1
\x, y x + y
\x \y x + y

-- Apply lambda immediately
(\x x * 2)(5)       -- 10

-- Store and call later
let doubler = \x x * 2
doubler(7)          -- 14

-- Curried application
let addc = \x \y x + y
addc(3)(4)          -- 7

Higher-order functions

-- Functions can take other functions as arguments
let apply_twice = \f, x f(f(x))
let add_one = \x x + 1

apply_twice(add_one, 5);
-- Returns: 7

-- Built-in higher-order list methods
[1, 2, 3].map(\x x * 2)
-- Returns: [2, 4, 6]
§8

Objects and methods

Prototype objects carry properties and methods; methods are called through the object’s name:

-- Define object
object counter {
  count: 0
  increment(): self.count + 1
  value(): self.count
}

-- Access methods via the object name
counter.value()       -- 0
counter.increment()   -- 1

Inside a method body, a small set of context variables is always available:

  • self — the receiver of the method call
  • parent — the parent object, for prototype-chain lookup
  • caller — the object that called this method
  • user — the current user context
  • args — the list of all arguments passed to the method
object greeter {
  name: "world"
  show(): "Hello, " + self.name
}

greeter.show()
-- Returns: "Hello, world"

The receiver is self, as in Python or Smalltalk — there is no this, and using this silently breaks the enclosing object definition.

§9

Metaprogramming

FMPL supports first-class AST and IR values — you can write compilers, DSLs, and code generators entirely in FMPL. This is the language’s thesis feature: the real bootstrap pipeline is built from exactly the pieces below.

Tagged values

Algebraic data types are written as lists whose first element is a symbol — the single canonical list form (DESIGN-002):

-- Create tagged values
[:Int, 42];
[:Binary, :+, [:Int, 1], [:Int, 2]];
[:User, "alice", %{active: true}]

-- Pattern match on tagged values (bare identifiers bind in tagged patterns)
let value = [:Binary, :+, [:Int, 1], [:Int, 2]]
value @ {
  [:Binary, :+, a, b] => "addition";
  [:Binary, :-, a, b] => "subtraction";
  [:Int, n] => "just a number"
}
-- Returns: "addition"

Operator symbols like :+, :-, :* are ordinary symbols and can appear in tagged values and patterns. The legacy constructor syntax :Int(42) is rejected with a hint: use [:Int, 42] instead.

Source → AST → IR → bytecode → value

ast::parse turns source text into a tagged AST; ir::compile turns IR into executable bytecode; code::eval runs it:

let ast = ast::parse("1 + 2")
-- Returns: [:Binary, :+, [:Int, 1], [:Int, 2]]

let ast2 = ast::parse("if true then 1 else 2")
-- Returns: [:If, [:Bool, true], [:Int, 1], [:Int, 2]]

let code = ir::compile([:Add, [:LoadInt, 1], [:LoadInt, 2]])
code::eval(code)
-- Returns: 3

Put the three together and a compiler is a pattern match:

-- Parse source, transform AST to IR, compile, execute
let ast = ast::parse("1 + 2")

let ir = ast @ {
  [:Binary, :+, [:Int, a], [:Int, b]] => [:Add, [:LoadInt, a], [:LoadInt, b]];
  [:Binary, :-, [:Int, a], [:Int, b]] => [:Sub, [:LoadInt, a], [:LoadInt, b]];
  [:Binary, :*, [:Int, a], [:Int, b]] => [:Mul, [:LoadInt, a], [:LoadInt, b]];
  [:Binary, :/, [:Int, a], [:Int, b]] => [:Div, [:LoadInt, a], [:LoadInt, b]]
}

let code = ir::compile(ir)
code::eval(code)
-- Returns: 3

This is the shape of the real thing: lib/core/ast_to_ir.fmpl transforms full ASTs to IR the same way, and ast::parse itself is backed by the FMPL-written parser in lib/core/fmpl_parser.fmpl (DESIGN-001, the metacircular bootstrap). Supported IR nodes: LoadNull, LoadBool, LoadInt, LoadFloat, LoadString, LoadVar, Var, Add, Sub, Mul, Div, Mod, Neg, Not, Eq, NotEq, Lt, Gt, LtEq, GtEq, Let, Seq, If, Return, MakeList, MakeTagged.

§10

Streams and cursors

Streaming is FMPL’s first-class concern, and the observable surface today is the cursor API: observe a value as a stream, then move a cursor over it. Cursors are immutable — advancing one returns a new cursor:

let data = [10, 20, 30]
let cursor = stream::observe(data)

-- Get current position
cursor::position(cursor)  -- => 0 (Int)

-- Advance cursor
let advanced = cursor::advance(cursor, 1)
cursor::current(advanced)  -- => 20

-- Rewind cursor
let rewound = cursor::rewind(advanced, 1)
cursor::current(rewound)  -- => 10

Multiple cursors over the same stream are independent (copy-on-write):

let data = [10, 20, 30]
let cursor1 = stream::observe(data)
let cursor2 = stream::observe(data)

cursor::position(cursor1)  -- => 0
cursor::position(cursor2)  -- => 0 (independent cursor)

let advanced = cursor::advance(cursor1, 1)
cursor::position(advanced)   -- => 1
cursor::position(cursor2)   -- => 0 (unchanged)

This is the substrate the grammar engine parses over — the same cursor discipline backs backtracking and streaming input. The async operators that will surface it in the language (<-, spawn, |>) have syntax in the parser today with the runtime in progress.

§11

Practical examples

JSON parsing and validation

-- Parse JSON string
let json_str = "{\"name\": \"Alice\", \"age\": 30}"

-- Use json::parse builtin
let parsed = json::parse(json_str)
-- Returns: %{age: 30, name: "Alice"}

-- Validate structure
parsed @ {
  %{name: _:n, age: _:a} when a >= 18 => "Adult: " + n;
  %{name: _:n, age: _:a} => "Minor: " + n;
  _ => "Invalid structure"
}
-- Returns: "Adult: Alice"

HTTP requests

requires network — not run by CI

-- Make HTTP GET request using the curl builtin
let response = curl.get("https://api.example.com/data")

-- Parse JSON response
let data = json::parse(response)

-- Extract specific fields
data @ {
  %{status: _:s, results: _:r} when s == "ok" => r;
  %{error: _:e} => "Error: " + e;
  _ => "Unknown response"
}

A simple agent loop

The pattern-matching machinery is exactly what tool-call dispatch wants:

requires network — not run by CI

-- Dispatch tool calls by binding the tool name and guarding on it
let handle_tool_call = \tc tc @ {
  %{tool: _:t, args: _:a} when t == "curl.get" => curl.get(a.url);
  %{tool: _:t, args: _:a} when t == "curl.post" => curl.post(a.url, a.body);
  %{text: _:txt} => txt;
  _ => "Error: Unrecognized response"
}

-- Simulate LLM response
let llm_output = "{\"tool\": \"curl.get\", \"args\": {\"url\": \"https://example.com\"}}"
let parsed = json::parse(llm_output)

-- Handle the tool call (performs the HTTP request)
handle_tool_call(parsed)
§12

Agents and the road ahead

FMPL is designed for agentic AI workflows — closing the loop between LLMs, tools, and human oversight. Real LLM clients ship in the standard library: lib/anthropic.fmpl (Claude API, requires ANTHROPIC_API_KEY) and lib/ollama.fmpl (local models), with shared plumbing in lib/llm-common.fmpl.

The single-turn shape is the agent loop from §11; a multi-turn loop is the same thing, recursive (llm_complete and execute_tool stand in for your LLM client and tool registry):

design sketch  not yet runnable

let agent_turn = \input, history
  llm_complete(%{history: history, input: input}) @ {
    %{tool: _:t, args: _:a} => agent_turn(execute_tool(t, a), history);
    %{answer: _:ans} => ans;
    _ => "Error: Unexpected LLM output"
  }

let result = agent_turn("Search for latest Rust version", [])

The project’s north star goes further: express agent control flow as grammars. Grammar rules define behavior declaratively, backtracking gives retry-on-failure for free, and rules are inspectable data rather than opaque code. Not yet a working feature — this sketch shows the intended shape:

design sketch  not yet runnable

grammar ToolAgent <: base::tree {
  -- Main loop: process messages
  turn = message:m => {
    let ctx = %{history: get_history()}
    ::llm_complete(m, ctx) |> tool_output
  }

  -- Handle LLM output stream
  tool_output =
    | %{tool: t, args: a} => {
        let result = ::execute_tool(t, a)
        turn(result)  -- recurse with result
      }
    | %{done: r} => r  -- terminate
    | %{text: t} => t  -- stream text
}

The same goes for durable suspension: the persistence engine (Fjall-backed) exists in-core, and language-level checkpoint / resume_from builtins are designed but not yet exposed — the intended payoff is pause-and-resume workflows, human-in-the-loop approvals, and crash recovery for long-running agents.

§13

Status at a glance

The short version of what’s real today. The tour’s §4 carries the full verified capability list and §5 the gap ledger; docs/known-gaps.md in the repo is the canonical inventory, grouped by root cause.

And don’t take the page’s word for any of it: verify re-runs every block above in a fresh VM, right here in your browser, and reports a verdict per block — without disturbing the text.

  • worksEverything demonstrated above — every snippet runs live on this page, and its source block is CI-executed
  • worksChecked arithmetic, pattern matching, grammars, objects, lambdas, recursion
  • worksMetaprogramming pipeline (ast::parse / ir::compile / code::eval)
  • worksjson::parse, curl.get/post, stream cursors, type predicates
  • partialAsync operators (<-, spawn, |>) — syntax in, runtime in progress
  • partialObject constructors (^name) — designed, implementation evolving
  • missing**, string interpolation, rest patterns, literal map-pattern values, recursive scoped let
  • missingGrammar inheritance (<:), tuple space, capability policies, durable approvals

To go deeper: TUTORIAL.md and DEMO.md are this page’s sources of truth; specs/ holds the implementation specs (VM, grammars, objects, persistence), and the engineering tour covers the architecture those specs describe.