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 (gating happens in step 5
9//! of `plans/webr-support.md`).
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 (struct fields, import
28// paths, macro names). The receiving `build.rs` (step 5) refuses to compile
29// a `wasm_registry.rs` whose header doesn't match.
30const GENERATOR_VERSION: u32 = 1;
31
32/// Pre-extracted, cdylib-side view of one `R_CallMethodDef`.
33///
34/// `R_CallMethodDef` carries `name` as a raw `*const c_char`; safely walking
35/// it requires `unsafe`. The formatter takes already-extracted, owned values
36/// so it can be unit-tested without globals.
37pub struct CallDefRow {
38    pub name: String,
39    pub num_args: i32,
40}
41
42/// Pre-extracted view of one `MX_ALTREP_REGISTRATIONS` entry.
43pub struct AltrepRegRow {
44    pub symbol: String,
45}
46
47/// Pre-extracted view of one `MX_TRAIT_DISPATCH` entry.
48pub struct TraitDispatchRow {
49    pub concrete_tag: mx_tag,
50    pub trait_tag: mx_tag,
51    pub vtable_symbol: String,
52}
53
54/// Format a `wasm_registry.rs` source file from extracted runtime data.
55///
56/// Output structure:
57/// ```text
58/// // header (auto-generated marker, generator-version, content-hash)
59/// use ...;
60/// unsafe extern "C-unwind" { fn <wrapper>(...); ... }
61/// unsafe extern "C" { fn <altrep_reg>(); ... static <vtable>: u8; ... }
62/// pub static MX_CALL_DEFS_WASM: &[R_CallMethodDef] = &[ ... ];
63/// pub static MX_ALTREP_REGISTRATIONS_WASM: &[AltrepRegistration] = &[ ... ];
64/// pub static MX_TRAIT_DISPATCH_WASM: &[TraitDispatchEntry] = &[ ... ];
65/// ```
66///
67/// Every fn / static referenced from a slice gets a matching `extern` decl —
68/// the WASM linker resolves them against the user crate's `#[no_mangle]`
69/// exports.
70pub fn format_wasm_registry(
71    call_defs: &[CallDefRow],
72    altrep_regs: &[AltrepRegRow],
73    trait_dispatches: &[TraitDispatchRow],
74) -> String {
75    let body = format_body(call_defs, altrep_regs, trait_dispatches);
76    let content_hash = fnv1a_64(body.as_bytes());
77
78    let mut out = String::new();
79    writeln!(&mut out, "// AUTO-GENERATED — DO NOT EDIT.").unwrap();
80    writeln!(&mut out, "//").unwrap();
81    writeln!(
82        &mut out,
83        "// Produced on host by `miniextendr_write_wasm_registry`. Compiled on"
84    )
85    .unwrap();
86    writeln!(
87        &mut out,
88        "// wasm32-* targets in place of the linkme distributed_slices."
89    )
90    .unwrap();
91    writeln!(&mut out, "//").unwrap();
92    writeln!(&mut out, "// generator-version: {GENERATOR_VERSION}").unwrap();
93    writeln!(&mut out, "// content-hash:      {content_hash:016x}").unwrap();
94    writeln!(&mut out).unwrap();
95    out.push_str(&body);
96    out
97}
98
99fn format_body(
100    call_defs: &[CallDefRow],
101    altrep_regs: &[AltrepRegRow],
102    trait_dispatches: &[TraitDispatchRow],
103) -> String {
104    let mut out = String::new();
105
106    writeln!(&mut out, "use ::miniextendr_api::abi::mx_tag;").unwrap();
107    writeln!(&mut out, "use ::miniextendr_api::SEXP;").unwrap();
108    writeln!(&mut out, "use ::miniextendr_api::sys::R_CallMethodDef;").unwrap();
109    writeln!(
110        &mut out,
111        "use ::miniextendr_api::registry::{{AltrepRegistration, TraitDispatchEntry}};"
112    )
113    .unwrap();
114    writeln!(&mut out, "use ::core::ffi::c_void;").unwrap();
115    writeln!(&mut out).unwrap();
116
117    format_extern_unwind_block(&mut out, call_defs);
118    format_extern_c_block(&mut out, altrep_regs, trait_dispatches);
119    format_call_defs_slice(&mut out, call_defs);
120    format_altrep_regs_slice(&mut out, altrep_regs);
121    format_trait_dispatch_slice(&mut out, trait_dispatches);
122
123    out
124}
125
126fn format_extern_unwind_block(out: &mut String, call_defs: &[CallDefRow]) {
127    writeln!(out, "unsafe extern \"C-unwind\" {{").unwrap();
128    for row in call_defs {
129        let params = sexp_param_list(row.num_args);
130        writeln!(out, "    pub fn {}({params}) -> SEXP;", row.name).unwrap();
131    }
132    writeln!(out, "}}").unwrap();
133    writeln!(out).unwrap();
134}
135
136fn format_extern_c_block(
137    out: &mut String,
138    altrep_regs: &[AltrepRegRow],
139    trait_dispatches: &[TraitDispatchRow],
140) {
141    writeln!(out, "unsafe extern \"C\" {{").unwrap();
142    // ALTREP register fns get the 2024-edition `safe` qualifier so the fn
143    // type stays `extern "C" fn()` (not `unsafe extern "C" fn()`), allowing
144    // direct assignment to `AltrepRegistration.register`. Semantically the
145    // register fns are safe to call from anywhere — they wrap a OnceLock
146    // init and don't take SEXP arguments.
147    for row in altrep_regs {
148        writeln!(out, "    pub safe fn {}();", row.symbol).unwrap();
149    }
150    // Vtable shape is opaque from wasm_registry.rs' perspective — we only
151    // need the address. Declaring as `u8` is the convention used in the
152    // plan sketch and keeps the file independent of trait-specific types.
153    for row in trait_dispatches {
154        writeln!(out, "    pub static {}: u8;", row.vtable_symbol).unwrap();
155    }
156    writeln!(out, "}}").unwrap();
157    writeln!(out).unwrap();
158}
159
160fn format_call_defs_slice(out: &mut String, call_defs: &[CallDefRow]) {
161    writeln!(out, "pub static MX_CALL_DEFS_WASM: &[R_CallMethodDef] = &[").unwrap();
162    for row in call_defs {
163        // The transmute target signature here is positional-only — Rust's
164        // `extern fn` *type* doesn't carry parameter names, only types — so
165        // it differs from the `extern { fn ... }` declaration form, which
166        // does require named or `_`-bound parameters.
167        let arg_types = sexp_type_list(row.num_args);
168        writeln!(out, "    R_CallMethodDef {{").unwrap();
169        writeln!(out, "        name: c\"{}\".as_ptr(),", row.name).unwrap();
170        writeln!(
171            out,
172            "        fun: Some(unsafe {{ ::core::mem::transmute::<unsafe extern \"C-unwind\" fn({arg_types}) -> SEXP, _>({}) }}),",
173            row.name
174        )
175        .unwrap();
176        writeln!(out, "        numArgs: {},", row.num_args).unwrap();
177        writeln!(out, "    }},").unwrap();
178    }
179    writeln!(out, "];").unwrap();
180    writeln!(out).unwrap();
181}
182
183fn format_altrep_regs_slice(out: &mut String, altrep_regs: &[AltrepRegRow]) {
184    writeln!(
185        out,
186        "pub static MX_ALTREP_REGISTRATIONS_WASM: &[AltrepRegistration] = &["
187    )
188    .unwrap();
189    for row in altrep_regs {
190        writeln!(out, "    AltrepRegistration {{").unwrap();
191        writeln!(out, "        register: {},", row.symbol).unwrap();
192        writeln!(out, "        symbol: {:?},", row.symbol).unwrap();
193        writeln!(out, "    }},").unwrap();
194    }
195    writeln!(out, "];").unwrap();
196    writeln!(out).unwrap();
197}
198
199fn format_trait_dispatch_slice(out: &mut String, trait_dispatches: &[TraitDispatchRow]) {
200    writeln!(
201        out,
202        "pub static MX_TRAIT_DISPATCH_WASM: &[TraitDispatchEntry] = &["
203    )
204    .unwrap();
205    for row in trait_dispatches {
206        writeln!(out, "    TraitDispatchEntry {{").unwrap();
207        writeln!(
208            out,
209            "        concrete_tag: mx_tag::new(0x{:016x}, 0x{:016x}),",
210            row.concrete_tag.lo, row.concrete_tag.hi
211        )
212        .unwrap();
213        writeln!(
214            out,
215            "        trait_tag: mx_tag::new(0x{:016x}, 0x{:016x}),",
216            row.trait_tag.lo, row.trait_tag.hi
217        )
218        .unwrap();
219        writeln!(
220            out,
221            "        vtable: unsafe {{ ::core::ptr::from_ref(&{}).cast::<c_void>() }},",
222            row.vtable_symbol
223        )
224        .unwrap();
225        writeln!(out, "        vtable_symbol: {:?},", row.vtable_symbol).unwrap();
226        writeln!(out, "    }},").unwrap();
227    }
228    writeln!(out, "];").unwrap();
229}
230
231/// Comma-joined `_: SEXP` parameter list for an `extern { fn ...; }` decl.
232///
233/// Extern fn declarations require parameter bindings — bare `SEXP, SEXP`
234/// is parsed as pattern-typed which fails. Each slot is `_: SEXP`.
235fn sexp_param_list(num_args: i32) -> String {
236    if num_args <= 0 {
237        return String::new();
238    }
239    std::iter::repeat_n("_: SEXP", num_args as usize)
240        .collect::<Vec<_>>()
241        .join(", ")
242}
243
244/// Comma-joined `SEXP` type list for an `extern fn(...)` *type* expression
245/// (e.g. inside `transmute::<...>`). Function pointer types don't carry
246/// parameter names, so `_:` would be invalid here.
247fn sexp_type_list(num_args: i32) -> String {
248    if num_args <= 0 {
249        return String::new();
250    }
251    std::iter::repeat_n("SEXP", num_args as usize)
252        .collect::<Vec<_>>()
253        .join(", ")
254}
255
256/// FNV-1a 64-bit hash. Matches the implementation in
257/// `miniextendr-macros/src/miniextendr_impl_trait.rs::type_to_uppercase_name`
258/// so a future build.rs check can recompute it portably.
259fn fnv1a_64(data: &[u8]) -> u64 {
260    const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
261    const PRIME: u64 = 0x00000100000001b3;
262    let mut h = OFFSET_BASIS;
263    for &b in data {
264        h ^= b as u64;
265        h = h.wrapping_mul(PRIME);
266    }
267    h
268}
269
270/// Read the live linkme distributed slices and return rows safe to pass to
271/// [`format_wasm_registry`].
272fn read_runtime_slices() -> (Vec<CallDefRow>, Vec<AltrepRegRow>, Vec<TraitDispatchRow>) {
273    let call_defs: Vec<CallDefRow> = MX_CALL_DEFS
274        .iter()
275        .map(|d| {
276            // SAFETY: every emission site sets `name` from a static CStr literal
277            // (see `c_wrapper_builder.rs` and friends), so the pointer is valid
278            // for the program lifetime and points to a NUL-terminated UTF-8
279            // ASCII string.
280            let name = unsafe { CStr::from_ptr(d.name) }
281                .to_str()
282                .expect("MX_CALL_DEFS.name is not valid UTF-8")
283                .to_string();
284            CallDefRow {
285                name,
286                num_args: d.numArgs,
287            }
288        })
289        .collect();
290
291    let altrep_regs: Vec<AltrepRegRow> = MX_ALTREP_REGISTRATIONS
292        .iter()
293        .map(|r: &AltrepRegistration| AltrepRegRow {
294            symbol: r.symbol.to_string(),
295        })
296        .collect();
297
298    let trait_dispatches: Vec<TraitDispatchRow> = MX_TRAIT_DISPATCH
299        .iter()
300        .map(|t: &TraitDispatchEntry| TraitDispatchRow {
301            concrete_tag: t.concrete_tag,
302            trait_tag: t.trait_tag,
303            vtable_symbol: t.vtable_symbol.to_string(),
304        })
305        .collect();
306
307    (call_defs, altrep_regs, trait_dispatches)
308}
309
310/// Read the live distributed slices, format `wasm_registry.rs`, and write it
311/// to `path`. No-op when content is unchanged (matches `write_r_wrappers_to_file`).
312pub fn write_wasm_registry_to_file(path: &str) {
313    let (call_defs, altrep_regs, trait_dispatches) = read_runtime_slices();
314    let content = format_wasm_registry(&call_defs, &altrep_regs, &trait_dispatches);
315
316    let existing = std::fs::read_to_string(path).unwrap_or_default();
317    if existing == content {
318        return;
319    }
320
321    // Write via a sibling temp file then rename for atomicity.
322    //
323    // Matches the strategy in `write_r_wrappers_to_file`: `rename(2)` is atomic
324    // on POSIX, so concurrent readers always see a complete file. On Windows,
325    // `rename` fails when the destination exists, so we remove it first.
326    let dest = std::path::Path::new(path);
327    let tmp = dest.with_extension("tmp");
328    std::fs::write(&tmp, content.as_bytes())
329        .unwrap_or_else(|e| panic!("failed to write {}: {e}", tmp.display()));
330    #[cfg(windows)]
331    let _ = std::fs::remove_file(dest);
332    std::fs::rename(&tmp, dest).unwrap_or_else(|e| {
333        panic!(
334            "failed to rename {} → {}: {e}",
335            tmp.display(),
336            dest.display()
337        )
338    });
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn sample_inputs() -> (Vec<CallDefRow>, Vec<AltrepRegRow>, Vec<TraitDispatchRow>) {
346        let call_defs = vec![
347            CallDefRow {
348                name: "miniextendr_my_fn".into(),
349                num_args: 0,
350            },
351            CallDefRow {
352                name: "miniextendr_other".into(),
353                num_args: 3,
354            },
355        ];
356        let altrep_regs = vec![AltrepRegRow {
357            symbol: "__mx_altrep_reg_MyType".into(),
358        }];
359        let trait_dispatches = vec![TraitDispatchRow {
360            concrete_tag: mx_tag::new(0xdead_beef_dead_beef, 0x1234_5678_1234_5678),
361            trait_tag: mx_tag::new(0xcafe_babe_cafe_babe, 0xfeed_face_feed_face),
362            vtable_symbol: "__VTABLE_COUNTER_FOR_MYTYPE".into(),
363        }];
364        (call_defs, altrep_regs, trait_dispatches)
365    }
366
367    #[test]
368    fn header_carries_generator_version_and_content_hash() {
369        let (a, b, c) = sample_inputs();
370        let out = format_wasm_registry(&a, &b, &c);
371        assert!(
372            out.contains(&format!("generator-version: {GENERATOR_VERSION}")),
373            "expected generator-version line; got:\n{out}"
374        );
375        assert!(
376            out.contains("content-hash:      "),
377            "expected content-hash line; got:\n{out}"
378        );
379    }
380
381    #[test]
382    fn content_hash_is_deterministic() {
383        let (a, b, c) = sample_inputs();
384        let first = format_wasm_registry(&a, &b, &c);
385        let second = format_wasm_registry(&a, &b, &c);
386        assert_eq!(first, second);
387    }
388
389    #[test]
390    fn content_hash_changes_when_body_changes() {
391        let (a, b, c) = sample_inputs();
392        let mut a2 = a.clone_into_unique();
393        a2.push(CallDefRow {
394            name: "different_fn".into(),
395            num_args: 1,
396        });
397        let first = format_wasm_registry(&a, &b, &c);
398        let second = format_wasm_registry(&a2, &b, &c);
399        assert_ne!(first, second);
400    }
401
402    #[test]
403    fn emits_extern_decls_for_every_referenced_symbol() {
404        let (a, b, c) = sample_inputs();
405        let out = format_wasm_registry(&a, &b, &c);
406        // call wrappers in the C-unwind block — params bound to `_` so the
407        // declaration parses (extern fn decls require parameter bindings).
408        assert!(out.contains("pub fn miniextendr_my_fn() -> SEXP;"));
409        assert!(out.contains("pub fn miniextendr_other(_: SEXP, _: SEXP, _: SEXP) -> SEXP;"));
410        // altrep registrations + vtables in the C block
411        assert!(out.contains("pub safe fn __mx_altrep_reg_MyType();"));
412        assert!(out.contains("pub static __VTABLE_COUNTER_FOR_MYTYPE: u8;"));
413    }
414
415    #[test]
416    fn emits_named_slice_constants() {
417        let (a, b, c) = sample_inputs();
418        let out = format_wasm_registry(&a, &b, &c);
419        assert!(out.contains("pub static MX_CALL_DEFS_WASM: &[R_CallMethodDef]"));
420        assert!(out.contains("pub static MX_ALTREP_REGISTRATIONS_WASM: &[AltrepRegistration]"));
421        assert!(out.contains("pub static MX_TRAIT_DISPATCH_WASM: &[TraitDispatchEntry]"));
422    }
423
424    #[test]
425    fn renders_mx_tag_with_const_constructor() {
426        let (a, b, c) = sample_inputs();
427        let out = format_wasm_registry(&a, &b, &c);
428        assert!(
429            out.contains("mx_tag::new(0xdeadbeefdeadbeef, 0x1234567812345678)"),
430            "expected concrete_tag literal; got:\n{out}"
431        );
432        assert!(
433            out.contains("mx_tag::new(0xcafebabecafebabe, 0xfeedfacefeedface)"),
434            "expected trait_tag literal; got:\n{out}"
435        );
436    }
437
438    #[test]
439    fn empty_inputs_produce_empty_slices() {
440        let out = format_wasm_registry(&[], &[], &[]);
441        assert!(out.contains("pub static MX_CALL_DEFS_WASM: &[R_CallMethodDef] = &[\n];"));
442        assert!(
443            out.contains("pub static MX_ALTREP_REGISTRATIONS_WASM: &[AltrepRegistration] = &[\n];")
444        );
445        assert!(out.contains("pub static MX_TRAIT_DISPATCH_WASM: &[TraitDispatchEntry] = &[\n];"));
446    }
447
448    #[test]
449    fn param_list_uses_underscore_bindings() {
450        assert_eq!(sexp_param_list(0), "");
451        assert_eq!(sexp_param_list(1), "_: SEXP");
452        assert_eq!(sexp_param_list(3), "_: SEXP, _: SEXP, _: SEXP");
453    }
454
455    #[test]
456    fn type_list_is_bare_types() {
457        assert_eq!(sexp_type_list(0), "");
458        assert_eq!(sexp_type_list(1), "SEXP");
459        assert_eq!(sexp_type_list(3), "SEXP, SEXP, SEXP");
460    }
461
462    #[test]
463    fn altrep_register_decls_use_safe_keyword() {
464        let (a, b, c) = sample_inputs();
465        let out = format_wasm_registry(&a, &b, &c);
466        assert!(
467            out.contains("pub safe fn __mx_altrep_reg_MyType()"),
468            "expected `safe fn` so the fn type matches AltrepRegistration.register; got:\n{out}"
469        );
470    }
471
472    // Helper: clone a Vec<CallDefRow> by re-creating each row (CallDefRow
473    // doesn't impl Clone — keeping it minimal). Used only in
474    // `content_hash_changes_when_body_changes`.
475    trait CloneIntoUnique {
476        fn clone_into_unique(&self) -> Vec<CallDefRow>;
477    }
478    impl CloneIntoUnique for Vec<CallDefRow> {
479        fn clone_into_unique(&self) -> Vec<CallDefRow> {
480            self.iter()
481                .map(|r| CallDefRow {
482                    name: r.name.clone(),
483                    num_args: r.num_args,
484                })
485                .collect()
486        }
487    }
488}