alef-backend-napi 0.15.10

Node.js (NAPI-RS) backend for alef
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
//! NAPI-RS (Node.js) backend: orchestration and `Backend` trait implementation.

pub mod enums;
pub mod errors;
pub mod functions;
pub mod methods;
pub mod types;

use crate::type_map::NapiMapper;
use ahash::AHashSet;
use alef_codegen::builder::RustFileBuilder;
use alef_codegen::generators::{self, AsyncPattern, RustBindingConfig};
use alef_codegen::naming::to_node_name;
use alef_core::backend::{Backend, BuildConfig, BuildDependency, Capabilities, GeneratedFile, PostBuildStep};
use alef_core::config::{Language, ResolvedCrateConfig, resolve_output_dir};
use alef_core::ir::{ApiSurface, TypeRef};
use std::path::PathBuf;

pub struct NapiBackend;

impl NapiBackend {
    fn binding_config<'a>(core_import: &'a str, prefix: &'a str, has_serde: bool) -> RustBindingConfig<'a> {
        RustBindingConfig {
            struct_attrs: &["napi"],
            field_attrs: &[],
            struct_derives: &["Clone"],
            method_block_attr: Some("napi"),
            constructor_attr: "#[napi(constructor)]",
            static_attr: None,
            function_attr: "#[napi]",
            enum_attrs: &["napi(string_enum)"],
            enum_derives: &["Clone"],
            needs_signature: false,
            signature_prefix: "",
            signature_suffix: "",
            core_import,
            async_pattern: AsyncPattern::NapiNativeAsync,
            has_serde,
            // NAPI napi(object) structs don't derive Serialize — disable serde bridge
            type_name_prefix: prefix,
            option_duration_on_defaults: true,
            opaque_type_names: &[],
            skip_impl_constructor: false,
            cast_uints_to_i32: false,
            cast_large_ints_to_f64: false,
            named_non_opaque_params_by_ref: false,
            lossy_skip_types: &[],
            serializable_opaque_type_names: &[],
        }
    }
}

impl Backend for NapiBackend {
    fn name(&self) -> &str {
        "napi"
    }

    fn language(&self) -> Language {
        Language::Node
    }

    fn capabilities(&self) -> Capabilities {
        Capabilities {
            supports_async: true,
            supports_classes: true,
            supports_enums: true,
            supports_option: true,
            supports_result: true,
            ..Capabilities::default()
        }
    }

    fn generate_bindings(&self, api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
        let prefix = config.node_type_prefix();
        let trait_type_names: AHashSet<String> = api
            .types
            .iter()
            .filter(|t| t.is_trait)
            .map(|t| t.name.clone())
            .collect();
        let mapper = NapiMapper::with_traits(prefix.clone(), trait_type_names);
        let core_import = config.core_import_name();

        // Detect serde availability from the output crate's Cargo.toml
        let output_dir = resolve_output_dir(config.output_paths.get("node"), &config.name, "crates/{name}-node/src/");
        let has_serde = alef_core::config::detect_serde_available(&output_dir);
        let cfg = Self::binding_config(&core_import, &prefix, has_serde);

        let mut builder = RustFileBuilder::new().with_generated_header();
        builder.add_inner_attribute("allow(dead_code, unused_imports, unused_variables)");
        builder.add_inner_attribute("allow(unsafe_code)");
        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)");
        // Cast lints fire heavily on the JS u32/i64/Number bridge — these are
        // intentional, deliberate at the FFI boundary. Pedantic/nursery noise
        // (must_use_candidate, use_self, missing_const_for_fn, etc.) is
        // suppressed for the same reasons documented in the pyo3 backend.
        builder.add_inner_attribute(
            "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)",
        );
        builder.add_import("napi::*");
        builder.add_import("napi_derive::napi");

        // Always import serde_json for type conversion in From/Into impls,
        // even if the binding crate doesn't explicitly list it as a dependency.
        // serde_json is needed for conversions of types with serde-serializable fields.
        builder.add_import("serde_json");

        // Import traits needed for trait method dispatch
        for trait_path in generators::collect_trait_imports(api) {
            builder.add_import(&trait_path);
        }

        // Only import HashMap when Map-typed fields or returns are present
        let has_maps = api
            .types
            .iter()
            .any(|t| t.fields.iter().any(|f| matches!(&f.ty, TypeRef::Map(_, _))))
            || api
                .functions
                .iter()
                .any(|f| matches!(&f.return_type, TypeRef::Map(_, _)));
        if has_maps {
            builder.add_import("std::collections::HashMap");
        }

        // Note: custom_modules for Node are TypeScript-only re-exports
        // (used in generate_public_api), not Rust module declarations.

        // Check if any function or method is async
        let has_async =
            api.functions.iter().any(|f| f.is_async) || api.types.iter().any(|t| t.methods.iter().any(|m| m.is_async));

        if has_async {
            builder.add_item(&functions::gen_tokio_runtime());
        }

        // Check if we have opaque types and trait types (visitors)
        // Exclude trait types from opaque_types since they use JsVisitorRef instead of Object<'static>
        let opaque_types: AHashSet<String> = api
            .types
            .iter()
            .filter(|t| t.is_opaque && !t.is_trait)
            .map(|t| t.name.clone())
            .collect();
        let has_traits = api.types.iter().any(|t| t.is_trait);
        if !opaque_types.is_empty() || has_traits {
            builder.add_import("std::sync::Arc");
        }

        let exclude_types: ahash::AHashSet<String> = config
            .node
            .as_ref()
            .map(|c| c.exclude_types.iter().cloned().collect())
            .unwrap_or_default();

        // Build adapter body map before type iteration so bodies are available for method generation.
        let adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Node)?;

        // Map "OwnerType.method" -> streaming item type. The napi backend needs to
        // override the IR-declared `String` return type with `Vec<{prefix}{item}>`
        // for streaming adapters, since the generated body returns chunks directly
        // as a JS array instead of a serialized JSON string.
        let streaming_item_types: ahash::AHashMap<String, String> = config
            .adapters
            .iter()
            .filter(|a| matches!(a.pattern, alef_core::config::AdapterPattern::Streaming))
            .filter_map(|a| {
                let owner = a.owner_type.as_deref()?;
                let item = a.item_type.as_deref()?;
                Some((format!("{owner}.{}", a.name), item.to_string()))
            })
            .collect();

        // JsVisitorRef: a thin wrapper around napi::Object that implements Clone.
        // This newtype makes Object<'static> work with napi(object) field derivations,
        // which require Clone. Uses std::sync::Arc to make the handle cheaply cloneable.
        if has_traits {
            let js_visitor_ref_def = r#"
/// Wrapper for trait visitor types (napi::Object<'static>) that implements Clone.
///
/// Object is not Clone. This wrapper uses Arc<Object<'static>> internally for cheap cloning.
/// The .inner field is public for compatibility with generated code that needs to access
/// the underlying Object for trait dispatch.
pub struct JsVisitorRef {
    pub inner: std::sync::Arc<napi::bindgen_prelude::Object<'static>>,
}

impl Clone for JsVisitorRef {
    fn clone(&self) -> Self {
        JsVisitorRef {
            inner: std::sync::Arc::clone(&self.inner),
        }
    }
}

#[allow(clippy::arc_with_non_send_sync)]
impl From<napi::bindgen_prelude::Object<'static>> for JsVisitorRef {
    fn from(visitor: napi::bindgen_prelude::Object<'static>) -> Self {
        JsVisitorRef {
            inner: std::sync::Arc::new(visitor),
        }
    }
}

impl From<JsVisitorRef> for napi::bindgen_prelude::Object<'static> {
    fn from(visitor_ref: JsVisitorRef) -> Self {
        // Object<'static> is Copy (it just holds an env+handle pair), so deref directly.
        *visitor_ref.inner
    }
}
"#;
            builder.add_item(js_visitor_ref_def);
        }

        // Emit adapter-generated standalone items (streaming iterators, callback bridges).
        for adapter in &config.adapters {
            match adapter.pattern {
                alef_core::config::AdapterPattern::Streaming => {
                    let key = format!("{}.__stream_struct__", adapter.item_type.as_deref().unwrap_or(""));
                    if let Some(struct_code) = adapter_bodies.get(&key) {
                        builder.add_item(struct_code);
                    }
                }
                alef_core::config::AdapterPattern::CallbackBridge => {
                    let struct_key = format!("{}.__bridge_struct__", adapter.name);
                    let impl_key = format!("{}.__bridge_impl__", adapter.name);
                    if let Some(struct_code) = adapter_bodies.get(&struct_key) {
                        builder.add_item(struct_code);
                    }
                    if let Some(impl_code) = adapter_bodies.get(&impl_key) {
                        builder.add_item(impl_code);
                    }
                }
                _ => {}
            }
        }

        // NAPI has some unique patterns: Js-prefixed names, Option-wrapped fields,
        // and custom constructor. Use shared generators for enums and functions,
        // but keep struct/method generation custom.
        for typ in api
            .types
            .iter()
            .filter(|typ| !typ.is_trait && !exclude_types.contains(&typ.name))
        {
            if typ.is_opaque {
                builder.add_item(&alef_codegen::generators::gen_opaque_struct_prefixed(
                    typ, &cfg, &prefix,
                ));
                builder.add_item(&types::gen_opaque_struct_methods(
                    typ,
                    &mapper,
                    &cfg,
                    &opaque_types,
                    &prefix,
                    &adapter_bodies,
                    &streaming_item_types,
                ));
            } else {
                // Non-opaque structs use #[napi(object)] — plain JS objects without methods.
                // napi(object) structs cannot have #[napi] impl blocks.
                // gen_struct adds Default to derives when typ.has_default is true.
                builder.add_item(&types::gen_struct(typ, &mapper, &prefix, has_serde, &opaque_types));
            }
        }

        // Collect struct names so tagged enum codegen knows which Named types have binding structs
        let struct_names: ahash::AHashSet<String> = api.types.iter().map(|t| t.name.clone()).collect();

        // Collect Named types that have a Default impl. These are eligible to be
        // promoted to Option<T> in binding signatures so JS callers may pass
        // `undefined` to fall back to a default-constructed instance.
        let default_types: ahash::AHashSet<String> = api
            .types
            .iter()
            .filter(|t| t.has_default)
            .map(|t| t.name.clone())
            .collect();

        for enum_def in &api.enums {
            builder.add_item(&enums::gen_enum(enum_def, &prefix, has_serde));
        }

        let exclude_functions: ahash::AHashSet<String> = config
            .node
            .as_ref()
            .map(|c| c.exclude_functions.iter().cloned().collect())
            .unwrap_or_default();

        for func in &api.functions {
            if exclude_functions.contains(&func.name) {
                continue;
            }
            let bridge_param = crate::trait_bridge::find_bridge_param(func, &config.trait_bridges);
            let options_field_bridge = crate::trait_bridge::find_options_field_binding(func, &config.trait_bridges);
            // Skip sanitized functions when there's no trait bridge that can replace the
            // sanitized parameter — such functions cannot be auto-delegated. Functions
            // whose only "sanitized" param is a configured trait_bridge param (e.g.
            // Option<VisitorHandle> in html-to-markdown) are emitted via gen_bridge_function.
            if func.sanitized && bridge_param.is_none() && options_field_bridge.is_none() {
                continue;
            }
            if let Some((param_idx, bridge_cfg)) = bridge_param {
                builder.add_item(&crate::trait_bridge::gen_bridge_function(
                    func,
                    param_idx,
                    bridge_cfg,
                    &mapper,
                    &cfg,
                    &Default::default(),
                    &opaque_types,
                    &core_import,
                ));
            } else if let Some((param_idx, bridge_cfg)) = options_field_bridge {
                builder.add_item(&crate::trait_bridge::gen_options_field_bridge_function(
                    func,
                    param_idx,
                    bridge_cfg,
                    &mapper,
                    &cfg,
                    &opaque_types,
                    &core_import,
                ));
            } else {
                builder.add_item(&functions::gen_function(
                    func,
                    &mapper,
                    &cfg,
                    &opaque_types,
                    &default_types,
                    &prefix,
                ));
            }
        }

        // Trait bridge wrappers — generate NAPI bridge structs that delegate to JS objects
        for bridge_cfg in &config.trait_bridges {
            if let Some(trait_type) = api.types.iter().find(|t| t.is_trait && t.name == bridge_cfg.trait_name) {
                let bridge = crate::trait_bridge::gen_trait_bridge(
                    trait_type,
                    bridge_cfg,
                    &core_import,
                    &config.error_type_name(),
                    &config.error_constructor_expr(),
                    api,
                );
                for imp in &bridge.imports {
                    builder.add_import(imp);
                }
                builder.add_item(&bridge.code);
            }
        }

        let binding_to_core = alef_codegen::conversions::convertible_types(api);
        let core_to_binding = alef_codegen::conversions::core_to_binding_convertible_types(api);
        let input_types = alef_codegen::conversions::input_type_names(api);
        let napi_conv_config = alef_codegen::conversions::ConversionConfig {
            type_name_prefix: &prefix,
            cast_large_ints_to_i64: true,
            cast_f32_to_f64: true,
            // optionalize_defaults: For types with has_default, conversion generators
            // make all fields Option<T> and apply defaults via FromNapiValue,
            // enabling JS users to pass partial objects and omit fields they want defaults for.
            optionalize_defaults: true,
            option_duration_on_defaults: true,
            include_cfg_metadata: true,
            // Pass opaque_types so the conversion generator can emit `Default::default()`
            // for opaque-type fields (e.g. visitor: Object<'static>) instead of trying to
            // convert them via Into — these fields are handled separately via bridge code.
            opaque_types: Some(&opaque_types),
            // Json fields are stored as serde_json::Value in the binding so JS
            // callers can pass objects/arrays/scalars directly.
            json_as_value: true,
            ..Default::default()
        };
        // From/Into conversions using shared parameterized generators
        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
            if input_types.contains(&typ.name)
                && alef_codegen::conversions::can_generate_conversion(typ, &binding_to_core)
            {
                builder.add_item(&alef_codegen::conversions::gen_from_binding_to_core_cfg(
                    typ,
                    &core_import,
                    &napi_conv_config,
                ));
            }
            if alef_codegen::conversions::can_generate_conversion(typ, &core_to_binding) {
                builder.add_item(&alef_codegen::conversions::gen_from_core_to_binding_cfg(
                    typ,
                    &core_import,
                    &opaque_types,
                    &napi_conv_config,
                ));
            }
        }
        for e in &api.enums {
            let has_data_variants = e.variants.iter().any(|v| !v.fields.is_empty());
            let is_tagged_data_enum = e.serde_tag.is_some() && has_data_variants;
            let is_untagged_data_enum = e.serde_untagged && has_data_variants;
            if is_tagged_data_enum {
                // Tagged data enums use flattened struct — generate custom conversions
                builder.add_item(&methods::gen_tagged_enum_binding_to_core(
                    e,
                    &core_import,
                    &prefix,
                    &struct_names,
                ));
                builder.add_item(&methods::gen_tagged_enum_core_to_binding(
                    e,
                    &core_import,
                    &prefix,
                    &struct_names,
                ));
            } else if is_untagged_data_enum {
                // Untagged data enums are wrapped around serde_json::Value — bridge via serde.
                let binding_name = format!("{prefix}{}", e.name);
                let core_path = alef_codegen::conversions::core_enum_path_remapped(
                    e,
                    &core_import,
                    napi_conv_config.source_crate_remaps,
                );
                builder.add_item(&format!(
                    "impl From<{binding_name}> for {core_path} {{\n    \
                         fn from(val: {binding_name}) -> Self {{\n        \
                             serde_json::from_value(val.0).unwrap_or_default()\n    \
                         }}\n\
                     }}\n"
                ));
                builder.add_item(&format!(
                    "impl From<{core_path}> for {binding_name} {{\n    \
                         fn from(val: {core_path}) -> Self {{\n        \
                             Self(serde_json::to_value(val).unwrap_or_default())\n    \
                         }}\n\
                     }}\n"
                ));
            } else {
                if input_types.contains(&e.name) && alef_codegen::conversions::can_generate_enum_conversion(e) {
                    builder.add_item(&alef_codegen::conversions::gen_enum_from_binding_to_core_cfg(
                        e,
                        &core_import,
                        &napi_conv_config,
                    ));
                }
                if alef_codegen::conversions::can_generate_enum_conversion_from_core(e) {
                    builder.add_item(&alef_codegen::conversions::gen_enum_from_core_to_binding_cfg(
                        e,
                        &core_import,
                        &napi_conv_config,
                    ));
                }
            }
        }

        // Error types (variant name constants + converter functions)
        for error in &api.errors {
            builder.add_item(&alef_codegen::error_gen::gen_napi_error_types(error));
            builder.add_item(&alef_codegen::error_gen::gen_napi_error_converter(error, &core_import));
        }

        let content = builder.build();

        let output_dir = resolve_output_dir(config.output_paths.get("node"), &config.name, "crates/{name}-node/src/");

        Ok(vec![GeneratedFile {
            path: PathBuf::from(&output_dir).join("lib.rs"),
            content,
            generated_header: false,
        }])
    }

    fn generate_public_api(
        &self,
        api: &ApiSurface,
        config: &ResolvedCrateConfig,
    ) -> anyhow::Result<Vec<GeneratedFile>> {
        let prefix = config.node_type_prefix();

        // Separate exports into functions (plain export) and types (export type)
        let mut type_exports = vec![];
        let mut function_exports = vec![];

        // Collect all types (exported with prefix from native module) - export type.
        // Skip trait definitions (e.g. HtmlVisitor): the NAPI binding exposes opaque
        // *Handle classes for trait bridges, not the trait types themselves, so
        // re-exporting `JsHtmlVisitor` produces a TS2305 'has no exported member'
        // error against the generated index.d.ts.
        for typ in api.types.iter() {
            if typ.is_trait {
                continue;
            }
            type_exports.push(format!("{prefix}{}", typ.name));
        }

        // Collect all enums as type exports.
        // With verbatimModuleSyntax enabled, re-exporting const enums as values causes
        // TS2748/TS1205; using `export type` avoids both errors.
        for enum_def in &api.enums {
            type_exports.push(format!("{prefix}{}", enum_def.name));
        }

        // NAPI errors are thrown as native JS Error objects, not exported as TS types.
        // Skip error types in the public API re-exports.

        // Collect all functions (exported from native module) - plain export
        for func in &api.functions {
            // Convert snake_case to camelCase for JavaScript naming
            let js_name = to_node_name(&func.name);
            function_exports.push(js_name);
        }

        // Sort for consistent output
        type_exports.sort();
        function_exports.sort();

        // Generate the index.ts re-export file using a single export block
        // with inline `type` annotations for verbatimModuleSyntax compatibility.
        let mut lines = vec![
            "// This file is auto-generated by alef. DO NOT EDIT.".to_string(),
            "".to_string(),
        ];

        // Separate value and type exports for verbatimModuleSyntax compatibility.
        // Value exports (functions) in one block, type exports (structs + enums) in another.
        if !function_exports.is_empty() {
            lines.push("export {".to_string());
            for name in &function_exports {
                lines.push(format!("  {name},"));
            }
            lines.push(format!("}} from '{}';", config.node_package_name()));
            lines.push("".to_string());
        }
        if !type_exports.is_empty() {
            lines.push("export type {".to_string());
            for name in &type_exports {
                lines.push(format!("  {name},"));
            }
            lines.push(format!("}} from '{}';", config.node_package_name()));
        }

        // Append re-exports for custom modules (from [custom_modules] node = [...])
        let custom_mods = config.custom_modules.for_language(Language::Node);
        for module_name in custom_mods {
            lines.push(format!("export * from './{module_name}';"));
        }

        let content = lines.join("\n");

        // Output path: packages/typescript/src/index.ts
        let output_path = PathBuf::from("packages/typescript/src/index.ts");

        Ok(vec![GeneratedFile {
            path: output_path,
            content,
            generated_header: false,
        }])
    }

    fn generate_type_stubs(
        &self,
        api: &ApiSurface,
        config: &ResolvedCrateConfig,
    ) -> anyhow::Result<Vec<GeneratedFile>> {
        let prefix = config.node_type_prefix();
        let exclude_functions: ahash::AHashSet<String> = config
            .node
            .as_ref()
            .map(|c| c.exclude_functions.iter().cloned().collect())
            .unwrap_or_default();
        let content = errors::gen_dts(api, &prefix, &exclude_functions, &config.trait_bridges);

        // `output_for("node")` points to the `src/` directory (e.g., `crates/{name}-node/src/`).
        // `index.d.ts` belongs at the crate root, one level up from `src/`.
        // When the configured path ends in `src/` or `src`, strip that suffix to get the crate root.
        // Falls back to `crates/{name}-node/` if no node output is configured.
        let src_dir = resolve_output_dir(config.output_paths.get("node"), &config.name, "crates/{name}-node/src/");
        let crate_root = {
            let p = PathBuf::from(&src_dir);
            match p.file_name().and_then(|n| n.to_str()) {
                Some("src") => p.parent().map(|parent| parent.to_path_buf()).unwrap_or(p),
                _ => p,
            }
        };

        Ok(vec![GeneratedFile {
            path: crate_root.join("index.d.ts"),
            content,
            generated_header: false,
        }])
    }

    fn build_config(&self) -> Option<BuildConfig> {
        Some(BuildConfig {
            tool: "napi",
            crate_suffix: "-node",
            build_dep: BuildDependency::None,
            post_build: vec![PostBuildStep::PatchFile {
                path: "index.d.ts",
                find: "export declare const enum",
                replace: "export declare enum",
            }],
        })
    }
}

/// Generate a NAPI struct with Js-prefixed name and fields wrapped in Option only if optional.
#[cfg(test)]
mod tests {
    use super::NapiBackend;
    use alef_core::backend::Backend;
    use alef_core::config::Language;

    /// NapiBackend::name returns "napi".
    #[test]
    fn napi_backend_name_is_napi() {
        let b = NapiBackend;
        assert_eq!(b.name(), "napi");
    }

    /// NapiBackend::language returns Language::Node.
    #[test]
    fn napi_backend_language_is_node() {
        let b = NapiBackend;
        assert_eq!(b.language(), Language::Node);
    }
}