miniextendr_api/registry.rs
1//! Automatic registration for miniextendr.
2//!
3//! Every `#[miniextendr]` item self-registers at link time. `package_init()`
4//! (generated by `miniextendr_init!`) calls [`miniextendr_register_routines`](crate::registry::miniextendr_register_routines)
5//! during `R_init_*` to finalize registration with R. Users never interact
6//! with this module.
7
8use crate::abi::{mx_erased, mx_tag};
9use crate::ffi::{DllInfo, R_CallMethodDef};
10#[cfg(not(target_arch = "wasm32"))]
11use linkme::distributed_slice;
12use std::os::raw::c_void;
13
14// region: Distributed Slices
15//
16// `linkme::distributed_slice` does not compile for `wasm32-*` targets — the
17// proc macro hits a `compile_error!` arm in `linkme-impl/src/declaration.rs`.
18// On wasm32 we keep the same public surface for the three runtime-critical
19// slices (MX_CALL_DEFS / MX_ALTREP_REGISTRATIONS / MX_TRAIT_DISPATCH) but
20// back them with a `OnceLock` populated from the user crate's
21// `wasm_registry.rs` snapshot at `R_init_*` time (see
22// `install_wasm_runtime_slices` below). The five host-only slices
23// (MX_R_WRAPPERS, MX_MATCH_ARG_*, MX_CLASS_NAMES, MX_S7_SIDECAR_PROPS) are
24// only consumed by the cdylib wrapper-gen pass — that pass is itself
25// native-only, so the slices, their consumers, and the `wasm_registry_writer`
26// module are all `cfg(not(target_arch = "wasm32"))`-gated.
27
28/// R `.Call` method registrations (function + method C wrappers).
29///
30/// Each `#[miniextendr]` function or method emits an entry here.
31#[cfg(not(target_arch = "wasm32"))]
32#[distributed_slice]
33pub static MX_CALL_DEFS: [R_CallMethodDef];
34
35/// R wrapper code fragments with priority for ordering. **Host-only.**
36///
37/// Each `#[miniextendr]` function, impl block, or trait impl emits an entry.
38/// Priorities ensure correct evaluation order when R sources the wrapper file
39/// (sidecar helpers must be defined before class definitions that reference them).
40#[cfg(not(target_arch = "wasm32"))]
41#[distributed_slice]
42pub static MX_R_WRAPPERS: [RWrapperEntry];
43
44/// ALTREP class registration entries, called once at package init.
45///
46/// Each ALTREP struct or trait impl emits an entry pairing the registration
47/// function pointer with its `#[no_mangle]` symbol name. The fn is declared
48/// `pub extern "C"` with `#[unsafe(no_mangle)]`, making it externally
49/// addressable from a separate compilation unit (e.g. the WASM snapshot
50/// codegen path). The fn pointer is not `unsafe` — R-thread invariants are a
51/// module-level contract, not encoded in the type (same convention as the
52/// `extern "C-unwind" fn` entries in `MX_CALL_DEFS`). The `symbol` string is
53/// consumed by the host-time WASM snapshot writer to emit
54/// `extern "C" { fn <symbol>(); }` declarations in `wasm_registry.rs`.
55#[cfg(not(target_arch = "wasm32"))]
56#[distributed_slice]
57pub static MX_ALTREP_REGISTRATIONS: [AltrepRegistration];
58
59/// Trait dispatch entries for [`universal_query`].
60///
61/// Each `#[miniextendr] impl Trait for Type` emits an entry mapping
62/// `(concrete_tag, trait_tag)` to the trait's vtable pointer.
63#[cfg(not(target_arch = "wasm32"))]
64#[distributed_slice]
65pub static MX_TRAIT_DISPATCH: [TraitDispatchEntry];
66
67/// Match-arg choices entries for R wrapper post-processing. **Host-only.**
68///
69/// Each `#[miniextendr]` function with `match_arg` params emits an entry.
70/// During `write_r_wrappers_to_file`, the placeholder in the R formal default
71/// is replaced with the actual choices from the enum's `MatchArg::CHOICES`.
72#[cfg(not(target_arch = "wasm32"))]
73#[distributed_slice]
74pub static MX_MATCH_ARG_CHOICES: [MatchArgChoicesEntry];
75
76/// Match-arg `@param` doc entries for R wrapper post-processing. **Host-only.**
77///
78/// Each `#[miniextendr]` function with `match_arg` params that has no
79/// user-written `@param` doc emits an entry here. During
80/// `write_r_wrappers_to_file`, the placeholder in the `@param` roxygen tag
81/// is replaced with e.g. `One of "Fast", "Safe", "Debug".`
82#[cfg(not(target_arch = "wasm32"))]
83#[distributed_slice]
84pub static MX_MATCH_ARG_PARAM_DOCS: [MatchArgParamDocEntry];
85
86/// Class name entries mapping Rust type names to R-visible class names. **Host-only.**
87///
88/// Each `#[miniextendr]` impl block emits an entry. During
89/// `write_r_wrappers_to_file`, `.__MX_CLASS_REF_<RustName>__` placeholders
90/// in generated R wrapper strings are replaced with the registered R class name
91/// (which may differ when `class = "Override"` is set on the impl block).
92#[cfg(not(target_arch = "wasm32"))]
93#[distributed_slice]
94pub static MX_CLASS_NAMES: [ClassNameEntry];
95
96/// S7 sidecar property documentation entries. **Host-only.**
97///
98/// Each `#[derive(ExternalPtr)] #[externalptr(s7)]` struct with `#[r_data]` fields
99/// emits one entry per public field. During `write_r_wrappers_to_file`, the
100/// `.__MX_S7_SIDECAR_PROP_DOCS_<TypeName>__` placeholder in the S7 class wrapper
101/// is replaced with the formatted `#' @prop {field} {doc}` roxygen lines.
102#[cfg(not(target_arch = "wasm32"))]
103#[distributed_slice]
104pub static MX_S7_SIDECAR_PROPS: [SidecarPropEntry];
105
106// endregion
107
108// region: Runtime slice access (cfg-uniform)
109
110/// Native: read directly from the linkme distributed slice.
111/// wasm32: read from a `OnceLock` populated by `install_wasm_runtime_slices`.
112#[inline]
113pub(crate) fn call_defs() -> &'static [R_CallMethodDef] {
114 #[cfg(not(target_arch = "wasm32"))]
115 {
116 &MX_CALL_DEFS
117 }
118 #[cfg(target_arch = "wasm32")]
119 {
120 wasm_runtime::call_defs()
121 }
122}
123
124#[inline]
125pub(crate) fn altrep_regs() -> &'static [AltrepRegistration] {
126 #[cfg(not(target_arch = "wasm32"))]
127 {
128 &MX_ALTREP_REGISTRATIONS
129 }
130 #[cfg(target_arch = "wasm32")]
131 {
132 wasm_runtime::altrep_regs()
133 }
134}
135
136#[inline]
137pub(crate) fn trait_dispatch() -> &'static [TraitDispatchEntry] {
138 #[cfg(not(target_arch = "wasm32"))]
139 {
140 &MX_TRAIT_DISPATCH
141 }
142 #[cfg(target_arch = "wasm32")]
143 {
144 wasm_runtime::trait_dispatch()
145 }
146}
147
148#[cfg(target_arch = "wasm32")]
149mod wasm_runtime {
150 use super::{AltrepRegistration, R_CallMethodDef, TraitDispatchEntry};
151 use std::sync::OnceLock;
152
153 static CALL_DEFS: OnceLock<&'static [R_CallMethodDef]> = OnceLock::new();
154 static ALTREP_REGS: OnceLock<&'static [AltrepRegistration]> = OnceLock::new();
155 static TRAIT_DISPATCH: OnceLock<&'static [TraitDispatchEntry]> = OnceLock::new();
156
157 pub(super) fn install(
158 c: &'static [R_CallMethodDef],
159 a: &'static [AltrepRegistration],
160 t: &'static [TraitDispatchEntry],
161 ) {
162 // Double-install is a programmer error but not memory-unsafe — silently
163 // ignore so a second `R_init_*` (e.g. dyn.unload + dyn.load) doesn't
164 // panic. The first install wins.
165 let _ = CALL_DEFS.set(c);
166 let _ = ALTREP_REGS.set(a);
167 let _ = TRAIT_DISPATCH.set(t);
168 }
169
170 pub(super) fn call_defs() -> &'static [R_CallMethodDef] {
171 CALL_DEFS.get().copied().unwrap_or(&[])
172 }
173 pub(super) fn altrep_regs() -> &'static [AltrepRegistration] {
174 ALTREP_REGS.get().copied().unwrap_or(&[])
175 }
176 pub(super) fn trait_dispatch() -> &'static [TraitDispatchEntry] {
177 TRAIT_DISPATCH.get().copied().unwrap_or(&[])
178 }
179}
180
181/// Install the runtime-critical slice data on `wasm32-*`.
182///
183/// Called from the user crate's `R_init_<pkg>` (generated by
184/// `miniextendr_init!`) before `package_init` runs. The slices are typically
185/// the `MX_*_WASM` constants emitted into `<crate>/src/rust/<crate>/src/wasm_registry.rs`
186/// by [`crate::wasm_registry_writer::write_wasm_registry_to_file`].
187///
188/// Calling more than once is harmless — the first install wins (the assumption
189/// being all calls supply the same data; second-install is treated as a
190/// re-init from `dyn.unload` + `dyn.load`).
191#[cfg(target_arch = "wasm32")]
192pub fn install_wasm_runtime_slices(
193 call_defs: &'static [R_CallMethodDef],
194 altrep_regs: &'static [AltrepRegistration],
195 trait_dispatch: &'static [TraitDispatchEntry],
196) {
197 wasm_runtime::install(call_defs, altrep_regs, trait_dispatch);
198}
199
200// endregion
201
202// region: Entry Types
203
204/// Ordering priority for R wrapper code fragments.
205///
206/// Variant declaration order = output order. The order matters because
207/// R evaluates the wrapper file top-to-bottom, so dependencies must come first:
208/// sidecar accessors before class definitions, classes before functions, etc.
209#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
210pub enum RWrapperPriority {
211 /// `#[r_data]` getters/setters — must come before class definitions.
212 Sidecar,
213 /// Class definitions (impl blocks: env/R6/S3/S4/S7).
214 Class,
215 /// Standalone `#[miniextendr]` functions.
216 Function,
217 /// Trait impl wrappers (`impl Trait for Type`).
218 TraitImpl,
219 /// Vctrs S3 method wrappers (`#[derive(Vctrs)]`).
220 Vctrs,
221}
222
223/// R wrapper code with priority for ordering.
224pub struct RWrapperEntry {
225 /// Ordering priority (lower = earlier in output file).
226 pub priority: RWrapperPriority,
227 /// R source code fragment.
228 pub content: &'static str,
229 /// Source file path (from `file!()`). Used to derive a default `@rdname`
230 /// for standalone functions that don't have an explicit one, so that all
231 /// functions from the same source file share a single .Rd page.
232 pub source_file: &'static str,
233}
234
235// SAFETY: All fields are immutable and valid for 'static lifetime.
236unsafe impl Sync for RWrapperEntry {}
237
238/// Entry for replacing match_arg placeholder defaults with actual choices.
239pub struct MatchArgChoicesEntry {
240 /// Placeholder string in the R formal default, e.g. `".__MX_MATCH_ARG_CHOICES_mode__"`.
241 pub placeholder: &'static str,
242 /// Function that returns the choices as a comma-separated quoted string,
243 /// e.g. `"\"Fast\", \"Safe\", \"Debug\""`.
244 pub choices_str: fn() -> String,
245 /// User-supplied `default = "..."` value (unquoted, e.g. `"zstd"`), or `""`
246 /// if the user did not supply one. When non-empty, the write-time pass
247 /// rotates the choice list so this value appears first, so R's
248 /// `match.arg(arg)` (no second arg) picks it as the default.
249 pub preferred_default: &'static str,
250}
251
252// SAFETY: function pointer and &'static str are Send+Sync.
253unsafe impl Sync for MatchArgChoicesEntry {}
254
255/// Entry for replacing match_arg `@param` doc placeholders with human-readable
256/// choice descriptions.
257pub struct MatchArgParamDocEntry {
258 /// Placeholder string in the `@param` roxygen tag, e.g.
259 /// `".__MX_MATCH_ARG_PARAM_DOC_match_arg_set_mode_mode__"`.
260 pub placeholder: &'static str,
261 /// `true` for `several_ok` params (emits "One or more of …");
262 /// `false` for plain `match_arg` (emits "One of …").
263 pub several_ok: bool,
264 /// Function that returns the choices as a comma-separated quoted string,
265 /// e.g. `"\"Fast\", \"Safe\", \"Debug\""`.
266 pub choices_str: fn() -> String,
267}
268
269// SAFETY: function pointer, bool, and &'static str are Send+Sync.
270unsafe impl Sync for MatchArgParamDocEntry {}
271
272/// Entry mapping a Rust type name to its R-visible class name and class system.
273///
274/// Emitted by every `#[miniextendr(env|r6|s3|s4|s7|vctrs)]` impl block.
275/// Used by the resolver in `write_r_wrappers_to_file` to replace
276/// `.__MX_CLASS_REF_<RustName>__` placeholders with the actual R class name.
277pub struct ClassNameEntry {
278 /// Rust type identifier, e.g. `"S7Shape"`.
279 pub rust_type: &'static str,
280 /// R-visible class name. Equals `rust_type` unless `class = "Override"` was
281 /// set on the impl block, in which case it is the override string.
282 pub r_class_name: &'static str,
283 /// Class system tag: `"env"` | `"r6"` | `"s3"` | `"s4"` | `"s7"` | `"vctrs"`.
284 pub class_system: &'static str,
285}
286
287// SAFETY: All fields are &'static str — immutable and valid for program lifetime.
288unsafe impl Sync for ClassNameEntry {}
289
290/// Entry documenting a sidecar (`#[r_data]`) property on an S7 ExternalPtr type.
291///
292/// Emitted by `#[derive(ExternalPtr)] #[externalptr(s7)]` for each public `#[r_data]`
293/// field. Used by `write_r_wrappers_to_file` to substitute the
294/// `.__MX_S7_SIDECAR_PROP_DOCS_<TypeName>__` placeholder with `#' @prop` lines.
295pub struct SidecarPropEntry {
296 /// Rust type name, e.g. `"SidecarS7"`.
297 pub rust_type: &'static str,
298 /// Field name, e.g. `"prop_int"`.
299 pub field_name: &'static str,
300 /// Documentation string for this property.
301 /// Defaults to `"(undocumented sidecar property)"` when no `prop_doc` was supplied.
302 pub prop_doc: &'static str,
303}
304
305// SAFETY: All fields are &'static str — immutable and valid for program lifetime.
306unsafe impl Sync for SidecarPropEntry {}
307
308/// Trait dispatch entry mapping (concrete_tag, trait_tag) → vtable.
309#[repr(C)]
310pub struct TraitDispatchEntry {
311 /// Tag identifying the concrete type.
312 pub concrete_tag: mx_tag,
313 /// Tag identifying the trait interface.
314 pub trait_tag: mx_tag,
315 /// Pointer to the trait's vtable (cast from `&'static SomeVTable`).
316 pub vtable: *const c_void,
317 /// Symbol name of the `#[no_mangle]` vtable static
318 /// (e.g. `"__VTABLE_COUNTER_FOR_MYTYPE"`). Consumed by the host-time WASM
319 /// snapshot writer to emit `extern "C" { static <symbol>: u8; }`
320 /// declarations in `wasm_registry.rs`.
321 pub vtable_symbol: &'static str,
322}
323
324// SAFETY: vtable points to a static vtable valid for program lifetime.
325// Tags are Copy values. All fields are safe to read from any thread.
326unsafe impl Sync for TraitDispatchEntry {}
327unsafe impl Send for TraitDispatchEntry {}
328
329/// ALTREP class registration entry: fn pointer + `#[no_mangle]` symbol name.
330///
331/// See [`MX_ALTREP_REGISTRATIONS`] for context.
332#[repr(C)]
333pub struct AltrepRegistration {
334 /// Registration function called once at `R_init_*`.
335 pub register: extern "C" fn(),
336 /// Symbol name of `register` (e.g. `"__mx_altrep_reg_MyType"`). Consumed by
337 /// the host-time WASM snapshot writer to emit `extern "C" { fn <symbol>(); }`
338 /// declarations in `wasm_registry.rs`.
339 pub symbol: &'static str,
340}
341
342// SAFETY: register is a static fn pointer; symbol is a `'static` string.
343unsafe impl Sync for AltrepRegistration {}
344unsafe impl Send for AltrepRegistration {}
345// endregion
346
347// region: Universal Query
348
349/// Universal query function for trait dispatch.
350///
351/// Scans [`MX_TRAIT_DISPATCH`] for a matching `(concrete_tag, trait_tag)` pair.
352/// Returns the vtable pointer, or null if the trait is not implemented.
353///
354/// This replaces per-type query functions — a single function handles all types
355/// by reading from the global dispatch table.
356///
357/// # Safety
358///
359/// - `ptr` must point to a valid `mx_erased` with a valid base vtable.
360/// - Must be called on R's main thread.
361pub unsafe extern "C" fn universal_query(ptr: *mut mx_erased, trait_tag: mx_tag) -> *const c_void {
362 let concrete_tag = unsafe { (*(*ptr).base).concrete_tag };
363 for entry in trait_dispatch().iter() {
364 if entry.concrete_tag == concrete_tag && entry.trait_tag == trait_tag {
365 return entry.vtable;
366 }
367 }
368 std::ptr::null()
369}
370// endregion
371
372// region: Initialization
373
374/// Register all `#[miniextendr]` routines and ALTREP classes with R.
375///
376/// Called from `package_init()` during `R_init_*` (via `miniextendr_init!`).
377/// Everything else is automatic.
378///
379/// # Safety
380///
381/// Must be called from R's main thread during `R_init_*`.
382/// `dll` must be a valid pointer provided by R.
383#[unsafe(no_mangle)]
384pub unsafe extern "C" fn miniextendr_register_routines(dll: *mut DllInfo) {
385 // 1. Register ALTREP classes (skip during cdylib wrapper generation)
386 //
387 // During wrapper-gen, the cdylib is loaded temporarily via dyn.load() then
388 // unloaded via dyn.unload(). ALTREP class registration creates R-global entries
389 // with method pointers into the cdylib code. After dyn.unload(), those pointers
390 // become dangling. When the staticlib later re-registers, R may still have the
391 // stale entries, leading to heap corruption (e.g., "malloc(): unsorted double
392 // linked list corrupted" on Linux).
393 let wrapper_gen = std::env::var_os("MINIEXTENDR_CDYLIB_WRAPPERS").is_some();
394 if !wrapper_gen {
395 // All ALTREP classes — both user-defined (#[miniextendr] structs) and
396 // builtins (Vec, Box, Range, Cow, Arrow) — register via linkme
397 // MX_ALTREP_REGISTRATIONS. Each call site emits a
398 // `#[distributed_slice(MX_ALTREP_REGISTRATIONS)]` entry, so a single
399 // iteration here covers everything. No hand-enumerated builtin list needed.
400 for reg in altrep_regs().iter() {
401 (reg.register)();
402 }
403
404 // Verify no two ALTREP types registered the same class name.
405 // Duplicates cause silent overwrites in R — the wrong type gets
406 // reconstructed on readRDS, leading to memory corruption.
407 crate::altrep::assert_altrep_class_uniqueness();
408 }
409
410 // 2. Build call method defs with null sentinel
411 let mut call_defs: Vec<R_CallMethodDef> = self::call_defs().to_vec();
412 // Always register the cdylib wrapper-gen entry points so they're visible
413 // via getNativeSymbolInfo even when R_forceSymbols(TRUE) is set. wasm32
414 // doesn't run wrapper-gen (it's host-only), so these are gated off there.
415 // SAFETY: DL_FUNC is Option<extern "C-unwind" fn() -> *mut c_void> — R's
416 // standard erased function pointer. The actual signature (SEXP -> SEXP) is
417 // ABI-compatible; R dispatches based on numArgs.
418 #[cfg(not(target_arch = "wasm32"))]
419 {
420 call_defs.push(R_CallMethodDef {
421 name: c"miniextendr_write_wrappers".as_ptr(),
422 fun: unsafe {
423 std::mem::transmute::<
424 *const (),
425 Option<unsafe extern "C-unwind" fn() -> *mut c_void>,
426 >(miniextendr_write_wrappers as *const ())
427 },
428 numArgs: 1,
429 });
430 call_defs.push(R_CallMethodDef {
431 name: c"miniextendr_write_wasm_registry".as_ptr(),
432 fun: unsafe {
433 std::mem::transmute::<
434 *const (),
435 Option<unsafe extern "C-unwind" fn() -> *mut c_void>,
436 >(miniextendr_write_wasm_registry as *const ())
437 },
438 numArgs: 1,
439 });
440 }
441 call_defs.push(R_CallMethodDef {
442 name: std::ptr::null(),
443 fun: None,
444 numArgs: 0,
445 });
446
447 // 3. Register routines
448 // Leak the Vec — init runs once at package load, so this is fine.
449 unsafe {
450 crate::ffi::R_registerRoutines_unchecked(
451 dll,
452 std::ptr::null(),
453 call_defs.leak().as_ptr(),
454 std::ptr::null(),
455 std::ptr::null(),
456 );
457 }
458}
459
460/// Collect all R wrapper entries, sorted by priority and deduplicated.
461///
462/// Within each priority group, S7 class definitions are topologically sorted
463/// so parents are defined before children (S7 `parent = X` requires X to exist).
464///
465/// Host-only — wasm32 doesn't run wrapper-gen.
466#[cfg(not(target_arch = "wasm32"))]
467pub fn collect_r_wrappers() -> Vec<std::borrow::Cow<'static, str>> {
468 let mut entries: Vec<&RWrapperEntry> = MX_R_WRAPPERS.iter().collect();
469 entries.sort_by_key(|e| e.priority);
470
471 let mut seen = std::collections::HashSet::<&str>::new();
472 let mut result: Vec<std::borrow::Cow<'static, str>> = Vec::with_capacity(entries.len());
473 for entry in entries {
474 let trimmed = entry.content.trim();
475 if !trimmed.is_empty() && seen.insert(trimmed) {
476 // For standalone functions without explicit @rdname, inject one
477 // derived from the source file stem so same-file functions share
478 // a single .Rd page.
479 if entry.priority == RWrapperPriority::Function
480 && !has_rdname_tag(trimmed)
481 && !has_no_rd_tag(trimmed)
482 {
483 if let Some(rdname) = rdname_from_source_file(entry.source_file) {
484 result.push(std::borrow::Cow::Owned(inject_rdname(trimmed, &rdname)));
485 continue;
486 }
487 }
488 result.push(std::borrow::Cow::Borrowed(trimmed));
489 }
490 }
491
492 // Topological sort for S7 inheritance ordering
493 sort_s7_classes(&mut result);
494
495 result
496}
497
498#[cfg(not(target_arch = "wasm32"))]
499/// Check if an R wrapper fragment already has an `@rdname` tag.
500fn has_rdname_tag(content: &str) -> bool {
501 content.lines().any(|line| {
502 let trimmed = line.trim();
503 trimmed.starts_with("#' @rdname ")
504 })
505}
506
507#[cfg(not(target_arch = "wasm32"))]
508/// Check if an R wrapper fragment has `@noRd`.
509fn has_no_rd_tag(content: &str) -> bool {
510 content.lines().any(|line| {
511 let trimmed = line.trim();
512 trimmed == "#' @noRd"
513 })
514}
515
516#[cfg(not(target_arch = "wasm32"))]
517/// Derive an `@rdname` value from a source file path.
518///
519/// `"src/rust/zero_copy_tests.rs"` → `"zero_copy_tests"`
520/// `"lib.rs"` → `"lib"`
521fn rdname_from_source_file(path: &str) -> Option<String> {
522 let file_name = path.rsplit(['/', '\\']).next()?;
523 let stem = file_name.strip_suffix(".rs").unwrap_or(file_name);
524 if stem.is_empty() || stem == "lib" || stem == "mod" {
525 return None;
526 }
527 Some(stem.to_string())
528}
529
530#[cfg(not(target_arch = "wasm32"))]
531/// Inject `#' @rdname <value>` (and `@title` if missing) into an R wrapper
532/// fragment. Inserts before the first `@export`/`@keywords`/`@source` line,
533/// or after the last roxygen line.
534fn inject_rdname(content: &str, rdname: &str) -> String {
535 let rdname_line = format!("#' @rdname {rdname}");
536 let has_title = content.lines().any(|l| l.trim().starts_with("#' @title "));
537 // Functions with no doc comments need a title so the @rdname page has an anchor
538 let title_line = if has_title {
539 None
540 } else {
541 Some(format!("#' @title {}", rdname.replace('_', " ")))
542 };
543
544 let lines: Vec<&str> = content.lines().collect();
545 let mut result = Vec::with_capacity(lines.len() + 2);
546 let mut inserted = false;
547
548 for line in &lines {
549 let trimmed = line.trim();
550 // Insert before @export, @keywords, or @source lines
551 if !inserted
552 && (trimmed.starts_with("#' @export")
553 || trimmed.starts_with("#' @keywords")
554 || trimmed.starts_with("#' @source"))
555 {
556 if let Some(ref t) = title_line {
557 result.push(t.as_str());
558 }
559 result.push(rdname_line.as_str());
560 inserted = true;
561 }
562 result.push(line);
563 }
564
565 // If we never found a good insertion point, insert before the function def
566 if !inserted {
567 let last_roxy = lines
568 .iter()
569 .rposition(|l| l.trim().starts_with("#'"))
570 .unwrap_or(0);
571 let insert_at = last_roxy + 1;
572 if let Some(ref t) = title_line {
573 result.insert(insert_at, t.as_str());
574 result.insert(insert_at + 1, rdname_line.as_str());
575 } else {
576 result.insert(insert_at, rdname_line.as_str());
577 }
578 }
579
580 result.join("\n")
581}
582
583#[cfg(not(target_arch = "wasm32"))]
584/// Sort S7 class definitions so parents come before children.
585///
586/// Detects `S7::new_class()` calls, extracts `parent = ClassName` relationships,
587/// and performs topological sort. Non-S7 entries keep their relative order.
588fn sort_s7_classes(entries: &mut [std::borrow::Cow<'static, str>]) {
589 use std::collections::HashMap;
590
591 // Parse S7 class definitions: find (index, name, parent)
592 let mut s7_info: Vec<(usize, String, Option<String>)> = Vec::new();
593
594 for (i, entry) in entries.iter().enumerate() {
595 if let Some(nc_pos) = entry.find("S7::new_class(") {
596 // Extract class name: "NAME <- S7::new_class("
597 let before = entry[..nc_pos].trim_end();
598 let name = before
599 .strip_suffix("<-")
600 .or_else(|| before.rsplit_once("<-").map(|(_, r)| r))
601 .map(|s| s.trim())
602 .and_then(|s| s.split_whitespace().last());
603
604 let Some(name) = name else { continue };
605
606 // Extract parent: "parent = ParentName,"
607 let after = &entry[nc_pos..];
608 let parent = after.find("parent = ").and_then(|p| {
609 let rest = &after[p + "parent = ".len()..];
610 let end = rest.find([',', ')', '\n']).unwrap_or(rest.len());
611 let p = rest[..end].trim();
612 if p.is_empty() {
613 None
614 } else {
615 Some(p.to_string())
616 }
617 });
618
619 s7_info.push((i, name.to_string(), parent));
620 }
621 }
622
623 if s7_info.len() <= 1 {
624 return;
625 }
626
627 // Build name → position-in-s7_info map.
628 // Also map placeholder .__MX_CLASS_REF_<Name>__ to the same position, since
629 // the placeholder carries the Rust type name which equals the R class name
630 // (before any `class = "Override"` resolution — good enough for ordering).
631 let mut name_to_pos: HashMap<String, usize> = HashMap::new();
632 for (pos, (_, name, _)) in s7_info.iter().enumerate() {
633 name_to_pos.insert(name.clone(), pos);
634 // Also register the placeholder form so a child class that references
635 // `parent = .__MX_CLASS_REF_ParentName__` can still be sorted correctly.
636 name_to_pos.insert(format!(".__MX_CLASS_REF_{name}__"), pos);
637 }
638
639 // Topological sort: repeatedly emit classes whose parent is already placed
640 let n = s7_info.len();
641 let mut order: Vec<usize> = Vec::with_capacity(n);
642 let mut placed = vec![false; n];
643
644 for _ in 0..n {
645 for (pos, (_, _, parent)) in s7_info.iter().enumerate() {
646 if placed[pos] {
647 continue;
648 }
649 let ready = match parent {
650 None => true,
651 Some(pname) => match name_to_pos.get(pname.as_str()) {
652 None => true, // external parent
653 Some(&pp) => placed[pp],
654 },
655 };
656 if ready {
657 order.push(pos);
658 placed[pos] = true;
659 }
660 }
661 }
662
663 // Fallback: add any remaining (cycles) in original order
664 for (pos, &is_placed) in placed.iter().enumerate().take(n) {
665 if !is_placed {
666 order.push(pos);
667 }
668 }
669
670 // Apply: place sorted S7 entries back at their original indices
671 let s7_indices: Vec<usize> = s7_info.iter().map(|(i, _, _)| *i).collect();
672 let original: Vec<std::borrow::Cow<'static, str>> =
673 s7_indices.iter().map(|&i| entries[i].clone()).collect();
674
675 for (slot, &src) in order.iter().enumerate() {
676 entries[s7_indices[slot]] = original[src].clone();
677 }
678}
679// endregion
680
681#[cfg(not(target_arch = "wasm32"))]
682/// Rotate a comma-separated quoted-choice string so `preferred` is first.
683///
684/// `choices_str` has the shape `"\"a\", \"b\", \"c\""` (already quoted +
685/// joined). `preferred` is the unquoted user-supplied default (e.g. `"b"`).
686/// On miss, panics with the placeholder name — the cdylib write step is the
687/// only caller, so a panic surfaces as a load-time error in the host R session
688/// rather than silently producing a broken wrapper.
689fn rotate_choices_for_default(choices_str: &str, preferred: &str, placeholder: &str) -> String {
690 let parts: Vec<&str> = choices_str.split(", ").collect();
691 let pos = parts
692 .iter()
693 .position(|p| p.strip_prefix('"').and_then(|s| s.strip_suffix('"')) == Some(preferred))
694 .unwrap_or_else(|| {
695 panic!(
696 "miniextendr: preferred default `{preferred}` for placeholder `{placeholder}` \
697 does not match any choice in [{choices_str}]"
698 )
699 });
700 let mut rotated: Vec<&str> = Vec::with_capacity(parts.len());
701 rotated.push(parts[pos]);
702 for (i, p) in parts.iter().enumerate() {
703 if i != pos {
704 rotated.push(p);
705 }
706 }
707 rotated.join(", ")
708}
709
710// region: R Wrapper File Generation
711
712/// Write all R wrapper entries to a file.
713///
714/// Called from [`miniextendr_write_wrappers`] (via cdylib `dyn.load`/`.Call`).
715/// All distributed_slice entries from `#[miniextendr]` items are available
716/// because the cdylib includes all symbols by design.
717///
718/// Host-only — wasm32 doesn't run wrapper-gen.
719#[cfg(not(target_arch = "wasm32"))]
720pub fn write_r_wrappers_to_file(path: &str) {
721 // Build the new content in memory
722 let mut content = String::from(
723 "# ---- AUTO-GENERATED FILE - DO NOT EDIT ----
724# This file is generated by the miniextendr proc-macro during package build.
725# Any manual changes will be overwritten.
726#
727# To regenerate: rebuild the package (R CMD INSTALL or devtools::install).
728# nolint start
729# nocov start
730
731# Internal helper: re-raise a tagged Rust error/condition value as an R condition.
732# Generated wrappers call this whenever `.Call()` returns a `rust_condition_value`.
733# `.call_default` is the wrapper's `sys.call()`, used as the fallback when the
734# Rust panic payload didn't carry a captured call (e.g. lambda contexts that
735# pass `.call = NULL` to `.Call`). For error/panic kinds `stop()` longjmps;
736# for warning/message/condition the helper signals and returns invisible(NULL),
737# which the wrapper's surrounding `return(...)` propagates as its result.
738.miniextendr_raise_condition <- function(.val, .call_default) {
739 .msg <- .val$error
740 .call <- (if (is.null(.val$call)) .call_default else .val$call)
741 .class <- .val$class
742 switch(.val$kind,
743 error = stop(structure(list(message = .msg, call = .call, kind = \"error\"),
744 class = c(.class, \"rust_error\", \"simpleError\", \"error\", \"condition\"))),
745 warning = warning(structure(list(message = .msg, call = .call, kind = \"warning\"),
746 class = c(.class, \"rust_warning\", \"simpleWarning\", \"warning\", \"condition\"))),
747 message = message(structure(list(message = paste0(.msg, \"\\n\"), call = NULL, kind = \"message\"),
748 class = c(.class, \"rust_message\", \"simpleMessage\", \"message\", \"condition\"))),
749 condition = signalCondition(structure(list(message = .msg, call = .call, kind = \"condition\"),
750 class = c(.class, \"rust_condition\", \"simpleCondition\", \"condition\"))),
751 panic = stop(structure(list(message = .msg, call = .call, kind = \"panic\"),
752 class = c(\"rust_error\", \"simpleError\", \"error\", \"condition\"))),
753 stop(structure(list(message = .msg, call = .call, kind = .val$kind),
754 class = c(\"rust_error\", \"simpleError\", \"error\", \"condition\")))
755 )
756 invisible(NULL)
757}
758
759",
760 );
761
762 for fragment in collect_r_wrappers() {
763 content.push_str(fragment.as_ref());
764 content.push_str("\n\n");
765 }
766
767 content.push_str("# nocov end\n# nolint end\n");
768
769 // Replace match_arg choices placeholders with actual enum choices.
770 // If the entry has a `preferred_default`, rotate the list so that value
771 // is first — R's `match.arg(arg)` returns `arg[1]` when arg matches the
772 // formal default, so the position-0 element becomes the effective default.
773 for entry in MX_MATCH_ARG_CHOICES.iter() {
774 let choices_str = (entry.choices_str)();
775 let rotated = if entry.preferred_default.is_empty() {
776 choices_str
777 } else {
778 rotate_choices_for_default(&choices_str, entry.preferred_default, entry.placeholder)
779 };
780 let replacement = format!("c({rotated})");
781 content = content.replace(entry.placeholder, &replacement);
782 }
783
784 // Replace match_arg @param doc placeholders with human-readable choice descriptions
785 for entry in MX_MATCH_ARG_PARAM_DOCS.iter() {
786 let choices = (entry.choices_str)();
787 let prefix = if entry.several_ok {
788 "One or more of"
789 } else {
790 "One of"
791 };
792 let replacement = format!("{prefix} {choices}.");
793 content = content.replace(entry.placeholder, &replacement);
794 }
795
796 // Replace .__MX_S7_SIDECAR_PROP_DOCS_<TypeName>__ placeholders with @prop lines.
797 //
798 // Each S7 class wrapper with `r_data_accessors` embeds one placeholder per type.
799 // We collect all entries for each type name and emit `#' @prop field doc` lines.
800 {
801 use std::collections::HashMap;
802 // Group entries by rust_type
803 let mut by_type: HashMap<&'static str, Vec<&SidecarPropEntry>> = HashMap::new();
804 for entry in MX_S7_SIDECAR_PROPS.iter() {
805 by_type.entry(entry.rust_type).or_default().push(entry);
806 }
807
808 const SIDECAR_PREFIX: &str = ".__MX_S7_SIDECAR_PROP_DOCS_";
809 const SIDECAR_SUFFIX: &str = "__";
810 let mut result = String::with_capacity(content.len());
811 let mut remaining = content.as_str();
812 while let Some(start) = remaining.find(SIDECAR_PREFIX) {
813 result.push_str(&remaining[..start]);
814 remaining = &remaining[start + SIDECAR_PREFIX.len()..];
815 if let Some(end) = remaining.find(SIDECAR_SUFFIX) {
816 let rust_name = &remaining[..end];
817 remaining = &remaining[end + SIDECAR_SUFFIX.len()..];
818 // Emit @prop lines for each sidecar field registered for this type
819 if let Some(entries) = by_type.get(rust_name) {
820 let prop_lines: String = entries
821 .iter()
822 .map(|e| format!("#' @prop {} {}", e.field_name, e.prop_doc))
823 .collect::<Vec<_>>()
824 .join("\n");
825 result.push_str(&prop_lines);
826 }
827 // If no entries, the placeholder is simply removed (empty replacement)
828 } else {
829 // Malformed placeholder — emit prefix and stop
830 result.push_str(SIDECAR_PREFIX);
831 break;
832 }
833 }
834 result.push_str(remaining);
835 content = result;
836 }
837
838 // Replace .__MX_CLASS_REF_<RustName>__ placeholders with actual R class names.
839 //
840 // Build a lookup from rust_type → ClassNameEntry, then do a linear scan over
841 // the content for each placeholder. We avoid pulling in a regex dependency
842 // by scanning for the sentinel prefix directly.
843 {
844 use std::collections::HashMap;
845 let class_index: HashMap<&'static str, &ClassNameEntry> =
846 MX_CLASS_NAMES.iter().map(|e| (e.rust_type, e)).collect();
847
848 // Two placeholder forms:
849 // .__MX_CLASS_REF_<RustName>__ → loud fallback on miss
850 // (used by R6 `inherit =`, S7 parent class, convert_from / convert_to)
851 // .__MX_CLASS_REF_OR_ANY_<RustName>__ → silent `S7::class_any` on miss
852 // (used by S7 property class constraints — #203: a getter returning
853 // `SEXP`, `PathBuf`, or an R6/S3/S4 type shouldn't break load-time
854 // with "object '…' not found"; the property just falls back to the
855 // permissive class_any it had pre-#154.)
856 const OR_ANY_PREFIX: &str = ".__MX_CLASS_REF_OR_ANY_";
857 const PREFIX: &str = ".__MX_CLASS_REF_";
858 const SUFFIX: &str = "__";
859
860 let mut result = String::with_capacity(content.len());
861 let mut remaining = content.as_str();
862 while let Some(start) = remaining.find(PREFIX) {
863 result.push_str(&remaining[..start]);
864 // Does this occurrence have the OR_ANY form?
865 let rest_from_prefix = &remaining[start..];
866 let (quiet_fallback, header_len) = if rest_from_prefix.starts_with(OR_ANY_PREFIX) {
867 (true, OR_ANY_PREFIX.len())
868 } else {
869 (false, PREFIX.len())
870 };
871 remaining = &rest_from_prefix[header_len..];
872 // Find the closing __
873 if let Some(end) = remaining.find(SUFFIX) {
874 let rust_name = &remaining[..end];
875 remaining = &remaining[end + SUFFIX.len()..];
876 match class_index.get(rust_name) {
877 Some(entry) if quiet_fallback => {
878 // S7 property resolved to an S7 class → use the
879 // registered name. Registered-but-non-S7 types
880 // (R6 / S3 / S4 / Env / Vctrs) fall through to
881 // class_any because S7 can't use them as class
882 // constraints.
883 if entry.class_system == "s7" {
884 result.push_str(entry.r_class_name);
885 } else {
886 result.push_str("S7::class_any");
887 }
888 }
889 Some(entry) => {
890 result.push_str(entry.r_class_name);
891 }
892 None if quiet_fallback => {
893 // Unresolved S7 property type → silent class_any,
894 // matching pre-#154 behavior.
895 result.push_str("S7::class_any");
896 }
897 None => {
898 // Unresolved non-property CLASS_REF: fall back to the
899 // bare Rust name with a warning.
900 eprintln!(
901 "miniextendr: unresolved class reference `{rust_name}` \
902 in R wrapper — is the class defined in a reachable crate?"
903 );
904 result.push_str(rust_name);
905 }
906 }
907 } else {
908 // Malformed placeholder (no closing __): emit as-is and stop scanning.
909 result.push_str(PREFIX);
910 break;
911 }
912 }
913 result.push_str(remaining);
914 content = result;
915 }
916
917 // Only write if content changed (avoids unnecessary NAMESPACE/man regeneration).
918 //
919 // "Semantic equality" means equal after stripping source-position suffixes from
920 // the source-attribution comments emitted by the `#[miniextendr]` proc-macro:
921 //
922 // # Generated from Rust fn `foo` (lib.rs:42:8)
923 // ^^^^^ positional suffix — ignored here
924 //
925 // These positions shift whenever any unrelated Rust source above a wrapper is
926 // edited. The NOTE and the file rewrite should only fire when the actual
927 // wrapper code, exports, or docstrings change — not when line numbers move.
928 // The written file still carries the real line:col for jump-to-source.
929 let existing = std::fs::read_to_string(path).unwrap_or_default();
930 if wrappers_semantically_equal(&existing, &content) {
931 return;
932 }
933
934 std::fs::write(path, content.as_bytes())
935 .unwrap_or_else(|e| panic!("failed to write {path}: {e}"));
936
937 if !existing.is_empty() {
938 let filename = std::path::Path::new(path)
939 .file_name()
940 .and_then(|f| f.to_str())
941 .unwrap_or("wrappers.R");
942 eprintln!();
943 eprintln!("NOTE: {filename} changed — run devtools::document() to update NAMESPACE.");
944 eprintln!();
945 }
946}
947
948/// Returns `true` when `a` and `b` are equal after normalising away
949/// `(<file>.rs:LINE:COL)` positional suffixes in source-attribution comments.
950///
951/// The `#[miniextendr]` proc-macro emits comments of the form:
952/// ```r
953/// # Generated from Rust fn `foo` (lib.rs:42:8)
954/// ```
955/// The `42:8` part shifts whenever unrelated code above the wrapper is edited,
956/// producing spurious wrapper-file rewrites and misleading NOTEs. Normalisation
957/// replaces `(lib.rs:42:8)` → `(lib.rs:_:_)` for the comparison only; the
958/// file on disk still carries the real positions.
959///
960/// A match requires `(` + one or more non-`()`/non-newline chars + `.rs:` +
961/// ASCII digits + `:` + ASCII digits + `)`. Malformed or non-`.rs` patterns
962/// are left untouched.
963fn wrappers_semantically_equal(a: &str, b: &str) -> bool {
964 normalize_source_locs(a) == normalize_source_locs(b)
965}
966
967/// Replace every `(<stem>.rs:LINE:COL)` occurrence with `(<stem>.rs:_:_)`.
968///
969/// Returns a [`std::borrow::Cow::Borrowed`] slice when no replacements are
970/// needed (zero-allocation fast path for the common case where the file has
971/// never been written or is truly unchanged).
972fn normalize_source_locs(s: &str) -> std::borrow::Cow<'_, str> {
973 // Fast path: bail early if there is no `.rs:` anywhere in the string.
974 if !s.contains(".rs:") {
975 return std::borrow::Cow::Borrowed(s);
976 }
977
978 let bytes = s.as_bytes();
979 let len = bytes.len();
980 let mut out = String::with_capacity(len);
981 let mut pos = 0usize;
982 let mut any_replaced = false;
983
984 while pos < len {
985 // Look for the next `(`.
986 let Some(open) = memchr(bytes, pos, b'(') else {
987 break;
988 };
989
990 // After `(`, scan for `.rs:` without crossing `)` or `(` or a newline.
991 let inner_start = open + 1;
992 let Some(dot_rs) = find_substr(bytes, inner_start, b".rs:") else {
993 // No `.rs:` at all in the rest of the string — copy remainder and stop.
994 out.push_str(&s[pos..]);
995 return std::borrow::Cow::Owned(out);
996 };
997
998 // Make sure `.rs:` precedes any closing `)`, `(`, or newline (no nesting).
999 let intervening = &bytes[inner_start..dot_rs];
1000 if intervening
1001 .iter()
1002 .any(|&b| b == b')' || b == b'(' || b == b'\n')
1003 {
1004 // Guard crossed; copy up to and including `(` and continue after it.
1005 out.push_str(&s[pos..=open]);
1006 pos = open + 1;
1007 continue;
1008 }
1009
1010 // After `.rs:`, consume digits for LINE.
1011 let after_colon1 = dot_rs + 4; // skip `.rs:`
1012 let Some(colon2) = scan_digits(bytes, after_colon1) else {
1013 // Not digits after `.rs:` — not a source-attribution pattern.
1014 out.push_str(&s[pos..=open]);
1015 pos = open + 1;
1016 continue;
1017 };
1018 if colon2 >= len || bytes[colon2] != b':' {
1019 out.push_str(&s[pos..=open]);
1020 pos = open + 1;
1021 continue;
1022 }
1023
1024 // After the second `:`, consume digits for COL.
1025 let after_colon2 = colon2 + 1;
1026 let Some(close_pos) = scan_digits(bytes, after_colon2) else {
1027 out.push_str(&s[pos..=open]);
1028 pos = open + 1;
1029 continue;
1030 };
1031 if close_pos >= len || bytes[close_pos] != b')' {
1032 out.push_str(&s[pos..=open]);
1033 pos = open + 1;
1034 continue;
1035 }
1036
1037 // We have a match: `(stem.rs:LINE:COL)` — emit `(stem.rs:_:_)`.
1038 any_replaced = true;
1039 out.push_str(&s[pos..inner_start]); // text before inner_start (includes `(`)
1040 out.push_str(&s[inner_start..dot_rs + 3]); // `stem.rs` (without the colon)
1041 out.push_str(":_:_)");
1042 pos = close_pos + 1; // skip past the closing `)`
1043 }
1044
1045 if !any_replaced {
1046 return std::borrow::Cow::Borrowed(s);
1047 }
1048
1049 out.push_str(&s[pos..]);
1050 std::borrow::Cow::Owned(out)
1051}
1052
1053/// Find the first occurrence of `needle` in `haystack[from..]`.
1054/// Returns the absolute index in `haystack`, or `None`.
1055#[inline]
1056fn find_substr(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
1057 let window = haystack.get(from..)?;
1058 window
1059 .windows(needle.len())
1060 .position(|w| w == needle)
1061 .map(|rel| from + rel)
1062}
1063
1064/// Find the first byte equal to `needle` in `haystack[from..]`.
1065/// Returns the absolute index, or `None`.
1066#[inline]
1067fn memchr(haystack: &[u8], from: usize, needle: u8) -> Option<usize> {
1068 haystack[from..]
1069 .iter()
1070 .position(|&b| b == needle)
1071 .map(|rel| from + rel)
1072}
1073
1074/// Advance past a run of ASCII digits starting at `haystack[from]`.
1075/// Returns the absolute index of the first non-digit byte, or `None` if
1076/// there are no digits at `from` (empty run is not valid).
1077#[inline]
1078fn scan_digits(haystack: &[u8], from: usize) -> Option<usize> {
1079 let start = haystack.get(from..)?;
1080 let count = start.iter().take_while(|&&b| b.is_ascii_digit()).count();
1081 if count == 0 { None } else { Some(from + count) }
1082}
1083// endregion
1084
1085// region: C-Callable Entry Points (cdylib)
1086//
1087// All cdylib wrapper-gen entry points are host-only — wasm32 builds skip
1088// them (the cdylib pass itself doesn't run on wasm32; wrappers and
1089// `wasm_registry.rs` are pre-generated on native and shipped into the
1090// wasm32 install).
1091
1092/// C-callable entry point for R wrapper generation via cdylib.
1093///
1094/// Called from Makevars via Rscript: loads the cdylib with `dyn.load()`,
1095/// then `.Call("miniextendr_write_wrappers", path)` to write
1096/// `R/miniextendr-wrappers.R`. NAMESPACE generation is left to roxygen2
1097/// (`devtools::document()`).
1098///
1099/// # Safety
1100///
1101/// `path_sexp` must be a valid STRSXP of length >= 1.
1102#[cfg(not(target_arch = "wasm32"))]
1103#[unsafe(no_mangle)]
1104pub unsafe extern "C" fn miniextendr_write_wrappers(
1105 path_sexp: crate::ffi::SEXP,
1106) -> crate::ffi::SEXP {
1107 unsafe {
1108 use crate::ffi::{SEXP, SexpExt};
1109
1110 let char_sexp = path_sexp.string_elt_unchecked(0);
1111 let c_str = std::ffi::CStr::from_ptr(char_sexp.r_char_unchecked());
1112 let path = c_str
1113 .to_str()
1114 .unwrap_or_else(|e| panic!("invalid UTF-8 in path: {e}"));
1115
1116 write_r_wrappers_to_file(path);
1117
1118 SEXP::nil()
1119 }
1120}
1121
1122/// C-callable entry point for `wasm_registry.rs` generation via cdylib.
1123///
1124/// Pairs with [`miniextendr_write_wrappers`]: same cdylib, separate `.Call`,
1125/// independent output path. Host-only; the generated file itself is then
1126/// consumed at compile time by the user crate's wasm32 build via
1127/// `install_wasm_runtime_slices`.
1128///
1129/// # Safety
1130///
1131/// `path_sexp` must be a valid STRSXP of length >= 1.
1132#[cfg(not(target_arch = "wasm32"))]
1133#[unsafe(no_mangle)]
1134pub unsafe extern "C" fn miniextendr_write_wasm_registry(
1135 path_sexp: crate::ffi::SEXP,
1136) -> crate::ffi::SEXP {
1137 unsafe {
1138 use crate::ffi::{SEXP, SexpExt};
1139
1140 let char_sexp = path_sexp.string_elt_unchecked(0);
1141 let c_str = std::ffi::CStr::from_ptr(char_sexp.r_char_unchecked());
1142 let path = c_str
1143 .to_str()
1144 .unwrap_or_else(|e| panic!("invalid UTF-8 in path: {e}"));
1145
1146 crate::wasm_registry_writer::write_wasm_registry_to_file(path);
1147
1148 SEXP::nil()
1149 }
1150}
1151// endregion
1152
1153// region: Unit tests for class-ref placeholder resolution
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::*;
1158
1159 /// Run the class-ref placeholder resolver over `content` using `entries` as the registry.
1160 ///
1161 /// This mirrors the logic in `write_r_wrappers_to_file` but operates on a caller-supplied
1162 /// slice so it can be called from unit tests without a live distributed_slice.
1163 fn resolve_class_refs(content: &str, entries: &[ClassNameEntry]) -> String {
1164 use std::collections::HashMap;
1165 let class_index: HashMap<&str, &ClassNameEntry> =
1166 entries.iter().map(|e| (e.rust_type, e)).collect();
1167
1168 const OR_ANY_PREFIX: &str = ".__MX_CLASS_REF_OR_ANY_";
1169 const PREFIX: &str = ".__MX_CLASS_REF_";
1170 const SUFFIX: &str = "__";
1171
1172 let mut result = String::with_capacity(content.len());
1173 let mut remaining = content;
1174 while let Some(start) = remaining.find(PREFIX) {
1175 result.push_str(&remaining[..start]);
1176 let rest_from_prefix = &remaining[start..];
1177 let (quiet_fallback, header_len) = if rest_from_prefix.starts_with(OR_ANY_PREFIX) {
1178 (true, OR_ANY_PREFIX.len())
1179 } else {
1180 (false, PREFIX.len())
1181 };
1182 remaining = &rest_from_prefix[header_len..];
1183 if let Some(end) = remaining.find(SUFFIX) {
1184 let rust_name = &remaining[..end];
1185 remaining = &remaining[end + SUFFIX.len()..];
1186 match class_index.get(rust_name) {
1187 Some(entry) if quiet_fallback => {
1188 if entry.class_system == "s7" {
1189 result.push_str(entry.r_class_name);
1190 } else {
1191 result.push_str("S7::class_any");
1192 }
1193 }
1194 Some(entry) => result.push_str(entry.r_class_name),
1195 None if quiet_fallback => result.push_str("S7::class_any"),
1196 None => result.push_str(rust_name),
1197 }
1198 } else {
1199 result.push_str(PREFIX);
1200 break;
1201 }
1202 }
1203 result.push_str(remaining);
1204 result
1205 }
1206
1207 #[test]
1208 fn test_class_ref_resolver_with_override() {
1209 // S7Shape is registered with r_class_name = "Shape" (override)
1210 let entries = [
1211 ClassNameEntry {
1212 rust_type: "S7Shape",
1213 r_class_name: "Shape",
1214 class_system: "s7",
1215 },
1216 ClassNameEntry {
1217 rust_type: "S7Circle",
1218 r_class_name: "S7Circle",
1219 class_system: "s7",
1220 },
1221 ];
1222
1223 let input = r#"S7Circle <- S7::new_class("S7Circle", parent = .__MX_CLASS_REF_S7Shape__, properties = list())"#;
1224 let output = resolve_class_refs(input, &entries);
1225 assert_eq!(
1226 output,
1227 r#"S7Circle <- S7::new_class("S7Circle", parent = Shape, properties = list())"#
1228 );
1229 }
1230
1231 #[test]
1232 fn test_class_ref_resolver_multiple_placeholders() {
1233 let entries = [
1234 ClassNameEntry {
1235 rust_type: "Point2D",
1236 r_class_name: "Point2D",
1237 class_system: "s7",
1238 },
1239 ClassNameEntry {
1240 rust_type: "Point3D",
1241 r_class_name: "Point3D",
1242 class_system: "s7",
1243 },
1244 ];
1245
1246 let input = "S7::method(convert, list(.__MX_CLASS_REF_Point2D__, Point3D)) <- function(from, to) {}";
1247 let output = resolve_class_refs(input, &entries);
1248 assert_eq!(
1249 output,
1250 "S7::method(convert, list(Point2D, Point3D)) <- function(from, to) {}"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_class_ref_resolver_unresolved_falls_back_to_rust_name() {
1256 let entries: [ClassNameEntry; 0] = [];
1257 let input = "parent = .__MX_CLASS_REF_UnknownClass__";
1258 let output = resolve_class_refs(input, &entries);
1259 // Falls back to the bare Rust name
1260 assert_eq!(output, "parent = UnknownClass");
1261 }
1262
1263 #[test]
1264 fn test_class_ref_resolver_verbatim_passthrough() {
1265 // Strings that are NOT bare identifiers should not have been wrapped in
1266 // a placeholder, so they pass through the resolver unchanged.
1267 let entries: [ClassNameEntry; 0] = [];
1268 let input = "parent = S7::class_any";
1269 let output = resolve_class_refs(input, &entries);
1270 assert_eq!(output, "parent = S7::class_any");
1271 }
1272
1273 #[test]
1274 fn test_is_bare_identifier_via_resolver() {
1275 // A placeholder for a bare identifier should be resolved.
1276 let entries = [ClassNameEntry {
1277 rust_type: "MyClass",
1278 r_class_name: "MyClass",
1279 class_system: "r6",
1280 }];
1281 let input = "inherit = .__MX_CLASS_REF_MyClass__";
1282 let output = resolve_class_refs(input, &entries);
1283 assert_eq!(output, "inherit = MyClass");
1284 }
1285
1286 // #203 — OR_ANY variant: S7 property class constraints should fall back
1287 // silently to S7::class_any when the type isn't a registered S7 class.
1288
1289 #[test]
1290 fn or_any_resolves_registered_s7_class() {
1291 let entries = [ClassNameEntry {
1292 rust_type: "S7PropInner",
1293 r_class_name: "S7PropInner",
1294 class_system: "s7",
1295 }];
1296 let input = "class = .__MX_CLASS_REF_OR_ANY_S7PropInner__";
1297 let output = resolve_class_refs(input, &entries);
1298 assert_eq!(output, "class = S7PropInner");
1299 }
1300
1301 #[test]
1302 fn or_any_unregistered_type_falls_back_to_class_any_silently() {
1303 // No entry for SEXP, PathBuf, Json, etc.
1304 let entries: [ClassNameEntry; 0] = [];
1305 let input = "class = .__MX_CLASS_REF_OR_ANY_SEXP__";
1306 let output = resolve_class_refs(input, &entries);
1307 assert_eq!(output, "class = S7::class_any");
1308 }
1309
1310 #[test]
1311 fn or_any_registered_non_s7_type_falls_back_to_class_any() {
1312 // A type registered as an R6 class can't serve as an S7 class
1313 // constraint — S7 would error at load time. Quiet fallback avoids
1314 // that footgun.
1315 let entries = [ClassNameEntry {
1316 rust_type: "R6Counter",
1317 r_class_name: "R6Counter",
1318 class_system: "r6",
1319 }];
1320 let input = "class = .__MX_CLASS_REF_OR_ANY_R6Counter__";
1321 let output = resolve_class_refs(input, &entries);
1322 assert_eq!(output, "class = S7::class_any");
1323 }
1324
1325 #[test]
1326 fn loud_class_ref_still_emits_bare_name_when_unresolved() {
1327 // The existing CLASS_REF variant (without OR_ANY) keeps its loud
1328 // behavior: bare Rust name + compile-time warning. Regression test
1329 // for that path alongside the OR_ANY introduction.
1330 let entries: [ClassNameEntry; 0] = [];
1331 let input = "parent = .__MX_CLASS_REF_UnknownClass__";
1332 let output = resolve_class_refs(input, &entries);
1333 assert_eq!(output, "parent = UnknownClass");
1334 }
1335
1336 #[test]
1337 fn mixed_placeholders_resolve_independently() {
1338 // A single wrapper can mix both variants — `inherit = parent` (loud)
1339 // and `class = prop_type` (quiet). Verify each takes its own path.
1340 let entries = [
1341 ClassNameEntry {
1342 rust_type: "Parent",
1343 r_class_name: "Parent",
1344 class_system: "s7",
1345 },
1346 // PropInner deliberately missing → should resolve to class_any.
1347 ];
1348 let input =
1349 "inherit = .__MX_CLASS_REF_Parent__, class = .__MX_CLASS_REF_OR_ANY_PropInner__";
1350 let output = resolve_class_refs(input, &entries);
1351 assert_eq!(output, "inherit = Parent, class = S7::class_any");
1352 }
1353
1354 // region: normalize_source_locs unit tests (#528)
1355
1356 #[test]
1357 fn normalize_source_locs_noop_on_plain_text() {
1358 // No `.rs:N:M` pattern — should return the input string unchanged (Borrowed).
1359 let input = "# just a comment\nfoo <- function() {}\n";
1360 let result = normalize_source_locs(input);
1361 assert_eq!(result.as_ref(), input);
1362 // Verify it's the zero-allocation borrowed path.
1363 assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
1364 }
1365
1366 #[test]
1367 fn normalize_source_locs_single_attribution() {
1368 let input = "# Generated from Rust fn `foo` (lib.rs:42:8)";
1369 let result = normalize_source_locs(input);
1370 assert_eq!(
1371 result.as_ref(),
1372 "# Generated from Rust fn `foo` (lib.rs:_:_)"
1373 );
1374 }
1375
1376 #[test]
1377 fn normalize_source_locs_multiple_attributions() {
1378 let input = concat!(
1379 "# (conversions.rs:1:5)\n",
1380 "foo <- function() {}\n",
1381 "# (conversions.rs:158:8)\n",
1382 "bar <- function() {}\n",
1383 );
1384 let result = normalize_source_locs(input);
1385 assert_eq!(
1386 result.as_ref(),
1387 concat!(
1388 "# (conversions.rs:_:_)\n",
1389 "foo <- function() {}\n",
1390 "# (conversions.rs:_:_)\n",
1391 "bar <- function() {}\n",
1392 )
1393 );
1394 }
1395
1396 #[test]
1397 fn normalize_source_locs_does_not_match_extra_colon() {
1398 // `(lib.rs:1:5:6)` — four components — is NOT a valid attribution; leave alone.
1399 let input = "(lib.rs:1:5:6)";
1400 let result = normalize_source_locs(input);
1401 // The pattern `(lib.rs:1:5` matches, but then we expect `)` after the col
1402 // digits and find `:` instead — so it should NOT be replaced.
1403 assert_eq!(result.as_ref(), input);
1404 }
1405
1406 #[test]
1407 fn normalize_source_locs_does_not_match_without_parens() {
1408 // Bare `lib.rs:1:5` without surrounding parens — leave untouched.
1409 let input = "lib.rs:1:5";
1410 let result = normalize_source_locs(input);
1411 assert_eq!(result.as_ref(), input);
1412 assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
1413 }
1414
1415 #[test]
1416 fn wrappers_semantically_equal_position_only_diff() {
1417 // Two wrappers identical except for line:col are semantically equal.
1418 let old = "# Generated from Rust fn `foo` (lib.rs:42:8)\nfoo <- function() {}\n";
1419 let new = "# Generated from Rust fn `foo` (lib.rs:99:8)\nfoo <- function() {}\n";
1420 assert!(wrappers_semantically_equal(old, new));
1421 }
1422
1423 #[test]
1424 fn wrappers_semantically_equal_content_diff() {
1425 // A real semantic change (different function body) is NOT equal.
1426 let old = "# Generated from Rust fn `foo` (lib.rs:42:8)\nfoo <- function() { 1L }\n";
1427 let new = "# Generated from Rust fn `foo` (lib.rs:42:8)\nfoo <- function() { 2L }\n";
1428 assert!(!wrappers_semantically_equal(old, new));
1429 }
1430
1431 #[test]
1432 fn wrappers_semantically_equal_both_position_and_content_diff() {
1433 // Both positions and content differ — still NOT equal.
1434 let old = "# Generated from Rust fn `foo` (lib.rs:1:1)\nfoo <- function() { 1L }\n";
1435 let new = "# Generated from Rust fn `bar` (lib.rs:9:1)\nbar <- function() { 2L }\n";
1436 assert!(!wrappers_semantically_equal(old, new));
1437 }
1438
1439 // endregion
1440}
1441// endregion