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