Skip to main content

miniextendr_api/
wasm_registry_writer.rs

1//! Host-time generator of `wasm_registry.rs` — the WASM-side replacement for
2//! `linkme`'s runtime distributed-slice gather.
3//!
4//! On native installs, `tools/write-wrappers.R` loads the linked package `.so`, which runs
5//! [`crate::wasm_registry_writer::write_wasm_registry_to_file`] to emit
6//! Rust source listing every `MX_CALL_DEFS` / `MX_ALTREP_REGISTRATIONS` /
7//! `MX_TRAIT_DISPATCH` entry as `extern "C-unwind" {}` declarations + ordinary
8//! `&[T]` static slices, plus the crate's `MX_CONVERSION_ERROR_CLASS` classes.
9//! On `wasm32-*` targets, the user crate compiles that
10//! file in place of the linkme distributed_slices (`miniextendr_init!` emits
11//! the wasm32-gated `mod __miniextendr_wasm_registry;` that includes it).
12//!
13//! The writer is intentionally pure-text-formatting — no `syn`, no
14//! `proc-macro2`, no template engine. Registration rows are sorted so linker
15//! table order does not affect the output; conversion-error classes retain
16//! their dispatch precedence. The generated `wasm_registry.rs` is
17//! **gitignored** (regenerated on every host `R CMD INSTALL`); determinism is
18//! what lets `just r-cmd-build` reproduce it into the tarball and lets CI
19//! assert a non-stub snapshot rather than git-diffing a committed copy.
20
21use crate::abi::mx_tag;
22use crate::registry::{
23    AltrepRegistration, MX_ALTREP_REGISTRATIONS, MX_CALL_DEFS, MX_TRAIT_DISPATCH,
24    TraitDispatchEntry,
25};
26use std::ffi::CStr;
27use std::fmt::Write as _;
28
29// Bumped whenever the generated-file shape changes in a way the receiving
30// `build.rs` version check must reject (e.g. registry struct fields renamed
31// so an old file no longer compiles). Cosmetic changes — import lines,
32// qualified paths, formatting — are NOT part of the checked contract and
33// don't warrant a bump (#1307). The receiving `build.rs` (step 5) refuses
34// to compile a `wasm_registry.rs` whose header doesn't match.
35//
36// 2: `MX_CONVERSION_ERROR_CLASS_WASM`, which `miniextendr_init!` passes to
37//    `install_wasm_runtime_slices` (#1768).
38// 3: ALTREP registration function pointers use the C-unwind ABI.
39// 4: Trait vtable symbols preserve crate, trait and type spelling (#1778).
40const GENERATOR_VERSION: u32 = 4;
41
42/// Pre-extracted, host-side view of one `R_CallMethodDef`.
43///
44/// `R_CallMethodDef` carries `name` as a raw `*const c_char`; safely walking
45/// it requires `unsafe`. The formatter takes already-extracted, owned values
46/// so it can be unit-tested without globals.
47pub struct CallDefRow {
48    pub name: String,
49    pub num_args: i32,
50}
51
52/// Pre-extracted view of one `MX_ALTREP_REGISTRATIONS` entry.
53pub struct AltrepRegRow {
54    pub symbol: String,
55}
56
57/// Pre-extracted view of one `MX_TRAIT_DISPATCH` entry.
58pub struct TraitDispatchRow {
59    pub concrete_tag: mx_tag,
60    pub trait_tag: mx_tag,
61    pub vtable_symbol: String,
62}
63
64/// Format a `wasm_registry.rs` source file from extracted runtime data.
65///
66/// Output structure:
67/// ```text
68/// // header (auto-generated marker, generator-version, content-hash)
69/// use ...;
70/// unsafe extern "C-unwind" { fn <wrapper>(...); ... }
71/// unsafe extern "C-unwind" { fn <altrep_reg>(); ... static <vtable>: u8; ... }
72/// pub static MX_CALL_DEFS_WASM: &[R_CallMethodDef] = &[ ... ];
73/// pub static MX_ALTREP_REGISTRATIONS_WASM: &[AltrepRegistration] = &[ ... ];
74/// pub static MX_TRAIT_DISPATCH_WASM: &[TraitDispatchEntry] = &[ ... ];
75/// pub static MX_CONVERSION_ERROR_CLASS_WASM: &[&str] = &[ ... ];
76/// ```
77///
78/// Every fn / static referenced from a slice gets a matching `extern` decl —
79/// the WASM linker resolves them against the user crate's `#[no_mangle]`
80/// exports. `conversion_error_class` is the crate's `conversion_error_class`
81/// (the `MX_CONVERSION_ERROR_CLASS` entry), which the raising guards resolve
82/// an `arg_error!`'s classes from (#1768).
83pub fn format_wasm_registry(
84    call_defs: &[CallDefRow],
85    altrep_regs: &[AltrepRegRow],
86    trait_dispatches: &[TraitDispatchRow],
87    conversion_error_class: &[&str],
88) -> String {
89    // Linkme does not promise a stable order across linking configurations.
90    // Sort borrowed rows without cloning their owned symbol names. Condition
91    // classes are deliberately not sorted: R dispatch uses their input order.
92    let mut call_defs: Vec<_> = call_defs.iter().collect();
93    call_defs.sort_unstable_by(|a, b| (&a.name, a.num_args).cmp(&(&b.name, b.num_args)));
94    let mut altrep_regs: Vec<_> = altrep_regs.iter().collect();
95    altrep_regs.sort_unstable_by(|a, b| a.symbol.cmp(&b.symbol));
96    let mut trait_dispatches: Vec<_> = trait_dispatches.iter().collect();
97    trait_dispatches.sort_unstable_by_key(|row| {
98        (
99            &row.vtable_symbol,
100            row.concrete_tag.lo,
101            row.concrete_tag.hi,
102            row.trait_tag.lo,
103            row.trait_tag.hi,
104        )
105    });
106
107    let body = format_body(
108        &call_defs,
109        &altrep_regs,
110        &trait_dispatches,
111        conversion_error_class,
112    );
113    let content_hash = fnv1a_64(body.as_bytes());
114
115    let mut out = String::new();
116    (&mut out).write_fmt(format_args!("// AUTO-GENERATED — DO NOT EDIT.\n"))writeln!(&mut out, "// AUTO-GENERATED — DO NOT EDIT.").unwrap();
117    (&mut out).write_fmt(format_args!("//\n"))writeln!(&mut out, "//").unwrap();
118    (&mut out).write_fmt(format_args!("// Produced on host by `miniextendr_write_wasm_registry`. Compiled on\n"))writeln!(
119        &mut out,
120        "// Produced on host by `miniextendr_write_wasm_registry`. Compiled on"
121    )
122    .unwrap();
123    (&mut out).write_fmt(format_args!("// wasm32-* targets in place of the linkme distributed_slices.\n"))writeln!(
124        &mut out,
125        "// wasm32-* targets in place of the linkme distributed_slices."
126    )
127    .unwrap();
128    (&mut out).write_fmt(format_args!("//\n"))writeln!(&mut out, "//").unwrap();
129    (&mut out).write_fmt(format_args!("// generator-version: {0}\n",
        GENERATOR_VERSION))writeln!(&mut out, "// generator-version: {GENERATOR_VERSION}").unwrap();
130    (&mut out).write_fmt(format_args!("// content-hash:      {0:016x}\n",
        content_hash))writeln!(&mut out, "// content-hash:      {content_hash:016x}").unwrap();
131    (&mut out).write_fmt(format_args!("\n"))writeln!(&mut out).unwrap();
132    out.push_str(&body);
133    out
134}
135
136fn format_body(
137    call_defs: &[&CallDefRow],
138    altrep_regs: &[&AltrepRegRow],
139    trait_dispatches: &[&TraitDispatchRow],
140    conversion_error_class: &[&str],
141) -> String {
142    let mut out = String::new();
143
144    // `mx_tag` and `c_void` are referenced only by trait-dispatch entries and
145    // deliberately NOT imported here: a minimal package with an empty dispatch
146    // table would get `unused_imports` warnings on wasm32 (#1307). The
147    // dispatch entries spell both fully qualified instead. `SEXP` appears only
148    // in the call-table entries, so it is imported only when there are some.
149    if !call_defs.is_empty() {
150        (&mut out).write_fmt(format_args!("use ::miniextendr_api::SEXP;\n"))writeln!(&mut out, "use ::miniextendr_api::SEXP;").unwrap();
151    }
152    (&mut out).write_fmt(format_args!("use ::miniextendr_api::sys::R_CallMethodDef;\n"))writeln!(&mut out, "use ::miniextendr_api::sys::R_CallMethodDef;").unwrap();
153    (&mut out).write_fmt(format_args!("use ::miniextendr_api::registry::{{AltrepRegistration, TraitDispatchEntry}};\n"))writeln!(
154        &mut out,
155        "use ::miniextendr_api::registry::{{AltrepRegistration, TraitDispatchEntry}};"
156    )
157    .unwrap();
158    (&mut out).write_fmt(format_args!("\n"))writeln!(&mut out).unwrap();
159
160    format_extern_unwind_block(&mut out, call_defs);
161    format_extern_registration_block(&mut out, altrep_regs, trait_dispatches);
162    format_call_defs_slice(&mut out, call_defs);
163    format_altrep_regs_slice(&mut out, altrep_regs);
164    format_trait_dispatch_slice(&mut out, trait_dispatches);
165    format_conversion_error_class_slice(&mut out, conversion_error_class);
166
167    out
168}
169
170fn format_extern_unwind_block(out: &mut String, call_defs: &[&CallDefRow]) {
171    out.write_fmt(format_args!("unsafe extern \"C-unwind\" {{\n"))writeln!(out, "unsafe extern \"C-unwind\" {{").unwrap();
172    for row in call_defs {
173        let params = sexp_param_list(row.num_args);
174        out.write_fmt(format_args!("    pub fn {0}({1}) -> SEXP;\n", row.name,
        params))writeln!(out, "    pub fn {}({params}) -> SEXP;", row.name).unwrap();
175    }
176    out.write_fmt(format_args!("}}\n"))writeln!(out, "}}").unwrap();
177    out.write_fmt(format_args!("\n"))writeln!(out).unwrap();
178}
179
180fn format_extern_registration_block(
181    out: &mut String,
182    altrep_regs: &[&AltrepRegRow],
183    trait_dispatches: &[&TraitDispatchRow],
184) {
185    out.write_fmt(format_args!("unsafe extern \"C-unwind\" {{\n"))writeln!(out, "unsafe extern \"C-unwind\" {{").unwrap();
186    // ALTREP register fns get the 2024-edition `safe` qualifier so the fn
187    // type stays `extern "C-unwind" fn()` (not `unsafe extern "C-unwind" fn()`), allowing
188    // direct assignment to `AltrepRegistration.register`. The dispatcher
189    // calls them on R's main thread inside the package-init unwind boundary.
190    for row in altrep_regs {
191        out.write_fmt(format_args!("    pub safe fn {0}();\n", row.symbol))writeln!(out, "    pub safe fn {}();", row.symbol).unwrap();
192    }
193    // Vtable shape is opaque from wasm_registry.rs' perspective — we only
194    // need the address. Declaring as `u8` is the convention used in the
195    // plan sketch and keeps the file independent of trait-specific types.
196    for row in trait_dispatches {
197        out.write_fmt(format_args!("    #[allow(non_upper_case_globals)]\n"))writeln!(out, "    #[allow(non_upper_case_globals)]").unwrap();
198        out.write_fmt(format_args!("    pub static {0}: u8;\n", row.vtable_symbol))writeln!(out, "    pub static {}: u8;", row.vtable_symbol).unwrap();
199    }
200    out.write_fmt(format_args!("}}\n"))writeln!(out, "}}").unwrap();
201    out.write_fmt(format_args!("\n"))writeln!(out).unwrap();
202}
203
204fn format_call_defs_slice(out: &mut String, call_defs: &[&CallDefRow]) {
205    out.write_fmt(format_args!("pub static MX_CALL_DEFS_WASM: &[R_CallMethodDef] = &[\n"))writeln!(out, "pub static MX_CALL_DEFS_WASM: &[R_CallMethodDef] = &[").unwrap();
206    for row in call_defs {
207        // The transmute target signature here is positional-only — Rust's
208        // `extern fn` *type* doesn't carry parameter names, only types — so
209        // it differs from the `extern { fn ... }` declaration form, which
210        // does require named or `_`-bound parameters.
211        let arg_types = sexp_type_list(row.num_args);
212        out.write_fmt(format_args!("    R_CallMethodDef {{\n"))writeln!(out, "    R_CallMethodDef {{").unwrap();
213        out.write_fmt(format_args!("        name: c\"{0}\".as_ptr(),\n", row.name))writeln!(out, "        name: c\"{}\".as_ptr(),", row.name).unwrap();
214        out.write_fmt(format_args!("        fun: Some(unsafe {{ ::core::mem::transmute::<unsafe extern \"C-unwind\" fn({1}) -> SEXP, _>({0}) }}),\n",
        row.name, arg_types))writeln!(
215            out,
216            "        fun: Some(unsafe {{ ::core::mem::transmute::<unsafe extern \"C-unwind\" fn({arg_types}) -> SEXP, _>({}) }}),",
217            row.name
218        )
219        .unwrap();
220        out.write_fmt(format_args!("        numArgs: {0},\n", row.num_args))writeln!(out, "        numArgs: {},", row.num_args).unwrap();
221        out.write_fmt(format_args!("    }},\n"))writeln!(out, "    }},").unwrap();
222    }
223    out.write_fmt(format_args!("];\n"))writeln!(out, "];").unwrap();
224    out.write_fmt(format_args!("\n"))writeln!(out).unwrap();
225}
226
227fn format_altrep_regs_slice(out: &mut String, altrep_regs: &[&AltrepRegRow]) {
228    out.write_fmt(format_args!("pub static MX_ALTREP_REGISTRATIONS_WASM: &[AltrepRegistration] = &[\n"))writeln!(
229        out,
230        "pub static MX_ALTREP_REGISTRATIONS_WASM: &[AltrepRegistration] = &["
231    )
232    .unwrap();
233    for row in altrep_regs {
234        out.write_fmt(format_args!("    AltrepRegistration {{\n"))writeln!(out, "    AltrepRegistration {{").unwrap();
235        out.write_fmt(format_args!("        register: {0},\n", row.symbol))writeln!(out, "        register: {},", row.symbol).unwrap();
236        out.write_fmt(format_args!("        symbol: {0:?},\n", row.symbol))writeln!(out, "        symbol: {:?},", row.symbol).unwrap();
237        out.write_fmt(format_args!("    }},\n"))writeln!(out, "    }},").unwrap();
238    }
239    out.write_fmt(format_args!("];\n"))writeln!(out, "];").unwrap();
240    out.write_fmt(format_args!("\n"))writeln!(out).unwrap();
241}
242
243fn format_trait_dispatch_slice(out: &mut String, trait_dispatches: &[&TraitDispatchRow]) {
244    out.write_fmt(format_args!("pub static MX_TRAIT_DISPATCH_WASM: &[TraitDispatchEntry] = &[\n"))writeln!(
245        out,
246        "pub static MX_TRAIT_DISPATCH_WASM: &[TraitDispatchEntry] = &["
247    )
248    .unwrap();
249    for row in trait_dispatches {
250        out.write_fmt(format_args!("    TraitDispatchEntry {{\n"))writeln!(out, "    TraitDispatchEntry {{").unwrap();
251        // `mx_tag` / `c_void` are fully qualified (no header import) so
252        // dispatch-free files carry no unused imports — see format_body.
253        out.write_fmt(format_args!("        concrete_tag: ::miniextendr_api::abi::mx_tag::new(0x{0:016x}, 0x{1:016x}),\n",
        row.concrete_tag.lo, row.concrete_tag.hi))writeln!(
254            out,
255            "        concrete_tag: ::miniextendr_api::abi::mx_tag::new(0x{:016x}, 0x{:016x}),",
256            row.concrete_tag.lo, row.concrete_tag.hi
257        )
258        .unwrap();
259        out.write_fmt(format_args!("        trait_tag: ::miniextendr_api::abi::mx_tag::new(0x{0:016x}, 0x{1:016x}),\n",
        row.trait_tag.lo, row.trait_tag.hi))writeln!(
260            out,
261            "        trait_tag: ::miniextendr_api::abi::mx_tag::new(0x{:016x}, 0x{:016x}),",
262            row.trait_tag.lo, row.trait_tag.hi
263        )
264        .unwrap();
265        out.write_fmt(format_args!("        vtable: unsafe {{ ::core::ptr::from_ref(&{0}).cast::<::core::ffi::c_void>() }},\n",
        row.vtable_symbol))writeln!(
266            out,
267            "        vtable: unsafe {{ ::core::ptr::from_ref(&{}).cast::<::core::ffi::c_void>() }},",
268            row.vtable_symbol
269        )
270        .unwrap();
271        out.write_fmt(format_args!("        vtable_symbol: {0:?},\n",
        row.vtable_symbol))writeln!(out, "        vtable_symbol: {:?},", row.vtable_symbol).unwrap();
272        out.write_fmt(format_args!("    }},\n"))writeln!(out, "    }},").unwrap();
273    }
274    out.write_fmt(format_args!("];\n"))writeln!(out, "];").unwrap();
275    out.write_fmt(format_args!("\n"))writeln!(out).unwrap();
276}
277
278fn format_conversion_error_class_slice(out: &mut String, classes: &[&str]) {
279    out.write_fmt(format_args!("pub static MX_CONVERSION_ERROR_CLASS_WASM: &[&str] = &[\n"))writeln!(
280        out,
281        "pub static MX_CONVERSION_ERROR_CLASS_WASM: &[&str] = &["
282    )
283    .unwrap();
284    for class in classes {
285        // `{:?}` renders a valid Rust string literal (quotes, backslashes and
286        // control characters escaped).
287        out.write_fmt(format_args!("    {0:?},\n", class))writeln!(out, "    {class:?},").unwrap();
288    }
289    out.write_fmt(format_args!("];\n"))writeln!(out, "];").unwrap();
290}
291
292/// Comma-joined `_: SEXP` parameter list for an `extern { fn ...; }` decl.
293///
294/// Extern fn declarations require parameter bindings — bare `SEXP, SEXP`
295/// is parsed as pattern-typed which fails. Each slot is `_: SEXP`.
296fn sexp_param_list(num_args: i32) -> String {
297    if num_args <= 0 {
298        return String::new();
299    }
300    std::iter::repeat_n("_: SEXP", num_args as usize)
301        .collect::<Vec<_>>()
302        .join(", ")
303}
304
305/// Comma-joined `SEXP` type list for an `extern fn(...)` *type* expression
306/// (e.g. inside `transmute::<...>`). Function pointer types don't carry
307/// parameter names, so `_:` would be invalid here.
308fn sexp_type_list(num_args: i32) -> String {
309    if num_args <= 0 {
310        return String::new();
311    }
312    std::iter::repeat_n("SEXP", num_args as usize)
313        .collect::<Vec<_>>()
314        .join(", ")
315}
316
317/// FNV-1a 64-bit hash. Matches the implementation in
318/// `miniextendr-macros/src/miniextendr_impl_trait.rs::type_to_symbol_name`
319/// so a future build.rs check can recompute it portably.
320fn fnv1a_64(data: &[u8]) -> u64 {
321    const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
322    const PRIME: u64 = 0x00000100000001b3;
323    let mut h = OFFSET_BASIS;
324    for &b in data {
325        h ^= b as u64;
326        h = h.wrapping_mul(PRIME);
327    }
328    h
329}
330
331/// Read the live linkme distributed slices and return rows safe to pass to
332/// [`format_wasm_registry`].
333fn read_runtime_slices() -> (Vec<CallDefRow>, Vec<AltrepRegRow>, Vec<TraitDispatchRow>) {
334    let call_defs: Vec<CallDefRow> = MX_CALL_DEFS
335        .iter()
336        .map(|d| {
337            // SAFETY: every emission site sets `name` from a static CStr literal
338            // (see `c_wrapper_builder.rs` and friends), so the pointer is valid
339            // for the program lifetime and points to a NUL-terminated UTF-8
340            // ASCII string.
341            let name = unsafe { CStr::from_ptr(d.name) }
342                .to_str()
343                .expect("MX_CALL_DEFS.name is not valid UTF-8")
344                .to_string();
345            CallDefRow {
346                name,
347                num_args: d.numArgs,
348            }
349        })
350        .collect();
351
352    let altrep_regs: Vec<AltrepRegRow> = MX_ALTREP_REGISTRATIONS
353        .iter()
354        .map(|r: &AltrepRegistration| AltrepRegRow {
355            symbol: r.symbol.to_string(),
356        })
357        .collect();
358
359    let trait_dispatches: Vec<TraitDispatchRow> = MX_TRAIT_DISPATCH
360        .iter()
361        .map(|t: &TraitDispatchEntry| TraitDispatchRow {
362            concrete_tag: t.concrete_tag,
363            trait_tag: t.trait_tag,
364            vtable_symbol: t.vtable_symbol.to_string(),
365        })
366        .collect();
367
368    (call_defs, altrep_regs, trait_dispatches)
369}
370
371/// Read the live distributed slices, format `wasm_registry.rs`, and write it
372/// to `path`. No-op when content is unchanged (matches `write_r_wrappers_to_file`).
373pub fn write_wasm_registry_to_file(path: &str) {
374    let (call_defs, altrep_regs, trait_dispatches) = read_runtime_slices();
375    let content = format_wasm_registry(
376        &call_defs,
377        &altrep_regs,
378        &trait_dispatches,
379        crate::registry::conversion_error_class(),
380    );
381
382    let existing = std::fs::read_to_string(path).unwrap_or_default();
383    if existing == content {
384        return;
385    }
386
387    // Write via a sibling temp file then rename for atomicity.
388    //
389    // Matches the strategy in `write_r_wrappers_to_file`: `rename(2)` is atomic
390    // on POSIX, so concurrent readers always see a complete file. On Windows,
391    // `rename` fails when the destination exists, so we remove it first.
392    let dest = std::path::Path::new(path);
393    let tmp = dest.with_extension("tmp");
394    std::fs::write(&tmp, content.as_bytes())
395        .unwrap_or_else(|e| {
    ::core::panicking::panic_fmt(format_args!("failed to write {0}: {1}",
            tmp.display(), e));
}panic!("failed to write {}: {e}", tmp.display()));
396    #[cfg(windows)]
397    let _ = std::fs::remove_file(dest);
398    std::fs::rename(&tmp, dest).unwrap_or_else(|e| {
399        {
    ::core::panicking::panic_fmt(format_args!("failed to rename {0} → {1}: {2}",
            tmp.display(), dest.display(), e));
}panic!(
400            "failed to rename {} → {}: {e}",
401            tmp.display(),
402            dest.display()
403        )
404    });
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    const CLASSES: &[&str] = &["pkg_error_argument", "pkg_error"];
412
413    fn sample_inputs() -> (Vec<CallDefRow>, Vec<AltrepRegRow>, Vec<TraitDispatchRow>) {
414        let call_defs = vec![
415            CallDefRow {
416                name: "miniextendr_my_fn".into(),
417                num_args: 0,
418            },
419            CallDefRow {
420                name: "miniextendr_other".into(),
421                num_args: 3,
422            },
423        ];
424        let altrep_regs = vec![AltrepRegRow {
425            symbol: "__mx_altrep_reg_MyType".into(),
426        }];
427        let trait_dispatches = vec![TraitDispatchRow {
428            concrete_tag: mx_tag::new(0xdead_beef_dead_beef, 0x1234_5678_1234_5678),
429            trait_tag: mx_tag::new(0xcafe_babe_cafe_babe, 0xfeed_face_feed_face),
430            vtable_symbol: "__VTABLE_mypkg_Counter_FOR_MyType".into(),
431        }];
432        (call_defs, altrep_regs, trait_dispatches)
433    }
434
435    #[test]
436    fn case_distinct_vtables_keep_separate_externs_and_dispatch_rows() {
437        let upper = TraitDispatchRow {
438            concrete_tag: mx_tag::new(1, 2),
439            trait_tag: mx_tag::new(3, 4),
440            vtable_symbol: "__VTABLE_mypkg_Counter_FOR_Foo".into(),
441        };
442        let lower = TraitDispatchRow {
443            concrete_tag: upper.concrete_tag,
444            trait_tag: mx_tag::new(5, 6),
445            vtable_symbol: "__VTABLE_mypkg_counter_FOR_Foo".into(),
446        };
447        let out = format_wasm_registry(&[], &[], &[upper, lower], &[]);
448        for symbol in [
449            "__VTABLE_mypkg_Counter_FOR_Foo",
450            "__VTABLE_mypkg_counter_FOR_Foo",
451        ] {
452            assert!(out.contains(&format!(
453                "#[allow(non_upper_case_globals)]\n    pub static {symbol}: u8;"
454            )));
455            assert!(out.contains(&format!("from_ref(&{symbol})")));
456        }
457    }
458
459    #[test]
460    fn header_carries_generator_version_and_content_hash() {
461        let (a, b, c) = sample_inputs();
462        let out = format_wasm_registry(&a, &b, &c, CLASSES);
463        assert!(
464            out.contains(&format!("generator-version: {GENERATOR_VERSION}")),
465            "expected generator-version line; got:\n{out}"
466        );
467        assert!(
468            out.contains("content-hash:      "),
469            "expected content-hash line; got:\n{out}"
470        );
471    }
472
473    #[test]
474    fn content_hash_is_deterministic() {
475        let (a, b, c) = sample_inputs();
476        let first = format_wasm_registry(&a, &b, &c, CLASSES);
477        let second = format_wasm_registry(&a, &b, &c, CLASSES);
478        assert_eq!(first, second);
479    }
480
481    #[test]
482    fn registration_order_does_not_change_snapshot() {
483        let (mut calls, mut altreps, mut dispatches) = sample_inputs();
484        altreps.push(AltrepRegRow {
485            symbol: "__mx_altrep_reg_AnotherType".into(),
486        });
487        dispatches.push(TraitDispatchRow {
488            concrete_tag: mx_tag::new(1, 2),
489            trait_tag: mx_tag::new(3, 4),
490            vtable_symbol: "__VTABLE_ANOTHER".into(),
491        });
492        let expected = format_wasm_registry(&calls, &altreps, &dispatches, CLASSES);
493        calls.reverse();
494        altreps.reverse();
495        dispatches.reverse();
496        assert_eq!(
497            expected,
498            format_wasm_registry(&calls, &altreps, &dispatches, CLASSES)
499        );
500    }
501
502    #[test]
503    fn conversion_error_class_precedence_is_preserved() {
504        let (calls, altreps, dispatches) = sample_inputs();
505        let forward = format_wasm_registry(&calls, &altreps, &dispatches, CLASSES);
506        let reversed =
507            format_wasm_registry(&calls, &altreps, &dispatches, &[CLASSES[1], CLASSES[0]]);
508        assert_ne!(forward, reversed);
509        assert!(
510            forward.find("\"pkg_error_argument\"").unwrap()
511                < forward.find("\"pkg_error\"").unwrap()
512        );
513    }
514
515    #[test]
516    fn content_hash_changes_when_body_changes() {
517        let (a, b, c) = sample_inputs();
518        let (mut a2, _, _) = sample_inputs();
519        a2.push(CallDefRow {
520            name: "different_fn".into(),
521            num_args: 1,
522        });
523        let first = format_wasm_registry(&a, &b, &c, CLASSES);
524        let second = format_wasm_registry(&a2, &b, &c, CLASSES);
525        assert_ne!(first, second);
526    }
527
528    #[test]
529    fn emits_extern_decls_for_every_referenced_symbol() {
530        let (a, b, c) = sample_inputs();
531        let out = format_wasm_registry(&a, &b, &c, CLASSES);
532        // call wrappers in the C-unwind block — params bound to `_` so the
533        // declaration parses (extern fn decls require parameter bindings).
534        assert!(out.contains("pub fn miniextendr_my_fn() -> SEXP;"));
535        assert!(out.contains("pub fn miniextendr_other(_: SEXP, _: SEXP, _: SEXP) -> SEXP;"));
536        // ALTREP registrations + vtables in the registration block
537        assert!(out.contains("pub safe fn __mx_altrep_reg_MyType();"));
538        assert!(out.contains("pub static __VTABLE_mypkg_Counter_FOR_MyType: u8;"));
539    }
540
541    #[test]
542    fn emits_named_slice_constants() {
543        let (a, b, c) = sample_inputs();
544        let out = format_wasm_registry(&a, &b, &c, CLASSES);
545        assert!(out.contains("pub static MX_CALL_DEFS_WASM: &[R_CallMethodDef]"));
546        assert!(out.contains("pub static MX_ALTREP_REGISTRATIONS_WASM: &[AltrepRegistration]"));
547        assert!(out.contains("pub static MX_TRAIT_DISPATCH_WASM: &[TraitDispatchEntry]"));
548        assert!(out.contains("pub static MX_CONVERSION_ERROR_CLASS_WASM: &[&str]"));
549    }
550
551    /// #1768: the crate's `conversion_error_class` crosses to wasm32 in the
552    /// snapshot, in order, as Rust string literals.
553    #[test]
554    fn emits_conversion_error_class_literals() {
555        let (a, b, c) = sample_inputs();
556        let out = format_wasm_registry(&a, &b, &c, &["pkg_error_argument", r#"odd"\class"#]);
557        assert!(
558            out.contains(
559                "pub static MX_CONVERSION_ERROR_CLASS_WASM: &[&str] = &[\n    \
560                 \"pkg_error_argument\",\n    \"odd\\\"\\\\class\",\n];"
561            ),
562            "expected the escaped class literals in order; got:\n{out}"
563        );
564    }
565
566    #[test]
567    fn conversion_error_class_changes_the_content_hash() {
568        let (a, b, c) = sample_inputs();
569        assert_ne!(
570            format_wasm_registry(&a, &b, &c, CLASSES),
571            format_wasm_registry(&a, &b, &c, &[])
572        );
573    }
574
575    #[test]
576    fn renders_mx_tag_with_const_constructor() {
577        let (a, b, c) = sample_inputs();
578        let out = format_wasm_registry(&a, &b, &c, CLASSES);
579        assert!(
580            out.contains(
581                "::miniextendr_api::abi::mx_tag::new(0xdeadbeefdeadbeef, 0x1234567812345678)"
582            ),
583            "expected fully-qualified concrete_tag literal; got:\n{out}"
584        );
585        assert!(
586            out.contains(
587                "::miniextendr_api::abi::mx_tag::new(0xcafebabecafebabe, 0xfeedfacefeedface)"
588            ),
589            "expected fully-qualified trait_tag literal; got:\n{out}"
590        );
591    }
592
593    /// #1307: a minimal package (call defs only — no trait impls, no ALTREP)
594    /// produces an empty dispatch table; the generated file must not carry
595    /// imports that only dispatch entries would use, or wasm32 `cargo check`
596    /// reports `unused_imports` on every build.
597    #[test]
598    fn dispatch_free_output_has_no_dispatch_only_imports() {
599        let (call_defs, _, _) = sample_inputs();
600        let out = format_wasm_registry(&call_defs, &[], &[], &[]);
601        assert!(
602            !out.contains("mx_tag"),
603            "dispatch-free output must not mention mx_tag; got:\n{out}"
604        );
605        assert!(
606            !out.contains("c_void"),
607            "dispatch-free output must not mention c_void; got:\n{out}"
608        );
609    }
610
611    /// Every emitted `use` line must be referenced in the body that follows —
612    /// the generalized no-unused-imports property for the minimal-package
613    /// shape (#1307). Imports are single-segment or `{A, B}` groups; each
614    /// imported ident must reappear after the import block.
615    #[test]
616    fn every_import_is_used_in_dispatch_free_output() {
617        let (call_defs, _, _) = sample_inputs();
618        let out = format_wasm_registry(&call_defs, &[], &[], &[]);
619        let (imports, rest): (Vec<&str>, Vec<&str>) =
620            out.lines().partition(|l| l.starts_with("use "));
621        assert!(!imports.is_empty(), "output has imports; got:\n{out}");
622        let body = rest.join("\n");
623        for line in imports {
624            let idents = line
625                .trim_start_matches("use ")
626                .trim_end_matches(';')
627                .rsplit("::")
628                .next()
629                .unwrap()
630                .trim_matches(['{', '}'])
631                .split(',')
632                .map(str::trim);
633            for ident in idents {
634                assert!(
635                    body.contains(ident),
636                    "import `{ident}` from `{line}` is unused; got:\n{out}"
637                );
638            }
639        }
640    }
641
642    /// With dispatch entries present, the emitted entries must reference
643    /// `mx_tag` / `c_void` fully qualified (no header import exists to
644    /// resolve bare names — #1307 dropped them).
645    #[test]
646    fn dispatch_entries_reference_items_fully_qualified() {
647        let (a, b, c) = sample_inputs();
648        let out = format_wasm_registry(&a, &b, &c, CLASSES);
649        assert!(
650            !out.contains("use ::miniextendr_api::abi::mx_tag;"),
651            "mx_tag header import must not be emitted; got:\n{out}"
652        );
653        assert!(
654            !out.contains("use ::core::ffi::c_void;"),
655            "c_void header import must not be emitted; got:\n{out}"
656        );
657        assert!(
658            out.contains("::miniextendr_api::abi::mx_tag::new("),
659            "dispatch tags must be fully qualified; got:\n{out}"
660        );
661        assert!(
662            out.contains(".cast::<::core::ffi::c_void>()"),
663            "vtable cast must be fully qualified; got:\n{out}"
664        );
665    }
666
667    #[test]
668    fn empty_inputs_produce_empty_slices() {
669        let out = format_wasm_registry(&[], &[], &[], &[]);
670        assert!(!out.contains("use ::miniextendr_api::SEXP;"));
671        assert!(out.contains("pub static MX_CALL_DEFS_WASM: &[R_CallMethodDef] = &[\n];"));
672        assert!(
673            out.contains("pub static MX_ALTREP_REGISTRATIONS_WASM: &[AltrepRegistration] = &[\n];")
674        );
675        assert!(out.contains("pub static MX_TRAIT_DISPATCH_WASM: &[TraitDispatchEntry] = &[\n];"));
676        assert!(out.contains("pub static MX_CONVERSION_ERROR_CLASS_WASM: &[&str] = &[\n];"));
677    }
678
679    #[test]
680    fn param_list_uses_underscore_bindings() {
681        assert_eq!(sexp_param_list(0), "");
682        assert_eq!(sexp_param_list(1), "_: SEXP");
683        assert_eq!(sexp_param_list(3), "_: SEXP, _: SEXP, _: SEXP");
684    }
685
686    #[test]
687    fn type_list_is_bare_types() {
688        assert_eq!(sexp_type_list(0), "");
689        assert_eq!(sexp_type_list(1), "SEXP");
690        assert_eq!(sexp_type_list(3), "SEXP, SEXP, SEXP");
691    }
692
693    #[test]
694    fn altrep_register_decls_use_safe_unwind_abi() {
695        let (a, b, c) = sample_inputs();
696        let out = format_wasm_registry(&a, &b, &c, CLASSES);
697        assert!(
698            out.contains("unsafe extern \"C-unwind\" {\n    pub safe fn __mx_altrep_reg_MyType();"),
699            "expected `safe extern C-unwind` to match AltrepRegistration.register; got:\n{out}"
700        );
701    }
702}