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}