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::sys::{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 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/// Build the `rust_type → ClassNameEntry` lookup used by every placeholder
291/// resolver in `write_r_wrappers_to_file`.
292///
293/// Multiple labeled `#[miniextendr]` impl blocks on one type each register a
294/// `ClassNameEntry` (#1242) — the class name is a property of the *type*, so
295/// identical duplicates collapse here. *Conflicting* registrations (same
296/// `rust_type`, different `r_class_name` or `class_system` — e.g. two labeled
297/// blocks with disagreeing `class = "..."` overrides) panic: silently keeping
298/// whichever entry linkme ordered last would resolve `.__MX_CLASS_REF_*__`
299/// placeholders nondeterministically.
300///
301/// Host-only like `MX_CLASS_NAMES` itself — the consumers all live in the
302/// wrapper-writing path, which never runs on wasm32.
303#[cfg(not(target_arch = "wasm32"))]
304fn build_class_name_index<'a>(
305    entries: impl IntoIterator<Item = &'a ClassNameEntry>,
306) -> std::collections::HashMap<&'a str, &'a ClassNameEntry> {
307    use std::collections::hash_map::Entry;
308    let mut index = std::collections::HashMap::new();
309    for e in entries {
310        match index.entry(e.rust_type) {
311            Entry::Vacant(slot) => {
312                slot.insert(e);
313            }
314            Entry::Occupied(slot) => {
315                let prev = *slot.get();
316                if prev.r_class_name != e.r_class_name || prev.class_system != e.class_system {
317                    panic!(
318                        "conflicting class registrations for Rust type `{}`: \
319                         `{}` ({}) vs `{}` ({}). Multiple #[miniextendr] impl blocks \
320                         on one type must agree on the class system and any \
321                         `class = \"...\"` override.",
322                        e.rust_type,
323                        prev.r_class_name,
324                        prev.class_system,
325                        e.r_class_name,
326                        e.class_system,
327                    );
328                }
329            }
330        }
331    }
332    index
333}
334
335/// Entry documenting a sidecar (`#[r_data]`) property on an S7 ExternalPtr type.
336///
337/// Emitted by `#[derive(ExternalPtr)] #[externalptr(s7)]` for each public `#[r_data]`
338/// field. Used by `write_r_wrappers_to_file` to substitute the
339/// `.__MX_S7_SIDECAR_PROP_DOCS_<TypeName>__` placeholder with `#' @prop` lines.
340pub struct SidecarPropEntry {
341    /// Rust type name, e.g. `"SidecarS7"`.
342    pub rust_type: &'static str,
343    /// Field name, e.g. `"prop_int"`.
344    pub field_name: &'static str,
345    /// Documentation string for this property.
346    /// Defaults to `"(undocumented sidecar property)"` when no `prop_doc` was supplied.
347    pub prop_doc: &'static str,
348}
349
350// SAFETY: All fields are &'static str — immutable and valid for program lifetime.
351unsafe impl Sync for SidecarPropEntry {}
352
353/// Trait dispatch entry mapping (concrete_tag, trait_tag) → vtable.
354#[repr(C)]
355pub struct TraitDispatchEntry {
356    /// Tag identifying the concrete type.
357    pub concrete_tag: mx_tag,
358    /// Tag identifying the trait interface.
359    pub trait_tag: mx_tag,
360    /// Pointer to the trait's vtable (cast from `&'static SomeVTable`).
361    pub vtable: *const c_void,
362    /// Symbol name of the `#[no_mangle]` vtable static
363    /// (e.g. `"__VTABLE_COUNTER_FOR_MYTYPE"`). Consumed by the host-time WASM
364    /// snapshot writer to emit `extern "C" { static <symbol>: u8; }`
365    /// declarations in `wasm_registry.rs`.
366    pub vtable_symbol: &'static str,
367}
368
369// SAFETY: vtable points to a static vtable valid for program lifetime.
370// Tags are Copy values. All fields are safe to read from any thread.
371unsafe impl Sync for TraitDispatchEntry {}
372unsafe impl Send for TraitDispatchEntry {}
373
374/// ALTREP class registration entry: fn pointer + `#[no_mangle]` symbol name.
375///
376/// See [`MX_ALTREP_REGISTRATIONS`] for context.
377#[repr(C)]
378pub struct AltrepRegistration {
379    /// Registration function called once at `R_init_*`.
380    pub register: extern "C" fn(),
381    /// Symbol name of `register` (e.g. `"__mx_altrep_reg_MyType"`). Consumed by
382    /// the host-time WASM snapshot writer to emit `extern "C" { fn <symbol>(); }`
383    /// declarations in `wasm_registry.rs`.
384    pub symbol: &'static str,
385}
386
387// SAFETY: register is a static fn pointer; symbol is a `'static` string.
388unsafe impl Sync for AltrepRegistration {}
389unsafe impl Send for AltrepRegistration {}
390// endregion
391
392// region: Universal Query
393
394/// Universal query function for trait dispatch.
395///
396/// Scans [`MX_TRAIT_DISPATCH`] for a matching `(concrete_tag, trait_tag)` pair.
397/// Returns the vtable pointer, or null if the trait is not implemented.
398///
399/// This replaces per-type query functions — a single function handles all types
400/// by reading from the global dispatch table.
401///
402/// # Safety
403///
404/// - `ptr` must point to a valid `mx_erased` with a valid base vtable.
405/// - Must be called on R's main thread.
406pub unsafe extern "C" fn universal_query(ptr: *mut mx_erased, trait_tag: mx_tag) -> *const c_void {
407    let concrete_tag = unsafe { (*(*ptr).base).concrete_tag };
408    for entry in trait_dispatch().iter() {
409        if entry.concrete_tag == concrete_tag && entry.trait_tag == trait_tag {
410            return entry.vtable;
411        }
412    }
413    std::ptr::null()
414}
415// endregion
416
417// region: Initialization
418
419/// Register all `#[miniextendr]` routines and ALTREP classes with R.
420///
421/// Called from `package_init()` during `R_init_*` (via `miniextendr_init!`).
422/// Everything else is automatic.
423///
424/// # Safety
425///
426/// Must be called from R's main thread during `R_init_*`.
427/// `dll` must be a valid pointer provided by R.
428#[unsafe(no_mangle)]
429pub unsafe extern "C" fn miniextendr_register_routines(dll: *mut DllInfo) {
430    // 1. Register ALTREP classes (skip during wrapper generation)
431    //
432    // During wrapper-gen, the installed shared object is loaded temporarily via
433    // dyn.load() then unloaded via dyn.unload(). ALTREP class registration creates
434    // R-global entries with method pointers into the loaded code. After dyn.unload(),
435    // those pointers become dangling. When R later loads the installed package and
436    // re-registers, it may still have the stale entries, leading to heap corruption
437    // (e.g., "malloc(): unsorted double linked list corrupted" on Linux).
438    // Shares the single read-site with package_init (see init::WRAPPER_GEN_ENV) so
439    // the env-var name cannot drift between the two consumers.
440    let wrapper_gen = crate::init::wrapper_gen_mode();
441    if !wrapper_gen {
442        // All ALTREP classes — both user-defined (#[miniextendr] structs) and
443        // builtins (Vec, Box, Range, Cow, Arrow) — register via linkme
444        // MX_ALTREP_REGISTRATIONS. Each call site emits a
445        // `#[distributed_slice(MX_ALTREP_REGISTRATIONS)]` entry, so a single
446        // iteration here covers everything. No hand-enumerated builtin list needed.
447        for reg in altrep_regs().iter() {
448            (reg.register)();
449        }
450
451        // Verify no two ALTREP types registered the same class name.
452        // Duplicates cause silent overwrites in R — the wrong type gets
453        // reconstructed on readRDS, leading to memory corruption.
454        crate::altrep::assert_altrep_class_uniqueness();
455    }
456
457    // 2. Build call method defs with null sentinel
458    let mut call_defs: Vec<R_CallMethodDef> = self::call_defs().to_vec();
459    // Always register the wrapper-gen entry points so they're visible
460    // via getNativeSymbolInfo even when R_forceSymbols(TRUE) is set. wasm32
461    // doesn't run wrapper-gen (it's host-only), so these are gated off there.
462    // SAFETY: DL_FUNC is Option<extern "C-unwind" fn() -> *mut c_void> — R's
463    // standard erased function pointer. The actual signature (SEXP -> SEXP) is
464    // ABI-compatible; R dispatches based on numArgs.
465    #[cfg(not(target_arch = "wasm32"))]
466    {
467        call_defs.push(R_CallMethodDef {
468            name: c"miniextendr_write_wrappers".as_ptr(),
469            fun: unsafe {
470                std::mem::transmute::<
471                    *const (),
472                    Option<unsafe extern "C-unwind" fn() -> *mut c_void>,
473                >(miniextendr_write_wrappers as *const ())
474            },
475            numArgs: 1,
476        });
477        call_defs.push(R_CallMethodDef {
478            name: c"miniextendr_write_wasm_registry".as_ptr(),
479            fun: unsafe {
480                std::mem::transmute::<
481                    *const (),
482                    Option<unsafe extern "C-unwind" fn() -> *mut c_void>,
483                >(miniextendr_write_wasm_registry as *const ())
484            },
485            numArgs: 1,
486        });
487    }
488    call_defs.push(R_CallMethodDef {
489        name: std::ptr::null(),
490        fun: None,
491        numArgs: 0,
492    });
493
494    // 3. Register routines
495    // Leak the Vec — init runs once at package load, so this is fine.
496    unsafe {
497        crate::sys::R_registerRoutines_unchecked(
498            dll,
499            std::ptr::null(),
500            call_defs.leak().as_ptr(),
501            std::ptr::null(),
502            std::ptr::null(),
503        );
504    }
505}
506
507/// Collect all R wrapper entries, sorted by priority and deduplicated.
508///
509/// Within each priority group, S7 class definitions are topologically sorted
510/// so parents are defined before children (S7 `parent = X` requires X to exist).
511///
512/// Host-only — wasm32 doesn't run wrapper-gen.
513#[cfg(not(target_arch = "wasm32"))]
514pub fn collect_r_wrappers() -> Vec<std::borrow::Cow<'static, str>> {
515    let mut entries: Vec<&RWrapperEntry> = MX_R_WRAPPERS.iter().collect();
516    entries.sort_by_key(|e| e.priority);
517
518    let mut seen = std::collections::HashSet::<&str>::new();
519    let mut result: Vec<std::borrow::Cow<'static, str>> = Vec::with_capacity(entries.len());
520    for entry in entries {
521        let trimmed = entry.content.trim();
522        if !trimmed.is_empty() && seen.insert(trimmed) {
523            // For standalone functions without explicit @rdname, inject one
524            // derived from the source file stem so same-file functions share
525            // a single .Rd page.
526            if entry.priority == RWrapperPriority::Function
527                && !has_rdname_tag(trimmed)
528                && !has_no_rd_tag(trimmed)
529            {
530                if let Some(rdname) = rdname_from_source_file(entry.source_file) {
531                    result.push(std::borrow::Cow::Owned(inject_rdname(trimmed, &rdname)));
532                    continue;
533                }
534            }
535            result.push(std::borrow::Cow::Borrowed(trimmed));
536        }
537    }
538
539    // Topological sort for S7 inheritance ordering
540    sort_s7_classes(&mut result);
541
542    result
543}
544
545#[cfg(not(target_arch = "wasm32"))]
546/// Check if an R wrapper fragment already has an `@rdname` tag.
547fn has_rdname_tag(content: &str) -> bool {
548    content.lines().any(|line| {
549        let trimmed = line.trim();
550        trimmed.starts_with("#' @rdname ")
551    })
552}
553
554#[cfg(not(target_arch = "wasm32"))]
555/// Check if an R wrapper fragment has `@noRd`.
556fn has_no_rd_tag(content: &str) -> bool {
557    content.lines().any(|line| {
558        let trimmed = line.trim();
559        trimmed == "#' @noRd"
560    })
561}
562
563#[cfg(not(target_arch = "wasm32"))]
564/// Derive an `@rdname` value from a source file path.
565///
566/// `"src/rust/zero_copy_tests.rs"` → `"zero_copy_tests"`
567/// `"lib.rs"` → `"lib"`
568fn rdname_from_source_file(path: &str) -> Option<String> {
569    let file_name = path.rsplit(['/', '\\']).next()?;
570    let stem = file_name.strip_suffix(".rs").unwrap_or(file_name);
571    if stem.is_empty() || stem == "lib" || stem == "mod" {
572        return None;
573    }
574    Some(stem.to_string())
575}
576
577#[cfg(not(target_arch = "wasm32"))]
578/// Inject `#' @rdname <value>` (and `@title` if missing) into an R wrapper
579/// fragment. Inserts before the first `@export`/`@keywords`/`@source` line,
580/// or after the last roxygen line.
581fn inject_rdname(content: &str, rdname: &str) -> String {
582    let rdname_line = format!("#' @rdname {rdname}");
583    let has_title = content.lines().any(|l| l.trim().starts_with("#' @title "));
584    // Functions with no doc comments need a title so the @rdname page has an anchor
585    let title_line = if has_title {
586        None
587    } else {
588        Some(format!("#' @title {}", rdname.replace('_', " ")))
589    };
590
591    let lines: Vec<&str> = content.lines().collect();
592    let mut result = Vec::with_capacity(lines.len() + 2);
593    let mut inserted = false;
594
595    for line in &lines {
596        let trimmed = line.trim();
597        // Insert before @export, @keywords, or @source lines
598        if !inserted
599            && (trimmed.starts_with("#' @export")
600                || trimmed.starts_with("#' @keywords")
601                || trimmed.starts_with("#' @source"))
602        {
603            if let Some(ref t) = title_line {
604                result.push(t.as_str());
605            }
606            result.push(rdname_line.as_str());
607            inserted = true;
608        }
609        result.push(line);
610    }
611
612    // If we never found a good insertion point, insert before the function def
613    if !inserted {
614        let last_roxy = lines
615            .iter()
616            .rposition(|l| l.trim().starts_with("#'"))
617            .unwrap_or(0);
618        let insert_at = last_roxy + 1;
619        if let Some(ref t) = title_line {
620            result.insert(insert_at, t.as_str());
621            result.insert(insert_at + 1, rdname_line.as_str());
622        } else {
623            result.insert(insert_at, rdname_line.as_str());
624        }
625    }
626
627    result.join("\n")
628}
629
630#[cfg(not(target_arch = "wasm32"))]
631/// Sort S7 class definitions so parents come before children.
632///
633/// Detects `S7::new_class()` calls, extracts `parent = ClassName` relationships,
634/// and performs topological sort. Non-S7 entries keep their relative order.
635fn sort_s7_classes(entries: &mut [std::borrow::Cow<'static, str>]) {
636    use std::collections::HashMap;
637
638    // Parse S7 class definitions: find (index, name, parent)
639    let mut s7_info: Vec<(usize, String, Option<String>)> = Vec::new();
640
641    for (i, entry) in entries.iter().enumerate() {
642        if let Some(nc_pos) = entry.find("S7::new_class(") {
643            // Extract class name: "NAME <- S7::new_class("
644            let before = entry[..nc_pos].trim_end();
645            let name = before
646                .strip_suffix("<-")
647                .or_else(|| before.rsplit_once("<-").map(|(_, r)| r))
648                .map(|s| s.trim())
649                .and_then(|s| s.split_whitespace().last());
650
651            let Some(name) = name else { continue };
652
653            // Extract parent: "parent = ParentName,"
654            let after = &entry[nc_pos..];
655            let parent = after.find("parent = ").and_then(|p| {
656                let rest = &after[p + "parent = ".len()..];
657                let end = rest.find([',', ')', '\n']).unwrap_or(rest.len());
658                let p = rest[..end].trim();
659                if p.is_empty() {
660                    None
661                } else {
662                    Some(p.to_string())
663                }
664            });
665
666            s7_info.push((i, name.to_string(), parent));
667        }
668    }
669
670    if s7_info.len() <= 1 {
671        return;
672    }
673
674    // Build name → position-in-s7_info map.
675    // Also map placeholder .__MX_CLASS_REF_<Name>__ to the same position, since
676    // the placeholder carries the Rust type name which equals the R class name
677    // (before any `class = "Override"` resolution — good enough for ordering).
678    let mut name_to_pos: HashMap<String, usize> = HashMap::new();
679    for (pos, (_, name, _)) in s7_info.iter().enumerate() {
680        name_to_pos.insert(name.clone(), pos);
681        // Also register the placeholder form so a child class that references
682        // `parent = .__MX_CLASS_REF_ParentName__` can still be sorted correctly.
683        name_to_pos.insert(format!(".__MX_CLASS_REF_{name}__"), pos);
684    }
685
686    // Topological sort: repeatedly emit classes whose parent is already placed
687    let n = s7_info.len();
688    let mut order: Vec<usize> = Vec::with_capacity(n);
689    let mut placed = vec![false; n];
690
691    for _ in 0..n {
692        for (pos, (_, _, parent)) in s7_info.iter().enumerate() {
693            if placed[pos] {
694                continue;
695            }
696            let ready = match parent {
697                None => true,
698                Some(pname) => match name_to_pos.get(pname.as_str()) {
699                    None => true, // external parent
700                    Some(&pp) => placed[pp],
701                },
702            };
703            if ready {
704                order.push(pos);
705                placed[pos] = true;
706            }
707        }
708    }
709
710    // Fallback: add any remaining (cycles) in original order
711    for (pos, &is_placed) in placed.iter().enumerate().take(n) {
712        if !is_placed {
713            order.push(pos);
714        }
715    }
716
717    // Apply: place sorted S7 entries back at their original indices
718    let s7_indices: Vec<usize> = s7_info.iter().map(|(i, _, _)| *i).collect();
719    let original: Vec<std::borrow::Cow<'static, str>> =
720        s7_indices.iter().map(|&i| entries[i].clone()).collect();
721
722    for (slot, &src) in order.iter().enumerate() {
723        entries[s7_indices[slot]] = original[src].clone();
724    }
725}
726// endregion
727
728#[cfg(not(target_arch = "wasm32"))]
729/// Rotate a comma-separated quoted-choice string so `preferred` is first.
730///
731/// `choices_str` has the shape `"\"a\", \"b\", \"c\""` (already quoted +
732/// joined). `preferred` is the unquoted user-supplied default (e.g. `"b"`).
733/// On miss, panics with the placeholder name — the wrapper-gen write step is the
734/// only caller, so a panic surfaces as a load-time error in the host R session
735/// rather than silently producing a broken wrapper.
736fn rotate_choices_for_default(choices_str: &str, preferred: &str, placeholder: &str) -> String {
737    let parts: Vec<&str> = choices_str.split(", ").collect();
738    let pos = parts
739        .iter()
740        .position(|p| p.strip_prefix('"').and_then(|s| s.strip_suffix('"')) == Some(preferred))
741        .unwrap_or_else(|| {
742            panic!(
743                "miniextendr: preferred default `{preferred}` for placeholder `{placeholder}` \
744                 does not match any choice in [{choices_str}]"
745            )
746        });
747    let mut rotated: Vec<&str> = Vec::with_capacity(parts.len());
748    rotated.push(parts[pos]);
749    for (i, p) in parts.iter().enumerate() {
750        if i != pos {
751            rotated.push(p);
752        }
753    }
754    rotated.join(", ")
755}
756
757// region: R Wrapper File Generation
758
759// region: Generic doc marker resolution
760
761/// Parse the payload of a `.__MX_GENERIC_DOC__(...)` marker line.
762///
763/// Returns `(kind, generic_name, class_name, export, dispatch, no_dots)` on success.
764/// The marker format (emitted by proc-macros) is:
765/// ```text
766/// .__MX_GENERIC_DOC__(kind="S7", generic="get_value", class="MyClass",
767///                     export=true, dispatch="x", no_dots=false)
768/// ```
769/// S4 markers omit `dispatch` and `no_dots` (always `"x"` / `false`).
770#[cfg(not(target_arch = "wasm32"))]
771fn parse_generic_doc_marker(marker: &str) -> Option<(String, String, String, bool, String, bool)> {
772    // Strip prefix and trailing ')'
773    let inner = marker
774        .strip_prefix(".__MX_GENERIC_DOC__(")?
775        .strip_suffix(')')?;
776
777    let mut kind = String::new();
778    let mut generic = String::new();
779    let mut class = String::new();
780    let mut export = false;
781    let mut dispatch = "x".to_string();
782    let mut no_dots = false;
783
784    for part in inner.split(", ") {
785        let part = part.trim();
786        if let Some(val) = part.strip_prefix("kind=") {
787            kind = val.trim_matches('"').to_string();
788        } else if let Some(val) = part.strip_prefix("generic=") {
789            generic = val.trim_matches('"').to_string();
790        } else if let Some(val) = part.strip_prefix("class=") {
791            class = val.trim_matches('"').to_string();
792        } else if let Some(val) = part.strip_prefix("export=") {
793            export = val == "true";
794        } else if let Some(val) = part.strip_prefix("dispatch=") {
795            dispatch = val.trim_matches('"').to_string();
796        } else if let Some(val) = part.strip_prefix("no_dots=") {
797            no_dots = val == "true";
798        }
799    }
800
801    if kind.is_empty() || generic.is_empty() || class.is_empty() {
802        return None;
803    }
804
805    Some((kind, generic, class, export, dispatch, no_dots))
806}
807
808/// Synthesise a standalone roxygen doc block for a generic.
809///
810/// Produces a block with:
811/// - `@title` / `@description` describing the generic
812/// - `@param` lines for each dispatch argument (plus `...` unless no_dots)
813/// - `@name <generic>` — yields the bare `\alias{<generic>}` that `?generic` resolves
814/// - `@export` or `@rawNamespace export(<generic>)` as appropriate
815/// - `NULL` anchor
816///
817/// The method blocks emitted by the class generators keep their qualified
818/// `@name Class-generic` so there is no duplicate-alias regression.
819#[cfg(not(target_arch = "wasm32"))]
820fn synthesise_generic_doc_block(
821    kind: &str,
822    generic: &str,
823    classes: &[String],
824    export: bool,
825    dispatch: &str,
826    no_dots: bool,
827) -> String {
828    let mut lines: Vec<String> = Vec::new();
829
830    // Title
831    lines.push(format!("#' The `{generic}()` generic"));
832    lines.push("#'".to_string());
833
834    // Description
835    let kind_phrase = if kind == "S4" {
836        "an S4 generic"
837    } else {
838        "an S7 generic"
839    };
840    lines.push("#' @description".to_string());
841    lines.push(format!(
842        "#' `{generic}()` is {kind_phrase} generated by miniextendr. Methods in this"
843    ));
844    lines.push("#' package are available for:".to_string());
845    lines.push("#' \\itemize{".to_string());
846    for cls in classes {
847        lines.push(format!("#'   \\item \\code{{\\link{{{cls}}}}}"));
848    }
849    lines.push("#' }".to_string());
850
851    // @param lines for dispatch formals
852    let dispatch_args: Vec<&str> = dispatch.split(',').map(|s| s.trim()).collect();
853    for arg in &dispatch_args {
854        lines.push(format!("#' @param {arg} An object."));
855    }
856    if !no_dots {
857        lines.push("#' @param ... Passed on to methods.".to_string());
858    }
859
860    // Bare @name — this is what produces the bare \\alias{<generic>} in Rd
861    lines.push(format!("#' @name {generic}"));
862
863    // Export directive
864    if export {
865        if kind == "S4" {
866            // S4 generics use @exportMethod (matches the method blocks)
867            lines.push(format!("#' @exportMethod {generic}"));
868        } else {
869            // S7 generics use @rawNamespace export(...) (matches the method blocks)
870            lines.push(format!("#' @rawNamespace export({generic})"));
871        }
872    }
873
874    lines.push("NULL".to_string());
875
876    lines.join("\n")
877}
878
879/// Scan `content` for `.__MX_GENERIC_DOC__(...)` marker lines, group by
880/// generic name, replace the first marker per generic with a synthesised
881/// standalone doc block, and delete all remaining markers.
882///
883/// This is the write-time counterpart to the proc-macro marker emission in
884/// `s7_class.rs` and `s4_class.rs`.
885#[cfg(not(target_arch = "wasm32"))]
886fn resolve_generic_doc_markers(content: String) -> String {
887    use std::collections::HashMap;
888
889    const MARKER_PREFIX: &str = ".__MX_GENERIC_DOC__(";
890
891    // First pass: collect all (generic, kind, classes[], export, dispatch, no_dots) grouped
892    // by generic name.  Preserve insertion order (first class encountered wins for kind/export).
893    let mut by_generic: HashMap<String, (String, Vec<String>, bool, String, bool)> = HashMap::new();
894    let mut generic_order: Vec<String> = Vec::new(); // tracks first-seen order
895
896    for line in content.lines() {
897        let trimmed = line.trim();
898        if !trimmed.starts_with(MARKER_PREFIX) {
899            continue;
900        }
901        if let Some((kind, generic, class, export, dispatch, no_dots)) =
902            parse_generic_doc_marker(trimmed)
903        {
904            let entry = by_generic.entry(generic.clone()).or_insert_with(|| {
905                generic_order.push(generic.clone());
906                (kind, Vec::new(), export, dispatch, no_dots)
907            });
908            entry.1.push(class);
909        }
910    }
911
912    if by_generic.is_empty() {
913        return content;
914    }
915
916    // Second pass: walk the content line by line.  For each generic, the first
917    // marker line is replaced by the synthesised doc block; subsequent markers
918    // for the same generic are dropped entirely.
919    let mut first_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
920    let mut result = String::with_capacity(content.len() + 512 * by_generic.len());
921
922    for line in content.lines() {
923        let trimmed = line.trim();
924        if trimmed.starts_with(MARKER_PREFIX) {
925            if let Some((_kind, generic, _class, _export, _dispatch, _no_dots)) =
926                parse_generic_doc_marker(trimmed)
927            {
928                if first_seen.insert(generic.clone()) {
929                    // First occurrence: replace with synthesised block
930                    if let Some((kind, classes, export, dispatch, no_dots)) =
931                        by_generic.get(&generic)
932                    {
933                        let block = synthesise_generic_doc_block(
934                            kind, &generic, classes, *export, dispatch, *no_dots,
935                        );
936                        result.push_str(&block);
937                        result.push('\n');
938                    }
939                }
940                // Subsequent occurrences: drop the line entirely (no push)
941                continue;
942            }
943        }
944        result.push_str(line);
945        result.push('\n');
946    }
947
948    // Trim trailing newlines added by the line-by-line loop and restore the
949    // original termination (content typically ends with \n).
950    result
951}
952
953// endregion
954
955// region: Inherited param marker resolution
956
957/// Parse a `.__MX_INHERITED_PARAM__(...)` marker line.
958///
959/// The marker format is:
960/// ```text
961/// .__MX_INHERITED_PARAM__(class="R6Dog", parent="R6Animal", method="speak", param="times")
962/// ```
963/// Returns `(class, parent, method, param)` on success.
964#[cfg(not(target_arch = "wasm32"))]
965fn parse_inherited_param_marker(marker: &str) -> Option<(String, String, String, String)> {
966    let inner = marker
967        .strip_prefix(".__MX_INHERITED_PARAM__(")?
968        .strip_suffix(')')?;
969
970    let mut class = String::new();
971    let mut parent = String::new();
972    let mut method = String::new();
973    let mut param = String::new();
974
975    for part in inner.split(", ") {
976        let part = part.trim();
977        if let Some(val) = part.strip_prefix("class=") {
978            class = val.trim_matches('"').to_string();
979        } else if let Some(val) = part.strip_prefix("parent=") {
980            parent = val.trim_matches('"').to_string();
981        } else if let Some(val) = part.strip_prefix("method=") {
982            method = val.trim_matches('"').to_string();
983        } else if let Some(val) = part.strip_prefix("param=") {
984            param = val.trim_matches('"').to_string();
985        }
986    }
987
988    if class.is_empty() || parent.is_empty() || method.is_empty() || param.is_empty() {
989        return None;
990    }
991
992    Some((class, parent, method, param))
993}
994
995/// Resolve `.__MX_INHERITED_PARAM__(...)` markers.
996///
997/// For each marker, checks whether the parent class is in-package and has
998/// `@param <param>` documented for either:
999/// - The same method name (in any of its method doc blocks), or
1000/// - The class-level `@param` (emitted in the class header).
1001///
1002/// If the parent is documented → delete the marker line (roxygen2 8.0.0 inherits
1003/// the parent method's `@param` into the subclass method).
1004///
1005/// If the parent is cross-package or the param is not documented there → replace
1006/// with `#' @param {param} (no documentation available)` to preserve the zero-warning
1007/// guarantee on rendered Rd.
1008///
1009/// Cross-package parents: markers are always replaced with the fallback text because
1010/// we cannot inspect foreign packages' wrapper content at write time.
1011#[cfg(not(target_arch = "wasm32"))]
1012fn resolve_inherited_param_markers(content: String) -> String {
1013    use std::collections::{HashMap, HashSet};
1014
1015    const MARKER_PREFIX: &str = ".__MX_INHERITED_PARAM__(";
1016
1017    // Check if the marker is present at all (fast path).
1018    if !content.contains(MARKER_PREFIX) {
1019        return content;
1020    }
1021
1022    // Build an index of in-package class param documentation:
1023    //   class_name → Set<param_name>
1024    // We scan the content for:
1025    //   1. Class-level @param tags: lines like `#' @param <name> ...` appearing
1026    //      between `#' @name <ClassName>` and the R6Class(...) definition.
1027    //   2. Method-level @param tags for a specific method: lines like
1028    //      `    #' @param <name> ...` inside the method's doc block.
1029    //
1030    // Strategy: build a map of (class_name, method_name_or_class) → Set<param_name>
1031    // where method_name_or_class = "" means class-level.
1032    let mut class_method_params: HashMap<(String, String), HashSet<String>> = HashMap::new();
1033
1034    // We need to scan the wrapper content to find documented params.
1035    // Simple heuristic: find `#' @name ClassName` then collect `#' @param` lines
1036    // until the next non-doc line (the R6Class definition).
1037    let lines: Vec<&str> = content.lines().collect();
1038    let mut i = 0;
1039    while i < lines.len() {
1040        let line = lines[i].trim();
1041        // Class-level: `#' @name ClassName` followed by class-level @param lines
1042        if let Some(rest) = line.strip_prefix("#' @name ") {
1043            let class_name = rest.trim().to_string();
1044            // Scan forward for @param lines in the class header (until non-doc)
1045            let mut j = i + 1;
1046            while j < lines.len() {
1047                let next = lines[j].trim();
1048                if next.starts_with("#'") {
1049                    if let Some(param_rest) = next.strip_prefix("#' @param ") {
1050                        if let Some(param_name) = param_rest.split_whitespace().next() {
1051                            class_method_params
1052                                .entry((class_name.clone(), String::new()))
1053                                .or_default()
1054                                .insert(param_name.to_string());
1055                        }
1056                    }
1057                    j += 1;
1058                } else {
1059                    break;
1060                }
1061            }
1062        }
1063        // Method-level: `    #' @description Method \`method_name\`.` or source comment
1064        // Simpler: look for `    #' @param` lines at method indent — attribute to the
1065        // nearest preceding `#' @rdname ClassName` (method blocks carry this).
1066        // Actually, method-level @param inside an R6 class block are indented with 4 spaces.
1067        // We track the current class context from the most recent R6Class definition.
1068        i += 1;
1069    }
1070
1071    // Walk the content to track current R6 class + method context, and also
1072    // build method-level param maps. We do a second pass specifically for method params.
1073    let mut current_class: Option<String> = None;
1074    let mut current_method: Option<String> = None;
1075    i = 0;
1076    while i < lines.len() {
1077        let raw = lines[i];
1078        let trimmed = raw.trim();
1079        // Detect R6Class definition: `ClassName <- R6::R6Class("ClassName",`
1080        if trimmed.contains("R6::R6Class(\"") {
1081            if let Some(start) = trimmed.find("R6::R6Class(\"") {
1082                let rest = &trimmed[start + "R6::R6Class(\"".len()..];
1083                if let Some(end) = rest.find('"') {
1084                    current_class = Some(rest[..end].to_string());
1085                    current_method = None;
1086                }
1087            }
1088        }
1089        // Detect method source comment: `    # ClassName::method_name (file:line)`
1090        if let Some(ref cls) = current_class.clone() {
1091            let prefix = format!("# {}::", cls);
1092            if trimmed.starts_with(&prefix) {
1093                let rest = &trimmed[prefix.len()..];
1094                let method_name = rest.split_whitespace().next().unwrap_or("").to_string();
1095                if !method_name.is_empty() {
1096                    current_method = Some(method_name);
1097                }
1098            }
1099            // Detect indented method @param: `    #' @param name ...`
1100            if let Some(ref method) = current_method.clone() {
1101                if let Some(rest) = raw.strip_prefix("    #' @param ") {
1102                    if let Some(param_name) = rest.split_whitespace().next() {
1103                        class_method_params
1104                            .entry((cls.clone(), method.clone()))
1105                            .or_default()
1106                            .insert(param_name.to_string());
1107                    }
1108                }
1109            }
1110        }
1111        i += 1;
1112    }
1113
1114    // Second pass: resolve markers.
1115    let mut result = String::with_capacity(content.len());
1116    for line in content.lines() {
1117        let trimmed = line.trim();
1118        // Marker format: `    #' .__MX_INHERITED_PARAM__(class="...", parent="...", method="...", param="...")`
1119        let inner = trimmed.strip_prefix("#' ").unwrap_or(trimmed);
1120        if inner.starts_with(MARKER_PREFIX) {
1121            if let Some((_class, parent, method, param)) = parse_inherited_param_marker(inner) {
1122                // Check if parent is in-package and has the param documented:
1123                // Either at method level (parent, method) or class level (parent, "").
1124                let parent_method_key = (parent.clone(), method.clone());
1125                let parent_class_key = (parent.clone(), String::new());
1126                let documented_in_method = class_method_params
1127                    .get(&parent_method_key)
1128                    .map(|s| s.contains(&param))
1129                    .unwrap_or(false);
1130                let documented_at_class = class_method_params
1131                    .get(&parent_class_key)
1132                    .map(|s| s.contains(&param))
1133                    .unwrap_or(false);
1134
1135                if documented_in_method || documented_at_class {
1136                    // Parent is documented in-package → drop this line entirely.
1137                    // roxygen2 8.0.0 inherits the parent method's @param.
1138                    continue;
1139                } else {
1140                    // Parent not found or param not documented → keep fallback.
1141                    // Reconstruct indentation from the original line.
1142                    let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
1143                    result.push_str(&indent);
1144                    result.push_str(&format!("#' @param {} (no documentation available)", param));
1145                    result.push('\n');
1146                    continue;
1147                }
1148            }
1149        }
1150        result.push_str(line);
1151        result.push('\n');
1152    }
1153
1154    result
1155}
1156
1157// endregion
1158
1159// region: cross-class return wrapper resolvers
1160
1161/// Scan `content` for `<prefix><RustName>__(<expr>)` markers and replace each
1162/// with whatever `emit` pushes for the resolved class entry (or `None` when
1163/// `RustName` is not registered).
1164///
1165/// Shared scan loop behind [`resolve_scalar_return_wrappers`] and
1166/// [`resolve_list_return_wrappers`]. The two marker families use
1167/// non-overlapping prefixes (`.__MX_WRAP_RETURN_` vs `.__MX_WRAP_LIST_RETURN_`)
1168/// so each resolver only ever consumes its own markers — the scalar resolver
1169/// rewrites *unrecognized* names of its own prefix to the bare expression, so
1170/// a shared prefix would destroy list markers before the list pass ran (#1284).
1171///
1172/// Malformed markers (missing `__(` or the closing `)`) are emitted as-is and
1173/// scanning stops, matching the other placeholder resolvers in this file.
1174#[cfg(not(target_arch = "wasm32"))]
1175fn resolve_wrap_return_markers(
1176    content: &str,
1177    prefix: &str,
1178    class_index: &std::collections::HashMap<&str, &ClassNameEntry>,
1179    emit: impl Fn(&mut String, Option<&ClassNameEntry>, &str),
1180) -> String {
1181    const ARG_OPEN: &str = "__(";
1182    const ARG_CLOSE: char = ')';
1183
1184    let mut result = String::with_capacity(content.len());
1185    let mut remaining = content;
1186    while let Some(start) = remaining.find(prefix) {
1187        result.push_str(&remaining[..start]);
1188        let rest_from_prefix = &remaining[start..];
1189        let marker_body = &rest_from_prefix[prefix.len()..];
1190        let Some(name_end) = marker_body.find(ARG_OPEN) else {
1191            // Malformed placeholder (no opening `__(`) — emit as-is and stop scanning.
1192            result.push_str(prefix);
1193            remaining = marker_body;
1194            break;
1195        };
1196        let rust_name = &marker_body[..name_end];
1197        let after_open = &marker_body[name_end + ARG_OPEN.len()..];
1198        let Some(expr_end) = after_open.find(ARG_CLOSE) else {
1199            // Malformed placeholder (no closing `)`) — emit as-is and stop scanning.
1200            result.push_str(prefix);
1201            remaining = marker_body;
1202            break;
1203        };
1204        let expr = &after_open[..expr_end];
1205        remaining = &after_open[expr_end + ARG_CLOSE.len_utf8()..];
1206        emit(&mut result, class_index.get(rust_name).copied(), expr);
1207    }
1208    result.push_str(remaining);
1209    result
1210}
1211
1212/// Resolve scalar `.__MX_WRAP_RETURN_<RustName>__(<expr>)` placeholders
1213/// emitted by class-method wrappers whose Rust return type may name a
1214/// different ExternalPtr-backed class.
1215///
1216/// Unlike `CLASS_REF`, an unresolved return wrapper is a quiet no-op (the bare
1217/// `<expr>`): the syntactic macro-time heuristic intentionally allows false
1218/// positives, and write time is where we know whether `<RustName>` is
1219/// registered in this package.
1220#[cfg(not(target_arch = "wasm32"))]
1221fn resolve_scalar_return_wrappers(
1222    content: &str,
1223    class_index: &std::collections::HashMap<&str, &ClassNameEntry>,
1224) -> String {
1225    resolve_wrap_return_markers(
1226        content,
1227        ".__MX_WRAP_RETURN_",
1228        class_index,
1229        |result, entry, expr| match entry {
1230            Some(entry) => match entry.class_system {
1231                "r6" => {
1232                    result.push_str(&format!("{}$new(.ptr = {})", entry.r_class_name, expr));
1233                }
1234                "s7" => {
1235                    result.push_str(&format!("{}(.ptr = {})", entry.r_class_name, expr));
1236                }
1237                "s4" => {
1238                    result.push_str(&format!(
1239                        "methods::new(\"{}\", ptr = {})",
1240                        entry.r_class_name, expr
1241                    ));
1242                }
1243                _ => {
1244                    result.push_str(&format!(
1245                        "structure({}, class = \"{}\")",
1246                        expr, entry.r_class_name
1247                    ));
1248                }
1249            },
1250            None => result.push_str(expr),
1251        },
1252    )
1253}
1254
1255/// Resolve list-shaped `.__MX_WRAP_LIST_RETURN_<RustName>__(<expr>)`
1256/// placeholders emitted for `Vec<Class>`-returning class methods (#1284).
1257///
1258/// `<expr>` is a bare R list of external pointers (the `IntoRVecElement`
1259/// conversion emitted by `#[derive(ExternalPtr)]`); each element gets the same
1260/// per-element wrap the scalar resolver applies, via `lapply`. Unregistered
1261/// element types fall back to the bare list, mirroring the scalar fallback.
1262#[cfg(not(target_arch = "wasm32"))]
1263fn resolve_list_return_wrappers(
1264    content: &str,
1265    class_index: &std::collections::HashMap<&str, &ClassNameEntry>,
1266) -> String {
1267    resolve_wrap_return_markers(
1268        content,
1269        ".__MX_WRAP_LIST_RETURN_",
1270        class_index,
1271        |result, entry, expr| match entry {
1272            Some(entry) => match entry.class_system {
1273                "r6" => {
1274                    result.push_str(&format!(
1275                        "lapply({}, function(.el) {}$new(.ptr = .el))",
1276                        expr, entry.r_class_name
1277                    ));
1278                }
1279                "s7" => {
1280                    result.push_str(&format!(
1281                        "lapply({}, function(.el) {}(.ptr = .el))",
1282                        expr, entry.r_class_name
1283                    ));
1284                }
1285                "s4" => {
1286                    result.push_str(&format!(
1287                        "lapply({}, function(.el) methods::new(\"{}\", ptr = .el))",
1288                        expr, entry.r_class_name
1289                    ));
1290                }
1291                _ => {
1292                    result.push_str(&format!(
1293                        "lapply({}, function(.el) structure(.el, class = \"{}\"))",
1294                        expr, entry.r_class_name
1295                    ));
1296                }
1297            },
1298            None => result.push_str(expr),
1299        },
1300    )
1301}
1302
1303// endregion
1304
1305/// Write all R wrapper entries to a file.
1306///
1307/// Called from [`miniextendr_write_wrappers`] (via `dyn.load`/`.Call` of the
1308/// installed shared object). All distributed_slice entries from `#[miniextendr]`
1309/// items are available because stub.c force-loads the whole user crate.
1310///
1311/// Host-only — wasm32 doesn't run wrapper-gen.
1312#[cfg(not(target_arch = "wasm32"))]
1313pub fn write_r_wrappers_to_file(path: &str) {
1314    // Build the new content in memory
1315    let mut content = String::from(
1316        "# ---- AUTO-GENERATED FILE - DO NOT EDIT ----
1317# This file is generated by the miniextendr proc-macro during package build.
1318# Any manual changes will be overwritten.
1319#
1320# To regenerate: rebuild the package (R CMD INSTALL or devtools::install).
1321# nolint start
1322# nocov start
1323
1324# Internal helper: re-raise a tagged Rust error/condition value as an R condition.
1325# Generated wrappers call this whenever `.Call()` returns a `rust_condition_value`.
1326# `.call_default` is the wrapper's `sys.call()`, used as the fallback when the
1327# Rust panic payload didn't carry a captured call (e.g. lambda contexts that
1328# pass `.call = NULL` to `.Call`). For error/panic kinds `stop()` longjmps;
1329# for warning/message/condition the helper signals and returns invisible(NULL),
1330# which the wrapper's surrounding `return(...)` propagates as its result.
1331.miniextendr_raise_condition <- function(.val, .call_default) {
1332  .msg <- .val$error
1333  .call <- (if (is.null(.val$call)) .call_default else .val$call)
1334  .class <- .val$class
1335  # `.val$data` is an optional named list of structured fields (from the
1336  # macros' `data = ...` form). When present, splice its named elements into
1337  # the condition object alongside message/call/kind so handlers can read
1338  # `e$<name>`. `utils::modifyList` keeps the base fields and appends the
1339  # data fields; a malformed (non-list / unnamed) payload is ignored.
1340  .data <- .val$data
1341  .cond_fields <- function(base) {
1342    if (is.null(.data) || !is.list(.data) || is.null(names(.data))) {
1343      base
1344    } else {
1345      utils::modifyList(base, .data)
1346    }
1347  }
1348  switch(.val$kind,
1349    error = stop(structure(.cond_fields(list(message = .msg, call = .call, kind = \"error\")),
1350      class = c(.class, \"rust_error\", \"simpleError\", \"error\", \"condition\"))),
1351    warning = warning(structure(.cond_fields(list(message = .msg, call = .call, kind = \"warning\")),
1352      class = c(.class, \"rust_warning\", \"simpleWarning\", \"warning\", \"condition\"))),
1353    message = message(structure(.cond_fields(list(message = paste0(.msg, \"\\n\"), call = NULL, kind = \"message\")),
1354      class = c(.class, \"rust_message\", \"simpleMessage\", \"message\", \"condition\"))),
1355    condition = signalCondition(structure(.cond_fields(list(message = .msg, call = .call, kind = \"condition\")),
1356      class = c(.class, \"rust_condition\", \"simpleCondition\", \"condition\"))),
1357    panic = stop(structure(list(message = .msg, call = .call, kind = \"panic\"),
1358      class = c(\"rust_error\", \"simpleError\", \"error\", \"condition\"))),
1359    stop(structure(list(message = .msg, call = .call, kind = .val$kind),
1360      class = c(\"rust_error\", \"simpleError\", \"error\", \"condition\")))
1361  )
1362  invisible(NULL)
1363}
1364
1365",
1366    );
1367
1368    for fragment in collect_r_wrappers() {
1369        content.push_str(fragment.as_ref());
1370        content.push_str("\n\n");
1371    }
1372
1373    content.push_str("# nocov end\n# nolint end\n");
1374
1375    // Replace match_arg choices placeholders with actual enum choices.
1376    // If the entry has a `preferred_default`, rotate the list so that value
1377    // is first — R's `match.arg(arg)` returns `arg[1]` when arg matches the
1378    // formal default, so the position-0 element becomes the effective default.
1379    for entry in MX_MATCH_ARG_CHOICES.iter() {
1380        let choices_str = (entry.choices_str)();
1381        let rotated = if entry.preferred_default.is_empty() {
1382            choices_str
1383        } else {
1384            rotate_choices_for_default(&choices_str, entry.preferred_default, entry.placeholder)
1385        };
1386        let replacement = format!("c({rotated})");
1387        content = content.replace(entry.placeholder, &replacement);
1388    }
1389
1390    // Replace match_arg @param doc placeholders with human-readable choice descriptions
1391    for entry in MX_MATCH_ARG_PARAM_DOCS.iter() {
1392        let choices = (entry.choices_str)();
1393        let prefix = if entry.several_ok {
1394            "One or more of"
1395        } else {
1396            "One of"
1397        };
1398        let replacement = format!("{prefix} {choices}.");
1399        content = content.replace(entry.placeholder, &replacement);
1400    }
1401
1402    // Replace .__MX_S7_SIDECAR_PROP_DOCS_<TypeName>__ placeholders with @prop lines.
1403    //
1404    // Each S7 class wrapper with `r_data_accessors` embeds one placeholder per type.
1405    // We collect all entries for each type name and emit `#' @prop field doc` lines.
1406    {
1407        use std::collections::HashMap;
1408        // Group entries by rust_type
1409        let mut by_type: HashMap<&'static str, Vec<&SidecarPropEntry>> = HashMap::new();
1410        for entry in MX_S7_SIDECAR_PROPS.iter() {
1411            by_type.entry(entry.rust_type).or_default().push(entry);
1412        }
1413
1414        const SIDECAR_PREFIX: &str = ".__MX_S7_SIDECAR_PROP_DOCS_";
1415        const SIDECAR_SUFFIX: &str = "__";
1416        let mut result = String::with_capacity(content.len());
1417        let mut remaining = content.as_str();
1418        while let Some(start) = remaining.find(SIDECAR_PREFIX) {
1419            result.push_str(&remaining[..start]);
1420            remaining = &remaining[start + SIDECAR_PREFIX.len()..];
1421            if let Some(end) = remaining.find(SIDECAR_SUFFIX) {
1422                let rust_name = &remaining[..end];
1423                remaining = &remaining[end + SIDECAR_SUFFIX.len()..];
1424                // Emit @prop lines for each sidecar field registered for this type
1425                if let Some(entries) = by_type.get(rust_name) {
1426                    let prop_lines: String = entries
1427                        .iter()
1428                        .map(|e| format!("#' @prop {} {}", e.field_name, e.prop_doc))
1429                        .collect::<Vec<_>>()
1430                        .join("\n");
1431                    result.push_str(&prop_lines);
1432                }
1433                // If no entries, the placeholder is simply removed (empty replacement)
1434            } else {
1435                // Malformed placeholder — emit prefix and stop
1436                result.push_str(SIDECAR_PREFIX);
1437                break;
1438            }
1439        }
1440        result.push_str(remaining);
1441        content = result;
1442    }
1443
1444    // Replace .__MX_CLASS_REF_<RustName>__ placeholders with actual R class names.
1445    //
1446    // Build a lookup from rust_type → ClassNameEntry, then do a linear scan over
1447    // the content for each placeholder. We avoid pulling in a regex dependency
1448    // by scanning for the sentinel prefix directly.
1449    {
1450        let class_index = build_class_name_index(MX_CLASS_NAMES.iter());
1451
1452        // Two placeholder forms:
1453        //   .__MX_CLASS_REF_<RustName>__         → loud fallback on miss
1454        //     (used by R6 `inherit =`, S7 parent class, convert_from / convert_to)
1455        //   .__MX_CLASS_REF_OR_ANY_<RustName>__  → silent `S7::class_any` on miss
1456        //     (used by S7 property class constraints — #203: a getter returning
1457        //     `SEXP`, `PathBuf`, or an R6/S3/S4 type shouldn't break load-time
1458        //     with "object '…' not found"; the property just falls back to the
1459        //     permissive class_any it had pre-#154.)
1460        const OR_ANY_PREFIX: &str = ".__MX_CLASS_REF_OR_ANY_";
1461        const PREFIX: &str = ".__MX_CLASS_REF_";
1462        const SUFFIX: &str = "__";
1463
1464        let mut result = String::with_capacity(content.len());
1465        let mut remaining = content.as_str();
1466        while let Some(start) = remaining.find(PREFIX) {
1467            result.push_str(&remaining[..start]);
1468            // Does this occurrence have the OR_ANY form?
1469            let rest_from_prefix = &remaining[start..];
1470            let (quiet_fallback, header_len) = if rest_from_prefix.starts_with(OR_ANY_PREFIX) {
1471                (true, OR_ANY_PREFIX.len())
1472            } else {
1473                (false, PREFIX.len())
1474            };
1475            remaining = &rest_from_prefix[header_len..];
1476            // Find the closing __
1477            if let Some(end) = remaining.find(SUFFIX) {
1478                let rust_name = &remaining[..end];
1479                remaining = &remaining[end + SUFFIX.len()..];
1480                match class_index.get(rust_name) {
1481                    Some(entry) if quiet_fallback => {
1482                        // S7 property resolved to an S7 class → use the
1483                        // registered name. Registered-but-non-S7 types
1484                        // (R6 / S3 / S4 / Env / Vctrs) fall through to
1485                        // class_any because S7 can't use them as class
1486                        // constraints.
1487                        if entry.class_system == "s7" {
1488                            result.push_str(entry.r_class_name);
1489                        } else {
1490                            result.push_str("S7::class_any");
1491                        }
1492                    }
1493                    Some(entry) => {
1494                        result.push_str(entry.r_class_name);
1495                    }
1496                    None if quiet_fallback => {
1497                        // Unresolved S7 property type → silent class_any,
1498                        // matching pre-#154 behavior.
1499                        result.push_str("S7::class_any");
1500                    }
1501                    None => {
1502                        // Unresolved non-property CLASS_REF: fall back to the
1503                        // bare Rust name with a warning.
1504                        eprintln!(
1505                            "miniextendr: unresolved class reference `{rust_name}` \
1506                             in R wrapper — is the class defined in a reachable crate?"
1507                        );
1508                        result.push_str(rust_name);
1509                    }
1510                }
1511            } else {
1512                // Malformed placeholder (no closing __): emit as-is and stop scanning.
1513                result.push_str(PREFIX);
1514                break;
1515            }
1516        }
1517        result.push_str(remaining);
1518        content = result;
1519    }
1520
1521    // Resolve the cross-class return wrapper placeholders: the scalar
1522    // `.__MX_WRAP_RETURN_<RustName>__(<expr>)` family and the list-shaped
1523    // `.__MX_WRAP_LIST_RETURN_<RustName>__(<expr>)` family (#1284). The
1524    // prefixes are non-overlapping, so pass order does not matter — each
1525    // resolver only consumes its own markers.
1526    {
1527        let class_index = build_class_name_index(MX_CLASS_NAMES.iter());
1528        content = resolve_scalar_return_wrappers(&content, &class_index);
1529        content = resolve_list_return_wrappers(&content, &class_index);
1530    }
1531
1532    // Resolve .__MX_GENERIC_DOC__(...) markers into standalone Rd pages.
1533    //
1534    // Each package-owned S7/S4 generic-creation site emits one marker line:
1535    //   .__MX_GENERIC_DOC__(kind="S7", generic="get_value", class="MyClass",
1536    //                       export=true, dispatch="x", no_dots=false)
1537    //
1538    // This pass:
1539    //   1. Scans all fragments for marker lines.
1540    //   2. Groups markers by generic name.
1541    //   3. Replaces the FIRST marker per generic with a synthesised standalone
1542    //      roxygen block (bare @name <generic> → bare \alias{<generic>}, listing
1543    //      every in-package implementing class).
1544    //   4. Deletes all remaining markers (keeping none in the final file).
1545    //
1546    // Method blocks keep their qualified @name Class-generic so the PR #83
1547    // duplicate-alias fix is not re-introduced.
1548    content = resolve_generic_doc_markers(content);
1549
1550    // Resolve .__MX_INHERITED_PARAM__(...) markers.
1551    //
1552    // Each R6 subclass method param that might be inherited from a parent class
1553    // emits one marker line per undocumented param. The write-time pass:
1554    //   1. Scans the assembled content for method and class-level @param docs.
1555    //   2. For each marker: if the parent class is in-package and has the param
1556    //      documented (at method or class level) → delete the marker line
1557    //      (roxygen2 8.0.0 inherits the parent's @param into the subclass method).
1558    //   3. Otherwise → replace the marker with the fallback placeholder text.
1559    //
1560    // This guarantees zero markers remain in the final wrappers.R and zero
1561    // roxygen2 warnings about undocumented parameters.
1562    content = resolve_inherited_param_markers(content);
1563
1564    // Detect collisions between separately-expanded wrapper definitions (#991).
1565    //
1566    // Two top-level `name <- function` definitions of the same name silently
1567    // clobber each other at load time (last write wins), dispatching calls to
1568    // the wrong implementation. The compile-time `check_s7_shortcut_collisions`
1569    // only sees a single impl block; collisions that span macro expansions --
1570    // an S7 shortcut vs a `#[derive(ExternalPtr)]` sidecar accessor, or two S7
1571    // impl blocks on the same type -- are only visible here, where every name
1572    // set is assembled. We resolve the sidecar-accessor prefix through
1573    // MX_CLASS_NAMES so a `class = "Override"` is honoured, then scan the final
1574    // content. This runs after all placeholder resolution so names are final.
1575    {
1576        let class_index = build_class_name_index(MX_CLASS_NAMES.iter());
1577        let accessor_producers = sidecar_accessor_names(&MX_S7_SIDECAR_PROPS, &class_index);
1578        if let Err(msg) = detect_duplicate_wrapper_defs(&content, &accessor_producers) {
1579            panic!("{msg}");
1580        }
1581    }
1582
1583    // Only write if content changed (avoids unnecessary NAMESPACE/man regeneration).
1584    //
1585    // "Semantic equality" means equal after stripping source-position suffixes from
1586    // the source-attribution comments emitted by the `#[miniextendr]` proc-macro:
1587    //
1588    //   # Generated from Rust fn `foo` (lib.rs:42:8)
1589    //                                           ^^^^^ positional suffix — ignored here
1590    //
1591    // These positions shift whenever any unrelated Rust source above a wrapper is
1592    // edited. The NOTE and the file rewrite should only fire when the actual
1593    // wrapper code, exports, or docstrings change — not when line numbers move.
1594    // The written file still carries the real line:col for jump-to-source.
1595    let existing = std::fs::read_to_string(path).unwrap_or_default();
1596    if wrappers_semantically_equal(&existing, &content) {
1597        return;
1598    }
1599
1600    // Write via a sibling temp file then rename for atomicity.
1601    //
1602    // A plain `std::fs::write` truncates in place; a concurrent reader (e.g. a
1603    // second `R CMD INSTALL` process) can observe a torn file. `rename(2)` is
1604    // atomic on POSIX — the destination is replaced in a single syscall, so
1605    // readers always see either the old or the new content. On Windows, `rename`
1606    // fails when the destination exists, so we remove it first; that window is
1607    // benign (worst case the reader gets "file not found" and retries, which is
1608    // the same outcome as a torn write).
1609    let dest = std::path::Path::new(path);
1610    let tmp = dest.with_extension("tmp");
1611    std::fs::write(&tmp, content.as_bytes())
1612        .unwrap_or_else(|e| panic!("failed to write {}: {e}", tmp.display()));
1613    #[cfg(windows)]
1614    let _ = std::fs::remove_file(dest);
1615    std::fs::rename(&tmp, dest).unwrap_or_else(|e| {
1616        panic!(
1617            "failed to rename {} → {}: {e}",
1618            tmp.display(),
1619            dest.display()
1620        )
1621    });
1622
1623    if !existing.is_empty() {
1624        let filename = dest
1625            .file_name()
1626            .and_then(|f| f.to_str())
1627            .unwrap_or("wrappers.R");
1628        eprintln!();
1629        eprintln!("NOTE: {filename} changed — run devtools::document() to update NAMESPACE.");
1630        eprintln!();
1631    }
1632}
1633
1634/// Returns `true` when `a` and `b` are equal after normalising away
1635/// `(<file>.rs:LINE:COL)` positional suffixes in source-attribution comments.
1636///
1637/// The `#[miniextendr]` proc-macro emits comments of the form:
1638/// ```r
1639/// # Generated from Rust fn `foo` (lib.rs:42:8)
1640/// ```
1641/// The `42:8` part shifts whenever unrelated code above the wrapper is edited,
1642/// producing spurious wrapper-file rewrites and misleading NOTEs. Normalisation
1643/// replaces `(lib.rs:42:8)` → `(lib.rs:_:_)` for the comparison only; the
1644/// file on disk still carries the real positions.
1645///
1646/// A match requires `(` + one or more non-`()`/non-newline chars + `.rs:` +
1647/// ASCII digits + `:` + ASCII digits + `)`. Malformed or non-`.rs` patterns
1648/// are left untouched.
1649///
1650/// Host-only: the sole caller is [`write_r_wrappers_to_file`], gated off on
1651/// wasm32 (the wrapper-gen pass doesn't run there).
1652#[cfg(not(target_arch = "wasm32"))]
1653fn wrappers_semantically_equal(a: &str, b: &str) -> bool {
1654    normalize_source_locs(a) == normalize_source_locs(b)
1655}
1656
1657/// Replace every `(<stem>.rs:LINE:COL)` occurrence with `(<stem>.rs:_:_)`.
1658///
1659/// Returns a [`std::borrow::Cow::Borrowed`] slice when no replacements are
1660/// needed (zero-allocation fast path for the common case where the file has
1661/// never been written or is truly unchanged).
1662#[cfg(not(target_arch = "wasm32"))]
1663fn normalize_source_locs(s: &str) -> std::borrow::Cow<'_, str> {
1664    // Fast path: bail early if there is no `.rs:` anywhere in the string.
1665    if !s.contains(".rs:") {
1666        return std::borrow::Cow::Borrowed(s);
1667    }
1668
1669    let bytes = s.as_bytes();
1670    let len = bytes.len();
1671    let mut out = String::with_capacity(len);
1672    let mut pos = 0usize;
1673    let mut any_replaced = false;
1674
1675    while pos < len {
1676        // Look for the next `(`.
1677        let Some(open) = memchr(bytes, pos, b'(') else {
1678            break;
1679        };
1680
1681        // After `(`, scan for `.rs:` without crossing `)` or `(` or a newline.
1682        let inner_start = open + 1;
1683        let Some(dot_rs) = find_substr(bytes, inner_start, b".rs:") else {
1684            // No `.rs:` at all in the rest of the string — copy remainder and stop.
1685            out.push_str(&s[pos..]);
1686            return std::borrow::Cow::Owned(out);
1687        };
1688
1689        // Make sure `.rs:` precedes any closing `)`, `(`, or newline (no nesting).
1690        let intervening = &bytes[inner_start..dot_rs];
1691        if intervening
1692            .iter()
1693            .any(|&b| b == b')' || b == b'(' || b == b'\n')
1694        {
1695            // Guard crossed; copy up to and including `(` and continue after it.
1696            out.push_str(&s[pos..=open]);
1697            pos = open + 1;
1698            continue;
1699        }
1700
1701        // After `.rs:`, consume digits for LINE.
1702        let after_colon1 = dot_rs + 4; // skip `.rs:`
1703        let Some(colon2) = scan_digits(bytes, after_colon1) else {
1704            // Not digits after `.rs:` — not a source-attribution pattern.
1705            out.push_str(&s[pos..=open]);
1706            pos = open + 1;
1707            continue;
1708        };
1709        if colon2 >= len || bytes[colon2] != b':' {
1710            out.push_str(&s[pos..=open]);
1711            pos = open + 1;
1712            continue;
1713        }
1714
1715        // After the second `:`, consume digits for COL.
1716        let after_colon2 = colon2 + 1;
1717        let Some(close_pos) = scan_digits(bytes, after_colon2) else {
1718            out.push_str(&s[pos..=open]);
1719            pos = open + 1;
1720            continue;
1721        };
1722        if close_pos >= len || bytes[close_pos] != b')' {
1723            out.push_str(&s[pos..=open]);
1724            pos = open + 1;
1725            continue;
1726        }
1727
1728        // We have a match: `(stem.rs:LINE:COL)` — emit `(stem.rs:_:_)`.
1729        any_replaced = true;
1730        out.push_str(&s[pos..inner_start]); // text before inner_start (includes `(`)
1731        out.push_str(&s[inner_start..dot_rs + 3]); // `stem.rs` (without the colon)
1732        out.push_str(":_:_)");
1733        pos = close_pos + 1; // skip past the closing `)`
1734    }
1735
1736    if !any_replaced {
1737        return std::borrow::Cow::Borrowed(s);
1738    }
1739
1740    out.push_str(&s[pos..]);
1741    std::borrow::Cow::Owned(out)
1742}
1743
1744/// Find the first occurrence of `needle` in `haystack[from..]`.
1745/// Returns the absolute index in `haystack`, or `None`.
1746#[cfg(not(target_arch = "wasm32"))]
1747#[inline]
1748fn find_substr(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
1749    let window = haystack.get(from..)?;
1750    window
1751        .windows(needle.len())
1752        .position(|w| w == needle)
1753        .map(|rel| from + rel)
1754}
1755
1756/// Find the first byte equal to `needle` in `haystack[from..]`.
1757/// Returns the absolute index, or `None`.
1758#[cfg(not(target_arch = "wasm32"))]
1759#[inline]
1760fn memchr(haystack: &[u8], from: usize, needle: u8) -> Option<usize> {
1761    haystack[from..]
1762        .iter()
1763        .position(|&b| b == needle)
1764        .map(|rel| from + rel)
1765}
1766
1767/// Advance past a run of ASCII digits starting at `haystack[from]`.
1768/// Returns the absolute index of the first non-digit byte, or `None` if
1769/// there are no digits at `from` (empty run is not valid).
1770#[cfg(not(target_arch = "wasm32"))]
1771#[inline]
1772fn scan_digits(haystack: &[u8], from: usize) -> Option<usize> {
1773    let start = haystack.get(from..)?;
1774    let count = start.iter().take_while(|&&b| b.is_ascii_digit()).count();
1775    if count == 0 { None } else { Some(from + count) }
1776}
1777// endregion
1778
1779// region: Duplicate wrapper-definition detection (#991)
1780
1781/// Compute the sidecar accessor function names a `#[derive(ExternalPtr)]` emits
1782/// for each `#[r_data]` field, resolving the R-visible prefix through the
1783/// class-name registry.
1784///
1785/// The derive emits two top-level functions per field —
1786/// `<prefix>_get_<field>` and `<prefix>_set_<field>` — where `<prefix>` is the
1787/// R-visible class name. That equals the Rust type name unless the impl block
1788/// set `class = "Override"`, in which case `MX_CLASS_NAMES` carries the
1789/// override. We resolve through `class_index` so the names we attribute match
1790/// what actually lands in the wrapper file.
1791///
1792/// Returns a map from accessor function name -> the human-readable producer
1793/// description used in the collision message.
1794#[cfg(not(target_arch = "wasm32"))]
1795fn sidecar_accessor_names(
1796    sidecar_props: &[SidecarPropEntry],
1797    class_index: &std::collections::HashMap<&str, &ClassNameEntry>,
1798) -> std::collections::HashMap<String, String> {
1799    let mut out = std::collections::HashMap::new();
1800    for entry in sidecar_props {
1801        // Resolve the R-visible prefix (honours `class = "Override"`).
1802        let prefix = class_index
1803            .get(entry.rust_type)
1804            .map(|c| c.r_class_name)
1805            .unwrap_or(entry.rust_type);
1806        let producer = format!(
1807            "#[derive(ExternalPtr)] sidecar field `{}` on `{}`",
1808            entry.field_name, entry.rust_type
1809        );
1810        out.insert(
1811            format!("{prefix}_get_{}", entry.field_name),
1812            producer.clone(),
1813        );
1814        out.insert(format!("{prefix}_set_{}", entry.field_name), producer);
1815    }
1816    out
1817}
1818
1819/// Detect a second top-level `name <- function` definition of any wrapper
1820/// function in the assembled R wrapper text.
1821///
1822/// This is the write-time counterpart to the compile-time
1823/// `check_s7_shortcut_collisions` (in `miniextendr-macros`): it catches name
1824/// collisions that span *separate* macro expansions and are therefore invisible
1825/// to any single proc-macro invocation. The two motivating cases (#991):
1826///
1827/// 1. An S7 fast-path shortcut `<Class>_<method>` colliding with a
1828///    `#[derive(ExternalPtr)]` sidecar accessor `<Class>_get_<field>` /
1829///    `<Class>_set_<field>` -- the sidecar field names live in the
1830///    `MX_S7_SIDECAR_PROPS` slice, which the impl-block macro can't see.
1831/// 2. Two `#[miniextendr(s7)]` impl blocks on the same type each emitting a
1832///    `<Class>_<method>` shortcut -- cross-impl-block, also invisible to a
1833///    single expansion.
1834///
1835/// Without this check the second definition silently clobbers the first
1836/// (last-write-wins in R), so a call dispatches to the wrong implementation.
1837///
1838/// `accessor_producers` maps a known sidecar-accessor name to a description of
1839/// its producing derive; when a duplicate matches one we name the derive in the
1840/// message, otherwise we report the generic "defined more than once" form.
1841///
1842/// Returns `Err(message)` naming the offending function on the first collision
1843/// found; `Ok(())` when every top-level definition name is unique.
1844#[cfg(not(target_arch = "wasm32"))]
1845fn detect_duplicate_wrapper_defs(
1846    content: &str,
1847    accessor_producers: &std::collections::HashMap<String, String>,
1848) -> Result<(), String> {
1849    use std::collections::HashSet;
1850
1851    let mut seen: HashSet<&str> = HashSet::new();
1852
1853    for line in content.lines() {
1854        // Only top-level definitions matter -- class-method assignments like
1855        // `S7::method(generic, Class) <- function(...)` and indented closures
1856        // (R6 methods, S7 getters) are not bare `name <- function` forms and
1857        // must not trip the scan.
1858        let Some(name) = parse_top_level_fn_def_name(line) else {
1859            continue;
1860        };
1861
1862        if !seen.insert(name) {
1863            // Second definition of this name -> collision.
1864            if let Some(producer) = accessor_producers.get(name) {
1865                return Err(format!(
1866                    "miniextendr: wrapper function `{name}` is defined more than once. \
1867                     One definition comes from {producer}; the other from an S7 fast-path \
1868                     shortcut or `#[miniextendr]` function of the same name. The second \
1869                     definition silently overwrites the first at load time. Rename the \
1870                     colliding S7 method (`#[miniextendr(s7(r_name = \"...\"))]`), opt it \
1871                     out of the shortcut (`#[miniextendr(s7(no_shortcut))]`), or rename the \
1872                     sidecar field."
1873                ));
1874            }
1875            return Err(format!(
1876                "miniextendr: wrapper function `{name}` is defined more than once in the \
1877                 generated R wrappers. Two `#[miniextendr]`-generated top-level functions \
1878                 emit the same name and the second silently overwrites the first at load \
1879                 time. Common causes: two `#[miniextendr(s7)]` impl blocks (or an S7 \
1880                 shortcut and another generated function) sharing a `<Class>_<method>` \
1881                 name — rename one (`#[miniextendr(s7(r_name = \"...\"))]`) or opt it out \
1882                 of the shortcut (`#[miniextendr(s7(no_shortcut))]`); or two trait impls \
1883                 whose generated standalone wrapper names are not class-qualified — rename \
1884                 the colliding method (`#[miniextendr(<system>(r_name = \"...\"))]`)."
1885            ));
1886        }
1887    }
1888
1889    Ok(())
1890}
1891
1892/// Parse the function name from a top-level `name <- function(...)` definition
1893/// line, or return `None` when the line is not such a definition.
1894///
1895/// Recognises only **top-level** definitions: the line must start at column 0
1896/// (no leading whitespace -- indented forms are R6/S7 closures, not standalone
1897/// wrappers) and the left-hand side must be a single bare R identifier. Lines
1898/// where the LHS is a call (e.g. `S7::method(...) <- function`) or contains `$`
1899/// / `[[` / `::` are not bare definitions and are skipped.
1900#[cfg(not(target_arch = "wasm32"))]
1901fn parse_top_level_fn_def_name(line: &str) -> Option<&str> {
1902    // Top-level only: reject any leading whitespace.
1903    if line.starts_with([' ', '\t']) {
1904        return None;
1905    }
1906    let (lhs, rhs) = line.split_once("<-")?;
1907    // RHS must be (after trimming) the start of a `function` definition.
1908    if !rhs.trim_start().starts_with("function") {
1909        return None;
1910    }
1911    let name = lhs.trim();
1912    if name.is_empty() {
1913        return None;
1914    }
1915    // Bare identifier only: R names may contain letters, digits, `.` and `_`,
1916    // and must not start with a digit. Anything else (a call on the LHS, a `$`
1917    // assignment, a `::`-qualified target, backtick-quoted names with spaces)
1918    // is not a standalone wrapper definition we own.
1919    let mut chars = name.chars();
1920    let first = chars.next()?;
1921    if !(first.is_ascii_alphabetic() || first == '.') {
1922        return None;
1923    }
1924    if name
1925        .chars()
1926        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_')
1927    {
1928        Some(name)
1929    } else {
1930        None
1931    }
1932}
1933// endregion
1934
1935// region: C-Callable Entry Points (wrapper generation)
1936//
1937// All wrapper-gen entry points are host-only — wasm32 builds skip
1938// them (the wrapper-gen pass itself doesn't run on wasm32; wrappers and
1939// `wasm_registry.rs` are pre-generated on native and shipped into the
1940// wasm32 install).
1941
1942/// C-callable entry point for R wrapper generation.
1943///
1944/// Called from Makevars via Rscript: loads the installed shared object with
1945/// `dyn.load()`, then `.Call("miniextendr_write_wrappers", path)` to write
1946/// `R/miniextendr-wrappers.R`. NAMESPACE generation is left to roxygen2
1947/// (`devtools::document()`).
1948///
1949/// # Safety
1950///
1951/// `path_sexp` must be a valid STRSXP of length >= 1.
1952#[cfg(not(target_arch = "wasm32"))]
1953#[unsafe(no_mangle)]
1954pub unsafe extern "C" fn miniextendr_write_wrappers(path_sexp: crate::SEXP) -> crate::SEXP {
1955    unsafe {
1956        use crate::{SEXP, SexpExt};
1957
1958        let char_sexp = path_sexp.string_elt_unchecked(0);
1959        let c_str = std::ffi::CStr::from_ptr(char_sexp.r_char_unchecked());
1960        let path = c_str
1961            .to_str()
1962            .unwrap_or_else(|e| panic!("invalid UTF-8 in path: {e}"));
1963
1964        write_r_wrappers_to_file(path);
1965
1966        SEXP::nil()
1967    }
1968}
1969
1970/// C-callable entry point for `wasm_registry.rs` generation.
1971///
1972/// Pairs with [`miniextendr_write_wrappers`]: same loaded object, separate `.Call`,
1973/// independent output path. Host-only; the generated file itself is then
1974/// consumed at compile time by the user crate's wasm32 build via
1975/// `install_wasm_runtime_slices`.
1976///
1977/// # Safety
1978///
1979/// `path_sexp` must be a valid STRSXP of length >= 1.
1980#[cfg(not(target_arch = "wasm32"))]
1981#[unsafe(no_mangle)]
1982pub unsafe extern "C" fn miniextendr_write_wasm_registry(path_sexp: crate::SEXP) -> crate::SEXP {
1983    unsafe {
1984        use crate::{SEXP, SexpExt};
1985
1986        let char_sexp = path_sexp.string_elt_unchecked(0);
1987        let c_str = std::ffi::CStr::from_ptr(char_sexp.r_char_unchecked());
1988        let path = c_str
1989            .to_str()
1990            .unwrap_or_else(|e| panic!("invalid UTF-8 in path: {e}"));
1991
1992        crate::wasm_registry_writer::write_wasm_registry_to_file(path);
1993
1994        SEXP::nil()
1995    }
1996}
1997// endregion
1998
1999// region: Unit tests for class-ref placeholder resolution
2000
2001#[cfg(test)]
2002mod tests {
2003    use super::*;
2004
2005    /// Run the class-ref placeholder resolver over `content` using `entries` as the registry.
2006    ///
2007    /// This mirrors the logic in `write_r_wrappers_to_file` but operates on a caller-supplied
2008    /// slice so it can be called from unit tests without a live distributed_slice.
2009    fn resolve_class_refs(content: &str, entries: &[ClassNameEntry]) -> String {
2010        let class_index = build_class_name_index(entries.iter());
2011
2012        const OR_ANY_PREFIX: &str = ".__MX_CLASS_REF_OR_ANY_";
2013        const PREFIX: &str = ".__MX_CLASS_REF_";
2014        const SUFFIX: &str = "__";
2015
2016        let mut result = String::with_capacity(content.len());
2017        let mut remaining = content;
2018        while let Some(start) = remaining.find(PREFIX) {
2019            result.push_str(&remaining[..start]);
2020            let rest_from_prefix = &remaining[start..];
2021            let (quiet_fallback, header_len) = if rest_from_prefix.starts_with(OR_ANY_PREFIX) {
2022                (true, OR_ANY_PREFIX.len())
2023            } else {
2024                (false, PREFIX.len())
2025            };
2026            remaining = &rest_from_prefix[header_len..];
2027            if let Some(end) = remaining.find(SUFFIX) {
2028                let rust_name = &remaining[..end];
2029                remaining = &remaining[end + SUFFIX.len()..];
2030                match class_index.get(rust_name) {
2031                    Some(entry) if quiet_fallback => {
2032                        if entry.class_system == "s7" {
2033                            result.push_str(entry.r_class_name);
2034                        } else {
2035                            result.push_str("S7::class_any");
2036                        }
2037                    }
2038                    Some(entry) => result.push_str(entry.r_class_name),
2039                    None if quiet_fallback => result.push_str("S7::class_any"),
2040                    None => result.push_str(rust_name),
2041                }
2042            } else {
2043                result.push_str(PREFIX);
2044                break;
2045            }
2046        }
2047        result.push_str(remaining);
2048        result
2049    }
2050
2051    /// Two labeled impl blocks on one type register identical entries (#1242);
2052    /// the index collapses them instead of treating the duplicate key as an error.
2053    #[test]
2054    fn class_name_index_dedups_identical_entries() {
2055        let entries = [
2056            ClassNameEntry {
2057                rust_type: "Foo",
2058                r_class_name: "Foo",
2059                class_system: "env",
2060            },
2061            ClassNameEntry {
2062                rust_type: "Foo",
2063                r_class_name: "Foo",
2064                class_system: "env",
2065            },
2066        ];
2067        let index = build_class_name_index(entries.iter());
2068        assert_eq!(index.len(), 1);
2069        assert_eq!(index["Foo"].r_class_name, "Foo");
2070    }
2071
2072    /// Disagreeing `class = "..."` overrides across blocks must fail loudly —
2073    /// placeholder resolution would otherwise pick whichever entry linkme
2074    /// ordered last.
2075    #[test]
2076    #[should_panic(expected = "conflicting class registrations for Rust type `Foo`")]
2077    fn class_name_index_panics_on_conflicting_override() {
2078        let entries = [
2079            ClassNameEntry {
2080                rust_type: "Foo",
2081                r_class_name: "Foo",
2082                class_system: "env",
2083            },
2084            ClassNameEntry {
2085                rust_type: "Foo",
2086                r_class_name: "FooOverride",
2087                class_system: "env",
2088            },
2089        ];
2090        let _ = build_class_name_index(entries.iter());
2091    }
2092
2093    /// Run the production scalar cross-class return wrapper resolver over
2094    /// `content` using `entries` as the class registry.
2095    fn resolve_return_wrappers(content: &str, entries: &[ClassNameEntry]) -> String {
2096        let class_index = build_class_name_index(entries.iter());
2097        resolve_scalar_return_wrappers(content, &class_index)
2098    }
2099
2100    /// Run the production list cross-class return wrapper resolver (#1284)
2101    /// over `content` using `entries` as the class registry.
2102    fn resolve_list_wrappers(content: &str, entries: &[ClassNameEntry]) -> String {
2103        let class_index = build_class_name_index(entries.iter());
2104        resolve_list_return_wrappers(content, &class_index)
2105    }
2106
2107    #[test]
2108    fn test_class_ref_resolver_with_override() {
2109        // S7Shape is registered with r_class_name = "Shape" (override)
2110        let entries = [
2111            ClassNameEntry {
2112                rust_type: "S7Shape",
2113                r_class_name: "Shape",
2114                class_system: "s7",
2115            },
2116            ClassNameEntry {
2117                rust_type: "S7Circle",
2118                r_class_name: "S7Circle",
2119                class_system: "s7",
2120            },
2121        ];
2122
2123        let input = r#"S7Circle <- S7::new_class("S7Circle", parent = .__MX_CLASS_REF_S7Shape__, properties = list())"#;
2124        let output = resolve_class_refs(input, &entries);
2125        assert_eq!(
2126            output,
2127            r#"S7Circle <- S7::new_class("S7Circle", parent = Shape, properties = list())"#
2128        );
2129    }
2130
2131    #[test]
2132    fn test_class_ref_resolver_multiple_placeholders() {
2133        let entries = [
2134            ClassNameEntry {
2135                rust_type: "Point2D",
2136                r_class_name: "Point2D",
2137                class_system: "s7",
2138            },
2139            ClassNameEntry {
2140                rust_type: "Point3D",
2141                r_class_name: "Point3D",
2142                class_system: "s7",
2143            },
2144        ];
2145
2146        let input = "S7::method(convert, list(.__MX_CLASS_REF_Point2D__, Point3D)) <- function(from, to) {}";
2147        let output = resolve_class_refs(input, &entries);
2148        assert_eq!(
2149            output,
2150            "S7::method(convert, list(Point2D, Point3D)) <- function(from, to) {}"
2151        );
2152    }
2153
2154    #[test]
2155    fn test_class_ref_resolver_unresolved_falls_back_to_rust_name() {
2156        let entries: [ClassNameEntry; 0] = [];
2157        let input = "parent = .__MX_CLASS_REF_UnknownClass__";
2158        let output = resolve_class_refs(input, &entries);
2159        // Falls back to the bare Rust name
2160        assert_eq!(output, "parent = UnknownClass");
2161    }
2162
2163    #[test]
2164    fn test_class_ref_resolver_verbatim_passthrough() {
2165        // Strings that are NOT bare identifiers should not have been wrapped in
2166        // a placeholder, so they pass through the resolver unchanged.
2167        let entries: [ClassNameEntry; 0] = [];
2168        let input = "parent = S7::class_any";
2169        let output = resolve_class_refs(input, &entries);
2170        assert_eq!(output, "parent = S7::class_any");
2171    }
2172
2173    #[test]
2174    fn test_is_bare_identifier_via_resolver() {
2175        // A placeholder for a bare identifier should be resolved.
2176        let entries = [ClassNameEntry {
2177            rust_type: "MyClass",
2178            r_class_name: "MyClass",
2179            class_system: "r6",
2180        }];
2181        let input = "inherit = .__MX_CLASS_REF_MyClass__";
2182        let output = resolve_class_refs(input, &entries);
2183        assert_eq!(output, "inherit = MyClass");
2184    }
2185
2186    // #203 — OR_ANY variant: S7 property class constraints should fall back
2187    // silently to S7::class_any when the type isn't a registered S7 class.
2188
2189    #[test]
2190    fn or_any_resolves_registered_s7_class() {
2191        let entries = [ClassNameEntry {
2192            rust_type: "S7PropInner",
2193            r_class_name: "S7PropInner",
2194            class_system: "s7",
2195        }];
2196        let input = "class = .__MX_CLASS_REF_OR_ANY_S7PropInner__";
2197        let output = resolve_class_refs(input, &entries);
2198        assert_eq!(output, "class = S7PropInner");
2199    }
2200
2201    #[test]
2202    fn or_any_unregistered_type_falls_back_to_class_any_silently() {
2203        // No entry for SEXP, PathBuf, Json, etc.
2204        let entries: [ClassNameEntry; 0] = [];
2205        let input = "class = .__MX_CLASS_REF_OR_ANY_SEXP__";
2206        let output = resolve_class_refs(input, &entries);
2207        assert_eq!(output, "class = S7::class_any");
2208    }
2209
2210    #[test]
2211    fn or_any_registered_non_s7_type_falls_back_to_class_any() {
2212        // A type registered as an R6 class can't serve as an S7 class
2213        // constraint — S7 would error at load time. Quiet fallback avoids
2214        // that footgun.
2215        let entries = [ClassNameEntry {
2216            rust_type: "R6Counter",
2217            r_class_name: "R6Counter",
2218            class_system: "r6",
2219        }];
2220        let input = "class = .__MX_CLASS_REF_OR_ANY_R6Counter__";
2221        let output = resolve_class_refs(input, &entries);
2222        assert_eq!(output, "class = S7::class_any");
2223    }
2224
2225    #[test]
2226    fn loud_class_ref_still_emits_bare_name_when_unresolved() {
2227        // The existing CLASS_REF variant (without OR_ANY) keeps its loud
2228        // behavior: bare Rust name + compile-time warning. Regression test
2229        // for that path alongside the OR_ANY introduction.
2230        let entries: [ClassNameEntry; 0] = [];
2231        let input = "parent = .__MX_CLASS_REF_UnknownClass__";
2232        let output = resolve_class_refs(input, &entries);
2233        assert_eq!(output, "parent = UnknownClass");
2234    }
2235
2236    #[test]
2237    fn mixed_placeholders_resolve_independently() {
2238        // A single wrapper can mix both variants — `inherit = parent` (loud)
2239        // and `class = prop_type` (quiet). Verify each takes its own path.
2240        let entries = [
2241            ClassNameEntry {
2242                rust_type: "Parent",
2243                r_class_name: "Parent",
2244                class_system: "s7",
2245            },
2246            // PropInner deliberately missing → should resolve to class_any.
2247        ];
2248        let input =
2249            "inherit = .__MX_CLASS_REF_Parent__, class = .__MX_CLASS_REF_OR_ANY_PropInner__";
2250        let output = resolve_class_refs(input, &entries);
2251        assert_eq!(output, "inherit = Parent, class = S7::class_any");
2252    }
2253
2254    #[test]
2255    fn return_wrapper_resolves_r6_constructor() {
2256        let entries = [ClassNameEntry {
2257            rust_type: "R6Board",
2258            r_class_name: "R6Board",
2259            class_system: "r6",
2260        }];
2261        let input = "return(.__MX_WRAP_RETURN_R6Board__(.val))";
2262        let output = resolve_return_wrappers(input, &entries);
2263        assert_eq!(output, "return(R6Board$new(.ptr = .val))");
2264    }
2265
2266    #[test]
2267    fn return_wrapper_resolves_s7_override_constructor() {
2268        let entries = [ClassNameEntry {
2269            rust_type: "S7Board",
2270            r_class_name: "PrettyBoard",
2271            class_system: "s7",
2272        }];
2273        let input = ".__MX_WRAP_RETURN_S7Board__(.val)";
2274        let output = resolve_return_wrappers(input, &entries);
2275        assert_eq!(output, "PrettyBoard(.ptr = .val)");
2276    }
2277
2278    #[test]
2279    fn return_wrapper_resolves_s4_constructor() {
2280        let entries = [ClassNameEntry {
2281            rust_type: "S4Board",
2282            r_class_name: "S4Board",
2283            class_system: "s4",
2284        }];
2285        let input = ".__MX_WRAP_RETURN_S4Board__(.val)";
2286        let output = resolve_return_wrappers(input, &entries);
2287        assert_eq!(output, "methods::new(\"S4Board\", ptr = .val)");
2288    }
2289
2290    #[test]
2291    fn return_wrapper_resolves_attribute_classes() {
2292        let entries = [
2293            ClassNameEntry {
2294                rust_type: "S3Board",
2295                r_class_name: "S3Board",
2296                class_system: "s3",
2297            },
2298            ClassNameEntry {
2299                rust_type: "EnvBoard",
2300                r_class_name: "EnvBoard",
2301                class_system: "env",
2302            },
2303        ];
2304        let input =
2305            "a <- .__MX_WRAP_RETURN_S3Board__(.val)\nb <- .__MX_WRAP_RETURN_EnvBoard__(.ptr)";
2306        let output = resolve_return_wrappers(input, &entries);
2307        assert_eq!(
2308            output,
2309            "a <- structure(.val, class = \"S3Board\")\nb <- structure(.ptr, class = \"EnvBoard\")"
2310        );
2311    }
2312
2313    #[test]
2314    fn return_wrapper_unregistered_type_falls_back_to_value() {
2315        let entries: [ClassNameEntry; 0] = [];
2316        let input = ".__MX_WRAP_RETURN_JsonValue__(.val)";
2317        let output = resolve_return_wrappers(input, &entries);
2318        assert_eq!(output, ".val");
2319    }
2320
2321    // #1284 — list-shaped cross-class return markers (`Vec<Class>` returns).
2322
2323    /// CRITICAL non-overlap pin: the scalar resolver rewrites *unrecognized*
2324    /// names of its own prefix to the bare expression, so if the list marker
2325    /// shared the `.__MX_WRAP_RETURN_` prefix the scalar pass would consume
2326    /// and destroy it before the list pass ran. The `LIST_` infix keeps the
2327    /// families disjoint — the scalar resolver must leave list markers
2328    /// completely untouched (and vice versa).
2329    #[test]
2330    fn scalar_resolver_leaves_list_markers_untouched() {
2331        let entries = [ClassNameEntry {
2332            rust_type: "R6Board",
2333            r_class_name: "R6Board",
2334            class_system: "r6",
2335        }];
2336        let input = "a <- .__MX_WRAP_LIST_RETURN_R6Board__(.val)";
2337        let output = resolve_return_wrappers(input, &entries);
2338        assert_eq!(output, input, "scalar pass must not consume list markers");
2339    }
2340
2341    #[test]
2342    fn list_resolver_leaves_scalar_markers_untouched() {
2343        let entries = [ClassNameEntry {
2344            rust_type: "R6Board",
2345            r_class_name: "R6Board",
2346            class_system: "r6",
2347        }];
2348        let input = "a <- .__MX_WRAP_RETURN_R6Board__(.val)";
2349        let output = resolve_list_wrappers(input, &entries);
2350        assert_eq!(output, input, "list pass must not consume scalar markers");
2351    }
2352
2353    /// Both passes over content mixing the two families resolve each marker
2354    /// with its own resolver, in either pass order.
2355    #[test]
2356    fn scalar_and_list_markers_resolve_independently() {
2357        let entries = [ClassNameEntry {
2358            rust_type: "R6Board",
2359            r_class_name: "R6Board",
2360            class_system: "r6",
2361        }];
2362        let input =
2363            "a <- .__MX_WRAP_RETURN_R6Board__(.val)\nb <- .__MX_WRAP_LIST_RETURN_R6Board__(.val)";
2364        let expected = "a <- R6Board$new(.ptr = .val)\nb <- lapply(.val, function(.el) R6Board$new(.ptr = .el))";
2365
2366        let scalar_first =
2367            resolve_list_wrappers(&resolve_return_wrappers(input, &entries), &entries);
2368        let list_first = resolve_return_wrappers(&resolve_list_wrappers(input, &entries), &entries);
2369        assert_eq!(scalar_first, expected);
2370        assert_eq!(list_first, expected);
2371    }
2372
2373    #[test]
2374    fn list_return_wrapper_resolves_r6_lapply() {
2375        let entries = [ClassNameEntry {
2376            rust_type: "R6Board",
2377            r_class_name: "R6Board",
2378            class_system: "r6",
2379        }];
2380        let input = "return(.__MX_WRAP_LIST_RETURN_R6Board__(.val))";
2381        let output = resolve_list_wrappers(input, &entries);
2382        assert_eq!(
2383            output,
2384            "return(lapply(.val, function(.el) R6Board$new(.ptr = .el)))"
2385        );
2386    }
2387
2388    #[test]
2389    fn list_return_wrapper_resolves_s7_override_lapply() {
2390        let entries = [ClassNameEntry {
2391            rust_type: "S7Board",
2392            r_class_name: "PrettyBoard",
2393            class_system: "s7",
2394        }];
2395        let input = ".__MX_WRAP_LIST_RETURN_S7Board__(.val)";
2396        let output = resolve_list_wrappers(input, &entries);
2397        assert_eq!(
2398            output,
2399            "lapply(.val, function(.el) PrettyBoard(.ptr = .el))"
2400        );
2401    }
2402
2403    #[test]
2404    fn list_return_wrapper_resolves_s4_lapply() {
2405        let entries = [ClassNameEntry {
2406            rust_type: "S4Board",
2407            r_class_name: "S4Board",
2408            class_system: "s4",
2409        }];
2410        let input = ".__MX_WRAP_LIST_RETURN_S4Board__(.val)";
2411        let output = resolve_list_wrappers(input, &entries);
2412        assert_eq!(
2413            output,
2414            "lapply(.val, function(.el) methods::new(\"S4Board\", ptr = .el))"
2415        );
2416    }
2417
2418    #[test]
2419    fn list_return_wrapper_resolves_attribute_classes_lapply() {
2420        let entries = [
2421            ClassNameEntry {
2422                rust_type: "S3Board",
2423                r_class_name: "S3Board",
2424                class_system: "s3",
2425            },
2426            ClassNameEntry {
2427                rust_type: "EnvBoard",
2428                r_class_name: "EnvBoard",
2429                class_system: "env",
2430            },
2431        ];
2432        let input = "a <- .__MX_WRAP_LIST_RETURN_S3Board__(.val)\nb <- .__MX_WRAP_LIST_RETURN_EnvBoard__(.ptr)";
2433        let output = resolve_list_wrappers(input, &entries);
2434        assert_eq!(
2435            output,
2436            "a <- lapply(.val, function(.el) structure(.el, class = \"S3Board\"))\nb <- lapply(.ptr, function(.el) structure(.el, class = \"EnvBoard\"))"
2437        );
2438    }
2439
2440    #[test]
2441    fn list_return_wrapper_unregistered_type_falls_back_to_bare_list() {
2442        let entries: [ClassNameEntry; 0] = [];
2443        let input = ".__MX_WRAP_LIST_RETURN_JsonValue__(.val)";
2444        let output = resolve_list_wrappers(input, &entries);
2445        assert_eq!(output, ".val");
2446    }
2447
2448    // region: normalize_source_locs unit tests (#528)
2449
2450    #[test]
2451    fn normalize_source_locs_noop_on_plain_text() {
2452        // No `.rs:N:M` pattern — should return the input string unchanged (Borrowed).
2453        let input = "# just a comment\nfoo <- function() {}\n";
2454        let result = normalize_source_locs(input);
2455        assert_eq!(result.as_ref(), input);
2456        // Verify it's the zero-allocation borrowed path.
2457        assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
2458    }
2459
2460    #[test]
2461    fn normalize_source_locs_single_attribution() {
2462        let input = "# Generated from Rust fn `foo` (lib.rs:42:8)";
2463        let result = normalize_source_locs(input);
2464        assert_eq!(
2465            result.as_ref(),
2466            "# Generated from Rust fn `foo` (lib.rs:_:_)"
2467        );
2468    }
2469
2470    #[test]
2471    fn normalize_source_locs_multiple_attributions() {
2472        let input = concat!(
2473            "# (conversions.rs:1:5)\n",
2474            "foo <- function() {}\n",
2475            "# (conversions.rs:158:8)\n",
2476            "bar <- function() {}\n",
2477        );
2478        let result = normalize_source_locs(input);
2479        assert_eq!(
2480            result.as_ref(),
2481            concat!(
2482                "# (conversions.rs:_:_)\n",
2483                "foo <- function() {}\n",
2484                "# (conversions.rs:_:_)\n",
2485                "bar <- function() {}\n",
2486            )
2487        );
2488    }
2489
2490    #[test]
2491    fn normalize_source_locs_does_not_match_extra_colon() {
2492        // `(lib.rs:1:5:6)` — four components — is NOT a valid attribution; leave alone.
2493        let input = "(lib.rs:1:5:6)";
2494        let result = normalize_source_locs(input);
2495        // The pattern `(lib.rs:1:5` matches, but then we expect `)` after the col
2496        // digits and find `:` instead — so it should NOT be replaced.
2497        assert_eq!(result.as_ref(), input);
2498    }
2499
2500    #[test]
2501    fn normalize_source_locs_does_not_match_without_parens() {
2502        // Bare `lib.rs:1:5` without surrounding parens — leave untouched.
2503        let input = "lib.rs:1:5";
2504        let result = normalize_source_locs(input);
2505        assert_eq!(result.as_ref(), input);
2506        assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
2507    }
2508
2509    #[test]
2510    fn wrappers_semantically_equal_position_only_diff() {
2511        // Two wrappers identical except for line:col are semantically equal.
2512        let old = "# Generated from Rust fn `foo` (lib.rs:42:8)\nfoo <- function() {}\n";
2513        let new = "# Generated from Rust fn `foo` (lib.rs:99:8)\nfoo <- function() {}\n";
2514        assert!(wrappers_semantically_equal(old, new));
2515    }
2516
2517    #[test]
2518    fn wrappers_semantically_equal_content_diff() {
2519        // A real semantic change (different function body) is NOT equal.
2520        let old = "# Generated from Rust fn `foo` (lib.rs:42:8)\nfoo <- function() { 1L }\n";
2521        let new = "# Generated from Rust fn `foo` (lib.rs:42:8)\nfoo <- function() { 2L }\n";
2522        assert!(!wrappers_semantically_equal(old, new));
2523    }
2524
2525    #[test]
2526    fn wrappers_semantically_equal_both_position_and_content_diff() {
2527        // Both positions and content differ — still NOT equal.
2528        let old = "# Generated from Rust fn `foo` (lib.rs:1:1)\nfoo <- function() { 1L }\n";
2529        let new = "# Generated from Rust fn `bar` (lib.rs:9:1)\nbar <- function() { 2L }\n";
2530        assert!(!wrappers_semantically_equal(old, new));
2531    }
2532
2533    // endregion
2534
2535    // region: generic doc marker tests
2536
2537    #[test]
2538    fn test_parse_generic_doc_marker_s7() {
2539        let marker = r#".__MX_GENERIC_DOC__(kind="S7", generic="get_value", class="MyClass", export=true, dispatch="x", no_dots=false)"#;
2540        let result = parse_generic_doc_marker(marker);
2541        assert!(result.is_some());
2542        let (kind, generic, class, export, dispatch, no_dots) = result.unwrap();
2543        assert_eq!(kind, "S7");
2544        assert_eq!(generic, "get_value");
2545        assert_eq!(class, "MyClass");
2546        assert!(export);
2547        assert_eq!(dispatch, "x");
2548        assert!(!no_dots);
2549    }
2550
2551    #[test]
2552    fn test_parse_generic_doc_marker_s4() {
2553        let marker =
2554            r#".__MX_GENERIC_DOC__(kind="S4", generic="s4_get", class="Counter", export=true)"#;
2555        let result = parse_generic_doc_marker(marker);
2556        assert!(result.is_some());
2557        let (kind, generic, class, export, dispatch, no_dots) = result.unwrap();
2558        assert_eq!(kind, "S4");
2559        assert_eq!(generic, "s4_get");
2560        assert_eq!(class, "Counter");
2561        assert!(export);
2562        assert_eq!(dispatch, "x"); // default
2563        assert!(!no_dots); // default
2564    }
2565
2566    #[test]
2567    fn test_resolve_generic_doc_markers_single_class() {
2568        let input = concat!(
2569            r#".__MX_GENERIC_DOC__(kind="S7", generic="get_value", class="MyClass", export=true, dispatch="x", no_dots=false)"#,
2570            "\n",
2571            "if (!exists(\"get_value\", mode = \"function\")) {\n",
2572            "  get_value <- S7::new_generic(\"get_value\", \"x\", function(x, ...) S7::S7_dispatch())\n",
2573            "}\n",
2574        );
2575        let output = resolve_generic_doc_markers(input.to_string());
2576
2577        // Marker must be gone
2578        assert!(!output.contains(".__MX_GENERIC_DOC__"));
2579        // Standalone page must have bare @name
2580        assert!(output.contains("#' @name get_value"));
2581        // Must list the class
2582        assert!(output.contains("\\link{MyClass}"));
2583        // Must have NULL anchor
2584        assert!(output.contains("\nNULL\n"));
2585        // Generic guard must survive
2586        assert!(output.contains("if (!exists(\"get_value\""));
2587    }
2588
2589    #[test]
2590    fn test_resolve_generic_doc_markers_two_classes_one_generic() {
2591        // Two classes sharing one generic: both appear in the listing, only one doc page.
2592        let input = concat!(
2593            r#".__MX_GENERIC_DOC__(kind="S7", generic="get_value", class="ClassA", export=true, dispatch="x", no_dots=false)"#,
2594            "\n",
2595            "line_a\n",
2596            r#".__MX_GENERIC_DOC__(kind="S7", generic="get_value", class="ClassB", export=true, dispatch="x", no_dots=false)"#,
2597            "\n",
2598            "line_b\n",
2599        );
2600        let output = resolve_generic_doc_markers(input.to_string());
2601
2602        // No markers remain
2603        assert!(!output.contains(".__MX_GENERIC_DOC__"));
2604        // Only one @name line
2605        let name_count = output.matches("#' @name get_value").count();
2606        assert_eq!(
2607            name_count, 1,
2608            "expected exactly one @name get_value, got {name_count}"
2609        );
2610        // Both classes listed
2611        assert!(output.contains("\\link{ClassA}"));
2612        assert!(output.contains("\\link{ClassB}"));
2613        // Other lines survive
2614        assert!(output.contains("line_a"));
2615        assert!(output.contains("line_b"));
2616    }
2617
2618    #[test]
2619    fn test_resolve_generic_doc_markers_external_generic_excluded() {
2620        // External generics (has_generic_override) do NOT emit markers.
2621        // Verify that content without any markers passes through unchanged.
2622        let input = "if (!exists(\"size\", mode = \"function\")) {\n  size <- S7::new_external_generic(\"vctrs\", \"size\")\n}\n";
2623        let output = resolve_generic_doc_markers(input.to_string());
2624        // Content should be identical (the line-by-line reconstruction adds \n)
2625        let output_trimmed: Vec<&str> = output.lines().collect();
2626        let input_trimmed: Vec<&str> = input.lines().collect();
2627        assert_eq!(output_trimmed, input_trimmed);
2628    }
2629
2630    #[test]
2631    fn test_resolve_generic_doc_markers_no_dots() {
2632        let input = concat!(
2633            r#".__MX_GENERIC_DOC__(kind="S7", generic="strict_fn", class="A", export=true, dispatch="x", no_dots=true)"#,
2634            "\n",
2635        );
2636        let output = resolve_generic_doc_markers(input.to_string());
2637        // no_dots=true → @param ... must NOT appear in the standalone block
2638        assert!(!output.contains("@param ..."));
2639        // @param x must appear
2640        assert!(output.contains("@param x"));
2641    }
2642
2643    #[test]
2644    fn test_resolve_generic_doc_markers_s4() {
2645        let input = concat!(
2646            r#".__MX_GENERIC_DOC__(kind="S4", generic="s4_compute", class="S4Counter", export=true)"#,
2647            "\n",
2648            "if (!methods::isGeneric(\"s4_compute\")) methods::setGeneric(\"s4_compute\", function(x, ...) standardGeneric(\"s4_compute\"))\n",
2649        );
2650        let output = resolve_generic_doc_markers(input.to_string());
2651        assert!(!output.contains(".__MX_GENERIC_DOC__"));
2652        assert!(output.contains("#' @name s4_compute"));
2653        assert!(output.contains("an S4 generic"));
2654        assert!(output.contains("\\link{S4Counter}"));
2655        // S4 export uses @exportMethod
2656        assert!(output.contains("#' @exportMethod s4_compute"));
2657        // setGeneric guard must survive
2658        assert!(output.contains("if (!methods::isGeneric(\"s4_compute\")"));
2659    }
2660
2661    // endregion
2662
2663    // region: duplicate wrapper-definition detection (#991)
2664
2665    /// Build an accessor-producer map the way `write_r_wrappers_to_file` does,
2666    /// from synthetic sidecar + class registries, for unit testing.
2667    fn accessor_map(
2668        sidecar: &[SidecarPropEntry],
2669        classes: &[ClassNameEntry],
2670    ) -> std::collections::HashMap<String, String> {
2671        let class_index: std::collections::HashMap<&str, &ClassNameEntry> =
2672            classes.iter().map(|e| (e.rust_type, e)).collect();
2673        sidecar_accessor_names(sidecar, &class_index)
2674    }
2675
2676    #[test]
2677    fn parse_top_level_fn_def_name_accepts_bare_def() {
2678        assert_eq!(
2679            parse_top_level_fn_def_name("Foo_get_value <- function(x) .Call(C, x)"),
2680            Some("Foo_get_value")
2681        );
2682        // Dotted/internal helper names are valid R identifiers.
2683        assert_eq!(
2684            parse_top_level_fn_def_name(".miniextendr_raise_condition <- function(.val) {"),
2685            Some(".miniextendr_raise_condition")
2686        );
2687    }
2688
2689    #[test]
2690    fn parse_top_level_fn_def_name_rejects_non_defs() {
2691        // Indented closures (R6 methods, S7 getters) are not top-level wrappers.
2692        assert_eq!(
2693            parse_top_level_fn_def_name("    get_value = function(self) self$x"),
2694            None
2695        );
2696        // S7::method(...) <- function : LHS is a call, not a bare ident.
2697        assert_eq!(
2698            parse_top_level_fn_def_name("S7::method(get_value, Shape) <- function(x) .Call(C, x)"),
2699            None
2700        );
2701        // `$`-assignment target is not a bare ident.
2702        assert_eq!(
2703            parse_top_level_fn_def_name("obj$method <- function() {}"),
2704            None
2705        );
2706        // Not a function definition at all.
2707        assert_eq!(parse_top_level_fn_def_name("x <- 1L"), None);
2708        // Comment / arbitrary line.
2709        assert_eq!(parse_top_level_fn_def_name("# a comment"), None);
2710    }
2711
2712    #[test]
2713    fn detect_duplicate_clean_content_passes() {
2714        // A sidecar accessor and an unrelated S7 shortcut: distinct names, no clash.
2715        let content = concat!(
2716            "Shape_get_area <- function(x) .Call(C_get, x)\n",
2717            "Shape_set_area <- function(x, value) { .Call(C_set, x, value); invisible(x) }\n",
2718            "Shape_describe <- function(x, ...) .Call(C_describe, x)\n",
2719        );
2720        let map = accessor_map(
2721            &[SidecarPropEntry {
2722                rust_type: "Shape",
2723                field_name: "area",
2724                prop_doc: "",
2725            }],
2726            &[ClassNameEntry {
2727                rust_type: "Shape",
2728                r_class_name: "Shape",
2729                class_system: "s7",
2730            }],
2731        );
2732        assert!(detect_duplicate_wrapper_defs(content, &map).is_ok());
2733    }
2734
2735    #[test]
2736    fn detect_duplicate_shortcut_vs_sidecar_accessor_fails() {
2737        // S7 method `get_area` emits `Shape_get_area`, colliding with the sidecar
2738        // accessor for field `area`. This is the core #991 case.
2739        let content = concat!(
2740            "Shape_get_area <- function(x) .Call(C_get, x)\n",
2741            "Shape_set_area <- function(x, value) { .Call(C_set, x, value); invisible(x) }\n",
2742            "Shape_get_area <- function(x, ...) .Call(C_method, x)\n",
2743        );
2744        let map = accessor_map(
2745            &[SidecarPropEntry {
2746                rust_type: "Shape",
2747                field_name: "area",
2748                prop_doc: "",
2749            }],
2750            &[ClassNameEntry {
2751                rust_type: "Shape",
2752                r_class_name: "Shape",
2753                class_system: "s7",
2754            }],
2755        );
2756        let err = detect_duplicate_wrapper_defs(content, &map).unwrap_err();
2757        assert!(err.contains("Shape_get_area"), "msg: {err}");
2758        // Message must name the sidecar producer and the field.
2759        assert!(err.contains("sidecar field `area`"), "msg: {err}");
2760        assert!(err.contains("Shape"), "msg: {err}");
2761    }
2762
2763    #[test]
2764    fn detect_duplicate_honours_class_override_prefix() {
2765        // The impl block set `class = "Shape"` on Rust type `S7Shape`, so the
2766        // sidecar accessor is `Shape_get_area`, not `S7Shape_get_area`. The
2767        // collision must be attributed via the override prefix.
2768        let content = concat!(
2769            "Shape_get_area <- function(x) .Call(C_get, x)\n",
2770            "Shape_get_area <- function(x, ...) .Call(C_method, x)\n",
2771        );
2772        let map = accessor_map(
2773            &[SidecarPropEntry {
2774                rust_type: "S7Shape",
2775                field_name: "area",
2776                prop_doc: "",
2777            }],
2778            &[ClassNameEntry {
2779                rust_type: "S7Shape",
2780                r_class_name: "Shape",
2781                class_system: "s7",
2782            }],
2783        );
2784        let err = detect_duplicate_wrapper_defs(content, &map).unwrap_err();
2785        assert!(err.contains("Shape_get_area"), "msg: {err}");
2786        assert!(err.contains("sidecar field `area`"), "msg: {err}");
2787    }
2788
2789    #[test]
2790    fn detect_duplicate_cross_impl_block_generic_message() {
2791        // Two S7 impl blocks both emit `Counter_inc` -- no sidecar involved, so
2792        // the generic "more than once" message is used.
2793        let content = concat!(
2794            "Counter_inc <- function(x, ...) .Call(C_inc_a, x)\n",
2795            "Counter_inc <- function(x, ...) .Call(C_inc_b, x)\n",
2796        );
2797        let map = std::collections::HashMap::new();
2798        let err = detect_duplicate_wrapper_defs(content, &map).unwrap_err();
2799        assert!(err.contains("Counter_inc"), "msg: {err}");
2800        assert!(err.contains("defined more than once"), "msg: {err}");
2801    }
2802
2803    #[test]
2804    fn detect_duplicate_ignores_repeated_s7_method_assignments() {
2805        // `S7::method(...) <- function` lines are NOT top-level bare defs; a class
2806        // may legitimately have many method assignments. These must never trip.
2807        let content = concat!(
2808            "S7::method(describe, Shape) <- function(x) .Call(C1, x)\n",
2809            "S7::method(area, Shape) <- function(x) .Call(C2, x)\n",
2810            "S7::method(describe, Circle) <- function(x) .Call(C3, x)\n",
2811        );
2812        let map = std::collections::HashMap::new();
2813        assert!(detect_duplicate_wrapper_defs(content, &map).is_ok());
2814    }
2815
2816    // endregion
2817}
2818// endregion