Skip to main content

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}