From 96e6711d077861c43396cec0f4d715085c3cd1ac Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Wed, 22 Jul 2026 19:08:35 -0700 Subject: [PATCH 01/12] =?UTF-8?q?docs(pages):=20language=20guide=20?= =?UTF-8?q?=E2=80=94=20web=20rendering=20of=20Norman's=20TUTORIAL.md=20+?= =?UTF-8?q?=20DEMO.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion page to the engineering tour, same design system. Tutorial is the spine; DEMO.md's stream/cursor material becomes its own section. Sketches and network-only examples are labeled; the sources of truth remain TUTORIAL.md / DEMO.md, which CI executes on every push. Co-Authored-By: Claude Fable 5 --- docs/fmpl-guide.html | 985 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 985 insertions(+) create mode 100644 docs/fmpl-guide.html diff --git a/docs/fmpl-guide.html b/docs/fmpl-guide.html new file mode 100644 index 0000000..4eed11a --- /dev/null +++ b/docs/fmpl-guide.html @@ -0,0 +1,985 @@ + + + + + +FMPL: The Language Guide + + + + +
+ +
+

Language guide · github.com/mparrett/fmpl

+

FMPL, the language guide

+

Norman Nunley’s tutorial for experienced programmers, rendered for the web — expressions, pattern matching, grammars, objects, and the metaprogramming pipeline.

+
+ Source TUTORIAL.md + DEMO.md + CI-verified every snippet executed on push + Companion to the engineering tour +
+ + +

A flavor of the REPL — or skip the transcript and run FMPL in your browser, no install. Every fmpl snippet on this page comes from TUTORIAL.md / DEMO.md, which CI executes against the real interpreter on every push: the -- Returns: comments are asserted, not aspirational.

+
+ + + +
+
§1

Quick start

+
+

The fastest path is the browser REPL — the real VM compiled to WebAssembly, nothing to install. For the full toolchain:

+
+ +
git clone https://github.com/mparrett/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
+ +
+

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.

+
+ +
+
    +
  • worksEverything demonstrated above — every snippet is CI-executed against the interpreter
  • +
  • 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.

+
+
+ +
+ FMPL · MIT · github.com/mparrett/fmpl (fork of nnunley/fmpl) + Original tutorial by Norman Nunley, Jr. · rendered from TUTORIAL.md + DEMO.md, CI-verified +
+ +
+ + + From 722e255f533725d318912822251e683b1caed95b Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Wed, 22 Jul 2026 19:09:07 -0700 Subject: [PATCH 02/12] =?UTF-8?q?docs:=20link=20the=20language=20guide=20f?= =?UTF-8?q?rom=20landing=20page,=20tour=20=C2=A77,=20and=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- README.md | 4 +++- docs/fmpl-tour.html | 2 +- docs/index.html | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f45d5ec..cb34792 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ image is the source of truth; source files are a bootstrapping convenience. New here? Read the **[engineering tour](https://mparrett.github.io/fmpl/fmpl-tour.html)** — architecture, verified capabilities, and the honest gap ledger, in one page — -or try the **[browser REPL](https://mparrett.github.io/fmpl/repl.html)** +learn the language from the **[language guide](https://mparrett.github.io/fmpl/fmpl-guide.html)** +(the web rendering of [`TUTORIAL.md`](TUTORIAL.md)), or try the +**[browser REPL](https://mparrett.github.io/fmpl/repl.html)** (fmpl-core compiled to WebAssembly, no install). > **Status: experimental.** FMPL is a working prototype under active diff --git a/docs/fmpl-tour.html b/docs/fmpl-tour.html index 45a2ff8..f8d253d 100644 --- a/docs/fmpl-tour.html +++ b/docs/fmpl-tour.html @@ -657,7 +657,7 @@

Objects, images, and durable state

just tui # terminal UI (Ctrl+L for LLM chat)
-

Start with TUTORIAL.md — every snippet in it runs as documented, verified line-by-line against the REPL. DEMO.md is the quick version; specs/ holds the implementation specs (VM, grammars, objects, persistence, tuplespace); docs/known-gaps.md is the honest map of the frontier. If you want to contribute, the metacircular-parser bucket is the critical path: pick a file from the ledger, run it with -- --ignored, and land the feature its tests describe.

+

Start with the language guide — the web rendering of TUTORIAL.md and DEMO.md, every snippet of which runs as documented, verified line-by-line against the REPL. specs/ holds the implementation specs (VM, grammars, objects, persistence, tuplespace); docs/known-gaps.md is the honest map of the frontier. If you want to contribute, the metacircular-parser bucket is the critical path: pick a file from the ledger, run it with -- --ignored, and land the feature its tests describe.

diff --git a/docs/index.html b/docs/index.html index 9b92f0d..0bd36aa 100644 --- a/docs/index.html +++ b/docs/index.html @@ -122,6 +122,10 @@

FMPL, “of Accardi”

start here The engineering tourWhat it is, what provably works, and what honestly doesn’t — every claim verified against a live REPL. + + learn it + The language guideNorman Nunley’s tutorial, rendered for the web — every snippet executed by CI against the real interpreter. + try it The browser REPLThe same VM compiled to WebAssembly, rebuilt from source on every deploy. No install. From 75eb405b036a74cf76045c7b940ffd2dce2524d3 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Wed, 22 Jul 2026 19:18:17 -0700 Subject: [PATCH 03/12] =?UTF-8?q?feat(pages):=20REPL=20deep=20links=20?= =?UTF-8?q?=E2=80=94=20#code=3D=20prefill=20+=20run-it=20links=20on=20the?= =?UTF-8?q?=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repl.html gains a hash runner: #code= is fed through the normal submit path after wasm init (echoed like typed input, history populated). Two REPL line-at-a-time gotchas are smoothed at intake: multi-line @{...} match blocks join to the documented single-line idiom, and multi-line if/then/else chains join so the else doesn't dangle. The guide marks browser-safe blocks with pre.run; a small script builds each link from the block's own text (annotation-only lines dropped), so no snippet is hand-copied. Network examples, sketches, and the comment demo are unmarked. Verified: all 29 links execute error-free in the wasm VM via Playwright, with value assertions on 11. Co-Authored-By: Claude Fable 5 --- docs/fmpl-guide.html | 82 ++++++++++++++++++++++++++++---------------- docs/repl.html | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 29 deletions(-) diff --git a/docs/fmpl-guide.html b/docs/fmpl-guide.html index 4eed11a..d0e0668 100644 --- a/docs/fmpl-guide.html +++ b/docs/fmpl-guide.html @@ -285,6 +285,13 @@ margin: 1.25rem 0 -1rem; } .codelabel + pre { margin-top: 1.25rem; } + .runlink { + font-family: var(--mono); + font-size: 0.6875rem; + letter-spacing: 0.05em; + display: inline-block; + margin: -0.85rem 0 1.25rem; + } /* ---------- field notes ---------- */ .fieldnote { @@ -399,7 +406,7 @@

FMPL, the language guide

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
+
"Hello, World!"   -- String literals evaluate to themselves
 42                -- Numbers evaluate to themselves
 true              -- Booleans evaluate to themselves
@@ -412,7 +419,7 @@

FMPL, the language guide

§2

Language basics

Primitive types

-
-- Numbers
+
-- Numbers
 42
 3.14
 -10
@@ -437,7 +444,7 @@ 

FMPL, the language guide

*/

Arithmetic and logic

-
-- Arithmetic operators
+
-- Arithmetic operators
 1 + 2          -- 3
 10 - 4         -- 6
 3 * 4          -- 12
@@ -463,7 +470,7 @@ 

FMPL, the language guide

§3

Data structures

Lists

-
-- List literals (`;` separates consecutive expressions — a `[` opening a
+
-- 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"];
@@ -481,7 +488,7 @@ 

FMPL, the language guide

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
+
-- Map literal
 %{name: "Alice", age: 30, city: "NYC"}
 
 -- Empty map
@@ -495,7 +502,7 @@ 

FMPL, the language guide

Objects

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

-
-- Basic object (must be named)
+
-- Basic object (must be named)
 object counter {
   count: 0
   increment(): self.count + 1
@@ -511,7 +518,7 @@ 

FMPL, the language guide

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
+
-- Match strings against regex-style patterns
 "hello" @ {
   [a-z]+ => "word"
 }
@@ -526,7 +533,7 @@ 

Matching text

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
+
-- Extract values using bindings, with a wildcard fallback arm
 let response = %{status: 200, body: "ok"}
 
 response @ { %{status: _:code, body: _:msg} => msg; _ => "other" }
@@ -539,7 +546,7 @@ 

Matching maps

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

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

Matching maps

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
+
-- Match a list and extract elements
 [1, 2, 3] @ { [ _:x, _:y, _:z ] => [x, y, z] }
 -- Returns: [1, 2, 3]
 
@@ -565,13 +572,13 @@ 

Matching maps

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" }
+
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 = %{
+
let response = %{
   tool: "curl.get",
   args: %{url: "https://example.com"}
 }
@@ -588,7 +595,7 @@ 

Matching maps

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
+
-- Define a grammar: capture the digits, return them from the action
 let g = grammar { num = [0-9]+:d => d }
 
 "42" @ g.num
@@ -603,7 +610,7 @@ 

Matching maps

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
+
-- Apply built-in base grammar rules to input
 "12345" @ base::parser.integer   -- Returns: "12345"
 "hello" @ base::parser.word      -- Returns: "hello"
@@ -616,7 +623,7 @@

Matching maps

Conditionals

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

-
-- if-then-else
+
-- if-then-else
 if 15 > 10 then "big" else "small"
 -- Returns: "big"
 
@@ -637,7 +644,7 @@ 

Matching maps

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)
+
-- 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)
@@ -654,12 +661,12 @@ 

Matching maps

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 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-in expression (scoped binding)
 let (x = 42) x * 2
 -- Returns: 84
 
@@ -679,7 +686,7 @@ 

Matching maps

Functions are lambdas bound to names with let:

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

Matching maps

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
+
-- Lambda syntax: single param, multi-param (comma-separated), curried
 \x x + 1
 \x, y x + y
 \x \y x + y
@@ -707,7 +714,7 @@ 

Matching maps

addc(3)(4) -- 7

Higher-order functions

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

Matching maps

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

-
-- Define object
+
-- Define object
 object counter {
   count: 0
   increment(): self.count + 1
@@ -745,7 +752,7 @@ 

Matching maps

  • args — the list of all arguments passed to the method
  • -
    object greeter {
    +
    object greeter {
       name: "world"
       show(): "Hello, " + self.name
     }
    @@ -763,7 +770,7 @@ 

    Matching maps

    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
    +
    -- Create tagged values
     [:Int, 42];
     [:Binary, :+, [:Int, 1], [:Int, 2]];
     [:User, "alice", %{active: true}]
    @@ -782,7 +789,7 @@ 

    Tagged values

    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")
    +
    let ast = ast::parse("1 + 2")
     -- Returns: [:Binary, :+, [:Int, 1], [:Int, 2]]
     
     let ast2 = ast::parse("if true then 1 else 2")
    @@ -795,7 +802,7 @@ 

    Tagged values

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

    -
    -- Parse source, transform AST to IR, compile, execute
    +
    -- Parse source, transform AST to IR, compile, execute
     let ast = ast::parse("1 + 2")
     
     let ir = ast @ {
    @@ -819,7 +826,7 @@ 

    Tagged values

    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 data = [10, 20, 30]
     let cursor = stream::observe(data)
     
     -- Get current position
    @@ -836,7 +843,7 @@ 

    Tagged values

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

    -
    let data = [10, 20, 30]
    +
    let data = [10, 20, 30]
     let cursor1 = stream::observe(data)
     let cursor2 = stream::observe(data)
     
    @@ -856,7 +863,7 @@ 

    Tagged values

    §11

    Practical examples

    JSON parsing and validation

    -
    -- Parse JSON string
    +
    -- Parse JSON string
     let json_str = "{\"name\": \"Alice\", \"age\": 30}"
     
     -- Use json::parse builtin
    @@ -981,5 +988,22 @@ 

    Tagged values

    + + diff --git a/docs/repl.html b/docs/repl.html index 7021a96..dca9806 100644 --- a/docs/repl.html +++ b/docs/repl.html @@ -250,11 +250,81 @@ examplesEl.appendChild(b); } +// Deep links: #code= is fed through the normal submit +// path after the VM loads — multi-line snippets buffer exactly as if typed. +// Safe to auto-run: the wasm build has no network or file builtins. + +// Net `{`/`}` delta of a line, ignoring string literals and -- comments. +function braceDelta(line) { + let d = 0, inStr = false, esc = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (inStr) { + if (esc) esc = false; + else if (c === "\\") esc = true; + else if (c === '"') inStr = false; + } else if (c === '"') inStr = true; + else if (c === "-" && line[i + 1] === "-") break; + else if (c === "{") d++; + else if (c === "}") d--; + } + return d; +} + +// Multi-line `@ { ... }` match blocks are a REPL gotcha: typed line-by-line +// they route to the grammar engine. The documented idiom is one line with +// `;` between arms — the arms already carry the `;`, so join them. +function joinMatchBlocks(lines) { + const out = []; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + if (/@\s*\{\s*$/.test(line.trim())) { + let depth = braceDelta(line); + while (depth > 0 && i + 1 < lines.length) { + i++; + line += " " + lines[i].trim(); + depth += braceDelta(lines[i]); + } + } + out.push(line); + } + return out; +} + +// Multi-line if/then/else has the same line-at-a-time trouble: the branch +// evaluates early and the trailing `else` dangles. Join the chain. +function joinIfChains(lines) { + const out = []; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + while (i + 1 < lines.length && + (/\b(then|else)\s*$/.test(line.trim()) || /^\s*else\b/.test(lines[i + 1]))) { + i++; + line += " " + lines[i].trim(); + } + out.push(line); + } + return out; +} + +function runFromHash() { + const m = location.hash.match(/^#code=(.*)$/); + if (!m) return; + let code = ""; + try { code = decodeURIComponent(m[1]); } catch { return; } + for (const line of joinIfChains(joinMatchBlocks(code.split("\n")))) { + if (line.trim() !== "") { history.push(line); histIdx = history.length; } + submit(line); + } + if (buffer.length) submit(""); // empty line force-submits a leftover buffer +} + try { await init(); screen.replaceChildren(); print("FMPL v0.1.0 — wasm build. Type .help for commands.", "sys"); input.disabled = false; + runFromHash(); input.focus(); } catch (e) { print(`Failed to load WebAssembly: ${e}`, "err"); From a637c3783f5079e9f3d33bf8c97f3304357ca8b8 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Wed, 22 Jul 2026 19:24:00 -0700 Subject: [PATCH 04/12] =?UTF-8?q?build(pages):=20size-tuned=20wasm=20?= =?UTF-8?q?=E2=80=94=20dedicated=20profile=20+=20wasm-opt=20(-36%=20wire)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [profile.wasm-release]: opt-level=z, fat LTO, 1 CGU, panic=abort, strip. panic=abort is safe here: VM errors are Results by design (no catch_unwind in-tree; overflow probed in-browser — clean error, no trap). wasm-opt -Oz needs --enable-bulk-memory since Rust emits bulk-memory ops by default. 2.1MB/580KB-gzip -> 1.13MB/373KB-gzip, verified against the pinned wasm-bindgen 0.2.114 locally. Co-Authored-By: Claude Fable 5 --- .github/workflows/pages.yml | 14 +++++++++++--- Cargo.toml | 11 +++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b2b7ae3..b6e1ff7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -43,8 +43,8 @@ jobs: FMPL_BOOTSTRAP_PHASE=1 cargo build -p fmpl-bootstrap touch fmpl-core/build.rs - - name: Build fmpl-wasm (release) - run: cargo build -p fmpl-wasm --release --target wasm32-unknown-unknown + - name: Build fmpl-wasm (size-tuned wasm-release profile) + run: cargo build -p fmpl-wasm --profile wasm-release --target wasm32-unknown-unknown # Must match the exact wasm-bindgen version pinned in fmpl-wasm/Cargo.toml. - name: Install wasm-bindgen CLI @@ -52,9 +52,17 @@ jobs: - name: Generate JS bindings into the site run: | - wasm-bindgen target/wasm32-unknown-unknown/release/fmpl_wasm.wasm \ + wasm-bindgen target/wasm32-unknown-unknown/wasm-release/fmpl_wasm.wasm \ --out-dir docs/repl --target web --no-typescript + # Rust emits bulk-memory ops by default; wasm-opt must be told. + - name: Shrink with wasm-opt + run: | + sudo apt-get update -q && sudo apt-get install -y -q binaryen + wasm-opt -Oz --enable-bulk-memory --enable-nontrapping-float-to-int \ + --strip-producers docs/repl/fmpl_wasm_bg.wasm -o docs/repl/fmpl_wasm_bg.wasm + ls -l docs/repl/fmpl_wasm_bg.wasm + - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/Cargo.toml b/Cargo.toml index f3c6556..67b9b6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,3 +50,14 @@ rkyv = { version = "0.8", features = ["smol_str-0_3"] } # Async streaming tokio-stream = "0.1" async-trait = "0.1" + +# Size-tuned profile for the browser wasm build only (pages.yml). Safe with +# panic="abort": VM errors are Results by design (no catch_unwind in-tree), +# so unwinding machinery is pure weight here. ~2.1MB -> ~1.1MB raw. +[profile.wasm-release] +inherits = "release" +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true From 2415a6170377dcdc777460ea53a70cc88c23e5bf Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Wed, 22 Jul 2026 19:48:58 -0700 Subject: [PATCH 05/12] =?UTF-8?q?feat(wasm):=20ReplVm=20class=20=E2=80=94?= =?UTF-8?q?=20independent=20VM=20instances=20for=20the=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The globals expose one persistent session VM; the language guide's run-in-place blocks need a fresh VM per run (the doctest harness's semantics) without nuking the session. Exports a constructor plus eval/is_complete with the same output contract as the globals. Co-Authored-By: Claude Fable 5 --- fmpl-wasm/src/lib.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/fmpl-wasm/src/lib.rs b/fmpl-wasm/src/lib.rs index df77925..35966a4 100644 --- a/fmpl-wasm/src/lib.rs +++ b/fmpl-wasm/src/lib.rs @@ -38,3 +38,35 @@ pub fn repl_is_complete(source: &str) -> bool { pub fn repl_reset() { VM.with(|vm| *vm.borrow_mut() = Vm::new()); } + +/// An independent VM instance, for pages that need more than the one global +/// session — e.g. the language guide's run-in-place blocks, where each run +/// gets a fresh VM (the doctest harness's semantics) while a console keeps +/// its own long-lived session. +#[wasm_bindgen] +pub struct ReplVm { + vm: Vm, +} + +#[wasm_bindgen] +impl ReplVm { + #[wasm_bindgen(constructor)] + #[allow(clippy::new_without_default)] // wasm-bindgen constructors are `new` by contract + pub fn new() -> ReplVm { + ReplVm { vm: Vm::new() } + } + + /// Evaluate source against this instance's VM. Same output contract as + /// [`repl_eval`]: `=> value` or `Error: ...`. + pub fn eval(&mut self, source: &str) -> String { + match eval(&mut self.vm, source) { + Ok(value) => format!("=> {value}"), + Err(e) => format!("Error: {e}"), + } + } + + /// Same completeness check as [`repl_is_complete`]. + pub fn is_complete(&self, source: &str) -> bool { + is_complete(source).unwrap_or(true) + } +} From 19b7ea7da635648a8072835fb6d0a017c1d3e8c5 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Wed, 22 Jul 2026 19:48:58 -0700 Subject: [PATCH 06/12] =?UTF-8?q?feat(pages):=20run-in-place=20=E2=80=94?= =?UTF-8?q?=20the=20guide=20executes=20and=20verifies=20itself=20in-browse?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every runnable block gets ▸ run / edit / reset controls. Run executes the block in a fresh ReplVm (CI doctest semantics); live results replace the documented '-- Returns:' lines with ✓/✗ verdicts, and a masthead 'run all blocks' re-verifies the whole page client-side (29/29 locally). edit swaps the block for a textarea; deep links remain for sharing. The REPL line-handling logic (multi-line @{} and if/else joining) moves to a shared docs/fmpl-live.js module used by both repl.html and the guide, along with the annotation-checking block runner (same conventions as fmpl-core/tests/doc_examples.rs, including order-insensitive top-level map compare). wasm loads lazily on first run; prefetch warms the cache. Co-Authored-By: Claude Fable 5 --- docs/fmpl-guide.html | 245 ++++++++++++++++++++++++++++++++++++++++--- docs/fmpl-live.js | 158 ++++++++++++++++++++++++++++ docs/repl.html | 59 +---------- 3 files changed, 391 insertions(+), 71 deletions(-) create mode 100644 docs/fmpl-live.js diff --git a/docs/fmpl-guide.html b/docs/fmpl-guide.html index d0e0668..efa7bf5 100644 --- a/docs/fmpl-guide.html +++ b/docs/fmpl-guide.html @@ -4,6 +4,9 @@ FMPL: The Language Guide + + +