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