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