Skip to main content

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