Skip to main content

miniextendr_macros/
dataframe_derive.rs

1//! Derive macros for bidirectional row ↔ dataframe conversions.
2//!
3//! Supports both structs (direct field mapping) and enums (field-name union
4//! across variants with `Option<T>` fill for missing fields).
5
6use proc_macro2::{Span, TokenStream};
7use quote::{format_ident, quote};
8use syn::{Data, DeriveInput, Fields};
9
10// region: Attribute parsing
11
12/// Parsed container-level `#[dataframe(...)]` attributes.
13pub(super) struct DataFrameAttrs {
14    /// Custom companion type name (default: `{TypeName}DataFrame`).
15    pub(super) name: Option<syn::Ident>,
16    /// Enum alignment mode — implicit for enums, accepted but not required.
17    pub(super) align: bool,
18    /// Tag column name for variant discriminator (also supported on structs).
19    pub(super) tag: Option<String>,
20    /// Conflict resolution mode for type collisions across enum variants.
21    /// Currently only "string" is supported: convert conflicting fields via `ToString`.
22    pub(super) conflicts: Option<String>,
23}
24
25/// Parse container-level `#[dataframe(...)]` attributes from the derive input.
26///
27/// Supported keys:
28/// - `name = "CustomName"` -- custom companion type name (default: `{TypeName}DataFrame`)
29/// - `align` -- enum alignment mode (field-name union across variants)
30/// - `tag = "col_name"` -- add a variant discriminator column (works on both structs and enums)
31/// - `conflicts = "string"` -- coerce type-conflicting columns to `String` via `ToString`
32///
33/// Returns `Err` for unknown keys or non-string-literal values.
34fn parse_dataframe_attrs(input: &DeriveInput) -> syn::Result<DataFrameAttrs> {
35    let mut attrs = DataFrameAttrs {
36        name: None,
37        align: false,
38        tag: None,
39        conflicts: None,
40    };
41
42    for attr in &input.attrs {
43        if !attr.path().is_ident("dataframe") {
44            continue;
45        }
46
47        let nested = attr.parse_args_with(
48            syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated,
49        )?;
50
51        for meta in &nested {
52            match meta {
53                syn::Meta::NameValue(nv) if nv.path.is_ident("name") => {
54                    if let syn::Expr::Lit(syn::ExprLit {
55                        lit: syn::Lit::Str(lit_str),
56                        ..
57                    }) = &nv.value
58                    {
59                        attrs.name =
60                            Some(format_ident!("{}", lit_str.value(), span = lit_str.span()));
61                    } else {
62                        return Err(syn::Error::new_spanned(
63                            &nv.value,
64                            "expected string literal for `name`",
65                        ));
66                    }
67                }
68                syn::Meta::NameValue(nv) if nv.path.is_ident("tag") => {
69                    if let syn::Expr::Lit(syn::ExprLit {
70                        lit: syn::Lit::Str(lit_str),
71                        ..
72                    }) = &nv.value
73                    {
74                        attrs.tag = Some(lit_str.value());
75                    } else {
76                        return Err(syn::Error::new_spanned(
77                            &nv.value,
78                            "expected string literal for `tag`",
79                        ));
80                    }
81                }
82                syn::Meta::NameValue(nv) if nv.path.is_ident("conflicts") => {
83                    if let syn::Expr::Lit(syn::ExprLit {
84                        lit: syn::Lit::Str(lit_str),
85                        ..
86                    }) = &nv.value
87                    {
88                        let value = lit_str.value();
89                        if value != "string" {
90                            return Err(syn::Error::new_spanned(
91                                lit_str,
92                                "unknown conflict resolution mode; only `\"string\"` is supported",
93                            ));
94                        }
95                        attrs.conflicts = Some(value);
96                    } else {
97                        return Err(syn::Error::new_spanned(
98                            &nv.value,
99                            "expected string literal for `conflicts`",
100                        ));
101                    }
102                }
103                syn::Meta::Path(path) if path.is_ident("align") => {
104                    attrs.align = true;
105                }
106                other => {
107                    return Err(syn::Error::new_spanned(
108                        other,
109                        "unknown dataframe attribute; expected `name`, `align`, `tag`, or `conflicts`",
110                    ));
111                }
112            }
113        }
114    }
115
116    Ok(attrs)
117}
118// endregion
119
120// region: Field-level attribute parsing
121
122/// Parsed field-level `#[dataframe(...)]` attributes.
123///
124/// These attributes control how individual struct/enum fields map to DataFrame columns.
125/// Mutually exclusive combinations (`as_list` + `expand`, `as_list` + `width`,
126/// `as_factor` + `as_list`, `as_factor` + `expand`, `as_factor` + `width`) are
127/// rejected during parsing.
128#[derive(Default)]
129pub(super) struct FieldAttrs {
130    /// `#[dataframe(skip)]` -- omit this field from the DataFrame entirely.
131    pub(super) skip: bool,
132    /// `#[dataframe(rename = "col")]` -- use a custom column name instead of the field name.
133    pub(super) rename: Option<String>,
134    /// `#[dataframe(as_list)]` -- keep a collection field as a single R list column
135    /// (suppresses automatic expansion into suffixed columns).
136    pub(super) as_list: bool,
137    /// `#[dataframe(as_factor)]` -- treat a unit-only inner enum field as an R factor column.
138    /// Only valid on bare-ident enum types (no generic parameters). The inner enum must be
139    /// unit-only (`#[derive(DataFrameRow)]` emits `IntoR` and `IntoR for Vec<Option<Self>>`).
140    pub(super) as_factor: bool,
141    /// `#[dataframe(expand)]` or `#[dataframe(unnest)]` -- explicitly expand a
142    /// collection field into multiple suffixed columns.
143    expand: bool,
144    /// `#[dataframe(width = N)]` -- pin the expansion width for `Vec<T>`, `Box<[T]>`,
145    /// or `&[T]` fields. Rows shorter than `N` get `None` for missing positions.
146    pub(super) width: Option<usize>,
147}
148
149/// Parse field-level `#[dataframe(...)]` attributes from a `syn::Field`.
150///
151/// Recognizes: `skip`, `rename`, `as_list`, `as_factor`, `expand` (alias `unnest`), and `width`.
152/// Validates mutual exclusivity of conflicting options (`as_list` vs `expand`/`width`,
153/// `as_factor` vs `as_list`/`expand`/`width`).
154/// Returns `Err` for unknown keys, invalid width values, or conflicting options.
155pub(super) fn parse_field_attrs(field: &syn::Field) -> syn::Result<FieldAttrs> {
156    let mut attrs = FieldAttrs::default();
157
158    for attr in &field.attrs {
159        if !attr.path().is_ident("dataframe") {
160            continue;
161        }
162
163        attr.parse_nested_meta(|meta| {
164            if meta.path.is_ident("skip") {
165                attrs.skip = true;
166                Ok(())
167            } else if meta.path.is_ident("rename") {
168                let value = meta.value()?;
169                let lit: syn::LitStr = value.parse()?;
170                attrs.rename = Some(lit.value());
171                Ok(())
172            } else if meta.path.is_ident("as_list") {
173                attrs.as_list = true;
174                Ok(())
175            } else if meta.path.is_ident("as_factor") {
176                attrs.as_factor = true;
177                Ok(())
178            } else if meta.path.is_ident("expand") || meta.path.is_ident("unnest") {
179                attrs.expand = true;
180                Ok(())
181            } else if meta.path.is_ident("width") {
182                let value = meta.value()?;
183                let lit: syn::LitInt = value.parse()?;
184                let n: usize = lit.base10_parse()?;
185                if n == 0 {
186                    return Err(syn::Error::new(lit.span(), "`width` must be >= 1"));
187                }
188                attrs.width = Some(n);
189                Ok(())
190            } else {
191                Err(meta.error(
192                    "unknown field attribute; expected `skip`, `rename`, `as_list`, `as_factor`, `expand`, `unnest`, or `width`",
193                ))
194            }
195        })?;
196    }
197
198    let span = field.ident.as_ref().map_or(Span::call_site(), |i| i.span());
199
200    // Validation: conflicting options
201    if attrs.as_list && attrs.expand {
202        return Err(syn::Error::new(
203            span,
204            "`as_list` and `expand`/`unnest` are mutually exclusive",
205        ));
206    }
207    if attrs.as_list && attrs.width.is_some() {
208        return Err(syn::Error::new(
209            span,
210            "`as_list` and `width` are mutually exclusive",
211        ));
212    }
213    if attrs.as_factor && attrs.as_list {
214        return Err(syn::Error::new(
215            span,
216            "`as_factor` and `as_list` are mutually exclusive",
217        ));
218    }
219    if attrs.as_factor && attrs.expand {
220        return Err(syn::Error::new(
221            span,
222            "`as_factor` and `expand`/`unnest` are mutually exclusive",
223        ));
224    }
225    if attrs.as_factor && attrs.width.is_some() {
226        return Err(syn::Error::new(
227            span,
228            "`as_factor` and `width` are mutually exclusive",
229        ));
230    }
231
232    Ok(attrs)
233}
234// endregion
235
236// region: Type classification
237
238/// Classification of a field type for DataFrame column expansion.
239///
240/// Used to decide whether a field maps to a single column or should be
241/// expanded into multiple suffixed columns (e.g., `coords_1`, `coords_2`).
242pub(super) enum FieldTypeKind<'a> {
243    /// Single column (most types). No expansion.
244    Scalar,
245    /// `[T; N]` -- fixed-size array, expands to `N` columns at compile time.
246    /// Contains the element type and array length.
247    FixedArray(&'a syn::Type, usize),
248    /// `Vec<T>` -- variable length, needs `width` attribute or `expand` for expansion.
249    /// Contains the element type.
250    VariableVec(&'a syn::Type),
251    /// `Box<[T]>` -- owned slice, treated like `Vec<T>` for expansion purposes.
252    /// Contains the element type.
253    BoxedSlice(&'a syn::Type),
254    /// `&[T]` -- borrowed slice, treated like `Vec<T>` for expansion purposes.
255    /// Contains the element type.
256    BorrowedSlice(&'a syn::Type),
257    /// `HashMap<K, V>` or `BTreeMap<K, V>`. The two derive paths treat maps
258    /// differently:
259    /// - *enum path*: expands to two parallel list-columns `<field>_keys` /
260    ///   `<field>_values` (see `enum_expansion.rs`).
261    /// - *struct path*: resolves to a `Single` opaque list-of-named-lists
262    ///   column (`Vec<map>: IntoR`); reader-capable for `String` keys +
263    ///   reader-scalar values (#764, see `SingleFieldData::map_reader`).
264    ///
265    /// Key order follows the map's own iteration order: `BTreeMap` yields
266    /// sorted keys, `HashMap` yields non-deterministic order.
267    Map {
268        key_ty: &'a syn::Type,
269        val_ty: &'a syn::Type,
270    },
271    /// A struct-typed field whose inner type implements `DataFrameRow`.
272    ///
273    /// Flattened into `<field>_<inner_col>` prefixed columns by default.
274    /// A compile-time assertion against `::miniextendr_api::markers::DataFrameRow`
275    /// is emitted so rustc gives a clear error when the inner type is missing the
276    /// derive.
277    ///
278    /// Suppressed by `#[dataframe(as_list)]` — with as_list the field becomes
279    /// a `Scalar` and uses the ordinary single-column codegen path.
280    Struct {
281        /// The full field type (used for the compile-time DataFrameRow assertion).
282        inner_ty: &'a syn::Type,
283    },
284}
285
286/// Classify a field type for DataFrame column expansion.
287///
288/// Inspects the type AST to detect:
289/// - `[T; N]` or `&[T; N]` -> `FixedArray`
290/// - `&[T]` -> `BorrowedSlice`
291/// - `Vec<T>` -> `VariableVec`
292/// - `Box<[T]>` -> `BoxedSlice`
293/// - `HashMap<K, V>` / `BTreeMap<K, V>` -> `Map`
294/// - Any non-scalar bare path type (single- or multi-segment, e.g. `Point` or
295///   `crate::geom::Point`) -> `Struct`
296/// - Everything else (known scalars, generic types with args, `::abs::Paths`) -> `Scalar`
297///
298/// Returns `Err` for shapes the macro cannot classify and that would silently
299/// become opaque list-columns: `Option<T>`, `Cow<T>`, `Rc<T>`, `Arc<T>`,
300/// `RefCell<T>`, `Cell<T>`, `Mutex<T>`, `RwLock<T>`.  Use
301/// `#[dataframe(as_list)]` to opt into list-column treatment explicitly.
302pub(super) fn classify_field_type(ty: &syn::Type) -> syn::Result<FieldTypeKind<'_>> {
303    // Check for [T; N]
304    if let syn::Type::Array(arr) = ty
305        && let syn::Expr::Lit(syn::ExprLit {
306            lit: syn::Lit::Int(lit_int),
307            ..
308        }) = &arr.len
309        && let Ok(n) = lit_int.base10_parse::<usize>()
310    {
311        return Ok(FieldTypeKind::FixedArray(&arr.elem, n));
312    }
313
314    // Check for &[T] and &[T; N]
315    if let syn::Type::Reference(ref_ty) = ty {
316        // &[T] → BorrowedSlice
317        if let syn::Type::Slice(slice) = &*ref_ty.elem {
318            return Ok(FieldTypeKind::BorrowedSlice(&slice.elem));
319        }
320        // &[T; N] → FixedArray (same as owned)
321        if let syn::Type::Array(arr) = &*ref_ty.elem
322            && let syn::Expr::Lit(syn::ExprLit {
323                lit: syn::Lit::Int(lit_int),
324                ..
325            }) = &arr.len
326            && let Ok(n) = lit_int.base10_parse::<usize>()
327        {
328            return Ok(FieldTypeKind::FixedArray(&arr.elem, n));
329        }
330    }
331
332    if let syn::Type::Path(type_path) = ty
333        && let Some(seg) = type_path.path.segments.last()
334        && let syn::PathArguments::AngleBracketed(args) = &seg.arguments
335    {
336        // Reject wrapper types that would silently fall through to Scalar /
337        // Struct and produce a confusing opaque list-column or a downstream
338        // DataFrameRow assertion error.  These are the common smart-pointer
339        // and interior-mutability types that wrap a meaningful inner type but
340        // that DataFrameRow does not know how to expand.
341        //
342        // The macro has no way to resolve through the wrapper without type-
343        // checking (which is unavailable in proc macros). The user must either
344        // unwrap to the inner type, or annotate with `#[dataframe(as_list)]`
345        // to opt into an explicit opaque list-column.
346        //
347        // IMPORTANT: The rejection fires on *path identity alone*, before we
348        // inspect generic args.  `Cow<'a, T>` has a lifetime as its first
349        // generic argument, not a type; inspecting `args.args.first()` as a
350        // `GenericArgument::Type` would silently skip `Cow`.  Checking ident
351        // before args makes the rejection robust to any generic shape.
352        const REJECTED_WRAPPERS: &[&str] = &[
353            "Option", "Cow", "Rc", "Arc", "RefCell", "Cell", "Mutex", "RwLock",
354        ];
355        let name = seg.ident.to_string();
356        if REJECTED_WRAPPERS.contains(&name.as_str()) {
357            return Err(syn::Error::new_spanned(
358                ty,
359                format!(
360                    "DataFrameRow does not support `{name}<…>` directly as a field type. \
361                     Use `#[dataframe(as_list)]` to opt into an explicit opaque list-column, \
362                     or unwrap to the inner type (e.g. store the inner value directly, using \
363                     a sentinel / empty collection for the absent case)."
364                ),
365            ));
366        }
367
368        // For the collection types below we need the first *type* argument.
369        // Skip any leading lifetime or const arguments (e.g. `Cow<'a, B>`
370        // has a lifetime first, but `Cow` is already rejected above so we
371        // only reach here for other angle-bracketed types).
372        let first_type_arg = args.args.iter().find_map(|arg| {
373            if let syn::GenericArgument::Type(t) = arg {
374                Some(t)
375            } else {
376                None
377            }
378        });
379
380        if let Some(inner) = first_type_arg {
381            // Check for Vec<T>
382            if seg.ident == "Vec" {
383                return Ok(FieldTypeKind::VariableVec(inner));
384            }
385
386            // Check for Box<[T]>
387            if seg.ident == "Box"
388                && let syn::Type::Slice(slice) = inner
389            {
390                return Ok(FieldTypeKind::BoxedSlice(&slice.elem));
391            }
392
393            // Check for HashMap<K, V> and BTreeMap<K, V>
394            if (seg.ident == "HashMap" || seg.ident == "BTreeMap")
395                && let Some(syn::GenericArgument::Type(val_ty)) = args.args.iter().nth(1)
396            {
397                return Ok(FieldTypeKind::Map {
398                    key_ty: inner,
399                    val_ty,
400                });
401            }
402        }
403    }
404
405    // Any remaining path type whose LAST segment is a bare ident (no generic args)
406    // that is NOT a known scalar is treated as a user-defined struct whose
407    // `DataFrameRow` derive should be called.  The compile-time assertion
408    // `_assert_inner_is_dataframe_row::<Inner>()` in the generated code surfaces a
409    // clear error if the inner type doesn't have the derive.
410    //
411    // Known scalars (i32, f64, String, bool, …) are kept as `Scalar` so that existing
412    // enum variants with primitive fields (e.g. `Click { id: i64, x: f64 }`) are not
413    // misclassified as struct fields.
414    //
415    // Multi-segment paths (e.g. `crate::geom::Point`, `geom::Point`) are now correctly
416    // classified here — the previous `segs.len() == 1` guard was overly restrictive.
417    // Paths with a leading `::` (absolute paths like `::std::ffi::CString`) still fall
418    // through to `Scalar`; use `#[dataframe(as_list)]` or an unqualified import if
419    // you need a custom treatment.
420    //
421    // RISK: a user type whose last path segment is named after a known-scalar
422    // (e.g. `mymod::String`) still correctly falls through to `Scalar` because of the
423    // KNOWN_SCALARS check. A type named `mymod::Option` / `mymod::Vec` would shadow
424    // the detection above — accepted per Rust naming convention (canonical names are
425    // rarely shadowed). `#[dataframe(as_list)]` is the documented escape hatch.
426    if let syn::Type::Path(type_path) = ty {
427        let segs = &type_path.path.segments;
428        // No leading colon (rules out `::std::…` absolute paths) and no self-type.
429        if type_path.qself.is_none() && type_path.path.leading_colon.is_none() {
430            let seg = segs.last().unwrap();
431            if matches!(seg.arguments, syn::PathArguments::None) {
432                let name = seg.ident.to_string();
433                // Known scalar type names — keep as Scalar so they do not trigger the
434                // struct-flatten path and the DataFrameRow compile-time assertion.
435                const KNOWN_SCALARS: &[&str] = &[
436                    "bool", "char", "str", "f32", "f64", "i8", "i16", "i32", "i64", "i128",
437                    "isize", "u8", "u16", "u32", "u64", "u128", "usize", "String",
438                ];
439                if !KNOWN_SCALARS.contains(&name.as_str()) {
440                    return Ok(FieldTypeKind::Struct { inner_ty: ty });
441                }
442            }
443        }
444    }
445
446    Ok(FieldTypeKind::Scalar)
447}
448
449/// Scalar element types whose `Vec<T>` (and `Vec<Option<T>>`) round-trips through
450/// `TryFromSexp` — the supported field types for the parallel from-R reader
451/// (`try_from_dataframe_par`). This is intentionally narrower than
452/// `FieldTypeKind::Scalar`: set/opaque collection types (`HashSet<…>`,
453/// `BTreeSet<…>`) also classify as `Scalar` but do NOT implement `Vec<_>:
454/// TryFromSexp`, so they must be excluded from the reader path.
455///
456/// `pub(super)` so `enum_expansion.rs` (in the `dataframe_derive` module dir)
457/// can reuse the same allow-list without duplication.
458pub(super) const READER_SCALAR_NAMES: &[&str] = &[
459    "bool", "f32", "f64", "i8", "i16", "i32", "i64", "u8", "u16", "u32", "String",
460];
461
462/// True if `ty` is a bare known-scalar ident (no `Option`, no generic args).
463///
464/// These are the element types whose `Vec<Option<ty>>` round-trips through
465/// `TryFromSexp` — required for the *column-expansion* readers, which read each
466/// expanded slot as `Vec<Option<elem>>` (the write side wraps every slot in
467/// `Option`). Allowing `Option<scalar>` here would ask for `Vec<Option<Option<…>>>`,
468/// which has no `TryFromSexp` impl.
469///
470/// `pub(super)` so `enum_expansion.rs` can reuse it.
471pub(super) fn is_bare_reader_scalar_ty(ty: &syn::Type) -> bool {
472    if let syn::Type::Path(tp) = ty
473        && tp.qself.is_none()
474        && tp.path.leading_colon.is_none()
475        && let Some(seg) = tp.path.segments.last()
476        && matches!(seg.arguments, syn::PathArguments::None)
477    {
478        return READER_SCALAR_NAMES.contains(&seg.ident.to_string().as_str());
479    }
480    false
481}
482
483/// True if `ty` is a bare known-scalar ident, or `Option<bare-known-scalar>`.
484///
485/// These are exactly the field types for which the from-R reader can pull a
486/// column out as `Vec<ty>` via `TryFromSexp` (scalar `Single` fields and
487/// `[T; N]` fixed-array elements, neither of which adds an `Option` wrapper).
488///
489/// `pub(super)` so `enum_expansion.rs` can reuse it.
490pub(super) fn is_reader_scalar_ty(ty: &syn::Type) -> bool {
491    if is_bare_reader_scalar_ty(ty) {
492        return true;
493    }
494    // Option<scalar>
495    if let syn::Type::Path(tp) = ty
496        && let Some(seg) = tp.path.segments.last()
497        && seg.ident == "Option"
498        && let syn::PathArguments::AngleBracketed(args) = &seg.arguments
499        && let Some(syn::GenericArgument::Type(inner)) = args.args.first()
500    {
501        return is_bare_reader_scalar_ty(inner);
502    }
503    false
504}
505
506/// True if `ty`'s last path segment is the bare ident `String` (path-identity
507/// check, same convention as `classify_field_type`).
508fn is_string_ty(ty: &syn::Type) -> bool {
509    if let syn::Type::Path(tp) = ty
510        && let Some(seg) = tp.path.segments.last()
511        && matches!(seg.arguments, syn::PathArguments::None)
512    {
513        return seg.ident == "String";
514    }
515    false
516}
517
518/// Number of *type* arguments on `ty`'s last path segment (0 when not
519/// angle-bracketed). Rejects `HashMap<K, V, S>` custom-hasher maps from the
520/// reader path — the `Vec<HashMap<String, V>>: TryFromSexp` impl only covers
521/// the default hasher.
522fn generic_type_arg_count(ty: &syn::Type) -> usize {
523    if let syn::Type::Path(tp) = ty
524        && let Some(seg) = tp.path.segments.last()
525        && let syn::PathArguments::AngleBracketed(args) = &seg.arguments
526    {
527        return args
528            .args
529            .iter()
530            .filter(|a| matches!(a, syn::GenericArgument::Type(_)))
531            .count();
532    }
533    0
534}
535
536/// True if a resolved struct field can be read back out of an R `data.frame`.
537///
538/// Determines whether the struct gets a generated `try_from_dataframe` reader.
539/// Each shape's reader is the inverse of its column-expansion write rule:
540/// - `Single` scalar: one column read as `Vec<ty>` (excludes `as_list` and
541///   opaque set/collection columns — those classify as `Single` but lack
542///   `Vec<_>: TryFromSexp`).
543/// - `Single` map column (`HashMap<String, V>` / `BTreeMap<String, V>`): one
544///   VECSXP list-of-named-lists column read whole via `Vec<map>: TryFromSexp`
545///   — the exact inverse of the `Vec<map>: IntoR` write shape (#764). Gated
546///   to `String` keys + reader-scalar values at resolve time (`map_reader`).
547/// - `Single` owned list-column: `Vec<scalar>` / `Box<[scalar]>` stored as a
548///   VECSXP list-column; the reader deserialises each row's element via
549///   `Vec<elem>: TryFromSexp` and `.into()`-converts to the field type (#809).
550/// - `ExpandedFixed` (`[T; N]`): `N` columns regrouped into the array.
551/// - `ExpandedVec` / `AutoExpandVec` (`Vec<T>`): suffixed `Option` columns
552///   flattened back per row (bare-scalar elements only).
553/// - `Struct` (nested `DataFrameRow`): always eligible — the reader routes the
554///   un-prefixed sub-frame through the inner type's `DataFrameRowConvert`, which
555///   degrades to a clear runtime error if the inner shape itself has no reader.
556///
557/// Borrowed expansion origins (`&[T]` / `&[T; N]`) are not readable (owned R data
558/// can't produce a borrow) — flagged via `readable` at resolve time.
559fn field_reader_capable(rf: &ResolvedField) -> bool {
560    match rf {
561        ResolvedField::Single(d) => {
562            !d.needs_into_list
563                && (is_reader_scalar_ty(&d.ty)
564                    || d.map_reader
565                    || d.list_elem_ty
566                        .as_ref()
567                        .is_some_and(is_bare_reader_scalar_ty))
568        }
569        ResolvedField::ExpandedFixed(d) => d.readable && is_reader_scalar_ty(&d.elem_ty),
570        ResolvedField::ExpandedVec(d) => d.readable && is_bare_reader_scalar_ty(&d.elem_ty),
571        ResolvedField::AutoExpandVec(d) => d.readable && is_bare_reader_scalar_ty(&d.elem_ty),
572        ResolvedField::Struct(_) => true,
573        ResolvedField::Map(d) => {
574            is_bare_reader_scalar_ty(&d.key_ty) && is_bare_reader_scalar_ty(&d.val_ty)
575        }
576    }
577}
578
579/// True if `ty` is a borrowed reference (`&[T]`, `&[T; N]`, `&str`, …). Such
580/// expansion fields can't be reconstructed by value in the R→Rust reader.
581fn field_is_borrowed_ref(ty: &syn::Type) -> bool {
582    matches!(ty, syn::Type::Reference(_))
583}
584// endregion
585
586// region: Resolved field model (struct path)
587
588/// A resolved struct field ready for codegen -- determines how this field maps
589/// to DataFrame companion struct columns.
590///
591/// Each variant represents a different expansion strategy:
592/// - `Single`: one field -> one `Vec<T>` column
593/// - `ExpandedFixed`: `[T; N]` -> N columns (`name_1..name_N`) at compile time
594/// - `ExpandedVec`: `Vec<T>` + `width = N` -> N `Vec<Option<T>>` columns
595/// - `AutoExpandVec`: `Vec<T>` + `expand` -> dynamic column count at runtime
596enum ResolvedField {
597    /// Single column: `name → Vec<ty>`.
598    Single(Box<SingleFieldData>),
599    /// Expanded fixed array: `name: [T; N]` → `name_1..name_N`.
600    ExpandedFixed(Box<ExpandedFixedData>),
601    /// Expanded variable vec with pinned width: `name: Vec<T>` + `width = N`.
602    ExpandedVec(Box<ExpandedVecData>),
603    /// Auto-expanded `Vec<T>`/`Box<[T]>`: column count determined at runtime from max row length.
604    AutoExpandVec(Box<AutoExpandVecData>),
605    /// Struct field whose inner type implements `DataFrameRow` (issue #485).
606    /// Companion holds `Vec<Inner>`; `into_data_frame` calls `Inner::to_dataframe`
607    /// and flattens columns under the `<base>_` prefix.
608    Struct(Box<StructFieldData>),
609    /// Non-String-keyed `HashMap<K,V>` / `BTreeMap<K,V>` field (#919).
610    /// Expands to two parallel list-columns `<base>_keys` / `<base>_values`,
611    /// each a `Vec<Vec<K>>` / `Vec<Vec<V>>` (VECSXP of typed vectors). The
612    /// reader zips `keys[i]` with `values[i]` back into the map type.
613    Map(Box<MapFieldData>),
614}
615
616/// Data for [`ResolvedField::Map`] (struct path, non-String-keyed maps, #919).
617struct MapFieldData {
618    /// Rust field name (for access on the row type).
619    rust_name: syn::Ident,
620    /// Column name base — keys col = `<base>_keys`, values col = `<base>_values`.
621    base_name: String,
622    /// Key type `K`.
623    key_ty: syn::Type,
624    /// Value type `V`.
625    val_ty: syn::Type,
626    /// Full map type `HashMap<K,V>` / `BTreeMap<K,V>` (used in reader zip).
627    map_ty: syn::Type,
628    /// Index in tuple struct (None for named).
629    tuple_index: Option<syn::Index>,
630}
631
632/// Data for [`ResolvedField::Single`].
633struct SingleFieldData {
634    /// Rust field name (for access).
635    rust_name: syn::Ident,
636    /// Column name in the DataFrame.
637    col_name: syn::Ident,
638    /// Column name string.
639    col_name_str: String,
640    /// Field type stored in the companion `Vec<#ty>`. For `#[dataframe(as_list)]`
641    /// on a struct-typed field this is overridden to `::miniextendr_api::list::List`
642    /// — see `needs_into_list`.
643    ty: syn::Type,
644    /// Index in tuple struct (None for named).
645    tuple_index: Option<syn::Index>,
646    /// `#[dataframe(as_list)]` on a struct-typed field (#485 workaround).
647    /// When `true`, the companion field type is overridden to `List` and
648    /// `From<Vec<Row>>` calls `IntoList::into_list()` on each row value.
649    needs_into_list: bool,
650    /// `Some(elem)` when this Single field is an un-annotated *owned* collection
651    /// (`Vec<scalar>` / `Box<[scalar]>`) stored as an opaque list-column (#809).
652    /// The reader deserialises the list-column back into the owned collection per
653    /// row via `Vec<elem>: TryFromSexp` then `.into()` to the field container type.
654    /// `None` for scalar Single, `as_list`, opaque `Map`/set columns, and borrowed
655    /// `&[T]` (not readable).
656    list_elem_ty: Option<syn::Type>,
657    /// `true` when this Single field is a reader-capable map column (#764):
658    /// `HashMap<String, V>` / `BTreeMap<String, V>` with a reader-scalar `V`
659    /// and no custom hasher. The column is read whole as `Vec<#ty>` via the
660    /// `Vec<map>: TryFromSexp` list-of-named-lists impl — the same `pull_col`
661    /// path scalar Singles use, so it only widens the capability gate.
662    map_reader: bool,
663}
664
665/// Data for [`ResolvedField::ExpandedFixed`].
666struct ExpandedFixedData {
667    /// Rust field name.
668    rust_name: syn::Ident,
669    /// Base column name (before suffix).
670    base_name: String,
671    /// Element type T.
672    elem_ty: syn::Type,
673    /// Array length N.
674    len: usize,
675    /// Index in tuple struct.
676    tuple_index: Option<syn::Index>,
677    /// Whether the field can be reconstructed by value in the R→Rust reader.
678    /// `false` for a borrowed origin (`&[T; N]`) — owned R data can't produce a
679    /// borrow, so the struct gets no reader (see [`field_reader_capable`]).
680    readable: bool,
681}
682
683/// Data for [`ResolvedField::ExpandedVec`].
684struct ExpandedVecData {
685    /// Rust field name.
686    rust_name: syn::Ident,
687    /// Base column name.
688    base_name: String,
689    /// Element type T.
690    elem_ty: syn::Type,
691    /// Pinned width.
692    width: usize,
693    /// Index in tuple struct.
694    tuple_index: Option<syn::Index>,
695    /// Whether the field can be reconstructed by value in the R→Rust reader.
696    /// `false` for a borrowed origin (`&[T]`); `Vec<T>` / `Box<[T]>` are readable
697    /// (the reader collects a `Vec<T>` and `.into()`-converts to the field type).
698    readable: bool,
699}
700
701/// Data for [`ResolvedField::Struct`].
702///
703/// A struct field whose inner type implements `DataFrameRow`. The companion
704/// struct holds `Vec<Inner>` (the same type users already pass into
705/// `to_dataframe(vec![...])`). At `into_data_frame()` time the inner rows are
706/// converted via `Inner::to_dataframe` → `into_named_columns()`, prefixed with
707/// `<base_name>_`, and pushed into the parent data.frame.
708struct StructFieldData {
709    /// Rust field name (for access on the row type).
710    rust_name: syn::Ident,
711    /// Companion struct field name (ident).
712    col_name: syn::Ident,
713    /// Column name base used as the R-side prefix (`<base>_<inner_col>`).
714    col_name_str: String,
715    /// Inner struct type (used for `to_dataframe` dispatch + DataFrameRow assertion).
716    inner_ty: syn::Type,
717    /// Index in tuple struct (None for named).
718    tuple_index: Option<syn::Index>,
719}
720
721/// Data for [`ResolvedField::AutoExpandVec`].
722struct AutoExpandVecData {
723    /// Rust field name (for row access).
724    rust_name: syn::Ident,
725    /// Companion struct field name (ident).
726    col_name: syn::Ident,
727    /// Column name base string (for suffixed column names).
728    col_name_str: String,
729    /// Element type T.
730    elem_ty: syn::Type,
731    /// Container type for companion struct (`Vec<T>` or `Box<[T]>`).
732    container_ty: syn::Type,
733    /// Index in tuple struct.
734    tuple_index: Option<syn::Index>,
735    /// Whether the field can be reconstructed by value in the R→Rust reader.
736    /// `false` for a borrowed origin (`&[T]`); `Vec<T>` / `Box<[T]>` are readable.
737    readable: bool,
738}
739
740/// Resolve a struct field into a [`ResolvedField`], applying field attributes.
741///
742/// Combines the field's `#[dataframe(...)]` attributes with its type classification
743/// to determine the codegen strategy:
744/// - `skip` -> returns `None`
745/// - `as_list` -> `Single` (suppresses expansion)
746/// - `FixedArray` -> `ExpandedFixed` (compile-time expansion to N columns)
747/// - `VariableVec`/`BoxedSlice`/`BorrowedSlice` + `width` -> `ExpandedVec`
748/// - `VariableVec`/`BoxedSlice`/`BorrowedSlice` + `expand` -> `AutoExpandVec`
749/// - Everything else -> `Single`
750///
751/// Returns `Err` if `width` or `expand` is used on an incompatible type.
752fn resolve_struct_field(
753    field: &syn::Field,
754    index: usize,
755    is_tuple: bool,
756) -> syn::Result<Option<ResolvedField>> {
757    let field_attrs = parse_field_attrs(field)?;
758
759    if field_attrs.skip {
760        return Ok(None);
761    }
762
763    let rust_name = if is_tuple {
764        format_ident!("_{}", index)
765    } else {
766        field.ident.as_ref().unwrap().clone()
767    };
768
769    let col_name_str = field_attrs
770        .rename
771        .clone()
772        .unwrap_or_else(|| rust_name.to_string());
773    let col_name = format_ident!("{}", col_name_str);
774
775    let tuple_index = if is_tuple {
776        Some(syn::Index::from(index))
777    } else {
778        None
779    };
780
781    let ty = &field.ty;
782    // Propagate classification errors (e.g. Option<T>, Arc<T>) when as_list is
783    // not set.  The as_list branch below uses `.ok()` to suppress errors.
784    let kind = classify_field_type(ty);
785
786    // as_list suppresses expansion. For struct-typed fields (#485 opt-out), the
787    // companion stores `Vec<List>` and From<Vec<Row>> converts each row value
788    // via `IntoList::into_list()`. For non-struct as_list fields, the existing
789    // behavior is preserved: companion stores `Vec<#ty>` and the field type is
790    // serialized natively (this requires `Vec<#ty>: IntoR`).
791    if field_attrs.as_list {
792        // Use `.ok()` here: `as_list` is an explicit opt-in, so wrapper types
793        // like `Option<T>` / `Arc<T>` are allowed — they become opaque list-
794        // columns. Any classification error is suppressed and treated as non-Struct.
795        let (final_ty, needs_into_list) = match classify_field_type(ty).ok() {
796            Some(FieldTypeKind::Struct { .. }) => {
797                (syn::parse_quote!(::miniextendr_api::list::List), true)
798            }
799            _ => (ty.clone(), false),
800        };
801        return Ok(Some(ResolvedField::Single(Box::new(SingleFieldData {
802            rust_name,
803            col_name,
804            col_name_str,
805            ty: final_ty,
806            tuple_index,
807            needs_into_list,
808            list_elem_ty: None,
809            map_reader: false,
810        }))));
811    }
812
813    match kind? {
814        FieldTypeKind::FixedArray(elem_ty, len) => Ok(Some(ResolvedField::ExpandedFixed(
815            Box::new(ExpandedFixedData {
816                rust_name,
817                base_name: col_name_str,
818                elem_ty: elem_ty.clone(),
819                len,
820                tuple_index,
821                readable: !field_is_borrowed_ref(ty),
822            }),
823        ))),
824        FieldTypeKind::VariableVec(elem_ty)
825        | FieldTypeKind::BoxedSlice(elem_ty)
826        | FieldTypeKind::BorrowedSlice(elem_ty) => {
827            if let Some(width) = field_attrs.width {
828                Ok(Some(ResolvedField::ExpandedVec(Box::new(
829                    ExpandedVecData {
830                        rust_name,
831                        base_name: col_name_str,
832                        elem_ty: elem_ty.clone(),
833                        width,
834                        tuple_index,
835                        readable: !field_is_borrowed_ref(ty),
836                    },
837                ))))
838            } else if field_attrs.expand {
839                Ok(Some(ResolvedField::AutoExpandVec(Box::new(
840                    AutoExpandVecData {
841                        rust_name,
842                        col_name,
843                        col_name_str,
844                        elem_ty: elem_ty.clone(),
845                        container_ty: ty.clone(),
846                        tuple_index,
847                        readable: !field_is_borrowed_ref(ty),
848                    },
849                ))))
850            } else {
851                // No expansion — keep as opaque single column (list-column on R side).
852                // Readable owned collections (`Vec<scalar>` / `Box<[scalar]>`) record
853                // the element type for the list-column reader (#809). Borrowed `&[T]`
854                // is not readable (can't produce a borrow from owned R data).
855                Ok(Some(ResolvedField::Single(Box::new(SingleFieldData {
856                    rust_name,
857                    col_name,
858                    col_name_str,
859                    ty: ty.clone(),
860                    tuple_index,
861                    needs_into_list: false,
862                    list_elem_ty: if field_is_borrowed_ref(ty) {
863                        None
864                    } else {
865                        Some((*elem_ty).clone())
866                    },
867                    map_reader: false,
868                }))))
869            }
870        }
871        // Struct-in-struct flattening (issue #485): inner type must implement
872        // `DataFrameRow`. Flattening happens at `into_data_frame()` time; the
873        // companion stores `Vec<Inner>`. `as_list` opts out (handled above).
874        FieldTypeKind::Struct { inner_ty } => {
875            if field_attrs.width.is_some() {
876                return Err(syn::Error::new_spanned(
877                    ty,
878                    "`width` is only valid on `Vec<T>`, `Box<[T]>`, or `&[T]` fields",
879                ));
880            }
881            if field_attrs.expand {
882                return Err(syn::Error::new_spanned(
883                    ty,
884                    "`expand`/`unnest` is only valid on `[T; N]`, `Vec<T>`, `Box<[T]>`, or `&[T]` fields",
885                ));
886            }
887            Ok(Some(ResolvedField::Struct(Box::new(StructFieldData {
888                rust_name,
889                col_name,
890                col_name_str,
891                inner_ty: inner_ty.clone(),
892                tuple_index,
893            }))))
894        }
895        kind @ (FieldTypeKind::Scalar | FieldTypeKind::Map { .. }) => {
896            if field_attrs.width.is_some() {
897                return Err(syn::Error::new_spanned(
898                    ty,
899                    "`width` is only valid on `Vec<T>`, `Box<[T]>`, or `&[T]` fields",
900                ));
901            }
902            if field_attrs.expand {
903                return Err(syn::Error::new_spanned(
904                    ty,
905                    "`expand`/`unnest` is only valid on `[T; N]`, `Vec<T>`, `Box<[T]>`, or `&[T]` fields",
906                ));
907            }
908            // Struct-path map fields:
909            //
910            // - `String`-keyed + reader-scalar value + 2 type args (default hasher):
911            //   write as one opaque list-of-named-lists column (`Vec<map>: IntoR`).
912            //   Reader-capable via `Vec<map>: TryFromSexp` (#764). Falls through
913            //   to `Single` with `map_reader: true` — unchanged.
914            //
915            // - Non-String bare-reader-scalar key + bare-reader-scalar value + 2 type args:
916            //   expand to two parallel list-columns `<base>_keys` / `<base>_values` (#919).
917            //   `Vec<Vec<K>>: IntoR` and `Vec<Vec<V>>: IntoR` work via the `T: RNativeType`
918            //   blanket. Float keys (`f32`/`f64`) are also bare-reader-scalar but lack
919            //   `Eq + Hash` / `Ord`, so reject them with a clear error.
920            //
921            // - Custom hasher (3+ type args): fall through to `Single` with no reader
922            //   (keeps existing behaviour for `HashMap<K, V, S>`).
923            //
924            // - Non-scalar key or value: emit a clear error directing to `as_list`.
925            if let FieldTypeKind::Map { key_ty, val_ty } = kind {
926                let two_args = generic_type_arg_count(ty) == 2;
927                if is_string_ty(key_ty) && is_reader_scalar_ty(val_ty) && two_args {
928                    // String-keyed — existing path (#764).
929                    return Ok(Some(ResolvedField::Single(Box::new(SingleFieldData {
930                        rust_name,
931                        col_name,
932                        col_name_str,
933                        ty: ty.clone(),
934                        tuple_index,
935                        needs_into_list: false,
936                        list_elem_ty: None,
937                        map_reader: true,
938                    }))));
939                }
940                // Float key check: f32/f64 classify as bare_reader_scalar but are
941                // neither Eq+Hash nor Ord, so they can't be a map key at all.
942                let is_float_ty = |t: &syn::Type| -> bool {
943                    if let syn::Type::Path(tp) = t
944                        && let Some(seg) = tp.path.segments.last()
945                        && matches!(seg.arguments, syn::PathArguments::None)
946                    {
947                        let n = seg.ident.to_string();
948                        return n == "f32" || n == "f64";
949                    }
950                    false
951                };
952                if is_float_ty(key_ty) {
953                    return Err(syn::Error::new_spanned(
954                        ty,
955                        "HashMap/BTreeMap with float keys is not supported \
956                         (f32/f64 are not Eq+Hash/Ord); use a newtype wrapper \
957                         or `#[dataframe(as_list)]`",
958                    ));
959                }
960                if two_args && is_bare_reader_scalar_ty(key_ty) && is_bare_reader_scalar_ty(val_ty)
961                {
962                    // Non-String bare-scalar keyed — new parallel _keys/_values path (#919).
963                    return Ok(Some(ResolvedField::Map(Box::new(MapFieldData {
964                        rust_name,
965                        base_name: col_name_str,
966                        key_ty: key_ty.clone(),
967                        val_ty: val_ty.clone(),
968                        map_ty: ty.clone(),
969                        tuple_index,
970                    }))));
971                }
972                if two_args
973                    && (!is_bare_reader_scalar_ty(key_ty) || !is_bare_reader_scalar_ty(val_ty))
974                {
975                    // Non-scalar key or value (and not String-keyed) — opaque, no reader.
976                    // Fall through to Single below.
977                }
978                // 3+ type args (custom hasher) or non-scalar — Single with no reader.
979            }
980            let map_reader = false;
981            Ok(Some(ResolvedField::Single(Box::new(SingleFieldData {
982                rust_name,
983                col_name,
984                col_name_str,
985                ty: ty.clone(),
986                tuple_index,
987                needs_into_list: false,
988                list_elem_ty: None,
989                map_reader,
990            }))))
991        }
992    }
993}
994// endregion
995
996// region: Top-level dispatch
997
998/// Derive `DataFrameRow`: generates a companion DataFrame type with collection fields.
999///
1000/// # Requirements
1001///
1002/// For structs: the type must implement `IntoList`.
1003/// For enums: all variants must have named fields.
1004///
1005/// # Generated Items
1006///
1007/// For a struct `Measurement { time: f64, value: f64 }`:
1008/// - Struct `MeasurementDataFrame { time: Vec<f64>, value: Vec<f64> }`
1009/// - `impl IntoDataFrame for MeasurementDataFrame` (rows → R `data.frame`)
1010/// - `impl ColumnarFrame for MeasurementDataFrame` (rows ↔ the pure-Rust companion)
1011/// - `impl From<Vec<Measurement>> for MeasurementDataFrame`
1012/// - `impl IntoIterator for MeasurementDataFrame`
1013///
1014/// For an enum:
1015/// - Companion struct with `Vec<Option<T>>` columns (field-name union)
1016/// - Optional tag column for variant discrimination
1017/// - `impl From<Vec<Enum>> for EnumDataFrame`
1018/// - `impl IntoDataFrame for EnumDataFrame`
1019/// - `impl ColumnarFrame for EnumDataFrame`
1020/// - `impl DataFrameRowSplit for Enum` (the split representation behind
1021///   `IntoDataFrameSplit`)
1022///
1023/// # Public surface (which verbs to call)
1024///
1025/// - Rows → R `data.frame`: `rows.into_dataframe()?` (`/_par`) or
1026///   `rows.wrap_data_frame()`, from `IntoDataFrame` / `AsDataFrameExt`.
1027/// - R `data.frame` → rows: `Vec::<Row>::from_dataframe(&df)?` (`/_par`) from
1028///   `FromDataFrame`, or the one-call `Row::try_from_dataframe(sexp)` reader.
1029/// - Rows ↔ the pure-Rust columnar companion: the `ColumnarFrame` trait
1030///   (`<Row>DataFrame::from_rows` / `from_rows_par` / `into_rows`), or the `std`
1031///   `Vec<Row>: Into<companion>` / companion `IntoIterator`.
1032/// - Enums: `rows.into_dataframe_split()` (one `data.frame` per variant) from
1033///   `IntoDataFrameSplit`, implemented via the hidden `DataFrameRowSplit` bridge
1034///   on the enum type.
1035///
1036/// All the traits above are re-exported from `miniextendr_api::prelude`. The row
1037/// type also carries two `#[doc(hidden)]` helpers: `to_dataframe(rows) -> companion`,
1038/// retained only because the struct-flatten / nested-enum write paths build a
1039/// nested companion via `Inner::to_dataframe(..)` without naming its type, and
1040/// `to_dataframe_split(rows)`, the body holder behind the `DataFrameRowSplit` bridge.
1041///
1042/// # Attributes
1043///
1044/// - `#[dataframe(name = "CustomName")]` — Custom companion type name
1045/// - `#[dataframe(align)]` — Enum alignment mode (accepted but implicit)
1046/// - `#[dataframe(tag = "col")]` — Add variant discriminator column
1047pub fn derive_dataframe_row(input: DeriveInput) -> syn::Result<TokenStream> {
1048    let row_name = &input.ident;
1049
1050    // Allow lifetime parameters (needed for &[T] borrowed slice fields).
1051    // Allow type parameters on unit-only enums (all variants are unit) — the
1052    // companion struct has no field columns to type-parameterise, and the three
1053    // unit-enum impls (UnitEnumFactor, IntoR, IntoList) handle generics via the
1054    // split path in enum_expansion.rs.
1055    // Reject type and const parameters for everything else.
1056    let has_type_params = input.generics.type_params().next().is_some();
1057    let has_const_params = input.generics.const_params().next().is_some();
1058    if has_type_params || has_const_params {
1059        let is_unit_only_enum = matches!(&input.data, Data::Enum(e)
1060            if e.variants.iter().all(|v| matches!(v.fields, Fields::Unit)));
1061        if !is_unit_only_enum {
1062            return Err(syn::Error::new_spanned(
1063                &input.generics,
1064                "DataFrameRow does not support type or const generic parameters",
1065            ));
1066        }
1067    }
1068
1069    // Parse attributes
1070    let attrs = parse_dataframe_attrs(&input)?;
1071
1072    let df_name = attrs
1073        .name
1074        .clone()
1075        .unwrap_or_else(|| format_ident!("{}DataFrame", row_name));
1076
1077    let base = match &input.data {
1078        Data::Struct(data) => {
1079            // `align` is a no-op on structs (only semantically meaningful for enums)
1080            derive_struct_dataframe(row_name, &input, data, &df_name, &attrs)
1081        }
1082        Data::Enum(data) => {
1083            // align is implicit for enums — accept but don't require
1084            derive_enum_dataframe(row_name, &input, data, &df_name, &attrs)
1085        }
1086        Data::Union(_) => Err(syn::Error::new_spanned(
1087            row_name,
1088            "DataFrameRow does not support unions",
1089        )),
1090    }?;
1091
1092    // Generate IntoR for the companion DataFrame type so it can be returned
1093    // directly from #[miniextendr] functions. This ensures both the standalone
1094    // #[derive(DataFrameRow)] path and the #[miniextendr(dataframe)] path
1095    // produce identical output.
1096    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
1097    Ok(quote::quote! {
1098        #base
1099
1100        impl #impl_generics ::miniextendr_api::into_r::IntoR for #df_name #ty_generics #where_clause {
1101            type Error = std::convert::Infallible;
1102
1103            #[inline]
1104            fn try_into_sexp(self) -> Result<::miniextendr_api::SEXP, Self::Error> {
1105                Ok(self.into_sexp())
1106            }
1107
1108            #[inline]
1109            unsafe fn try_into_sexp_unchecked(self) -> Result<::miniextendr_api::SEXP, Self::Error> {
1110                self.try_into_sexp()
1111            }
1112
1113            #[inline]
1114            fn into_sexp(self) -> ::miniextendr_api::SEXP {
1115                ::miniextendr_api::convert::ColumnSource::into_column_list(self).into_sexp()
1116            }
1117
1118            #[inline]
1119            unsafe fn into_sexp_unchecked(self) -> ::miniextendr_api::SEXP {
1120                ::miniextendr_api::convert::ColumnSource::into_column_list(self).into_sexp()
1121            }
1122        }
1123    })
1124}
1125// endregion
1126
1127// region: Struct path (existing logic, extracted)
1128
1129/// Generate `DataFrameRow` expansion for struct types.
1130///
1131/// Produces:
1132/// - A companion struct `{Name}DataFrame` with `Vec<T>` columns
1133/// - `impl IntoDataFrame for {Name}DataFrame`
1134/// - `impl From<Vec<{Name}>> for {Name}DataFrame`
1135/// - `impl IntoIterator` (for named structs without expansion)
1136/// - Associated methods: `to_dataframe`, `from_dataframe`, `from_rows`, `from_rows_par`
1137/// - A compile-time `IntoList` assertion (for non-expanded named structs)
1138///
1139/// Handles fixed-array expansion (`[T; N]`), pinned-width Vec expansion
1140/// (`Vec<T>` + `width`), and auto-expand Vec (`Vec<T>` + `expand`).
1141fn derive_struct_dataframe(
1142    row_name: &syn::Ident,
1143    input: &DeriveInput,
1144    data: &syn::DataStruct,
1145    df_name: &syn::Ident,
1146    attrs: &DataFrameAttrs,
1147) -> syn::Result<TokenStream> {
1148    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
1149
1150    let is_tuple_struct = matches!(&data.fields, Fields::Unnamed(_));
1151    let is_unit_struct = matches!(&data.fields, Fields::Unit);
1152
1153    // Resolve fields through the new FieldAttrs + type classification system.
1154    let resolved: Vec<ResolvedField> = match &data.fields {
1155        Fields::Named(fields) => {
1156            let mut out = Vec::new();
1157            for (i, f) in fields.named.iter().enumerate() {
1158                if let Some(rf) = resolve_struct_field(f, i, false)? {
1159                    out.push(rf);
1160                }
1161            }
1162            out
1163        }
1164        Fields::Unnamed(fields) => {
1165            let mut out = Vec::new();
1166            for (i, f) in fields.unnamed.iter().enumerate() {
1167                if let Some(rf) = resolve_struct_field(f, i, true)? {
1168                    out.push(rf);
1169                }
1170            }
1171            out
1172        }
1173        Fields::Unit => vec![],
1174    };
1175
1176    // Check whether any field uses expansion — affects whether we can generate
1177    // IntoIterator (expanded fields change the companion struct shape).
1178    let has_expansion = resolved
1179        .iter()
1180        .any(|rf| !matches!(rf, ResolvedField::Single(..)));
1181    // Track which Rust fields were skipped (for destructure patterns).
1182    let skipped_fields: Vec<syn::Ident> = match &data.fields {
1183        Fields::Named(fields) => fields
1184            .named
1185            .iter()
1186            .filter_map(|f| {
1187                let fa = parse_field_attrs(f).ok()?;
1188                if fa.skip {
1189                    Some(f.ident.as_ref().unwrap().clone())
1190                } else {
1191                    None
1192                }
1193            })
1194            .collect(),
1195        _ => vec![],
1196    };
1197
1198    let has_tag = attrs.tag.is_some();
1199    let row_name_str = row_name.to_string();
1200
1201    // region: Build flat column lists from resolved fields
1202    // Each resolved field may produce 1..N columns.
1203    struct FlatCol {
1204        /// Companion struct field name.
1205        df_field: syn::Ident,
1206        /// Column name string in the R data frame.
1207        col_name_str: String,
1208        /// Type of the companion Vec<T>.
1209        vec_elem_ty: syn::Type,
1210        /// `#[dataframe(as_list)]` on a struct-typed field — companion stores
1211        /// `Vec<List>`. The `from_rows_par` pre-pass handles these sequentially
1212        /// instead of scatter-writing (List doesn't implement Default).
1213        needs_into_list: bool,
1214    }
1215
1216    let mut flat_cols: Vec<FlatCol> = Vec::new();
1217
1218    for rf in &resolved {
1219        match rf {
1220            ResolvedField::Single(data) => {
1221                flat_cols.push(FlatCol {
1222                    df_field: data.col_name.clone(),
1223                    col_name_str: data.col_name_str.clone(),
1224                    vec_elem_ty: data.ty.clone(),
1225                    needs_into_list: data.needs_into_list,
1226                });
1227            }
1228            ResolvedField::ExpandedFixed(data) => {
1229                for i in 1..=data.len {
1230                    let name = format!("{}_{}", data.base_name, i);
1231                    flat_cols.push(FlatCol {
1232                        df_field: format_ident!("{}_{}", data.base_name, i),
1233                        col_name_str: name,
1234                        vec_elem_ty: data.elem_ty.clone(),
1235                        needs_into_list: false,
1236                    });
1237                }
1238            }
1239            ResolvedField::ExpandedVec(data) => {
1240                for i in 1..=data.width {
1241                    let name = format!("{}_{}", data.base_name, i);
1242                    let elem_ty = &data.elem_ty;
1243                    let opt_ty: syn::Type = syn::parse_quote!(Option<#elem_ty>);
1244                    flat_cols.push(FlatCol {
1245                        df_field: format_ident!("{}_{}", data.base_name, i),
1246                        col_name_str: name,
1247                        vec_elem_ty: opt_ty,
1248                        needs_into_list: false,
1249                    });
1250                }
1251            }
1252            // AutoExpandVec / Struct do not produce FlatCols — handled separately.
1253            ResolvedField::AutoExpandVec(..) | ResolvedField::Struct(..) => {}
1254            // Map (#919): two parallel list-columns `<base>_keys` / `<base>_values`.
1255            // Each is a `Vec<Vec<K>>` / `Vec<Vec<V>>` (VECSXP of typed vectors).
1256            ResolvedField::Map(data) => {
1257                let keys_col_name = format!("{}_keys", data.base_name);
1258                let vals_col_name = format!("{}_values", data.base_name);
1259                let key_ty = &data.key_ty;
1260                let val_ty = &data.val_ty;
1261                let keys_vec_ty: syn::Type = syn::parse_quote!(Vec<#key_ty>);
1262                let vals_vec_ty: syn::Type = syn::parse_quote!(Vec<#val_ty>);
1263                flat_cols.push(FlatCol {
1264                    df_field: format_ident!("{}_keys", data.base_name),
1265                    col_name_str: keys_col_name,
1266                    vec_elem_ty: keys_vec_ty,
1267                    needs_into_list: false,
1268                });
1269                flat_cols.push(FlatCol {
1270                    df_field: format_ident!("{}_values", data.base_name),
1271                    col_name_str: vals_col_name,
1272                    vec_elem_ty: vals_vec_ty,
1273                    needs_into_list: false,
1274                });
1275            }
1276        }
1277    }
1278    // endregion
1279
1280    // region: Collect auto-expand fields
1281    struct AutoExpandCol {
1282        /// Companion struct field name.
1283        df_field: syn::Ident,
1284        /// Container type (Vec<T> or Box<[T]>).
1285        container_ty: syn::Type,
1286    }
1287
1288    let auto_expand_cols: Vec<AutoExpandCol> = resolved
1289        .iter()
1290        .filter_map(|rf| {
1291            if let ResolvedField::AutoExpandVec(data) = rf {
1292                Some(AutoExpandCol {
1293                    df_field: format_ident!("{}", data.col_name_str),
1294                    container_ty: data.container_ty.clone(),
1295                })
1296            } else {
1297                None
1298            }
1299        })
1300        .collect();
1301    let has_auto_expand = !auto_expand_cols.is_empty();
1302    // endregion
1303
1304    // region: Collect struct (DataFrameRow-flattened) fields (#485)
1305    //
1306    // Only the codegen-time bits are mirrored here — `rust_name` / `tuple_index`
1307    // are read directly off `ResolvedField::Struct` at the per-row pushes site.
1308    struct StructCol {
1309        df_field: syn::Ident,
1310        col_name_str: String,
1311        inner_ty: syn::Type,
1312    }
1313
1314    let struct_cols: Vec<StructCol> = resolved
1315        .iter()
1316        .filter_map(|rf| {
1317            if let ResolvedField::Struct(data) = rf {
1318                Some(StructCol {
1319                    df_field: data.col_name.clone(),
1320                    col_name_str: data.col_name_str.clone(),
1321                    inner_ty: data.inner_ty.clone(),
1322                })
1323            } else {
1324                None
1325            }
1326        })
1327        .collect();
1328    let has_struct = !struct_cols.is_empty();
1329
1330    // Any `#[dataframe(as_list)]` on a struct-typed field stores `List` in the
1331    // companion (#485 opt-out). We can't round-trip List back to the inner
1332    // struct without a `FromList`-like trait, and `List` doesn't impl
1333    // `Default`, so several codegen branches need to suppress themselves:
1334    // IntoIterator generation, the `IntoList` compile-time assertion, and
1335    // `from_rows_par`.
1336    let has_into_list_struct = resolved
1337        .iter()
1338        .any(|rf| matches!(rf, ResolvedField::Single(d) if d.needs_into_list));
1339    // endregion
1340
1341    // region: Companion struct
1342    let tag_field_decl = if has_tag {
1343        quote! { pub _tag: Vec<String>, }
1344    } else {
1345        TokenStream::new()
1346    };
1347
1348    let mut df_fields_tokens: Vec<TokenStream> = flat_cols
1349        .iter()
1350        .map(|fc| {
1351            let name = &fc.df_field;
1352            let ty = &fc.vec_elem_ty;
1353            quote! { pub #name: Vec<#ty> }
1354        })
1355        .collect();
1356    for ac in &auto_expand_cols {
1357        let name = &ac.df_field;
1358        let cty = &ac.container_ty;
1359        df_fields_tokens.push(quote! { pub #name: Vec<#cty> });
1360    }
1361    for sc in &struct_cols {
1362        let name = &sc.df_field;
1363        let ity = &sc.inner_ty;
1364        df_fields_tokens.push(quote! { pub #name: Vec<#ity> });
1365    }
1366
1367    let len_field_decl = if flat_cols.is_empty()
1368        && auto_expand_cols.is_empty()
1369        && struct_cols.is_empty()
1370        && !has_tag
1371    {
1372        quote! { pub _len: usize, }
1373    } else {
1374        TokenStream::new()
1375    };
1376
1377    let dataframe_struct = quote! {
1378        #[derive(Debug, Clone)]
1379        pub struct #df_name #impl_generics #where_clause {
1380            #tag_field_decl
1381            #len_field_decl
1382            #(#df_fields_tokens),*
1383        }
1384    };
1385    // endregion
1386
1387    // region: IntoDataFrame
1388    let length_ref = if has_tag {
1389        quote! { self._tag.len() }
1390    } else if !flat_cols.is_empty() {
1391        let first = &flat_cols[0].df_field;
1392        quote! { self.#first.len() }
1393    } else if !auto_expand_cols.is_empty() {
1394        let first = &auto_expand_cols[0].df_field;
1395        quote! { self.#first.len() }
1396    } else if !struct_cols.is_empty() {
1397        let first = &struct_cols[0].df_field;
1398        quote! { self.#first.len() }
1399    } else {
1400        quote! { self._len }
1401    };
1402
1403    // Each pair protects its SEXP via `__scope.protect_raw` so previously-built
1404    // column SEXPs survive subsequent column allocations. Pre-fix the raw
1405    // `vec![(name, into_sexp(...)), ...]` left every SEXP unrooted across the
1406    // next column's allocations — UAF under gctorture
1407    // (reviews/2026-05-07-gctorture-audit.md).
1408    let tag_pair = if let Some(ref tag_name) = attrs.tag {
1409        quote! { (#tag_name, __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self._tag))), }
1410    } else {
1411        TokenStream::new()
1412    };
1413
1414    let df_pairs: Vec<TokenStream> = flat_cols
1415        .iter()
1416        .map(|fc| {
1417            let name = &fc.df_field;
1418            let name_str = &fc.col_name_str;
1419            quote! { (#name_str, __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#name))) }
1420        })
1421        .collect();
1422
1423    let mut length_checks: Vec<TokenStream> = flat_cols
1424        .iter()
1425        .map(|fc| {
1426            let name = &fc.df_field;
1427            let name_str = &fc.col_name_str;
1428            quote! {
1429                assert!(
1430                    self.#name.len() == _n_rows,
1431                    "column length mismatch in {}: column `{}` has length {} but expected {}",
1432                    stringify!(#df_name),
1433                    #name_str,
1434                    self.#name.len(),
1435                    _n_rows,
1436                );
1437            }
1438        })
1439        .collect();
1440    for sc in &struct_cols {
1441        let name = &sc.df_field;
1442        let name_str = &sc.col_name_str;
1443        length_checks.push(quote! {
1444            assert!(
1445                self.#name.len() == _n_rows,
1446                "column length mismatch in {}: struct column `{}` has length {} but expected {}",
1447                stringify!(#df_name),
1448                #name_str,
1449                self.#name.len(),
1450                _n_rows,
1451            );
1452        });
1453    }
1454
1455    let into_dataframe_impl = if has_auto_expand || has_struct {
1456        // Dynamic pair building: iterate resolved fields in order,
1457        // emitting static pairs for flat columns and runtime-expanded
1458        // pairs for auto-expand fields.
1459        let tag_push_pair = if let Some(ref tag_name) = attrs.tag {
1460            quote! {
1461                __df_pairs.push((#tag_name.to_string(), __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self._tag))));
1462            }
1463        } else {
1464            TokenStream::new()
1465        };
1466
1467        let pair_pushes: Vec<TokenStream> = resolved
1468            .iter()
1469            .map(|rf| match rf {
1470                ResolvedField::Single(data) => {
1471                    let col_name = &data.col_name;
1472                    let col_name_str = &data.col_name_str;
1473                    quote! {
1474                        __df_pairs.push((
1475                            #col_name_str.to_string(),
1476                            __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#col_name)),
1477                        ));
1478                    }
1479                }
1480                ResolvedField::ExpandedFixed(data) => {
1481                    let pushes: Vec<TokenStream> = (1..=data.len)
1482                        .map(|i| {
1483                            let name = format!("{}_{}", data.base_name, i);
1484                            let ident = format_ident!("{}_{}", data.base_name, i);
1485                            quote! {
1486                                __df_pairs.push((
1487                                    #name.to_string(),
1488                                    __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#ident)),
1489                                ));
1490                            }
1491                        })
1492                        .collect();
1493                    quote! { #(#pushes)* }
1494                }
1495                ResolvedField::ExpandedVec(data) => {
1496                    let pushes: Vec<TokenStream> = (1..=data.width)
1497                        .map(|i| {
1498                            let name = format!("{}_{}", data.base_name, i);
1499                            let ident = format_ident!("{}_{}", data.base_name, i);
1500                            quote! {
1501                                __df_pairs.push((
1502                                    #name.to_string(),
1503                                    __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#ident)),
1504                                ));
1505                            }
1506                        })
1507                        .collect();
1508                    quote! { #(#pushes)* }
1509                }
1510                ResolvedField::AutoExpandVec(data) => {
1511                    let col_name = &data.col_name;
1512                    let col_name_str = &data.col_name_str;
1513                    let elem_ty = &data.elem_ty;
1514                    quote! {
1515                        {
1516                            let __auto = self.#col_name;
1517                            let __max = __auto.iter().map(|v| v.len()).max().unwrap_or(0);
1518                            let mut __cols: Vec<Vec<Option<#elem_ty>>> = (0..__max)
1519                                .map(|_| Vec::with_capacity(_n_rows))
1520                                .collect();
1521                            for __row_vec in &__auto {
1522                                for (__i, __col) in __cols.iter_mut().enumerate() {
1523                                    __col.push(__row_vec.get(__i).cloned());
1524                                }
1525                            }
1526                            for (__i, __col) in __cols.into_iter().enumerate() {
1527                                __df_pairs.push((
1528                                    format!("{}_{}", #col_name_str, __i + 1),
1529                                    __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(__col)),
1530                                ));
1531                            }
1532                        }
1533                    }
1534                }
1535                ResolvedField::Struct(data) => {
1536                    // Issue #485: convert `Vec<Inner>` via Inner::to_dataframe,
1537                    // extract its named columns, and push under `<base>_` prefix.
1538                    let col_name = &data.col_name;
1539                    let base_name_str = &data.col_name_str;
1540                    let inner_ty = &data.inner_ty;
1541                    quote! {
1542                        {
1543                            let __inner_df = <#inner_ty>::to_dataframe(self.#col_name);
1544                            let __inner_cols = ::miniextendr_api::convert::ColumnSource::into_named_columns(__inner_df);
1545                            for (__inner_col_name, __inner_col_sexp) in __inner_cols {
1546                                // Protect the source column SEXP across subsequent allocations.
1547                                let __src = __scope.protect_raw(__inner_col_sexp);
1548                                __df_pairs.push((
1549                                    format!("{}_{}", #base_name_str, __inner_col_name),
1550                                    __src,
1551                                ));
1552                            }
1553                        }
1554                    }
1555                }
1556                // Map (#919): push two list-columns `<base>_keys` / `<base>_values`.
1557                ResolvedField::Map(data) => {
1558                    let keys_ident = format_ident!("{}_keys", data.base_name);
1559                    let vals_ident = format_ident!("{}_values", data.base_name);
1560                    let keys_name = format!("{}_keys", data.base_name);
1561                    let vals_name = format!("{}_values", data.base_name);
1562                    quote! {
1563                        __df_pairs.push((
1564                            #keys_name.to_string(),
1565                            __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#keys_ident)),
1566                        ));
1567                        __df_pairs.push((
1568                            #vals_name.to_string(),
1569                            __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#vals_ident)),
1570                        ));
1571                    }
1572                }
1573            })
1574            .collect();
1575
1576        quote! {
1577            impl #impl_generics ::miniextendr_api::convert::ColumnSource for #df_name #ty_generics #where_clause {
1578                fn into_column_list(self) -> ::miniextendr_api::List {
1579                    let _n_rows = #length_ref;
1580                    #(#length_checks)*
1581                    // SAFETY: into_column_list only runs on the R main thread.
1582                    // ProtectScope keeps each column SEXP rooted across the
1583                    // next column's allocations; from_raw_pairs writes them
1584                    // into the parent VECSXP before we drop the scope.
1585                    unsafe {
1586                        let __scope = ::miniextendr_api::gc_protect::ProtectScope::new();
1587                        let mut __df_pairs: Vec<(
1588                            String,
1589                            ::miniextendr_api::SEXP,
1590                        )> = Vec::new();
1591                        #tag_push_pair
1592                        #(#pair_pushes)*
1593                        ::miniextendr_api::list::List::from_raw_pairs(__df_pairs)
1594                            .set_class_str(&["data.frame"])
1595                            .set_row_names_int(_n_rows)
1596                    }
1597                }
1598            }
1599        }
1600    } else {
1601        quote! {
1602            impl #impl_generics ::miniextendr_api::convert::ColumnSource for #df_name #ty_generics #where_clause {
1603                fn into_column_list(self) -> ::miniextendr_api::List {
1604                    let _n_rows = #length_ref;
1605                    #(#length_checks)*
1606                    // SAFETY: see auto-expand branch.
1607                    unsafe {
1608                        let __scope = ::miniextendr_api::gc_protect::ProtectScope::new();
1609                        ::miniextendr_api::list::List::from_raw_pairs(vec![
1610                            #tag_pair
1611                            #(#df_pairs),*
1612                        ])
1613                        .set_class_str(&["data.frame"])
1614                        .set_row_names_int(_n_rows)
1615                    }
1616                }
1617            }
1618        }
1619    };
1620    // endregion
1621
1622    // region: From<Vec<RowType>>
1623    let mut col_vec_inits: Vec<TokenStream> = flat_cols
1624        .iter()
1625        .map(|fc| {
1626            let name = &fc.df_field;
1627            let ty = &fc.vec_elem_ty;
1628            quote! { let mut #name: Vec<#ty> = Vec::with_capacity(len); }
1629        })
1630        .collect();
1631    for ac in &auto_expand_cols {
1632        let name = &ac.df_field;
1633        let cty = &ac.container_ty;
1634        col_vec_inits.push(quote! { let mut #name: Vec<#cty> = Vec::with_capacity(len); });
1635    }
1636    for sc in &struct_cols {
1637        let name = &sc.df_field;
1638        let ity = &sc.inner_ty;
1639        col_vec_inits.push(quote! { let mut #name: Vec<#ity> = Vec::with_capacity(len); });
1640    }
1641
1642    let tag_init = if has_tag {
1643        quote! { let mut _tag: Vec<String> = Vec::with_capacity(len); }
1644    } else {
1645        TokenStream::new()
1646    };
1647
1648    let tag_push = if has_tag {
1649        quote! { _tag.push(#row_name_str.to_string()); }
1650    } else {
1651        TokenStream::new()
1652    };
1653
1654    // Generate push statements for each resolved field
1655    let col_pushes: Vec<TokenStream> = resolved
1656        .iter()
1657        .map(|rf| match rf {
1658            ResolvedField::Single(data) => {
1659                let access = if let Some(idx) = &data.tuple_index {
1660                    quote! { row.#idx }
1661                } else {
1662                    let rust_name = &data.rust_name;
1663                    quote! { row.#rust_name }
1664                };
1665                let col_name = &data.col_name;
1666                if data.needs_into_list {
1667                    quote! { #col_name.push(::miniextendr_api::list::IntoList::into_list(#access)); }
1668                } else {
1669                    quote! { #col_name.push(#access); }
1670                }
1671            }
1672            ResolvedField::ExpandedFixed(data) => {
1673                let access = if let Some(idx) = &data.tuple_index {
1674                    quote! { row.#idx }
1675                } else {
1676                    let rust_name = &data.rust_name;
1677                    quote! { row.#rust_name }
1678                };
1679                let bind = format_ident!("__arr_{}", data.rust_name);
1680                let pushes: Vec<TokenStream> = (0..data.len)
1681                    .map(|i| {
1682                        let col_ident = format_ident!("{}_{}", data.base_name, i + 1);
1683                        let idx = syn::Index::from(i);
1684                        quote! { #col_ident.push(#bind[#idx]); }
1685                    })
1686                    .collect();
1687                quote! {
1688                    let #bind = #access;
1689                    #(#pushes)*
1690                }
1691            }
1692            ResolvedField::ExpandedVec(data) => {
1693                let access = if let Some(idx) = &data.tuple_index {
1694                    quote! { row.#idx }
1695                } else {
1696                    let rust_name = &data.rust_name;
1697                    quote! { row.#rust_name }
1698                };
1699                let bind = format_ident!("__vec_{}", data.rust_name);
1700                let pushes: Vec<TokenStream> = (0..data.width)
1701                    .map(|i| {
1702                        let col_ident = format_ident!("{}_{}", data.base_name, i + 1);
1703                        quote! { #col_ident.push(#bind.get(#i).cloned()); }
1704                    })
1705                    .collect();
1706                quote! {
1707                    let #bind = #access;
1708                    #(#pushes)*
1709                }
1710            }
1711            ResolvedField::AutoExpandVec(data) => {
1712                let access = if let Some(idx) = &data.tuple_index {
1713                    quote! { row.#idx }
1714                } else {
1715                    let rust_name = &data.rust_name;
1716                    quote! { row.#rust_name }
1717                };
1718                let col_name = &data.col_name;
1719                quote! { #col_name.push(#access); }
1720            }
1721            ResolvedField::Struct(data) => {
1722                let access = if let Some(idx) = &data.tuple_index {
1723                    quote! { row.#idx }
1724                } else {
1725                    let rust_name = &data.rust_name;
1726                    quote! { row.#rust_name }
1727                };
1728                let col_name = &data.col_name;
1729                quote! { #col_name.push(#access); }
1730            }
1731            // Map (#919): unzip into parallel keys/values vecs.
1732            ResolvedField::Map(data) => {
1733                let access = if let Some(idx) = &data.tuple_index {
1734                    quote! { row.#idx }
1735                } else {
1736                    let rust_name = &data.rust_name;
1737                    quote! { row.#rust_name }
1738                };
1739                let keys_col = format_ident!("{}_keys", data.base_name);
1740                let vals_col = format_ident!("{}_values", data.base_name);
1741                quote! {
1742                    let (__mx_keys, __mx_vals) = #access
1743                        .into_iter()
1744                        .unzip::<_, _, Vec<_>, Vec<_>>();
1745                    #keys_col.push(__mx_keys);
1746                    #vals_col.push(__mx_vals);
1747                }
1748            }
1749        })
1750        .collect();
1751
1752    let tag_struct_field = if has_tag {
1753        quote! { _tag, }
1754    } else {
1755        TokenStream::new()
1756    };
1757
1758    let len_struct_field = if flat_cols.is_empty()
1759        && auto_expand_cols.is_empty()
1760        && struct_cols.is_empty()
1761        && !has_tag
1762    {
1763        quote! { _len: len, }
1764    } else {
1765        TokenStream::new()
1766    };
1767
1768    let mut col_struct_fields: Vec<TokenStream> = flat_cols
1769        .iter()
1770        .map(|fc| {
1771            let name = &fc.df_field;
1772            quote! { #name }
1773        })
1774        .collect();
1775    for ac in &auto_expand_cols {
1776        let name = &ac.df_field;
1777        col_struct_fields.push(quote! { #name });
1778    }
1779    for sc in &struct_cols {
1780        let name = &sc.df_field;
1781        col_struct_fields.push(quote! { #name });
1782    }
1783
1784    // For skipped fields in destructure: bind to `_`
1785    let skip_bindings: Vec<TokenStream> = skipped_fields
1786        .iter()
1787        .map(|name| quote! { let _ = row.#name; })
1788        .collect();
1789
1790    let from_vec_impl = quote! {
1791        impl #impl_generics From<Vec<#row_name #ty_generics>> for #df_name #ty_generics #where_clause {
1792            fn from(rows: Vec<#row_name #ty_generics>) -> Self {
1793                let len = rows.len();
1794                #tag_init
1795                #(#col_vec_inits)*
1796                for row in rows {
1797                    #tag_push
1798                    #(#skip_bindings)*
1799                    #(#col_pushes)*
1800                }
1801                #df_name {
1802                    #tag_struct_field
1803                    #len_struct_field
1804                    #(#col_struct_fields),*
1805                }
1806            }
1807        }
1808    };
1809    // endregion
1810
1811    // region: Generate from_rows_par (parallel scatter-write via ColumnWriter)
1812    //
1813    // Two field kinds require special handling instead of parallel scatter-write:
1814    //   - struct (DataFrameRow-flattened) fields (#485): companion stores
1815    //     `Vec<Inner>` where `Inner` doesn't implement `Default`. These are
1816    //     collected sequentially in a pre-pass (`for __prerow in &rows { ... }`)
1817    //     before `into_par_iter()` consumes the vector. Requires `Inner: Clone`.
1818    //   - `as_list`-on-struct fields (#485 opt-out) store `Vec<List>` in the
1819    //     companion, and `List` doesn't implement `Default`. Same pre-pass approach.
1820    // Both are handled via sequential pre-pass + skip in the parallel loop.
1821    // The pre-pass is O(n) extra per struct/list-struct field but does not change
1822    // asymptotic complexity — just adds a constant factor for these column types.
1823    let from_rows_par_method = if !flat_cols.is_empty()
1824        || !auto_expand_cols.is_empty()
1825        || has_tag
1826        || has_struct
1827        || has_into_list_struct
1828    {
1829        // Column declarations:
1830        //   - scalar / expand cols: vec![default; len]  (scatter-write in parallel)
1831        //   - struct / as_list-struct cols: Vec::with_capacity(len) filled in pre-pass
1832        let mut par_col_decls = Vec::new();
1833        if has_tag {
1834            par_col_decls.push(quote! {
1835                let mut _tag: Vec<String> = vec![String::new(); len];
1836            });
1837        }
1838        // Sequential pre-pass: struct fields (Inner: Clone required).
1839        // Iterate resolved to pick up tuple_index for tuple-struct outers.
1840        for rf in &resolved {
1841            if let ResolvedField::Struct(data) = rf {
1842                let col_name = &data.col_name;
1843                let ity = &data.inner_ty;
1844                let access = if let Some(idx) = &data.tuple_index {
1845                    quote! { __prerow.#idx }
1846                } else {
1847                    let rust_name = &data.rust_name;
1848                    quote! { __prerow.#rust_name }
1849                };
1850                par_col_decls.push(quote! {
1851                    let mut #col_name: Vec<#ity> = Vec::with_capacity(len);
1852                    for __prerow in &rows {
1853                        #col_name.push(::core::clone::Clone::clone(&#access));
1854                    }
1855                });
1856            }
1857        }
1858        // Sequential pre-pass: as_list-on-struct fields (List: !Default).
1859        for rf in &resolved {
1860            if let ResolvedField::Single(data) = rf
1861                && data.needs_into_list
1862            {
1863                let col_name = &data.col_name;
1864                let rust_name = &data.rust_name;
1865                let access = if let Some(idx) = &data.tuple_index {
1866                    quote! { __prerow.#idx }
1867                } else {
1868                    quote! { __prerow.#rust_name }
1869                };
1870                par_col_decls.push(quote! {
1871                    let mut #col_name: Vec<::miniextendr_api::list::List> = Vec::with_capacity(len);
1872                    for __prerow in &rows {
1873                        #col_name.push(::miniextendr_api::list::IntoList::into_list(
1874                            ::core::clone::Clone::clone(&#access)
1875                        ));
1876                    }
1877                });
1878            }
1879        }
1880        // Parallel scalar/expand columns.
1881        for fc in &flat_cols {
1882            if fc.needs_into_list {
1883                // Handled in the sequential pre-pass above.
1884                continue;
1885            }
1886            let name = &fc.df_field;
1887            let ty = &fc.vec_elem_ty;
1888            par_col_decls.push(quote! {
1889                let mut #name: Vec<#ty> = vec![<#ty as ::core::default::Default>::default(); len];
1890            });
1891        }
1892        for ac in &auto_expand_cols {
1893            let name = &ac.df_field;
1894            let cty = &ac.container_ty;
1895            par_col_decls.push(quote! {
1896                let mut #name: Vec<#cty> = vec![<#cty as ::core::default::Default>::default(); len];
1897            });
1898        }
1899
1900        // Writer declarations (only for scatter-write cols — struct/as_list pre-pass
1901        // cols are already populated and need no ColumnWriter).
1902        let mut writer_decls = Vec::new();
1903        if has_tag {
1904            writer_decls.push(quote! {
1905                let __w_tag = unsafe {
1906                    ::miniextendr_api::rayon_bridge::ColumnWriter::new(&mut _tag)
1907                };
1908            });
1909        }
1910        for fc in &flat_cols {
1911            if fc.needs_into_list {
1912                continue;
1913            }
1914            let name = &fc.df_field;
1915            let w_name = format_ident!("__w_{}", name);
1916            writer_decls.push(quote! {
1917                let #w_name = unsafe {
1918                    ::miniextendr_api::rayon_bridge::ColumnWriter::new(&mut #name)
1919                };
1920            });
1921        }
1922        for ac in &auto_expand_cols {
1923            let name = &ac.df_field;
1924            let w_name = format_ident!("__w_{}", name);
1925            writer_decls.push(quote! {
1926                let #w_name = unsafe {
1927                    ::miniextendr_api::rayon_bridge::ColumnWriter::new(&mut #name)
1928                };
1929            });
1930        }
1931
1932        // Write calls per resolved field (parallel scatter-write only).
1933        let tag_write = if has_tag {
1934            quote! { __w_tag.write(__i, #row_name_str.to_string()); }
1935        } else {
1936            TokenStream::new()
1937        };
1938
1939        let par_write_calls: Vec<TokenStream> = resolved
1940            .iter()
1941            .map(|rf| match rf {
1942                ResolvedField::Single(data) => {
1943                    if data.needs_into_list {
1944                        // Handled in the sequential pre-pass; skip in par loop.
1945                        return TokenStream::new();
1946                    }
1947                    let access = if let Some(idx) = &data.tuple_index {
1948                        quote! { __row.#idx }
1949                    } else {
1950                        let rust_name = &data.rust_name;
1951                        quote! { __row.#rust_name }
1952                    };
1953                    let w_name = format_ident!("__w_{}", data.col_name);
1954                    quote! { #w_name.write(__i, #access); }
1955                }
1956                ResolvedField::ExpandedFixed(data) => {
1957                    let access = if let Some(idx) = &data.tuple_index {
1958                        quote! { __row.#idx }
1959                    } else {
1960                        let rust_name = &data.rust_name;
1961                        quote! { __row.#rust_name }
1962                    };
1963                    let bind = format_ident!("__arr_{}", data.rust_name);
1964                    let writes: Vec<TokenStream> = (0..data.len)
1965                        .map(|i| {
1966                            let w_name = format_ident!("__w_{}_{}", data.base_name, i + 1);
1967                            let idx = syn::Index::from(i);
1968                            quote! { #w_name.write(__i, #bind[#idx]); }
1969                        })
1970                        .collect();
1971                    quote! {
1972                        let #bind = #access;
1973                        #(#writes)*
1974                    }
1975                }
1976                ResolvedField::ExpandedVec(data) => {
1977                    let access = if let Some(idx) = &data.tuple_index {
1978                        quote! { __row.#idx }
1979                    } else {
1980                        let rust_name = &data.rust_name;
1981                        quote! { __row.#rust_name }
1982                    };
1983                    let bind = format_ident!("__vec_{}", data.rust_name);
1984                    let writes: Vec<TokenStream> = (0..data.width)
1985                        .map(|i| {
1986                            let w_name = format_ident!("__w_{}_{}", data.base_name, i + 1);
1987                            quote! { #w_name.write(__i, #bind.get(#i).cloned()); }
1988                        })
1989                        .collect();
1990                    quote! {
1991                        let #bind = #access;
1992                        #(#writes)*
1993                    }
1994                }
1995                ResolvedField::AutoExpandVec(data) => {
1996                    let access = if let Some(idx) = &data.tuple_index {
1997                        quote! { __row.#idx }
1998                    } else {
1999                        let rust_name = &data.rust_name;
2000                        quote! { __row.#rust_name }
2001                    };
2002                    let w_name = format_ident!("__w_{}", data.col_name);
2003                    quote! { #w_name.write(__i, #access); }
2004                }
2005                // Struct fields (#485) are collected in the sequential pre-pass
2006                // above; nothing to write in the parallel loop.
2007                ResolvedField::Struct(_) => TokenStream::new(),
2008                // Map (#919): unzip into parallel keys/values vecs via scatter-write.
2009                ResolvedField::Map(data) => {
2010                    let access = if let Some(idx) = &data.tuple_index {
2011                        quote! { __row.#idx }
2012                    } else {
2013                        let rust_name = &data.rust_name;
2014                        quote! { __row.#rust_name }
2015                    };
2016                    let w_keys = format_ident!("__w_{}_keys", data.base_name);
2017                    let w_vals = format_ident!("__w_{}_values", data.base_name);
2018                    quote! {
2019                        let (__mx_keys, __mx_vals) = #access
2020                            .into_iter()
2021                            .unzip::<_, _, Vec<_>, Vec<_>>();
2022                        #w_keys.write(__i, __mx_keys);
2023                        #w_vals.write(__i, __mx_vals);
2024                    }
2025                }
2026            })
2027            .collect();
2028
2029        let par_skip_bindings: Vec<TokenStream> = skipped_fields
2030            .iter()
2031            .map(|name| quote! { let _ = __row.#name; })
2032            .collect();
2033
2034        // Return struct fields
2035        let par_tag_field = if has_tag {
2036            quote! { _tag, }
2037        } else {
2038            TokenStream::new()
2039        };
2040        // Emit `_len: len` only when the companion struct has a `_len` field —
2041        // that is, when there are truly no column vecs at all (no scalars, no
2042        // as_list-on-struct fields, no struct-flattened fields, no tag).
2043        // `as_list`-on-struct fields live in `flat_cols` with `needs_into_list=true`;
2044        // they provide their own length reference and do NOT require `_len`.
2045        // The `flat_cols.iter().all(…)` guard is redundant with `flat_cols.is_empty()`
2046        // but makes the intent explicit: _len is emitted only when every dimension
2047        // that tracks length is absent.
2048        let par_len_field = if flat_cols.is_empty()
2049            && flat_cols.iter().all(|fc| !fc.needs_into_list)
2050            && auto_expand_cols.is_empty()
2051            && !has_tag
2052            && struct_cols.is_empty()
2053        {
2054            quote! { _len: len, }
2055        } else {
2056            TokenStream::new()
2057        };
2058        let mut par_struct_fields: Vec<TokenStream> = flat_cols
2059            .iter()
2060            .map(|fc| {
2061                let name = &fc.df_field;
2062                quote! { #name }
2063            })
2064            .collect();
2065        for ac in &auto_expand_cols {
2066            let name = &ac.df_field;
2067            par_struct_fields.push(quote! { #name });
2068        }
2069        for sc in &struct_cols {
2070            let name = &sc.df_field;
2071            par_struct_fields.push(quote! { #name });
2072        }
2073
2074        // Only emit an into_par_iter call when there are scalar/expand/tag cols
2075        // to scatter-write; struct/as_list-only structs skip the parallel loop.
2076        let has_par_cols = !flat_cols.iter().all(|fc| fc.needs_into_list)
2077            || !auto_expand_cols.is_empty()
2078            || has_tag;
2079        let par_loop = if has_par_cols {
2080            quote! {
2081                {
2082                    ::miniextendr_api::optionals::parallel::ensure_pool();
2083                    #(#writer_decls)*
2084                    rows.into_par_iter().enumerate().for_each(|(__i, __row)| unsafe {
2085                        #tag_write
2086                        #(#par_write_calls)*
2087                        #(#par_skip_bindings)*
2088                    });
2089                }
2090            }
2091        } else {
2092            // All columns were collected in the pre-pass; rows already consumed.
2093            quote! { let _rows = rows; }
2094        };
2095
2096        quote! {
2097            // The `#[cfg(feature = "rayon")]` gate lives on the api-side macro
2098            // (evaluated against miniextendr-api's own features), not here —
2099            // stamping the cfg into the consumer crate trips `unexpected_cfgs`
2100            // wherever the derive is used without a `rayon` feature (#1117).
2101            ::miniextendr_api::__dataframe_row_when_rayon! {
2102                // Parallel override of `ColumnarFrame::from_rows_par` (rayon scatter-write).
2103                // Scalar/expand columns are scatter-written in parallel; struct-flattened and
2104                // `as_list`-on-struct fields are collected in a sequential pre-pass (they don't
2105                // implement `Default`). Inner struct types must be `Clone`; that bound rides the
2106                // `impl ColumnarFrame` block below (a trait-method override cannot add its own
2107                // stricter `where` clause).
2108                #[allow(clippy::uninit_vec)]
2109                fn from_rows_par(rows: ::std::vec::Vec<#row_name #ty_generics>) -> Self {
2110                    use ::miniextendr_api::rayon_bridge::rayon::prelude::*;
2111                    let len = rows.len();
2112                    #(#par_col_decls)*
2113                    #par_loop
2114                    #df_name { #par_tag_field #par_len_field #(#par_struct_fields),* }
2115                }
2116            }
2117        }
2118    } else {
2119        TokenStream::new()
2120    };
2121
2122    // ── IntoIterator (only for named non-empty structs without expansion) ─
2123    let can_iterate = !flat_cols.is_empty()
2124        && !is_tuple_struct
2125        && !is_unit_struct
2126        && !has_expansion
2127        && !has_into_list_struct;
2128    let into_iterator_impl = if can_iterate {
2129        let iterator_name = format_ident!("{}Iterator", df_name);
2130
2131        let iter_field_decls: Vec<_> = flat_cols
2132            .iter()
2133            .map(|fc| {
2134                let name = &fc.df_field;
2135                let ty = &fc.vec_elem_ty;
2136                quote! { #name: std::vec::IntoIter<#ty> }
2137            })
2138            .collect();
2139
2140        let destruct_pattern: Vec<_> = flat_cols
2141            .iter()
2142            .map(|fc| {
2143                let name = &fc.df_field;
2144                quote! { #name }
2145            })
2146            .collect();
2147
2148        let mut iter_init_tokens = TokenStream::new();
2149        for (i, fc) in flat_cols.iter().enumerate() {
2150            let name = &fc.df_field;
2151            let ty = &fc.vec_elem_ty;
2152            if i > 0 {
2153                iter_init_tokens.extend(quote! { , });
2154            }
2155            iter_init_tokens.extend(quote! { #name: <Vec<#ty>>::into_iter(#name) });
2156        }
2157
2158        // For next(): reconstruct original field names (col_name == rust_name for Single)
2159        let mut next_struct_tokens = TokenStream::new();
2160        for (i, rf) in resolved.iter().enumerate() {
2161            if let ResolvedField::Single(data) = rf {
2162                if i > 0 {
2163                    next_struct_tokens.extend(quote! { , });
2164                }
2165                let rust_name = &data.rust_name;
2166                let col_name = &data.col_name;
2167                next_struct_tokens.extend(quote! { #rust_name: self.#col_name.next()? });
2168            }
2169        }
2170
2171        let ignore_tag = if has_tag {
2172            quote! { _tag: _, }
2173        } else {
2174            TokenStream::new()
2175        };
2176
2177        // Skipped fields are reconstructed via `Default::default()` each time
2178        // `next()` yields a row. This is why any field type annotated with
2179        // `#[dataframe(skip)]` must implement `Default`.
2180        let skip_defaults: Vec<TokenStream> = skipped_fields
2181            .iter()
2182            .map(|name| quote! { , #name: Default::default() })
2183            .collect();
2184
2185        quote! {
2186            pub struct #iterator_name #impl_generics #where_clause {
2187                #(#iter_field_decls),*
2188            }
2189
2190            impl #impl_generics IntoIterator for #df_name #ty_generics #where_clause {
2191                type Item = #row_name #ty_generics;
2192                type IntoIter = #iterator_name #ty_generics;
2193
2194                fn into_iter(self) -> Self::IntoIter {
2195                    let #df_name { #ignore_tag #(#destruct_pattern),* } = self;
2196                    #iterator_name {
2197                        #iter_init_tokens
2198                    }
2199                }
2200            }
2201
2202            impl #impl_generics Iterator for #iterator_name #ty_generics #where_clause {
2203                type Item = #row_name #ty_generics;
2204
2205                fn next(&mut self) -> Option<Self::Item> {
2206                    Some(#row_name {
2207                        #next_struct_tokens
2208                        #(#skip_defaults)*
2209                    })
2210                }
2211            }
2212        }
2213    } else {
2214        TokenStream::new()
2215    };
2216    // endregion
2217
2218    // The companion→rows path (`from_dataframe`) is not an inherent method: it is the
2219    // companion's `IntoIterator` impl, surfaced as the documented verb
2220    // `ColumnarFrame::into_rows` (available for row-iterable companions).
2221
2222    // region: from-R readers (try_from_dataframe / try_from_dataframe_par, #738/#782)
2223    //
2224    // Read an R `data.frame` SEXP directly into `Vec<Self>` without first
2225    // materialising a companion `#df_name`. A reader is generated for every
2226    // *named* struct whose fields are all reader-capable (see
2227    // `field_reader_capable`): scalar `Single` fields, column-expansion fields
2228    // (`[T; N]` / `Vec<T>` + `width`/`expand`), and struct-flatten fields (nested
2229    // `DataFrameRow`). Each shape's reader is the exact inverse of its write rule
2230    // — regroup the suffixed expansion columns, un-prefix and recurse into the
2231    // nested reader. Struct-path `HashMap<String, V>`/`BTreeMap<String, V>` map
2232    // columns read whole via `Vec<map>: TryFromSexp` (#764) — they share the
2233    // scalar `pull_col` path, gated by `SingleFieldData::map_reader` (non-String
2234    // keys / non-scalar values / custom hashers stay reader-incapable). `skip` /
2235    // `as_list` / set columns / tuple / unit shapes are not reader-capable and
2236    // fall through to the trait default (a clear runtime `DataFrameError`).
2237    // Tagged-enum and enum-`Map` readers already landed (#807/#816) — see
2238    // `enum_expansion::build_enum_reader`.
2239    //
2240    // The parallel variant (`#[cfg(feature = "rayon")]`) splits cleanly along the
2241    // R-thread boundary: all SEXP access (column extraction, ALTREP
2242    // materialisation, sub-frame selection + recursive nested reads) happens up
2243    // front on the R/worker thread; only then does `(0..nrow).into_par_iter()`
2244    // assemble each `Self` from the pre-extracted, owned column data by index —
2245    // pure Rust, zero R API calls. Shapes containing a struct-flatten field would
2246    // need `Inner: Clone` for by-index parallel assembly, so their `_par` reader
2247    // delegates to the sequential one (which moves) rather than imposing `Clone`.
2248    let struct_reader = !is_tuple_struct
2249        && !is_unit_struct
2250        && !has_tag
2251        && skipped_fields.is_empty()
2252        && !resolved.is_empty()
2253        && resolved.iter().all(field_reader_capable);
2254
2255    let has_autoexpand_field = resolved
2256        .iter()
2257        .any(|rf| matches!(rf, ResolvedField::AutoExpandVec(_)));
2258    let has_struct_field = resolved
2259        .iter()
2260        .any(|rf| matches!(rf, ResolvedField::Struct(_)));
2261
2262    let reader_methods = if struct_reader {
2263        // Per-field fragments:
2264        //   extracts   — prelude statements (R-thread): pull/convert columns, run
2265        //                length checks, materialise nested sub-frames.
2266        //   seq_decls  — draining-iterator decls for the sequential row loop.
2267        //   seq_builds — `field: expr` in the sequential `Self { … }` literal
2268        //                (drains moved columns; indexes only `AutoExpandVec`).
2269        //   par_builds — `field: expr` in the parallel `Self { … }` literal
2270        //                (by-index `.clone()`; scalars only — never reached when
2271        //                a struct-flatten field is present).
2272        let mut extracts: Vec<TokenStream> = Vec::new();
2273        let mut seq_decls: Vec<TokenStream> = Vec::new();
2274        let mut seq_builds: Vec<TokenStream> = Vec::new();
2275        let mut par_builds: Vec<TokenStream> = Vec::new();
2276
2277        // Pull a named column out of R as an owned `Vec<#elem>` via `TryFromSexp`
2278        // (NA-aware, ALTREP-materialising), then length-check it against `__nrow`.
2279        // Bypasses `DataFrame::column` because its `Error = SexpError` bound is
2280        // tighter than the scalar element types' `TryFromSexp::Error`.
2281        let pull_col = |col_var: &syn::Ident, col_name_str: &str, elem_ty: &syn::Type| {
2282            quote! {
2283                let #col_var: Vec<#elem_ty> = {
2284                    let __col_sexp = __view.column_raw(#col_name_str).ok_or_else(|| {
2285                        ::std::format!("column `{}` is missing from the data.frame", #col_name_str)
2286                    })?;
2287                    <Vec<#elem_ty> as ::miniextendr_api::from_r::TryFromSexp>::try_from_sexp(__col_sexp)
2288                        .map_err(|e| ::std::format!(
2289                            "column `{}` could not be converted to the expected type: {}",
2290                            #col_name_str, e
2291                        ))?
2292                };
2293                if #col_var.len() != __nrow {
2294                    return ::core::result::Result::Err(::std::format!(
2295                        "column `{}` has length {} but data.frame has {} rows",
2296                        #col_name_str, #col_var.len(), __nrow
2297                    ));
2298                }
2299            }
2300        };
2301
2302        for rf in &resolved {
2303            match rf {
2304                ResolvedField::Single(data) => {
2305                    let rust_name = &data.rust_name;
2306                    let col_var = format_ident!("__col_{}", rust_name);
2307                    let it_var = format_ident!("__it_{}", rust_name);
2308                    match &data.list_elem_ty {
2309                        // Un-annotated owned collection: opaque list-column (VECSXP). Read
2310                        // each row's element back via `Vec<elem>: TryFromSexp`, then
2311                        // `.into()` to the field container type (`Vec<elem>` identity /
2312                        // `Box<[elem]>`). A non-list column (e.g. an all-empty column
2313                        // materialised as logical-NA) reads back as `__nrow` empty
2314                        // collections. (#809)
2315                        ::core::option::Option::Some(elem_ty) => {
2316                            let field_ty = &data.ty;
2317                            let col_name_str = &data.col_name_str;
2318                            extracts.push(quote! {
2319                                let #col_var: Vec<#field_ty> = {
2320                                    let __col_sexp = __view.column_raw(#col_name_str).ok_or_else(|| {
2321                                        ::std::format!("column `{}` is missing from the data.frame", #col_name_str)
2322                                    })?;
2323                                    // VECSXP check via `SexpExt::is_list` (UFCS — avoids the
2324                                    // `List::is_list()` bug that calls `is_pair_list()` instead).
2325                                    if <::miniextendr_api::SEXP as ::miniextendr_api::SexpExt>::is_list(&__col_sexp) {
2326                                        let __list = unsafe {
2327                                            ::miniextendr_api::list::List::from_raw(__col_sexp)
2328                                        };
2329                                        let __len = __list.len();
2330                                        let mut __v: Vec<#field_ty> = ::std::vec::Vec::with_capacity(__len as usize);
2331                                        for __j in 0..__len {
2332                                            // in-bounds by construction (0..len)
2333                                            let __elt = __list.get(__j).unwrap();
2334                                            let __inner: Vec<#elem_ty> =
2335                                                <Vec<#elem_ty> as ::miniextendr_api::from_r::TryFromSexp>::try_from_sexp(__elt)
2336                                                    .map_err(|e| ::std::format!(
2337                                                        "column `{}` element {} could not be converted to the expected type: {}",
2338                                                        #col_name_str, __j, e
2339                                                    ))?;
2340                                            __v.push(::core::convert::Into::into(__inner));
2341                                        }
2342                                        __v
2343                                    } else {
2344                                        // Non-list column → every row is an empty collection.
2345                                        (0..__nrow)
2346                                            .map(|_| ::core::convert::Into::into(::std::vec::Vec::<#elem_ty>::new()))
2347                                            .collect()
2348                                    }
2349                                };
2350                                if #col_var.len() != __nrow {
2351                                    return ::core::result::Result::Err(::std::format!(
2352                                        "column `{}` has length {} but data.frame has {} rows",
2353                                        #col_name_str, #col_var.len(), __nrow
2354                                    ));
2355                                }
2356                            });
2357                            seq_decls.push(quote! { let mut #it_var = #col_var.into_iter(); });
2358                            seq_builds.push(quote! { #rust_name: #it_var.next().unwrap() });
2359                            par_builds.push(quote! { #rust_name: #col_var[__i].clone() });
2360                        }
2361                        // Scalar Single — and reader-capable map Single (#764):
2362                        // `pull_col`'s `Vec<#ty>: TryFromSexp` covers both
2363                        // (maps via the list-of-named-lists impl).
2364                        ::core::option::Option::None => {
2365                            extracts.push(pull_col(&col_var, &data.col_name_str, &data.ty));
2366                            seq_decls.push(quote! { let mut #it_var = #col_var.into_iter(); });
2367                            seq_builds.push(quote! { #rust_name: #it_var.next().unwrap() });
2368                            par_builds.push(quote! { #rust_name: #col_var[__i].clone() });
2369                        }
2370                    }
2371                }
2372                // `[T; N]` → columns `base_1..base_N`, each a plain `Vec<elem>`.
2373                // Regroup into the fixed array per row.
2374                ResolvedField::ExpandedFixed(data) => {
2375                    let rust_name = &data.rust_name;
2376                    let elem_ty = &data.elem_ty;
2377                    let mut it_nexts: Vec<TokenStream> = Vec::new();
2378                    let mut idx_clones: Vec<TokenStream> = Vec::new();
2379                    for k in 1..=data.len {
2380                        let col_var = format_ident!("__ef_{}_{}", rust_name, k);
2381                        let it_var = format_ident!("__efit_{}_{}", rust_name, k);
2382                        let col_name_str = format!("{}_{}", data.base_name, k);
2383                        extracts.push(pull_col(&col_var, &col_name_str, elem_ty));
2384                        seq_decls.push(quote! { let mut #it_var = #col_var.into_iter(); });
2385                        it_nexts.push(quote! { #it_var.next().unwrap() });
2386                        idx_clones.push(quote! { #col_var[__i].clone() });
2387                    }
2388                    seq_builds.push(quote! { #rust_name: [ #(#it_nexts),* ] });
2389                    par_builds.push(quote! { #rust_name: [ #(#idx_clones),* ] });
2390                }
2391                // `Vec<T>` + `width = N` → columns `base_1..base_N`, each
2392                // `Vec<Option<elem>>`. Flatten the N optionals per row back into a
2393                // `Vec<elem>` (trailing-NA padding from the write side drops out).
2394                ResolvedField::ExpandedVec(data) => {
2395                    let rust_name = &data.rust_name;
2396                    let elem_ty = &data.elem_ty;
2397                    let opt_ty: syn::Type = syn::parse_quote!(::core::option::Option<#elem_ty>);
2398                    let mut it_nexts: Vec<TokenStream> = Vec::new();
2399                    let mut idx_clones: Vec<TokenStream> = Vec::new();
2400                    for k in 1..=data.width {
2401                        let col_var = format_ident!("__ev_{}_{}", rust_name, k);
2402                        let it_var = format_ident!("__evit_{}_{}", rust_name, k);
2403                        let col_name_str = format!("{}_{}", data.base_name, k);
2404                        extracts.push(pull_col(&col_var, &col_name_str, &opt_ty));
2405                        seq_decls.push(quote! { let mut #it_var = #col_var.into_iter(); });
2406                        it_nexts.push(quote! { #it_var.next().unwrap() });
2407                        idx_clones.push(quote! { #col_var[__i].clone() });
2408                    }
2409                    // `.into()` converts the collected `Vec<elem>` to the field's
2410                    // own container type (`Vec<T>` identity or `Box<[T]>`).
2411                    seq_builds.push(quote! {
2412                        #rust_name: [ #(#it_nexts),* ]
2413                            .into_iter().flatten().collect::<Vec<#elem_ty>>().into()
2414                    });
2415                    par_builds.push(quote! {
2416                        #rust_name: [ #(#idx_clones),* ]
2417                            .into_iter().flatten().collect::<Vec<#elem_ty>>().into()
2418                    });
2419                }
2420                // `Vec<T>`/`Box<[T]>` + `expand` → a runtime-determined number of
2421                // columns `name_1..name_k`, each `Vec<Option<elem>>`. Discover them
2422                // by walking `name_<i>` until the first gap, then flatten per row.
2423                ResolvedField::AutoExpandVec(data) => {
2424                    let rust_name = &data.rust_name;
2425                    let elem_ty = &data.elem_ty;
2426                    let cols_var = format_ident!("__aev_{}", rust_name);
2427                    let col_name_str = &data.col_name_str;
2428                    extracts.push(quote! {
2429                        let #cols_var: Vec<Vec<::core::option::Option<#elem_ty>>> = {
2430                            let mut __cols: Vec<Vec<::core::option::Option<#elem_ty>>> =
2431                                ::std::vec::Vec::new();
2432                            let mut __k: usize = 1;
2433                            loop {
2434                                let __cn = ::std::format!("{}_{}", #col_name_str, __k);
2435                                match __view.column_raw(&__cn) {
2436                                    ::core::option::Option::Some(__s) => {
2437                                        let __c: Vec<::core::option::Option<#elem_ty>> =
2438                                            <Vec<::core::option::Option<#elem_ty>> as ::miniextendr_api::from_r::TryFromSexp>::try_from_sexp(__s)
2439                                                .map_err(|e| ::std::format!(
2440                                                    "column `{}` could not be converted to the expected type: {}",
2441                                                    __cn, e
2442                                                ))?;
2443                                        if __c.len() != __nrow {
2444                                            return ::core::result::Result::Err(::std::format!(
2445                                                "column `{}` has length {} but data.frame has {} rows",
2446                                                __cn, __c.len(), __nrow
2447                                            ));
2448                                        }
2449                                        __cols.push(__c);
2450                                        __k += 1;
2451                                    }
2452                                    ::core::option::Option::None => break,
2453                                }
2454                            }
2455                            __cols
2456                        };
2457                    });
2458                    // Both seq and par index by row (`__i`): the columns are a
2459                    // `Vec<Vec<…>>`, so there is nothing to drain field-wise.
2460                    let build = quote! {
2461                        #rust_name: #cols_var
2462                            .iter()
2463                            .filter_map(|__c| __c[__i].clone())
2464                            .collect::<Vec<#elem_ty>>()
2465                            .into()
2466                    };
2467                    seq_builds.push(build.clone());
2468                    par_builds.push(build);
2469                }
2470                // Nested `DataFrameRow` (#485): the inner type's columns were
2471                // written under a `<base>_` prefix. Select those parent columns,
2472                // strip the prefix into a fresh sub-frame, and recurse through the
2473                // inner type's `DataFrameRowConvert` reader. Routing through the
2474                // trait (rather than `Inner::try_from_dataframe`) keeps this
2475                // compiling even when the inner shape has no reader — it degrades
2476                // to a clear runtime error instead.
2477                ResolvedField::Struct(data) => {
2478                    let rust_name = &data.rust_name;
2479                    let inner_ty = &data.inner_ty;
2480                    let vec_var = format_ident!("__sf_{}", rust_name);
2481                    let it_var = format_ident!("__sfit_{}", rust_name);
2482                    let base = &data.col_name_str;
2483                    let prefix_lit = format!("{}_", data.col_name_str);
2484                    extracts.push(quote! {
2485                        let #vec_var: Vec<#inner_ty> = {
2486                            let __prefix: &str = #prefix_lit;
2487                            let __names = __view.names();
2488                            let __sel: Vec<&str> = __names
2489                                .iter()
2490                                .filter(|__n| __n.starts_with(__prefix))
2491                                .map(|__n| __n.as_str())
2492                                .collect();
2493                            if __sel.is_empty() {
2494                                return ::core::result::Result::Err(::std::format!(
2495                                    "struct column `{}`: no columns with prefix `{}` found in the data.frame",
2496                                    #base, __prefix
2497                                ));
2498                            }
2499                            // `select` returns an owned, GC-rooted `BuiltDataFrame`
2500                            // (#1247); `strip_prefix` (inherent forward) edits the
2501                            // same frame in place and carries the handle through, so
2502                            // the sub-frame stays rooted across the CHARSXP
2503                            // allocations and the recursive read below.
2504                            let __sub = __view.select(&__sel).strip_prefix(__prefix);
2505                            let __out = match <#inner_ty as ::miniextendr_api::dataframe::DataFrameRowConvert>::rows_from_dataframe(&__sub) {
2506                                ::core::option::Option::Some(::core::result::Result::Ok(__v)) => __v,
2507                                ::core::option::Option::Some(::core::result::Result::Err(__e)) => {
2508                                    return ::core::result::Result::Err(::std::format!(
2509                                        "struct column `{}`: {}", #base, __e
2510                                    ));
2511                                }
2512                                ::core::option::Option::None => {
2513                                    return ::core::result::Result::Err(::std::format!(
2514                                        "struct column `{}`: nested type has no data.frame reader", #base
2515                                    ));
2516                                }
2517                            };
2518                            __out
2519                        };
2520                        if #vec_var.len() != __nrow {
2521                            return ::core::result::Result::Err(::std::format!(
2522                                "struct column `{}` produced {} rows but data.frame has {} rows",
2523                                #base, #vec_var.len(), __nrow
2524                            ));
2525                        }
2526                    });
2527                    seq_decls.push(quote! { let mut #it_var = #vec_var.into_iter(); });
2528                    seq_builds.push(quote! { #rust_name: #it_var.next().unwrap() });
2529                    par_builds.push(quote! { #rust_name: #vec_var[__i].clone() });
2530                }
2531                // Non-String-keyed map (#919): read two parallel list-columns
2532                // `<base>_keys` / `<base>_values` (VECSXP of typed vectors), then
2533                // zip keys[i] with values[i] back into the map type per row.
2534                // NULL VECSXP elements → empty Vec (not None — struct path uses owned cols).
2535                // All SEXP access happens in `extracts` on the R thread; the
2536                // parallel iterator touches only owned Rust `Vec<Vec<K>>` / `Vec<Vec<V>>`.
2537                ResolvedField::Map(data) => {
2538                    let rust_name = &data.rust_name;
2539                    let key_ty = &data.key_ty;
2540                    let val_ty = &data.val_ty;
2541                    let map_ty = &data.map_ty;
2542                    let base = &data.base_name;
2543                    let keys_col_name = format!("{}_keys", base);
2544                    let vals_col_name = format!("{}_values", base);
2545                    let keys_var = format_ident!("__mapcol_{}_keys", base.replace('-', "_"));
2546                    let vals_var = format_ident!("__mapcol_{}_values", base.replace('-', "_"));
2547                    let keys_it_var = format_ident!("__mapcol_it_{}_keys", base.replace('-', "_"));
2548                    let vals_it_var =
2549                        format_ident!("__mapcol_it_{}_values", base.replace('-', "_"));
2550
2551                    // Extract helper: walk a VECSXP list-column; NULL/nil element → empty Vec.
2552                    let extract_col = |col_var: &syn::Ident,
2553                                       col_name: &str,
2554                                       elem_ty: &syn::Type| {
2555                        quote! {
2556                            let #col_var: Vec<Vec<#elem_ty>> = {
2557                                let __col_sexp = __view.column_raw(#col_name).ok_or_else(|| {
2558                                    ::std::format!("column `{}` is missing from the data.frame", #col_name)
2559                                })?;
2560                                if <::miniextendr_api::SEXP as ::miniextendr_api::SexpExt>::is_list(&__col_sexp) {
2561                                    let __list = unsafe {
2562                                        ::miniextendr_api::list::List::from_raw(__col_sexp)
2563                                    };
2564                                    let __len = __list.len();
2565                                    let mut __v: Vec<Vec<#elem_ty>> =
2566                                        ::std::vec::Vec::with_capacity(__len as usize);
2567                                    for __j in 0..__len {
2568                                        let __elt = __list.get(__j).unwrap();
2569                                        if __elt == ::miniextendr_api::SEXP::nil() {
2570                                            __v.push(::std::vec::Vec::new());
2571                                        } else {
2572                                            let __inner: Vec<#elem_ty> =
2573                                                <Vec<#elem_ty> as ::miniextendr_api::from_r::TryFromSexp>::try_from_sexp(__elt)
2574                                                    .map_err(|e| ::std::format!(
2575                                                        "column `{}` element {} could not be converted: {}",
2576                                                        #col_name, __j, e
2577                                                    ))?;
2578                                            __v.push(__inner);
2579                                        }
2580                                    }
2581                                    __v
2582                                } else {
2583                                    // Non-list column → all rows have empty maps.
2584                                    (0..__nrow).map(|_| ::std::vec::Vec::new()).collect()
2585                                }
2586                            };
2587                            if #col_var.len() != __nrow {
2588                                return ::core::result::Result::Err(::std::format!(
2589                                    "column `{}` has length {} but data.frame has {} rows",
2590                                    #col_name, #col_var.len(), __nrow
2591                                ));
2592                            }
2593                        }
2594                    };
2595                    extracts.push(extract_col(&keys_var, &keys_col_name, key_ty));
2596                    extracts.push(extract_col(&vals_var, &vals_col_name, val_ty));
2597
2598                    seq_decls.push(quote! { let mut #keys_it_var = #keys_var.into_iter(); });
2599                    seq_decls.push(quote! { let mut #vals_it_var = #vals_var.into_iter(); });
2600                    seq_builds.push(quote! {
2601                        #rust_name: {
2602                            let __k = #keys_it_var.next().unwrap();
2603                            let __v = #vals_it_var.next().unwrap();
2604                            __k.into_iter().zip(__v).collect::<#map_ty>()
2605                        }
2606                    });
2607                    par_builds.push(quote! {
2608                        #rust_name: #keys_var[__i].clone()
2609                            .into_iter()
2610                            .zip(#vals_var[__i].clone())
2611                            .collect::<#map_ty>()
2612                    });
2613                }
2614            }
2615        }
2616
2617        // Only `AutoExpandVec` builds reference `__i` in the sequential loop; bind
2618        // the counter only then to avoid an `unused_variables` warning.
2619        let seq_counter = if has_autoexpand_field {
2620            quote! { __i }
2621        } else {
2622            quote! { _ }
2623        };
2624
2625        // A struct-flatten field would need `Inner: Clone` for by-index parallel
2626        // assembly. Rather than impose that, the parallel reader delegates to the
2627        // sequential one (which moves) whenever a struct field is present.
2628        let par_body = if has_struct_field {
2629            quote! { Self::try_from_dataframe(sexp) }
2630        } else {
2631            quote! {
2632                use ::miniextendr_api::rayon_bridge::rayon::prelude::*;
2633                ::miniextendr_api::optionals::parallel::ensure_pool();
2634                let __view = ::miniextendr_api::dataframe::DataFrame::from_sexp(sexp)
2635                    .map_err(|e| e.to_string())?;
2636                let __nrow = __view.nrow();
2637                #(#extracts)*
2638                let __rows: Vec<Self> = (0..__nrow)
2639                    .into_par_iter()
2640                    .map(|__i| Self { #(#par_builds),* })
2641                    .collect();
2642                ::core::result::Result::Ok(__rows)
2643            }
2644        };
2645
2646        quote! {
2647            /// Read an R `data.frame` directly into a `Vec<Self>` (sequential).
2648            ///
2649            /// Each column is materialised out of R (NA-aware, ALTREP-materialising)
2650            /// and the rows are assembled by transposing column-major into row-major.
2651            /// Column-expansion fields are regrouped and nested-struct fields are
2652            /// read from their `<field>_`-prefixed sub-frame. Returns `Err` with a
2653            /// descriptive message if a column is missing, mis-typed, or ragged.
2654            ///
2655            /// This is the one-call `SEXP → Vec<Self>` reader; the same conversion is also
2656            /// reachable via the boundary-crossing [`FromDataFrame`] trait as
2657            /// `Vec::<Self>::from_dataframe(&df)`.
2658            ///
2659            /// [`FromDataFrame`]: ::miniextendr_api::dataframe::FromDataFrame
2660            pub fn try_from_dataframe(
2661                sexp: ::miniextendr_api::SEXP,
2662            ) -> ::core::result::Result<Vec<Self>, ::std::string::String> {
2663                let __view = ::miniextendr_api::dataframe::DataFrame::from_sexp(sexp)
2664                    .map_err(|e| e.to_string())?;
2665                let __nrow = __view.nrow();
2666                #(#extracts)*
2667                #(#seq_decls)*
2668                let mut __rows: Vec<Self> = Vec::with_capacity(__nrow);
2669                for #seq_counter in 0..__nrow {
2670                    __rows.push(Self { #(#seq_builds),* });
2671                }
2672                ::core::result::Result::Ok(__rows)
2673            }
2674
2675            // api-side rayon gate — see the #1117 note on `from_rows_par` above.
2676            ::miniextendr_api::__dataframe_row_when_rayon! {
2677                /// Read an R `data.frame` directly into a `Vec<Self>` (parallel).
2678                ///
2679                /// Mirrors [`Self::try_from_dataframe`] but assembles rows off the R
2680                /// thread via rayon. Safety: all SEXP access (column extraction, ALTREP
2681                /// materialisation, nested sub-frame reads) happens up front on the
2682                /// R/worker thread; the `into_par_iter()` region touches only
2683                /// pre-extracted owned data and makes no R API calls.
2684                pub fn try_from_dataframe_par(
2685                    sexp: ::miniextendr_api::SEXP,
2686                ) -> ::core::result::Result<Vec<Self>, ::std::string::String> {
2687                    #par_body
2688                }
2689            }
2690        }
2691    } else {
2692        TokenStream::new()
2693    };
2694    // endregion
2695
2696    // region: ColumnarFrame impl on the companion (from_rows / into_rows / from_rows_par)
2697    //
2698    // The pure-Rust columnar verbs live on the `ColumnarFrame` trait, not as inherent
2699    // methods: `from_rows` (= the `From<Vec<Row>>` impl), `into_rows` (= the companion's
2700    // `IntoIterator`, for row-iterable shapes) and the parallel `from_rows_par` override.
2701    // The `Inner: Clone` bound the struct-flatten parallel builder needs rides the impl
2702    // block (a trait-method override cannot carry its own stricter `where` clause); the
2703    // sequential `From` path is unaffected and stays `Clone`-free.
2704    let columnar_clone_bounds: Vec<TokenStream> = struct_cols
2705        .iter()
2706        .map(|sc| {
2707            let inner_ty = &sc.inner_ty;
2708            quote! { #inner_ty: ::core::clone::Clone }
2709        })
2710        .collect();
2711    let columnar_where = {
2712        let mut preds: Vec<TokenStream> = Vec::new();
2713        if let Some(wc) = where_clause {
2714            for p in wc.predicates.iter() {
2715                preds.push(quote! { #p });
2716            }
2717        }
2718        for cb in &columnar_clone_bounds {
2719            preds.push(cb.clone());
2720        }
2721        if preds.is_empty() {
2722            TokenStream::new()
2723        } else {
2724            quote! { where #(#preds),* }
2725        }
2726    };
2727    let df_methods = quote! {
2728        impl #impl_generics ::miniextendr_api::dataframe::ColumnarFrame<#row_name #ty_generics>
2729            for #df_name #ty_generics #columnar_where
2730        {
2731            fn from_rows(rows: ::std::vec::Vec<#row_name #ty_generics>) -> Self {
2732                rows.into()
2733            }
2734
2735            #from_rows_par_method
2736        }
2737    };
2738
2739    // The row type carries the one-call SEXP readers (when reader-capable) plus a hidden
2740    // `to_dataframe`. The documented rows↔companion verbs live on `ColumnarFrame` / `From` /
2741    // `IntoIterator`; `to_dataframe` is retained `#[doc(hidden)]` because the struct-flatten
2742    // write path builds a nested companion via `Inner::to_dataframe(..)` without naming the
2743    // inner companion type (its name may be customised with `#[dataframe(name = ..)]`).
2744    let row_methods = quote! {
2745        impl #impl_generics #row_name #ty_generics #where_clause {
2746            #[doc(hidden)]
2747            pub fn to_dataframe(rows: Vec<Self>) -> #df_name #ty_generics {
2748                rows.into()
2749            }
2750
2751            #reader_methods
2752        }
2753    };
2754
2755    // Compile-time assertion: row type must implement IntoList
2756    // Skip for unit/empty structs, tuple structs, structs with expansion,
2757    // and structs that store `List`-converted struct fields (#485 as_list).
2758    let trait_check = if !flat_cols.is_empty()
2759        && !is_tuple_struct
2760        && !is_unit_struct
2761        && !has_expansion
2762        && !has_into_list_struct
2763    {
2764        quote! {
2765            const _: () = {
2766                fn _assert_into_list #impl_generics () #where_clause {
2767                    fn _check<T: ::miniextendr_api::list::IntoList>() {}
2768                    _check::<#row_name #ty_generics>();
2769                }
2770            };
2771        }
2772    } else {
2773        TokenStream::new()
2774    };
2775
2776    // Marker trait impl: struct type implements DataFrameRow via IntoDataFrame chain.
2777    let marker_impl = quote! {
2778        impl #impl_generics ::miniextendr_api::markers::DataFrameRow
2779            for #row_name #ty_generics #where_clause {}
2780    };
2781
2782    // DataFramePayloadFields impl: exposes FIELDS (all resolved column names) and TAG
2783    // (the #[dataframe(tag = "...")] value, or "") for compile-time collision detection
2784    // by outer DataFrameRow enums that nest this type as a struct-flattened field.
2785    let payload_fields_impl = {
2786        // Collect all column names: flat_cols + struct_col base names.
2787        let mut field_names: Vec<String> =
2788            flat_cols.iter().map(|fc| fc.col_name_str.clone()).collect();
2789        for sc in &struct_cols {
2790            field_names.push(sc.col_name_str.clone());
2791        }
2792        let tag_str = attrs.tag.as_deref().unwrap_or("");
2793        quote! {
2794            impl #impl_generics ::miniextendr_api::markers::DataFramePayloadFields
2795                for #row_name #ty_generics #where_clause
2796            {
2797                const FIELDS: &'static [&'static str] = &[#(#field_names),*];
2798                const TAG: &'static str = #tag_str;
2799            }
2800        }
2801    };
2802
2803    // Compile-time assertions for struct-flattened fields (#485): each inner
2804    // type must implement `DataFrameRow`, otherwise users get a confusing
2805    // error pointing at the `to_dataframe` call site instead of the field.
2806    // Note: `Clone` is no longer asserted here — it is enforced via a where
2807    // clause on `from_rows_par` itself, giving a clearer error at the call site.
2808    let struct_assertions: Vec<TokenStream> = struct_cols
2809        .iter()
2810        .map(|sc| {
2811            let inner_ty = &sc.inner_ty;
2812            quote! {
2813                const _: () = {
2814                    fn _assert_inner_is_dataframe_row<T: ::miniextendr_api::markers::DataFrameRow>() {}
2815                    fn _do_assert #impl_generics () #where_clause {
2816                        _assert_inner_is_dataframe_row::<#inner_ty>();
2817                    }
2818                };
2819            }
2820        })
2821        .collect();
2822
2823    // region: DataFrameRowConvert on Row — orphan-rule bridge for the public verbs
2824    //
2825    // The derive cannot write `impl IntoDataFrame for Vec<Row>` directly: the orphan rule
2826    // forbids it (both `IntoDataFrame` and `Vec` are foreign in the user crate, and `Row` is
2827    // only *covered* inside `Vec<_>`). Instead it implements the local `DataFrameRowConvert`
2828    // marker on the local `Row`; miniextendr_api's blanket
2829    // `impl<T: DataFrameRowConvert> IntoDataFrame/FromDataFrame for Vec<T>` then gives users the
2830    // public verbs `rows.into_dataframe()?` / `Vec::<Row>::from_dataframe(&df)?`. The methods
2831    // delegate to the companion engine (`to_dataframe` → `ColumnSource::into_dataframe`), the
2832    // merged parallel builder (#777 `from_rows_par`) and reader (#765 `try_from_dataframe[_par]`),
2833    // converting the reader's bare `String` error into the unified `DataFrameError`.
2834
2835    // The parallel build uses the scatter-write builder when one was generated for this shape;
2836    // otherwise it falls back to the sequential transposition.
2837    let has_par_builder = !from_rows_par_method.is_empty();
2838    let rows_into_dataframe_par_body = if has_par_builder {
2839        quote! {
2840            ::miniextendr_api::convert::ColumnSource::into_dataframe(
2841                <#df_name #ty_generics as ::miniextendr_api::dataframe::ColumnarFrame<
2842                    #row_name #ty_generics,
2843                >>::from_rows_par(rows),
2844            )
2845        }
2846    } else {
2847        quote! { Self::rows_into_dataframe(rows) }
2848    };
2849
2850    // Readers are overridden for every reader-capable struct shape (scalar,
2851    // column-expansion, struct-flatten — see `struct_reader` / `try_from_dataframe`).
2852    // Other shapes use the trait default (`None`), surfaced by the blanket as a
2853    // clear `DataFrameError`.
2854    let reader_overrides = if struct_reader {
2855        quote! {
2856            fn rows_from_dataframe(
2857                df: &::miniextendr_api::dataframe::DataFrame,
2858            ) -> ::core::option::Option<::core::result::Result<
2859                Vec<Self>,
2860                ::miniextendr_api::dataframe::DataFrameError,
2861            >> {
2862                ::core::option::Option::Some(
2863                    <#row_name #ty_generics>::try_from_dataframe(df.as_sexp())
2864                        .map_err(::miniextendr_api::dataframe::DataFrameError::Conversion),
2865                )
2866            }
2867
2868            ::miniextendr_api::__dataframe_row_when_rayon! {
2869                fn rows_from_dataframe_par(
2870                    df: &::miniextendr_api::dataframe::DataFrame,
2871                ) -> ::core::option::Option<::core::result::Result<
2872                    Vec<Self>,
2873                    ::miniextendr_api::dataframe::DataFrameError,
2874                >> {
2875                    ::core::option::Option::Some(
2876                        <#row_name #ty_generics>::try_from_dataframe_par(df.as_sexp())
2877                            .map_err(::miniextendr_api::dataframe::DataFrameError::Conversion),
2878                    )
2879                }
2880            }
2881        }
2882    } else {
2883        TokenStream::new()
2884    };
2885
2886    let datarow_convert_impl = quote! {
2887        impl #impl_generics ::miniextendr_api::dataframe::DataFrameRowConvert
2888            for #row_name #ty_generics #where_clause
2889        {
2890            fn rows_into_dataframe(
2891                rows: Vec<Self>,
2892            ) -> ::core::result::Result<
2893                ::miniextendr_api::dataframe::DataFrame,
2894                ::miniextendr_api::dataframe::DataFrameError,
2895            > {
2896                ::miniextendr_api::convert::ColumnSource::into_dataframe(
2897                    <#df_name #ty_generics as ::core::convert::From<
2898                        ::std::vec::Vec<#row_name #ty_generics>,
2899                    >>::from(rows),
2900                )
2901            }
2902
2903            ::miniextendr_api::__dataframe_row_when_rayon! {
2904                fn rows_into_dataframe_par(
2905                    rows: Vec<Self>,
2906                ) -> ::core::result::Result<
2907                    ::miniextendr_api::dataframe::DataFrame,
2908                    ::miniextendr_api::dataframe::DataFrameError,
2909                > {
2910                    #rows_into_dataframe_par_body
2911                }
2912            }
2913
2914            #reader_overrides
2915        }
2916    };
2917    // endregion
2918
2919    Ok(quote! {
2920        #dataframe_struct
2921        #into_dataframe_impl
2922        #from_vec_impl
2923        #df_methods
2924        #into_iterator_impl
2925        #row_methods
2926        #trait_check
2927        #marker_impl
2928        #payload_fields_impl
2929        #datarow_convert_impl
2930        #(#struct_assertions)*
2931    })
2932    // endregion
2933}
2934// endregion
2935
2936// region: Enum align path
2937
2938/// A resolved column in the unified schema across all enum variants.
2939///
2940/// Tracks the column name, element type, which variants contribute to this column,
2941/// and whether the type was coerced to `String` due to cross-variant type conflicts
2942/// (when `#[dataframe(conflicts = "string")]` is active).
2943pub(super) struct ResolvedColumn {
2944    /// Column name in the companion struct / data frame.
2945    pub(super) col_name: syn::Ident,
2946    /// Element type (used as `Vec<Option<#ty>>`).
2947    /// When `string_coerced` is true, this is always `String`.
2948    pub(super) ty: syn::Type,
2949    /// Indices of variants that contain this field.
2950    pub(super) present_in: Vec<usize>,
2951    /// Whether this column was coerced to `String` due to type conflicts.
2952    /// When true, values are converted via `ToString::to_string()` at push time.
2953    pub(super) string_coerced: bool,
2954    /// Whether this column should be emitted as an R factor (via `as_factor` attribute).
2955    /// When `true`, `into_data_frame` wraps the `Vec<Option<T>>` in `FactorOptionVec<T>`
2956    /// before calling `IntoR::into_sexp`, using the `UnitEnumFactor` blanket impl.
2957    pub(super) is_factor: bool,
2958}
2959
2960/// Accumulates unique columns for an enum-to-dataframe unified schema.
2961///
2962/// As columns are registered from each variant's fields, the registry detects
2963/// duplicates and validates type consistency. When `coerce_to_string` is enabled,
2964/// type conflicts are resolved by coercing to `String`; otherwise they produce errors.
2965pub(super) struct ColumnRegistry<'a> {
2966    /// The ordered list of resolved columns in the schema.
2967    pub(super) columns: Vec<ResolvedColumn>,
2968    /// Maps column name strings to their index in `columns` for O(1) dedup lookup.
2969    pub(super) col_index: std::collections::HashMap<String, usize>,
2970    /// Whether to coerce type-conflicting columns to `String` instead of erroring.
2971    pub(super) coerce_to_string: bool,
2972    /// Cached `String` type AST node, used as the coercion target type.
2973    pub(super) string_ty: &'a syn::Type,
2974}
2975
2976impl<'a> ColumnRegistry<'a> {
2977    /// Create a new empty column registry.
2978    fn new(coerce_to_string: bool, string_ty: &'a syn::Type) -> Self {
2979        Self {
2980            columns: Vec::new(),
2981            col_index: std::collections::HashMap::new(),
2982            coerce_to_string,
2983            string_ty,
2984        }
2985    }
2986
2987    /// Register a single column in the schema, or merge with an existing column.
2988    ///
2989    /// If a column with the same name already exists, validates that the types match.
2990    /// On type conflict: coerces to `String` (if `coerce_to_string` is true) or
2991    /// returns `Err`. The `variant_idx` is appended to the column's `present_in` list.
2992    fn register(
2993        &mut self,
2994        col_name: &str,
2995        col_ty: &syn::Type,
2996        variant_idx: usize,
2997        variant_name: &syn::Ident,
2998        error_span: Span,
2999    ) -> syn::Result<()> {
3000        if let Some(&idx) = self.col_index.get(col_name) {
3001            let existing = &self.columns[idx];
3002            if !existing.string_coerced && existing.ty != *col_ty {
3003                if self.coerce_to_string {
3004                    self.columns[idx].ty = self.string_ty.clone();
3005                    self.columns[idx].string_coerced = true;
3006                } else {
3007                    return Err(syn::Error::new(
3008                        error_span,
3009                        format!(
3010                            "type conflict for field `{}`: variant `{}` has a different type \
3011                             than a previous variant; \
3012                             use `#[dataframe(conflicts = \"string\")]` to coerce all conflicting fields to String",
3013                            col_name, variant_name
3014                        ),
3015                    ));
3016                }
3017            }
3018            self.columns[idx].present_in.push(variant_idx);
3019        } else {
3020            let idx = self.columns.len();
3021            self.columns.push(ResolvedColumn {
3022                col_name: format_ident!("{}", col_name),
3023                ty: col_ty.clone(),
3024                present_in: vec![variant_idx],
3025                string_coerced: false,
3026                is_factor: false,
3027            });
3028            self.col_index.insert(col_name.to_string(), idx);
3029        }
3030        Ok(())
3031    }
3032
3033    /// Like `register`, but marks the column as a factor column (`is_factor = true`).
3034    ///
3035    /// Used for fields annotated with `#[dataframe(as_factor)]`. The companion struct
3036    /// field type stays `Vec<Option<T>>`, but `into_data_frame` wraps it in
3037    /// `FactorOptionVec<T>` (using the `UnitEnumFactor` blanket `IntoR` impl).
3038    pub(super) fn register_factor(
3039        &mut self,
3040        col_name: &str,
3041        col_ty: &syn::Type,
3042        variant_idx: usize,
3043        variant_name: &syn::Ident,
3044        error_span: Span,
3045    ) -> syn::Result<()> {
3046        self.register(col_name, col_ty, variant_idx, variant_name, error_span)?;
3047        if let Some(&idx) = self.col_index.get(col_name) {
3048            self.columns[idx].is_factor = true;
3049        }
3050        Ok(())
3051    }
3052}
3053
3054/// Describes the shape of an enum variant's fields.
3055#[derive(Clone, Copy, PartialEq, Eq)]
3056pub(super) enum VariantShape {
3057    /// `Variant { field: Type, ... }`
3058    Named,
3059    /// `Variant(Type, ...)`
3060    Tuple,
3061    /// `Variant` (no fields)
3062    Unit,
3063}
3064
3065/// A resolved enum field ready for codegen -- either a single column or expanded
3066/// from an array/Vec into multiple suffixed columns.
3067///
3068/// This is the enum-path counterpart of [`ResolvedField`] (used for structs).
3069/// Each variant carries both the binding name (for destructure patterns) and the
3070/// original Rust field name (for error reporting and named-variant patterns).
3071pub(super) enum EnumResolvedField {
3072    /// Single column contribution.
3073    Single(Box<EnumSingleFieldData>),
3074    /// Expanded from [T; N].
3075    ExpandedFixed(Box<EnumExpandedFixedData>),
3076    /// Expanded from `Vec<T>` with pinned width.
3077    ExpandedVec(Box<EnumExpandedVecData>),
3078    /// Auto-expanded `Vec<T>`/`Box<[T]>`: column count determined at runtime.
3079    AutoExpandVec(Box<EnumAutoExpandVecData>),
3080    /// `HashMap<K,V>` or `BTreeMap<K,V>` → two parallel list-columns: `<field>_keys`, `<field>_values`.
3081    Map(Box<EnumMapFieldData>),
3082    /// Struct field whose inner type implements `DataFrameRow` → flattened `<base>_<inner_col>` columns.
3083    Struct(Box<EnumStructFieldData>),
3084}
3085
3086impl EnumResolvedField {
3087    /// Binding name used in destructure patterns.
3088    pub(super) fn binding(&self) -> &syn::Ident {
3089        match self {
3090            Self::Single(data) => &data.binding,
3091            Self::ExpandedFixed(data) => &data.binding,
3092            Self::ExpandedVec(data) => &data.binding,
3093            Self::AutoExpandVec(data) => &data.binding,
3094            Self::Map(data) => &data.binding,
3095            Self::Struct(data) => &data.binding,
3096        }
3097    }
3098
3099    /// Original Rust field name.
3100    pub(super) fn rust_name(&self) -> &syn::Ident {
3101        match self {
3102            Self::Single(data) => &data.rust_name,
3103            Self::ExpandedFixed(data) => &data.rust_name,
3104            Self::ExpandedVec(data) => &data.rust_name,
3105            Self::AutoExpandVec(data) => &data.rust_name,
3106            Self::Map(data) => &data.rust_name,
3107            Self::Struct(data) => &data.rust_name,
3108        }
3109    }
3110}
3111
3112/// Data for [`EnumResolvedField::Single`].
3113pub(super) struct EnumSingleFieldData {
3114    /// Column name in the schema.
3115    pub(super) col_name: syn::Ident,
3116    /// Binding name used in destructure pattern.
3117    pub(super) binding: syn::Ident,
3118    /// Original Rust field name (for named variants).
3119    pub(super) rust_name: syn::Ident,
3120    /// Column type stored in the companion Vec.
3121    ///
3122    /// For most fields this is the raw Rust type. When `needs_into_list` is
3123    /// `true` (struct-typed fields with `#[dataframe(as_list)]`), this is
3124    /// `::miniextendr_api::list::List` — the actual inner type is erased at
3125    /// the storage level and each row value is converted via `.into_list()`.
3126    pub(super) ty: syn::Type,
3127    /// Whether the field's value must be converted via `.into_list()` before
3128    /// being pushed into the companion `Vec<Option<List>>`.
3129    ///
3130    /// Set to `true` only for struct-typed fields (`FieldTypeKind::Struct`)
3131    /// that carry `#[dataframe(as_list)]`. The companion struct field type is
3132    /// `Vec<Option<::miniextendr_api::list::List>>` in this case.
3133    pub(super) needs_into_list: bool,
3134    /// Whether the field should be emitted as an R factor column.
3135    ///
3136    /// Set to `true` for fields annotated with `#[dataframe(as_factor)]`.
3137    /// The companion struct field type is `Vec<Option<T>>` (unchanged), but
3138    /// `into_data_frame` wraps it in `FactorOptionVec<T>` to use the
3139    /// `UnitEnumFactor`-based blanket `IntoR` impl.
3140    pub(super) is_factor: bool,
3141}
3142
3143/// Data for [`EnumResolvedField::ExpandedFixed`].
3144pub(super) struct EnumExpandedFixedData {
3145    /// Base column name.
3146    pub(super) base_name: String,
3147    /// Binding name.
3148    pub(super) binding: syn::Ident,
3149    /// Original Rust field name.
3150    pub(super) rust_name: syn::Ident,
3151    /// Element type.
3152    pub(super) elem_ty: syn::Type,
3153    /// Array length.
3154    pub(super) len: usize,
3155}
3156
3157/// Data for [`EnumResolvedField::ExpandedVec`].
3158pub(super) struct EnumExpandedVecData {
3159    /// Base column name.
3160    pub(super) base_name: String,
3161    /// Binding name.
3162    pub(super) binding: syn::Ident,
3163    /// Original Rust field name.
3164    pub(super) rust_name: syn::Ident,
3165    /// Element type.
3166    pub(super) elem_ty: syn::Type,
3167    /// Pinned width.
3168    pub(super) width: usize,
3169}
3170
3171/// Data for [`EnumResolvedField::AutoExpandVec`].
3172pub(super) struct EnumAutoExpandVecData {
3173    /// Base column name.
3174    pub(super) base_name: String,
3175    /// Binding name.
3176    pub(super) binding: syn::Ident,
3177    /// Original Rust field name.
3178    pub(super) rust_name: syn::Ident,
3179    /// Element type.
3180    pub(super) elem_ty: syn::Type,
3181    /// Container type for companion struct (`Vec<T>` or `Box<[T]>`).
3182    pub(super) container_ty: syn::Type,
3183}
3184
3185/// Data for [`EnumResolvedField::Map`].
3186///
3187/// A `HashMap<K,V>` or `BTreeMap<K,V>` field expands to two parallel list-columns:
3188/// `<base_name>_keys: Vec<Option<Vec<K>>>` and `<base_name>_values: Vec<Option<Vec<V>>>`.
3189/// Absent-variant rows get `None` in both columns. Key order follows the map's own
3190/// iteration order: `BTreeMap` yields sorted keys, `HashMap` yields non-deterministic order.
3191/// Both are produced via `into_iter().unzip()` which guarantees pairwise alignment.
3192pub(super) struct EnumMapFieldData {
3193    /// Base column name (field name or `rename` override).
3194    pub(super) base_name: String,
3195    /// Binding name used in destructure pattern.
3196    pub(super) binding: syn::Ident,
3197    /// Original Rust field name.
3198    pub(super) rust_name: syn::Ident,
3199    /// Key type K.
3200    pub(super) key_ty: syn::Type,
3201    /// Value type V.
3202    pub(super) val_ty: syn::Type,
3203    /// Full original field type (`HashMap<K, V>` / `BTreeMap<K, V>`). The reader
3204    /// regroups the `_keys`/`_values` list-columns and `collect()`s back into this
3205    /// exact map type — both `HashMap` and `BTreeMap` implement `FromIterator<(K, V)>`.
3206    pub(super) map_ty: syn::Type,
3207}
3208
3209/// Data for [`EnumResolvedField::Struct`].
3210///
3211/// A field whose inner type implements `DataFrameRow` expands to `<base_name>_<inner_col>`
3212/// prefixed columns — one output column per column emitted by the inner type's companion
3213/// DataFrame. Absent-variant rows produce `None` in every prefixed column.
3214///
3215/// The companion struct holds `Vec<Option<Inner>>` (not `Vec<Inner>`). The `into_data_frame`
3216/// method collects present rows into a dense `Vec<Inner>` (tracking presence indices),
3217/// calls `Inner::to_dataframe(present_rows)`, extracts named column SEXPs, and scatters
3218/// them back to the full row count with `None`-fill for absent rows.
3219pub(super) struct EnumStructFieldData {
3220    /// Base name for column prefixing (field name or `rename` override).
3221    pub(super) base_name: String,
3222    /// Binding name used in destructure pattern.
3223    pub(super) binding: syn::Ident,
3224    /// Original Rust field name.
3225    pub(super) rust_name: syn::Ident,
3226    /// Inner struct type (used for the compile-time DataFrameRow assertion and codegen).
3227    pub(super) inner_ty: syn::Type,
3228}
3229
3230/// Parsed and resolved information about a single enum variant for DataFrame codegen.
3231///
3232/// Contains the variant's name, shape (named/tuple/unit), resolved fields (after
3233/// applying `#[dataframe(...)]` attributes and type classification), and any
3234/// skipped field names (needed for complete destructure patterns in named variants).
3235pub(super) struct VariantInfo {
3236    /// Variant name.
3237    pub(super) name: syn::Ident,
3238    /// Shape of this variant.
3239    pub(super) shape: VariantShape,
3240    /// Resolved fields (after applying field attrs + type classification).
3241    pub(super) fields: Vec<EnumResolvedField>,
3242    /// Original Rust field names (for named variants) — needed for skipped fields in destructure.
3243    pub(super) skipped_fields: Vec<syn::Ident>,
3244}
3245// endregion
3246
3247// region: Enum-specific expansion (in sub-module)
3248
3249mod enum_expansion;
3250use enum_expansion::derive_enum_dataframe;
3251// endregion
3252
3253// region: tests
3254#[cfg(test)]
3255mod tests {
3256    use super::*;
3257
3258    /// Stringify the derive output (whitespace-normalised) for substring assertions.
3259    fn expand(input: DeriveInput) -> String {
3260        derive_dataframe_row(input).unwrap().to_string()
3261    }
3262
3263    /// Scalar named struct: the baseline `try_from_dataframe_par` shape (#765).
3264    /// Both the sequential and parallel readers must be emitted, and the parallel
3265    /// one must drive a `into_par_iter()` row-assembly region.
3266    #[test]
3267    fn scalar_struct_gets_parallel_reader() {
3268        let code = expand(syn::parse_quote! {
3269            #[derive(DataFrameRow)]
3270            struct Measurement {
3271                time: f64,
3272                value: f64,
3273            }
3274        });
3275        assert!(code.contains("fn try_from_dataframe"));
3276        assert!(code.contains("fn try_from_dataframe_par"));
3277        assert!(code.contains("into_par_iter"));
3278    }
3279
3280    /// `[T; N]` fixed-array expansion field (#782/#808): the reader regroups the
3281    /// `pos_1`/`pos_2` columns back into the array inside the parallel loop, with
3282    /// zero SEXP access in `into_par_iter` (the invariant #764 protects).
3283    #[test]
3284    fn fixed_array_struct_gets_parallel_reader() {
3285        let code = expand(syn::parse_quote! {
3286            #[derive(DataFrameRow)]
3287            struct Point {
3288                #[dataframe(rename = "pos")]
3289                pos: [f64; 2],
3290            }
3291        });
3292        assert!(code.contains("fn try_from_dataframe_par"));
3293        assert!(code.contains("into_par_iter"));
3294        // Regrouped from the suffixed expansion columns.
3295        assert!(code.contains("pos_1"));
3296        assert!(code.contains("pos_2"));
3297    }
3298
3299    /// `Vec<T>` + `width = N` expansion field (#782/#808): the reader flattens the
3300    /// `scores_1`/`scores_2` Option columns per row back into the vec.
3301    #[test]
3302    fn pinned_vec_struct_gets_parallel_reader() {
3303        let code = expand(syn::parse_quote! {
3304            #[derive(DataFrameRow)]
3305            struct Scored {
3306                #[dataframe(width = 2)]
3307                scores: Vec<i32>,
3308            }
3309        });
3310        assert!(code.contains("fn try_from_dataframe_par"));
3311        assert!(code.contains("into_par_iter"));
3312        assert!(code.contains("scores_1"));
3313        assert!(code.contains("scores_2"));
3314    }
3315
3316    /// `Vec<T>` + `expand` auto-expansion field (#782/#808): the reader discovers
3317    /// `tags_<i>` columns at runtime and flattens per row. Still a true parallel
3318    /// reader (the column discovery happens on the R thread, up front).
3319    #[test]
3320    fn auto_expand_struct_gets_parallel_reader() {
3321        let code = expand(syn::parse_quote! {
3322            #[derive(DataFrameRow)]
3323            struct Tagged {
3324                #[dataframe(expand)]
3325                tags: Vec<i32>,
3326            }
3327        });
3328        assert!(code.contains("fn try_from_dataframe_par"));
3329        assert!(code.contains("into_par_iter"));
3330    }
3331
3332    /// Struct-flatten field (#485/#808): the struct still gets readers, but the
3333    /// parallel variant deliberately delegates to the sequential one to avoid
3334    /// imposing `Inner: Clone` for by-index parallel assembly (#764 design note).
3335    #[test]
3336    fn struct_flatten_par_delegates_to_sequential() {
3337        let code = expand(syn::parse_quote! {
3338            #[derive(DataFrameRow)]
3339            struct Outer {
3340                id: i32,
3341                inner: Inner,
3342            }
3343        });
3344        assert!(code.contains("fn try_from_dataframe"));
3345        assert!(code.contains("fn try_from_dataframe_par"));
3346        // The `_par` body delegates rather than running its own `into_par_iter`.
3347        assert!(code.contains("Self :: try_from_dataframe (sexp)"));
3348    }
3349
3350    /// Tagged enum companion (#807/#816): enums now get full readers too, including
3351    /// a parallel per-row tag-dispatch loop. Documents that the #764 "no reader at
3352    /// all today" framing for enums is now stale.
3353    #[test]
3354    fn tagged_enum_gets_parallel_reader() {
3355        let code = expand(syn::parse_quote! {
3356            #[derive(DataFrameRow)]
3357            #[dataframe(tag = "_type")]
3358            enum Event {
3359                Click { x: i32, y: i32 },
3360                Key { code: i32 },
3361            }
3362        });
3363        assert!(code.contains("fn try_from_dataframe"));
3364        assert!(code.contains("fn try_from_dataframe_par"));
3365        assert!(code.contains("into_par_iter"));
3366    }
3367
3368    /// Struct-path `HashMap<String, V>` map column (#764): the list-of-named-lists
3369    /// column reads back whole via `Vec<map>: TryFromSexp` on the R thread, so the
3370    /// struct gets both readers (it shares the scalar `pull_col` path — zero SEXP
3371    /// access inside `into_par_iter`). Flips the pre-#764 lock-in test from #920.
3372    #[test]
3373    fn struct_with_string_keyed_map_field_gets_parallel_reader() {
3374        let code = expand(syn::parse_quote! {
3375            #[derive(DataFrameRow)]
3376            struct Config {
3377                opts: ::std::collections::HashMap<String, i32>,
3378            }
3379        });
3380        assert!(code.contains("fn try_from_dataframe"));
3381        assert!(code.contains("fn try_from_dataframe_par"));
3382        assert!(code.contains("into_par_iter"));
3383    }
3384
3385    /// `BTreeMap<String, Option<scalar>>` is also reader-capable: the
3386    /// `Vec<map>: TryFromSexp` impl is generic over `V: TryFromSexp`, and
3387    /// `Option<scalar>` qualifies (NULL list elements → `None`).
3388    #[test]
3389    fn struct_with_btreemap_option_value_gets_reader() {
3390        let code = expand(syn::parse_quote! {
3391            #[derive(DataFrameRow)]
3392            struct Config {
3393                opts: ::std::collections::BTreeMap<String, Option<f64>>,
3394            }
3395        });
3396        assert!(code.contains("fn try_from_dataframe"));
3397        assert!(code.contains("fn try_from_dataframe_par"));
3398    }
3399
3400    /// Non-`String` bare-scalar map keys (#919): the struct gets parallel `_keys`/`_values`
3401    /// list-columns and both readers. The write side uses `Vec<Vec<K>>/Vec<Vec<V>>: IntoR`
3402    /// (VECSXP of typed vectors); the read side zips them back into the map type per row.
3403    #[test]
3404    fn struct_with_non_string_keyed_map_gets_parallel_reader() {
3405        let code = expand(syn::parse_quote! {
3406            #[derive(DataFrameRow)]
3407            struct Config {
3408                opts: ::std::collections::HashMap<i32, f64>,
3409            }
3410        });
3411        assert!(
3412            code.contains("fn try_from_dataframe"),
3413            "non-String bare-scalar map keys should produce a reader via _keys/_values columns"
3414        );
3415        assert!(
3416            code.contains("fn try_from_dataframe_par"),
3417            "non-String bare-scalar map keys should produce a parallel reader"
3418        );
3419        assert!(
3420            code.contains("opts_keys"),
3421            "non-String map field `opts` should expand to `opts_keys` column"
3422        );
3423        assert!(
3424            code.contains("opts_values"),
3425            "non-String map field `opts` should expand to `opts_values` column"
3426        );
3427    }
3428
3429    /// Same as above but using an unqualified path `std::collections::BTreeMap` (no
3430    /// leading `::`) — path form used in actual rpkg fixtures.
3431    #[test]
3432    fn struct_with_btreemap_int_key_unqualified_path() {
3433        let code = expand(syn::parse_quote! {
3434            #[derive(DataFrameRow)]
3435            struct Tally {
3436                id: i32,
3437                tally: std::collections::BTreeMap<i32, f64>,
3438            }
3439        });
3440        assert!(
3441            code.contains("tally_keys"),
3442            "unqualified std::collections::BTreeMap<i32, f64> must expand to tally_keys"
3443        );
3444        assert!(
3445            code.contains("fn try_from_dataframe"),
3446            "unqualified BTreeMap<i32, f64> should produce a reader"
3447        );
3448    }
3449
3450    /// Float-keyed maps (`f32`/`f64`) are rejected with a clear error.
3451    #[test]
3452    #[should_panic]
3453    fn struct_with_float_keyed_map_is_rejected() {
3454        let _code = expand(syn::parse_quote! {
3455            #[derive(DataFrameRow)]
3456            struct Config {
3457                opts: ::std::collections::HashMap<f64, i32>,
3458            }
3459        });
3460    }
3461
3462    /// Custom-hasher maps (`HashMap<K, V, S>`) are rejected from the reader path:
3463    /// the `Vec<HashMap<String, V>>: TryFromSexp` impl only covers the default
3464    /// hasher, so emitting a reader would not compile.
3465    #[test]
3466    fn struct_with_custom_hasher_map_has_no_reader() {
3467        let code = expand(syn::parse_quote! {
3468            #[derive(DataFrameRow)]
3469            struct Config {
3470                opts: ::std::collections::HashMap<String, i32, MyHasher>,
3471            }
3472        });
3473        assert!(
3474            !code.contains("fn try_from_dataframe"),
3475            "custom-hasher maps lack `Vec<map>: TryFromSexp`; the reader must stay gated out"
3476        );
3477    }
3478}
3479// endregion