Skip to main content

miniextendr_macros/r_macro/
lowering.rs

1//! Lowering of the call-shaped `r!()` subset to `RCall`-based Rf_lang* construction.
2//!
3//! # Strategy
4//!
5//! After grammar validation, the macro attempts to classify the tail token
6//! stream as a **lowerable call**: a single top-level call of the form
7//! `ident(args…)` or `pkg::fn(args…)` whose every argument belongs to the
8//! lowerable atom set (string literals, integer/float literals, symbolic
9//! constants, bare identifiers, nested lowerable calls, or `name = value`
10//! named args).
11//!
12//! When lowerable, the macro emits code that builds the call tree with
13//! `RCall` + literal→SEXP constructors and evaluates it. This avoids the
14//! parse-then-eval round-trip through `r_eval_str`.
15//!
16//! When NOT lowerable (operators, assignments, control flow, `[`/`$`/`@`,
17//! any ambiguity), it falls back to the original `r_eval_str` string path.
18//! The fallback is the safety guarantee — no accepted input can start failing.
19//!
20//! # Lowerable subset (exhaustive)
21//!
22//! ## Top-level shape
23//! - `ident(args…)` — simple function call
24//! - `pkg::fn(args…)` — namespaced call
25//!
26//! ## Arg atoms
27//! - String literal (`"hello"`)
28//! - Integer literal (`42L` — one proc_macro2 token with the `L` suffix).
29//!   An unsuffixed `42` lowers as a **double**, matching R's parser (R only
30//!   produces INTSXP for `L`-suffixed literals; `typeof(42)` is `"double"`)
31//! - Float literal (`1.5`, `1e3`)
32//! - Symbolic constants: `TRUE`, `FALSE`, `NULL`, `NA`, `NA_integer_`,
33//!   `NA_real_`, `NA_character_`, `NA_complex_`, `Inf`, `NaN`
34//! - Bare identifier (symbol lookup at eval time)
35//! - Nested lowerable call
36//! - Named arg: `name = <atom>`
37//!
38//! Everything else → fallback.
39
40use proc_macro2::{Delimiter, Group, TokenStream, TokenTree};
41use quote::quote;
42
43// region: Public entry point
44
45/// Attempt to lower a validated R tail token stream to a `RCall`-based
46/// `TokenStream`.
47///
48/// Returns `Some(ts)` when the tail is fully lowerable; `None` to signal
49/// the caller should emit the `r_eval_str` string path instead.
50pub(crate) fn try_lower(tail: &TokenStream, env_expr: &TokenStream) -> Option<TokenStream> {
51    let tokens: Vec<TokenTree> = tail.clone().into_iter().collect();
52    let call = classify_call(&tokens)?;
53    Some(emit_call(&call, env_expr))
54}
55
56// endregion
57
58// region: AST for lowerable calls
59
60/// A lowerable R call expression.
61#[derive(Debug)]
62pub(crate) struct LowerCall {
63    /// The function to call. Either `plain::name` or namespaced `pkg::fun`.
64    fun: LowerFun,
65    /// The arguments.
66    args: Vec<LowerArg>,
67}
68
69/// Function designator.
70#[derive(Debug)]
71enum LowerFun {
72    /// Simple `ident(args…)` — `Rf_install("ident")`.
73    Simple(String),
74    /// `pkg::fun(args…)`.
75    Namespaced { pkg: String, fun: String },
76}
77
78/// A single argument (positional or named).
79#[derive(Debug)]
80struct LowerArg {
81    name: Option<String>,
82    value: LowerAtom,
83}
84
85/// An atom that maps directly to an R SEXP constructor.
86#[derive(Debug)]
87enum LowerAtom {
88    /// `"hello"` — `SEXP::scalar_string_from_str(…)`
89    StringLit(String),
90    /// `42L` — `SEXP::scalar_integer(…)`
91    IntLit(i32),
92    /// `1.5`, `1e3`, or unsuffixed `42` (R parses unsuffixed numeric
93    /// literals as double) — `SEXP::scalar_real(…)`
94    RealLit(f64),
95    /// `TRUE` / `FALSE` — `SEXP::scalar_logical(…)`
96    Bool(bool),
97    /// `NULL` — `SEXP::nil()`
98    Null,
99    /// Bare `NA` — **logical** NA (`typeof(NA)` is `"logical"` in R)
100    Na,
101    /// `NA_integer_` — `SEXP::scalar_integer(i32::MIN)`
102    NaInteger,
103    /// `NA_real_` — `SEXP::scalar_real(NA_REAL)`
104    NaReal,
105    /// `NA_character_` — `SEXP::scalar_string(SEXP::na_string())`
106    NaCharacter,
107    /// `Inf` — `SEXP::scalar_real(f64::INFINITY)`
108    Inf,
109    /// `NaN` — `SEXP::scalar_real(f64::NAN)`
110    NaN,
111    /// Bare identifier (symbol) — `Rf_install` at eval time via RSymbol
112    Symbol(String),
113    /// Nested lowerable call
114    Call(Box<LowerCall>),
115}
116
117// endregion
118
119// region: Classifier
120
121/// Classify a flat token slice as a lowerable top-level call.
122///
123/// Returns `None` for anything that's not a lowerable call.
124///
125/// Supported patterns:
126/// - `ident(args…)` — simple call, 2 tokens
127/// - `ident.ident.…(args…)` — R dot-name call (e.g. `is.null`), multiple tokens + group
128/// - `pkg::fn(args…)` — namespaced call, 5 tokens
129fn classify_call(tokens: &[TokenTree]) -> Option<LowerCall> {
130    // We need at minimum an Ident + a paren group.
131    if tokens.len() < 2 {
132        return None;
133    }
134
135    // The last token must be a parenthesis group.
136    let group = match tokens.last() {
137        Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Parenthesis => g,
138        _ => return None,
139    };
140
141    let head = &tokens[..tokens.len() - 1];
142
143    // Case 1: single ident `ident(args…)`
144    if let [TokenTree::Ident(name)] = head {
145        let fun = LowerFun::Simple(name.to_string());
146        let args = classify_args(group)?;
147        return Some(LowerCall { fun, args });
148    }
149
150    // Case 2: `pkg::fn(args…)` — five tokens total (4 in head)
151    // Tokenises as: Ident("pkg") Punct(':', Joint) Punct(':') Ident("fn") Group(…)
152    // The first `:` must be Joint-spaced: `pkg : : fn(x)` (spaced) is an R
153    // parse error and must fall back so the string path rejects it at runtime.
154    if let [
155        TokenTree::Ident(pkg),
156        TokenTree::Punct(c1),
157        TokenTree::Punct(c2),
158        TokenTree::Ident(fun),
159    ] = head
160        && c1.as_char() == ':'
161        && c1.spacing() == proc_macro2::Spacing::Joint
162        && c2.as_char() == ':'
163    {
164        let fun = LowerFun::Namespaced {
165            pkg: pkg.to_string(),
166            fun: fun.to_string(),
167        };
168        let args = classify_args(group)?;
169        return Some(LowerCall { fun, args });
170    }
171
172    // Case 3: R dot-name `ident.ident.…(args…)` (e.g. `is.null`, `as.character`)
173    // Head alternates: Ident Punct('.') Ident Punct('.') … Ident
174    // Accept only if every separator is '.' and no other punct.
175    if let Some(name) = try_parse_r_dotname(head) {
176        let fun = LowerFun::Simple(name);
177        let args = classify_args(group)?;
178        return Some(LowerCall { fun, args });
179    }
180
181    // Anything else (operators, assignments, control flow, etc.) → fallback
182    None
183}
184
185/// Try to parse a token sequence as an R dot-separated function name.
186///
187/// Accepts alternating `Ident Punct('.') Ident Punct('.') … Ident` (odd
188/// number of tokens, starting and ending with Ident). Returns the
189/// reconstructed name string (e.g. `"is.null"`) or `None`.
190fn try_parse_r_dotname(tokens: &[TokenTree]) -> Option<String> {
191    if tokens.is_empty() || tokens.len().is_multiple_of(2) {
192        return None;
193    }
194    // Must start and end with Ident, alternating with '.' puncts.
195    let mut name = String::new();
196    for (i, tt) in tokens.iter().enumerate() {
197        if i % 2 == 0 {
198            // Ident position.
199            match tt {
200                TokenTree::Ident(id) => {
201                    if i > 0 {
202                        name.push('.');
203                    }
204                    name.push_str(&id.to_string());
205                }
206                _ => return None,
207            }
208        } else {
209            // Separator position: must be '.'.
210            match tt {
211                TokenTree::Punct(p) if p.as_char() == '.' => {}
212                _ => return None,
213            }
214        }
215    }
216    if name.is_empty() { None } else { Some(name) }
217}
218
219/// Parse the argument group of a call into `LowerArg` list.
220/// Returns `None` if any argument is not lowerable.
221fn classify_args(g: &Group) -> Option<Vec<LowerArg>> {
222    let inner: Vec<TokenTree> = g.stream().into_iter().collect();
223    if inner.is_empty() {
224        return Some(Vec::new());
225    }
226    // Split by top-level commas.
227    let segments = split_by_comma_owned(&inner);
228    let mut args = Vec::with_capacity(segments.len());
229    for seg in segments {
230        args.push(classify_one_arg(&seg)?);
231    }
232    Some(args)
233}
234
235/// Split a flat `Vec<TokenTree>` by top-level commas.
236fn split_by_comma_owned(tokens: &[TokenTree]) -> Vec<Vec<TokenTree>> {
237    let mut result: Vec<Vec<TokenTree>> = Vec::new();
238    let mut current: Vec<TokenTree> = Vec::new();
239    for tt in tokens {
240        if matches!(tt, TokenTree::Punct(p) if p.as_char() == ',') {
241            result.push(current.clone());
242            current.clear();
243        } else {
244            current.push(tt.clone());
245        }
246    }
247    result.push(current);
248    result
249}
250
251/// Classify one argument slot (possibly `name = atom`).
252fn classify_one_arg(tokens: &[TokenTree]) -> Option<LowerArg> {
253    // Skip leading/trailing whitespace by trimming empty.
254    // Check for `name = value` shape:
255    //   tokens[0] = Ident, tokens[1] = Punct('='), tokens[2..] = value
256    if tokens.len() >= 3
257        && let (TokenTree::Ident(name), TokenTree::Punct(eq)) = (&tokens[0], &tokens[1])
258        && eq.as_char() == '='
259    {
260        let name_str = name.to_string();
261        let value_tokens = &tokens[2..];
262        let atom = classify_atom(value_tokens)?;
263        return Some(LowerArg {
264            name: Some(name_str),
265            value: atom,
266        });
267    }
268
269    // Positional arg.
270    let atom = classify_atom(tokens)?;
271    Some(LowerArg {
272        name: None,
273        value: atom,
274    })
275}
276
277/// Classify a token slice as a lowerable atom.
278fn classify_atom(tokens: &[TokenTree]) -> Option<LowerAtom> {
279    match tokens {
280        // String literal: `"hello"`
281        [TokenTree::Literal(lit)] => {
282            let s = lit.to_string();
283            if s.starts_with('"') {
284                // syn::LitStr can parse the Rust literal to get the unescaped string.
285                let ts: TokenStream = std::iter::once(TokenTree::Literal(lit.clone())).collect();
286                let lit_str: syn::LitStr = syn::parse2(ts).ok()?;
287                return Some(LowerAtom::StringLit(lit_str.value()));
288            }
289            // Integer literal with `L` suffix: `42L` — ONE token in proc_macro2.
290            // The string representation ends with 'L' after the digits.
291            if s.ends_with('L') {
292                let digits = &s[..s.len() - 1];
293                // Must be a plain decimal integer (no dot/exponent).
294                if !digits.contains('.')
295                    && !digits.contains('e')
296                    && !digits.contains('E')
297                    && let Ok(v) = digits.parse::<i64>()
298                    && v > i32::MIN as i64
299                    && v <= i32::MAX as i64
300                {
301                    return Some(LowerAtom::IntLit(v as i32));
302                }
303                return None;
304            }
305            // Float literal: has `.` or `e`/`E`
306            if s.contains('.') || s.to_lowercase().contains('e') {
307                let v: f64 = s.parse().ok()?;
308                return Some(LowerAtom::RealLit(v));
309            }
310            // Plain integer literal (no suffix) — R parses unsuffixed numeric
311            // literals as DOUBLE (`typeof(42)` is "double"; only `42L` is
312            // integer), so lower to a real to match the string path.
313            if let Ok(v) = s.parse::<i64>() {
314                // i64 → f64 is exact up to 2^53; anything a user writes as a
315                // plain literal in R code is far below that, but guard anyway.
316                let real = v as f64;
317                if real as i64 == v {
318                    return Some(LowerAtom::RealLit(real));
319                }
320            }
321            // Out of range or unrecognised form → not lowerable.
322            None
323        }
324
325        // Unary minus before a literal: `-42L`, `-1.5`
326        [TokenTree::Punct(minus), rest @ ..] if minus.as_char() == '-' && !rest.is_empty() => {
327            match classify_atom(rest)? {
328                LowerAtom::IntLit(v) => {
329                    // Negation must not produce NA (i32::MIN).
330                    let neg = v.checked_neg()?;
331                    Some(LowerAtom::IntLit(neg))
332                }
333                LowerAtom::RealLit(v) => Some(LowerAtom::RealLit(-v)),
334                // -Inf is a common R idiom.
335                LowerAtom::Inf => Some(LowerAtom::RealLit(f64::NEG_INFINITY)),
336                _ => None,
337            }
338        }
339
340        // Bare identifiers and symbolic constants
341        [TokenTree::Ident(id)] => {
342            Some(match id.to_string().as_str() {
343                "TRUE" => LowerAtom::Bool(true),
344                "FALSE" => LowerAtom::Bool(false),
345                "NULL" => LowerAtom::Null,
346                "NA" => LowerAtom::Na,
347                "NA_integer_" => LowerAtom::NaInteger,
348                "NA_real_" => LowerAtom::NaReal,
349                "NA_character_" => LowerAtom::NaCharacter,
350                "NA_complex_" => return None, // no constructor available
351                "Inf" => LowerAtom::Inf,
352                "NaN" => LowerAtom::NaN,
353                sym => LowerAtom::Symbol(sym.to_string()),
354            })
355        }
356
357        // Nested call: ident(args…) or pkg::fn(args…)
358        _ => {
359            let call = classify_call(tokens)?;
360            Some(LowerAtom::Call(Box::new(call)))
361        }
362    }
363}
364
365// endregion
366
367// region: Code emitter
368
369/// Emit a `TokenStream` that builds + evaluates the lowered call.
370///
371/// The emitted code is:
372/// ```rust,ignore
373/// unsafe {
374///     let __r_scope = ::miniextendr_api::gc_protect::ProtectScope::new();
375///     let __r_arg_0 = __r_scope.protect_raw(<atom>);
376///     ...
377///     ::miniextendr_api::expression::RCall::new("ident")
378///         .arg(__r_arg_0)
379///         ...
380///         .eval(#env_expr)
381/// }
382/// ```
383///
384/// For namespaced calls (`pkg::fn(args…)`), the emitted code first resolves
385/// the function via `RCall::namespaced`, propagating errors early:
386/// ```rust,ignore
387/// unsafe {
388///     let __r_scope = ::miniextendr_api::gc_protect::ProtectScope::new();
389///     let __r_ns_call = ::miniextendr_api::expression::RCall::namespaced("pkg", "fn")?;
390///     ...
391///     __r_ns_call.arg(...).eval(#env_expr)
392/// }
393/// ```
394fn emit_call(call: &LowerCall, env_expr: &TokenStream) -> TokenStream {
395    // Collect all arg values up front, protecting each one.
396    let mut scope_lets: Vec<TokenStream> = Vec::new();
397    let mut rcall_chain: Vec<TokenStream> = Vec::new();
398
399    for (i, arg) in call.args.iter().enumerate() {
400        let var = quote::format_ident!("__r_arg_{}", i);
401        let val_ts = emit_atom(&arg.value, &mut scope_lets);
402        scope_lets.push(quote! {
403            let #var = __r_scope.protect_raw(#val_ts);
404        });
405        if let Some(ref name) = arg.name {
406            rcall_chain.push(quote! {
407                .named_arg(#name, #var)
408            });
409        } else {
410            rcall_chain.push(quote! {
411                .arg(#var)
412            });
413        }
414    }
415
416    // Wrap the expansion in an immediately-invoked closure that returns
417    // `Result<SEXP, String>`. This lets `?` inside the body (from
418    // `RCall::namespaced()?` and nested calls) propagate to the closure's
419    // return type rather than to the enclosing function (which may return `()`).
420    //
421    // All FFI calls live inside `unsafe {}` blocks within the closure body.
422    match &call.fun {
423        LowerFun::Simple(name) => {
424            quote! {
425                (|| -> ::std::result::Result<::miniextendr_api::SEXP, ::std::string::String> {
426                    unsafe {
427                        let __r_scope = ::miniextendr_api::gc_protect::ProtectScope::new();
428                        #(#scope_lets)*
429                        ::miniextendr_api::expression::RCall::new(#name)
430                            #(#rcall_chain)*
431                            .eval(#env_expr)
432                    }
433                })()
434            }
435        }
436        LowerFun::Namespaced { pkg, fun } => {
437            quote! {
438                (|| -> ::std::result::Result<::miniextendr_api::SEXP, ::std::string::String> {
439                    let __r_ns_call = unsafe {
440                        ::miniextendr_api::expression::RCall::namespaced(#pkg, #fun)?
441                    };
442                    unsafe {
443                        let __r_scope = ::miniextendr_api::gc_protect::ProtectScope::new();
444                        #(#scope_lets)*
445                        __r_ns_call
446                            #(#rcall_chain)*
447                            .eval(#env_expr)
448                    }
449                })()
450            }
451        }
452    }
453}
454
455/// Emit the SEXP-producing expression for an atom.
456///
457/// Nested calls may themselves introduce new `protect_raw` lines; those are
458/// pushed into `scope_lets`.
459fn emit_atom(atom: &LowerAtom, scope_lets: &mut Vec<TokenStream>) -> TokenStream {
460    match atom {
461        LowerAtom::StringLit(s) => {
462            quote! { ::miniextendr_api::SEXP::scalar_string_from_str(#s) }
463        }
464        LowerAtom::IntLit(v) => {
465            let v = *v;
466            quote! { ::miniextendr_api::SEXP::scalar_integer(#v) }
467        }
468        LowerAtom::RealLit(v) => {
469            // f64 literals in quote must avoid NaN/Inf special forms.
470            let bits = v.to_bits();
471            quote! {
472                ::miniextendr_api::SEXP::scalar_real(f64::from_bits(#bits))
473            }
474        }
475        LowerAtom::Bool(b) => {
476            quote! { ::miniextendr_api::SEXP::scalar_logical(#b) }
477        }
478        LowerAtom::Null => {
479            quote! { ::miniextendr_api::SEXP::nil() }
480        }
481        LowerAtom::Na => {
482            // R's bare NA is LOGICAL NA (`typeof(NA)` is "logical").
483            quote! {
484                ::miniextendr_api::SEXP::scalar_logical_raw(
485                    ::miniextendr_api::altrep_traits::NA_LOGICAL
486                )
487            }
488        }
489        LowerAtom::NaInteger => {
490            quote! { ::miniextendr_api::SEXP::scalar_integer(i32::MIN) }
491        }
492        LowerAtom::NaReal => {
493            quote! {
494                ::miniextendr_api::SEXP::scalar_real(
495                    ::miniextendr_api::altrep_traits::NA_REAL
496                )
497            }
498        }
499        LowerAtom::NaCharacter => {
500            quote! {
501                ::miniextendr_api::SEXP::scalar_string(
502                    ::miniextendr_api::SEXP::na_string()
503                )
504            }
505        }
506        LowerAtom::Inf => {
507            let bits = f64::INFINITY.to_bits();
508            quote! { ::miniextendr_api::SEXP::scalar_real(f64::from_bits(#bits)) }
509        }
510        LowerAtom::NaN => {
511            // Use R's canonical NaN bit pattern.
512            let bits = f64::NAN.to_bits();
513            quote! { ::miniextendr_api::SEXP::scalar_real(f64::from_bits(#bits)) }
514        }
515        LowerAtom::Symbol(name) => {
516            // Look up the symbol in the eval environment via Rf_install.
517            // Symbols are never GC'd so no OwnedProtect needed.
518            quote! {
519                ::miniextendr_api::SEXP::symbol(#name)
520            }
521        }
522        LowerAtom::Call(nested) => {
523            // Nested calls: build inline using a fresh inner call structure.
524            // We push the sub-scope-lets into the parent scope; all
525            // protect_raw calls share the same __r_scope handle (LIFO order
526            // is fine — ProtectScope drops all at once).
527            emit_nested_call(nested, scope_lets)
528        }
529    }
530}
531
532/// Emit a nested call expression (returns the SEXP, adds protect_raw lines
533/// to the parent scope via a temporary).
534///
535/// For namespaced nested calls (`pkg::fn(args…)`), the namespace resolution
536/// is inlined via `RCall::namespaced(pkg, fun)?.build()`. The `?` propagates
537/// resolution errors to the outer `Result<SEXP, String>` return.
538fn emit_nested_call(call: &LowerCall, scope_lets: &mut Vec<TokenStream>) -> TokenStream {
539    // Allocate vars for this nested call's args.
540    static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
541    let idx = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
542
543    let mut arg_names: Vec<proc_macro2::Ident> = Vec::new();
544    let mut arg_named: Vec<bool> = Vec::new();
545    let mut arg_names_str: Vec<String> = Vec::new();
546
547    for (i, arg) in call.args.iter().enumerate() {
548        let var = quote::format_ident!("__r_nested_{}_{}", idx, i);
549        let val_ts = emit_atom(&arg.value, scope_lets);
550        scope_lets.push(quote! {
551            let #var = __r_scope.protect_raw(#val_ts);
552        });
553        arg_names.push(var);
554        arg_named.push(arg.name.is_some());
555        arg_names_str.push(arg.name.clone().unwrap_or_default());
556    }
557
558    // Build the RCall builder init depending on whether this is a simple or
559    // namespaced call. Namespaced resolution uses `?` on the Result.
560    let builder_init: TokenStream = match &call.fun {
561        LowerFun::Simple(name) => {
562            quote! { ::miniextendr_api::expression::RCall::new(#name) }
563        }
564        LowerFun::Namespaced { pkg, fun } => {
565            quote! { ::miniextendr_api::expression::RCall::namespaced(#pkg, #fun)? }
566        }
567    };
568
569    // Build the full chain.
570    let mut chain = builder_init;
571    for (i, var) in arg_names.iter().enumerate() {
572        if arg_named[i] {
573            let name = &arg_names_str[i];
574            chain = quote! { #chain.named_arg(#name, #var) };
575        } else {
576            chain = quote! { #chain.arg(#var) };
577        }
578    }
579
580    // `.build()` returns an unprotected LANGSXP; we protect it in the caller's
581    // scope_lets before passing to the outer `.arg()`.
582    let call_var = quote::format_ident!("__r_call_sexp_{}", idx);
583    scope_lets.push(quote! {
584        let #call_var = __r_scope.protect_raw(#chain.build());
585    });
586
587    quote! { #call_var }
588}
589
590// endregion
591
592// region: Unit tests for the classifier
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    fn parse(s: &str) -> Vec<TokenTree> {
599        let ts: TokenStream = s.parse().expect("failed to parse");
600        ts.into_iter().collect()
601    }
602
603    fn is_lowerable(s: &str) -> bool {
604        classify_call(&parse(s)).is_some()
605    }
606
607    // --- Positive cases: all should be lowerable ---
608
609    #[test]
610    fn simple_call_no_args() {
611        assert!(is_lowerable("noop()"));
612    }
613
614    #[test]
615    fn simple_call_integer_l_suffix() {
616        assert!(is_lowerable("identity(42L)"));
617    }
618
619    #[test]
620    fn simple_call_negative_integer() {
621        assert!(is_lowerable("identity(-1L)"));
622    }
623
624    #[test]
625    fn simple_call_string() {
626        assert!(is_lowerable(r#"paste("hello", "world")"#));
627    }
628
629    #[test]
630    fn simple_call_float() {
631        assert!(is_lowerable("identity(3.14)"));
632    }
633
634    #[test]
635    fn simple_call_true_false() {
636        assert!(is_lowerable("identity(TRUE)"));
637        assert!(is_lowerable("identity(FALSE)"));
638    }
639
640    #[test]
641    fn simple_call_null() {
642        assert!(is_lowerable("is.null(NULL)"));
643    }
644
645    #[test]
646    fn simple_call_na_forms() {
647        assert!(is_lowerable("identity(NA)"));
648        assert!(is_lowerable("identity(NA_integer_)"));
649        assert!(is_lowerable("identity(NA_real_)"));
650        assert!(is_lowerable("identity(NA_character_)"));
651    }
652
653    #[test]
654    fn simple_call_inf_nan() {
655        assert!(is_lowerable("identity(Inf)"));
656        assert!(is_lowerable("identity(NaN)"));
657    }
658
659    #[test]
660    fn simple_call_neg_inf() {
661        assert!(is_lowerable("identity(-Inf)"));
662    }
663
664    #[test]
665    fn simple_call_bare_symbol() {
666        assert!(is_lowerable("identity(x)"));
667    }
668
669    #[test]
670    fn named_arg() {
671        assert!(is_lowerable("seq(1L, 10L, by = 2L)"));
672    }
673
674    #[test]
675    fn nested_call() {
676        assert!(is_lowerable("c(1L, c(2L, 3L))"));
677    }
678
679    #[test]
680    fn namespaced_call() {
681        assert!(is_lowerable("base::sum(1L, 2L)"));
682    }
683
684    #[test]
685    fn plain_integer_lowers_as_double() {
686        // R parses unsuffixed numeric literals as double: typeof(42) is
687        // "double". Only `42L` is INTSXP.
688        let call = classify_call(&parse("identity(42)")).unwrap();
689        assert!(matches!(call.args[0].value, LowerAtom::RealLit(v) if v == 42.0));
690        let call = classify_call(&parse("identity(42L)")).unwrap();
691        assert!(matches!(call.args[0].value, LowerAtom::IntLit(42)));
692    }
693
694    #[test]
695    fn bare_na_is_logical_na() {
696        // typeof(NA) is "logical", not integer.
697        let call = classify_call(&parse("identity(NA)")).unwrap();
698        assert!(matches!(call.args[0].value, LowerAtom::Na));
699    }
700
701    // --- Negative cases: all should fall back ---
702
703    #[test]
704    fn arithmetic_not_lowerable() {
705        assert!(!is_lowerable("1L + 2L"));
706    }
707
708    #[test]
709    fn assignment_not_lowerable() {
710        assert!(!is_lowerable("x <- 5L"));
711    }
712
713    #[test]
714    fn semicolon_sequence_not_lowerable() {
715        // Two-statement sequence — only a call is lowerable.
716        assert!(!is_lowerable("x <- 5L; x"));
717    }
718
719    #[test]
720    fn indexing_not_lowerable() {
721        assert!(!is_lowerable("x[1L]"));
722    }
723
724    #[test]
725    fn dollar_not_lowerable() {
726        assert!(!is_lowerable("x$y"));
727    }
728
729    #[test]
730    fn bare_symbol_not_lowerable() {
731        // A bare symbol at top level is not a call — fallback.
732        assert!(!is_lowerable("x"));
733    }
734
735    #[test]
736    fn na_complex_arg_not_lowerable() {
737        // NA_complex_ has no constructor → not lowerable.
738        assert!(!is_lowerable("identity(NA_complex_)"));
739    }
740
741    #[test]
742    fn if_not_lowerable() {
743        assert!(!is_lowerable("if (TRUE) 1L else 2L"));
744    }
745
746    #[test]
747    fn empty_arg_slot_not_lowerable() {
748        // `matrix(, 2, 2)` is valid R (missing arg) and passes the grammar
749        // validator — it must fall back to the string path, never lower with
750        // the empty slot silently dropped.
751        assert!(!is_lowerable("matrix(, 2, 2)"));
752        assert!(!is_lowerable("f(x,,y)"));
753    }
754
755    #[test]
756    fn spaced_colons_not_namespaced() {
757        // `pkg : : fn(x)` is an R parse error — must not lower to a working
758        // namespaced call; fallback lets the string path reject it.
759        assert!(!is_lowerable("pkg : : fn(1L)"));
760    }
761}
762
763// endregion