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