diff --git a/.dev/debt.yaml b/.dev/debt.yaml index 547c36fbf..17decb763 100644 --- a/.dev/debt.yaml +++ b/.dev/debt.yaml @@ -24,50 +24,6 @@ # md-table-align no longer pads it to ~800 KB of whitespace. active: - - id: "D-587" - status: |- - open (2026-09-09). USE-AFTER-FREE, user-reachable panic. Re-registering a - deftype/defrecord name frees a TypeDescriptor that live instances still - point at. `type_descriptor.zig registerType` keys `rt.types` by the SIMPLE - name, and on re-registration does `fetchRemove(name)` then frees the old - descriptor (each field_layout entry name, the layout slice, fqcn, - defining_ns, method_table entries, protocol_impls, then - `gpa.destroy(old.value)`). Every constructed instance holds - `inst.descriptor` as a raw pointer to it, so `print.zig - printTypedInstance` then reads `field_layout` out of freed memory: the - symptom is `thread panic: integer overflow` (a garbage field index or - layout length), the defect is the free. - Repro (ReleaseSafe v1.14.3): `(ns aaa) (defrecord Point [x y]) (def a - (->Point 1 2)) (ns bbb) (defrecord Point [x y]) (def b (->Point 1 2)) - (prn (pr-str aaa/a))` panics; printing `bbb/b` is fine. Variants: two ns - with DIFFERENT names is fine; two ns SAME name panics; SAME ns redefining - the same name panics (this is the ordinary REPL / `require :reload` path - `dev/user.clj` depends on); same name with a different field count panics; - the deftype form raises `WriteFailed` instead, same root. - TWO separable defects: (1) the registry is keyed by simple name, so - `aaa/Point` and `bbb/Point` collide with no redefinition at all (clj gives - each ns its own class); (2) re-registration frees a descriptor live - instances reference. - ADR-0019 forbids a panic on a user-reachable path, and this one is reached - by re-evaluating a defrecord form. It also caps the native-suite migration: - `test/clj/suites/` shares ONE image, so no two suites may define the same - type name (worked around 2026-09-09 by renaming defrecord_test's record to - DPoint; 45 type definitions across 103 suites, no other duplicate today). - Found while migrating phase7_defrecord.sh to a native suite. - Card `[CLJW-TD-UAF]` 20260909211032-6471bc49. - category: "correctness" - quality_floor: "correctness: a user-reachable panic from an ordinary redefine" - barrier: |- - Needs an ADR: the free is deliberate (comments cite row 7.7 cycle 5 and - D-190 / ADR-0068) and exists to avoid leaking on redefine, so removing it - trades the leak back. Candidate shapes: key `rt.types` by the qualified - name when `defining_ns` is present and RETIRE rather than free a replaced - descriptor (a leak bounded by redefinition count, which is what clj does - with the old class), or make descriptors GC-managed so a descriptor dies - only when no instance references it. Devil's-advocate fork is mandatory at - this depth. Not blocked on anything external: the repro is one file and the - owning function is ~70 lines. - last_reviewed: "2026-09-09" - id: "D-586" status: |- open (2026-09-05, ADR-0195 trigger). UPSTREAM: zwasm/D-584. zwasm's JIT @@ -7583,6 +7539,17 @@ discharged: refs: |- src/runtime/collection/literal.zig (the one literal construction); src/eval/analyzer/analyzer.zig (the constant fold); src/eval/backend/vm/compiler.zig and vm.zig (the extend opcodes); docs/spec/formats/chunk-v12.edn (bytecode format v12). discharged_at: "2026-10-02" + - id: "D-587" + status: |- + DISCHARGED 2026-10-14 by ADR-0198 plus ADR-0201. Both defects the row named are fixed. (1) rt.types is keyed by ., so aaa.Point and bbb.Point are two classes (ADR-0198), and a bare constructor binds the class of the namespace its form was analyzed in ([CLJW-CTOR-ANALYZE-TIME]: a ctor site carries definer_ns, bytecode v13), so `aaa/->Point` builds aaa.Point even after bbb registered a namesake. (2) A displaced descriptor is retired, never freed, until Runtime.deinit (ADR-0198; ADR-0201 Part B makes that the one lifetime rule for every user descriptor). ADR-0201 also made membership descriptor identity: instance?, .isInstance, isa? and multimethod dispatch agree with (= (class x) C) for a redefined record, a namesake in another namespace and a reify class. The original repro prints both records and the narrowed redefine on both backends. + Was: open (2026-09-09), USE-AFTER-FREE, user-reachable panic. Re-registering a deftype/defrecord name freed a TypeDescriptor that live instances still pointed at, and the registry was keyed by the simple name, so two namespaces defining Point collided with no redefinition at all. Symptom `thread panic: integer overflow` from printTypedInstance, or `WriteFailed` for a deftype. + category: "correctness" + barrier: |- + none, discharged. Pinned by test/clj/suites/type_redefine_test.clj (the six scenarios of the former type_descriptor_redefinition e2e, incl. 3000 redefinitions interleaved with reify under a forced collection and the defining-namespace ctor binding), test/clj/suites/user_class_identity_test.clj, the type_redefinition diff corpus, and the class_name.zig / analyzer.zig unit tests. Still open and separate: rt.types_by_simple still exists for the bare-name paths that are not ctor sites (card 20260909231443-1ba4303c), and D-574 (reify mints a descriptor per evaluation). + last_reviewed: "2026-10-14" + refs: |- + ADR-0198, ADR-0201; src/runtime/type_descriptor.zig (registerType retires), src/runtime/class_name.zig (isInstanceOfClass, classIsaOf, descriptorIsa), src/eval/analyzer/special_forms.zig (constructInstance definer_ns lookup), docs/spec/formats/chunk-v13.edn. + discharged_at: "2026-10-14" conventions: |- - Category vocabulary is a closed set (guarded by scripts/check_debt_category.sh; synonyms were normalized 2026-10-08: structural->structure, tests/test->testing, numerics->numeric, performance->perf). Allowed: app backend bench bootstrap build clj-parity concurrency correctness debt-hygiene demo discipline distribution docs docs-onboarding ecosystem error-system eval eval-semantics feature gc governance host-surface infra interop isolation license macro meta-reorg missing-ns namespace numeric perf polymorphism process project-flow reader refactor regex robustness runtime scope security structure testing tooling wasm. A new category is added here first, in the same commit as the row that needs it. - Structure: two top-level lists — `active:` and `discharged:`. Active entries carry id / status / category / barrier (+ optional quality_floor / last_reviewed); discharged entries carry id / discharged_at / resolution. Edit as YAML (no Markdown table to align). diff --git a/CHANGELOG.md b/CHANGELOG.md index d5bc55917..084d6f929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ first stable `1.0.0` tag; pre-1.0 `alpha` / `rc` tags may still change surfaces. ## [Unreleased] +## [1.15.1] - 2026-10-21 + +### Added + +- **`wasm/exports` lists a core module's exports.** `(wasm/exports m)` returns + a vector in export order: a function as `{:name "sum_f64" :kind :func + :params [:i32 :i32] :results [:f64]}`, a memory, global or table as + `{:name "memory" :kind :memory}`. Signatures come from the same runtime probe + `wasm/call` uses (ADR-0203). + +- **Dot interop on a Wasm module handle.** `(.sum_f64 m buf 4)`, + `(. m sum_f64 buf 4)` and `(. m (sum_f64 buf 4))` call the export named by + the method symbol, verbatim, through `wasm/call`'s own path, so results, + fuel and errors are identical. A C function compiled to Wasm is called like a + host method (ADR-0203). + +### Changed + +- **zwasm pinned to the fork's `main` (d74e63b4, upstream main of + 2026-10-21).** Upstream now carries the component re-export, wasip2 + `metadata-hash` and `PreparedWasi` fixes the fork's `cljw` branch existed + for. + +- **A shorter README** led by calling C from Clojure, with the architecture, + size and startup charts kept. + +### Fixed + +- **Redefining a deftype or defrecord is safe, and a user class is matched by + identity, not by name.** Re-registering a type no longer frees a descriptor + live instances still use (the `thread panic: integer overflow` on printing + an old instance is gone). `aaa.Point` and `bbb.Point` are two classes, and + `instance?`, `.isInstance`, class `isa?` and multimethod dispatch agree with + `(= (class x) C)` for a redefined record, a namesake in another namespace + and a reify class. A bare `(Point. x y)` or `->Point` builds the class of the + namespace the form was written in, even after another namespace registered a + namesake of a different shape. Compiled chunks move to format v13. + +- **The VM reports errors at their form.** `set!` on an unbound var, an + unresolved or unsupported `.member` call, a static method call, a + constructor arity failure, `import` and `require`/`refer` failures raised on + the VM with no source position (`0:0`) while TreeWalk reported line and + column. Both backends now report the same position, and the dual-backend + test oracle checks it for every failing case. + ## [1.15.0] - 2026-10-13 ### Added diff --git a/README.md b/README.md index 144c7d29e..dd7e248a4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@

- ClojureWasm + ClojureWasm +     + BuddhiLW

ClojureWasm

@@ -13,133 +15,97 @@ CI Homebrew tap Zig 0.16.0 - Clojure runtime License: EPL 2.0

-ClojureWasm (`cljw`) runs Clojure without a JVM. It is written in Zig and -Clojure, ships as one static binary, starts in milliseconds, and embeds a -WebAssembly engine so a `.wasm` compiled from Rust, Go, C or Zig can be -required like a namespace and called like a function, inside a sandbox whose -fuel and memory the caller sets. - -## News - -**2026-10: the project has a new maintainer.** -[@chaploud](https://github.com/chaploud), who created ClojureWasm and released -it through v1.10.1, has handed the [clojurewasm](https://github.com/clojurewasm) -organization over to [@BuddhiLW](https://github.com/BuddhiLW). The fork's work -(v1.11 through v1.15.0) is merged back into -[clojurewasm/ClojureWasm](https://github.com/clojurewasm/ClojureWasm), so the -original repository is active again. Releases and the Homebrew tap keep -publishing from [BuddhiLW/ClojureWasm](https://github.com/BuddhiLW/ClojureWasm) -for now; existing install commands and version pins keep working. - -[Issues](https://github.com/BuddhiLW/ClojureWasm/issues) and PRs are open, and -the most useful report is Clojure code that behaves differently here than on -the JVM (there is an issue template for it). - -## Where it shines - -**Sandboxed plugins and tools.** A module instantiates with an empty import -object, so a guest reaches nothing the host did not grant, and every load takes -a budget. A tool that spins burns its fuel and returns an error; the host -process keeps running. That makes `cljw` a fit for hosts where untrusted code -runs in-process: plugin systems, MCP tool servers, request handlers that -evaluate user code. +

+ Install · + Call C from Clojure · + How it works · + Numbers · + Where it fits · + What runs · + Docs +

-```clojure -(def zk (wasm/load-component "zk_groth16.wasm" {:fuel 5000000 :max-memory-pages 64})) -(wasm/component-call zk "verify" proof 35) ;=> [:ok true] -(wasm/component-call zk "spin") ; throws: exhausted its fuel budget -``` +`cljw` runs Clojure without a JVM. It ships as one static binary, starts in +milliseconds, and embeds a WebAssembly engine: a `.wasm` built from C, Rust, +Go or Zig loads like a library and runs inside a sandbox whose fuel and memory +you set. -**Other languages' libraries, from Clojure.** JVM Clojure calls Java; -ClojureScript calls JavaScript; here the host is a language-neutral `.wasm`. -A WIT-typed component is a namespace: records arrive as maps, lists as -vectors, `result` as `[:ok v]` / `[:err e]`. +## Install -```clojure -(ns my.app - (:require ["./typed_payload.wasm" :as tp])) ; Rust + wit-bindgen, 51 KB +```sh +brew install buddhilw/tap/cljw # macOS arm64, Linux x86_64 +zig build -Dwasm -Doptimize=ReleaseSafe # or from source (Zig 0.16) +``` -(tp/process {:xs [3 4 5] :label "data"}) ;; => [:ok {:xs [3 4 5 12], :label "data!"}] -(tp/process {:xs [] :label "fail"}) ;; => [:err "boom: fail"] -(:arglists (meta #'tp/process)) ;; => ([input]) +Prebuilt binaries are on [Releases](https://github.com/BuddhiLW/ClojureWasm/releases). +They are not code-signed; on macOS, `xattr -d com.apple.quarantine "$(which cljw)"` +once. + +```sh +cljw -e '(->> (range) (filter even?) (take 5))' # (0 2 4 6 8) +cljw script.clj # run a file +cljw # REPL +cljw nrepl --port 7888 # nREPL, CIDER-compatible +cljw build script.clj -o app # self-contained native binary ``` -Each guest language has a natural shape, and cljw has an entry point for each: +## Call C from Clojure -| guest | built with | crosses the boundary as | entry point | -|---|---|---|---| -| C | `zig cc -target wasm32-freestanding` | scalars, arrays in a guest-owned buffer | `wasm/load`, `wasm/call`, `wasm/mem-*` | -| Zig | `zig build-exe -target wasm32-freestanding` | scalars | `wasm/load`, `wasm/call` | -| Rust, `no_std` | `rustc --target wasm32-unknown-unknown` | scalars | `wasm/load`, `wasm/call` | -| Rust, wit-bindgen | `cargo build --target wasm32-wasip2` | records, lists, strings, results | `(:require ["x.wasm" :as x])` | -| Go | `GOOS=wasip1 GOARCH=wasm go build` | argv, env, stdin, stdout, exit code | `wasm/run` | +A C function, compiled to Wasm, called with ordinary Clojure interop. -All five run in [`docs/examples/polyglot`](./docs/examples/polyglot/README.md), -sources and build lines included; CI rebuilds them from source and runs them on -every push. +```c +// docs/examples/polyglot/c/kernel.c +EXPORT("sum_f64") double sum_f64(int off, int n) { + const double *p = (const double *)(long)off; + double s = 0.0; + for (int i = 0; i < n; i++) s += p[i]; + return s; +} +``` -**Native tools, unchanged.** When the tool already exists as a native binary, -nothing needs recompiling to Wasm and nothing needs bindings: its seam is its -command line. `clojure.java.shell/sh` runs any program by argv (never through a -shell) and returns `{:exit :out :err}`, with a non-zero exit as data. The same -call runs on the JVM and on clojurust, so the client that wraps the tool is one -`.cljc`. +```sh +zig cc -target wasm32-freestanding -O2 -nostdlib \ + -Wl,--no-entry -Wl,--export-memory -Wl,-z,stack-size=65536 kernel.c -o kernel.wasm +``` ```clojure -(require '[clojure.java.shell :refer [sh]]) -(sh "autopdf" "build" "invoice.tex" "invoice.json" "clean" :dir "examples") -;; => {:exit 0, :out "...", :err "..."} ; a Go binary, driven as is -``` +(def k (wasm/load "docs/examples/polyglot/c/kernel.wasm" {:fuel 1000000 :max-memory-pages 16})) -[AutoPDF's Clojure client](https://github.com/BuddhiLW/AutoPDF/tree/main/clients/clojure) -drives the unmodified Go `autopdf` CLI this way and renders the same PDF, -byte for byte, on cljw, clojurust and the JVM. A child process escapes every -in-process containment, so `sh` is refused under a filesystem root -(`CLJW_FS_ROOT`) or inside `cljw.eval/with-budget`: pick Wasm when the guest -must be confined, the process seam when the tool is trusted. - -**Short-lived processes.** 8.1 MB on disk, about 6 ms from process start to -first eval, and `cljw build app.clj -o app` produces a self-contained -executable. CLI tools, serverless handlers, scripts that run a thousand times -a day. - -**One program, two runtimes.** `cljw` reads `.cljc` under the feature set -`{:cljw :clj :default}`, and its sibling [clojurust](https://github.com/BuddhiLW/clojurust) -(`cljrs`, Rust-hosted, Cranelift JIT) reads `{:rust}`. A host-free `.cljc` -runs unchanged on the JVM, on `cljw` and on `cljrs` -([`hosts.cljc`](./docs/examples/polyglot/hosts.cljc) prints the same map on -all three, ratios and bigints included, and CI checks that), and the same Rust -crate can serve both: as a zero-import Wasm component on `cljw`, as a native -`cdylib` on `cljrs`. The program picks confinement or speed without a rewrite. +(mapv :name (wasm/exports k)) ;=> ["memory" "buf_addr" "buf_cap" "sum_f64" "scale_f64"] -```mermaid -flowchart LR - P["demo.cljc
one Clojure program"] --> A["cljw
Wasm component, zero imports"] - P --> B["cljrs
native cdylib"] - A --> L[("arkworks Groth16
the same Rust crate")] - B --> L +(def buf (.buf_addr k)) +(wasm/mem-write! k :f64 buf [1.0 2.0 3.0 4.0]) +(.sum_f64 k buf 4) ;=> 10.0 ``` -Measured on that exact shape (a Groth16 prover over BN254, 2026-09-12, same -crate and feature set on both sides): +`wasm/exports` and the dot form need v1.15.1 or later; `(wasm/call k "sum_f64" +buf 4)` works on every version. A guest fault is a catchable +exception, never a host crash, and a guest that runs out of fuel stops with an +error while your process keeps going. + +A WIT component goes further: it is required like a namespace, and records, +lists and `result` cross as plain Clojure data. + +```clojure +(ns my.app (:require ["./typed_payload.wasm" :as tp])) ; Rust + wit-bindgen -| | cljrs (native) | cljw (Wasm) | +(tp/process {:xs [3 4 5] :label "data"}) ;=> [:ok {:xs [3 4 5 12], :label "data!"}] +(tp/process {:xs [] :label "fail"}) ;=> [:err "boom: fail"] +``` + +| guest | crosses the boundary as | entry point | |---|---|---| -| proof | 128 bytes | 128 bytes | -| verify, correct / wrong statement | true / false | true / false | -| wall time | 1.29 s (debug build) | 9.17 s | -| env vars visible to the prover | 212 | 0 | -| file read | granted | denied | -| wall clock | granted | trap | +| C, Zig, Rust `no_std` | scalars, arrays in guest memory | `wasm/load`, `wasm/call`, `(.f m args)`, `wasm/mem-*` | +| Rust + wit-bindgen | records, lists, strings, results | `(:require ["x.wasm" :as x])` | +| Go (`wasip1`) | argv, env, stdin, stdout, exit code | `wasm/run` | -Same proof bytes; the only thing that differs is how much authority the prover -holds while it runs. +All of them build and run in [`docs/examples/polyglot`](./docs/examples/polyglot/README.md), +rebuilt from source by CI on every push. -## How it is put together +## How it works ```mermaid flowchart LR @@ -149,77 +115,15 @@ flowchart LR TW --> RT["runtime: NaN-boxed values, mark-sweep GC,
persistent collections, STM, agents, nREPL"] VM --> RT RT --> Z["zwasm engine
interpreter + JIT, fuel and memory budgets"] - Z --> W[".wasm modules and WIT components
from Rust, Go, C, Zig"] + Z --> W[".wasm modules and WIT components
from C, Rust, Go, Zig"] ``` -Both backends evaluate the same analyzer output, and the test suite runs every -case on both and fails on a mismatch. That differential oracle is how a -from-scratch runtime keeps Clojure semantics honest without a reference -implementation in the process. [`docs/architecture.md`](./docs/architecture.md) +Two backends evaluate the same analyzer output, and every test runs on both: +a mismatch fails the build. [`docs/architecture.md`](./docs/architecture.md) has the map. -## Install - -```sh -brew install buddhilw/tap/cljw # macOS arm64, Linux x86_64 -``` - -Binaries are also on the [Releases](https://github.com/BuddhiLW/ClojureWasm/releases) -page. The binary is not code-signed; if macOS blocks it once, clear the -quarantine flag: `xattr -d com.apple.quarantine "$(which cljw)"`. - -From source (Zig 0.16; `direnv allow` or `nix develop` provides it): - -```sh -zig build -Dwasm -Doptimize=ReleaseSafe # → ./zig-out/bin/cljw -``` - -## Quickstart - -```sh -cljw -e '(->> (range) (filter even?) (take 5))' # (0 2 4 6 8) -cljw -e '(wasm/call (wasm/load "docs/examples/wasm/add.wasm") "add" 40 2)' # 42 -cljw script.clj # run a file -cljw # REPL -cljw nrepl --port 7888 # nREPL, CIDER-compatible -cljw build script.clj -o app # one self-contained native binary -``` - -## The Wasm FFI in one screen - -```clojure -;; A core module (docs/examples/polyglot/c/kernel.c): numbers in, numbers -;; out, arrays in a buffer the guest owns and hands out by address. -(def m (wasm/load "docs/examples/polyglot/c/kernel.wasm" {:fuel 1000000 :max-memory-pages 16})) -(def buf (wasm/call m "buf_addr")) -(wasm/mem-write! m :f64 buf [1.0 2.0 3.0 4.0]) -(wasm/call m "sum_f64" buf 4) ;=> 10.0 -(wasm/mem-read m :f64 buf 4) ;=> [1.0 2.0 3.0 4.0] - -;; A WIT component: typed, so arguments and results are plain Clojure data. -(def g "test/e2e/fixtures/wasm/greet_component.wasm") -(wasm/component-exports g) ;=> ({:name "greet", ...}) -(def c (wasm/load-component g {:fuel 100000})) -(wasm/component-call c "greet" "zwasm") ;=> "Hello, zwasm!" - -;; A fault in the guest is a catchable Clojure exception, never a host crash. -(try (wasm/call (wasm/load "docs/examples/wasm/trap.wasm") "boom") - (catch Exception e (ex-message e))) ;=> "wasm/call: 'boom' ... trapped ..." -``` - -Budgets: a missing axis keeps the engine's finite default (1e9 fuel, 4096 -pages), `0` removes a cap for a trusted guest, and fuel is per instance, so an -exhausted handle stays exhausted until you load again. The engine is JIT by -default for modules (`:engine :interp` to force the interpreter); components -run on the interpreter. Element types for memory access are the guest's -layout: `:i8 :u8 :i16 :u16 :i32 :u32 :i64 :f32 :f64`. - ## Numbers -Every figure below is generated from a committed measurement file under -[`bench/`](./bench/README.md), not typed by hand, and the release size is -checked against the built binary by a gate on every push. - ```mermaid xychart-beta title "Binary size, MB (cljw 8.14 MB, others measured 2026-07-16)" @@ -236,83 +140,82 @@ xychart-beta bar [1.4, 3.2, 3.2, 10.0, 13.6, 18.8, 28.0, 33.7, 46.4, 231.9] ``` -Persistent collections trade wins with JVM Clojure op by op (20 000 ops at -n = 16 000, Clojure 1.12.4): `assoc` on a map is 3.9× faster on cljw, `conj` -on a vector 4.4× slower; `seq` / `rest` / `next` over a vector are O(1) views -on both and cljw's are about 2× faster. The interpreter is where the JVM wins: -an empty loop iteration costs ~45 ns here against ~2 ns under the JVM JIT, so -tight numeric Clojure loops belong in a Wasm guest or on the JVM. -[Measured, both sides.](./docs/works/collection_performance.md) +- Persistent collections trade wins with JVM Clojure op by op: map `assoc` + 3.9× faster, vector `conj` 4.4× slower ([measured](./docs/works/collection_performance.md)). +- Tight numeric loops are the JVM's: about 45 ns per empty iteration here + against 2 ns under its JIT. Put a hot kernel in a Wasm guest. +- In a guest, the engine runs 2× to 3× behind wasmtime, and one call costs + about 1 µs ([bench](./bench/README.md)). + +The release size is checked against the built binary on every push. + +## Where it fits + +- **Untrusted code in-process.** Plugins, MCP tool servers, handlers that run + user code: a module sees only what you pass it, under a fuel and memory budget. +- **Other languages' libraries.** Reach Rust, C, Go or Zig code through Wasm + instead of a JVM. +- **Existing native tools.** `clojure.java.shell/sh` runs a binary by argv and + returns `{:exit :out :err}`, as on the JVM and babashka. +- **Short-lived processes.** CLIs, scripts and serverless handlers that pay + for startup thousands of times a day. +- **One program, two runtimes.** A host-free `.cljc` runs unchanged on the + JVM, on `cljw` and on its Rust-hosted sibling + [clojurust](https://github.com/BuddhiLW/clojurust) (`cljrs`). -Inside a Wasm guest the embedded engine runs 2× to 3× behind wasmtime on -compute-bound loops (fib: 1.29 s vs 0.49 s, same module, 2026-09-04) and one -`wasm/call` crossing costs about 1 µs. The FFI is for keeping a hot kernel -native, not for beating a standalone runtime; -[the comparison is published as measured](./bench/README.md). +```mermaid +flowchart LR + P["demo.cljc
one Clojure program"] --> A["cljw
Wasm component, zero imports"] + P --> B["cljrs
native cdylib"] + A --> L[("arkworks Groth16
the same Rust crate")] + B --> L +``` -## What runs +| Groth16 over BN254, same crate | cljrs (native) | cljw (Wasm) | +|---|---|---| +| proof, verify | 128 bytes, true / false | 128 bytes, true / false | +| wall time | 1.29 s (debug build) | 9.17 s | +| env vars / file read / clock | 212 / granted / granted | 0 / denied / trap | -`clojure.core` and the everyday standard library: `string`, `set`, `walk`, -`zip`, `edn`, `data.json`, `data.csv`, `math`, `pprint`, `test`, `repl`, -`template`, `tools.cli`, `spec`. STM, atoms, agents, futures, promises, -watches, real threads. Lazy and chunked seqs, transducers, protocols, records, -multimethods, `deftype` / `reify`, the full numeric tower (`Ratio`, `BigInt`, -`BigDecimal`). A `deps.edn`-aware classpath with `:local/root` and `:git` -coordinates, and a CIDER-compatible nREPL. - -Real libraries that load and run today (medley, tools.cli, data.json, -core.cache, malli, hiccup, honeysql, test.check, ...) are tracked in -[`docs/works/ladder.md`](./docs/works/ladder.md). - -Maven `:mvn/version` coordinates resolve Clojure source entries in cached JARs -from `$M2_REPO` (default `~/.m2/repository`). Missing artifacts are downloaded -from Clojars, then Maven Central via HTTPS (`curl`); extracted source roots are -cached under `$CLJW_HOME/mvn` (default `~/.cljw/mvn`). Compile/runtime POM -coordinates are traversed; `org.clojure/clojure` and JARs with no Clojure -sources are omitted. Remote JARs and POMs must match their repository's SHA-1 -sidecar before installation; a missing or mismatched checksum fails closed. -`CLJW_MVN_REPOS` overrides the ordered repository bases as comma-separated URLs -(default Clojars, then Maven Central; `file://` is useful for hermetic tests). -Artifacts already in `$M2_REPO` are trusted. `curl` and `unzip` are required on the host. POM traversal -supports direct compile/runtime dependencies with literal versions or local -properties; it does not implement BOM imports, inherited dependencyManagement, -classifiers, exclusions, or Maven version mediation. JVM-only jars provide no -source path (native Java classes remain unavailable). For example: +Confinement or speed, chosen per deployment, without a rewrite. -```clojure -{:deps {metosin/malli {:mvn/version "0.20.2"}}} -``` +## What runs + +`clojure.core` and the everyday library (`string`, `set`, `walk`, `zip`, +`edn`, `data.json`, `data.csv`, `pprint`, `test`, `spec`, `tools.cli` and more), +STM, agents, futures, real threads, transducers, protocols, records, +multimethods, `deftype` / `reify` and the full numeric tower. `deps.edn` with +`:local/root`, `:git` and `:mvn/version` coordinates; libraries such as malli, +hiccup, honeysql and test.check load today ([ladder](./docs/works/ladder.md)). -What does not: `core.async` is not bundled, and JVM -interop that exists only to reach the JVM (`gen-class`, `proxy` over arbitrary -classes, reflection, `import` of arbitrary Java classes) is out of scope by -design. The full picture, including the intentional divergences, is +Not yet: `core.async`, `java.time.format`, and a strict `clojure.edn` reader. +JVM-only interop (`gen-class`, `proxy` over arbitrary classes, reflection) is +out of scope by design. Details: [`docs/clojure_vs_clojurewasm.md`](./docs/clojure_vs_clojurewasm.md). ## Documentation -[`docs/`](./docs/README.md) is the index. The pieces most people want: +[`docs/`](./docs/README.md) · +[CHANGELOG](./CHANGELOG.md) · +[vs JVM Clojure](./docs/clojure_vs_clojurewasm.md) · +[landscape](./docs/landscape.md) · +[testing](./docs/testing.md) · +[demos](./docs/works/demos.md) · +[roadmap](./.dev/ROADMAP.md) -- [`CHANGELOG.md`](./CHANGELOG.md), every release, newest first. -- [`docs/clojure_vs_clojurewasm.md`](./docs/clojure_vs_clojurewasm.md), what - matches JVM Clojure, what deliberately does not, what is missing. -- [`docs/landscape.md`](./docs/landscape.md), where cljw sits among Clojure - runtimes, `cljrs` included. -- [`docs/testing.md`](./docs/testing.md), the eight test layers and where a - new test belongs. Read it before sending a patch. -- [`docs/works/demos.md`](./docs/works/demos.md), two real applications - (a browser playground and a bookshelf with SQLite as `sqlite3.wasm`) whose - source still builds. -- [`.dev/ROADMAP.md`](./.dev/ROADMAP.md), the plan. +Issues and PRs are welcome at +[BuddhiLW/ClojureWasm](https://github.com/BuddhiLW/ClojureWasm/issues); the most +useful report is Clojure code that behaves differently here than on the JVM. ## License and credit -Eclipse Public License 2.0, see [LICENSE](./LICENSE) and -[NOTICE](./legal/NOTICE). +Eclipse Public License 2.0 ([LICENSE](./LICENSE), [NOTICE](./legal/NOTICE)). -ClojureWasm was created by [Shota Kudo (@chaploud)](https://github.com/chaploud), -who took it from nothing to a working JVM-free Clojure runtime with a Wasm FFI -through v1.10, and in 2026-10 passed the project on to -[@BuddhiLW](https://github.com/BuddhiLW). Everything shipped since is built on -that work. The embedded -engine is [zwasm](https://github.com/zwasm/zwasm). +Created by [Shota Kudo (@chaploud)](https://github.com/chaploud), who built it +from nothing to a working JVM-free Clojure with a Wasm FFI through v1.10.1. In +2026-10 the project passed to [@BuddhiLW](https://github.com/BuddhiLW); the +fork's work (v1.11 to v1.15.0) is merged back into +[clojurewasm/ClojureWasm](https://github.com/clojurewasm/ClojureWasm), while +releases and the Homebrew tap publish from +[BuddhiLW/ClojureWasm](https://github.com/BuddhiLW/ClojureWasm). The engine is +[zwasm](https://github.com/zwasm/zwasm). diff --git a/build.zig.zon b/build.zig.zon index 9b041366a..8bea727f0 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .cljw, - .version = "1.15.0", + .version = "1.15.1", .fingerprint = 0x1869d207073beffa, .minimum_zig_version = "0.16.0", .dependencies = .{ @@ -14,7 +14,15 @@ // zwasm v2 embedding API (F-001 / D-037). `lazy` so the DEFAULT build + // gate never resolve it — only `-Dwasm` (b.lazyDependency) does. // - // PINNED to the `cljw` branch of THIS FORK, BuddhiLW/zwasm + // 2026-10-21: PINNED to `main` of THIS FORK, BuddhiLW/zwasm, at + // d74e63b4 (fork main fast-forwarded to upstream zwasm/zwasm main, + // version string still 2.7.0, no newer tag). Upstream now carries all + // three cljw fixes listed below (851cd0e7 component re-export, + // f3067d03 metadata-hash, 767b808a PreparedWasi), so the `cljw` + // branch's patches are no longer needed. The pin returns to a + // zwasm/zwasm tag when the next one is cut. + // + // Before 2026-10-21: PINNED to the `cljw` branch of THIS FORK, BuddhiLW/zwasm // (2026-09-14, user direction: "use our zwasm for now"). That branch // is upstream v2.7.0 (d09d9248) plus two fixes found from cljw that // upstream does not carry yet: every exported interface of a @@ -60,8 +68,8 @@ // moment anything else claims that name. The `.hash` is // content-addressed, so the host of the URL never changes it. .zwasm = .{ - .url = "git+https://github.com/BuddhiLW/zwasm.git?ref=cljw#70c8b7ee60e66115e3bf1030e1d657f772e7b99f", - .hash = "zwasm-2.7.0-FT1FvzKOnADpIAKlkOhKgEpJcG6LFoKrWsUAjd-jSCid", + .url = "git+https://github.com/BuddhiLW/zwasm.git?ref=main#d74e63b4aac1c7d392fc7e562467232945788dfe", + .hash = "zwasm-2.7.0-FT1Fv-zingBTL0it1Jtud_E9LvRZ6jkeZWH66zgwYpsw", .lazy = true, }, }, diff --git a/data/placement.yaml b/data/placement.yaml index baf8a5848..04d946bb4 100644 --- a/data/placement.yaml +++ b/data/placement.yaml @@ -24,8 +24,8 @@ schema_version: "2.0" summary: namespaces_required: 36 - total: 1455 - zig: 423 + total: 1456 + zig: 424 clj: 806 value: 220 unbound: 6 @@ -1478,6 +1478,7 @@ vars: "wasm/component-exports": { impl: zig, flags: "-" } "wasm/component-invoke": { impl: zig, flags: "-" } "wasm/engine": { impl: zig, flags: "-" } + "wasm/exports": { impl: zig, flags: "-" } "wasm/load": { impl: zig, flags: "-" } "wasm/load-component": { impl: zig, flags: "-" } "wasm/mem-read": { impl: zig, flags: "-" } diff --git a/docs/assets/buddhilw_logo.png b/docs/assets/buddhilw_logo.png new file mode 100644 index 000000000..51e078de3 Binary files /dev/null and b/docs/assets/buddhilw_logo.png differ diff --git a/docs/examples/wasm/README.md b/docs/examples/wasm/README.md index 4cd63e458..6d8caa80d 100644 --- a/docs/examples/wasm/README.md +++ b/docs/examples/wasm/README.md @@ -14,6 +14,22 @@ sandboxed `.wasm` and invokes its exports. name, marshalling arguments and results from the export's signature (`i32`/`i64`/`f32`/`f64` today). +A module handle also answers Clojure's host-interop method syntax (ADR-0203): +the method name is the export name, verbatim, and the call is `wasm/call`'s +own path, so results, fuel limits and traps behave the same. + +```clojure +(.add m 40 2) ;=> 42 +(. m add 40 2) ;=> 42 +(. m (add 40 2)) ;=> 42 + +;; A C kernel (../polyglot/c/kernel.c): C names work as written. +(def k (wasm/load "docs/examples/polyglot/c/kernel.wasm")) +(def buf (.buf_addr k)) +(wasm/mem-write! k :f64 buf [1.0 2.0 3.0 4.0]) +(.sum_f64 k buf 4) ;=> 10.0 +``` + ## Run it The **released binaries** (GitHub releases, `brew install diff --git a/docs/spec/formats/chunk-v13.edn b/docs/spec/formats/chunk-v13.edn new file mode 100644 index 000000000..e19d50431 --- /dev/null +++ b/docs/spec/formats/chunk-v13.edn @@ -0,0 +1,17 @@ +;; Add-only bytecode archive amendment, 2026-10-14 (ADR-0034 D11, ADR-0198, +;; ADR-0201, [CLJW-CTOR-ANALYZE-TIME]). Earlier records remain unchanged. +;; v13 keeps the chunk-body framing of chunk-v12.edn except for one field +;; added to each ctor_sites entry, so a decoder of an older version must +;; reject the chunk. +{:chunk-format + {:version 13 + :magic "CLJW" + :base-layout "chunk-v12.edn" + :semantic-history + [{:version 13 + :change "A ctor site carries definer_ns, the namespace its `(Name. args)` form was analyzed in, so a bare user-type name resolves to . before any reading the calling namespace could give it (ADR-0198, ADR-0201)."}] + :ctor-site + {:fields [{:name :type_name :body "u32 length, then bytes"} + {:name :definer_ns :body "u32 length, then bytes; empty when the name was qualified at analysis or no namespace was current"} + {:name :arg_count :body "u16"}]} + :compatibility "The runtime rejects a chunk version unequal to 13; rebuild older payloads."}} diff --git a/src/eval/analyzer/analyzer.zig b/src/eval/analyzer/analyzer.zig index 6a29ac4b3..5723828b2 100644 --- a/src/eval/analyzer/analyzer.zig +++ b/src/eval/analyzer/analyzer.zig @@ -2444,6 +2444,27 @@ test "ctor call (Foo. ...) analyses into interop_call_node .constructor" { try testing.expect(n.interop_call_node.kind == .constructor); try testing.expectEqualStrings("Foo", n.interop_call_node.type_name); try testing.expectEqual(@as(usize, 2), n.interop_call_node.args.len); + // [CLJW-CTOR-ANALYZE-TIME]: a bare type name carries the namespace it was + // analyzed in, so eval-time lookup qualifies it lexically. + try testing.expectEqualStrings(fix.env.current_ns.?.name, n.interop_call_node.definer_ns); +} + +test "constructInstance resolves . before the last-wins simple name" { + const td_mod = @import("../../runtime/type_descriptor.zig"); + var fix: TestFixture = undefined; + try fix.init(testing.allocator); + defer fix.deinit(); + // aaa.Point has two fields; bbb.Point, registered later, has one, so a + // simple-name lookup would pick bbb's and reject two arguments. + const aaa = try td_mod.registerType(&fix.rt, "Point", "aaa", &.{ "x", "y" }, &.{}, .defrecord); + const bbb = try td_mod.registerType(&fix.rt, "Point", "bbb", &.{"q"}, &.{}, .defrecord); + const two = [_]Value{ Value.initInteger(1), Value.initInteger(2) }; + const a = try special_forms.constructInstance(&fix.rt, &fix.env, "Point", "aaa", &two, .{}); + try testing.expect(a.tag() == .typed_instance); + try testing.expect(a.decodePtr(*const td_mod.TypedInstance).descriptor == aaa); + const one = [_]Value{Value.initInteger(9)}; + const b = try special_forms.constructInstance(&fix.rt, &fix.env, "Point", "bbb", &one, .{}); + try testing.expect(b.decodePtr(*const td_mod.TypedInstance).descriptor == bbb); } test "member access (.x inst) analyses into interop_call_node .instance_member" { diff --git a/src/eval/analyzer/special_forms.zig b/src/eval/analyzer/special_forms.zig index ffccaa65c..27681b7d3 100644 --- a/src/eval/analyzer/special_forms.zig +++ b/src/eval/analyzer/special_forms.zig @@ -104,9 +104,22 @@ pub fn constructInstance( rt: *Runtime, env: *Env, type_name: []const u8, + /// The namespace the ctor form was analyzed in ("" when none). A user + /// type registered as `.` wins over every other + /// reading of the bare name, as a class name clj compiled there would. + definer_ns: []const u8, args: []const Value, loc: error_catalog.SourceLocation, ) anyerror!Value { + if (definer_ns.len != 0) { + var qbuf: [512]u8 = undefined; + if (std.fmt.bufPrint(&qbuf, "{s}.{s}", .{ definer_ns, type_name })) |qualified| { + if (rt.types.get(qualified)) |td| return constructFromDescriptor(rt, env, td, type_name, args, loc); + } else |_| { + // Namespace + name longer than the buffer: no registered key + // could match it either, so fall through to the steps below. + } + } // D-284: `(MapEntry. k v)` / `(new clojure.lang.MapEntry k v)` constructs an // entry, so collection libs that build entries explicitly (priority-map, // ordered, …) work. It used to return a plain 2-vector, from when a map entry @@ -151,9 +164,23 @@ pub fn constructInstance( } const td = resolveJavaSurface(rt, env, type_name) orelse return error_catalog.raise(.symbol_unresolved, loc, .{ .sym = type_name }); + return constructFromDescriptor(rt, env, td, type_name, args, loc); +} + +/// The descriptor-driven half of `constructInstance`: a field layout +/// (deftype / record) allocates an instance, a `` entry (java surface) +/// is called through the runtime vtable, anything else takes no constructor. +fn constructFromDescriptor( + rt: *Runtime, + env: *Env, + td: *const type_descriptor_mod.TypeDescriptor, + type_name: []const u8, + args: []const Value, + loc: error_catalog.SourceLocation, +) anyerror!Value { if (td.field_layout) |fl| { if (args.len != fl.len) - return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = type_name, .expected = fl.len }); + return error_catalog.raise(.arity_not_expected, loc, .{ .got = args.len, .fn_name = td.fqcn orelse type_name, .expected = fl.len }); return type_descriptor_mod.allocInstance(rt, td, args); } if (td.lookupMethod(null, "")) |me| { @@ -261,14 +288,20 @@ pub fn analyzeCtorCall( // lexically correct one. Eval-time resolution would consult the CALLER's // ns and miss it (hiccup.util's `(URI. s)` inside to-uri, called from // user code). Guarded on the FQCN actually resolving as a registered - // surface. A USER deftype keeps the bare spelling here and resolves on - // the eval-time path (which also allows forward refs to later deftypes); - // since ADR-0198 that path finds it by qualifying the bare name with the - // current ns inside `host_class_resolve.resolve`, rather than by the - // simple-name registry hit this comment used to describe. + // surface. + // + // A USER type keeps the bare spelling and resolves at eval time, which is + // what lets a `(Name. …)` inside the defrecord's own expansion refer to a + // type registered a moment later. The namespace of THIS analysis rides + // along as `definer_ns` so that eval-time lookup qualifies the name + // lexically (ADR-0198): the current ns at eval time is the CALLER's, and + // a bare `Point` there fell to the last-wins simple-name index, so + // `aaa/->Point` built whichever `Point` any namespace registered last. var resolved_name = type_name; + var definer_ns: []const u8 = ""; if (std.mem.findScalar(u8, type_name, '.') == null) { if (env.current_ns) |cur_ns| { + definer_ns = try arena.dupe(u8, cur_ns.name); if (cur_ns.imports.get(type_name)) |fqcn| { if (rt.types.get(fqcn) != null) { resolved_name = fqcn; @@ -288,6 +321,7 @@ pub fn analyzeCtorCall( n.* = .{ .interop_call_node = .{ .kind = .constructor, .type_name = resolved_name, + .definer_ns = if (resolved_name.ptr == type_name.ptr) definer_ns else "", .args = args, .loc = form.location, } }; diff --git a/src/eval/backend/tree_walk.zig b/src/eval/backend/tree_walk.zig index ba3632d82..3430533c3 100644 --- a/src/eval/backend/tree_walk.zig +++ b/src/eval/backend/tree_walk.zig @@ -722,7 +722,7 @@ fn evalConstructorCall(rt: *Runtime, env: *Env, locals: []Value, n: node_mod.Int buf[i] = try eval(rt, env, locals, &arg_node); af.publish(i + 1); } - return special_forms.constructInstance(rt, env, n.type_name, buf[0..n.args.len], n.loc); + return special_forms.constructInstance(rt, env, n.type_name, n.definer_ns, buf[0..n.args.len], n.loc); } /// `(.member recv args...)` / `(.-field recv)` unified instance member @@ -777,6 +777,9 @@ fn evalInstanceMember(rt: *Runtime, env: *Env, locals: []Value, n: node_mod.Inte } const me = td.lookupMethod(null, n.name) orelse { + // ADR-0203: a core wasm module handle answers `(.export h …)` through + // wasm/call's path (hook installed only under -Dwasm). + if (receiver.tag() == .wasm_module) if (rt.wasm_member_call) |call| return call(rt, receiver, n.name, args_buf[1..], n.loc); // Universal java.lang.Object method fallback (D-207): str/=/hash/class. if (try object_method.tryObjectMethod(rt, env, receiver, td, n.name, args_buf[1..])) |r| return r; // clojure.lang read/op methods on a NATIVE collection (D-371): `.valAt`/ diff --git a/src/eval/backend/vm.zig b/src/eval/backend/vm.zig index b8a0551f0..0199de64f 100644 --- a/src/eval/backend/vm.zig +++ b/src/eval/backend/vm.zig @@ -527,7 +527,7 @@ inline fn stepOnce( }, .op_load_local => { if (instr.operand >= locals.len) - return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "Local", .index = instr.operand, .max = locals.len }); + return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "Local", .index = instr.operand, .max = locals.len }); if (sp >= stack.len) return operandOverflow(instr_loc); stack[sp] = locals[instr.operand]; @@ -535,7 +535,7 @@ inline fn stepOnce( }, .op_store_local => { if (instr.operand >= locals.len) - return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "let*", .index = instr.operand, .max = locals.len }); + return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "let*", .index = instr.operand, .max = locals.len }); if (sp == 0) return raiseInternal("vm: op_store_local on empty stack"); sp -= 1; locals[instr.operand] = stack[sp]; @@ -600,7 +600,7 @@ inline fn stepOnce( return raiseInternal("vm: op_ns_import site index out of range"); const imp = chunk.import_sites[instr.operand]; const here = env.current_ns orelse - return error_catalog.raise(.current_namespace_missing, .{}, .{ .sym = imp.simple }); + return error_catalog.raise(.current_namespace_missing, instr_loc, .{ .sym = imp.simple }); try here.addImport(env.alloc, imp.simple, imp.fqcn); if (sp >= stack.len) return operandOverflow(instr_loc); @@ -618,7 +618,7 @@ inline fn stepOnce( if (!env_mod.setBinding(var_ptr, val)) { const full = try std.fmt.allocPrint(rt.gpa, "{s}/{s}", .{ var_ptr.ns.name, var_ptr.name }); defer rt.gpa.free(full); - return error_catalog.raise(.var_set_not_bound, .{}, .{ .@"var" = full }); + return error_catalog.raise(.var_set_not_bound, instr_loc, .{ .@"var" = full }); } }, .op_set_field => { @@ -644,7 +644,7 @@ inline fn stepOnce( break; } } - if (!wrote) return error_catalog.raise(.symbol_unresolved, .{}, .{ .sym = field_name }); + if (!wrote) return error_catalog.raise(.symbol_unresolved, instr_loc, .{ .sym = field_name }); sp -= 2; stack[sp] = value; sp += 1; @@ -860,7 +860,7 @@ inline fn stepOnce( const lslot: usize = instr.operand >> 8; const cidx: usize = instr.operand & 0xFF; if (lslot >= locals.len) - return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "Local", .index = @as(u16, @intCast(lslot)), .max = locals.len }); + return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "Local", .index = @as(u16, @intCast(lslot)), .max = locals.len }); if (cidx >= chunk.constants.len) return raiseInternal("vm: local_const constant index out of range"); const a = locals[lslot]; @@ -893,7 +893,7 @@ inline fn stepOnce( const sa: usize = instr.operand >> 8; const sb: usize = instr.operand & 0xFF; if (sa >= locals.len or sb >= locals.len) - return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "Local", .index = @as(u16, @intCast(if (sa >= locals.len) sa else sb)), .max = locals.len }); + return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "Local", .index = @as(u16, @intCast(if (sa >= locals.len) sa else sb)), .max = locals.len }); const a = locals[sa]; const b = locals[sb]; const aop = intrinsic.fromLocalsOpcode(instr_op).?; @@ -931,7 +931,7 @@ inline fn stepOnce( const offset: i16 = @bitCast(chunk.instructions[ip].operand); ip += 1; if (sa >= locals.len) - return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "Local", .index = @as(u16, @intCast(sa)), .max = locals.len }); + return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "Local", .index = @as(u16, @intCast(sa)), .max = locals.len }); const a = locals[sa]; const b = if (info.b_is_const) bblk: { if (sb >= chunk.constants.len) @@ -939,7 +939,7 @@ inline fn stepOnce( break :bblk chunk.constants[sb]; } else bblk: { if (sb >= locals.len) - return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "Local", .index = @as(u16, @intCast(sb)), .max = locals.len }); + return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "Local", .index = @as(u16, @intCast(sb)), .max = locals.len }); break :bblk locals[sb]; }; const fast: ?Value = if (rt.core_arith_pristine) @@ -975,7 +975,7 @@ inline fn stepOnce( ip += 1; if (sp < nb) return raiseInternal("vm: op_recur_loop underflow"); if (base + nb > locals.len) - return error_catalog.raise(.slot_out_of_range, .{}, .{ .form = "loop*", .index = @as(u16, @intCast(base + nb)), .max = locals.len }); + return error_catalog.raise(.slot_out_of_range, instr_loc, .{ .form = "loop*", .index = @as(u16, @intCast(base + nb)), .max = locals.len }); const top: u16 = sp - @as(u16, @intCast(nb)); var k: usize = 0; while (k < nb) : (k += 1) { @@ -1096,7 +1096,7 @@ inline fn stepOnce( // through `op_call` + vtable). Per // `no_op_stub_forbidden.md`, raise rather than fall // through silently. - return error_catalog.raise(.feature_not_supported, .{}, .{ .name = "op_invoke_builtin" }); + return error_catalog.raise(.feature_not_supported, instr_loc, .{ .name = "op_invoke_builtin" }); }, .op_push_handler => { const offset: i16 = @bitCast(instr.operand); @@ -1303,7 +1303,7 @@ inline fn stepOnce( // lib_not_found renders the caret (tree_walk parity). const target_ns = try loader.loadOrFindNs(rt, env, spec.ns_name, instr_loc); const here = env.current_ns orelse - return error_catalog.raise(.current_namespace_missing, .{}, .{ .sym = spec.ns_name }); + return error_catalog.raise(.current_namespace_missing, instr_loc, .{ .sym = spec.ns_name }); if (spec.alias) |alias_name| { try env.setAlias(here, alias_name, target_ns); } @@ -1319,7 +1319,7 @@ inline fn stepOnce( .private_blocked => { const full = try std.fmt.allocPrint(rt.gpa, "{s}/{s}", .{ spec.ns_name, refer_name }); defer rt.gpa.free(full); - return error_catalog.raise(.private_access_error, .{}, .{ + return error_catalog.raise(.private_access_error, instr_loc, .{ .sym = full, .ns = spec.ns_name, }); @@ -1327,7 +1327,7 @@ inline fn stepOnce( .not_found => { const full = try std.fmt.allocPrint(rt.gpa, "{s}/{s}", .{ spec.ns_name, refer_name }); defer rt.gpa.free(full); - return error_catalog.raise(.symbol_unresolved, .{}, .{ .sym = full }); + return error_catalog.raise(.symbol_unresolved, instr_loc, .{ .sym = full }); }, } } @@ -1373,7 +1373,7 @@ inline fn stepOnce( // Shared resolver/dispatcher (deftype/record + java-surface // ``) — identical to TreeWalk's evalConstructorCall so a // `(java.io.File. …)` ctor works on both backends. - const new_val = try special_forms.constructInstance(rt, env, type_name, args_slice, .{}); + const new_val = try special_forms.constructInstance(rt, env, type_name, ctor.definer_ns, args_slice, instr_loc); sp -= arg_count; stack[sp] = new_val; sp += 1; @@ -1419,11 +1419,11 @@ inline fn stepOnce( stack[sp] = fv; sp += 1; } else if (cs_entry.field_only) { - return error_catalog.raise(.symbol_unresolved, .{}, .{ .sym = cs_entry.method_name }); + return error_catalog.raise(.symbol_unresolved, instr_loc, .{ .sym = cs_entry.method_name }); } else { if (cs_entry.cache.lookupWithCache(td, null, cs_entry.method_name, rt.protocol_generation)) |me| { if (me.method_val.tag() == .nil) - return error_catalog.raise(.feature_not_supported, .{}, .{ .name = "method declared but not implemented" }); + return error_catalog.raise(.feature_not_supported, instr_loc, .{ .name = "method declared but not implemented" }); const args_slice = stack[sp - arg_count .. sp]; const vt = rt.vtable orelse return error.NoVTable; // Frame the user `/` so the interop form @@ -1435,6 +1435,13 @@ inline fn stepOnce( sp -= arg_count; stack[sp] = result; sp += 1; + } else if (receiver.tag() == .wasm_module and rt.wasm_member_call != null) { + // ADR-0203: `(.export h …)` on a core wasm module handle is + // wasm/call's path — mirrors TreeWalk's evalInstanceMember. + const result = try rt.wasm_member_call.?(rt, receiver, cs_entry.method_name, stack[sp - arg_count + 1 .. sp], instr_loc); + sp -= arg_count; + stack[sp] = result; + sp += 1; } else if (try object_method.tryObjectMethod(rt, env, receiver, td, cs_entry.method_name, stack[sp - arg_count + 1 .. sp])) |r| { // Universal java.lang.Object method fallback: // str/=/hash/class — mirrors TreeWalk's evalInstanceMember. @@ -1448,7 +1455,7 @@ inline fn stepOnce( stack[sp] = r; sp += 1; } else { - return error_catalog.raise(.protocol_no_satisfies, .{}, .{ + return error_catalog.raise(.protocol_no_satisfies, instr_loc, .{ .protocol = "<.member>", .method = cs_entry.method_name, .type_name = td.fqcn orelse "", @@ -1470,17 +1477,17 @@ inline fn stepOnce( const td = cs_entry.descriptor orelse return raiseInternal("vm: op_static_method_call missing descriptor (compiler bug)"); const me = td.lookupMethod(null, cs_entry.method_name) orelse { - return error_catalog.raise(.protocol_no_satisfies, .{}, .{ + return error_catalog.raise(.protocol_no_satisfies, instr_loc, .{ .protocol = "", .method = cs_entry.method_name, .type_name = td.fqcn orelse "", }); }; if (me.method_val.tag() == .nil) - return error_catalog.raise(.feature_not_supported, .{}, .{ .name = "static method declared but not implemented" }); + return error_catalog.raise(.feature_not_supported, instr_loc, .{ .name = "static method declared but not implemented" }); const args_slice = stack[sp - arg_count .. sp]; const vt = rt.vtable orelse return error.NoVTable; - const result = try vt.callFn(rt, env, me.method_val, args_slice, .{}); + const result = try vt.callFn(rt, env, me.method_val, args_slice, instr_loc); sp -= arg_count; stack[sp] = result; // Result loc = this call form (pops below sp_entry, so the sweep misses it). diff --git a/src/eval/backend/vm/compiler.zig b/src/eval/backend/vm/compiler.zig index d9f2dda8f..8569bf20c 100644 --- a/src/eval/backend/vm/compiler.zig +++ b/src/eval/backend/vm/compiler.zig @@ -193,6 +193,9 @@ const Compiler = struct { fn compileSet(self: *Compiler, n: node_mod.SetNode) Error!void { try self.compileNode(n.value_expr); const idx = try self.addConstant(Value.encodeHeapPtr(.var_ref, n.var_ptr)); + // The value expr re-stamped `current_*`; op_set_var raises at the + // set! form itself (TreeWalk evalSet's n.loc, ADR-0036 error half). + self.stampLoc(n.loc); try self.emit(.op_set_var, idx); } @@ -205,6 +208,7 @@ const Compiler = struct { try self.compileNode(n.value_expr); const name_val = try string_mod.alloc(self.rt, n.field_name); const idx = try self.addConstant(name_val); + self.stampLoc(n.loc); try self.emit(.op_set_field, idx); } @@ -738,8 +742,12 @@ const Compiler = struct { const name_dup = try self.arena.dupe(u8, n.type_name); try self.ctor_sites.append(self.arena, .{ .type_name = name_dup, + .definer_ns = try self.arena.dupe(u8, n.definer_ns), .arg_count = @intCast(n.args.len), }); + // Raise at the ctor form, not its last argument + // (TreeWalk evalConstructorCall's n.loc, ADR-0036 error half). + self.stampLoc(n.loc); try self.emit(.op_ctor_call, ctor_idx); }, .instance_member => { @@ -757,6 +765,9 @@ const Compiler = struct { .arg_count = total_args, .field_only = n.field_only, }); + // Raise at the interop form, not its last argument + // (TreeWalk evalInstanceMember's n.loc, ADR-0036 error half). + self.stampLoc(n.loc); try self.emit(.op_method_call, cs_idx); }, .static_method => { @@ -773,6 +784,7 @@ const Compiler = struct { .arg_count = arg_count, .descriptor = td, }); + self.stampLoc(n.loc); try self.emit(.op_static_method_call, cs_idx); }, } diff --git a/src/eval/backend/vm/opcode.zig b/src/eval/backend/vm/opcode.zig index 88d7337d1..a1526d73a 100644 --- a/src/eval/backend/vm/opcode.zig +++ b/src/eval/backend/vm/opcode.zig @@ -682,9 +682,12 @@ pub const LibspecEntry = struct { /// here (not packed into the operand) so it carries full width — the old /// `(name_idx << 8) | arg_count` packing truncated name_idx to 8 bits and /// corrupted ctor calls in chunks with > 255 constants. `type_name` is -/// analyzer-arena-owned, chunk lifetime. +/// analyzer-arena-owned, chunk lifetime. `definer_ns` is the namespace the +/// form was analyzed in (empty when none), which qualifies a bare user-type +/// name at run time, where the current namespace is the caller's. pub const CtorEntry = struct { type_name: []const u8, + definer_ns: []const u8 = "", arg_count: u16, }; diff --git a/src/eval/bytecode/serialize.zig b/src/eval/bytecode/serialize.zig index 336c1c476..7e607f8da 100644 --- a/src/eval/bytecode/serialize.zig +++ b/src/eval/bytecode/serialize.zig @@ -29,7 +29,8 @@ //! + has_only:u8 + (only_count:u32 //! + each: only_len:u32 + bytes)? //! [...] ctor_sites_count (u32 LE) -//! for each entry: type_name_len:u32 + bytes + arg_count:u16 +//! for each entry: type_name_len:u32 + bytes +//! + definer_ns_len:u32 + bytes + arg_count:u16 //! [...] import_sites_count (u32 LE) //! for each entry: simple_len:u32 + bytes + fqcn_len:u32 + bytes //! @@ -78,6 +79,10 @@ const tree_walk = @import("../backend/tree_walk.zig"); const Function = tree_walk.Function; pub const MAGIC: [4]u8 = .{ 'C', 'L', 'J', 'W' }; +// v13: a ctor site carries `definer_ns`, the namespace its form was analyzed +// in, so a bare user-type name is qualified lexically instead of by the +// caller's namespace (ADR-0198, ADR-0201). A v12 ctor site has no such field, +// so v12 is rejected. // v12 (D-346): the op_*_extend opcodes build a large literal in bounded steps, // and a folded constant literal may be a hash_map (new wire tag) or a set past // the array-map threshold. A v11 decoder cannot read either, so v11 is rejected. @@ -89,7 +94,7 @@ pub const MAGIC: [4]u8 = .{ 'C', 'L', 'J', 'W' }; // v10: def operands capture Var constants instead of caller-relative names. // v9 added op_var_meta; v8 unified host-class FQCNs; v7 compacted WireInstr // and interned the name pool; v6 merged rt into clojure.core. -pub const VERSION: u16 = 12; +pub const VERSION: u16 = 13; pub const SerializeError = error{ OutOfMemory, @@ -852,6 +857,7 @@ fn writeChunkBody(ctx: *WriteCtx, w: *std.Io.Writer, chunk: BytecodeChunk) Seria try writeU32(w, @intCast(chunk.ctor_sites.len)); for (chunk.ctor_sites) |ct| { try writeLenPrefixed(w, ct.type_name); + try writeLenPrefixed(w, ct.definer_ns); try writeU16(w, ct.arg_count); } try writeU32(w, @intCast(chunk.import_sites.len)); @@ -1039,8 +1045,10 @@ fn readChunkBody(ctx: *ReadCtx, r: *ByteReader) DeserializeError!BytecodeChunk { while (i < ctor_count) : (i += 1) { const tn_bytes = try r.readLenPrefixed(); const tn_dup = try allocator.dupe(u8, tn_bytes); + const dn_bytes = try r.readLenPrefixed(); + const dn_dup = try allocator.dupe(u8, dn_bytes); const arg_count = try r.readU16(); - ctor_sites[i] = .{ .type_name = tn_dup, .arg_count = arg_count }; + ctor_sites[i] = .{ .type_name = tn_dup, .definer_ns = dn_dup, .arg_count = arg_count }; } const import_count = try r.readU32(); @@ -1132,7 +1140,10 @@ pub fn freeChunk(allocator: std.mem.Allocator, chunk: BytecodeChunk) void { if (nf.doc) |d| allocator.free(d); } allocator.free(chunk.ns_filters); - for (chunk.ctor_sites) |ct| allocator.free(ct.type_name); + for (chunk.ctor_sites) |ct| { + allocator.free(ct.type_name); + allocator.free(ct.definer_ns); + } allocator.free(chunk.ctor_sites); for (chunk.import_sites) |ip| { allocator.free(ip.simple); @@ -2077,7 +2088,7 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365 } inline for (std.meta.fields(CtorEntry)) |f| { const classified: bool = switch (@field(std.meta.FieldEnum(CtorEntry), f.name)) { - .type_name, .arg_count => true, + .type_name, .definer_ns, .arg_count => true, }; try testing.expect(classified); } @@ -2119,6 +2130,7 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365 }; const ctor_sites = [_]CtorEntry{ .{ .type_name = "java.io.File", .arg_count = 1 }, + .{ .type_name = "Point", .definer_ns = "app.core", .arg_count = 2 }, }; const import_sites = [_]ImportPair{ .{ .simple = "File", .fqcn = "java.io.File" }, @@ -2179,9 +2191,13 @@ test "chunk completeness gate: every side-table + entry field round-trips (D-365 try testing.expectEqual(@as(u32, 7), round.ns_filters[0].attr_const); // ctor_sites - try testing.expectEqual(@as(usize, 1), round.ctor_sites.len); + try testing.expectEqual(@as(usize, 2), round.ctor_sites.len); try testing.expectEqualStrings("java.io.File", round.ctor_sites[0].type_name); + try testing.expectEqualStrings("", round.ctor_sites[0].definer_ns); try testing.expectEqual(@as(u16, 1), round.ctor_sites[0].arg_count); + try testing.expectEqualStrings("Point", round.ctor_sites[1].type_name); + try testing.expectEqualStrings("app.core", round.ctor_sites[1].definer_ns); + try testing.expectEqual(@as(u16, 2), round.ctor_sites[1].arg_count); // import_sites try testing.expectEqual(@as(usize, 1), round.import_sites.len); diff --git a/src/eval/evaluator.zig b/src/eval/evaluator.zig index 98f07e7a8..58c84cfeb 100644 --- a/src/eval/evaluator.zig +++ b/src/eval/evaluator.zig @@ -32,6 +32,7 @@ const tree_walk = @import("backend/tree_walk.zig"); const vm = @import("backend/vm.zig"); const vm_compiler = @import("backend/vm/compiler.zig"); const driver = @import("driver.zig"); +const error_info = @import("../runtime/error/info.zig"); pub const CompareResult = struct { tree_walk: anyerror!Value, @@ -39,8 +40,40 @@ pub const CompareResult = struct { /// `true` when both backends produced the same NaN-boxed bit pattern, or /// failed with the same Zig error. equal: bool, + /// Source position of the error each backend raised; null when that + /// backend returned a value. Only `line`/`column` are captured: the + /// `file` field of a fixture-analysed form is "unknown" on both + /// backends and carries no signal. + tree_walk_loc: ?ErrorPos = null, + vm_loc: ?ErrorPos = null, + /// `true` when both backends agree on WHERE the failure is: neither + /// raised, or both raised at the same line:column. The error half of + /// the dual-backend contract (ADR-0036). A backend that raises the + /// same Kind at 0:0 while the other reports 3:11 is a parity break, + /// invisible to `equal` (which only compares returned Values). + loc_equal: bool = true, }; +/// The comparable part of a raised error's `SourceLocation`. +pub const ErrorPos = struct { + line: u32, + column: u16, +}; + +/// The position of the error a just-finished backend run raised, or null +/// when the run returned a value. Reads the threadlocal payload without +/// clearing it, so the caller's own error handling still sees it; must be +/// called before the next run overwrites the slot. A run that failed +/// without publishing a payload (a user throw, OOM) reports 0:0, the same +/// position a dropped location has, rather than null ("did not raise"). +fn errorPos(result: anyerror!Value) ?ErrorPos { + _ = result catch { + const info = error_info.peekLastError() orelse return .{ .line = 0, .column = 0 }; + return .{ .line = info.location.line, .column = info.location.column }; + }; + return null; +} + pub fn compare( rt: *Runtime, env: *Env, @@ -48,11 +81,20 @@ pub fn compare( arena: std.mem.Allocator, source: []const u8, ) CompareResult { + // runOnce does not reset the threadlocal error slot, so clear it before + // each run: a stale payload from an earlier raise must not be read as + // this run's position. + error_info.clearLastError(); tree_walk.installVTable(rt); const tw_value = runOnce(rt, env, table, arena, source, .tree_walk); + // Read the raise position NOW: the threadlocal payload is a single slot + // that the VM run below overwrites. + const tw_loc = errorPos(tw_value); + error_info.clearLastError(); vm.installVTable(rt); const vm_value = runOnce(rt, env, table, arena, source, .vm); + const vm_loc = errorPos(vm_value); const equal = if (tw_value) |tw| blk: { const vm_v = vm_value catch break :blk false; @@ -61,7 +103,18 @@ pub fn compare( _ = vm_value catch |vm_err| break :blk tw_err == vm_err; break :blk false; }; - return .{ .tree_walk = tw_value, .vm = vm_value, .equal = equal }; + const loc_equal = if (tw_loc) |a| + if (vm_loc) |b| a.line == b.line and a.column == b.column else false + else + vm_loc == null; + return .{ + .tree_walk = tw_value, + .vm = vm_value, + .equal = equal, + .tree_walk_loc = tw_loc, + .vm_loc = vm_loc, + .loc_equal = loc_equal, + }; } pub const BackendChoice = enum { tree_walk, vm }; diff --git a/src/eval/node.zig b/src/eval/node.zig index a3c8d5c0a..b9a63bd61 100644 --- a/src/eval/node.zig +++ b/src/eval/node.zig @@ -425,6 +425,12 @@ pub const InteropCallNode = struct { /// `.constructor`: type name string for eval-time `resolveJavaSurface` /// lookup. Other kinds: empty string. type_name: []const u8 = "", + /// `.constructor` with a dot-free `type_name`: the namespace the form was + /// ANALYZED in. A user type is keyed `.` (ADR-0198) and clj + /// binds a class name lexically, so the eval-time lookup tries + /// `.` before anything the caller's namespace + /// could answer. Empty when there is none. + definer_ns: []const u8 = "", /// `.static_method` / `.instance_member`: method or field name. /// `.constructor`: ignored. name: []const u8 = "", diff --git a/src/lang/diff_test.zig b/src/lang/diff_test.zig index 1af1c9fea..9906b16db 100644 --- a/src/lang/diff_test.zig +++ b/src/lang/diff_test.zig @@ -74,20 +74,47 @@ const Fixture = struct { fn check(self: *Fixture, source: []const u8, expected: i64) !void { const r = evaluator.compare(&self.rt, &self.env, &self.table, self.arena.allocator(), source); + try expectLocParity(r, source); try testing.expect(r.equal); try testing.expectEqual(expected, (try r.tree_walk).asInteger()); } + /// Both backends raise the same error at the same source position + /// (ADR-0036 error half). For cases whose contract is the failure itself. + fn checkRaises(self: *Fixture, source: []const u8, expected: anyerror) !void { + const r = evaluator.compare(&self.rt, &self.env, &self.table, self.arena.allocator(), source); + try testing.expectError(expected, r.tree_walk); + try testing.expectError(expected, r.vm); + try expectLocParity(r, source); + } + /// Assert only that both backends agree (VM == TreeWalk) — for results whose /// type is not an inline integer (bigint / float / string), where /// `asInteger()` cannot be used. `r.equal` is the dual-backend parity oracle. fn checkEqual(self: *Fixture, source: []const u8) !void { const r = evaluator.compare(&self.rt, &self.env, &self.table, self.arena.allocator(), source); + try expectLocParity(r, source); _ = try r.tree_walk; // surface an eval error rather than a silent mismatch try testing.expect(r.equal); } }; +/// The shared error-location assertion every fixture check runs (ADR-0036 +/// error half): whenever either backend raises, both must report the same +/// line:column. `loc_equal` is true when neither raised, so value cases pass +/// through; a raise on one backend only, or at a different position, fails +/// here with both positions printed. +fn expectLocParity(r: evaluator.CompareResult, source: []const u8) !void { + if (r.loc_equal) return; + const tw = r.tree_walk_loc orelse evaluator.ErrorPos{ .line = 0, .column = 0 }; + const v = r.vm_loc orelse evaluator.ErrorPos{ .line = 0, .column = 0 }; + std.debug.print( + "error-location parity broken for: {s}\n tree_walk {d}:{d} (raised: {})\n vm {d}:{d} (raised: {})\n", + .{ source, tw.line, tw.column, r.tree_walk_loc != null, v.line, v.column, r.vm_loc != null }, + ); + return error.TestUnexpectedResult; +} + test "diff: D-346 chunked collection literals preserve order and nested evaluation" { var f: Fixture = undefined; try Fixture.init(&f, testing.allocator); @@ -120,9 +147,7 @@ test "diff: D-413 unresolved symbol → clean symbol_unresolved, not a panic" { var f: Fixture = undefined; try Fixture.init(&f, testing.allocator); defer f.deinit(); - const r = evaluator.compare(&f.rt, &f.env, &f.table, f.arena.allocator(), "(re-matcher 1)"); - try testing.expectError(error.NameError, r.tree_walk); - try testing.expectError(error.NameError, r.vm); + try f.checkRaises("(re-matcher 1)", error.NameError); } test "diff: D-563b def meta — user ^meta + compiler :line land on the Var in BOTH backends (op_var_meta ≡ evalDef meta_expr)" { @@ -1800,3 +1825,48 @@ test "diff: op_get / op_nth collection intrinsics (O-043, both backends)" { // fixture's literal-arg quirk in TreeWalk; the value path is what matters). try f.check("(let [v [[1 2] [3 4]]] (count (nth v 0 nil)))", 2); } + +/// Assert the error-location half of the dual-backend contract (ADR-0036): +/// both backends must raise, and both must report the SAME source position. +/// A raise at line 0 is rejected on either side: "both agree on nothing" is +/// vacuous parity, and 0:0 is exactly what a dropped location looks like. +fn checkErrorLoc(f: *Fixture, source: []const u8) !void { + const r = evaluator.compare(&f.rt, &f.env, &f.table, f.arena.allocator(), source); + const tw = r.tree_walk_loc orelse { + std.debug.print("tree_walk did not raise for: {s}\n", .{source}); + return error.TestExpectedRaise; + }; + const vm_pos = r.vm_loc orelse { + std.debug.print("vm did not raise for: {s}\n", .{source}); + return error.TestExpectedRaise; + }; + if (tw.line == 0 or vm_pos.line == 0 or !r.loc_equal) { + std.debug.print( + "error-location parity broken for: {s}\n tree_walk {d}:{d}\n vm {d}:{d}\n", + .{ source, tw.line, tw.column, vm_pos.line, vm_pos.column }, + ); + return error.TestUnexpectedResult; + } +} + +test "diff: error source location survives BOTH backends (ADR-0036 error half)" { + var f: Fixture = undefined; + try Fixture.init(&f, testing.allocator); + defer f.deinit(); + // Control arm: a builtin raising through op_call already threads the call + // form's loc into `vt.callFn`, so this one held before the fix. + try checkErrorLoc(&f, "(/ 1 0)"); + // `set!` on a var with no active thread binding → var_set_not_bound, raised + // by the VM's op_set_var directly rather than through `vt.callFn`. + try checkErrorLoc(&f, "(do (def svl 1) (set! svl 2))"); + // Interop member on a receiver whose type has no such method → raised by + // the VM's op_method_call directly. + try checkErrorLoc(&f, "(.noSuchMember \"s\")"); + // Constructor arity failure → raised inside constructInstance with the + // loc the backend hands it (TreeWalk n.loc, VM op_ctor_call instr_loc). + try checkErrorLoc(&f, "(MapEntry. 1)"); + // Multi-line source: the position must be the inner form's own line and + // column, not the enclosing form's and not the last argument's. + try checkErrorLoc(&f, "(do\n (def svl2 1)\n (set! svl2\n 2))"); + try checkErrorLoc(&f, "(let [x 1]\n (MapEntry. x))"); +} diff --git a/src/lang/primitive/core.zig b/src/lang/primitive/core.zig index 5c3954a68..96f2a0c7a 100644 --- a/src/lang/primitive/core.zig +++ b/src/lang/primitive/core.zig @@ -51,36 +51,38 @@ const big_int = @import("../../runtime/numeric/big_int.zig"); /// `(map (partial instance? C) …)`, works exactly as in clj). Returns true iff /// `x` is an instance of `c`. /// -/// `runtime/class_name.zig::isInstance` is the COMPLETE membership oracle -/// (native exact / interface multi-tag / Throwable hierarchy / Object-universal / -/// user TypeDescriptor walk; an opaque collapsed class — Integer / BigInteger — -/// is in no table so it returns false, clj-faithful). So this primitive is just -/// `fqcn → isInstance` with no opaque/Object/Number special-case (those dissolved -/// into the oracle per ADR-0128). A non-class `c` raises (clj: ClassCastException). +/// `runtime/class_name.zig` is the COMPLETE membership oracle (native exact / +/// interface multi-tag / Throwable hierarchy / Object-universal / descriptor +/// identity for a user class; an opaque collapsed class, Integer / BigInteger, +/// is in no table so it returns false, clj-faithful). So this primitive only +/// picks the entry point, with no opaque/Object/Number special-case (those +/// dissolved into the oracle per ADR-0128). A non-class `c` raises (clj: +/// ClassCastException). pub fn instanceOf(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { _ = rt; _ = env; try error_catalog.checkArity("instance?", args, 2, loc); - // The class arg is either a class VALUE (`.type_descriptor` — String / Number / - // Object / a user deftype / an exception class / `clojure.lang.IFn` FQCN) OR a - // cljw interface-marker PROTOCOL (`.protocol` — bare `IFn`/`ISeq`/`IPersistentMap`/ - // `IDeref`/… resolve to the defprotocol Var, the cljw representation of the clj - // interface). Both name a class in the membership oracle, so dispatch by fqcn. - const fqcn = switch (args[0].tag()) { - .type_descriptor => td_mod.asTypeDescriptorRef(args[0]).fqcn orelse return .false_val, - .protocol => protocol_mod.asProtocol(args[0]).fqcn(), + // The class arg is either a class VALUE (`.type_descriptor`: String / Number / + // Object / a user deftype / an exception class / a `clojure.lang.IFn` FQCN), + // answered through its descriptor so a user class is matched by identity + // (ADR-0201), OR a cljw interface-marker PROTOCOL (`.protocol`: bare `IFn` / + // `ISeq` / `IPersistentMap` / `IDeref` resolve to the defprotocol Var, the + // cljw representation of the clj interface), which names an interface. + const member = switch (args[0].tag()) { + .type_descriptor => class_name.isInstanceOfClass(args[1], td_mod.asTypeDescriptorRef(args[0])), + .protocol => class_name.isInstance(args[1], protocol_mod.asProtocol(args[0]).fqcn()), else => return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = "instance?", .expected = "class", .actual = @tagName(args[0].tag()), }), }; - return if (class_name.isInstance(args[1], fqcn)) .true_val else .false_val; + return Value.initBoolean(member); } /// `(-class-isa? child parent)` — true iff both args are class values /// (TypeDescriptor) and `child`'s class is `parent` or a subclass of it, via the -/// shared `class_name.classIsa` (Object/Number/IFn + interface markers + the host +/// shared `class_name.classIsaOf` (Object/Number/IFn + interface markers + the host /// exception/parent hierarchy — shared with multimethod dispatch so the two /// cannot drift, D-464). The class-hierarchy arm of `isa?` (clj's /// `Class.isAssignableFrom` step); equality + the ad-hoc `derive` hierarchy stay @@ -91,13 +93,16 @@ pub fn classIsaPrim(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLoc try error_catalog.checkArity("__class-isa?", args, 2, loc); if (args[0].tag() != .type_descriptor or args[1].tag() != .type_descriptor) return .false_val; - const child = td_mod.asTypeDescriptorRef(args[0]).fqcn orelse return .false_val; - const parent = td_mod.asTypeDescriptorRef(args[1]).fqcn orelse return .false_val; - // The class-vs-class hierarchy step (ADR-0109 Object/Number/IFn + D-293 - // interface markers + the exception/parent hierarchy) lives in the shared - // `class_name.classIsa` so `isa?` and multimethod dispatch cannot drift - // (D-464). The ad-hoc `derive` hierarchy + equality stay in the `.clj` isa?. - return if (class_name.classIsa(child, parent)) .true_val else .false_val; + // The class-vs-class hierarchy step lives in the shared + // `class_name.classIsaOf` so `isa?` and multimethod dispatch cannot drift + // (D-464): a user class by descriptor identity and by what it declares, + // never by its name (ADR-0201); a native class by name (ADR-0109 + // Object/Number/IFn, D-293 interface markers, the exception/parent + // hierarchy). The ad-hoc `derive` hierarchy + equality stay in the `.clj` + // isa?. + const child = td_mod.asTypeDescriptorRef(args[0]); + const parent = td_mod.asTypeDescriptorRef(args[1]); + return Value.initBoolean(class_name.classIsaOf(child, parent)); } /// Java String's immediate interfaces plus Object. JVM's `Class.getInterfaces` diff --git a/src/runtime/class_name.zig b/src/runtime/class_name.zig index bbb736102..30f5df0f3 100644 --- a/src/runtime/class_name.zig +++ b/src/runtime/class_name.zig @@ -28,9 +28,13 @@ //! - **Throwable hierarchy**: delegates to `host_class.matches` //! (covers `Throwable` / `Exception` / `RuntimeException` / //! `ExceptionInfo` etc.). -//! - **User types** (`defrecord` / `deftype` / `reify`): walks the -//! `TypeDescriptor.parent` chain, comparing `fqcn` against the -//! class name. +//! - **User classes** (`defrecord` / `deftype` / `reify`) are NOT matched +//! by name (ADR-0201). A user class is its TypeDescriptor, so membership +//! in one is descriptor identity: `isInstanceOfClass` / `classIsaOf`, +//! which take the class VALUE's descriptor. The name oracle below only +//! answers which host interfaces a user type declares (its +//! `protocol_impls` / `method_table`), and matches a descriptor's own +//! fqcn only for a native host-surface descriptor. //! //! ## DIVERGENCE from cw v0 //! @@ -308,13 +312,14 @@ fn isInterfaceName(simple: []const u8) bool { /// 1. Throwable hierarchy (host_class.matches walks the parent chain). /// 2. Native exact-tag table (`String`, `Long`, etc.). /// 3. Interface-shaped multi-tag set (`IFn`, `Number`, `IPersistent*`). -/// 4. User TypeDescriptor walk for `.typed_instance` / `.reified_instance` -/// receivers — comparing the simple name against each ancestor's -/// `fqcn`. +/// 4. Descriptor walk for `.typed_instance` / `.reified_instance` / +/// `.host_instance` receivers: the interfaces a type declares, plus the +/// own fqcn of a native host-surface descriptor (see `matchUserType`). /// -/// Unknown class names return false here; callers (= the -/// `__instance?` primitive) should pre-check with `isKnown` and raise -/// loud rather than relying on silent false. +/// This is the NAME oracle, for host / native classes. A user class is +/// never answered by name; callers holding a class VALUE go through +/// `isInstanceOfClass`, which routes a user class to descriptor identity. +/// Unknown class names return false here. pub fn isInstance(v: Value, class_name: []const u8) bool { const simple = normalizeClassName(class_name); @@ -339,6 +344,59 @@ pub fn isInstance(v: Value, class_name: []const u8) bool { return false; } +/// `(instance? C v)` for a class VALUE, where `class_td` is the descriptor `C` +/// evaluates to. The entry point for every caller that holds a class rather +/// than a name (`instance?`, `Class.isInstance`). A user class is answered by +/// identity (ADR-0201): `v` is a member iff `class_td` is `v`'s descriptor or on +/// its parent chain, so a redefined `R` and its previous vintage, or `aaa.Point` +/// and `bbb.Point`, never answer for each other. A host / native class has no +/// user descriptor and goes to the name oracle `isInstance`. +pub fn isInstanceOfClass(v: Value, class_td: *const TypeDescriptor) bool { + if (class_td.isUserClass()) { + const own = userInstanceDescriptor(v) orelse return false; + return descendsFrom(own, class_td); + } + const name = class_td.fqcn orelse return false; + return isInstance(v, name); +} + +/// Class-vs-class `isa?` over class VALUES, shared by the `isa?` primitive and +/// multimethod dispatch so the two cannot drift (D-464). Identity first. A +/// user-class PARENT is reached only along `child`'s parent chain. A user-class +/// CHILD under a host parent is a subclass of Object and of what it declares +/// (`descriptorIsa`, the relation `instance?` reads), never of a host class that +/// merely shares its simple name: a `(deftype BigInt [])` is not a Number. Only +/// a native child keeps the name relation `classIsa` (ADR-0201). +pub fn classIsaOf(child: *const TypeDescriptor, parent: *const TypeDescriptor) bool { + if (child == parent) return true; + if (parent.isUserClass()) return descendsFrom(child, parent); + const parent_name = parent.fqcn orelse return false; + if (child.isUserClass()) { + const simple = normalizeClassName(parent_name); + return host_class.isUniversalClass(simple) or descriptorIsa(child, simple); + } + return classIsa(child.fqcn orelse "", parent_name); +} + +/// The descriptor a user-class instance carries, or null for any other value +/// (a host_instance's descriptor is a native surface, never a user class). +fn userInstanceDescriptor(v: Value) ?*const TypeDescriptor { + return switch (v.tag()) { + .typed_instance => v.decodePtr(*const TypedInstance).descriptor, + .reified_instance => v.decodePtr(*const ReifiedInstance).descriptor, + else => null, + }; +} + +/// True iff `ancestor` is `td` itself or on `td`'s parent chain, by pointer. +fn descendsFrom(td: *const TypeDescriptor, ancestor: *const TypeDescriptor) bool { + var cursor: ?*const TypeDescriptor = td; + while (cursor) |t| : (cursor = t.parent) { + if (t == ancestor) return true; + } + return false; +} + fn isThrowableTag(t: Tag) bool { return switch (t) { .ex_info => true, @@ -408,15 +466,15 @@ fn displayClassTag(name: []const u8) ?Tag { return M.get(name); } -/// Class-vs-class `isa?` (the `(isa? )` hierarchy step), by fqcn -/// name. SHARED by `core.classIsaPrim` (the `isa?` primitive) and -/// `multimethod.isaCheck` (method dispatch) so the two cannot drift (D-464 — a -/// `(defmethod f java.lang.Number)` must be found for a `Long` dispatch value -/// exactly as `(isa? Long Number)` is true). Order mirrors clj's isa? class -/// step: identity, universal Object, narrow Number, narrow IFn, interface-marker -/// membership, then the host exception/parent hierarchy. The ad-hoc `derive` -/// hierarchy and non-class isa? stay with the callers. -pub fn classIsa(child: []const u8, parent: []const u8) bool { +/// Class-vs-class `isa?` BY NAME, for a host / native parent: the name half of +/// `classIsaOf`, which is the entry both `core.classIsaPrim` (the `isa?` +/// primitive) and `multimethod.isaCheck` (method dispatch) call so the two +/// cannot drift (D-464: a `(defmethod f java.lang.Number)` must be found for a +/// `Long` dispatch value exactly as `(isa? Long Number)` is true). Order mirrors +/// clj's isa? class step: identity, universal Object, narrow Number, narrow IFn, +/// interface-marker membership, then the host exception/parent hierarchy. The +/// ad-hoc `derive` hierarchy and non-class isa? stay with the callers. +fn classIsa(child: []const u8, parent: []const u8) bool { if (std.mem.eql(u8, child, parent)) return true; if (host_class.isUniversalClass(parent)) return true; if (host_class.isNumberClass(parent)) return host_class.isNumericClass(child); @@ -442,11 +500,27 @@ fn matchUserType(v: Value, simple: []const u8) bool { .host_instance => @import("host_instance.zig").asHostInstance(v).descriptor, else => return false, }; + return descriptorIsa(td, simple); +} + +/// True iff a value whose descriptor is `td` is an instance of the host class +/// named `simple` (normalised). The descriptor-level half of `matchUserType`, +/// shared with `classIsaOf` so `(instance? C x)` and `(isa? (class x) C)` read +/// ONE relation. A user class answers only from what it DECLARES (its +/// interfaces), never from its own name; a native host-surface descriptor also +/// answers to its own fqcn (ADR-0201). +fn descriptorIsa(td: *const TypeDescriptor, simple: []const u8) bool { var cursor: ?*const TypeDescriptor = td; while (cursor) |t| { - if (t.fqcn) |fqcn| { - if (std.mem.eql(u8, fqcn, simple)) return true; - if (std.mem.eql(u8, normalizeClassName(fqcn), simple)) return true; + // Only a native host-surface descriptor is its NAME. A user class's + // fqcn is a display string it may share with a host interface (a + // `(deftype Sorted [])` is not `clojure.lang.Sorted`) or with another + // user class, so it never answers here (ADR-0201). + if (!t.isUserClass()) { + if (t.fqcn) |fqcn| { + if (std.mem.eql(u8, fqcn, simple)) return true; + if (std.mem.eql(u8, normalizeClassName(fqcn), simple)) return true; + } } // The declared interfaces (ADR-0116 Decision B). `protocol_impls` holds // what the type DECLARED; the method table is not consulted, because a @@ -676,3 +750,54 @@ test "isCallableClassName derives from the IFn tag set" { try testing.expect(isClassInterfaceMember("TransientArrayMap", "Counted")); try testing.expect(!isClassInterfaceMember("Cons", "Counted")); } + +test "ADR-0201: a user class is matched by descriptor identity, never by its simple name" { + const Runtime = @import("runtime.zig").Runtime; + const td_mod = @import("type_descriptor.zig"); + var threaded = std.Io.Threaded.init(testing.allocator, .{}); + defer threaded.deinit(); + var rt = Runtime.init(threaded.io(), testing.allocator); + defer rt.deinit(); + + // Two vintages of user.R, plus a namesake in another namespace. + const old = try td_mod.registerType(&rt, "R", "user", &.{"a"}, &.{}, .defrecord); + const old_inst = try td_mod.allocInstance(&rt, old, &.{Value.initInteger(1)}); + const new = try td_mod.registerType(&rt, "R", "user", &.{ "a", "b" }, &.{}, .defrecord); + const namesake = try td_mod.registerType(&rt, "R", "other", &.{"a"}, &.{}, .defrecord); + try testing.expect(old != new); + + try testing.expect(isInstanceOfClass(old_inst, old)); + try testing.expect(!isInstanceOfClass(old_inst, new)); + try testing.expect(!isInstanceOfClass(old_inst, namesake)); + try testing.expect(!isInstanceOfClass(Value.initInteger(1), old)); + try testing.expect(classIsaOf(old, old)); + try testing.expect(!classIsaOf(old, new)); + try testing.expect(!classIsaOf(new, old)); + try testing.expect(!classIsaOf(namesake, old)); + // The name oracle no longer answers for a user class by its display name. + try testing.expect(!isInstance(old_inst, "R")); + + // Under a host parent a user class is an Object, and is NOT the subclass of + // a host class that merely shares its simple name. + const object = try rt.classDescriptor("Object"); + try testing.expect(classIsaOf(old, object)); + try testing.expect(isInstanceOfClass(old_inst, object)); + const user_big_int = try td_mod.registerType(&rt, "BigInt", "user", &.{}, &.{}, .deftype); + try testing.expect(!classIsaOf(user_big_int, try rt.classDescriptor("Number"))); + try testing.expect(classIsaOf(user_big_int, object)); + + // A reify descriptor has no name at all and is still its own class. + const reify_td: TypeDescriptor = .{ + .fqcn = null, + .kind = .reify_anon, + .field_layout = null, + .protocol_impls = &.{}, + .method_table = &.{}, + .parent = null, + .meta = .nil_val, + }; + const reified = try td_mod.allocReifiedInstance(&rt, &reify_td); + try testing.expect(isInstanceOfClass(reified, &reify_td)); + try testing.expect(!isInstanceOfClass(reified, old)); + try testing.expect(classIsaOf(&reify_td, object)); +} diff --git a/src/runtime/cljw/wasm/engine.zig b/src/runtime/cljw/wasm/engine.zig index 84cc0e593..29b52429e 100644 --- a/src/runtime/cljw/wasm/engine.zig +++ b/src/runtime/cljw/wasm/engine.zig @@ -25,6 +25,8 @@ pub const Value = zwasm.Value; pub const FuncType = zwasm.ir.zir.FuncType; pub const ValType = zwasm.ir.zir.ValType; pub const Memory = zwasm.Memory; +/// Owned export-descriptor list (zwasm `Module.exports`), ADR-0203. +pub const ModuleExports = @typeInfo(@typeInfo(@TypeOf(zwasm.Module.exports)).@"fn".return_type.?).error_union.payload; /// Per-axis runtime budget for a loaded module (re-exported from zwasm's /// `Module.Budget`, ADR-0179). `.unmetered` lifts the cap (trusted modules @@ -80,6 +82,10 @@ pub const Loaded = struct { /// Keys are owned copies; values are slices into the instance's own /// signature storage, valid for the instance's lifetime. sig_cache: std.StringHashMapUnmanaged(FuncType), + /// Export descriptors (name + kind, export order) decoded at `load`, while + /// the module bytes are still alive: zwasm's native section view borrows + /// them, so a later `Module.exports` would decode freed memory (ADR-0203). + export_list: ModuleExports, /// Runtime signature of an exported function (`null` if absent / not a /// func). Drives the marshal: caller sizes its `[]Value` buffers from @@ -120,6 +126,7 @@ pub const Loaded = struct { var keys = self.sig_cache.keyIterator(); while (keys.next()) |k| self.alloc.free(k.*); self.sig_cache.deinit(self.alloc); + self.export_list.deinit(); self.instance.deinit(); self.module.deinit(); self.engine.deinit(); @@ -295,6 +302,8 @@ pub fn load(alloc: std.mem.Allocator, bytes: []const u8, opts: LoadOpts) !*Loade if (opts.fuel) |b| inst_opts.fuel = b; if (opts.max_memory_pages) |b| inst_opts.max_memory_pages = b; inst_opts.engine = opts.engine; + self.export_list = try self.module.exports(alloc); + errdefer self.export_list.deinit(); self.instance = try self.module.instantiate(inst_opts); self.engine_kind = opts.engine; self.alloc = alloc; diff --git a/src/runtime/cljw/wasm/exports.zig b/src/runtime/cljw/wasm/exports.zig new file mode 100644 index 000000000..322061da5 --- /dev/null +++ b/src/runtime/cljw/wasm/exports.zig @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: EPL-2.0 +//! `(wasm/exports h)` — list a core module's exports in export-section order +//! (ADR-0203 decision 1). A function answers +//! `{:name "add" :kind :func :params [:i32 :i32] :results [:i32]}`; a memory, +//! global or table answers `{:name "memory" :kind :memory}`. The function +//! signature comes from `Loaded.exportSig`, the same runtime probe `wasm/call` +//! marshals through, so nothing here is declared by hand and a listed function +//! is callable at exactly the listed arity. Compiled ONLY under `-Dwasm`. +//! +//! Backend: impl-only +//! Impl deps: none +//! Clojure peer: wasm/exports +const std = @import("std"); +const zwasm = @import("zwasm"); +const engine = @import("engine.zig"); +const wasm_handle = @import("wasm_handle.zig"); +const Runtime = @import("../../runtime.zig").Runtime; +const Env = @import("../../env.zig").Env; +const Value = @import("../../value/value.zig").Value; +const error_catalog = @import("../../error/catalog.zig"); +const SourceLocation = @import("../../error/info.zig").SourceLocation; +const string_mod = @import("../../collection/string.zig"); +const vector_mod = @import("../../collection/vector.zig"); +const map_mod = @import("../../collection/map.zig"); +const keyword_mod = @import("../../keyword.zig"); + +/// `(wasm/exports handle)` — a vector of export maps, in export order. +pub fn wasmExportsFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { + _ = env; + try error_catalog.checkArity("wasm/exports", args, 1, loc); + if (!wasm_handle.isHandle(args[0])) + return error_catalog.raise(.wasm_handle_invalid, loc, .{ .fn_name = "wasm/exports" }); + const loaded = wasm_handle.unwrap(args[0]); + + const name_kw = try keyword_mod.intern(rt, null, "name"); + const kind_kw = try keyword_mod.intern(rt, null, "kind"); + const params_kw = try keyword_mod.intern(rt, null, "params"); + const results_kw = try keyword_mod.intern(rt, null, "results"); + + // The partial `out` vector and each `m` live only in Zig locals until + // return, so no collection may run while they are built. + rt.gc.enterFabrication(); + defer rt.gc.exitFabrication(); + + var out = vector_mod.empty(); + for (loaded.export_list.items) |item| { + var m = map_mod.empty(); + m = try map_mod.assoc(rt, m, name_kw, try string_mod.alloc(rt, item.name)); + m = try map_mod.assoc(rt, m, kind_kw, try keyword_mod.intern(rt, null, kindName(item.kind))); + if (item.kind == .func) { + // A func export always has a signature; a miss would mean the + // instance and the export section disagree, so the map simply + // carries no signature rather than inventing one. + if (loaded.exportSig(item.name)) |sig| { + m = try map_mod.assoc(rt, m, params_kw, try typeVector(rt, sig.params)); + m = try map_mod.assoc(rt, m, results_kw, try typeVector(rt, sig.results)); + } + } + out = try vector_mod.conj(rt, out, m); + } + return out; +} + +fn typeVector(rt: *Runtime, types: []const engine.ValType) anyerror!Value { + var v = vector_mod.empty(); + for (types) |t| v = try vector_mod.conj(rt, v, try keyword_mod.intern(rt, null, valTypeName(t))); + return v; +} + +/// The `:kind` keyword name of an export. +pub fn kindName(kind: zwasm.ExternKind) []const u8 { + return switch (kind) { + .func => "func", + .table => "table", + .memory => "memory", + .global => "global", + // The binary format has no tag export; zwasm's enum shares the import side. + .tag => "tag", + }; +} + +/// The keyword name of a value type: `i32 i64 f32 f64 v128`, and for a +/// reference its abstract heap type's text-format shorthand (`funcref`, +/// `externref`, ...). A concrete typed reference `(ref $t)` names as `ref`. +pub fn valTypeName(t: engine.ValType) []const u8 { + return switch (t) { + .i32 => "i32", + .i64 => "i64", + .f32 => "f32", + .f64 => "f64", + .v128 => "v128", + .ref => |r| switch (r.heap_type) { + .concrete => "ref", + .abstract => |a| switch (a) { + .func => "funcref", + .extern_ => "externref", + .any => "anyref", + .eq => "eqref", + .i31 => "i31ref", + .struct_ => "structref", + .array => "arrayref", + .none => "nullref", + .noextern => "nullexternref", + .nofunc => "nullfuncref", + .exn => "exnref", + .noexn => "nullexnref", + }, + }, + }; +} + +test "valTypeName names scalars and the abstract references" { + const VT = engine.ValType; + try std.testing.expectEqualStrings("i32", valTypeName(.i32)); + try std.testing.expectEqualStrings("f64", valTypeName(.f64)); + try std.testing.expectEqualStrings("v128", valTypeName(.v128)); + try std.testing.expectEqualStrings("funcref", valTypeName(VT.funcref)); + try std.testing.expectEqualStrings("externref", valTypeName(VT.externref)); +} + +test "kindName names every export kind" { + try std.testing.expectEqualStrings("func", kindName(.func)); + try std.testing.expectEqualStrings("memory", kindName(.memory)); + try std.testing.expectEqualStrings("global", kindName(.global)); + try std.testing.expectEqualStrings("table", kindName(.table)); +} + +// (module (func (export "add") (param i32 i32) (result i32) +// local.get 0 local.get 1 i32.add) (memory (export "memory") 1)) +const add_mem_wasm = [_]u8{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x07, 0x01, 0x60, + 0x02, 0x7f, 0x7f, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x05, 0x03, 0x01, + 0x00, 0x01, 0x07, 0x10, 0x02, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, 0x06, + 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x0a, 0x09, 0x01, 0x07, + 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b, +}; + +test "the export list outlives the module bytes and pairs with exportSig" { + const alloc = std.testing.allocator; + inline for (.{ engine.EngineKind.interp, engine.EngineKind.jit }) |kind| { + // `wasm/load` frees the file bytes right after `load`; mirror that. + const bytes = try alloc.dupe(u8, &add_mem_wasm); + const loaded = try engine.load(alloc, bytes, .{ .engine = kind }); + @memset(bytes, 0); + alloc.free(bytes); + defer { + loaded.deinit(); + alloc.destroy(loaded); + } + const items = loaded.export_list.items; + try std.testing.expectEqual(@as(usize, 2), items.len); + try std.testing.expectEqualStrings("add", items[0].name); + try std.testing.expectEqualStrings("func", kindName(items[0].kind)); + try std.testing.expectEqualStrings("memory", items[1].name); + try std.testing.expectEqualStrings("memory", kindName(items[1].kind)); + const sig = loaded.exportSig("add").?; + try std.testing.expectEqual(@as(usize, 2), sig.params.len); + try std.testing.expectEqualStrings("i32", valTypeName(sig.results[0])); + } +} diff --git a/src/runtime/cljw/wasm/surface.zig b/src/runtime/cljw/wasm/surface.zig index 58c1afc30..ae00e73a9 100644 --- a/src/runtime/cljw/wasm/surface.zig +++ b/src/runtime/cljw/wasm/surface.zig @@ -12,7 +12,7 @@ //! //! Backend: impl-only //! Impl deps: none -//! Clojure peer: wasm/load, wasm/call, wasm/engine, wasm/mem-size, wasm/mem-read, wasm/mem-write! +//! Clojure peer: wasm/load, wasm/call, wasm/exports, wasm/engine, wasm/mem-size, wasm/mem-read, wasm/mem-write! const std = @import("std"); const engine = @import("engine.zig"); const run_cache = @import("run_cache.zig"); @@ -138,10 +138,19 @@ pub fn wasmCallFn(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocat if (!args[1].isString()) return error_catalog.raise(.wasm_export_name_invalid, loc, .{}); - const loaded = wasm_handle.unwrap(args[0]); - const name = string_mod.asString(args[1]); - const call_args = args[2..]; + return callExport(rt, wasm_handle.unwrap(args[0]), string_mod.asString(args[1]), args[2..], loc); +} + +/// `(.export h args…)` on a `.wasm_module` receiver (ADR-0203 decision 2): +/// the method name is the export name verbatim, and the call is `wasm/call`'s +/// own path. Installed on `rt.wasm_member_call` by `register`. +fn memberCallFn(rt: *Runtime, receiver: Value, name: []const u8, args: []const Value, loc: SourceLocation) anyerror!Value { + return callExport(rt, wasm_handle.unwrap(receiver), name, args, loc); +} +/// The one export-invoke path shared by `wasm/call` and dot-interop: signature +/// probe, arity check, marshalling, fuel and trap mapping. +fn callExport(rt: *Runtime, loaded: *engine.Loaded, name: []const u8, call_args: []const Value, loc: SourceLocation) anyerror!Value { const sig = loaded.exportSig(name) orelse return error_catalog.raise(.wasm_export_not_found, loc, .{ .name = name }); if (call_args.len != sig.params.len) @@ -600,10 +609,13 @@ pub fn register(env: *Env) !void { // Register the `.wasm_module` GC finaliser so a swept `(wasm/load …)` handle // tears down its zwasm triple instead of leaking (D-259 (b)). wasm_handle.registerGcHooks(); + // ADR-0203: `.member` dispatch on a handle routes to `callExport`. + env.rt.wasm_member_call = &memberCallFn; const ns = try env.findOrCreateNs("wasm"); _ = try env.intern(ns, "load", Value.initBuiltinFn(&wasmLoadFn), null); _ = try env.intern(ns, "call", Value.initBuiltinFn(&wasmCallFn), null); _ = try env.intern(ns, "engine", Value.initBuiltinFn(&wasmEngineFn), null); + _ = try env.intern(ns, "exports", Value.initBuiltinFn(&@import("exports.zig").wasmExportsFn), null); _ = try env.intern(ns, "run", Value.initBuiltinFn(&wasmRunFn), null); _ = try env.intern(ns, "clear-cache!", Value.initBuiltinFn(&wasmClearCacheFn), null); // ADR-0192: the (ptr,len) half of the FFI — `wasm/call` passes the pointer, diff --git a/src/runtime/java/lang/Class.zig b/src/runtime/java/lang/Class.zig index 8279e9ee2..b064def83 100644 --- a/src/runtime/java/lang/Class.zig +++ b/src/runtime/java/lang/Class.zig @@ -60,16 +60,16 @@ fn getSimpleName(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocati } /// `(.isInstance klass obj)` — true iff `obj` is an instance of `klass`. Routes -/// through the shared `class_name.isInstance` predicate (native tags + interface -/// sets + Throwable hierarchy + user TypeDescriptor walk). JVM reference: +/// through the same `class_name.isInstanceOfClass` entry `instance?` uses +/// (native tags + interface sets + Throwable hierarchy by name, descriptor +/// identity for a user class per ADR-0201). JVM reference: /// java.lang.Class#isInstance. fn isInstance(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value { _ = rt; _ = env; try error_catalog.checkArity(".isInstance", args, 2, loc); const td = type_descriptor.asTypeDescriptorRef(args[0]); - const name = td.fqcn orelse return .false_val; - return Value.initBoolean(class_name.isInstance(args[1], name)); + return Value.initBoolean(class_name.isInstanceOfClass(args[1], td)); } /// Populate the per-Runtime `.type_descriptor` native descriptor's method table diff --git a/src/runtime/multimethod.zig b/src/runtime/multimethod.zig index 70dd1d373..1c6a7daf2 100644 --- a/src/runtime/multimethod.zig +++ b/src/runtime/multimethod.zig @@ -153,13 +153,11 @@ pub fn isPreferred(prefer_table: Value, x: Value, y: Value) !bool { return try set_mod.contains(preferred_over, y); } -/// cw v1 `isa?` check: equality + hierarchy ancestors-map lookup + -/// TypeDescriptor parent walk. Mirrors `clojure.core/isa?` steps -/// 1 + 3. Step 2 (`Class.isAssignableFrom`) and step 4 (`supers` -/// walk) have no JVM-direct equivalent in cw v1 per ADR-0007 -/// Option β + `.claude/rules/no_jvm_specific_assumption.md` — the -/// `typeDescriptorIsa` walk is the cw-side replacement (DIVERGENCE -/// from JVM class hierarchy). +/// cw v1 `isa?` check for multimethod dispatch, mirroring `clojure.core/isa?`: +/// equality, element-wise vectors, the class-vs-class step through the shared +/// `class_name.classIsaOf` (clj's `Class.isAssignableFrom` step, the same +/// function the `isa?` primitive calls), then the `derive` hierarchy's +/// ancestors map. /// /// `hierarchy_ancestors` is the `:ancestors` sub-map of a /// hierarchy struct: `{child #{ancestor1 ancestor2 ...}, ...}`. @@ -186,38 +184,22 @@ pub fn isaCheck(hierarchy_ancestors: Value, child: Value, parent: Value) !bool { // D-464: a class VALUE child isa? a class VALUE parent via the class hierarchy // (Long isa? Number, vector-class isa? Sequential), through the SAME shared - // `class_name.classIsa` the `isa?` primitive uses. Only short-circuits on a - // true match; a class can ALSO sit in an ad-hoc `derive` hierarchy, so a + // `class_name.classIsaOf` the `isa?` primitive uses, so a method keyed by a + // user class catches only that class, never a same-named redefinition + // vintage or another namespace's namesake (ADR-0201). Only short-circuits on + // a true match; a class can ALSO sit in an ad-hoc `derive` hierarchy, so a // non-match falls through to the ancestors lookup below. if (child.tag() == .type_descriptor and parent.tag() == .type_descriptor) { - const child_fqcn = td_mod.asTypeDescriptorRef(child).fqcn orelse ""; - const parent_fqcn = td_mod.asTypeDescriptorRef(parent).fqcn orelse ""; - if (class_name_mod.classIsa(child_fqcn, parent_fqcn)) return true; - } - - // Row 7.5 cycle 6 — D-082 discharge: typed_instance + reified_instance - // children walk their descriptor chain. JVM `clojure.core/isa?` step 2 - // (`Class.isAssignableFrom`) + step 4 (`supers` walk) map onto: - // - `descriptor.fqcn` ≡ JVM Class.name - // - `descriptor.protocol_impls` ≡ JVM implementedInterfaces walk - // - `descriptor.parent` ≡ JVM superclass chain - // Parent symbol/keyword name is matched against each. Triggered only - // when child is a TypedInstance / ReifiedInstance and parent is - // a Named (symbol or keyword) — the JVM analogue's input shape. - if ((child.tag() == .typed_instance or child.tag() == .reified_instance) and - (parent.tag() == .symbol or parent.tag() == .keyword)) - { - const parent_name: []const u8 = if (parent.tag() == .symbol) - symbol.asSymbol(parent).name - else - keyword.asKeyword(parent).name; - const child_desc: *const td_mod.TypeDescriptor = if (child.tag() == .typed_instance) - child.decodePtr(*const td_mod.TypedInstance).descriptor - else - child.decodePtr(*const td_mod.ReifiedInstance).descriptor; - if (typeDescriptorIsa(child_desc, parent_name)) return true; + const child_td = td_mod.asTypeDescriptorRef(child); + const parent_td = td_mod.asTypeDescriptorRef(parent); + if (class_name_mod.classIsaOf(child_td, parent_td)) return true; } + // An INSTANCE child (a `(defmulti m identity)` dispatch value) is related + // to a symbol or keyword parent only through the `derive` hierarchy below, + // never by its class's simple name or a protocol its type declares: + // clj's isa? has no such step, and `(isa? (->R 1) 'R)` is false here too + // (ADR-0201). if (hierarchy_ancestors.tag() == .nil) return false; const child_ancestors = try map_mod.get(hierarchy_ancestors, child); @@ -225,22 +207,6 @@ pub fn isaCheck(hierarchy_ancestors: Value, child: Value, parent: Value) !bool { return try set_mod.contains(child_ancestors, parent); } -/// Walk a descriptor's `fqcn` + `protocol_impls` + `parent` chain -/// looking for a match against `parent_name`. Row 7.5 cycle 6 D-082 -/// discharge — replaces JVM `Class.isAssignableFrom` + `supers` walk -/// per `.claude/rules/no_jvm_specific_assumption.md` (cw-side -/// equivalent of class hierarchy via TypeDescriptor). -fn typeDescriptorIsa(td: *const td_mod.TypeDescriptor, parent_name: []const u8) bool { - if (td.fqcn) |fqcn| { - if (std.mem.eql(u8, fqcn, parent_name)) return true; - } - for (td.protocol_impls) |impl| { - if (std.mem.eql(u8, impl, parent_name)) return true; - } - if (td.parent) |p| return typeDescriptorIsa(p, parent_name); - return false; -} - /// Dereference an IRef-shape hierarchy reference to its held map. /// `defmulti` threads the `-global-hierarchy` atom (D-161), so an /// atom ref is deref'd to its current value — and crucially each @@ -545,74 +511,31 @@ test "isaCheck: nil hierarchy falls back to equality only" { try testing.expect(!(try isaCheck(Value.nil_val, a, b))); } -test "isaCheck: typed_instance child matches descriptor.fqcn against Symbol parent (D-082)" { +test "isaCheck: an instance child is not isa? a Named parent spelled like its class (ADR-0201)" { var fix: TestFixture = undefined; fix.init(testing.allocator); defer fix.deinit(); - // Synthetic descriptor with fqcn "Foo". + // clj's isa? relates an INSTANCE only by equality (derive rejects one as a + // child), so neither its class's simple name nor a protocol its type + // declares makes `(defmulti m identity)` catch it under that name. + const impls = [_][]const u8{"Drawable"}; const td = try testing.allocator.create(td_mod.TypeDescriptor); defer testing.allocator.destroy(td); td.* = .{ .fqcn = "Foo", .kind = .defrecord, .field_layout = null, - .protocol_impls = &.{}, - .method_table = &.{}, - .parent = null, - .meta = Value.nil_val, - }; - const inst_val = try td_mod.allocInstance(&fix.rt, td, &.{}); - const foo_sym = try symbol.intern(&fix.rt, null, "Foo"); - const bar_sym = try symbol.intern(&fix.rt, null, "Bar"); - try testing.expect(try isaCheck(Value.nil_val, inst_val, foo_sym)); - try testing.expect(!(try isaCheck(Value.nil_val, inst_val, bar_sym))); -} - -test "isaCheck: typed_instance child walks protocol_impls list (D-082)" { - var fix: TestFixture = undefined; - fix.init(testing.allocator); - defer fix.deinit(); - - const impls = [_][]const u8{ "Drawable", "Serializable" }; - const td = try testing.allocator.create(td_mod.TypeDescriptor); - defer testing.allocator.destroy(td); - td.* = .{ - .fqcn = "Shape", - .kind = .defrecord, - .field_layout = null, .protocol_impls = &impls, .method_table = &.{}, .parent = null, .meta = Value.nil_val, }; const inst_val = try td_mod.allocInstance(&fix.rt, td, &.{}); - const drawable_sym = try symbol.intern(&fix.rt, null, "Drawable"); - const unknown_sym = try symbol.intern(&fix.rt, null, "Unknown"); - try testing.expect(try isaCheck(Value.nil_val, inst_val, drawable_sym)); - try testing.expect(!(try isaCheck(Value.nil_val, inst_val, unknown_sym))); -} - -test "isaCheck: reified_instance child walks descriptor like typed_instance (D-082)" { - var fix: TestFixture = undefined; - fix.init(testing.allocator); - defer fix.deinit(); - - const impls = [_][]const u8{"IProto"}; - const td = try testing.allocator.create(td_mod.TypeDescriptor); - defer testing.allocator.destroy(td); - td.* = .{ - .fqcn = null, - .kind = .reify_anon, - .field_layout = null, - .protocol_impls = &impls, - .method_table = &.{}, - .parent = null, - .meta = Value.nil_val, - }; - const inst_val = try td_mod.allocReifiedInstance(&fix.rt, td); - const iproto_sym = try symbol.intern(&fix.rt, null, "IProto"); - try testing.expect(try isaCheck(Value.nil_val, inst_val, iproto_sym)); + try testing.expect(!(try isaCheck(Value.nil_val, inst_val, try symbol.intern(&fix.rt, null, "Foo")))); + try testing.expect(!(try isaCheck(Value.nil_val, inst_val, try keyword.intern(&fix.rt, null, "Foo")))); + try testing.expect(!(try isaCheck(Value.nil_val, inst_val, try symbol.intern(&fix.rt, null, "Drawable")))); + try testing.expect(try isaCheck(Value.nil_val, inst_val, inst_val)); } test "getMethod: hierarchy walk returns ancestor's method on isa? match" { diff --git a/src/runtime/runtime.zig b/src/runtime/runtime.zig index 32aa1aa67..a508c7c0e 100644 --- a/src/runtime/runtime.zig +++ b/src/runtime/runtime.zig @@ -393,6 +393,13 @@ pub const Runtime = struct { /// the zone direction legal (lang installs into runtime). source_resolver: ?*const fn (*Runtime, []const u8) ?SourceContext = null, + /// ADR-0203 decision 2: `(.export h args…)` on a `.wasm_module` receiver. + /// Both backends' `.member` dispatch call this on a method-table miss; the + /// wasm surface (`runtime/cljw/wasm/surface.zig`) installs it at `register`, + /// so it is set only under `-Dwasm` (F-001) and the eval zone never imports + /// a surface. `args` EXCLUDES the receiver; `name` is the export, verbatim. + wasm_member_call: ?*const fn (rt: *Runtime, receiver: @import("value/value.zig").Value, name: []const u8, args: []const @import("value/value.zig").Value, loc: @import("error/info.zig").SourceLocation) anyerror!@import("value/value.zig").Value = null, + /// Swappable resolver fn for `require`. ADR-0035 D8. `null` at /// `init`; bootstrap installs the embedded resolver. Phase 12+ /// can swap for classpath / build-artifact resolvers; Phase 16+ diff --git a/src/runtime/type_descriptor.zig b/src/runtime/type_descriptor.zig index 8fc5b5389..de2e8a663 100644 --- a/src/runtime/type_descriptor.zig +++ b/src/runtime/type_descriptor.zig @@ -262,6 +262,15 @@ pub const TypeDescriptor = struct { big_decimal: i64, }; + /// True iff this descriptor is a class the USER defined (`deftype`, + /// `defrecord`, `reify`), as opposed to a native / host-surface class. + /// A user class is identified by the descriptor itself, never by its + /// display name (ADR-0201): two vintages of a redefined `R`, or `aaa.Point` + /// and `bbb.Point`, share a simple name and are still different classes. + pub fn isUserClass(self: *const TypeDescriptor) bool { + return self.kind != .native; + } + /// Find a static field by name (ADR-0061). Linear — field tables are /// tiny (≤ 2 today). Parallel to `lookupMethod`; walks the parent /// chain for symmetry though host surfaces have no parent. @@ -662,17 +671,6 @@ pub fn finaliseReifiedInstance(gc_ptr: *anyopaque, header: *HeapHeader) void { _ = header; } -/// Allocate a TypeDescriptor on `rt.gpa` with the declared field -/// layout and register it in `rt.types` under `name`. Re-registering -/// the same name frees the previous descriptor (keeps the REPL -/// re-`def` path clean). `field_names` is borrowed; each entry is -/// `dupe`'d into the descriptor. -/// -/// Shared by the `__deftype!` and `__defrecord!` Layer-2 primitives — -/// both lower to the same Layer-0 surface per F-009 (feature-impl -/// neutrality), differing only in the `kind` argument. Since ADR-0066 -/// both `deftype` and `defrecord` are macros emitting their respective -/// `rt/__…!` registration call (no analyzer Node fork, no backend op). /// Build a defrecord instance from map `m` — clj's `Record/create`: /// declared fields pulled by keyword (nil when absent), every remaining /// key held in the extmap (nil when empty, so the result `=`/hashes as a @@ -700,6 +698,19 @@ pub fn recordFromMap(rt: *Runtime, td: *const TypeDescriptor, m: Value) !Value { return allocInstanceFull(rt, td, fields, Value.nil_val, ext); } +/// Allocate a TypeDescriptor on `rt.gpa` with the declared field layout and +/// register it in `rt.types` under its qualified key `.` +/// (ADR-0198). Re-registering the same key RETIRES the previous descriptor to +/// `rt.retired_types` rather than freeing it: instances, boxed class values +/// and CallSite caches still hold it, and the retired descriptor stays a +/// distinct class from its replacement (ADR-0201). `field_names` is borrowed; +/// each entry is `dupe`'d into the descriptor. +/// +/// Shared by the `__deftype!` and `__defrecord!` Layer-2 primitives, which +/// lower to the same Layer-0 surface per F-009 (feature-impl neutrality) and +/// differ only in the `kind` argument. Since ADR-0066 both `deftype` and +/// `defrecord` are macros emitting their `__deftype!` / `__defrecord!` +/// registration call (no analyzer Node fork, no backend op). pub fn registerType( rt: *Runtime, name: []const u8, diff --git a/test/clj/suites/class_type_test.clj b/test/clj/suites/class_type_test.clj index 885e603e3..c8cb9aa9c 100644 --- a/test/clj/suites/class_type_test.clj +++ b/test/clj/suites/class_type_test.clj @@ -135,3 +135,55 @@ (is (= "TransientVector" (pr-str (class (transient [])))))) (testing "class-level isa? still holds after the rename (isCallableClassName)" (is (= true (isa? (class +) clojure.lang.IFn))))) + +;; --- ADR-0194: the five seq class symbols tools.reader's `inspect*` +;; dispatches on. A defmethod on a class symbol resolves it at ANALYZE time, +;; so these forms loading at all is the first assertion. LazySeq and Cons +;; name types cljw has, so they are native exact-tag classes and their +;; methods are reached. The three JVM inner seq classes name types cljw has +;; no values of (its vector seq is ArraySeq, its map seq is PersistentList), +;; so they resolve as OPAQUE class values whose `instance?` is uniformly +;; false and whose methods are never reached (AD-066 / AD-067). +(defmulti inspect-shape + (fn [x] (cond (vector? x) :vector (map? x) :map :else (class x)))) +(defmethod inspect-shape clojure.lang.PersistentVector$ChunkedSeq [_] :vec-seq) +(defmethod inspect-shape clojure.lang.PersistentArrayMap$Seq [_] :map-seq) +(defmethod inspect-shape clojure.lang.PersistentHashMap$NodeSeq [_] :map-seq) +(defmethod inspect-shape clojure.lang.Cons [_] :cons) +(defmethod inspect-shape clojure.lang.LazySeq [_] :lazy-seq) +(defmethod inspect-shape :default [_] :other) + +(deftest native-seq-classes + (testing "LazySeq resolves and is the class of a lazy seq" + (is (= true (class? clojure.lang.LazySeq))) + (is (= true (= clojure.lang.LazySeq (class (map inc [1]))))) + (is (= true (instance? clojure.lang.LazySeq (map inc [1])))) + (is (= false (instance? clojure.lang.LazySeq [1]))) + (is (= false (instance? clojure.lang.LazySeq (range 3))))) + (testing "Cons is a native exact-tag class, as on the JVM" + (is (= true (class? clojure.lang.Cons))) + (is (= true (= clojure.lang.Cons (class (cons 1 [2 3]))))) + (is (= true (instance? clojure.lang.Cons (cons 1 [2 3])))))) + +(deftest opaque-jvm-seq-classes + (testing "each resolves as a class value distinct from the others" + (is (= true (class? clojure.lang.PersistentVector$ChunkedSeq))) + (is (= true (class? clojure.lang.PersistentArrayMap$Seq))) + (is (= true (class? clojure.lang.PersistentHashMap$NodeSeq))) + (is (= false (= clojure.lang.Cons clojure.lang.PersistentVector$ChunkedSeq))) + (is (= false (= clojure.lang.PersistentArrayMap$Seq + clojure.lang.PersistentHashMap$NodeSeq)))) + (testing "no cljw value is an instance of one (AD-066 / AD-067)" + (is (= false (instance? clojure.lang.PersistentVector$ChunkedSeq (seq [1 2 3])))) + (is (= false (instance? clojure.lang.PersistentArrayMap$Seq (seq {:a 1})))) + (is (= false (instance? clojure.lang.PersistentHashMap$NodeSeq + (seq (zipmap (range 20) (range 20)))))))) + +(deftest inspect-multimethod-dispatches + (testing "the native methods are reached" + (is (= :lazy-seq (inspect-shape (map inc [1 2])))) + (is (= :cons (inspect-shape (cons 1 [2 3]))))) + (testing "the opaque methods are dead: cljw seqs fall to :default (AD-066 / AD-067)" + (is (= :other (inspect-shape (seq [1 2 3])))) + (is (= :other (inspect-shape (seq (range 3))))) + (is (= :other (inspect-shape (seq {:a 1})))))) diff --git a/test/clj/suites/type_redefine_test.clj b/test/clj/suites/type_redefine_test.clj index 8fd4b771c..28b75b858 100644 --- a/test/clj/suites/type_redefine_test.clj +++ b/test/clj/suites/type_redefine_test.clj @@ -19,6 +19,26 @@ (ns suites.type-redefine-test (:require [clojure.test :refer [deftest is]])) +;; --- two namespaces defining the SAME simple name must retain distinct +;; descriptors. Unlike re-evaluation, this is not a redefine: the registry +;; key must include the namespace. The CLI reproduction is the +;; `ns-collision-then-narrowing-redefine` row at the end of this file. +(ns suites.tduafa) +(defrecord Point [x y]) +(def a (->Point 1 2)) + +(ns suites.tduafb) +(defrecord Point [x y]) +(def b (->Point 3 4)) + +(ns suites.type-redefine-test + (:require [clojure.test :refer [deftest is]])) + +(deftest equal-simple-names-in-distinct-namespaces + (is (= "#suites.tduafa.Point{:x 1, :y 2}" (pr-str suites.tduafa/a))) + (is (= "#suites.tduafb.Point{:x 3, :y 4}" (pr-str suites.tduafb/b))) + (is (not (= (class suites.tduafa/a) (class suites.tduafb/b))))) + ;; --- same name, same shape, redefined in one namespace (the REPL re-eval ;; and `require :reload` path) --- @@ -115,3 +135,116 @@ (dotimes [_ 200] (vec (range 64))) (is (some? captured-class)) (is (string? (pr-str captured-class)))) + +;; --- scenarios migrated from the former test/e2e/type_descriptor_redefinition.sh. +;; Every expected value is the JVM's (clojure -M). The printed forms use +;; hyphen-free namespaces because clj munges `-` to `_` in a record class name +;; and cljw does not yet (ADR-0201 Consequences). --- + +;; Same simple name in two namespaces, then a narrowing redefine in the first. +(ns suites.tdra) +(defrecord Point [x y]) +(def a (->Point 1 2)) + +(ns suites.tdrb) +(defrecord Point [x y]) +(def b (->Point 3 4)) + +(ns suites.tdra) +(def a-repr-after-namesake (pr-str a)) +(defrecord Point [x]) +(def a-after (->Point 5)) +(deftype Box [v]) +(def old-box (Box. 7)) +(deftype Box [v]) + +;; defrecord R [a] -> R [a b], each vintage extended separately; a deftype +;; with an inline impl redefined without it; then registration churn under GC. +(ns suites.tdrc) +(defprotocol Area (area [s])) +(defrecord R [a]) +(extend-protocol Area R (area [s] (:a s))) +(def r (->R 1)) +(def rc (class r)) +(defrecord R [a b]) +(extend-protocol Area R (area [s] (+ (:a s) (:b s)))) +(def r-new (->R 2 3)) +(def r-nil (->R 1 nil)) + +(deftype T [x] Area (area [_] x)) +(def t (T. 5)) +(deftype T [x y]) + +(def last-area (atom nil)) +(dotimes [i 3000] + (eval '(defrecord Churn [v])) + (reset! last-area (area (reify Area (area [_] i))))) +(def cs (vec (for [i (range 50)] + (do (eval '(defrecord Churn [v w])) + ((ns-resolve 'suites.tdrc '->Churn) i i))))) +(System/gc) + +;; A constructor form binds the class of the namespace it was WRITTEN in +;; ([CLJW-CTOR-ANALYZE-TIME]). The factories, a `(Point. x y)` in a later fn +;; and one inside the record's own method body all build suites.tdrd.Point when +;; called from another namespace after suites.tdre registered a namesake with a +;; different shape. +(ns suites.tdrd) +(defprotocol Grow (grow [p])) +(defrecord Point [x y] Grow (grow [_] (Point. (inc x) y))) +(deftype Box [v]) +(defn mk [] (Point. 1 2)) + +(ns suites.tdre) +(defrecord Point [q]) +(deftype Box [v w]) + +(ns suites.type-redefine-test + (:require [clojure.test :refer [deftest is]])) + +(deftest ns-collision-then-narrowing-redefine + (is (= "#suites.tdra.Point{:x 1, :y 2}" (pr-str suites.tdra/a))) + (is (= "#suites.tdra.Point{:x 1, :y 2}" suites.tdra/a-repr-after-namesake)) + (is (= "#suites.tdrb.Point{:x 3, :y 4}" (pr-str suites.tdrb/b))) + (is (= "#suites.tdra.Point{:x 5}" (pr-str suites.tdra/a-after))) + (is (= 7 (.-v suites.tdra/old-box)))) + +(deftest widening-redefine-keeps-the-old-vintage-whole + (let [r suites.tdrc/r rc suites.tdrc/rc] + (is (= "#suites.tdrc.R{:a 1}" (pr-str r))) + (is (= 1 (:a r))) + (is (= 1 (.-a r))) + (is (true? (instance? rc r))) + (is (true? (= rc (class r)))) + (is (false? (= (class r) suites.tdrc.R))) + (is (true? (= r r))) + (is (false? (= r suites.tdrc/r-nil))) + (is (= (hash r) (hash r))) + (is (true? (map? r))) + (is (= 1 (suites.tdrc/area r))) + (is (= 5 (suites.tdrc/area suites.tdrc/r-new))))) + +(deftest deftype-redefine-keeps-the-old-method-table + (let [t suites.tdrc/t] + (is (= 5 (.-x t))) + (is (= 5 (suites.tdrc/area t))) + (is (= t t)) + (is (integer? (hash t))))) + +(deftest redefine-churn-under-gc + (let [cs suites.tdrc/cs] + (is (= 1225 (reduce + (map :v cs)))) + (is (= "#suites.tdrc.Churn{:v 0, :w 0}" (pr-str (first cs)))) + (is (= "#suites.tdrc.Churn{:v 49, :w 49}" (pr-str (last cs)))) + (is (= 2999 @suites.tdrc/last-area)))) + +(deftest ctor-binds-its-defining-namespace + (is (= "#suites.tdrd.Point{:x 1, :y 2}" (pr-str (suites.tdrd/->Point 1 2)))) + (is (= "#suites.tdrd.Point{:x 1, :y 2}" (pr-str (suites.tdrd/mk)))) + (is (= "#suites.tdrd.Point{:x 2, :y 2}" (pr-str (suites.tdrd/grow (suites.tdrd/->Point 1 2))))) + (is (= "#suites.tdre.Point{:q 9}" (pr-str (suites.tdre/->Point 9)))) + (is (= "#suites.tdrd.Point{:x 1, :y 2}" (pr-str (suites.tdrd/map->Point {:x 1 :y 2})))) + (is (true? (instance? suites.tdrd.Point (suites.tdrd/mk)))) + (is (false? (instance? suites.tdre.Point (suites.tdrd/mk)))) + (is (= 7 (.-v (suites.tdrd/->Box 7)))) + (is (= 8 (.-w (suites.tdre/->Box 7 8))))) diff --git a/test/clj/suites/user_class_identity_test.clj b/test/clj/suites/user_class_identity_test.clj new file mode 100644 index 000000000..fb4b61a21 --- /dev/null +++ b/test/clj/suites/user_class_identity_test.clj @@ -0,0 +1,169 @@ +;; ADR-0201: a user-defined class (deftype / defrecord / reify) IS its +;; TypeDescriptor, so membership in it is decided by descriptor identity, +;; never by comparing simple-name strings. +;; +;; The simple-name oracle survived ADR-0198 (which gave every other question, +;; `(class x)`, `=` on classes, printing, the registry key, a per-descriptor +;; answer) and so answered `instance?` and `isa?` differently from `=`: +;; after `(defrecord R [a b])` replaced `(defrecord R [a])`, an OLD instance +;; was still `(instance? R old)`, a record from another namespace with the same +;; simple name was an instance of this one, and a deftype named `Sorted` was +;; `sorted?`. Every expected value below is the JVM's (`clojure -M`). +(ns suites.user-class-identity-test + (:require [clojure.test :refer [deftest is testing]])) + +(defprotocol Area (area [s])) + +;; --- same-namespace redefinition: the new class is a DIFFERENT class --- + +(defrecord R [a]) +(def ^:private old-r (->R 1)) +(def ^:private old-r-class (class old-r)) +(defrecord R [a b]) +(def ^:private new-r (->R 1 2)) + +(deftest instance-of-redefined-record + (is (false? (instance? R old-r))) + (is (true? (instance? old-r-class old-r))) + (is (true? (instance? R new-r))) + (is (false? (instance? old-r-class new-r)))) + +(deftest class-isInstance-agrees-with-instance? + (is (false? (.isInstance R old-r))) + (is (true? (.isInstance old-r-class old-r)))) + +(deftest isa?-between-record-vintages + (is (false? (isa? old-r-class R))) + (is (false? (isa? R old-r-class))) + (is (true? (isa? old-r-class old-r-class))) + (is (true? (isa? R R))) + (testing "a user class still sits under Object" + (is (true? (isa? old-r-class Object))) + (is (true? (instance? Object old-r))))) + +(defmulti ^:private by-class class) +(defmethod by-class :default [_] :default) +(defmethod by-class R [_] :new-r) + +(defmulti ^:private by-old-class class) +(defmethod by-old-class old-r-class [_] :old-r) +(defmethod by-old-class :default [_] :default) + +(deftest multimethod-dispatch-on-class-vintage + (testing "a method keyed by the NEW class does not catch an old instance" + (is (= :default (by-class old-r))) + (is (= :new-r (by-class new-r)))) + (testing "a method keyed by the OLD class catches only the old instance" + (is (= :old-r (by-old-class old-r))) + (is (= :default (by-old-class new-r))))) + +(deftype T [x]) +(def ^:private old-t (T. 1)) +(def ^:private old-t-class (class old-t)) +(deftype T [x]) + +(deftest instance-of-redefined-deftype + (is (false? (instance? T old-t))) + (is (true? (instance? old-t-class old-t))) + (is (true? (instance? T (T. 2)))) + (is (false? (= old-t-class T)))) + +;; --- reify: its class value is a class like any other --- + +(defn- mk-area [i] (reify Area (area [_] i))) + +(deftest reify-instance-of-its-own-class + (let [a (mk-area 1)] + (is (true? (instance? (class a) a))) + (is (true? (.isInstance (class a) a))) + (is (true? (isa? (class a) (class a)))) + (is (true? (instance? Object a))) + (is (= 1 (area a))))) + +;; --- a user type named like a host interface is not that interface --- + +(deftype Sorted []) + +(deftest user-type-named-like-an-interface + (is (false? (sorted? (Sorted.)))) + (is (true? (instance? Sorted (Sorted.)))) + (is (false? (instance? clojure.lang.Sorted (Sorted.))))) + +;; --- class-level isa? of a user class against a host class: what the user +;; class DECLARES decides, never its simple name. These names are each a host +;; class's simple name, which the name oracle used to match. --- + +(deftype BigInt []) +(deftype Keyword []) +(deftype PersistentVector []) +(deftype LazySeq []) +(deftype ExceptionInfo []) +(deftype Ratio []) +(deftype Deref [] clojure.lang.IDeref (deref [_] :derefed)) + +(deftest user-class-named-like-a-host-class-is-not-a-subclass-of-it + (is (false? (isa? BigInt Number))) + (is (false? (isa? Ratio Number))) + (is (false? (isa? Keyword clojure.lang.IFn))) + (is (false? (isa? PersistentVector clojure.lang.IFn))) + (is (false? (isa? PersistentVector clojure.lang.IPersistentVector))) + (is (false? (isa? LazySeq clojure.lang.Seqable))) + (is (false? (isa? ExceptionInfo Exception))) + (is (false? (isa? Sorted clojure.lang.Sorted))) + (is (false? (number? (BigInt.)))) + (is (false? (ifn? (Keyword.)))) + (testing "Object and the declared interfaces still answer" + (is (true? (isa? BigInt Object))) + (is (true? (isa? Deref clojure.lang.IDeref))) + (is (= :derefed @(Deref.))))) + +(defmulti ^:private by-host-class class) +(defmethod by-host-class Number [_] :number) +(defmethod by-host-class clojure.lang.IFn [_] :ifn) +(defmethod by-host-class :default [_] :default) + +(deftest multimethod-dispatch-on-a-user-class-named-like-a-host-class + (is (= :default (by-host-class (BigInt.)))) + (is (= :default (by-host-class (Keyword.)))) + (is (= :number (by-host-class 1))) + (is (= :ifn (by-host-class :k)))) + +;; --- two namespaces, one simple name: two classes --- + +(ns suites.ucia) +(defrecord Point [x y]) +(def a (->Point 1 2)) + +(ns suites.ucib) +(defrecord Point [q]) +(def b (->Point 9)) + +(ns suites.user-class-identity-test) + +(deftest same-simple-name-in-two-namespaces + (is (false? (instance? suites.ucia.Point suites.ucib/b))) + (is (true? (instance? suites.ucib.Point suites.ucib/b))) + (is (true? (instance? suites.ucia.Point suites.ucia/a))) + (is (false? (= (class suites.ucia/a) (class suites.ucib/b)))) + (is (false? (isa? (class suites.ucib/b) suites.ucia.Point))) + ;; a factory builds ITS namespace's class, also when called from another + ;; namespace after a namesake was registered later + (is (= [5 6] ((juxt :x :y) (suites.ucia/->Point 5 6)))) + (is (true? (instance? suites.ucia.Point (suites.ucia/->Point 5 6)))) + (is (= {:x 7 :y 8} (into {} (suites.ucia/map->Point {:x 7 :y 8})))) + (is (true? (instance? suites.ucib.Point (suites.ucib/->Point 1))))) + +;; --- an INSTANCE dispatch value is not isa? its class's name, nor a protocol +;; its type declares: clj relates an instance only by equality or derive --- + +(defmulti ^:private by-identity identity) +(defmethod by-identity 'R [_] :sym) +(defmethod by-identity :R [_] :kw) +(defmethod by-identity 'Area [_] :protocol) +(defmethod by-identity :default [_] :default) + +(deftest instance-dispatch-value-is-not-its-class-name + (is (= :default (by-identity new-r))) + (is (= :default (by-identity (mk-area 1)))) + (is (false? (isa? new-r 'R))) + (is (false? (isa? new-r :R)))) diff --git a/test/clj/suites/wasm_dot_interop_test.clj b/test/clj/suites/wasm_dot_interop_test.clj new file mode 100644 index 000000000..964de3867 --- /dev/null +++ b/test/clj/suites/wasm_dot_interop_test.clj @@ -0,0 +1,48 @@ +;; Dot-interop on a core wasm module handle (ADR-0203 decision 2). +;; `(.add h 40 2)`, `(. h add 40 2)` and `(. h (add 40 2))` are +;; `(wasm/call h "add" 40 2)`: the method symbol's name is the export name +;; verbatim, and the call takes the same path as wasm/call, so a missing +;; export, an arity mismatch and a trap raise the same catalogued errors. +;; Both backends carry the case (VM op_method_call, TreeWalk +;; evalInstanceMember). Needs the -Dwasm binary (the gate configuration). +(ns suites.wasm-dot-interop-test + (:require [clojure.test :refer [deftest is testing]])) + +(def ^:private add-wasm "docs/examples/wasm/add.wasm") +(def ^:private kernel-wasm "docs/examples/polyglot/c/kernel.wasm") +(def ^:private trap-wasm "docs/examples/wasm/trap.wasm") + +(defn- msg [f] + (try (f) :not-raised (catch Throwable e (.getMessage e)))) + +(deftest three-spellings + (let [h (wasm/load add-wasm)] + (is (= 42 (.add h 40 2))) + (is (= 42 (. h add 40 2))) + (is (= 42 (. h (add 40 2)))) + (is (= (wasm/call h "add" 40 2) (.add h 40 2))))) + +(deftest c-export-names-verbatim + (let [m (wasm/load kernel-wasm) + buf (.buf_addr m)] + (wasm/mem-write! m :f64 buf [1.0 2.0 3.0 4.0]) + (is (= 10.0 (.sum_f64 m buf 4))) + (is (= 4 (.scale_f64 m buf 4 2.0))) + (is (= 20.0 (.sum_f64 m buf 4))))) + +(deftest errors-are-wasm-call-errors + (let [h (wasm/load add-wasm) + t (wasm/load trap-wasm)] + (testing "missing export" + (is (= (msg #(wasm/call h "nope" 1)) (msg #(.nope h 1)))) + (is (= "wasm/call: no exported function named 'nope'" (msg #(.nope h 1))))) + (testing "arity" + (is (= "wasm/call: 'add' expects 2 argument(s), got 1" (msg #(.add h 1))))) + (testing "trap" + (is (= (msg #(wasm/call t "boom")) (msg #(.boom t)))) + (is (re-find #"^wasm/call: 'boom' \(\) -> \(i32\) trapped" (msg #(.boom t))))))) + +(deftest non-wasm-receivers-unchanged + (is (= "ABC" (.toUpperCase "abc"))) + (is (re-find #"No implementation of method 'add' on protocol '<\.member>'" + (str (msg #(.add 42 1)))))) diff --git a/test/clj/suites/wasm_exports_test.clj b/test/clj/suites/wasm_exports_test.clj new file mode 100644 index 000000000..8cdfb6b8a --- /dev/null +++ b/test/clj/suites/wasm_exports_test.clj @@ -0,0 +1,42 @@ +;; `(wasm/exports h)` lists a core module's exports in export order with +;; runtime-probed signatures (ADR-0203 decision 1). A function answers +;; {:name :kind :func :params [...] :results [...]}, any other export +;; {:name :kind :memory|:global|:table}. The signature comes from the probe +;; `wasm/call` marshals through, so a listed function is callable at exactly +;; the listed arity. Needs the -Dwasm binary (the gate configuration). +(ns suites.wasm-exports-test + (:require [clojure.test :refer [deftest is testing]])) + +(def ^:private add-wasm "docs/examples/wasm/add.wasm") +(def ^:private kernel-wasm "docs/examples/polyglot/c/kernel.wasm") + +(deftest single-function-module + (is (= [{:name "add" :kind :func :params [:i32 :i32] :results [:i32]}] + (wasm/exports (wasm/load add-wasm))))) + +(deftest c-kernel-exports + (let [es (wasm/exports (wasm/load kernel-wasm))] + (testing "the linear memory and four functions, in export order" + (is (= [["memory" :memory] ["buf_addr" :func] ["buf_cap" :func] + ["sum_f64" :func] ["scale_f64" :func]] + (mapv (juxt :name :kind) es)))) + (testing "function signatures" + (is (= [["buf_addr" [] [:i32]] + ["buf_cap" [] [:i32]] + ["sum_f64" [:i32 :i32] [:f64]] + ["scale_f64" [:i32 :i32 :f64] [:i32]]] + (vec (for [e es :when (= :func (:kind e))] + [(:name e) (:params e) (:results e)]))))) + (testing "a non-function export carries no :params / :results" + (is (= {:name "memory" :kind :memory} + (first (filter #(= :memory (:kind %)) es))))))) + +(deftest exports-drive-call + (let [h (wasm/load add-wasm) + e (first (wasm/exports h))] + (is (= 42 (apply wasm/call h (:name e) (take (count (:params e)) [40 2 99])))))) + +(deftest bad-handle-is-catalogued + (is (= "wasm/exports: the first argument must be a loaded wasm module" + (try (wasm/exports 42) nil + (catch Exception e (ex-message e)))))) diff --git a/test/diff/clj_corpus/type_redefinition.txt b/test/diff/clj_corpus/type_redefinition.txt new file mode 100644 index 000000000..cb7d2cedf --- /dev/null +++ b/test/diff/clj_corpus/type_redefinition.txt @@ -0,0 +1,254 @@ +(do (defprotocol Area (area [s])) :ok) +;;=> :ok +(do (defrecord R [a] Area (area [_] :old-area)) :ok) +;;=> :ok +(do (def r (->R 1)) (def rc (class r)) :ok) +;;=> :ok +(do (defrecord R [a b] Area (area [_] :new-area)) :ok) +;;=> :ok +(:a r) +;;=> 1 +(.-a r) +;;=> 1 +(get r :b :missing) +;;=> :missing +(pr-str r) +;;=> "#user.R{:a 1}" +(pr-str (->R 1 2)) +;;=> "#user.R{:a 1, :b 2}" +(instance? R r) +;;=> false +(instance? rc r) +;;=> true +(instance? user.R r) +;;=> false +(instance? R (->R 1 2)) +;;=> true +(instance? rc (->R 1 2)) +;;=> false +(.isInstance R r) +;;=> false +(.isInstance rc r) +;;=> true +(= rc R) +;;=> false +(identical? rc (class r)) +;;=> true +(= r r) +;;=> true +(= r (->R 1 nil)) +;;=> false +(= (->R 1 2) (->R 1 2)) +;;=> true +(= (hash r) (hash r)) +;;=> true +(integer? (hash r)) +;;=> true +(area r) +;;=> :old-area +(area (->R 1 2)) +;;=> :new-area +(satisfies? Area r) +;;=> true +(extends? Area rc) +;;=> true +(map? r) +;;=> true +(record? r) +;;=> true +(pr-str (assoc r :c 3)) +;;=> "#user.R{:a 1, :c 3}" +(pr-str (assoc r :a 9)) +;;=> "#user.R{:a 9}" +(pr-str (dissoc r :a)) +;;=> "{}" +(keys r) +;;=> (:a) +(vals r) +;;=> (1) +(count r) +;;=> 1 +(isa? rc R) +;;=> false +(isa? R rc) +;;=> false +(isa? rc rc) +;;=> true +(isa? rc Object) +;;=> true +(instance? Object r) +;;=> true +(do (defmulti f class) (defmethod f :default [_] :default) (defmethod f R [_] :new) :ok) +;;=> :ok +(f r) +;;=> :default +(f (->R 1 2)) +;;=> :new +(do (defmethod f rc [_] :old) :ok) +;;=> :ok +(f r) +;;=> :old +(f (->R 1 2)) +;;=> :new +(do (defprotocol P2 (p2 [x])) (extend-protocol P2 R (p2 [_] :new-p2)) :ok) +;;=> :ok +(satisfies? P2 r) +;;=> false +(satisfies? P2 (->R 1 2)) +;;=> true +(extends? P2 rc) +;;=> false +(p2 (->R 1 2)) +;;=> :new-p2 +(do (derive rc ::old-kind) :ok) +;;=> :ok +(isa? rc ::old-kind) +;;=> true +(isa? R ::old-kind) +;;=> false +(do (defrecord S [a]) :ok) +;;=> :ok +(do (def s1 (->S 1)) :ok) +;;=> :ok +(do (defrecord S [a]) :ok) +;;=> :ok +(= s1 (->S 1)) +;;=> false +(= (hash s1) (hash (->S 1))) +;;=> true +(instance? S s1) +;;=> false +(pr-str s1) +;;=> "#user.S{:a 1}" +(do (deftype T [x] Area (area [_] :old-t)) :ok) +;;=> :ok +(do (def t (T. 1)) (def tc (class t)) :ok) +;;=> :ok +(do (deftype T [x y]) :ok) +;;=> :ok +(.-x t) +;;=> 1 +(.-x (T. 1 2)) +;;=> 1 +(.-y (T. 1 2)) +;;=> 2 +(instance? T t) +;;=> false +(instance? tc t) +;;=> true +(instance? T (T. 1 2)) +;;=> true +(.isInstance T t) +;;=> false +(= tc T) +;;=> false +(= t t) +;;=> true +(= t (T. 1 2)) +;;=> false +(= (hash t) (hash t)) +;;=> true +(area t) +;;=> :old-t +(satisfies? Area t) +;;=> true +(satisfies? Area (T. 1 2)) +;;=> false +(isa? tc T) +;;=> false +(isa? tc Object) +;;=> true +(f t) +;;=> :default +(do (defn mk [i] (reify Area (area [_] i))) :ok) +;;=> :ok +(reduce + (map area (map mk (range 1000)))) +;;=> 499500 +(let [xs (doall (map mk (range 200)))] (System/gc) (reduce + (map area xs))) +;;=> 19900 +(let [a (mk 1)] [(instance? (class a) a) (.isInstance (class a) a) (isa? (class a) (class a)) (instance? Object a) (isa? (class a) Object)]) +;;=> [true true true true true] +(do (dotimes [_ 300] (eval '(defrecord Churn [v]))) :ok) +;;=> :ok +(pr-str (->Churn 7)) +;;=> "#user.Churn{:v 7}" +(let [olds (doall (for [i (range 50)] (do (eval '(defrecord Churn2 [v])) (eval (list '->Churn2 i)))))] (System/gc) [(reduce + (map :v olds)) (pr-str (first olds)) (pr-str (last olds)) (count (set (map class olds)))]) +;;=> [1225 "#user.Churn2{:v 0}" "#user.Churn2{:v 49}" 50] +(let [olds (doall (for [i (range 20)] (do (eval '(defrecord Churn3 [v])) (eval (list '->Churn3 i)))))] (count (filter #(= (class %) (class (last olds))) olds))) +;;=> 1 +(do (deftype Sorted []) :ok) +;;=> :ok +(sorted? (Sorted.)) +;;=> false +(instance? clojure.lang.Sorted (Sorted.)) +;;=> false +(isa? Sorted clojure.lang.Sorted) +;;=> false +(do (deftype Keyword []) :ok) +;;=> :ok +(ifn? (Keyword.)) +;;=> false +(instance? clojure.lang.IFn (Keyword.)) +;;=> false +(isa? Keyword clojure.lang.IFn) +;;=> false +(keyword? (Keyword.)) +;;=> false +(do (deftype PersistentVector []) :ok) +;;=> :ok +(vector? (PersistentVector.)) +;;=> false +(isa? PersistentVector clojure.lang.IFn) +;;=> false +(isa? PersistentVector clojure.lang.IPersistentVector) +;;=> false +(do (deftype BigInt []) :ok) +;;=> :ok +(number? (BigInt.)) +;;=> false +(instance? Number (BigInt.)) +;;=> false +(isa? BigInt Number) +;;=> false +(do (deftype Ratio []) :ok) +;;=> :ok +(isa? Ratio Number) +;;=> false +(rational? (Ratio.)) +;;=> false +(do (deftype ExceptionInfo []) :ok) +;;=> :ok +(isa? ExceptionInfo Exception) +;;=> false +(instance? Throwable (ExceptionInfo.)) +;;=> false +(do (deftype LazySeq []) :ok) +;;=> :ok +(isa? LazySeq clojure.lang.Seqable) +;;=> false +(seq? (LazySeq.)) +;;=> false +(do (defmulti g class) (defmethod g Number [_] :number) (defmethod g clojure.lang.IFn [_] :ifn) (defmethod g :default [_] :default) :ok) +;;=> :ok +(g (BigInt.)) +;;=> :default +(g (Keyword.)) +;;=> :default +(g 1) +;;=> :number +(g :k) +;;=> :ifn +(do (defmulti mi identity) (defmethod mi 'R [_] :sym) (defmethod mi :R [_] :kw) (defmethod mi 'Area [_] :protocol) (defmethod mi :default [_] :default) :ok) +;;=> :ok +(mi (->R 1 2)) +;;=> :default +(mi (mk 1)) +;;=> :default +(mi r) +;;=> :default +(isa? (->R 1 2) 'R) +;;=> false +(isa? (->R 1 2) :R) +;;=> false +(isa? (mk 1) 'Area) +;;=> false