//! WASM (wasm-bindgen) backend: orchestration and `Backend` trait implementation.
pub mod enums;
pub mod errors;
pub mod functions;
pub mod methods;
pub mod types;
use crate::type_map::WasmMapper;
use ahash::AHashSet;
use alef_codegen::builder::RustFileBuilder;
use alef_codegen::generators;
use alef_core::backend::{Backend, BuildConfig, BuildDependency, Capabilities, GeneratedFile};
use alef_core::config::{Language, ResolvedCrateConfig, resolve_output_dir};
use alef_core::hash::{self, CommentStyle};
use alef_core::ir::{ApiSurface, TypeRef};
use alef_core::template_versions as tv;
use std::path::PathBuf;
use enums::gen_enum;
use errors::{gen_error_converter, gen_error_methods};
use functions::{gen_env_shims, gen_function};
use types::{gen_opaque_struct, gen_opaque_struct_methods, gen_struct, gen_struct_methods};
pub struct WasmBackend;
/// Check if a TypeRef references a Named type that is in the exclude set.
/// Used to skip fields whose types were excluded from WASM generation,
/// preventing references to non-existent Js* wrapper types.
pub(super) fn field_references_excluded_type(ty: &TypeRef, exclude_types: &[String]) -> bool {
match ty {
TypeRef::Named(name) => exclude_types.iter().any(|e| e == name),
TypeRef::Optional(inner) | TypeRef::Vec(inner) => field_references_excluded_type(inner, exclude_types),
TypeRef::Map(k, v) => {
field_references_excluded_type(k, exclude_types) || field_references_excluded_type(v, exclude_types)
}
_ => false,
}
}
/// Check if an item is gated behind a disabled feature.
///
/// Evaluates cfg condition strings against the enabled feature list.
/// Returns `true` when the cfg condition is *not* satisfied (i.e. the item
/// must be excluded from generation). Handles:
/// - `feature = "name"`
/// - `any(feature = "a", feature = "b", ...)`
/// - `all(feature = "a", feature = "b", ...)`
/// - `not(<inner>)`
///
/// The IR encodes cfgs via `proc_macro2::TokenStream::to_string()`, which
/// inserts whitespace between tokens (e.g. `any (feature = "a" , ...)`); the
/// evaluator normalises that before parsing.
fn is_gated_behind_disabled_feature(cfg: &Option<String>, enabled_features: &[String]) -> bool {
let Some(cfg_str) = cfg else {
return false;
};
!cfg_condition_enabled(cfg_str, enabled_features)
}
fn cfg_condition_enabled(cfg_str: &str, enabled_features: &[String]) -> bool {
let normalized = cfg_str.trim().replace(" (", "(");
let cfg_str = normalized.as_str();
if let Some(feature) = cfg_str.strip_prefix("feature = \"").and_then(|s| s.strip_suffix('"')) {
return enabled_features.iter().any(|ef| ef == feature);
}
if let Some(inner) = cfg_str.strip_prefix("any(").and_then(|s| s.strip_suffix(')')) {
return parse_cfg_list(inner)
.iter()
.any(|cond| cfg_condition_enabled(cond, enabled_features));
}
if let Some(inner) = cfg_str.strip_prefix("all(").and_then(|s| s.strip_suffix(')')) {
return parse_cfg_list(inner)
.iter()
.all(|cond| cfg_condition_enabled(cond, enabled_features));
}
if let Some(inner) = cfg_str.strip_prefix("not(").and_then(|s| s.strip_suffix(')')) {
return !cfg_condition_enabled(inner.trim(), enabled_features);
}
// Unknown pattern → treat as enabled (no exclusion). Preserves prior behaviour
// for cfgs the WASM backend has never inspected (target_arch, target_os, ...).
true
}
fn parse_cfg_list(s: &str) -> Vec<String> {
let mut result = Vec::new();
let mut depth = 0usize;
let mut current = String::new();
for ch in s.chars() {
match ch {
'(' => {
depth += 1;
current.push(ch);
}
')' => {
depth = depth.saturating_sub(1);
current.push(ch);
}
',' if depth == 0 => {
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
result.push(trimmed);
}
current.clear();
}
_ => current.push(ch),
}
}
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
result.push(trimmed);
}
result
}
impl Backend for WasmBackend {
fn name(&self) -> &str {
"wasm"
}
fn language(&self) -> Language {
Language::Wasm
}
fn capabilities(&self) -> Capabilities {
Capabilities {
supports_async: true,
supports_classes: true,
supports_enums: true,
supports_option: true,
supports_result: true,
..Capabilities::default()
}
}
fn generate_bindings(&self, api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
let wasm_config = config.wasm.as_ref();
let mut exclude_functions = wasm_config.map(|c| c.exclude_functions.clone()).unwrap_or_default();
let mut exclude_types = wasm_config.map(|c| c.exclude_types.clone()).unwrap_or_default();
let type_overrides = wasm_config.map(|c| c.type_overrides.clone()).unwrap_or_default();
let env_shims = wasm_config.map(|c| c.env_shims.clone()).unwrap_or_default();
let prefix = config.wasm_type_prefix();
// Auto-exclude types/functions/enums gated behind disabled features
let enabled_features = config.features_for_language(Language::Wasm).to_vec();
for typ in &api.types {
if is_gated_behind_disabled_feature(&typ.cfg, &enabled_features) {
exclude_types.push(typ.name.clone());
}
}
for enum_def in &api.enums {
if is_gated_behind_disabled_feature(&enum_def.cfg, &enabled_features) {
exclude_types.push(enum_def.name.clone());
}
}
for func in &api.functions {
if is_gated_behind_disabled_feature(&func.cfg, &enabled_features) {
exclude_functions.push(func.name.clone());
}
}
let mapper = WasmMapper::new(type_overrides, prefix.clone());
let core_import = config.core_import_for_language(Language::Wasm);
// Build source-crate remaps from config: each `source_crate_remaps` entry
// becomes `(original_crate_name_with_underscores, core_import)`. References
// to `<original>::T` in IR rust_paths are rewritten to `<core_import>::T`.
let source_remap_pairs: Vec<(String, String)> = wasm_config
.map(|c| c.source_crate_remaps.clone())
.unwrap_or_default()
.into_iter()
.map(|orig| (orig.replace('-', "_"), core_import.clone()))
.collect();
let source_remaps_borrowed: Vec<(&str, &str)> = source_remap_pairs
.iter()
.map(|(o, n)| (o.as_str(), n.as_str()))
.collect();
// Crates whose types should be auto-skipped: source crates that aren't
// available as deps (excluded via `exclude_extra_dependencies`) and aren't
// remapped to the override. Generated code referencing these would fail
// to resolve because the crate isn't in the binding's Cargo deps.
let dropped_crates: AHashSet<String> = wasm_config
.map(|c| c.exclude_extra_dependencies.clone())
.unwrap_or_default()
.into_iter()
.map(|name| name.replace('-', "_"))
.filter(|underscored| {
// Keep if it's the core_import (already a dep via override) or remapped
underscored != &core_import && !source_remap_pairs.iter().any(|(orig, _)| orig == underscored)
})
.collect();
// Auto-exclude types whose source crate is in `dropped_crates`. The IR's
// `rust_path` starts with the source crate name; if that name is in the
// dropped set, alef cannot generate working bindings for the type
// (the From impl would reference a crate not in the dep tree).
for typ in &api.types {
let crate_seg = typ.rust_path.split("::").next().unwrap_or("").replace('-', "_");
if dropped_crates.contains(&crate_seg) && !exclude_types.contains(&typ.name) {
exclude_types.push(typ.name.clone());
}
}
for enum_def in &api.enums {
let crate_seg = enum_def.rust_path.split("::").next().unwrap_or("").replace('-', "_");
if dropped_crates.contains(&crate_seg) && !exclude_types.contains(&enum_def.name) {
exclude_types.push(enum_def.name.clone());
}
}
for func in &api.functions {
let crate_seg = func.rust_path.split("::").next().unwrap_or("").replace('-', "_");
if dropped_crates.contains(&crate_seg) && !exclude_functions.contains(&func.name) {
exclude_functions.push(func.name.clone());
}
}
// Errors mirror types — skip those whose source crate isn't in the dep tree.
let dropped_error_names: Vec<String> = api
.errors
.iter()
.filter(|e| {
let crate_seg = e.rust_path.split("::").next().unwrap_or("").replace('-', "_");
dropped_crates.contains(&crate_seg)
})
.map(|e| e.name.clone())
.collect();
for name in dropped_error_names {
if !exclude_types.contains(&name) {
exclude_types.push(name);
}
}
// Apply per-type field exclusions: any field listed in `[wasm].exclude_fields`
// is treated as if it were `#[cfg]`-gated, so the binding struct omits it and
// the From impl uses `..Default::default()` to fill it.
let exclude_fields_map = wasm_config.map(|c| c.exclude_fields.clone()).unwrap_or_default();
// Build a fresh ApiSurface clone with exclude_fields applied: each field in
// the exclude list gets `cfg: Some("excluded_via_alef_toml")` so all
// downstream cfg-skip code paths (binding struct gen, From impls, builder
// patterns) treat it as native-only and emit ..Default::default() on wasm.
let api_owned;
let api: &ApiSurface = if exclude_fields_map.is_empty() {
api
} else {
api_owned = {
let mut cloned = api.clone();
for typ in &mut cloned.types {
if let Some(skip_list) = exclude_fields_map.get(&typ.name) {
let before = typ.fields.len();
typ.fields.retain(|field| !skip_list.iter().any(|s| s == &field.name));
if typ.fields.len() != before {
typ.has_stripped_cfg_fields = true;
}
}
}
cloned
};
&api_owned
};
// Note: custom modules and registrations handled below after builder creation
let mut builder = RustFileBuilder::new().with_generated_header();
builder.add_inner_attribute("allow(dead_code, unused_imports, unused_variables)");
builder.add_inner_attribute("allow(clippy::too_many_arguments, clippy::let_unit_value, clippy::needless_borrow, clippy::map_identity, clippy::just_underscores_and_digits, clippy::unused_unit, clippy::unnecessary_cast, clippy::unwrap_or_default, clippy::derivable_impls, clippy::needless_borrows_for_generic_args, clippy::unnecessary_fallible_conversions, clippy::useless_conversion, clippy::arc_with_non_send_sync, clippy::collapsible_if, clippy::clone_on_copy, clippy::should_implement_trait, clippy::await_holding_refcell_ref)");
builder.add_import("wasm_bindgen::prelude::*");
// Hand-written Rust modules: declare `pub mod <name>;` at the top of lib.rs
// and re-export with `pub use <name>::*;`. Source files for these modules
// must exist in `crates/<name>-wasm/src/<name>.rs` and are not managed by alef.
if let Some(modules) = wasm_config.map(|c| c.custom_rust_modules.as_slice()) {
for module in modules {
builder.add_item(&format!("pub mod {module};"));
builder.add_item(&format!("pub use {module}::*;"));
}
}
// js_sys items are always referenced with full paths (js_sys::Object, js_sys::Reflect, etc.)
// so no explicit `use js_sys;` import is needed (clippy::single_component_path_imports).
// Import traits needed for trait method dispatch
for trait_path in generators::collect_trait_imports(api) {
builder.add_import(&trait_path);
}
// Note: HashMap is intentionally not imported here.
// The WasmMapper always converts Map types to JsValue (wasm-bindgen cannot
// pass HashMap<K, V> across the JS boundary), so HashMap is never referenced
// in the generated WASM binding code.
// Emit environment shims for C external scanner interop (e.g. iswspace, iswalnum).
// Only shims whose names appear in env_shims are emitted.
if !env_shims.is_empty() {
builder.add_item(&gen_env_shims(&env_shims));
}
// Check if we have opaque types and add Arc import if needed
let opaque_types: AHashSet<String> = api
.types
.iter()
.filter(|t| t.is_opaque && !exclude_types.contains(&t.name))
.map(|t| t.name.clone())
.collect();
let mutex_types: AHashSet<String> = api
.types
.iter()
.filter(|t| t.is_opaque && !exclude_types.contains(&t.name) && generators::type_needs_mutex(t))
.map(|t| t.name.clone())
.collect();
if !opaque_types.is_empty() {
builder.add_import("std::sync::Arc");
if !mutex_types.is_empty() {
builder.add_import("std::sync::Mutex");
}
}
// Trait bridge type aliases (e.g. `VisitorHandle`) are opaque — they map to
// `Arc<core::VisitorHandle>` in the binding layer and must not attempt From/Into
// conversion in generated From impls. Include them so struct fields referencing
// these types use Default::default() instead of val.visitor.map(Into::into).
let bridge_type_aliases: Vec<String> = config
.trait_bridges
.iter()
.filter_map(|b| b.type_alias.clone())
.collect();
let mut opaque_names_vec: Vec<String> = opaque_types.iter().cloned().collect();
opaque_names_vec.extend(bridge_type_aliases);
let opaque_names_set: AHashSet<String> = opaque_names_vec.iter().cloned().collect();
// Build adapter body map before type iteration so bodies are available for method generation.
let adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Wasm)?;
// Map "OwnerType.method" -> streaming item type. The wasm backend needs to
// override the IR-declared return type with the iterator struct name
// for streaming adapters, since the generated body returns an iterator instance.
// Exclude adapters that have "wasm" in skip_languages — those methods fall back
// to the unimplemented-body path and must not receive an iterator return type.
let streaming_item_types: ahash::AHashMap<String, String> = config
.adapters
.iter()
.filter(|a| matches!(a.pattern, alef_core::config::AdapterPattern::Streaming))
.filter(|a| !a.skip_languages.iter().any(|l| l == "wasm"))
.filter_map(|a| {
let owner = a.owner_type.as_deref()?;
let item = a.item_type.as_deref()?;
Some((format!("{owner}.{}", a.name), item.to_string()))
})
.collect();
// Build the set of "OwnerType.method" keys for streaming adapters that are
// explicitly skipped for WASM via skip_languages. The WASM backend must omit
// these methods entirely — not emit unimplemented stubs — because their
// parameter types (e.g. WasmCrawlStreamRequest) are only generated by the
// streaming adapter body path and will be missing when the adapter is skipped.
let wasm_skipped_methods: AHashSet<String> = config
.adapters
.iter()
.filter(|a| matches!(a.pattern, alef_core::config::AdapterPattern::Streaming))
.filter(|a| a.skip_languages.iter().any(|l| l == "wasm"))
.filter_map(|a| {
let owner = a.owner_type.as_deref()?;
Some(format!("{owner}.{}", a.name))
})
.collect();
// Emit adapter-generated standalone items (streaming iterators, callback bridges).
for adapter in &config.adapters {
match adapter.pattern {
alef_core::config::AdapterPattern::Streaming => {
let key = alef_adapters::stream_struct_key(adapter);
if let Some(struct_code) = adapter_bodies.get(&key) {
builder.add_item(struct_code);
}
}
alef_core::config::AdapterPattern::CallbackBridge => {
let struct_key = format!("{}.__bridge_struct__", adapter.name);
let impl_key = format!("{}.__bridge_impl__", adapter.name);
if let Some(struct_code) = adapter_bodies.get(&struct_key) {
builder.add_item(struct_code);
}
if let Some(impl_code) = adapter_bodies.get(&impl_key) {
builder.add_item(impl_code);
}
}
_ => {}
}
}
// Collect tagged-data enum names once for use in struct generation and conversions.
// These are enums with a serde tag *and* at least one data variant — they are emitted
// as wasm-bindgen structs with `JsValue` storage for fields that are Vec<TaggedEnum>.
let tagged_data_enum_names: AHashSet<String> = api
.enums
.iter()
.filter(|e| !exclude_types.contains(&e.name) && enums::is_tagged_data_enum(e))
.map(|e| e.name.clone())
.collect();
for typ in api.types.iter().filter(|typ| !typ.is_trait) {
if exclude_types.contains(&typ.name) {
continue;
}
if typ.is_opaque {
builder.add_item(&gen_opaque_struct(typ, &core_import, &prefix));
builder.add_item(&gen_opaque_struct_methods(
typ,
&mapper,
&opaque_types,
&core_import,
&prefix,
&adapter_bodies,
&mutex_types,
&streaming_item_types,
&wasm_skipped_methods,
));
// Client constructor — emit a #[wasm_bindgen(constructor)] impl
if let Some(ctor) = config.client_constructors.get(&typ.name) {
let struct_name = format!("{prefix}{}", typ.name);
let ctor_body = generators::gen_opaque_constructor(
ctor,
&typ.name,
&core_import,
"#[wasm_bindgen(constructor)]",
);
let ctor_impl = format!("#[wasm_bindgen]\nimpl {struct_name} {{\n{}}}", ctor_body);
builder.add_item(&ctor_impl);
}
} else {
// gen_struct adds #[derive(Default)] when typ.has_default is true,
// so no separate Default impl is needed.
builder.add_item(&gen_struct(
typ,
&mapper,
&exclude_types,
&prefix,
&tagged_data_enum_names,
));
builder.add_item(&gen_struct_methods(
typ,
&mapper,
&exclude_types,
&core_import,
&opaque_types,
&api.enums,
&prefix,
&mutex_types,
&streaming_item_types,
));
}
}
for enum_def in &api.enums {
if !exclude_types.contains(&enum_def.name) {
builder.add_item(&gen_enum(enum_def, &prefix));
}
}
for func in &api.functions {
if !exclude_functions.contains(&func.name) {
// clear_fn functions are emitted inside the bridge module and glob-re-exported;
// emitting them again here would produce duplicate wasm-bindgen symbol names.
if alef_codegen::generators::trait_bridge::is_trait_bridge_managed_fn(&func.name, &config.trait_bridges)
{
continue;
}
// Skip functions whose signature references excluded types
let refs_excluded = func
.params
.iter()
.any(|p| field_references_excluded_type(&p.ty, &exclude_types))
|| field_references_excluded_type(&func.return_type, &exclude_types);
if refs_excluded {
continue;
}
let bridge_param = crate::trait_bridge::find_bridge_param(func, &config.trait_bridges);
let options_field_bridge = crate::trait_bridge::find_options_field_binding(func, &config.trait_bridges)
// Only use the options-field path when the bridge field actually survives
// into the binding struct.
.filter(|(_, bridge_cfg)| {
let Some(field_name) = bridge_cfg.resolved_options_field() else { return false; };
let Some(options_type) = bridge_cfg.options_type.as_deref() else { return false; };
api.types
.iter()
.filter(|t| t.name == options_type)
.flat_map(|t| t.fields.iter())
.any(|f| f.cfg.is_none() && f.name == field_name)
});
if let Some((param_idx, bridge_cfg)) = bridge_param {
builder.add_item(&crate::trait_bridge::gen_bridge_function(
func,
param_idx,
bridge_cfg,
&mapper,
&opaque_types,
&core_import,
&prefix,
));
} else if let Some((param_idx, bridge_cfg)) = options_field_bridge {
builder.add_item(&crate::trait_bridge::gen_options_field_bridge_function(
func,
param_idx,
bridge_cfg,
&mapper,
&opaque_types,
&core_import,
&prefix,
));
} else {
builder.add_item(&gen_function(
func,
&mapper,
&core_import,
&opaque_types,
&prefix,
&mutex_types,
api,
));
}
}
}
// Trait bridge wrappers — generate WASM bridge structs that delegate to JS objects
for bridge_cfg in &config.trait_bridges {
if let Some(trait_type) = api.types.iter().find(|t| t.is_trait && t.name == bridge_cfg.trait_name) {
let bridge = crate::trait_bridge::gen_trait_bridge(
trait_type,
bridge_cfg,
&core_import,
&config.error_type_name(),
&config.error_constructor_expr(),
api,
);
for imp in &bridge.imports {
builder.add_import(imp);
}
builder.add_item(&bridge.code);
}
}
// Trait-bridge fields whose binding-side wrapper holds `inner: Arc<core::T>`
// (every OptionsField-style bridge in alef follows this convention). Used by
// `binding_to_core` to emit `val.{f}.map(|v| (*v.inner).clone())` instead of
// `Default::default()` so the JS visitor handle survives `.into()`.
let trait_bridge_arc_wrapper_field_names: Vec<String> = config
.trait_bridges
.iter()
.filter(|b| b.bind_via == alef_core::config::BridgeBinding::OptionsField)
.filter_map(|b| b.resolved_options_field().map(String::from))
.collect();
let wasm_conv_config = alef_codegen::conversions::ConversionConfig {
type_name_prefix: &prefix,
map_uses_jsvalue: true,
option_duration_on_defaults: true,
exclude_types: &exclude_types,
source_crate_remaps: &source_remaps_borrowed,
// Treat bridge type aliases (e.g. VisitorHandle) as opaque so binding→core
// From impls emit Default::default() instead of val.visitor.map(Into::into),
// unless the field is in `trait_bridge_arc_wrapper_field_names` (then it gets
// proper Arc-dereferencing forwarding).
opaque_types: if opaque_names_set.is_empty() {
None
} else {
Some(&opaque_names_set)
},
trait_bridge_arc_wrapper_field_names: &trait_bridge_arc_wrapper_field_names,
// Vec<TaggedDataEnum> fields are stored as JsValue; conversions use serde_wasm_bindgen.
tagged_data_enum_names: if tagged_data_enum_names.is_empty() {
None
} else {
Some(&tagged_data_enum_names)
},
..Default::default()
};
let convertible = alef_codegen::conversions::convertible_types(api);
let core_to_binding_convertible = alef_codegen::conversions::core_to_binding_convertible_types(api);
let input_types = alef_codegen::conversions::input_type_names(api);
// From/Into conversions using shared parameterized generators
for typ in api.types.iter().filter(|typ| !typ.is_trait) {
if exclude_types.contains(&typ.name) {
continue;
}
let is_strict = alef_codegen::conversions::can_generate_conversion(typ, &convertible);
let is_relaxed = alef_codegen::conversions::can_generate_conversion(typ, &core_to_binding_convertible);
if is_strict {
// Both directions
if input_types.contains(&typ.name) {
builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
typ,
&core_import,
&wasm_conv_config,
));
}
builder.add_item(&alef_codegen::conversions::gen_from_core_to_binding_cfg(
typ,
&core_import,
&opaque_types,
&wasm_conv_config,
));
} else if is_relaxed {
// Only core→binding (sanitized fields prevent binding→core)
builder.add_item(&alef_codegen::conversions::gen_from_core_to_binding_cfg(
typ,
&core_import,
&opaque_types,
&wasm_conv_config,
));
}
}
for e in &api.enums {
if !exclude_types.contains(&e.name) {
if enums::is_tagged_data_enum(e) {
// Tagged data enums emit as a flat wasm-bindgen struct (see
// `gen_tagged_enum_as_struct`); their From/Into impls match on the
// string discriminator field instead of the destination enum variants
// produced by the generic enum-conversion templates.
if input_types.contains(&e.name) {
builder.add_item(&enums::gen_tagged_enum_binding_to_core(e, &core_import, &prefix));
}
builder.add_item(&enums::gen_tagged_enum_core_to_binding(e, &core_import, &prefix));
} else {
if input_types.contains(&e.name) && alef_codegen::conversions::can_generate_enum_conversion(e) {
builder.add_item(&alef_codegen::conversions::gen_enum_from_binding_to_core_cfg(
e,
&core_import,
&wasm_conv_config,
));
}
if alef_codegen::conversions::can_generate_enum_conversion_from_core(e) {
builder.add_item(&alef_codegen::conversions::gen_enum_from_core_to_binding_cfg(
e,
&core_import,
&wasm_conv_config,
));
}
}
}
}
// Error converter functions (skip excluded errors).
// When an error declares whitelisted introspection methods, also emit an opaque
// WASM struct + impl block so JS/TS callers can inspect the error object.
for error in &api.errors {
if exclude_types.contains(&error.name) {
continue;
}
builder.add_item(&gen_error_converter(error, &core_import));
let methods_block = gen_error_methods(error, &core_import, &prefix);
if !methods_block.is_empty() {
builder.add_item(&methods_block);
}
}
let mut content = builder.build();
// Post-process generated code to fix bridge type builder methods.
// Builder methods on has_default types with opaque bridge parameters
// (e.g., visitor: WasmVisitorHandle) should not attempt to access .inner,
// as there is no From impl from Arc<VisitorHandle> to the core visitor type.
// Replace patterns like .visitor(visitor.as_ref().map(|v| &v.inner))
// with .visitor(None) to skip setting the visitor on the core builder.
for bridge in &config.trait_bridges {
if let Some(field_name) = bridge.resolved_options_field() {
let param_name = bridge.param_name.as_deref().unwrap_or(field_name);
let pattern = format!(".{}({}.as_ref().map(|v| &v.inner))", field_name, param_name);
let replacement = format!(".{}(None)", field_name);
content = content.replace(&pattern, &replacement);
}
}
// Fix From<Wasm*> impls to pass through the bridge field instead of Default::default().
// Post-process every From<Wasm{options_type}{variant}> impl whose bridge is bound via
// options_field, replacing `{field_name}: Default::default()` with the proper pass-through.
for bridge in &config.trait_bridges {
if bridge.bind_via != alef_core::config::BridgeBinding::OptionsField {
continue;
}
let (Some(options_type), Some(field_name)) =
(bridge.options_type.as_deref(), bridge.resolved_options_field())
else {
continue;
};
for variant in ["", "Update"] {
let binding_name = format!("Wasm{options_type}{variant}");
let core_path = format!("{core_import}::options::{options_type}{variant}");
let impl_header = format!("impl From<{binding_name}> for {core_path} {{");
if !content.contains(&impl_header) {
continue;
}
// Try multiple indentation patterns (tabs or spaces)
// Most commonly 12 spaces (3 indent levels) in generated code
let patterns = &[
(" ", "\n "), // 12 spaces
(" ", "\n "), // 8 spaces
(" ", "\n "), // 2 spaces
];
for (indent, newline_indent) in patterns {
let old_pattern =
format!("{indent}{field_name}: Default::default(),{newline_indent}..Default::default()");
let new_pattern = format!(
"{indent}{field_name}: val.{field_name}.map(|v| (*v.inner).clone()),{newline_indent}..Default::default()"
);
if content.contains(&old_pattern) {
content = content.replace(&old_pattern, &new_pattern);
}
}
}
}
let output_dir = resolve_output_dir(config.output_paths.get("wasm"), &config.name, "crates/{name}-wasm/src/");
let cargo_toml_path = PathBuf::from(&output_dir)
.parent()
.map(|p| p.join("Cargo.toml"))
.unwrap_or_else(|| PathBuf::from("Cargo.toml"));
Ok(vec![
GeneratedFile {
path: PathBuf::from(&output_dir).join("lib.rs"),
content,
generated_header: false,
},
GeneratedFile {
path: cargo_toml_path,
content: gen_cargo_toml(api, config),
generated_header: true,
},
])
}
fn build_config(&self) -> Option<BuildConfig> {
Some(BuildConfig {
tool: "wasm-pack",
crate_suffix: "-wasm",
build_dep: BuildDependency::None,
post_build: vec![],
})
}
}
/// Generate the `Cargo.toml` for the WASM binding crate.
///
/// This is emitted by [`WasmBackend::generate_bindings`] so that the file is
/// always regenerated on `alef generate` / `alef all` alongside `lib.rs`.
/// Emitting it here (rather than only in `alef-scaffold`) ensures that the
/// `js-sys` dependency required by trait-bridge and visitor-bridge generated
/// code is always present, even in projects whose `Cargo.toml` was created
/// before `js-sys` was added to the scaffold template.
fn gen_cargo_toml(api: &ApiSurface, config: &ResolvedCrateConfig) -> String {
let core_crate_dir = config.core_crate_for_language(Language::Wasm);
let crate_name = &config.name;
// Package-name prefix for `<prefix>-wasm`. Preserves prior behaviour
// (derived from sources) when no override is set; switches to the
// umbrella crate name when an override redirects the core dep elsewhere
// so the binding crate keeps its original published name.
let pkg_prefix: String = if config
.wasm
.as_ref()
.and_then(|c| c.core_crate_override.as_deref())
.is_some()
{
crate_name.clone()
} else {
core_crate_dir.clone()
};
// Cargo dep KEY for the core dependency: the override when set, otherwise
// the umbrella crate name. Must match `core_crate_dir` so
// `path = "../{core_crate_dir}"` resolves to a crate whose Cargo.toml
// `name` equals the dep key.
let core_dep_key: String = config
.wasm
.as_ref()
.and_then(|c| c.core_crate_override.clone())
.unwrap_or_else(|| crate_name.clone());
let version = &api.version;
let scaffold = config.scaffold.as_ref();
let license = scaffold.and_then(|s| s.license.as_deref()).unwrap_or("MIT");
let description = scaffold
.and_then(|s| s.description.as_deref())
.unwrap_or(crate_name.as_str());
let repository = scaffold.and_then(|s| s.repository.as_deref()).unwrap_or("");
let keywords = scaffold.map(|s| s.keywords.as_slice()).unwrap_or(&[]);
let keywords_toml = if keywords.is_empty() {
String::new()
} else {
let quoted: Vec<String> = keywords.iter().map(|k| format!("\"{k}\"")).collect();
format!("keywords = [{}]\n", quoted.join(", "))
};
let features = config.features_for_language(Language::Wasm);
let features_clause = if features.is_empty() {
String::new()
} else {
// When the consumer pinned an explicit feature set for wasm, also
// disable default features so "download" or similar host-only
// defaults don't sneak in (mio/getrandom can't compile to
// wasm32-unknown-unknown).
let quoted: Vec<String> = features.iter().map(|f| format!("\"{f}\"")).collect();
format!(", default-features = false, features = [{}]", quoted.join(", "))
};
let extra_deps = config.extra_deps_for_language(Language::Wasm);
let mut extra_dep_lines: Vec<String> = extra_deps
.iter()
.map(|(name, value)| {
if let Some(s) = value.as_str() {
format!("{name} = \"{s}\"")
} else {
format!("{name} = {value}")
}
})
.collect();
extra_dep_lines.sort();
let extra_deps_section = if extra_dep_lines.is_empty() {
String::new()
} else {
format!("\n{}", extra_dep_lines.join("\n"))
};
let header = hash::header(CommentStyle::Hash);
// Layout follows cargo-sort canonical order: [package] -> [package.metadata.*]
// -> [lib] -> [dependencies] (alphabetical). Otherwise cargo-sort rewrites the
// file post-generate and breaks the alef hash header.
let mut deps: Vec<(String, String)> = vec![
(
core_dep_key.clone(),
format!(r#"{{ path = "../{core_crate_dir}"{features_clause} }}"#),
),
("futures".to_string(), format!(r#""{}""#, tv::cargo::FUTURES)),
("futures-util".to_string(), format!(r#""{}""#, tv::cargo::FUTURES_UTIL)),
("js-sys".to_string(), format!(r#""{}""#, tv::cargo::JS_SYS)),
(
"serde".to_string(),
r#"{ version = "1", features = ["derive"] }"#.to_string(),
),
(
"serde-wasm-bindgen".to_string(),
format!(r#""{}""#, tv::cargo::SERDE_WASM_BINDGEN),
),
("serde_json".to_string(), r#""1""#.to_string()),
("wasm-bindgen".to_string(), format!(r#""{}""#, tv::cargo::WASM_BINDGEN)),
(
"wasm-bindgen-futures".to_string(),
format!(r#""{}""#, tv::cargo::WASM_BINDGEN_FUTURES),
),
];
// Append parsed extra deps as (name, value) pairs.
for line in extra_deps_section.lines() {
let trimmed = line.trim();
if let Some((name, value)) = trimmed.split_once('=') {
deps.push((name.trim().to_string(), value.trim().to_string()));
}
}
deps.sort_by(|a, b| a.0.cmp(&b.0));
let deps_block = deps
.iter()
.map(|(name, value)| format!("{name} = {value}"))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"{header}
[package]
name = "{pkg_prefix}-wasm"
version = "{version}"
edition = "2024"
license = "{license}"
description = "{description}"
repository = "{repository}"
{keywords_toml}
[package.metadata.cargo-machete]
ignored = ["futures", "futures-util", "js-sys", "wasm-bindgen-futures", "serde", "serde_json"]
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
[lib]
crate-type = ["cdylib"]
[dependencies]
{deps_block}
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = {{ version = "0.4", features = ["wasm_js"] }}
getrandom_03 = {{ package = "getrandom", version = "0.3", features = ["wasm_js"] }}
"#,
header = header,
pkg_prefix = pkg_prefix,
version = version,
license = license,
description = description,
repository = repository,
keywords_toml = keywords_toml,
deps_block = deps_block,
)
}
#[cfg(test)]
mod tests {
use super::WasmBackend;
use alef_core::backend::Backend;
use alef_core::config::{NewAlefConfig, ResolvedCrateConfig};
use alef_core::ir::ApiSurface;
fn make_config() -> ResolvedCrateConfig {
let cfg: NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["wasm"]
[[crates]]
name = "test-lib"
sources = ["src/lib.rs"]
[crates.wasm]
"#,
)
.unwrap();
cfg.resolve().unwrap().remove(0)
}
#[test]
fn wasm_backend_name_is_wasm() {
assert_eq!(WasmBackend.name(), "wasm");
}
#[test]
fn generate_bindings_empty_api_produces_files() {
let api = ApiSurface {
crate_name: "test-lib".to_string(),
version: "0.1.0".to_string(),
types: vec![],
functions: vec![],
enums: vec![],
errors: vec![],
excluded_type_paths: ::std::collections::HashMap::new(),
excluded_trait_names: ::std::collections::HashSet::new(),
};
let config = make_config();
let files = WasmBackend.generate_bindings(&api, &config).unwrap();
assert_eq!(files.len(), 2);
assert!(files[0].path.to_string_lossy().ends_with("lib.rs"));
assert!(files[1].path.to_string_lossy().ends_with("Cargo.toml"));
}
#[test]
fn test_visitor_field_substitution_in_post_process() {
// Test the substitution logic directly with a real multi-line string
let mut content = "impl From<WasmConversionOptions> for html_to_markdown_rs::options::ConversionOptions {\n fn from(val: WasmConversionOptions) -> Self {\n Self {\n heading_style: val.heading_style.into(),\n visitor: Default::default(),\n ..Default::default()\n }\n }\n}\nimpl From<WasmConversionOptionsUpdate> for html_to_markdown_rs::options::ConversionOptionsUpdate {\n fn from(val: WasmConversionOptionsUpdate) -> Self {\n Self {\n heading_style: val.heading_style.map(Into::into),\n visitor: Default::default(),\n ..Default::default()\n }\n }\n}\n".to_string();
// Apply the same post-process logic
let field_name = "visitor";
let patterns = &[
(" ", "\n "), // 12 spaces
(" ", "\n "), // 8 spaces
(" ", "\n "), // 2 spaces
];
for (indent, newline_indent) in patterns {
let old_pattern = format!("{indent}{field_name}: Default::default(),{newline_indent}..Default::default()");
let new_pattern = format!(
"{indent}{field_name}: val.{field_name}.map(|v| (*v.inner).clone()),{newline_indent}..Default::default()"
);
if content.contains(&old_pattern) {
content = content.replace(&old_pattern, &new_pattern);
}
}
// Verify both From impls were updated
assert!(
content.contains("visitor: val.visitor.map(|v| (*v.inner).clone()),"),
"Visitor field not forwarded in From impl"
);
assert!(
!content.contains("visitor: Default::default(),\n ..Default::default()"),
"Unreplaced visitor: Default::default() with 12 spaces still present"
);
}
}