crossbind 2.0.0-beta.60 → 2.0.0-beta.62

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.
Files changed (43) hide show
  1. package/README.md +7 -5
  2. package/package.json +1 -1
  3. package/src/actions/buildDependencies.js +22 -7
  4. package/src/actions/buildJs.js +1 -1
  5. package/src/actions/buildLib.js +2 -0
  6. package/src/actions/buildNode.js +149 -0
  7. package/src/actions/buildWasm.js +43 -66
  8. package/src/actions/createInterface.js +101 -27
  9. package/src/actions/createLib.js +3 -2
  10. package/src/actions/getAllBridges.js +1 -1
  11. package/src/actions/getLinkInputs.js +63 -0
  12. package/src/actions/licenses.js +34 -0
  13. package/src/actions/run.js +97 -2
  14. package/src/assets/cmake/CMakeLists.txt +1 -1
  15. package/src/assets/cmake/dist.cmake +1 -1
  16. package/src/assets/js-runtime/adapters/worker-comlink.js +6 -2
  17. package/src/assets/toolchain-digests.json +26 -12
  18. package/src/bin.js +59 -13
  19. package/src/index.js +3 -1
  20. package/src/integration/getCrossbindScript.js +8 -10
  21. package/src/integration/getDependFilePath.js +9 -12
  22. package/src/state/index.js +16 -13
  23. package/src/state/loadConfig.js +3 -0
  24. package/src/utils/appRustCrates.js +2 -0
  25. package/src/utils/appSources.js +21 -0
  26. package/src/utils/cargoImport.js +44 -0
  27. package/src/utils/cargoTarget.js +2 -0
  28. package/src/utils/cppDts.js +77 -41
  29. package/src/utils/embindArgumentGuards.js +45 -0
  30. package/src/utils/embindRsFingerprint.js +26 -0
  31. package/src/utils/headerImports.js +45 -0
  32. package/src/utils/inputStamp.js +23 -0
  33. package/src/utils/linkLayout.js +8 -1
  34. package/src/utils/pullDockerImage.js +8 -5
  35. package/src/utils/resolveCompanionPackage.js +51 -0
  36. package/src/utils/resolveEmbindNapi.js +22 -0
  37. package/src/utils/resolveEmbindRust.js +17 -52
  38. package/src/utils/runCargo.js +4 -3
  39. package/src/utils/rustBridgeGen.js +895 -250
  40. package/src/utils/swigInterface.js +19 -4
  41. package/src/utils/systemKeys.js +8 -0
  42. package/src/utils/targets.js +56 -0
  43. package/src/utils/cppFieldBindings.js +0 -23
package/src/bin.js CHANGED
@@ -9,12 +9,15 @@ import createBridgeFile from './actions/createInterface.js';
9
9
  import createLib from './actions/createLib.js';
10
10
  import buildWasm from './actions/buildWasm.js';
11
11
  import buildWasiCommand from './actions/buildWasiCommand.js';
12
+ import buildNode, { publishNodeData } from './actions/buildNode.js';
12
13
  import buildExternal from './actions/buildExternal.js';
13
14
  import buildPackageTypes from './actions/buildTypes.js';
14
15
  import buildLib from './actions/buildLib.js';
15
16
  import buildDependencies from './actions/buildDependencies.js';
16
17
  import runCrossbindApp from './actions/run.js';
17
- import { getFilteredBuildTargets } from './actions/target.js';
18
+ import { getBuildTargets, getFilteredBuildTargets } from './actions/target.js';
19
+ import { OPT_IN_PLATFORMS } from './utils/targets.js';
20
+ import resolveEmbindNapiRoot, { resolveEmbindJsiRoot } from './utils/resolveEmbindNapi.js';
18
21
 
19
22
  import writeJson from './utils/writeJson.js';
20
23
  import flattenConfigForTable from './utils/flattenConfigForTable.js';
@@ -77,7 +80,7 @@ program.command('build')
77
80
  targetParams.buildType = ['release'];
78
81
  }
79
82
 
80
- targetParams.platform = targetParams.platform || platforms;
83
+ targetParams.platform = targetParams.platform || platforms.filter((item) => !OPT_IN_PLATFORMS.includes(item));
81
84
  targetParams.arch = targetParams.arch || archs;
82
85
  targetParams.runtime = targetParams.runtime || runtimes;
83
86
  targetParams.buildType = targetParams.buildType || buildTypes;
@@ -162,7 +165,7 @@ program.command('clean-deps')
162
165
  });
163
166
 
164
167
  // A container belongs to one image: the web container carries no NDK, and android runs a forced platform.
165
- const IMAGE_ROLES = ['web', 'android'];
168
+ const IMAGE_ROLES = ['web', 'android', 'linux', 'windows'];
166
169
  const dockerContainerName = (role) => {
167
170
  if (!IMAGE_ROLES.includes(role)) {
168
171
  console.error(`crossbind: unknown image "${role}" - expected one of ${IMAGE_ROLES.join(', ')}.`);
@@ -322,7 +325,8 @@ function run(programName, params) {
322
325
  async function build(targetParams, rebuildOption) {
323
326
  await buildDependencies({ targetParams, rebuildOption });
324
327
  buildLib(targetParams);
325
- createWasmJs(targetParams);
328
+ await createWasmJs(targetParams);
329
+ await createNodeAddons(targetParams);
326
330
  await createWasiCommands(targetParams);
327
331
  buildPackageTypes();
328
332
  }
@@ -348,14 +352,7 @@ async function createWasiCommands(targetParams) {
348
352
  }
349
353
  }
350
354
 
351
- async function createWasmJs(targetParams) {
352
- if (state.config.export.bundle === false) {
353
- return;
354
- }
355
- const targets = getFilteredBuildTargets(targetParams, { platform: 'wasm' });
356
- if (targets.length === 0) {
357
- return;
358
- }
355
+ function createBridges() {
359
356
  let headers = [];
360
357
  state.config.paths.header.forEach((headerPath) => {
361
358
  headers.push(findFiles('**/*.h', { cwd: headerPath }));
@@ -367,12 +364,61 @@ async function createWasmJs(targetParams) {
367
364
  const bridgePath = createBridgeFile(header);
368
365
  bridges.push(bridgePath);
369
366
  });
367
+ return bridges;
368
+ }
370
369
 
370
+ async function createNodeAddons(targetParams) {
371
+ if (state.config.export.bundle === false) {
372
+ return;
373
+ }
374
+ const targets = getBuildTargets(targetParams)
375
+ .filter((target) => target.runtimeEnv === 'node' && target.platform !== 'wasm');
376
+ if (targets.length === 0) {
377
+ return;
378
+ }
379
+ // The bridges compile against the embind runtime, which speaks JSI.
380
+ const headerDirs = [
381
+ `${resolveEmbindJsiRoot()}/cpp/src`,
382
+ `${resolveEmbindNapiRoot()}/third_party/node-api-jsi/jsi`,
383
+ ];
384
+ const opt = {
385
+ buildSource: false,
386
+ nativeGlob: [
387
+ `${state.config.paths.cli}/assets/cpp-runtime/commonBridges.cpp`,
388
+ ...createBridges(),
389
+ ],
390
+ headerDirs,
391
+ inputs: headerDirs.flatMap((dir) => findFiles('**/*.h', { cwd: dir })),
392
+ };
393
+
394
+ for (const target of targets) {
395
+ const distComplete = fs.existsSync(`${state.config.paths.output}/${target.addonName}`)
396
+ && fs.existsSync(`${state.config.paths.output}/${target.jsName}`);
397
+ createLib(target, 'Bridge', opt);
398
+ const built = await buildNode(target, { force: !distComplete });
399
+ publishNodeData(target, { refresh: built });
400
+ if (!built) {
401
+ continue;
402
+ }
403
+ fs.mkdirSync(state.config.paths.output, { recursive: true });
404
+ fs.copyFileSync(`${state.config.paths.build}/${target.addonName}`, `${state.config.paths.output}/${target.addonName}`);
405
+ fs.copyFileSync(`${state.config.paths.build}/${target.jsName}`, `${state.config.paths.output}/${target.jsName}`);
406
+ }
407
+ }
408
+
409
+ async function createWasmJs(targetParams) {
410
+ if (state.config.export.bundle === false) {
411
+ return;
412
+ }
413
+ const targets = getFilteredBuildTargets(targetParams, { platform: 'wasm' });
414
+ if (targets.length === 0) {
415
+ return;
416
+ }
371
417
  const opt = {
372
418
  buildSource: false,
373
419
  nativeGlob: [
374
420
  `${state.config.paths.cli}/assets/cpp-runtime/commonBridges.cpp`,
375
- ...bridges,
421
+ ...createBridges(),
376
422
  ],
377
423
  };
378
424
 
package/src/index.js CHANGED
@@ -15,5 +15,7 @@ export { default as getDependFilePath } from './integration/getDependFilePath.js
15
15
  export { default as buildDependencies, getDependenciesStamp } from './actions/buildDependencies.js';
16
16
  export { default as findFiles } from './utils/findFiles.js';
17
17
  export { getContentHash, getFileHash } from './utils/hash.js';
18
- export { collectInputFiles, computeInputStamp } from './utils/inputStamp.js';
18
+ export {
19
+ collectInputFiles, collectRustBridgeFiles, collectRustSources, computeInputStamp,
20
+ } from './utils/inputStamp.js';
19
21
  export { default as buildAppRustCrates } from './utils/appRustCrates.js';
@@ -4,6 +4,7 @@ import getData from '../actions/getData.js';
4
4
  import loadJson from '../utils/loadJson.js';
5
5
  import state from '../state/index.js';
6
6
  import { parseSurface, createRustBridgeCrate, createCrateImportBridge } from '../utils/rustBridgeGen.js';
7
+ import { parseCargoMarkerName } from '../utils/cargoImport.js';
7
8
 
8
9
  export default function getCrossbindScript(target, bridgePath) {
9
10
  if (!bridgePath) {
@@ -25,25 +26,21 @@ export function getRustJsScript(target, rsFile) {
25
26
 
26
27
  // Marker under <cache>/rust-crates/: a direct crate import - the model comes from the
27
28
  // upstream crate's own source, and the bridge is synthesized against the cargo dependency.
28
- const crateName = path.basename(rsReal, '.rs');
29
+ const { crateName, modulePath } = parseCargoMarkerName(path.basename(rsReal, '.rs'));
29
30
  const cargoDeps = state.config.cargoDependencies ?? {};
30
31
  if (Object.hasOwn(cargoDeps, crateName)
31
32
  && path.dirname(rsReal) === fs.realpathSync(path.join(state.config.paths.cache, 'rust-crates'))) {
32
- const { model, namePrefix } = createCrateImportBridge({
33
+ const { exports } = createCrateImportBridge({
33
34
  crateName,
35
+ modulePath,
34
36
  spec: cargoDeps[crateName],
35
37
  cacheDir: state.config.paths.cache,
36
38
  dtsMode: state.config.dts,
37
39
  log: () => {},
38
40
  });
39
- // A crate import registers its public names prefixed (two crates may export the same
40
- // name), so the proxy exports the clean name and reads the prefixed one off the module.
41
- return buildScript(target, [
42
- ...model.classes.map((c) => c.name),
43
- ...model.enums.map((e) => e.name),
44
- ...model.freeFns.map((f) => f.jsName),
45
- ...(model.consts ?? []).map((c) => c.name),
46
- ].map((name) => ({ local: name, wire: `${namePrefix}${name}` })));
41
+ // A crate registers its public names under per-crate names (two crates may export the
42
+ // same name), so the proxy exports the clean name and reads the registered one off the module.
43
+ return buildScript(target, exports);
47
44
  }
48
45
 
49
46
  const pkg = state.config.allDependencies.find((d) => d.export?.type === 'cargo'
@@ -65,6 +62,7 @@ export function getRustJsScript(target, rsFile) {
65
62
  }
66
63
  const symbols = [
67
64
  ...model.classes.map((c) => c.name),
65
+ ...(model.streams ?? []).map((s) => s.name),
68
66
  ...model.enums.map((e) => e.name),
69
67
  ...(model.freeFns ?? []).map((f) => f.jsName),
70
68
  // `pub const`/`pub static` register as module constants, so the proxy exports them too.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import state from '../state/index.js';
4
+ import { parseCargoImport, writeCargoMarker } from '../utils/cargoImport.js';
4
5
 
5
6
  const LAYOUT = { header: 'include', module: 'swig', source: '' };
6
7
 
@@ -76,21 +77,17 @@ function getFolderCandidates(pkg, alias) {
76
77
  }
77
78
 
78
79
  export default function getDependFilePath(source, target) {
79
- // `cargo:<crate>` imports (the node:/npm: convention for a non-npm store) resolve to a
80
- // generated marker .rs; the transformer turns it into the crate-import bridge (no surface
81
- // file, no package). The scheme is unambiguous, so npm names never collide and an
82
- // undeclared crate is a hard, actionable error instead of a silent npm fallthrough.
80
+ // `cargo:<crate>[/<module>...]` imports (the node:/npm: convention for a non-npm store)
81
+ // resolve to a generated marker .rs; the transformer turns it into the crate-import bridge
82
+ // (no surface file, no package). The scheme is unambiguous, so npm names never collide and
83
+ // an undeclared crate is a hard, actionable error instead of a silent npm fallthrough.
83
84
  if (source.startsWith('cargo:')) {
84
- const crateName = source.slice('cargo:'.length);
85
+ const cargoImport = parseCargoImport(source);
85
86
  const cargoDeps = state.config.cargoDependencies ?? {};
86
- if (!Object.hasOwn(cargoDeps, crateName)) {
87
- throw new Error(`crossbind: '${source}' is not declared - add '${crateName}' to cargoDependencies in crossbind.config.`);
88
- }
89
- const marker = path.join(state.config.paths.cache, 'rust-crates', `${crateName}.rs`);
90
- if (!fs.existsSync(marker)) {
91
- fs.mkdirSync(path.dirname(marker), { recursive: true });
92
- fs.writeFileSync(marker, `// crossbind cargo crate import marker: ${crateName}\n`);
87
+ if (!Object.hasOwn(cargoDeps, cargoImport.crateName)) {
88
+ throw new Error(`crossbind: '${source}' is not declared - add '${cargoImport.crateName}' to cargoDependencies in crossbind.config.`);
93
89
  }
90
+ const marker = writeCargoMarker(state.config.paths.cache, cargoImport);
94
91
  existsCache.set(marker, true);
95
92
  return marker;
96
93
  }
@@ -1,8 +1,7 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
1
  import loadJson from '../utils/loadJson.js';
4
2
  import writeJson from '../utils/writeJson.js';
5
- import { TARGETS, targetPathOf } from '../utils/targets.js';
3
+ import { TARGETS, targetPathOf, nodeAddonNamesOf } from '../utils/targets.js';
4
+ import { findCargoModuleImportsIn, writeCargoMarker } from '../utils/cargoImport.js';
6
5
  import loadConfig from './loadConfig.js';
7
6
 
8
7
  const cacheDir = `${process.cwd()}/.crossbind`;
@@ -34,6 +33,8 @@ async function initProcessState() {
34
33
  DOCKER_REGISTRY_MIRROR: 'CROSSBIND_REGISTRY_MIRROR',
35
34
  DOCKER_IMAGE_WEB: 'CROSSBIND_IMAGE_WEB',
36
35
  DOCKER_IMAGE_ANDROID: 'CROSSBIND_IMAGE_ANDROID',
36
+ DOCKER_IMAGE_LINUX: 'CROSSBIND_IMAGE_LINUX',
37
+ DOCKER_IMAGE_WINDOWS: 'CROSSBIND_IMAGE_WINDOWS',
37
38
  }).forEach(([key, envKey]) => {
38
39
  if (state.config.system[key] && !process.env[envKey]) {
39
40
  process.env[envKey] = state.config.system[key];
@@ -54,21 +55,23 @@ async function initProcessState() {
54
55
  // A wasi build is a single command module - no JS glue, no preload.
55
56
  target.wasmName = `${state.config.general.name}-${target.path}.wasm`;
56
57
  }
58
+ if (target.runtimeEnv === 'node' && target.platform !== 'wasm') {
59
+ Object.assign(target, nodeAddonNamesOf(target, state.config.general.name));
60
+ }
57
61
  });
58
62
 
59
63
  setAllDependecyPaths();
60
64
 
61
- // Bare cargo-crate import markers must exist BEFORE any bundler starts: metro resolves
62
- // against its startup file map, so a marker first created mid-resolution is invisible
63
- // to that very build.
65
+ // Cargo-crate import markers must exist BEFORE any bundler starts: metro resolves against
66
+ // its startup file map, so a marker first created mid-resolution is invisible to that very
67
+ // build. Bare crates come from the config, module imports from the app's own sources.
64
68
  const cargoDeps = state.config.cargoDependencies ?? {};
65
- for (const name of Object.keys(cargoDeps)) {
66
- const marker = `${state.config.paths.cache}/rust-crates/${name}.rs`;
67
- if (!fs.existsSync(marker)) {
68
- fs.mkdirSync(path.dirname(marker), { recursive: true });
69
- fs.writeFileSync(marker, `// crossbind cargo crate import marker: ${name}\n`);
70
- }
71
- }
69
+ const crateNames = Object.keys(cargoDeps);
70
+ const moduleImports = crateNames.length && state.config.paths.project
71
+ ? findCargoModuleImportsIn(state.config.paths.project).filter((i) => Object.hasOwn(cargoDeps, i.crateName))
72
+ : [];
73
+ [...crateNames.map((crateName) => ({ crateName, modulePath: [] })), ...moduleImports]
74
+ .forEach((cargoImport) => writeCargoMarker(state.config.paths.cache, cargoImport));
72
75
 
73
76
  if (state.config.build?.setState) {
74
77
  state.config.build.setState(state);
@@ -126,6 +126,9 @@ export function getFilledConfig(config, options = { isDepend: false }) {
126
126
  if (typeof newConfig.types !== 'boolean') {
127
127
  throw new Error(`crossbind: "types" must be true or false (got '${newConfig.types}').`);
128
128
  }
129
+ if (newConfig.targetSpecs.some((t) => Array.isArray(t?.specs?.cmake))) {
130
+ throw new Error('crossbind: "targetSpecs[].specs.cmake" must be an object such as { compileOptions: [\'-O3\'] } (got an array). CMake configure flags (-D...) belong in a recipe\'s getBuildParams.');
131
+ }
129
132
 
130
133
  if (newConfig.paths.config && !newConfig.paths.project) {
131
134
  newConfig.paths.project = getParentPath(newConfig.paths.config);
@@ -36,6 +36,8 @@ export default function buildAppRustCrates(target, cacheDir, cargoDependencies =
36
36
  // perfectly live bridge.
37
37
  stale = !fs.existsSync(path.resolve(path.dirname(libRs), pathMod[1]));
38
38
  } else if (cargoDependencies && name.startsWith('crate_')) {
39
+ // One bridge per crate; a `crate_<crate>__<module>` left by an older build would
40
+ // register the crate's types a second time.
39
41
  stale = !Object.keys(cargoDependencies)
40
42
  .some((c) => `crate_${c.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase()}` === name);
41
43
  }
@@ -0,0 +1,21 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte']);
5
+ // Native projects and build outputs: large, and never the app's JavaScript.
6
+ const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build', 'ios', 'android', 'Pods', 'target']);
7
+
8
+ // The app's own JavaScript and TypeScript sources, whose import statements say what the app uses.
9
+ export default function appSourceFiles(projectDir) {
10
+ const files = [];
11
+ const walk = (dir) => {
12
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
13
+ if (entry.name.startsWith('.') || SKIPPED_DIRECTORIES.has(entry.name)) continue;
14
+ const full = path.join(dir, entry.name);
15
+ if (entry.isDirectory()) walk(full);
16
+ else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) files.push(full);
17
+ }
18
+ };
19
+ walk(projectDir);
20
+ return files;
21
+ }
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import appSourceFiles from './appSources.js';
4
+
5
+ const PREFIX = 'cargo:';
6
+ const RUST_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
7
+ const MODULE_IMPORT = /['"`]cargo:([A-Za-z0-9_-]+)((?:\/[A-Za-z_][A-Za-z0-9_]*)+)['"`]/g;
8
+
9
+ export function parseCargoImport(source) {
10
+ const [crateName, ...modulePath] = source.slice(PREFIX.length).split('/');
11
+ const bad = modulePath.find((segment) => !RUST_IDENT.test(segment));
12
+ if (bad !== undefined) {
13
+ throw new Error(`crossbind: '${source}' - '${bad}' is not a Rust module name; write the path as cargo:<crate>/<module>/<submodule>.`);
14
+ }
15
+ return { crateName, modulePath };
16
+ }
17
+
18
+ // The marker's file name carries the module path after dots, which crate and module names cannot contain.
19
+ export function cargoMarkerName({ crateName, modulePath }) {
20
+ return [crateName, ...modulePath].join('.');
21
+ }
22
+
23
+ export function parseCargoMarkerName(name) {
24
+ const [crateName, ...modulePath] = name.split('.');
25
+ return { crateName, modulePath };
26
+ }
27
+
28
+ export function writeCargoMarker(cacheDir, cargoImport) {
29
+ const marker = path.join(cacheDir, 'rust-crates', `${cargoMarkerName(cargoImport)}.rs`);
30
+ if (!fs.existsSync(marker)) {
31
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
32
+ fs.writeFileSync(marker, `// crossbind cargo crate import marker: ${[cargoImport.crateName, ...cargoImport.modulePath].join('/')}\n`);
33
+ }
34
+ return marker;
35
+ }
36
+
37
+ // A specifier built at run time is not found.
38
+ export function findCargoModuleImports(text) {
39
+ return [...text.matchAll(MODULE_IMPORT)].map((m) => ({ crateName: m[1], modulePath: m[2].slice(1).split('/') }));
40
+ }
41
+
42
+ export function findCargoModuleImportsIn(projectDir) {
43
+ return appSourceFiles(projectDir).flatMap((file) => findCargoModuleImports(fs.readFileSync(file, 'utf8')));
44
+ }
@@ -9,6 +9,8 @@ export function cargoTripleFor(target) {
9
9
  return target.arch === 'iphonesimulator' ? 'aarch64-apple-ios-sim' : 'aarch64-apple-ios';
10
10
  case 'android':
11
11
  return target.arch === 'x86_64' ? 'x86_64-linux-android' : 'aarch64-linux-android';
12
+ case 'darwin':
13
+ return target.arch === 'x64' ? 'x86_64-apple-darwin' : 'aarch64-apple-darwin';
12
14
  default:
13
15
  return null; // wasi (no rust wasip3 target yet) and anything else: unsupported
14
16
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { stripComments } from './swigInterface.js';
3
4
  import writeIfChanged from './writeIfChanged.js';
4
5
 
5
6
  // Best-effort .d.ts for `.h` imports, the C++ analog of the Rust emitDts: parse the
@@ -41,16 +42,7 @@ function tsType(raw, classNames, { isReturn = false } = {}) {
41
42
  function parseArgs(rawArgs, classNames) {
42
43
  const trimmed = rawArgs.trim();
43
44
  if (trimmed === '' || trimmed === 'void') return [];
44
- const parts = [];
45
- let depth = 0;
46
- let current = '';
47
- for (const ch of trimmed) {
48
- if (ch === '<' || ch === '(') depth += 1;
49
- if (ch === '>' || ch === ')') depth -= 1;
50
- if (ch === ',' && depth === 0) { parts.push(current); current = ''; } else current += ch;
51
- }
52
- parts.push(current);
53
- return parts.map((part, i) => {
45
+ return splitTopLevel(trimmed).map((part, i) => {
54
46
  const noDefault = part.split('=')[0].trim();
55
47
  // Declaration-style unnamed parameter: the whole token is a type.
56
48
  const unnamed = tsType(noDefault, classNames);
@@ -81,36 +73,86 @@ function bodyStatements(body) {
81
73
  return statements.map((s) => s.trim()).filter(Boolean);
82
74
  }
83
75
 
76
+ // Splits at commas outside <>, (), [] and {}.
77
+ function splitTopLevel(text) {
78
+ const parts = [];
79
+ let depth = 0;
80
+ let current = '';
81
+ for (const ch of text) {
82
+ if ('<([{'.includes(ch)) depth += 1;
83
+ if ('>)]}'.includes(ch)) depth -= 1;
84
+ if (ch === ',' && depth === 0) { parts.push(current); current = ''; } else current += ch;
85
+ }
86
+ return [...parts, current];
87
+ }
88
+
89
+ // Value-semantics fields only: these are the types embind's .property can expose
90
+ // without ownership questions (and the worker clone can carry).
91
+ const FIELD_TYPES = new Set(['number', 'boolean', 'string']);
92
+
93
+ // `int x, *p, y = 2;` declares the int fields x and y and the pointer p. The types cover the value fields: pointers,
94
+ // arrays, bit-fields and references are left out.
95
+ const FIRST_DECLARATOR = /^([\s\S]*?)([*&\s]*)\b([A-Za-z_]\w*)\s*((?:\[[^\]]*\])*|:\s*\w+)$/;
96
+ const NEXT_DECLARATOR = /^([*&\s]*)([A-Za-z_]\w*)\s*((?:\[[^\]]*\])*|:\s*\w+)$/;
97
+
98
+ function valueField(typeText, classNames) {
99
+ const type = tsType(typeText.replace(/\b(?:const|volatile)\b/g, '').replace(/\s+/g, ' ').trim(), classNames);
100
+ return FIELD_TYPES.has(type) ? { type } : null;
101
+ }
102
+
103
+ function declaredFields(statement, classNames) {
104
+ const [first, ...rest] = splitTopLevel(statement).map((part) => part.split('=')[0].trim());
105
+ const head = first.match(FIRST_DECLARATOR);
106
+ if (!head || !head[1].trim()) return [];
107
+ const value = valueField(head[1], classNames);
108
+ const declarators = [head.slice(2), ...rest.map((part) => part.match(NEXT_DECLARATOR)?.slice(1) ?? null)];
109
+ return declarators.flatMap((declarator) => {
110
+ if (!value || !declarator || declarator[2] || /[*&]/.test(declarator[0])) return [];
111
+ return [{ name: declarator[1], ...value }];
112
+ });
113
+ }
114
+
115
+ // `struct Name {`, `class Name : Base {` and `typedef struct [Tag] { ... } Alias, *Pointer;`, unions too.
116
+ // A class takes its tag, or its first typedef name when it has none, and keeps its other typedef names:
117
+ // SWIG registers a typedef'd struct under the typedef name.
118
+ const CLASS_HEAD = /\b(typedef\s+)?(class|struct|union)\b\s*([A-Za-z_]\w*)?\s*(?::[^{;]*)?\{/g;
119
+
120
+ function closingBrace(text, from) {
121
+ let depth = 1;
122
+ let i = from;
123
+ while (i < text.length && depth > 0) {
124
+ if (text[i] === '{') depth += 1;
125
+ if (text[i] === '}') depth -= 1;
126
+ i += 1;
127
+ }
128
+ return i;
129
+ }
130
+
131
+ function typedefNamesAfter(text, from) {
132
+ const tail = text.slice(from).match(/^([^;{}]*);/);
133
+ return tail ? tail[1].split(',').map((part) => part.trim()).filter((part) => /^[A-Za-z_]\w*$/.test(part)) : [];
134
+ }
135
+
84
136
  export function parseCppSurface(source, log = console.log) {
85
- const clean = source
86
- .replace(/\/\*[\s\S]*?\*\//g, ' ')
87
- .replace(/\/\/[^\n]*/g, ' ')
88
- .replace(/^[ \t]*#[^\n]*$/gm, ' ');
137
+ const clean = stripComments(source).replace(/^[ \t]*#[^\n]*$/gm, ' ');
89
138
 
90
139
  const classes = [];
91
140
  const classNames = new Set();
92
- const classRe = /\b(class|struct)\s+([A-Za-z_]\w*)\s*(?::[^{]*)?\{/g;
93
141
  const found = [];
94
- let m = classRe.exec(clean);
95
- while (m) {
96
- let depth = 1;
97
- let i = classRe.lastIndex;
98
- while (i < clean.length && depth > 0) {
99
- if (clean[i] === '{') depth += 1;
100
- if (clean[i] === '}') depth -= 1;
101
- i += 1;
102
- }
103
- found.push({ kind: m[1], name: m[2], body: clean.slice(classRe.lastIndex, i - 1) });
104
- classNames.add(m[2]);
105
- m = classRe.exec(clean);
142
+ for (const m of clean.matchAll(CLASS_HEAD)) {
143
+ const [head, typedefKeyword, kind, tag] = m;
144
+ const bodyStart = m.index + head.length;
145
+ const end = closingBrace(clean, bodyStart);
146
+ const names = typedefKeyword ? typedefNamesAfter(clean, end) : [];
147
+ const name = tag ?? names[0];
148
+ if (!name) continue;
149
+ found.push({ kind, name, aliases: names.filter((alias) => alias !== name), body: clean.slice(bodyStart, end - 1) });
150
+ classNames.add(name);
151
+ names.forEach((alias) => classNames.add(alias));
106
152
  }
107
153
 
108
- // Value-semantics fields only: these are the types embind's .property can expose
109
- // without ownership questions (and the worker clone can carry).
110
- const FIELD_TYPES = new Set(['number', 'boolean', 'string']);
111
-
112
154
  for (const cls of found) {
113
- let access = cls.kind === 'struct' ? 'public' : 'private';
155
+ let access = cls.kind === 'class' ? 'private' : 'public';
114
156
  let ctor = null;
115
157
  const methods = [];
116
158
  const fields = [];
@@ -122,14 +164,8 @@ export function parseCppSurface(source, log = console.log) {
122
164
 
123
165
  const sig = statement.match(/^(static\s+)?(?:explicit\s+)?([\w:<>,\s*&]*?)\s*\b([A-Za-z_]\w*)\s*\(([\s\S]*)\)$/);
124
166
  if (!sig) {
125
- const decl = statement.split('=')[0].trim();
126
- const field = !statement.includes('(') && !/^(static|using|typedef|friend|enum)\b/.test(decl)
127
- ? decl.match(/^(.*?)\b([A-Za-z_]\w*)$/s)
128
- : null;
129
- if (field) {
130
- const type = tsType(field[1], classNames);
131
- // A reference member has no address a pointer to member could name, and a pointer member is memory.
132
- if (type !== null && FIELD_TYPES.has(type) && !/[&*]/.test(field[1])) fields.push({ name: field[2], type });
167
+ if (!statement.includes('(') && !/^(static|using|typedef|friend|enum)\b/.test(statement)) {
168
+ fields.push(...declaredFields(statement, classNames));
133
169
  }
134
170
  continue;
135
171
  }
@@ -142,7 +178,7 @@ export function parseCppSurface(source, log = console.log) {
142
178
  if (ret === null) { log(`crossbind: dts: skipped ${cls.name}::${name} (unsupported return type '${retRaw.trim()}')`); continue; }
143
179
  methods.push({ name, isStatic: Boolean(staticKw), args, ret });
144
180
  }
145
- classes.push({ name: cls.name, ctor, methods, fields });
181
+ classes.push({ name: cls.name, aliases: cls.aliases, ctor, methods, fields });
146
182
  }
147
183
  return { classes };
148
184
  }
@@ -0,0 +1,45 @@
1
+ // A release build of embind passes whatever JavaScript hands an integer or enum parameter straight to
2
+ // the wire, where anything that is not a number becomes 0: an enum member given to an `int` parameter,
3
+ // a Number given to an enum parameter, a one-letter string given to a `char`. The call then runs with 0
4
+ // and reports no error. These rewrites convert what has one meaning and throw a TypeError for the rest,
5
+ // as embind's assertion builds do.
6
+
7
+ const GUARDS = [
8
+ {
9
+ registration: '_embind_register_integer',
10
+ from: 'toWireType:(destructors,value)=>value,readValueFromPointer:integerReadValueFromPointer(',
11
+ to: 'toWireType:(destructors,value)=>{'
12
+ + 'if(typeof value=="number"||typeof value=="boolean"||typeof value=="bigint")return value;'
13
+ + 'if(typeof value=="string"&&size==1&&value.length==1)return value.charCodeAt(0);'
14
+ + 'if(value!==null&&typeof value=="object"&&typeof value.value=="number")return value.value;'
15
+ + 'throw new TypeError(name+" takes a number, got "+(typeof value=="string"?JSON.stringify(value):value===null?"null":typeof value))'
16
+ + '},readValueFromPointer:integerReadValueFromPointer(',
17
+ },
18
+ {
19
+ registration: '_embind_register_enum',
20
+ from: 'toWireType:(destructors,c)=>c.value,',
21
+ to: 'toWireType:(destructors,c)=>{'
22
+ + 'if(typeof c=="number")return c;'
23
+ + 'if(c!==null&&typeof c=="object"&&typeof c.value=="number")return c.value;'
24
+ + 'throw new TypeError(name+" takes a member of the enum or its number, got "+(typeof c=="string"?JSON.stringify(c):c===null?"null":typeof c))'
25
+ + '},',
26
+ },
27
+ ];
28
+
29
+ // The release glue spells the bigint conversion `typeof value=="number"`, the debug glue `typeof value == 'number'`.
30
+ const BIGINT_FROM_NUMBER = /if\s*\(\s*typeof value\s*==\s*["']number["']\s*\)\s*\{\s*value\s*=\s*BigInt\(value\)\s*;?\s*\}/g;
31
+ const SAFE_BIGINT_FROM_NUMBER = 'if(typeof value=="number"){if(!Number.isSafeInteger(value))throw new TypeError("a 64-bit integer parameter takes a BigInt or a safe integer Number, got "+value);value=BigInt(value)}';
32
+
33
+ // Returns the rewritten glue and whether a bigint registration kept a conversion this rewrite does not recognise.
34
+ export function guardBigIntArguments(glue) {
35
+ const text = glue.replace(BIGINT_FROM_NUMBER, SAFE_BIGINT_FROM_NUMBER);
36
+ return { text, missed: text.includes('_embind_register_bigint') && !text.includes('Number.isSafeInteger(value)') };
37
+ }
38
+
39
+ // Returns the rewritten glue and the registrations whose code no longer looks the way these rewrites expect.
40
+ export function guardEmbindArguments(glue) {
41
+ const missed = GUARDS.filter(({ registration, from, to }) => glue.includes(`${registration}=`) && !glue.includes(from) && !glue.includes(to))
42
+ .map(({ registration }) => registration);
43
+ const text = GUARDS.reduce((current, { from, to }) => current.split(from).join(to), glue);
44
+ return { text, missed };
45
+ }
@@ -0,0 +1,26 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getContentHash, getFileHash } from './hash.js';
4
+ import { collectRustSources } from './inputStamp.js';
5
+ import resolveEmbindRustRoot from './resolveEmbindRust.js';
6
+
7
+ // Every cargo staticlib bundles the embind-rs it was built from. An archive from another one links stale glue, and next
8
+ // to an archive built after it every embind_rs symbol is defined twice, so a prebuilt records the one it came from.
9
+ export const EMBIND_RS_FINGERPRINT_FILE = 'crossbind-embind-rs.fingerprint';
10
+
11
+ // Paths count relative to the crate, so an archive built from the same embind-rs elsewhere matches.
12
+ export function getEmbindRsFingerprint() {
13
+ const crate = `${resolveEmbindRustRoot()}/crate`;
14
+ const files = [...collectRustSources([crate]), `${crate}/Cargo.toml`];
15
+ return getContentHash(files.map((file) => `${path.relative(crate, file)}:${getFileHash(file)}`).sort().join('\n'));
16
+ }
17
+
18
+ export function isEmbindRsFingerprintStale(prebuiltDir, fingerprint) {
19
+ const file = `${prebuiltDir}/${EMBIND_RS_FINGERPRINT_FILE}`;
20
+ return !fs.existsSync(file) || fs.readFileSync(file, { encoding: 'utf8' }) !== fingerprint;
21
+ }
22
+
23
+ export function writeEmbindRsFingerprint(prebuiltDir, fingerprint) {
24
+ fs.mkdirSync(prebuiltDir, { recursive: true });
25
+ fs.writeFileSync(`${prebuiltDir}/${EMBIND_RS_FINGERPRINT_FILE}`, fingerprint);
26
+ }