ts-gen 0.1.0

Generate wasm-bindgen Rust bindings from TypeScript .d.ts files
Documentation
//! Code generation: IR → Rust source code.
//!
//! The main entry point is [`generate`], which takes a parsed IR [`Module`]
//! and produces formatted Rust source code as a string.

pub mod classes;
pub mod enums;
pub mod functions;
pub mod signatures;
pub mod typemap;

use proc_macro2::TokenStream;
use quote::quote;

use crate::ir::{InterfaceClassification, Module, TypeDeclaration, TypeKind};
use crate::parse::scope::ScopeId;

use typemap::CodegenContext;

/// Convert an optional doc string into `/// ...` doc-comment attributes.
///
/// Returns an empty `TokenStream` if `doc` is `None`.
pub(crate) fn doc_tokens(doc: &Option<String>) -> TokenStream {
    match doc {
        Some(text) => {
            let lines: Vec<TokenStream> = text
                .lines()
                .map(|line| {
                    let line = format!(" {line}");
                    quote! { #[doc = #line] }
                })
                .collect();
            quote! { #(#lines)* }
        }
        None => quote! {},
    }
}

/// Options for controlling code generation output.
#[derive(Debug, Clone, Default)]
pub struct GenerateOptions {
    /// When `true`, omit the `PromiseExt` trait and its `impl` block from
    /// the generated preamble. Useful when multiple generated files are
    /// compiled in the same crate and only one copy of the trait is needed.
    pub skip_promise_ext: bool,
}

/// Generate Rust source code from a parsed IR module + global context.
///
/// Returns the formatted source as a string, ready to be written to a file.
pub fn generate(module: &Module, gctx: &crate::context::GlobalContext) -> anyhow::Result<String> {
    generate_with_options(module, gctx, &GenerateOptions::default())
}

/// Generate Rust source code with explicit options.
pub fn generate_with_options(
    module: &Module,
    gctx: &crate::context::GlobalContext,
    options: &GenerateOptions,
) -> anyhow::Result<String> {
    let tokens = generate_tokens(module, gctx, options);
    let file = syn::parse2::<syn::File>(tokens.clone()).map_err(|e| {
        anyhow::anyhow!("generated tokens are not valid syn:\n{e}\n\nTokens:\n{tokens}")
    })?;
    Ok(prettyplease::unparse(&file))
}

/// Generate the token stream for a full module.
fn generate_tokens(
    module: &Module,
    gctx: &crate::context::GlobalContext,
    options: &GenerateOptions,
) -> TokenStream {
    let cgctx = CodegenContext::from_module(module, gctx);

    let promise_ext = if options.skip_promise_ext {
        quote! {}
    } else {
        quote! {
            /// Extension trait for awaiting `js_sys::Promise<T>`.
            ///
            /// Since `IntoFuture` can't be implemented for `js_sys::Promise` from
            /// generated code (orphan rule), use `.into_future().await` instead:
            /// ```ignore
            /// use bindings::PromiseExt;
            /// let data: ArrayBuffer = promise.into_future().await?;
            /// ```
            pub trait PromiseExt {
                type Output;
                fn into_future(self) -> wasm_bindgen_futures::JsFuture<Self::Output>;
            }

            impl<T: 'static + wasm_bindgen::convert::FromWasmAbi> PromiseExt for js_sys::Promise<T> {
                type Output = T;
                fn into_future(self) -> wasm_bindgen_futures::JsFuture<T> {
                    wasm_bindgen_futures::JsFuture::from(self)
                }
            }
        }
    };

    let preamble = quote! {
        // Auto-generated by ts-gen. Do not edit.

        #[allow(unused_imports)]
        use wasm_bindgen::prelude::*;
        #[allow(unused_imports)]
        use js_sys::*;

        #promise_ext
    };

    // Group declarations by module context.
    // Global declarations go at the top level.
    // Module declarations go inside `mod <name> { ... }` blocks.
    let mut global_items = Vec::new();
    let mut module_items: std::collections::BTreeMap<std::rc::Rc<str>, Vec<TokenStream>> =
        std::collections::BTreeMap::new();

    for &type_id in &module.types {
        let decl = gctx.get_type(type_id);
        if let Some(tokens) = generate_declaration(decl, &cgctx) {
            match &decl.module_context {
                crate::ir::ModuleContext::Global => {
                    global_items.push(tokens);
                }
                crate::ir::ModuleContext::Module(m) => {
                    module_items.entry(m.clone()).or_default().push(tokens);
                }
            }
        }
    }

    // Wrap each module's items in a `mod` block
    let mod_blocks: Vec<TokenStream> = module_items
        .into_iter()
        .map(|(mod_specifier, items)| {
            // Strip protocol prefix: "cloudflare:email" → "email", "node:url" → "url"
            let short_name = mod_specifier
                .rsplit_once(':')
                .map(|(_, rest)| rest)
                .unwrap_or(&mod_specifier);
            let mod_name = typemap::make_ident(&crate::util::naming::to_snake_case(
                &short_name.replace('/', "_").replace('*', "star"),
            ));
            quote! {
                pub mod #mod_name {
                    use wasm_bindgen::prelude::*;
                    use js_sys::*;
                    use super::*;
                    #(#items)*
                }
            }
        })
        .collect();

    // External type use aliases (collected during codegen above)
    let external_uses = cgctx.external_use_tokens();

    // Emit codegen diagnostics
    cgctx.take_diagnostics().emit();

    quote! {
        #preamble
        #external_uses
        #(#global_items)*
        #(#mod_blocks)*
    }
}

/// Generate tokens for a single declaration.
fn generate_declaration(decl: &TypeDeclaration, cgctx: &CodegenContext) -> Option<TokenStream> {
    match &decl.kind {
        TypeKind::Class(c) => Some(classes::generate_class(
            c,
            &decl.module_context,
            Some(cgctx),
            decl.scope_id,
        )),
        TypeKind::Interface(i) => match i.classification {
            InterfaceClassification::ClassLike | InterfaceClassification::Unclassified => {
                Some(classes::generate_class_like_interface(
                    i,
                    &decl.module_context,
                    Some(cgctx),
                    None,
                    decl.scope_id,
                ))
            }
            InterfaceClassification::Dictionary => Some(classes::generate_dictionary_extern(
                i,
                &decl.module_context,
                Some(cgctx),
                None,
                decl.scope_id,
            )),
        },
        TypeKind::StringEnum(e) => Some(enums::generate_string_enum(e)),
        TypeKind::NumericEnum(e) => Some(enums::generate_numeric_enum(e)),
        TypeKind::Function(f) => Some(functions::generate_function(
            f,
            &decl.module_context,
            Some(cgctx),
            &decl.doc,
            decl.scope_id,
        )),
        TypeKind::Variable(v) => Some(functions::generate_variable(
            v,
            &decl.module_context,
            Some(cgctx),
            &decl.doc,
            None,
            decl.scope_id,
        )),
        TypeKind::TypeAlias(alias) => Some(generate_type_alias(alias, cgctx, decl.scope_id)),
        TypeKind::Namespace(ns) => Some(generate_namespace(ns, &decl.module_context, cgctx)),
    }
}

/// Generate output for a TypeAlias declaration.
///
/// - Local alias: `pub type WritableStream = Writable;`
/// - External re-export: `pub use external_crate::Foo;`
fn generate_type_alias(
    alias: &crate::ir::TypeAliasDecl,
    cgctx: &CodegenContext,
    scope: ScopeId,
) -> TokenStream {
    if let Some(ref module) = alias.from_module {
        // External re-export: resolve through external map.
        let type_name = match &alias.target {
            crate::ir::TypeRef::Named(n) => n.as_str(),
            _ => &alias.name,
        };
        if let Some(rust_path) = cgctx.resolve_external(type_name, module) {
            let path: syn::Path = syn::parse_str(&rust_path.path).unwrap_or_else(|_| {
                syn::Path::from(syn::Ident::new("JsValue", proc_macro2::Span::call_site()))
            });
            let name = typemap::make_ident(&alias.name);
            if alias.name == type_name {
                return quote! { pub use #path; };
            } else {
                return quote! { pub use #path as #name; };
            }
        }
        cgctx.warn(format!(
            "No external mapping for `{}` from \"{module}\" — emitting JsValue alias",
            type_name,
        ));
        let name = typemap::make_ident(&alias.name);
        return quote! {
            #[allow(dead_code)]
            pub type #name = JsValue;
        };
    }

    // Local alias — only emit if the target resolves to a known type.
    if let crate::ir::TypeRef::Named(ref target_name) = alias.target {
        if !cgctx.local_types.contains(target_name)
            && !crate::codegen::typemap::JS_SYS_RESERVED.contains(&target_name.as_str())
        {
            cgctx.warn(format!(
                "Type alias `{}` targets unknown type `{target_name}`, skipping",
                alias.name
            ));
            return quote! {};
        }
    }

    let target = typemap::to_syn_type(
        &alias.target,
        typemap::TypePosition::ARGUMENT.to_inner(),
        Some(cgctx),
        scope,
    );
    let name = typemap::make_ident(&alias.name);

    // Identity — skip.
    if target.to_string() == alias.name {
        return quote! {};
    }

    quote! {
        #[allow(dead_code)]
        pub type #name = #target;
    }
}

/// Generate a Rust `mod` block for a namespace, with all nested declarations.
fn generate_namespace(
    ns: &crate::ir::NamespaceDecl,
    _parent_ctx: &crate::ir::ModuleContext,
    cgctx: &CodegenContext,
) -> TokenStream {
    let mod_name = typemap::make_ident(&crate::util::naming::to_snake_case(&ns.name));
    let js_name = &ns.name;

    let items: Vec<TokenStream> = ns
        .declarations
        .iter()
        .filter_map(|decl| generate_ns_declaration(decl, js_name, cgctx))
        .collect();

    quote! {
        pub mod #mod_name {
            use wasm_bindgen::prelude::*;
            #(#items)*
        }
    }
}

/// Generate tokens for a declaration inside a namespace.
/// Adds `js_namespace` attribute to extern blocks so wasm_bindgen emits
/// the correct JS access (e.g., `WebAssembly.Module`).
fn generate_ns_declaration(
    decl: &TypeDeclaration,
    ns_js_name: &str,
    cgctx: &CodegenContext,
) -> Option<TokenStream> {
    match &decl.kind {
        TypeKind::Class(c) => Some(classes::generate_class_with_js_namespace(
            c,
            &decl.module_context,
            ns_js_name,
            Some(cgctx),
            decl.scope_id,
        )),
        TypeKind::Interface(i) => match i.classification {
            InterfaceClassification::ClassLike | InterfaceClassification::Unclassified => {
                Some(classes::generate_class_like_interface(
                    i,
                    &decl.module_context,
                    Some(cgctx),
                    Some(ns_js_name),
                    decl.scope_id,
                ))
            }
            InterfaceClassification::Dictionary => Some(classes::generate_dictionary_extern(
                i,
                &decl.module_context,
                Some(cgctx),
                Some(ns_js_name),
                decl.scope_id,
            )),
        },
        TypeKind::Function(f) => Some(functions::generate_function_with_js_namespace(
            f,
            &decl.module_context,
            ns_js_name,
            Some(cgctx),
            &decl.doc,
            decl.scope_id,
        )),
        TypeKind::StringEnum(e) => Some(enums::generate_string_enum(e)),
        TypeKind::NumericEnum(e) => Some(enums::generate_numeric_enum(e)),
        TypeKind::Variable(v) => Some(functions::generate_variable(
            v,
            &decl.module_context,
            Some(cgctx),
            &decl.doc,
            Some(ns_js_name),
            decl.scope_id,
        )),
        TypeKind::TypeAlias(_) => None,
        TypeKind::Namespace(ns) => Some(generate_namespace(ns, &decl.module_context, cgctx)),
    }
}