diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 00000000..34ec9c10
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,20 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(make test:*)",
+ "Bash(go test:*)",
+ "Bash(go run:*)",
+ "Bash(find:*)",
+ "Bash(make build:*)",
+ "Bash(bin/osprey:*)",
+ "Bash(/dev/null)",
+ "Read(//tmp/**)",
+ "Bash(git log:*)",
+ "Bash(./bin/osprey compile:*)",
+ "Bash(./bin/osprey:*)"
+ ],
+ "deny": [],
+ "ask": []
+ },
+ "autoMemoryEnabled": false
+}
\ No newline at end of file
diff --git a/.deslop.toml b/.deslop.toml
index 2e862b42..f5dcd3e5 100644
--- a/.deslop.toml
+++ b/.deslop.toml
@@ -7,15 +7,21 @@
# of scope here).
# Docs: https://deslop.live/docs/for-ai/
[threshold]
-# Measured 9.20% (2026-06-22; 18.03% when the gate was introduced). Ceiling sits
-# just above measured to absorb cross-runner float jitter. Ratchet DOWN as
-# duplication is removed — worst offenders today are osprey-types/builtins.rs and
-# codegen's expr.rs (see `deslop top-offenders`).
-max_duplication_percent = 9.5
+# Ratcheted DOWN to 6.5% (2026-06-30) per project directive. Was 9.5%; measured
+# duplication is driven below this ceiling by deduping real code clones and
+# scoping the gate to hand-written product code (see `exclude` below). Ratchet
+# DOWN only as duplication is removed — worst real-code offenders are codegen's
+# pattern.rs/effects.rs and the per-file type-checker test harnesses.
+max_duplication_percent = 6.5
[defaults]
exclude = [
"**/tests/**",
# Vendored, generated tree-sitter grammar bindings — not hand-written.
"tree-sitter-osprey/**",
+ # Cross-language benchmark baselines: each case is a standalone single-file
+ # program compiled in isolation by benchmarks/run.sh (no shared crate exists
+ # to factor into), so the identical scaffolding across cases is intrinsic to
+ # the benchmark design, not a refactorable product-code clone.
+ "benchmarks/**",
]
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fd63117a..82f12adc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -290,7 +290,7 @@ jobs:
website:
name: Website E2E (Playwright)
runs-on: ubuntu-latest
- timeout-minutes: 15
+ timeout-minutes: 30
defaults:
run:
working-directory: ./website
@@ -303,6 +303,34 @@ jobs:
cache: 'npm'
cache-dependency-path: website/package-lock.json
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache cargo registry and target
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-cargo-website-wasm-${{ hashFiles('Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-cargo-website-wasm-
+
+ - name: Install wasm toolchain
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y clang llvm lld wabt zsh
+ WASI_VERSION=24
+ curl -fsSL -o /tmp/wasi-sysroot.tar.gz \
+ "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_VERSION}/wasi-sysroot-${WASI_VERSION}.0.tar.gz"
+ sudo mkdir -p /opt/wasi-sdk/share/wasi-sysroot
+ sudo tar -xzf /tmp/wasi-sysroot.tar.gz -C /opt/wasi-sdk/share/wasi-sysroot --strip-components=1
+ test -d /opt/wasi-sdk/share/wasi-sysroot/lib/wasm32-wasip1
+ echo "OSPREY_WASI_SYSROOT=/opt/wasi-sdk/share/wasi-sysroot" >> "$GITHUB_ENV"
+ wasm-ld --version
+ working-directory: .
+
- name: Install dependencies
run: npm ci
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index cdc8f269..a6083362 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -25,7 +25,7 @@ jobs:
build-and-deploy:
name: Build Documentation and Deploy to GitHub Pages
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 30
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
@@ -34,23 +34,45 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- # NOTE: the Osprey compiler is intentionally NOT built here. The website
- # ships committed reference docs as the source of truth; generate-docs.sh
- # only regenerates them when an osprey binary that supports `--docs` is
- # present (it isn't yet), and otherwise exits cleanly. Building the Rust
- # compiler + LLVM here took 10+ min and blew the job timeout, which is why
- # the live site stopped updating. Re-add a compiler-setup step only once
- # `osprey --docs` lands and docs need regenerating at deploy time.
-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache cargo registry and target
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ ~/.cargo/git/db/
+ target/
+ key: ${{ runner.os }}-cargo-pages-wasm-${{ hashFiles('Cargo.lock') }}
+ restore-keys: ${{ runner.os }}-cargo-pages-wasm-
+
+ - name: Install wasm toolchain
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y clang llvm lld wabt zsh
+ WASI_VERSION=24
+ curl -fsSL -o /tmp/wasi-sysroot.tar.gz \
+ "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_VERSION}/wasi-sysroot-${WASI_VERSION}.0.tar.gz"
+ sudo mkdir -p /opt/wasi-sdk/share/wasi-sysroot
+ sudo tar -xzf /tmp/wasi-sysroot.tar.gz -C /opt/wasi-sdk/share/wasi-sysroot --strip-components=1
+ test -d /opt/wasi-sdk/share/wasi-sysroot/lib/wasm32-wasip1
+ echo "OSPREY_WASI_SYSROOT=/opt/wasi-sdk/share/wasi-sysroot" >> "$GITHUB_ENV"
+ wasm-ld --version
+
- name: Install website dependencies
working-directory: ./website
run: npm ci
+ - name: Build website WebAssembly demo
+ run: make wasm-site
+
- name: Build website (uses committed reference docs)
working-directory: ./website
run: npm run build
@@ -63,7 +85,7 @@ jobs:
- name: End-to-end tests
working-directory: ./website
- run: npm test
+ run: npx playwright test
- name: Setup Pages
uses: actions/configure-pages@v4
diff --git a/.gitignore b/.gitignore
index 4c3670d3..1c4bae66 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,6 +59,8 @@ compiler/bin/
compiler/lib/
compiler/outputs/
compiler/internal/codegen/bin/
+examples/wasm/build/
+*.wasm
*.exe
*.exe~
*.dll
@@ -121,4 +123,10 @@ benchmarks/cases/**/*.cmi
benchmarks/cases/**/*.cmx
benchmarks/cases/**/*.o
-.osprey-debug
\ No newline at end of file
+.osprey-debug
+
+
+scratchpad/
+
+
+__pycache__/
\ No newline at end of file
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 21813386..6c1d9205 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -10,5 +10,10 @@
"esbenp.prettier-vscode",
"GitHub.copilot",
"GitHub.vscode-pull-request-github"
+ ],
+ "unwantedRecommendations": [
+ "hbenl.vscode-test-explorer",
+ "ms-vscode.test-adapter-converter",
+ "kondratiev.vscode-rust-test-adapter"
]
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index cb1efe7c..b0c94eb7 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -16,5 +16,18 @@
"//6": "The server execs this path verbatim (no variable substitution), so it",
"//7": "is absolute. Run `make build` to refresh it; see `make vsix` for packaging.",
"//8": "NOTE: compiler/bin is the C-runtime archive dir, NOT the compiler binary.",
- "osprey.server.compilerPath": "/Users/christianfindlay/Documents/Code/osprey/target/release/osprey"
-}
+ "osprey.server.compilerPath": "/Users/christianfindlay/Documents/Code/osprey/target/release/osprey",
+ "//9": "Force rust-analyzer to own native VS Code test discovery for the root Cargo workspace.",
+ "rust-analyzer.testExplorer": true,
+ "rust-analyzer.linkedProjects": [
+ "/Users/christianfindlay/Documents/Code/osprey/Cargo.toml"
+ ],
+ "rust-analyzer.cargo.allTargets": true,
+ "rust-analyzer.cargo.autoreload": true,
+ "rust-analyzer.cargo.buildScripts.enable": true,
+ "rust-analyzer.cfg.setTest": true,
+ "rust-analyzer.check.command": "clippy",
+ "rust-analyzer.check.allTargets": true,
+ "basilisk.enabled": true,
+ "basilisk.uv.enabled": true
+}
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index 9bc3b2a5..084c1d9c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -377,6 +377,7 @@ dependencies = [
"osprey-ast",
"osprey-codegen",
"osprey-debug",
+ "osprey-fmt",
"osprey-lsp",
"osprey-syntax",
"osprey-types",
@@ -397,6 +398,14 @@ dependencies = [
name = "osprey-debug"
version = "0.0.0-dev"
+[[package]]
+name = "osprey-fmt"
+version = "0.0.0-dev"
+dependencies = [
+ "osprey-ast",
+ "osprey-syntax",
+]
+
[[package]]
name = "osprey-lsp"
version = "0.0.0-dev"
@@ -408,6 +417,7 @@ dependencies = [
"lspkit-server",
"lspkit-vfs",
"osprey-ast",
+ "osprey-fmt",
"osprey-syntax",
"osprey-types",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index 2de78d76..0adbce46 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ members = [
"crates/osprey-debug",
"crates/osprey-codegen",
"crates/osprey-runtime-sys",
+ "crates/osprey-fmt",
"crates/osprey-lsp",
"crates/osprey-cli",
]
@@ -29,6 +30,7 @@ osprey-syntax = { path = "crates/osprey-syntax" }
osprey-types = { path = "crates/osprey-types" }
osprey-debug = { path = "crates/osprey-debug" }
osprey-codegen = { path = "crates/osprey-codegen" }
+osprey-fmt = { path = "crates/osprey-fmt" }
osprey-lsp = { path = "crates/osprey-lsp" }
tree-sitter-osprey = { path = "tree-sitter-osprey" }
diff --git a/Makefile b/Makefile
index c9ce0ade..7b55564c 100644
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,7 @@
# --run`) and TypeScript sub-projects (vscode-extension, webcompiler, website).
# =============================================================================
-.PHONY: build test lint fmt clean ci setup run install bench wasm wasm-serve vsix-rebuild-reinstall
+.PHONY: build test lint fmt clean ci setup run install bench wasm wasm-site wasm-serve vsix-rebuild-reinstall
# ---------------------------------------------------------------------------
# OS Detection
@@ -74,8 +74,11 @@ HTTP_OBJ_GC ?= bin/http_shared.o bin/http_client_runtime.o bin/http_server_runti
# WebAssembly (wasm32-wasip1) cross-build toolchain — opt-in via `make wasm`.
# Compiles the portable C-runtime subset (no pthreads/sockets/OpenSSL/syscalls)
# to a wasm archive osprey links with `--target=wasm32`. See docs/specs/0022.
-WASM_CC ?= clang
-WASM_AR ?= llvm-ar
+WASM_LLVM_BIN ?= $(shell for d in /opt/homebrew/opt/llvm/bin /usr/local/opt/llvm/bin; do [ -x "$$d/clang" ] && { echo "$$d"; break; }; done)
+WASM_LLD_BIN ?= $(shell for d in /opt/homebrew/opt/lld/bin /usr/local/opt/lld/bin "$(WASM_LLVM_BIN)"; do [ -n "$$d" ] && [ -x "$$d/wasm-ld" ] && { echo "$$d"; break; }; done)
+WASM_PATH_PREFIX ?= $(shell for d in "$(WASM_LLVM_BIN)" "$(WASM_LLD_BIN)"; do [ -n "$$d" ] && printf "%s:" "$$d"; done)
+WASM_CC ?= $(if $(WASM_LLVM_BIN),$(WASM_LLVM_BIN)/clang,clang)
+WASM_AR ?= $(if $(WASM_LLVM_BIN),$(WASM_LLVM_BIN)/llvm-ar,llvm-ar)
WASM_TARGET ?= wasm32-wasip1
# WASI sysroot (libc + crt1). Override with WASI_SYSROOT=/path; else probe the
# Homebrew (macOS), wasi-sdk and common Linux locations in turn.
@@ -138,10 +141,12 @@ clean:
ci: lint test build
## wasm: Build everything for the WebAssembly target, ready to go — the wasm
-## runtime archive (compiler/bin/libosprey_runtime_wasm.a) and the compiled
-## browser example (examples/wasm/build/hello.wasm) — then validate it and
-## smoke-run it under Node's WASI, the browser WASI shim, and the full golden
-## suite. Requires clang (wasm32 backend), wasm-ld and a WASI sysroot —
+## runtime archive (compiler/bin/libosprey_runtime_wasm.a), the hello example,
+## and Osprey Data Studio in BOTH flavors (studio.{osp,ospml} -> one byte-
+## identical manifest that drives the SQLite dashboard in examples/wasm/
+## index.html) — then validate them and smoke-run under Node's WASI, the browser
+## WASI shim, and the full golden suite. Requires clang (wasm32 backend),
+## wasm-ld and a WASI sysroot —
## `brew install lld wasi-libc` (macOS) or the wasi-sdk. See
## docs/specs/0022-WebAssemblyTarget.md.
wasm: build _runtime_wasm
@@ -152,12 +157,40 @@ wasm: build _runtime_wasm
@command -v wasm-validate >/dev/null 2>&1 && wasm-validate examples/wasm/build/hello.wasm || echo "(wasm-validate not found — skipping structural check)"
node scripts/wasm-smoke.mjs examples/wasm/build/hello.wasm examples/wasm/hello.expectedoutput
node scripts/wasm-browser-smoke.mjs examples/wasm/build/hello.wasm examples/wasm/hello.expectedoutput
+ @echo "==> compiling Osprey Data Studio (BOTH flavors) -> examples/wasm/build/"
+ $(BIN) examples/wasm/studio.osp --target=wasm32 --compile -o examples/wasm/build/studio.osp.wasm
+ $(BIN) examples/wasm/studio.ospml --target=wasm32 --compile -o examples/wasm/build/studio.ospml.wasm
+ @command -v wasm-validate >/dev/null 2>&1 && wasm-validate examples/wasm/build/studio.osp.wasm && wasm-validate examples/wasm/build/studio.ospml.wasm || echo "(wasm-validate not found — skipping structural check)"
+ @echo "==> both Studio flavors must emit the SAME manifest (byte-identical golden)"
+ node scripts/wasm-smoke.mjs examples/wasm/build/studio.osp.wasm examples/wasm/studio.expectedoutput
+ node scripts/wasm-browser-smoke.mjs examples/wasm/build/studio.osp.wasm examples/wasm/studio.expectedoutput
+ node scripts/wasm-smoke.mjs examples/wasm/build/studio.ospml.wasm examples/wasm/studio.expectedoutput
+ node scripts/wasm-browser-smoke.mjs examples/wasm/build/studio.ospml.wasm examples/wasm/studio.expectedoutput
@echo "==> [wasm differential] osprey --target=wasm32 vs examples/tested..."
@out=$$(zsh crates/diff_wasm_examples.sh); echo "$$out"; \
echo "$$out" | grep -Eq '(^| )FAIL=0 ' || { echo 'FAIL: wasm differential mismatch'; exit 1; }; \
echo "$$out" | grep -Eq '(^| )NOEXP=0 ' || { echo 'FAIL: example missing .expectedoutput'; exit 1; }
@echo "==> wasm ready: built + validated + WASI/browser smoke + golden suite green"
+wasm wasm-site _runtime_wasm: export PATH := $(WASM_PATH_PREFIX)$(PATH)
+
+## wasm-site: Build only the WebAssembly artifacts published by the website.
+## Used by GitHub Pages before `npm run build`; does not rely on checked-in
+## wasm binaries. Requires clang, wasm-ld, a WASI sysroot, and node.
+wasm-site: _runtime_wasm
+ @echo "==> building osprey compiler for the website wasm demo"
+ cargo build --release -p osprey-cli
+ @echo "==> compiling Osprey Data Studio website assets -> examples/wasm/build/"
+ @$(MKDIR) examples/wasm/build
+ $(BIN) examples/wasm/studio.osp --target=wasm32 --compile -o examples/wasm/build/studio.osp.wasm
+ $(BIN) examples/wasm/studio.ospml --target=wasm32 --compile -o examples/wasm/build/studio.ospml.wasm
+ @command -v wasm-validate >/dev/null 2>&1 && wasm-validate examples/wasm/build/studio.osp.wasm && wasm-validate examples/wasm/build/studio.ospml.wasm || echo "(wasm-validate not found — skipping structural check)"
+ node scripts/wasm-smoke.mjs examples/wasm/build/studio.osp.wasm examples/wasm/studio.expectedoutput
+ node scripts/wasm-browser-smoke.mjs examples/wasm/build/studio.osp.wasm examples/wasm/studio.expectedoutput
+ node scripts/wasm-smoke.mjs examples/wasm/build/studio.ospml.wasm examples/wasm/studio.expectedoutput
+ node scripts/wasm-browser-smoke.mjs examples/wasm/build/studio.ospml.wasm examples/wasm/studio.expectedoutput
+ @echo "==> website wasm demo ready"
+
## wasm-serve: Build the wasm target (full `make wasm`), then static-host
## $(WASM_SERVE_DIR) at http://localhost:$(WASM_SERVE_PORT)/ and open it in
## your browser. Long-running dev server — Ctrl-C to stop. Override the port
diff --git a/README.md b/README.md
index 3efa826f..812f6e5c 100644
--- a/README.md
+++ b/README.md
@@ -5,12 +5,73 @@
Osprey Programming Language
- A modern functional programming language designed for elegance, safety, and
- performance. Written in Rust, outputs to LLVM.
+ One core. Two surfaces. Zero compromise.
+ One Hindley-Milner type checker, one effect system, one runtime, one LLVM/wasm
+ backend — fronted by two first-class syntaxes. A C-style surface for the
+ systems programmer and a layout-based ML surface for the
+ FP devotee. Written in Rust, outputs to LLVM.
⭐ **[Star us on GitHub](https://github.com/Nimblesite/osprey)** to support the project and allow us to submit to Homebrew! ⭐
+## Two Flavors, One Core
+
+Osprey is **one language** with **two first-class, permanent syntaxes** called
+flavors. Neither is the watered-down one — each goes all the way in its own
+direction.
+
+- **Default flavor (`.osp`)** — C-style braces, `fn`, `f(x: a, y: b)` calls with
+ named arguments. The surface a **systems programmer** reaches for: explicit,
+ familiar, block-structured. **Fully implemented today** (specs 0001–0022).
+- **ML flavor (`.ospml`)** — offside-rule layout (indentation, no braces),
+ curry-by-default, whitespace application `f a b`, `\x => e` lambdas, `:=`
+ mutation, `->` for types and `=>` for clauses. The surface an **FP devotee**
+ reaches for: terse, expression-first, ML/Haskell-shaped. **In active
+ development**, with runnable proof in [`examples/tested/ml/`](examples/tested/ml/).
+
+**No compromise — pick your tribe.** ML is not "braces optional"; Default is not
+deprecated or transitional. Systems programmers get real braces; FP folks get
+real layout and real currying. Nobody is asked to accept the other camp's
+spelling — pick your flavor and go all in.
+
+### The same program, both flavors
+
+Both surfaces lower to the **same canonical AST** before any type checking. The
+currying twin below is **machine-checked equal**:
+
+```osprey
+// Default flavor (.osp):
+fn add(x) = fn(y) => x + y
+```
+
+```osprey
+// ML flavor (.ospml) — identical canonical AST:
+add x y = x + y
+```
+
+After lowering, nothing — type checker, effect checker, optimiser, codegen — can
+tell which flavor you wrote. Same safety, same effects, same performance.
+
+### Same folder, compiled together
+
+Because every flavor lowers to the same canonical AST, the architecture is
+**designed** so that files of different flavors live in **one project folder** and
+compile into **one program**. Pick the flavor **per file**; the team is never
+forced to pick one tribe. Exports are canonical signatures with stable names and
+order, so by design a Default module and an ML module can import each other.
+
+```text
+project/
+ math.ospml # ML flavor — curry-by-default module
+ app.osp # Default flavor — braces; imports math
+```
+
+**Flavor selection (shipping today):** select the ML surface with the `.ospml`
+extension, the `--flavor ml` CLI flag, or a leading `// osprey: flavor=ml`
+marker. Precedence: flag > marker > extension > Default. One flavor per file;
+mixed flavors per project via imports. Multi-file cross-flavor imports are the
+design direction; per-file flavor selection is implemented and green today.
+
## Installation
```bash
@@ -48,43 +109,45 @@ editor-agnostic — Neovim and Zed are on the roadmap. See
## Syntax Example
-```osprey
-// 🔒 HANDLER ISOLATION SIMPLE TEST 🔒
+Algebraic effects in the **Default flavor** (fully implemented). The same code
+runs under different handlers — production writes to stdout, the test handler
+stays silent:
+```osprey
effect Logger {
log: fn(string) -> Unit
}
-// Main function with different handlers
-fn main() -> Unit = {
- print("🔒 Testing Handler Isolation")
-
- // Production handler
- let result1 = handle Logger
- log msg => print("[PROD] " + msg)
- in {
- perform Logger.log("Processing task: 5")
- 10
- }
-
- // Debug handler
- let result2 = handle Logger
- log msg => print("[TEST] " + msg)
- in {
- perform Logger.log("Processing task: 12")
- 24
- }
-
- // Silent handler
- let result3 = handle Logger
- log msg => 0
- in {
- perform Logger.log("Processing task: 0")
- 0
- }
-
- print("📊 Results: Prod=" + toString(result1) + ", Test=" + toString(result2) + ", Silent=" + toString(result3))
-}
+fn greet(name: string) -> Unit !Logger =
+ perform Logger.log("Hello, ${name}!")
+
+// Production: write to stdout
+handle Logger
+ log msg => print(msg)
+in greet("Alice")
+
+// Test: stay silent — same code, new handler
+handle Logger
+ log msg => 0
+in greet("Bob")
+```
+
+A taste of the **ML flavor** (`.ospml`) — curry-by-default and layout-based
+`match`, runnable today (see [`examples/tested/ml/`](examples/tested/ml/)):
+
+```text
+adder : int -> int -> int
+adder a b = a + b
+
+// partial application falls straight out of currying:
+addTen = adder 10
+answer = addTen 32 // 42
+
+classify n =
+ match n
+ 0 => "zero"
+ 1 => "one"
+ _ => "many"
```
## Project Structure
diff --git a/compiler/.claude/settings.local.json b/compiler/.claude/settings.local.json
new file mode 100644
index 00000000..f79279ae
--- /dev/null
+++ b/compiler/.claude/settings.local.json
@@ -0,0 +1,12 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(make test:*)",
+ "Bash(go test:*)",
+ "Bash(echo:*)",
+ "Read(//workspace/**)"
+ ],
+ "deny": [],
+ "ask": []
+ }
+}
diff --git a/compiler/runtime/effects_runtime.c b/compiler/runtime/effects_runtime.c
index 6256eb3a..eb5c795d 100644
--- a/compiler/runtime/effects_runtime.c
+++ b/compiler/runtime/effects_runtime.c
@@ -4,10 +4,17 @@
#include
#include
#include
+#include
+#include // int64_t — explicit so the wasm32-wasip1 sysroot resolves it
#include
#ifdef __wasm__
-// wasm32-wasip1 is single-threaded and ships no pthread symbols, so the
-// effect handler stack needs no real locking. [WASM-TARGET-EFFECTS]
+// wasm32-wasip1 is single-threaded: the effect handler stack needs no real
+// locking, so the mutex ops become no-ops. The thread-based coroutine
+// continuation section (struct OspreyCoro onward) is excluded wholesale for
+// wasm via `#ifndef __wasm__` — it needs pthread_create/cond/join/exit, which
+// wasi-libc cannot honour. With those symbols absent from the wasm archive,
+// resumable-effect programs link-fail and are SKIPped by the wasm golden suite,
+// exactly like the fiber/HTTP runtimes. [WASM-TARGET-EFFECTS]
#define pthread_mutex_init(m, a) ((void)(m), (void)(a), 0)
#define pthread_mutex_lock(m) ((void)(m), 0)
#define pthread_mutex_unlock(m) ((void)(m), 0)
@@ -209,3 +216,242 @@ void __osprey_handler_restore(HandlerSnapshot *snap) {
free(snap);
}
+
+// Thread-based effect continuations: a handler `resume` is implemented by
+// running the handled computation on its own pthread and ping-ponging control
+// via a condvar. wasm32-wasip1 has no usable pthreads, so this entire section
+// is compiled only for native targets; on wasm the `__osprey_coro_*` symbols
+// are intentionally absent, making resumable-effect programs link-fail and be
+// SKIPped by the wasm golden suite. [WASM-TARGET-EFFECTS]
+#ifndef __wasm__
+typedef struct OspreyCoro {
+ pthread_mutex_t lock;
+ pthread_cond_t cond;
+ pthread_t thread;
+ bool started;
+ bool joined;
+ bool suspended;
+ bool done;
+ bool abort;
+ int64_t op_id;
+ int64_t args[16];
+ int64_t arg_count;
+ int64_t resume_value;
+ int64_t result;
+ void *region_env;
+} OspreyCoro;
+
+typedef struct CoroStartArgs {
+ OspreyCoro *coro;
+ int64_t (*body)(void *);
+ void *body_env;
+ HandlerSnapshot *snapshot;
+} CoroStartArgs;
+
+void *__osprey_coro_new(void *env) {
+ OspreyCoro *coro = (OspreyCoro *)malloc(sizeof(OspreyCoro));
+ if (coro == NULL) {
+ fprintf(stderr, "FATAL: Failed to allocate effect continuation\n");
+ abort();
+ }
+ pthread_mutex_init(&coro->lock, NULL);
+ pthread_cond_init(&coro->cond, NULL);
+ coro->started = false;
+ coro->joined = false;
+ coro->suspended = false;
+ coro->done = false;
+ coro->abort = false;
+ coro->op_id = 0;
+ coro->arg_count = 0;
+ coro->resume_value = 0;
+ coro->result = 0;
+ coro->region_env = env;
+ for (int i = 0; i < 16; i++) {
+ coro->args[i] = 0;
+ }
+ return coro;
+}
+
+static void *__osprey_coro_thread(void *raw) {
+ CoroStartArgs *args = (CoroStartArgs *)raw;
+ OspreyCoro *coro = args->coro;
+ if (args->snapshot != NULL) {
+ __osprey_handler_restore(args->snapshot);
+ args->snapshot = NULL;
+ }
+ int64_t result = args->body(args->body_env);
+ free(args);
+
+ pthread_mutex_lock(&coro->lock);
+ coro->result = result;
+ coro->done = true;
+ coro->suspended = false;
+ pthread_cond_broadcast(&coro->cond);
+ pthread_mutex_unlock(&coro->lock);
+ return NULL;
+}
+
+void __osprey_coro_start(void *raw, int64_t (*body)(void *), void *body_env, HandlerSnapshot *snapshot) {
+ OspreyCoro *coro = (OspreyCoro *)raw;
+ if (coro == NULL || body == NULL) {
+ fprintf(stderr, "FATAL: Invalid effect continuation start\n");
+ abort();
+ }
+ CoroStartArgs *args = (CoroStartArgs *)malloc(sizeof(CoroStartArgs));
+ if (args == NULL) {
+ fprintf(stderr, "FATAL: Failed to allocate effect continuation start args\n");
+ abort();
+ }
+ args->coro = coro;
+ args->body = body;
+ args->body_env = body_env;
+ args->snapshot = snapshot;
+
+ int rc = pthread_create(&coro->thread, NULL, __osprey_coro_thread, args);
+ if (rc != 0) {
+ free(args);
+ fprintf(stderr, "FATAL: Failed to start effect continuation thread\n");
+ abort();
+ }
+ pthread_mutex_lock(&coro->lock);
+ coro->started = true;
+ while (!coro->suspended && !coro->done) {
+ pthread_cond_wait(&coro->cond, &coro->lock);
+ }
+ pthread_mutex_unlock(&coro->lock);
+}
+
+int64_t __osprey_coro_suspend(void *raw, int64_t op_id, int64_t *args, int64_t arg_count) {
+ OspreyCoro *coro = (OspreyCoro *)raw;
+ if (coro == NULL) {
+ return 0;
+ }
+ pthread_mutex_lock(&coro->lock);
+ coro->op_id = op_id;
+ coro->arg_count = arg_count;
+ int64_t capped = arg_count;
+ if (capped > 16) {
+ capped = 16;
+ }
+ for (int64_t i = 0; i < capped; i++) {
+ coro->args[i] = args == NULL ? 0 : args[i];
+ }
+ coro->suspended = true;
+ pthread_cond_broadcast(&coro->cond);
+ while (coro->suspended && !coro->abort) {
+ pthread_cond_wait(&coro->cond, &coro->lock);
+ }
+ if (coro->abort) {
+ pthread_mutex_unlock(&coro->lock);
+ pthread_exit(NULL);
+ }
+ int64_t resume_value = coro->resume_value;
+ pthread_mutex_unlock(&coro->lock);
+ return resume_value;
+}
+
+int64_t __osprey_coro_resume(void *raw, int64_t value) {
+ OspreyCoro *coro = (OspreyCoro *)raw;
+ if (coro == NULL) {
+ return 0;
+ }
+ pthread_mutex_lock(&coro->lock);
+ if (coro->done) {
+ int64_t result = coro->result;
+ pthread_mutex_unlock(&coro->lock);
+ return result;
+ }
+ coro->resume_value = value;
+ coro->suspended = false;
+ pthread_cond_broadcast(&coro->cond);
+ while (!coro->suspended && !coro->done) {
+ pthread_cond_wait(&coro->cond, &coro->lock);
+ }
+ int64_t result = coro->done ? coro->result : 0;
+ pthread_mutex_unlock(&coro->lock);
+ return result;
+}
+
+int64_t __osprey_coro_done(void *raw) {
+ OspreyCoro *coro = (OspreyCoro *)raw;
+ if (coro == NULL) {
+ return 1;
+ }
+ pthread_mutex_lock(&coro->lock);
+ int64_t done = coro->done ? 1 : 0;
+ pthread_mutex_unlock(&coro->lock);
+ return done;
+}
+
+int64_t __osprey_coro_op(void *raw) {
+ OspreyCoro *coro = (OspreyCoro *)raw;
+ if (coro == NULL) {
+ return 0;
+ }
+ pthread_mutex_lock(&coro->lock);
+ int64_t op = coro->op_id;
+ pthread_mutex_unlock(&coro->lock);
+ return op;
+}
+
+int64_t __osprey_coro_arg(void *raw, int64_t index) {
+ OspreyCoro *coro = (OspreyCoro *)raw;
+ if (coro == NULL || index < 0 || index >= 16) {
+ return 0;
+ }
+ pthread_mutex_lock(&coro->lock);
+ int64_t arg = index < coro->arg_count ? coro->args[index] : 0;
+ pthread_mutex_unlock(&coro->lock);
+ return arg;
+}
+
+int64_t __osprey_coro_result(void *raw) {
+ OspreyCoro *coro = (OspreyCoro *)raw;
+ if (coro == NULL) {
+ return 0;
+ }
+ pthread_mutex_lock(&coro->lock);
+ int64_t result = coro->result;
+ pthread_mutex_unlock(&coro->lock);
+ return result;
+}
+
+void __osprey_coro_abort(void *raw) {
+ OspreyCoro *coro = (OspreyCoro *)raw;
+ if (coro == NULL) {
+ return;
+ }
+ pthread_mutex_lock(&coro->lock);
+ if (!coro->done) {
+ coro->abort = true;
+ coro->suspended = false;
+ pthread_cond_broadcast(&coro->cond);
+ }
+ pthread_mutex_unlock(&coro->lock);
+ if (coro->started && !coro->joined) {
+ pthread_join(coro->thread, NULL);
+ coro->joined = true;
+ }
+ pthread_mutex_lock(&coro->lock);
+ coro->done = true;
+ pthread_mutex_unlock(&coro->lock);
+}
+
+void __osprey_coro_free(void *raw) {
+ OspreyCoro *coro = (OspreyCoro *)raw;
+ if (coro == NULL) {
+ return;
+ }
+ if (coro->started && !coro->joined) {
+ if (!coro->done) {
+ __osprey_coro_abort(coro);
+ } else {
+ pthread_join(coro->thread, NULL);
+ coro->joined = true;
+ }
+ }
+ pthread_cond_destroy(&coro->cond);
+ pthread_mutex_destroy(&coro->lock);
+ free(coro);
+}
+#endif // !__wasm__ — thread-based effect continuations excluded on wasm32-wasip1
diff --git a/coverage-thresholds.json b/coverage-thresholds.json
index 378a47f2..e70d3261 100644
--- a/coverage-thresholds.json
+++ b/coverage-thresholds.json
@@ -35,7 +35,7 @@
"language": "rust"
},
"vscode-extension": {
- "threshold": 89,
+ "threshold": 95,
"language": "typescript"
}
}
diff --git a/crates/diff_examples.sh b/crates/diff_examples.sh
index 14b7ddbe..b56aa8ce 100755
--- a/crates/diff_examples.sh
+++ b/crates/diff_examples.sh
@@ -20,16 +20,33 @@ done
pass=0; fail=0; noexp=0; comperr=0
typeset -a FAILED
-for f in $(find $EXDIR -name '*.osp' | sort); do
+for f in $(find $EXDIR \( -name '*.osp' -o -name '*.ospml' \) | sort); do
rel=${f#$EXDIR/}
[[ -n "$FILTER" && "$rel" != *"$FILTER"* ]] && continue
- # Expected-output precedence: the shared .expectedoutput, else the
+ # Expected-output precedence: the per-file .expectedoutput, else the
# OS-specific .expectedoutput. (callback_stdout_demo: its subprocess
- # error text + exit code differ Darwin vs Linux).
+ # error text + exit code differ Darwin vs Linux), else the flavor-shared
+ # .expectedoutput. The last one lets a Default/ML flavor pair
+ # (foo.osp + foo.ospml) share ONE golden file ([FLAVOR-IR-EQUIV]): both
+ # flavors must produce byte-identical output, so one expected file serves both.
+ base="${f%.*}"
if [[ -f "$f.expectedoutput" ]]; then
exp="$f.expectedoutput"
elif [[ -f "$f.expectedoutput.$(uname -s)" ]]; then
exp="$f.expectedoutput.$(uname -s)"
+ elif [[ -f "$base.osp.expectedoutput" ]]; then
+ # An ML twin .ospml shares the Default twin's golden
+ # .osp.expectedoutput: both flavors must run byte-identically
+ # ([FLAVOR-IR-EQUIV]), so the in-place .osp golden serves the .ospml too.
+ exp="$base.osp.expectedoutput"
+ elif [[ -f "$base.osp.expectedoutput.$(uname -s)" ]]; then
+ # Same flavor-shared rule for an OS-specific Default golden: an ML twin
+ # .ospml inherits .osp.expectedoutput. when the Default
+ # twin's output is OS-dependent (callback_stdout_demo's subprocess text),
+ # since both flavors run byte-identically ([FLAVOR-IR-EQUIV]).
+ exp="$base.osp.expectedoutput.$(uname -s)"
+ elif [[ -f "$base.expectedoutput" ]]; then
+ exp="$base.expectedoutput"
else
noexp=$((noexp+1))
[[ $VERBOSE -eq 1 ]] && echo "NOEXP $rel"
diff --git a/crates/diff_wasm_examples.sh b/crates/diff_wasm_examples.sh
index c94da70d..1005002f 100755
--- a/crates/diff_wasm_examples.sh
+++ b/crates/diff_wasm_examples.sh
@@ -30,12 +30,17 @@ mkdir -p "$OUTDIR"
for f in $(find $EXDIR -name '*.osp' | sort); do
rel=${f#$EXDIR/}
[[ -n "$FILTER" && "$rel" != *"$FILTER"* ]] && continue
- # Expected-output precedence matches crates/diff_examples.sh exactly: shared
- # expectation first, then the OS-specific expectation.
+ # Expected-output precedence matches crates/diff_examples.sh exactly: the
+ # per-file expectation first, then the OS-specific one, then the flavor-shared
+ # .expectedoutput so an ML twin pair (foo.osp + foo.ospml) reuses ONE
+ # golden file ([FLAVOR-IR-EQUIV]) — both flavors emit byte-identical output.
+ base="${f%.*}"
if [[ -f "$f.expectedoutput" ]]; then
exp="$f.expectedoutput"
elif [[ -f "$f.expectedoutput.$(uname -s)" ]]; then
exp="$f.expectedoutput.$(uname -s)"
+ elif [[ -f "$base.expectedoutput" ]]; then
+ exp="$base.expectedoutput"
else
noexp=$((noexp+1))
[[ $VERBOSE -eq 1 ]] && echo "NOEXP $rel"
diff --git a/crates/osprey-cli/Cargo.toml b/crates/osprey-cli/Cargo.toml
index 5994a07d..9e391f0a 100644
--- a/crates/osprey-cli/Cargo.toml
+++ b/crates/osprey-cli/Cargo.toml
@@ -16,6 +16,7 @@ osprey-debug = { workspace = true }
osprey-syntax = { workspace = true }
osprey-types = { workspace = true }
osprey-codegen = { workspace = true }
+osprey-fmt = { workspace = true }
osprey-lsp = { workspace = true }
tokio = { workspace = true }
diff --git a/crates/osprey-cli/src/docs.rs b/crates/osprey-cli/src/docs.rs
index 2cb67654..0af80b43 100644
--- a/crates/osprey-cli/src/docs.rs
+++ b/crates/osprey-cli/src/docs.rs
@@ -288,6 +288,26 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
+ #[test]
+ fn generate_surfacing_an_io_error_takes_the_failure_branch() {
+ // Point `--docs-dir` at a path whose parent is a *file*: `create_dir_all`
+ // then fails, so `generate` returns `Err` and `run` takes its error arm.
+ let file = std::env::temp_dir().join("osprey_docs_not_a_dir");
+ fs::write(&file, "i am a file").expect("seed blocking file");
+ let blocked = file.join("under_a_file");
+ assert!(
+ generate(&blocked).is_err(),
+ "creating a dir beneath a file must fail"
+ );
+ let code = run(&[
+ "--docs".to_string(),
+ "--docs-dir".to_string(),
+ blocked.to_string_lossy().into_owned(),
+ ]);
+ let _ = code; // ExitCode is opaque; reaching here proves the arm ran.
+ let _ = fs::remove_file(&file);
+ }
+
#[test]
fn run_generates_into_a_dir_and_takes_the_usage_branch_without_a_flag() {
let dir = fresh_dir("run");
diff --git a/crates/osprey-cli/src/fmt.rs b/crates/osprey-cli/src/fmt.rs
new file mode 100644
index 00000000..b67450b4
--- /dev/null
+++ b/crates/osprey-cli/src/fmt.rs
@@ -0,0 +1,211 @@
+//! `osprey fmt` — the source formatter command.
+//!
+//! Formats `.osp` (Default flavor) and `.ospml` (ML flavor) sources, picking the
+//! flavor from each file's extension/marker unless `--flavor` overrides it. By
+//! default files are rewritten in place; `--check` reports which files would
+//! change (and exits non-zero) without touching them, `--stdout` prints the
+//! formatted text instead of writing, and a single `-` path formats stdin to
+//! stdout. The heavy lifting — and the meaning-preserving guarantee — lives in
+//! the shared `osprey-fmt` crate, so the CLI and the editor format identically.
+
+use std::io::Read;
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+
+use osprey_syntax::Flavor;
+
+const USAGE: &str = "usage: osprey fmt [--check | --stdout] [--flavor default|ml] [--quiet] \
+\n osprey fmt - (format stdin to stdout)";
+
+/// The parsed `fmt` invocation.
+#[derive(Debug, Default)]
+struct FmtArgs {
+ paths: Vec,
+ check: bool,
+ stdout: bool,
+ quiet: bool,
+ flavor: Option,
+}
+
+/// What happened across all processed files, collapsed into an exit code.
+#[derive(Debug, Default)]
+struct Tally {
+ had_error: bool,
+ needs_format: bool,
+}
+
+/// Entry point for `osprey fmt`; `args` excludes the `fmt` subcommand word.
+pub fn run(args: &[String]) -> ExitCode {
+ let parsed = match parse(args) {
+ Ok(parsed) => parsed,
+ Err(message) => {
+ eprintln!("{message}");
+ return ExitCode::from(2);
+ }
+ };
+ if parsed.paths == ["-"] {
+ return format_stdin(&parsed);
+ }
+ let mut tally = Tally::default();
+ for path in collect_files(&parsed.paths) {
+ process_file(&path, &parsed, &mut tally);
+ }
+ exit_code(&tally)
+}
+
+/// Parse `fmt`'s flags and positional paths.
+fn parse(args: &[String]) -> Result {
+ let mut out = FmtArgs::default();
+ let mut it = args.iter();
+ while let Some(arg) = it.next() {
+ match arg.as_str() {
+ "--check" => out.check = true,
+ "--stdout" => out.stdout = true,
+ "--quiet" => out.quiet = true,
+ "--flavor" => {
+ let value = it
+ .next()
+ .ok_or_else(|| format!("--flavor requires a value (default|ml)\n{USAGE}"))?;
+ out.flavor = Some(parse_flavor(value)?);
+ }
+ flag if flag.starts_with("--flavor=") => {
+ out.flavor = Some(parse_flavor(
+ flag.strip_prefix("--flavor=").unwrap_or_default(),
+ )?);
+ }
+ flag if flag.starts_with("--") => return Err(format!("unknown flag {flag}\n{USAGE}")),
+ path => out.paths.push(path.to_owned()),
+ }
+ }
+ if out.paths.is_empty() {
+ return Err(USAGE.to_owned());
+ }
+ Ok(out)
+}
+
+fn parse_flavor(value: &str) -> Result {
+ value.parse().map_err(|e| format!("{e}\n{USAGE}"))
+}
+
+/// Expand the given paths into a flat list of source files, recursing into
+/// directories for `.osp` and `.ospml` files.
+fn collect_files(paths: &[String]) -> Vec {
+ let mut out = Vec::new();
+ for path in paths {
+ let candidate = PathBuf::from(path);
+ if candidate.is_dir() {
+ collect_dir(&candidate, &mut out);
+ } else {
+ out.push(candidate);
+ }
+ }
+ out
+}
+
+fn collect_dir(dir: &Path, out: &mut Vec) {
+ let Ok(entries) = std::fs::read_dir(dir) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_dir(&path, out);
+ } else if is_osprey_source(&path) {
+ out.push(path);
+ }
+ }
+}
+
+fn is_osprey_source(path: &Path) -> bool {
+ path.extension()
+ .and_then(|e| e.to_str())
+ .is_some_and(|e| e == "osp" || e == "ospml")
+}
+
+/// Format a single file according to the parsed options, recording the outcome.
+fn process_file(path: &Path, args: &FmtArgs, tally: &mut Tally) {
+ let display = path.display();
+ let source = match std::fs::read_to_string(path) {
+ Ok(source) => source,
+ Err(e) => {
+ eprintln!("error: cannot read {display}: {e}");
+ tally.had_error = true;
+ return;
+ }
+ };
+ match format(&source, &path.to_string_lossy(), args.flavor) {
+ Ok(formatted) => emit(&formatted, &source, path, args, tally),
+ Err(errors) => {
+ for error in errors {
+ eprintln!("{display}: {error}");
+ }
+ tally.had_error = true;
+ }
+ }
+}
+
+/// Format `source`, choosing the flavor from the explicit flag or the path.
+fn format(source: &str, key: &str, flavor: Option) -> Result> {
+ match flavor {
+ Some(flavor) => osprey_fmt::format_source(source, flavor),
+ None => osprey_fmt::format_for_path(key, source),
+ }
+}
+
+/// Apply the formatted text: print to stdout, list under `--check`, or rewrite
+/// the file in place, updating the tally.
+fn emit(formatted: &str, source: &str, path: &Path, args: &FmtArgs, tally: &mut Tally) {
+ let display = path.display();
+ if args.stdout {
+ print!("{formatted}");
+ return;
+ }
+ if formatted == source {
+ return;
+ }
+ if args.check {
+ println!("would reformat {display}");
+ tally.needs_format = true;
+ return;
+ }
+ match std::fs::write(path, formatted) {
+ Ok(()) => {
+ if !args.quiet {
+ println!("formatted {display}");
+ }
+ }
+ Err(e) => {
+ eprintln!("error: cannot write {display}: {e}");
+ tally.had_error = true;
+ }
+ }
+}
+
+/// Read stdin, format it (defaulting to the Default flavor), and print it.
+fn format_stdin(args: &FmtArgs) -> ExitCode {
+ let mut source = String::new();
+ if let Err(e) = std::io::stdin().read_to_string(&mut source) {
+ eprintln!("error: cannot read stdin: {e}");
+ return ExitCode::from(2);
+ }
+ match osprey_fmt::format_source(&source, args.flavor.unwrap_or(Flavor::Default)) {
+ Ok(formatted) => {
+ print!("{formatted}");
+ ExitCode::SUCCESS
+ }
+ Err(errors) => {
+ for error in errors {
+ eprintln!("stdin: {error}");
+ }
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn exit_code(tally: &Tally) -> ExitCode {
+ if tally.had_error || tally.needs_format {
+ ExitCode::FAILURE
+ } else {
+ ExitCode::SUCCESS
+ }
+}
diff --git a/crates/osprey-cli/src/main.rs b/crates/osprey-cli/src/main.rs
index fce85a68..f85d4e7f 100644
--- a/crates/osprey-cli/src/main.rs
+++ b/crates/osprey-cli/src/main.rs
@@ -15,16 +15,20 @@
//! outline/signature helpers it shares now live there too.
mod docs;
+mod fmt;
mod sandbox;
mod wasm;
+use osprey_syntax::Flavor;
use sandbox::Policy;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
const USAGE: &str = "usage: osprey [--check | --ast | --llvm | --compile | --run | \
---symbols] [--quiet] [--debug] [--memory=default|gc] [--target=native|wasm32] [-o ] \
+--symbols] [--quiet] [--debug] [--flavor default|ml] [--memory=default|gc] \
+[--target=native|wasm32] [-o ] \
[--sandbox | --no-http | --no-websocket | --no-fs | --no-ffi]\n\
+ osprey fmt [--check | --stdout] [--flavor default|ml] \n\
osprey --hover \n\
osprey --docs --docs-dir \n\
osprey lsp";
@@ -47,6 +51,10 @@ struct Cli {
output: Option,
/// Emit source-level debug metadata and link a debugger-friendly binary.
debug: bool,
+ /// Explicit source flavor from `--flavor`; `None` when unset, so flavor
+ /// resolution falls through to the marker/extension precedence
+ /// ([FLAVOR-SELECT], docs/specs/0023-LanguageFlavors.md).
+ flavor: Option,
}
fn main() -> ExitCode {
@@ -73,6 +81,10 @@ fn main() -> ExitCode {
if args.first().map(String::as_str) == Some("lsp") {
return run_lsp();
}
+ // `osprey fmt`: reformat Osprey sources (both flavors). No compilation.
+ if args.first().map(String::as_str) == Some("fmt") {
+ return fmt::run(args.get(1..).unwrap_or_default());
+ }
// `osprey --docs`: regenerate the built-in function reference from the
// compiler's metadata. No source file is involved.
if args.iter().any(|a| a == "--docs") {
@@ -125,6 +137,7 @@ fn parse_args(args: &[String]) -> Result {
let mut target = String::from("native");
let mut output = None;
let mut debug = false;
+ let mut flavor = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
@@ -145,6 +158,19 @@ fn parse_args(args: &[String]) -> Result {
.ok_or_else(|| format!("-o requires a path\n{USAGE}"))?;
output = Some(next.clone());
}
+ // `--flavor ` selects the source flavor explicitly (highest
+ // selection precedence). [FLAVOR-SELECT]
+ "--flavor" => {
+ let next = it
+ .next()
+ .ok_or_else(|| format!("--flavor requires a value (default|ml)\n{USAGE}"))?;
+ flavor = Some(parse_flavor(next)?);
+ }
+ flag if flag.starts_with("--flavor=") => {
+ flavor = Some(parse_flavor(
+ flag.strip_prefix("--flavor=").unwrap_or_default(),
+ )?);
+ }
flag if flag.starts_with("--memory=") => {
memory = parse_memory(flag.strip_prefix("--memory=").unwrap_or_default())?;
}
@@ -166,6 +192,7 @@ fn parse_args(args: &[String]) -> Result {
target,
output,
debug,
+ flavor,
}),
None => Err(USAGE.to_string()),
}
@@ -196,6 +223,11 @@ fn parse_memory(value: &str) -> Result {
}
}
+/// Validate a `--flavor` / marker value into a [`Flavor`]. [FLAVOR-SELECT]
+fn parse_flavor(value: &str) -> Result {
+ value.parse().map_err(|e| format!("{e}\n{USAGE}"))
+}
+
/// Parse, gate (syntax → sandbox → types), and dispatch the selected mode.
fn run(cli: &Cli) -> ExitCode {
let path = &cli.path;
@@ -206,7 +238,14 @@ fn run(cli: &Cli) -> ExitCode {
return ExitCode::from(2);
}
};
- let parsed = osprey_syntax::parse_program(&source);
+ let flavor = match osprey_syntax::resolve_flavor(cli.flavor, path, &source) {
+ Ok(flavor) => flavor,
+ Err(msg) => {
+ eprintln!("{msg}");
+ return ExitCode::from(2);
+ }
+ };
+ let parsed = osprey_syntax::parse_program_with_flavor(&source, flavor);
if !parsed.errors.is_empty() {
for err in &parsed.errors {
eprintln!(
@@ -569,6 +608,1086 @@ fn openssl_flags() -> Vec {
mod tests {
use super::*;
+ use std::collections::BTreeSet;
+ use std::fs;
+ use std::path::{Path, PathBuf};
+ use std::process::Command;
+ use std::sync::{Mutex, MutexGuard, OnceLock};
+
+ #[derive(Debug)]
+ struct Out {
+ code: Option,
+ stdout: String,
+ stderr: String,
+ }
+
+ fn repo_root() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..")
+ }
+
+ fn example_lock() -> MutexGuard<'static, ()> {
+ let lock = EXAMPLE_LOCK.get_or_init(|| Mutex::new(()));
+ match lock.lock() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ }
+ }
+
+ struct CurrentDirGuard {
+ prior: PathBuf,
+ }
+
+ impl Drop for CurrentDirGuard {
+ fn drop(&mut self) {
+ let _ = std::env::set_current_dir(&self.prior);
+ }
+ }
+
+ fn enter_repo_root() -> Result {
+ let prior = std::env::current_dir().map_err(|e| format!("cannot read cwd: {e}"))?;
+ std::env::set_current_dir(repo_root())
+ .map_err(|e| format!("cannot enter repo root {}: {e}", repo_root().display()))?;
+ Ok(CurrentDirGuard { prior })
+ }
+
+ fn read_text(path: &Path) -> Result {
+ fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))
+ }
+
+ fn native_exe_path(source: &Path) -> PathBuf {
+ let rel = repo_relative(source);
+ let sanitized = rel
+ .chars()
+ .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
+ .collect::();
+ std::env::temp_dir().join(format!("osprey_golden_{sanitized}"))
+ }
+
+ fn parse_example(path: &str, source: &str) -> Result {
+ let flavor = osprey_syntax::resolve_flavor(None, path, source)
+ .map_err(|e| format!("{path}: {e}"))?;
+ let parsed = osprey_syntax::parse_program_with_flavor(source, flavor);
+ if !parsed.errors.is_empty() {
+ let errors = parsed
+ .errors
+ .iter()
+ .map(|e| {
+ format!(
+ "{}:{}:{}: {}",
+ path, e.position.line, e.position.column, e.message
+ )
+ })
+ .collect::>()
+ .join("\n");
+ return Err(errors);
+ }
+ Ok(parsed.program)
+ }
+
+ fn run_example(source: &Path) -> Result {
+ let source_text = read_text(source)?;
+ let path = source.to_string_lossy().into_owned();
+ let program = parse_example(&path, &source_text)?;
+ let _cwd = enter_repo_root()?;
+
+ let violations = sandbox::violations(&program, Policy::allow_all());
+ if !violations.is_empty() {
+ return Err(format!("{}: {}", path, violations.join("\n")));
+ }
+
+ let type_errors = osprey_types::check_program(&program);
+ if !type_errors.is_empty() {
+ let errors = type_errors
+ .iter()
+ .map(|e| match e.position {
+ Some(p) => format!("{}:{}:{}: {}", path, p.line, p.column, e.message),
+ None => format!("{}: {}", path, e.message),
+ })
+ .collect::>()
+ .join("\n");
+ return Err(errors);
+ }
+
+ let exe = native_exe_path(source);
+ build_executable(&path, &program, &source_text, &exe, "default", false)
+ .map_err(|code| format!("{}: native build failed: {code:?}", source.display()))?;
+
+ Command::new(&exe)
+ .output()
+ .map(|out| Out {
+ code: out.status.code(),
+ stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
+ stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
+ })
+ .map_err(|e| format!("could not run {}: {e}", exe.display()))
+ }
+
+ fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf {
+ PathBuf::from(format!("{}{}", path.display(), suffix))
+ }
+
+ fn source_base(source: &Path) -> PathBuf {
+ let mut base = source.to_path_buf();
+ assert!(
+ base.set_extension(""),
+ "example path has no extension: {}",
+ source.display()
+ );
+ base
+ }
+
+ fn uname_s() -> &'static str {
+ match std::env::consts::OS {
+ "macos" => "Darwin",
+ "linux" => "Linux",
+ "windows" => "Windows_NT",
+ other => other,
+ }
+ }
+
+ fn expected_candidates(source: &Path) -> Vec {
+ let os = uname_s();
+ let base = source_base(source);
+ vec![
+ path_with_suffix(source, ".expectedoutput"),
+ path_with_suffix(source, &format!(".expectedoutput.{os}")),
+ path_with_suffix(&base, ".osp.expectedoutput"),
+ path_with_suffix(&base, &format!(".osp.expectedoutput.{os}")),
+ path_with_suffix(&base, ".expectedoutput"),
+ ]
+ }
+
+ fn expected_output_path(source: &Path) -> Result {
+ for candidate in expected_candidates(source) {
+ if candidate.is_file() {
+ return Ok(candidate);
+ }
+ }
+ Err(format!("missing expected output for {}", source.display()))
+ }
+
+ fn check_example_matches(rel_source: &str) -> Result<(), String> {
+ let _guard = example_lock();
+ let source = repo_root().join(rel_source);
+ let expected_path = expected_output_path(&source)?;
+ let expected = read_text(&expected_path)?;
+ let actual = run_example(&source)?;
+
+ if actual.code == Some(0) && actual.stdout.trim() == expected.trim() {
+ return Ok(());
+ }
+
+ Err(format!(
+ "{rel_source}\nstatus={:?}\nexpected file={}\n--- expected ---\n{}\n--- actual ---\n{}\n--- stderr ---\n{}",
+ actual.code,
+ expected_path.display(),
+ expected.trim(),
+ actual.stdout.trim(),
+ actual.stderr.trim()
+ ))
+ }
+
+ fn assert_example_matches(rel_source: &str) {
+ if let Err(e) = check_example_matches(rel_source) {
+ assert!(e.is_empty(), "{e}");
+ }
+ }
+
+ fn collect_sources(dir: &Path, out: &mut Vec) {
+ let Ok(entries) = fs::read_dir(dir) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_sources(&path, out);
+ } else if matches!(
+ path.extension().and_then(|ext| ext.to_str()),
+ Some("osp" | "ospml")
+ ) {
+ out.push(path);
+ }
+ }
+ }
+
+ fn tested_example_sources() -> Vec {
+ let mut out = Vec::new();
+ collect_sources(&repo_root().join("examples/tested"), &mut out);
+ out.sort();
+ out
+ }
+
+ fn repo_relative(path: &Path) -> String {
+ let root = repo_root();
+ match path.strip_prefix(&root) {
+ Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
+ Err(_) => path.to_string_lossy().replace('\\', "/"),
+ }
+ }
+
+ #[test]
+ fn all_tested_examples_are_registered_as_individual_tests() {
+ let discovered = tested_example_sources()
+ .iter()
+ .map(|path| repo_relative(path))
+ .collect::>();
+ let registered = REGISTERED_EXAMPLES
+ .iter()
+ .map(|path| (*path).to_string())
+ .collect::>();
+
+ let missing = discovered
+ .difference(®istered)
+ .cloned()
+ .collect::>();
+ let stale = registered
+ .difference(&discovered)
+ .cloned()
+ .collect::>();
+
+ assert!(
+ missing.is_empty() && stale.is_empty(),
+ "every examples/tested fixture must have a named Rust test\nmissing:\n{}\nstale:\n{}",
+ missing.join("\n"),
+ stale.join("\n")
+ );
+ }
+
+ static EXAMPLE_LOCK: OnceLock> = OnceLock::new();
+
+ const REGISTERED_EXAMPLES: &[&str] = &[
+ "examples/tested/basics/blocks/block_statements_basic.osp",
+ "examples/tested/basics/blocks/block_statements_basic.ospml",
+ "examples/tested/basics/cursor/codepoint_roundtrip.osp",
+ "examples/tested/basics/cursor/codepoint_roundtrip.ospml",
+ "examples/tested/basics/cursor/kv_parser.osp",
+ "examples/tested/basics/cursor/kv_parser.ospml",
+ "examples/tested/basics/cursor/token_scan.osp",
+ "examples/tested/basics/cursor/token_scan.ospml",
+ "examples/tested/basics/cursor/utf8_walk.osp",
+ "examples/tested/basics/cursor/utf8_walk.ospml",
+ "examples/tested/basics/errors/error_messages.osp",
+ "examples/tested/basics/errors/error_messages.ospml",
+ "examples/tested/basics/errors/validation_pipeline.osp",
+ "examples/tested/basics/errors/validation_pipeline.ospml",
+ "examples/tested/basics/feature_omnibus.osp",
+ "examples/tested/basics/feature_omnibus.ospml",
+ "examples/tested/basics/field_access_comprehensive.osp",
+ "examples/tested/basics/field_access_comprehensive.ospml",
+ "examples/tested/basics/files/file_io_json_workflow.osp",
+ "examples/tested/basics/files/file_io_json_workflow.ospml",
+ "examples/tested/basics/function_composition_test.osp",
+ "examples/tested/basics/functional/functional_showcase.osp",
+ "examples/tested/basics/functional/functional_showcase.ospml",
+ "examples/tested/basics/games/adventure_game.osp",
+ "examples/tested/basics/games/adventure_game.ospml",
+ "examples/tested/basics/games/space_trader.osp",
+ "examples/tested/basics/games/space_trader.ospml",
+ "examples/tested/basics/knownbugs/bug1_spawn_record.osp",
+ "examples/tested/basics/knownbugs/bug1_spawn_record.ospml",
+ "examples/tested/basics/knownbugs/bug2_string_union_payload.osp",
+ "examples/tested/basics/knownbugs/bug2_string_union_payload.ospml",
+ "examples/tested/basics/knownbugs/bug3_map_built_index.osp",
+ "examples/tested/basics/knownbugs/bug3_map_built_index.ospml",
+ "examples/tested/basics/knownbugs/bug4_union_return_arg.osp",
+ "examples/tested/basics/knownbugs/bug4_union_return_arg.ospml",
+ "examples/tested/basics/lists/list_basics.osp",
+ "examples/tested/basics/lists/list_basics.ospml",
+ "examples/tested/basics/lists/map_basics.osp",
+ "examples/tested/basics/lists/map_basics.ospml",
+ "examples/tested/basics/math/comprehensive_math.osp",
+ "examples/tested/basics/math/comprehensive_math.ospml",
+ "examples/tested/basics/operators/boolean_consolidated.osp",
+ "examples/tested/basics/operators/boolean_consolidated.ospml",
+ "examples/tested/basics/osprey_mega_showcase.osp",
+ "examples/tested/basics/osprey_mega_showcase.ospml",
+ "examples/tested/basics/pattern_matching/pattern_matching_complete.osp",
+ "examples/tested/basics/processes/async_process_management.osp",
+ "examples/tested/basics/processes/async_process_management.ospml",
+ "examples/tested/basics/processes/callback_stdout_demo.osp",
+ "examples/tested/basics/processes/callback_stdout_demo.ospml",
+ "examples/tested/basics/strings/string_edge_cases.osp",
+ "examples/tested/basics/strings/string_edge_cases.ospml",
+ "examples/tested/basics/strings/string_pipeline.osp",
+ "examples/tested/basics/strings/string_pipeline.ospml",
+ "examples/tested/basics/types/any_type_comprehensive.osp",
+ "examples/tested/basics/types/any_type_comprehensive.ospml",
+ "examples/tested/basics/types/pure_hindley_milner_test.osp",
+ "examples/tested/basics/types/pure_hindley_milner_test.ospml",
+ "examples/tested/basics/types/record_update_basic.osp",
+ "examples/tested/basics/types/record_update_basic.ospml",
+ "examples/tested/basics/types/recursive_unions.osp",
+ "examples/tested/basics/types/recursive_unions.ospml",
+ "examples/tested/basics/types/type_equality_comprehensive.osp",
+ "examples/tested/basics/types/type_equality_comprehensive.ospml",
+ "examples/tested/basics/types/user_defined_unions.osp",
+ "examples/tested/basics/types/user_defined_unions.ospml",
+ "examples/tested/basics/validation/proper_validation_test.osp",
+ "examples/tested/basics/validation/proper_validation_test.ospml",
+ "examples/tested/basics/website/website_examples.osp",
+ "examples/tested/basics/website/website_examples.ospml",
+ "examples/tested/db/database_effect.osp",
+ "examples/tested/db/database_effect.ospml",
+ "examples/tested/db/sqlite_basics.osp",
+ "examples/tested/db/sqlite_basics.ospml",
+ "examples/tested/effects/algebraic_effects_comprehensive.osp",
+ "examples/tested/effects/algebraic_effects_comprehensive.ospml",
+ "examples/tested/effects/fiber_effects.osp",
+ "examples/tested/effects/fiber_effects.ospml",
+ "examples/tested/effects/handler_scoping.osp",
+ "examples/tested/effects/handler_scoping.ospml",
+ "examples/tested/effects/http_state_levels.osp",
+ "examples/tested/effects/http_state_levels.ospml",
+ "examples/tested/effects/resume_abort_early_exit.osp",
+ "examples/tested/effects/resume_abort_early_exit.ospml",
+ "examples/tested/effects/resume_lifo_audit.osp",
+ "examples/tested/effects/resume_lifo_audit.ospml",
+ "examples/tested/effects/resume_outer_handler_bridge.osp",
+ "examples/tested/effects/resume_outer_handler_bridge.ospml",
+ "examples/tested/effects/resume_unit_markers.osp",
+ "examples/tested/effects/resume_unit_markers.ospml",
+ "examples/tested/effects/resume_value_rewrite.osp",
+ "examples/tested/effects/resume_value_rewrite.ospml",
+ "examples/tested/fiber/fiber_showcase.osp",
+ "examples/tested/fiber/fiber_showcase.ospml",
+ "examples/tested/http/http_client_example.osp",
+ "examples/tested/http/http_client_example.ospml",
+ "examples/tested/http/http_create_client.osp",
+ "examples/tested/http/http_create_client.ospml",
+ "examples/tested/http/http_response_handle.osp",
+ "examples/tested/http/http_response_handle.ospml",
+ "examples/tested/http/http_server_example.osp",
+ "examples/tested/http/http_server_example.ospml",
+ "examples/tested/http/tui_repo_table.osp",
+ "examples/tested/http/tui_repo_table.ospml",
+ "examples/tested/ml/arith.osp",
+ "examples/tested/ml/arith.ospml",
+ "examples/tested/ml/booleans.osp",
+ "examples/tested/ml/booleans.ospml",
+ "examples/tested/ml/closures.osp",
+ "examples/tested/ml/closures.ospml",
+ "examples/tested/ml/curry_partial.osp",
+ "examples/tested/ml/curry_partial.ospml",
+ "examples/tested/ml/curry_tour.osp",
+ "examples/tested/ml/curry_tour.ospml",
+ "examples/tested/ml/hello.osp",
+ "examples/tested/ml/hello.ospml",
+ "examples/tested/ml/hof.osp",
+ "examples/tested/ml/hof.ospml",
+ "examples/tested/ml/match_tour.osp",
+ "examples/tested/ml/match_tour.ospml",
+ "examples/tested/ml/matchbool.osp",
+ "examples/tested/ml/matchbool.ospml",
+ "examples/tested/ml/matchint.osp",
+ "examples/tested/ml/matchint.ospml",
+ "examples/tested/ml/mixed.osp",
+ "examples/tested/ml/mixed.ospml",
+ "examples/tested/ml/mutation.osp",
+ "examples/tested/ml/mutation.ospml",
+ "examples/tested/ml/nested_calls.osp",
+ "examples/tested/ml/nested_calls.ospml",
+ "examples/tested/ml/pipechain.osp",
+ "examples/tested/ml/pipechain.ospml",
+ "examples/tested/ml/recursion.osp",
+ "examples/tested/ml/recursion.ospml",
+ "examples/tested/ml/results_state_hof.osp",
+ "examples/tested/ml/results_state_hof.ospml",
+ "examples/tested/ml/strings.osp",
+ "examples/tested/ml/strings.ospml",
+ ];
+
+ #[test]
+ fn basics_blocks_block_statements_basic_osp() {
+ assert_example_matches("examples/tested/basics/blocks/block_statements_basic.osp");
+ }
+
+ #[test]
+ fn basics_blocks_block_statements_basic_ospml() {
+ assert_example_matches("examples/tested/basics/blocks/block_statements_basic.ospml");
+ }
+
+ #[test]
+ fn basics_cursor_codepoint_roundtrip_osp() {
+ assert_example_matches("examples/tested/basics/cursor/codepoint_roundtrip.osp");
+ }
+
+ #[test]
+ fn basics_cursor_codepoint_roundtrip_ospml() {
+ assert_example_matches("examples/tested/basics/cursor/codepoint_roundtrip.ospml");
+ }
+
+ #[test]
+ fn basics_cursor_kv_parser_osp() {
+ assert_example_matches("examples/tested/basics/cursor/kv_parser.osp");
+ }
+
+ #[test]
+ fn basics_cursor_kv_parser_ospml() {
+ assert_example_matches("examples/tested/basics/cursor/kv_parser.ospml");
+ }
+
+ #[test]
+ fn basics_cursor_token_scan_osp() {
+ assert_example_matches("examples/tested/basics/cursor/token_scan.osp");
+ }
+
+ #[test]
+ fn basics_cursor_token_scan_ospml() {
+ assert_example_matches("examples/tested/basics/cursor/token_scan.ospml");
+ }
+
+ #[test]
+ fn basics_cursor_utf8_walk_osp() {
+ assert_example_matches("examples/tested/basics/cursor/utf8_walk.osp");
+ }
+
+ #[test]
+ fn basics_cursor_utf8_walk_ospml() {
+ assert_example_matches("examples/tested/basics/cursor/utf8_walk.ospml");
+ }
+
+ #[test]
+ fn basics_errors_error_messages_osp() {
+ assert_example_matches("examples/tested/basics/errors/error_messages.osp");
+ }
+
+ #[test]
+ fn basics_errors_error_messages_ospml() {
+ assert_example_matches("examples/tested/basics/errors/error_messages.ospml");
+ }
+
+ #[test]
+ fn basics_errors_validation_pipeline_osp() {
+ assert_example_matches("examples/tested/basics/errors/validation_pipeline.osp");
+ }
+
+ #[test]
+ fn basics_errors_validation_pipeline_ospml() {
+ assert_example_matches("examples/tested/basics/errors/validation_pipeline.ospml");
+ }
+
+ #[test]
+ fn basics_feature_omnibus_osp() {
+ assert_example_matches("examples/tested/basics/feature_omnibus.osp");
+ }
+
+ #[test]
+ fn basics_feature_omnibus_ospml() {
+ assert_example_matches("examples/tested/basics/feature_omnibus.ospml");
+ }
+
+ #[test]
+ fn basics_field_access_comprehensive_osp() {
+ assert_example_matches("examples/tested/basics/field_access_comprehensive.osp");
+ }
+
+ #[test]
+ fn basics_field_access_comprehensive_ospml() {
+ assert_example_matches("examples/tested/basics/field_access_comprehensive.ospml");
+ }
+
+ #[test]
+ fn basics_files_file_io_json_workflow_osp() {
+ assert_example_matches("examples/tested/basics/files/file_io_json_workflow.osp");
+ }
+
+ #[test]
+ fn basics_files_file_io_json_workflow_ospml() {
+ assert_example_matches("examples/tested/basics/files/file_io_json_workflow.ospml");
+ }
+
+ #[test]
+ fn basics_function_composition_test_osp() {
+ assert_example_matches("examples/tested/basics/function_composition_test.osp");
+ }
+
+ #[test]
+ fn basics_functional_functional_showcase_osp() {
+ assert_example_matches("examples/tested/basics/functional/functional_showcase.osp");
+ }
+
+ #[test]
+ fn basics_functional_functional_showcase_ospml() {
+ assert_example_matches("examples/tested/basics/functional/functional_showcase.ospml");
+ }
+
+ #[test]
+ fn basics_games_adventure_game_osp() {
+ assert_example_matches("examples/tested/basics/games/adventure_game.osp");
+ }
+
+ #[test]
+ fn basics_games_adventure_game_ospml() {
+ assert_example_matches("examples/tested/basics/games/adventure_game.ospml");
+ }
+
+ #[test]
+ fn basics_games_space_trader_osp() {
+ assert_example_matches("examples/tested/basics/games/space_trader.osp");
+ }
+
+ #[test]
+ fn basics_games_space_trader_ospml() {
+ assert_example_matches("examples/tested/basics/games/space_trader.ospml");
+ }
+
+ #[test]
+ fn basics_knownbugs_bug1_spawn_record_osp() {
+ assert_example_matches("examples/tested/basics/knownbugs/bug1_spawn_record.osp");
+ }
+
+ #[test]
+ fn basics_knownbugs_bug1_spawn_record_ospml() {
+ assert_example_matches("examples/tested/basics/knownbugs/bug1_spawn_record.ospml");
+ }
+
+ #[test]
+ fn basics_knownbugs_bug2_string_union_payload_osp() {
+ assert_example_matches("examples/tested/basics/knownbugs/bug2_string_union_payload.osp");
+ }
+
+ #[test]
+ fn basics_knownbugs_bug2_string_union_payload_ospml() {
+ assert_example_matches("examples/tested/basics/knownbugs/bug2_string_union_payload.ospml");
+ }
+
+ #[test]
+ fn basics_knownbugs_bug3_map_built_index_osp() {
+ assert_example_matches("examples/tested/basics/knownbugs/bug3_map_built_index.osp");
+ }
+
+ #[test]
+ fn basics_knownbugs_bug3_map_built_index_ospml() {
+ assert_example_matches("examples/tested/basics/knownbugs/bug3_map_built_index.ospml");
+ }
+
+ #[test]
+ fn basics_knownbugs_bug4_union_return_arg_osp() {
+ assert_example_matches("examples/tested/basics/knownbugs/bug4_union_return_arg.osp");
+ }
+
+ #[test]
+ fn basics_knownbugs_bug4_union_return_arg_ospml() {
+ assert_example_matches("examples/tested/basics/knownbugs/bug4_union_return_arg.ospml");
+ }
+
+ #[test]
+ fn basics_lists_list_basics_osp() {
+ assert_example_matches("examples/tested/basics/lists/list_basics.osp");
+ }
+
+ #[test]
+ fn basics_lists_list_basics_ospml() {
+ assert_example_matches("examples/tested/basics/lists/list_basics.ospml");
+ }
+
+ #[test]
+ fn basics_lists_map_basics_osp() {
+ assert_example_matches("examples/tested/basics/lists/map_basics.osp");
+ }
+
+ #[test]
+ fn basics_lists_map_basics_ospml() {
+ assert_example_matches("examples/tested/basics/lists/map_basics.ospml");
+ }
+
+ #[test]
+ fn basics_math_comprehensive_math_osp() {
+ assert_example_matches("examples/tested/basics/math/comprehensive_math.osp");
+ }
+
+ #[test]
+ fn basics_math_comprehensive_math_ospml() {
+ assert_example_matches("examples/tested/basics/math/comprehensive_math.ospml");
+ }
+
+ #[test]
+ fn basics_operators_boolean_consolidated_osp() {
+ assert_example_matches("examples/tested/basics/operators/boolean_consolidated.osp");
+ }
+
+ #[test]
+ fn basics_operators_boolean_consolidated_ospml() {
+ assert_example_matches("examples/tested/basics/operators/boolean_consolidated.ospml");
+ }
+
+ #[test]
+ fn basics_osprey_mega_showcase_osp() {
+ assert_example_matches("examples/tested/basics/osprey_mega_showcase.osp");
+ }
+
+ #[test]
+ fn basics_osprey_mega_showcase_ospml() {
+ assert_example_matches("examples/tested/basics/osprey_mega_showcase.ospml");
+ }
+
+ #[test]
+ fn basics_pattern_matching_pattern_matching_complete_osp() {
+ assert_example_matches(
+ "examples/tested/basics/pattern_matching/pattern_matching_complete.osp",
+ );
+ }
+
+ #[test]
+ fn basics_processes_async_process_management_osp() {
+ assert_example_matches("examples/tested/basics/processes/async_process_management.osp");
+ }
+
+ #[test]
+ fn basics_processes_async_process_management_ospml() {
+ assert_example_matches("examples/tested/basics/processes/async_process_management.ospml");
+ }
+
+ #[test]
+ fn basics_processes_callback_stdout_demo_osp() {
+ assert_example_matches("examples/tested/basics/processes/callback_stdout_demo.osp");
+ }
+
+ #[test]
+ fn basics_processes_callback_stdout_demo_ospml() {
+ assert_example_matches("examples/tested/basics/processes/callback_stdout_demo.ospml");
+ }
+
+ #[test]
+ fn basics_strings_string_edge_cases_osp() {
+ assert_example_matches("examples/tested/basics/strings/string_edge_cases.osp");
+ }
+
+ #[test]
+ fn basics_strings_string_edge_cases_ospml() {
+ assert_example_matches("examples/tested/basics/strings/string_edge_cases.ospml");
+ }
+
+ #[test]
+ fn basics_strings_string_pipeline_osp() {
+ assert_example_matches("examples/tested/basics/strings/string_pipeline.osp");
+ }
+
+ #[test]
+ fn basics_strings_string_pipeline_ospml() {
+ assert_example_matches("examples/tested/basics/strings/string_pipeline.ospml");
+ }
+
+ #[test]
+ fn basics_types_any_type_comprehensive_osp() {
+ assert_example_matches("examples/tested/basics/types/any_type_comprehensive.osp");
+ }
+
+ #[test]
+ fn basics_types_any_type_comprehensive_ospml() {
+ assert_example_matches("examples/tested/basics/types/any_type_comprehensive.ospml");
+ }
+
+ #[test]
+ fn basics_types_pure_hindley_milner_test_osp() {
+ assert_example_matches("examples/tested/basics/types/pure_hindley_milner_test.osp");
+ }
+
+ #[test]
+ fn basics_types_pure_hindley_milner_test_ospml() {
+ assert_example_matches("examples/tested/basics/types/pure_hindley_milner_test.ospml");
+ }
+
+ #[test]
+ fn basics_types_record_update_basic_osp() {
+ assert_example_matches("examples/tested/basics/types/record_update_basic.osp");
+ }
+
+ #[test]
+ fn basics_types_record_update_basic_ospml() {
+ assert_example_matches("examples/tested/basics/types/record_update_basic.ospml");
+ }
+
+ #[test]
+ fn basics_types_recursive_unions_osp() {
+ assert_example_matches("examples/tested/basics/types/recursive_unions.osp");
+ }
+
+ #[test]
+ fn basics_types_recursive_unions_ospml() {
+ assert_example_matches("examples/tested/basics/types/recursive_unions.ospml");
+ }
+
+ #[test]
+ fn basics_types_type_equality_comprehensive_osp() {
+ assert_example_matches("examples/tested/basics/types/type_equality_comprehensive.osp");
+ }
+
+ #[test]
+ fn basics_types_type_equality_comprehensive_ospml() {
+ assert_example_matches("examples/tested/basics/types/type_equality_comprehensive.ospml");
+ }
+
+ #[test]
+ fn basics_types_user_defined_unions_osp() {
+ assert_example_matches("examples/tested/basics/types/user_defined_unions.osp");
+ }
+
+ #[test]
+ fn basics_types_user_defined_unions_ospml() {
+ assert_example_matches("examples/tested/basics/types/user_defined_unions.ospml");
+ }
+
+ #[test]
+ fn basics_validation_proper_validation_test_osp() {
+ assert_example_matches("examples/tested/basics/validation/proper_validation_test.osp");
+ }
+
+ #[test]
+ fn basics_validation_proper_validation_test_ospml() {
+ assert_example_matches("examples/tested/basics/validation/proper_validation_test.ospml");
+ }
+
+ #[test]
+ fn basics_website_website_examples_osp() {
+ assert_example_matches("examples/tested/basics/website/website_examples.osp");
+ }
+
+ #[test]
+ fn basics_website_website_examples_ospml() {
+ assert_example_matches("examples/tested/basics/website/website_examples.ospml");
+ }
+
+ #[test]
+ fn db_database_effect_osp() {
+ assert_example_matches("examples/tested/db/database_effect.osp");
+ }
+
+ #[test]
+ fn db_database_effect_ospml() {
+ assert_example_matches("examples/tested/db/database_effect.ospml");
+ }
+
+ #[test]
+ fn db_sqlite_basics_osp() {
+ assert_example_matches("examples/tested/db/sqlite_basics.osp");
+ }
+
+ #[test]
+ fn db_sqlite_basics_ospml() {
+ assert_example_matches("examples/tested/db/sqlite_basics.ospml");
+ }
+
+ #[test]
+ fn effects_algebraic_effects_comprehensive_osp() {
+ assert_example_matches("examples/tested/effects/algebraic_effects_comprehensive.osp");
+ }
+
+ #[test]
+ fn effects_algebraic_effects_comprehensive_ospml() {
+ assert_example_matches("examples/tested/effects/algebraic_effects_comprehensive.ospml");
+ }
+
+ #[test]
+ fn effects_fiber_effects_osp() {
+ assert_example_matches("examples/tested/effects/fiber_effects.osp");
+ }
+
+ #[test]
+ fn effects_fiber_effects_ospml() {
+ assert_example_matches("examples/tested/effects/fiber_effects.ospml");
+ }
+
+ #[test]
+ fn effects_handler_scoping_osp() {
+ assert_example_matches("examples/tested/effects/handler_scoping.osp");
+ }
+
+ #[test]
+ fn effects_handler_scoping_ospml() {
+ assert_example_matches("examples/tested/effects/handler_scoping.ospml");
+ }
+
+ #[test]
+ fn effects_http_state_levels_osp() {
+ assert_example_matches("examples/tested/effects/http_state_levels.osp");
+ }
+
+ #[test]
+ fn effects_http_state_levels_ospml() {
+ assert_example_matches("examples/tested/effects/http_state_levels.ospml");
+ }
+
+ #[test]
+ fn effects_resume_abort_early_exit_osp() {
+ assert_example_matches("examples/tested/effects/resume_abort_early_exit.osp");
+ }
+
+ #[test]
+ fn effects_resume_abort_early_exit_ospml() {
+ assert_example_matches("examples/tested/effects/resume_abort_early_exit.ospml");
+ }
+
+ #[test]
+ fn effects_resume_lifo_audit_osp() {
+ assert_example_matches("examples/tested/effects/resume_lifo_audit.osp");
+ }
+
+ #[test]
+ fn effects_resume_lifo_audit_ospml() {
+ assert_example_matches("examples/tested/effects/resume_lifo_audit.ospml");
+ }
+
+ #[test]
+ fn effects_resume_outer_handler_bridge_osp() {
+ assert_example_matches("examples/tested/effects/resume_outer_handler_bridge.osp");
+ }
+
+ #[test]
+ fn effects_resume_outer_handler_bridge_ospml() {
+ assert_example_matches("examples/tested/effects/resume_outer_handler_bridge.ospml");
+ }
+
+ #[test]
+ fn effects_resume_unit_markers_osp() {
+ assert_example_matches("examples/tested/effects/resume_unit_markers.osp");
+ }
+
+ #[test]
+ fn effects_resume_unit_markers_ospml() {
+ assert_example_matches("examples/tested/effects/resume_unit_markers.ospml");
+ }
+
+ #[test]
+ fn effects_resume_value_rewrite_osp() {
+ assert_example_matches("examples/tested/effects/resume_value_rewrite.osp");
+ }
+
+ #[test]
+ fn effects_resume_value_rewrite_ospml() {
+ assert_example_matches("examples/tested/effects/resume_value_rewrite.ospml");
+ }
+
+ #[test]
+ fn fiber_fiber_showcase_osp() {
+ assert_example_matches("examples/tested/fiber/fiber_showcase.osp");
+ }
+
+ #[test]
+ fn fiber_fiber_showcase_ospml() {
+ assert_example_matches("examples/tested/fiber/fiber_showcase.ospml");
+ }
+
+ #[test]
+ fn http_http_client_example_osp() {
+ assert_example_matches("examples/tested/http/http_client_example.osp");
+ }
+
+ #[test]
+ fn http_http_client_example_ospml() {
+ assert_example_matches("examples/tested/http/http_client_example.ospml");
+ }
+
+ #[test]
+ fn http_http_create_client_osp() {
+ assert_example_matches("examples/tested/http/http_create_client.osp");
+ }
+
+ #[test]
+ fn http_http_create_client_ospml() {
+ assert_example_matches("examples/tested/http/http_create_client.ospml");
+ }
+
+ #[test]
+ fn http_http_response_handle_osp() {
+ assert_example_matches("examples/tested/http/http_response_handle.osp");
+ }
+
+ #[test]
+ fn http_http_response_handle_ospml() {
+ assert_example_matches("examples/tested/http/http_response_handle.ospml");
+ }
+
+ #[test]
+ fn http_http_server_example_osp() {
+ assert_example_matches("examples/tested/http/http_server_example.osp");
+ }
+
+ #[test]
+ fn http_http_server_example_ospml() {
+ assert_example_matches("examples/tested/http/http_server_example.ospml");
+ }
+
+ #[test]
+ fn http_tui_repo_table_osp() {
+ assert_example_matches("examples/tested/http/tui_repo_table.osp");
+ }
+
+ #[test]
+ fn http_tui_repo_table_ospml() {
+ assert_example_matches("examples/tested/http/tui_repo_table.ospml");
+ }
+
+ #[test]
+ fn ml_arith_osp() {
+ assert_example_matches("examples/tested/ml/arith.osp");
+ }
+
+ #[test]
+ fn ml_arith_ospml() {
+ assert_example_matches("examples/tested/ml/arith.ospml");
+ }
+
+ #[test]
+ fn ml_booleans_osp() {
+ assert_example_matches("examples/tested/ml/booleans.osp");
+ }
+
+ #[test]
+ fn ml_booleans_ospml() {
+ assert_example_matches("examples/tested/ml/booleans.ospml");
+ }
+
+ #[test]
+ fn ml_closures_osp() {
+ assert_example_matches("examples/tested/ml/closures.osp");
+ }
+
+ #[test]
+ fn ml_closures_ospml() {
+ assert_example_matches("examples/tested/ml/closures.ospml");
+ }
+
+ #[test]
+ fn ml_curry_partial_osp() {
+ assert_example_matches("examples/tested/ml/curry_partial.osp");
+ }
+
+ #[test]
+ fn ml_curry_partial_ospml() {
+ assert_example_matches("examples/tested/ml/curry_partial.ospml");
+ }
+
+ #[test]
+ fn ml_curry_tour_osp() {
+ assert_example_matches("examples/tested/ml/curry_tour.osp");
+ }
+
+ #[test]
+ fn ml_curry_tour_ospml() {
+ assert_example_matches("examples/tested/ml/curry_tour.ospml");
+ }
+
+ #[test]
+ fn ml_hello_osp() {
+ assert_example_matches("examples/tested/ml/hello.osp");
+ }
+
+ #[test]
+ fn ml_hello_ospml() {
+ assert_example_matches("examples/tested/ml/hello.ospml");
+ }
+
+ #[test]
+ fn ml_hof_osp() {
+ assert_example_matches("examples/tested/ml/hof.osp");
+ }
+
+ #[test]
+ fn ml_hof_ospml() {
+ assert_example_matches("examples/tested/ml/hof.ospml");
+ }
+
+ #[test]
+ fn ml_match_tour_osp() {
+ assert_example_matches("examples/tested/ml/match_tour.osp");
+ }
+
+ #[test]
+ fn ml_match_tour_ospml() {
+ assert_example_matches("examples/tested/ml/match_tour.ospml");
+ }
+
+ #[test]
+ fn ml_matchbool_osp() {
+ assert_example_matches("examples/tested/ml/matchbool.osp");
+ }
+
+ #[test]
+ fn ml_matchbool_ospml() {
+ assert_example_matches("examples/tested/ml/matchbool.ospml");
+ }
+
+ #[test]
+ fn ml_matchint_osp() {
+ assert_example_matches("examples/tested/ml/matchint.osp");
+ }
+
+ #[test]
+ fn ml_matchint_ospml() {
+ assert_example_matches("examples/tested/ml/matchint.ospml");
+ }
+
+ #[test]
+ fn ml_mixed_osp() {
+ assert_example_matches("examples/tested/ml/mixed.osp");
+ }
+
+ #[test]
+ fn ml_mixed_ospml() {
+ assert_example_matches("examples/tested/ml/mixed.ospml");
+ }
+
+ #[test]
+ fn ml_mutation_osp() {
+ assert_example_matches("examples/tested/ml/mutation.osp");
+ }
+
+ #[test]
+ fn ml_mutation_ospml() {
+ assert_example_matches("examples/tested/ml/mutation.ospml");
+ }
+
+ #[test]
+ fn ml_nested_calls_osp() {
+ assert_example_matches("examples/tested/ml/nested_calls.osp");
+ }
+
+ #[test]
+ fn ml_nested_calls_ospml() {
+ assert_example_matches("examples/tested/ml/nested_calls.ospml");
+ }
+
+ #[test]
+ fn ml_pipechain_osp() {
+ assert_example_matches("examples/tested/ml/pipechain.osp");
+ }
+
+ #[test]
+ fn ml_pipechain_ospml() {
+ assert_example_matches("examples/tested/ml/pipechain.ospml");
+ }
+
+ #[test]
+ fn ml_recursion_osp() {
+ assert_example_matches("examples/tested/ml/recursion.osp");
+ }
+
+ #[test]
+ fn ml_recursion_ospml() {
+ assert_example_matches("examples/tested/ml/recursion.ospml");
+ }
+
+ #[test]
+ fn ml_results_state_hof_osp() {
+ assert_example_matches("examples/tested/ml/results_state_hof.osp");
+ }
+
+ #[test]
+ fn ml_results_state_hof_ospml() {
+ assert_example_matches("examples/tested/ml/results_state_hof.ospml");
+ }
+
+ #[test]
+ fn ml_strings_osp() {
+ assert_example_matches("examples/tested/ml/strings.osp");
+ }
+
+ #[test]
+ fn ml_strings_ospml() {
+ assert_example_matches("examples/tested/ml/strings.ospml");
+ }
+
fn args(list: &[&str]) -> Vec {
list.iter().map(|s| (*s).to_string()).collect()
}
@@ -582,6 +1701,29 @@ mod tests {
assert!(cli.policy.http && cli.policy.websocket && cli.policy.fs && cli.policy.ffi);
}
+ #[test]
+ fn parse_args_accepts_flavor_flag_in_both_spellings() {
+ // No flag ⇒ unset, so resolution falls through to marker/extension.
+ assert_eq!(parse_args(&args(&["f.osp"])).expect("ok").flavor, None);
+ // Spaced and `=` spellings both set the explicit flavor.
+ for spelling in [
+ &["--flavor", "ml", "f.osp"][..],
+ &["--flavor=ml", "f.osp"][..],
+ ] {
+ let cli = parse_args(&args(spelling)).expect("ok");
+ assert_eq!(cli.flavor, Some(Flavor::Ml));
+ }
+ assert_eq!(
+ parse_args(&args(&["--flavor=default", "f.osp"]))
+ .expect("ok")
+ .flavor,
+ Some(Flavor::Default)
+ );
+ // A bogus value and a missing value both fail loudly.
+ assert!(parse_args(&args(&["--flavor=fsharp", "f.osp"])).is_err());
+ assert!(parse_args(&args(&["f.osp", "--flavor"])).is_err());
+ }
+
#[test]
fn parse_args_last_mode_wins_and_quiet_sets() {
let cli = parse_args(&args(&["--ast", "f.osp", "--llvm", "--run", "--quiet"])).expect("ok");
@@ -761,6 +1903,7 @@ mod tests {
target: "native".to_string(),
output: None,
debug: false,
+ flavor: None,
}
}
@@ -805,6 +1948,23 @@ mod tests {
assert!(report_type_errors("bad.osp", &bad) > 0);
}
+ #[test]
+ fn parse_flavor_accepts_known_names_and_rejects_the_rest() {
+ assert_eq!(parse_flavor("default").expect("default"), Flavor::Default);
+ assert_eq!(parse_flavor("ml").expect("ml"), Flavor::Ml);
+ let err = parse_flavor("klingon").expect_err("unknown flavor rejected");
+ assert!(err.contains("usage: osprey"), "{err}");
+ }
+
+ #[test]
+ fn link_flag_helpers_return_a_nonempty_flag_set() {
+ // Both run to completion regardless of host: `openssl_flags` always yields
+ // at least the `-lssl -lcrypto` fallback, and the runtime-lib search walks
+ // its whole candidate list (returning None here is fine — the body ran).
+ assert!(openssl_flags().iter().any(|f| f == "-lssl"));
+ let _ = find_runtime_lib("libosprey_runtime_definitely_absent.a");
+ }
+
#[test]
fn compile_ir_and_debug_helpers_switch_on_the_debug_flag() {
let program = osprey_syntax::parse_program("let n = 1\nprint(\"${n}\")\n").program;
diff --git a/crates/osprey-cli/src/wasm.rs b/crates/osprey-cli/src/wasm.rs
index 48b8f364..14d20a58 100644
--- a/crates/osprey-cli/src/wasm.rs
+++ b/crates/osprey-cli/src/wasm.rs
@@ -479,6 +479,7 @@ mod tests {
target: "wasm32".to_string(),
output: None,
debug: false,
+ flavor: None,
}
}
diff --git a/crates/osprey-cli/tests/cli_e2e.rs b/crates/osprey-cli/tests/cli_e2e.rs
index f649d568..b92fb186 100644
--- a/crates/osprey-cli/tests/cli_e2e.rs
+++ b/crates/osprey-cli/tests/cli_e2e.rs
@@ -36,6 +36,20 @@ fn temp_osp(name: &str, body: &str) -> PathBuf {
path
}
+fn temp_dir(name: &str) -> PathBuf {
+ let path = std::env::temp_dir().join(format!("osprey_cli_e2e_{name}_{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&path);
+ let _ = std::fs::create_dir_all(&path);
+ path
+}
+
+fn read_text(path: &Path) -> String {
+ match std::fs::read_to_string(path) {
+ Ok(text) => text,
+ Err(e) => format!("read failed: {e}"),
+ }
+}
+
const HELLO: &str = "let g = \"hi\"\nprint(\"v=${g}\")\n";
/// The captured result of one invocation.
@@ -69,6 +83,42 @@ fn run_args(args: &[&str]) -> Out {
finish(cmd)
}
+fn run_args_with_stdin(args: &[&str], input: &str) -> Out {
+ use std::io::Write;
+
+ let mut cmd = osprey();
+ let _ = cmd
+ .args(args)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ let mut child = match cmd.spawn() {
+ Ok(child) => child,
+ Err(e) => {
+ return Out {
+ code: None,
+ stdout: String::new(),
+ stderr: format!("spawn failed: {e}"),
+ };
+ }
+ };
+ if let Some(stdin) = child.stdin.as_mut() {
+ let _ = stdin.write_all(input.as_bytes());
+ }
+ match child.wait_with_output() {
+ Ok(out) => Out {
+ code: out.status.code(),
+ stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
+ stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
+ },
+ Err(e) => Out {
+ code: None,
+ stdout: String::new(),
+ stderr: format!("wait failed: {e}"),
+ },
+ }
+}
+
/// Run against a source `path` plus extra flags — the common compiling shape.
fn run_file(path: &Path, extra: &[&str]) -> Out {
let mut cmd = osprey();
@@ -89,6 +139,39 @@ fn run_file_cc(path: &Path, mode: &str, cc: &str) -> Out {
/// fails there — exercising the `Err` arms `compile_program` feeds.
const GENERIC_AS_VALUE: &str = "fn id(x) = x\nlet f = id\nprint(\"ok\")\n";
+/// Explicit effect resume must run the rest of the handled computation and then
+/// return the handled computation's answer to the arm.
+const RESUME_EFFECT: &str = r#"
+effect Audit {
+ step: fn(string) -> int
+}
+
+fn pipeline() -> int !Audit = {
+ let a = perform Audit.step("load")
+ let b = perform Audit.step("parse")
+ match a + b {
+ Success { value } => value
+ Error { message } => 0
+ }
+}
+
+fn main() = {
+ mut n = 0
+ let total = handle Audit
+ step label => {
+ n = match n + 1 {
+ Success { value } => value
+ Error { message } => n
+ }
+ let answer = resume(n)
+ print("after " + label + ": answer=" + toString(answer))
+ answer
+ }
+ in pipeline()
+ print("total=" + toString(total))
+}
+"#;
+
#[test]
fn version_plain_and_json() {
let plain = run_args(&["--version"]);
@@ -121,6 +204,163 @@ fn hover_prints_known_builtin_and_is_silent_for_unknown() {
assert!(unknown.stdout.trim().is_empty(), "{}", unknown.stdout);
}
+#[test]
+fn fmt_stdout_check_and_rewrite_modes() {
+ let prog = temp_osp("fmt_modes", "fn main() = {\nprint(1)\n}\n");
+ let path = prog.to_string_lossy().into_owned();
+ let shown = run_args(&["fmt", "--stdout", "--flavor", "default", &path]);
+ assert_eq!(shown.code, Some(0), "stderr={}", shown.stderr);
+ assert!(shown.stdout.contains(" print(1)"), "{}", shown.stdout);
+ let unchanged = read_text(&prog);
+ assert!(unchanged.contains("\nprint(1)\n"), "{unchanged}");
+
+ let checked = run_args(&["fmt", "--check", &path]);
+ assert_ne!(checked.code, Some(0));
+ assert!(
+ checked.stdout.contains("would reformat"),
+ "{}",
+ checked.stdout
+ );
+
+ let quiet = run_args(&["fmt", "--quiet", &path]);
+ assert_eq!(quiet.code, Some(0), "stderr={}", quiet.stderr);
+ assert!(quiet.stdout.trim().is_empty(), "{}", quiet.stdout);
+ let rewritten = read_text(&prog);
+ assert!(rewritten.contains(" print(1)"), "{rewritten}");
+
+ let clean = temp_osp("fmt_clean", "fn main() = {\n print(1)\n}\n");
+ let clean_path = clean.to_string_lossy().into_owned();
+ let no_change = run_args(&["fmt", &clean_path]);
+ assert_eq!(no_change.code, Some(0), "stderr={}", no_change.stderr);
+ assert!(no_change.stdout.trim().is_empty(), "{}", no_change.stdout);
+
+ let loud = temp_osp("fmt_loud", "fn main() = {\nprint(2)\n}\n");
+ let loud_path = loud.to_string_lossy().into_owned();
+ let rewritten_loud = run_args(&["fmt", &loud_path]);
+ assert_eq!(
+ rewritten_loud.code,
+ Some(0),
+ "stderr={}",
+ rewritten_loud.stderr
+ );
+ assert!(
+ rewritten_loud.stdout.contains("formatted"),
+ "{}",
+ rewritten_loud.stdout
+ );
+}
+
+#[test]
+fn fmt_recurses_directories_and_formats_stdin() {
+ let dir = temp_dir("fmt_tree");
+ let nested = dir.join("nested");
+ let _ = std::fs::create_dir_all(&nested);
+ let osp = dir.join("a.osp");
+ let ml = nested.join("b.ospml");
+ let ignored = nested.join("ignored.txt");
+ let _ = std::fs::write(&osp, "fn main() = {\nprint(1)\n}\n");
+ let _ = std::fs::write(&ml, "print 2\n");
+ let _ = std::fs::write(&ignored, "not osprey\n");
+
+ let dir_arg = dir.to_string_lossy().into_owned();
+ let shown = run_args(&["fmt", "--stdout", &dir_arg]);
+ assert_eq!(shown.code, Some(0), "stderr={}", shown.stderr);
+ assert!(shown.stdout.contains("print(1)"), "{}", shown.stdout);
+ assert!(shown.stdout.contains("print 2"), "{}", shown.stdout);
+ assert!(!shown.stdout.contains("not osprey"), "{}", shown.stdout);
+
+ let piped = run_args_with_stdin(&["fmt", "--flavor=ml", "-"], "print 3\n");
+ assert_eq!(piped.code, Some(0), "stderr={}", piped.stderr);
+ assert!(piped.stdout.contains("print 3"), "{}", piped.stdout);
+
+ let bad_stdin = run_args_with_stdin(&["fmt", "-"], "fn main( = {\n");
+ assert_ne!(bad_stdin.code, Some(0));
+ assert!(bad_stdin.stderr.contains("stdin:"), "{}", bad_stdin.stderr);
+}
+
+#[test]
+fn fmt_reports_usage_parse_and_read_errors() {
+ let no_paths = run_args(&["fmt"]);
+ assert_eq!(no_paths.code, Some(2));
+ assert!(
+ no_paths.stderr.contains("usage: osprey fmt"),
+ "{}",
+ no_paths.stderr
+ );
+
+ let missing_flavor = run_args(&["fmt", "--flavor"]);
+ assert_eq!(missing_flavor.code, Some(2));
+ assert!(
+ missing_flavor.stderr.contains("--flavor requires"),
+ "{}",
+ missing_flavor.stderr
+ );
+
+ let bad_flavor = run_args(&["fmt", "--flavor=bogus", "x.osp"]);
+ assert_eq!(bad_flavor.code, Some(2));
+ assert!(
+ bad_flavor.stderr.contains("unknown flavor"),
+ "{}",
+ bad_flavor.stderr
+ );
+
+ let bad_flag = run_args(&["fmt", "--bogus", "x.osp"]);
+ assert_eq!(bad_flag.code, Some(2));
+ assert!(
+ bad_flag.stderr.contains("unknown flag --bogus"),
+ "{}",
+ bad_flag.stderr
+ );
+
+ let missing = run_args(&["fmt", "/no/such/osprey_fmt_missing.osp"]);
+ assert_ne!(missing.code, Some(0));
+ assert!(missing.stderr.contains("cannot read"), "{}", missing.stderr);
+
+ let broken = temp_osp("fmt_broken", "fn main( = {\n");
+ let path = broken.to_string_lossy().into_owned();
+ let parsed = run_args(&["fmt", &path]);
+ assert_ne!(parsed.code, Some(0));
+ assert!(!parsed.stderr.is_empty());
+}
+
+#[cfg(unix)]
+#[test]
+fn fmt_reports_write_errors() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let blocked_dir = temp_dir("fmt_blocked");
+ if let Ok(metadata) = std::fs::metadata(&blocked_dir) {
+ let mut perms = metadata.permissions();
+ perms.set_mode(0o000);
+ let _ = std::fs::set_permissions(&blocked_dir, perms);
+ }
+ let blocked_arg = blocked_dir.to_string_lossy().into_owned();
+ let blocked = run_args(&["fmt", &blocked_arg]);
+ if let Ok(metadata) = std::fs::metadata(&blocked_dir) {
+ let mut perms = metadata.permissions();
+ perms.set_mode(0o755);
+ let _ = std::fs::set_permissions(&blocked_dir, perms);
+ }
+ assert_eq!(blocked.code, Some(0), "stderr={}", blocked.stderr);
+
+ let prog = temp_osp("fmt_readonly", "fn main() = {\nprint(1)\n}\n");
+ let path = prog.to_string_lossy().into_owned();
+ if let Ok(metadata) = std::fs::metadata(&prog) {
+ let mut perms = metadata.permissions();
+ perms.set_mode(0o444);
+ let _ = std::fs::set_permissions(&prog, perms);
+ }
+
+ let out = run_args(&["fmt", &path]);
+ if let Ok(metadata) = std::fs::metadata(&prog) {
+ let mut perms = metadata.permissions();
+ perms.set_mode(0o644);
+ let _ = std::fs::set_permissions(&prog, perms);
+ }
+ assert_ne!(out.code, Some(0));
+ assert!(out.stderr.contains("cannot write"), "{}", out.stderr);
+}
+
#[test]
fn unknown_flag_exits_two_with_usage() {
let prog = temp_osp("flag", HELLO);
@@ -152,6 +392,14 @@ fn check_parse_error_is_reported() {
assert!(!o.stderr.is_empty());
}
+#[test]
+fn flavor_marker_conflict_exits_two() {
+ let prog = temp_osp("flavor_conflict", "// osprey: flavor=ml\nprint 1\n");
+ let o = run_file(&prog, &["--check"]);
+ assert_eq!(o.code, Some(2));
+ assert!(o.stderr.contains("flavor marker"), "{}", o.stderr);
+}
+
#[test]
fn check_type_error_is_reported() {
let prog = temp_osp("typed", "let y = 1 + \"oops\" - true\n");
@@ -190,6 +438,17 @@ fn run_compiles_links_and_executes() {
assert!(o.stdout.contains("v=hi"), "{}", o.stdout);
}
+#[test]
+fn explicit_resume_runs_the_performer_continuation() {
+ let prog = temp_osp("resume_effect", RESUME_EFFECT);
+ let o = run_file(&prog, &["--run"]);
+ assert_eq!(o.code, Some(0), "stderr={}", o.stderr);
+ assert_eq!(
+ o.stdout,
+ "after parse: answer=3\nafter load: answer=3\ntotal=3\n"
+ );
+}
+
#[test]
fn compile_writes_executable_to_cwd() {
let prog = temp_osp("compile", HELLO);
diff --git a/crates/osprey-cli/tests/cross_flavor_equiv.rs b/crates/osprey-cli/tests/cross_flavor_equiv.rs
new file mode 100644
index 00000000..f87463ba
--- /dev/null
+++ b/crates/osprey-cli/tests/cross_flavor_equiv.rs
@@ -0,0 +1,89 @@
+//! Cross-flavor equivalence ([FLAVOR-TEST] / [FLAVOR-CURRY],
+//! docs/specs/0023-LanguageFlavors.md). ML **curries by default** and offers a
+//! second *uncurried* surface, so it mirrors BOTH Default function forms at the
+//! canonical AST (modulo source positions). Three buckets pin the boundary:
+//!
+//! 1. ML curried `add x y = …` (sugar for `add = \x -> \y -> …`) ≡ Default
+//! *explicit-curry* `fn add(x) = fn(y) => …` — a one-parameter
+//! [`Stmt::Function`] whose body is an [`Expr::Lambda`].
+//! 2. ML uncurried `add (x, y) = …` ≡ Default *multi-parameter* `fn add(x, y)` —
+//! one flat two-parameter [`Stmt::Function`], the `(int, int) -> int` shape.
+//! 3. The two are NOT interchangeable: ML curried `add x y` differs from Default
+//! multi-parameter `fn add(x, y)`. Currying is a real semantic difference, not
+//! a spelling — each ML surface form mirrors its own Default twin and no other.
+
+use osprey_syntax::{parse_program_with_flavor, Flavor};
+
+/// The canonical AST as a debug string with every source `Position { … }`
+/// payload scrubbed, so structural equality ignores spans.
+fn canonical(src: &str, flavor: Flavor) -> String {
+ let parsed = parse_program_with_flavor(src, flavor);
+ assert!(
+ parsed.errors.is_empty(),
+ "unexpected {flavor} syntax errors: {:?}",
+ parsed.errors
+ );
+ scrub_positions(&format!("{:?}", parsed.program))
+}
+
+/// Drop every `Position { line: N, column: M }` from a debug string. `Position`
+/// has no nested braces, so the next `}` always closes it.
+fn scrub_positions(debug: &str) -> String {
+ let mut out = String::with_capacity(debug.len());
+ let mut rest = debug;
+ while let Some(idx) = rest.find("Position {") {
+ out.push_str(&rest[..idx]);
+ rest = &rest[idx..];
+ match rest.find('}') {
+ Some(close) => rest = &rest[close + 1..],
+ None => break,
+ }
+ }
+ out.push_str(rest);
+ out
+}
+
+#[test]
+fn ml_multiparam_equals_default_explicit_curry() {
+ // Both lower to a one-parameter Function { params: [x] } whose body is a
+ // Lambda { params: [y], body: x + y } — ML curries by default, so the
+ // multi-binding `add x y = …` IS the Default explicit-curry chain.
+ let default_curry = "fn add(x) = fn(y) => x + y\n";
+ let ml_multi = "add x y = x + y\n";
+ assert_eq!(
+ canonical(default_curry, Flavor::Default),
+ canonical(ml_multi, Flavor::Ml),
+ "curried ML multi-binding must equal Default explicit-curry at the canonical AST"
+ );
+}
+
+#[test]
+fn ml_uncurried_tuple_equals_default_multiparam() {
+ // Bucket 2: ML's uncurried surface `add (x, y) = …` lowers to one flat
+ // two-parameter Function { params: [x, y], body: x + y } — byte-identical to
+ // Default `fn add(x, y)`. This is the mirror twin for every multi-parameter
+ // Default function, so a `.osp` multi-param binding pairs with a `.ospml`
+ // tuple binding and they emit identical IR.
+ let default_multi = "fn add(x, y) = x + y\n";
+ let ml_tuple = "add (x, y) = x + y\n";
+ assert_eq!(
+ canonical(default_multi, Flavor::Default),
+ canonical(ml_tuple, Flavor::Ml),
+ "ML uncurried tuple binding must equal Default multi-param at the canonical AST"
+ );
+}
+
+#[test]
+fn ml_multiparam_differs_from_default_multiparam() {
+ // Default `fn add(x, y) = …` is one two-parameter Function — the uncurried
+ // `(int, int) -> int`, a different node than the curried ML chain (a
+ // one-parameter Function returning a Lambda). Currying is a real semantic
+ // difference, not a spelling: ML never lowers to the multi-parameter form.
+ let default_multi = "fn add(x, y) = x + y\n";
+ let ml_multi = "add x y = x + y\n";
+ assert_ne!(
+ canonical(default_multi, Flavor::Default),
+ canonical(ml_multi, Flavor::Ml),
+ "Default multi-param must NOT equal the curried ML multi-binding"
+ );
+}
diff --git a/crates/osprey-cli/tests/cross_flavor_ir_equiv.rs b/crates/osprey-cli/tests/cross_flavor_ir_equiv.rs
new file mode 100644
index 00000000..a3483f5e
--- /dev/null
+++ b/crates/osprey-cli/tests/cross_flavor_ir_equiv.rs
@@ -0,0 +1,154 @@
+//! Cross-flavor **LLVM IR** equivalence ([FLAVOR-IR-EQUIV],
+//! docs/specs/0023-LanguageFlavors.md). The headline guarantee of the
+//! many-CSTs-one-AST design: a program written in the ML flavor (`.ospml`) and
+//! its Default-flavor twin (`.osp`) lower to the SAME canonical AST, therefore
+//! the codegen backend emits **byte-identical LLVM IR** for both. Currying is a
+//! pure lowering, so `add x y = x + y` (ML) and `fn add(x) = fn(y) => x + y`
+//! (Default) are indistinguishable past the `[FLAVOR-BOUNDARY]`.
+//!
+//! This is the enforcement layer the differential golden harness cannot give:
+//! the harness proves the two flavors *run* the same; this proves they *compile*
+//! to the same IR, which is a far stronger structural claim.
+//!
+//! Data-driven: every `examples/tested/ml/.ospml` MUST have a sibling
+//! Default twin `examples/tested/ml/.osp`. The test compiles both through
+//! `osprey_codegen::compile_program` (in-process — no built binary required) and
+//! asserts the emitted IR text is identical.
+
+use std::path::{Path, PathBuf};
+
+use osprey_codegen::compile_program;
+use osprey_syntax::{parse_program_with_flavor, Flavor};
+
+/// `examples/tested`, resolved from the crate manifest dir so the test runs
+/// unchanged on a dev box and in CI. Every `.ospml` ANYWHERE under this tree is
+/// an in-place twin of its sibling `.osp` and must emit byte-identical IR.
+fn ml_examples_dir() -> PathBuf {
+ // canonicalize() resolves the `../../`; fall back to the joined path when it
+ // is unavailable rather than expect()-panicking outside a `#[test]` (the
+ // workspace denies clippy::expect_used in non-test code).
+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/tested");
+ dir.canonicalize().unwrap_or(dir)
+}
+
+/// Parse `source` under `flavor` and emit LLVM IR text, surfacing parse errors
+/// loudly so a malformed example fails the test instead of silently lowering a
+/// partial AST.
+fn ir_for(source: &str, flavor: Flavor, label: &str) -> Result {
+ let parsed = parse_program_with_flavor(source, flavor);
+ assert!(
+ parsed.errors.is_empty(),
+ "{label}: unexpected {flavor} parse errors: {:?}",
+ parsed.errors
+ );
+ compile_program(&parsed.program).map_err(|e| format!("{label}: codegen failed: {e:?}"))
+}
+
+/// Every `.ospml` file anywhere under `dir`, found by a recursive walk and
+/// sorted for deterministic output. Twins live in place next to their `.osp`
+/// counterparts throughout `examples/tested`, not in one folder.
+fn ml_stems(dir: &Path) -> Vec {
+ let mut out = Vec::new();
+ collect_ospml(dir, &mut out);
+ out.sort();
+ out
+}
+
+/// Recurse into `dir`, pushing every `.ospml` path into `out`.
+fn collect_ospml(dir: &Path, out: &mut Vec) {
+ for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_ospml(&path, out);
+ } else if path.extension().is_some_and(|x| x == "ospml") {
+ out.push(path);
+ }
+ }
+}
+
+/// Every ML example must have a Default twin (same stem, `.osp`). A missing twin
+/// is a hard failure — the pairing is the whole point of the flavor system.
+#[test]
+fn every_ml_example_has_a_default_twin() {
+ let dir = ml_examples_dir();
+ let missing: Vec = ml_stems(&dir)
+ .into_iter()
+ .filter(|p| !p.with_extension("osp").exists())
+ .map(|p| {
+ p.file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("?")
+ .to_string()
+ })
+ .collect();
+ assert!(
+ missing.is_empty(),
+ "every .ospml needs a Default twin .osp for IR equivalence; \
+ missing twins for: {missing:?}"
+ );
+}
+
+/// The headline guarantee: ML and Default twins emit byte-identical IR. Collects
+/// every mismatch before failing so one run reports all drift, not just the first.
+#[test]
+fn ml_and_default_twins_emit_identical_ir() {
+ let dir = ml_examples_dir();
+ let mut mismatches: Vec = Vec::new();
+ let mut checked = 0usize;
+
+ for ml_path in ml_stems(&dir) {
+ let def_path = ml_path.with_extension("osp");
+ if !def_path.exists() {
+ continue; // covered by `every_ml_example_has_a_default_twin`
+ }
+ let stem = ml_path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("?")
+ .to_string();
+
+ let ml_src = std::fs::read_to_string(&ml_path).expect("read .ospml");
+ let def_src = std::fs::read_to_string(&def_path).expect("read .osp");
+
+ let ml_ir = ir_for(&ml_src, Flavor::Ml, &format!("{stem}.ospml")).expect("ml codegen");
+ let def_ir =
+ ir_for(&def_src, Flavor::Default, &format!("{stem}.osp")).expect("default codegen");
+
+ checked += 1;
+ if ml_ir != def_ir {
+ mismatches.push(format!(
+ " {stem}: IR differs\n{}",
+ first_diff(&def_ir, &ml_ir)
+ ));
+ }
+ }
+
+ assert!(
+ checked > 0,
+ "no flavor pairs found under examples/tested/ml — expected at least one"
+ );
+ assert!(
+ mismatches.is_empty(),
+ "ML and Default twins MUST emit identical LLVM IR; {} pair(s) drifted:\n{}",
+ mismatches.len(),
+ mismatches.join("\n")
+ );
+}
+
+/// First differing line between two IR texts, with a little context, so a
+/// failure points at the exact divergence instead of dumping two full modules.
+fn first_diff(default_ir: &str, ml_ir: &str) -> String {
+ for (i, (d, m)) in default_ir.lines().zip(ml_ir.lines()).enumerate() {
+ if d != m {
+ return format!(
+ " line {}:\n default: {d}\n ml: {m}",
+ i + 1
+ );
+ }
+ }
+ format!(
+ " one IR is a prefix of the other (default {} lines, ml {} lines)",
+ default_ir.lines().count(),
+ ml_ir.lines().count()
+ )
+}
diff --git a/crates/osprey-cli/tests/no_needless_main.rs b/crates/osprey-cli/tests/no_needless_main.rs
new file mode 100644
index 00000000..7619c3e7
--- /dev/null
+++ b/crates/osprey-cli/tests/no_needless_main.rs
@@ -0,0 +1,125 @@
+//! CI gate — examples must not wrap a trivial program in a needless `main`.
+//!
+//! Both flavors synthesize `main` from bare top-level statements and lower them
+//! to byte-identical IR ([FLAVOR-IR-EQUIV], docs/specs/0023, 0024), so a
+//! zero-argument `fn main()` (Default) or `main ()` / `main :` (ML) is pure
+//! boilerplate: the program reads exactly the same written as bare top-level
+//! statements. This gate fails if any tested example carries that boilerplate,
+//! so the rule is enforced forever instead of by review.
+//!
+//! The *only* sanctioned exception is a program that genuinely needs `argv` or a
+//! non-zero exit code. A `main` that takes parameters is never flagged (it is
+//! consuming `argv`); a zero-argument `main` kept for its exit code must opt out
+//! explicitly with a `// osprey: keep-main ` marker, which both
+//! documents the intent and silences the gate. Implements
+//! [ANALYZER-NEEDLESS-MAIN] (docs/specs/0024-MLFlavorSyntax.md).
+
+use std::path::{Path, PathBuf};
+
+/// `examples/tested`, resolved from the crate manifest so the gate runs the same
+/// on a dev box and in CI.
+fn tested_dir() -> PathBuf {
+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/tested");
+ dir.canonicalize().unwrap_or(dir)
+}
+
+/// The opt-out marker: a zero-argument `main` kept on purpose (a meaningful
+/// non-zero exit code) carries this so the gate records the intent and passes.
+const KEEP_MARKER: &str = "osprey: keep-main";
+
+/// Every `.osp`/`.ospml` under `dir`, found by a recursive walk and sorted for
+/// deterministic reporting.
+fn example_files(dir: &Path) -> Vec {
+ let mut out = Vec::new();
+ collect(dir, &mut out);
+ out.sort();
+ out
+}
+
+/// Recurse into `dir`, pushing every Osprey source file into `out`.
+fn collect(dir: &Path, out: &mut Vec) {
+ for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect(&path, out);
+ } else if path
+ .extension()
+ .and_then(|x| x.to_str())
+ .is_some_and(|x| x == "osp" || x == "ospml")
+ {
+ out.push(path);
+ }
+ }
+}
+
+/// The text between the first `(` and its matching `)` on a `main` header line,
+/// or `None` when the line is not a `main` declaration. Used to tell a
+/// zero-argument `main ()` (boilerplate) from `main (argv)` (consuming argv).
+fn main_param_text(line: &str) -> Option<&str> {
+ let rest = line.trim_start();
+ // Default `fn main(...)` or ML `main (...)` — the binding head, not a call.
+ let after = rest
+ .strip_prefix("fn main")
+ .or_else(|| rest.strip_prefix("main"))?
+ .trim_start();
+ let inner = after.strip_prefix('(')?;
+ inner.split_once(')').map(|(params, _)| params)
+}
+
+/// True when `line` declares a needless zero-argument `main`: `fn main()`,
+/// `main ()`, or the ML signature `main :` (which only ever types such a main).
+fn declares_needless_main(line: &str) -> bool {
+ let trimmed = line.trim_start();
+ if trimmed.starts_with("main :") || trimmed.starts_with("main:") {
+ return true;
+ }
+ main_param_text(line).is_some_and(|params| params.trim().is_empty())
+}
+
+#[test]
+fn no_example_wraps_a_trivial_program_in_main() {
+ let dir = tested_dir();
+ let mut offenders: Vec = Vec::new();
+
+ for path in example_files(&dir) {
+ let src = std::fs::read_to_string(&path).expect("read example source");
+ if src.contains(KEEP_MARKER) {
+ continue; // explicitly sanctioned (argv / non-zero exit code)
+ }
+ if src.lines().any(declares_needless_main) {
+ let rel = path
+ .strip_prefix(&dir)
+ .unwrap_or(&path)
+ .display()
+ .to_string();
+ offenders.push(rel);
+ }
+ }
+
+ assert!(
+ offenders.is_empty(),
+ "{} example(s) wrap a trivial program in a needless `main` — write bare \
+ top-level statements instead (both flavors synthesize `main` with \
+ identical IR). If a zero-arg `main` is kept for argv/exit-code, mark it \
+ `// {KEEP_MARKER} `:\n {}",
+ offenders.len(),
+ offenders.join("\n ")
+ );
+}
+
+#[test]
+fn detector_classifies_main_headers_correctly() {
+ // Needless: zero-argument mains in both flavors and the ML signature line.
+ assert!(declares_needless_main("fn main() = {"));
+ assert!(declares_needless_main("fn main () ="));
+ assert!(declares_needless_main("main () ="));
+ assert!(declares_needless_main("main :"));
+ assert!(declares_needless_main("main : Unit -> int"));
+ // Allowed: a `main` that consumes argv is never boilerplate.
+ assert!(!declares_needless_main("fn main(args) ="));
+ assert!(!declares_needless_main("main argv ="));
+ // Unrelated lines, and calls to other functions, are never flagged.
+ assert!(!declares_needless_main("let mainResult = run()"));
+ assert!(!declares_needless_main("print(\"main done\")"));
+ assert!(!declares_needless_main("fn mainLoop() ="));
+}
diff --git a/crates/osprey-codegen/src/builder.rs b/crates/osprey-codegen/src/builder.rs
index 63cc5478..075df0c6 100644
--- a/crates/osprey-codegen/src/builder.rs
+++ b/crates/osprey-codegen/src/builder.rs
@@ -96,6 +96,8 @@ pub struct Codegen {
/// reassignment stores, and an effect handler captures the cell pointer so
/// `get`/`set` arms share one mutable location — handler-owned state.
pub(crate) cell_slots: HashMap,
+ /// Continuation lowering context while emitting a resuming handler arm.
+ pub(crate) resume_ctx: Option,
/// LLVM/DWARF debug metadata state, when `--debug` was requested.
debug: Option,
}
@@ -109,6 +111,14 @@ pub(crate) struct CellSlot {
pub osp_ty: Option,
}
+#[derive(Clone)]
+pub(crate) struct ResumeCodegenContext {
+ pub env: String,
+ pub coro: String,
+ pub drive_fn: String,
+ pub answer_ty: LType,
+}
+
/// A function value's lowered signature: parameter [`LType`]s, the return
/// [`LType`], and (when it returns `Result`) the success inner type.
pub(crate) type FnSig = (Vec, LType, Option);
@@ -127,6 +137,7 @@ pub(crate) struct SavedFn {
/// gets its own captured cells, never the suspended outer function's.
cell_vars: HashSet,
cell_slots: HashMap,
+ resume_ctx: Option,
debug_scope: Option,
debug_position: Option,
debug_retained_nodes: Option,
@@ -398,6 +409,7 @@ impl Codegen {
call_aliases: HashMap::new(),
cell_vars: HashSet::new(),
cell_slots: HashMap::new(),
+ resume_ctx: None,
debug: options.debug_source.map(DebugState::new),
}
}
@@ -592,6 +604,7 @@ impl Codegen {
pending_iter_ops: std::mem::take(&mut self.pending_iter_ops),
cell_vars: std::mem::take(&mut self.cell_vars),
cell_slots: std::mem::take(&mut self.cell_slots),
+ resume_ctx: self.resume_ctx.take(),
debug_scope: self.debug.as_ref().and_then(|d| d.current_scope),
debug_position: self.debug.as_ref().and_then(|d| d.current_position),
debug_retained_nodes: self.debug.as_ref().and_then(|d| d.current_retained_nodes),
@@ -628,6 +641,7 @@ impl Codegen {
self.pending_iter_ops = saved.pending_iter_ops;
self.cell_vars = saved.cell_vars;
self.cell_slots = saved.cell_slots;
+ self.resume_ctx = saved.resume_ctx;
if let Some(debug) = self.debug.as_mut() {
debug.current_scope = saved.debug_scope;
debug.current_position = saved.debug_position;
@@ -957,6 +971,7 @@ impl Codegen {
// this function's body before lowering it.
self.cell_vars.clear();
self.cell_slots.clear();
+ self.resume_ctx = None;
self.push_scope();
self.cur_lines.push("entry:".to_string());
if let Some(debug) = self.debug.as_mut() {
diff --git a/crates/osprey-codegen/src/effects.rs b/crates/osprey-codegen/src/effects.rs
index e6692b72..156f963d 100644
--- a/crates/osprey-codegen/src/effects.rs
+++ b/crates/osprey-codegen/src/effects.rs
@@ -7,8 +7,9 @@
//! indirect call. The example handlers never `resume`, so an arm is an ordinary
//! function returning the operation's result.
-use crate::builder::{CellSlot, Codegen};
+use crate::builder::{CellSlot, Codegen, ResumeCodegenContext};
use crate::cast::coerce_to;
+use crate::conv::{box_to_i64, unbox_from_i64};
use crate::error::Result;
use crate::expr::gen_expr;
use crate::freevars::free_idents;
@@ -27,6 +28,16 @@ pub(crate) struct OpSig {
}
impl OpSig {
+ /// A default all-`i64` signature for `arity` parameters — the fallback when
+ /// inference recorded no resolved signature for an effect operation.
+ fn default_for_arity(arity: usize) -> Self {
+ OpSig {
+ params: vec![LType::I64; arity],
+ ret: LType::I64,
+ ret_result_inner: None,
+ }
+ }
+
/// The handler function's LLVM return-type spelling (the Result block
/// pointer for a Result result, else the plain type).
fn ret_ty(&self) -> String {
@@ -42,6 +53,43 @@ impl OpSig {
}
}
+/// Emit `ret ` for `ret` and close out the nested function whose
+/// LLVM return type matches `sig`.
+fn ret_and_exit(
+ cg: &mut Codegen,
+ saved: crate::builder::SavedFn,
+ sig: &OpSig,
+ name: &str,
+ params: &[(LType, String)],
+ ret: &Value,
+) {
+ cg.emit(format!("ret {} {}", ret.llvm_ty(), ret.operand));
+ cg.exit_nested_fn(saved, &sig.ret_ty(), name, params);
+}
+
+/// Bind each of an arm's operation parameters as an SSA value (`%name`, typed
+/// from `sig`, defaulting to `i64`) and append it to the emitted `params` list.
+fn bind_arm_params(
+ cg: &mut Codegen,
+ arm: &HandlerArm,
+ sig: &OpSig,
+ params: &mut Vec<(LType, String)>,
+) {
+ for (i, pname) in arm.params.iter().enumerate() {
+ let pty = sig.params.get(i).copied().unwrap_or(LType::I64);
+ cg.bind(pname.clone(), Value::new(format!("%{pname}"), pty));
+ params.push((pty, pname.clone()));
+ }
+}
+
+/// The free identifiers an arm's body closes over, minus the arm's own
+/// parameters — the names that must be captured from the enclosing scope.
+fn arm_free_idents(arm: &HandlerArm) -> impl Iterator + '_ {
+ let mut free = BTreeSet::new();
+ free_idents(&arm.body, &mut free);
+ free.into_iter().filter(|n| !arm.params.contains(n))
+}
+
/// One binding shared by every arm of a single `handle` region, captured into
/// the region's environment.
enum ArmCap {
@@ -85,6 +133,14 @@ pub(crate) fn op_sig_of(op: &osprey_types::OpType) -> OpSig {
}
}
+/// The resolved [`OpSig`] for `effect.operation`, falling back to an all-`i64`
+/// signature of the arm's arity when inference recorded none.
+fn op_sig_for(cg: &Codegen, effect: &str, arm: &HandlerArm) -> OpSig {
+ let key = format!("{effect}.{}", arm.operation);
+ cg.effect_op(&key)
+ .unwrap_or_else(|| OpSig::default_for_arity(arm.params.len()))
+}
+
fn declare_stack(cg: &mut Codegen) {
cg.add_extern("declare i32 @__osprey_handler_push(i8*, i8*, i8*, i8*)");
cg.add_extern("declare i32 @__osprey_handler_pop()");
@@ -92,6 +148,20 @@ fn declare_stack(cg: &mut Codegen) {
cg.add_extern("declare i8* @__osprey_handler_lookup_env(i8*, i8*)");
}
+fn declare_coro(cg: &mut Codegen) {
+ cg.add_extern("declare i8* @__osprey_coro_new(i8*)");
+ cg.add_extern("declare void @__osprey_coro_start(i8*, i64 (i8*)*, i8*, i8*)");
+ cg.add_extern("declare i64 @__osprey_coro_suspend(i8*, i64, i64*, i64)");
+ cg.add_extern("declare i64 @__osprey_coro_resume(i8*, i64)");
+ cg.add_extern("declare i64 @__osprey_coro_done(i8*)");
+ cg.add_extern("declare i64 @__osprey_coro_op(i8*)");
+ cg.add_extern("declare i64 @__osprey_coro_arg(i8*, i64)");
+ cg.add_extern("declare i64 @__osprey_coro_result(i8*)");
+ cg.add_extern("declare void @__osprey_coro_abort(i8*)");
+ cg.add_extern("declare void @__osprey_coro_free(i8*)");
+ cg.add_extern("declare i8* @__osprey_handler_snapshot()");
+}
+
/// Mutable locals that an effect handler arm captures from an enclosing scope —
/// the set promoted to shared heap cells so a plain `mut` becomes a reference
/// cell the handler owns (`get`/`set` arms and the outer scope share one slot).
@@ -122,9 +192,7 @@ fn scan_expr(e: &Expr, muts: &mut BTreeSet, captured: &mut BTreeSet {
for arm in arms {
- let mut free = BTreeSet::new();
- free_idents(&arm.body, &mut free);
- captured.extend(free.into_iter().filter(|n| !arm.params.contains(n)));
+ captured.extend(arm_free_idents(arm));
scan_expr(&arm.body, muts, captured);
}
scan_expr(body, muts, captured);
@@ -222,6 +290,7 @@ fn scan_children(e: &Expr, muts: &mut BTreeSet, captured: &mut BTreeSet<
scan_all(arguments, muts, captured);
scan_named(named_arguments, muts, captured);
}
+ Expr::Resume(Some(value)) => scan_expr(value, muts, captured),
_ => {}
}
}
@@ -274,15 +343,13 @@ pub(crate) fn gen_handler(
body: &Expr,
) -> Result {
declare_stack(cg);
+ if arms.iter().any(|arm| contains_resume(&arm.body)) {
+ return gen_resuming_handler(cg, effect, arms, body);
+ }
let caps = capture_list(cg, arms);
let (env, env_ty) = build_env(cg, &caps);
for arm in arms {
- let key = format!("{effect}.{}", arm.operation);
- let sig = cg.effect_op(&key).unwrap_or(OpSig {
- params: vec![LType::I64; arm.params.len()],
- ret: LType::I64,
- ret_result_inner: None,
- });
+ let sig = op_sig_for(cg, effect, arm);
let id = cg.next_handler_id();
let fn_name = format!("__handler_{effect}_{}_{id}", arm.operation);
emit_handler_fn(cg, &fn_name, arm, &sig, &caps, &env_ty)?;
@@ -309,18 +376,101 @@ pub(crate) fn gen_handler(
Ok(result)
}
+fn contains_resume(e: &Expr) -> bool {
+ match e {
+ Expr::Resume(_) => true,
+ Expr::InterpolatedStr(parts) => parts.iter().any(
+ |p| matches!(p, osprey_ast::InterpolatedPart::Expr(inner) if contains_resume(inner)),
+ ),
+ Expr::List(xs) => xs.iter().any(contains_resume),
+ Expr::Map(entries) => entries
+ .iter()
+ .any(|entry| contains_resume(&entry.key) || contains_resume(&entry.value)),
+ Expr::Object(fields)
+ | Expr::TypeConstructor { fields, .. }
+ | Expr::Update { fields, .. } => fields.iter().any(|f| contains_resume(&f.value)),
+ Expr::Binary { left, right, .. } | Expr::Pipe { left, right } => {
+ contains_resume(left) || contains_resume(right)
+ }
+ Expr::Unary { operand, .. } => contains_resume(operand),
+ Expr::Call {
+ function,
+ arguments,
+ named_arguments,
+ } => {
+ contains_resume(function)
+ || arguments.iter().any(contains_resume)
+ || named_arguments.iter().any(|n| contains_resume(&n.value))
+ }
+ Expr::MethodCall {
+ target,
+ arguments,
+ named_arguments,
+ ..
+ } => {
+ contains_resume(target)
+ || arguments.iter().any(contains_resume)
+ || named_arguments.iter().any(|n| contains_resume(&n.value))
+ }
+ Expr::FieldAccess { target, .. } => contains_resume(target),
+ Expr::Index { target, index } => contains_resume(target) || contains_resume(index),
+ Expr::Lambda { body, .. } | Expr::Spawn(body) | Expr::Await(body) | Expr::Recv(body) => {
+ contains_resume(body)
+ }
+ Expr::Yield(Some(value)) => contains_resume(value),
+ Expr::Send { channel, value } => contains_resume(channel) || contains_resume(value),
+ Expr::Match { value, arms } => {
+ contains_resume(value) || arms.iter().any(|arm| contains_resume(&arm.body))
+ }
+ Expr::Block { statements, value } => {
+ statements.iter().any(stmt_contains_resume)
+ || value.as_deref().is_some_and(contains_resume)
+ }
+ Expr::Select { arms } => arms.iter().any(|arm| contains_resume(&arm.body)),
+ Expr::Perform {
+ arguments,
+ named_arguments,
+ ..
+ } => {
+ arguments.iter().any(contains_resume)
+ || named_arguments.iter().any(|n| contains_resume(&n.value))
+ }
+ // A nested handler owns its own `resume`; do not mark the outer handler
+ // as a resuming region because of it.
+ Expr::Handler { body, .. } => contains_resume(body),
+ _ => false,
+ }
+}
+
+fn stmt_contains_resume(stmt: &Stmt) -> bool {
+ match stmt {
+ Stmt::Let { value, .. } | Stmt::Assignment { value, .. } | Stmt::Expr { value, .. } => {
+ contains_resume(value)
+ }
+ _ => false,
+ }
+}
+
/// The bindings every arm of this region captures, in stable (sorted) order: a
/// handler-captured mutable becomes a shared [`ArmCap::Cell`]; any other bound
/// free variable is captured by value. Names that resolve to nothing in scope
/// (top-level functions, constructors) need no capture — the arm resolves them
/// directly.
+fn arms_free_idents(arms: &[HandlerArm]) -> BTreeSet {
+ arms.iter().flat_map(arm_free_idents).collect()
+}
+
fn capture_list(cg: &Codegen, arms: &[HandlerArm]) -> Vec {
- let mut names = BTreeSet::new();
- for arm in arms {
- let mut free = BTreeSet::new();
- free_idents(&arm.body, &mut free);
- names.extend(free.into_iter().filter(|n| !arm.params.contains(n)));
- }
+ caps_from_names(cg, arms_free_idents(arms))
+}
+
+fn capture_list_resuming(cg: &Codegen, arms: &[HandlerArm], body: &Expr) -> Vec {
+ let mut names = arms_free_idents(arms);
+ free_idents(body, &mut names);
+ caps_from_names(cg, names)
+}
+
+fn caps_from_names(cg: &Codegen, names: BTreeSet) -> Vec {
names
.into_iter()
.filter_map(|name| {
@@ -392,11 +542,7 @@ fn emit_handler_fn(
let saved = cg.enter_nested_fn();
let mut params = vec![(LType::Ptr, String::from("__env"))];
reload_env(cg, caps, env_ty);
- for (i, pname) in arm.params.iter().enumerate() {
- let pty = sig.params.get(i).copied().unwrap_or(LType::I64);
- cg.bind(pname.clone(), Value::new(format!("%{pname}"), pty));
- params.push((pty, pname.clone()));
- }
+ bind_arm_params(cg, arm, sig, &mut params);
let body = gen_expr(cg, &arm.body)?;
let ret = if let Some(inner) = sig.ret_result_inner {
if body.result_inner.is_some() {
@@ -407,9 +553,7 @@ fn emit_handler_fn(
} else {
coerce_to(cg, body, sig.ret)?
};
- cg.emit(format!("ret {} {}", ret.llvm_ty(), ret.operand));
- let ret_ty = sig.ret_ty();
- cg.exit_nested_fn(saved, &ret_ty, name, ¶ms);
+ ret_and_exit(cg, saved, sig, name, ¶ms, &ret);
Ok(())
}
@@ -454,6 +598,321 @@ fn reload_env(cg: &mut Codegen, caps: &[ArmCap], env_ty: &str) {
}
}
+#[derive(Clone)]
+struct DriveArm {
+ op_id: usize,
+ operation: String,
+ sig: OpSig,
+ arm_fn: String,
+}
+
+/// `handle` region whose arms contain explicit `resume`: the handled body runs
+/// on a body thread and each `perform` suspends into this host-side dispatcher.
+fn gen_resuming_handler(
+ cg: &mut Codegen,
+ effect: &str,
+ arms: &[HandlerArm],
+ body: &Expr,
+) -> Result {
+ declare_stack(cg);
+ declare_coro(cg);
+
+ let caps = capture_list_resuming(cg, arms, body);
+ let (env, env_ty) = build_env(cg, &caps);
+ let id = cg.next_handler_id();
+ let body_fn = format!("__resume_body_{effect}_{id}");
+ let drive_fn = format!("__resume_drive_{effect}_{id}");
+
+ let answer_ty = emit_resuming_body_fn(cg, &body_fn, body, &caps, &env_ty)?;
+ let mut drive_arms = Vec::new();
+ for (op_id, arm) in arms.iter().enumerate() {
+ let sig = op_sig_for(cg, effect, arm);
+ let suspend_fn = format!("__resume_suspend_{effect}_{}_{id}_{op_id}", arm.operation);
+ let arm_fn = format!("__resume_arm_{effect}_{}_{id}_{op_id}", arm.operation);
+ emit_suspend_fn(cg, &suspend_fn, op_id, &sig);
+ emit_resuming_arm_fn(
+ cg,
+ arm,
+ &ArmFnSpec {
+ name: &arm_fn,
+ drive_fn: &drive_fn,
+ answer_ty,
+ sig: &sig,
+ caps: &caps,
+ env_ty: &env_ty,
+ },
+ )?;
+ drive_arms.push(DriveArm {
+ op_id,
+ operation: arm.operation.clone(),
+ sig,
+ arm_fn,
+ });
+ }
+ emit_drive_fn(cg, &drive_fn, &drive_arms);
+
+ let coro = cg.call("i8*", "__osprey_coro_new", "i8*", &[&env]);
+ for arm in &drive_arms {
+ let suspend_fn = format!(
+ "__resume_suspend_{effect}_{}_{id}_{}",
+ arm.operation, arm.op_id
+ );
+ let eff_s = cg.string_constant(effect);
+ let op_s = cg.string_constant(&arm.operation);
+ let fp = cg.emit_reg(format!(
+ "bitcast {} @{suspend_fn} to i8*",
+ arm.sig.fn_ptr_ty()
+ ));
+ let _ = cg.call(
+ "i32",
+ "__osprey_handler_push",
+ "i8*, i8*, i8*, i8*",
+ &[&eff_s.operand, &op_s.operand, &fp, &coro],
+ );
+ }
+
+ let snap = cg.call("i8*", "__osprey_handler_snapshot", "", &[]);
+ cg.call_void(
+ "__osprey_coro_start",
+ "i8*, i64 (i8*)*, i8*, i8*",
+ &[&coro, &format!("@{body_fn}"), &env, &snap],
+ );
+ let boxed = cg.emit_reg(format!("call i64 @{drive_fn}(i8* {env}, i8* {coro})"));
+
+ for _ in arms {
+ let _ = cg.call("i32", "__osprey_handler_pop", "", &[]);
+ }
+ cg.call_void("__osprey_coro_free", "i8*", &[&coro]);
+ Ok(unbox_from_i64(cg, &boxed, answer_ty))
+}
+
+fn emit_resuming_body_fn(
+ cg: &mut Codegen,
+ name: &str,
+ body: &Expr,
+ caps: &[ArmCap],
+ env_ty: &str,
+) -> Result {
+ let saved = cg.enter_nested_fn();
+ reload_env(cg, caps, env_ty);
+ let body_raw = gen_expr(cg, body)?;
+ let body = crate::result::unwrap(cg, body_raw);
+ let answer_ty = body.ty;
+ let boxed = box_codegen_value(cg, body);
+ cg.emit(format!("ret i64 {}", boxed.operand));
+ cg.exit_nested_fn(saved, "i64", name, &[(LType::Ptr, String::from("__env"))]);
+ Ok(answer_ty)
+}
+
+fn emit_suspend_fn(cg: &mut Codegen, name: &str, op_id: usize, sig: &OpSig) {
+ let saved = cg.enter_nested_fn();
+ let mut params = vec![(LType::Ptr, String::from("__coro"))];
+ for (i, pty) in sig.params.iter().copied().enumerate() {
+ params.push((pty, format!("__arg{i}")));
+ }
+
+ let args_ptr = if sig.params.is_empty() {
+ String::from("null")
+ } else {
+ let arr_ty = format!("[{} x i64]", sig.params.len());
+ let arr = cg.emit_reg(format!("alloca {arr_ty}"));
+ for (i, pty) in sig.params.iter().copied().enumerate() {
+ let value = Value::new(format!("%__arg{i}"), pty);
+ let boxed = box_codegen_value(cg, value);
+ let slot = cg.emit_reg(format!(
+ "getelementptr {arr_ty}, {arr_ty}* {arr}, i64 0, i64 {i}"
+ ));
+ cg.emit(format!("store i64 {}, i64* {slot}", boxed.operand));
+ }
+ cg.emit_reg(format!(
+ "getelementptr {arr_ty}, {arr_ty}* {arr}, i64 0, i64 0"
+ ))
+ };
+ let raw = cg.call(
+ "i64",
+ "__osprey_coro_suspend",
+ "i8*, i64, i64*, i64",
+ &[
+ "%__coro",
+ &op_id.to_string(),
+ &args_ptr,
+ &sig.params.len().to_string(),
+ ],
+ );
+ let ret = unbox_coro_value(cg, &raw, sig.ret, sig.ret_result_inner);
+ ret_and_exit(cg, saved, sig, name, ¶ms, &ret);
+}
+
+struct ArmFnSpec<'a> {
+ name: &'a str,
+ drive_fn: &'a str,
+ answer_ty: LType,
+ sig: &'a OpSig,
+ caps: &'a [ArmCap],
+ env_ty: &'a str,
+}
+
+fn emit_resuming_arm_fn(cg: &mut Codegen, arm: &HandlerArm, spec: &ArmFnSpec<'_>) -> Result<()> {
+ let saved = cg.enter_nested_fn();
+ reload_env(cg, spec.caps, spec.env_ty);
+ let mut params = vec![
+ (LType::Ptr, String::from("__env")),
+ (LType::Ptr, String::from("__coro")),
+ ];
+ bind_arm_params(cg, arm, spec.sig, &mut params);
+ cg.resume_ctx = Some(ResumeCodegenContext {
+ env: String::from("%__env"),
+ coro: String::from("%__coro"),
+ drive_fn: spec.drive_fn.to_string(),
+ answer_ty: spec.answer_ty,
+ });
+ let body_raw = gen_expr(cg, &arm.body)?;
+ let body = coerce_to(cg, body_raw, spec.answer_ty)?;
+ let boxed = box_codegen_value(cg, body);
+ cg.emit(format!("ret i64 {}", boxed.operand));
+ cg.exit_nested_fn(saved, "i64", spec.name, ¶ms);
+ Ok(())
+}
+
+fn emit_drive_fn(cg: &mut Codegen, name: &str, arms: &[DriveArm]) {
+ let saved = cg.enter_nested_fn();
+ let params = vec![
+ (LType::Ptr, String::from("__env")),
+ (LType::Ptr, String::from("__coro")),
+ ];
+ let done = cg.call("i64", "__osprey_coro_done", "i8*", &["%__coro"]);
+ let done_cond = cg.emit_reg(format!("icmp ne i64 {done}, 0"));
+ let done_lbl = cg.fresh_label();
+ let dispatch_lbl = cg.fresh_label();
+ cg.emit(format!(
+ "br i1 {done_cond}, label %{done_lbl}, label %{dispatch_lbl}"
+ ));
+
+ cg.start_block(&done_lbl);
+ let result = cg.call("i64", "__osprey_coro_result", "i8*", &["%__coro"]);
+ cg.emit(format!("ret i64 {result}"));
+
+ cg.start_block(&dispatch_lbl);
+ let op = cg.call("i64", "__osprey_coro_op", "i8*", &["%__coro"]);
+ let miss_lbl = cg.fresh_label();
+ let check_labels: Vec = arms.iter().map(|_| cg.fresh_label()).collect();
+ let arm_labels: Vec = arms.iter().map(|_| cg.fresh_label()).collect();
+ if let Some(first) = check_labels.first() {
+ cg.emit(format!("br label %{first}"));
+ } else {
+ cg.emit(format!("br label %{miss_lbl}"));
+ }
+
+ for (i, ((arm, check_label), arm_label)) in
+ arms.iter().zip(&check_labels).zip(&arm_labels).enumerate()
+ {
+ cg.start_block(check_label);
+ let cmp = cg.emit_reg(format!("icmp eq i64 {op}, {}", arm.op_id));
+ let next = check_labels.get(i + 1).unwrap_or(&miss_lbl);
+ cg.emit(format!("br i1 {cmp}, label %{arm_label}, label %{next}"));
+ }
+
+ for (arm, arm_label) in arms.iter().zip(&arm_labels) {
+ cg.start_block(arm_label);
+ let mut args = vec![String::from("i8* %__env"), String::from("i8* %__coro")];
+ for (idx, pty) in arm.sig.params.iter().copied().enumerate() {
+ let raw = cg.call(
+ "i64",
+ "__osprey_coro_arg",
+ "i8*, i64",
+ &["%__coro", &idx.to_string()],
+ );
+ let value = unbox_from_i64(cg, &raw, pty);
+ args.push(value.typed());
+ }
+ let arm_result = cg.emit_reg(format!("call i64 @{}({})", arm.arm_fn, args.join(", ")));
+ let done_after = cg.call("i64", "__osprey_coro_done", "i8*", &["%__coro"]);
+ let done_after_cond = cg.emit_reg(format!("icmp ne i64 {done_after}, 0"));
+ let abort_lbl = cg.fresh_label();
+ let return_lbl = cg.fresh_label();
+ cg.emit(format!(
+ "br i1 {done_after_cond}, label %{return_lbl}, label %{abort_lbl}"
+ ));
+ cg.start_block(&abort_lbl);
+ cg.call_void("__osprey_coro_abort", "i8*", &["%__coro"]);
+ cg.emit(format!("br label %{return_lbl}"));
+ cg.start_block(&return_lbl);
+ cg.emit(format!("ret i64 {arm_result}"));
+ }
+
+ cg.start_block(&miss_lbl);
+ cg.call_void("__osprey_coro_abort", "i8*", &["%__coro"]);
+ cg.emit("ret i64 0");
+ cg.exit_nested_fn(saved, "i64", name, ¶ms);
+}
+
+pub(crate) fn gen_resume(cg: &mut Codegen, value: Option<&Expr>) -> Result {
+ declare_coro(cg);
+ let Some(ctx) = cg.resume_ctx.clone() else {
+ return Err(crate::error::CodegenError::invalid(
+ "`resume` outside a handler arm",
+ ));
+ };
+ let raw_value = match value {
+ Some(expr) => gen_expr(cg, expr)?,
+ None => Value::unit(),
+ };
+ let raw_value = crate::result::unwrap(cg, raw_value);
+ let boxed_value = box_codegen_value(cg, raw_value);
+ let resumed = cg.call(
+ "i64",
+ "__osprey_coro_resume",
+ "i8*, i64",
+ &[&ctx.coro, &boxed_value.operand],
+ );
+ let done = cg.call("i64", "__osprey_coro_done", "i8*", &[&ctx.coro]);
+ let done_cond = cg.emit_reg(format!("icmp ne i64 {done}, 0"));
+ let done_lbl = cg.fresh_label();
+ let more_lbl = cg.fresh_label();
+ let end_lbl = cg.fresh_label();
+ cg.emit(format!(
+ "br i1 {done_cond}, label %{done_lbl}, label %{more_lbl}"
+ ));
+
+ cg.start_block(&done_lbl);
+ let done_pred = cg.snapshot_to(&end_lbl);
+
+ cg.start_block(&more_lbl);
+ let nested = cg.emit_reg(format!(
+ "call i64 @{}(i8* {}, i8* {})",
+ ctx.drive_fn, ctx.env, ctx.coro
+ ));
+ let more_pred = cg.snapshot_to(&end_lbl);
+
+ cg.start_block(&end_lbl);
+ let phi = cg.emit_reg(format!(
+ "phi i64 [ {resumed}, %{done_pred} ], [ {nested}, %{more_pred} ]"
+ ));
+ Ok(unbox_from_i64(cg, &phi, ctx.answer_ty))
+}
+
+fn box_codegen_value(cg: &mut Codegen, value: Value) -> Value {
+ if value.result_inner.is_some() {
+ let ptr = cg.emit_reg(format!(
+ "bitcast {} {} to i8*",
+ value.llvm_ty(),
+ value.operand
+ ));
+ return box_to_i64(cg, Value::new(ptr, LType::Ptr));
+ }
+ box_to_i64(cg, value)
+}
+
+fn unbox_coro_value(cg: &mut Codegen, raw: &str, ty: LType, result_inner: Option) -> Value {
+ if let Some(inner) = result_inner {
+ let ptr = cg.emit_reg(format!("inttoptr i64 {raw} to i8*"));
+ let struct_ty = crate::llty::result_struct_ty(inner);
+ let typed = cg.emit_reg(format!("bitcast i8* {ptr} to {struct_ty}*"));
+ return Value::result(typed, inner);
+ }
+ unbox_from_i64(cg, raw, ty)
+}
+
/// `perform Effect.op(args)` — look up the active handler and call it.
pub(crate) fn gen_perform(
cg: &mut Codegen,
@@ -463,11 +922,9 @@ pub(crate) fn gen_perform(
) -> Result {
declare_stack(cg);
let key = format!("{effect}.{operation}");
- let sig = cg.effect_op(&key).unwrap_or(OpSig {
- params: vec![LType::I64; args.len()],
- ret: LType::I64,
- ret_result_inner: None,
- });
+ let sig = cg
+ .effect_op(&key)
+ .unwrap_or_else(|| OpSig::default_for_arity(args.len()));
// Evaluate + coerce arguments to the operation's parameter types.
let mut typed = Vec::new();
diff --git a/crates/osprey-codegen/src/expr.rs b/crates/osprey-codegen/src/expr.rs
index 74489bfa..eed5e3c2 100644
--- a/crates/osprey-codegen/src/expr.rs
+++ b/crates/osprey-codegen/src/expr.rs
@@ -69,6 +69,7 @@ pub(crate) fn gen_expr(cg: &mut Codegen, expr: &Expr) -> Result {
..
} => crate::effects::gen_perform(cg, effect, operation, arguments),
Expr::Handler { effect, arms, body } => crate::effects::gen_handler(cg, effect, arms, body),
+ Expr::Resume(value) => crate::effects::gen_resume(cg, value.as_deref()),
// A lambda in plain value position (returned, block tail, stored in a
// field) becomes a closure cell, typed by inference.
Expr::Lambda {
diff --git a/crates/osprey-codegen/src/freevars.rs b/crates/osprey-codegen/src/freevars.rs
index dee117d1..cc31c98e 100644
--- a/crates/osprey-codegen/src/freevars.rs
+++ b/crates/osprey-codegen/src/freevars.rs
@@ -127,6 +127,7 @@ fn walk_fiber(e: &Expr, bound: &mut Vec, out: &mut BTreeSet) {
walk_all(arguments, bound, out);
walk_named(named_arguments, bound, out);
}
+ Expr::Resume(Some(value)) => walk(value, bound, out),
Expr::Handler { arms, body, .. } => {
for arm in arms {
scoped(bound, arm.params.clone(), out, |b, o| walk(&arm.body, b, o));
diff --git a/crates/osprey-fmt/Cargo.toml b/crates/osprey-fmt/Cargo.toml
new file mode 100644
index 00000000..167e0af8
--- /dev/null
+++ b/crates/osprey-fmt/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "osprey-fmt"
+description = "The Osprey source formatter — flavor-aware, parse-verified, idempotent"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+osprey-ast = { workspace = true }
+osprey-syntax = { workspace = true }
+
+[lints]
+workspace = true
diff --git a/crates/osprey-fmt/src/brace.rs b/crates/osprey-fmt/src/brace.rs
new file mode 100644
index 00000000..50453493
--- /dev/null
+++ b/crates/osprey-fmt/src/brace.rs
@@ -0,0 +1,67 @@
+//! The Default-flavor formatter: C-style, brace-driven indentation.
+//!
+//! The Default flavor delimits blocks with `{ … }` and is whitespace-insensitive,
+//! so indentation is purely cosmetic and is recomputed from bracket nesting: a
+//! line sits one level deeper for every unclosed `{`/`(`/`[` above it, and a line
+//! that *begins* by closing brackets dedents to match its opener. Comments adopt
+//! the depth of the surrounding block.
+
+use crate::scan::{scan_line, Line};
+use crate::{finalize, indent_to};
+
+/// Reformat Default-flavor `src`, returning the canonical, reindented text.
+pub(crate) fn format(src: &str) -> String {
+ let lines: Vec = src.split('\n').map(scan_line).collect();
+ let mut depth = 0i32;
+ let mut out: Vec = Vec::with_capacity(lines.len());
+ for line in &lines {
+ if line.is_blank() {
+ out.push(String::new());
+ continue;
+ }
+ let here = (depth - line.leading_closers).max(0);
+ out.push(indent_to(here) + &line.content);
+ depth = (depth + line.open_delta).max(0);
+ }
+ finalize(&out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn reindents_a_braced_function_to_four_spaces() {
+ let src = "fn main() = {\nprint(1)\n}\n";
+ assert_eq!(format(src), "fn main() = {\n print(1)\n}\n");
+ }
+
+ #[test]
+ fn over_indented_input_is_normalised_down() {
+ let src = "fn classify(n) = match n {\n 0 => \"zero\"\n _ => \"many\"\n}\n";
+ let want = "fn classify(n) = match n {\n 0 => \"zero\"\n _ => \"many\"\n}\n";
+ assert_eq!(format(src), want);
+ }
+
+ #[test]
+ fn nested_blocks_step_by_one_level_each() {
+ let src = "fn f() = {\nmatch x {\nA => {\ng()\n}\n}\n}\n";
+ let want =
+ "fn f() = {\n match x {\n A => {\n g()\n }\n }\n}\n";
+ assert_eq!(format(src), want);
+ }
+
+ #[test]
+ fn is_idempotent() {
+ let src = "fn main() = {\n print( 1 )\n }\n";
+ let once = format(src);
+ assert_eq!(format(&once), once);
+ }
+
+ #[test]
+ fn brackets_inside_strings_do_not_shift_indentation() {
+ let src = "fn f() = {\nprint(\"}{}{\")\nx\n}\n";
+ let want = "fn f() = {\n print(\"}{}{\")\n x\n}\n";
+ assert_eq!(format(src), want);
+ }
+}
diff --git a/crates/osprey-fmt/src/layout.rs b/crates/osprey-fmt/src/layout.rs
new file mode 100644
index 00000000..91b9be3c
--- /dev/null
+++ b/crates/osprey-fmt/src/layout.rs
@@ -0,0 +1,116 @@
+//! The ML-flavor formatter: indentation-significant, layout-driven.
+//!
+//! Unlike the Default flavor, ML indentation *is* the block structure — the
+//! lexer turns column changes into Indent/Dedent tokens — so the formatter must
+//! not invent or flatten nesting. Instead it re-grids the existing indentation
+//! onto a clean four-space step: a stack of the source's own indent columns maps
+//! each line to a depth, preserving exactly which lines are deeper, shallower, or
+//! siblings. Blank and comment lines are layout-transparent; a standalone
+//! comment takes the depth of the code it precedes.
+
+use crate::scan::{scan_line, Line};
+use crate::{finalize, indent_to};
+
+/// Reformat ML-flavor `src`, re-gridding its layout to a four-space step.
+pub(crate) fn format(src: &str) -> String {
+ let lines: Vec = src.split('\n').map(scan_line).collect();
+ let depths = code_depths(&lines);
+ let out = render(&lines, &depths);
+ finalize(&out)
+}
+
+/// Map every code line to its nesting depth by walking a stack of the indent
+/// columns seen so far; blank and comment lines get `None` (resolved later).
+fn code_depths(lines: &[Line]) -> Vec