Skip to content

feat(interp): implement deftype — continuation of #356, with the merge damage repaired - #366

Merged
csm merged 10 commits into
mainfrom
claude/pr-356-wasm-build-fix-hzx9ib
Aug 30, 2026
Merged

feat(interp): implement deftype — continuation of #356, with the merge damage repaired#366
csm merged 10 commits into
mainfrom
claude/pr-356-wasm-build-fix-hzx9ib

Conversation

@csm

@csm csm commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Continuation of #356 (@BuddhiLW's deftype implementation). This branch is #356's head (939d5cc) plus one commit that repairs what the main merges into it broke, so it carries the original feature in full — merging this lands #356.

Opened as a separate PR only because #356's head lives on a fork branch I cannot push to. The author's commits are unchanged and retain their authorship; my single commit sits on top.

Why a continuation was needed

#356 is red on both CI jobs. The two main merges (a86d5fc, 939d5cc) resolved the conflict in crates/cljrs-runtime/src/interp/special.rs by keeping both sides of eval_deftype: a truncated copy of the new one — its body cut off after the field-spec parse, with an orphan fragment of intern_type_symbol's body spliced in referencing globals and ns that don't exist in that scope — followed by #355's old "not implemented" stub.

That is E0428 plus two E0425, so cljrs-runtime did not compile at all. The WASM build failed on those compile errors; Build, test, lint failed separately on rustfmt drift the same merge left behind (}, match arms, a re-wrapped globals.intern(…)).

What the merges silently reverted

Three things, all confined to special.rs:

  1. eval_deftype's body. Restored: it registers the protocol impls with the mutable field names, builds the positional ->T constructor (routing to make-type-instance-mut when the type declares mutable fields), and interns the type symbol. The duplicate stub is gone.

  2. defrecord's field parse, reverted off the shared parse_field_specs onto a second hand-rolled copy — undoing the author's single-source consolidation.

  3. resolve_protocol_sym's three call sites (from fix(interp): resolve a qualified protocol name in impl position #354, included in this stack). All three went back to lookup_in_ns(current_ns, "mp/IThing"), leaving the helper dead — which also fails clippy -D warnings — and cross-namespace protocol impls broken again with "mp/IThing is not a protocol". This one was also lost on main itself, where it is papered over with #[expect(dead_code)] // until next PR uses this; that attribute is no longer needed.

Everything outside special.rs survived the merges intact and is unchanged here: TypeInstance.mutable, the serialize fold, rt_assoc, the set! lowering declines in anf.rs, .-field dispatch, and the make-type-instance-mut builtin.

One deliberate deviation from the original

Restoring parse_field_specs for defrecord broke main's meta_transparency::defrecord_name_and_field_vector: the author's parser matched &form.kind directly and so rejected a ^:marker on the field vector itself, which main's hand-rolled parser accepted via as_vector. parse_field_specs now reads through as_vector, satisfying both the author's single-source intent and main's metadata transparency — and closing the same latent hole for deftype, where (deftype T ^:marker [x]) previously failed.

Tests

None of this was caught, because nothing tested the behaviour — only compilation. Three new files, each verified to actually discriminate by reverting the fix underneath it:

  • deftype_types.rs (16 tests) — constructor, .-field, instance?, the absent map->T, fields in scope in a method body, multi-protocol dispatch, both set! forms, instance independence, mixed immutable/mutable fields.
  • deftype_mutable_tiered.rs (4 tests, its own binary so it can force eager IR lowering process-wide) — 3 of 4 fail when the set!-on-a-local decline is removed (IR interpreter: var not found user/n), while the immutable control still passes. The tree-walking tests cannot reach that path at all, so this is where the tiered-safety half is actually pinned.
  • qualified_protocol_impl.rs (7 tests) — all five impl sites across a namespace boundary, by alias and fully qualified, plus the same-namespace control. 6 of 7 fail when resolve_protocol_sym is neutered.

Docs

docs/book/src/language/differences.md and TODO.md both still said deftype is not implemented. The crate READMEs now carry TypeInstance.mutable and its clone/serialize semantics, the shared deftype/defrecord helpers, the .-field-only dispatch rule on a TypeInstance, and the set! lowering decline.

Verification

All four CI steps run locally on this head:

  • cargo fmt --check — clean
  • cargo clippy --workspace -- -D warnings — clean
  • cargo test --workspace — exit 0, no failures
  • wasm-pack's underlying cargo build --lib --release --target wasm32-unknown-unknown — builds
  • AOT clojure-test-suite310 tests / 5543 assertions, 0 failures, 0 errors

Generated by Claude Code

BuddhiLW and others added 9 commits August 22, 2026 18:59
…od/defrecord

`(defprotocol ^:private Driver ...)` reads as Meta(:private, Symbol("Driver")),
and require_sym matched only FormKind::Symbol, so the interpreter rejected a
form Clojure accepts. malli.impl.regex opens with five such protocols, which
made the whole of malli unloadable on cljrs.

Four defining forms shared the one helper and so shared the one bug. require_sym
becomes require_sym_meta, which peels nested Meta wrappers (`^:a ^:b x`, outer
mark winning) and RETURNS the metadata rather than discarding it: defprotocol
and defmulti attach it to the var they intern, so `^:private` survives instead
of being silently dropped. defmethod resolves an existing multi and defrecord
names a generated type, so neither has a var to carry it; both unwrap and
discard explicitly.
…s own name

Two lexical-scope defects, both silent until call time.

1. A defrecord's fields were unbound as bare symbols in a protocol method body
   ((mutable? [_] (valid-sha? sha)) threw "unbound symbol: sha"), because a
   method impl is built as an ordinary fn whose only bindings are its params,
   while Clojure compiles the fields as instance fields in scope. build_impl_fn
   now wraps the body in (let* [f (:f this) ...] body) for each field a param
   does not already shadow, threaded from defrecord through register_impls_for_tag.
   reify (no fields) and extend-type/extend-protocol are unaffected.

2. A defn/fn param sharing the function's OWN name did not shadow it: the
   self-reference was bound AFTER the params in the same frame, overwriting the
   param. (defn text [text] {:text text}) returned the function as its :text.
   Binding the self-reference before the params, in both the interp and tiered
   apply paths, lets the param shadow it as Clojure does while the name stays
   visible in the body.

Verified against the JVM: bare-field access, mixed field/this, param shadowing,
assoc-then-read, empty-field records, reify, extend-type, self-recursion,
name-in-body identity, arity-specific and destructured and rest params.
defrecord/reify/extend-type/extend-protocol looked up the protocol name with
lookup_in_ns(current_ns, s), passing the whole symbol string. A qualified name
— mp/IThing or mini.proto/IThing — is neither interned nor referred under that
string in the current ns, so a cross-namespace protocol impl failed with
"mp/IThing is not a protocol" even though the protocol was loaded and
(resolve 'mini.proto/IThing) was truthy. This sank every DIP/port design on
cljrs, where the protocol and its implementations live in different namespaces
by definition.

Added resolve_protocol_sym, which parses the symbol, resolves the namespace
part through the current ns's :require :as aliases (falling back to the literal
ns), and looks the name up there — the same resolution eval uses for any other
qualified symbol. All three impl sites route through it; an unqualified name
still resolves in the current ns.

Verified against the JVM: defrecord (alias and fully-qualified), reify,
extend-type, extend-protocol across a namespace boundary, plus the same-file
control, all identical. cljrs-runtime suite 431/431.
deftype was a builtin wired to a nil stub, so (deftype T [x y]) took the
function-application path, evaluated its args, and died on the unbound symbol T
with "Unable to resolve symbol: T" — an error that names a symbol and points
nowhere near the real cause. deftype is genuinely unimplemented (it needs
mutable/volatile fields, set! over them, and array interop).

Made it a special form that returns "deftype is not implemented (defining T);
use defrecord where a map-backed type suffices" without evaluating its args, so
the failure lands on the deftype line and says what is actually missing.
Removed the unreachable builtin registration. defrecord and reify unaffected;
suite 431/431.
…tocol impls)

deftype was a loud "not implemented" special form. Make it real: a
positional ->T constructor, protocol/interface method impls with the
fields in scope (the machinery defrecord/reify already share), the type
symbol interned so instance? resolves, and .-field access on type
instances via dispatch_method.

Extract the constructor / field-parse / symbol-intern helpers so deftype
and defrecord share one definition rather than two divergent copies;
defrecord now also accepts per-field metadata as a side effect.

Immutable deftype only — mutable/volatile fields + set! land separately.
clojure-test-suite: 5486/5486.
…tile-mutable) + set!

A deftype field marked ^:unsynchronized-mutable or ^:volatile-mutable now
carries an interior-mutable slot on the instance — an Atom cell over a
keyword→value map. Method bodies read it live through (.-field this);
(set! field v) inside a method and (set! (.-field inst) v) externally update
the cell, and the in-method local snapshot is refreshed so read-after-write
within one method is correct. Instances are !Send, so volatile and
unsynchronized-mutable behave identically.

- TypeInstance gains `mutable: Option<GcPtr<Atom>>`; assoc/assoc-in/with-meta
  preserve the cell; make-type-instance/reify/deserialize leave it None
  (serialize folds the slot values into the field map).
- New make-type-instance-mut builtin; the deftype ->T ctor routes to it when
  the type declares mutable fields.
- The IR lowerer declines to lower a set! whose target is a local binding (a
  mutable-field write), so a hot mutable-field method tree-walks instead of
  miscompiling to a global var store.
- defrecord now shares the field-spec parser (Single-Source Lever).

clojure-test-suite: 5486/5486; verified interactively (instance
independence, >300-iter hot path, read-after-write).
… tests

The merge of main into upstream/deftype resolved the conflict in
interp/special.rs by keeping BOTH sides of eval_deftype: a truncated copy of
the new one — its body cut off after the field-spec parse and an orphan
fragment of intern_type_symbol's body spliced in, referencing `globals` and
`ns` that do not exist there — followed by #355's old "not implemented" stub.
That is E0428 plus two E0425, so cljrs-runtime did not compile at all: both the
WASM build and Build/test/lint went red, the WASM job on the compile errors and
the lint job on rustfmt drift the same merge left behind.

Three things the merges silently reverted, restored:

1. eval_deftype's body. It registers the protocol impls with the mutable field
   names, builds the positional ->T constructor (routing to
   make-type-instance-mut when the type declares mutable fields) and interns
   the type symbol. The duplicate stub is gone.

2. defrecord's field parse, back onto the shared parse_field_specs rather than
   a second hand-rolled copy. parse_field_specs now reads the vector through
   `as_vector`, so a marker on the field vector itself — (defrecord R ^:marker
   [x]) — stays transparent for deftype as it already was for defrecord.

3. resolve_protocol_sym's three call sites (PR #354). The merges dropped all
   three back to lookup_in_ns(current_ns, "mp/IThing"), leaving the helper dead
   — which also fails `clippy -D warnings` — and cross-namespace protocol impls
   broken again with "mp/IThing is not a protocol".

Everything outside special.rs survived and is unchanged: TypeInstance.mutable,
the serialize fold, rt_assoc, the set! lowering declines, .-field dispatch and
the make-type-instance-mut builtin.

Nothing caught any of this, because nothing tested the behaviour — so:

- deftype_types.rs: the constructor, .-field, instance?, the absent map->T,
  fields in scope in a method body, multi-protocol dispatch, set! in both forms,
  instance independence, and mixed immutable/mutable fields.
- deftype_mutable_tiered.rs: its own binary so it can force eager IR lowering
  process-wide. Three of its four tests fail when the set!-on-a-local decline
  is removed ("IR interpreter: var not found user/n"), and the immutable
  control still passes — the tree-walking tests cannot discriminate there.
- qualified_protocol_impl.rs: all five impl sites across a namespace boundary,
  by alias and fully qualified, plus the same-ns control. Six of the seven fail
  when resolve_protocol_sym is neutered.

Docs: differences.md and TODO.md said deftype is not implemented; the crate
READMEs now carry TypeInstance.mutable, the shared deftype/defrecord helpers,
the .-field dispatch rule and the set! lowering decline.

Verified: cargo fmt --check, clippy --workspace -D warnings, cargo test
--workspace, the wasm32-unknown-unknown release build, and the AOT
clojure-test-suite (310 tests / 5543 assertions, 0 failures).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wdq2umKkdALhZbbGMxM8n1
Comment thread crates/cljrs-runtime/tests/deftype_mutable_tiered.rs Fixed
Comment thread crates/cljrs-runtime/tests/deftype_types.rs Fixed
Comment thread crates/cljrs-runtime/tests/deftype_types.rs Fixed
Comment thread crates/cljrs-runtime/tests/qualified_protocol_impl.rs Fixed
CodeQL raised four high "cleartext logging of sensitive information" alerts
on the three new test files, at the panic arms of eval_pr/eval_err.

The finding is a false positive in substance — these are test helpers whose
panic fires only when the test is already failing, and there is no secret in an
interpreter value — but the dataflow behind it is real: `Value::Uuid(u128)` is
a variant, so Debug-formatting ANY `Value` into a panic reads as writing a UUID
to a log. Which type came back instead of a string is what these assertions are
actually about, so report `type_name()` and the alert has nothing to follow.

The same line exists in tests/defrecord_method_fields.rs, which this was copied
from; CodeQL reports only alerts new in a PR's diff, so it is untouched here
rather than widening this PR.

27 tests across the three binaries still pass; fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wdq2umKkdALhZbbGMxM8n1
@csm
csm merged commit cda6d7a into main Aug 30, 2026
5 checks passed
@csm
csm deleted the claude/pr-356-wasm-build-fix-hzx9ib branch August 30, 2026 04:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants