Skip to main content

miniextendr_macros/
r_preconditions.rs

1//! R-side precondition generation for type checking.
2//!
3//! Generates `stopifnot()` checks in R wrapper functions that run BEFORE the `.Call()` boundary.
4//! This gives users clear, idiomatic R error messages with proper stack traces instead of
5//! Rust panic messages.
6//!
7//! Each assertion checks ONE thing with a precise error message:
8//!
9//! ```r
10//! add <- function(a, b) {
11//!   stopifnot(
12//!     "'a' must be numeric, logical, or raw" = is.numeric(a) || is.logical(a) || is.raw(a),
13//!     "'a' must have length 1" = length(a) == 1L,
14//!     "'b' must be numeric, logical, or raw" = is.numeric(b) || is.logical(b) || is.raw(b),
15//!     "'b' must have length 1" = length(b) == 1L
16//!   )
17//!   .Call(C_add, .call = match.call(), a, b)
18//! }
19//! ```
20
21use std::collections::HashSet;
22
23/// A single `stopifnot()` assertion: `"message" = condition`.
24///
25/// When formatted, produces a named argument for R's `stopifnot()`:
26/// `"'x' must be numeric" = is.numeric(x)`.
27struct RAssertion {
28    /// Human-readable error message shown when the assertion fails.
29    message: String,
30    /// R expression that must evaluate to `TRUE` for the check to pass.
31    condition: String,
32}
33
34impl RAssertion {
35    /// Create a new assertion with the given error message and R condition expression.
36    fn new(message: impl Into<String>, condition: impl Into<String>) -> Self {
37        Self {
38            message: message.into(),
39            condition: condition.into(),
40        }
41    }
42
43    /// Format as a `stopifnot()` named argument: `"message" = condition`.
44    fn to_stopifnot_arg(&self) -> String {
45        format!("\"{}\" = {}", self.message, self.condition)
46    }
47
48    /// Wrap for nullable: prepend `is.null(param) || ` to the condition,
49    /// and adjust the message to mention NULL.
50    fn nullable(self, param: &str) -> Self {
51        let message = if self.message.contains("must be ") {
52            // "'x' must be character" → "'x' must be NULL or character"
53            self.message.replacen("must be ", "must be NULL or ", 1)
54        } else if self.message.contains("must have ") {
55            // "'x' must have length 1" → "'x' must be NULL or have length 1"
56            self.message
57                .replacen("must have ", "must be NULL or have ", 1)
58        } else {
59            format!("{} (or NULL)", self.message)
60        };
61        Self {
62            message,
63            condition: format!("is.null({}) || {}", param, self.condition),
64        }
65    }
66}
67
68/// Per-function knobs that influence precondition codegen.
69///
70/// Currently only the `coerce` knob matters: `#[miniextendr(coerce)]` (or a
71/// per-param `#[miniextendr(coerce)]`) changes the inbound conversion for an
72/// integer-element vector to read via the native `&[i32]` slice and then
73/// `TryCoerce` element-wise (see `rust_conversion_builder.rs` `CoercionMapping::Vec`).
74/// That `&[i32]` read is INTSXP-only, so a coerced integer vector that would
75/// otherwise accept whole-number `REALSXP` ([`RTypeCheck::VectorIntegerWide`])
76/// must instead get the strict `is.integer` gate ([`RTypeCheck::VectorIntegerStrict`])
77/// — otherwise a `double` passes the R precondition only to fail the Rust read
78/// with "expected INTSXP, got REALSXP" (issue #616).
79///
80/// `strict` is intentionally not represented: inbound conversion is identical in
81/// strict and default mode (`TryFromSexp`), so the R-side integer gate doesn't
82/// change. The precise strict checking happens on the Rust *outbound* side.
83#[derive(Clone, Default)]
84pub struct PreconditionOptions {
85    /// `coerce` knob is active for all parameters (`coerce_all`).
86    pub coerce_all: bool,
87    /// R-normalized names of parameters with a per-param `coerce` attribute.
88    pub coerce_params: HashSet<String>,
89}
90
91impl PreconditionOptions {
92    /// Returns `true` if the parameter `r_name` is coerced (function-wide or
93    /// per-param).
94    fn is_coerced(&self, r_name: &str) -> bool {
95        self.coerce_all || self.coerce_params.contains(r_name)
96    }
97}
98
99/// Classification of an R-side type check for a function parameter.
100///
101/// Each variant maps to a specific set of `stopifnot()` assertions. Numeric checks
102/// use a broad predicate (`is.numeric || is.logical || is.raw`) because R coerces
103/// logical to numeric freely and raw to integer is valid for byte-sized types.
104/// Borderline cases (e.g., raw to i64 in strict mode) pass the precondition and
105/// reach Rust's strict checker, which produces better contextual error messages.
106enum RTypeCheck {
107    /// Numeric scalar: type check + length-1 check (2 assertions).
108    /// Used for `i32`, `f64`, `f32`, `i8`, `i16`, `i64`, `isize`.
109    ScalarNumeric,
110    /// Non-negative numeric scalar: type + length-1 + `>= 0` (3 assertions).
111    /// Used for `u16`, `u32`, `u64`, `usize`.
112    ScalarNonNeg,
113    /// Non-numeric scalar: `is.<type>(x)` + length-1 check (2 assertions).
114    /// The string is the R type predicate name (e.g., `"logical"`, `"character"`).
115    Scalar(&'static str),
116    /// Floating-point numeric vector: loose `is.numeric || is.logical || is.raw`
117    /// (1 assertion). Used for `Vec<f64>` / `Vec<f32>` / `&[f64]` — doubles are
118    /// the natural representation, no truncation risk.
119    VectorNumeric,
120    /// **INTSXP-only** integer vector: `is.integer(x)` (1 assertion). Used *only*
121    /// for `Vec<i32>` / `&[i32]` (issue #616). These use the native `RNativeType`
122    /// inbound path (`impl_vec_try_from_sexp_native!(i32)`), which requires
123    /// `INTSXP` and rejects `REALSXP` outright. The loose `is.numeric` predicate
124    /// previously let a `double` like `c(1, 2)` pass the R gate only to fail with
125    /// a cryptic "expected INTSXP, got REALSXP". `is.integer(x)` rejects every
126    /// `double` (whole or fractional) at the boundary with a clean message,
127    /// matching the actual Rust behaviour and closing the silent-truncation gap.
128    VectorIntegerStrict,
129    /// **Wide** integer vector accepting `REALSXP` whole-number values: the
130    /// lossless whole-number predicate (1 assertion). Used for every non-`i32`
131    /// integer element type — `Vec<i8>` / `Vec<i16>` / `Vec<u16>` / `Vec<u32>`
132    /// and the 64-bit family `Vec<i64>` / `Vec<u64>` / `Vec<isize>` / `Vec<usize>`
133    /// (issue #616). These use the coercing inbound path
134    /// (`impl_vec_try_from_sexp_numeric!` → `from_numeric_vec_with`) which accepts
135    /// INTSXP/REALSXP/RAWSXP/LGLSXP and rejects fractional doubles element-wise
136    /// (`f64: TryCoerce<T>` checks `self.fract() != 0`). We accept
137    /// integer/logical/raw **and** whole-number doubles, and reject genuinely
138    /// lossy fractional doubles (`1.5`) at the boundary.
139    VectorIntegerWide,
140    /// Non-numeric vector: `is.<type>(x)` only (1 assertion).
141    /// The string is the R type predicate name.
142    Vector(&'static str),
143    /// Nullable wrapper around an inner check: prepends `is.null(x) ||` to each assertion
144    /// and adjusts messages to mention NULL.
145    Nullable(Box<RTypeCheck>),
146    /// List check: `is.list(x)` (1 assertion).
147    /// Used for `HashMap`, `BTreeMap`, `NamedList`, `List`, `ListMut`.
148    List,
149}
150
151/// Build the R expression for the numeric type predicate.
152///
153/// Returns `"is.numeric(p) || is.logical(p) || is.raw(p)"` for a given parameter `p`.
154/// This broad predicate matches R's coercion rules: logical coerces to numeric freely,
155/// and raw is accepted because it represents byte-level data.
156fn numeric_type_check(param: &str) -> String {
157    format!(
158        "is.numeric({p}) || is.logical({p}) || is.raw({p})",
159        p = param
160    )
161}
162
163/// Build the lossless whole-number predicate for a `REALSXP`-accepting integer
164/// vector (the `i64` / `u64` / `isize` / `usize` family).
165///
166/// Accepts integer/logical/raw and whole-number doubles, rejects fractional
167/// doubles. The whole-number test is NA-safe (`is.na(p) | p == trunc(p)`):
168/// `NA_real_` passes (maps to `NA_integer_`), `1.5` fails. `Inf`/`NaN` satisfy
169/// `x == trunc(x)` in R but are caught by the Rust-side range/NaN conversion
170/// check instead.
171fn integer_vector_wide_check(param: &str) -> String {
172    format!(
173        "is.integer({p}) || is.logical({p}) || is.raw({p}) || \
174         (is.numeric({p}) && all(is.na({p}) | {p} == trunc({p})))",
175        p = param
176    )
177}
178
179impl RTypeCheck {
180    /// Produce the individual `stopifnot()` assertions for this type check.
181    ///
182    /// Returns one or more `RAssertion` values, each representing a single
183    /// `"message" = condition` entry in the `stopifnot()` call. The `param`
184    /// argument is the R parameter name to use in messages and conditions.
185    fn assertions(&self, param: &str) -> Vec<RAssertion> {
186        match self {
187            RTypeCheck::ScalarNumeric => vec![
188                RAssertion::new(
189                    format!("'{}' must be numeric, logical, or raw", param),
190                    numeric_type_check(param),
191                ),
192                RAssertion::new(
193                    format!("'{}' must have length 1", param),
194                    format!("length({}) == 1L", param),
195                ),
196            ],
197            RTypeCheck::ScalarNonNeg => vec![
198                RAssertion::new(
199                    format!("'{}' must be numeric, logical, or raw", param),
200                    numeric_type_check(param),
201                ),
202                RAssertion::new(
203                    format!("'{}' must have length 1", param),
204                    format!("length({}) == 1L", param),
205                ),
206                RAssertion::new(
207                    format!("'{}' must be non-negative", param),
208                    // raw is always non-negative; guard with is.raw() to avoid
209                    // "comparison not implemented" error for raw values
210                    format!("is.raw({p}) || {p} >= 0", p = param),
211                ),
212            ],
213            RTypeCheck::Scalar(r_type) => vec![
214                RAssertion::new(
215                    format!("'{}' must be {}", param, r_type),
216                    format!("is.{}({})", r_type, param),
217                ),
218                RAssertion::new(
219                    format!("'{}' must have length 1", param),
220                    format!("length({}) == 1L", param),
221                ),
222            ],
223            RTypeCheck::VectorNumeric => vec![RAssertion::new(
224                format!("'{}' must be numeric, logical, or raw", param),
225                numeric_type_check(param),
226            )],
227            RTypeCheck::VectorIntegerStrict => vec![RAssertion::new(
228                format!("'{}' must be an integer vector", param),
229                format!("is.integer({})", param),
230            )],
231            RTypeCheck::VectorIntegerWide => vec![RAssertion::new(
232                format!("'{}' must be integer or whole-number numeric", param),
233                integer_vector_wide_check(param),
234            )],
235            RTypeCheck::Vector(r_type) => vec![RAssertion::new(
236                format!("'{}' must be {}", param, r_type),
237                format!("is.{}({})", r_type, param),
238            )],
239            RTypeCheck::Nullable(inner) => inner
240                .assertions(param)
241                .into_iter()
242                .map(|a| a.nullable(param))
243                .collect(),
244            RTypeCheck::List => vec![RAssertion::new(
245                format!("'{}' must be a list", param),
246                format!("is.list({})", param),
247            )],
248        }
249    }
250
251    /// Tighten the check for a coerced parameter.
252    ///
253    /// `#[miniextendr(coerce)]` on an integer-element vector reads via the native
254    /// `&[i32]` slice (INTSXP-only), so a [`RTypeCheck::VectorIntegerWide`] that
255    /// would otherwise accept whole-number `REALSXP` must become a strict
256    /// `is.integer` gate (issue #616). Recurses through [`RTypeCheck::Nullable`].
257    /// All other checks are unaffected. Type-sensitive tightening (e.g. `bool`,
258    /// whose logical gate must become an integer gate) lives in
259    /// [`coerce_tightened`], which needs the declared Rust type.
260    fn coerced(self) -> Self {
261        match self {
262            RTypeCheck::VectorIntegerWide => RTypeCheck::VectorIntegerStrict,
263            RTypeCheck::Nullable(inner) => RTypeCheck::Nullable(Box::new(inner.coerced())),
264            other => other,
265        }
266    }
267}
268
269/// Tighten the check for a coerced parameter, given the declared Rust type.
270///
271/// Under `coerce` (per-param, function-wide, or `coerce-default`), `bool`
272/// converts from R's native integer type (`CoercionMapping::from_type`:
273/// `bool` ← `i32`, INTSXP-only), so its R gate must be `is.integer` — keeping
274/// the `is.logical` gate makes the parameter unusable: R rejects the integers
275/// Rust accepts, and passes the logicals Rust rejects (same boundary-mismatch
276/// class as #616; surfaced by the feature-legs coerce-default run).
277/// `Rbool`/`Rboolean` have no coercion mapping and keep the logical gate.
278/// Everything else defers to [`RTypeCheck::coerced`].
279fn coerce_tightened(check: RTypeCheck, ty: &syn::Type) -> RTypeCheck {
280    let syn::Type::Path(tp) = ty else {
281        return check.coerced();
282    };
283    let Some(seg) = tp.path.segments.last() else {
284        return check.coerced();
285    };
286    match seg.ident.to_string().as_str() {
287        "bool" => match check {
288            RTypeCheck::Scalar(_) => RTypeCheck::Scalar("integer"),
289            other => other.coerced(),
290        },
291        // Vec<bool> reads via &[i32] under coerce — INTSXP-only.
292        "Vec" => match extract_single_generic_arg(seg) {
293            Some(syn::Type::Path(inner))
294                if inner
295                    .path
296                    .segments
297                    .last()
298                    .is_some_and(|s| s.ident == "bool") =>
299            {
300                RTypeCheck::VectorIntegerStrict
301            }
302            _ => check.coerced(),
303        },
304        "Option" => match (check, extract_single_generic_arg(seg)) {
305            (RTypeCheck::Nullable(inner_check), Some(inner_ty)) => {
306                RTypeCheck::Nullable(Box::new(coerce_tightened(*inner_check, inner_ty)))
307            }
308            (other, _) => other.coerced(),
309        },
310        _ => check.coerced(),
311    }
312}
313
314/// Map a Rust type to its R-side type check, if applicable.
315///
316/// Returns `None` for types that should skip precondition checks (SEXP, Dots, ExternalPtr, etc.).
317fn r_check_for_type(ty: &syn::Type) -> Option<RTypeCheck> {
318    match ty {
319        syn::Type::Path(type_path) => r_check_for_type_path(type_path),
320        syn::Type::Reference(type_ref) => r_check_for_reference(type_ref),
321        _ => None,
322    }
323}
324
325/// Map a `syn::TypePath` to its R-side type check.
326///
327/// Handles the most common case: simple types (`i32`, `String`, `bool`),
328/// generic wrappers (`Vec<T>`, `Option<T>`), map types, and skip types.
329/// Returns `None` for types that cannot be prechecked from R.
330fn r_check_for_type_path(type_path: &syn::TypePath) -> Option<RTypeCheck> {
331    let segment = type_path.path.segments.last()?;
332    let ident = segment.ident.to_string();
333
334    match ident.as_str() {
335        // Numeric scalars (accepts numeric, logical, and raw via R coercion)
336        "i32" | "f64" | "f32" | "i8" | "i16" | "i64" | "isize" => Some(RTypeCheck::ScalarNumeric),
337
338        // Unsigned numeric scalars (non-negative constraint)
339        "u16" | "u32" | "u64" | "usize" => Some(RTypeCheck::ScalarNonNeg),
340
341        // Logical scalar
342        "bool" | "Rbool" | "Rboolean" => Some(RTypeCheck::Scalar("logical")),
343
344        // Character scalar
345        "String" | "char" | "PathBuf" => Some(RTypeCheck::Scalar("character")),
346
347        // Raw scalar
348        "u8" => Some(RTypeCheck::Scalar("raw")),
349
350        // Complex scalar
351        "Rcomplex" => Some(RTypeCheck::Scalar("complex")),
352
353        // Option<T> → Nullable
354        "Option" => {
355            let inner_ty = extract_single_generic_arg(segment)?;
356            r_check_for_type(inner_ty).map(|inner| RTypeCheck::Nullable(Box::new(inner)))
357        }
358
359        // Vec<T> → Vector (depends on element type)
360        "Vec" => {
361            let inner_ty = extract_single_generic_arg(segment)?;
362            r_check_for_vec_element(inner_ty)
363        }
364
365        // Map types and named list → List
366        "HashMap" | "BTreeMap" | "NamedList" => Some(RTypeCheck::List),
367
368        // List (bare) → List
369        "List" | "ListMut" => Some(RTypeCheck::List),
370
371        // Skip types: SEXP, Dots, Missing, ExternalPtr, RLogical, etc.
372        "SEXP" | "Dots" | "Missing" | "ExternalPtr" | "OwnedProtect" => None,
373
374        // Unknown type → skip (let Rust side validate)
375        _ => None,
376    }
377}
378
379/// Map a reference type to its R-side type check.
380///
381/// Handles `&str` and `&Path` (character scalar), `&[T]` (vector based on element type),
382/// and `&Dots` (skipped). Returns `None` for unrecognized reference types.
383fn r_check_for_reference(type_ref: &syn::TypeReference) -> Option<RTypeCheck> {
384    match type_ref.elem.as_ref() {
385        // &str → character scalar
386        syn::Type::Path(tp) => {
387            let seg = tp.path.segments.last()?;
388            match seg.ident.to_string().as_str() {
389                "str" => Some(RTypeCheck::Scalar("character")),
390                "Path" => Some(RTypeCheck::Scalar("character")),
391                "Dots" => None,
392                _ => None,
393            }
394        }
395        // &[T] → vector check based on element type
396        syn::Type::Slice(slice) => r_check_for_vec_element(&slice.elem),
397        _ => None,
398    }
399}
400
401/// Map a `Vec<T>` or `&[T]` element type to the appropriate vector type check.
402///
403/// Numeric elements produce `VectorNumeric`, `bool` produces `Vector("logical")`,
404/// `String` produces `Vector("character")`, etc. Handles nested `Option<T>` for
405/// nullable element types (e.g., `Vec<Option<String>>` becomes character vector).
406fn r_check_for_vec_element(elem_ty: &syn::Type) -> Option<RTypeCheck> {
407    let syn::Type::Path(tp) = elem_ty else {
408        return None;
409    };
410    let seg = tp.path.segments.last()?;
411    let ident = seg.ident.to_string();
412
413    match ident.as_str() {
414        // Floating-point vectors: doubles are the natural form, loose predicate.
415        "f64" | "f32" => Some(RTypeCheck::VectorNumeric),
416
417        // `Vec<i32>` / `&[i32]` is the *only* INTSXP-only integer vector (issue
418        // #616): its inbound conversion uses the native `RNativeType` path
419        // (`impl_vec_try_from_sexp_native!(i32)` in `from_r/collections.rs`),
420        // which rejects REALSXP outright. So the R gate is `is.integer` — a
421        // `double` like `c(1, 2)` fails cleanly at the boundary instead of
422        // producing a cryptic "expected INTSXP, got REALSXP". `u8` is handled
423        // below as a raw vector.
424        "i32" => Some(RTypeCheck::VectorIntegerStrict),
425
426        // Every other integer-element vector (`i8`/`i16`/`u16`/`u32` and the
427        // 64-bit family `i64`/`u64`/`isize`/`usize`) uses the coercing inbound
428        // path (`impl_vec_try_from_sexp_numeric!` → `from_numeric_vec_with`),
429        // which accepts INTSXP/REALSXP/RAWSXP/LGLSXP and rejects fractional
430        // doubles element-wise via `f64: TryCoerce<T>` (the `self.fract() != 0`
431        // check in `coerce.rs`). The R gate mirrors that: accept integer/logical/
432        // raw + whole-number doubles, reject fractional. (64-bit ints also arrive
433        // as REALSXP since R has no native 64-bit integer type.)
434        "i8" | "i16" | "u16" | "u32" | "i64" | "u64" | "isize" | "usize" => {
435            Some(RTypeCheck::VectorIntegerWide)
436        }
437
438        // Logical vector
439        "bool" => Some(RTypeCheck::Vector("logical")),
440
441        // Character vector
442        "String" => Some(RTypeCheck::Vector("character")),
443
444        // Raw vector
445        "u8" => Some(RTypeCheck::Vector("raw")),
446
447        // Complex vector
448        "Rcomplex" => Some(RTypeCheck::Vector("complex")),
449
450        // Vec<Option<T>> — e.g., Vec<Option<String>> for nullable strings
451        "Option" => {
452            let inner = extract_single_generic_arg(seg)?;
453            // Vec<Option<String>> → character, Vec<Option<i32>> → numeric, etc.
454            r_check_for_vec_element(inner)
455        }
456
457        _ => None,
458    }
459}
460
461/// Extract the single generic type argument from a path segment.
462///
463/// e.g., `Option<String>` → `String`, `Vec<i32>` → `i32`
464fn extract_single_generic_arg(segment: &syn::PathSegment) -> Option<&syn::Type> {
465    if let syn::PathArguments::AngleBracketed(ref args) = segment.arguments
466        && let Some(syn::GenericArgument::Type(ty)) = args.args.first()
467    {
468        return Some(ty);
469    }
470    None
471}
472
473/// A parameter whose Rust type is not in the static type table.
474///
475/// Currently, fallback params are recorded but no R-side validation is generated
476/// for them -- the Rust-side conversion handles type errors with its own messages.
477#[allow(dead_code)] // Read in tests
478pub struct FallbackParam {
479    /// R-normalized parameter name (e.g., `_dots` becomes `.dots`).
480    pub r_name: String,
481}
482
483/// Output of precondition analysis for a function's parameters.
484///
485/// Contains both the generated R `stopifnot()` code for known types and a list
486/// of parameters with unknown types that were not statically prechecked.
487pub struct PreconditionOutput {
488    /// Lines forming a `stopifnot(...)` call for known types.
489    ///
490    /// Empty if no parameters have known type checks. For a single assertion,
491    /// contains one line (`stopifnot(...)`). For multiple assertions, contains
492    /// `stopifnot(`, indented assertion lines, and `)`.
493    pub static_checks: Vec<String>,
494    /// Parameters with unknown custom types that were not prechecked.
495    #[allow(dead_code)] // Read in tests
496    pub fallback_params: Vec<FallbackParam>,
497}
498
499/// Returns `true` for types that should never get a fallback precheck.
500///
501/// These types are either handled specially by the FFI layer (`SEXP`),
502/// consumed by the macro infrastructure (`Dots`, `Missing`), or managed
503/// internally (`ExternalPtr`, `OwnedProtect`).
504fn is_skip_type(ident: &str) -> bool {
505    matches!(
506        ident,
507        "SEXP" | "Dots" | "Missing" | "ExternalPtr" | "OwnedProtect"
508    )
509}
510
511/// Returns `true` if a type is unknown to the static type table and should
512/// be recorded as a fallback parameter.
513///
514/// Returns `false` for skip types (SEXP, Dots, etc.) and reference types
515/// (which are handled by the static table or skipped).
516fn needs_fallback(ty: &syn::Type) -> bool {
517    match ty {
518        syn::Type::Path(tp) => {
519            let Some(seg) = tp.path.segments.last() else {
520                return false;
521            };
522            !is_skip_type(&seg.ident.to_string())
523        }
524        // References (&str, &[T], &Dots) are handled by static table or skipped
525        syn::Type::Reference(_) => false,
526        _ => false,
527    }
528}
529
530/// Build precondition checks for a function's parameters.
531///
532/// Returns:
533/// - **`static_checks`**: Lines forming a `stopifnot(...)` call for known types
534/// - **`fallback_params`**: Parameters needing validation (unknown custom types)
535///
536/// Static checks produce R-side `stopifnot()`:
537/// ```r
538/// stopifnot(
539///   "'a' must be numeric, logical, or raw" = is.numeric(a) || is.logical(a) || is.raw(a),
540///   "'a' must have length 1" = length(a) == 1L
541/// )
542/// ```
543///
544/// Skips:
545/// - `self`/`&self`/`&mut self` (receiver args)
546/// - Parameters in `skip_params` (e.g., match_arg params already validated)
547/// - Skip types (SEXP, Dots, ExternalPtr, etc.)
548pub fn build_precondition_checks(
549    inputs: &syn::punctuated::Punctuated<syn::FnArg, syn::Token![,]>,
550    skip_params: &HashSet<String>,
551    opts: &PreconditionOptions,
552) -> PreconditionOutput {
553    let mut args = Vec::new();
554    let mut fallback_params = Vec::new();
555
556    for arg in inputs {
557        // Skip receiver (self/&self/&mut self)
558        let syn::FnArg::Typed(pt) = arg else {
559            continue;
560        };
561
562        // Extract parameter name
563        let syn::Pat::Ident(pat_ident) = pt.pat.as_ref() else {
564            continue;
565        };
566
567        // Use the R-normalized name for the check (matches the R formal)
568        let r_name = crate::r_wrapper_builder::normalize_r_arg_ident(&pat_ident.ident).to_string();
569
570        // Skip match_arg params (already validated by match.arg())
571        if skip_params.contains(&r_name) {
572            continue;
573        }
574
575        // Map the Rust type to R assertions (known types). A coerced parameter
576        // reads via its R-native type (`&[i32]`/`i32`/`f64`), so tighten its
577        // check to match (#616; bool → integer gate).
578        if let Some(mut check) = r_check_for_type(pt.ty.as_ref()) {
579            if opts.is_coerced(&r_name) {
580                check = coerce_tightened(check, pt.ty.as_ref());
581            }
582            for assertion in check.assertions(&r_name) {
583                args.push(assertion.to_stopifnot_arg());
584            }
585        } else if needs_fallback(pt.ty.as_ref()) {
586            // Unknown type → record for potential future validation
587            fallback_params.push(FallbackParam { r_name });
588        }
589    }
590
591    let static_checks = match args.len() {
592        0 => Vec::new(),
593        1 => vec![format!("stopifnot({})", args[0])],
594        _ => {
595            let mut lines = Vec::with_capacity(args.len() + 2);
596            lines.push("stopifnot(".to_string());
597            for (i, arg) in args.iter().enumerate() {
598                let comma = if i < args.len() - 1 { "," } else { "" };
599                lines.push(format!("  {}{}", arg, comma));
600            }
601            lines.push(")".to_string());
602            lines
603        }
604    };
605
606    PreconditionOutput {
607        static_checks,
608        fallback_params,
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    /// Helper to parse a type string into syn::Type
617    fn parse_type(s: &str) -> syn::Type {
618        syn::parse_str(s).unwrap()
619    }
620
621    /// Helper to get assertions for a type.
622    fn assertions_for(ty_str: &str, param: &str) -> Vec<RAssertion> {
623        let ty = parse_type(ty_str);
624        r_check_for_type(&ty).unwrap().assertions(param)
625    }
626
627    #[test]
628    fn scalar_numeric_produces_two_assertions() {
629        let asserts = assertions_for("i32", "x");
630        assert_eq!(asserts.len(), 2);
631        assert_eq!(asserts[0].message, "'x' must be numeric, logical, or raw");
632        assert_eq!(
633            asserts[0].condition,
634            "is.numeric(x) || is.logical(x) || is.raw(x)"
635        );
636        assert_eq!(asserts[1].message, "'x' must have length 1");
637        assert_eq!(asserts[1].condition, "length(x) == 1L");
638    }
639
640    #[test]
641    fn all_signed_numeric_types_use_scalar_numeric() {
642        for ty_str in &["i32", "f64", "f32", "i8", "i16", "i64", "isize"] {
643            let asserts = assertions_for(ty_str, "x");
644            assert_eq!(asserts.len(), 2, "{} should produce 2 assertions", ty_str);
645            assert!(
646                asserts[0].condition.contains("is.numeric(x)"),
647                "{} type check",
648                ty_str
649            );
650            assert!(
651                asserts[0].condition.contains("is.logical(x)"),
652                "{} accepts logical",
653                ty_str
654            );
655            assert!(
656                asserts[0].condition.contains("is.raw(x)"),
657                "{} accepts raw",
658                ty_str
659            );
660        }
661    }
662
663    #[test]
664    fn scalar_non_neg_produces_three_assertions() {
665        let asserts = assertions_for("u32", "n");
666        assert_eq!(asserts.len(), 3);
667        assert_eq!(asserts[0].message, "'n' must be numeric, logical, or raw");
668        assert_eq!(asserts[1].message, "'n' must have length 1");
669        assert_eq!(asserts[2].message, "'n' must be non-negative");
670        assert_eq!(asserts[2].condition, "is.raw(n) || n >= 0");
671    }
672
673    #[test]
674    fn all_unsigned_types_use_scalar_non_neg() {
675        for ty_str in &["u16", "u32", "u64", "usize"] {
676            let asserts = assertions_for(ty_str, "x");
677            assert_eq!(asserts.len(), 3, "{} should produce 3 assertions", ty_str);
678            assert!(
679                asserts[2].condition.contains(">= 0"),
680                "{} non-neg check",
681                ty_str
682            );
683        }
684    }
685
686    #[test]
687    fn scalar_logical() {
688        let asserts = assertions_for("bool", "x");
689        assert_eq!(asserts.len(), 2);
690        assert_eq!(asserts[0].message, "'x' must be logical");
691        assert_eq!(asserts[0].condition, "is.logical(x)");
692        assert_eq!(asserts[1].condition, "length(x) == 1L");
693    }
694
695    #[test]
696    fn scalar_character() {
697        for ty_str in &["String", "char", "PathBuf"] {
698            let asserts = assertions_for(ty_str, "s");
699            assert_eq!(asserts.len(), 2);
700            assert_eq!(asserts[0].message, "'s' must be character");
701            assert_eq!(asserts[0].condition, "is.character(s)");
702        }
703    }
704
705    #[test]
706    fn ref_str() {
707        let ty: syn::Type = syn::parse_str("& str").unwrap();
708        let asserts = r_check_for_type(&ty).unwrap().assertions("s");
709        assert_eq!(asserts.len(), 2);
710        assert_eq!(asserts[0].condition, "is.character(s)");
711    }
712
713    #[test]
714    fn scalar_raw() {
715        let asserts = assertions_for("u8", "x");
716        assert_eq!(asserts.len(), 2);
717        assert_eq!(asserts[0].message, "'x' must be raw");
718        assert_eq!(asserts[0].condition, "is.raw(x)");
719    }
720
721    #[test]
722    fn vector_float_stays_loose() {
723        // Float vectors keep the loose predicate — doubles are the natural form.
724        for ty_str in &["Vec<f64>", "Vec<f32>"] {
725            let asserts = assertions_for(ty_str, "x");
726            assert_eq!(asserts.len(), 1, "{} should produce 1 assertion", ty_str);
727            assert_eq!(
728                asserts[0].condition,
729                "is.numeric(x) || is.logical(x) || is.raw(x)"
730            );
731        }
732    }
733
734    #[test]
735    fn vector_i32_is_intsxp_strict() {
736        // `Vec<i32>` is the only INTSXP-only integer vector: the native inbound
737        // conversion rejects REALSXP, so the R gate is `is.integer(x)` — a clean
738        // boundary rejection for any double (issue #616).
739        let asserts = assertions_for("Vec<i32>", "x");
740        assert_eq!(asserts.len(), 1);
741        assert_eq!(asserts[0].condition, "is.integer(x)");
742        assert_eq!(asserts[0].message, "'x' must be an integer vector");
743    }
744
745    #[test]
746    fn vector_integer_wide_is_lossless() {
747        // Every non-i32 integer element type uses the coercing inbound path that
748        // accepts whole REALSXP and rejects fractional → lossless whole-number gate.
749        for ty_str in &[
750            "Vec<i8>",
751            "Vec<i16>",
752            "Vec<u16>",
753            "Vec<u32>",
754            "Vec<i64>",
755            "Vec<u64>",
756            "Vec<isize>",
757            "Vec<usize>",
758        ] {
759            let asserts = assertions_for(ty_str, "x");
760            assert_eq!(asserts.len(), 1, "{} should produce 1 assertion", ty_str);
761            assert_eq!(
762                asserts[0].condition,
763                "is.integer(x) || is.logical(x) || is.raw(x) || \
764                 (is.numeric(x) && all(is.na(x) | x == trunc(x)))",
765                "{}",
766                ty_str
767            );
768            assert_eq!(
769                asserts[0].message,
770                "'x' must be integer or whole-number numeric"
771            );
772        }
773    }
774
775    #[test]
776    fn slice_int_is_strict() {
777        let ty: syn::Type = syn::parse_str("& [i32]").unwrap();
778        let asserts = r_check_for_type(&ty).unwrap().assertions("x");
779        assert_eq!(asserts.len(), 1);
780        assert_eq!(asserts[0].condition, "is.integer(x)");
781    }
782
783    #[test]
784    fn coerced_wide_integer_vec_tightens_to_strict() {
785        // A coerced `Vec<u16>` reads via `&[i32]` (INTSXP-only), so its Wide gate
786        // must tighten to `is.integer` (#616).
787        let check = r_check_for_type(&parse_type("Vec<u16>")).unwrap();
788        let coerced = check.coerced();
789        let asserts = coerced.assertions("x");
790        assert_eq!(asserts.len(), 1);
791        assert_eq!(asserts[0].condition, "is.integer(x)");
792    }
793
794    #[test]
795    fn coerced_does_not_change_float_or_i32() {
796        // i32 is already strict; floats are unaffected by coerce.
797        let i32_coerced = r_check_for_type(&parse_type("Vec<i32>")).unwrap().coerced();
798        assert_eq!(i32_coerced.assertions("x")[0].condition, "is.integer(x)");
799        let f64_coerced = r_check_for_type(&parse_type("Vec<f64>")).unwrap().coerced();
800        assert_eq!(
801            f64_coerced.assertions("x")[0].condition,
802            "is.numeric(x) || is.logical(x) || is.raw(x)"
803        );
804    }
805
806    #[test]
807    fn build_checks_coerced_param_uses_is_integer() {
808        let sig: syn::Signature = syn::parse_str("fn f(x: Vec<u16>)").unwrap();
809        let mut coerce_params = HashSet::new();
810        coerce_params.insert("x".to_string());
811        let opts = PreconditionOptions {
812            coerce_all: false,
813            coerce_params,
814        };
815        let output = build_precondition_checks(&sig.inputs, &HashSet::new(), &opts);
816        let joined = output.static_checks.join("\n");
817        assert!(joined.contains("is.integer(x)"));
818        assert!(!joined.contains("trunc(x)"));
819    }
820
821    #[test]
822    fn vector_character() {
823        let asserts = assertions_for("Vec<String>", "x");
824        assert_eq!(asserts.len(), 1);
825        assert_eq!(asserts[0].condition, "is.character(x)");
826    }
827
828    #[test]
829    fn vector_optional_string() {
830        let asserts = assertions_for("Vec<Option<String>>", "x");
831        assert_eq!(asserts.len(), 1);
832        assert_eq!(asserts[0].condition, "is.character(x)");
833    }
834
835    #[test]
836    fn slice_u8() {
837        let ty: syn::Type = syn::parse_str("& [u8]").unwrap();
838        let asserts = r_check_for_type(&ty).unwrap().assertions("x");
839        assert_eq!(asserts.len(), 1);
840        assert_eq!(asserts[0].condition, "is.raw(x)");
841    }
842
843    #[test]
844    fn nullable_wraps_inner_assertions() {
845        let asserts = assertions_for("Option<i32>", "x");
846        assert_eq!(asserts.len(), 2);
847        assert_eq!(
848            asserts[0].message,
849            "'x' must be NULL or numeric, logical, or raw"
850        );
851        assert_eq!(
852            asserts[0].condition,
853            "is.null(x) || is.numeric(x) || is.logical(x) || is.raw(x)"
854        );
855        assert_eq!(asserts[1].message, "'x' must be NULL or have length 1");
856        assert_eq!(asserts[1].condition, "is.null(x) || length(x) == 1L");
857    }
858
859    #[test]
860    fn nullable_character() {
861        let asserts = assertions_for("Option<String>", "s");
862        assert_eq!(asserts.len(), 2);
863        assert_eq!(asserts[0].message, "'s' must be NULL or character");
864        assert_eq!(asserts[0].condition, "is.null(s) || is.character(s)");
865        assert_eq!(asserts[1].message, "'s' must be NULL or have length 1");
866    }
867
868    #[test]
869    fn map_types() {
870        for ty_str in &["HashMap<String, i32>", "BTreeMap<String, f64>"] {
871            let ty = parse_type(ty_str);
872            let asserts = r_check_for_type(&ty).unwrap().assertions("x");
873            assert_eq!(asserts.len(), 1);
874            assert_eq!(asserts[0].condition, "is.list(x)");
875        }
876    }
877
878    #[test]
879    fn skip_types() {
880        for ty_str in &["SEXP", "ExternalPtr<MyType>"] {
881            let ty = parse_type(ty_str);
882            assert!(
883                r_check_for_type(&ty).is_none(),
884                "{} should be skipped",
885                ty_str
886            );
887        }
888    }
889
890    #[test]
891    fn single_param_produces_multi_line() {
892        // i32 produces 2 assertions → always multi-line now
893        let sig: syn::Signature = syn::parse_str("fn f(n: i32)").unwrap();
894        let output = build_precondition_checks(
895            &sig.inputs,
896            &HashSet::new(),
897            &PreconditionOptions::default(),
898        );
899        let checks = &output.static_checks;
900        assert_eq!(checks.len(), 4); // stopifnot( + 2 args + )
901        assert_eq!(checks[0], "stopifnot(");
902        assert!(checks[1].contains("numeric, logical, or raw"));
903        assert!(checks[2].contains("length 1"));
904        assert_eq!(checks[3], ")");
905        assert!(output.fallback_params.is_empty());
906    }
907
908    #[test]
909    fn vector_param_single_line() {
910        // Vec<f64> produces 1 assertion → single line
911        let sig: syn::Signature = syn::parse_str("fn f(x: Vec<f64>)").unwrap();
912        let output = build_precondition_checks(
913            &sig.inputs,
914            &HashSet::new(),
915            &PreconditionOptions::default(),
916        );
917        let checks = &output.static_checks;
918        assert_eq!(checks.len(), 1);
919        assert!(checks[0].starts_with("stopifnot("));
920        assert!(checks[0].ends_with(')'));
921    }
922
923    #[test]
924    fn two_scalar_params_produces_six_lines() {
925        let sig: syn::Signature = syn::parse_str("fn f(a: i32, b: f64)").unwrap();
926        let output = build_precondition_checks(
927            &sig.inputs,
928            &HashSet::new(),
929            &PreconditionOptions::default(),
930        );
931        let checks = &output.static_checks;
932        // stopifnot( + 4 assertions (2 per param) + )
933        assert_eq!(checks.len(), 6);
934        assert_eq!(checks[0], "stopifnot(");
935        assert!(checks[1].contains("'a'") && checks[1].contains("numeric"));
936        assert!(checks[2].contains("'a'") && checks[2].contains("length 1"));
937        assert!(checks[3].contains("'b'") && checks[3].contains("numeric"));
938        assert!(checks[4].contains("'b'") && checks[4].contains("length 1"));
939        assert_eq!(checks[5], ")");
940    }
941
942    #[test]
943    fn build_checks_skips_match_arg() {
944        let sig: syn::Signature = syn::parse_str("fn f(n: i32, mode: String)").unwrap();
945        let mut skip = HashSet::new();
946        skip.insert("mode".to_string());
947        let output = build_precondition_checks(&sig.inputs, &skip, &PreconditionOptions::default());
948        // Only n's 2 assertions remain
949        let joined = output.static_checks.join("\n");
950        assert!(joined.contains("'n'"));
951        assert!(!joined.contains("'mode'"));
952    }
953
954    #[test]
955    fn unknown_type_produces_fallback() {
956        let sig: syn::Signature = syn::parse_str("fn f(x: MyCustomType)").unwrap();
957        let output = build_precondition_checks(
958            &sig.inputs,
959            &HashSet::new(),
960            &PreconditionOptions::default(),
961        );
962        assert!(output.static_checks.is_empty());
963        assert_eq!(output.fallback_params.len(), 1);
964        assert_eq!(output.fallback_params[0].r_name, "x");
965    }
966
967    #[test]
968    fn mixed_known_and_unknown_types() {
969        let sig: syn::Signature = syn::parse_str("fn f(a: i32, b: MyType, c: String)").unwrap();
970        let output = build_precondition_checks(
971            &sig.inputs,
972            &HashSet::new(),
973            &PreconditionOptions::default(),
974        );
975        // a (i32) and c (String) are known → static checks
976        let joined = output.static_checks.join("\n");
977        assert!(joined.contains("'a'"));
978        assert!(joined.contains("'c'"));
979        assert!(!joined.contains("'b'"));
980        // b (MyType) is unknown → fallback
981        assert_eq!(output.fallback_params.len(), 1);
982        assert_eq!(output.fallback_params[0].r_name, "b");
983    }
984
985    #[test]
986    fn sexp_not_fallback() {
987        let sig: syn::Signature = syn::parse_str("fn f(x: SEXP)").unwrap();
988        let output = build_precondition_checks(
989            &sig.inputs,
990            &HashSet::new(),
991            &PreconditionOptions::default(),
992        );
993        assert!(output.static_checks.is_empty());
994        assert!(output.fallback_params.is_empty());
995    }
996}