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`
1010/// - `impl From<Vec<Measurement>> for MeasurementDataFrame`
1011/// - `impl IntoIterator for MeasurementDataFrame`
1012/// - Associated methods on `Measurement`:
1013///   - `to_dataframe(Vec<Self>) -> MeasurementDataFrame`
1014///   - `from_dataframe(MeasurementDataFrame) -> Vec<Self>`
1015///
1016/// For an enum:
1017/// - Companion struct with `Vec<Option<T>>` columns (field-name union)
1018/// - Optional tag column for variant discrimination
1019/// - `impl From<Vec<Enum>> for EnumDataFrame`
1020/// - `impl IntoDataFrame for EnumDataFrame`
1021/// - Associated `to_dataframe` method
1022///
1023/// # Attributes
1024///
1025/// - `#[dataframe(name = "CustomName")]` — Custom companion type name
1026/// - `#[dataframe(align)]` — Enum alignment mode (accepted but implicit)
1027/// - `#[dataframe(tag = "col")]` — Add variant discriminator column
1028///
1029/// Both struct and enum companion types get `from_rows()` (sequential) and
1030/// `from_rows_par()` (parallel, `#[cfg(feature = "rayon")]`) methods automatically.
1031pub fn derive_dataframe_row(input: DeriveInput) -> syn::Result<TokenStream> {
1032    let row_name = &input.ident;
1033
1034    // Allow lifetime parameters (needed for &[T] borrowed slice fields).
1035    // Allow type parameters on unit-only enums (all variants are unit) — the
1036    // companion struct has no field columns to type-parameterise, and the three
1037    // unit-enum impls (UnitEnumFactor, IntoR, IntoList) handle generics via the
1038    // split path in enum_expansion.rs.
1039    // Reject type and const parameters for everything else.
1040    let has_type_params = input.generics.type_params().next().is_some();
1041    let has_const_params = input.generics.const_params().next().is_some();
1042    if has_type_params || has_const_params {
1043        let is_unit_only_enum = matches!(&input.data, Data::Enum(e)
1044            if e.variants.iter().all(|v| matches!(v.fields, Fields::Unit)));
1045        if !is_unit_only_enum {
1046            return Err(syn::Error::new_spanned(
1047                &input.generics,
1048                "DataFrameRow does not support type or const generic parameters",
1049            ));
1050        }
1051    }
1052
1053    // Parse attributes
1054    let attrs = parse_dataframe_attrs(&input)?;
1055
1056    let df_name = attrs
1057        .name
1058        .clone()
1059        .unwrap_or_else(|| format_ident!("{}DataFrame", row_name));
1060
1061    let base = match &input.data {
1062        Data::Struct(data) => {
1063            // `align` is a no-op on structs (only semantically meaningful for enums)
1064            derive_struct_dataframe(row_name, &input, data, &df_name, &attrs)
1065        }
1066        Data::Enum(data) => {
1067            // align is implicit for enums — accept but don't require
1068            derive_enum_dataframe(row_name, &input, data, &df_name, &attrs)
1069        }
1070        Data::Union(_) => Err(syn::Error::new_spanned(
1071            row_name,
1072            "DataFrameRow does not support unions",
1073        )),
1074    }?;
1075
1076    // Generate IntoR for the companion DataFrame type so it can be returned
1077    // directly from #[miniextendr] functions. This ensures both the standalone
1078    // #[derive(DataFrameRow)] path and the #[miniextendr(dataframe)] path
1079    // produce identical output.
1080    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
1081    Ok(quote::quote! {
1082        #base
1083
1084        impl #impl_generics ::miniextendr_api::into_r::IntoR for #df_name #ty_generics #where_clause {
1085            type Error = std::convert::Infallible;
1086
1087            #[inline]
1088            fn try_into_sexp(self) -> Result<::miniextendr_api::SEXP, Self::Error> {
1089                Ok(self.into_sexp())
1090            }
1091
1092            #[inline]
1093            unsafe fn try_into_sexp_unchecked(self) -> Result<::miniextendr_api::SEXP, Self::Error> {
1094                self.try_into_sexp()
1095            }
1096
1097            #[inline]
1098            fn into_sexp(self) -> ::miniextendr_api::SEXP {
1099                ::miniextendr_api::convert::ColumnSource::into_column_list(self).into_sexp()
1100            }
1101
1102            #[inline]
1103            unsafe fn into_sexp_unchecked(self) -> ::miniextendr_api::SEXP {
1104                ::miniextendr_api::convert::ColumnSource::into_column_list(self).into_sexp()
1105            }
1106        }
1107    })
1108}
1109// endregion
1110
1111// region: Struct path (existing logic, extracted)
1112
1113/// Generate `DataFrameRow` expansion for struct types.
1114///
1115/// Produces:
1116/// - A companion struct `{Name}DataFrame` with `Vec<T>` columns
1117/// - `impl IntoDataFrame for {Name}DataFrame`
1118/// - `impl From<Vec<{Name}>> for {Name}DataFrame`
1119/// - `impl IntoIterator` (for named structs without expansion)
1120/// - Associated methods: `to_dataframe`, `from_dataframe`, `from_rows`, `from_rows_par`
1121/// - A compile-time `IntoList` assertion (for non-expanded named structs)
1122///
1123/// Handles fixed-array expansion (`[T; N]`), pinned-width Vec expansion
1124/// (`Vec<T>` + `width`), and auto-expand Vec (`Vec<T>` + `expand`).
1125fn derive_struct_dataframe(
1126    row_name: &syn::Ident,
1127    input: &DeriveInput,
1128    data: &syn::DataStruct,
1129    df_name: &syn::Ident,
1130    attrs: &DataFrameAttrs,
1131) -> syn::Result<TokenStream> {
1132    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
1133
1134    let is_tuple_struct = matches!(&data.fields, Fields::Unnamed(_));
1135    let is_unit_struct = matches!(&data.fields, Fields::Unit);
1136
1137    // Resolve fields through the new FieldAttrs + type classification system.
1138    let resolved: Vec<ResolvedField> = match &data.fields {
1139        Fields::Named(fields) => {
1140            let mut out = Vec::new();
1141            for (i, f) in fields.named.iter().enumerate() {
1142                if let Some(rf) = resolve_struct_field(f, i, false)? {
1143                    out.push(rf);
1144                }
1145            }
1146            out
1147        }
1148        Fields::Unnamed(fields) => {
1149            let mut out = Vec::new();
1150            for (i, f) in fields.unnamed.iter().enumerate() {
1151                if let Some(rf) = resolve_struct_field(f, i, true)? {
1152                    out.push(rf);
1153                }
1154            }
1155            out
1156        }
1157        Fields::Unit => vec![],
1158    };
1159
1160    // Check whether any field uses expansion — affects whether we can generate
1161    // IntoIterator (expanded fields change the companion struct shape).
1162    let has_expansion = resolved
1163        .iter()
1164        .any(|rf| !matches!(rf, ResolvedField::Single(..)));
1165    // Track which Rust fields were skipped (for destructure patterns).
1166    let skipped_fields: Vec<syn::Ident> = match &data.fields {
1167        Fields::Named(fields) => fields
1168            .named
1169            .iter()
1170            .filter_map(|f| {
1171                let fa = parse_field_attrs(f).ok()?;
1172                if fa.skip {
1173                    Some(f.ident.as_ref().unwrap().clone())
1174                } else {
1175                    None
1176                }
1177            })
1178            .collect(),
1179        _ => vec![],
1180    };
1181
1182    let has_tag = attrs.tag.is_some();
1183    let row_name_str = row_name.to_string();
1184
1185    // region: Build flat column lists from resolved fields
1186    // Each resolved field may produce 1..N columns.
1187    struct FlatCol {
1188        /// Companion struct field name.
1189        df_field: syn::Ident,
1190        /// Column name string in the R data frame.
1191        col_name_str: String,
1192        /// Type of the companion Vec<T>.
1193        vec_elem_ty: syn::Type,
1194        /// `#[dataframe(as_list)]` on a struct-typed field — companion stores
1195        /// `Vec<List>`. The `from_rows_par` pre-pass handles these sequentially
1196        /// instead of scatter-writing (List doesn't implement Default).
1197        needs_into_list: bool,
1198    }
1199
1200    let mut flat_cols: Vec<FlatCol> = Vec::new();
1201
1202    for rf in &resolved {
1203        match rf {
1204            ResolvedField::Single(data) => {
1205                flat_cols.push(FlatCol {
1206                    df_field: data.col_name.clone(),
1207                    col_name_str: data.col_name_str.clone(),
1208                    vec_elem_ty: data.ty.clone(),
1209                    needs_into_list: data.needs_into_list,
1210                });
1211            }
1212            ResolvedField::ExpandedFixed(data) => {
1213                for i in 1..=data.len {
1214                    let name = format!("{}_{}", data.base_name, i);
1215                    flat_cols.push(FlatCol {
1216                        df_field: format_ident!("{}_{}", data.base_name, i),
1217                        col_name_str: name,
1218                        vec_elem_ty: data.elem_ty.clone(),
1219                        needs_into_list: false,
1220                    });
1221                }
1222            }
1223            ResolvedField::ExpandedVec(data) => {
1224                for i in 1..=data.width {
1225                    let name = format!("{}_{}", data.base_name, i);
1226                    let elem_ty = &data.elem_ty;
1227                    let opt_ty: syn::Type = syn::parse_quote!(Option<#elem_ty>);
1228                    flat_cols.push(FlatCol {
1229                        df_field: format_ident!("{}_{}", data.base_name, i),
1230                        col_name_str: name,
1231                        vec_elem_ty: opt_ty,
1232                        needs_into_list: false,
1233                    });
1234                }
1235            }
1236            // AutoExpandVec / Struct do not produce FlatCols — handled separately.
1237            ResolvedField::AutoExpandVec(..) | ResolvedField::Struct(..) => {}
1238            // Map (#919): two parallel list-columns `<base>_keys` / `<base>_values`.
1239            // Each is a `Vec<Vec<K>>` / `Vec<Vec<V>>` (VECSXP of typed vectors).
1240            ResolvedField::Map(data) => {
1241                let keys_col_name = format!("{}_keys", data.base_name);
1242                let vals_col_name = format!("{}_values", data.base_name);
1243                let key_ty = &data.key_ty;
1244                let val_ty = &data.val_ty;
1245                let keys_vec_ty: syn::Type = syn::parse_quote!(Vec<#key_ty>);
1246                let vals_vec_ty: syn::Type = syn::parse_quote!(Vec<#val_ty>);
1247                flat_cols.push(FlatCol {
1248                    df_field: format_ident!("{}_keys", data.base_name),
1249                    col_name_str: keys_col_name,
1250                    vec_elem_ty: keys_vec_ty,
1251                    needs_into_list: false,
1252                });
1253                flat_cols.push(FlatCol {
1254                    df_field: format_ident!("{}_values", data.base_name),
1255                    col_name_str: vals_col_name,
1256                    vec_elem_ty: vals_vec_ty,
1257                    needs_into_list: false,
1258                });
1259            }
1260        }
1261    }
1262    // endregion
1263
1264    // region: Collect auto-expand fields
1265    struct AutoExpandCol {
1266        /// Companion struct field name.
1267        df_field: syn::Ident,
1268        /// Container type (Vec<T> or Box<[T]>).
1269        container_ty: syn::Type,
1270    }
1271
1272    let auto_expand_cols: Vec<AutoExpandCol> = resolved
1273        .iter()
1274        .filter_map(|rf| {
1275            if let ResolvedField::AutoExpandVec(data) = rf {
1276                Some(AutoExpandCol {
1277                    df_field: format_ident!("{}", data.col_name_str),
1278                    container_ty: data.container_ty.clone(),
1279                })
1280            } else {
1281                None
1282            }
1283        })
1284        .collect();
1285    let has_auto_expand = !auto_expand_cols.is_empty();
1286    // endregion
1287
1288    // region: Collect struct (DataFrameRow-flattened) fields (#485)
1289    //
1290    // Only the codegen-time bits are mirrored here — `rust_name` / `tuple_index`
1291    // are read directly off `ResolvedField::Struct` at the per-row pushes site.
1292    struct StructCol {
1293        df_field: syn::Ident,
1294        col_name_str: String,
1295        inner_ty: syn::Type,
1296    }
1297
1298    let struct_cols: Vec<StructCol> = resolved
1299        .iter()
1300        .filter_map(|rf| {
1301            if let ResolvedField::Struct(data) = rf {
1302                Some(StructCol {
1303                    df_field: data.col_name.clone(),
1304                    col_name_str: data.col_name_str.clone(),
1305                    inner_ty: data.inner_ty.clone(),
1306                })
1307            } else {
1308                None
1309            }
1310        })
1311        .collect();
1312    let has_struct = !struct_cols.is_empty();
1313
1314    // Any `#[dataframe(as_list)]` on a struct-typed field stores `List` in the
1315    // companion (#485 opt-out). We can't round-trip List back to the inner
1316    // struct without a `FromList`-like trait, and `List` doesn't impl
1317    // `Default`, so several codegen branches need to suppress themselves:
1318    // IntoIterator generation, the `IntoList` compile-time assertion, and
1319    // `from_rows_par`.
1320    let has_into_list_struct = resolved
1321        .iter()
1322        .any(|rf| matches!(rf, ResolvedField::Single(d) if d.needs_into_list));
1323    // endregion
1324
1325    // region: Companion struct
1326    let tag_field_decl = if has_tag {
1327        quote! { pub _tag: Vec<String>, }
1328    } else {
1329        TokenStream::new()
1330    };
1331
1332    let mut df_fields_tokens: Vec<TokenStream> = flat_cols
1333        .iter()
1334        .map(|fc| {
1335            let name = &fc.df_field;
1336            let ty = &fc.vec_elem_ty;
1337            quote! { pub #name: Vec<#ty> }
1338        })
1339        .collect();
1340    for ac in &auto_expand_cols {
1341        let name = &ac.df_field;
1342        let cty = &ac.container_ty;
1343        df_fields_tokens.push(quote! { pub #name: Vec<#cty> });
1344    }
1345    for sc in &struct_cols {
1346        let name = &sc.df_field;
1347        let ity = &sc.inner_ty;
1348        df_fields_tokens.push(quote! { pub #name: Vec<#ity> });
1349    }
1350
1351    let len_field_decl = if flat_cols.is_empty()
1352        && auto_expand_cols.is_empty()
1353        && struct_cols.is_empty()
1354        && !has_tag
1355    {
1356        quote! { pub _len: usize, }
1357    } else {
1358        TokenStream::new()
1359    };
1360
1361    let dataframe_struct = quote! {
1362        #[derive(Debug, Clone)]
1363        pub struct #df_name #impl_generics #where_clause {
1364            #tag_field_decl
1365            #len_field_decl
1366            #(#df_fields_tokens),*
1367        }
1368    };
1369    // endregion
1370
1371    // region: IntoDataFrame
1372    let length_ref = if has_tag {
1373        quote! { self._tag.len() }
1374    } else if !flat_cols.is_empty() {
1375        let first = &flat_cols[0].df_field;
1376        quote! { self.#first.len() }
1377    } else if !auto_expand_cols.is_empty() {
1378        let first = &auto_expand_cols[0].df_field;
1379        quote! { self.#first.len() }
1380    } else if !struct_cols.is_empty() {
1381        let first = &struct_cols[0].df_field;
1382        quote! { self.#first.len() }
1383    } else {
1384        quote! { self._len }
1385    };
1386
1387    // Each pair protects its SEXP via `__scope.protect_raw` so previously-built
1388    // column SEXPs survive subsequent column allocations. Pre-fix the raw
1389    // `vec![(name, into_sexp(...)), ...]` left every SEXP unrooted across the
1390    // next column's allocations — UAF under gctorture
1391    // (reviews/2026-05-07-gctorture-audit.md).
1392    let tag_pair = if let Some(ref tag_name) = attrs.tag {
1393        quote! { (#tag_name, __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self._tag))), }
1394    } else {
1395        TokenStream::new()
1396    };
1397
1398    let df_pairs: Vec<TokenStream> = flat_cols
1399        .iter()
1400        .map(|fc| {
1401            let name = &fc.df_field;
1402            let name_str = &fc.col_name_str;
1403            quote! { (#name_str, __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#name))) }
1404        })
1405        .collect();
1406
1407    let mut length_checks: Vec<TokenStream> = flat_cols
1408        .iter()
1409        .map(|fc| {
1410            let name = &fc.df_field;
1411            let name_str = &fc.col_name_str;
1412            quote! {
1413                assert!(
1414                    self.#name.len() == _n_rows,
1415                    "column length mismatch in {}: column `{}` has length {} but expected {}",
1416                    stringify!(#df_name),
1417                    #name_str,
1418                    self.#name.len(),
1419                    _n_rows,
1420                );
1421            }
1422        })
1423        .collect();
1424    for sc in &struct_cols {
1425        let name = &sc.df_field;
1426        let name_str = &sc.col_name_str;
1427        length_checks.push(quote! {
1428            assert!(
1429                self.#name.len() == _n_rows,
1430                "column length mismatch in {}: struct column `{}` has length {} but expected {}",
1431                stringify!(#df_name),
1432                #name_str,
1433                self.#name.len(),
1434                _n_rows,
1435            );
1436        });
1437    }
1438
1439    let into_dataframe_impl = if has_auto_expand || has_struct {
1440        // Dynamic pair building: iterate resolved fields in order,
1441        // emitting static pairs for flat columns and runtime-expanded
1442        // pairs for auto-expand fields.
1443        let tag_push_pair = if let Some(ref tag_name) = attrs.tag {
1444            quote! {
1445                __df_pairs.push((#tag_name.to_string(), __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self._tag))));
1446            }
1447        } else {
1448            TokenStream::new()
1449        };
1450
1451        let pair_pushes: Vec<TokenStream> = resolved
1452            .iter()
1453            .map(|rf| match rf {
1454                ResolvedField::Single(data) => {
1455                    let col_name = &data.col_name;
1456                    let col_name_str = &data.col_name_str;
1457                    quote! {
1458                        __df_pairs.push((
1459                            #col_name_str.to_string(),
1460                            __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#col_name)),
1461                        ));
1462                    }
1463                }
1464                ResolvedField::ExpandedFixed(data) => {
1465                    let pushes: Vec<TokenStream> = (1..=data.len)
1466                        .map(|i| {
1467                            let name = format!("{}_{}", data.base_name, i);
1468                            let ident = format_ident!("{}_{}", data.base_name, i);
1469                            quote! {
1470                                __df_pairs.push((
1471                                    #name.to_string(),
1472                                    __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#ident)),
1473                                ));
1474                            }
1475                        })
1476                        .collect();
1477                    quote! { #(#pushes)* }
1478                }
1479                ResolvedField::ExpandedVec(data) => {
1480                    let pushes: Vec<TokenStream> = (1..=data.width)
1481                        .map(|i| {
1482                            let name = format!("{}_{}", data.base_name, i);
1483                            let ident = format_ident!("{}_{}", data.base_name, i);
1484                            quote! {
1485                                __df_pairs.push((
1486                                    #name.to_string(),
1487                                    __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#ident)),
1488                                ));
1489                            }
1490                        })
1491                        .collect();
1492                    quote! { #(#pushes)* }
1493                }
1494                ResolvedField::AutoExpandVec(data) => {
1495                    let col_name = &data.col_name;
1496                    let col_name_str = &data.col_name_str;
1497                    let elem_ty = &data.elem_ty;
1498                    quote! {
1499                        {
1500                            let __auto = self.#col_name;
1501                            let __max = __auto.iter().map(|v| v.len()).max().unwrap_or(0);
1502                            let mut __cols: Vec<Vec<Option<#elem_ty>>> = (0..__max)
1503                                .map(|_| Vec::with_capacity(_n_rows))
1504                                .collect();
1505                            for __row_vec in &__auto {
1506                                for (__i, __col) in __cols.iter_mut().enumerate() {
1507                                    __col.push(__row_vec.get(__i).cloned());
1508                                }
1509                            }
1510                            for (__i, __col) in __cols.into_iter().enumerate() {
1511                                __df_pairs.push((
1512                                    format!("{}_{}", #col_name_str, __i + 1),
1513                                    __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(__col)),
1514                                ));
1515                            }
1516                        }
1517                    }
1518                }
1519                ResolvedField::Struct(data) => {
1520                    // Issue #485: convert `Vec<Inner>` via Inner::to_dataframe,
1521                    // extract its named columns, and push under `<base>_` prefix.
1522                    let col_name = &data.col_name;
1523                    let base_name_str = &data.col_name_str;
1524                    let inner_ty = &data.inner_ty;
1525                    quote! {
1526                        {
1527                            let __inner_df = <#inner_ty>::to_dataframe(self.#col_name);
1528                            let __inner_cols = ::miniextendr_api::convert::ColumnSource::into_named_columns(__inner_df);
1529                            for (__inner_col_name, __inner_col_sexp) in __inner_cols {
1530                                // Protect the source column SEXP across subsequent allocations.
1531                                let __src = __scope.protect_raw(__inner_col_sexp);
1532                                __df_pairs.push((
1533                                    format!("{}_{}", #base_name_str, __inner_col_name),
1534                                    __src,
1535                                ));
1536                            }
1537                        }
1538                    }
1539                }
1540                // Map (#919): push two list-columns `<base>_keys` / `<base>_values`.
1541                ResolvedField::Map(data) => {
1542                    let keys_ident = format_ident!("{}_keys", data.base_name);
1543                    let vals_ident = format_ident!("{}_values", data.base_name);
1544                    let keys_name = format!("{}_keys", data.base_name);
1545                    let vals_name = format!("{}_values", data.base_name);
1546                    quote! {
1547                        __df_pairs.push((
1548                            #keys_name.to_string(),
1549                            __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#keys_ident)),
1550                        ));
1551                        __df_pairs.push((
1552                            #vals_name.to_string(),
1553                            __scope.protect_raw(::miniextendr_api::IntoR::into_sexp(self.#vals_ident)),
1554                        ));
1555                    }
1556                }
1557            })
1558            .collect();
1559
1560        quote! {
1561            impl #impl_generics ::miniextendr_api::convert::ColumnSource for #df_name #ty_generics #where_clause {
1562                fn into_column_list(self) -> ::miniextendr_api::List {
1563                    let _n_rows = #length_ref;
1564                    #(#length_checks)*
1565                    // SAFETY: into_column_list only runs on the R main thread.
1566                    // ProtectScope keeps each column SEXP rooted across the
1567                    // next column's allocations; from_raw_pairs writes them
1568                    // into the parent VECSXP before we drop the scope.
1569                    unsafe {
1570                        let __scope = ::miniextendr_api::gc_protect::ProtectScope::new();
1571                        let mut __df_pairs: Vec<(
1572                            String,
1573                            ::miniextendr_api::SEXP,
1574                        )> = Vec::new();
1575                        #tag_push_pair
1576                        #(#pair_pushes)*
1577                        ::miniextendr_api::list::List::from_raw_pairs(__df_pairs)
1578                            .set_class_str(&["data.frame"])
1579                            .set_row_names_int(_n_rows)
1580                    }
1581                }
1582            }
1583        }
1584    } else {
1585        quote! {
1586            impl #impl_generics ::miniextendr_api::convert::ColumnSource for #df_name #ty_generics #where_clause {
1587                fn into_column_list(self) -> ::miniextendr_api::List {
1588                    let _n_rows = #length_ref;
1589                    #(#length_checks)*
1590                    // SAFETY: see auto-expand branch.
1591                    unsafe {
1592                        let __scope = ::miniextendr_api::gc_protect::ProtectScope::new();
1593                        ::miniextendr_api::list::List::from_raw_pairs(vec![
1594                            #tag_pair
1595                            #(#df_pairs),*
1596                        ])
1597                        .set_class_str(&["data.frame"])
1598                        .set_row_names_int(_n_rows)
1599                    }
1600                }
1601            }
1602        }
1603    };
1604    // endregion
1605
1606    // region: From<Vec<RowType>>
1607    let mut col_vec_inits: Vec<TokenStream> = flat_cols
1608        .iter()
1609        .map(|fc| {
1610            let name = &fc.df_field;
1611            let ty = &fc.vec_elem_ty;
1612            quote! { let mut #name: Vec<#ty> = Vec::with_capacity(len); }
1613        })
1614        .collect();
1615    for ac in &auto_expand_cols {
1616        let name = &ac.df_field;
1617        let cty = &ac.container_ty;
1618        col_vec_inits.push(quote! { let mut #name: Vec<#cty> = Vec::with_capacity(len); });
1619    }
1620    for sc in &struct_cols {
1621        let name = &sc.df_field;
1622        let ity = &sc.inner_ty;
1623        col_vec_inits.push(quote! { let mut #name: Vec<#ity> = Vec::with_capacity(len); });
1624    }
1625
1626    let tag_init = if has_tag {
1627        quote! { let mut _tag: Vec<String> = Vec::with_capacity(len); }
1628    } else {
1629        TokenStream::new()
1630    };
1631
1632    let tag_push = if has_tag {
1633        quote! { _tag.push(#row_name_str.to_string()); }
1634    } else {
1635        TokenStream::new()
1636    };
1637
1638    // Generate push statements for each resolved field
1639    let col_pushes: Vec<TokenStream> = resolved
1640        .iter()
1641        .map(|rf| match rf {
1642            ResolvedField::Single(data) => {
1643                let access = if let Some(idx) = &data.tuple_index {
1644                    quote! { row.#idx }
1645                } else {
1646                    let rust_name = &data.rust_name;
1647                    quote! { row.#rust_name }
1648                };
1649                let col_name = &data.col_name;
1650                if data.needs_into_list {
1651                    quote! { #col_name.push(::miniextendr_api::list::IntoList::into_list(#access)); }
1652                } else {
1653                    quote! { #col_name.push(#access); }
1654                }
1655            }
1656            ResolvedField::ExpandedFixed(data) => {
1657                let access = if let Some(idx) = &data.tuple_index {
1658                    quote! { row.#idx }
1659                } else {
1660                    let rust_name = &data.rust_name;
1661                    quote! { row.#rust_name }
1662                };
1663                let bind = format_ident!("__arr_{}", data.rust_name);
1664                let pushes: Vec<TokenStream> = (0..data.len)
1665                    .map(|i| {
1666                        let col_ident = format_ident!("{}_{}", data.base_name, i + 1);
1667                        let idx = syn::Index::from(i);
1668                        quote! { #col_ident.push(#bind[#idx]); }
1669                    })
1670                    .collect();
1671                quote! {
1672                    let #bind = #access;
1673                    #(#pushes)*
1674                }
1675            }
1676            ResolvedField::ExpandedVec(data) => {
1677                let access = if let Some(idx) = &data.tuple_index {
1678                    quote! { row.#idx }
1679                } else {
1680                    let rust_name = &data.rust_name;
1681                    quote! { row.#rust_name }
1682                };
1683                let bind = format_ident!("__vec_{}", data.rust_name);
1684                let pushes: Vec<TokenStream> = (0..data.width)
1685                    .map(|i| {
1686                        let col_ident = format_ident!("{}_{}", data.base_name, i + 1);
1687                        quote! { #col_ident.push(#bind.get(#i).cloned()); }
1688                    })
1689                    .collect();
1690                quote! {
1691                    let #bind = #access;
1692                    #(#pushes)*
1693                }
1694            }
1695            ResolvedField::AutoExpandVec(data) => {
1696                let access = if let Some(idx) = &data.tuple_index {
1697                    quote! { row.#idx }
1698                } else {
1699                    let rust_name = &data.rust_name;
1700                    quote! { row.#rust_name }
1701                };
1702                let col_name = &data.col_name;
1703                quote! { #col_name.push(#access); }
1704            }
1705            ResolvedField::Struct(data) => {
1706                let access = if let Some(idx) = &data.tuple_index {
1707                    quote! { row.#idx }
1708                } else {
1709                    let rust_name = &data.rust_name;
1710                    quote! { row.#rust_name }
1711                };
1712                let col_name = &data.col_name;
1713                quote! { #col_name.push(#access); }
1714            }
1715            // Map (#919): unzip into parallel keys/values vecs.
1716            ResolvedField::Map(data) => {
1717                let access = if let Some(idx) = &data.tuple_index {
1718                    quote! { row.#idx }
1719                } else {
1720                    let rust_name = &data.rust_name;
1721                    quote! { row.#rust_name }
1722                };
1723                let keys_col = format_ident!("{}_keys", data.base_name);
1724                let vals_col = format_ident!("{}_values", data.base_name);
1725                quote! {
1726                    let (__mx_keys, __mx_vals) = #access
1727                        .into_iter()
1728                        .unzip::<_, _, Vec<_>, Vec<_>>();
1729                    #keys_col.push(__mx_keys);
1730                    #vals_col.push(__mx_vals);
1731                }
1732            }
1733        })
1734        .collect();
1735
1736    let tag_struct_field = if has_tag {
1737        quote! { _tag, }
1738    } else {
1739        TokenStream::new()
1740    };
1741
1742    let len_struct_field = if flat_cols.is_empty()
1743        && auto_expand_cols.is_empty()
1744        && struct_cols.is_empty()
1745        && !has_tag
1746    {
1747        quote! { _len: len, }
1748    } else {
1749        TokenStream::new()
1750    };
1751
1752    let mut col_struct_fields: Vec<TokenStream> = flat_cols
1753        .iter()
1754        .map(|fc| {
1755            let name = &fc.df_field;
1756            quote! { #name }
1757        })
1758        .collect();
1759    for ac in &auto_expand_cols {
1760        let name = &ac.df_field;
1761        col_struct_fields.push(quote! { #name });
1762    }
1763    for sc in &struct_cols {
1764        let name = &sc.df_field;
1765        col_struct_fields.push(quote! { #name });
1766    }
1767
1768    // For skipped fields in destructure: bind to `_`
1769    let skip_bindings: Vec<TokenStream> = skipped_fields
1770        .iter()
1771        .map(|name| quote! { let _ = row.#name; })
1772        .collect();
1773
1774    let from_vec_impl = quote! {
1775        impl #impl_generics From<Vec<#row_name #ty_generics>> for #df_name #ty_generics #where_clause {
1776            fn from(rows: Vec<#row_name #ty_generics>) -> Self {
1777                let len = rows.len();
1778                #tag_init
1779                #(#col_vec_inits)*
1780                for row in rows {
1781                    #tag_push
1782                    #(#skip_bindings)*
1783                    #(#col_pushes)*
1784                }
1785                #df_name {
1786                    #tag_struct_field
1787                    #len_struct_field
1788                    #(#col_struct_fields),*
1789                }
1790            }
1791        }
1792    };
1793    // endregion
1794
1795    // region: Generate from_rows_par (parallel scatter-write via ColumnWriter)
1796    //
1797    // Two field kinds require special handling instead of parallel scatter-write:
1798    //   - struct (DataFrameRow-flattened) fields (#485): companion stores
1799    //     `Vec<Inner>` where `Inner` doesn't implement `Default`. These are
1800    //     collected sequentially in a pre-pass (`for __prerow in &rows { ... }`)
1801    //     before `into_par_iter()` consumes the vector. Requires `Inner: Clone`.
1802    //   - `as_list`-on-struct fields (#485 opt-out) store `Vec<List>` in the
1803    //     companion, and `List` doesn't implement `Default`. Same pre-pass approach.
1804    // Both are handled via sequential pre-pass + skip in the parallel loop.
1805    // The pre-pass is O(n) extra per struct/list-struct field but does not change
1806    // asymptotic complexity — just adds a constant factor for these column types.
1807    let from_rows_par_method = if !flat_cols.is_empty()
1808        || !auto_expand_cols.is_empty()
1809        || has_tag
1810        || has_struct
1811        || has_into_list_struct
1812    {
1813        // Column declarations:
1814        //   - scalar / expand cols: vec![default; len]  (scatter-write in parallel)
1815        //   - struct / as_list-struct cols: Vec::with_capacity(len) filled in pre-pass
1816        let mut par_col_decls = Vec::new();
1817        if has_tag {
1818            par_col_decls.push(quote! {
1819                let mut _tag: Vec<String> = vec![String::new(); len];
1820            });
1821        }
1822        // Sequential pre-pass: struct fields (Inner: Clone required).
1823        // Iterate resolved to pick up tuple_index for tuple-struct outers.
1824        for rf in &resolved {
1825            if let ResolvedField::Struct(data) = rf {
1826                let col_name = &data.col_name;
1827                let ity = &data.inner_ty;
1828                let access = if let Some(idx) = &data.tuple_index {
1829                    quote! { __prerow.#idx }
1830                } else {
1831                    let rust_name = &data.rust_name;
1832                    quote! { __prerow.#rust_name }
1833                };
1834                par_col_decls.push(quote! {
1835                    let mut #col_name: Vec<#ity> = Vec::with_capacity(len);
1836                    for __prerow in &rows {
1837                        #col_name.push(::core::clone::Clone::clone(&#access));
1838                    }
1839                });
1840            }
1841        }
1842        // Sequential pre-pass: as_list-on-struct fields (List: !Default).
1843        for rf in &resolved {
1844            if let ResolvedField::Single(data) = rf
1845                && data.needs_into_list
1846            {
1847                let col_name = &data.col_name;
1848                let rust_name = &data.rust_name;
1849                let access = if let Some(idx) = &data.tuple_index {
1850                    quote! { __prerow.#idx }
1851                } else {
1852                    quote! { __prerow.#rust_name }
1853                };
1854                par_col_decls.push(quote! {
1855                    let mut #col_name: Vec<::miniextendr_api::list::List> = Vec::with_capacity(len);
1856                    for __prerow in &rows {
1857                        #col_name.push(::miniextendr_api::list::IntoList::into_list(
1858                            ::core::clone::Clone::clone(&#access)
1859                        ));
1860                    }
1861                });
1862            }
1863        }
1864        // Parallel scalar/expand columns.
1865        for fc in &flat_cols {
1866            if fc.needs_into_list {
1867                // Handled in the sequential pre-pass above.
1868                continue;
1869            }
1870            let name = &fc.df_field;
1871            let ty = &fc.vec_elem_ty;
1872            par_col_decls.push(quote! {
1873                let mut #name: Vec<#ty> = vec![<#ty as ::core::default::Default>::default(); len];
1874            });
1875        }
1876        for ac in &auto_expand_cols {
1877            let name = &ac.df_field;
1878            let cty = &ac.container_ty;
1879            par_col_decls.push(quote! {
1880                let mut #name: Vec<#cty> = vec![<#cty as ::core::default::Default>::default(); len];
1881            });
1882        }
1883
1884        // Writer declarations (only for scatter-write cols — struct/as_list pre-pass
1885        // cols are already populated and need no ColumnWriter).
1886        let mut writer_decls = Vec::new();
1887        if has_tag {
1888            writer_decls.push(quote! {
1889                let __w_tag = unsafe {
1890                    ::miniextendr_api::rayon_bridge::ColumnWriter::new(&mut _tag)
1891                };
1892            });
1893        }
1894        for fc in &flat_cols {
1895            if fc.needs_into_list {
1896                continue;
1897            }
1898            let name = &fc.df_field;
1899            let w_name = format_ident!("__w_{}", name);
1900            writer_decls.push(quote! {
1901                let #w_name = unsafe {
1902                    ::miniextendr_api::rayon_bridge::ColumnWriter::new(&mut #name)
1903                };
1904            });
1905        }
1906        for ac in &auto_expand_cols {
1907            let name = &ac.df_field;
1908            let w_name = format_ident!("__w_{}", name);
1909            writer_decls.push(quote! {
1910                let #w_name = unsafe {
1911                    ::miniextendr_api::rayon_bridge::ColumnWriter::new(&mut #name)
1912                };
1913            });
1914        }
1915
1916        // Write calls per resolved field (parallel scatter-write only).
1917        let tag_write = if has_tag {
1918            quote! { __w_tag.write(__i, #row_name_str.to_string()); }
1919        } else {
1920            TokenStream::new()
1921        };
1922
1923        let par_write_calls: Vec<TokenStream> = resolved
1924            .iter()
1925            .map(|rf| match rf {
1926                ResolvedField::Single(data) => {
1927                    if data.needs_into_list {
1928                        // Handled in the sequential pre-pass; skip in par loop.
1929                        return TokenStream::new();
1930                    }
1931                    let access = if let Some(idx) = &data.tuple_index {
1932                        quote! { __row.#idx }
1933                    } else {
1934                        let rust_name = &data.rust_name;
1935                        quote! { __row.#rust_name }
1936                    };
1937                    let w_name = format_ident!("__w_{}", data.col_name);
1938                    quote! { #w_name.write(__i, #access); }
1939                }
1940                ResolvedField::ExpandedFixed(data) => {
1941                    let access = if let Some(idx) = &data.tuple_index {
1942                        quote! { __row.#idx }
1943                    } else {
1944                        let rust_name = &data.rust_name;
1945                        quote! { __row.#rust_name }
1946                    };
1947                    let bind = format_ident!("__arr_{}", data.rust_name);
1948                    let writes: Vec<TokenStream> = (0..data.len)
1949                        .map(|i| {
1950                            let w_name = format_ident!("__w_{}_{}", data.base_name, i + 1);
1951                            let idx = syn::Index::from(i);
1952                            quote! { #w_name.write(__i, #bind[#idx]); }
1953                        })
1954                        .collect();
1955                    quote! {
1956                        let #bind = #access;
1957                        #(#writes)*
1958                    }
1959                }
1960                ResolvedField::ExpandedVec(data) => {
1961                    let access = if let Some(idx) = &data.tuple_index {
1962                        quote! { __row.#idx }
1963                    } else {
1964                        let rust_name = &data.rust_name;
1965                        quote! { __row.#rust_name }
1966                    };
1967                    let bind = format_ident!("__vec_{}", data.rust_name);
1968                    let writes: Vec<TokenStream> = (0..data.width)
1969                        .map(|i| {
1970                            let w_name = format_ident!("__w_{}_{}", data.base_name, i + 1);
1971                            quote! { #w_name.write(__i, #bind.get(#i).cloned()); }
1972                        })
1973                        .collect();
1974                    quote! {
1975                        let #bind = #access;
1976                        #(#writes)*
1977                    }
1978                }
1979                ResolvedField::AutoExpandVec(data) => {
1980                    let access = if let Some(idx) = &data.tuple_index {
1981                        quote! { __row.#idx }
1982                    } else {
1983                        let rust_name = &data.rust_name;
1984                        quote! { __row.#rust_name }
1985                    };
1986                    let w_name = format_ident!("__w_{}", data.col_name);
1987                    quote! { #w_name.write(__i, #access); }
1988                }
1989                // Struct fields (#485) are collected in the sequential pre-pass
1990                // above; nothing to write in the parallel loop.
1991                ResolvedField::Struct(_) => TokenStream::new(),
1992                // Map (#919): unzip into parallel keys/values vecs via scatter-write.
1993                ResolvedField::Map(data) => {
1994                    let access = if let Some(idx) = &data.tuple_index {
1995                        quote! { __row.#idx }
1996                    } else {
1997                        let rust_name = &data.rust_name;
1998                        quote! { __row.#rust_name }
1999                    };
2000                    let w_keys = format_ident!("__w_{}_keys", data.base_name);
2001                    let w_vals = format_ident!("__w_{}_values", data.base_name);
2002                    quote! {
2003                        let (__mx_keys, __mx_vals) = #access
2004                            .into_iter()
2005                            .unzip::<_, _, Vec<_>, Vec<_>>();
2006                        #w_keys.write(__i, __mx_keys);
2007                        #w_vals.write(__i, __mx_vals);
2008                    }
2009                }
2010            })
2011            .collect();
2012
2013        let par_skip_bindings: Vec<TokenStream> = skipped_fields
2014            .iter()
2015            .map(|name| quote! { let _ = __row.#name; })
2016            .collect();
2017
2018        // Return struct fields
2019        let par_tag_field = if has_tag {
2020            quote! { _tag, }
2021        } else {
2022            TokenStream::new()
2023        };
2024        // Emit `_len: len` only when the companion struct has a `_len` field —
2025        // that is, when there are truly no column vecs at all (no scalars, no
2026        // as_list-on-struct fields, no struct-flattened fields, no tag).
2027        // `as_list`-on-struct fields live in `flat_cols` with `needs_into_list=true`;
2028        // they provide their own length reference and do NOT require `_len`.
2029        // The `flat_cols.iter().all(…)` guard is redundant with `flat_cols.is_empty()`
2030        // but makes the intent explicit: _len is emitted only when every dimension
2031        // that tracks length is absent.
2032        let par_len_field = if flat_cols.is_empty()
2033            && flat_cols.iter().all(|fc| !fc.needs_into_list)
2034            && auto_expand_cols.is_empty()
2035            && !has_tag
2036            && struct_cols.is_empty()
2037        {
2038            quote! { _len: len, }
2039        } else {
2040            TokenStream::new()
2041        };
2042        let mut par_struct_fields: Vec<TokenStream> = flat_cols
2043            .iter()
2044            .map(|fc| {
2045                let name = &fc.df_field;
2046                quote! { #name }
2047            })
2048            .collect();
2049        for ac in &auto_expand_cols {
2050            let name = &ac.df_field;
2051            par_struct_fields.push(quote! { #name });
2052        }
2053        for sc in &struct_cols {
2054            let name = &sc.df_field;
2055            par_struct_fields.push(quote! { #name });
2056        }
2057
2058        // Only emit an into_par_iter call when there are scalar/expand/tag cols
2059        // to scatter-write; struct/as_list-only structs skip the parallel loop.
2060        let has_par_cols = !flat_cols.iter().all(|fc| fc.needs_into_list)
2061            || !auto_expand_cols.is_empty()
2062            || has_tag;
2063        let par_loop = if has_par_cols {
2064            quote! {
2065                {
2066                    ::miniextendr_api::optionals::parallel::ensure_pool();
2067                    #(#writer_decls)*
2068                    rows.into_par_iter().enumerate().for_each(|(__i, __row)| unsafe {
2069                        #tag_write
2070                        #(#par_write_calls)*
2071                        #(#par_skip_bindings)*
2072                    });
2073                }
2074            }
2075        } else {
2076            // All columns were collected in the pre-pass; rows already consumed.
2077            quote! { let _rows = rows; }
2078        };
2079
2080        // Build `where Inner: Clone` bounds for all struct-flattened fields.
2081        // Emitting these on the method (rather than in a `const _` assertion block)
2082        // points the compiler error at the `from_rows_par` call site, not at the
2083        // expanded macro internals — cleaner diagnostic for downstream users.
2084        let par_inner_clone_bounds: Vec<TokenStream> = struct_cols
2085            .iter()
2086            .map(|sc| {
2087                let inner_ty = &sc.inner_ty;
2088                quote! { #inner_ty: ::core::clone::Clone, }
2089            })
2090            .collect();
2091        let par_where_clause = if par_inner_clone_bounds.is_empty() {
2092            TokenStream::new()
2093        } else {
2094            quote! { where #(#par_inner_clone_bounds)* }
2095        };
2096
2097        quote! {
2098            /// Parallel row→column transposition using rayon scatter-write.
2099            ///
2100            /// Scalar/expand columns are scatter-written in parallel via rayon.
2101            /// Struct-flattened and `as_list`-on-struct fields are collected
2102            /// sequentially in a pre-pass before the parallel loop (these field
2103            /// types don't implement `Default`, so scatter-write is not possible).
2104            /// Inner struct types must implement `Clone` (enforced by the where
2105            /// clause; the error will point at the `from_rows_par` call site).
2106            ///
2107            /// Always uses rayon — no threshold check. Use `from_rows` for the
2108            /// sequential path.
2109            #[cfg(feature = "rayon")]
2110            #[allow(clippy::uninit_vec)]
2111            pub fn from_rows_par(rows: Vec<#row_name #ty_generics>) -> Self
2112            #par_where_clause
2113            {
2114                use ::miniextendr_api::rayon_bridge::rayon::prelude::*;
2115                let len = rows.len();
2116                #(#par_col_decls)*
2117                #par_loop
2118                #df_name { #par_tag_field #par_len_field #(#par_struct_fields),* }
2119            }
2120        }
2121    } else {
2122        TokenStream::new()
2123    };
2124
2125    // ── IntoIterator (only for named non-empty structs without expansion) ─
2126    let can_iterate = !flat_cols.is_empty()
2127        && !is_tuple_struct
2128        && !is_unit_struct
2129        && !has_expansion
2130        && !has_into_list_struct;
2131    let into_iterator_impl = if can_iterate {
2132        let iterator_name = format_ident!("{}Iterator", df_name);
2133
2134        let iter_field_decls: Vec<_> = flat_cols
2135            .iter()
2136            .map(|fc| {
2137                let name = &fc.df_field;
2138                let ty = &fc.vec_elem_ty;
2139                quote! { #name: std::vec::IntoIter<#ty> }
2140            })
2141            .collect();
2142
2143        let destruct_pattern: Vec<_> = flat_cols
2144            .iter()
2145            .map(|fc| {
2146                let name = &fc.df_field;
2147                quote! { #name }
2148            })
2149            .collect();
2150
2151        let mut iter_init_tokens = TokenStream::new();
2152        for (i, fc) in flat_cols.iter().enumerate() {
2153            let name = &fc.df_field;
2154            let ty = &fc.vec_elem_ty;
2155            if i > 0 {
2156                iter_init_tokens.extend(quote! { , });
2157            }
2158            iter_init_tokens.extend(quote! { #name: <Vec<#ty>>::into_iter(#name) });
2159        }
2160
2161        // For next(): reconstruct original field names (col_name == rust_name for Single)
2162        let mut next_struct_tokens = TokenStream::new();
2163        for (i, rf) in resolved.iter().enumerate() {
2164            if let ResolvedField::Single(data) = rf {
2165                if i > 0 {
2166                    next_struct_tokens.extend(quote! { , });
2167                }
2168                let rust_name = &data.rust_name;
2169                let col_name = &data.col_name;
2170                next_struct_tokens.extend(quote! { #rust_name: self.#col_name.next()? });
2171            }
2172        }
2173
2174        let ignore_tag = if has_tag {
2175            quote! { _tag: _, }
2176        } else {
2177            TokenStream::new()
2178        };
2179
2180        // Skipped fields are reconstructed via `Default::default()` each time
2181        // `next()` yields a row. This is why any field type annotated with
2182        // `#[dataframe(skip)]` must implement `Default`.
2183        let skip_defaults: Vec<TokenStream> = skipped_fields
2184            .iter()
2185            .map(|name| quote! { , #name: Default::default() })
2186            .collect();
2187
2188        quote! {
2189            pub struct #iterator_name #impl_generics #where_clause {
2190                #(#iter_field_decls),*
2191            }
2192
2193            impl #impl_generics IntoIterator for #df_name #ty_generics #where_clause {
2194                type Item = #row_name #ty_generics;
2195                type IntoIter = #iterator_name #ty_generics;
2196
2197                fn into_iter(self) -> Self::IntoIter {
2198                    let #df_name { #ignore_tag #(#destruct_pattern),* } = self;
2199                    #iterator_name {
2200                        #iter_init_tokens
2201                    }
2202                }
2203            }
2204
2205            impl #impl_generics Iterator for #iterator_name #ty_generics #where_clause {
2206                type Item = #row_name #ty_generics;
2207
2208                fn next(&mut self) -> Option<Self::Item> {
2209                    Some(#row_name {
2210                        #next_struct_tokens
2211                        #(#skip_defaults)*
2212                    })
2213                }
2214            }
2215        }
2216    } else {
2217        TokenStream::new()
2218    };
2219    // endregion
2220
2221    // region: Associated methods
2222    let from_dataframe_method = if can_iterate {
2223        quote! {
2224            /// Convert a DataFrame back into a vector of rows.
2225            ///
2226            /// This transposes column-oriented data back into row-oriented format.
2227            pub fn from_dataframe(df: #df_name #ty_generics) -> Vec<Self> {
2228                df.into_iter().collect()
2229            }
2230        }
2231    } else {
2232        TokenStream::new()
2233    };
2234    // endregion
2235
2236    // region: from-R readers (try_from_dataframe / try_from_dataframe_par, #738/#782)
2237    //
2238    // Read an R `data.frame` SEXP directly into `Vec<Self>` without first
2239    // materialising a companion `#df_name`. A reader is generated for every
2240    // *named* struct whose fields are all reader-capable (see
2241    // `field_reader_capable`): scalar `Single` fields, column-expansion fields
2242    // (`[T; N]` / `Vec<T>` + `width`/`expand`), and struct-flatten fields (nested
2243    // `DataFrameRow`). Each shape's reader is the exact inverse of its write rule
2244    // — regroup the suffixed expansion columns, un-prefix and recurse into the
2245    // nested reader. Struct-path `HashMap<String, V>`/`BTreeMap<String, V>` map
2246    // columns read whole via `Vec<map>: TryFromSexp` (#764) — they share the
2247    // scalar `pull_col` path, gated by `SingleFieldData::map_reader` (non-String
2248    // keys / non-scalar values / custom hashers stay reader-incapable). `skip` /
2249    // `as_list` / set columns / tuple / unit shapes are not reader-capable and
2250    // fall through to the trait default (a clear runtime `DataFrameError`).
2251    // Tagged-enum and enum-`Map` readers already landed (#807/#816) — see
2252    // `enum_expansion::build_enum_reader`.
2253    //
2254    // The parallel variant (`#[cfg(feature = "rayon")]`) splits cleanly along the
2255    // R-thread boundary: all SEXP access (column extraction, ALTREP
2256    // materialisation, sub-frame selection + recursive nested reads) happens up
2257    // front on the R/worker thread; only then does `(0..nrow).into_par_iter()`
2258    // assemble each `Self` from the pre-extracted, owned column data by index —
2259    // pure Rust, zero R API calls. Shapes containing a struct-flatten field would
2260    // need `Inner: Clone` for by-index parallel assembly, so their `_par` reader
2261    // delegates to the sequential one (which moves) rather than imposing `Clone`.
2262    let struct_reader = !is_tuple_struct
2263        && !is_unit_struct
2264        && !has_tag
2265        && skipped_fields.is_empty()
2266        && !resolved.is_empty()
2267        && resolved.iter().all(field_reader_capable);
2268
2269    let has_autoexpand_field = resolved
2270        .iter()
2271        .any(|rf| matches!(rf, ResolvedField::AutoExpandVec(_)));
2272    let has_struct_field = resolved
2273        .iter()
2274        .any(|rf| matches!(rf, ResolvedField::Struct(_)));
2275
2276    let reader_methods = if struct_reader {
2277        // Per-field fragments:
2278        //   extracts   — prelude statements (R-thread): pull/convert columns, run
2279        //                length checks, materialise nested sub-frames.
2280        //   seq_decls  — draining-iterator decls for the sequential row loop.
2281        //   seq_builds — `field: expr` in the sequential `Self { … }` literal
2282        //                (drains moved columns; indexes only `AutoExpandVec`).
2283        //   par_builds — `field: expr` in the parallel `Self { … }` literal
2284        //                (by-index `.clone()`; scalars only — never reached when
2285        //                a struct-flatten field is present).
2286        let mut extracts: Vec<TokenStream> = Vec::new();
2287        let mut seq_decls: Vec<TokenStream> = Vec::new();
2288        let mut seq_builds: Vec<TokenStream> = Vec::new();
2289        let mut par_builds: Vec<TokenStream> = Vec::new();
2290
2291        // Pull a named column out of R as an owned `Vec<#elem>` via `TryFromSexp`
2292        // (NA-aware, ALTREP-materialising), then length-check it against `__nrow`.
2293        // Bypasses `DataFrame::column` because its `Error = SexpError` bound is
2294        // tighter than the scalar element types' `TryFromSexp::Error`.
2295        let pull_col = |col_var: &syn::Ident, col_name_str: &str, elem_ty: &syn::Type| {
2296            quote! {
2297                let #col_var: Vec<#elem_ty> = {
2298                    let __col_sexp = __view.column_raw(#col_name_str).ok_or_else(|| {
2299                        ::std::format!("column `{}` is missing from the data.frame", #col_name_str)
2300                    })?;
2301                    <Vec<#elem_ty> as ::miniextendr_api::from_r::TryFromSexp>::try_from_sexp(__col_sexp)
2302                        .map_err(|e| ::std::format!(
2303                            "column `{}` could not be converted to the expected type: {}",
2304                            #col_name_str, e
2305                        ))?
2306                };
2307                if #col_var.len() != __nrow {
2308                    return ::core::result::Result::Err(::std::format!(
2309                        "column `{}` has length {} but data.frame has {} rows",
2310                        #col_name_str, #col_var.len(), __nrow
2311                    ));
2312                }
2313            }
2314        };
2315
2316        for rf in &resolved {
2317            match rf {
2318                ResolvedField::Single(data) => {
2319                    let rust_name = &data.rust_name;
2320                    let col_var = format_ident!("__col_{}", rust_name);
2321                    let it_var = format_ident!("__it_{}", rust_name);
2322                    match &data.list_elem_ty {
2323                        // Un-annotated owned collection: opaque list-column (VECSXP). Read
2324                        // each row's element back via `Vec<elem>: TryFromSexp`, then
2325                        // `.into()` to the field container type (`Vec<elem>` identity /
2326                        // `Box<[elem]>`). A non-list column (e.g. an all-empty column
2327                        // materialised as logical-NA) reads back as `__nrow` empty
2328                        // collections. (#809)
2329                        ::core::option::Option::Some(elem_ty) => {
2330                            let field_ty = &data.ty;
2331                            let col_name_str = &data.col_name_str;
2332                            extracts.push(quote! {
2333                                let #col_var: Vec<#field_ty> = {
2334                                    let __col_sexp = __view.column_raw(#col_name_str).ok_or_else(|| {
2335                                        ::std::format!("column `{}` is missing from the data.frame", #col_name_str)
2336                                    })?;
2337                                    // VECSXP check via `SexpExt::is_list` (UFCS — avoids the
2338                                    // `List::is_list()` bug that calls `is_pair_list()` instead).
2339                                    if <::miniextendr_api::SEXP as ::miniextendr_api::SexpExt>::is_list(&__col_sexp) {
2340                                        let __list = unsafe {
2341                                            ::miniextendr_api::list::List::from_raw(__col_sexp)
2342                                        };
2343                                        let __len = __list.len();
2344                                        let mut __v: Vec<#field_ty> = ::std::vec::Vec::with_capacity(__len as usize);
2345                                        for __j in 0..__len {
2346                                            // in-bounds by construction (0..len)
2347                                            let __elt = __list.get(__j).unwrap();
2348                                            let __inner: Vec<#elem_ty> =
2349                                                <Vec<#elem_ty> as ::miniextendr_api::from_r::TryFromSexp>::try_from_sexp(__elt)
2350                                                    .map_err(|e| ::std::format!(
2351                                                        "column `{}` element {} could not be converted to the expected type: {}",
2352                                                        #col_name_str, __j, e
2353                                                    ))?;
2354                                            __v.push(::core::convert::Into::into(__inner));
2355                                        }
2356                                        __v
2357                                    } else {
2358                                        // Non-list column → every row is an empty collection.
2359                                        (0..__nrow)
2360                                            .map(|_| ::core::convert::Into::into(::std::vec::Vec::<#elem_ty>::new()))
2361                                            .collect()
2362                                    }
2363                                };
2364                                if #col_var.len() != __nrow {
2365                                    return ::core::result::Result::Err(::std::format!(
2366                                        "column `{}` has length {} but data.frame has {} rows",
2367                                        #col_name_str, #col_var.len(), __nrow
2368                                    ));
2369                                }
2370                            });
2371                            seq_decls.push(quote! { let mut #it_var = #col_var.into_iter(); });
2372                            seq_builds.push(quote! { #rust_name: #it_var.next().unwrap() });
2373                            par_builds.push(quote! { #rust_name: #col_var[__i].clone() });
2374                        }
2375                        // Scalar Single — and reader-capable map Single (#764):
2376                        // `pull_col`'s `Vec<#ty>: TryFromSexp` covers both
2377                        // (maps via the list-of-named-lists impl).
2378                        ::core::option::Option::None => {
2379                            extracts.push(pull_col(&col_var, &data.col_name_str, &data.ty));
2380                            seq_decls.push(quote! { let mut #it_var = #col_var.into_iter(); });
2381                            seq_builds.push(quote! { #rust_name: #it_var.next().unwrap() });
2382                            par_builds.push(quote! { #rust_name: #col_var[__i].clone() });
2383                        }
2384                    }
2385                }
2386                // `[T; N]` → columns `base_1..base_N`, each a plain `Vec<elem>`.
2387                // Regroup into the fixed array per row.
2388                ResolvedField::ExpandedFixed(data) => {
2389                    let rust_name = &data.rust_name;
2390                    let elem_ty = &data.elem_ty;
2391                    let mut it_nexts: Vec<TokenStream> = Vec::new();
2392                    let mut idx_clones: Vec<TokenStream> = Vec::new();
2393                    for k in 1..=data.len {
2394                        let col_var = format_ident!("__ef_{}_{}", rust_name, k);
2395                        let it_var = format_ident!("__efit_{}_{}", rust_name, k);
2396                        let col_name_str = format!("{}_{}", data.base_name, k);
2397                        extracts.push(pull_col(&col_var, &col_name_str, elem_ty));
2398                        seq_decls.push(quote! { let mut #it_var = #col_var.into_iter(); });
2399                        it_nexts.push(quote! { #it_var.next().unwrap() });
2400                        idx_clones.push(quote! { #col_var[__i].clone() });
2401                    }
2402                    seq_builds.push(quote! { #rust_name: [ #(#it_nexts),* ] });
2403                    par_builds.push(quote! { #rust_name: [ #(#idx_clones),* ] });
2404                }
2405                // `Vec<T>` + `width = N` → columns `base_1..base_N`, each
2406                // `Vec<Option<elem>>`. Flatten the N optionals per row back into a
2407                // `Vec<elem>` (trailing-NA padding from the write side drops out).
2408                ResolvedField::ExpandedVec(data) => {
2409                    let rust_name = &data.rust_name;
2410                    let elem_ty = &data.elem_ty;
2411                    let opt_ty: syn::Type = syn::parse_quote!(::core::option::Option<#elem_ty>);
2412                    let mut it_nexts: Vec<TokenStream> = Vec::new();
2413                    let mut idx_clones: Vec<TokenStream> = Vec::new();
2414                    for k in 1..=data.width {
2415                        let col_var = format_ident!("__ev_{}_{}", rust_name, k);
2416                        let it_var = format_ident!("__evit_{}_{}", rust_name, k);
2417                        let col_name_str = format!("{}_{}", data.base_name, k);
2418                        extracts.push(pull_col(&col_var, &col_name_str, &opt_ty));
2419                        seq_decls.push(quote! { let mut #it_var = #col_var.into_iter(); });
2420                        it_nexts.push(quote! { #it_var.next().unwrap() });
2421                        idx_clones.push(quote! { #col_var[__i].clone() });
2422                    }
2423                    // `.into()` converts the collected `Vec<elem>` to the field's
2424                    // own container type (`Vec<T>` identity or `Box<[T]>`).
2425                    seq_builds.push(quote! {
2426                        #rust_name: [ #(#it_nexts),* ]
2427                            .into_iter().flatten().collect::<Vec<#elem_ty>>().into()
2428                    });
2429                    par_builds.push(quote! {
2430                        #rust_name: [ #(#idx_clones),* ]
2431                            .into_iter().flatten().collect::<Vec<#elem_ty>>().into()
2432                    });
2433                }
2434                // `Vec<T>`/`Box<[T]>` + `expand` → a runtime-determined number of
2435                // columns `name_1..name_k`, each `Vec<Option<elem>>`. Discover them
2436                // by walking `name_<i>` until the first gap, then flatten per row.
2437                ResolvedField::AutoExpandVec(data) => {
2438                    let rust_name = &data.rust_name;
2439                    let elem_ty = &data.elem_ty;
2440                    let cols_var = format_ident!("__aev_{}", rust_name);
2441                    let col_name_str = &data.col_name_str;
2442                    extracts.push(quote! {
2443                        let #cols_var: Vec<Vec<::core::option::Option<#elem_ty>>> = {
2444                            let mut __cols: Vec<Vec<::core::option::Option<#elem_ty>>> =
2445                                ::std::vec::Vec::new();
2446                            let mut __k: usize = 1;
2447                            loop {
2448                                let __cn = ::std::format!("{}_{}", #col_name_str, __k);
2449                                match __view.column_raw(&__cn) {
2450                                    ::core::option::Option::Some(__s) => {
2451                                        let __c: Vec<::core::option::Option<#elem_ty>> =
2452                                            <Vec<::core::option::Option<#elem_ty>> as ::miniextendr_api::from_r::TryFromSexp>::try_from_sexp(__s)
2453                                                .map_err(|e| ::std::format!(
2454                                                    "column `{}` could not be converted to the expected type: {}",
2455                                                    __cn, e
2456                                                ))?;
2457                                        if __c.len() != __nrow {
2458                                            return ::core::result::Result::Err(::std::format!(
2459                                                "column `{}` has length {} but data.frame has {} rows",
2460                                                __cn, __c.len(), __nrow
2461                                            ));
2462                                        }
2463                                        __cols.push(__c);
2464                                        __k += 1;
2465                                    }
2466                                    ::core::option::Option::None => break,
2467                                }
2468                            }
2469                            __cols
2470                        };
2471                    });
2472                    // Both seq and par index by row (`__i`): the columns are a
2473                    // `Vec<Vec<…>>`, so there is nothing to drain field-wise.
2474                    let build = quote! {
2475                        #rust_name: #cols_var
2476                            .iter()
2477                            .filter_map(|__c| __c[__i].clone())
2478                            .collect::<Vec<#elem_ty>>()
2479                            .into()
2480                    };
2481                    seq_builds.push(build.clone());
2482                    par_builds.push(build);
2483                }
2484                // Nested `DataFrameRow` (#485): the inner type's columns were
2485                // written under a `<base>_` prefix. Select those parent columns,
2486                // strip the prefix into a fresh sub-frame, and recurse through the
2487                // inner type's `DataFrameRowConvert` reader. Routing through the
2488                // trait (rather than `Inner::try_from_dataframe`) keeps this
2489                // compiling even when the inner shape has no reader — it degrades
2490                // to a clear runtime error instead.
2491                ResolvedField::Struct(data) => {
2492                    let rust_name = &data.rust_name;
2493                    let inner_ty = &data.inner_ty;
2494                    let vec_var = format_ident!("__sf_{}", rust_name);
2495                    let it_var = format_ident!("__sfit_{}", rust_name);
2496                    let base = &data.col_name_str;
2497                    let prefix_lit = format!("{}_", data.col_name_str);
2498                    extracts.push(quote! {
2499                        let #vec_var: Vec<#inner_ty> = {
2500                            let __prefix: &str = #prefix_lit;
2501                            let __names = __view.names();
2502                            let __sel: Vec<&str> = __names
2503                                .iter()
2504                                .filter(|__n| __n.starts_with(__prefix))
2505                                .map(|__n| __n.as_str())
2506                                .collect();
2507                            if __sel.is_empty() {
2508                                return ::core::result::Result::Err(::std::format!(
2509                                    "struct column `{}`: no columns with prefix `{}` found in the data.frame",
2510                                    #base, __prefix
2511                                ));
2512                            }
2513                            // `select` builds a fresh list (shared column SEXPs, a
2514                            // fresh names vector); protect it across the CHARSXP
2515                            // allocations in `strip_prefix` and the recursive read.
2516                            let __sub_df = __view.select(&__sel);
2517                            let __guard = unsafe {
2518                                ::miniextendr_api::OwnedProtect::new(__sub_df.as_sexp())
2519                            };
2520                            let __sub = ::miniextendr_api::dataframe::DataFrame::from_sexp(__guard.get())
2521                                .map_err(|e| e.to_string())?
2522                                .strip_prefix(__prefix);
2523                            let __out = match <#inner_ty as ::miniextendr_api::dataframe::DataFrameRowConvert>::rows_from_dataframe(&__sub) {
2524                                ::core::option::Option::Some(::core::result::Result::Ok(__v)) => __v,
2525                                ::core::option::Option::Some(::core::result::Result::Err(__e)) => {
2526                                    return ::core::result::Result::Err(::std::format!(
2527                                        "struct column `{}`: {}", #base, __e
2528                                    ));
2529                                }
2530                                ::core::option::Option::None => {
2531                                    return ::core::result::Result::Err(::std::format!(
2532                                        "struct column `{}`: nested type has no data.frame reader", #base
2533                                    ));
2534                                }
2535                            };
2536                            drop(__guard);
2537                            __out
2538                        };
2539                        if #vec_var.len() != __nrow {
2540                            return ::core::result::Result::Err(::std::format!(
2541                                "struct column `{}` produced {} rows but data.frame has {} rows",
2542                                #base, #vec_var.len(), __nrow
2543                            ));
2544                        }
2545                    });
2546                    seq_decls.push(quote! { let mut #it_var = #vec_var.into_iter(); });
2547                    seq_builds.push(quote! { #rust_name: #it_var.next().unwrap() });
2548                    par_builds.push(quote! { #rust_name: #vec_var[__i].clone() });
2549                }
2550                // Non-String-keyed map (#919): read two parallel list-columns
2551                // `<base>_keys` / `<base>_values` (VECSXP of typed vectors), then
2552                // zip keys[i] with values[i] back into the map type per row.
2553                // NULL VECSXP elements → empty Vec (not None — struct path uses owned cols).
2554                // All SEXP access happens in `extracts` on the R thread; the
2555                // parallel iterator touches only owned Rust `Vec<Vec<K>>` / `Vec<Vec<V>>`.
2556                ResolvedField::Map(data) => {
2557                    let rust_name = &data.rust_name;
2558                    let key_ty = &data.key_ty;
2559                    let val_ty = &data.val_ty;
2560                    let map_ty = &data.map_ty;
2561                    let base = &data.base_name;
2562                    let keys_col_name = format!("{}_keys", base);
2563                    let vals_col_name = format!("{}_values", base);
2564                    let keys_var = format_ident!("__mapcol_{}_keys", base.replace('-', "_"));
2565                    let vals_var = format_ident!("__mapcol_{}_values", base.replace('-', "_"));
2566                    let keys_it_var = format_ident!("__mapcol_it_{}_keys", base.replace('-', "_"));
2567                    let vals_it_var =
2568                        format_ident!("__mapcol_it_{}_values", base.replace('-', "_"));
2569
2570                    // Extract helper: walk a VECSXP list-column; NULL/nil element → empty Vec.
2571                    let extract_col = |col_var: &syn::Ident,
2572                                       col_name: &str,
2573                                       elem_ty: &syn::Type| {
2574                        quote! {
2575                            let #col_var: Vec<Vec<#elem_ty>> = {
2576                                let __col_sexp = __view.column_raw(#col_name).ok_or_else(|| {
2577                                    ::std::format!("column `{}` is missing from the data.frame", #col_name)
2578                                })?;
2579                                if <::miniextendr_api::SEXP as ::miniextendr_api::SexpExt>::is_list(&__col_sexp) {
2580                                    let __list = unsafe {
2581                                        ::miniextendr_api::list::List::from_raw(__col_sexp)
2582                                    };
2583                                    let __len = __list.len();
2584                                    let mut __v: Vec<Vec<#elem_ty>> =
2585                                        ::std::vec::Vec::with_capacity(__len as usize);
2586                                    for __j in 0..__len {
2587                                        let __elt = __list.get(__j).unwrap();
2588                                        if __elt == ::miniextendr_api::SEXP::nil() {
2589                                            __v.push(::std::vec::Vec::new());
2590                                        } else {
2591                                            let __inner: Vec<#elem_ty> =
2592                                                <Vec<#elem_ty> as ::miniextendr_api::from_r::TryFromSexp>::try_from_sexp(__elt)
2593                                                    .map_err(|e| ::std::format!(
2594                                                        "column `{}` element {} could not be converted: {}",
2595                                                        #col_name, __j, e
2596                                                    ))?;
2597                                            __v.push(__inner);
2598                                        }
2599                                    }
2600                                    __v
2601                                } else {
2602                                    // Non-list column → all rows have empty maps.
2603                                    (0..__nrow).map(|_| ::std::vec::Vec::new()).collect()
2604                                }
2605                            };
2606                            if #col_var.len() != __nrow {
2607                                return ::core::result::Result::Err(::std::format!(
2608                                    "column `{}` has length {} but data.frame has {} rows",
2609                                    #col_name, #col_var.len(), __nrow
2610                                ));
2611                            }
2612                        }
2613                    };
2614                    extracts.push(extract_col(&keys_var, &keys_col_name, key_ty));
2615                    extracts.push(extract_col(&vals_var, &vals_col_name, val_ty));
2616
2617                    seq_decls.push(quote! { let mut #keys_it_var = #keys_var.into_iter(); });
2618                    seq_decls.push(quote! { let mut #vals_it_var = #vals_var.into_iter(); });
2619                    seq_builds.push(quote! {
2620                        #rust_name: {
2621                            let __k = #keys_it_var.next().unwrap();
2622                            let __v = #vals_it_var.next().unwrap();
2623                            __k.into_iter().zip(__v).collect::<#map_ty>()
2624                        }
2625                    });
2626                    par_builds.push(quote! {
2627                        #rust_name: #keys_var[__i].clone()
2628                            .into_iter()
2629                            .zip(#vals_var[__i].clone())
2630                            .collect::<#map_ty>()
2631                    });
2632                }
2633            }
2634        }
2635
2636        // Only `AutoExpandVec` builds reference `__i` in the sequential loop; bind
2637        // the counter only then to avoid an `unused_variables` warning.
2638        let seq_counter = if has_autoexpand_field {
2639            quote! { __i }
2640        } else {
2641            quote! { _ }
2642        };
2643
2644        // A struct-flatten field would need `Inner: Clone` for by-index parallel
2645        // assembly. Rather than impose that, the parallel reader delegates to the
2646        // sequential one (which moves) whenever a struct field is present.
2647        let par_body = if has_struct_field {
2648            quote! { Self::try_from_dataframe(sexp) }
2649        } else {
2650            quote! {
2651                use ::miniextendr_api::rayon_bridge::rayon::prelude::*;
2652                ::miniextendr_api::optionals::parallel::ensure_pool();
2653                let __view = ::miniextendr_api::dataframe::DataFrame::from_sexp(sexp)
2654                    .map_err(|e| e.to_string())?;
2655                let __nrow = __view.nrow();
2656                #(#extracts)*
2657                let __rows: Vec<Self> = (0..__nrow)
2658                    .into_par_iter()
2659                    .map(|__i| Self { #(#par_builds),* })
2660                    .collect();
2661                ::core::result::Result::Ok(__rows)
2662            }
2663        };
2664
2665        quote! {
2666            /// Read an R `data.frame` directly into a `Vec<Self>` (sequential).
2667            ///
2668            /// Each column is materialised out of R (NA-aware, ALTREP-materialising)
2669            /// and the rows are assembled by transposing column-major into row-major.
2670            /// Column-expansion fields are regrouped and nested-struct fields are
2671            /// read from their `<field>_`-prefixed sub-frame. Returns `Err` with a
2672            /// descriptive message if a column is missing, mis-typed, or ragged.
2673            pub fn try_from_dataframe(
2674                sexp: ::miniextendr_api::SEXP,
2675            ) -> ::core::result::Result<Vec<Self>, ::std::string::String> {
2676                let __view = ::miniextendr_api::dataframe::DataFrame::from_sexp(sexp)
2677                    .map_err(|e| e.to_string())?;
2678                let __nrow = __view.nrow();
2679                #(#extracts)*
2680                #(#seq_decls)*
2681                let mut __rows: Vec<Self> = Vec::with_capacity(__nrow);
2682                for #seq_counter in 0..__nrow {
2683                    __rows.push(Self { #(#seq_builds),* });
2684                }
2685                ::core::result::Result::Ok(__rows)
2686            }
2687
2688            /// Read an R `data.frame` directly into a `Vec<Self>` (parallel).
2689            ///
2690            /// Mirrors [`Self::try_from_dataframe`] but assembles rows off the R
2691            /// thread via rayon. Safety: all SEXP access (column extraction, ALTREP
2692            /// materialisation, nested sub-frame reads) happens up front on the
2693            /// R/worker thread; the `into_par_iter()` region touches only
2694            /// pre-extracted owned data and makes no R API calls.
2695            #[cfg(feature = "rayon")]
2696            pub fn try_from_dataframe_par(
2697                sexp: ::miniextendr_api::SEXP,
2698            ) -> ::core::result::Result<Vec<Self>, ::std::string::String> {
2699                #par_body
2700            }
2701        }
2702    } else {
2703        TokenStream::new()
2704    };
2705    // endregion
2706
2707    // region: DataFrame type methods (from_rows, from_rows_par)
2708    let df_methods = quote! {
2709        impl #impl_generics #df_name #ty_generics #where_clause {
2710            /// Sequential row→column transposition.
2711            pub fn from_rows(rows: Vec<#row_name #ty_generics>) -> Self {
2712                rows.into()
2713            }
2714
2715            #from_rows_par_method
2716        }
2717    };
2718
2719    let row_methods = quote! {
2720        impl #impl_generics #row_name #ty_generics #where_clause {
2721            /// Name of the generated DataFrame companion type.
2722            pub const DATAFRAME_TYPE_NAME: &'static str = stringify!(#df_name);
2723
2724            /// Convert a vector of rows into the companion DataFrame type.
2725            ///
2726            /// This transposes row-oriented data into column-oriented format.
2727            pub fn to_dataframe(rows: Vec<Self>) -> #df_name #ty_generics {
2728                rows.into()
2729            }
2730
2731            #from_dataframe_method
2732
2733            #reader_methods
2734        }
2735    };
2736
2737    // Compile-time assertion: row type must implement IntoList
2738    // Skip for unit/empty structs, tuple structs, structs with expansion,
2739    // and structs that store `List`-converted struct fields (#485 as_list).
2740    let trait_check = if !flat_cols.is_empty()
2741        && !is_tuple_struct
2742        && !is_unit_struct
2743        && !has_expansion
2744        && !has_into_list_struct
2745    {
2746        quote! {
2747            const _: () = {
2748                fn _assert_into_list #impl_generics () #where_clause {
2749                    fn _check<T: ::miniextendr_api::list::IntoList>() {}
2750                    _check::<#row_name #ty_generics>();
2751                }
2752            };
2753        }
2754    } else {
2755        TokenStream::new()
2756    };
2757
2758    // Marker trait impl: struct type implements DataFrameRow via IntoDataFrame chain.
2759    let marker_impl = quote! {
2760        impl #impl_generics ::miniextendr_api::markers::DataFrameRow
2761            for #row_name #ty_generics #where_clause {}
2762    };
2763
2764    // DataFramePayloadFields impl: exposes FIELDS (all resolved column names) and TAG
2765    // (the #[dataframe(tag = "...")] value, or "") for compile-time collision detection
2766    // by outer DataFrameRow enums that nest this type as a struct-flattened field.
2767    let payload_fields_impl = {
2768        // Collect all column names: flat_cols + struct_col base names.
2769        let mut field_names: Vec<String> =
2770            flat_cols.iter().map(|fc| fc.col_name_str.clone()).collect();
2771        for sc in &struct_cols {
2772            field_names.push(sc.col_name_str.clone());
2773        }
2774        let tag_str = attrs.tag.as_deref().unwrap_or("");
2775        quote! {
2776            impl #impl_generics ::miniextendr_api::markers::DataFramePayloadFields
2777                for #row_name #ty_generics #where_clause
2778            {
2779                const FIELDS: &'static [&'static str] = &[#(#field_names),*];
2780                const TAG: &'static str = #tag_str;
2781            }
2782        }
2783    };
2784
2785    // Compile-time assertions for struct-flattened fields (#485): each inner
2786    // type must implement `DataFrameRow`, otherwise users get a confusing
2787    // error pointing at the `to_dataframe` call site instead of the field.
2788    // Note: `Clone` is no longer asserted here — it is enforced via a where
2789    // clause on `from_rows_par` itself, giving a clearer error at the call site.
2790    let struct_assertions: Vec<TokenStream> = struct_cols
2791        .iter()
2792        .map(|sc| {
2793            let inner_ty = &sc.inner_ty;
2794            quote! {
2795                const _: () = {
2796                    fn _assert_inner_is_dataframe_row<T: ::miniextendr_api::markers::DataFrameRow>() {}
2797                    fn _do_assert #impl_generics () #where_clause {
2798                        _assert_inner_is_dataframe_row::<#inner_ty>();
2799                    }
2800                };
2801            }
2802        })
2803        .collect();
2804
2805    // region: DataFrameRowConvert on Row — orphan-rule bridge for the public verbs
2806    //
2807    // The derive cannot write `impl IntoDataFrame for Vec<Row>` directly: the orphan rule
2808    // forbids it (both `IntoDataFrame` and `Vec` are foreign in the user crate, and `Row` is
2809    // only *covered* inside `Vec<_>`). Instead it implements the local `DataFrameRowConvert`
2810    // marker on the local `Row`; miniextendr_api's blanket
2811    // `impl<T: DataFrameRowConvert> IntoDataFrame/FromDataFrame for Vec<T>` then gives users the
2812    // public verbs `rows.into_dataframe()?` / `Vec::<Row>::from_dataframe(&df)?`. The methods
2813    // delegate to the companion engine (`to_dataframe` → `ColumnSource::into_dataframe`), the
2814    // merged parallel builder (#777 `from_rows_par`) and reader (#765 `try_from_dataframe[_par]`),
2815    // converting the reader's bare `String` error into the unified `DataFrameError`.
2816
2817    // The parallel build uses the scatter-write builder when one was generated for this shape;
2818    // otherwise it falls back to the sequential transposition.
2819    let has_par_builder = !from_rows_par_method.is_empty();
2820    let rows_into_dataframe_par_body = if has_par_builder {
2821        quote! {
2822            ::miniextendr_api::convert::ColumnSource::into_dataframe(
2823                <#df_name #ty_generics>::from_rows_par(rows),
2824            )
2825        }
2826    } else {
2827        quote! { Self::rows_into_dataframe(rows) }
2828    };
2829
2830    // Readers are overridden for every reader-capable struct shape (scalar,
2831    // column-expansion, struct-flatten — see `struct_reader` / `try_from_dataframe`).
2832    // Other shapes use the trait default (`None`), surfaced by the blanket as a
2833    // clear `DataFrameError`.
2834    let reader_overrides = if struct_reader {
2835        quote! {
2836            fn rows_from_dataframe(
2837                df: &::miniextendr_api::dataframe::DataFrame,
2838            ) -> ::core::option::Option<::core::result::Result<
2839                Vec<Self>,
2840                ::miniextendr_api::dataframe::DataFrameError,
2841            >> {
2842                ::core::option::Option::Some(
2843                    <#row_name #ty_generics>::try_from_dataframe(df.as_sexp())
2844                        .map_err(::miniextendr_api::dataframe::DataFrameError::Conversion),
2845                )
2846            }
2847
2848            #[cfg(feature = "rayon")]
2849            fn rows_from_dataframe_par(
2850                df: &::miniextendr_api::dataframe::DataFrame,
2851            ) -> ::core::option::Option<::core::result::Result<
2852                Vec<Self>,
2853                ::miniextendr_api::dataframe::DataFrameError,
2854            >> {
2855                ::core::option::Option::Some(
2856                    <#row_name #ty_generics>::try_from_dataframe_par(df.as_sexp())
2857                        .map_err(::miniextendr_api::dataframe::DataFrameError::Conversion),
2858                )
2859            }
2860        }
2861    } else {
2862        TokenStream::new()
2863    };
2864
2865    let datarow_convert_impl = quote! {
2866        impl #impl_generics ::miniextendr_api::dataframe::DataFrameRowConvert
2867            for #row_name #ty_generics #where_clause
2868        {
2869            fn rows_into_dataframe(
2870                rows: Vec<Self>,
2871            ) -> ::core::result::Result<
2872                ::miniextendr_api::dataframe::DataFrame,
2873                ::miniextendr_api::dataframe::DataFrameError,
2874            > {
2875                ::miniextendr_api::convert::ColumnSource::into_dataframe(
2876                    <#row_name #ty_generics>::to_dataframe(rows),
2877                )
2878            }
2879
2880            #[cfg(feature = "rayon")]
2881            fn rows_into_dataframe_par(
2882                rows: Vec<Self>,
2883            ) -> ::core::result::Result<
2884                ::miniextendr_api::dataframe::DataFrame,
2885                ::miniextendr_api::dataframe::DataFrameError,
2886            > {
2887                #rows_into_dataframe_par_body
2888            }
2889
2890            #reader_overrides
2891        }
2892    };
2893    // endregion
2894
2895    Ok(quote! {
2896        #dataframe_struct
2897        #into_dataframe_impl
2898        #from_vec_impl
2899        #df_methods
2900        #into_iterator_impl
2901        #row_methods
2902        #trait_check
2903        #marker_impl
2904        #payload_fields_impl
2905        #datarow_convert_impl
2906        #(#struct_assertions)*
2907    })
2908    // endregion
2909}
2910// endregion
2911
2912// region: Enum align path
2913
2914/// A resolved column in the unified schema across all enum variants.
2915///
2916/// Tracks the column name, element type, which variants contribute to this column,
2917/// and whether the type was coerced to `String` due to cross-variant type conflicts
2918/// (when `#[dataframe(conflicts = "string")]` is active).
2919pub(super) struct ResolvedColumn {
2920    /// Column name in the companion struct / data frame.
2921    pub(super) col_name: syn::Ident,
2922    /// Element type (used as `Vec<Option<#ty>>`).
2923    /// When `string_coerced` is true, this is always `String`.
2924    pub(super) ty: syn::Type,
2925    /// Indices of variants that contain this field.
2926    pub(super) present_in: Vec<usize>,
2927    /// Whether this column was coerced to `String` due to type conflicts.
2928    /// When true, values are converted via `ToString::to_string()` at push time.
2929    pub(super) string_coerced: bool,
2930    /// Whether this column should be emitted as an R factor (via `as_factor` attribute).
2931    /// When `true`, `into_data_frame` wraps the `Vec<Option<T>>` in `FactorOptionVec<T>`
2932    /// before calling `IntoR::into_sexp`, using the `UnitEnumFactor` blanket impl.
2933    pub(super) is_factor: bool,
2934}
2935
2936/// Accumulates unique columns for an enum-to-dataframe unified schema.
2937///
2938/// As columns are registered from each variant's fields, the registry detects
2939/// duplicates and validates type consistency. When `coerce_to_string` is enabled,
2940/// type conflicts are resolved by coercing to `String`; otherwise they produce errors.
2941pub(super) struct ColumnRegistry<'a> {
2942    /// The ordered list of resolved columns in the schema.
2943    pub(super) columns: Vec<ResolvedColumn>,
2944    /// Maps column name strings to their index in `columns` for O(1) dedup lookup.
2945    pub(super) col_index: std::collections::HashMap<String, usize>,
2946    /// Whether to coerce type-conflicting columns to `String` instead of erroring.
2947    pub(super) coerce_to_string: bool,
2948    /// Cached `String` type AST node, used as the coercion target type.
2949    pub(super) string_ty: &'a syn::Type,
2950}
2951
2952impl<'a> ColumnRegistry<'a> {
2953    /// Create a new empty column registry.
2954    fn new(coerce_to_string: bool, string_ty: &'a syn::Type) -> Self {
2955        Self {
2956            columns: Vec::new(),
2957            col_index: std::collections::HashMap::new(),
2958            coerce_to_string,
2959            string_ty,
2960        }
2961    }
2962
2963    /// Register a single column in the schema, or merge with an existing column.
2964    ///
2965    /// If a column with the same name already exists, validates that the types match.
2966    /// On type conflict: coerces to `String` (if `coerce_to_string` is true) or
2967    /// returns `Err`. The `variant_idx` is appended to the column's `present_in` list.
2968    fn register(
2969        &mut self,
2970        col_name: &str,
2971        col_ty: &syn::Type,
2972        variant_idx: usize,
2973        variant_name: &syn::Ident,
2974        error_span: Span,
2975    ) -> syn::Result<()> {
2976        if let Some(&idx) = self.col_index.get(col_name) {
2977            let existing = &self.columns[idx];
2978            if !existing.string_coerced && existing.ty != *col_ty {
2979                if self.coerce_to_string {
2980                    self.columns[idx].ty = self.string_ty.clone();
2981                    self.columns[idx].string_coerced = true;
2982                } else {
2983                    return Err(syn::Error::new(
2984                        error_span,
2985                        format!(
2986                            "type conflict for field `{}`: variant `{}` has a different type \
2987                             than a previous variant; \
2988                             use `#[dataframe(conflicts = \"string\")]` to coerce all conflicting fields to String",
2989                            col_name, variant_name
2990                        ),
2991                    ));
2992                }
2993            }
2994            self.columns[idx].present_in.push(variant_idx);
2995        } else {
2996            let idx = self.columns.len();
2997            self.columns.push(ResolvedColumn {
2998                col_name: format_ident!("{}", col_name),
2999                ty: col_ty.clone(),
3000                present_in: vec![variant_idx],
3001                string_coerced: false,
3002                is_factor: false,
3003            });
3004            self.col_index.insert(col_name.to_string(), idx);
3005        }
3006        Ok(())
3007    }
3008
3009    /// Like `register`, but marks the column as a factor column (`is_factor = true`).
3010    ///
3011    /// Used for fields annotated with `#[dataframe(as_factor)]`. The companion struct
3012    /// field type stays `Vec<Option<T>>`, but `into_data_frame` wraps it in
3013    /// `FactorOptionVec<T>` (using the `UnitEnumFactor` blanket `IntoR` impl).
3014    pub(super) fn register_factor(
3015        &mut self,
3016        col_name: &str,
3017        col_ty: &syn::Type,
3018        variant_idx: usize,
3019        variant_name: &syn::Ident,
3020        error_span: Span,
3021    ) -> syn::Result<()> {
3022        self.register(col_name, col_ty, variant_idx, variant_name, error_span)?;
3023        if let Some(&idx) = self.col_index.get(col_name) {
3024            self.columns[idx].is_factor = true;
3025        }
3026        Ok(())
3027    }
3028}
3029
3030/// Describes the shape of an enum variant's fields.
3031#[derive(Clone, Copy, PartialEq, Eq)]
3032pub(super) enum VariantShape {
3033    /// `Variant { field: Type, ... }`
3034    Named,
3035    /// `Variant(Type, ...)`
3036    Tuple,
3037    /// `Variant` (no fields)
3038    Unit,
3039}
3040
3041/// A resolved enum field ready for codegen -- either a single column or expanded
3042/// from an array/Vec into multiple suffixed columns.
3043///
3044/// This is the enum-path counterpart of [`ResolvedField`] (used for structs).
3045/// Each variant carries both the binding name (for destructure patterns) and the
3046/// original Rust field name (for error reporting and named-variant patterns).
3047pub(super) enum EnumResolvedField {
3048    /// Single column contribution.
3049    Single(Box<EnumSingleFieldData>),
3050    /// Expanded from [T; N].
3051    ExpandedFixed(Box<EnumExpandedFixedData>),
3052    /// Expanded from `Vec<T>` with pinned width.
3053    ExpandedVec(Box<EnumExpandedVecData>),
3054    /// Auto-expanded `Vec<T>`/`Box<[T]>`: column count determined at runtime.
3055    AutoExpandVec(Box<EnumAutoExpandVecData>),
3056    /// `HashMap<K,V>` or `BTreeMap<K,V>` → two parallel list-columns: `<field>_keys`, `<field>_values`.
3057    Map(Box<EnumMapFieldData>),
3058    /// Struct field whose inner type implements `DataFrameRow` → flattened `<base>_<inner_col>` columns.
3059    Struct(Box<EnumStructFieldData>),
3060}
3061
3062impl EnumResolvedField {
3063    /// Binding name used in destructure patterns.
3064    pub(super) fn binding(&self) -> &syn::Ident {
3065        match self {
3066            Self::Single(data) => &data.binding,
3067            Self::ExpandedFixed(data) => &data.binding,
3068            Self::ExpandedVec(data) => &data.binding,
3069            Self::AutoExpandVec(data) => &data.binding,
3070            Self::Map(data) => &data.binding,
3071            Self::Struct(data) => &data.binding,
3072        }
3073    }
3074
3075    /// Original Rust field name.
3076    pub(super) fn rust_name(&self) -> &syn::Ident {
3077        match self {
3078            Self::Single(data) => &data.rust_name,
3079            Self::ExpandedFixed(data) => &data.rust_name,
3080            Self::ExpandedVec(data) => &data.rust_name,
3081            Self::AutoExpandVec(data) => &data.rust_name,
3082            Self::Map(data) => &data.rust_name,
3083            Self::Struct(data) => &data.rust_name,
3084        }
3085    }
3086}
3087
3088/// Data for [`EnumResolvedField::Single`].
3089pub(super) struct EnumSingleFieldData {
3090    /// Column name in the schema.
3091    pub(super) col_name: syn::Ident,
3092    /// Binding name used in destructure pattern.
3093    pub(super) binding: syn::Ident,
3094    /// Original Rust field name (for named variants).
3095    pub(super) rust_name: syn::Ident,
3096    /// Column type stored in the companion Vec.
3097    ///
3098    /// For most fields this is the raw Rust type. When `needs_into_list` is
3099    /// `true` (struct-typed fields with `#[dataframe(as_list)]`), this is
3100    /// `::miniextendr_api::list::List` — the actual inner type is erased at
3101    /// the storage level and each row value is converted via `.into_list()`.
3102    pub(super) ty: syn::Type,
3103    /// Whether the field's value must be converted via `.into_list()` before
3104    /// being pushed into the companion `Vec<Option<List>>`.
3105    ///
3106    /// Set to `true` only for struct-typed fields (`FieldTypeKind::Struct`)
3107    /// that carry `#[dataframe(as_list)]`. The companion struct field type is
3108    /// `Vec<Option<::miniextendr_api::list::List>>` in this case.
3109    pub(super) needs_into_list: bool,
3110    /// Whether the field should be emitted as an R factor column.
3111    ///
3112    /// Set to `true` for fields annotated with `#[dataframe(as_factor)]`.
3113    /// The companion struct field type is `Vec<Option<T>>` (unchanged), but
3114    /// `into_data_frame` wraps it in `FactorOptionVec<T>` to use the
3115    /// `UnitEnumFactor`-based blanket `IntoR` impl.
3116    pub(super) is_factor: bool,
3117}
3118
3119/// Data for [`EnumResolvedField::ExpandedFixed`].
3120pub(super) struct EnumExpandedFixedData {
3121    /// Base column name.
3122    pub(super) base_name: String,
3123    /// Binding name.
3124    pub(super) binding: syn::Ident,
3125    /// Original Rust field name.
3126    pub(super) rust_name: syn::Ident,
3127    /// Element type.
3128    pub(super) elem_ty: syn::Type,
3129    /// Array length.
3130    pub(super) len: usize,
3131}
3132
3133/// Data for [`EnumResolvedField::ExpandedVec`].
3134pub(super) struct EnumExpandedVecData {
3135    /// Base column name.
3136    pub(super) base_name: String,
3137    /// Binding name.
3138    pub(super) binding: syn::Ident,
3139    /// Original Rust field name.
3140    pub(super) rust_name: syn::Ident,
3141    /// Element type.
3142    pub(super) elem_ty: syn::Type,
3143    /// Pinned width.
3144    pub(super) width: usize,
3145}
3146
3147/// Data for [`EnumResolvedField::AutoExpandVec`].
3148pub(super) struct EnumAutoExpandVecData {
3149    /// Base column name.
3150    pub(super) base_name: String,
3151    /// Binding name.
3152    pub(super) binding: syn::Ident,
3153    /// Original Rust field name.
3154    pub(super) rust_name: syn::Ident,
3155    /// Element type.
3156    pub(super) elem_ty: syn::Type,
3157    /// Container type for companion struct (`Vec<T>` or `Box<[T]>`).
3158    pub(super) container_ty: syn::Type,
3159}
3160
3161/// Data for [`EnumResolvedField::Map`].
3162///
3163/// A `HashMap<K,V>` or `BTreeMap<K,V>` field expands to two parallel list-columns:
3164/// `<base_name>_keys: Vec<Option<Vec<K>>>` and `<base_name>_values: Vec<Option<Vec<V>>>`.
3165/// Absent-variant rows get `None` in both columns. Key order follows the map's own
3166/// iteration order: `BTreeMap` yields sorted keys, `HashMap` yields non-deterministic order.
3167/// Both are produced via `into_iter().unzip()` which guarantees pairwise alignment.
3168pub(super) struct EnumMapFieldData {
3169    /// Base column name (field name or `rename` override).
3170    pub(super) base_name: String,
3171    /// Binding name used in destructure pattern.
3172    pub(super) binding: syn::Ident,
3173    /// Original Rust field name.
3174    pub(super) rust_name: syn::Ident,
3175    /// Key type K.
3176    pub(super) key_ty: syn::Type,
3177    /// Value type V.
3178    pub(super) val_ty: syn::Type,
3179    /// Full original field type (`HashMap<K, V>` / `BTreeMap<K, V>`). The reader
3180    /// regroups the `_keys`/`_values` list-columns and `collect()`s back into this
3181    /// exact map type — both `HashMap` and `BTreeMap` implement `FromIterator<(K, V)>`.
3182    pub(super) map_ty: syn::Type,
3183}
3184
3185/// Data for [`EnumResolvedField::Struct`].
3186///
3187/// A field whose inner type implements `DataFrameRow` expands to `<base_name>_<inner_col>`
3188/// prefixed columns — one output column per column emitted by the inner type's companion
3189/// DataFrame. Absent-variant rows produce `None` in every prefixed column.
3190///
3191/// The companion struct holds `Vec<Option<Inner>>` (not `Vec<Inner>`). The `into_data_frame`
3192/// method collects present rows into a dense `Vec<Inner>` (tracking presence indices),
3193/// calls `Inner::to_dataframe(present_rows)`, extracts named column SEXPs, and scatters
3194/// them back to the full row count with `None`-fill for absent rows.
3195pub(super) struct EnumStructFieldData {
3196    /// Base name for column prefixing (field name or `rename` override).
3197    pub(super) base_name: String,
3198    /// Binding name used in destructure pattern.
3199    pub(super) binding: syn::Ident,
3200    /// Original Rust field name.
3201    pub(super) rust_name: syn::Ident,
3202    /// Inner struct type (used for the compile-time DataFrameRow assertion and codegen).
3203    pub(super) inner_ty: syn::Type,
3204}
3205
3206/// Parsed and resolved information about a single enum variant for DataFrame codegen.
3207///
3208/// Contains the variant's name, shape (named/tuple/unit), resolved fields (after
3209/// applying `#[dataframe(...)]` attributes and type classification), and any
3210/// skipped field names (needed for complete destructure patterns in named variants).
3211pub(super) struct VariantInfo {
3212    /// Variant name.
3213    pub(super) name: syn::Ident,
3214    /// Shape of this variant.
3215    pub(super) shape: VariantShape,
3216    /// Resolved fields (after applying field attrs + type classification).
3217    pub(super) fields: Vec<EnumResolvedField>,
3218    /// Original Rust field names (for named variants) — needed for skipped fields in destructure.
3219    pub(super) skipped_fields: Vec<syn::Ident>,
3220}
3221// endregion
3222
3223// region: Enum-specific expansion (in sub-module)
3224
3225mod enum_expansion;
3226use enum_expansion::derive_enum_dataframe;
3227// endregion
3228
3229// region: tests
3230#[cfg(test)]
3231mod tests {
3232    use super::*;
3233
3234    /// Stringify the derive output (whitespace-normalised) for substring assertions.
3235    fn expand(input: DeriveInput) -> String {
3236        derive_dataframe_row(input).unwrap().to_string()
3237    }
3238
3239    /// Scalar named struct: the baseline `try_from_dataframe_par` shape (#765).
3240    /// Both the sequential and parallel readers must be emitted, and the parallel
3241    /// one must drive a `into_par_iter()` row-assembly region.
3242    #[test]
3243    fn scalar_struct_gets_parallel_reader() {
3244        let code = expand(syn::parse_quote! {
3245            #[derive(DataFrameRow)]
3246            struct Measurement {
3247                time: f64,
3248                value: f64,
3249            }
3250        });
3251        assert!(code.contains("fn try_from_dataframe"));
3252        assert!(code.contains("fn try_from_dataframe_par"));
3253        assert!(code.contains("into_par_iter"));
3254    }
3255
3256    /// `[T; N]` fixed-array expansion field (#782/#808): the reader regroups the
3257    /// `pos_1`/`pos_2` columns back into the array inside the parallel loop, with
3258    /// zero SEXP access in `into_par_iter` (the invariant #764 protects).
3259    #[test]
3260    fn fixed_array_struct_gets_parallel_reader() {
3261        let code = expand(syn::parse_quote! {
3262            #[derive(DataFrameRow)]
3263            struct Point {
3264                #[dataframe(rename = "pos")]
3265                pos: [f64; 2],
3266            }
3267        });
3268        assert!(code.contains("fn try_from_dataframe_par"));
3269        assert!(code.contains("into_par_iter"));
3270        // Regrouped from the suffixed expansion columns.
3271        assert!(code.contains("pos_1"));
3272        assert!(code.contains("pos_2"));
3273    }
3274
3275    /// `Vec<T>` + `width = N` expansion field (#782/#808): the reader flattens the
3276    /// `scores_1`/`scores_2` Option columns per row back into the vec.
3277    #[test]
3278    fn pinned_vec_struct_gets_parallel_reader() {
3279        let code = expand(syn::parse_quote! {
3280            #[derive(DataFrameRow)]
3281            struct Scored {
3282                #[dataframe(width = 2)]
3283                scores: Vec<i32>,
3284            }
3285        });
3286        assert!(code.contains("fn try_from_dataframe_par"));
3287        assert!(code.contains("into_par_iter"));
3288        assert!(code.contains("scores_1"));
3289        assert!(code.contains("scores_2"));
3290    }
3291
3292    /// `Vec<T>` + `expand` auto-expansion field (#782/#808): the reader discovers
3293    /// `tags_<i>` columns at runtime and flattens per row. Still a true parallel
3294    /// reader (the column discovery happens on the R thread, up front).
3295    #[test]
3296    fn auto_expand_struct_gets_parallel_reader() {
3297        let code = expand(syn::parse_quote! {
3298            #[derive(DataFrameRow)]
3299            struct Tagged {
3300                #[dataframe(expand)]
3301                tags: Vec<i32>,
3302            }
3303        });
3304        assert!(code.contains("fn try_from_dataframe_par"));
3305        assert!(code.contains("into_par_iter"));
3306    }
3307
3308    /// Struct-flatten field (#485/#808): the struct still gets readers, but the
3309    /// parallel variant deliberately delegates to the sequential one to avoid
3310    /// imposing `Inner: Clone` for by-index parallel assembly (#764 design note).
3311    #[test]
3312    fn struct_flatten_par_delegates_to_sequential() {
3313        let code = expand(syn::parse_quote! {
3314            #[derive(DataFrameRow)]
3315            struct Outer {
3316                id: i32,
3317                inner: Inner,
3318            }
3319        });
3320        assert!(code.contains("fn try_from_dataframe"));
3321        assert!(code.contains("fn try_from_dataframe_par"));
3322        // The `_par` body delegates rather than running its own `into_par_iter`.
3323        assert!(code.contains("Self :: try_from_dataframe (sexp)"));
3324    }
3325
3326    /// Tagged enum companion (#807/#816): enums now get full readers too, including
3327    /// a parallel per-row tag-dispatch loop. Documents that the #764 "no reader at
3328    /// all today" framing for enums is now stale.
3329    #[test]
3330    fn tagged_enum_gets_parallel_reader() {
3331        let code = expand(syn::parse_quote! {
3332            #[derive(DataFrameRow)]
3333            #[dataframe(tag = "_type")]
3334            enum Event {
3335                Click { x: i32, y: i32 },
3336                Key { code: i32 },
3337            }
3338        });
3339        assert!(code.contains("fn try_from_dataframe"));
3340        assert!(code.contains("fn try_from_dataframe_par"));
3341        assert!(code.contains("into_par_iter"));
3342    }
3343
3344    /// Struct-path `HashMap<String, V>` map column (#764): the list-of-named-lists
3345    /// column reads back whole via `Vec<map>: TryFromSexp` on the R thread, so the
3346    /// struct gets both readers (it shares the scalar `pull_col` path — zero SEXP
3347    /// access inside `into_par_iter`). Flips the pre-#764 lock-in test from #920.
3348    #[test]
3349    fn struct_with_string_keyed_map_field_gets_parallel_reader() {
3350        let code = expand(syn::parse_quote! {
3351            #[derive(DataFrameRow)]
3352            struct Config {
3353                opts: ::std::collections::HashMap<String, i32>,
3354            }
3355        });
3356        assert!(code.contains("fn try_from_dataframe"));
3357        assert!(code.contains("fn try_from_dataframe_par"));
3358        assert!(code.contains("into_par_iter"));
3359    }
3360
3361    /// `BTreeMap<String, Option<scalar>>` is also reader-capable: the
3362    /// `Vec<map>: TryFromSexp` impl is generic over `V: TryFromSexp`, and
3363    /// `Option<scalar>` qualifies (NULL list elements → `None`).
3364    #[test]
3365    fn struct_with_btreemap_option_value_gets_reader() {
3366        let code = expand(syn::parse_quote! {
3367            #[derive(DataFrameRow)]
3368            struct Config {
3369                opts: ::std::collections::BTreeMap<String, Option<f64>>,
3370            }
3371        });
3372        assert!(code.contains("fn try_from_dataframe"));
3373        assert!(code.contains("fn try_from_dataframe_par"));
3374    }
3375
3376    /// Non-`String` bare-scalar map keys (#919): the struct gets parallel `_keys`/`_values`
3377    /// list-columns and both readers. The write side uses `Vec<Vec<K>>/Vec<Vec<V>>: IntoR`
3378    /// (VECSXP of typed vectors); the read side zips them back into the map type per row.
3379    #[test]
3380    fn struct_with_non_string_keyed_map_gets_parallel_reader() {
3381        let code = expand(syn::parse_quote! {
3382            #[derive(DataFrameRow)]
3383            struct Config {
3384                opts: ::std::collections::HashMap<i32, f64>,
3385            }
3386        });
3387        assert!(
3388            code.contains("fn try_from_dataframe"),
3389            "non-String bare-scalar map keys should produce a reader via _keys/_values columns"
3390        );
3391        assert!(
3392            code.contains("fn try_from_dataframe_par"),
3393            "non-String bare-scalar map keys should produce a parallel reader"
3394        );
3395        assert!(
3396            code.contains("opts_keys"),
3397            "non-String map field `opts` should expand to `opts_keys` column"
3398        );
3399        assert!(
3400            code.contains("opts_values"),
3401            "non-String map field `opts` should expand to `opts_values` column"
3402        );
3403    }
3404
3405    /// Same as above but using an unqualified path `std::collections::BTreeMap` (no
3406    /// leading `::`) — path form used in actual rpkg fixtures.
3407    #[test]
3408    fn struct_with_btreemap_int_key_unqualified_path() {
3409        let code = expand(syn::parse_quote! {
3410            #[derive(DataFrameRow)]
3411            struct Tally {
3412                id: i32,
3413                tally: std::collections::BTreeMap<i32, f64>,
3414            }
3415        });
3416        assert!(
3417            code.contains("tally_keys"),
3418            "unqualified std::collections::BTreeMap<i32, f64> must expand to tally_keys"
3419        );
3420        assert!(
3421            code.contains("fn try_from_dataframe"),
3422            "unqualified BTreeMap<i32, f64> should produce a reader"
3423        );
3424    }
3425
3426    /// Float-keyed maps (`f32`/`f64`) are rejected with a clear error.
3427    #[test]
3428    #[should_panic]
3429    fn struct_with_float_keyed_map_is_rejected() {
3430        let _code = expand(syn::parse_quote! {
3431            #[derive(DataFrameRow)]
3432            struct Config {
3433                opts: ::std::collections::HashMap<f64, i32>,
3434            }
3435        });
3436    }
3437
3438    /// Custom-hasher maps (`HashMap<K, V, S>`) are rejected from the reader path:
3439    /// the `Vec<HashMap<String, V>>: TryFromSexp` impl only covers the default
3440    /// hasher, so emitting a reader would not compile.
3441    #[test]
3442    fn struct_with_custom_hasher_map_has_no_reader() {
3443        let code = expand(syn::parse_quote! {
3444            #[derive(DataFrameRow)]
3445            struct Config {
3446                opts: ::std::collections::HashMap<String, i32, MyHasher>,
3447            }
3448        });
3449        assert!(
3450            !code.contains("fn try_from_dataframe"),
3451            "custom-hasher maps lack `Vec<map>: TryFromSexp`; the reader must stay gated out"
3452        );
3453    }
3454}
3455// endregion