Skip to main content

ts_gen/codegen/
typemap.rs

1//! TypeRef → syn::Type mapping with unified position-based system.
2//!
3//! Follows the wasm-bindgen WebIDL approach: a single `to_syn_type` function
4//! that uses `TypePosition` to determine how types are lowered to Rust.
5//!
6//! `TypePosition` is a struct with two fields:
7//! - `direction`: `Argument` or `Return` — controls borrowing (e.g., `&str` vs `String`)
8//! - `inner`: whether we're nested inside a generic container (e.g., `Promise<T>`)
9//!
10//! When `inner` is true:
11//! - Primitives map to JS wrapper types (`Number`, `JsString`, `Boolean`, `Undefined`)
12//! - `Nullable` becomes `JsOption<T>` instead of `Option<T>`
13//! - Argument-position types are NOT borrowed (owned `T`, not `&T`)
14
15use std::cell::RefCell;
16use std::collections::{HashMap, HashSet};
17
18use proc_macro2::TokenStream;
19use quote::quote;
20
21use crate::context::GlobalContext;
22use crate::ir::{self, TypeKind, TypeRef};
23use crate::parse::scope::ScopeId;
24use crate::util::diagnostics::DiagnosticCollector;
25
26/// js_sys type names reserved by the `use js_sys::*` glob import.
27/// User-defined types that collide with these will be renamed.
28pub const JS_SYS_RESERVED: &[&str] = &[
29    "Array",
30    "ArrayBuffer",
31    "ArrayTuple",
32    "AsyncGenerator",
33    "AsyncIterator",
34    "BigInt",
35    "BigInt64Array",
36    "BigUint64Array",
37    "Boolean",
38    "DataView",
39    "Date",
40    "Error",
41    "EvalError",
42    "Float32Array",
43    "Float64Array",
44    "Function",
45    "Generator",
46    "Global",
47    "Int16Array",
48    "Int32Array",
49    "Int8Array",
50    "Iterator",
51    "IteratorNext",
52    "JsOption",
53    "JsString",
54    "Map",
55    "Number",
56    "Object",
57    "Promise",
58    "Proxy",
59    "RangeError",
60    "ReferenceError",
61    "RegExp",
62    "Set",
63    "SharedArrayBuffer",
64    "Symbol",
65    "SyntaxError",
66    "TypeError",
67    "Uint16Array",
68    "Uint32Array",
69    "Uint8Array",
70    "Uint8ClampedArray",
71    "Undefined",
72    "UriError",
73    "WeakMap",
74    "WeakRef",
75    "WeakSet",
76];
77
78/// Direction of data flow at the FFI boundary.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum Direction {
81    /// Data flowing from Rust to JS (function arguments).
82    Argument,
83    /// Data flowing from JS to Rust (function returns).
84    Return,
85}
86
87/// Position context for type mapping, following the wasm-bindgen WebIDL pattern.
88///
89/// Combines a direction (Argument/Return) with an inner flag indicating
90/// whether we're inside a generic container. When `inner` is true,
91/// primitives use their JS wrapper types and nullable uses `JsOption`.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub struct TypePosition {
94    pub direction: Direction,
95    /// Whether this type is nested inside a generic or callback.
96    /// When true, must use JS-compatible wrapper types.
97    pub inner: bool,
98}
99
100impl TypePosition {
101    /// Top-level function argument position.
102    pub const ARGUMENT: Self = Self {
103        direction: Direction::Argument,
104        inner: false,
105    };
106    /// Top-level function return position.
107    pub const RETURN: Self = Self {
108        direction: Direction::Return,
109        inner: false,
110    };
111
112    /// Convert to inner position (for generic type parameters).
113    /// Preserves direction but sets `inner: true`.
114    pub fn to_inner(self) -> Self {
115        Self {
116            direction: self.direction,
117            inner: true,
118        }
119    }
120
121    pub fn is_argument(self) -> bool {
122        matches!(self.direction, Direction::Argument)
123    }
124}
125
126/// Context for codegen that tracks locally-defined types and resolved type aliases.
127///
128/// This allows `to_syn_type` to distinguish between locally-generated types
129/// and types that should resolve via `use js_sys::*`.
130pub struct CodegenContext<'a> {
131    /// Read-only access to the global context (scopes, modules, external map).
132    pub gctx: &'a GlobalContext,
133    /// Set of type names defined in this codegen unit (classes, interfaces, enums, etc.).
134    pub local_types: HashSet<String>,
135    /// Type aliases whose target is a union or other non-representable type.
136
137    /// Local types that collide with js_sys reserved names — maps original name → renamed name.
138    pub renamed_locals: HashMap<String, String>,
139    /// Builtin (root) scope id.
140    pub root_scope: ScopeId,
141    /// Per-file scopes (children of root, contain imports + local types).
142    pub file_scopes: Vec<ScopeId>,
143    /// External type use aliases collected during codegen: (local_name, rust_path).
144    pub external_uses: RefCell<HashMap<String, String>>,
145    /// Diagnostics collected during code generation.
146    pub diagnostics: RefCell<DiagnosticCollector>,
147}
148
149impl<'a> CodegenContext<'a> {
150    /// Build a `CodegenContext` from a parsed IR module + global context.
151    pub fn from_module(module: &ir::Module, gctx: &'a GlobalContext) -> Self {
152        let mut ctx = CodegenContext {
153            gctx,
154            local_types: HashSet::new(),
155            renamed_locals: HashMap::new(),
156            root_scope: module.builtin_scope,
157            file_scopes: module.file_scopes.clone(),
158            external_uses: RefCell::new(HashMap::new()),
159            diagnostics: RefCell::new(DiagnosticCollector::new()),
160        };
161        for &type_id in &module.types {
162            let decl = gctx.get_type(type_id);
163            ctx.collect_declaration(&decl.kind);
164        }
165        ctx.resolve_collisions();
166        ctx
167    }
168
169    /// Create an empty context (for tests). Requires a valid root scope.
170    pub fn empty(gctx: &'a GlobalContext, root_scope: ScopeId) -> Self {
171        CodegenContext {
172            gctx,
173            local_types: HashSet::new(),
174            renamed_locals: HashMap::new(),
175            root_scope,
176            file_scopes: vec![],
177            external_uses: RefCell::new(HashMap::new()),
178            diagnostics: RefCell::new(DiagnosticCollector::new()),
179        }
180    }
181
182    /// Register an external type use alias.
183    /// Returns the local name to use in generated code.
184    fn register_external(&self, local_name: &str, rust_path: &str) {
185        self.external_uses
186            .borrow_mut()
187            .insert(local_name.to_string(), rust_path.to_string());
188    }
189
190    /// Generate `use` statements for all external type aliases.
191    pub fn external_use_tokens(&self) -> TokenStream {
192        let uses = self.external_uses.borrow();
193        let mut entries: Vec<_> = uses.iter().collect();
194        entries.sort_by_key(|(name, _)| (*name).clone());
195
196        let stmts: Vec<TokenStream> = entries
197            .into_iter()
198            .map(|(local_name, rust_path)| {
199                let local_ident = make_ident(local_name);
200                // Parse the rust path into tokens
201                let path: TokenStream = rust_path.parse().unwrap_or_else(|_| {
202                    // Fallback: just use JsValue
203                    quote! { JsValue }
204                });
205                if rust_path == "JsValue" || rust_path.ends_with("::JsValue") {
206                    // JsValue fallback: use JsValue as LocalName
207                    quote! { #[allow(dead_code)] use JsValue as #local_ident; }
208                } else {
209                    quote! { #[allow(dead_code)] use #path as #local_ident; }
210                }
211            })
212            .collect();
213
214        quote! { #(#stmts)* }
215    }
216
217    /// Resolve an external type through the external map.
218    pub fn resolve_external(
219        &self,
220        type_name: &str,
221        from_module: &str,
222    ) -> Option<crate::external_map::RustPath> {
223        self.gctx.external_map.resolve(type_name, from_module)
224    }
225
226    /// Resolve a named type through the scope chain, chasing the full alias chain
227    /// until a non-alias terminal type is reached.
228    ///
229    /// Returns the final `TypeRef` target if the name resolves to a type alias
230    /// (or a chain of aliases). Returns `None` if the name resolves to a
231    /// non-alias declaration (Class, Interface, Enum, etc.) or is not found.
232    ///
233    /// Uses a visited set to detect and break circular alias chains.
234    pub fn resolve_alias(&self, name: &str, scope: ScopeId) -> Option<&ir::TypeRef> {
235        let mut visited = HashSet::new();
236        self.resolve_alias_impl(name, scope, &mut visited)
237    }
238
239    fn resolve_alias_impl<'b>(
240        &'b self,
241        name: &str,
242        scope: ScopeId,
243        visited: &mut HashSet<String>,
244    ) -> Option<&'b ir::TypeRef> {
245        if !visited.insert(name.to_string()) {
246            return None; // circular alias chain
247        }
248        if let Some(type_id) = self.gctx.scopes.resolve(scope, name) {
249            let decl = self.gctx.get_type(type_id);
250            if let TypeKind::TypeAlias(ref alias) = decl.kind {
251                // If the target is itself a named reference, keep resolving.
252                if let ir::TypeRef::Named(ref inner_name) = alias.target {
253                    if let Some(resolved) = self.resolve_alias_impl(inner_name, scope, visited) {
254                        return Some(resolved);
255                    }
256                }
257                return Some(&alias.target);
258            }
259        }
260        None
261    }
262
263    /// Emit an error diagnostic during code generation.
264    pub fn error(&self, message: impl Into<String>) {
265        self.diagnostics.borrow_mut().error(message);
266    }
267
268    /// Emit a warning diagnostic during code generation.
269    pub fn warn(&self, message: impl Into<String>) {
270        self.diagnostics.borrow_mut().warn(message);
271    }
272
273    /// Take ownership of the collected diagnostics.
274    pub fn take_diagnostics(&self) -> DiagnosticCollector {
275        self.diagnostics.take()
276    }
277
278    /// Detect collisions between local type names and the js_sys glob import.
279    /// Colliding local types get renamed with a trailing underscore.
280    fn resolve_collisions(&mut self) {
281        let reserved: HashSet<&str> = JS_SYS_RESERVED.iter().copied().collect();
282
283        for name in &reserved {
284            if self.local_types.contains(*name) {
285                let mut renamed = format!("{name}_");
286                let mut i = 2;
287                while self.local_types.contains(&renamed) || reserved.contains(renamed.as_str()) {
288                    renamed = format!("{name}_{i}");
289                    i += 1;
290                }
291                self.renamed_locals.insert(name.to_string(), renamed);
292            }
293        }
294    }
295
296    fn collect_declaration(&mut self, kind: &ir::TypeKind) {
297        match kind {
298            ir::TypeKind::Class(c) => {
299                self.local_types.insert(c.name.clone());
300            }
301            ir::TypeKind::Interface(i) => {
302                self.local_types.insert(i.name.clone());
303            }
304            ir::TypeKind::StringEnum(e) => {
305                self.local_types.insert(e.name.clone());
306            }
307            ir::TypeKind::NumericEnum(e) => {
308                self.local_types.insert(e.name.clone());
309            }
310            ir::TypeKind::TypeAlias(_) => {
311                // Type aliases are resolved through the scope during codegen.
312                // No special collection needed.
313            }
314            ir::TypeKind::Namespace(ns) => {
315                for inner in &ns.declarations {
316                    self.collect_declaration(&inner.kind);
317                }
318            }
319            ir::TypeKind::Function(_) | ir::TypeKind::Variable(_) => {}
320        }
321    }
322}
323
324/// Map an IR `TypeRef` to a `proc_macro2::TokenStream` representing the Rust type.
325///
326/// This is the unified type mapping function, following the wasm-bindgen WebIDL
327/// `to_syn_type` pattern. A single function handles all positions:
328///
329/// - When `pos.inner` is true, primitives become JS wrapper types
330///   (`Number`, `JsString`, `Boolean`, `Undefined`), nullable becomes `JsOption`,
331///   and argument-position types are NOT borrowed.
332/// - When `pos.inner` is false, standard Rust types are used (`f64`, `&str`/`String`,
333///   `bool`, `()`), nullable becomes `Option<T>`, and argument-position types
334///   may be borrowed.
335pub fn to_syn_type(
336    ty: &TypeRef,
337    pos: TypePosition,
338    ctx: Option<&CodegenContext<'_>>,
339    scope: ScopeId,
340) -> TokenStream {
341    // When inner, intercept primitives and nullable early to use JS wrapper forms
342    if pos.inner {
343        match ty {
344            TypeRef::Boolean | TypeRef::BooleanLiteral(_) => return quote! { Boolean },
345            TypeRef::Number | TypeRef::NumberLiteral(_) => return quote! { Number },
346            TypeRef::String | TypeRef::StringLiteral(_) => return quote! { JsString },
347            TypeRef::Void | TypeRef::Undefined => return quote! { Undefined },
348            TypeRef::Nullable(inner) => {
349                let inner_ty = to_syn_type(inner, pos, ctx, scope);
350                return quote! { JsOption<#inner_ty> };
351            }
352            _ => {}
353        }
354    }
355
356    // Helper: should this type get `&` in argument position?
357    // Returns true for all JS/non-Rust types (anything that crosses the FFI boundary
358    // as a wasm-bindgen reference). Rust-native primitives (bool, f64, ()) do NOT get `&`.
359    let borrow = pos.is_argument() && !pos.inner;
360
361    match ty {
362        // === Primitives (outer position only reaches here) ===
363        TypeRef::Boolean => quote! { bool },
364        TypeRef::Number => quote! { f64 },
365        TypeRef::String => {
366            if borrow {
367                quote! { &str }
368            } else {
369                quote! { String }
370            }
371        }
372        TypeRef::BigInt => maybe_ref(quote! { BigInt }, borrow),
373        TypeRef::Void => quote! { () },
374        TypeRef::Undefined => maybe_ref(quote! { Undefined }, borrow),
375        TypeRef::Null => maybe_ref(quote! { JsValue }, borrow),
376        TypeRef::Any => maybe_ref(quote! { JsValue }, borrow),
377        TypeRef::Unknown => maybe_ref(quote! { JsValue }, borrow),
378        TypeRef::Object => maybe_ref(quote! { Object }, borrow),
379        TypeRef::Symbol => maybe_ref(quote! { JsValue }, borrow),
380
381        // === Typed Arrays ===
382        TypeRef::Int8Array => maybe_ref(quote! { Int8Array }, borrow),
383        TypeRef::Uint8Array => maybe_ref(quote! { Uint8Array }, borrow),
384        TypeRef::Uint8ClampedArray => maybe_ref(quote! { Uint8ClampedArray }, borrow),
385        TypeRef::Int16Array => maybe_ref(quote! { Int16Array }, borrow),
386        TypeRef::Uint16Array => maybe_ref(quote! { Uint16Array }, borrow),
387        TypeRef::Int32Array => maybe_ref(quote! { Int32Array }, borrow),
388        TypeRef::Uint32Array => maybe_ref(quote! { Uint32Array }, borrow),
389        TypeRef::Float32Array => maybe_ref(quote! { Float32Array }, borrow),
390        TypeRef::Float64Array => maybe_ref(quote! { Float64Array }, borrow),
391        TypeRef::BigInt64Array => maybe_ref(quote! { BigInt64Array }, borrow),
392        TypeRef::BigUint64Array => maybe_ref(quote! { BigUint64Array }, borrow),
393        TypeRef::ArrayBuffer => maybe_ref(quote! { ArrayBuffer }, borrow),
394        TypeRef::ArrayBufferView => maybe_ref(quote! { Object }, borrow),
395        TypeRef::DataView => maybe_ref(quote! { DataView }, borrow),
396
397        // === Built-in Generic Containers ===
398        TypeRef::Promise(inner) => maybe_ref(
399            generic_container(quote! { Promise }, inner, pos, ctx, scope),
400            borrow,
401        ),
402        TypeRef::Array(inner) => maybe_ref(
403            generic_container(quote! { Array }, inner, pos, ctx, scope),
404            borrow,
405        ),
406        TypeRef::Record(_k, v) => maybe_ref(
407            generic_container(quote! { Object }, v, pos, ctx, scope),
408            borrow,
409        ),
410        TypeRef::Map(k, v) => {
411            let inner_pos = pos.to_inner();
412            let k_arg = to_syn_type(k, inner_pos, ctx, scope);
413            let v_arg = to_syn_type(v, inner_pos, ctx, scope);
414            let base = if is_jsvalue_arg(&k_arg) && is_jsvalue_arg(&v_arg) {
415                quote! { Map }
416            } else {
417                quote! { Map<#k_arg, #v_arg> }
418            };
419            maybe_ref(base, borrow)
420        }
421        TypeRef::Set(inner) => maybe_ref(
422            generic_container(quote! { Set }, inner, pos, ctx, scope),
423            borrow,
424        ),
425
426        // === Structural Types ===
427        TypeRef::Nullable(inner) => {
428            if pos.inner {
429                let inner_ty = to_syn_type(inner, pos, ctx, scope);
430                quote! { JsOption<#inner_ty> }
431            } else {
432                let inner_ty = to_syn_type(inner, pos, ctx, scope);
433                quote! { Option<#inner_ty> }
434            }
435        }
436        TypeRef::Union(_) => maybe_ref(quote! { JsValue }, borrow),
437        TypeRef::Intersection(_) => maybe_ref(quote! { JsValue }, borrow),
438        TypeRef::Tuple(elems) => {
439            let base = if elems.is_empty() {
440                quote! { Array }
441            } else {
442                let inner_pos = pos.to_inner();
443                let elem_types: Vec<TokenStream> = elems
444                    .iter()
445                    .map(|e| to_syn_type(e, inner_pos, ctx, scope))
446                    .collect();
447                quote! { ArrayTuple<(#(#elem_types),*)> }
448            };
449            maybe_ref(base, borrow)
450        }
451        TypeRef::Function(sig) => {
452            let inner_pos = pos.to_inner();
453            let params: Vec<TokenStream> = sig
454                .params
455                .iter()
456                .take(8)
457                .map(|p| to_syn_type(&p.type_ref, inner_pos, ctx, scope))
458                .collect();
459            let ret = to_syn_type(&sig.return_type, inner_pos, ctx, scope);
460            let base = if params.iter().all(is_jsvalue_arg) && is_jsvalue_arg(&ret) {
461                quote! { Function }
462            } else {
463                quote! { Function<fn(#(#params),*) -> #ret> }
464            };
465            maybe_ref(base, borrow)
466        }
467
468        // === Literal Types ===
469        TypeRef::StringLiteral(_) => {
470            if borrow {
471                quote! { &str }
472            } else {
473                quote! { String }
474            }
475        }
476        TypeRef::NumberLiteral(_) => quote! { f64 },
477        TypeRef::BooleanLiteral(_) => quote! { bool },
478
479        // === Named References ===
480        TypeRef::Named(name) => {
481            // Resolve through type aliases before falling back to named_type_to_rust.
482            if let Some(c) = ctx {
483                if let Some(target) = c.resolve_alias(name, scope) {
484                    let target = target.clone();
485                    return to_syn_type(&target, pos, ctx, scope);
486                }
487            }
488            maybe_ref(named_type_to_rust(name, ctx), borrow)
489        }
490        TypeRef::GenericInstantiation(name, _args) => {
491            // TODO (Phase 3): preserve generic type arguments once wasm_bindgen
492            // generic support is wired through. For now, emit just the base type.
493            if let Some(c) = ctx {
494                c.warn(format!(
495                    "generic type arguments on `{name}<...>` are not yet emitted, using bare `{name}`"
496                ));
497            }
498            maybe_ref(named_type_to_rust(name, ctx), borrow)
499        }
500
501        // === Special ===
502        TypeRef::Date => maybe_ref(quote! { Date }, borrow),
503        TypeRef::RegExp => maybe_ref(quote! { RegExp }, borrow),
504        TypeRef::Error => maybe_ref(quote! { Error }, borrow),
505
506        // === Fallback ===
507        TypeRef::Unresolved(desc) => {
508            if let Some(cgctx) = ctx {
509                cgctx.warn(format!("unresolved type `{desc}`, falling back to JsValue"));
510            }
511            maybe_ref(quote! { JsValue }, borrow)
512        }
513    }
514}
515
516/// Wrap a type in `&` when in argument position (the `externref` pattern from wasm-bindgen WebIDL).
517///
518/// All JS object types (anything that isn't a Rust `Copy` primitive like `bool`/`f64`)
519/// are passed by reference in argument position at the top level.
520fn maybe_ref(ty: TokenStream, borrow: bool) -> TokenStream {
521    if borrow {
522        quote! { &#ty }
523    } else {
524        ty
525    }
526}
527
528/// Helper: emit `Base<T'>` or just `Base` if T' is JsValue (the default).
529fn generic_container(
530    base: TokenStream,
531    inner: &TypeRef,
532    pos: TypePosition,
533    ctx: Option<&CodegenContext<'_>>,
534    scope: ScopeId,
535) -> TokenStream {
536    let arg = to_syn_type(inner, pos.to_inner(), ctx, scope);
537    if is_jsvalue_arg(&arg) {
538        base
539    } else {
540        quote! { #base<#arg> }
541    }
542}
543
544/// Check if a generic argument token stream represents `JsValue` (the default).
545/// When it is the default, we elide the generic parameter.
546fn is_jsvalue_arg(tokens: &TokenStream) -> bool {
547    let s = tokens.to_string();
548    s == "JsValue"
549}
550
551/// Emit a type name as Rust tokens.
552///
553/// Single unified path for ALL type name emission:
554/// 1. Resolve name → TypeId through scope
555/// 2. Get canonical name (last segment for dotted paths)
556/// 3. Local type (in our output)? → emit directly (with js_sys collision rename)
557/// 4. Not local → external map lookup → use alias
558/// 5. Not in external map → js_sys type? → emit directly (glob import covers it)
559/// 6. Nothing? → error + `use JsValue as Foo;`
560fn emit_type_name(name: &str, ctx: &CodegenContext<'_>) -> TokenStream {
561    // Resolve through scope
562    let resolved = ctx.file_scopes.iter().find_map(|&scope| {
563        if name.contains('.') {
564            ctx.gctx.resolve_path(scope, name)
565        } else {
566            ctx.gctx.scopes.resolve(scope, name)
567        }
568    });
569
570    // Canonical ident name (last segment for dotted paths)
571    let ident_name = name.rsplit('.').next().unwrap_or(name);
572
573    // If resolved to a namespace (not a type), emit JsValue
574    if let Some(type_id) = resolved {
575        if matches!(&ctx.gctx.get_type(type_id).kind, TypeKind::Namespace(_)) {
576            return quote! { JsValue };
577        }
578    }
579
580    // Local type (defined in our output) → emit directly
581    if ctx.local_types.contains(ident_name) {
582        if let Some(renamed) = ctx.renamed_locals.get(ident_name) {
583            let ident = make_ident(renamed);
584            return quote! { #ident };
585        }
586        let ident = make_ident(ident_name);
587        return quote! { #ident };
588    }
589
590    // External map
591    if let Some(rust_path) = ctx.gctx.external_map.resolve_type(ident_name) {
592        ctx.register_external(ident_name, &rust_path.path);
593        let ident = make_ident(ident_name);
594        return quote! { #ident };
595    }
596
597    // Type resolved through scope but is not local and not in external map.
598    // It's a dependency type — register as JsValue alias (user needs --external).
599    if resolved.is_some() {
600        ctx.error(format!(
601            "Non-local type `{name}` resolved but has no external mapping. \
602             Use --external to map this type."
603        ));
604        ctx.register_external(ident_name, "JsValue");
605        let ident = make_ident(ident_name);
606        return quote! { #ident };
607    }
608
609    // Type did NOT resolve through scope at all.
610    // js_sys type? (available via `use js_sys::*`)
611    if JS_SYS_RESERVED.contains(&ident_name) {
612        let ident = make_ident(ident_name);
613        return quote! { #ident };
614    }
615
616    // Truly unresolved — error + JsValue alias
617    ctx.error(format!(
618        "Unresolved type `{name}`. Use --external to map this type."
619    ));
620    ctx.register_external(ident_name, "JsValue");
621    let ident = make_ident(ident_name);
622    quote! { #ident }
623}
624
625/// Backward-compatible wrapper: calls `emit_type_name` when ctx is available.
626fn named_type_to_rust(name: &str, ctx: Option<&CodegenContext<'_>>) -> TokenStream {
627    match ctx {
628        Some(ctx) => emit_type_name(name, ctx),
629        None => quote! { JsValue },
630    }
631}
632
633/// Create a `syn::Ident`, sanitizing invalid characters and escaping keywords.
634pub(crate) fn make_ident(name: &str) -> syn::Ident {
635    // Strip characters that aren't valid in Rust identifiers
636    let sanitized: String = name
637        .chars()
638        .filter(|c| c.is_alphanumeric() || *c == '_')
639        .collect();
640    let sanitized = if sanitized.is_empty() {
641        "__unknown__".to_string()
642    } else if sanitized.starts_with(|c: char| c.is_ascii_digit()) {
643        format!("_{sanitized}")
644    } else {
645        sanitized
646    };
647    // Try as a normal identifier first.
648    if let Ok(ident) = syn::parse_str::<syn::Ident>(&sanitized) {
649        return ident;
650    }
651    // `self`, `Self`, `super`, `crate` cannot be raw identifiers — append `_`.
652    match sanitized.as_str() {
653        "self" | "Self" | "super" | "crate" => {
654            syn::Ident::new(&format!("{sanitized}_"), proc_macro2::Span::call_site())
655        }
656        // All other keywords can use r# raw identifiers.
657        _ => syn::Ident::new_raw(&sanitized, proc_macro2::Span::call_site()),
658    }
659}
660
661/// Map an IR `TypeRef` to the type used in a wasm_bindgen return position,
662/// wrapping in `Result<T, JsValue>` when `catch` is true.
663pub fn to_return_type(
664    ty: &TypeRef,
665    catch: bool,
666    ctx: Option<&CodegenContext<'_>>,
667    scope: ScopeId,
668) -> TokenStream {
669    let inner = to_syn_type(ty, TypePosition::RETURN, ctx, scope);
670    if catch {
671        quote! { Result<#inner, JsValue> }
672    } else {
673        inner
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    use crate::parse::scope::ScopeId;
682
683    // Helper to run to_syn_type with ARGUMENT position
684    fn arg_type(ty: &TypeRef) -> String {
685        // Scope is unused when ctx is None — use a dummy value.
686        to_syn_type(ty, TypePosition::ARGUMENT, None, ScopeId(0)).to_string()
687    }
688
689    fn ret_type(ty: &TypeRef) -> String {
690        to_syn_type(ty, TypePosition::RETURN, None, ScopeId(0)).to_string()
691    }
692
693    fn inner_type(ty: &TypeRef) -> String {
694        to_syn_type(ty, TypePosition::RETURN.to_inner(), None, ScopeId(0)).to_string()
695    }
696
697    #[test]
698    fn test_string_positions() {
699        assert_eq!(arg_type(&TypeRef::String), "& str");
700        assert_eq!(ret_type(&TypeRef::String), "String");
701    }
702
703    #[test]
704    fn test_string_inner_position() {
705        // Inner position: string → JsString
706        assert_eq!(inner_type(&TypeRef::String), "JsString");
707    }
708
709    #[test]
710    fn test_number_inner_position() {
711        // Inner position: number → Number
712        assert_eq!(inner_type(&TypeRef::Number), "Number");
713    }
714
715    #[test]
716    fn test_boolean_inner_position() {
717        // Inner position: boolean → Boolean
718        assert_eq!(inner_type(&TypeRef::Boolean), "Boolean");
719    }
720
721    #[test]
722    fn test_void_inner_position() {
723        // Inner position: void → Undefined
724        assert_eq!(inner_type(&TypeRef::Void), "Undefined");
725    }
726
727    #[test]
728    fn test_nullable() {
729        let ty = TypeRef::Nullable(Box::new(TypeRef::String));
730        // Option<T> passes through position — Option<String> at return position
731        let result = ret_type(&ty);
732        assert_eq!(result, "Option < String >");
733    }
734
735    #[test]
736    fn test_promise_with_named_type_unresolved() {
737        // Without ctx, Foo is unresolved → JsValue, so Promise<JsValue> elides to Promise
738        let ty = TypeRef::Promise(Box::new(TypeRef::Named("Foo".into())));
739        assert_eq!(ret_type(&ty), "Promise");
740    }
741
742    #[test]
743    fn test_nullable_inner() {
744        // Nullable inside generic (inner position) → JsOption
745        let ty = TypeRef::Nullable(Box::new(TypeRef::String));
746        let result = inner_type(&ty);
747        assert_eq!(result, "JsOption < JsString >");
748    }
749
750    #[test]
751    fn test_promise_with_string() {
752        let ty = TypeRef::Promise(Box::new(TypeRef::String));
753        let result = ret_type(&ty);
754        assert_eq!(result, "Promise < JsString >");
755    }
756
757    #[test]
758    fn test_promise_with_any_elides_generic() {
759        let ty = TypeRef::Promise(Box::new(TypeRef::Any));
760        let result = ret_type(&ty);
761        assert_eq!(result, "Promise");
762    }
763
764    #[test]
765    fn test_promise_with_void() {
766        let ty = TypeRef::Promise(Box::new(TypeRef::Void));
767        let result = ret_type(&ty);
768        assert_eq!(result, "Promise < Undefined >");
769    }
770
771    #[test]
772    fn test_nullable_named_type_unresolved() {
773        // Without ctx, Foo is unresolved → JsValue
774        let ty = TypeRef::Nullable(Box::new(TypeRef::Named("Foo".into())));
775        assert_eq!(arg_type(&ty), "Option < & JsValue >");
776        assert_eq!(ret_type(&ty), "Option < JsValue >");
777    }
778
779    #[test]
780    fn test_promise_with_arraybuffer() {
781        let ty = TypeRef::Promise(Box::new(TypeRef::ArrayBuffer));
782        let result = ret_type(&ty);
783        assert_eq!(result, "Promise < ArrayBuffer >");
784    }
785
786    #[test]
787    fn test_array_with_type() {
788        let ty = TypeRef::Array(Box::new(TypeRef::Number));
789        let result = ret_type(&ty);
790        assert_eq!(result, "Array < Number >");
791    }
792
793    #[test]
794    fn test_array_with_any_elides() {
795        let ty = TypeRef::Array(Box::new(TypeRef::Any));
796        let result = ret_type(&ty);
797        assert_eq!(result, "Array");
798    }
799
800    #[test]
801    fn test_set_with_type() {
802        let ty = TypeRef::Set(Box::new(TypeRef::String));
803        let result = ret_type(&ty);
804        assert_eq!(result, "Set < JsString >");
805    }
806
807    #[test]
808    fn test_map_with_types() {
809        let ty = TypeRef::Map(Box::new(TypeRef::String), Box::new(TypeRef::Number));
810        let result = ret_type(&ty);
811        assert_eq!(result, "Map < JsString , Number >");
812    }
813
814    #[test]
815    fn test_record_erases_key() {
816        let ty = TypeRef::Record(Box::new(TypeRef::String), Box::new(TypeRef::Number));
817        let result = ret_type(&ty);
818        assert_eq!(result, "Object < Number >");
819    }
820
821    #[test]
822    fn test_promise_nullable_inner() {
823        // Promise<string | null> → Promise<JsOption<JsString>>
824        let ty = TypeRef::Promise(Box::new(TypeRef::Nullable(Box::new(TypeRef::String))));
825        let result = ret_type(&ty);
826        assert_eq!(result, "Promise < JsOption < JsString > >");
827    }
828
829    #[test]
830    fn test_function_typed() {
831        let sig = ir::FunctionSig {
832            params: vec![ir::Param {
833                name: "x".into(),
834                type_ref: TypeRef::Number,
835                optional: false,
836                variadic: false,
837            }],
838            return_type: Box::new(TypeRef::Boolean),
839        };
840        let ty = TypeRef::Function(sig);
841        let result = ret_type(&ty);
842        assert_eq!(result, "Function < fn (Number) -> Boolean >");
843    }
844
845    #[test]
846    fn test_function_untyped() {
847        let sig = ir::FunctionSig {
848            params: vec![ir::Param {
849                name: "x".into(),
850                type_ref: TypeRef::Any,
851                optional: false,
852                variadic: false,
853            }],
854            return_type: Box::new(TypeRef::Any),
855        };
856        let ty = TypeRef::Function(sig);
857        let result = ret_type(&ty);
858        assert_eq!(result, "Function");
859    }
860
861    #[test]
862    fn test_named_unresolved_without_ctx() {
863        // Without a CodegenContext, unknown types fall back to JsValue
864        let ty = TypeRef::Named("Request".into());
865        assert_eq!(ret_type(&ty), "JsValue");
866    }
867
868    #[test]
869    fn test_named_unknown_without_ctx() {
870        let ty = TypeRef::Named("MyCustomType".into());
871        assert_eq!(ret_type(&ty), "JsValue");
872    }
873
874    #[test]
875    fn test_return_with_catch() {
876        let ty = TypeRef::Promise(Box::new(TypeRef::Void));
877        let result = to_return_type(&ty, true, None, ScopeId(0)).to_string();
878        assert_eq!(result, "Result < Promise < Undefined > , JsValue >");
879    }
880
881    #[test]
882    fn test_union_erases() {
883        let ty = TypeRef::Union(vec![TypeRef::String, TypeRef::Number]);
884        // Unions erase to JsValue, but in argument position they're borrowed
885        assert_eq!(arg_type(&ty), "& JsValue");
886        assert_eq!(ret_type(&ty), "JsValue");
887    }
888
889    fn test_gctx() -> (GlobalContext, ScopeId) {
890        let mut gctx = GlobalContext::new();
891        let scope = gctx.create_root_scope();
892        (gctx, scope)
893    }
894
895    #[test]
896    fn test_local_type_overrides_web_sys() {
897        let (gctx, scope) = test_gctx();
898        let mut ctx = CodegenContext::empty(&gctx, scope);
899        ctx.local_types.insert("Response".into());
900        let ty = TypeRef::Named("Response".into());
901        let result = to_syn_type(&ty, TypePosition::RETURN, Some(&ctx), scope).to_string();
902        assert_eq!(result, "Response");
903    }
904
905    #[test]
906    fn test_union_alias_resolves_to_jsvalue() {
907        // A type alias to a union resolves through the scope and erases to JsValue.
908        let (mut gctx, scope) = test_gctx();
909        let alias_id = gctx.insert_type(crate::ir::TypeDeclaration {
910            kind: crate::ir::TypeKind::TypeAlias(crate::ir::TypeAliasDecl {
911                name: "BodyInit".to_string(),
912                type_params: vec![],
913                target: TypeRef::Union(vec![TypeRef::String, TypeRef::ArrayBuffer]),
914                from_module: None,
915            }),
916            module_context: crate::ir::ModuleContext::Global,
917            doc: None,
918            scope_id: scope,
919            exported: false,
920        });
921        gctx.scopes.insert(scope, "BodyInit".to_string(), alias_id);
922
923        let ctx = CodegenContext::empty(&gctx, scope);
924        let ty = TypeRef::Named("BodyInit".into());
925        let result = to_syn_type(&ty, TypePosition::RETURN, Some(&ctx), scope).to_string();
926        assert_eq!(result, "JsValue");
927    }
928
929    #[test]
930    fn test_unresolved_with_ctx_registers_jsvalue_alias() {
931        let (gctx, scope) = test_gctx();
932        let ctx = CodegenContext::empty(&gctx, scope);
933        let ty = TypeRef::Named("Response".into());
934        let result = to_syn_type(&ty, TypePosition::RETURN, Some(&ctx), scope).to_string();
935        // With ctx, unresolved types emit the name (aliased to JsValue via use statement)
936        assert_eq!(result, "Response");
937        // Verify the JsValue alias was registered
938        let uses = ctx.external_uses.borrow();
939        assert_eq!(uses.get("Response"), Some(&"JsValue".to_string()));
940    }
941
942    #[test]
943    fn test_local_type_in_promise() {
944        let (gctx, scope) = test_gctx();
945        let mut ctx = CodegenContext::empty(&gctx, scope);
946        ctx.local_types.insert("MyThing".into());
947        let ty = TypeRef::Promise(Box::new(TypeRef::Named("MyThing".into())));
948        let result = to_syn_type(&ty, TypePosition::RETURN, Some(&ctx), scope).to_string();
949        assert_eq!(result, "Promise < MyThing >");
950    }
951
952    // === New tests for the unified approach ===
953
954    #[test]
955    fn test_to_inner_preserves_direction() {
956        let pos = TypePosition::ARGUMENT.to_inner();
957        assert!(pos.is_argument());
958        assert!(pos.inner);
959
960        let pos = TypePosition::RETURN.to_inner();
961        assert!(!pos.is_argument());
962        assert!(pos.inner);
963    }
964
965    #[test]
966    fn test_inner_position_named_type_unresolved() {
967        // Without ctx, unresolved named types → JsValue
968        let ty = TypeRef::Named("Response".into());
969        assert_eq!(inner_type(&ty), "JsValue");
970        assert_eq!(ret_type(&ty), "JsValue");
971    }
972
973    #[test]
974    fn test_inner_position_typed_array_unchanged() {
975        // Typed arrays pass through in inner position
976        let ty = TypeRef::Uint8Array;
977        assert_eq!(inner_type(&ty), "Uint8Array");
978        assert_eq!(ret_type(&ty), "Uint8Array");
979    }
980
981    #[test]
982    fn test_tuple_generates_array_tuple() {
983        // Without ctx, named types are unresolved → JsValue, so Array<JsValue> elides to Array
984        let ty = TypeRef::Tuple(vec![
985            TypeRef::Array(Box::new(TypeRef::Named("ImportSpecifier".into()))),
986            TypeRef::Array(Box::new(TypeRef::Named("ExportSpecifier".into()))),
987            TypeRef::Boolean,
988            TypeRef::Boolean,
989        ]);
990        let result = ret_type(&ty);
991        assert_eq!(result, "ArrayTuple < (Array , Array , Boolean , Boolean) >");
992    }
993
994    #[test]
995    fn test_empty_tuple_is_bare_array() {
996        let ty = TypeRef::Tuple(vec![]);
997        assert_eq!(ret_type(&ty), "Array");
998    }
999
1000    #[test]
1001    fn test_type_position_all_variants() {
1002        // Verify TypePosition constants and to_inner() work correctly
1003        let ty = TypeRef::String;
1004        assert_eq!(
1005            to_syn_type(&ty, TypePosition::ARGUMENT, None, ScopeId(0)).to_string(),
1006            "& str"
1007        );
1008        assert_eq!(
1009            to_syn_type(&ty, TypePosition::RETURN, None, ScopeId(0)).to_string(),
1010            "String"
1011        );
1012        // to_inner() → inner:true, so should give JsString
1013        assert_eq!(
1014            to_syn_type(&ty, TypePosition::RETURN.to_inner(), None, ScopeId(0)).to_string(),
1015            "JsString"
1016        );
1017        // Argument inner also gives JsString (inner overrides borrowing)
1018        assert_eq!(
1019            to_syn_type(&ty, TypePosition::ARGUMENT.to_inner(), None, ScopeId(0)).to_string(),
1020            "JsString"
1021        );
1022    }
1023}