Skip to main content

miniextendr_macros/
externalptr_derive.rs

1//! # `#[derive(ExternalPtr)]` - ExternalPtr Support
2//!
3//! This module implements the `#[derive(ExternalPtr)]` macro which generates
4//! a `TypedExternal` impl for use with `ExternalPtr<T>`.
5//!
6//! Trait ABI wrapper infrastructure is automatically generated when you use
7//! `#[miniextendr]` on `impl Trait for Type` blocks.
8//!
9//! ## Sidecar codegen vs `#[miniextendr]` fns
10//!
11//! `#[derive(ExternalPtr)]` with `#[r_data]` fields emits a pair of C/R
12//! wrappers per sidecar field (`<Type>_get_<field>` /
13//! `<Type>_set_<field>`). **These wrappers do NOT go through
14//! `c_wrapper_builder::CWrapperContext`** — they are hand-rolled in this
15//! module so they can present the simplest signature R expects from a slot
16//! accessor:
17//!
18//! | Aspect | `#[miniextendr]` fn / method (`c_wrapper_builder`) | Sidecar accessor (here) |
19//! |---|---|---|
20//! | First C param | `__miniextendr_call: SEXP` | none |
21//! | C `numArgs` | 1 + user args | `1` (getter) / `2` (setter) |
22//! | R `.Call` | `.Call(C_…, .call = match.call(), …)` | `.Call(C_…, x)` / `.Call(C_…, x, value)` |
23//! | Error transport | tagged-condition SEXP via `with_r_unwind_protect(…, Some(call))` | tagged-condition SEXP via `with_r_unwind_protect(…, None)`; the R guard's `sys.call()` supplies attribution |
24//!
25//! **Do not add `.call = match.call()` to a sidecar R wrapper.** The C
26//! function doesn't have a slot for it — R will throw "Incorrect number of
27//! arguments" at runtime. The two paths drifting apart is tracked in #348.
28//!
29//! ## Usage
30//!
31//! ### Basic (no traits)
32//!
33//! ```ignore
34//! #[derive(ExternalPtr)]
35//! struct MyData {
36//!     value: i32,
37//! }
38//! // Generates: impl TypedExternal for MyData { ... }
39//! ```
40//!
41//! ### With R Sidecar Slots and Class System
42//!
43//! The `#[r_data]` attribute marks fields for R-side storage. Use `#[externalptr(...)]`
44//! to specify a class system for appropriate R wrapper generation:
45//!
46//! | Class System | Attribute | R Accessors |
47//! |--------------|-----------|-------------|
48//! | Environment | `#[externalptr(env)]` (default) | `Type_get_field()`, `Type_set_field()` |
49//! | R6 | `#[externalptr(r6)]` | Active bindings in R6Class |
50//! | S3 | `#[externalptr(s3)]` | `$.class`, `$<-.class` methods |
51//! | S4 | `#[externalptr(s4)]` | Slot accessors |
52//! | S7 | `#[externalptr(s7)]` | Properties via `new_property()` |
53//!
54//! Three field tiers are supported:
55//!
56//! 1. **Raw SEXP** (`SEXP`) - Direct SEXP access, no conversion
57//! 2. **Zero-overhead scalars** (`i32`, `f64`, `bool`, `u8`) - Direct R memory access
58//! 3. **Conversion types** (anything else) - Uses `IntoR`/`TryFromSexp` traits
59//!
60//! ```ignore
61//! #[derive(ExternalPtr)]
62//! #[externalptr(r6)]  // R6 class - generates active bindings
63//! pub struct MyType {
64//!     pub x: i32,
65//!
66//!     #[r_data]
67//!     r: RSidecar,  // Selector - enables R accessors for this type
68//!
69//!     #[r_data]
70//!     pub raw_slot: SEXP,  // Raw SEXP, no conversion
71//!
72//!     #[r_data]
73//!     pub count: i32,  // Zero-overhead: stored as R INTEGER(1)
74//!
75//!     #[r_data]
76//!     pub score: f64,  // Zero-overhead: stored as R REAL(1)
77//!
78//!     #[r_data]
79//!     pub name: String,  // Conversion: uses IntoR/TryFromSexp
80//! }
81//! // Generates: active bindings `count`, `score`, `name` in R6Class
82//! ```
83//!
84//! ### Trait ABI wiring
85//!
86//! ```ignore
87//! #[derive(ExternalPtr)]
88//! struct MyCounter {
89//!     value: i32,
90//! }
91//!
92//! #[miniextendr]
93//! impl Counter for MyCounter { /* ... */ }
94//! ```
95//!
96//! ## Generated Types (trait impls)
97//!
98//! ### Wrapper Struct
99//!
100//! ```ignore
101//! #[repr(C)]
102//! struct __MxWrapperMyCounter {
103//!     erased: mx_erased,  // Must be first field
104//!     data: MyCounter,
105//! }
106//! ```
107//!
108//! ### Base Vtable
109//!
110//! ```ignore
111//! static __MX_BASE_VTABLE_MYCOUNTER: mx_base_vtable = mx_base_vtable {
112//!     drop: __mx_drop_mycounter,
113//!     concrete_tag: TAG_MYCOUNTER,
114//!     query: __mx_query_mycounter,
115//! };
116//! ```
117//!
118//! ### Query Function
119//!
120//! The query function maps trait tags to vtable pointers:
121//!
122//! ```ignore
123//! unsafe extern "C" fn __mx_query_mycounter(
124//!     ptr: *mut mx_erased,
125//!     trait_tag: mx_tag,
126//! ) -> *const c_void {
127//!     if trait_tag == TAG_COUNTER {
128//!         return std::ptr::from_ref(&__VTABLE_COUNTER_FOR_MYCOUNTER).cast::<c_void>();
129//!     }
130//!     std::ptr::null()
131//! }
132//! ```
133
134use proc_macro2::{Span, TokenStream};
135use syn::{DeriveInput, Field, Ident, Visibility};
136
137use crate::miniextendr_impl::ClassSystem;
138
139/// Parse `#[externalptr(...)]` attributes to extract class system.
140///
141/// Supported forms:
142/// - `#[externalptr(env)]` - Environment style (default)
143/// - `#[externalptr(r6)]` - R6 class
144/// - `#[externalptr(s3)]` - S3 class
145/// - `#[externalptr(s4)]` - S4 class
146/// - `#[externalptr(s7)]` - S7 class
147fn parse_externalptr_attrs(input: &DeriveInput) -> syn::Result<ClassSystem> {
148    let mut class_system = ClassSystem::Env; // Default
149
150    for attr in &input.attrs {
151        if attr.path().is_ident("externalptr") {
152            attr.parse_nested_meta(|meta| {
153                let ident_str = meta
154                    .path
155                    .get_ident()
156                    .map(|i| i.to_string())
157                    .unwrap_or_default();
158
159                match ident_str.as_str() {
160                    "env" => class_system = ClassSystem::Env,
161                    "r6" => class_system = ClassSystem::R6,
162                    "s3" => class_system = ClassSystem::S3,
163                    "s4" => class_system = ClassSystem::S4,
164                    "s7" => class_system = ClassSystem::S7,
165                    "vctrs" => class_system = ClassSystem::Vctrs,
166                    _ => {
167                        return Err(syn::Error::new_spanned(
168                            &meta.path,
169                            format!(
170                                "unknown class system '{}'; expected one of: env, r6, s3, s4, s7, vctrs",
171                                ident_str
172                            ),
173                        ));
174                    }
175                }
176                Ok(())
177            })?;
178        }
179    }
180
181    Ok(class_system)
182}
183
184/// Check if a field has the `#[r_data]` attribute.
185fn has_r_data_attr(field: &Field) -> bool {
186    field.attrs.iter().any(|a| a.path().is_ident("r_data"))
187}
188
189/// Parse `prop_doc = "..."` from an `#[r_data(prop_doc = "...")]` attribute.
190///
191/// Returns `None` if the field has no `#[r_data]` attribute, no `prop_doc` key,
192/// or if the attribute has no parenthesized arguments (bare `#[r_data]`).
193fn parse_r_data_prop_doc(field: &Field) -> syn::Result<Option<String>> {
194    for attr in &field.attrs {
195        if !attr.path().is_ident("r_data") {
196            continue;
197        }
198        // `#[r_data]` with no arguments — no prop_doc
199        if matches!(attr.meta, syn::Meta::Path(_)) {
200            return Ok(None);
201        }
202        let mut prop_doc = None;
203        attr.parse_nested_meta(|meta| {
204            if meta.path.is_ident("prop_doc") {
205                let value = meta.value()?;
206                let lit: syn::LitStr = value.parse()?;
207                prop_doc = Some(lit.value());
208                Ok(())
209            } else {
210                Err(meta.error(format!(
211                    "unknown key `{}`; supported: `prop_doc`",
212                    meta.path
213                        .get_ident()
214                        .map(|i| i.to_string())
215                        .unwrap_or_default()
216                )))
217            }
218        })?;
219        return Ok(prop_doc);
220    }
221    Ok(None)
222}
223
224/// Check if a field type is `RSidecar`.
225///
226/// Returns `true` if the last path segment of the field's type is `RSidecar`,
227/// which acts as the selector marker enabling R sidecar accessor generation.
228fn is_rsidecar_type(field: &Field) -> bool {
229    if let syn::Type::Path(type_path) = &field.ty {
230        type_path
231            .path
232            .segments
233            .last()
234            .map(|seg| seg.ident == "RSidecar")
235            .unwrap_or(false)
236    } else {
237        false
238    }
239}
240
241/// Check if a field is public.
242fn is_pub(field: &Field) -> bool {
243    matches!(field.vis, Visibility::Public(_))
244}
245
246/// The kind of sidecar slot, determining how getter/setter FFI functions are generated.
247///
248/// Each kind maps to a different codegen strategy for reading from and writing to
249/// the Rust struct through R's `.Call` interface:
250/// - Raw SEXP: no conversion, direct pass-through.
251/// - Zero-overhead scalars: use R's `Rf_Scalar*`/`Rf_as*` for single-element coercion.
252/// - Conversion: use the `IntoR`/`TryFromSexp` traits for arbitrary types.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254enum SlotKind {
255    /// SEXP type -- raw SEXP access (getter returns SEXP, setter takes SEXP).
256    RawSexp,
257    /// Zero-overhead scalar: `i32` (or `i16`/`i8`) stored as `INTEGER(1)`.
258    ScalarInt,
259    /// Zero-overhead scalar: `f64` (or `f32`) stored as `REAL(1)`.
260    ScalarReal,
261    /// Zero-overhead scalar: `bool` (or `Rbool`) stored as `LOGICAL(1)`.
262    ScalarLogical,
263    /// Zero-overhead scalar: `u8` stored as `RAW(1)`.
264    ScalarRaw,
265    /// Conversion type -- uses `IntoR`/`TryFromSexp` traits for arbitrary Rust types.
266    Conversion,
267}
268
269/// Information about a single `#[r_data]`-annotated sidecar slot field.
270///
271/// Collected during struct field parsing and used to generate FFI getter/setter
272/// functions and R wrapper code for each public slot.
273struct SidecarSlot {
274    /// Rust identifier of the field (e.g., `count`, `name`).
275    name: Ident,
276    /// Rust type of the field, used in conversion-based getter/setter codegen.
277    ty: syn::Type,
278    /// Zero-based index of this slot in the protection VECSXP
279    /// (offset from `PROT_BASE_LEN`, which reserves slots for type ID and user data).
280    index: usize,
281    /// Whether the field is `pub`. Only public fields get R accessor functions.
282    is_public: bool,
283    /// Determines the codegen strategy for reading/writing this slot.
284    kind: SlotKind,
285    /// Optional documentation string for the S7 `@prop` tag.
286    /// Sourced from `#[r_data(prop_doc = "...")]`. `None` means no doc was supplied;
287    /// a default fallback string is used at emit time.
288    prop_doc: Option<String>,
289}
290
291/// Aggregated sidecar information extracted from struct field analysis.
292///
293/// Contains everything needed to generate sidecar accessor code: the
294/// selector presence, the list of typed slots, and the target class system.
295struct SidecarInfo {
296    /// Whether the struct contains an `RSidecar`-typed field marked with `#[r_data]`.
297    /// At most one selector is allowed per struct.
298    has_selector: bool,
299    /// The `#[r_data]` slot fields (excluding the RSidecar selector itself),
300    /// each carrying its index, kind, and visibility.
301    slots: Vec<SidecarSlot>,
302    /// The R class system chosen via `#[externalptr(...)]`, controlling the
303    /// style of generated R wrapper code.
304    class_system: ClassSystem,
305}
306
307/// Determine the [`SlotKind`] for a field type by inspecting its last path segment.
308///
309/// Recognizes `SEXP`, scalar numerics (`i32`, `i16`, `i8`, `f64`, `f32`),
310/// booleans (`bool`, `Rbool`), and raw bytes (`u8`). Everything else falls
311/// through to [`SlotKind::Conversion`].
312fn slot_kind_for_type(ty: &syn::Type) -> SlotKind {
313    if let syn::Type::Path(type_path) = ty
314        && let Some(seg) = type_path.path.segments.last()
315    {
316        let ident = &seg.ident;
317        // Check for raw SEXP access
318        if ident == "SEXP" {
319            return SlotKind::RawSexp;
320        }
321        // Check for zero-overhead scalar types
322        if ident == "i32" || ident == "i16" || ident == "i8" {
323            return SlotKind::ScalarInt;
324        }
325        if ident == "f64" || ident == "f32" {
326            return SlotKind::ScalarReal;
327        }
328        if ident == "bool" || ident == "Rbool" {
329            return SlotKind::ScalarLogical;
330        }
331        if ident == "u8" {
332            return SlotKind::ScalarRaw;
333        }
334    }
335    // Everything else uses conversion
336    SlotKind::Conversion
337}
338
339/// Parse struct fields for sidecar information.
340///
341/// Iterates over all fields, identifying `#[r_data]` markers. Fields with
342/// `RSidecar` type are tracked as selector markers (at most one allowed);
343/// all other `#[r_data]` fields become [`SidecarSlot`] entries with their
344/// slot kind inferred from the field type.
345///
346/// Returns `Err` if more than one `RSidecar` field is found.
347fn parse_sidecar_info(input: &DeriveInput, class_system: ClassSystem) -> syn::Result<SidecarInfo> {
348    let fields = match &input.data {
349        syn::Data::Struct(data) => &data.fields,
350        _ => {
351            return Ok(SidecarInfo {
352                has_selector: false,
353                slots: vec![],
354                class_system,
355            });
356        }
357    };
358
359    let mut selector_fields: Vec<&Field> = vec![];
360    let mut slots = vec![];
361    let mut slot_index = 0usize;
362
363    for field in fields.iter() {
364        if !has_r_data_attr(field) {
365            continue;
366        }
367
368        if is_rsidecar_type(field) {
369            // RSidecar is the selector marker, not a slot
370            selector_fields.push(field);
371        } else if let Some(ref ident) = field.ident {
372            // Any other type with #[r_data] becomes a slot
373            let kind = slot_kind_for_type(&field.ty);
374            let prop_doc = parse_r_data_prop_doc(field)?;
375            slots.push(SidecarSlot {
376                name: ident.clone(),
377                ty: field.ty.clone(),
378                index: slot_index,
379                is_public: is_pub(field),
380                kind,
381                prop_doc,
382            });
383            slot_index += 1;
384        }
385    }
386
387    // Check for multiple selectors
388    if selector_fields.len() > 1 {
389        return Err(syn::Error::new_spanned(
390            selector_fields[1],
391            "only one RSidecar field is allowed per struct",
392        ));
393    }
394
395    Ok(SidecarInfo {
396        has_selector: !selector_fields.is_empty(),
397        slots,
398        class_system,
399    })
400}
401
402/// Generate the token stream for a sidecar getter function body.
403///
404/// Reads the field value from the Rust struct (accessed via the external
405/// pointer address) and converts it to an R SEXP. The conversion strategy
406/// depends on the slot kind:
407/// - `RawSexp`: returns the SEXP field directly.
408/// - Scalar kinds: wraps in `Rf_Scalar*` for zero-overhead conversion.
409/// - `Conversion`: clones the value and calls `IntoR::into_sexp`.
410///
411/// The body runs inside `with_r_unwind_protect` (see the emission site in
412/// [`generate_sidecar_accessors`]); a non-external-pointer argument, a null
413/// pointer address, or a wrong stored type panics with the same
414/// `expected ExternalPtr<T>` message the main class-method path uses. The
415/// panic is transported as a tagged condition and re-raised by the R wrapper.
416fn generate_getter_body(
417    struct_name: &syn::Ident,
418    slot: &SidecarSlot,
419    _prot_index_lit: &syn::LitInt,
420) -> TokenStream {
421    let field_name = &slot.name;
422
423    // Helper: generate the pointer extraction code for Box<dyn Any> storage.
424    // R_ExternalPtrAddr returns *mut Box<dyn Any>; we downcast to &T.
425    // Failure modes panic (caught by the surrounding with_r_unwind_protect
426    // and raised as an R error) instead of silently returning R_NilValue.
427    let extract_ref = quote::quote! {
428        use ::miniextendr_api::{SEXP, SexpExt};
429        use ::miniextendr_api::sys::R_ExternalPtrAddr;
430        if x.type_of() != ::miniextendr_api::SEXPTYPE::EXTPTRSXP {
431            ::std::panic!(concat!(
432                "expected ExternalPtr<", stringify!(#struct_name),
433                ">, got a non-external-pointer object"
434            ));
435        }
436        let any_raw = R_ExternalPtrAddr(x) as *mut Box<dyn ::std::any::Any>;
437        if any_raw.is_null() {
438            ::std::panic!(concat!(
439                "expected ExternalPtr<", stringify!(#struct_name),
440                ">, got a null external pointer"
441            ));
442        }
443        let any_box: &Box<dyn ::std::any::Any> = &*any_raw;
444        let data: &#struct_name = any_box
445            .downcast_ref::<#struct_name>()
446            .expect(concat!("expected ExternalPtr<", stringify!(#struct_name), ">"));
447    };
448
449    match slot.kind {
450        SlotKind::RawSexp => {
451            // Raw SEXP field - return directly (already an R value)
452            quote::quote! {
453                unsafe {
454                    #extract_ref
455                    data.#field_name
456                }
457            }
458        }
459        SlotKind::ScalarInt => {
460            quote::quote! {
461                use ::miniextendr_api::SEXP;
462                unsafe {
463                    #extract_ref
464                    SEXP::scalar_integer(data.#field_name)
465                }
466            }
467        }
468        SlotKind::ScalarReal => {
469            quote::quote! {
470                use ::miniextendr_api::SEXP;
471                unsafe {
472                    #extract_ref
473                    SEXP::scalar_real(data.#field_name)
474                }
475            }
476        }
477        SlotKind::ScalarLogical => {
478            quote::quote! {
479                use ::miniextendr_api::SEXP;
480                unsafe {
481                    #extract_ref
482                    SEXP::scalar_logical(data.#field_name)
483                }
484            }
485        }
486        SlotKind::ScalarRaw => {
487            quote::quote! {
488                use ::miniextendr_api::SEXP;
489                unsafe {
490                    #extract_ref
491                    SEXP::scalar_raw(data.#field_name)
492                }
493            }
494        }
495        SlotKind::Conversion => {
496            // Use IntoR trait for conversion (e.g., String -> character)
497            let ty = &slot.ty;
498            quote::quote! {
499                use ::miniextendr_api::into_r::IntoR;
500                unsafe {
501                    #extract_ref
502                    let val: #ty = data.#field_name.clone();
503                    <#ty as IntoR>::into_sexp(val)
504                }
505            }
506        }
507    }
508}
509
510/// Generate the token stream for a sidecar setter function body.
511///
512/// Converts the incoming R SEXP `value` and writes it to the corresponding
513/// Rust struct field. The conversion strategy depends on the slot kind:
514/// - `RawSexp`: stores the SEXP directly.
515/// - Scalar kinds: uses `Rf_as*` or coercion for single-element extraction;
516///   an input that doesn't reduce to a single non-NA scalar raises a
517///   conversion error instead of silently storing an NA/`false` sentinel.
518/// - `Conversion`: uses `TryFromSexp::try_from_sexp`; a failed conversion
519///   raises a conversion error instead of silently dropping the write.
520///
521/// Returns the external pointer `x` (for R's invisible return convention).
522/// The body runs inside `with_r_unwind_protect` (see the emission site in
523/// [`generate_sidecar_accessors`]); a bad receiver panics with the main-path
524/// `expected ExternalPtr<T>` message and conversion failures return a tagged
525/// condition SEXP with kind `conversion` — both are re-raised by the R
526/// wrapper. Sidecar accessors have no `__miniextendr_call` slot (#344/#348),
527/// so the tagged conditions carry null call attribution.
528fn generate_setter_body(
529    struct_name: &syn::Ident,
530    slot: &SidecarSlot,
531    _prot_index_lit: &syn::LitInt,
532) -> TokenStream {
533    let field_name = &slot.name;
534
535    // Helper: extract mutable reference via Box<dyn Any> downcast.
536    // Failure modes panic (caught by the surrounding with_r_unwind_protect
537    // and raised as an R error) instead of silently no-op'ing.
538    let extract_mut = quote::quote! {
539        use ::miniextendr_api::SexpExt;
540        use ::miniextendr_api::sys::R_ExternalPtrAddr;
541        if x.type_of() != ::miniextendr_api::SEXPTYPE::EXTPTRSXP {
542            ::std::panic!(concat!(
543                "expected ExternalPtr<", stringify!(#struct_name),
544                ">, got a non-external-pointer object"
545            ));
546        }
547        let any_raw = R_ExternalPtrAddr(x) as *mut Box<dyn ::std::any::Any>;
548        if any_raw.is_null() {
549            ::std::panic!(concat!(
550                "expected ExternalPtr<", stringify!(#struct_name),
551                ">, got a null external pointer"
552            ));
553        }
554        let any_box: &mut Box<dyn ::std::any::Any> = &mut *any_raw;
555        let data = any_box
556            .downcast_mut::<#struct_name>()
557            .expect(concat!("expected ExternalPtr<", stringify!(#struct_name), ">"));
558    };
559
560    // Conversion-failure error message prefix, mirroring the main path's
561    // "failed to convert parameter '<name>' to <ty>: ..." wording.
562    let err_prefix = format!(
563        "failed to convert value for sidecar field '{}' on `{}`",
564        field_name, struct_name
565    );
566    let scalar_err = |expected: &str| -> syn::LitStr {
567        syn::LitStr::new(
568            &format!("{err_prefix}: expected {expected}"),
569            field_name.span(),
570        )
571    };
572
573    match slot.kind {
574        SlotKind::RawSexp => {
575            quote::quote! {
576                unsafe {
577                    #extract_mut
578                    data.#field_name = value;
579                    x
580                }
581            }
582        }
583        SlotKind::ScalarInt => {
584            let err_msg = scalar_err("a single non-NA integer-compatible value");
585            quote::quote! {
586                use ::miniextendr_api::SexpExt;
587                unsafe {
588                    #extract_mut
589                    data.#field_name = match value.as_integer() {
590                        Some(v) => v,
591                        None => return ::miniextendr_api::error_value::make_rust_condition_value(
592                            #err_msg,
593                            ::miniextendr_api::error_value::kind::CONVERSION,
594                            ::core::option::Option::None,
595                            ::core::option::Option::None,
596                        ),
597                    };
598                    x
599                }
600            }
601        }
602        SlotKind::ScalarReal => {
603            let err_msg = scalar_err("a single non-NA numeric-compatible value");
604            quote::quote! {
605                use ::miniextendr_api::SexpExt;
606                unsafe {
607                    #extract_mut
608                    data.#field_name = match value.as_real() {
609                        Some(v) => v,
610                        None => return ::miniextendr_api::error_value::make_rust_condition_value(
611                            #err_msg,
612                            ::miniextendr_api::error_value::kind::CONVERSION,
613                            ::core::option::Option::None,
614                            ::core::option::Option::None,
615                        ),
616                    };
617                    x
618                }
619            }
620        }
621        SlotKind::ScalarLogical => {
622            let err_msg = scalar_err("a single non-NA logical-compatible value");
623            quote::quote! {
624                use ::miniextendr_api::SexpExt;
625                unsafe {
626                    #extract_mut
627                    data.#field_name = match value.as_logical() {
628                        Some(v) => v,
629                        None => return ::miniextendr_api::error_value::make_rust_condition_value(
630                            #err_msg,
631                            ::miniextendr_api::error_value::kind::CONVERSION,
632                            ::core::option::Option::None,
633                            ::core::option::Option::None,
634                        ),
635                    };
636                    x
637                }
638            }
639        }
640        SlotKind::ScalarRaw => {
641            let err_msg = scalar_err("at least one raw-compatible value");
642            quote::quote! {
643                use ::miniextendr_api::{SexpExt, SEXPTYPE};
644                unsafe {
645                    #extract_mut
646                    let raw_vec = value.coerce(SEXPTYPE::RAWSXP);
647                    if raw_vec.len() == 0 {
648                        return ::miniextendr_api::error_value::make_rust_condition_value(
649                            #err_msg,
650                            ::miniextendr_api::error_value::kind::CONVERSION,
651                            ::core::option::Option::None,
652                            ::core::option::Option::None,
653                        );
654                    }
655                    data.#field_name = raw_vec.raw_elt(0);
656                    x
657                }
658            }
659        }
660        SlotKind::Conversion => {
661            let ty = &slot.ty;
662            let err_msg_lit = syn::LitStr::new(&err_prefix, field_name.span());
663            quote::quote! {
664                use ::miniextendr_api::TryFromSexp;
665                unsafe {
666                    #extract_mut
667                    data.#field_name = match <#ty as TryFromSexp>::try_from_sexp(value) {
668                        Ok(val) => val,
669                        Err(e) => return ::miniextendr_api::error_value::make_rust_condition_value(
670                            &format!("{}: {e}", #err_msg_lit),
671                            ::miniextendr_api::error_value::kind::CONVERSION,
672                            ::core::option::Option::None,
673                            ::core::option::Option::None,
674                        ),
675                    };
676                    x
677                }
678            }
679        }
680    }
681}
682
683/// Generate R wrapper code (roxygen-annotated R functions) for a single sidecar slot.
684///
685/// Produces getter and setter R functions that call the corresponding C entry
686/// points via `.Call`. The generated R code includes roxygen tags (`@rdname`,
687/// `@param`, `@return`, `@export`) for documentation.
688///
689/// All class systems generate the same standalone function pattern
690/// (`Type_get_field` / `Type_set_field`); only the roxygen title suffix
691/// differs. Class-specific integration (e.g., R6 active bindings, S7
692/// properties) is handled separately by [`generate_class_integration_r_code`].
693///
694/// Each wrapper body carries the shared tagged-condition guard
695/// ([`crate::method_return_builder::standalone_body`]) so Rust-origin
696/// panics/conversion failures are re-raised as structured R conditions.
697/// Note the sidecar C functions have **no** `.call` slot — the `.Call()` must
698/// not pass `.call = match.call()` (#344/#348); `sys.call()` inside the guard
699/// supplies fallback attribution instead.
700fn generate_r_wrapper_for_slot(
701    class_system: ClassSystem,
702    type_name: &str,
703    field_name: &str,
704    getter_c_name: &str,
705    setter_c_name: &str,
706) -> String {
707    // Only the roxygen title suffix differs between class systems.
708    let suffix = match class_system {
709        ClassSystem::Env => "",
710        ClassSystem::R6 => " (for R6)",
711        ClassSystem::S3 => " (for S3)",
712        ClassSystem::S4 => " (for S4)",
713        ClassSystem::S7 => " (for S7)",
714        ClassSystem::Vctrs => " (for vctrs)",
715    };
716    let r_getter_name = format!("{}_get_{}", type_name, field_name);
717    let r_setter_name = format!("{}_set_{}", type_name, field_name);
718    let getter_body = crate::method_return_builder::standalone_body(
719        &format!(".Call({getter_c_name}, x)"),
720        ".val",
721        "  ",
722    );
723    let setter_body = crate::method_return_builder::standalone_body(
724        &format!(".Call({setter_c_name}, x, value)"),
725        "invisible(x)",
726        "  ",
727    );
728    format!(
729        r#"
730#' Get `{field}` field from {type}{suffix}
731#' @rdname {type}
732#' @param x The {type} external pointer
733#' @return The value of the `{field}` field
734#' @export
735{r_getter} <- function(x) {{
736  {getter_body}
737}}
738
739#' Set `{field}` field on {type}{suffix}
740#' @rdname {type}
741#' @param x The {type} external pointer
742#' @param value The new value to set
743#' @return The {type} pointer (invisibly)
744#' @export
745{r_setter} <- function(x, value) {{
746  {setter_body}
747}}
748"#,
749        type = type_name,
750        field = field_name,
751        suffix = suffix,
752        r_getter = r_getter_name,
753        r_setter = r_setter_name,
754        getter_body = getter_body,
755        setter_body = setter_body,
756    )
757}
758
759/// Generate class-integrated R code for sidecar fields.
760///
761/// For R6: generates `Type$set("active", "field", ...)` calls that add active bindings
762/// referencing the sidecar getter/setter .Call entrypoints.
763///
764/// For S7: generates `.rdata_properties_Type <- list(...)` with S7::new_property()
765/// definitions that can be spliced into the S7 class's properties list.
766///
767/// Other class systems return empty strings (their standalone accessors suffice).
768fn generate_class_integration_r_code(
769    class_system: ClassSystem,
770    type_name: &str,
771    pub_slots: &[&SidecarSlot],
772) -> String {
773    if pub_slots.is_empty() {
774        return String::new();
775    }
776
777    // Shared tagged-condition guard (one line); `sys.call()` supplies fallback
778    // attribution because the sidecar C functions carry no `.call` slot
779    // (#344/#348 — never add `.call = match.call()` to a sidecar `.Call()`).
780    let guard =
781        |indent: &str| crate::method_return_builder::condition_check_lines(indent).join("\n");
782
783    match class_system {
784        ClassSystem::R6 => {
785            // Generate $set("active", ...) calls for each sidecar field.
786            // These are appended after the R6Class definition and add active bindings
787            // that delegate to the sidecar .Call accessors.
788            let mut code = String::new();
789            code.push_str(&format!(
790                "\n# Auto-generated active bindings for {type} sidecar fields.\n",
791                type = type_name,
792            ));
793            code.push_str(
794                "# These are applied when `r_data_accessors` is set on the impl block.\n",
795            );
796            code.push_str(&format!(
797                ".rdata_active_bindings_{type} <- function(cls) {{\n\
798                 \x20 # R CMD check: self/private are R6 runtime bindings (set by cls$set)\n\
799                 \x20 self <- private <- NULL\n",
800                type = type_name,
801            ));
802            for slot in pub_slots {
803                let field = slot.name.to_string();
804                let getter_c = format!("C__mx_rdata_get_{}_{}", type_name, field);
805                let setter_c = format!("C__mx_rdata_set_{}_{}", type_name, field);
806                code.push_str(&format!(
807                    "  cls$set(\"active\", \"{field}\", function(value) {{\n\
808                     \x20   if (missing(value)) {{\n\
809                     \x20     .val <- .Call({getter_c}, private$.ptr)\n\
810                     {getter_guard}\n\
811                     \x20     .val\n\
812                     \x20   }} else {{\n\
813                     \x20     .val <- .Call({setter_c}, private$.ptr, value)\n\
814                     {setter_guard}\n\
815                     \x20     invisible(self)\n\
816                     \x20   }}\n\
817                     \x20 }}, overwrite = TRUE)\n",
818                    field = field,
819                    getter_c = getter_c,
820                    setter_c = setter_c,
821                    getter_guard = guard("      "),
822                    setter_guard = guard("      "),
823                ));
824            }
825            code.push_str("}\n");
826            code
827        }
828        ClassSystem::S7 => {
829            // Generate a helper list of S7::new_property() definitions.
830            // The S7 wrapper generator references this when `r_data_accessors` is set.
831            let mut code = String::new();
832            code.push_str(&format!(
833                "\n# Auto-generated S7 property definitions for {type} sidecar fields.\n",
834                type = type_name,
835            ));
836            code.push_str(&format!(
837                ".rdata_properties_{type} <- list(\n",
838                type = type_name,
839            ));
840            for (i, slot) in pub_slots.iter().enumerate() {
841                let field = slot.name.to_string();
842                let getter_c = format!("C__mx_rdata_get_{}_{}", type_name, field);
843                let setter_c = format!("C__mx_rdata_set_{}_{}", type_name, field);
844                let comma = if i < pub_slots.len() - 1 { "," } else { "" };
845                code.push_str(&format!(
846                    "    {field} = S7::new_property(\n\
847                     \x20       getter = function(self) {{\n\
848                     \x20         .val <- .Call({getter_c}, self@.ptr)\n\
849                     {getter_guard}\n\
850                     \x20         .val\n\
851                     \x20       }},\n\
852                     \x20       setter = function(self, value) {{\n\
853                     \x20         .val <- .Call({setter_c}, self@.ptr, value)\n\
854                     {setter_guard}\n\
855                     \x20         self\n\
856                     \x20       }}\n\
857                     \x20   ){comma}\n",
858                    field = field,
859                    getter_c = getter_c,
860                    setter_c = setter_c,
861                    getter_guard = guard("          "),
862                    setter_guard = guard("          "),
863                    comma = comma,
864                ));
865            }
866            code.push_str(")\n");
867            code
868        }
869        // Other class systems don't need class-integrated code
870        _ => String::new(),
871    }
872}
873
874/// Generate sidecar accessor constants and `extern "C-unwind"` functions.
875///
876/// For each public `#[r_data]` field, generates:
877/// - A getter FFI function (`C__mx_rdata_get_Type_field`)
878/// - A setter FFI function (`C__mx_rdata_set_Type_field`)
879/// - `R_CallMethodDef` entries for routine registration
880/// - R wrapper function code (roxygen-documented)
881/// - Class-integration code for R6 / S7 (active bindings / properties)
882///
883/// The generated constants are:
884/// - `RDATA_CALL_DEFS_{TYPE}`: slice of `R_CallMethodDef` for registration
885/// - `R_WRAPPERS_RDATA_{TYPE}`: string literal of R wrapper code
886///
887/// Returns `Err` if the struct has generic type parameters (`.Call` entrypoints
888/// cannot be generic).
889fn generate_sidecar_accessors(input: &DeriveInput, info: &SidecarInfo) -> syn::Result<TokenStream> {
890    // Reject generic structs — .Call entrypoints cannot be generic
891    if !input.generics.params.is_empty() {
892        return Err(syn::Error::new_spanned(
893            &input.generics,
894            "ExternalPtr does not support generic structs; \
895             .Call entrypoints cannot be generic",
896        ));
897    }
898
899    // If no selector or no public slots, nothing to register
900    let pub_slots: Vec<_> = info.slots.iter().filter(|s| s.is_public).collect();
901    if !info.has_selector || pub_slots.is_empty() {
902        return Ok(quote::quote! {});
903    }
904
905    let name = &input.ident;
906    let name_str = name.to_string();
907    let name_upper = name_str.to_uppercase();
908
909    // Base prot slot indices (type ID at 0, user at 1)
910    const PROT_BASE_LEN: usize = 2;
911
912    // Generate getter/setter functions and R wrappers for each pub slot
913    let mut c_functions = vec![];
914    let mut r_wrappers = String::new();
915
916    // Add documentation header that establishes the @name topic for sidecar accessors.
917    // This ensures @rdname references have a valid target even if the main class
918    // definition uses @noRd.
919    if !pub_slots.is_empty() {
920        r_wrappers.push_str(&format!(
921            r#"
922#' @title {type} Sidecar Accessors
923#' @name {type}
924#' @description Getter and setter functions for `#[r_data]` fields on `{type}`.
925#' @source Generated by miniextendr from `#[derive(ExternalPtr)]` on `{type}`
926NULL
927
928"#,
929            type = name_str,
930        ));
931    }
932
933    for slot in &pub_slots {
934        let field_name = &slot.name;
935        let field_name_str = field_name.to_string();
936        let prot_index = PROT_BASE_LEN + slot.index;
937
938        // C function names
939        let getter_c_name = format!("C__mx_rdata_get_{}_{}", name_str, field_name_str);
940        let setter_c_name = format!("C__mx_rdata_set_{}_{}", name_str, field_name_str);
941        let getter_fn_name = Ident::new(&getter_c_name, Span::call_site());
942        let setter_fn_name = Ident::new(&setter_c_name, Span::call_site());
943        let source_location_doc = crate::source_location_doc(field_name.span());
944        let getter_doc = format!(
945            "Generated sidecar getter for `{}` field on Rust type `{}`.",
946            field_name_str, name_str
947        );
948        let setter_doc = format!(
949            "Generated sidecar setter for `{}` field on Rust type `{}`.",
950            field_name_str, name_str
951        );
952        let getter_doc_lit = syn::LitStr::new(&getter_doc, field_name.span());
953        let setter_doc_lit = syn::LitStr::new(&setter_doc, field_name.span());
954
955        let prot_index_lit = syn::LitInt::new(&prot_index.to_string(), Span::call_site());
956
957        // Generate getter/setter bodies based on slot kind
958        let getter_body = generate_getter_body(name, slot, &prot_index_lit);
959        let setter_body = generate_setter_body(name, slot, &prot_index_lit);
960
961        // Generate C getter function.
962        //
963        // The body runs under `with_r_unwind_protect` so Rust panics (and R
964        // longjmps during allocation) become a tagged condition SEXP that the
965        // R wrapper re-raises, matching the main call-slot path. Sidecar
966        // accessors have no `__miniextendr_call` slot (#344/#348), so the
967        // transport uses null call attribution; the R wrapper's guard passes
968        // `sys.call()` to `.miniextendr_raise_condition` as the fallback.
969        c_functions.push(quote::quote! {
970            #[doc = #getter_doc_lit]
971            #[doc = #source_location_doc]
972            #[doc = concat!("Generated from source file `", file!(), "`.")]
973            #[doc(hidden)]
974            #[unsafe(no_mangle)]
975            pub unsafe extern "C-unwind" fn #getter_fn_name(
976                x: ::miniextendr_api::SEXP
977            ) -> ::miniextendr_api::SEXP {
978                ::miniextendr_api::unwind_protect::with_r_unwind_protect(
979                    || { #getter_body },
980                    ::core::option::Option::None,
981                )
982            }
983        });
984
985        // Generate C setter function (same unwind/condition transport as the
986        // getter above).
987        c_functions.push(quote::quote! {
988            #[doc = #setter_doc_lit]
989            #[doc = #source_location_doc]
990            #[doc = concat!("Generated from source file `", file!(), "`.")]
991            #[doc(hidden)]
992            #[unsafe(no_mangle)]
993            pub unsafe extern "C-unwind" fn #setter_fn_name(
994                x: ::miniextendr_api::SEXP,
995                value: ::miniextendr_api::SEXP,
996            ) -> ::miniextendr_api::SEXP {
997                ::miniextendr_api::unwind_protect::with_r_unwind_protect(
998                    || { #setter_body },
999                    ::core::option::Option::None,
1000                )
1001            }
1002        });
1003
1004        // Generate R_CallMethodDef entries via distributed slice
1005        let getter_c_name_cstr = format!("{}\0", getter_c_name);
1006        let setter_c_name_cstr = format!("{}\0", setter_c_name);
1007        let getter_cstr_lit =
1008            syn::LitByteStr::new(getter_c_name_cstr.as_bytes(), Span::call_site());
1009        let setter_cstr_lit =
1010            syn::LitByteStr::new(setter_c_name_cstr.as_bytes(), Span::call_site());
1011        let getter_def_ident = Ident::new(
1012            &format!(
1013                "__MX_CALL_DEF_RDATA_GET_{}_{}",
1014                name_upper,
1015                field_name_str.to_uppercase()
1016            ),
1017            Span::call_site(),
1018        );
1019        let setter_def_ident = Ident::new(
1020            &format!(
1021                "__MX_CALL_DEF_RDATA_SET_{}_{}",
1022                name_upper,
1023                field_name_str.to_uppercase()
1024            ),
1025            Span::call_site(),
1026        );
1027
1028        c_functions.push(quote::quote! {
1029            #[cfg_attr(not(target_arch = "wasm32"), ::miniextendr_api::linkme::distributed_slice(::miniextendr_api::registry::MX_CALL_DEFS), linkme(crate = ::miniextendr_api::linkme))]
1030            #[doc(hidden)]
1031            static #getter_def_ident: ::miniextendr_api::sys::R_CallMethodDef =
1032                ::miniextendr_api::sys::R_CallMethodDef {
1033                    name: #getter_cstr_lit.as_ptr().cast(),
1034                    fun: Some(unsafe { ::std::mem::transmute(#getter_fn_name as unsafe extern "C-unwind" fn(_) -> _) }),
1035                    numArgs: 1,
1036                };
1037        });
1038        c_functions.push(quote::quote! {
1039            #[cfg_attr(not(target_arch = "wasm32"), ::miniextendr_api::linkme::distributed_slice(::miniextendr_api::registry::MX_CALL_DEFS), linkme(crate = ::miniextendr_api::linkme))]
1040            #[doc(hidden)]
1041            static #setter_def_ident: ::miniextendr_api::sys::R_CallMethodDef =
1042                ::miniextendr_api::sys::R_CallMethodDef {
1043                    name: #setter_cstr_lit.as_ptr().cast(),
1044                    fun: Some(unsafe { ::std::mem::transmute(#setter_fn_name as unsafe extern "C-unwind" fn(_, _) -> _) }),
1045                    numArgs: 2,
1046                };
1047        });
1048
1049        // Generate R wrapper code based on class system
1050        let field_start = field_name.span().start();
1051        r_wrappers.push_str(&format!(
1052            "# Generated from Rust source line {}:{}\n# Wraps sidecar field `{}` on Rust type `{}` via `{}` and `{}`.\n",
1053            field_start.line,
1054            field_start.column + 1,
1055            field_name_str,
1056            name_str,
1057            getter_c_name,
1058            setter_c_name,
1059        ));
1060        r_wrappers.push_str(&generate_r_wrapper_for_slot(
1061            info.class_system,
1062            &name_str,
1063            &field_name_str,
1064            &getter_c_name,
1065            &setter_c_name,
1066        ));
1067    }
1068
1069    // Generate class-integrated R code for R6 and S7.
1070    // This code is appended after the standalone accessors so that
1071    // `r_data_accessors` in the impl block can auto-integrate sidecar fields.
1072    r_wrappers.push_str(&generate_class_integration_r_code(
1073        info.class_system,
1074        &name_str,
1075        &pub_slots,
1076    ));
1077
1078    let const_name_wrappers = Ident::new(
1079        &format!("R_WRAPPERS_RDATA_{}", name_upper),
1080        Span::call_site(),
1081    );
1082    let source_location_doc = crate::source_location_doc(name.span());
1083
1084    // For S7 class systems, emit MX_S7_SIDECAR_PROPS entries so the S7 codegen
1085    // can substitute @prop lines for sidecar properties at write time.
1086    let sidecar_prop_entries = if info.class_system == ClassSystem::S7 {
1087        let entries: Vec<_> = pub_slots
1088            .iter()
1089            .map(|slot| {
1090                let field_str = slot.name.to_string();
1091                let doc_str = slot
1092                    .prop_doc
1093                    .as_deref()
1094                    .unwrap_or("(undocumented sidecar property)");
1095                let entry_ident = Ident::new(
1096                    &format!(
1097                        "__MX_S7_SIDECAR_PROP_{}_{}",
1098                        name_upper,
1099                        field_str.to_uppercase()
1100                    ),
1101                    Span::call_site(),
1102                );
1103                quote::quote! {
1104                    #[doc(hidden)]
1105                    #[cfg_attr(not(target_arch = "wasm32"), ::miniextendr_api::linkme::distributed_slice(::miniextendr_api::registry::MX_S7_SIDECAR_PROPS), linkme(crate = ::miniextendr_api::linkme))]
1106                    static #entry_ident: ::miniextendr_api::registry::SidecarPropEntry =
1107                        ::miniextendr_api::registry::SidecarPropEntry {
1108                            rust_type: #name_str,
1109                            field_name: #field_str,
1110                            prop_doc: #doc_str,
1111                        };
1112                }
1113            })
1114            .collect();
1115        quote::quote! { #(#entries)* }
1116    } else {
1117        quote::quote! {}
1118    };
1119
1120    Ok(quote::quote! {
1121        #(#c_functions)*
1122
1123        /// Sidecar accessor R wrapper code via distributed slice.
1124        #[doc = #source_location_doc]
1125        #[doc = concat!("Generated from source file `", file!(), "`.")]
1126        #[doc(hidden)]
1127        #[cfg_attr(not(target_arch = "wasm32"), ::miniextendr_api::linkme::distributed_slice(::miniextendr_api::registry::MX_R_WRAPPERS), linkme(crate = ::miniextendr_api::linkme))]
1128        static #const_name_wrappers: ::miniextendr_api::registry::RWrapperEntry =
1129            ::miniextendr_api::registry::RWrapperEntry {
1130                priority: ::miniextendr_api::registry::RWrapperPriority::Sidecar,
1131                source_file: file!(),
1132                content: #r_wrappers,
1133            };
1134
1135        #sidecar_prop_entries
1136    })
1137}
1138
1139/// Generate the `TypedExternal` trait implementation for the derive target.
1140///
1141/// Produces three associated constants:
1142/// - `TYPE_NAME`: the struct name as a `&'static str`
1143/// - `TYPE_NAME_CSTR`: null-terminated byte string of the struct name
1144/// - `TYPE_ID_CSTR`: globally unique ID in the format
1145///   `"<crate_name>@<crate_version>::<module_path>::<type_name>\0"`,
1146///   using `CARGO_PKG_NAME`, `CARGO_PKG_VERSION`, and `module_path!()`.
1147///
1148/// Supports generic structs (generics are forwarded to the impl).
1149fn generate_typed_external(input: &DeriveInput) -> TokenStream {
1150    let name = &input.ident;
1151    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
1152
1153    let name_str = name.to_string();
1154    let name_lit = syn::LitStr::new(&name_str, name.span());
1155    let name_cstr = syn::LitByteStr::new(format!("{}\0", name_str).as_bytes(), name.span());
1156
1157    // TYPE_ID_CSTR format: "<crate_name>@<crate_version>::<module_path>::<type_name>\0"
1158    //
1159    // Uses env!("CARGO_PKG_NAME") and env!("CARGO_PKG_VERSION") for the crate identifier,
1160    // ensuring two packages with the same type name from the same crate+version are compatible,
1161    // while different crate versions are considered distinct types.
1162    //
1163    // The module_path!() may include "crate::" prefix when compiled within the crate,
1164    // but combined with the explicit crate@version prefix, this is unambiguous.
1165    quote::quote! {
1166        impl #impl_generics ::miniextendr_api::externalptr::TypedExternal for #name #ty_generics #where_clause {
1167            const TYPE_NAME: &'static str = #name_lit;
1168            const TYPE_NAME_CSTR: &'static [u8] = #name_cstr;
1169            const TYPE_ID_CSTR: &'static [u8] =
1170                concat!(
1171                    env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION"),
1172                    "::", module_path!(), "::", #name_lit, "\0"
1173                ).as_bytes();
1174        }
1175    }
1176}
1177
1178/// Generate the `IntoExternalPtr` marker trait impl.
1179///
1180/// This marker trait enables the blanket `impl<T: IntoExternalPtr> IntoR for T`
1181/// in miniextendr-api, allowing the type to be returned directly from functions.
1182fn generate_into_external_ptr(input: &DeriveInput) -> TokenStream {
1183    let name = &input.ident;
1184    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
1185
1186    quote::quote! {
1187        impl #impl_generics ::miniextendr_api::externalptr::IntoExternalPtr for #name #ty_generics #where_clause {}
1188    }
1189}
1190
1191/// Main entry point for `#[derive(ExternalPtr)]`.
1192///
1193/// Orchestrates the full derive expansion:
1194/// 1. Parses `#[externalptr(...)]` attributes for class system selection.
1195/// 2. Analyzes struct fields for `#[r_data]` sidecar slots.
1196/// 3. Generates `TypedExternal` impl (type identity for `ExternalPtr<T>`).
1197/// 4. Generates `IntoExternalPtr` marker impl (enables `IntoR` blanket impl).
1198/// 5. Generates sidecar accessor FFI functions, registration constants, and R wrappers.
1199///
1200/// Returns the combined token stream of all generated items.
1201pub fn derive_external_ptr(input: DeriveInput) -> syn::Result<TokenStream> {
1202    // Parse class system from #[externalptr(...)] attribute
1203    let class_system = parse_externalptr_attrs(&input)?;
1204
1205    // Parse sidecar information from struct fields
1206    let sidecar_info = parse_sidecar_info(&input, class_system)?;
1207
1208    let typed_external = generate_typed_external(&input);
1209    let into_external_ptr = generate_into_external_ptr(&input);
1210    let sidecar_accessors = generate_sidecar_accessors(&input, &sidecar_info)?;
1211    let erased_wrapper = generate_erased_wrapper(&input);
1212
1213    Ok(quote::quote! {
1214        #typed_external
1215        #into_external_ptr
1216        #sidecar_accessors
1217        #erased_wrapper
1218    })
1219}
1220
1221/// Generate the type-erased wrapper infrastructure for trait ABI dispatch.
1222///
1223/// For `#[derive(ExternalPtr)] struct MyCounter { ... }` generates:
1224/// - `__MxWrapperMyCounter` - repr(C) wrapper with mx_erased header + data
1225/// - `__MX_TAG_MYCOUNTER` - concrete type tag (FNV-1a hash of module_path + type)
1226/// - `__mx_drop_mycounter` - destructor for R GC
1227/// - `__MX_BASE_VTABLE_MYCOUNTER` - base vtable with universal_query
1228/// - `__mx_wrap_mycounter` - constructor returning `*mut mx_erased`
1229fn generate_erased_wrapper(input: &DeriveInput) -> TokenStream {
1230    let type_ident = &input.ident;
1231
1232    // Only for non-generic types (generics can't participate in trait dispatch)
1233    if !input.generics.params.is_empty() {
1234        return quote::quote! {};
1235    }
1236
1237    let type_upper = type_ident.to_string().to_uppercase();
1238    let type_lower = type_ident.to_string().to_lowercase();
1239
1240    let wrapper_name = quote::format_ident!("__MxWrapper{}", type_ident);
1241    let base_vtable_name = quote::format_ident!("__MX_BASE_VTABLE_{}", type_upper);
1242    let concrete_tag_name = quote::format_ident!("__MX_TAG_{}", type_upper);
1243    let drop_fn_name = quote::format_ident!("__mx_drop_{}", type_lower);
1244    let wrap_fn_name = quote::format_ident!("__mx_wrap_{}", type_lower);
1245    let source_loc_doc = crate::source_location_doc(type_ident.span());
1246    let tag_path = format!("::{}", type_ident);
1247
1248    quote::quote! {
1249        #[doc = concat!(
1250            "Type-erased wrapper for `",
1251            stringify!(#type_ident),
1252            "` with trait dispatch support."
1253        )]
1254        #[doc = "Generated by `#[derive(ExternalPtr)]`."]
1255        #[doc = #source_loc_doc]
1256        #[doc = concat!("Generated from source file `", file!(), "`.")]
1257        #[repr(C)]
1258        #[doc(hidden)]
1259        struct #wrapper_name {
1260            pub erased: ::miniextendr_api::abi::mx_erased,
1261            pub data: #type_ident,
1262        }
1263
1264        #[doc(hidden)]
1265        const #concrete_tag_name: ::miniextendr_api::abi::mx_tag =
1266            ::miniextendr_api::abi::mx_tag_from_path(concat!(module_path!(), #tag_path));
1267
1268        #[doc(hidden)]
1269        unsafe extern "C" fn #drop_fn_name(ptr: *mut ::miniextendr_api::abi::mx_erased) {
1270            if ptr.is_null() {
1271                return;
1272            }
1273            let wrapper = ptr.cast::<#wrapper_name>();
1274            // A panicking Drop impl must not unwind across the C-ABI boundary.
1275            // `drop_catching_panic` catches any panic and aborts instead.
1276            ::miniextendr_api::externalptr::drop_catching_panic(|| {
1277                unsafe { drop(Box::from_raw(wrapper)); }
1278            });
1279        }
1280
1281        #[doc(hidden)]
1282        static #base_vtable_name: ::miniextendr_api::abi::mx_base_vtable =
1283            ::miniextendr_api::abi::mx_base_vtable {
1284                drop: #drop_fn_name,
1285                concrete_tag: #concrete_tag_name,
1286                query: ::miniextendr_api::registry::universal_query,
1287                data_offset: ::std::mem::offset_of!(#wrapper_name, data),
1288            };
1289
1290        #[doc(hidden)]
1291        fn #wrap_fn_name(data: #type_ident) -> *mut ::miniextendr_api::abi::mx_erased {
1292            let wrapper = Box::new(#wrapper_name {
1293                erased: ::miniextendr_api::abi::mx_erased {
1294                    base: &#base_vtable_name,
1295                },
1296                data,
1297            });
1298            Box::into_raw(wrapper).cast::<::miniextendr_api::abi::mx_erased>()
1299        }
1300    }
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305    use super::{ClassSystem, generate_class_integration_r_code, generate_r_wrapper_for_slot};
1306
1307    const ALL_CLASS_SYSTEMS: [ClassSystem; 6] = [
1308        ClassSystem::Env,
1309        ClassSystem::R6,
1310        ClassSystem::S3,
1311        ClassSystem::S4,
1312        ClassSystem::S7,
1313        ClassSystem::Vctrs,
1314    ];
1315
1316    /// Sidecar accessor C functions take only `x` (getter) or `x, value` (setter) —
1317    /// no `__miniextendr_call` parameter. The R wrappers must NOT pass `.call = match.call()`
1318    /// because that would be counted as an extra positional argument by `.Call()`, causing
1319    /// "Incorrect number of arguments" errors at runtime. Cover every class system variant.
1320    #[test]
1321    fn sidecar_accessors_do_not_pass_match_call() {
1322        let getter_c = "C__mx_rdata_get_T_f";
1323        let setter_c = "C__mx_rdata_set_T_f";
1324
1325        for cs in ALL_CLASS_SYSTEMS {
1326            let out = generate_r_wrapper_for_slot(cs, "T", "f", getter_c, setter_c);
1327            // Correct form: no .call argument — the C function only accepts x (getter) or x, value (setter).
1328            assert!(
1329                out.contains(&format!(".Call({getter_c}, x)")),
1330                "{cs:?} getter should call without .call:\n{out}"
1331            );
1332            assert!(
1333                out.contains(&format!(".Call({setter_c}, x, value)")),
1334                "{cs:?} setter should call without .call:\n{out}"
1335            );
1336            // Must NOT include the erroneous .call = match.call() form.
1337            assert!(
1338                !out.contains(".call = match.call()"),
1339                "{cs:?} sidecar wrapper must not pass .call = match.call():\n{out}"
1340            );
1341        }
1342    }
1343
1344    /// Every sidecar R wrapper (getter and setter, all class systems) must
1345    /// carry the tagged-condition guard so Rust panics / conversion failures
1346    /// transported by `with_r_unwind_protect(…, None)` are re-raised as
1347    /// structured R conditions instead of leaking the tagged SEXP to the user.
1348    #[test]
1349    fn sidecar_accessors_reraise_tagged_conditions() {
1350        let getter_c = "C__mx_rdata_get_T_f";
1351        let setter_c = "C__mx_rdata_set_T_f";
1352
1353        for cs in ALL_CLASS_SYSTEMS {
1354            let out = generate_r_wrapper_for_slot(cs, "T", "f", getter_c, setter_c);
1355            assert_eq!(
1356                out.matches(".miniextendr_raise_condition(.val, sys.call())")
1357                    .count(),
1358                2,
1359                "{cs:?} getter+setter should each carry the condition guard:\n{out}"
1360            );
1361        }
1362    }
1363
1364    /// The R6 active-binding and S7 property integration code also call the
1365    /// sidecar C entrypoints directly; they need the same guard (with
1366    /// `sys.call()` fallback attribution) and must not pass `.call =`.
1367    #[test]
1368    fn sidecar_class_integration_reraises_tagged_conditions() {
1369        let slot = super::SidecarSlot {
1370            name: syn::Ident::new("f", proc_macro2::Span::call_site()),
1371            ty: syn::parse_quote!(i32),
1372            index: 0,
1373            is_public: true,
1374            kind: super::SlotKind::ScalarInt,
1375            prop_doc: None,
1376        };
1377        let slots = [&slot];
1378
1379        for cs in [ClassSystem::R6, ClassSystem::S7] {
1380            let out = generate_class_integration_r_code(cs, "T", &slots);
1381            assert_eq!(
1382                out.matches(".miniextendr_raise_condition(.val, sys.call())")
1383                    .count(),
1384                2,
1385                "{cs:?} integration getter+setter should each carry the condition guard:\n{out}"
1386            );
1387            assert!(
1388                !out.contains(".call = match.call()"),
1389                "{cs:?} integration code must not pass .call = match.call():\n{out}"
1390            );
1391        }
1392    }
1393}