squonk_ast/render/dyn_ext.rs
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Moderately AI Inc.
3
4//! The dynamic extension escape hatch: [`DynExt`].
5//!
6//! The stock extension seam is the *generic, typed* one: a node parameterized by
7//! `X: Extension` ([`Statement<X>`](crate::ast::Statement), [`Expr<X>`](crate::ast::Expr),
8//! …) monomorphizes to one concrete extension type chosen at compile time. That is
9//! the zero-cost default — `NoExt` makes the `Other` variant statically dead, and a
10//! concrete `X` is inlined with no indirection.
11//!
12//! This module adds the *opt-in* alternative for callers who must compose an
13//! extension set at **run time** — a plugin host, say, that loads several unrelated
14//! custom-node kinds and cannot name a single `X` enum that closes over all of
15//! them. Such a caller uses [`DynExt`] as their `X`: one type-erased node that any
16//! number of concrete extension kinds can inhabit, selected dynamically. Nothing
17//! about the static paths changes — `Statement<NoExt>` and `Statement<MyEnum>` keep
18//! their exact layout and codegen; `DynExt` is a distinct instantiation you pay for
19//! only where you write it.
20//!
21//! # Why a facet, not `Box<dyn Extension>`
22//!
23//! [`Extension`] is *not* object-safe, so `Box<dyn Extension>`
24//! cannot exist. Its supertraits each break dyn-compatibility for a different
25//! reason: `Clone` returns `Self` (and requires `Sized`), `PartialEq`/`Eq` take
26//! `Self` by reference in argument position, and `Hash::hash` is generic over the
27//! `Hasher`. A trait object erases the concrete type, so none of those signatures
28//! can be dispatched through a vtable.
29//!
30//! [`DynAstExt`] is the standard dyn-compatible *facet* of that obligation set
31//! (mirroring the `dyn`-wrapper idiom std uses for `Error`/`Any`): every
32//! non-object-safe method is re-expressed as an object-safe shim — `dyn_clone`
33//! returns a fresh box, `dyn_eq` takes an erased `&dyn DynAstExt` and downcasts,
34//! `dyn_hash` drives a `&mut dyn Hasher`. [`Render`] and [`Spanned`] are *already*
35//! object-safe, so they ride along as supertraits and need no shim. A blanket impl
36//! lifts every `T: Extension + Render + 'static` into a `DynAstExt`.
37//!
38//! # Why the newtype, not a bare `Box<dyn DynAstExt>`
39//!
40//! It is tempting to make `Box<dyn DynAstExt>` *itself* the `X` by implementing
41//! `Clone`/`Eq`/`Hash` on it. That compiles, but it does **not** compose with the
42//! `#[derive(PartialEq)]`/`Clone`/`Hash` the node types use: a derived
43//! `self.ext == other.ext` over a `Box<dyn Trait>` field *moves* the box out of the
44//! shared `&other` instead of borrowing it (the `==` operator lowers through `Box`'s
45//! `Deref`, and a user `PartialEq` impl on the box does not get the borrow the std
46//! blanket gets), so `Statement<Box<dyn DynAstExt>>` fails to derive `PartialEq` —
47//! the box is then not a drop-in `X` at all. Wrapping the box in the `Sized` newtype
48//! [`DynExt`], whose own `PartialEq`/`Hash`/`Clone` are hand-written to borrow the
49//! inner box explicitly, restores composition: a derived `self.ext == other.ext`
50//! over a `DynExt` field borrows like any ordinary field. So `DynExt` — not the bare
51//! box — is the public hatch, and it slots into every node and render/visit site.
52//!
53//! # Equality and hashing of erased nodes
54//!
55//! `dyn_eq` compares by *concrete type then value*: two nodes are equal exactly when
56//! they hold the same underlying type and that type's `PartialEq` deems the payloads
57//! equal; differently-typed nodes are never equal. This is the only rule consistent
58//! with the structural `Eq`/`Hash` the rest of the AST derives, and it is not
59//! optional — `Eq` and `Hash` are *load-bearing* for the [`Extension`] bound, so
60//! without them `DynExt` could not be an `X` at all. `dyn_hash` mixes the [`TypeId`]
61//! before the payload hash so the hash stays consistent with that type-discriminating
62//! equality.
63//!
64//! # Trade-off: thread-safety
65//!
66//! `DynExt` wraps `Box<dyn DynAstExt + 'static>`; it is deliberately *not*
67//! `Send`/`Sync`, so a [`Parsed`](crate::ast) carrying it forgoes the stock root's
68//! `Send + Sync`. That is inherent to an unbounded trait object and is
69//! the price of run-time composition; a `Send + Sync` variant would need a second,
70//! separately-named bound and is out of scope until a caller needs it.
71//!
72//! [`Extension`]: crate::ast::Extension
73
74use std::any::{Any, TypeId};
75use std::fmt;
76use std::hash::{Hash, Hasher};
77
78use crate::ast::{Extension, Spanned};
79use crate::precedence::BindingPower;
80use crate::vocab::Span;
81
82use super::{Render, RenderCtx};
83
84/// Object-safe facet of [`Extension`] `+` [`Render`], so a
85/// runtime-composed extension set can be erased behind [`DynExt`].
86///
87/// This trait is rarely named directly and almost never implemented by hand: the
88/// blanket impl covers every `T: Extension + Render + 'static`, and callers
89/// interact with the erased node through [`DynExt`] and its ordinary `Clone`/
90/// `PartialEq`/`Hash`/`Spanned`/`Render` impls. The one method worth calling
91/// directly is [`as_any`](DynAstExt::as_any), to downcast an erased node back to a
92/// concrete type (or use [`DynExt::downcast_ref`]).
93///
94/// `Render` and `Spanned` are supertraits (both are already object-safe), so a
95/// `&dyn DynAstExt` can be rendered and span-queried straight through the vtable.
96pub trait DynAstExt: Render + Spanned + fmt::Debug {
97 /// Erase to `&dyn Any` for downcasting a node back to its concrete type.
98 fn as_any(&self) -> &dyn Any;
99
100 /// Clone into a fresh box — the object-safe stand-in for `Clone` (whose
101 /// `Self`-returning signature cannot go through a vtable).
102 fn dyn_clone(&self) -> Box<dyn DynAstExt>;
103
104 /// Structural equality against another erased node — the object-safe stand-in
105 /// for `PartialEq` (whose `&Self` argument cannot go through a vtable). Equal
106 /// iff `other` holds the same concrete type and that type deems the values
107 /// equal; differently-typed nodes are never equal.
108 fn dyn_eq(&self, other: &dyn DynAstExt) -> bool;
109
110 /// Feed this node's hash into an erased hasher — the object-safe stand-in for
111 /// `Hash::hash` (whose generic `H: Hasher` cannot go through a vtable).
112 fn dyn_hash(&self, state: &mut dyn Hasher);
113}
114
115impl<T: Extension + Render + 'static> DynAstExt for T {
116 fn as_any(&self) -> &dyn Any {
117 self
118 }
119
120 fn dyn_clone(&self) -> Box<dyn DynAstExt> {
121 Box::new(self.clone())
122 }
123
124 fn dyn_eq(&self, other: &dyn DynAstExt) -> bool {
125 // Downcast to `Self`; a different concrete type can never be equal. This is
126 // what makes erased equality match the typed path: `Other(a) == Other(b)`
127 // holds exactly when `a` and `b` are the same node, same as the derived
128 // `PartialEq` on a static `X`.
129 other
130 .as_any()
131 .downcast_ref::<T>()
132 .is_some_and(|other| self == other)
133 }
134
135 fn dyn_hash(&self, mut state: &mut dyn Hasher) {
136 // `&mut dyn Hasher: Hasher` (std's `impl Hasher for &mut H`), so the concrete
137 // `Hash` impl drives the type-erased hasher directly. Mixing the `TypeId`
138 // first keeps `Hash` consistent with the type-discriminating `dyn_eq`: two
139 // different extension types that happen to hash their payloads identically
140 // still (almost surely) land on different hashes, never colliding as equal.
141 TypeId::of::<T>().hash(&mut state);
142 self.hash(&mut state);
143 }
144}
145
146/// A type-erased extension node — the opt-in `X` for runtime-composed extension
147/// sets.
148///
149/// `DynExt` reconstructs the full `Extension + Render` surface from the object-safe
150/// [`DynAstExt`] shims, so it drops into any node (`Statement<DynExt>`,
151/// `Expr<DynExt>`, …) and every render/visit site exactly where `NoExt` or a
152/// concrete `X` would go — while the static paths keep their zero-cost layout. The
153/// newtype (rather than a bare `Box<dyn DynAstExt>`) is what lets the node types'
154/// derived `PartialEq`/`Hash`/`Clone` compose; see the module docs.
155///
156/// Build one with [`new`](DynExt::new) from any concrete extension node, and recover
157/// the concrete type with [`downcast_ref`](DynExt::downcast_ref):
158///
159/// ```
160/// use squonk_ast::render::{DynExt, Render, RenderCtx};
161/// use squonk_ast::{Span, Spanned};
162/// use std::fmt;
163///
164/// #[derive(Clone, Debug, PartialEq, Eq, Hash)]
165/// struct MyNode(u32);
166/// impl Spanned for MyNode {
167/// fn span(&self) -> Span { Span::SYNTHETIC }
168/// }
169/// impl Render for MyNode {
170/// fn render(&self, _ctx: &RenderCtx<'_>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171/// write!(f, "my({})", self.0)
172/// }
173/// }
174///
175/// let ext = DynExt::new(MyNode(7));
176/// assert_eq!(ext.downcast_ref::<MyNode>(), Some(&MyNode(7)));
177/// assert_eq!(ext.clone(), ext);
178/// ```
179pub struct DynExt(Box<dyn DynAstExt>);
180
181impl DynExt {
182 /// Erase a concrete extension node into the dynamic hatch.
183 pub fn new<T: Extension + Render + 'static>(ext: T) -> Self {
184 DynExt(Box::new(ext))
185 }
186
187 /// Recover the concrete extension type, or `None` if this node is some other type.
188 pub fn downcast_ref<T: 'static>(&self) -> Option<&T> {
189 self.0.as_any().downcast_ref::<T>()
190 }
191}
192
193// The impls below rebuild the full `Extension + Render` surface on the `Sized`
194// newtype from the object-safe shims. Each borrows the inner box explicitly (never a
195// bare `==`/`Clone::clone` on `Box<dyn DynAstExt>`), so they compose with the node
196// types' `#[derive(..)]` (see module docs) and impose no cost on the static paths.
197
198impl Clone for DynExt {
199 fn clone(&self) -> Self {
200 DynExt(self.0.dyn_clone())
201 }
202}
203
204impl fmt::Debug for DynExt {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 // Transparent, mirroring the typed path where `Other { ext }` debugs as the
207 // inner node rather than as a wrapper.
208 fmt::Debug::fmt(&self.0, f)
209 }
210}
211
212impl PartialEq for DynExt {
213 fn eq(&self, other: &Self) -> bool {
214 self.0.dyn_eq(&*other.0)
215 }
216}
217
218impl Eq for DynExt {}
219
220impl Hash for DynExt {
221 fn hash<H: Hasher>(&self, state: &mut H) {
222 self.0.dyn_hash(state);
223 }
224}
225
226impl Spanned for DynExt {
227 fn span(&self) -> Span {
228 self.0.span()
229 }
230}
231
232impl Render for DynExt {
233 fn render(&self, ctx: &RenderCtx<'_>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 self.0.render(ctx, f)
235 }
236
237 fn operand_binding_power(&self) -> Option<BindingPower> {
238 // Forward so a *dynamic* extension operator parenthesizes by the same
239 // binding-power rule as a typed one (ADR-0008/0009).
240 self.0.operand_binding_power()
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::ast::{BinaryOperator, Expr, NoExt, Statement};
248 use crate::generated::visit::{self, Visit};
249 use crate::render::{RenderConfig, RenderCtx, RenderExt as _};
250 use crate::vocab::{Meta, NodeId, Resolver, Span, Symbol};
251
252 fn meta() -> Meta {
253 Meta::new(Span::SYNTHETIC, NodeId::new(1).expect("non-zero node id"))
254 }
255
256 /// A trivial renderable extension node: renders as `#<n>`. Used erased to prove
257 /// the dynamic hatch carries a real, type-erased extension.
258 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
259 struct Tag(u32);
260
261 impl Spanned for Tag {
262 fn span(&self) -> Span {
263 Span::SYNTHETIC
264 }
265 }
266
267 impl Render for Tag {
268 fn render(&self, _ctx: &RenderCtx<'_>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269 write!(f, "#{}", self.0)
270 }
271 }
272
273 /// A *second, unrelated* extension type, to show heterogeneous nodes coexisting
274 /// behind one `DynExt` — the runtime-composition the hatch exists for.
275 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
276 struct Marker;
277
278 impl Spanned for Marker {
279 fn span(&self) -> Span {
280 Span::SYNTHETIC
281 }
282 }
283
284 impl Render for Marker {
285 fn render(&self, _ctx: &RenderCtx<'_>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286 f.write_str("<marker>")
287 }
288 }
289
290 fn other(ext: DynExt) -> Expr<DynExt> {
291 Expr::Other { ext, meta: meta() }
292 }
293
294 fn rendered(node: &impl Render) -> String {
295 // No identifiers/literals in these trees, so an empty resolver/source suffice.
296 struct Empty;
297 impl Resolver for Empty {
298 fn try_resolve(&self, _sym: Symbol) -> Option<&str> {
299 None
300 }
301 }
302 let config = RenderConfig::default();
303 let ctx = RenderCtx::new(&Empty, "", &config);
304 node.displayed(&ctx).to_string()
305 }
306
307 #[test]
308 fn erased_extension_renders_through_the_node_render_path() {
309 // A `DynExt` drops straight into `Expr::Other` and renders via the ordinary
310 // `impl<X: Extension + Render> Render for Expr<X>` seam.
311 let expr = other(DynExt::new(Tag(7)));
312 assert_eq!(rendered(&expr), "#7");
313 }
314
315 #[test]
316 fn heterogeneous_erased_extensions_compose_in_one_tree() {
317 // Two unrelated concrete types behind the same `X = DynExt`, in a single
318 // built-in node — impossible on the typed path without a hand-written sum
319 // type enumerating both. Each renders through its own vtable.
320 let expr: Expr<DynExt> = Expr::BinaryOp {
321 left: Box::new(other(DynExt::new(Tag(1)))),
322 op: BinaryOperator::Plus,
323 right: Box::new(other(DynExt::new(Marker))),
324 meta: meta(),
325 };
326 assert_eq!(rendered(&expr), "#1 + <marker>");
327 }
328
329 #[test]
330 fn node_holding_dynext_derives_eq_and_clone() {
331 // The crux this design exists to make work: the node types' own
332 // `#[derive(PartialEq, Eq, Clone)]` compose over a `DynExt` field. A bare
333 // `Box<dyn DynAstExt>` field would *fail* to derive (see module docs).
334 let expr = other(DynExt::new(Tag(7)));
335 let same = other(DynExt::new(Tag(7)));
336 let different = other(DynExt::new(Tag(8)));
337
338 assert!(
339 expr == same,
340 "structurally equal erased nodes compare equal"
341 );
342 assert!(expr != different, "different payloads compare unequal");
343 assert!(
344 expr == expr.clone(),
345 "a cloned subtree stays equal to its origin"
346 );
347 }
348
349 #[test]
350 fn erased_extension_equality_is_concrete_type_then_value() {
351 let a = DynExt::new(Tag(1));
352 let a2 = DynExt::new(Tag(1));
353 let b = DynExt::new(Tag(2));
354 let m = DynExt::new(Marker);
355
356 assert!(a == a2, "same type, same value compares equal");
357 assert!(a != b, "same type, different value compares unequal");
358 assert!(a != m, "different concrete types are never equal");
359 }
360
361 #[test]
362 fn erased_extension_clone_is_a_deep_typed_clone() {
363 let original = DynExt::new(Tag(42));
364 let clone = original.clone();
365 assert!(original == clone);
366 // The clone is a real `Tag`, recoverable by downcast — not some erased husk.
367 assert_eq!(clone.downcast_ref::<Tag>(), Some(&Tag(42)));
368 }
369
370 #[test]
371 fn erased_extension_hash_agrees_with_equality() {
372 use std::collections::hash_map::DefaultHasher;
373
374 fn hash_of(ext: &DynExt) -> u64 {
375 let mut hasher = DefaultHasher::new();
376 ext.hash(&mut hasher);
377 hasher.finish()
378 }
379
380 // Equal values must hash equally (the `Hash`/`Eq` contract the AST relies on).
381 assert_eq!(hash_of(&DynExt::new(Tag(1))), hash_of(&DynExt::new(Tag(1))));
382 }
383
384 #[test]
385 fn visitor_threads_and_downcasts_erased_extensions() {
386 // The generated `Visit` traversal needs no object-safety work: it hands the
387 // visitor `&X`, and here `X = DynExt`, which a visitor can both count and
388 // downcast — so existing tooling sees dynamic extensions for free.
389 #[derive(Default)]
390 struct Collect {
391 tags: Vec<u32>,
392 others: usize,
393 }
394
395 impl<'ast> Visit<'ast, DynExt> for Collect {
396 fn visit_extension(&mut self, node: &'ast DynExt) {
397 match node.downcast_ref::<Tag>() {
398 Some(Tag(n)) => self.tags.push(*n),
399 None => self.others += 1,
400 }
401 }
402 }
403
404 let expr: Expr<DynExt> = Expr::BinaryOp {
405 left: Box::new(other(DynExt::new(Tag(1)))),
406 op: BinaryOperator::Plus,
407 right: Box::new(other(DynExt::new(Marker))),
408 meta: meta(),
409 };
410 let mut collect = Collect::default();
411 collect.visit_expr(&expr);
412
413 assert_eq!(collect.tags, vec![1]);
414 assert_eq!(collect.others, 1, "the non-Tag extension was still visited");
415 let _ = visit::walk_expr::<Collect, DynExt>; // walk_* is generic over X
416 }
417
418 #[test]
419 fn dynamic_hatch_does_not_change_the_static_noext_layout() {
420 use std::mem::size_of;
421
422 // The headline guarantee, pinned locally. The stock `NoExt` path is pinned
423 // byte-for-byte by `crate::generated::size_asserts`; those budgets did not
424 // move when this hatch was added (the generated file is unchanged). Here we
425 // re-state the two invariants that make the hatch *opt-in*:
426 // 1. the default type parameter is `NoExt`, so the hot path is `NoExt`;
427 assert_eq!(size_of::<Statement<NoExt>>(), size_of::<Statement>());
428 // 2. the erased extension is a strictly *wider*, distinct instantiation —
429 // its fat-pointer payload widens `Other`, so you pay only when you reach
430 // for it; the `NoExt` variant stays zero-width and dead.
431 assert!(size_of::<Statement<NoExt>>() < size_of::<Statement<DynExt>>());
432 }
433}