Call attribution
Generated R wrappers pass .call = sys.call() into every .Call() so that errors raised from Rust are attributed to the user's call, as the user wrote it. This page shows the difference using a real, runnable fixture.
Generated R wrappers pass .call = sys.call() into every .Call() so that errors raised from Rust are attributed to the user’s call, as the user wrote it. This page shows the difference using a real, runnable fixture.
🔗The fixture
rpkg/src/rust/call_attribution_demo.rs defines two functions that raise the same error message. Only the wrapper differs.
// Wrapped path. Generated R wrapper passes `.call = sys.call()` into the
// C entry; on panic, `Rf_errorcall(call, msg)` shows the user's call.
#[miniextendr]
pub fn call_attr_with(_left: i32, _right: i32) -> i32 {
panic!("left + right is too risky")
}
// Unwrapped path. `extern "C-unwind"` bypasses the wrapper entirely — there is
// no call slot and no `with_r_unwind_protect`. We raise an R error directly
// with `Rf_error`, which carries no call attribution.
#[miniextendr]
#[unsafe(no_mangle)]
#[allow(non_snake_case)]
pub extern "C-unwind" fn C_call_attr_without(_left: SEXP, _right: SEXP) -> SEXP {
unsafe {
::miniextendr_api::sys::Rf_error(
c"%s".as_ptr(),
c"left + right is too risky".as_ptr(),
)
}
}
Generated R wrappers (excerpted from rpkg/R/miniextendr-wrappers.R):
call_attr_with <- function(left, right) {
# ... preconditions ...
.val <- .Call(C_miniextendr_call_attr_with, .call = sys.call(), left, right)
# ... tagged-condition demux ...
}
unsafe_C_call_attr_without <- function(left, right) {
.val <- .Call(C_call_attr_without, left, right) # no .call slot
# ...
}
The extern "C-unwind" path is registered directly as the .Call symbol, so there is nowhere to thread a call SEXP.
🔗The transcript
These two functions are internal demo fixtures (not exported); the ::: prefix is intentional.
> library(miniextendr)
> miniextendr:::call_attr_with(1L, 2L)
Error in miniextendr:::call_attr_with(1L, 2L) :
left + right is too risky
> miniextendr:::unsafe_C_call_attr_without(1L, 2L)
Error in miniextendr:::unsafe_C_call_attr_without(1L, 2L) :
left + right is too risky
Wrapped inside another function so the difference is sharper:
> outer_with <- function(x) miniextendr:::call_attr_with(x, x + 1L)
> outer_with(5L)
Error in miniextendr:::call_attr_with(x, x + 1L) :
left + right is too risky
> outer_without <- function(x) miniextendr:::unsafe_C_call_attr_without(x, x + 1L)
> outer_without(5L)
Error in miniextendr:::unsafe_C_call_attr_without(x, x + 1L) :
left + right is too risky
Programmatic comparison via tryCatch:
> e_with <- tryCatch(outer_with(5L), error = identity)
> e_without <- tryCatch(outer_without(5L), error = identity)
> class(e_with)
[1] "rust_error" "simpleError" "error" "condition"
> class(e_without)
[1] "simpleError" "error" "condition"
> conditionCall(e_with)
miniextendr:::call_attr_with(x, x + 1L)
> conditionCall(e_without)
miniextendr:::unsafe_C_call_attr_without(x, x + 1L)🔗Which call: the call as written
The call slot carries the call the way the user wrote it, sys.call(), not
the call with its formals matched (match.call()):
- The R convention.
stop(),stopifnot()and base R’s argument errors report the call as written, and so does rlang / vctrs’call = caller_env(), which reports that frame’ssys.call(). - One form on every path. The R-side checks (type and length guards,
no_na,inherits,not_inherits,match_arg/choices), a Rust conversion error, anError panic from the body, a warning or message deferred from Rust, and theCall/CallerCallmarkers all see the same call. A handler that comparesconditionCall(e)does not have to know which side refused the input. - Forwarded arguments stay as written. A call through
function(...) f(...)reportsf(...), andlapply(xs, f)reportsFUN(X[[i]], ...), the frames’ own calls; there is no...to expand. - The cheaper form. The slot is evaluated on every call, not only when
one raises.
sys.call()is a closure with one default argument around an.Internal();match.call()has four, and matches the formals. - Structured
rust_errorclass. The wrapped path goes through the tagged-condition decoder and returns a condition with classrust_error, which downstream handlers can catch specifically. The unwrapped path produces a plainsimpleError.
A positional call therefore reports positional arguments:
call_attr_with(1L, 2L), not call_attr_with(left = 1L, right = 2L). A
partial name stays partial (f(val = 1)), and a call that do.call() built
reports the arguments do.call() was given.
🔗How it flows end to end
R user calls: call_attr_with(1L, 2L)
│
▼
R wrapper: .Call(C_miniextendr_call_attr_with, .call = sys.call(), left, right)
│
▼
C wrapper: extern "C-unwind" fn(__miniextendr_call: SEXP, left: SEXP, right: SEXP)
│
└── with_r_unwind_protect(closure, Some(__miniextendr_call))
│
▼
Rust panic caught
│
▼
Rf_errorcall(__miniextendr_call, "left + right is too risky")
│
▼
R error: "Error in call_attr_with(1L, 2L) : ..."
For the unwrapped extern "C-unwind" path, the call slot does not exist, so the panic-to-error path becomes Rf_error(msg) and R falls back to sys.call() of the wrapper frame.
🔗Internal entry points: caller attribution
noexport exists for package-internal entry points, and the natural layout is a
hand-written R function that validates its arguments and delegates to the
generated wrapper. With the default attribution the user then sees the bridge:
Error in call_attr_self_impl(value) : x must be positive, got -1
#[miniextendr(noexport, call = caller)] moves the attribution one frame up.
The wrapper resolves its caller’s call as the first thing in its body, then
hands that call to every R-side check, to .Call() and to the raise fallback.
Its last formal, .call = NULL, lets a helper in between pass on a different
call (below):
call_attr_caller_impl <- function(x, .call = NULL) {
.mx_call <- .miniextendr_caller_call(.call)
if (!isTRUE(is.integer(x))) .miniextendr_arg_error("x", "must be integer", .mx_call)
if (!isTRUE(length(x) == 1L)) .miniextendr_arg_error("x", "must have length 1", .mx_call)
.val <- .Call(C_mypkg_call_attr_caller_impl, .call = .mx_call, x)
if (inherits(.val, "rust_condition_value") && ...) return(.miniextendr_raise_condition(.val, .mx_call))
.val
}
so the condition carries the hand-written function’s call as written, whether Rust or the R-side checks raised it:
Error in call_attr_caller(-1L) : x must be positive, got -1
Error in call_attr_caller(1.5) : 'x' must be integer
The R-side checks take the caller’s call under this option (#1548). Every
wrapper emits each precondition as a guard,
if (!isTRUE(<check>)) .miniextendr_arg_error("<p>", "<requirement>"), whose
helper raises the same argument error as a failed Rust conversion (#1591) and
by default reports the wrapper’s own call, the frame stopifnot() used to
report (isTRUE() keeps stopifnot()’s failure semantics, and the guards are
cheaper than the stopifnot() call they replaced); the choice helpers default
to the same frame. With that default a Rust-side failure would name the public
function and a bad choice or a non-integer argument the bridge
(verb_impl(...)). A call = caller wrapper therefore passes .mx_call to
every precondition guard, and the caller’s call to every choice parameter’s
helper:
.miniextendr_match_arg(kind, c(...), "kind", .mx_call) for a scalar
match_arg / choices parameter, the same form inside if (!is.null(kind))
for an Option<T> choice (if (!missing(kind) && ...) when it is wrapped in
Missing<..>, #1551), and
.miniextendr_match_arg_several(kinds, c(...), "kinds", .mx_call) for
several_ok, behind if (is.character(kinds) || is.factor(kinds)) when the
list sits in an Either<Vec<T>, R> (#1612). Default-attribution wrappers emit
the same statements without the call argument, so the helper reports the
wrapper’s own call (#1552).
Both helpers name the argument ('kind' should be one of "a", "b"). If a
downstream package’s tests pinned conditionCall() for such a failure to
the _impl wrapper or to match.arg(), they now see the public call.
.miniextendr_caller_call() is defined once at the top of the generated
wrappers file. Called from the wrapper’s body with the wrapper’s .call, it
returns that argument’s call when one was passed (see
below). For the default NULL it looks two
frames up for the wrapper’s caller, and returns that frame’s call as written.
It falls back to the wrapper’s own call, without .call, in two cases: there
is no parent frame (called from top level, also under tryCatch() there), or
the parent frame’s function is not a closure. The second case covers
eval()’d code (a testthat block, source(), local()): R gives such a
frame the eval primitive as its function, and its call would name eval.
A caller that forwards its dots (function(...) call_attr_caller(...))
reports call_attr_caller(...), as written.
The option requires noexport or internal (an exported function’s caller is
arbitrary user code) and applies to standalone functions only; class methods
keep their own attribution. A CallerCall parameter is the type-level spelling
of the same option, and [package.metadata.miniextendr] call_attribution = "caller" in Cargo.toml makes it the default for every internal entry point
(see the next section). The frame is the calling frame, so an entry point reached
through lapply() reports FUN(X[[i]], ...), and one reached through
do.call() reports the call do.call() built (call_attr_caller(-1L) for a
function name, the deparsed function for a function object), the same way
sys.call(-1) would. A helper that calls the entry point through do.call()
on behalf of a public function passes that function’s frame as .call
instead (next subsection). Fixture pair:
call_attr_caller_impl / call_attr_self_impl in
rpkg/src/rust/call_attribution_demo.rs with the delegates in
rpkg/R/call_attribution.R, verified by test-call-attribution.R.
🔗A helper in between: .call
The calling frame is one frame. When a hand-written helper sits between the public function and the entry point, the entry point’s caller is the helper, and the condition names it:
.prepare <- function(value) call_attr_caller_impl(value)
call_attr_via_plain_helper <- function(value) .prepare(value)
# Error in .prepare(value) : x must be positive, got -1
Every wrapper whose attribution resolves to caller (the attribute, the
CallerCall marker or the crate default) therefore ends its formals with
.call = NULL. The helper takes its own caller’s frame, following the vctrs /
rlang call = caller_env() convention, and passes it on:
.prepare <- function(value, call = parent.frame()) {
call_attr_caller_impl(value, .call = call)
}
call_attr_via_helper <- function(value) .prepare(value)
# Error in call_attr_via_helper(-1L) : x must be positive, got -1
What .call accepts:
NULL, the default: the wrapper’s caller, resolved as above.- An environment: the call of the closure whose frame it is, as written, as
for the default.
parent.frame()in a helper’s formals names the helper’s caller;environment()names the function that passes it. A frame passes unchanged through nested helpers (each forwardscall = call),lapply(),...forwarding,local()anddo.call(). An environment that is no closure’s live frame, such asglobalenv(), counts asNULL. - A call object: used as is,
.call = quote(verb(x = 1)). - Anything else: an argument error on
.call(e$param == ".call", classrust_error) reported against the wrapper’s own call. This also catches a positional argument too many,call_attr_caller_impl(-1L, 2), which used to be R’sunused argumenterror.
Pass a frame, not a call. do.call() evaluates the arguments it is given
as a call, so a call object in the argument list runs the function it names
again:
f <- function(x, .call = NULL) { force(.call); x } # the wrapper forces .call first
g <- function() { n <<- n + 1; if (n > 3) stop("re-entered"); do.call(f, list(1, .call = quote(g()))) }
n <- 0; try(g()) # Error in g() : re-entered. Passing environment() instead returns 1.
An environment is a value, so it evaluates to itself. A call object needs
do.call(..., quote = TRUE).
On a wrapper with ..., .call follows the dots and any formal after them
(function(x, ..., .call = NULL), function(x, ..., flag = FALSE, .call = NULL)):
positional extras land in the dots, and .call is matched by name only, as R
requires for any formal after .... Pass
it by name everywhere. Two cases keep today’s shape. An S3 method has no
.call, because a formal the generic lacks breaks generic/method consistency;
its conditions name the generic’s caller. A CallerCall body receives the
resolved call, which is still is.call(). An internal wrapper, which
renders a page, gets a generated @param .call line. Fixtures: the
call_attr_via_* delegates in rpkg/R/call_attribution.R,
call_attr_internal_impl / call_attr_dots_impl in
rpkg/src/rust/call_attribution_demo.rs, and producer_via_helper in
tests/cross-package/producer.pkg (the crate default), verified by the
test-call-attribution.R files.
🔗An exported function: call_arg
caller attribution is for internal entry points. An exported function’s
caller is arbitrary user code, so its conditions name its own call. An R
function that composes exported functions then can’t make them name its call
instead. An update() method that applies verbs by name through do.call()
is one: a verb that refuses a value reports verb(x, value), or, called as
do.call(verb, ...) with the function object, a call whose head deparses as
the verb’s whole body.
#[miniextendr(call_arg)] gives a standalone function, exported or not, the
same trailing .call = NULL formal as a caller wrapper (#1834). NULL, the
default, is the wrapper’s own call, so a direct call reports what it always
did. A composing function passes its own frame:
/// @param x A count: negative is an error, zero a warning.
/// @param mode One of the modes.
#[miniextendr(call_arg)]
pub fn call_arg_verb(x: i32, #[miniextendr(match_arg)] mode: Mode) -> Result<i32, String> {
if x < 0 {
return Err(format!("x must be non-negative, got {x}"));
}
if x == 0 {
defer_warning!("x is zero");
}
Ok(x)
}call_arg_verb <- function(x, mode = c("Fast", "Safe", "Debug"), .call = NULL) {
.mx_call <- if (is.null(.call)) sys.call() else .miniextendr_caller_call(.call, own = TRUE)
if (!isTRUE(is.integer(x))) .miniextendr_arg_error("x", "must be integer", .mx_call)
...
}
compose_verb <- function(x, mode = "Fast") {
do.call("call_arg_verb", list(x, mode, .call = environment()))
}
compose_verb(-1L)
# Error in compose_verb(-1L) : x must be non-negative, got -1
compose_verb(1.5)
# Error in compose_verb(1.5) : 'x' must be integer
compose_verb(1L, "Slow")
# Error in compose_verb(1L, "Slow") :
# 'mode' should be one of "Fast", "Safe", "Debug"
compose_verb(0L)
# Warning message:
# In compose_verb(0L) : x is zero
call_arg_verb(-1L)
# Error in call_arg_verb(-1L) : x must be non-negative, got -1
From a hand-written S3 method, pass the generic’s call. Under
UseMethod() dispatch a method’s own frame call names the method:
environment() from a hand-written update.thing() reports
update.thing(object, ...), not the update(a, dose = 1) the user wrote (a
method generated from Rust rewrites that itself, see
S3 methods: the generic call). The
generic’s frame sits just below the method’s, so the method passes
sys.call(-1L), a call object. Through do.call() that needs quote = TRUE
(see “Pass a frame, not a call” above):
update.thing <- function(object, ...) {
call <- sys.call(-1L) # update(a, dose = 1), as written
changes <- list(...)
for (verb in names(changes)) {
object <- do.call(verb, list(object, changes[[verb]], .call = call), quote = TRUE)
}
object
}
A method that is also called directly, update.thing(a, ...), has no
generic frame below it. There sys.call(-1L) is whatever called it, so check
identical(sys.function(-1L), stats::update) first and pass sys.call()
otherwise.
Every condition the wrapper raises reads .mx_call: the R-side checks, the
match_arg helpers, the .Call() slot and with it Rust errors and the
warnings and messages deferred from Rust, and the raise helper’s fallback.
.call accepts what a caller wrapper’s does (see the list above), with one
difference: NULL, and an environment that is no closure’s live frame, give
the wrapper’s own call, not its caller’s. A call made without .call costs
one is.null(); the helper runs only when .call is set.
- The
.callformal goes last, after the dots, as on acallerwrapper. Pass it by name. - A
Callparameter receives the call.callresolves to. - The generated
@param .callline is written as an author’s line would be, so it stays on a page the function joins with@rdname/@describeInand next to@inheritParams;R CMD checkfinds.calldocumented wherever the usage shows it. An author’s own@param .callreplaces it. - Compile errors: with
call = calleror aCallerCallparameter (whose wrapper already takes.call, whereNULLis the caller’s call), on an S3 method (s3(...): a formal its generic lacks), and on anextern "C-unwind"function (no call slot). Class and trait methods don’t take it. - There is no crate default: the formal shows in every usage line that has it, so a package opts in function by function.
Fixtures: call_arg_verb, call_arg_topic / call_arg_joined (a page the
.call function joins) and call_arg_marker_impl in
rpkg/src/rust/call_attribution_demo.rs, verified by test-call-arg.R.
🔗S3 methods: the generic call
Under UseMethod() dispatch R gives the method frame the dispatched call:
sys.call() inside summary.mx_rec() is summary.mx_rec(x) when the user
wrote summary(x), and a replacement method sees the temporary R assigns
through, `$<-.mx_rec`(`*tmp*`, f, value = <the new value>) for
x$f <- v. Neither is a call the user wrote, and the second carries the
whole new value: for x$f$col <- big, R hands the outer method the
evaluated inner value inlined, and conditionCall(e) deparses to as many
lines as the value takes.
A generated S3 method, a standalone s3(generic = ..., class = ...) function
or a method of a #[miniextendr(s3)] impl block, reports the generic’s call
instead (#1851):
| The user wrote | The method frame’s call | Reported |
|---|---|---|
summary(x, extra = 1) | summary.mx_rec(x, extra = 1) | summary(x, extra = 1) |
lapply(xs, summary) | summary.mx_rec(X[[i]], ...) | summary(X[[i]], ...) |
x + 1 (the Ops group generic) | Ops.mx_rec(x, 1) | x + 1 |
x[5], x$nope, t[[5L]] | `[.mx_rec`(x, 5), … | x[5], x$nope, t[[5L]] |
x$f <- v, x$f$col[1] <- v | `$<-.mx_rec`(`*tmp*`, f, value = …) | x$f <- value |
x[["f"]] <- v, x["f"] <- list(v) | `[[<-.mx_rec`(`*tmp*`, "f", value = …), … | x[["f"]] <- value, x["f"] <- value |
names(x) <- v | `names<-.mx_rec`(`*tmp*`, value = …) | names(x) <- value |
summary.mx_rec(x), called directly | summary.mx_rec(x) | summary.mx_rec(x) |
The rule: when .Generic is bound in the method frame, which R does under
UseMethod(), NextMethod(), internal dispatch ([, $<-, …) and the group
generics, the call’s function becomes as.name(.Generic), so an Ops method
reports the operator. A receiver spelled *tmp* becomes the method’s first
formal (x). For a replacement generic (.Generic ends in <-) the call is
call("<-", <lhs>, quote(value)), where <lhs> is the frame’s call with the
<- dropped from its head and the value argument removed: compact, never
holding the value, and the shape stop() reports from inside a hand-written
function(x, value). Without .Generic, a direct call of the method by
name, the frame’s call is reported as is.
Every condition the method raises reads this call: an Err or panic from the
body, a warning or message deferred from Rust, the R-side argument checks
('i' must be numeric for x["a"] reports x["a"]) and a failed Rust
conversion. A Call parameter on the method receives it.
Cost. The rewrite is lazy. The wrapper passes .call = environment(), the
method frame, into .Call() and to its guards, and the helper that raises a
condition resolves the frame to the call then (.miniextendr_frame_call(),
defined at the top of the generated wrappers file and inlined into the raise
helper’s copy for deferred conditions). On the success path the only change
from .call = sys.call() is environment() for sys.call(), two builtins
of the same cost. A method with a Call parameter resolves the call in a
prelude instead (.mx_call <- .miniextendr_frame_call(environment())),
because the body wants the call object; that walk over sys.frames() costs
on the order of ten microseconds per call, which is why it is not the
default.
call = caller and call_arg stay refused on an S3 method (its conditions
name the generic’s call, and it can’t take a formal its generic lacks);
call = none applies (next section). Fixtures:
rpkg/src/rust/s3_call_tests.rs, verified by test-s3-call.R.
🔗No call at all: none
#[miniextendr(call = none)] makes a function’s conditions carry no call at
all, however the function was reached: conditionCall(e) is NULL and an
error prints as Error: <message>. It is the function-level form of the
per-condition opt-out
(Conditions without a call), for
a function whose conditions are about the data, never about the call
(#1851):
#[miniextendr(call = none)]
pub fn no_call_verb(x: i32, #[miniextendr(match_arg)] mode: Mode) -> Result<i32, String> { … }no_call_verb <- function(x, mode = c("Fast", "Safe", "Debug")) {
if (!isTRUE(is.integer(x))) .miniextendr_arg_error("x", "must be integer", NULL)
if (!isTRUE(length(x) == 1L)) .miniextendr_arg_error("x", "must have length 1", NULL)
mode <- .miniextendr_match_arg(mode, c("Fast", "Safe", "Debug"), "mode", NULL)
.val <- .Call(C_mypkg_no_call_verb, .call = FALSE, x, mode)
if (inherits(.val, "rust_condition_value") && ...) return(.miniextendr_raise_condition(.val, NULL))
.val
}no_call_verb(-1L, "Fast")
# Error: x must be non-negative, got -1
no_call_verb(1.5, "Fast")
# Error: 'x' must be integer
no_call_verb(0L, "Fast")
# Warning message:
# x is zero
Every condition drops the call: an Err or panic from the body, a classed
error!, a warning or message deferred from Rust, and the argument errors
too, both the R-side checks (type and length guards, no_na, inherits,
not_inherits, match_arg / choices) and a failed Rust conversion. The
per-condition opt-out leaves argument errors alone; the function-level one
is the whole function’s decision. .call = FALSE is the marker of a
call-less condition that the transport already carries, so the Rust side
needs nothing new.
- It applies to standalone functions, S3 methods included. Class and trait
methods have no
call = ...option (#1868). - It has no marker: a
Call/CallerCallparameter would have nothing to receive, so one next tocall = noneis a compile error, as arecall_arg(.callnames the call to report) and anextern "C-unwind"function (no call slot). [package.metadata.miniextendr] call_attribution = "none"makes it the default for every free function of the crate, exported or not; a per-itemcall = wrapper(or aCallparameter) restores the call on one.
Fixture pair: no_call_verb / with_call_verb and the format method of
mx_rec in rpkg/src/rust/s3_call_tests.rs, verified by test-s3-call.R.
🔗Choosing the attribution: marker, attribute, crate default
A standalone #[miniextendr] function picks one of three attributions, in
up to three equivalent spellings (#1566). Most specific wins:
| Spelling | wrapper | caller | none |
|---|---|---|---|
Marker parameter (miniextendr_api::{Call, CallerCall}) | call: Call | call: CallerCall | none (there is no call to receive) |
| Attribute | call = wrapper | call = caller | call = none |
Crate default (Cargo.toml) | call_attribution = "wrapper" | call_attribution = "caller" | call_attribution = "none" |
Below those, wrapper. The attribute accepts the path and string forms
(call = caller, call = "caller"). On an S3 method wrapper reports the
generic’s call (above). The attribution is independent of
no_preconditions: a wrapper without its R-side checks still passes the call,
and no_preconditions, call = caller or a Call parameter under
no_preconditions work as without it.
use miniextendr_api::{Call, CallerCall, miniextendr};
/// `.call = sys.call()`, and the body gets to see it.
#[miniextendr]
pub fn scale(x: f64, call: Call) -> f64 { let _ = call.sexp(); x * 2.0 }
/// Internal entry point behind a hand-written `scale2()`: the caller's call,
/// as written, reaches Rust as `call`.
#[miniextendr(noexport)]
pub fn scale2_impl(x: f64, _call: CallerCall) -> f64 { x * 2.0 }# Cargo.toml: every noexport / internal entry point reports its caller.
[package.metadata.miniextendr]
call_attribution = "caller"
The marker is not an R formal: the generated wrapper’s formals are the
other parameters (scale <- function(x)), and the C wrapper binds the marker
from its hidden __miniextendr_call slot, so the body receives exactly the
SEXP the wrapper passed as .call = .... A caller wrapper, CallerCall
included, still takes its own trailing .call = NULL formal
(scale2_impl <- function(x, .call = NULL), see
A helper in between); the body then receives the
call that resolves to. Both markers are repr(transparent)
newtypes over SEXP (.sexp(), Deref, From<CallerCall> for Call), and a
function taking one runs on R’s main thread like one taking SEXP. The marker
selects the attribution the same way the attribute does: Call is wrapper,
CallerCall is caller and therefore needs noexport / internal too.
Per-parameter options (coerce, match_arg, choices, default) do not
apply to it.
A crate default of "caller" applies to noexport / internal free
functions only: an exported function’s caller is arbitrary user code, so it
keeps wrapper. "wrapper" and "none" apply to every standalone function.
A per-item spelling beats the crate default; a call = wrapper on one entry
point restores the wrapper’s own call under a "caller" or "none" default.
Class and trait methods are untouched by all three spellings (a marker on a
method is a compile error) and always report their own call.
Two spellings on one function must agree. A Call parameter with
call = caller, any marker with call = none, two markers, a CallerCall
on an exported function, per-parameter options on a marker and
call = parent are all compile errors listed in
MACRO_ERRORS.md.
Fixtures: call_marker_wrapper_impl / call_marker_caller_impl /
call_marker_checked_impl in
rpkg/src/rust/call_attribution_demo.rs (verified by test-call-attribution.R),
and the crate default in tests/cross-package/producer.pkg
(test-call-attribution.R there).
🔗Where this is emitted
Every .Call() inside generated R wrappers puts the call slot first. Class and trait methods go through DotCallBuilder in miniextendr-macros/src/r_wrapper_builder.rs, which prepends .call = sys.call() (.call = environment() for an S3 class’s methods, with_call_attribution(CallAttribution::Generic); .call = NULL for the lambda frames below, null_call_attribution()). Standalone functions take the argument from CallAttribution::dot_call_arg() in the same file: .call = sys.call() for wrapper, .call = .mx_call for caller, call_arg and an S3 method with a Call parameter, .call = environment() for an S3 method (for_s3_method, resolved to the generic’s call by .miniextendr_frame_call() when a condition is raised) and .call = FALSE for none. The C wrapper builder in miniextendr-macros/src/c_wrapper_builder.rs always declares __miniextendr_call: SEXP as the first parameter, so the convention is symmetric.
It applies uniformly to:
- Standalone
#[miniextendr]functions - All six class systems (R6, S3, S4, S7, Env, Vctrs) — constructors, instance methods, static methods, active bindings (the R6 finalizer and
deep_clonepass.call = NULLinstead, see below) - All trait implementations across all class systems
match_argchoices helper calls- Warnings and messages deferred from Rust, which carry the slot’s call
🔗Where it is intentionally absent
extern "C-unwind"functions registered directly with#[miniextendr]. The function is the C entry point — there is no generated wrapper and no call slot. This is the demo above. Use only for low-level fixtures and tests where you control the error path manually.vctrs_deriveboilerplate —format.<class>,vec_ptype2.<class>.<class>, etc. — pure R, no.Call().- Sidecar
Type_get_field/Type_set_fieldaccessors generated by#[derive(ExternalPtr)]. Their C functions take onlyx(andvalue), so the.Call()passes no.callargument; the guard’ssys.call()supplies the call.
🔗Where .call = NULL is used instead of sys.call()
No attribute selects it. Five lambda dispatch sites cannot use sys.call() because the lambda is invoked by R6/S7 dispatch machinery, not by user code. sys.call() inside those lambdas would name the dispatch frame (e.g., R6$finalize(), S7::prop_get()), not the user’s obj$field access. The generated .Call() instead passes .call = NULL. The raise helper’s fallback, if (isFALSE(.val$call)) NULL else if (is.null(.val$call)) .call_default else .val$call with the wrapper’s sys.call() as .call_default (condition_check_lines), then surfaces the nearest meaningful frame. (FALSE is the marker of a condition raised with call = none, which keeps no call at all; see Conditions without a call.)
The five sites are:
- R6 finalizer —
finalize = function() .Call(C_mypkg_Type__finalize, .call = NULL, private$.ptr) - R6
deep_clone—deep_clone = function(name, value) .Call(C_mypkg_Type__deep_clone, .call = NULL, private$.ptr, name, value) - S7 property validator —
validator = function(value) .Call(C_mypkg_Type__validate_prop, .call = NULL, value) - S7 property getter —
getter = function(self) .Call(C_mypkg_Type__get_prop, .call = NULL, self@.ptr) - S7 property setter —
setter = function(self, value) { .Call(C_mypkg_Type__set_prop, .call = NULL, self@.ptr, value); self }
This is implemented via DotCallBuilder::null_call_attribution() in miniextendr-macros/src/r_wrapper_builder.rs. The C wrapper still receives __miniextendr_call: SEXP (it always does) and gets R_NilValue; make_rust_condition_value stores it, and the raise helper’s .call_default fallback (the wrapper’s sys.call()) recovers the user’s frame.
🔗Reproducing the transcript
just configure
just rcmdinstall
Rscript -e '
library(miniextendr)
try(miniextendr:::call_attr_with(1L, 2L))
try(miniextendr:::unsafe_C_call_attr_without(1L, 2L))
'
The fixture lives at rpkg/src/rust/call_attribution_demo.rs.