crossbind 2.0.0-beta.55 → 2.0.0-beta.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,21 +21,75 @@ import writeIfChanged from './writeIfChanged.js';
21
21
  // top-level pub fn f(..) -> R -> free function (<=4 args)
22
22
  // impl Display for C -> toString() on the class
23
23
  // params may also be &str / &String (String on the wire, borrowed at the call site)
24
- // i64 / u64 cross as JS BigInt; Result<T, E> in any return above throws in JS on Err
24
+ // a returned &str / &String comes back owned (a JS string);
25
+ // i64 / u64 cross as JS BigInt, the narrower integers and f32 as numbers, char as a
26
+ // one-character string; Result<T, E> in any return above throws in JS on Err
25
27
  // (E: Display); Option<Self> factories return JS null; Option<i32/f64/bool/String> works in
26
- // params (undefined/null -> None) and returns (None -> undefined); other Option shapes skip
28
+ // params (undefined/null -> None) and returns (None -> null); other Option shapes skip
27
29
  // vectors come from crossbind.config.mjs: export.bindings.vectors = [{ of: 'i32', name: '..' }]
28
30
  // Anything outside this surface is skipped WITH a log line - never silently.
29
31
 
30
- const PRIMITIVES = new Set(['i32', 'i64', 'u64', 'f64', 'bool', 'String', '()']);
32
+ const PRIMITIVES = new Set(['i32', 'i64', 'u64', 'f64', 'bool', 'String', '()',
33
+ 'i8', 'i16', 'u8', 'u16', 'u32', 'f32', 'usize', 'isize', 'char']);
31
34
  const PARAM_ONLY = new Set(['&str', '&String']);
32
35
  const OPTION_INNERS = new Set(['i32', 'f64', 'bool', 'String']);
33
- const OPTION_PARAM_RE = /^Option<(i32|f64|bool|String)>$/;
36
+ const OPTION_PARAM_RE = /^Option<(i32|f64|bool|String|&str)>$/;
37
+ const OPTION_CLASS_REF_RE = /^Option<&(\w+)>$/;
34
38
  const OPTIONAL_REG = { i32: 'register_optional_i32', f64: 'register_optional_f64', bool: 'register_optional_bool', String: 'register_optional_string' };
35
39
  const VECTOR_ITEM_TYPES = new Set(['i32', 'f64', 'bool']);
36
40
  // serde_json::Value params/returns cross as a deep JSON copy (adapter-side codec, canonical
37
41
  // token 'Json'); the bare `Value` spelling counts only when the file imports serde_json.
38
42
  const JSON_TY = 'Json';
43
+ // A closure parameter (`impl Fn(..) -> R` or `Box<dyn Fn(..) -> R>`) takes a JS function: the
44
+ // shim wraps it so the Rust side calls back through the live handle.
45
+ const CLOSURE_RE = /^(?:impl\s+Fn|Box<\s*dyn\s+Fn)\s*\(([^)]*)\)\s*(?:->\s*([\w:<>&' ]+?)\s*)?(?:>\s*)?(?:\+[^>]*)?$/;
46
+ function closureShape(ty) {
47
+ const text = String(ty).replace(/\s+/g, ' ').trim();
48
+ const m = text.match(CLOSURE_RE);
49
+ if (!m) return null;
50
+ const boxed = text.startsWith('Box<');
51
+ const args = (m[1] ?? '').split(',').map((a) => a.trim()).filter(Boolean);
52
+ const ret = (m[2] ?? '()').replace(/>\s*$/, '').trim();
53
+ const CLOSURE_ARGS = new Set(['i32', 'f64', 'bool', 'String', '&str']);
54
+ if (!args.every((a) => CLOSURE_ARGS.has(a))) return null;
55
+ if (!['i32', 'f64', 'bool', 'String', '()'].includes(ret)) return null;
56
+ return { args, ret, boxed };
57
+ }
58
+ // Collections cross as plain JS arrays and objects. They ride the JSON wire, so the element type
59
+ // only has to be something serde can write: primitives, String, nested collections, and user
60
+ // structs that derive Serialize/Deserialize. The canonical token keeps the Rust spelling, so the
61
+ // shim can name the exact type it converts to.
62
+ const COLLECTION_RE = /^(?:Vec<.+>|\[.+;\s*\d+\]|&\[.+\]|\(.+,.*\)|(?:std::collections::)?(?:HashMap|BTreeMap|HashSet|BTreeSet)<.+>)$/;
63
+ // `&[u8]` / `Vec<u8>` and `&[f64]` / `Vec<f64>` are typed arrays on the JS side.
64
+ const TYPED_ARRAYS = {
65
+ 'Vec<u8>': { wrapper: 'JsBytes', owned: 'Vec<u8>', slice: false },
66
+ '&[u8]': { wrapper: 'JsBytes', owned: 'Vec<u8>', slice: true },
67
+ 'Vec<f64>': { wrapper: 'JsF64s', owned: 'Vec<f64>', slice: false },
68
+ '&[f64]': { wrapper: 'JsF64s', owned: 'Vec<f64>', slice: true },
69
+ };
70
+ const typedArrayOf = (ty) => TYPED_ARRAYS[String(ty).replace(/\s+/g, '')] ?? null;
71
+
72
+ const isCollection = (ty) => {
73
+ if (typedArrayOf(ty)) return false;
74
+ const text = String(ty).trim();
75
+ if (COLLECTION_RE.test(text)) return true;
76
+ // An optional collection rides the same wire: JSON writes None as null.
77
+ const inner = text.match(/^Option<(.+)>$/)?.[1];
78
+ return Boolean(inner && COLLECTION_RE.test(inner.trim()));
79
+ };
80
+
81
+ // A user type inside a collection travels as JSON, so it has to derive Serialize/Deserialize.
82
+ // Types crossbind knows by itself (primitives, String) always do.
83
+ function collectionCarries(ty, ctx) {
84
+ const names = String(ty).match(/\b[A-Z]\w*\b/g) ?? [];
85
+ return names.every((name) => {
86
+ const vo = (ctx.valueObjects ?? []).find((v) => v.name === name);
87
+ if (vo) return Boolean(vo.serde);
88
+ const known = (ctx.classes?.has?.(name)) || (ctx.enums ?? []).some((e) => e.name === name)
89
+ || (ctx.newtypes ?? []).some((n) => n.name === name);
90
+ return !known;
91
+ });
92
+ }
39
93
  const isJsonSpelling = (ty, ctx) => Boolean(ctx?.allowJson)
40
94
  && (ty === 'serde_json::Value' || (Boolean(ctx.hasSerdeUse) && ty === 'Value'));
41
95
  // Arc<Class> params/returns (shared ownership): canonical token 'Arc<X>'; the bare `Arc`
@@ -63,6 +117,30 @@ const matchJsTok = (ty, ctx) => {
63
117
  };
64
118
  const FN_SIG_RE = /^pub (?:const )?fn (\w+)\s*\(([^)]*)\)\s*(?:->\s*([\w:<>(),& ]+?))?\s*\{/;
65
119
 
120
+ // The parameter list is read by counting parentheses, because a closure parameter carries its
121
+ // own: `f: impl Fn(i32) -> i32` would end the list early for a regex.
122
+ function matchFnSignature(line) {
123
+ const head = /^pub (?:const )?fn (\w+)\s*\(/.exec(line);
124
+ if (!head) return null;
125
+ let depth = 0;
126
+ let end = -1;
127
+ for (let i = head[0].length - 1; i < line.length; i += 1) {
128
+ if (line[i] === '(') depth += 1;
129
+ else if (line[i] === ')') {
130
+ depth -= 1;
131
+ if (depth === 0) { end = i; break; }
132
+ }
133
+ }
134
+ if (end === -1) return null;
135
+ const params = line.slice(head[0].length, end);
136
+ // The body may start on the same line, so the return type ends at the opening brace.
137
+ const brace = line.indexOf('{', end);
138
+ if (brace === -1) return null;
139
+ const rest = line.slice(end + 1, brace).trim();
140
+ const ret = rest.startsWith('->') ? rest.slice(2).trim() : undefined;
141
+ return [line, head[1], params, ret];
142
+ }
143
+
66
144
  export default function generateRustBridge({ crateDir, vectors = [], dtsFile = null, keepName = null, dtsMode = 'sync', log = console.log }) {
67
145
  const libRsPath = `${crateDir}/src/lib.rs`;
68
146
  if (!fs.existsSync(libRsPath)) throw new Error(`crossbind: rust bridge: ${libRsPath} not found`);
@@ -96,10 +174,11 @@ export default function generateRustBridge({ crateDir, vectors = [], dtsFile = n
96
174
  '[dependencies]',
97
175
  `${userCrate} = { package = "${rawCrateName}", path = "${relPath(bridgeDir, crateDir)}" }`,
98
176
  `embind-rs = { path = "${relPath(bridgeDir, embindRsDir)}" }`,
99
- ...(model.usesJson ? ['serde_json = "1"'] : []),
177
+ ...(model.usesJson ? ['serde = "1"', 'serde_json = "1"'] : []),
100
178
  '',
101
179
  '[profile.release]',
102
- 'panic = "abort"',
180
+ // Unwinding, not abort: the shims catch a panic and raise it as a JS exception.
181
+ 'panic = "unwind"',
103
182
  '# One object per crate: the keep symbol and the init-array ctor must share an object.',
104
183
  'codegen-units = 1',
105
184
  '',
@@ -149,10 +228,12 @@ export function createRustBridgeCrate({ rsFile, cacheDir, projectPath, vectors =
149
228
  ...Object.entries(cargoDependencies).map(([name, spec]) => (
150
229
  String(spec).trim().startsWith('{') ? `${name} = ${spec}` : `${name} = "${spec}"`
151
230
  )),
231
+ ...(model.usesJson && !cargoDependencies.serde ? ['serde = "1"'] : []),
152
232
  ...(model.usesJson && !cargoDependencies.serde_json ? ['serde_json = "1"'] : []),
153
233
  '',
154
234
  '[profile.release]',
155
- 'panic = "abort"',
235
+ // Unwinding, not abort: the shims catch a panic and raise it as a JS exception.
236
+ 'panic = "unwind"',
156
237
  'codegen-units = 1',
157
238
  '',
158
239
  '# Isolated on purpose: never join a surrounding workspace.',
@@ -180,7 +261,10 @@ export function createCrateImportBridge({ crateName, spec, cacheDir, dtsMode = '
180
261
  const stem = `crate_${crateName.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase()}`;
181
262
  const dir = `${cacheDir}/rust-bridges/${stem}`;
182
263
  const depLine = String(spec).trim().startsWith('{') ? `${crateName} = ${spec}` : `${crateName} = "${spec}"`;
183
- const manifest = [
264
+ // The surface is only known after cargo metadata, which itself needs a manifest: the first
265
+ // write is the minimal one, the second adds the JSON wire's dependencies once the model says
266
+ // the bridge needs them.
267
+ const manifestWith = (extraDeps) => [
184
268
  '# Generated by crossbind rustBridgeGen - do not edit.',
185
269
  `# embind-rs from @crossbind/core-embind-rust ${embindRustVersion()}`,
186
270
  '[package]',
@@ -196,16 +280,18 @@ export function createCrateImportBridge({ crateName, spec, cacheDir, dtsMode = '
196
280
  '[dependencies]',
197
281
  depLine,
198
282
  `embind-rs = { path = "${relPath(dir, resolveEmbindRsDir())}" }`,
283
+ ...extraDeps,
199
284
  '',
200
285
  '[profile.release]',
201
- 'panic = "abort"',
286
+ // Unwinding, not abort: the shims catch a panic and raise it as a JS exception.
287
+ 'panic = "unwind"',
202
288
  'codegen-units = 1',
203
289
  '',
204
290
  '# Isolated on purpose: never join a surrounding workspace.',
205
291
  '[workspace]',
206
292
  '',
207
293
  ].join('\n');
208
- writeIfChanged(`${dir}/Cargo.toml`, manifest);
294
+ writeIfChanged(`${dir}/Cargo.toml`, manifestWith([]));
209
295
  // cargo metadata needs a resolvable lib target before the real bridge exists.
210
296
  if (!fs.existsSync(`${dir}/src/lib.rs`)) writeIfChanged(`${dir}/src/lib.rs`, '// crossbind placeholder\n');
211
297
 
@@ -235,14 +321,21 @@ export function createCrateImportBridge({ crateName, spec, cacheDir, dtsMode = '
235
321
  crateModelCache.set(cacheKey, model);
236
322
  }
237
323
 
238
- const bridge = emitBridge(model, { userCrate: crateName.replaceAll('-', '_'), vectors: [], log });
324
+ const jsonDeps = ['serde', 'serde_json'].filter((dep) => dep !== crateName).map((dep) => `${dep} = "1"`);
325
+ writeIfChanged(`${dir}/Cargo.toml`, manifestWith(model.usesJson ? jsonDeps : []));
326
+ // Two crates can export the same type name (semver::Version and uuid::Version both do), so a
327
+ // crate import registers under `<crate>_<Name>` and the proxy module re-exports the clean name.
328
+ const namePrefix = `${crateName.replaceAll('-', '_')}_`;
329
+ const bridge = emitBridge(model, { userCrate: crateName.replaceAll('-', '_'), vectors: [], log, namePrefix });
239
330
  writeIfChanged(`${dir}/src/lib.rs`, bridge);
240
331
  // Editor types for the `cargo:<crate>` import: an ambient module the app's tsconfig
241
332
  // includes (e.g. "include": ["**/*", ".crossbind/rust-crates/types/**/*.d.ts"]). The scheme
242
333
  // keeps the module name unique, so npm packages/@types of the same name never clash.
243
334
  const dtsBody = emitDts(model, [], dtsMode).split('\n').map((l) => (l ? ` ${l}` : l)).join('\n');
244
335
  writeIfChanged(`${cacheDir}/rust-crates/types/${crateName}.d.ts`, `declare module 'cargo:${crateName}' {\n${dtsBody}\n}\n`);
245
- return { bridgeDir: dir, crateName: `${stem}_crossbind_app`, model };
336
+ return {
337
+ bridgeDir: dir, crateName: `${stem}_crossbind_app`, model, namePrefix,
338
+ };
246
339
  }
247
340
 
248
341
  // The embind-rs runtime crate ships inside @crossbind/core-embind-rust; the consumer (a plugin
@@ -306,6 +399,9 @@ function newAcc() {
306
399
  return {
307
400
  enums: [], // { name, variants: [{ name, value }] }
308
401
  valueObjects: [], // { name, fields: [{ name, type }] }
402
+ newtypes: [], // { name, inner } - crosses as the inner value
403
+ jsonTypes: [], // { name } - data enums that cross as a plain JS value
404
+ consts: [], // { name, ty, value } - registered as a module constant
309
405
  classes: new Map(), // name -> { name, ctor, factories: [], methods: [], hasDisplay }
310
406
  freeFns: [], // { name, jsName, args, ret, throws }
311
407
  displayNames: new Set(),
@@ -333,7 +429,7 @@ function cfgEnabled(cfg, enabled) {
333
429
  // collectTypes: types and free fns register only from the crate root (lib.rs) - module files
334
430
  // contribute impls (methods, factories, Display) for already-known types.
335
431
  function scanSource(src, acc, { collectTypes, log }) {
336
- const { enums, valueObjects, classes, freeFns, displayNames, wantedTypes } = acc;
432
+ const { enums, valueObjects, newtypes, jsonTypes, consts, classes, freeFns, displayNames, wantedTypes } = acc;
337
433
  acc.hasSerdeUse ||= /\buse\s+serde_json\b/.test(src);
338
434
  acc.hasArcUse ||= /\buse\s+std::sync::(?:Arc\b|\{[^}]*\bArc\b)/.test(src);
339
435
  acc.hasEmbindUse ||= /\buse\s+embind_rs::/.test(src);
@@ -396,38 +492,70 @@ function scanSource(src, acc, { collectTypes, log }) {
396
492
  let structM = t.match(/^pub struct (\w+)/);
397
493
  if (structM && !collectTypes && !wantedTypes.has(structM[1])) structM = null;
398
494
  const modM = t.match(/^(?:pub(?:\([^)]*\))?\s+)?mod (\w+)\s*([;{])/);
495
+ // `pub const NAME: T = value;` (and `pub static`) - a value, not a getter, in JS.
496
+ const constM = collectTypes ? t.match(/^pub (?:const|static) (\w+)\s*:\s*([\w()&' ]+?)\s*=\s*(.+?);$/) : null;
399
497
  const displayM = t.match(/^impl (?:[\w:]+::)?Display for (\w+)/);
400
498
  const traitImplM = !displayM && /^impl\b[^{]*\bfor\b/.test(t);
401
499
  const implM = displayM || traitImplM ? null : t.match(/^impl (\w+)\s*\{/);
402
500
  const freeFnM = collectTypes && !enumM && !structM && !modM && !displayM && !traitImplM && !implM
403
- ? t.match(FN_SIG_RE) : null;
501
+ ? matchFnSignature(t) : null;
404
502
 
405
503
  if (enumM) {
406
- const isReprI32 = attrs.some((a) => /repr\(i32\)/.test(a));
504
+ const derive = attrs.find((a) => a.startsWith('#[derive')) ?? '';
505
+
407
506
  const variants = [];
408
507
  let idx = 0;
409
508
  for (i += 1; i < lines.length && !/^\}/.test(lines[i].trim()); i += 1) {
410
509
  const v = lines[i].trim().match(/^(\w+)(?:\s*=\s*(-?\d+))?\s*,?$/);
411
510
  if (v) { variants.push({ name: v[1], value: v[2] !== undefined ? Number(v[2]) : idx }); idx = variants.at(-1).value + 1; }
412
511
  }
413
- if (isReprI32 && variants.length) enums.push({ name: enumM[1], variants });
414
- else log(`crossbind: rust bridge: enum ${enumM[1]} skipped (needs #[repr(i32)] and unit variants)`);
512
+ const hasSerde = derive.includes('Serialize') && derive.includes('Deserialize');
513
+ if (variants.length) enums.push({ name: enumM[1], variants });
514
+ // A variant that carries data cannot be an embind enum, but it can cross as a plain
515
+ // JS value when the enum derives serde - in serde's own representation.
516
+ else if (hasSerde) jsonTypes.push({ name: enumM[1] });
517
+ else log(`crossbind: rust bridge: enum ${enumM[1]} skipped (a data enum crosses only when it derives Serialize and Deserialize)`);
415
518
  } else if (structM) {
416
519
  const isReprC = attrs.some((a) => /repr\(C\)/.test(a));
417
520
  const derive = attrs.find((a) => a.startsWith('#[derive')) ?? '';
418
- if (isReprC) {
521
+ // A single-field tuple struct over a primitive is a newtype: it crosses as the inner
522
+ // value, the way napi-rs spells `#[napi(transparent)]`.
523
+ const newtypeM = t.match(/^pub struct (\w+)\s*\(\s*pub ([\w()]+)\s*,?\s*\)\s*;/);
524
+ if (newtypeM && PRIMITIVES.has(newtypeM[2])) {
525
+ newtypes.push({ name: newtypeM[1], inner: newtypeM[2] });
526
+ } else if (isReprC) {
419
527
  const ok = derive.includes('Default') && derive.includes('Copy');
420
528
  const fields = [];
421
529
  for (i += 1; i < lines.length && !/^\}/.test(lines[i].trim()); i += 1) {
422
530
  const f = lines[i].trim().match(/^pub (\w+)\s*:\s*([\w()]+)\s*,?$/);
423
- if (f && PRIMITIVES.has(f[2])) fields.push({ name: f[1], type: f[2] });
531
+ // A field may be another value object: embind nests them, and the wrapper is
532
+ // repr(transparent) over the user struct, so the offset still lands right.
533
+ if (f && (PRIMITIVES.has(f[2]) || valueObjects.some((v) => v.name === f[2]))) fields.push({ name: f[1], type: f[2] });
424
534
  }
425
- if (ok && fields.length) valueObjects.push({ name: structM[1], fields });
426
- else log(`crossbind: rust bridge: struct ${structM[1]} skipped (repr(C) needs derive(Default, Copy) and pub primitive fields)`);
427
- } else {
428
- if (!classes.has(structM[1])) classes.set(structM[1], { name: structM[1], ctor: null, factories: [], methods: [] });
535
+ if (ok && fields.length) valueObjects.push({ name: structM[1], fields, serde: derive.includes('Serialize') && derive.includes('Deserialize') });
536
+ else log(`crossbind: rust bridge: struct ${structM[1]} skipped (repr(C) needs derive(Default, Copy) and pub primitive or value-object fields)`);
537
+ } else if (derive.includes('Serialize') && derive.includes('Deserialize') && !newtypeM) {
538
+ // Deriving serde says "this is data": it crosses as a plain JS object, and its
539
+ // methods (if any) stay native, the way napi-rs separates #[napi(object)] from
540
+ // #[napi].
541
+ if (!jsonTypes.some((j) => j.name === structM[1])) jsonTypes.push({ name: structM[1] });
429
542
  if (t.endsWith('{')) { for (i += 1; i < lines.length && !/^\}/.test(lines[i].trim()); i += 1); }
543
+ } else {
544
+ if (!classes.has(structM[1])) classes.set(structM[1], { name: structM[1], ctor: null, factories: [], methods: [], fields: [], hasDefault: derive.includes('Default') });
545
+ const cls = classes.get(structM[1]);
546
+ cls.serde = derive.includes('Serialize') && derive.includes('Deserialize');
547
+ // Public fields read and write as JS properties, the way napi-rs exposes them.
548
+ if (t.endsWith('{')) {
549
+ for (i += 1; i < lines.length && !/^\}/.test(lines[i].trim()); i += 1) {
550
+ const f = lines[i].trim().match(/^pub (\w+)\s*:\s*([\w()]+)\s*,?$/);
551
+ if (f) cls.fields.push({ name: f[1], type: f[2] });
552
+ }
553
+ }
430
554
  }
555
+ } else if (constM) {
556
+ const constTy = normalizeStringSpelling(constM[2]);
557
+ if (PRIMITIVES.has(constTy) || constTy === '&str') consts.push({ name: constM[1], ty: constTy, value: constM[3].trim() });
558
+ else log(`crossbind: rust bridge: const ${constM[1]} skipped (only i32, f64 and bool constants cross)`);
431
559
  } else if (modM) {
432
560
  // `mod x;` is followed for crate imports; inline `mod x { .. }` bodies are opaque.
433
561
  if (modM[2] === ';') mods.push({ name: modM[1], cfg: cfgOf(attrs) });
@@ -443,8 +571,8 @@ function scanSource(src, acc, { collectTypes, log }) {
443
571
  let depth = 1;
444
572
  for (i += 1; i < lines.length && depth > 0; i += 1) {
445
573
  const s = lines[i];
446
- const sig = s.trim().match(FN_SIG_RE);
447
- if (sig && depth === 1) parseFn(cls, sig, { enums, valueObjects, classes, allowJson: collectTypes, hasSerdeUse: acc.hasSerdeUse, hasArcUse: acc.hasArcUse, hasEmbindUse: acc.hasEmbindUse }, log);
574
+ const sig = matchFnSignature(s.trim());
575
+ if (sig && depth === 1) parseFn(cls, sig, { enums, valueObjects, newtypes, jsonTypes, classes, allowJson: collectTypes, hasSerdeUse: acc.hasSerdeUse, hasArcUse: acc.hasArcUse, hasEmbindUse: acc.hasEmbindUse }, log);
448
576
  depth += (s.match(/\{/g) ?? []).length - (s.match(/\}/g) ?? []).length;
449
577
  }
450
578
  i -= 1;
@@ -452,7 +580,7 @@ function scanSource(src, acc, { collectTypes, log }) {
452
580
  // Consume unknown-impl bodies so their fns are never misread as free functions.
453
581
  i = skipBlock(lines, i);
454
582
  } else if (freeFnM) {
455
- parseFreeFn(freeFns, freeFnM, { enums, valueObjects, classes, allowJson: collectTypes, hasSerdeUse: acc.hasSerdeUse, hasArcUse: acc.hasArcUse, hasEmbindUse: acc.hasEmbindUse }, log);
583
+ parseFreeFn(freeFns, freeFnM, { enums, valueObjects, newtypes, jsonTypes, classes, allowJson: collectTypes, hasSerdeUse: acc.hasSerdeUse, hasArcUse: acc.hasArcUse, hasEmbindUse: acc.hasEmbindUse }, log);
456
584
  }
457
585
  attrs = [];
458
586
  }
@@ -460,20 +588,66 @@ function scanSource(src, acc, { collectTypes, log }) {
460
588
  }
461
589
 
462
590
  function finalizeModel(acc, log) {
463
- const { enums, valueObjects, classes, freeFns, displayNames } = acc;
591
+ const { enums, valueObjects, newtypes, jsonTypes, consts, classes, freeFns, displayNames } = acc;
464
592
  // A class with no exported surface is dropped (with a note), mirroring the C++ generator.
465
- for (const [name, cls] of classes) {
466
- if (!cls.ctor && !cls.factories.length && !cls.methods.length) {
467
- classes.delete(name);
468
- log(`crossbind: rust bridge: struct ${name} has no exportable pub fns - not registered`);
593
+ // A binding that borrows a dropped class would name a class that never registers and abort
594
+ // the module at init, so dropping a class drops those bindings too, until nothing changes.
595
+ const dropped = new Set();
596
+ const borrowsDropped = (args) => args.some((p) => {
597
+ const ty = String(p.ty);
598
+ return (ty.startsWith('&') && dropped.has(ty.slice(1))) || dropped.has(ty.match(ARC_RE)?.[1]);
599
+ });
600
+ let changed = true;
601
+ while (changed) {
602
+ changed = false;
603
+ for (const [name, cls] of classes) {
604
+ if (!cls.ctor && !cls.factories.length && !cls.methods.length && !(cls.fields ?? []).length) {
605
+ classes.delete(name);
606
+ dropped.add(name);
607
+ changed = true;
608
+ log(`crossbind: rust bridge: struct ${name} has no exportable pub fns - not registered`);
609
+ }
610
+ }
611
+ const keepFn = (owner) => (f) => {
612
+ // A function that hands back a class the model dropped would name a type that never
613
+ // registers, so it goes with it.
614
+ if (f.ownedClass && dropped.has(f.ownedClass)) {
615
+ changed = true;
616
+ log(`crossbind: rust bridge: ${owner}${f.name} skipped (it returns a struct that is not registered)`);
617
+ return false;
618
+ }
619
+ if (!borrowsDropped(f.args)) return true;
620
+ changed = true;
621
+ log(`crossbind: rust bridge: ${owner}${f.name} skipped (a parameter borrows a struct that is not registered)`);
622
+ return false;
623
+ };
624
+ freeFns.splice(0, freeFns.length, ...freeFns.filter(keepFn('fn ')));
625
+ for (const cls of classes.values()) {
626
+ cls.methods = cls.methods.filter(keepFn(`${cls.name}::`));
627
+ cls.factories = cls.factories.filter(keepFn(`${cls.name}::`));
628
+ if (cls.ctor && borrowsDropped(cls.ctor.args)) { cls.ctor = null; changed = true; log(`crossbind: rust bridge: ${cls.name}::new skipped (a parameter borrows a struct that is not registered)`); }
469
629
  }
470
630
  }
631
+ for (const cls of classes.values()) {
632
+ cls.fields = (cls.fields ?? []).filter((f) => {
633
+ const carries = PRIMITIVES.has(f.type) || enums.some((e) => e.name === f.type) || valueObjects.some((v) => v.name === f.type);
634
+ if (!carries) log(`crossbind: rust bridge: ${cls.name}.${f.name} skipped (a property carries primitives, enums and value objects)`);
635
+ return carries;
636
+ });
637
+ }
471
638
  for (const cls of classes.values()) {
472
639
  const collides = cls.methods.some((m) => camel(m.name) === 'toString');
473
640
  if (displayNames.has(cls.name) && collides) log(`crossbind: rust bridge: ${cls.name} Display->toString skipped (a toString method already exists)`);
474
641
  cls.hasDisplay = displayNames.has(cls.name) && !collides;
475
642
  }
476
- const anyJson = (args, ret) => args.some((p) => p.ty === JSON_TY) || ret === JSON_TY;
643
+ const isJsonRecordName = (ty) => {
644
+ const text = String(ty).trim();
645
+ const inner = text.match(/^Option<(.+)>$/)?.[1]?.trim() ?? text;
646
+ return jsonTypes.some((j) => j.name === inner)
647
+ || (text.startsWith('Option<') && valueObjects.some((v) => v.name === inner));
648
+ };
649
+ const anyJson = (args, ret) => args.some((p) => p.ty === JSON_TY || isCollection(p.ty) || isJsonRecordName(p.ty))
650
+ || ret === JSON_TY || isCollection(ret) || isJsonRecordName(ret);
477
651
  const usesJson = freeFns.some((f) => anyJson(f.args, f.ret))
478
652
  || [...classes.values()].some((c) => (c.ctor && anyJson(c.ctor.args, '()'))
479
653
  || c.factories.some((f) => anyJson(f.args, '()'))
@@ -506,7 +680,7 @@ function finalizeModel(acc, log) {
506
680
  throw new Error(`crossbind: rust bridge: ${c.name} is shared via Arc<...>, so every factory must return Arc<Self> ('${boxed.name}' returns Self)`);
507
681
  }
508
682
  }
509
- return { enums, valueObjects, classes: [...classes.values()], freeFns, usesJson, sharedOf: [...sharedOf].sort() };
683
+ return { enums, valueObjects, newtypes, jsonTypes, consts, classes: [...classes.values()], freeFns, usesJson, sharedOf: [...sharedOf].sort() };
510
684
  }
511
685
 
512
686
  // Consumes a brace-delimited block starting at line i; returns the closing line's index.
@@ -524,24 +698,93 @@ function skipBlock(lines, i) {
524
698
 
525
699
  // Splits `Result<T, E>` / `Option<T>` off a return type; `Result<T>` (an alias like
526
700
  // anyhow/io::Result) also matches - the shim's Ok/Err arms work on any core Result underneath.
527
- function analyzeReturn(raw) {
528
- const r = raw.trim();
529
- const res = r.match(/^Result\s*<\s*(.+?)\s*(?:,\s*([^<>]+?)\s*)?>$/);
530
- if (res) return { inner: res[1], throws: true, optional: false };
531
- const opt = r.match(/^Option\s*<\s*(.+?)\s*>$/);
532
- if (opt) return { inner: opt[1], throws: false, optional: true };
701
+ // `&'static str` and `Cow<'_, str>` are the same JS string as `&str`.
702
+ function normalizeStringSpelling(ty) {
703
+ const text = String(ty).trim();
704
+
705
+ if (/^&\s*'\w+\s+str$/.test(text)) return '&str';
706
+ // A Cow return becomes an owned String on the way out, like any borrowed string.
707
+ if (/^(?:std::borrow::)?Cow<\s*(?:'\w+\s*,\s*)?str\s*>$/.test(text)) return '&str';
708
+ return text;
709
+ }
710
+
711
+ // `Option<Record>` and `Option<ValueObject>` ride the JSON wire whole: null is the None.
712
+ function isOptionalRecord(ty, ctx) {
713
+ const inner = String(ty).trim().match(/^Option<(.+)>$/)?.[1]?.trim();
714
+ if (!inner || !ctx) return false;
715
+ return (ctx.jsonTypes ?? []).some((j) => j.name === inner)
716
+ || (ctx.valueObjects ?? []).some((v) => v.name === inner);
717
+ }
718
+
719
+ function analyzeReturn(raw, ctx) {
720
+ // `impl Iterator<Item = T>` is read as the sequence it produces: the shim collects it, so JS
721
+ // gets the values rather than a lazy iterator.
722
+ const iterator = raw.trim().match(/^impl\s+Iterator\s*<\s*Item\s*=\s*(.+?)\s*>$/);
723
+ if (iterator) return { inner: `Vec<${iterator[1].trim()}>`, throws: false, optional: false, collect: true };
724
+ const r = normalizeStringSpelling(raw.trim());
725
+ const res = unwrapGeneric(r, 'Result');
726
+ // Only the Ok type crosses; the error type needs nothing but Display, so `Box<dyn Error>`
727
+ // and friends are read past here.
728
+ if (res !== null) {
729
+ const ok = splitTopLevel(res)[0].trim();
730
+ if (isCollection(ok)) return { inner: ok, throws: true, optional: false };
731
+ const inner = unwrapGeneric(ok, 'Option');
732
+ return inner !== null
733
+ ? { inner: inner.trim(), throws: true, optional: true }
734
+ : { inner: ok, throws: true, optional: false };
735
+ }
736
+ if (isCollection(r) || isOptionalRecord(r, ctx)) return { inner: r, throws: false, optional: false };
737
+ const opt = unwrapGeneric(r, 'Option');
738
+ if (opt !== null) return { inner: opt.trim(), throws: false, optional: true };
533
739
  return { inner: r, throws: false, optional: false };
534
740
  }
535
741
 
742
+ // `Name<...>` -> the text between the outermost angle brackets, or null when the type is not
743
+ // that generic. Counting depth is what lets a nested generic (`Result<i32, Box<dyn Error>>`)
744
+ // keep its own commas.
745
+ function unwrapGeneric(ty, name) {
746
+ const head = new RegExp(`^${name}\\s*<`).exec(ty);
747
+ if (!head || !ty.endsWith('>')) return null;
748
+ let depth = 0;
749
+ for (let i = head[0].length - 1; i < ty.length; i += 1) {
750
+ if (ty[i] === '<') depth += 1;
751
+ else if (ty[i] === '>') {
752
+ depth -= 1;
753
+ if (depth === 0) return i === ty.length - 1 ? ty.slice(head[0].length, i) : null;
754
+ }
755
+ }
756
+ return null;
757
+ }
758
+
759
+ function splitTopLevel(text) {
760
+ const parts = [];
761
+ let depth = 0;
762
+ let start = 0;
763
+ for (let i = 0; i < text.length; i += 1) {
764
+ // `->` is an arrow, not a closing angle bracket: a closure parameter has one.
765
+ const isArrow = text[i] === '>' && text[i - 1] === '-';
766
+ if (text[i] === '<' || text[i] === '(' || text[i] === '[') depth += 1;
767
+ else if (!isArrow && (text[i] === '>' || text[i] === ')' || text[i] === ']')) depth -= 1;
768
+ else if (text[i] === ',' && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
769
+ }
770
+ parts.push(text.slice(start));
771
+ return parts;
772
+ }
773
+
536
774
  function parseFn(cls, sig, ctx, log) {
537
775
  const { enums, valueObjects, classes } = ctx;
538
776
  const [, name, rawParams, rawRet] = sig;
539
- const known = (ty) => PRIMITIVES.has(ty)
540
- || enums.some((e) => e.name === ty) || valueObjects.some((v) => v.name === ty);
777
+ // A borrowed string is known in both directions: as a parameter it borrows, as a return it
778
+ // comes back owned.
779
+ const known = (ty) => PRIMITIVES.has(ty) || PARAM_ONLY.has(ty) || typedArrayOf(ty) || (isCollection(ty) && collectionCarries(ty, ctx))
780
+ || enums.some((e) => e.name === ty) || valueObjects.some((v) => v.name === ty)
781
+ || (ctx.newtypes ?? []).some((n) => n.name === ty)
782
+ || (ctx.jsonTypes ?? []).some((j) => j.name === ty)
783
+ || isOptionalRecord(ty, ctx);
541
784
  // `&OtherClass` params: the referenced struct must already be declared (parsed) above.
542
785
  const isClassRef = (ty) => ty.startsWith('&') && !PARAM_ONLY.has(ty) && classes.has(ty.slice(1));
543
786
 
544
- const params = rawParams.split(',').map((p) => p.trim()).filter(Boolean);
787
+ const params = splitTopLevel(rawParams).map((p) => p.trim()).filter(Boolean);
545
788
  let selfKind = null;
546
789
  if (params[0] === '&mut self' || params[0] === '&self') selfKind = params.shift();
547
790
  else if (params[0] === 'self' || params[0] === 'mut self') {
@@ -551,26 +794,34 @@ function parseFn(cls, sig, ctx, log) {
551
794
 
552
795
  const args = [];
553
796
  for (const p of params) {
554
- const m = p.match(/^(\w+)\s*:\s*(&\s*(?:str|String)|Option\s*<\s*(?:i32|f64|bool|String)\s*>|serde_json\s*::\s*Value|(?:std\s*::\s*sync\s*::\s*)?Arc\s*<\s*\w+\s*>|embind_rs\s*::\s*Js(?:Value|Function)|&\s*\w+|[\w()]+)$/);
797
+ const m = p.match(/^(\w+)\s*:\s*(&\s*(?:str|String)|Option\s*<\s*(?:i32|f64|bool|String|&\s*\w+)\s*>|Vec\s*<[^;]+>|\[[^\]]+;\s*\d+\]|&\s*\[[^\]]+\]|\([^)]*,[^)]*\)|(?:std\s*::\s*collections\s*::\s*)?(?:HashMap|BTreeMap|HashSet|BTreeSet)\s*<[^>]+>|serde_json\s*::\s*Value|(?:std\s*::\s*sync\s*::\s*)?Arc\s*<\s*\w+\s*>|embind_rs\s*::\s*Js(?:Value|Function)|&\s*\w+|[\w()]+)$/);
555
798
  let ty = m?.[2].replace(/\s+/g, '');
799
+ if (ty) ty = normalizeStringSpelling(ty);
556
800
  if (ty && !known(ty) && isJsonSpelling(ty, ctx)) ty = JSON_TY;
557
801
  if (ty) ty = normalizeArc(ty, ctx);
558
802
  const jsTokP = ty ? matchJsTok(ty, ctx) : null;
559
803
  if (jsTokP) ty = jsTokP;
560
804
  const arcParam = ty?.match(ARC_RE)?.[1];
561
- if (!m || !(known(ty) || ty === JSON_TY || jsTokP || (arcParam && classes.has(arcParam)) || PARAM_ONLY.has(ty) || OPTION_PARAM_RE.test(ty) || isClassRef(ty))) {
562
- log(`crossbind: rust bridge: ${cls.name}::${name} skipped (unsupported parameter '${p}')`);
805
+ const optClassRef = ty?.match(OPTION_CLASS_REF_RE)?.[1];
806
+ const closure = closureShape(p.slice(p.indexOf(':') + 1));
807
+ if (closure) { args.push({ name: `a${args.length}`, ty: `__closure${JSON.stringify(closure)}` }); continue; }
808
+ if (!m || !(known(ty) || ty === JSON_TY || jsTokP || (arcParam && classes.has(arcParam)) || PARAM_ONLY.has(ty) || OPTION_PARAM_RE.test(ty) || isClassRef(ty)
809
+ || (optClassRef && classes.has(optClassRef)))) {
810
+ log(isCollection(ty) && !collectionCarries(ty, ctx)
811
+ ? `crossbind: rust bridge: ${cls.name}::${name} skipped (a struct inside '${ty}' must derive Serialize and Deserialize to cross)`
812
+ : `crossbind: rust bridge: ${cls.name}::${name} skipped (unsupported parameter '${p}')`);
563
813
  return;
564
814
  }
565
815
  args.push({ name: m[1], ty });
566
816
  }
567
- let { inner: ret, throws, optional } = analyzeReturn(rawRet ?? '()');
817
+ const returnInfo = analyzeReturn(rawRet ?? '()', ctx);
818
+ let { inner: ret, throws, optional } = returnInfo;
568
819
  if (!known(ret) && isJsonSpelling(ret, ctx)) ret = JSON_TY;
569
820
  ret = normalizeArc(ret.replace(/\s+/g, ''), ctx);
570
821
  const jsTokR = matchJsTok(ret, ctx);
571
822
  if (jsTokR) ret = jsTokR;
572
823
  if (jsTokR && optional) {
573
- log(`crossbind: rust bridge: ${cls.name}::${name} skipped (Option<${ret}> returns are not supported - return JsValue::undefined() instead)`);
824
+ log(`crossbind: rust bridge: ${cls.name}::${name} skipped (Option<${ret}> returns are not supported - return JsValue::null() instead)`);
574
825
  return;
575
826
  }
576
827
 
@@ -583,11 +834,11 @@ function parseFn(cls, sig, ctx, log) {
583
834
  if (name === 'new') {
584
835
  if (arcSelf) { log(`crossbind: rust bridge: ${cls.name}::new skipped (Arc<Self> has no ctor shape - use a named factory like 'create')`); return; }
585
836
  if (optional) { log(`crossbind: rust bridge: ${cls.name}::new skipped (Option<Self> has no ctor shape - use a named factory or Result<Self, E>)`); return; }
586
- if (args.length > 3) { log(`crossbind: rust bridge: ${cls.name}::new skipped (max 3 args)`); return; }
837
+ if (args.length > 6) { log(`crossbind: rust bridge: ${cls.name}::new skipped (max 6 args)`); return; }
587
838
  cls.ctor = { args, throws };
588
839
  } else {
589
840
  if (arcSelf && optional) { log(`crossbind: rust bridge: ${cls.name}::${name} skipped (Option<Arc<Self>> is not supported in this wave)`); return; }
590
- if (args.length > 2) { log(`crossbind: rust bridge: ${cls.name}::${name} skipped (factories take max 2 args)`); return; }
841
+ if (args.length > 6) { log(`crossbind: rust bridge: ${cls.name}::${name} skipped (factories take max 6 args)`); return; }
591
842
  cls.factories.push({ name, args, throws, optional, shared: arcSelf });
592
843
  }
593
844
  return;
@@ -599,11 +850,11 @@ function parseFn(cls, sig, ctx, log) {
599
850
  log(`crossbind: rust bridge: ${cls.name}::${name} skipped (unsupported return '${ret}')`);
600
851
  return;
601
852
  }
602
- if (optional || throws) {
603
- log(`crossbind: rust bridge: ${cls.name}::${name} skipped (Arc returns on methods support neither Option nor Result in this wave)`);
853
+ if (throws) {
854
+ log(`crossbind: rust bridge: ${cls.name}::${name} skipped (a fallible Arc return is not carried yet)`);
604
855
  return;
605
856
  }
606
- if (args.length > 4) { log(`crossbind: rust bridge: ${cls.name}::${name} skipped (max 4 args)`); return; }
857
+ if (args.length > 6) { log(`crossbind: rust bridge: ${cls.name}::${name} skipped (max 6 args)`); return; }
607
858
  cls.methods.push({ name, args, ret: `Arc<${target}>`, byRef: selfKind === '&self', throws: false, optionalRet: false });
608
859
  return;
609
860
  }
@@ -611,37 +862,62 @@ function parseFn(cls, sig, ctx, log) {
611
862
  log(`crossbind: rust bridge: ${cls.name}::${name} skipped (Option<${ret}> return is not representable - inners: i32, f64, bool, String; or an Option<Self> factory)`);
612
863
  return;
613
864
  }
865
+ const selfOwned = ret === 'Self' || ret === cls.name || classes.has(ret);
866
+ const borrowedSelf = ret.replace(/\s+/g, '').match(/^&(?:mut)?(Self|\w+)$/)?.[1];
867
+ const selfBorrowed = borrowedSelf === 'Self' || borrowedSelf === cls.name;
868
+ if (selfOwned || selfBorrowed) {
869
+ cls.methods.push({
870
+ name, args, ret: selfOwned ? (ret === 'Self' ? cls.name : ret) : cls.name,
871
+ byRef: selfKind === '&self', throws, optionalRet: false,
872
+ ownedClass: selfOwned ? (ret === 'Self' ? cls.name : ret) : null,
873
+ borrowedSelf: selfBorrowed,
874
+ });
875
+ return;
876
+ }
614
877
  if (!known(ret) && ret !== JSON_TY && !jsTokR) {
615
878
  log(`crossbind: rust bridge: ${cls.name}::${name} skipped (unsupported return '${ret}')`);
616
879
  return;
617
880
  }
618
- if (args.length > 4) { log(`crossbind: rust bridge: ${cls.name}::${name} skipped (max 4 args)`); return; }
619
- cls.methods.push({ name, args, ret, byRef: selfKind === '&self', throws, optionalRet: optional });
881
+ if (args.length > 6) { log(`crossbind: rust bridge: ${cls.name}::${name} skipped (max 6 args)`); return; }
882
+ cls.methods.push({ name, args, ret, byRef: selfKind === '&self', throws, optionalRet: optional, collect: returnInfo.collect });
620
883
  }
621
884
 
622
885
  function parseFreeFn(freeFns, sig, ctx, log) {
623
886
  const { enums, valueObjects, classes } = ctx;
624
887
  const [, name, rawParams, rawRet] = sig;
625
- const known = (ty) => PRIMITIVES.has(ty)
626
- || enums.some((e) => e.name === ty) || valueObjects.some((v) => v.name === ty);
888
+ // A borrowed string is known in both directions: as a parameter it borrows, as a return it
889
+ // comes back owned.
890
+ const known = (ty) => PRIMITIVES.has(ty) || PARAM_ONLY.has(ty) || typedArrayOf(ty) || (isCollection(ty) && collectionCarries(ty, ctx))
891
+ || enums.some((e) => e.name === ty) || valueObjects.some((v) => v.name === ty)
892
+ || (ctx.newtypes ?? []).some((n) => n.name === ty)
893
+ || (ctx.jsonTypes ?? []).some((j) => j.name === ty)
894
+ || isOptionalRecord(ty, ctx);
627
895
  const isClassRef = (ty) => ty.startsWith('&') && !PARAM_ONLY.has(ty) && classes.has(ty.slice(1));
628
896
 
629
897
  const args = [];
630
- for (const p of rawParams.split(',').map((s) => s.trim()).filter(Boolean)) {
631
- const m = p.match(/^(\w+)\s*:\s*(&\s*(?:str|String)|Option\s*<\s*(?:i32|f64|bool|String)\s*>|serde_json\s*::\s*Value|(?:std\s*::\s*sync\s*::\s*)?Arc\s*<\s*\w+\s*>|embind_rs\s*::\s*Js(?:Value|Function)|&\s*\w+|[\w()]+)$/);
898
+ for (const p of splitTopLevel(rawParams).map((s) => s.trim()).filter(Boolean)) {
899
+ const m = p.match(/^(\w+)\s*:\s*(&\s*(?:str|String)|Option\s*<\s*(?:i32|f64|bool|String|&\s*\w+)\s*>|Vec\s*<[^;]+>|\[[^\]]+;\s*\d+\]|&\s*\[[^\]]+\]|\([^)]*,[^)]*\)|(?:std\s*::\s*collections\s*::\s*)?(?:HashMap|BTreeMap|HashSet|BTreeSet)\s*<[^>]+>|serde_json\s*::\s*Value|(?:std\s*::\s*sync\s*::\s*)?Arc\s*<\s*\w+\s*>|embind_rs\s*::\s*Js(?:Value|Function)|&\s*\w+|[\w()]+)$/);
632
900
  let ty = m?.[2].replace(/\s+/g, '');
901
+ if (ty) ty = normalizeStringSpelling(ty);
633
902
  if (ty && !known(ty) && isJsonSpelling(ty, ctx)) ty = JSON_TY;
634
903
  if (ty) ty = normalizeArc(ty, ctx);
635
904
  const jsTokP = ty ? matchJsTok(ty, ctx) : null;
636
905
  if (jsTokP) ty = jsTokP;
637
906
  const arcParam = ty?.match(ARC_RE)?.[1];
638
- if (!m || !(known(ty) || ty === JSON_TY || jsTokP || (arcParam && classes.has(arcParam)) || PARAM_ONLY.has(ty) || OPTION_PARAM_RE.test(ty) || isClassRef(ty))) {
639
- log(`crossbind: rust bridge: fn ${name} skipped (unsupported parameter '${p}')`);
907
+ const optClassRef = ty?.match(OPTION_CLASS_REF_RE)?.[1];
908
+ const closure = closureShape(p.slice(p.indexOf(':') + 1));
909
+ if (closure) { args.push({ name: `a${args.length}`, ty: `__closure${JSON.stringify(closure)}` }); continue; }
910
+ if (!m || !(known(ty) || ty === JSON_TY || jsTokP || (arcParam && classes.has(arcParam)) || PARAM_ONLY.has(ty) || OPTION_PARAM_RE.test(ty) || isClassRef(ty)
911
+ || (optClassRef && classes.has(optClassRef)))) {
912
+ log(isCollection(ty) && !collectionCarries(ty, ctx)
913
+ ? `crossbind: rust bridge: fn ${name} skipped (a struct inside '${ty}' must derive Serialize and Deserialize to cross)`
914
+ : `crossbind: rust bridge: fn ${name} skipped (unsupported parameter '${p}')`);
640
915
  return;
641
916
  }
642
917
  args.push({ name: m[1], ty });
643
918
  }
644
- let { inner: ret, throws, optional } = analyzeReturn(rawRet ?? '()');
919
+ const returnInfo = analyzeReturn(rawRet ?? '()', ctx);
920
+ let { inner: ret, throws, optional } = returnInfo;
645
921
  if (!known(ret) && isJsonSpelling(ret, ctx)) ret = JSON_TY;
646
922
  ret = normalizeArc(ret.replace(/\s+/g, ''), ctx);
647
923
  const jsTokR = matchJsTok(ret, ctx);
@@ -649,38 +925,137 @@ function parseFreeFn(freeFns, sig, ctx, log) {
649
925
  const retArc = ret.match(ARC_RE)?.[1];
650
926
  if (retArc) {
651
927
  if (!classes.has(retArc)) { log(`crossbind: rust bridge: fn ${name} skipped (unsupported return '${ret}')`); return; }
652
- if (optional || throws) { log(`crossbind: rust bridge: fn ${name} skipped (Arc returns support neither Option nor Result in this wave)`); return; }
928
+ if (throws) { log(`crossbind: rust bridge: fn ${name} skipped (a fallible Arc return is not carried yet)`); return; }
929
+ }
930
+ if (jsTokR && optional) { log(`crossbind: rust bridge: fn ${name} skipped (Option<${ret}> returns are not supported - return JsValue::null() instead)`); return; }
931
+ if (optional && !OPTION_INNERS.has(ret) && !retArc) { log(`crossbind: rust bridge: fn ${name} skipped (Option<${ret}> return is not representable - inners: i32, f64, bool, String, Arc<Class>)`); return; }
932
+ if (isCollection(ret) && !collectionCarries(ret, ctx)) { log(`crossbind: rust bridge: fn ${name} skipped (a struct inside '${ret}' must derive Serialize and Deserialize to cross)`); return; }
933
+ // A fresh instance handed back by value: JS owns it and frees it on delete().
934
+ if (classes.has(ret) && !(ctx.jsonTypes ?? []).some((j) => j.name === ret)) {
935
+ freeFns.push({ name, jsName: camel(name), args, ret, throws, optionalRet: false, ownedClass: ret });
936
+ return;
653
937
  }
654
- if (jsTokR && optional) { log(`crossbind: rust bridge: fn ${name} skipped (Option<${ret}> returns are not supported - return JsValue::undefined() instead)`); return; }
655
- if (optional && !OPTION_INNERS.has(ret)) { log(`crossbind: rust bridge: fn ${name} skipped (Option<${ret}> return is not representable - inners: i32, f64, bool, String)`); return; }
656
938
  if (!known(ret) && ret !== JSON_TY && !retArc && !jsTokR) { log(`crossbind: rust bridge: fn ${name} skipped (unsupported return '${ret}')`); return; }
657
- if (args.length > 4) { log(`crossbind: rust bridge: fn ${name} skipped (max 4 args)`); return; }
658
- freeFns.push({ name, jsName: camel(name), args, ret, throws, optionalRet: optional });
939
+ if (args.length > 6) { log(`crossbind: rust bridge: fn ${name} skipped (max 6 args)`); return; }
940
+ freeFns.push({ name, jsName: camel(name), args, ret, throws, optionalRet: optional, collect: returnInfo.collect });
659
941
  }
660
942
 
661
943
  // ---------------- emitter ----------------
662
944
 
663
945
  const camel = (s) => s.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
664
946
 
665
- function emitBridge(model, { userCrate, vectors, log, prelude = '' }) {
947
+ function emitBridge(model, {
948
+ userCrate, vectors, log, prelude = '', namePrefix = '',
949
+ }) {
666
950
  const U = userCrate;
951
+ // embind's registry is flat, so two crate imports that both export `Version` would collide.
952
+ // A direct `cargo:` import registers its public names under a per-crate prefix; the generated
953
+ // proxy module maps them back to the clean names the import specifier already scopes.
954
+ const pub = (name) => `${namePrefix}${name}`;
667
955
  const isEnum = (ty) => model.enums.some((e) => e.name === ty);
956
+ const newtypeOf = (ty) => (model.newtypes ?? []).find((n) => n.name === ty);
957
+ // A serde record, a data enum, a value object inside an Option: all cross as plain JS values
958
+ // through the JSON wire.
959
+ const jsonRecord = (ty) => {
960
+ const text = String(ty).trim();
961
+ const inner = text.match(/^Option<(.+)>$/)?.[1]?.trim() ?? text;
962
+ return (model.jsonTypes ?? []).some((j) => j.name === inner)
963
+ || (text.startsWith('Option<') && model.valueObjects.some((v) => v.name === inner));
964
+ };
965
+ // A panic inside a binding becomes a JS exception; an Arc wrapper has no sentinel to return
966
+ // afterwards, so those bodies run unguarded (a panic there still aborts).
967
+ const guarded = (retTy, body) => (retTy.endsWith('Shared') ? body : `embind_rs::guard(|| ${body})`);
668
968
  const isVo = (ty) => model.valueObjects.some((v) => v.name === ty);
669
969
  // Bridge-local wire type of a signature token, and the two directions of the shim adaptation.
670
970
  // &str/&String params cross as String (borrowed at the call site); &OtherClass params cross
671
971
  // as the class pointer via a bridge-local Ref wrapper (borrowed unsafely at the call site).
672
- const isRef = (ty) => ty.startsWith('&') && !PARAM_ONLY.has(ty);
972
+ // A slice is a collection, not a borrowed class.
973
+ const isRef = (ty) => ty.startsWith('&') && !PARAM_ONLY.has(ty) && !isCollection(ty) && !typedArrayOf(ty);
673
974
  const arcInner = (ty) => String(ty).match(ARC_RE)?.[1];
674
- const wireTy = (ty) => (PARAM_ONLY.has(ty) ? 'String'
975
+ // An optional Arc rides the nullable shared wrapper; every other optional keeps `Option<T>`.
976
+ // `&[T]` decodes into a Vec and is handed back as a slice; every other collection is owned.
977
+ // `&[T; N]` keeps its fixed length (serde reads a JS array of exactly N items); `&[T]`
978
+ // decodes into a Vec.
979
+ const fixedArrayRef = (ty) => String(ty).trim().match(/^&\s*\[\s*(.+?)\s*;\s*(\d+)\s*\]$/);
980
+ const ownedTy = (ty) => {
981
+ const text = String(ty).trim();
982
+ const fixed = fixedArrayRef(text);
983
+ if (fixed) return qualifyUserTypes(`[${fixed[1]}; ${fixed[2]}]`);
984
+ const slice = text.match(/^&\s*\[(.+)\]$/);
985
+ return qualifyUserTypes(slice ? `Vec<${slice[1]}>` : text);
986
+ };
987
+ // Element and value types declared by the user crate need its path; std and primitive
988
+ // spellings stay as they are.
989
+ const STD_COLLECTIONS = { HashMap: 'std::collections::HashMap', BTreeMap: 'std::collections::BTreeMap', HashSet: 'std::collections::HashSet', BTreeSet: 'std::collections::BTreeSet' };
990
+ const qualifyUserTypes = (text) => String(text).replace(/\b([A-Z]\w*)\b/g, (name) => (
991
+ STD_COLLECTIONS[name] ? STD_COLLECTIONS[name] :
992
+ model.classes.some((c) => c.name === name)
993
+ || model.valueObjects.some((v) => v.name === name)
994
+ || model.enums.some((e) => e.name === name)
995
+ || (model.newtypes ?? []).some((n) => n.name === name)
996
+ || (model.jsonTypes ?? []).some((j) => j.name === name)
997
+ ? `${U}::${name}`
998
+ : name));
999
+ const borrowSuffix = (ty) => (!fixedArrayRef(ty) && /^&\s*\[/.test(String(ty).trim()) ? '.as_slice()' : '');
1000
+ const borrowPrefix = (ty) => (fixedArrayRef(ty) ? '&' : '');
1001
+ // `__closure{...}` carries the shape the parser read off `impl Fn(..)`.
1002
+ const closureOf = (ty) => {
1003
+ const text = String(ty);
1004
+ return text.startsWith('__closure') ? JSON.parse(text.slice('__closure'.length)) : null;
1005
+ };
1006
+ const jsValueOf = (ty, expr) => (ty === 'i32' || ty === 'f64' ? `embind_rs::JsValue::from_f64(${expr} as f64)`
1007
+ : ty === 'bool' ? `embind_rs::JsValue::from_bool(${expr})`
1008
+ : `embind_rs::JsValue::from_str(&${expr})`);
1009
+ const fromJsValue = (ty, expr) => (ty === 'i32' ? `${expr}.as_f64().unwrap_or(0.0) as i32`
1010
+ : ty === 'f64' ? `${expr}.as_f64().unwrap_or(0.0)`
1011
+ : ty === 'bool' ? `${expr}.as_bool().unwrap_or(false)`
1012
+ : ty === 'String' ? `${expr}.as_string().unwrap_or_default()`
1013
+ : '()');
1014
+ const closureExpr = (shape, handle) => {
1015
+ const params = shape.args.map((ty, i) => `p${i}: ${ty}`).join(', ');
1016
+ const call = `${handle}.call${shape.args.length}(${shape.args.map((ty, i) => `&${jsValueOf(ty, `p${i}`)}`).join(', ')})`;
1017
+ const body = shape.ret === '()'
1018
+ ? `match ${call} { Ok(_) => (), Err(e) => embind_rs::raise_err(e) }`
1019
+ : `match ${call} { Ok(v) => ${fromJsValue(shape.ret, 'v')}, Err(e) => embind_rs::raise_err(e) }`;
1020
+ const closure = `move |${params}| -> ${shape.ret} { ${body} }`;
1021
+ return shape.boxed ? `Box::new(${closure})` : closure;
1022
+ };
1023
+ const optionStrParam = (ty) => ty.replace(/\s+/g, '') === 'Option<&str>';
1024
+ // Only a bound class counts: `Option<&str>` is a string, not a class reference.
1025
+ const optionClassRef = (ty) => {
1026
+ const name = String(ty).replace(/\s+/g, '').match(/^Option<&(\w+)>$/)?.[1];
1027
+ return name && model.classes.some((c) => c.name === name) ? name : null;
1028
+ };
1029
+ const optionalWireTy = (ty) => (arcInner(ty) ? `${arcInner(ty)}SharedOpt` : `Option<${ty}>`);
1030
+ const wireTy = (ty) => (typedArrayOf(ty) ? `embind_rs::${typedArrayOf(ty).wrapper}`
1031
+ : closureOf(ty) ? 'embind_rs::JsFunction'
1032
+ : isCollection(ty) || jsonRecord(ty) ? '__CrossbindJson'
1033
+ : optionStrParam(ty) ? 'Option<String>'
1034
+ : optionClassRef(ty) ? `${optionClassRef(ty)}RefOpt`
1035
+ : newtypeOf(ty) ? newtypeOf(ty).inner
1036
+ : PARAM_ONLY.has(ty) ? 'String'
675
1037
  : isRef(ty) ? `${ty.slice(1)}Ref`
676
1038
  : ty === JSON_TY ? '__CrossbindJson'
677
1039
  : JS_TOKS.has(ty) ? `embind_rs::${ty}`
678
1040
  : arcInner(ty) ? `${arcInner(ty)}Shared`
679
1041
  : isEnum(ty) || isVo(ty) ? `${ty}W` : ty);
680
- const unwrap = (ty, expr) => (PARAM_ONLY.has(ty) ? `&${expr}`
1042
+ // A collection parameter is decoded from the JSON value; a shape the Rust type cannot hold
1043
+ // raises instead of silently defaulting.
1044
+ const unwrap = (ty, expr) => (typedArrayOf(ty) ? `${expr}.0${typedArrayOf(ty).slice ? '.as_slice()' : ''}`
1045
+ : closureOf(ty) ? closureExpr(closureOf(ty), expr)
1046
+ : isCollection(ty) || jsonRecord(ty) ? `${borrowPrefix(ty)}__crossbind_from_json::<${ownedTy(ty)}>(${expr}.0)${borrowSuffix(ty)}`
1047
+ // (ownedTy qualifies user type names with the crate path)
1048
+ : optionStrParam(ty) ? `${expr}.as_deref()`
1049
+ : optionClassRef(ty) ? `${expr}.as_option()`
1050
+ : newtypeOf(ty) ? `${U}::${ty}(${expr})`
1051
+ : PARAM_ONLY.has(ty) ? `&${expr}`
681
1052
  : isRef(ty) ? `unsafe { &*${expr}.0 }`
682
1053
  : ty === JSON_TY || arcInner(ty) || isEnum(ty) || isVo(ty) ? `${expr}.0` : expr);
683
- const wrap = (ty, expr) => (ty === JSON_TY ? `__CrossbindJson(${expr})`
1054
+ const wrap = (ty, expr) => (typedArrayOf(ty) ? `embind_rs::${typedArrayOf(ty).wrapper}(${expr})`
1055
+ : isCollection(ty) || jsonRecord(ty) ? `__CrossbindJson(__crossbind_to_json(&(${expr})))`
1056
+ : newtypeOf(ty) ? `(${expr}).0`
1057
+ : PARAM_ONLY.has(ty) ? `String::from(${expr})`
1058
+ : ty === JSON_TY ? `__CrossbindJson(${expr})`
684
1059
  : arcInner(ty) ? `${arcInner(ty)}Shared(${expr})`
685
1060
  : isEnum(ty) || isVo(ty) ? `${ty}W(${expr})` : expr);
686
1061
 
@@ -698,7 +1073,8 @@ function emitBridge(model, { userCrate, vectors, log, prelude = '' }) {
698
1073
  }
699
1074
 
700
1075
  for (const e of model.enums) {
701
- out.push('#[derive(Clone, Copy)]');
1076
+ // No derives on the wrapper: a plain fieldless enum need not be Clone or Copy, and the
1077
+ // wire only ever moves one (`to_wire` takes self, `from_wire` builds a fresh one).
702
1078
  out.push('#[repr(transparent)]');
703
1079
  out.push(`pub struct ${e.name}W(pub ${U}::${e.name});`);
704
1080
  out.push(`impl WireType for ${e.name}W {`);
@@ -736,7 +1112,13 @@ function emitBridge(model, { userCrate, vectors, log, prelude = '' }) {
736
1112
  // `&OtherClass` params ride the class pointer wire through per-class Ref wrappers; collect
737
1113
  // the referenced classes up front so the wrappers exist before the shims that use them.
738
1114
  const classRefs = new Set();
739
- const scanRefs = (args) => args.forEach((p) => { if (isRef(p.ty)) classRefs.add(p.ty.slice(1)); });
1115
+ (model.classes ?? []).forEach((c) => c.methods.forEach((m) => { if (m.ownedClass || m.borrowedSelf) classRefs.add(m.ret); }));
1116
+ (model.freeFns ?? []).forEach((f) => { if (f.ownedClass) classRefs.add(f.ownedClass); });
1117
+ const scanRefs = (args) => args.forEach((p) => {
1118
+ if (isRef(p.ty)) classRefs.add(p.ty.slice(1));
1119
+ const optRef = optionClassRef(p.ty);
1120
+ if (optRef) classRefs.add(optRef);
1121
+ });
740
1122
  for (const cls of model.classes) {
741
1123
  if (cls.ctor) scanRefs(cls.ctor.args);
742
1124
  cls.factories.forEach((f) => scanRefs(f.args));
@@ -755,9 +1137,70 @@ function emitBridge(model, { userCrate, vectors, log, prelude = '' }) {
755
1137
  out.push(` fn to_wire(self) -> *mut c_void { self.0 as *mut c_void }`);
756
1138
  out.push('}');
757
1139
  out.push('');
1140
+ // A fresh instance of the class: the wire is the smart pointer JS owns and frees.
1141
+ out.push('#[repr(transparent)]');
1142
+ out.push(`pub struct ${name}Owned(pub *mut ${U}::${name});`);
1143
+ out.push(`impl WireType for ${name}Owned {`);
1144
+ out.push(' type Wire = *mut c_void;');
1145
+ out.push(" const SIG: char = 'p';");
1146
+ out.push(` fn tid() -> *const c_void { embind_rs::owned_ptr_tid::<${U}::${name}>() }`);
1147
+ out.push(` fn from_wire(w: *mut c_void) -> ${name}Owned { ${name}Owned(w as *mut ${U}::${name}) }`);
1148
+ out.push(' fn to_wire(self) -> *mut c_void { self.0 as *mut c_void }');
1149
+ out.push('}');
1150
+ out.push(`impl embind_rs::ErrSentinel for ${name}Owned { fn err_sentinel() -> Self { ${name}Owned(core::ptr::null_mut()) } }`);
1151
+ out.push('');
1152
+ // The same object handed back for chaining: a pointer, so JS does not own it twice.
1153
+ out.push('#[repr(transparent)]');
1154
+ out.push(`pub struct ${name}RefOut(pub *mut ${U}::${name});`);
1155
+ out.push(`impl WireType for ${name}RefOut {`);
1156
+ out.push(' type Wire = *mut c_void;');
1157
+ out.push(" const SIG: char = 'p';");
1158
+ out.push(` fn tid() -> *const c_void { embind_rs::class_ptr_tid::<${U}::${name}>() }`);
1159
+ out.push(` fn from_wire(w: *mut c_void) -> ${name}RefOut { ${name}RefOut(w as *mut ${U}::${name}) }`);
1160
+ out.push(' fn to_wire(self) -> *mut c_void { self.0 as *mut c_void }');
1161
+ out.push('}');
1162
+ out.push(`impl embind_rs::ErrSentinel for ${name}RefOut { fn err_sentinel() -> Self { ${name}RefOut(core::ptr::null_mut()) } }`);
1163
+ out.push('');
1164
+ // `Option<&Class>` parameter: a null pointer is the None the caller wrote as null.
1165
+ out.push('#[derive(Clone, Copy)]');
1166
+ out.push('#[repr(transparent)]');
1167
+ out.push(`pub struct ${name}RefOpt(pub *mut ${U}::${name});`);
1168
+ out.push(`impl ${name}RefOpt {`);
1169
+ out.push(` pub fn as_option(&self) -> Option<&${U}::${name}> {`);
1170
+ out.push(' if self.0.is_null() { None } else { Some(unsafe { &*self.0 }) }');
1171
+ out.push(' }');
1172
+ out.push('}');
1173
+ out.push(`impl WireType for ${name}RefOpt {`);
1174
+ out.push(' type Wire = *mut c_void;');
1175
+ out.push(" const SIG: char = 'p';");
1176
+ out.push(` fn tid() -> *const c_void { embind_rs::class_ptr_tid::<${U}::${name}>() }`);
1177
+ out.push(` fn from_wire(w: *mut c_void) -> ${name}RefOpt { ${name}RefOpt(w as *mut ${U}::${name}) }`);
1178
+ out.push(` fn to_wire(self) -> *mut c_void { self.0 as *mut c_void }`);
1179
+ out.push('}');
1180
+ out.push('');
758
1181
  }
759
1182
 
760
1183
  if (model.usesJson) {
1184
+ out.push('// A collection crosses as a JS array or object through the same JSON wire: decoding a');
1185
+ out.push('// shape the Rust type cannot hold raises in JS instead of quietly yielding a default.');
1186
+ out.push('fn __crossbind_from_json<T: serde::de::DeserializeOwned>(value: serde_json::Value) -> T {');
1187
+ out.push(' match serde_json::from_value(value) {');
1188
+ out.push(' Ok(parsed) => parsed,');
1189
+ out.push(' Err(e) => {');
1190
+ out.push(' // Raise first: on wasm that throws into JS right here, so a bad argument');
1191
+ out.push(' // costs one error instead of the panic that would poison the instance.');
1192
+ out.push(' let _: () = embind_rs::raise_err(format!("cannot read this value as {}: {e}", std::any::type_name::<T>()));');
1193
+ out.push(' // Native only, where raising parks the error and returns: no value of T');
1194
+ out.push(' // can be fabricated, so unwind into the shim guard instead.');
1195
+ out.push(' panic!("cannot read this value as {}", std::any::type_name::<T>())');
1196
+ out.push(' }');
1197
+ out.push(' }');
1198
+ out.push('}');
1199
+ out.push('');
1200
+ out.push('fn __crossbind_to_json<T: serde::Serialize + ?Sized>(value: &T) -> serde_json::Value {');
1201
+ out.push(' serde_json::to_value(value).unwrap_or(serde_json::Value::Null)');
1202
+ out.push('}');
1203
+ out.push('');
761
1204
  out.push('// serde_json::Value crosses as a deep JSON copy: the adapter converts handle <->');
762
1205
  out.push('// [u32 len][bytes] JSON text through the host JSON codec; serde maps text <-> Value here.');
763
1206
  out.push('#[repr(transparent)]');
@@ -821,44 +1264,84 @@ function emitBridge(model, { userCrate, vectors, log, prelude = '' }) {
821
1264
  out.push(` fn to_wire(self) -> *mut c_void { std::sync::Arc::into_raw(self.0) as *mut c_void }`);
822
1265
  out.push('}');
823
1266
  out.push('');
1267
+ // `Option<Arc<T>>`: a null smart pointer is what embind reads back as JS null, so None
1268
+ // needs no optional type of its own.
1269
+ out.push('#[repr(transparent)]');
1270
+ out.push(`pub struct ${inner}SharedOpt(pub Option<std::sync::Arc<${U}::${inner}>>);`);
1271
+ out.push(`impl WireType for ${inner}SharedOpt {`);
1272
+ out.push(' type Wire = *mut c_void;');
1273
+ out.push(" const SIG: char = 'p';");
1274
+ out.push(` fn tid() -> *const c_void { embind_rs::shared_tid::<${U}::${inner}>() }`);
1275
+ out.push(' fn from_wire(w: *mut c_void) -> Self {');
1276
+ out.push(` if w.is_null() { return ${inner}SharedOpt(None); }`);
1277
+ out.push(' unsafe {');
1278
+ out.push(` std::sync::Arc::increment_strong_count(w as *const ${U}::${inner});`);
1279
+ out.push(` ${inner}SharedOpt(Some(std::sync::Arc::from_raw(w as *const ${U}::${inner})))`);
1280
+ out.push(' }');
1281
+ out.push(' }');
1282
+ out.push(' fn to_wire(self) -> *mut c_void {');
1283
+ out.push(' match self.0 {');
1284
+ out.push(' Some(value) => std::sync::Arc::into_raw(value) as *mut c_void,');
1285
+ out.push(' None => core::ptr::null_mut(),');
1286
+ out.push(' }');
1287
+ out.push(' }');
1288
+ out.push('}');
1289
+ out.push(`impl embind_rs::ErrSentinel for ${inner}SharedOpt { fn err_sentinel() -> Self { ${inner}SharedOpt(None) } }`);
1290
+ out.push('');
824
1291
  }
825
1292
 
826
1293
  const registrations = [];
827
1294
  const usedOptionals = new Set();
828
1295
  const noteOptionArgs = (args) => args.forEach((p) => {
829
1296
  const m = p.ty.match(OPTION_PARAM_RE);
830
- if (m) usedOptionals.add(m[1]);
1297
+ // `Option<&str>` rides the String optional; a class reference needs none (null is None).
1298
+ if (m) usedOptionals.add(m[1] === '&str' ? 'String' : m[1]);
831
1299
  });
1300
+ for (const c of model.consts ?? []) {
1301
+ registrations.push(c.ty === '&str'
1302
+ ? ` embind_rs::constant_str("${pub(c.name)}", ${U}::${c.name});`
1303
+ : ` embind_rs::constant("${pub(c.name)}", ${U}::${c.name});`);
1304
+ }
832
1305
  for (const e of model.enums) {
833
- registrations.push(` enum_::<${e.name}W>("${e.name}")${e.variants.map((v) => `.value("${v.name}", ${v.value})`).join('')};`);
1306
+ registrations.push(` enum_::<${e.name}W>("${pub(e.name)}")${e.variants.map((v) => `.value("${v.name}", ${v.value})`).join('')};`);
834
1307
  }
835
1308
  for (const v of model.valueObjects) {
836
- const fields = v.fields.map((f) => `.field::<${f.type}>("${f.name}", core::mem::offset_of!(${U}::${v.name}, ${f.name}))`).join('');
837
- registrations.push(` value_object_::<${v.name}W>("${v.name}")${fields}.finalize();`);
1309
+ const fields = v.fields.map((f) => {
1310
+ const fieldTy = model.valueObjects.some((o) => o.name === f.type) ? `${f.type}W` : f.type;
1311
+ return `.field::<${fieldTy}>("${f.name}", core::mem::offset_of!(${U}::${v.name}, ${f.name}))`;
1312
+ }).join('');
1313
+ registrations.push(` value_object_::<${v.name}W>("${pub(v.name)}")${fields}.finalize();`);
838
1314
  }
839
1315
  for (const vec of vectors) {
840
1316
  if (!VECTOR_ITEM_TYPES.has(vec.of)) { log(`crossbind: rust bridge: vector of '${vec.of}' skipped (supported: i32, f64, bool)`); continue; }
841
- registrations.push(` register_vector::<${vec.of}>("${vec.name}");`);
1317
+ registrations.push(` register_vector::<${vec.of}>("${pub(vec.name)}");`);
842
1318
  }
843
1319
 
844
1320
  for (const cls of model.classes) {
845
1321
  const C = `${U}::${cls.name}`;
846
1322
  const shim = (fnName) => `__${cls.name.toLowerCase()}_${fnName}`;
847
- const chain = [` class_::<${C}>("${cls.name}")`];
1323
+ const chain = [` class_::<${C}>("${pub(cls.name)}")`];
848
1324
 
849
- if (cls.shared) chain.push(` .smart_ptr_shared("${cls.name}Shared")`);
850
- else if (cls.factories.length) chain.push(` .smart_ptr("${cls.name}Ptr")`);
1325
+ const handsBackSelf = cls.methods.some((m) => m.ownedClass) || (model.freeFns ?? []).some((f) => f.ownedClass === cls.name);
1326
+ if (cls.shared) chain.push(` .smart_ptr_shared("${pub(cls.name)}Shared")`);
1327
+ else if (cls.factories.length || handsBackSelf) chain.push(` .smart_ptr("${pub(cls.name)}Ptr")`);
1328
+ // A struct that derives Default and declares no `new` still constructs from JS: the
1329
+ // derive is the author saying what a default instance is.
1330
+ if (!cls.ctor && !cls.shared && cls.hasDefault) {
1331
+ out.push(`fn ${shim('default')}() -> *mut ${C} { ${guarded('*mut', `Box::into_raw(Box::new(<${C} as Default>::default()))`)} }`);
1332
+ chain.push(` .constructor_ptr0(${shim('default')})`);
1333
+ }
851
1334
  if (cls.ctor) {
852
1335
  const a = cls.ctor.args;
853
1336
  noteOptionArgs(a);
854
1337
  const ps = a.map((p, i) => `a${i}: ${wireTy(p.ty)}`).join(', ');
855
1338
  const call = `${C}::new(${a.map((p, i) => unwrap(p.ty, `a${i}`)).join(', ')})`;
856
1339
  if (cls.ctor.throws) {
857
- out.push(`fn ${shim('new')}(${ps}) -> *mut ${C} { match ${call} { Ok(v) => Box::into_raw(Box::new(v)), Err(e) => embind_rs::raise_err(e.to_string()) } }`);
1340
+ out.push(`fn ${shim('new')}(${ps}) -> *mut ${C} { ${guarded('*mut', `match ${call} { Ok(v) => Box::into_raw(Box::new(v)), Err(e) => embind_rs::raise_err_coded(e.to_string(), { use embind_rs::{CrossbindErrorCode, CrossbindErrorCodeFallback}; (&e).crossbind_code() }) }`)} }`);
858
1341
  chain.push(` .constructor_ptr${a.length}(${shim('new')})`);
859
1342
  } else {
860
- out.push(`fn ${shim('new')}(${ps}) -> ${C} { ${call} }`);
861
- chain.push(` .constructor${a.length}(${shim('new')})`);
1343
+ out.push(`fn ${shim('new')}(${ps}) -> *mut ${C} { ${guarded('*mut', `Box::into_raw(Box::new(${call}))`)} }`);
1344
+ chain.push(` .constructor_ptr${a.length}(${shim('new')})`);
862
1345
  }
863
1346
  }
864
1347
  for (const f of cls.factories) {
@@ -867,7 +1350,7 @@ function emitBridge(model, { userCrate, vectors, log, prelude = '' }) {
867
1350
  const call = `${C}::${f.name}(${f.args.map((p, i) => unwrap(p.ty, `a${i}`)).join(', ')})`;
868
1351
  if (f.shared) {
869
1352
  if (f.throws) {
870
- out.push(`fn ${shim(f.name)}(${ps}) -> *mut ${C} { match ${call} { Ok(v) => std::sync::Arc::into_raw(v) as *mut ${C}, Err(e) => embind_rs::raise_err(e.to_string()) } }`);
1353
+ out.push(`fn ${shim(f.name)}(${ps}) -> *mut ${C} { match ${call} { Ok(v) => std::sync::Arc::into_raw(v) as *mut ${C}, Err(e) => embind_rs::raise_err_coded(e.to_string(), { use embind_rs::{CrossbindErrorCode, CrossbindErrorCodeFallback}; (&e).crossbind_code() }) } }`);
871
1354
  chain.push(` .create_ptr${f.args.length}("${camel(f.name)}", ${shim(f.name)})`);
872
1355
  } else {
873
1356
  out.push(`fn ${shim(f.name)}(${ps}) -> std::sync::Arc<${C}> { ${call} }`);
@@ -876,32 +1359,63 @@ function emitBridge(model, { userCrate, vectors, log, prelude = '' }) {
876
1359
  continue;
877
1360
  }
878
1361
  if (f.throws) {
879
- out.push(`fn ${shim(f.name)}(${ps}) -> *mut ${C} { match ${call} { Ok(v) => Box::into_raw(Box::new(v)), Err(e) => embind_rs::raise_err(e.to_string()) } }`);
1362
+ out.push(`fn ${shim(f.name)}(${ps}) -> *mut ${C} { ${guarded('*mut', `match ${call} { Ok(v) => Box::into_raw(Box::new(v)), Err(e) => embind_rs::raise_err_coded(e.to_string(), { use embind_rs::{CrossbindErrorCode, CrossbindErrorCodeFallback}; (&e).crossbind_code() }) }`)} }`);
880
1363
  chain.push(` .create_ptr${f.args.length}("${camel(f.name)}", ${shim(f.name)})`);
881
1364
  } else if (f.optional) {
882
- out.push(`fn ${shim(f.name)}(${ps}) -> *mut ${C} { match ${call} { Some(v) => Box::into_raw(Box::new(v)), None => core::ptr::null_mut() } }`);
1365
+ out.push(`fn ${shim(f.name)}(${ps}) -> *mut ${C} { ${guarded('*mut', `match ${call} { Some(v) => Box::into_raw(Box::new(v)), None => core::ptr::null_mut() }`)} }`);
883
1366
  chain.push(` .create_ptr${f.args.length}("${camel(f.name)}", ${shim(f.name)})`);
884
1367
  } else {
885
- out.push(`fn ${shim(f.name)}(${ps}) -> ${C} { ${call} }`);
886
- chain.push(` .create${f.args.length}("${camel(f.name)}", ${shim(f.name)})`);
1368
+ out.push(`fn ${shim(f.name)}(${ps}) -> *mut ${C} { ${guarded('*mut', `Box::into_raw(Box::new(${call}))`)} }`);
1369
+ chain.push(` .create_ptr${f.args.length}("${camel(f.name)}", ${shim(f.name)})`);
887
1370
  }
888
1371
  }
1372
+ for (const f of cls.fields ?? []) {
1373
+ out.push(`fn ${shim(`get_${f.name}`)}(t: &mut ${C}) -> ${wireTy(f.type)} { ${wrap(f.type, `t.${f.name}.clone()`)} }`);
1374
+ out.push(`fn ${shim(`set_${f.name}`)}(t: &mut ${C}, v: ${wireTy(f.type)}) { t.${f.name} = ${unwrap(f.type, 'v')}; }`);
1375
+ chain.push(` .property("${camel(f.name)}", ${shim(`get_${f.name}`)}, ${shim(`set_${f.name}`)})`);
1376
+ }
1377
+ // `foo()` next to `set_foo(v)` reads as one JS property, the pair napi-rs spells with
1378
+ // #[napi(getter)] and #[napi(setter)].
1379
+ // A setter returns nothing: `set_point(p) -> f64` is a method that happens to be named
1380
+ // that way, not the write half of a property.
1381
+ const accessorPairs = cls.methods.filter((m) => m.args.length === 0 && m.ret !== '()'
1382
+ && cls.methods.some((other) => other.name === `set_${m.name}` && other.args.length === 1
1383
+ && other.args[0].ty === m.ret && other.ret === '()'));
1384
+ const accessorNames = new Set(accessorPairs.flatMap((m) => [m.name, `set_${m.name}`]));
1385
+ for (const m of accessorPairs) {
1386
+ out.push(`fn ${shim(`prop_get_${m.name}`)}(t: &mut ${C}) -> ${wireTy(m.ret)} { ${wrap(m.ret, `(&*t).${m.name}()`)} }`);
1387
+ out.push(`fn ${shim(`prop_set_${m.name}`)}(t: &mut ${C}, v: ${wireTy(m.ret)}) { t.set_${m.name}(${unwrap(m.ret, 'v')}); }`);
1388
+ chain.push(` .property("${camel(m.name)}", ${shim(`prop_get_${m.name}`)}, ${shim(`prop_set_${m.name}`)})`);
1389
+ }
889
1390
  for (const m of cls.methods) {
1391
+ if (accessorNames.has(m.name)) continue;
890
1392
  noteOptionArgs(m.args);
891
1393
  const params = [`t: &mut ${C}`, ...m.args.map((p, i) => `a${i}: ${wireTy(p.ty)}`)].join(', ');
892
1394
  const callArgs = m.args.map((p, i) => unwrap(p.ty, `a${i}`)).join(', ');
893
1395
  const recv = m.byRef ? '(&*t)' : 't';
894
- const call = `${recv}.${m.name}(${callArgs})`;
1396
+ const rawCall = `${recv}.${m.name}(${callArgs})`;
1397
+ const call = m.collect ? `(${rawCall}).collect::<Vec<_>>()` : rawCall;
1398
+ if (m.ownedClass || m.borrowedSelf) {
1399
+ const wrapper = m.ownedClass ? `${m.ret}Owned` : `${m.ret}RefOut`;
1400
+ const value = m.ownedClass
1401
+ ? `${wrapper}(Box::into_raw(Box::new(${call})))`
1402
+ : `${wrapper}(${call} as *mut ${U}::${m.ret})`;
1403
+ out.push(`fn ${shim(m.name)}(${params}) -> ${wrapper} { ${guarded(wrapper, value)} }`);
1404
+ chain.push(` .function${m.args.length}("${camel(m.name)}", ${shim(m.name)})`);
1405
+ classRefs.add(m.ret);
1406
+ continue;
1407
+ }
1408
+ const optArc = m.optionalRet && arcInner(m.ret);
895
1409
  const body = m.throws
896
- ? `match ${call} { Ok(v) => ${wrap(m.ret, 'v')}, Err(e) => embind_rs::raise_err(e.to_string()) }`
897
- : (m.ret === '()' ? call : wrap(m.ret, call));
898
- const retTy = m.optionalRet ? `Option<${m.ret}>` : (m.ret === '()' ? '()' : wireTy(m.ret));
899
- if (m.optionalRet) usedOptionals.add(m.ret);
900
- out.push(`fn ${shim(m.name)}(${params}) -> ${retTy} { ${body} }`);
1410
+ ? `match ${call} { Ok(v) => ${wrap(m.ret, 'v')}, Err(e) => embind_rs::raise_err_coded(e.to_string(), { use embind_rs::{CrossbindErrorCode, CrossbindErrorCodeFallback}; (&e).crossbind_code() }) }`
1411
+ : (m.ret === '()' ? call : optArc ? `${arcInner(m.ret)}SharedOpt(${call})` : wrap(m.ret, call));
1412
+ const retTy = m.optionalRet ? optionalWireTy(m.ret) : (m.ret === '()' ? '()' : wireTy(m.ret));
1413
+ if (m.optionalRet && !arcInner(m.ret)) usedOptionals.add(m.ret);
1414
+ out.push(`fn ${shim(m.name)}(${params}) -> ${retTy} { ${guarded(retTy, body)} }`);
901
1415
  chain.push(` .function${m.args.length}("${camel(m.name)}", ${shim(m.name)})`);
902
1416
  }
903
1417
  if (cls.hasDisplay) {
904
- out.push(`fn ${shim('display_tostring')}(t: &mut ${C}) -> String { format!("{}", (&*t)) }`);
1418
+ out.push(`fn ${shim('display_tostring')}(t: &mut ${C}) -> String { ${guarded('String', 'format!("{}", (&*t))')} }`);
905
1419
  chain.push(` .function0("toString", ${shim('display_tostring')})`);
906
1420
  }
907
1421
  registrations.push(`${chain.join('\n')};`);
@@ -910,14 +1424,23 @@ function emitBridge(model, { userCrate, vectors, log, prelude = '' }) {
910
1424
  for (const f of model.freeFns ?? []) {
911
1425
  noteOptionArgs(f.args);
912
1426
  const ps = f.args.map((p, i) => `a${i}: ${wireTy(p.ty)}`).join(', ');
913
- const call = `${U}::${f.name}(${f.args.map((p, i) => unwrap(p.ty, `a${i}`)).join(', ')})`;
914
- const retTy = f.optionalRet ? `Option<${f.ret}>` : (f.ret === '()' ? '()' : wireTy(f.ret));
915
- if (f.optionalRet) usedOptionals.add(f.ret);
1427
+ const rawCall = `${U}::${f.name}(${f.args.map((p, i) => unwrap(p.ty, `a${i}`)).join(', ')})`;
1428
+ const call = f.collect ? `(${rawCall}).collect::<Vec<_>>()` : rawCall;
1429
+ const retTy = f.optionalRet ? optionalWireTy(f.ret) : (f.ret === '()' ? '()' : wireTy(f.ret));
1430
+ if (f.optionalRet && !arcInner(f.ret)) usedOptionals.add(f.ret);
1431
+ if (f.ownedClass) {
1432
+ const wrapper = `${f.ownedClass}Owned`;
1433
+ out.push(`fn __free_${f.name}(${ps}) -> ${wrapper} { ${guarded(wrapper, `${wrapper}(Box::into_raw(Box::new(${call})))`)} }`);
1434
+ registrations.push(` embind_rs::fn${f.args.length}("${pub(f.jsName)}", __free_${f.name});`);
1435
+ classRefs.add(f.ownedClass);
1436
+ continue;
1437
+ }
1438
+ const optArc = f.optionalRet && arcInner(f.ret);
916
1439
  const body = f.throws
917
- ? `match ${call} { Ok(v) => ${wrap(f.ret, 'v')}, Err(e) => embind_rs::raise_err(e.to_string()) }`
918
- : (f.ret === '()' ? call : wrap(f.ret, call));
919
- out.push(`fn __free_${f.name}(${ps}) -> ${retTy} { ${body} }`);
920
- registrations.push(` embind_rs::fn${f.args.length}("${f.jsName}", __free_${f.name});`);
1440
+ ? `match ${call} { Ok(v) => ${wrap(f.ret, 'v')}, Err(e) => embind_rs::raise_err_coded(e.to_string(), { use embind_rs::{CrossbindErrorCode, CrossbindErrorCodeFallback}; (&e).crossbind_code() }) }`
1441
+ : (f.ret === '()' ? call : optArc ? `${arcInner(f.ret)}SharedOpt(${call})` : wrap(f.ret, call));
1442
+ out.push(`fn __free_${f.name}(${ps}) -> ${retTy} { ${guarded(retTy, body)} }`);
1443
+ registrations.push(` embind_rs::fn${f.args.length}("${pub(f.jsName)}", __free_${f.name});`);
921
1444
  }
922
1445
 
923
1446
  // Optional inner types register once up front (the adapters dedupe across archives).
@@ -935,9 +1458,30 @@ function emitBridge(model, { userCrate, vectors, log, prelude = '' }) {
935
1458
 
936
1459
  // ---------------- .d.ts emitter ----------------
937
1460
 
1461
+ // A collection's TypeScript shape: an array for the sequence kinds, a Record for the maps, a
1462
+ // tuple for a tuple. Anything the mapping does not recognise stays `unknown`, which is honest.
1463
+ function tsCollection(ty) {
1464
+ const text = String(ty).trim();
1465
+ const map = text.match(/^(?:std::collections::)?(?:HashMap|BTreeMap)<\s*([^,]+),\s*(.+)>$/);
1466
+ if (map) return `Record<${tsInner(map[1])}, ${tsInner(map[2])}>`;
1467
+ const vec = text.match(/^Vec<(.+)>$/) || text.match(/^&?\s*\[(.+);\s*\d+\]$/) || text.match(/^&\s*\[(.+)\]$/);
1468
+ if (vec) return `${tsInner(vec[1])}[]`;
1469
+ const tuple = text.match(/^\((.+)\)$/);
1470
+ if (tuple) return `[${tuple[1].split(',').map((part) => tsInner(part)).join(', ')}]`;
1471
+ return 'unknown';
1472
+ }
1473
+
1474
+ function tsInner(ty) {
1475
+ const text = String(ty).trim();
1476
+ return TS_TYPES[text] ?? (isCollection(text) ? tsCollection(text) : 'unknown');
1477
+ }
1478
+
938
1479
  const TS_TYPES = {
939
1480
  i32: 'number', i64: 'bigint', u64: 'bigint', f64: 'number', bool: 'boolean',
940
1481
  String: 'string', '&str': 'string', '&String': 'string', '()': 'void',
1482
+ i8: 'number', i16: 'number', u8: 'number', u16: 'number', u32: 'number', f32: 'number',
1483
+ // usize/isize carry a pointer-wide range: a number on wasm (32-bit), a BigInt on native.
1484
+ usize: 'number | bigint', isize: 'number | bigint', char: 'string',
941
1485
  };
942
1486
 
943
1487
  // Editor-facing types for the package import (`import { X } from '<pkg>'`): the metro/vite
@@ -953,6 +1497,20 @@ export function emitDts(model, vectors, mode = 'sync') {
953
1497
  if (arc) return arc[1]; // Arc<X> is transparent in JS: the shared instance itself
954
1498
  if (ty === 'JsValue') return 'unknown';
955
1499
  if (ty === 'JsFunction') return '(...args: unknown[]) => unknown';
1500
+ const shape = String(ty).startsWith('__closure') ? JSON.parse(String(ty).slice('__closure'.length)) : null;
1501
+ if (shape) {
1502
+ const args = shape.args.map((a, i) => `a${i}: ${TS_TYPES[a] ?? 'unknown'}`).join(', ');
1503
+ return `(${args}) => ${shape.ret === '()' ? 'void' : TS_TYPES[shape.ret] ?? 'unknown'}`;
1504
+ }
1505
+ const typed = typedArrayOf(ty);
1506
+ if (typed) return typed.wrapper === 'JsBytes' ? 'Uint8Array' : 'Float64Array';
1507
+ if (isCollection(ty)) return tsCollection(ty);
1508
+ const optStr = ty.replace(/\s+/g, '') === 'Option<&str>';
1509
+ if (optStr) return 'string | null | undefined';
1510
+ const optRef = ty.replace(/\s+/g, '').match(/^Option<&(\w+)>$/)?.[1];
1511
+ if (optRef) return `${optRef} | null | undefined`;
1512
+ const nt = (model.newtypes ?? []).find((n) => n.name === ty);
1513
+ if (nt) return TS_TYPES[nt.inner] ?? nt.inner;
956
1514
  if (ty.startsWith('&')) return TS_TYPES[ty] ?? ty.slice(1); // &OtherClass param
957
1515
  return TS_TYPES[ty] ?? ty;
958
1516
  };
@@ -962,26 +1520,31 @@ export function emitDts(model, vectors, mode = 'sync') {
962
1520
  for (const v of model.valueObjects) {
963
1521
  out.push(`export interface ${v.name} { ${v.fields.map((f) => `${f.name}: ${ts(f.type)};`).join(' ')} }`);
964
1522
  }
1523
+ for (const c of model.consts ?? []) {
1524
+ out.push(`export declare const ${c.name}: ${TS_TYPES[c.ty] ?? c.ty};`);
1525
+ }
965
1526
  for (const e of model.enums) {
966
1527
  out.push(`export interface ${e.name} { readonly __crossbindEnum?: '${e.name}'; }`);
967
1528
  out.push(`export declare let ${e.name}: { ${e.variants.map((v) => `readonly ${v.name}: ${e.name};`).join(' ')} };`);
968
1529
  }
969
1530
  for (const cls of model.classes) {
970
1531
  out.push(`export declare class ${cls.name} {`);
1532
+ for (const f of cls.fields ?? []) out.push(` ${camel(f.name)}: ${ts(f.type)};`);
971
1533
  if (cls.ctor) out.push(` constructor(${cls.ctor.args.map((p) => `${p.name}: ${ts(p.ty)}`).join(', ')});`);
1534
+ else if (!cls.shared && cls.hasDefault) out.push(' constructor();');
972
1535
  else out.push(' private constructor();');
973
1536
  for (const f of cls.factories) {
974
1537
  out.push(` static ${camel(f.name)}(${f.args.map((p) => `${p.name}: ${ts(p.ty)}`).join(', ')}): ${wrap(`${cls.name}${f.optional ? ' | null' : ''}`)};`);
975
1538
  }
976
1539
  for (const m of cls.methods) {
977
- out.push(` ${camel(m.name)}(${m.args.map((p) => `${p.name}: ${ts(p.ty)}`).join(', ')}): ${wrap(`${ts(m.ret)}${m.optionalRet ? ' | undefined' : ''}`)};`);
1540
+ out.push(` ${camel(m.name)}(${m.args.map((p) => `${p.name}: ${ts(p.ty)}`).join(', ')}): ${wrap(`${ts(m.ret)}${m.optionalRet ? ' | null' : ''}`)};`);
978
1541
  }
979
1542
  if (cls.hasDisplay) out.push(` toString(): ${wrap('string')};`);
980
1543
  out.push(` delete(): ${wrap('void')};`);
981
1544
  out.push('}');
982
1545
  }
983
1546
  for (const f of model.freeFns ?? []) {
984
- out.push(`export declare function ${f.jsName}(${f.args.map((p) => `${p.name}: ${ts(p.ty)}`).join(', ')}): ${wrap(`${ts(f.ret)}${f.optionalRet ? ' | undefined' : ''}`)};`);
1547
+ out.push(`export declare function ${f.jsName}(${f.args.map((p) => `${p.name}: ${ts(p.ty)}`).join(', ')}): ${wrap(`${ts(f.ret)}${f.optionalRet ? ' | null' : ''}`)};`);
985
1548
  }
986
1549
  for (const vec of vectors) {
987
1550
  if (!VECTOR_ITEM_TYPES.has(vec.of)) continue;