diff --git a/.claude/skills/code-dedup/SKILL.md b/.claude/skills/code-dedup/SKILL.md
index 2032a9b6..f2a05bcf 100644
--- a/.claude/skills/code-dedup/SKILL.md
+++ b/.claude/skills/code-dedup/SKILL.md
@@ -1,109 +1,70 @@
---
name: code-dedup
-description: Searches for duplicate code, duplicate tests, and dead code, then safely merges or removes them. Use when the user says "deduplicate", "find duplicates", "remove dead code", "DRY up", or "code dedup". Requires test coverage — refuses to touch untested code.
+description: Finds duplicated code and dead code with deslop, then merges or removes the worst of it. Use when the user says "deduplicate", "find duplicates", "remove dead code", "DRY up", or "code dedup".
---
# Code Dedup
-Carefully search for duplicate code, duplicate tests, and dead code across the repo. Merge duplicates and delete dead code — but only when test coverage proves the change is safe.
+Find duplication with **deslop**, then use judgement to merge only what is worth merging.
-## Prerequisites — hard gate
+## Judgement first
-Before touching ANY code, verify these conditions. If any fail, stop and report why.
+Deslop measures structural repetition. It cannot tell whether collapsing two blocks makes the code better or worse. **You decide.** A report with 476 clusters does not mean 476 edits — it means the five or ten that matter. `merge-plan` returns verdict `ai_or_human` for anything non-mechanical: that is the tool handing you the call.
-1. Run `make test` — all tests must pass. If tests fail, stop. Do not dedup a broken codebase.
-2. Run `make test` — tests are fail-fast AND enforce the coverage threshold from `coverage-thresholds.json`. If anything fails, stop and fix it before deduping.
-3. Verify the project uses **static typing**:
- - Rust (crates/, tree-sitter-osprey/): typed by default — proceed
- - TypeScript (vscode-extension/, webcompiler/, website/): check `tsconfig.json` has `"strict": true` — proceed if yes
- - C (compiler/runtime/): typed by default — proceed
+**Merge** real copy-pasted logic — a block differing only in a symbol or a literal, 3+ near-identical call sites, one algorithm restated in two modules.
-## Steps
+**Leave** anything else, especially:
-Copy this checklist and track progress:
+- **Data, not logic** — constant tables, per-platform SDK/triple/runtime arms, doc rows. Merging buries the values the reader came for.
+- **Structural coincidence** — `structural_only` CST walks and `match` shapes: same skeleton, different meaning.
+- **Already factored** — only thin wrappers remain over a real helper.
+- **Merges needing a new bool/enum parameter** to make one function do two jobs.
-```
-Dedup Progress:
-- [ ] Step 1: Prerequisites passed (tests green, coverage met, typed)
-- [ ] Step 2: Dead code scan complete
-- [ ] Step 3: Duplicate code scan complete
-- [ ] Step 4: Duplicate test scan complete
-- [ ] Step 5: Changes applied
-- [ ] Step 6: Verification passed (tests green, coverage stable)
-```
-
-### Step 1 — Inventory test coverage
-
-Before deciding what to touch, understand what is tested.
-
-1. Run `make test` to confirm green baseline. `make test` is fail-fast AND enforces the coverage threshold from `coverage-thresholds.json` (REPO-STANDARDS-SPEC [TEST-RULES], [COVERAGE-THRESHOLDS-JSON]). It exits non-zero on any test failure OR coverage shortfall.
-2. Note the current coverage percentage — this is the floor. It must not drop.
-3. Identify which files/modules have coverage and which do not. Only files WITH coverage are candidates for dedup.
+A wrong merge is worse than a duplicate.
-### Step 2 — Scan for dead code
+## Tools
-Search for code that is never called, never imported, never referenced.
+Prefer the MCP — live index, no rescan cost:
-1. Look for unused exports, unused functions, unused variables
-2. Use language-appropriate tools:
- - Rust: `dead_code`/`unused_*` are denied workspace-wide, so `cargo clippy --all-targets` reports unused code as errors
- - TypeScript: check for `noUnusedLocals`/`noUnusedParameters` in tsconfig, look for unexported functions with zero references
- - C: compiler warnings for unused functions/variables (already caught by `-Wall -Wextra`)
-3. For each candidate: **grep the entire codebase** for references (including tests, scripts, configs). Only mark as dead if truly zero references.
-4. List all dead code found with file paths and line numbers. Do NOT delete yet.
+| Tool | Use |
+|---|---|
+| `mcp__deslop__duplicates` | **Start here.** Clusters worst-first by `mass`. `{detail:"summary", limit:20}`; `include_per_file:true` for a per-file table, `path_contains` to scope. |
+| `mcp__deslop__cluster-by-id` | Every occurrence path + byte range for one `id`. |
+| `mcp__deslop__compare-pair` | The **only** pair evidence: two `{path,start_byte,end_byte}` in, `text_identity` and `structural` out. |
+| `mcp__deslop__merge-plan` | Mechanical plan for a cluster. `ai_or_human` → hand-edit. |
+| `mcp__deslop__find-similar` | Call **before writing new code** (CLAUDE.md mandates it). |
+| `mcp__deslop__rescan` | Refresh after edits. |
-### Step 3 — Scan for duplicate code
+A cluster's `kind` is the *weakest* pair against the canonical — **never assume two members match each other**. `compare-pair` the exact ranges you intend to merge.
-Search for code blocks that do the same thing in multiple places.
+No MCP? Use the installed CLI — what `make _deslop` runs:
-1. Look for functions/methods with identical or near-identical logic
-2. Look for copy-pasted blocks (same structure, maybe different variable names)
-3. Look for multiple implementations of the same algorithm or pattern
-4. Check across module boundaries — duplicates often hide in different packages
-5. For each duplicate pair: note both locations, what they do, and how they differ (if at all)
-6. List all duplicates found. Do NOT merge yet.
-
-### Step 4 — Scan for duplicate tests
-
-Search for tests that verify the same behavior.
-
-1. Look for test functions with identical assertions against the same code paths
-2. Look for test fixtures/helpers that are duplicated across test files
-3. Look for integration tests that fully cover what a unit test also covers
-4. List all duplicate tests found. Do NOT delete yet.
-
-### Step 5 — Apply changes (one at a time)
+```bash
+deslop . --nohtml --nojson --output "$PWD/target/deslop-report" --log-to-console --log-level error --no-color
+```
-For each change, follow this cycle: **change → test → verify coverage → continue or revert**.
+Exit `3` means over the ceiling in `.deslop.toml`. Check `deslop --version`; install from only if the binary is missing, matching the version pinned in `.github/workflows/ci.yml`. Never upgrade to move a number — the MCP server (`tool_version: 0.0.0-dev`) and the pinned CLI disagree, so **find** with the MCP and **measure** with `make _deslop`.
-#### 5a. Remove dead code
-- Delete dead code identified in Step 2
-- After each deletion: run `make test` (fail-fast + coverage + threshold all in one)
-- If `make test` exits non-zero (test failure OR coverage drop): **revert immediately** and investigate
+## The ratchet
-#### 5b. Merge duplicate code
-- For each duplicate pair: extract the shared logic into a single function/module
-- Update all call sites to use the shared version
-- After each merge: run `make test`
-- If tests fail: **revert immediately**
+**CI must never allow duplication to increase.** `max_duplication_percent` is a ratchet, not a budget.
-#### 5c. Remove duplicate tests
-- Delete the redundant test (keep the more thorough one)
-- After each deletion: run `make test`
-- If coverage drops below threshold, `make test` exits non-zero — **revert immediately**
+- **Never raise it.** Over the ceiling means you added duplication — remove it. The number is not the thing to edit.
+- **Lower it after every round** to the fresh `make _deslop` measurement. Slack above the measurement lets the next clone land free.
+- **A branch may not measure above `main`** — compare with the *same* CLI version, or the version gap alone will convict or exonerate falsely.
+- `.deslop.toml`'s comment block records the ratchet history. Extend it when you lower the ceiling; if that history and the live value disagree, someone raised it — **report a gate violation**.
-### Step 6 — Final verification
+## Process
-1. Run `make lint` — all linters must pass
-2. Run `make test` — tests must pass AND coverage must remain ≥ the baseline from Step 1
-3. Report: what was removed, what was merged, final coverage vs baseline
+- **Baseline.** `make _deslop` — record the percentage you start from.
+- **Gather.** `mcp__deslop__duplicates`, worst first. `cargo clippy --all-targets --all-features` is the whole dead-code scan (`dead_code`/`unused_*` are denied workspace-wide); grep the repo before deleting anything it flags.
+- **Triage.** Apply the judgement above. Record each keeper's two locations and intended helper, and each rejection's reason so nobody re-litigates it. `.deslop.toml` scopes the gate *by path*, so inline `#[cfg(test)]` and `#[path = "…"]` modules still inflate the number — an artifact, not a task.
+- **Apply.** One merge at a time, smallest diff that removes the duplication. Keep public and `pub(crate)` signatures intact so no caller has to change.
+- **Verify.** `make lint`, then `make _deslop`. Report what merged, what you left and why, and duplication vs baseline.
## Rules
-- **No test coverage = do not touch.** If a file has no tests covering it, leave it alone entirely.
-- **Coverage must not drop.** The coverage floor from Step 1 is sacred.
-- **One change at a time.** Make one dedup change, run tests, verify coverage.
-- **When in doubt, leave it.** If two code blocks look similar but you're not 100% sure they're functionally identical, leave both.
-- **Preserve public API surface.** Do not change function signatures or exported names.
-- **Three similar lines is fine.** Only dedup when the shared logic is substantial (>10 lines) or when there are 3+ copies.
+- **Three similar lines is fine** — merge at >10 shared lines or 3+ copies.
+- **Edit in place.** Never leave a parallel version of anything behind.
+- **When in doubt, leave it.**
diff --git a/.gitignore b/.gitignore
index d36c1751..9439014d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -141,6 +141,11 @@ benchmarks/cases/**/*.o
/tests/regressions/basics/files/test_output.txt
/tests/regressions/basics/files/test_stale_reason.txt
/tests/regressions/effects/osprey_http_state_levels.db
+# The injected disk handler in storage_injection.test.{osp,ospml} writes the
+# notebook and its archive. Each flavor owns its own names so the twins cannot
+# race when the corpus runs them concurrently from the same directory.
+/osprey_storage_injection_*.txt
+/tests/effects/injection/osprey_storage_injection_*.txt
# Core dumps from a crashed C test binary (a container run leaves them here).
core
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 57449b1a..33efe241 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -30,7 +30,7 @@
"rust-analyzer.check.allTargets": true,
"basilisk.enabled": true,
"basilisk.uv.enabled": true,
- "deslop.topOffenders.groupBy": "type",
+ "deslop.topOffenders.groupBy": "kind",
"deslop.embedding.provider": "ollama",
"deslop.embedding.model": "nomic-embed-text",
"deslop.embedding.mode": "auto"
diff --git a/CLAUDE.md b/CLAUDE.md
index f62b02f0..6ad38eab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,6 +70,16 @@ Silently-wrong output is worse than a crash: a panic is found in seconds; a sile
- If removing an annotation still compiles with identical output, it was redundant — remove it. Applies to every `.osp` you touch: `tests/regressions/`, `benchmarks/`, docs and website snippets.
- **No consecutive print calls** — consolidate into one interpolated string.
+```ospml
+ // This is wrong because the signature can be inferred. You must omit this!
+ escape : string -> string
+ escape s =
+ bslash = "\\"
+ quote = "\""
+ step = replace s bslash (bslash + bslash) ?: ""
+ escapeControls (replace step quote (bslash + quote) ?: "") 1
+```
+
## Rust
- **Panics are illegal** outside the broken-code quarantine. Return `Result`.
diff --git a/Cargo.toml b/Cargo.toml
index 86fa01f5..bdfbabd0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -67,6 +67,13 @@ unused_variables = "deny"
unused_mut = "deny"
unused_assignments = "deny"
unused_results = "deny"
+# A `pub` item not reachable from the crate root is invisible outside the crate,
+# so `dead_code` never evaluates it and it can rot forever. Forcing `pub(crate)`
+# puts every such item back under the dead-code lint. [LINTS-DEADCODE-REACH]
+unreachable_pub = "deny"
+unused_lifetimes = "deny"
+unused_macro_rules = "deny"
+unused_qualifications = "deny"
trivial_casts = "deny"
trivial_numeric_casts = "deny"
elided_lifetimes_in_paths = "deny"
diff --git a/Makefile b/Makefile
index 4e544dda..db9cf423 100644
--- a/Makefile
+++ b/Makefile
@@ -279,6 +279,7 @@ lint: _deslop _lint
_lint: $(EXT_NODE_DEPS)
@echo "==> Linting..."
node scripts/verify-node-deps-guard.mjs
+ node scripts/verify-no-dead-code.mjs
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cd $(EXT_DIR) && npm run lint
@@ -288,6 +289,12 @@ _lint: $(EXT_NODE_DEPS)
# threshold live in that committed config — the single source of truth. When
# the `deslop` binary is absent this target FAILS: a gate that cannot run must
# not report success. CI enforces the same ceiling through the official action.
+# Version of the deslop CLI `make setup` installs. MUST equal the `version:` the
+# Deslop action pins in .github/workflows/ci.yml — deslop measures source text,
+# so a different build can report a different percentage for the SAME tree, and
+# a local gate that disagrees with the merge gate is worse than no local gate.
+DESLOP_VERSION ?= 0.27.0
+
_deslop:
@echo "==> Duplication gate (deslop)..."
@if ! command -v deslop >/dev/null 2>&1; then \
@@ -301,7 +308,14 @@ _deslop:
deslop . --nohtml --nojson --output $(CURDIR)/target/deslop-report --log-to-console --log-level error --no-color
## hawk: Dead-code gate (astral-sh/hawk). Fails the build when any `pub`
-## declaration is unreachable from the osprey binary (hawk::dead_public). Scoped
+## declaration is unreachable from the osprey binary (hawk::dead_public).
+##
+## hawk counts the workspace's TEST binaries as reachability roots, so an item
+## whose only callers are its own `#[cfg(test)]` module passes this gate — that
+## is how `osprey_debug::DebugBuild` and `osprey_syntax::dependency_sets` lived
+## in the tree. `scripts/verify-no-dead-code.mjs` (run by `_lint`) answers the
+## narrower question "does PRODUCT code name this?" and catches that class.
+## Neither gate subsumes the other; both run. Scoped
## to dead_public ONLY — unnecessary_public / restricted-visibility findings are
## over-exposure, not dead code, and several are irreducibly public for the
## integration tests under this workspace's `dead_code = "deny"` policy, so they
@@ -407,6 +421,7 @@ setup: $(EXT_NODE_DEPS) $(WEBCOMPILER_NODE_DEPS) $(WEBSITE_NODE_DEPS)
@echo "==> Setting up development environment..."
rustup component add rustfmt clippy llvm-tools-preview
command -v cargo-llvm-cov >/dev/null 2>&1 || cargo install cargo-llvm-cov
+ command -v deslop >/dev/null 2>&1 || DESLOP_VERSION=$(DESLOP_VERSION) bash scripts/install-deslop.sh
@echo "==> Setup complete. Run 'make ci' to validate."
# ---------------------------------------------------------------------------
diff --git a/compiler/runtime/test_cmake_integration.sh b/compiler/runtime/test_cmake_integration.sh
deleted file mode 100755
index 8f4052d0..00000000
--- a/compiler/runtime/test_cmake_integration.sh
+++ /dev/null
@@ -1,60 +0,0 @@
-#!/bin/bash
-
-echo "🔧 CMake C Test Integration Verification"
-echo "========================================="
-echo "⚠️ This script must be run inside the VS Code Dev Container"
-echo " Use Command Palette → 'Dev Containers: Rebuild Container'"
-echo ""
-
-# Navigate to runtime directory
-cd "$(dirname "$0")"
-
-# Clean and create build directory
-echo "📁 Setting up build directory..."
-rm -rf build
-mkdir -p build
-cd build
-
-# Configure with CMake
-echo "⚙️ Configuring CMake..."
-cmake .. -DCMAKE_BUILD_TYPE=Debug
-
-if [ $? -ne 0 ]; then
- echo "❌ CMake configuration failed!"
- exit 1
-fi
-
-echo "✅ CMake configuration successful!"
-
-# Build the tests
-echo "🔨 Building C runtime tests..."
-make -j$(nproc)
-
-if [ $? -ne 0 ]; then
- echo "❌ Build failed!"
- exit 1
-fi
-
-echo "✅ Build successful!"
-
-# List available tests
-echo "📋 Available CTest tests:"
-ctest --show-only=json-v1 | jq -r '.tests[].name' 2>/dev/null || ctest -N
-
-# Run tests with CTest
-echo "🧪 Running CTest..."
-ctest --verbose
-
-if [ $? -eq 0 ]; then
- echo "✅ All C runtime tests passed!"
- echo ""
- echo "🎉 VS Code Integration Ready!"
- echo " - Open this project in VS Code with Dev Container"
- echo " - Go to Test Explorer (flask icon in sidebar)"
- echo " - You should see 'SystemRuntimeTests' and 'FiberRuntimeTests'"
- echo " - Click the play button to run individual tests"
- echo " - Use debug button to debug tests with breakpoints"
-else
- echo "❌ Some tests failed!"
- exit 1
-fi
\ No newline at end of file
diff --git a/compiler/runtime/test_http_runtime.sh b/compiler/runtime/test_http_runtime.sh
deleted file mode 100755
index f50d112f..00000000
--- a/compiler/runtime/test_http_runtime.sh
+++ /dev/null
@@ -1,80 +0,0 @@
-#!/bin/bash
-
-# Test script for HTTP runtime
-echo "🧪 Compiling and testing HTTP runtime..."
-
-# Compile all the runtime modules and tests
-echo "📦 Compiling runtime modules..."
-
-# Detect OpenSSL paths cross-platform
-OPENSSL_CFLAGS=""
-OPENSSL_LDFLAGS="-lssl -lcrypto"
-
-# Try to find OpenSSL include/lib directories
-if [ "$(uname)" = "Darwin" ]; then
- # macOS with Homebrew
- for path in "/opt/homebrew/opt/openssl@3" "/usr/local/opt/openssl@3" "/opt/homebrew/opt/openssl" "/usr/local/opt/openssl"; do
- if [ -d "$path" ]; then
- OPENSSL_CFLAGS="-I${path}/include"
- OPENSSL_LDFLAGS="-L${path}/lib -lssl -lcrypto"
- break
- fi
- done
-elif [ "$(uname)" = "Linux" ]; then
- # Linux - usually in standard locations
- if pkg-config --exists openssl 2>/dev/null; then
- OPENSSL_CFLAGS="$(pkg-config --cflags openssl)"
- OPENSSL_LDFLAGS="$(pkg-config --libs openssl)"
- fi
-fi
-
-gcc -c http_shared.c -o http_shared.o -pthread $OPENSSL_CFLAGS
-gcc -c http_client_runtime.c -o http_client_runtime.o -pthread $OPENSSL_CFLAGS
-gcc -c http_server_request.c -o http_server_request.o -pthread $OPENSSL_CFLAGS
-gcc -c http_server_response.c -o http_server_response.o -pthread $OPENSSL_CFLAGS
-gcc -c http_server_runtime.c -o http_server_runtime.o -pthread $OPENSSL_CFLAGS
-gcc -c websocket_client_runtime.c -o websocket_client_runtime.o -pthread $OPENSSL_CFLAGS
-gcc -c websocket_server_runtime.c -o websocket_server_runtime.o -pthread $OPENSSL_CFLAGS
-
-if [ $? -ne 0 ]; then
- echo "❌ Runtime compilation failed!"
- exit 1
-fi
-
-echo "🧪 Compiling test suite..."
-gcc -o test_http_runtime http_runtime_tests.c \
- http_shared.o \
- http_client_runtime.o \
- http_server_request.o \
- http_server_response.o \
- http_server_runtime.o \
- websocket_client_runtime.o \
- websocket_server_runtime.o \
- -L/usr/local/lib -lfiber_runtime \
- -pthread $OPENSSL_CFLAGS $OPENSSL_LDFLAGS
-
-if [ $? -ne 0 ]; then
- echo "❌ Test compilation failed!"
- exit 1
-fi
-
-echo "✅ Compilation successful!"
-echo ""
-
-# Run the tests
-echo "🚀 Running HTTP runtime tests..."
-echo ""
-./test_http_runtime
-
-if [ $? -ne 0 ]; then
- echo "❌ Tests failed!"
- exit 1
-fi
-
-echo ""
-echo "🎉 All tests passed! HTTP runtime is working correctly."
-
-# Clean up
-rm -f *.o test_http_runtime
-
-echo "✅ Test cleanup complete."
diff --git a/crates/osprey-ast/src/doc.rs b/crates/osprey-ast/src/doc.rs
index 71add3bb..cdf16816 100644
--- a/crates/osprey-ast/src/doc.rs
+++ b/crates/osprey-ast/src/doc.rs
@@ -83,13 +83,6 @@ impl DocComment {
}
}
- /// A summary-only outer doc — the common case, and the shape a bare doc
- /// comment with no recognised sections lowers to.
- #[must_use]
- pub fn summary_only(summary: impl Into) -> DocComment {
- Self::new(summary, String::new(), DocScope::Outer)
- }
-
/// Render the whole doc comment as the Markdown block a hover shows: the
/// summary, the body, then each populated section as a heading. `[Symbol]`
/// links are preserved verbatim so the LSP client renders them as links.
@@ -170,7 +163,7 @@ mod tests {
#[test]
fn summary_only_leaves_every_section_empty() {
// [DOC-MODEL] a summary-only comment has the canonical empty-field shape.
- let doc = DocComment::summary_only("Adds two ints.");
+ let doc = DocComment::new("Adds two ints.", String::new(), DocScope::Outer);
assert_eq!(doc.summary, "Adds two ints.");
assert!(doc.body.is_empty());
assert!(doc.params.is_empty() && doc.returns.is_none());
diff --git a/crates/osprey-ast/src/freevars.rs b/crates/osprey-ast/src/freevars.rs
index 499cf33b..ebf202f9 100644
--- a/crates/osprey-ast/src/freevars.rs
+++ b/crates/osprey-ast/src/freevars.rs
@@ -6,7 +6,7 @@
//! parameters, `let`s in blocks, `match`/`select`/handler pattern bindings)
//! are subtracted; everything else referenced is free.
-use crate::{Expr, InterpolatedPart, MatchArm, Pattern, Stmt};
+use crate::{AstNode, Expr, MatchArm, Pattern, Stmt};
use std::collections::BTreeSet;
/// Collect the free identifiers of `e` into `out` (sorted, deduplicated).
@@ -36,60 +36,8 @@ fn scoped(
fn walk(e: &Expr, bound: &mut Vec, out: &mut BTreeSet) {
match e {
- Expr::Integer(_) | Expr::Float(_) | Expr::Str(_) | Expr::Bool(_) => {}
- Expr::Identifier(n) => note(n, bound, out),
+ Expr::Identifier(name) => note(name, bound, out),
Expr::Path(path) => note(&path.to_string(), bound, out),
- Expr::InterpolatedStr(parts) => {
- for p in parts {
- if let InterpolatedPart::Expr(inner) = p {
- walk(inner, bound, out);
- }
- }
- }
- Expr::List(xs, _) => walk_slice(xs, bound, out, |x| x),
- Expr::Map(entries) => {
- for en in entries {
- walk(&en.key, bound, out);
- walk(&en.value, bound, out);
- }
- }
- Expr::Object(fields) => walk_slice(fields, bound, out, |f| &f.value),
- Expr::Binary { left, right, .. } | Expr::Pipe { left, right } => {
- walk(left, bound, out);
- walk(right, bound, out);
- }
- Expr::Unary { operand, .. } => walk(operand, bound, out),
- e2 => walk_rest(e2, bound, out),
- }
-}
-
-/// Continuation of [`walk`] (kept in thirds so each stays small).
-fn walk_rest(e: &Expr, bound: &mut Vec, out: &mut BTreeSet) {
- match e {
- Expr::Call {
- function,
- arguments,
- named_arguments,
- } => {
- walk(function, bound, out);
- walk_slice(arguments, bound, out, |x| x);
- walk_slice(named_arguments, bound, out, |n| &n.value);
- }
- Expr::MethodCall {
- target,
- arguments,
- named_arguments,
- ..
- } => {
- walk(target, bound, out);
- walk_slice(arguments, bound, out, |x| x);
- walk_slice(named_arguments, bound, out, |n| &n.value);
- }
- Expr::FieldAccess { target, .. } => walk(target, bound, out),
- Expr::Index { target, index } => {
- walk(target, bound, out);
- walk(index, bound, out);
- }
Expr::Lambda {
parameters, body, ..
} => {
@@ -100,52 +48,29 @@ fn walk_rest(e: &Expr, bound: &mut Vec, out: &mut BTreeSet) {
walk(value, bound, out);
walk_arms(arms, bound, out);
}
- Expr::Block { statements, value } => walk_block(statements, value.as_deref(), bound, out),
- // `name { … }` where `name` is a bound local is a record UPDATE that
- // reads `name` (`aggregate::gen_constructor` redirects; the parser
- // cannot tell the forms apart). Noting a real type name is harmless:
- // consumers filter against actual locals (captures via `cg.lookup`,
- // liveness against `let`-bound ledger names).
- Expr::TypeConstructor { name, fields, .. } => {
- note(name, bound, out);
- walk_slice(fields, bound, out, |f| &f.value);
- }
- Expr::Update { record, fields } => {
- note(record, bound, out);
- walk_slice(fields, bound, out, |f| &f.value);
- }
- e2 => walk_fiber(e2, bound, out),
- }
-}
-
-/// Final third of the walker: fiber/effect forms (and the leaf-handled rest).
-fn walk_fiber(e: &Expr, bound: &mut Vec, out: &mut BTreeSet) {
- match e {
- Expr::Spawn(inner) | Expr::Await(inner) | Expr::Recv(inner) | Expr::Yield(Some(inner)) => {
- walk(inner, bound, out);
- }
- Expr::Send { channel, value } => {
- walk(channel, bound, out);
- walk(value, bound, out);
- }
Expr::Select { arms } => walk_arms(arms, bound, out),
- Expr::Perform {
- arguments,
- named_arguments,
- ..
+ Expr::Block { statements, value } => walk_block(statements, value.as_deref(), bound, out),
+ // A constructor spelling can name a local record update. Consumers
+ // filter actual type names against locals; an update's base is a read.
+ Expr::TypeConstructor { name, fields, .. }
+ | Expr::Update {
+ record: name,
+ fields,
} => {
- walk_slice(arguments, bound, out, |x| x);
- walk_slice(named_arguments, bound, out, |n| &n.value);
+ note(name, bound, out);
+ walk_slice(fields, bound, out, |field| &field.value);
}
- 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));
}
walk(body, bound, out);
}
- // Every other variant is fully handled by the first two thirds.
- _ => {}
+ _ => AstNode::Expression(e).for_each_child(|child| {
+ if let AstNode::Expression(expression) = child {
+ walk(expression, bound, out);
+ }
+ }),
}
}
diff --git a/crates/osprey-ast/src/lib.rs b/crates/osprey-ast/src/lib.rs
index f086f107..e1c120ac 100644
--- a/crates/osprey-ast/src/lib.rs
+++ b/crates/osprey-ast/src/lib.rs
@@ -26,7 +26,7 @@ pub use generics::{EffectRef, TypeParam, Variance};
pub use multiplicity::{Multiplicity, OperationTable, REPLAYABLE_KEYWORD};
pub use resume::{contains_resume, resumes_on_one_path};
pub use stage::{Stage, STATIC_STAGE_KEYWORD};
-pub use visit::{walk_each, walk_program, AstVisitor};
+pub use visit::{walk_each, walk_program, AstNode, AstVisitor};
/// The one wording for an entry conflict [MODULES-ENTRYPOINT]. Two phases can
/// reach it — the type checker for a plain source, the project assembler for a
@@ -217,6 +217,21 @@ pub struct TypeExpr {
}
impl TypeExpr {
+ /// A module contract supplies a type without adding a written annotation.
+ /// Line zero is reserved for compiler-generated source metadata.
+ #[must_use]
+ pub fn as_contract_annotation(&self) -> Self {
+ let mut ty = self.clone();
+ ty.position = Some(Position { line: 0, column: 0 });
+ ty
+ }
+
+ /// Whether this annotation was supplied by module-signature elaboration.
+ #[must_use]
+ pub fn is_from_contract(&self) -> bool {
+ self.position.is_some_and(|position| position.line == 0)
+ }
+
/// A bare named type like `Int` or `Ptr`.
pub fn named(name: impl Into) -> Self {
TypeExpr {
@@ -765,6 +780,17 @@ pub enum Expr {
/// Named arguments.
named_arguments: Vec,
},
+ /// Explicit declaration-binder arguments on a call's callee.
+ /// Both flavors use this node for [TYPE-GENERICS-APPLY]. Keeping the
+ /// application on the callee preserves ordinary and curried call nodes.
+ TypeApply {
+ /// The named function being instantiated.
+ function: Box,
+ /// Written type arguments, in declaration order.
+ type_args: Vec,
+ /// Source position of the callee, for application diagnostics.
+ position: Option,
+ },
/// `a |> b` pipe.
Pipe {
/// Piped value.
diff --git a/crates/osprey-ast/src/multiplicity.rs b/crates/osprey-ast/src/multiplicity.rs
index 62f83faf..3b9a59bc 100644
--- a/crates/osprey-ast/src/multiplicity.rs
+++ b/crates/osprey-ast/src/multiplicity.rs
@@ -178,17 +178,13 @@ mod tests {
statements: vec![
effect(
"Choice",
- crate::Stage::Dynamic,
+ Stage::Dynamic,
vec![
operation("pick", Some(Multiplicity::Many), false),
operation("seed", None, true),
],
),
- effect(
- "Tile",
- crate::Stage::Static,
- vec![operation("size", None, false)],
- ),
+ effect("Tile", Stage::Static, vec![operation("size", None, false)]),
],
};
let table = OperationTable::collect(&program);
diff --git a/crates/osprey-ast/src/mutate.rs b/crates/osprey-ast/src/mutate.rs
index be73cec9..5d84bfef 100644
--- a/crates/osprey-ast/src/mutate.rs
+++ b/crates/osprey-ast/src/mutate.rs
@@ -45,7 +45,10 @@ pub fn children_mut(expression: &mut Expr, visit: &mut impl FnMut(&mut Expr)) {
visit(left);
visit(right);
}
- Expr::Unary { operand, .. }
+ Expr::TypeApply {
+ function: operand, ..
+ }
+ | Expr::Unary { operand, .. }
| Expr::Spawn(operand)
| Expr::Await(operand)
| Expr::Recv(operand)
diff --git a/crates/osprey-ast/src/resume.rs b/crates/osprey-ast/src/resume.rs
index 4c3cb4ee..3070885c 100644
--- a/crates/osprey-ast/src/resume.rs
+++ b/crates/osprey-ast/src/resume.rs
@@ -9,7 +9,7 @@
//! which is the affine rule multiplicity enforces.
//! Implements [EFFECTS-RESUME], [MULTI-HANDLE-ONCE].
-use crate::{Expr, InterpolatedPart, Stmt};
+use crate::{AstNode, Expr, Stmt};
/// True when `e` contains a `resume` belonging to the ENCLOSING handler arm —
/// a nested handler's body owns its own `resume`s, so they don't count.
@@ -17,66 +17,19 @@ use crate::{Expr, InterpolatedPart, Stmt};
pub fn contains_resume(e: &Expr) -> bool {
match e {
Expr::Resume(_) => true,
- Expr::InterpolatedStr(parts) => parts
- .iter()
- .any(|p| matches!(p, crate::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.
+ // Only the handled body belongs to the enclosing arm.
Expr::Handler { body, .. } => contains_resume(body),
- _ => false,
+ _ => {
+ let mut found = false;
+ AstNode::Expression(e).for_each_child(|child| {
+ found = found
+ || match child {
+ AstNode::Statement(statement) => stmt_contains_resume(statement),
+ AstNode::Expression(expression) => contains_resume(expression),
+ };
+ });
+ found
+ }
}
}
@@ -133,74 +86,14 @@ pub fn resumes_on_one_path(body: &Expr) -> u32 {
/// Sum the path length of every child evaluated on the way through `body`.
fn sequential_children(body: &Expr) -> u32 {
- let each = |xs: &[Expr]| xs.iter().map(resumes_on_one_path).sum();
- match body {
- Expr::InterpolatedStr(parts) => parts
- .iter()
- .map(|part| match part {
- InterpolatedPart::Expr(inner) => resumes_on_one_path(inner),
- InterpolatedPart::Text(_) => 0,
- })
- .sum(),
- Expr::List(values, _) => each(values),
- Expr::Map(entries) => entries
- .iter()
- .map(|entry| resumes_on_one_path(&entry.key) + resumes_on_one_path(&entry.value))
- .sum(),
- Expr::Object(fields)
- | Expr::TypeConstructor { fields, .. }
- | Expr::Update { fields, .. } => fields.iter().map(|f| resumes_on_one_path(&f.value)).sum(),
- Expr::Binary { left, right, .. } | Expr::Pipe { left, right } => {
- resumes_on_one_path(left) + resumes_on_one_path(right)
- }
- Expr::Unary { operand, .. } => resumes_on_one_path(operand),
- Expr::Call {
- function,
- arguments,
- named_arguments,
- } => {
- resumes_on_one_path(function)
- + each(arguments)
- + named_arguments
- .iter()
- .map(|n| resumes_on_one_path(&n.value))
- .sum::()
- }
- Expr::MethodCall {
- target,
- arguments,
- named_arguments,
- ..
- } => {
- resumes_on_one_path(target)
- + each(arguments)
- + named_arguments
- .iter()
- .map(|n| resumes_on_one_path(&n.value))
- .sum::()
- }
- Expr::FieldAccess { target, .. } => resumes_on_one_path(target),
- Expr::Index { target, index } => resumes_on_one_path(target) + resumes_on_one_path(index),
- Expr::Spawn(inner) | Expr::Await(inner) | Expr::Recv(inner) => resumes_on_one_path(inner),
- Expr::Yield(value) => value.as_deref().map_or(0, resumes_on_one_path),
- Expr::Send { channel, value } => resumes_on_one_path(channel) + resumes_on_one_path(value),
- Expr::Block { statements, value } => {
- statements.iter().map(statement_resumes).sum::()
- + value.as_deref().map_or(0, resumes_on_one_path)
- }
- Expr::Perform {
- arguments,
- named_arguments,
- ..
- } => {
- each(arguments)
- + named_arguments
- .iter()
- .map(|n| resumes_on_one_path(&n.value))
- .sum::()
- }
- _ => 0,
- }
+ let mut total = 0;
+ AstNode::Expression(body).for_each_child(|child| {
+ total += match child {
+ AstNode::Statement(statement) => statement_resumes(statement),
+ AstNode::Expression(expression) => resumes_on_one_path(expression),
+ };
+ });
+ total
}
fn statement_resumes(stmt: &Stmt) -> u32 {
diff --git a/crates/osprey-ast/src/resume_tests.rs b/crates/osprey-ast/src/resume_tests.rs
index 216c9a72..2b59237a 100644
--- a/crates/osprey-ast/src/resume_tests.rs
+++ b/crates/osprey-ast/src/resume_tests.rs
@@ -33,18 +33,18 @@ fn field(value: Expr) -> crate::FieldAssignment {
value,
}
}
-fn assert_all_contain(cases: &[Expr]) {
- for e in cases {
- assert!(contains_resume(e), "resume not found in {e:?}");
- }
-}
-
-#[test]
-fn walks_literal_and_data_container_forms() {
- assert_all_contain(&[
- Expr::InterpolatedStr(vec![crate::InterpolatedPart::Expr(r())]),
+/// Every expression form that holds a sub-expression in a **container**
+/// position, each built around exactly one `resume()`. The containment walk and
+/// the one-path count must both cross all of them, so the list is stated once
+/// here instead of once per test, where the two copies could drift apart.
+fn container_forms() -> Vec {
+ vec![
+ Expr::InterpolatedStr(vec![
+ InterpolatedPart::Text("t".into()),
+ InterpolatedPart::Expr(r()),
+ ]),
Expr::List(vec![r()], None),
- Expr::Map(vec![crate::MapEntry {
+ Expr::Map(vec![MapEntry {
key: r(),
value: Expr::Integer(0),
}]),
@@ -71,16 +71,23 @@ fn walks_literal_and_data_container_forms() {
op: "-".into(),
operand: b(r()),
},
- ]);
+ ]
}
-#[test]
-fn walks_call_control_and_concurrency_forms() {
- assert_all_contain(&[
+/// The call, field and concurrency forms — still one sequential path each, so
+/// they too are crossed exactly once. Positional and named argument lists are
+/// both listed, because they are separate positions in the walk.
+fn call_forms() -> Vec {
+ vec![
+ Expr::Call {
+ function: b(Expr::Identifier("f".into())),
+ arguments: vec![r()],
+ named_arguments: Vec::new(),
+ },
Expr::Call {
function: b(Expr::Identifier("f".into())),
arguments: Vec::new(),
- named_arguments: vec![crate::NamedArgument {
+ named_arguments: vec![NamedArgument {
name: "a".into(),
value: r(),
}],
@@ -99,12 +106,6 @@ fn walks_call_control_and_concurrency_forms() {
target: b(Expr::Identifier("xs".into())),
index: b(r()),
},
- Expr::Lambda {
- parameters: Vec::new(),
- return_type: None,
- body: b(r()),
- position: None,
- },
Expr::Spawn(b(r())),
Expr::Await(b(r())),
Expr::Recv(b(r())),
@@ -113,16 +114,6 @@ fn walks_call_control_and_concurrency_forms() {
channel: b(Expr::Integer(0)),
value: b(r()),
},
- Expr::Match {
- value: b(r()),
- arms: Vec::new(),
- },
- Expr::Select {
- arms: vec![crate::MatchArm {
- pattern: crate::Pattern::Wildcard,
- body: r(),
- }],
- },
Expr::Perform {
effect: "E".into(),
operation: "o".into(),
@@ -130,7 +121,45 @@ fn walks_call_control_and_concurrency_forms() {
named_arguments: Vec::new(),
position: None,
},
- ]);
+ ]
+}
+
+/// The forms whose sub-expression is NOT on one sequential path: a lambda body
+/// runs later, and `match`/`select` arms are alternatives. The walk still finds
+/// a `resume` inside them — the one-path count deliberately does not cross them.
+fn branching_forms() -> Vec {
+ vec![
+ Expr::Lambda {
+ parameters: Vec::new(),
+ return_type: None,
+ body: b(r()),
+ position: None,
+ },
+ Expr::Match {
+ value: b(r()),
+ arms: Vec::new(),
+ },
+ Expr::Select {
+ arms: vec![arm(r())],
+ },
+ ]
+}
+
+fn assert_all_contain(cases: &[Expr]) {
+ for e in cases {
+ assert!(contains_resume(e), "resume not found in {e:?}");
+ }
+}
+
+#[test]
+fn walks_literal_and_data_container_forms() {
+ assert_all_contain(&container_forms());
+}
+
+#[test]
+fn walks_call_control_and_concurrency_forms() {
+ assert_all_contain(&call_forms());
+ assert_all_contain(&branching_forms());
}
#[test]
@@ -139,7 +168,7 @@ fn negatives_and_statement_walks() {
assert!(!contains_resume(&Expr::Integer(1)));
assert!(!contains_resume(&Expr::Yield(None)));
let import_only = Expr::Block {
- statements: vec![crate::Stmt::Import(crate::ImportDecl {
+ statements: vec![Stmt::Import(crate::ImportDecl {
target: crate::ImportTarget {
namespace: crate::NamespaceName::Identifier("m".into()),
path: crate::SymbolPath::default(),
@@ -153,7 +182,7 @@ fn negatives_and_statement_walks() {
assert!(!contains_resume(&import_only));
// Assignment statements inside blocks are walked.
let assign = Expr::Block {
- statements: vec![crate::Stmt::Assignment {
+ statements: vec![Stmt::Assignment {
name: "x".into(),
value: r(),
position: None,
@@ -180,7 +209,7 @@ fn finds_resume_through_blocks_but_not_nested_handlers() {
let nested = Expr::Handler {
stage: crate::Stage::Dynamic,
effect: "E".into(),
- arms: vec![crate::HandlerArm {
+ arms: vec![HandlerArm {
operation: "op".into(),
params: Vec::new(),
body: Expr::Resume(None),
@@ -250,87 +279,10 @@ fn select_branches_and_lambdas_and_nested_handlers() {
#[test]
fn every_sequential_position_is_crossed() {
- let cases = [
- Expr::InterpolatedStr(vec![
- InterpolatedPart::Text("t".into()),
- InterpolatedPart::Expr(r()),
- ]),
- Expr::List(vec![r()], None),
- Expr::Map(vec![MapEntry {
- key: r(),
- value: Expr::Integer(0),
- }]),
- Expr::Object(vec![field(r())]),
- Expr::TypeConstructor {
- name: "C".into(),
- type_args: Vec::new(),
- fields: vec![field(r())],
- },
- Expr::Update {
- record: "r".into(),
- fields: vec![field(r())],
- },
- Expr::Binary {
- op: "+".into(),
- left: b(Expr::Integer(1)),
- right: b(r()),
- },
- Expr::Pipe {
- left: b(r()),
- right: b(Expr::Identifier("f".into())),
- },
- Expr::Unary {
- op: "-".into(),
- operand: b(r()),
- },
- Expr::Call {
- function: b(Expr::Identifier("f".into())),
- arguments: vec![r()],
- named_arguments: Vec::new(),
- },
- Expr::MethodCall {
- target: b(r()),
- method: "m".into(),
- arguments: Vec::new(),
- named_arguments: Vec::new(),
- },
- Expr::FieldAccess {
- target: b(r()),
- field: "x".into(),
- },
- Expr::Index {
- target: b(Expr::Identifier("xs".into())),
- index: b(r()),
- },
- Expr::Spawn(b(r())),
- Expr::Await(b(r())),
- Expr::Recv(b(r())),
- Expr::Yield(Some(b(r()))),
- Expr::Send {
- channel: b(Expr::Integer(0)),
- value: b(r()),
- },
- Expr::Perform {
- effect: "E".into(),
- operation: "op".into(),
- arguments: vec![r()],
- named_arguments: Vec::new(),
- position: None,
- },
- ];
- for case in &cases {
+ for case in container_forms().iter().chain(&call_forms()) {
assert_eq!(resumes_on_one_path(case), 1, "not crossed: {case:?}");
}
- // Named arguments are crossed too, and a value-less `yield` crosses nothing.
- let named = Expr::Call {
- function: b(Expr::Identifier("f".into())),
- arguments: Vec::new(),
- named_arguments: vec![NamedArgument {
- name: "a".into(),
- value: r(),
- }],
- };
- assert_eq!(resumes_on_one_path(&named), 1);
+ // A value-less `yield` and a bare identifier cross nothing.
assert_eq!(resumes_on_one_path(&Expr::Yield(None)), 0);
assert_eq!(resumes_on_one_path(&Expr::Identifier("x".into())), 0);
}
diff --git a/crates/osprey-ast/src/stage.rs b/crates/osprey-ast/src/stage.rs
index a3ed12b9..4bf1b7d5 100644
--- a/crates/osprey-ast/src/stage.rs
+++ b/crates/osprey-ast/src/stage.rs
@@ -297,7 +297,7 @@ fn instantiation_errors(
if declared.stage == Stage::Dynamic {
return vec![StageError::new(
format!(
- "`{site} {effect}` names an instantiation of dynamic effect `{base}`; a dynamic handler is keyed by effect name at runtime, so instantiations share one key and cannot be told apart (docs/plans/0024-staged-effects.md). Declare `static effect {base}`, or write `{site} {base}` and let inference instantiate it"
+ "`{site} {effect}` names an instantiation of dynamic effect `{base}`; a written instantiation is the identity of a `static effect`, while a dynamic effect takes its instantiation from inference (docs/plans/0024-staged-effects.md). Declare `static effect {base}`, or write `{site} {base}` and let inference instantiate it"
),
position,
)];
diff --git a/crates/osprey-ast/src/visit.rs b/crates/osprey-ast/src/visit.rs
index 91f69f74..c22889b4 100644
--- a/crates/osprey-ast/src/visit.rs
+++ b/crates/osprey-ast/src/visit.rs
@@ -18,11 +18,26 @@ pub trait AstVisitor {
fn expression(&mut self, _expression: &Expr) {}
}
-enum Node<'a> {
+/// A borrowed syntax node whose immediate children can be visited in source order.
+#[derive(Debug, Clone, Copy)]
+pub enum AstNode<'a> {
+ /// A declaration or executable statement.
Statement(&'a Stmt),
+ /// An expression, including any statements in a block.
Expression(&'a Expr),
}
+impl AstNode<'_> {
+ /// Visit immediate children only. Callers retain control of recursion,
+ /// lexical scopes, and branch-specific combination rules.
+ pub fn for_each_child(self, mut visit: impl FnMut(Self)) {
+ match self {
+ Self::Statement(statement) => statement_children(statement, &mut visit),
+ Self::Expression(expression) => expression_children(expression, &mut visit),
+ }
+ }
+}
+
/// Walk every statement and expression in source order without recursive
/// visitor boilerplate. Implements the shared traversal required by
/// [LSP-HOVER-EFFECT-OPERATIONS] and [LSP-IMPLEMENTATIONS-EFFECT-HANDLERS].
@@ -31,134 +46,134 @@ pub fn walk_program(program: &Program, visitor: &mut impl AstVisitor) {
.statements
.iter()
.rev()
- .map(Node::Statement)
+ .map(AstNode::Statement)
.collect();
while let Some(node) = pending.pop() {
match node {
- Node::Statement(statement) => {
- visitor.statement(statement);
- push_statement_children(statement, &mut pending);
- }
- Node::Expression(expression) => {
- visitor.expression(expression);
- push_expression_children(expression, &mut pending);
- }
+ AstNode::Statement(statement) => visitor.statement(statement),
+ AstNode::Expression(expression) => visitor.expression(expression),
+ }
+ let start = pending.len();
+ node.for_each_child(|child| pending.push(child));
+ if let Some(children) = pending.get_mut(start..) {
+ children.reverse();
}
}
}
-fn push_statement_children<'a>(statement: &'a Stmt, pending: &mut Vec>) {
+fn statement_children<'a>(statement: &'a Stmt, visit: &mut impl FnMut(AstNode<'a>)) {
match statement {
Stmt::Namespace { body, .. } => {
- pending.extend(body.iter().rev().map(Node::Statement));
+ for statement in body {
+ visit(AstNode::Statement(statement));
+ }
+ }
+ Stmt::Module { body, .. } => {
+ for item in body {
+ visit(AstNode::Statement(&item.declaration));
+ }
}
- Stmt::Module { body, .. } => pending.extend(
- body.iter()
- .rev()
- .map(|item| Node::Statement(item.declaration.as_ref())),
- ),
Stmt::Let { value, .. }
| Stmt::Assignment { value, .. }
| Stmt::Expr { value, .. }
- | Stmt::Function { body: value, .. } => pending.push(Node::Expression(value)),
- Stmt::Type { variants, .. } => push_constraints(variants, pending),
- Stmt::Import(_) | Stmt::Extern { .. } | Stmt::Effect { .. } | Stmt::Signature { .. } => {}
- }
-}
-
-fn push_constraints<'a>(variants: &'a [crate::TypeVariant], pending: &mut Vec>) {
- for variant in variants.iter().rev() {
- for field in variant.fields.iter().rev() {
- if let Some(constraint) = &field.constraint {
- pending.push(Node::Expression(constraint));
+ | Stmt::Function { body: value, .. } => visit(AstNode::Expression(value)),
+ Stmt::Type { variants, .. } => {
+ for field in variants.iter().flat_map(|variant| &variant.fields) {
+ if let Some(constraint) = &field.constraint {
+ visit(AstNode::Expression(constraint));
+ }
}
}
+ Stmt::Import(_) | Stmt::Extern { .. } | Stmt::Effect { .. } | Stmt::Signature { .. } => {}
}
}
-fn push_expression_children<'a>(expression: &'a Expr, pending: &mut Vec>) {
+fn expression_children<'a>(expression: &'a Expr, visit: &mut impl FnMut(AstNode<'a>)) {
match expression {
Expr::InterpolatedStr(parts) => {
- for part in parts.iter().rev() {
+ for part in parts {
if let InterpolatedPart::Expr(value) = part {
- pending.push(Node::Expression(value));
+ visit(AstNode::Expression(value));
}
}
}
- Expr::List(values, _) => push_each(values, pending, |value| value),
+ Expr::List(values, _) => visit_each(values, visit, |value| value),
Expr::Map(entries) => {
- for entry in entries.iter().rev() {
- pending.push(Node::Expression(&entry.value));
- pending.push(Node::Expression(&entry.key));
+ for entry in entries {
+ visit(AstNode::Expression(&entry.key));
+ visit(AstNode::Expression(&entry.value));
}
}
Expr::Object(fields)
| Expr::TypeConstructor { fields, .. }
- | Expr::Update { fields, .. } => push_each(fields, pending, |field| &field.value),
+ | Expr::Update { fields, .. } => visit_each(fields, visit, |field| &field.value),
Expr::Binary { left, right, .. } | Expr::Pipe { left, right } => {
- pending.push(Node::Expression(right));
- pending.push(Node::Expression(left));
+ visit(AstNode::Expression(left));
+ visit(AstNode::Expression(right));
+ }
+ Expr::TypeApply {
+ function: operand, ..
}
- Expr::Unary { operand, .. }
+ | Expr::Unary { operand, .. }
| Expr::Spawn(operand)
| Expr::Await(operand)
- | Expr::Recv(operand) => pending.push(Node::Expression(operand)),
+ | Expr::Recv(operand)
+ | Expr::FieldAccess {
+ target: operand, ..
+ }
+ | Expr::Lambda { body: operand, .. } => visit(AstNode::Expression(operand)),
Expr::Call {
- function,
+ function: target,
arguments,
named_arguments,
- } => {
- push_each(named_arguments, pending, |argument| &argument.value);
- push_each(arguments, pending, |argument| argument);
- pending.push(Node::Expression(function));
}
- Expr::MethodCall {
+ | Expr::MethodCall {
target,
arguments,
named_arguments,
..
} => {
- push_each(named_arguments, pending, |argument| &argument.value);
- push_each(arguments, pending, |argument| argument);
- pending.push(Node::Expression(target));
+ visit(AstNode::Expression(target));
+ visit_each(arguments, visit, |argument| argument);
+ visit_each(named_arguments, visit, |argument| &argument.value);
}
- Expr::FieldAccess { target, .. } => pending.push(Node::Expression(target)),
Expr::Index { target, index } => {
- pending.push(Node::Expression(index));
- pending.push(Node::Expression(target));
+ visit(AstNode::Expression(target));
+ visit(AstNode::Expression(index));
}
- Expr::Lambda { body, .. } => pending.push(Node::Expression(body)),
Expr::Match { value, arms } => {
- push_each(arms, pending, |arm| &arm.body);
- pending.push(Node::Expression(value));
+ visit(AstNode::Expression(value));
+ visit_each(arms, visit, |arm| &arm.body);
}
Expr::Block { statements, value } => {
+ for statement in statements {
+ visit(AstNode::Statement(statement));
+ }
if let Some(value) = value {
- pending.push(Node::Expression(value));
+ visit(AstNode::Expression(value));
}
- pending.extend(statements.iter().rev().map(Node::Statement));
}
Expr::Yield(value) | Expr::Resume(value) => {
if let Some(value) = value {
- pending.push(Node::Expression(value));
+ visit(AstNode::Expression(value));
}
}
Expr::Send { channel, value } => {
- pending.push(Node::Expression(value));
- pending.push(Node::Expression(channel));
+ visit(AstNode::Expression(channel));
+ visit(AstNode::Expression(value));
}
- Expr::Select { arms } => push_each(arms, pending, |arm| &arm.body),
+ Expr::Select { arms } => visit_each(arms, visit, |arm| &arm.body),
Expr::Perform {
arguments,
named_arguments,
..
} => {
- push_each(named_arguments, pending, |argument| &argument.value);
- push_each(arguments, pending, |argument| argument);
+ visit_each(arguments, visit, |argument| argument);
+ visit_each(named_arguments, visit, |argument| &argument.value);
}
Expr::Handler { arms, body, .. } => {
- pending.push(Node::Expression(body));
- push_each(arms, pending, |arm| &arm.body);
+ visit_each(arms, visit, |arm| &arm.body);
+ visit(AstNode::Expression(body));
}
Expr::Integer(_)
| Expr::Float(_)
@@ -169,17 +184,14 @@ fn push_expression_children<'a>(expression: &'a Expr, pending: &mut Vec
}
}
-fn push_each<'a, T>(
+fn visit_each<'a, T>(
items: &'a [T],
- pending: &mut Vec>,
+ visit: &mut impl FnMut(AstNode<'a>),
expression: impl Fn(&'a T) -> &'a Expr,
) {
- pending.extend(
- items
- .iter()
- .rev()
- .map(|item| Node::Expression(expression(item))),
- );
+ for item in items {
+ visit(AstNode::Expression(expression(item)));
+ }
}
/// Recurse into every element of `items`, projecting each to its
diff --git a/crates/osprey-cli/src/android.rs b/crates/osprey-cli/src/android.rs
index 4263e985..eb2b95dd 100644
--- a/crates/osprey-cli/src/android.rs
+++ b/crates/osprey-cli/src/android.rs
@@ -43,18 +43,11 @@ impl Target {
/// Unsupported options fail even when only checking a program. [ANDROID-TARGET-OPTIONS]
pub(crate) fn validate(cli: &Cli) -> Result<(), ExitCode> {
- if let Some(code) = crate::reject_debug_cross_target(cli) {
- return Err(code);
- }
- if cli.memory != "default" {
- return Err(fail(
- "Android supports --memory=default; other runtime archives are not available",
- ));
- }
- if cli.mode == "--run" {
- return Err(fail("Android produces an app-logic library; use --compile and call it from an Android host (see examples/mobile/android/)"));
- }
- Ok(())
+ crate::reject_cross_target_options(
+ cli,
+ "Android",
+ Some("an Android host (see examples/mobile/android/)"),
+ )
}
pub(crate) fn source(
@@ -73,11 +66,7 @@ pub(crate) fn build(
out: &Path,
target: Target,
) -> Result<(), ExitCode> {
- if out.extension().and_then(|e| e.to_str()) != Some("a") {
- return Err(fail(
- "Android output must end in .a; a matching .h is generated beside it",
- ));
- }
+ crate::toolchain::validate_archive_output(out, "Android").map_err(|e| fail(&e))?;
let (ir, header) = source(program, path, target).map_err(|e| fail(&e))?;
let bin = ndk_bin().map_err(|e| fail(&e))?;
let runtime = find_runtime_lib(target.runtime()).ok_or_else(|| {
diff --git a/crates/osprey-cli/src/docs.rs b/crates/osprey-cli/src/docs.rs
index 0af80b43..66f07800 100644
--- a/crates/osprey-cli/src/docs.rs
+++ b/crates/osprey-cli/src/docs.rs
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
/// Entry point for the `--docs` mode. Reads `--docs-dir ` from `args`.
-pub fn run(args: &[String]) -> ExitCode {
+pub(crate) fn run(args: &[String]) -> ExitCode {
let dir = if let Some(dir) = docs_dir(args) {
PathBuf::from(dir)
} else {
diff --git a/crates/osprey-cli/src/fmt.rs b/crates/osprey-cli/src/fmt.rs
index b4aae630..eeb52568 100644
--- a/crates/osprey-cli/src/fmt.rs
+++ b/crates/osprey-cli/src/fmt.rs
@@ -35,7 +35,7 @@ struct Tally {
}
/// Entry point for `osprey fmt`; `args` excludes the `fmt` subcommand word.
-pub fn run(args: &[String]) -> ExitCode {
+pub(crate) fn run(args: &[String]) -> ExitCode {
let parsed = match parse(args) {
Ok(parsed) => parsed,
Err(message) => {
diff --git a/crates/osprey-cli/src/ios.rs b/crates/osprey-cli/src/ios.rs
index 415fef4f..be3c2370 100644
--- a/crates/osprey-cli/src/ios.rs
+++ b/crates/osprey-cli/src/ios.rs
@@ -60,18 +60,7 @@ impl Target {
/// Refuse native-only settings before invoking the cross compiler.
/// Implements [IOS-TARGET-OPTIONS].
pub(crate) fn validate(cli: &Cli) -> Result<(), ExitCode> {
- if let Some(code) = crate::reject_debug_cross_target(cli) {
- return Err(code);
- }
- if cli.memory != "default" {
- return Err(fail(
- "iOS supports --memory=default; other runtime archives are not available",
- ));
- }
- if cli.mode == "--run" {
- return Err(fail("iOS produces an app-logic library; use --compile and call it from a Swift host (see examples/ios/)"));
- }
- Ok(())
+ crate::reject_cross_target_options(cli, "iOS", Some("a Swift host (see examples/ios/)"))
}
/// Emit the app's C ABI and LLVM implementation before any toolchain work.
@@ -116,10 +105,7 @@ pub(crate) fn build(
}
fn validate_output(out: &Path) -> Result<(), String> {
- if out.extension().and_then(|e| e.to_str()) != Some("a") {
- return Err("iOS output must end in .a; a matching .h is generated beside it".to_string());
- }
- Ok(())
+ crate::toolchain::validate_archive_output(out, "iOS")
}
fn sdk_path(target: Target) -> Result {
diff --git a/crates/osprey-cli/src/ios_abi_tests.rs b/crates/osprey-cli/src/ios_abi_tests.rs
index c314fdf2..e1e8cb05 100644
--- a/crates/osprey-cli/src/ios_abi_tests.rs
+++ b/crates/osprey-cli/src/ios_abi_tests.rs
@@ -1,4 +1,5 @@
use super::*;
+use crate::testkit::shows;
use std::path::{Path, PathBuf};
const SOURCE: &str = "extern fn host_log(message: string) -> int\n\
@@ -67,13 +68,23 @@ fn thunks_rename_the_entry_and_forward_with_the_c_bool_convention() {
let out = with_host_abi(&ir, &abi).expect("thunked");
assert!(out.contains("define i32 @osprey_main() "), "entry renamed");
assert!(!out.contains("@main("), "no `main` symbol survives");
- assert!(out.contains("define zeroext i1 @osprey_flag(i1 zeroext %p0) {"));
- assert!(out.contains(" %r = call i1 @flag(i1 %p0)\n ret i1 %r"));
+ shows(
+ &out,
+ &[
+ "define zeroext i1 @osprey_flag(i1 zeroext %p0) {",
+ " %r = call i1 @flag(i1 %p0)\n ret i1 %r",
+ ],
+ );
assert!(out
.contains("define void @osprey_shout(i8* %p0) {\n call i64 @shout(i8* %p0)\n ret void"));
- assert!(out.contains("define i8* @osprey_greet(i8* %p0) {"));
- assert!(out.contains("define double @osprey_scaled(i64 %p0) {"));
- assert!(out.contains("define i64 @osprey_total(i64 %p0, i64 %p1) {"));
+ shows(
+ &out,
+ &[
+ "define i8* @osprey_greet(i8* %p0) {",
+ "define double @osprey_scaled(i64 %p0) {",
+ "define i64 @osprey_total(i64 %p0, i64 %p1) {",
+ ],
+ );
assert!(with_host_abi("; no entry here", &abi).is_err());
}
@@ -277,8 +288,10 @@ fn unsupported_extern_signatures_report_the_c_boundary_restriction() {
fn renaming_imports_preserves_strings_and_similarly_prefixed_symbols() {
let (abi, ir) = abi_of("extern fn host_flag(b: bool) -> bool\nfn host_flag_more(b) = host_flag(b)\nfn text() = \"@host_flag( @main(\"\n");
let out = with_host_abi(&ir, &abi).expect("adapted");
- assert!(out.contains("c\"@host_flag( @main(\\00\""), "{out}");
- assert!(out.contains("define i1 @host_flag_more("), "{out}");
+ shows(
+ &out,
+ &["c\"@host_flag( @main(\\00\"", "define i1 @host_flag_more("],
+ );
}
#[test]
diff --git a/crates/osprey-cli/src/main.rs b/crates/osprey-cli/src/main.rs
index 31f09be7..2ca136a5 100644
--- a/crates/osprey-cli/src/main.rs
+++ b/crates/osprey-cli/src/main.rs
@@ -29,7 +29,11 @@ mod target_capabilities;
mod test_cmd;
mod test_coverage;
mod test_skips;
+#[cfg(test)]
+#[path = "../../testkit.rs"]
+mod testkit;
mod toolchain;
+mod warnings;
mod wasm;
use osprey_syntax::Flavor;
@@ -381,7 +385,7 @@ pub(crate) fn load_input(cli: &Cli) -> Result {
eprintln!("error: --flavor applies to single files; projects select flavor per source");
return Err(ExitCode::from(2));
}
- return project::CompilationInput::load_project(path).map_err(|errors| {
+ return CompilationInput::load_project(path).map_err(|errors| {
print_project_errors(&errors, path);
ExitCode::FAILURE
});
@@ -464,11 +468,16 @@ fn dispatch(cli: &Cli, input: &CompilationInput) -> ExitCode {
/// Type-check `program`, print every error in `file:line:col: message` form,
/// and return how many there were. The shared gate for every compiling mode.
+///
+/// Warnings print alongside the errors and are deliberately not counted: a
+/// redundant annotation is a defect to delete, never a reason to fail a build
+/// ([TYPE-ANNOTATION-REDUNDANT]).
pub(crate) fn report_type_errors(input: &CompilationInput) -> usize {
let errors = osprey_types::check_program(input.program());
for e in &errors {
eprintln!("{}", input.diagnostic(e.position, &e.message));
}
+ warnings::report(input, &osprey_types::redundant_annotations(input.program()));
errors.len()
}
@@ -494,34 +503,37 @@ fn target_error(cli: &Cli, input: &CompilationInput) -> Option {
return Some(ExitCode::FAILURE);
}
if cli.target == "wasm32" {
- if let Some(code) = reject_debug_cross_target(cli) {
+ if let Err(code) = reject_cross_target_options(cli, "wasm32", None) {
return Some(code);
}
- if cli.memory != "default" {
- return Some(toolchain::fail(
- "wasm32 supports --memory=default; other runtime archives are not available",
- ));
- }
}
if let Some(target) = android::Target::parse(&cli.target) {
- if let Err(code) = android::validate(cli) {
- return Some(code);
- }
- if cli.mode == "--check" {
- return android::source(input.program(), input.debug_path(), target)
- .err()
- .map(|error| toolchain::fail(&error));
- }
+ return app_target_error(&cli.mode, android::validate(cli), || {
+ android::source(input.program(), input.debug_path(), target)
+ });
}
if let Some(target) = ios::Target::parse(&cli.target) {
- if let Err(code) = ios::validate(cli) {
- return Some(code);
- }
- if cli.mode == "--check" {
- return ios::source(input.program(), input.debug_path(), target)
- .err()
- .map(|error| toolchain::fail(&error));
- }
+ return app_target_error(&cli.mode, ios::validate(cli), || {
+ ios::source(input.program(), input.debug_path(), target)
+ });
+ }
+ None
+}
+
+/// The app-library targets (iOS, Android) share one order: reject the
+/// unsupported options, then — for `--check` alone — generate the C ABI and
+/// report its failure as a diagnostic. `source` stays lazy so no other mode
+/// pays for ABI generation, and so option errors always win the race.
+fn app_target_error(
+ mode: &str,
+ validation: Result<(), ExitCode>,
+ source: impl FnOnce() -> Result<(String, String), String>,
+) -> Option {
+ if let Err(code) = validation {
+ return Some(code);
+ }
+ if mode == "--check" {
+ return source().err().map(|error| toolchain::fail(&error));
}
None
}
@@ -551,17 +563,43 @@ fn reject_debug_cross_target(cli: &Cli) -> Option {
None
}
+/// Every cross target refuses the native-only build flags and the non-default
+/// runtime archives. A target that produces a LIBRARY rather than a runnable
+/// image also refuses `--run`, and names the host that calls it in `host_hint`;
+/// `None` marks a target with a `--run` form of its own.
+/// Implements [IOS-TARGET-OPTIONS] and [ANDROID-TARGET-OPTIONS].
+fn reject_cross_target_options(
+ cli: &Cli,
+ platform: &str,
+ host_hint: Option<&str>,
+) -> Result<(), ExitCode> {
+ if let Some(code) = reject_debug_cross_target(cli) {
+ return Err(code);
+ }
+ if cli.memory != "default" {
+ return Err(toolchain::fail(&format!(
+ "{platform} supports --memory=default; other runtime archives are not available"
+ )));
+ }
+ match host_hint {
+ Some(hint) if cli.mode == "--run" => Err(toolchain::fail(&format!(
+ "{platform} produces an app-logic library; use --compile and call it from {hint}"
+ ))),
+ _ => Ok(()),
+ }
+}
+
/// The native build kind this invocation asked for (`--debug` and `--profile`
/// are mutually exclusive; `parse_args` enforces that).
fn build_kind(cli: &Cli) -> osprey_debug::BuildKind {
if cli.debug {
- osprey_debug::BuildKind::Debug
+ osprey_debug::DebugBuild::ON.kind()
} else if cli.profile {
osprey_debug::BuildKind::Profile
} else if std::env::var_os(TEST_COVERAGE_BUILD_ENV).is_some() {
osprey_debug::BuildKind::Coverage
} else {
- osprey_debug::BuildKind::Release
+ osprey_debug::DebugBuild::OFF.kind()
}
}
diff --git a/crates/osprey-cli/src/project.rs b/crates/osprey-cli/src/project.rs
index b101f0d1..0bed56c6 100644
--- a/crates/osprey-cli/src/project.rs
+++ b/crates/osprey-cli/src/project.rs
@@ -128,24 +128,24 @@ impl CompilationInput {
&self.debug_path
}
+ /// Resolve a flattened checker position to the physical file it was
+ /// written in, with that file's own line number.
+ pub(crate) fn location(&self, position: Position) -> (String, u32, u32) {
+ if let CompilationUnit::Project(project) = &self.unit {
+ if let Some((source, line)) = project.source_at_line(position.line) {
+ return (source.path.display().to_string(), line, position.column);
+ }
+ }
+ (self.display_path.clone(), position.line, position.column)
+ }
+
/// Format a flattened checker location using its physical source file.
pub(crate) fn diagnostic(&self, position: Option, message: &str) -> String {
let Some(position) = position else {
return format!("{}: {message}", self.display_path);
};
- if let CompilationUnit::Project(project) = &self.unit {
- if let Some((source, line)) = project.source_at_line(position.line) {
- return format!(
- "{}:{line}:{}: {message}",
- source.path.display(),
- position.column
- );
- }
- }
- format!(
- "{}:{}:{}: {message}",
- self.display_path, position.line, position.column
- )
+ let (path, line, column) = self.location(position);
+ format!("{path}:{line}:{column}: {message}")
}
/// Render symbols with source-level qualified names where assembly mangled them.
diff --git a/crates/osprey-cli/src/toolchain.rs b/crates/osprey-cli/src/toolchain.rs
index 6730cee1..cfbb82c5 100644
--- a/crates/osprey-cli/src/toolchain.rs
+++ b/crates/osprey-cli/src/toolchain.rs
@@ -26,6 +26,17 @@ pub(crate) fn fail(msg: &str) -> ExitCode {
ExitCode::FAILURE
}
+/// An app-library target publishes a static archive plus a matching C header,
+/// so `-o` must name the archive. `platform` opens the diagnostic.
+pub(crate) fn validate_archive_output(out: &Path, platform: &str) -> Result<(), String> {
+ if out.extension().and_then(|e| e.to_str()) != Some("a") {
+ return Err(format!(
+ "{platform} output must end in .a; a matching .h is generated beside it"
+ ));
+ }
+ Ok(())
+}
+
pub(crate) fn write(path: &Path, contents: &str) -> Result<(), ExitCode> {
std::fs::write(path, contents)
.map_err(|e| fail(&format!("cannot write {}: {e}", path.display())))
diff --git a/crates/osprey-cli/src/warnings.rs b/crates/osprey-cli/src/warnings.rs
new file mode 100644
index 00000000..b95b4d33
--- /dev/null
+++ b/crates/osprey-cli/src/warnings.rs
@@ -0,0 +1,175 @@
+//! Terminal rendering for compiler warnings.
+//!
+//! A warning is advice, and a hundred of them are only useful if a reader can
+//! see the shape of the advice at a glance. Warnings are therefore grouped by
+//! the file they were written in, listed under an aligned `line:column`
+//! gutter, and closed with the count and the rules that raised them, so the
+//! summary answers "how much of this is there, and what turned it on" without
+//! scrolling back.
+//!
+//! Rendering is a pure function over the diagnostics so the exact text is
+//! testable; printing is the only side effect.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use osprey_types::TypeWarning;
+
+use crate::project::CompilationInput;
+
+/// One file's warnings: the `line:column` gutter and the message, in order.
+type Listing = Vec<(String, String)>;
+
+/// Print `warnings` to stderr. A clean build prints nothing.
+pub(crate) fn report(input: &CompilationInput, warnings: &[TypeWarning]) {
+ if let Some(text) = render(input, warnings) {
+ eprintln!("{text}");
+ }
+}
+
+/// The whole warning block, or `None` when there is nothing to say.
+pub(crate) fn render(input: &CompilationInput, warnings: &[TypeWarning]) -> Option {
+ if warnings.is_empty() {
+ return None;
+ }
+ let blocks: Vec = group(input, warnings)
+ .into_iter()
+ .map(|(file, listing)| block(&file, &listing))
+ .collect();
+ Some(format!("{}\n{}", blocks.join("\n"), summary(warnings)))
+}
+
+/// One file's heading and its aligned listing.
+fn block(file: &str, listing: &Listing) -> String {
+ let width = listing
+ .iter()
+ .map(|(gutter, _)| gutter.len())
+ .max()
+ .unwrap_or_default();
+ let lines = listing
+ .iter()
+ .map(|(gutter, message)| format!(" {gutter:>width$} warning: {message}"))
+ .collect::>()
+ .join("\n");
+ format!("\n{file}\n{lines}\n")
+}
+
+/// Group warnings by file, keeping each file's own source order.
+fn group(input: &CompilationInput, warnings: &[TypeWarning]) -> BTreeMap {
+ let mut grouped: BTreeMap = BTreeMap::new();
+ for warning in warnings {
+ let (file, gutter) = locate(input, warning);
+ grouped
+ .entry(file)
+ .or_default()
+ .push((gutter, warning.message.clone()));
+ }
+ grouped
+}
+
+/// A warning's file and `line:column` gutter, falling back to the unit's own
+/// label for a warning inference could not anchor to a position.
+fn locate(input: &CompilationInput, warning: &TypeWarning) -> (String, String) {
+ match warning.position {
+ Some(position) => {
+ let (file, line, column) = input.location(position);
+ (file, format!("{line}:{column}"))
+ }
+ None => (input.display_path().to_string(), String::from("-")),
+ }
+}
+
+/// The closing line: how many warnings there are and which rules raised them.
+fn summary(warnings: &[TypeWarning]) -> String {
+ let rules: BTreeSet<&str> = warnings.iter().map(|w| w.rule).collect();
+ let plural = if warnings.len() == 1 { "" } else { "s" };
+ format!(
+ "{} warning{plural} ({})",
+ warnings.len(),
+ rules.into_iter().collect::>().join(", ")
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::render;
+ use crate::project::CompilationInput;
+ use osprey_ast::Position;
+ use osprey_types::{TypeWarning, REDUNDANT_ANNOTATION};
+
+ /// A single-file unit whose warnings resolve against `main.osp`.
+ fn unit() -> CompilationInput {
+ let program = osprey_syntax::parse_program("let answer = 42\n").program;
+ CompilationInput::script("main.osp", String::new(), program)
+ }
+
+ fn warning(line: u32, column: u32, message: &str) -> TypeWarning {
+ TypeWarning {
+ message: message.to_string(),
+ position: Some(Position { line, column }),
+ rule: REDUNDANT_ANNOTATION,
+ }
+ }
+
+ #[test]
+ fn a_clean_program_renders_nothing_at_all() {
+ assert_eq!(render(&unit(), &[]), None);
+ }
+
+ #[test]
+ fn one_warning_renders_its_file_gutter_message_and_singular_summary() {
+ let raised = [warning(3, 4, "redundant return type annotation on `greet`")];
+ assert_eq!(
+ render(&unit(), &raised),
+ Some(
+ "\nmain.osp\n 3:4 warning: redundant return type annotation on `greet`\n\n1 warning (redundant-annotation)"
+ .to_string()
+ )
+ );
+ }
+
+ #[test]
+ fn gutters_are_right_aligned_so_messages_line_up() {
+ let raised = [warning(9, 1, "first"), warning(100, 12, "second")];
+ assert_eq!(
+ render(&unit(), &raised),
+ Some(
+ "\nmain.osp\n 9:1 warning: first\n 100:12 warning: second\n\n2 warnings (redundant-annotation)"
+ .to_string()
+ )
+ );
+ }
+
+ #[test]
+ fn a_positionless_warning_still_lists_under_its_unit() {
+ let raised = [TypeWarning {
+ message: String::from("nowhere in particular"),
+ position: None,
+ rule: REDUNDANT_ANNOTATION,
+ }];
+ assert_eq!(
+ render(&unit(), &raised),
+ Some(
+ "\nmain.osp\n - warning: nowhere in particular\n\n1 warning (redundant-annotation)"
+ .to_string()
+ )
+ );
+ }
+
+ #[test]
+ fn the_summary_lists_every_rule_that_fired_once_each() {
+ let raised = [
+ warning(1, 0, "a"),
+ warning(2, 0, "b"),
+ TypeWarning {
+ message: String::from("c"),
+ position: Some(Position { line: 3, column: 0 }),
+ rule: "some-other-rule",
+ },
+ ];
+ let text = render(&unit(), &raised).unwrap_or_default();
+ assert!(
+ text.ends_with("\n3 warnings (redundant-annotation, some-other-rule)"),
+ "{text}"
+ );
+ }
+}
diff --git a/crates/osprey-cli/src/wasm.rs b/crates/osprey-cli/src/wasm.rs
index d21fc9ad..0eacedbf 100644
--- a/crates/osprey-cli/src/wasm.rs
+++ b/crates/osprey-cli/src/wasm.rs
@@ -284,6 +284,7 @@ fn run_host(wasm: &Path) -> ExitCode {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::testkit::shows;
/// Serializes tests that read/write process-global toolchain env vars
/// (`OSPREY_WASM_*`, `*_SYSROOT`) so they neither race each other nor the
@@ -336,9 +337,14 @@ mod tests {
fn entry_thunk_wraps_main_for_the_wasi_start_path() {
// [WASM-ENTRY]
let out = with_entry_thunk("define i32 @main() {\n ret i32 0\n}\n");
- assert!(out.contains("define i32 @main()"), "original main kept");
- assert!(out.contains("define i32 @__main_void()"), "thunk added");
- assert!(out.contains("call i32 @main()"), "thunk calls main");
+ shows(
+ &out,
+ &[
+ "define i32 @main()",
+ "define i32 @__main_void()",
+ "call i32 @main()",
+ ],
+ );
}
#[test]
@@ -346,9 +352,14 @@ mod tests {
// [WASM-WEB-ABI]
let mangled = format!("__osp_3x617070{WEB_DISPATCH_MANGLED_SUFFIX}");
let out = with_web_dispatch_thunk("; module", Some(&mangled));
- assert!(out.contains("define i64 @osprey_web_dispatch(i8* %message)"));
- assert!(out.contains(&format!("call i64 @{mangled}(i8* %message)")));
- assert!(out.contains("ret i64 %r"));
+ shows(
+ &out,
+ &[
+ "define i64 @osprey_web_dispatch(i8* %message)",
+ &format!("call i64 @{mangled}(i8* %message)"),
+ "ret i64 %r",
+ ],
+ );
let unchanged = with_web_dispatch_thunk("; module", Some(WEB_DISPATCH));
assert_eq!(unchanged, "; module", "source spelling needs no thunk");
diff --git a/crates/osprey-cli/tests/cli_e2e.rs b/crates/osprey-cli/tests/cli_e2e.rs
index 49a88d9f..151c760a 100644
--- a/crates/osprey-cli/tests/cli_e2e.rs
+++ b/crates/osprey-cli/tests/cli_e2e.rs
@@ -134,16 +134,27 @@ fn run_file_cc(path: &Path, mode: &str, cc: &str) -> Out {
finish(cmd)
}
-/// Type-clean but codegen-rejected: a still-generic lambda used as a bare
-/// VALUE has no ABI to fix and no call site to specialise against
-/// ([TYPE-GENERICS-FN]). It passes the type gate, so every compiling mode
-/// reaches codegen and fails there — exercising the `Err` arms
-/// `compile_program` feeds.
+/// Type-clean but codegen-rejected: an FFI callback slot is a raw C code
+/// pointer, so a capturing lambda has nowhere to carry its environment
+/// ([FFI-CALLBACKS]). It passes the type gate, so every compiling mode reaches
+/// codegen and fails there — exercising the `Err` arms `compile_program` feeds.
///
-/// This used to bind the value first (`let f = mk(1)` then `f(0)`). That shape
-/// now COMPILES: the returned lambda is inlined at each call site of the
-/// binding, so it no longer reaches a codegen error and could not exercise
-/// these arms.
+/// `(x + base) ?: 0` discharges the arithmetic `Result`; without it the lambda
+/// is `(int) -> Result` and the type gate rejects the call
+/// before codegen sees the capture.
+const CODEGEN_REJECTED: &str = concat!(
+ "extern fn registerCallback(cb: fn(int) -> int) -> int\n",
+ "let base = 10\n",
+ "let r = registerCallback(fn(x) => (x + base) ?: 0)\n",
+ "print(\"${r}\")\n",
+);
+
+/// A still-generic lambda used as a bare VALUE: no ABI to fix and no call site
+/// to specialise against ([TYPE-GENERICS-FN]).
+///
+/// This drove the two codegen-error tests until the checker learned to reject
+/// it, which is a strictly better place to catch it. It stays here to pin
+/// WHERE it is rejected, so the move cannot happen again unnoticed.
const GENERIC_AS_VALUE: &str = "fn mk(x: T) = |y| => x\nprint(\"${mk(1)}\")\n";
/// Explicit effect resume must run the rest of the handled computation and then
@@ -727,7 +738,7 @@ fn quiet_suppresses_the_ok_line() {
#[test]
fn llvm_reports_a_codegen_error() {
- let prog = temp_osp("cgllvm", GENERIC_AS_VALUE);
+ let prog = temp_osp("cgllvm", CODEGEN_REJECTED);
let o = run_file(&prog, &["--llvm"]);
assert_ne!(o.code, Some(0));
assert!(o.stderr.contains("codegen"), "{}", o.stderr);
@@ -735,12 +746,30 @@ fn llvm_reports_a_codegen_error() {
#[test]
fn run_reports_a_codegen_error() {
- let prog = temp_osp("cgrun", GENERIC_AS_VALUE);
+ let prog = temp_osp("cgrun", CODEGEN_REJECTED);
let o = run_file(&prog, &["--run"]);
assert_ne!(o.code, Some(0));
assert!(o.stderr.contains("codegen"), "{}", o.stderr);
}
+#[test]
+fn a_generic_closure_value_is_rejected_by_the_type_gate() {
+ // The two tests above assert a CODEGEN failure, so they go quiet the moment
+ // their input starts being rejected earlier — which is exactly what happened
+ // to this program. Pinning the checker's message here means a future move of
+ // the gate fails a test that names the gate, instead of silently draining
+ // the codegen arms of coverage.
+ let prog = temp_osp("genval", GENERIC_AS_VALUE);
+ let o = run_file(&prog, &["--llvm"]);
+ assert_ne!(o.code, Some(0));
+ assert!(
+ o.stderr
+ .contains("a closure value with a still-generic type cannot be interpolated"),
+ "{}",
+ o.stderr
+ );
+}
+
#[test]
fn compile_reports_a_failing_c_compiler() {
// `false` runs and exits non-zero -> the "cc failed to compile" branch.
@@ -1417,3 +1446,86 @@ fn error_result_assertions_render_the_error() {
);
assert!(o.stdout.contains("not ok 1 - div"), "{}", o.stdout);
}
+
+/// The whole call-site type-application pipeline — parse, check, lower, emit,
+/// link, run — for the shape that has no other spelling: a binder appearing in
+/// no parameter position. [TYPE-GENERICS-APPLY]
+#[test]
+fn a_written_type_argument_pins_an_instantiation_end_to_end() {
+ let prog = temp_osp(
+ "turbofish_run",
+ "fn identity(x: T) -> T = x\n\
+ fn emptyOf() -> List = []\n\
+ fn pickOf(first: T, second: U) -> T = first\n\
+ let n = identity(5)\n\
+ let s = identity(\"os\")\n\
+ let nested = length(identity>([1, 2]))\n\
+ let empty = length(emptyOf())\n\
+ let kept = pickOf(7, \"seven\")\n\
+ print(\"n=${n} s=${s} nested=${nested} empty=${empty} kept=${kept}\")\n",
+ );
+ let o = run_file(&prog, &["--run"]);
+ assert_eq!(o.code, Some(0), "stderr={}", o.stderr);
+ assert_eq!(o.stdout, "n=5 s=os nested=2 empty=0 kept=7\n");
+}
+
+/// The ML twin of the same program prints the same bytes ([FLAVOR-IR-EQUIV]).
+#[test]
+fn the_ml_written_type_argument_prints_the_same_bytes() {
+ let path = std::env::temp_dir().join("osprey_cli_e2e_turbofish_run_ml.ospml");
+ let _ = std::fs::write(
+ &path,
+ "identity : T -> T\n\
+ identity x = x\n\
+ emptyOf : Unit -> List\n\
+ emptyOf () = []\n\
+ pickOf : (T, U) -> T\n\
+ pickOf (first, second) = first\n\
+ n = identity 5\n\
+ s = identity \"os\"\n\
+ nested = length (identity> [1, 2])\n\
+ empty = length (emptyOf ())\n\
+ kept = pickOf (7, \"seven\")\n\
+ print \"n=${n} s=${s} nested=${nested} empty=${empty} kept=${kept}\"\n",
+ );
+ let o = run_file(&path, &["--run"]);
+ assert_eq!(o.code, Some(0), "stderr={}", o.stderr);
+ assert_eq!(o.stdout, "n=5 s=os nested=2 empty=0 kept=7\n");
+}
+
+/// A written list that misses the declared binder count is rejected before
+/// anything is emitted, naming both counts. [TYPE-GENERICS-APPLY]
+#[test]
+fn a_written_type_argument_count_mismatch_is_rejected_by_the_cli() {
+ let prog = temp_osp(
+ "turbofish_arity",
+ "fn identity(x: T) -> T = x\n\
+ print(\"${identity(5)}\")\n",
+ );
+ let o = run_file(&prog, &["--check"]);
+ assert_ne!(o.code, Some(0), "stdout={}", o.stdout);
+ assert!(
+ o.stderr
+ .contains("function `identity` takes 1 type argument(s), got 2"),
+ "stderr={}",
+ o.stderr
+ );
+}
+
+/// A written argument contradicting the value argument is a type error, not a
+/// silently ignored annotation. [TYPE-GENERICS-APPLY]
+#[test]
+fn a_contradicting_written_type_argument_is_rejected_by_the_cli() {
+ let prog = temp_osp(
+ "turbofish_contradiction",
+ "fn identity(x: T) -> T = x\n\
+ print(\"${identity(\\\"text\\\")}\")\n",
+ );
+ let o = run_file(&prog, &["--check"]);
+ assert_ne!(o.code, Some(0), "stdout={}", o.stdout);
+ assert!(
+ o.stderr.contains("cannot unify int with string"),
+ "stderr={}",
+ o.stderr
+ );
+}
diff --git a/crates/osprey-cli/tests/common/mod.rs b/crates/osprey-cli/tests/common/mod.rs
index 02280424..67ae8c5d 100644
--- a/crates/osprey-cli/tests/common/mod.rs
+++ b/crates/osprey-cli/tests/common/mod.rs
@@ -12,13 +12,13 @@ use std::path::{Path, PathBuf};
///
/// Left un-canonicalized on purpose: there is no fallible call to unwrap, and
/// the `..` components resolve the same way for every consumer here.
-pub fn repo_root() -> PathBuf {
+pub(crate) fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..")
}
/// Every file with extension `ext` under `dir`, recursively, sorted so a
/// failure names the same program on every machine.
-pub fn sources(dir: &Path, ext: &str) -> Vec {
+pub(crate) fn sources(dir: &Path, ext: &str) -> Vec {
let mut out = Vec::new();
collect(dir, ext, &mut out);
out.sort();
@@ -47,7 +47,7 @@ fn collect(dir: &Path, ext: &str, out: &mut Vec) {
/// an instantiation that was never emitted collapsed onto one that was and the
/// gate reported a clean module — the exact dangling reference it exists to
/// catch ([`crate::monofn::specialize_callback`]).
-pub fn symbol_at(rest: &str) -> Option {
+pub(crate) fn symbol_at(rest: &str) -> Option {
let name: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '.' || *c == '$')
@@ -57,7 +57,7 @@ pub fn symbol_at(rest: &str) -> Option {
}
/// Every `@symbol` the module BINDS — defined, declared or a global.
-pub fn bound_symbols(ir: &str) -> BTreeSet {
+pub(crate) fn bound_symbols(ir: &str) -> BTreeSet {
let mut bound = BTreeSet::new();
for line in ir.lines() {
let trimmed = line.trim_start();
@@ -83,7 +83,7 @@ pub fn bound_symbols(ir: &str) -> BTreeSet {
/// global initializer is a use like any other (`@table = global i8* @missing`),
/// and skipping the whole line let exactly that reference through: the one form
/// where a dangling symbol is written on the same line as a definition.
-pub fn undefined_symbols(ir: &str) -> BTreeSet {
+pub(crate) fn undefined_symbols(ir: &str) -> BTreeSet {
let bound = bound_symbols(ir);
let mut missing = BTreeSet::new();
for line in ir.lines() {
diff --git a/crates/osprey-cli/tests/common/staging.rs b/crates/osprey-cli/tests/common/staging.rs
index dc134847..f425a1e8 100644
--- a/crates/osprey-cli/tests/common/staging.rs
+++ b/crates/osprey-cli/tests/common/staging.rs
@@ -9,7 +9,7 @@ use osprey_syntax::{parse_program_with_flavor, Flavor};
/// Parse — which discharges static handlers at the flavor boundary
/// ([STAGE-LOWER-ORDER-PHASE]) — and emit LLVM IR.
-pub fn compile_staged(source: &str) -> String {
+pub(crate) fn compile_staged(source: &str) -> String {
let parsed = parse_program_with_flavor(source, Flavor::Default);
assert!(
parsed.errors.is_empty(),
@@ -22,7 +22,7 @@ pub fn compile_staged(source: &str) -> String {
}
/// Every diagnostic the frontend produces for `source`, joined for matching.
-pub fn diagnostics(source: &str, flavor: Flavor) -> String {
+pub(crate) fn diagnostics(source: &str, flavor: Flavor) -> String {
let parsed = parse_program_with_flavor(source, flavor);
if !parsed.errors.is_empty() {
return parsed
@@ -42,7 +42,7 @@ pub fn diagnostics(source: &str, flavor: Flavor) -> String {
/// Compile `source` for `target` through the real CLI, returning stderr and
/// whether it succeeded — the only path that runs the per-target capability
/// gate [MULTI-WASM] and [STAGE-WASM] are checked by.
-pub fn compile_for_target(source: &str, target: &str) -> (bool, String) {
+pub(crate) fn compile_for_target(source: &str, target: &str) -> (bool, String) {
let dir = std::env::temp_dir().join(format!(
"osprey_staged_{}_{:?}",
std::process::id(),
diff --git a/crates/osprey-cli/tests/staged_effects.rs b/crates/osprey-cli/tests/staged_effects.rs
index b3736d91..615eab91 100644
--- a/crates/osprey-cli/tests/staged_effects.rs
+++ b/crates/osprey-cli/tests/staged_effects.rs
@@ -11,7 +11,7 @@
#[path = "common/staging.rs"]
mod staging;
-use osprey_syntax::{dependency_sets, Flavor};
+use osprey_syntax::{dependency_report, Flavor};
use staging::{compile_for_target, compile_staged, diagnostics};
/// The C runtime symbols a dynamic handler region registers and looks up.
@@ -75,7 +75,7 @@ fn greeting() = "hello ${perform NameSignal.read()}"
fn statusBar() = "${greeting()} | ${counterLabel()}"
fn footer() = "osprey"
"#;
- let deps = dependency_sets(source, Flavor::Default);
+ let deps = dependency_report(source, Flavor::Default).0;
let of = |name: &str| deps.get(name).cloned().unwrap_or_default();
assert_eq!(of("doubled"), vec!["CountSignal.read"]);
// Transitive through a call, and only what is actually read.
@@ -101,7 +101,7 @@ fn root() = handle static CountSignal
read => 7
in label()
"#;
- let deps = dependency_sets(source, Flavor::Default);
+ let deps = dependency_report(source, Flavor::Default).0;
assert_eq!(
deps.get("label").cloned().unwrap_or_default(),
vec!["CountSignal.read"]
@@ -258,7 +258,7 @@ static effect Signal { read: fn() -> T }
fn counterLabel() = "count: ${(perform Signal.read()).value}"
fn cursorLabel() = "at: ${(perform Signal.read()).at}"
"#;
- let deps = dependency_sets(source, Flavor::Default);
+ let deps = dependency_report(source, Flavor::Default).0;
let of = |name: &str| deps.get(name).cloned().unwrap_or_default();
assert_eq!(
of("counterLabel"),
diff --git a/crates/osprey-codegen/src/aggregate.rs b/crates/osprey-codegen/src/aggregate.rs
index e1e76e16..60a18191 100644
--- a/crates/osprey-codegen/src/aggregate.rs
+++ b/crates/osprey-codegen/src/aggregate.rs
@@ -200,8 +200,7 @@ fn own_struct_handle(
obj: &str,
owner: impl Into,
) -> Value {
- let handle = cg.fresh_reg();
- cg.emit(format!("{handle} = bitcast {struct_ty}* {obj} to i8*"));
+ let handle = cg.emit_reg(format!("bitcast {struct_ty}* {obj} to i8*"));
let v = Value::handle(handle, owner);
crate::arc::own(cg, &v);
v
@@ -249,9 +248,8 @@ fn gen_http_response(cg: &mut Codegen, fields: &[FieldAssignment]) -> Result crate::cast::coerce_to(cg, v, LType::Str)?.operand,
};
crate::arc::dup_store(cg, llty, &operand);
- let p = cg.fresh_reg();
- cg.emit(format!(
- "{p} = getelementptr {HTTP_RESPONSE_STRUCT}, {HTTP_RESPONSE_STRUCT}* {obj}, i32 0, i32 {i}"
+ let p = cg.emit_reg(format!(
+ "getelementptr {HTTP_RESPONSE_STRUCT}, {HTTP_RESPONSE_STRUCT}* {obj}, i32 0, i32 {i}"
));
cg.emit(format!("store {llty} {operand}, {llty}* {p}"));
}
@@ -316,11 +314,7 @@ pub(crate) fn gen_update(
.ctor_struct_ty(&owner)
.ok_or_else(|| CodegenError::unknown(&owner))?;
- let src = cg.fresh_reg();
- cg.emit(format!(
- "{src} = bitcast i8* {} to {struct_ty}*",
- base.operand
- ));
+ let src = cg.emit_reg(format!("bitcast i8* {} to {struct_ty}*", base.operand));
// view.meta comes from the Osprey field types (builder.rs `field_meta`),
// which prove more than the erased LTypes visible here: an all-union field
// set upgrades to the probe-free KIND_MASK_DIRECT. noinit: the tag and every
@@ -347,6 +341,10 @@ pub(crate) fn gen_update(
/// load the field.
pub(crate) fn gen_field_access(cg: &mut Codegen, target: &Expr, field: &str) -> Result {
let tv = gen_expr(cg, target)?;
+ let inferred = tv
+ .inferred_type
+ .as_ref()
+ .and_then(|ty| cg.prog.field_type(ty, field));
// Use the statically-known owner (a named record or an anonymous object
// literal) when it actually declares `field`; otherwise (a generic accessor
// whose parameter infers to a type variable) resolve the field by name across
@@ -358,6 +356,14 @@ pub(crate) fn gen_field_access(cg: &mut Codegen, target: &Expr, field: &str) ->
let owner = known
.or_else(|| cg.find_field_owner(field))
.ok_or_else(|| CodegenError::invalid(format!("field `{field}` on a non-record")))?;
+ // Ordinary records can cross an inlined generic call with only their owner
+ // tag. Keep a concrete field's complete type, including every returned
+ // function arrow, so chained calls retain their ABI. [TYPE-FN-HIGHER-ORDER]
+ let inferred = inferred.or_else(|| {
+ cg.prog
+ .field_type(&osprey_types::Type::con(&owner, Vec::new()), field)
+ .filter(|ty| !osprey_types::has_type_var(ty))
+ });
if cg.ctor_field_result_inner(&owner, field).is_some() {
return Err(result_field_unsupported());
}
@@ -370,24 +376,32 @@ pub(crate) fn gen_field_access(cg: &mut Codegen, target: &Expr, field: &str) ->
.find_map(|(i, (f, t))| (f == field).then_some((i, *t)))
.ok_or_else(|| CodegenError::invalid(format!("`{owner}` has no field `{field}`")))?;
+ let fty = inferred.as_ref().map_or(fty, crate::types::ltype_of);
// A record that crossed a generic boundary — a list element, an inlined
// parameter whose type is still a variable — travels in the uniform machine
// word, so restore the handle before reading a slot out of it.
let tv = crate::cast::coerce_to(cg, tv, LType::Ptr)?;
- let src = cg.fresh_reg();
- cg.emit(format!(
- "{src} = bitcast i8* {} to {struct_ty}*",
- tv.operand
- ));
+ let src = cg.emit_reg(format!("bitcast i8* {} to {struct_ty}*", tv.operand));
let loaded = load_field(cg, &struct_ty, src.as_str(), idx + 1, fty);
// A handle field carries its ELEMENT's ABI, not an owner of its own: the
// slot holds a runtime id, and `recv`/`await` on it needs the element type
// to unbox with ([CONCURRENCY-CHANNEL]).
- if let Some(handle) = cg.ctor_field_handle(&owner, field) {
- return Ok(handle.restore(Value::new(loaded, fty)));
+ if let Some(handle) = inferred
+ .as_ref()
+ .and_then(|ty| crate::builder::FiberSig::of(&cg.prog, ty))
+ .or_else(|| cg.ctor_field_handle(&owner, field))
+ {
+ let mut value = handle.restore(Value::new(loaded, fty));
+ value.inferred_type = inferred;
+ return Ok(value);
}
- let owner = cg.ctor_field_owner(&owner, field);
- Ok(Value::new(loaded, fty).with_owner(owner))
+ let owner = inferred.as_ref().map_or_else(
+ || cg.ctor_field_owner(&owner, field),
+ |ty| crate::types::owner_name(&cg.prog, ty),
+ );
+ let mut value = Value::new(loaded, fty).with_owner(owner);
+ value.inferred_type = inferred;
+ Ok(value)
}
/// Aggregate layouts do not yet carry the shape metadata needed to preserve a
@@ -418,9 +432,8 @@ pub(crate) fn store_field(
if !moved {
crate::arc::dup_store(cg, fty.as_str(), val);
}
- let p = cg.fresh_reg();
- cg.emit(format!(
- "{p} = getelementptr {struct_ty}, {struct_ty}* {obj}, i32 0, i32 {idx}"
+ let p = cg.emit_reg(format!(
+ "getelementptr {struct_ty}, {struct_ty}* {obj}, i32 0, i32 {idx}"
));
cg.emit(format!("store {fty} {val}, {fty}* {p}"));
}
@@ -440,11 +453,9 @@ pub(crate) fn load_field(
idx: usize,
fty: LType,
) -> String {
- let p = cg.fresh_reg();
- cg.emit(format!(
- "{p} = getelementptr {struct_ty}, {struct_ty}* {obj}, i32 0, i32 {idx}"
+ let p = cg.emit_reg(format!(
+ "getelementptr {struct_ty}, {struct_ty}* {obj}, i32 0, i32 {idx}"
));
- let r = cg.fresh_reg();
- cg.emit(format!("{r} = load {fty}, {fty}* {p}"));
+ let r = cg.emit_reg(format!("load {fty}, {fty}* {p}"));
r
}
diff --git a/crates/osprey-codegen/src/builder.rs b/crates/osprey-codegen/src/builder.rs
index b7b9b285..656b1743 100644
--- a/crates/osprey-codegen/src/builder.rs
+++ b/crates/osprey-codegen/src/builder.rs
@@ -14,7 +14,7 @@ use std::fmt::Write as _;
/// Code generation switches that alter the emitted module without changing
/// Osprey semantics.
#[derive(Debug, Clone, Default)]
-pub struct CodegenOptions {
+pub(crate) struct CodegenOptions {
/// Source file identity used for LLVM/DWARF debug metadata.
pub debug_source: Option,
/// Instrument coverable lines with hit counters [TESTING-COVERAGE-CODEGEN].
@@ -26,11 +26,7 @@ pub struct CodegenOptions {
/// A lambda kept for inline application at its direct call sites: its
/// parameters, its body, and the position inference keyed its type by.
-pub(crate) type LambdaDef = (
- Vec,
- osprey_ast::Expr,
- Option,
-);
+pub(crate) type LambdaDef = (Vec, Expr, Option);
/// What the program turned out to CONTAIN — decided while lowering, read once
/// by `main`'s epilogue. These belong together because they are answered the
@@ -52,7 +48,7 @@ pub(crate) struct Lowered {
}
/// Accumulates a whole module while lowering one function at a time.
-pub struct Codegen {
+pub(crate) struct Codegen {
/// `declare` lines, de-duplicated and stably ordered.
externs: BTreeSet,
/// Global constant definitions (string literals).
@@ -74,6 +70,9 @@ pub struct Codegen {
/// Declared parameter names per function, for named-argument ordering.
pub(crate) fn_params: HashMap>,
+ /// Extern parameter names. Kept separate because `fn_params` also identifies
+ /// Osprey functions whose callbacks use closure cells rather than C pointers.
+ pub(crate) extern_params: HashMap>,
/// Type names an `extern fn` claims to return (every name in the declared
/// return type expression, conservatively). A foreign pointer typed as a
/// union would break the `KIND_MASK_DIRECT` all-children-are-ARC-bodies
@@ -145,7 +144,7 @@ pub struct Codegen {
/// function at each call site so its type variables monomorphize to the
/// concrete argument types there (specialisation by inlining rather than by
/// emitting a name-mangled copy per instantiation).
- pub(crate) fn_defs: HashMap, osprey_ast::Expr)>,
+ pub(crate) fn_defs: HashMap, Expr)>,
/// Generic functions currently being inlined — a re-entry guard so a
/// (mutually) recursive generic call falls back to a direct call instead of
/// inlining forever.
@@ -300,11 +299,12 @@ impl FiberSig {
}
}
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ParamSig {
pub(crate) ty: LType,
pub(crate) result_inner: Option,
pub(crate) fiber: Option,
+ pub(crate) inferred_type: Option,
}
impl ParamSig {
@@ -315,11 +315,13 @@ impl ParamSig {
ty: LType::Ptr,
result_inner: Some(inner),
fiber,
+ inferred_type: Some(ty.clone()),
},
None => Self {
ty: ltype_of(ty),
result_inner: None,
fiber,
+ inferred_type: Some(ty.clone()),
},
}
}
@@ -605,18 +607,18 @@ impl DebugState {
}
impl Codegen {
- pub fn new() -> Codegen {
+ pub(crate) fn new() -> Codegen {
Codegen::with_types(ProgramTypes::default())
}
/// Build with the inferred program types that drive parameter/return/value
/// typing.
- pub fn with_types(prog: ProgramTypes) -> Codegen {
+ pub(crate) fn with_types(prog: ProgramTypes) -> Codegen {
Codegen::with_options(prog, CodegenOptions::default())
}
/// Build with inferred program types and explicit code generation options.
- pub fn with_options(prog: ProgramTypes, options: CodegenOptions) -> Codegen {
+ pub(crate) fn with_options(prog: ProgramTypes, options: CodegenOptions) -> Codegen {
Codegen {
externs: BTreeSet::new(),
globals: Vec::new(),
@@ -631,6 +633,7 @@ impl Codegen {
scope_ids: Vec::new(),
next_scope_id: 0,
fn_params: HashMap::new(),
+ extern_params: HashMap::new(),
extern_ret_types: BTreeSet::new(),
nullary_singletons: HashMap::new(),
prog,
@@ -702,6 +705,11 @@ impl Codegen {
/// [TYPE-FN-HIGHER-ORDER].
pub(crate) fn callee_fn_type(&self, expr: &Expr) -> Option {
match expr {
+ Expr::TypeApply {
+ function, position, ..
+ } => self
+ .callee_fn_type(function)
+ .map(|ty| self.prog.application_type(*position, &ty)),
Expr::Identifier(name) => self.identifier_fn_type(name),
// A call evaluates to its callee's return type — recurse so a chain
// peels one arrow per application.
@@ -747,13 +755,7 @@ impl Codegen {
/// unique field-name match across known layouts.
fn field_fn_type(&self, target: &Expr, field: &str) -> Option {
let owner = self.callee_field_owner(target, field)?;
- self.prog
- .ctors
- .get(&owner)?
- .fields
- .iter()
- .find(|(f, _)| f == field)
- .map(|(_, t)| t.clone())
+ self.ctor_field_ty(&owner, field).cloned()
}
/// Resolve the owner type of `target.field`: prefer a bound identifier's
@@ -770,12 +772,21 @@ impl Codegen {
self.find_field_owner(field)
}
- /// Whether `owner`'s layout declares `field`.
- fn declares_field(&self, owner: &str, field: &str) -> bool {
+ /// The declared type of `field` on constructor `owner` — the single field
+ /// lookup behind [`Self::declares_field`] and every `ctor_field_*` accessor.
+ fn ctor_field_ty(&self, owner: &str, field: &str) -> Option<&Type> {
self.prog
.ctors
- .get(owner)
- .is_some_and(|c| c.fields.iter().any(|(f, _)| f == field))
+ .get(owner)?
+ .fields
+ .iter()
+ .find(|(f, _)| f == field)
+ .map(|(_, t)| t)
+ }
+
+ /// Whether `owner`'s layout declares `field`.
+ fn declares_field(&self, owner: &str, field: &str) -> bool {
+ self.ctor_field_ty(owner, field).is_some()
}
/// Whether `name` is a user function whose inferred signature still contains
@@ -979,7 +990,7 @@ impl Codegen {
pub(crate) fn fn_ret_is_unit(&self, name: &str) -> bool {
self.prog
.return_type(name)
- .is_some_and(|t| *t == osprey_types::Type::unit())
+ .is_some_and(|t| *t == Type::unit())
}
/// The LLVM parameter types of a user function, from inference.
@@ -1076,7 +1087,7 @@ impl Codegen {
/// smuggle in a foreign pointer and break the proof. Everything else
/// (strings can be rodata, records can cross the C ABI, `Ptr` is FFI)
/// keeps the probe-tolerant `LType` mapping. [GC-ARC-PERCEUS]
- fn field_meta(&self, t: &osprey_types::Type) -> crate::meta::MetaField {
+ fn field_meta(&self, t: &Type) -> crate::meta::MetaField {
let proven = crate::types::proven_heap_name(t).is_some_and(|n| {
self.prog.unions.contains_key(n) && !self.extern_ret_types.contains(n)
});
@@ -1164,14 +1175,7 @@ impl Codegen {
.find(|(f, _, _)| f == field)
.and_then(|(_, _, tag)| tag.clone());
}
- let ty = self
- .prog
- .ctors
- .get(owner)?
- .fields
- .iter()
- .find(|(f, _)| f == field)
- .map(|(_, t)| t.clone())?;
+ let ty = self.ctor_field_ty(owner, field)?.clone();
let head = crate::types::owner_name(&self.prog, &ty)?;
let known = self.prog.ctors.contains_key(&head)
|| self.prog.unions.contains_key(&head)
@@ -1190,30 +1194,16 @@ impl Codegen {
/// read the wire word raw and the element came back untyped.
/// Implements [CONCURRENCY-CHANNEL].
pub(crate) fn ctor_field_handle(&self, owner: &str, field: &str) -> Option {
- let ty = self
- .prog
- .ctors
- .get(owner)?
- .fields
- .iter()
- .find(|(name, _)| name == field)
- .map(|(_, ty)| ty)?;
- FiberSig::of(&self.prog, ty)
+ FiberSig::of(&self.prog, self.ctor_field_ty(owner, field)?)
}
pub(crate) fn ctor_field_result_inner(&self, owner: &str, field: &str) -> Option {
- self.prog
- .ctors
- .get(owner)?
- .fields
- .iter()
- .find(|(name, _)| name == field)
- .and_then(|(_, ty)| crate::types::result_inner(ty))
+ crate::types::result_inner(self.ctor_field_ty(owner, field)?)
}
/// The variant constructor names of a union owner, in tag order.
pub(crate) fn union_variants(&self, owner: &str) -> Option<&[String]> {
- self.prog.unions.get(owner).map(std::vec::Vec::as_slice)
+ self.prog.unions.get(owner).map(Vec::as_slice)
}
// ---- SSA + block naming (function-local) ----
@@ -1246,6 +1236,17 @@ impl Codegen {
/// Emit `r = {rhs}` to a fresh SSA register and return `r` — the ubiquitous
/// "name the result of one instruction" step (`zext …`, `icmp …`, `fneg …`).
+ /// Open a diamond on `cond`: mint the two arm labels plus the join they
+ /// both reach, and emit the branch between them. Answers
+ /// `(true_arm, false_arm, join)`; the caller starts whichever arm it means
+ /// to fill first. Minting labels apart from the `br` that names them is how
+ /// a block ends up unterminated, so the two steps are one call.
+ pub(crate) fn diamond(&mut self, cond: &str) -> (String, String, String) {
+ let (taken, other, join) = (self.fresh_label(), self.fresh_label(), self.fresh_label());
+ self.emit(format!("br i1 {cond}, label %{taken}, label %{other}"));
+ (taken, other, join)
+ }
+
pub(crate) fn emit_reg(&mut self, rhs: impl std::fmt::Display) -> String {
let r = self.fresh_reg();
self.emit(format!("{r} = {rhs}"));
@@ -1297,8 +1298,7 @@ impl Codegen {
};
self.add_extern("declare void @llvm.dbg.declare(metadata, metadata, metadata)");
let ty = value.ty.as_str();
- let slot = self.fresh_reg();
- self.emit(format!("{slot} = alloca {ty}"));
+ let slot = self.emit_reg(format!("alloca {ty}"));
self.emit(format!("store {ty} {}, {ty}* {slot}", value.operand));
self.emit(format!(
"call void @llvm.dbg.declare(metadata {ty}* {slot}, metadata !{var_id}, metadata !DIExpression())"
@@ -1369,9 +1369,8 @@ impl Codegen {
self.globals.push(format!(
"{name} = private unnamed_addr constant [{len} x i8] c\"{escaped}\""
));
- let reg = self.fresh_reg();
- self.emit(format!(
- "{reg} = getelementptr [{len} x i8], [{len} x i8]* {name}, i64 0, i64 0"
+ let reg = self.emit_reg(format!(
+ "getelementptr [{len} x i8], [{len} x i8]* {name}, i64 0, i64 0"
));
let _ = self.rodata_regs.insert(reg.clone());
Value::new(reg, LType::Str)
@@ -1610,19 +1609,16 @@ impl Codegen {
}
fn malloc_struct_with(&mut self, struct_ty: &str, meta: i64, noinit: bool) -> String {
- let szp = self.fresh_reg();
- self.emit(format!(
- "{szp} = getelementptr {struct_ty}, {struct_ty}* null, i64 1"
+ let szp = self.emit_reg(format!(
+ "getelementptr {struct_ty}, {struct_ty}* null, i64 1"
));
- let sz = self.fresh_reg();
- self.emit(format!("{sz} = ptrtoint {struct_ty}* {szp} to i64"));
+ let sz = self.emit_reg(format!("ptrtoint {struct_ty}* {szp} to i64"));
let raw = if noinit {
self.heap_alloc_tagged_noinit(&sz, meta)
} else {
self.heap_alloc_tagged(&sz, meta)
};
- let obj = self.fresh_reg();
- self.emit(format!("{obj} = bitcast i8* {raw} to {struct_ty}*"));
+ let obj = self.emit_reg(format!("bitcast i8* {raw} to {struct_ty}*"));
obj
}
diff --git a/crates/osprey-codegen/src/call.rs b/crates/osprey-codegen/src/call.rs
index 7a4cec0e..457858ab 100644
--- a/crates/osprey-codegen/src/call.rs
+++ b/crates/osprey-codegen/src/call.rs
@@ -31,8 +31,7 @@ impl Codegen {
/// result register `r`.
pub(crate) fn call(&mut self, ret: &str, cname: &str, params: &str, args: &[&str]) -> String {
let typed = declare_and_args(self, ret, cname, params, args);
- let r = self.fresh_reg();
- self.emit(format!("{r} = call {ret} @{cname}({typed})"));
+ let r = self.emit_reg(format!("call {ret} @{cname}({typed})"));
r
}
diff --git a/crates/osprey-codegen/src/cast.rs b/crates/osprey-codegen/src/cast.rs
index dbaafff7..8fb9dcde 100644
--- a/crates/osprey-codegen/src/cast.rs
+++ b/crates/osprey-codegen/src/cast.rs
@@ -49,8 +49,7 @@ pub(crate) fn coerce_to(cg: &mut Codegen, v: Value, want: LType) -> Result,
) -> Value {
+ let owner = owner.or_else(|| {
+ sig.inferred_type
+ .as_ref()
+ .and_then(|ty| crate::types::owner_name(&cg.prog, ty))
+ });
// A handle parameter's `owner` slot carries its ELEMENT's tag, not its own
// — a fiber or channel id is a machine word with nothing to own.
let (own_tag, elem_tag) = match sig.fiber {
Some(_) => (None, owner),
None => (owner, None),
};
- let value = if let Some(inner) = sig.result_inner {
+ let mut value = if let Some(inner) = sig.result_inner {
let struct_ty = crate::llty::result_struct_ty(inner);
let typed = cg.emit_reg(format!("bitcast i8* {operand} to {struct_ty}*"));
Value::result(typed, inner)
} else {
Value::new(operand, sig.ty).with_owner(own_tag)
};
+ value.inferred_type = sig.inferred_type;
match sig.fiber {
Some(fiber) => {
let mut restored = fiber.restore(value);
- restored.fiber_elem_owner = elem_tag;
+ restored.fiber_elem_owner = elem_tag.or(restored.fiber_elem_owner);
restored
}
None => value,
diff --git a/crates/osprey-codegen/src/closure.rs b/crates/osprey-codegen/src/closure.rs
index 1c422b1a..3d8c750f 100644
--- a/crates/osprey-codegen/src/closure.rs
+++ b/crates/osprey-codegen/src/closure.rs
@@ -54,7 +54,10 @@ pub(crate) fn lambda_value(
}
let sig = Codegen::fn_value_sig(&cg.prog, ty)
.ok_or_else(|| CodegenError::invalid("lambda has no inferred function type"))?;
- emit_closure(cg, parameters, body, &sig)
+ let ty = ty.clone();
+ let mut value = emit_closure(cg, parameters, body, &sig)?;
+ value.inferred_type = Some(ty);
+ Ok(value)
}
/// Emit a lambda as a closure value with the given signature (the consuming
@@ -130,10 +133,7 @@ pub(crate) fn specialisation_key(target: &str, sig: &FnSig) -> String {
let semantic_params = sig
.0
.iter()
- .map(|param| match param.result_inner {
- Some(inner) => format!("Result<{inner}>"),
- None => param.ty.to_string(),
- })
+ .map(|param| format!("{param:?}"))
.collect::>()
.join(",");
format!(
@@ -214,6 +214,9 @@ pub(crate) fn bind_params_from(
for (i, (p, pty)) in parameters.iter().zip(param_tys).enumerate() {
let reg = crate::llty::param_register(first + i);
let value = crate::cast::incoming_param(cg, format!("%{reg}"), pty.clone(), None);
+ if let Some(ty) = &value.inferred_type {
+ cg.bind_fn_local(&p.name, ty.clone());
+ }
cg.bind(p.name.clone(), value);
out.push((pty.ty, reg));
}
@@ -236,6 +239,9 @@ pub(crate) fn reload_captures(cg: &mut Codegen, cell_ty: &str, caps: &[Capture])
let r = cg.emit_reg(format!("load {lty}, {lty}* {p}"));
let mut v = c.val.clone();
v.operand = r;
+ if let Some(ty) = &v.inferred_type {
+ cg.bind_fn_local(&c.name, ty.clone());
+ }
cg.bind(c.name.clone(), v);
}
}
@@ -373,7 +379,17 @@ pub(crate) fn cell_call_exprs(
for e in exprs {
vals.push(gen_expr(cg, e)?);
}
- let typed = coerce_closure_args(cg, sig, vals)?;
+ cell_call_values(cg, handle, sig, vals)
+}
+
+/// Call using values already lowered against their semantic parameter types.
+pub(crate) fn cell_call_values(
+ cg: &mut Codegen,
+ handle: &str,
+ sig: &FnSig,
+ values: Vec,
+) -> Result {
+ let typed = coerce_closure_args(cg, sig, values)?;
Ok(cell_call(cg, handle, sig, &typed))
}
@@ -421,17 +437,37 @@ pub(crate) fn returned(reg: String, sig: &FnSig) -> Value {
/// module) a forwarder that drops the env argument and tail-calls the real
/// function, plus a constant cell pointing at it.
pub(crate) fn named_fn_cell(cg: &mut Codegen, name: &str) -> Result {
- if cg.fn_defs.contains_key(name) {
- return Err(CodegenError::unsupported(
- "a generic function as a function value",
- ));
+ if let Some((parameters, body)) = cg.fn_defs.get(name).cloned() {
+ return specialized_named_cell(cg, name, ¶meters, &body);
}
let cell = match cg.fnval_cells.get(name) {
Some(g) => g.clone(),
None => emit_forwarder(cg, name)?,
};
let reg = cg.emit_reg(format!("bitcast {{ i8* }}* {cell} to i8*"));
- Ok(Value::new(reg, LType::Ptr))
+ let mut value = Value::new(reg, LType::Ptr);
+ value.inferred_type = cg.callee_fn_type(&Expr::Identifier(name.to_owned()));
+ Ok(value)
+}
+
+fn specialized_named_cell(
+ cg: &mut Codegen,
+ name: &str,
+ parameters: &[Parameter],
+ body: &Expr,
+) -> Result {
+ let ty = cg.callee_fn_type(&Expr::Identifier(name.to_owned()));
+ let Some(ty) = ty.filter(crate::types::fn_value_concrete) else {
+ return Err(CodegenError::unsupported(
+ "a generic function as a function value",
+ ));
+ };
+ let sig = Codegen::fn_value_sig(&cg.prog, &ty)
+ .ok_or_else(|| CodegenError::invalid("function value has no signature"))?;
+ let key = format!("{}|{ty:?}", specialisation_key(name, &sig));
+ let mut value = emit_closure_keyed(cg, parameters, body, &sig, Some(key))?;
+ value.inferred_type = Some(ty);
+ Ok(value)
}
/// Emit `@__fnval_{name}` (env-dropping forwarder) and its constant cell;
diff --git a/crates/osprey-codegen/src/collections.rs b/crates/osprey-codegen/src/collections.rs
index 1d6b1c8e..5eb2943e 100644
--- a/crates/osprey-codegen/src/collections.rs
+++ b/crates/osprey-codegen/src/collections.rs
@@ -168,8 +168,7 @@ pub(crate) fn gen_receiver_directed(
},
};
Ok(Some(if name == "isEmpty" {
- let r = cg.fresh_reg();
- cg.emit(format!("{r} = icmp eq i64 {}, 0", count.operand));
+ let r = cg.emit_reg(format!("icmp eq i64 {}, 0", count.operand));
Value::new(r, LType::I1)
} else {
count
@@ -379,15 +378,13 @@ fn list_contains(cg: &mut Codegen, args: &[Expr]) -> Result {
let is_str = needle.ty == LType::Str;
let boxed = box_to_i64(cg, needle.clone());
- let res = cg.fresh_reg();
- cg.emit(format!("{res} = alloca i1"));
+ let res = cg.emit_reg("alloca i1");
cg.emit(format!("store i1 0, i1* {res}"));
let lp = open_list_loop(cg, &l.operand);
let eq = cg.fresh_reg();
if is_str {
- let ep = cg.fresh_reg();
- cg.emit(format!("{ep} = inttoptr i64 {} to i8*", lp.elem));
+ let ep = cg.emit_reg(format!("inttoptr i64 {} to i8*", lp.elem));
let c = cg.call("i32", "strcmp", "i8*, i8*", &[&ep, &needle.operand]);
cg.emit(format!("{eq} = icmp eq i32 {c}, 0"));
} else {
@@ -402,8 +399,7 @@ fn list_contains(cg: &mut Codegen, args: &[Expr]) -> Result {
cg.start_block(&cont);
close_list_loop(cg, &lp);
- let out = cg.fresh_reg();
- cg.emit(format!("{out} = load i1, i1* {res}"));
+ let out = cg.emit_reg(format!("load i1, i1* {res}"));
Ok(Value::new(out, LType::I1))
}
@@ -453,8 +449,7 @@ fn map_contains(cg: &mut Codegen, args: &[Expr]) -> Result {
"i8*, i64",
&[&m.operand, &k.operand],
);
- let r = cg.fresh_reg();
- cg.emit(format!("{r} = icmp ne i32 {raw}, 0"));
+ let r = cg.emit_reg(format!("icmp ne i32 {raw}, 0"));
Ok(Value::new(r, LType::I1))
}
@@ -536,10 +531,8 @@ fn map_to_list(cg: &mut Codegen, args: &[Expr], take_key: bool) -> Result
let managed = cg.call("i32", kind, "i8*", &[&m.operand]);
let bld = list_builder_new_of(cg, &managed);
let iter = cg.call("i8*", "osprey_map_iter_new", "i8*", &[&m.operand]);
- let kp = cg.fresh_reg();
- cg.emit(format!("{kp} = alloca i64"));
- let vp = cg.fresh_reg();
- cg.emit(format!("{vp} = alloca i64"));
+ let kp = cg.emit_reg("alloca i64");
+ let vp = cg.emit_reg("alloca i64");
let cond = cg.fresh_label();
let body = cg.fresh_label();
@@ -553,14 +546,12 @@ fn map_to_list(cg: &mut Codegen, args: &[Expr], take_key: bool) -> Result
"i8*, i64*, i64*",
&[&iter, &kp, &vp],
);
- let more = cg.fresh_reg();
- cg.emit(format!("{more} = icmp ne i32 {has}, 0"));
+ let more = cg.emit_reg(format!("icmp ne i32 {has}, 0"));
cg.emit(format!("br i1 {more}, label %{body}, label %{endl}"));
cg.start_block(&body);
let slot = if take_key { &kp } else { &vp };
- let elem = cg.fresh_reg();
- cg.emit(format!("{elem} = load i64, i64* {slot}"));
+ let elem = cg.emit_reg(format!("load i64, i64* {slot}"));
list_builder_push_borrowed(cg, &bld, &elem);
cg.emit(format!("br label %{cond}"));
diff --git a/crates/osprey-codegen/src/curry.rs b/crates/osprey-codegen/src/curry.rs
index 7656b973..9a6e7b07 100644
--- a/crates/osprey-codegen/src/curry.rs
+++ b/crates/osprey-codegen/src/curry.rs
@@ -12,11 +12,17 @@ use crate::builder::Codegen;
use crate::error::Result;
use crate::expr::gen_expr;
use crate::llty::Value;
-use osprey_ast::{Expr, NamedArgument};
+use osprey_ast::{Expr, NamedArgument, Position};
/// One application group of a spine: `f(a, b)(c)` has groups `[a, b]`, `[c]`.
pub(crate) type ArgGroup<'a> = (&'a [Expr], &'a [NamedArgument]);
+struct Spine<'a> {
+ head: &'a str,
+ application: Option,
+ groups: Vec>,
+}
+
/// Lower `function(arguments)` when `function` is itself an application spine
/// headed by a generic user function — `None` when it is anything else, so the
/// ordinary call paths keep precedence.
@@ -26,7 +32,12 @@ pub(crate) fn try_spine(
arguments: &[Expr],
named: &[NamedArgument],
) -> Result