Skip to main content

weaveffi_gen_node/
lib.rs

1use anyhow::Result;
2use camino::Utf8Path;
3use weaveffi_core::codegen::Generator;
4use weaveffi_core::config::GeneratorConfig;
5use weaveffi_ir::ir::{Api, TypeRef};
6
7pub struct NodeGenerator;
8
9impl NodeGenerator {
10    fn generate_impl(&self, api: &Api, out_dir: &Utf8Path, package_name: &str) -> Result<()> {
11        let dir = out_dir.join("node");
12        std::fs::create_dir_all(&dir)?;
13        std::fs::write(
14            dir.join("index.js"),
15            "module.exports = require('./index.node')\n",
16        )?;
17        std::fs::write(dir.join("types.d.ts"), render_node_dts(api))?;
18        std::fs::write(dir.join("package.json"), render_package_json(package_name))?;
19        std::fs::write(dir.join("binding.gyp"), render_binding_gyp())?;
20        std::fs::write(dir.join("weaveffi_addon.c"), render_addon_c(api))?;
21        Ok(())
22    }
23}
24
25impl Generator for NodeGenerator {
26    fn name(&self) -> &'static str {
27        "node"
28    }
29
30    fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()> {
31        self.generate_impl(api, out_dir, "weaveffi")
32    }
33
34    fn generate_with_config(
35        &self,
36        api: &Api,
37        out_dir: &Utf8Path,
38        config: &GeneratorConfig,
39    ) -> Result<()> {
40        self.generate_impl(api, out_dir, config.node_package_name())
41    }
42
43    fn output_files(&self, _api: &Api, out_dir: &Utf8Path) -> Vec<String> {
44        vec![
45            out_dir.join("node/index.js").to_string(),
46            out_dir.join("node/types.d.ts").to_string(),
47            out_dir.join("node/package.json").to_string(),
48            out_dir.join("node/binding.gyp").to_string(),
49            out_dir.join("node/weaveffi_addon.c").to_string(),
50        ]
51    }
52}
53
54fn render_package_json(name: &str) -> String {
55    format!(
56        r#"{{
57  "name": "{name}",
58  "version": "0.1.0",
59  "main": "index.js",
60  "types": "types.d.ts",
61  "gypfile": true,
62  "scripts": {{
63    "install": "node-gyp rebuild"
64  }}
65}}
66"#
67    )
68}
69
70fn render_binding_gyp() -> String {
71    r#"{
72  "targets": [
73    {
74      "target_name": "weaveffi",
75      "sources": ["weaveffi_addon.c"],
76      "include_dirs": ["../c"],
77      "libraries": ["-lweaveffi"]
78    }
79  ]
80}
81"#
82    .to_string()
83}
84
85fn render_addon_c(api: &Api) -> String {
86    let mut out = String::from("#include <node_api.h>\n#include \"weaveffi.h\"\n\n");
87
88    let mut all_exports: Vec<(String, String)> = Vec::new();
89
90    for m in &api.modules {
91        for f in &m.functions {
92            let c_name = format!("weaveffi_{}_{}", m.name, f.name);
93            let napi_name = format!("Napi_{c_name}");
94            all_exports.push((f.name.clone(), napi_name.clone()));
95
96            out.push_str(&format!(
97                "static napi_value {napi_name}(napi_env env, napi_callback_info info) {{\n"
98            ));
99            out.push_str(&format!("  // TODO: implement — call {c_name}()\n"));
100            out.push_str("  return NULL;\n");
101            out.push_str("}\n\n");
102        }
103    }
104
105    out.push_str("static napi_value Init(napi_env env, napi_value exports) {\n");
106    if !all_exports.is_empty() {
107        out.push_str("  napi_property_descriptor props[] = {\n");
108        for (js_name, napi_fn) in &all_exports {
109            out.push_str(&format!(
110                "    {{ \"{js_name}\", NULL, {napi_fn}, NULL, NULL, NULL, napi_default, NULL }},\n"
111            ));
112        }
113        out.push_str("  };\n");
114        out.push_str(&format!(
115            "  napi_define_properties(env, exports, {}, props);\n",
116            all_exports.len()
117        ));
118    }
119    out.push_str("  return exports;\n");
120    out.push_str("}\n\n");
121    out.push_str("NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)\n");
122    out
123}
124
125fn ts_type_for(ty: &TypeRef) -> String {
126    match ty {
127        TypeRef::I32 | TypeRef::U32 | TypeRef::I64 | TypeRef::F64 => "number".into(),
128        TypeRef::Bool => "boolean".into(),
129        TypeRef::StringUtf8 => "string".into(),
130        TypeRef::Bytes => "Buffer".into(),
131        TypeRef::Handle => "bigint".into(),
132        TypeRef::Struct(name) | TypeRef::Enum(name) => name.clone(),
133        TypeRef::Optional(inner) => format!("{} | null", ts_type_for(inner)),
134        TypeRef::List(inner) => {
135            let inner_ts = ts_type_for(inner);
136            if matches!(inner.as_ref(), TypeRef::Optional(_)) {
137                format!("({inner_ts})[]")
138            } else {
139                format!("{inner_ts}[]")
140            }
141        }
142        TypeRef::Map(k, v) => format!("Record<{}, {}>", ts_type_for(k), ts_type_for(v)),
143    }
144}
145
146fn render_node_dts(api: &Api) -> String {
147    let mut out = String::from("// Generated types for WeaveFFI functions\n");
148    for m in &api.modules {
149        for s in &m.structs {
150            out.push_str(&format!("export interface {} {{\n", s.name));
151            for field in &s.fields {
152                out.push_str(&format!("  {}: {};\n", field.name, ts_type_for(&field.ty)));
153            }
154            out.push_str("}\n");
155        }
156        for e in &m.enums {
157            out.push_str(&format!("export enum {} {{\n", e.name));
158            for v in &e.variants {
159                out.push_str(&format!("  {} = {},\n", v.name, v.value));
160            }
161            out.push_str("}\n");
162        }
163        out.push_str(&format!("// module {}\n", m.name));
164        for f in &m.functions {
165            let params: Vec<String> = f
166                .params
167                .iter()
168                .map(|p| format!("{}: {}", p.name, ts_type_for(&p.ty)))
169                .collect();
170            let ret = match &f.returns {
171                Some(ty) => ts_type_for(ty),
172                None => "void".into(),
173            };
174            out.push_str(&format!(
175                "/** Maps to C function: weaveffi_{}_{} */\n",
176                m.name, f.name
177            ));
178            out.push_str(&format!(
179                "export function {}({}): {}\n",
180                f.name,
181                params.join(", "),
182                ret
183            ));
184        }
185    }
186    out
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use weaveffi_core::config::GeneratorConfig;
193    use weaveffi_ir::ir::{EnumDef, EnumVariant, Function, Module, Param, StructDef, StructField};
194
195    fn make_api(modules: Vec<Module>) -> Api {
196        Api {
197            version: "0.1.0".into(),
198            modules,
199        }
200    }
201
202    fn make_module(name: &str) -> Module {
203        Module {
204            name: name.into(),
205            functions: vec![],
206            structs: vec![],
207            enums: vec![],
208            errors: None,
209        }
210    }
211
212    #[test]
213    fn ts_type_for_primitives() {
214        assert_eq!(ts_type_for(&TypeRef::I32), "number");
215        assert_eq!(ts_type_for(&TypeRef::Bool), "boolean");
216        assert_eq!(ts_type_for(&TypeRef::StringUtf8), "string");
217        assert_eq!(ts_type_for(&TypeRef::Bytes), "Buffer");
218        assert_eq!(ts_type_for(&TypeRef::Handle), "bigint");
219    }
220
221    #[test]
222    fn ts_type_for_struct_and_enum() {
223        assert_eq!(ts_type_for(&TypeRef::Struct("Contact".into())), "Contact");
224        assert_eq!(ts_type_for(&TypeRef::Enum("Color".into())), "Color");
225    }
226
227    #[test]
228    fn ts_type_for_optional() {
229        let ty = TypeRef::Optional(Box::new(TypeRef::StringUtf8));
230        assert_eq!(ts_type_for(&ty), "string | null");
231    }
232
233    #[test]
234    fn ts_type_for_list() {
235        let ty = TypeRef::List(Box::new(TypeRef::I32));
236        assert_eq!(ts_type_for(&ty), "number[]");
237    }
238
239    #[test]
240    fn ts_type_for_list_of_optional() {
241        let ty = TypeRef::List(Box::new(TypeRef::Optional(Box::new(TypeRef::I32))));
242        assert_eq!(ts_type_for(&ty), "(number | null)[]");
243    }
244
245    #[test]
246    fn ts_type_for_map() {
247        let ty = TypeRef::Map(Box::new(TypeRef::StringUtf8), Box::new(TypeRef::I32));
248        assert_eq!(ts_type_for(&ty), "Record<string, number>");
249    }
250
251    #[test]
252    fn ts_type_for_optional_list() {
253        let ty = TypeRef::Optional(Box::new(TypeRef::List(Box::new(TypeRef::I32))));
254        assert_eq!(ts_type_for(&ty), "number[] | null");
255    }
256
257    #[test]
258    fn generate_node_dts_with_structs() {
259        let mut m = make_module("contacts");
260        m.structs.push(StructDef {
261            name: "Contact".into(),
262            doc: None,
263            fields: vec![
264                StructField {
265                    name: "name".into(),
266                    ty: TypeRef::StringUtf8,
267                    doc: None,
268                },
269                StructField {
270                    name: "age".into(),
271                    ty: TypeRef::I32,
272                    doc: None,
273                },
274                StructField {
275                    name: "active".into(),
276                    ty: TypeRef::Bool,
277                    doc: None,
278                },
279            ],
280        });
281        m.enums.push(EnumDef {
282            name: "Color".into(),
283            doc: None,
284            variants: vec![
285                EnumVariant {
286                    name: "Red".into(),
287                    value: 0,
288                    doc: None,
289                },
290                EnumVariant {
291                    name: "Green".into(),
292                    value: 1,
293                    doc: None,
294                },
295                EnumVariant {
296                    name: "Blue".into(),
297                    value: 2,
298                    doc: None,
299                },
300            ],
301        });
302        m.functions.push(Function {
303            name: "get_contact".into(),
304            params: vec![Param {
305                name: "id".into(),
306                ty: TypeRef::I32,
307            }],
308            returns: Some(TypeRef::Optional(Box::new(TypeRef::Struct(
309                "Contact".into(),
310            )))),
311            doc: None,
312            r#async: false,
313        });
314        m.functions.push(Function {
315            name: "list_contacts".into(),
316            params: vec![],
317            returns: Some(TypeRef::List(Box::new(TypeRef::Struct("Contact".into())))),
318            doc: None,
319            r#async: false,
320        });
321
322        let dts = render_node_dts(&make_api(vec![m]));
323
324        assert!(dts.contains("export interface Contact {"));
325        assert!(dts.contains("  name: string;"));
326        assert!(dts.contains("  age: number;"));
327        assert!(dts.contains("  active: boolean;"));
328        assert!(dts.contains("export enum Color {"));
329        assert!(dts.contains("  Red = 0,"));
330        assert!(dts.contains("  Green = 1,"));
331        assert!(dts.contains("  Blue = 2,"));
332        assert!(dts.contains("export function get_contact(id: number): Contact | null"));
333        assert!(dts.contains("export function list_contacts(): Contact[]"));
334
335        let iface_pos = dts.find("export interface Contact").unwrap();
336        let enum_pos = dts.find("export enum Color").unwrap();
337        let fn_pos = dts.find("export function get_contact").unwrap();
338        assert!(
339            iface_pos < fn_pos,
340            "interface should appear before functions"
341        );
342        assert!(enum_pos < fn_pos, "enum should appear before functions");
343    }
344
345    #[test]
346    fn node_generates_binding_gyp() {
347        let api = make_api(vec![{
348            let mut m = make_module("math");
349            m.functions.push(Function {
350                name: "add".into(),
351                params: vec![
352                    Param {
353                        name: "a".into(),
354                        ty: TypeRef::I32,
355                    },
356                    Param {
357                        name: "b".into(),
358                        ty: TypeRef::I32,
359                    },
360                ],
361                returns: Some(TypeRef::I32),
362                doc: None,
363                r#async: false,
364            });
365            m
366        }]);
367
368        let tmp = std::env::temp_dir().join("weaveffi_test_node_binding_gyp");
369        let _ = std::fs::remove_dir_all(&tmp);
370        std::fs::create_dir_all(&tmp).unwrap();
371        let out_dir = Utf8Path::from_path(&tmp).expect("temp dir is valid UTF-8");
372
373        NodeGenerator.generate(&api, out_dir).unwrap();
374
375        let gyp = std::fs::read_to_string(tmp.join("node").join("binding.gyp")).unwrap();
376        assert!(
377            gyp.contains("\"target_name\": \"weaveffi\""),
378            "missing target_name: {gyp}"
379        );
380        assert!(
381            gyp.contains("weaveffi_addon.c"),
382            "missing source file: {gyp}"
383        );
384
385        let addon = std::fs::read_to_string(tmp.join("node").join("weaveffi_addon.c")).unwrap();
386        assert!(
387            addon.contains("napi_value Init("),
388            "missing Init function: {addon}"
389        );
390        assert!(
391            addon.contains("weaveffi_math_add"),
392            "missing C ABI call: {addon}"
393        );
394        assert!(
395            addon.contains("// TODO: implement"),
396            "missing TODO comment: {addon}"
397        );
398
399        let pkg = std::fs::read_to_string(tmp.join("node").join("package.json")).unwrap();
400        assert!(pkg.contains("\"gypfile\": true"), "missing gypfile: {pkg}");
401        assert!(
402            pkg.contains("node-gyp rebuild"),
403            "missing install script: {pkg}"
404        );
405
406        let _ = std::fs::remove_dir_all(&tmp);
407    }
408
409    #[test]
410    fn generate_node_dts_with_structs_and_enums() {
411        let api = make_api(vec![Module {
412            name: "contacts".to_string(),
413            functions: vec![
414                Function {
415                    name: "get_contact".to_string(),
416                    params: vec![Param {
417                        name: "id".to_string(),
418                        ty: TypeRef::I32,
419                    }],
420                    returns: Some(TypeRef::Optional(Box::new(TypeRef::Struct(
421                        "Contact".into(),
422                    )))),
423                    doc: None,
424                    r#async: false,
425                },
426                Function {
427                    name: "list_contacts".to_string(),
428                    params: vec![],
429                    returns: Some(TypeRef::List(Box::new(TypeRef::Struct("Contact".into())))),
430                    doc: None,
431                    r#async: false,
432                },
433                Function {
434                    name: "set_favorite_color".to_string(),
435                    params: vec![
436                        Param {
437                            name: "contact_id".to_string(),
438                            ty: TypeRef::I32,
439                        },
440                        Param {
441                            name: "color".to_string(),
442                            ty: TypeRef::Optional(Box::new(TypeRef::Enum("Color".into()))),
443                        },
444                    ],
445                    returns: None,
446                    doc: None,
447                    r#async: false,
448                },
449                Function {
450                    name: "get_tags".to_string(),
451                    params: vec![Param {
452                        name: "contact_id".to_string(),
453                        ty: TypeRef::I32,
454                    }],
455                    returns: Some(TypeRef::List(Box::new(TypeRef::StringUtf8))),
456                    doc: None,
457                    r#async: false,
458                },
459            ],
460            structs: vec![StructDef {
461                name: "Contact".to_string(),
462                doc: None,
463                fields: vec![
464                    StructField {
465                        name: "name".to_string(),
466                        ty: TypeRef::StringUtf8,
467                        doc: None,
468                    },
469                    StructField {
470                        name: "email".to_string(),
471                        ty: TypeRef::Optional(Box::new(TypeRef::StringUtf8)),
472                        doc: None,
473                    },
474                    StructField {
475                        name: "tags".to_string(),
476                        ty: TypeRef::List(Box::new(TypeRef::StringUtf8)),
477                        doc: None,
478                    },
479                ],
480            }],
481            enums: vec![EnumDef {
482                name: "Color".to_string(),
483                doc: None,
484                variants: vec![
485                    EnumVariant {
486                        name: "Red".to_string(),
487                        value: 0,
488                        doc: None,
489                    },
490                    EnumVariant {
491                        name: "Green".to_string(),
492                        value: 1,
493                        doc: None,
494                    },
495                    EnumVariant {
496                        name: "Blue".to_string(),
497                        value: 2,
498                        doc: None,
499                    },
500                ],
501            }],
502            errors: None,
503        }]);
504
505        let tmp = std::env::temp_dir().join("weaveffi_test_node_structs_and_enums");
506        let _ = std::fs::remove_dir_all(&tmp);
507        std::fs::create_dir_all(&tmp).unwrap();
508        let out_dir = Utf8Path::from_path(&tmp).expect("temp dir is valid UTF-8");
509
510        NodeGenerator.generate(&api, out_dir).unwrap();
511
512        let dts = std::fs::read_to_string(tmp.join("node").join("types.d.ts")).unwrap();
513
514        assert!(
515            dts.contains("export interface Contact {"),
516            "missing Contact interface: {dts}"
517        );
518        assert!(dts.contains("  name: string;"), "missing name field: {dts}");
519        assert!(
520            dts.contains("  email: string | null;"),
521            "missing optional email field: {dts}"
522        );
523        assert!(
524            dts.contains("  tags: string[];"),
525            "missing list tags field: {dts}"
526        );
527
528        assert!(
529            dts.contains("export enum Color {"),
530            "missing Color enum: {dts}"
531        );
532        assert!(dts.contains("  Red = 0,"), "missing Red variant: {dts}");
533        assert!(dts.contains("  Green = 1,"), "missing Green variant: {dts}");
534        assert!(dts.contains("  Blue = 2,"), "missing Blue variant: {dts}");
535
536        assert!(
537            dts.contains("export function get_contact(id: number): Contact | null"),
538            "missing get_contact with optional return: {dts}"
539        );
540        assert!(
541            dts.contains("export function list_contacts(): Contact[]"),
542            "missing list_contacts with list return: {dts}"
543        );
544        assert!(
545            dts.contains(
546                "export function set_favorite_color(contact_id: number, color: Color | null): void"
547            ),
548            "missing set_favorite_color with optional enum param: {dts}"
549        );
550        assert!(
551            dts.contains("export function get_tags(contact_id: number): string[]"),
552            "missing get_tags with list return: {dts}"
553        );
554
555        let iface_pos = dts.find("export interface Contact").unwrap();
556        let enum_pos = dts.find("export enum Color").unwrap();
557        let fn_pos = dts.find("export function get_contact").unwrap();
558        assert!(
559            iface_pos < fn_pos,
560            "interface should appear before functions"
561        );
562        assert!(enum_pos < fn_pos, "enum should appear before functions");
563
564        let _ = std::fs::remove_dir_all(&tmp);
565    }
566
567    #[test]
568    fn node_custom_package_name() {
569        let api = make_api(vec![make_module("math")]);
570
571        let tmp = std::env::temp_dir().join("weaveffi_test_node_custom_pkg");
572        let _ = std::fs::remove_dir_all(&tmp);
573        std::fs::create_dir_all(&tmp).unwrap();
574        let out_dir = Utf8Path::from_path(&tmp).expect("temp dir is valid UTF-8");
575
576        let config = GeneratorConfig {
577            node_package_name: Some("@myorg/cool-lib".into()),
578            ..GeneratorConfig::default()
579        };
580        NodeGenerator
581            .generate_with_config(&api, out_dir, &config)
582            .unwrap();
583
584        let pkg = std::fs::read_to_string(tmp.join("node").join("package.json")).unwrap();
585        assert!(
586            pkg.contains("\"name\": \"@myorg/cool-lib\""),
587            "package.json should use custom name: {pkg}"
588        );
589        assert!(
590            !pkg.contains("\"name\": \"weaveffi\""),
591            "package.json should not contain default name: {pkg}"
592        );
593
594        let _ = std::fs::remove_dir_all(&tmp);
595    }
596
597    #[test]
598    fn node_dts_has_jsdoc() {
599        let api = make_api(vec![{
600            let mut m = make_module("math");
601            m.functions.push(Function {
602                name: "add".into(),
603                params: vec![
604                    Param {
605                        name: "a".into(),
606                        ty: TypeRef::I32,
607                    },
608                    Param {
609                        name: "b".into(),
610                        ty: TypeRef::I32,
611                    },
612                ],
613                returns: Some(TypeRef::I32),
614                doc: None,
615                r#async: false,
616            });
617            m.functions.push(Function {
618                name: "subtract".into(),
619                params: vec![
620                    Param {
621                        name: "a".into(),
622                        ty: TypeRef::I32,
623                    },
624                    Param {
625                        name: "b".into(),
626                        ty: TypeRef::I32,
627                    },
628                ],
629                returns: Some(TypeRef::I32),
630                doc: None,
631                r#async: false,
632            });
633            m
634        }]);
635
636        let dts = render_node_dts(&api);
637
638        assert!(
639            dts.contains("/** Maps to C function: weaveffi_math_add */\nexport function add("),
640            "missing JSDoc for add: {dts}"
641        );
642        assert!(
643            dts.contains(
644                "/** Maps to C function: weaveffi_math_subtract */\nexport function subtract("
645            ),
646            "missing JSDoc for subtract: {dts}"
647        );
648    }
649}