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