Skip to main content

alef_backend_napi/gen_bindings/
mod.rs

1//! NAPI-RS (Node.js) backend: orchestration and `Backend` trait implementation.
2
3pub mod capsule;
4pub mod enums;
5pub mod errors;
6pub mod functions;
7pub mod methods;
8pub mod types;
9
10use crate::type_map::NapiMapper;
11use ahash::AHashSet;
12use alef_codegen::builder::RustFileBuilder;
13use alef_codegen::generators::{self, AsyncPattern, RustBindingConfig};
14use alef_codegen::naming::to_node_name;
15use alef_core::backend::{Backend, BuildConfig, BuildDependency, Capabilities, GeneratedFile, PostBuildStep};
16use alef_core::config::{Language, NodeCapsuleTypeConfig, ResolvedCrateConfig, resolve_output_dir};
17use alef_core::ir::{ApiSurface, TypeRef};
18use std::collections::HashMap;
19use std::path::PathBuf;
20
21pub struct NapiBackend;
22
23impl NapiBackend {
24    fn binding_config<'a>(core_import: &'a str, prefix: &'a str, has_serde: bool) -> RustBindingConfig<'a> {
25        RustBindingConfig {
26            struct_attrs: &["napi"],
27            field_attrs: &[],
28            struct_derives: &["Clone"],
29            method_block_attr: Some("napi"),
30            constructor_attr: "#[napi(constructor)]",
31            static_attr: None,
32            function_attr: "#[napi]",
33            enum_attrs: &["napi(string_enum)"],
34            enum_derives: &["Clone"],
35            needs_signature: false,
36            signature_prefix: "",
37            signature_suffix: "",
38            core_import,
39            async_pattern: AsyncPattern::NapiNativeAsync,
40            has_serde,
41            // NAPI napi(object) structs don't derive Serialize — disable serde bridge
42            type_name_prefix: prefix,
43            option_duration_on_defaults: true,
44            opaque_type_names: &[],
45            skip_impl_constructor: false,
46            cast_uints_to_i32: false,
47            cast_large_ints_to_f64: false,
48            named_non_opaque_params_by_ref: false,
49            lossy_skip_types: &[],
50            serializable_opaque_type_names: &[],
51            never_skip_cfg_field_names: &[],
52        }
53    }
54}
55
56impl Backend for NapiBackend {
57    fn name(&self) -> &str {
58        "napi"
59    }
60
61    fn language(&self) -> Language {
62        Language::Node
63    }
64
65    fn capabilities(&self) -> Capabilities {
66        Capabilities {
67            supports_async: true,
68            supports_classes: true,
69            supports_enums: true,
70            supports_option: true,
71            supports_result: true,
72            ..Capabilities::default()
73        }
74    }
75
76    fn generate_bindings(&self, api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
77        let prefix = config.node_type_prefix();
78        let trait_type_names: AHashSet<String> = api
79            .types
80            .iter()
81            .filter(|t| t.is_trait)
82            .map(|t| t.name.clone())
83            .collect();
84        let capsule_type_names_for_mapper: AHashSet<String> = config
85            .node
86            .as_ref()
87            .map(|c| c.capsule_types.keys().cloned().collect())
88            .unwrap_or_default();
89        let mapper =
90            NapiMapper::with_traits_and_capsules(prefix.clone(), trait_type_names, capsule_type_names_for_mapper);
91        let core_import = config.core_import_name();
92
93        // Detect serde availability from the output crate's Cargo.toml
94        let output_dir = resolve_output_dir(config.output_paths.get("node"), &config.name, "crates/{name}-node/src/");
95        let has_serde = alef_core::config::detect_serde_available(&output_dir);
96        let mut cfg = Self::binding_config(&core_import, &prefix, has_serde);
97        let never_skip_cfg_field_names: Vec<String> = config
98            .trait_bridges
99            .iter()
100            .filter_map(|b| {
101                if b.bind_via == alef_core::config::BridgeBinding::OptionsField {
102                    b.resolved_options_field().map(|s| s.to_string())
103                } else {
104                    None
105                }
106            })
107            .collect();
108        cfg.never_skip_cfg_field_names = &never_skip_cfg_field_names;
109
110        let mut builder = RustFileBuilder::new().with_generated_header();
111        builder.add_inner_attribute("allow(dead_code, unused_imports, unused_variables)");
112        builder.add_inner_attribute("allow(unsafe_code)");
113        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::unnecessary_cast, clippy::unused_unit, clippy::unwrap_or_default, clippy::derivable_impls, clippy::needless_borrows_for_generic_args, clippy::unnecessary_fallible_conversions, clippy::arc_with_non_send_sync, clippy::collapsible_if, clippy::clone_on_copy, clippy::should_implement_trait)");
114        // Cast lints fire heavily on the JS u32/i64/Number bridge — these are
115        // intentional, deliberate at the FFI boundary. Pedantic/nursery noise
116        // (must_use_candidate, use_self, missing_const_for_fn, etc.) is
117        // suppressed for the same reasons documented in the pyo3 backend.
118        builder.add_inner_attribute(
119            "allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::default_trait_access, clippy::useless_conversion, clippy::unsafe_derive_deserialize, clippy::must_use_candidate, clippy::return_self_not_must_use, clippy::use_self, clippy::missing_const_for_fn, clippy::missing_errors_doc, clippy::needless_pass_by_value, clippy::doc_markdown, clippy::derive_partial_eq_without_eq, clippy::uninlined_format_args, clippy::redundant_clone, clippy::implicit_clone, clippy::redundant_closure_for_method_calls, clippy::wildcard_imports, clippy::option_if_let_else, clippy::too_many_lines)",
120        );
121        builder.add_import("napi::*");
122        builder.add_import("napi_derive::napi");
123
124        // Always import serde_json for type conversion in From/Into impls,
125        // even if the binding crate doesn't explicitly list it as a dependency.
126        // serde_json is needed for conversions of types with serde-serializable fields.
127        builder.add_import("serde_json");
128
129        // Import traits needed for trait method dispatch
130        for trait_path in generators::collect_trait_imports(api) {
131            builder.add_import(&trait_path);
132        }
133
134        // Only import HashMap when Map-typed fields or returns are present
135        let has_maps = api
136            .types
137            .iter()
138            .any(|t| t.fields.iter().any(|f| matches!(&f.ty, TypeRef::Map(_, _))))
139            || api
140                .functions
141                .iter()
142                .any(|f| matches!(&f.return_type, TypeRef::Map(_, _)));
143        if has_maps {
144            builder.add_import("std::collections::HashMap");
145        }
146
147        // Note: custom_modules for Node are TypeScript-only re-exports
148        // (used in generate_public_api), not Rust module declarations.
149
150        // Check if any function or method is async
151        let has_async =
152            api.functions.iter().any(|f| f.is_async) || api.types.iter().any(|t| t.methods.iter().any(|m| m.is_async));
153
154        if has_async {
155            builder.add_item(&functions::gen_tokio_runtime());
156        }
157
158        // Extract capsule_types from NodeConfig. Types listed here skip #[napi] opaque-class
159        // emission; functions returning them produce a JsObject with __parser External<T>.
160        let capsule_types: HashMap<String, NodeCapsuleTypeConfig> = config
161            .node
162            .as_ref()
163            .map(|c| c.capsule_types.clone())
164            .unwrap_or_default();
165
166        // When capsule types are present, generated shims call set_named_property which
167        // requires the JsObjectValue trait to be in scope.
168        if !capsule_types.is_empty() {
169            builder.add_import("napi::bindgen_prelude::JsObjectValue");
170            // Emit the FFI declarations for napi_create_external and napi_type_tag_object,
171            // and any per-capsule type tag constants. Done once per crate.
172            builder.add_item(&capsule::gen_ffi_declarations());
173            let constants = capsule::gen_type_tag_constants(&capsule_types);
174            if !constants.is_empty() {
175                builder.add_item(&constants);
176            }
177        }
178
179        // Check if we have opaque types and trait types (visitors)
180        // Exclude trait types from opaque_types since they use JsVisitorRef instead of Object<'static>
181        // Also exclude capsule types — they do not get #[napi] class wrappers.
182        let opaque_types: AHashSet<String> = api
183            .types
184            .iter()
185            .filter(|t| t.is_opaque && !t.is_trait && !capsule_types.contains_key(&t.name))
186            .map(|t| t.name.clone())
187            .collect();
188        let mutex_types: AHashSet<String> = api
189            .types
190            .iter()
191            .filter(|t| t.is_opaque && generators::type_needs_mutex(t))
192            .map(|t| t.name.clone())
193            .collect();
194        let has_traits = api.types.iter().any(|t| t.is_trait);
195        if !opaque_types.is_empty() || has_traits {
196            builder.add_import("std::sync::Arc");
197        }
198        if !mutex_types.is_empty() {
199            builder.add_import("std::sync::Mutex");
200        }
201
202        let exclude_types: ahash::AHashSet<String> = config
203            .node
204            .as_ref()
205            .map(|c| c.exclude_types.iter().cloned().collect())
206            .unwrap_or_default();
207
208        // Build adapter body map before type iteration so bodies are available for method generation.
209        let adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Node)?;
210
211        // Map "OwnerType.method" -> streaming item type. The napi backend needs to
212        // override the IR-declared `String` return type with `Vec<{prefix}{item}>`
213        // for streaming adapters, since the generated body returns chunks directly
214        // as a JS array instead of a serialized JSON string.
215        let streaming_item_types: ahash::AHashMap<String, String> = config
216            .adapters
217            .iter()
218            .filter(|a| matches!(a.pattern, alef_core::config::AdapterPattern::Streaming))
219            .filter_map(|a| {
220                let owner = a.owner_type.as_deref()?;
221                let item = a.item_type.as_deref()?;
222                Some((format!("{owner}.{}", a.name), item.to_string()))
223            })
224            .collect();
225
226        // JsBytes: a newtype wrapper for Vec<u8> with custom FromNapiValue that accepts
227        // Buffer.from(...) from JavaScript. Fixes NAPI v3 macro-derived deserialization
228        // of Vec<u8> fields in #[napi(object)] structs, which normally expect Array[number].
229        let js_bytes_def = r#"
230/// Wrapper for byte arrays that implements custom FromNapiValue to accept Buffer.from(...).
231///
232/// NAPI v3's default FromNapiValue for Vec<u8> expects Array[number], not Buffer.
233/// This wrapper provides custom deserialization that accepts Buffer, Uint8Array, or Array,
234/// converting them to Vec<u8>. Implements Clone and serde traits for use in struct fields.
235#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
236pub struct JsBytes(pub Vec<u8>);
237
238impl From<Vec<u8>> for JsBytes {
239    fn from(v: Vec<u8>) -> Self {
240        JsBytes(v)
241    }
242}
243
244impl From<JsBytes> for Vec<u8> {
245    fn from(js_bytes: JsBytes) -> Self {
246        js_bytes.0
247    }
248}
249
250impl AsRef<[u8]> for JsBytes {
251    fn as_ref(&self) -> &[u8] {
252        &self.0
253    }
254}
255
256impl std::ops::Deref for JsBytes {
257    type Target = Vec<u8>;
258    fn deref(&self) -> &Self::Target {
259        &self.0
260    }
261}
262
263impl std::ops::DerefMut for JsBytes {
264    fn deref_mut(&mut self) -> &mut Self::Target {
265        &mut self.0
266    }
267}
268
269impl napi::bindgen_prelude::FromNapiValue for JsBytes {
270    unsafe fn from_napi_value(env: napi::sys::napi_env, napi_val: napi::sys::napi_value) -> napi::Result<Self> {
271        use napi::bindgen_prelude::FromNapiValue;
272
273        // Try Buffer first (most common for binary data in JS)
274        if let Ok(buffer) = unsafe { napi::bindgen_prelude::Buffer::from_napi_value(env, napi_val) } {
275            return Ok(JsBytes(buffer.as_ref().to_vec()));
276        }
277
278        // Try Uint8Array
279        if let Ok(ua) = unsafe { napi::bindgen_prelude::Uint8Array::from_napi_value(env, napi_val) } {
280            return Ok(JsBytes(ua.to_vec()));
281        }
282
283        // Fall back to Array[number]
284        if let Ok(vec) = unsafe { Vec::<u8>::from_napi_value(env, napi_val) } {
285            return Ok(JsBytes(vec));
286        }
287
288        Err(napi::Error::new(
289            napi::Status::InvalidArg,
290            "Expected Buffer, Uint8Array, or Array<number> for bytes field",
291        ))
292    }
293}
294
295impl napi::bindgen_prelude::ToNapiValue for JsBytes {
296    unsafe fn to_napi_value(env: napi::sys::napi_env, val: Self) -> napi::Result<napi::sys::napi_value> {
297        // Delegate to Vec<u8>'s implementation (which returns an Uint8Array/Buffer).
298        unsafe { <Vec<u8> as napi::bindgen_prelude::ToNapiValue>::to_napi_value(env, val.0) }
299    }
300}
301"#;
302        builder.add_item(js_bytes_def);
303
304        // JsVisitorRef: a thin wrapper around napi::Object that implements Clone.
305        // This newtype makes Object<'static> work with napi(object) field derivations,
306        // which require Clone. Uses std::sync::Arc to make the handle cheaply cloneable.
307        if has_traits {
308            let js_visitor_ref_def = r#"
309/// Wrapper for trait visitor types (napi::Object<'static>) that implements Clone.
310///
311/// Object is not Clone. This wrapper uses Arc<Object<'static>> internally for cheap cloning.
312/// The .inner field is public for compatibility with generated code that needs to access
313/// the underlying Object for trait dispatch.
314pub struct JsVisitorRef {
315    pub inner: std::sync::Arc<napi::bindgen_prelude::Object<'static>>,
316}
317
318impl Clone for JsVisitorRef {
319    fn clone(&self) -> Self {
320        JsVisitorRef {
321            inner: std::sync::Arc::clone(&self.inner),
322        }
323    }
324}
325
326#[allow(clippy::arc_with_non_send_sync)]
327impl From<napi::bindgen_prelude::Object<'static>> for JsVisitorRef {
328    fn from(visitor: napi::bindgen_prelude::Object<'static>) -> Self {
329        JsVisitorRef {
330            inner: std::sync::Arc::new(visitor),
331        }
332    }
333}
334
335impl From<JsVisitorRef> for napi::bindgen_prelude::Object<'static> {
336    fn from(visitor_ref: JsVisitorRef) -> Self {
337        // Object<'static> is Copy (it just holds an env+handle pair), so deref directly.
338        *visitor_ref.inner
339    }
340}
341"#;
342            builder.add_item(js_visitor_ref_def);
343        }
344
345        // Emit adapter-generated standalone items (streaming iterators, callback bridges).
346        for adapter in &config.adapters {
347            match adapter.pattern {
348                alef_core::config::AdapterPattern::Streaming => {
349                    let key = format!("{}.__stream_struct__", adapter.item_type.as_deref().unwrap_or(""));
350                    if let Some(struct_code) = adapter_bodies.get(&key) {
351                        builder.add_item(struct_code);
352                    }
353                }
354                alef_core::config::AdapterPattern::CallbackBridge => {
355                    let struct_key = format!("{}.__bridge_struct__", adapter.name);
356                    let impl_key = format!("{}.__bridge_impl__", adapter.name);
357                    if let Some(struct_code) = adapter_bodies.get(&struct_key) {
358                        builder.add_item(struct_code);
359                    }
360                    if let Some(impl_code) = adapter_bodies.get(&impl_key) {
361                        builder.add_item(impl_code);
362                    }
363                }
364                _ => {}
365            }
366        }
367
368        // NAPI has some unique patterns: Js-prefixed names, Option-wrapped fields,
369        // and custom constructor. Use shared generators for enums and functions,
370        // but keep struct/method generation custom.
371        for typ in api
372            .types
373            .iter()
374            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
375        {
376            // Capsule types bypass #[napi] class emission entirely — they are exposed
377            // as raw External<T> pointers in JsObject wrappers from functions that return them.
378            if capsule_types.contains_key(&typ.name) {
379                continue;
380            }
381            if typ.is_opaque {
382                // gen_opaque_struct_prefixed emits `#[napi]` from cfg.struct_attrs.
383                // Replace with `#[napi(js_name = "Foo")]` so NAPI-RS exports the
384                // unprefixed name while the Rust struct stays JsFoo internally.
385                // Prepend `///` rustdoc so napi-derive forwards it to JSDoc on
386                // the corresponding `export declare class` in index.d.ts.
387                let opaque_struct_code = {
388                    let raw = alef_codegen::generators::gen_opaque_struct_prefixed(typ, &cfg, &prefix);
389                    let struct_name = format!("{prefix}{}", typ.name);
390                    let body = raw.replace(
391                        &format!("#[napi]pub struct {struct_name}"),
392                        &format!("#[napi(js_name = \"{}\")]pub struct {struct_name}", typ.name),
393                    );
394                    let mut out = String::new();
395                    alef_codegen::doc_emission::emit_rustdoc(&mut out, &typ.doc, "");
396                    out.push_str(&body);
397                    out
398                };
399                builder.add_item(&opaque_struct_code);
400                let capsule_type_names: AHashSet<String> = capsule_types.keys().cloned().collect();
401                builder.add_item(&types::gen_opaque_struct_methods(
402                    typ,
403                    &mapper,
404                    &cfg,
405                    &opaque_types,
406                    &prefix,
407                    &adapter_bodies,
408                    &streaming_item_types,
409                    &capsule_type_names,
410                    &mutex_types,
411                    &capsule_types,
412                ));
413            } else {
414                // Non-opaque structs use #[napi(object)] — plain JS objects without methods.
415                // napi(object) structs cannot have #[napi] impl blocks.
416                // gen_struct adds Default to derives when typ.has_default is true.
417                builder.add_item(&types::gen_struct(
418                    typ,
419                    &mapper,
420                    &prefix,
421                    has_serde,
422                    &opaque_types,
423                    &never_skip_cfg_field_names,
424                ));
425            }
426        }
427
428        // Collect struct names so tagged enum codegen knows which Named types have binding structs
429        let struct_names: ahash::AHashSet<String> = api.types.iter().map(|t| t.name.clone()).collect();
430
431        // Collect Named types that have a Default impl. These are eligible to be
432        // promoted to Option<T> in binding signatures so JS callers may pass
433        // `undefined` to fall back to a default-constructed instance.
434        let default_types: ahash::AHashSet<String> = api
435            .types
436            .iter()
437            .filter(|t| t.has_default)
438            .map(|t| t.name.clone())
439            .collect();
440
441        for enum_def in &api.enums {
442            builder.add_item(&enums::gen_enum(enum_def, &prefix, has_serde));
443        }
444
445        let exclude_functions: ahash::AHashSet<String> = config
446            .node
447            .as_ref()
448            .map(|c| c.exclude_functions.iter().cloned().collect())
449            .unwrap_or_default();
450
451        for func in &api.functions {
452            if exclude_functions.contains(&func.name) {
453                continue;
454            }
455            let bridge_param = crate::trait_bridge::find_bridge_param(func, &config.trait_bridges);
456            let options_field_bridge = crate::trait_bridge::find_options_field_binding(func, &config.trait_bridges)
457                // Only use the options-field path when the bridge field actually survives
458                // into the binding struct. If the core field is `#[cfg(...)]`-gated, the
459                // struct generator strips it and the generated bridge code would reference
460                // a missing field, producing `E0609 no field` at compile time.
461                // Exception: fields listed in never_skip_cfg_field_names are cfg-gated but
462                // preserved by the struct generator, so they are valid for bridge codegen.
463                .filter(|(_, bridge_cfg)| {
464                    let Some(field_name) = bridge_cfg.resolved_options_field() else { return false; };
465                    let Some(options_type) = bridge_cfg.options_type.as_deref() else { return false; };
466                    api.types
467                        .iter()
468                        .filter(|t| t.name == options_type)
469                        .flat_map(|t| t.fields.iter())
470                        .any(|f| f.name == field_name && (f.cfg.is_none() || never_skip_cfg_field_names.iter().any(|n| n == field_name)))
471                });
472            // Skip sanitized functions when there's no trait bridge that can replace the
473            // sanitized parameter — such functions cannot be auto-delegated. Functions
474            // whose only "sanitized" param is a configured trait_bridge param (e.g.
475            // Option<VisitorHandle> in html-to-markdown) are emitted via gen_bridge_function.
476            if func.sanitized && bridge_param.is_none() && options_field_bridge.is_none() {
477                continue;
478            }
479            if let Some((param_idx, bridge_cfg)) = bridge_param {
480                builder.add_item(&crate::trait_bridge::gen_bridge_function(
481                    func,
482                    param_idx,
483                    bridge_cfg,
484                    &mapper,
485                    &cfg,
486                    &Default::default(),
487                    &opaque_types,
488                    &core_import,
489                ));
490            } else if let Some((param_idx, bridge_cfg)) = options_field_bridge {
491                builder.add_item(&crate::trait_bridge::gen_options_field_bridge_function(
492                    func,
493                    param_idx,
494                    bridge_cfg,
495                    &mapper,
496                    &cfg,
497                    &opaque_types,
498                    &core_import,
499                ));
500            } else if !capsule_types.is_empty() && capsule::function_involves_capsule(func, &capsule_types) {
501                // Function returns a capsule type — emit a napi shim that returns JsObject
502                // with __parser = External<T>(ptr from value.into_raw()).
503                // JsObjectValue provides set_named_property; imported once below.
504                builder.add_item(&capsule::gen_capsule_function(func, &capsule_types, &core_import));
505            } else {
506                builder.add_item(&functions::gen_function(
507                    func,
508                    &mapper,
509                    &cfg,
510                    &opaque_types,
511                    &default_types,
512                    &prefix,
513                    &capsule_types,
514                    &mutex_types,
515                ));
516            }
517        }
518
519        // Trait bridge wrappers — generate NAPI bridge structs that delegate to JS objects
520        for bridge_cfg in &config.trait_bridges {
521            if let Some(trait_type) = api.types.iter().find(|t| t.is_trait && t.name == bridge_cfg.trait_name) {
522                let bridge = crate::trait_bridge::gen_trait_bridge(
523                    trait_type,
524                    bridge_cfg,
525                    &core_import,
526                    &config.error_type_name(),
527                    &config.error_constructor_expr(),
528                    api,
529                );
530                for imp in &bridge.imports {
531                    builder.add_import(imp);
532                }
533                builder.add_item(&bridge.code);
534            }
535        }
536
537        let binding_to_core = alef_codegen::conversions::convertible_types(api);
538        let core_to_binding = alef_codegen::conversions::core_to_binding_convertible_types(api);
539        let input_types = alef_codegen::conversions::input_type_names(api);
540        // NOTE: NAPI does NOT populate `trait_bridge_arc_wrapper_field_names`. Unlike
541        // PHP/WASM which wrap their visitor handle as `WrapperType { inner: Arc<...> }`,
542        // the NAPI binding stores the raw JS `napi::bindgen_prelude::Object` directly on
543        // `JsConversionOptions.visitor`. There is no `.inner` field to dereference, so
544        // the `(*v.inner).clone()` substitution would emit code that fails to compile.
545        // Instead, the NAPI `convert` codegen attaches the visitor in a post-process
546        // step after the `From<JsConversionOptions>` impl runs (`o.visitor = None;`
547        // then `result.visitor = visitor_handle.clone()`), so the From impl harmlessly
548        // emits `Default::default()` for the visitor field.
549        let napi_conv_config = alef_codegen::conversions::ConversionConfig {
550            type_name_prefix: &prefix,
551            cast_large_ints_to_i64: true,
552            cast_f32_to_f64: true,
553            // optionalize_defaults: For types with has_default, conversion generators
554            // make all fields Option<T> and apply defaults via FromNapiValue,
555            // enabling JS users to pass partial objects and omit fields they want defaults for.
556            optionalize_defaults: true,
557            option_duration_on_defaults: true,
558            include_cfg_metadata: true,
559            // Pass opaque_types so the conversion generator can emit `Default::default()`
560            // for opaque-type fields (e.g. visitor: Object<'static>) instead of trying to
561            // convert them via Into — these fields are handled separately via bridge code.
562            opaque_types: Some(&opaque_types),
563            // Json fields are stored as serde_json::Value in the binding so JS
564            // callers can pass objects/arrays/scalars directly.
565            json_as_value: true,
566            never_skip_cfg_field_names: &never_skip_cfg_field_names,
567            ..Default::default()
568        };
569        // From/Into conversions using shared parameterized generators
570        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
571            if input_types.contains(&typ.name)
572                && alef_codegen::conversions::can_generate_conversion(typ, &binding_to_core)
573            {
574                builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
575                    typ,
576                    &core_import,
577                    &napi_conv_config,
578                ));
579            }
580            if alef_codegen::conversions::can_generate_conversion(typ, &core_to_binding) {
581                builder.add_item(&alef_codegen::conversions::gen_from_core_to_binding_cfg(
582                    typ,
583                    &core_import,
584                    &opaque_types,
585                    &napi_conv_config,
586                ));
587            }
588        }
589        for e in &api.enums {
590            let has_data_variants = e.variants.iter().any(|v| !v.fields.is_empty());
591            let is_tagged_data_enum = e.serde_tag.is_some() && has_data_variants;
592            let is_untagged_data_enum = e.serde_untagged && has_data_variants;
593            if is_tagged_data_enum {
594                // Tagged data enums use flattened struct — generate custom conversions
595                builder.add_item(&methods::gen_tagged_enum_binding_to_core(
596                    e,
597                    &core_import,
598                    &prefix,
599                    &struct_names,
600                ));
601                builder.add_item(&methods::gen_tagged_enum_core_to_binding(
602                    e,
603                    &core_import,
604                    &prefix,
605                    &struct_names,
606                ));
607            } else if is_untagged_data_enum {
608                // Untagged data enums are wrapped around serde_json::Value — bridge via serde.
609                let binding_name = format!("{prefix}{}", e.name);
610                let core_path = alef_codegen::conversions::core_enum_path_remapped(
611                    e,
612                    &core_import,
613                    napi_conv_config.source_crate_remaps,
614                );
615                builder.add_item(&format!(
616                    "impl From<{binding_name}> for {core_path} {{\n    \
617                         fn from(val: {binding_name}) -> Self {{\n        \
618                             serde_json::from_value(val.0).unwrap_or_default()\n    \
619                         }}\n\
620                     }}\n"
621                ));
622                builder.add_item(&format!(
623                    "impl From<{core_path}> for {binding_name} {{\n    \
624                         fn from(val: {core_path}) -> Self {{\n        \
625                             Self(serde_json::to_value(val).unwrap_or_default())\n    \
626                         }}\n\
627                     }}\n"
628                ));
629            } else {
630                if input_types.contains(&e.name) && alef_codegen::conversions::can_generate_enum_conversion(e) {
631                    builder.add_item(&alef_codegen::conversions::gen_enum_from_binding_to_core_cfg(
632                        e,
633                        &core_import,
634                        &napi_conv_config,
635                    ));
636                }
637                if alef_codegen::conversions::can_generate_enum_conversion_from_core(e) {
638                    builder.add_item(&alef_codegen::conversions::gen_enum_from_core_to_binding_cfg(
639                        e,
640                        &core_import,
641                        &napi_conv_config,
642                    ));
643                }
644            }
645        }
646
647        // Error types (variant name constants + converter functions)
648        for error in &api.errors {
649            builder.add_item(&alef_codegen::error_gen::gen_napi_error_types(error));
650            builder.add_item(&alef_codegen::error_gen::gen_napi_error_converter(error, &core_import));
651        }
652
653        let mut content = builder.build();
654
655        // Post-process: Fix From<JsXxx> (binding to core) impls to forward visitor field.
656        // The conversion generator emits `__result.visitor = Default::default();` in binding→core
657        // conversions because the raw JS napi::bindgen_prelude::Object is not Clone-able.
658        // This post-process detects that pattern in the JS→Rust direction and replaces it with
659        // code that wraps val.visitor into a JsHtmlVisitorBridge and then into the core Rc<RefCell<>> type.
660        //
661        // Key: only fix `impl From<Js{type}>` (binding→core), NOT `impl From<core_type>` (core→binding).
662        // The core→binding direction correctly uses Default because the Rc<RefCell<>> is opaque to JS.
663        for bridge in &config.trait_bridges {
664            if bridge.bind_via != alef_core::config::BridgeBinding::OptionsField {
665                continue;
666            }
667            if let Some(field_name) = bridge.resolved_options_field() {
668                // Verify the field is present in the binding struct (not cfg-gated away)
669                let Some(options_type) = bridge.options_type.as_deref() else {
670                    continue;
671                };
672                let field_in_binding = api
673                    .types
674                    .iter()
675                    .filter(|t| t.name == options_type)
676                    .flat_map(|t| t.fields.iter())
677                    .any(|f| f.cfg.is_none() && f.name == field_name);
678                if !field_in_binding {
679                    continue;
680                }
681
682                // Find the binding→core conversion impl: `impl From<Js{options_type}> for core...`
683                let prefix = config.node_type_prefix();
684                let js_type_name = format!("{prefix}{options_type}");
685                let impl_marker = format!("impl From<{js_type_name}> for {core_import}");
686
687                // Search forward from the impl marker to find its closing brace and visitor wipe.
688                // We only fix the impl that converts FROM the JS binding type.
689                if let Some(impl_start) = content.find(&impl_marker) {
690                    // Find the matching closing brace for this impl block
691                    let from_impl_start = impl_start;
692                    let impl_body = &content[from_impl_start..];
693
694                    // Find the next `}` that closes this impl — being careful to count braces
695                    let mut brace_depth = 0;
696                    let mut impl_end = 0;
697                    let mut found_fn_from = false;
698                    for (i, ch) in impl_body.char_indices() {
699                        if ch == '{' {
700                            brace_depth += 1;
701                            // Once we see the opening brace of `fn from(...) {`, mark it
702                            if impl_body[..i].contains("fn from") {
703                                found_fn_from = true;
704                            }
705                        } else if ch == '}' {
706                            brace_depth -= 1;
707                            if brace_depth == 0 && found_fn_from {
708                                impl_end = i;
709                                break;
710                            }
711                        }
712                    }
713
714                    if impl_end > 0 {
715                        let impl_block = &impl_body[..impl_end];
716                        let pattern = "__result.visitor = Default::default();";
717
718                        if let Some(rel_pos) = impl_block.find(pattern) {
719                            let pos = from_impl_start + rel_pos;
720                            let before = &content[..pos];
721                            let after = &content[pos + pattern.len()..];
722
723                            // Build the replacement that wraps val.visitor into JsHtmlVisitorBridge
724                            // and then into the core Arc<Mutex<...>> type.
725                            let type_alias = bridge.type_alias.as_deref().unwrap_or("VisitorHandle");
726                            let handle_path = format!("{core_import}::visitor::{type_alias}");
727                            let replacement = format!(
728                                "__result.visitor = val.{field_name}.map(|obj| {{\n            \
729                                    let bridge = JsHtmlVisitorBridge::new(obj);\n            \
730                                    std::sync::Arc::new(std::sync::Mutex::new(bridge)) as {handle_path}\n        \
731                                }});"
732                            );
733
734                            content = format!("{}{}{}", before, replacement, after);
735                        }
736                    }
737                }
738            }
739        }
740
741        let output_dir = resolve_output_dir(config.output_paths.get("node"), &config.name, "crates/{name}-node/src/");
742
743        Ok(vec![GeneratedFile {
744            path: PathBuf::from(&output_dir).join("lib.rs"),
745            content,
746            generated_header: false,
747        }])
748    }
749
750    fn generate_public_api(
751        &self,
752        api: &ApiSurface,
753        config: &ResolvedCrateConfig,
754    ) -> anyhow::Result<Vec<GeneratedFile>> {
755        let prefix = config.node_type_prefix();
756        let capsule_types_pub: HashMap<String, NodeCapsuleTypeConfig> = config
757            .node
758            .as_ref()
759            .map(|c| c.capsule_types.clone())
760            .unwrap_or_default();
761
762        // Separate exports into functions (plain export) and types (export type)
763        let mut type_exports = vec![];
764        let mut function_exports = vec![];
765
766        // Collect all types as type exports. Types are exported with their unprefixed
767        // TS name because the Rust structs carry `#[napi(js_name = "Foo")]`, so the
768        // NAPI-RS runtime already maps JsFoo → Foo in the generated .d.ts.
769        // Skip trait definitions (e.g. HtmlVisitor): the NAPI binding exposes opaque
770        // *Handle classes for trait bridges, not the trait types themselves, so
771        // re-exporting `JsHtmlVisitor` produces a TS2305 'has no exported member'
772        // error against the generated index.d.ts.
773        // Skip capsule types — they are not emitted as napi classes and therefore
774        // do not exist in the native module's exports.
775        let _ = &prefix; // prefix is not used for public TS export names
776        for typ in api.types.iter() {
777            if typ.is_trait {
778                continue;
779            }
780            if capsule_types_pub.contains_key(&typ.name) {
781                continue;
782            }
783            type_exports.push(typ.name.clone());
784        }
785
786        // Collect all enums as type exports (unprefixed, matching the js_name attribute).
787        // With verbatimModuleSyntax enabled, re-exporting const enums as values causes
788        // TS2748/TS1205; using `export type` avoids both errors.
789        for enum_def in &api.enums {
790            type_exports.push(enum_def.name.clone());
791        }
792
793        // NAPI errors are thrown as native JS Error objects, not exported as TS types.
794        // Skip error types in the public API re-exports.
795
796        // Collect all functions (exported from native module) - plain export
797        for func in &api.functions {
798            // Convert snake_case to camelCase for JavaScript naming
799            let js_name = to_node_name(&func.name);
800            function_exports.push(js_name);
801        }
802
803        // Include trait-bridge register/unregister/clear functions — these are emitted
804        // directly as #[napi]-annotated free functions on the native module, but they do
805        // not appear in `api.functions`, so the index.ts re-export block must add them
806        // explicitly. Without this, callers cannot `import { registerOcrBackend, ... }`
807        // from the public package root.
808        for bridge in &config.trait_bridges {
809            if let Some(name) = bridge.register_fn.as_deref() {
810                function_exports.push(to_node_name(name));
811            }
812            if let Some(name) = bridge.unregister_fn.as_deref() {
813                function_exports.push(to_node_name(name));
814            }
815            if let Some(name) = bridge.clear_fn.as_deref() {
816                function_exports.push(to_node_name(name));
817            }
818        }
819
820        // Sort for consistent output
821        type_exports.sort();
822        function_exports.sort();
823
824        // Generate the index.ts re-export file using a single export block
825        // with inline `type` annotations for verbatimModuleSyntax compatibility.
826        let mut lines = vec![
827            "// This file is auto-generated by alef. DO NOT EDIT.".to_string(),
828            "".to_string(),
829        ];
830
831        // Separate value and type exports for verbatimModuleSyntax compatibility.
832        // Value exports (functions) in one block, type exports (structs + enums) in another.
833        if !function_exports.is_empty() {
834            lines.push("export {".to_string());
835            for name in &function_exports {
836                lines.push(format!("  {name},"));
837            }
838            lines.push(format!("}} from '{}';", config.node_package_name()));
839            lines.push("".to_string());
840        }
841        if !type_exports.is_empty() {
842            lines.push("export type {".to_string());
843            for name in &type_exports {
844                lines.push(format!("  {name},"));
845            }
846            lines.push(format!("}} from '{}';", config.node_package_name()));
847        }
848
849        // Append re-exports for custom modules (from [custom_modules] node = [...])
850        let custom_mods = config.custom_modules.for_language(Language::Node);
851        for module_name in custom_mods {
852            lines.push(format!("export * from './{module_name}';"));
853        }
854
855        let content = lines.join("\n");
856
857        // Output path: packages/typescript/src/index.ts
858        let output_path = PathBuf::from("packages/typescript/src/index.ts");
859
860        Ok(vec![GeneratedFile {
861            path: output_path,
862            content,
863            generated_header: false,
864        }])
865    }
866
867    fn generate_type_stubs(
868        &self,
869        api: &ApiSurface,
870        config: &ResolvedCrateConfig,
871    ) -> anyhow::Result<Vec<GeneratedFile>> {
872        let prefix = config.node_type_prefix();
873        let exclude_functions: ahash::AHashSet<String> = config
874            .node
875            .as_ref()
876            .map(|c| c.exclude_functions.iter().cloned().collect())
877            .unwrap_or_default();
878        let capsule_types: HashMap<String, NodeCapsuleTypeConfig> = config
879            .node
880            .as_ref()
881            .map(|c| c.capsule_types.clone())
882            .unwrap_or_default();
883        let content = errors::gen_dts(api, &prefix, &exclude_functions, &config.trait_bridges, &capsule_types);
884
885        // `output_for("node")` points to the `src/` directory (e.g., `crates/{name}-node/src/`).
886        // `index.d.ts` belongs at the crate root, one level up from `src/`.
887        // When the configured path ends in `src/` or `src`, strip that suffix to get the crate root.
888        // Falls back to `crates/{name}-node/` if no node output is configured.
889        let src_dir = resolve_output_dir(config.output_paths.get("node"), &config.name, "crates/{name}-node/src/");
890        let crate_root = {
891            let p = PathBuf::from(&src_dir);
892            match p.file_name().and_then(|n| n.to_str()) {
893                Some("src") => p.parent().map(|parent| parent.to_path_buf()).unwrap_or(p),
894                _ => p,
895            }
896        };
897
898        Ok(vec![GeneratedFile {
899            path: crate_root.join("index.d.ts"),
900            content,
901            generated_header: false,
902        }])
903    }
904
905    fn build_config(&self) -> Option<BuildConfig> {
906        Some(BuildConfig {
907            tool: "napi",
908            crate_suffix: "-node",
909            build_dep: BuildDependency::None,
910            post_build: vec![PostBuildStep::PatchFile {
911                path: "index.d.ts",
912                find: "export declare const enum",
913                replace: "export declare enum",
914            }],
915        })
916    }
917}
918
919/// Generate a NAPI struct with Js-prefixed name and fields wrapped in Option only if optional.
920#[cfg(test)]
921mod tests {
922    use super::NapiBackend;
923    use alef_core::backend::Backend;
924    use alef_core::config::Language;
925
926    /// NapiBackend::name returns "napi".
927    #[test]
928    fn napi_backend_name_is_napi() {
929        let b = NapiBackend;
930        assert_eq!(b.name(), "napi");
931    }
932
933    /// NapiBackend::language returns Language::Node.
934    #[test]
935    fn napi_backend_language_is_node() {
936        let b = NapiBackend;
937        assert_eq!(b.language(), Language::Node);
938    }
939
940    /// Test that cfg-gated fields in never_skip_cfg_field_names pass the options-field-bridge filter.
941    #[test]
942    fn cfg_gated_field_accepted_when_in_never_skip_list() {
943        // Test the predicate logic: a cfg-gated field "visitor" should be accepted
944        // when it appears in never_skip_cfg_field_names.
945        let never_skip_cfg_field_names = ["visitor".to_string()];
946        let field_is_target = "visitor";
947
948        // Simulate a field with cfg = Some(...)
949        let field_has_cfg = Some("feature = \"visitor\"");
950
951        // Predicate: f.cfg.is_none() || never_skip_cfg_field_names.iter().any(|n| n == field_name)
952        let accepted = field_has_cfg.is_none() || never_skip_cfg_field_names.iter().any(|n| n == field_is_target);
953
954        assert!(
955            accepted,
956            "cfg-gated field 'visitor' should pass filter when in never_skip_cfg_field_names"
957        );
958    }
959}