vimp-engine 0.22.1 → 0.24.0

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 (69) hide show
  1. package/bin/vimp-surface.js +88 -0
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/lib/formBuilder.js +38 -27
  5. package/src/client/lib/pickActiveGame.js +59 -0
  6. package/src/client/lib/socketDispatch.js +34 -0
  7. package/src/client/main.js +61 -14
  8. package/src/config/abiOps.js +37 -0
  9. package/src/config/clientServices.js +27 -0
  10. package/src/config/opcodes.js +11 -2
  11. package/src/config/wsports.js +9 -0
  12. package/src/devtools/contract/loadContext.js +25 -2
  13. package/src/devtools/contract/rules/b10-respawns.js +5 -3
  14. package/src/devtools/contract/rules/b2-engine-api.js +81 -10
  15. package/src/devtools/contract/rules/b3-game-config-shape.js +46 -5
  16. package/src/devtools/contract/rules/b4-teams.js +6 -3
  17. package/src/devtools/contract/rules/b5-room-form.js +40 -8
  18. package/src/devtools/contract/rules/c10-auth-schema.js +34 -1
  19. package/src/devtools/contract/rules/c4-component-dependencies.js +10 -15
  20. package/src/devtools/surface/abiParse.js +175 -0
  21. package/src/devtools/surface/collect.js +413 -0
  22. package/src/host/GameCoreAdapter.js +49 -0
  23. package/src/lib/applyRoomOverrides.js +16 -2
  24. package/src/lib/capabilities.js +32 -0
  25. package/src/lib/coreAbi.js +116 -0
  26. package/src/lib/coreConfig.js +3 -3
  27. package/src/lib/createHostRuntime.js +5 -3
  28. package/src/lib/formControls.js +89 -0
  29. package/src/lib/formUnit.js +25 -0
  30. package/src/lib/gameConfigView.js +214 -0
  31. package/src/lib/gamePlugin.js +86 -86
  32. package/src/lib/loadGamePackage.js +9 -2
  33. package/src/lib/registry.js +94 -0
  34. package/src/lib/validators.js +79 -17
  35. package/src/standalone/index.js +42 -13
  36. package/tests/fixtures/generations/gen-api3/README.md +7 -0
  37. package/tests/fixtures/generations/gen-api3/client/fakeClientCore.js +307 -0
  38. package/tests/fixtures/generations/gen-api3/client/index.js +38 -0
  39. package/tests/fixtures/generations/gen-api3/client/parts/Actor.js +18 -0
  40. package/tests/fixtures/generations/gen-api3/client/parts/ActorRadar.js +7 -0
  41. package/tests/fixtures/generations/gen-api3/config/auth.js +41 -0
  42. package/tests/fixtures/generations/gen-api3/config/client.js +151 -0
  43. package/tests/fixtures/generations/gen-api3/config/game.js +167 -0
  44. package/tests/fixtures/generations/gen-api3/core/pkg-node/core.js +4 -0
  45. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +129 -0
  46. package/tests/fixtures/generations/gen-api3/host/createModules.js +6 -0
  47. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +263 -0
  48. package/tests/fixtures/generations/gen-api3/host/index.js +30 -0
  49. package/tests/fixtures/generations/gen-api3/host/spawnCommand.js +14 -0
  50. package/tests/fixtures/generations/gen-api3/host/systemMessages.js +6 -0
  51. package/tests/fixtures/generations/gen-api3/manifest.json +25 -0
  52. package/tests/fixtures/generations/gen-api4/README.md +7 -0
  53. package/tests/fixtures/generations/gen-api4/client/fakeClientCore.js +307 -0
  54. package/tests/fixtures/generations/gen-api4/client/index.js +38 -0
  55. package/tests/fixtures/generations/gen-api4/client/parts/Actor.js +18 -0
  56. package/tests/fixtures/generations/gen-api4/client/parts/ActorRadar.js +7 -0
  57. package/tests/fixtures/generations/gen-api4/config/auth.js +41 -0
  58. package/tests/fixtures/generations/gen-api4/config/client.js +151 -0
  59. package/tests/fixtures/generations/gen-api4/config/game.js +156 -0
  60. package/tests/fixtures/generations/gen-api4/core/pkg-node/core.js +4 -0
  61. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +129 -0
  62. package/tests/fixtures/generations/gen-api4/host/createModules.js +6 -0
  63. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +263 -0
  64. package/tests/fixtures/generations/gen-api4/host/index.js +30 -0
  65. package/tests/fixtures/generations/gen-api4/host/spawnCommand.js +14 -0
  66. package/tests/fixtures/generations/gen-api4/host/systemMessages.js +6 -0
  67. package/tests/fixtures/generations/gen-api4/manifest.json +17 -0
  68. package/tests/fixtures/miniGame/client/fakeClientCore.js +17 -0
  69. package/tests/fixtures/miniGame/host/fakeCore.js +38 -8
@@ -18,9 +18,11 @@ export default {
18
18
  return skip('no gameConfig.maps or gameConfig.teams');
19
19
  }
20
20
 
21
- const playing = Object.keys(teams).filter(
22
- team => team !== ctx.gameConfig.spectatorTeam,
23
- );
21
+ // эффективное значение: игра вправе не объявлять spectatorTeam (И2), а
22
+ // по объявленному команда наблюдателей попала бы в играющие и правило
23
+ // потребовало бы для неё респауны
24
+ const spectatorTeam = ctx.gameConfigView?.spectatorTeam ?? null;
25
+ const playing = Object.keys(teams).filter(team => team !== spectatorTeam);
24
26
  const maxPlayers = ctx.gameConfig.roomDefaults?.maxPlayers;
25
27
  const violations = [];
26
28
 
@@ -1,20 +1,35 @@
1
- import { ERROR, skip, verdict } from '../result.js';
1
+ import { ERROR, WARN, skip, verdict } from '../result.js';
2
+ import {
3
+ ENGINE_CAPABILITIES,
4
+ CAPABILITIES,
5
+ } from '../../../lib/capabilities.js';
2
6
 
3
- // engineApi живёт в трёх местах (манифест, HostPlugin, ClientPlugin) и во
4
- // всех трёх обязан быть импортом ENGINE_API_VERSION, а не числом: литерал
5
- // не расходится со сборкой движка в момент написания и молча расходится
6
- // через релиз, после чего плагин отклоняется гейтом совместимости.
7
+ // `engineApi` живёт в трёх местах (манифест, HostPlugin, ClientPlugin).
8
+ // Расхождение между ними рассинхрон сборки внутри пакета, и это ошибка.
9
+ // Расхождение с установленным движком ошибкой БОЛЬШЕ НЕ является (этап 5
10
+ // плана plugin-forward-compat): `ENGINE_API_VERSION` заморожен на 4 и не
11
+ // гейт, а игра, собранная год назад против движка не последней версии, —
12
+ // нормальное состояние, ради которого весь план и делался.
13
+ //
14
+ // Импортом, а не литералом, значение остаётся по-прежнему: литерал не
15
+ // расходится со сборкой движка в момент написания и молча расходится через
16
+ // релиз, после чего манифест игры врёт о своём поколении.
17
+ //
18
+ // Возможности из `manifest.requires` проверяются по реестру установленного
19
+ // движка: имени, которого нет, движок дать не может — игра просит будущее.
7
20
  export default {
8
21
  id: 'B2',
9
22
  name: 'engineApiVersion',
10
23
  level: ERROR,
11
- title: 'engineApi matches ENGINE_API_VERSION in all three places',
24
+ title: 'engineApi is consistent and requires name existing capabilities',
12
25
 
13
26
  check(ctx) {
27
+ // манифест первый: он — то, что о поколении пакета читает движок,
28
+ // и с ним сверяются половины плагина
14
29
  const declared = [
30
+ ['manifest', ctx.manifest?.engineApi],
15
31
  ['host plugin', ctx.hostPlugin?.engineApi],
16
32
  ['client plugin', ctx.clientPlugin?.engineApi],
17
- ['manifest', ctx.manifest?.engineApi],
18
33
  ].filter(([, value]) => value !== undefined);
19
34
 
20
35
  if (!declared.length) {
@@ -22,11 +37,14 @@ export default {
22
37
  }
23
38
 
24
39
  const violations = [];
40
+ const retired = [];
41
+ const [source, reference] = declared[0];
25
42
 
26
43
  for (const [where, value] of declared) {
27
- if (value !== ctx.engineApi) {
44
+ if (value !== reference) {
28
45
  violations.push(
29
- `${where} declares engineApi v${value}, this engine is v${ctx.engineApi}`,
46
+ `${where} declares engineApi v${value}, but ${source} declares ` +
47
+ `v${reference} — rebuild the package`,
30
48
  );
31
49
  }
32
50
  }
@@ -42,6 +60,59 @@ export default {
42
60
  }
43
61
  }
44
62
 
45
- return verdict(violations);
63
+ // форма `requires` — то же недоверие, что в checkPluginCompatibility:
64
+ // строка проитерировалась бы здесь посимвольно, объект уронил бы чекер
65
+ // «not iterable»
66
+ const requires = ctx.manifest?.requires;
67
+
68
+ if (requires === undefined || requires === null) {
69
+ return finish(violations, retired);
70
+ }
71
+
72
+ if (
73
+ !Array.isArray(requires) ||
74
+ requires.some(name => typeof name !== 'string')
75
+ ) {
76
+ violations.push(
77
+ 'manifest.requires must be an array of capability names, got ' +
78
+ `${JSON.stringify(requires)} — the engine reads it before it ` +
79
+ 'loads the plugin',
80
+ );
81
+
82
+ return finish(violations, retired);
83
+ }
84
+
85
+ for (const name of requires) {
86
+ // has, а не CAPABILITIES.includes: реестр возможностей append-only,
87
+ // и выведенное алиасом имя движок принимает вечно (ENGINE_CAPABILITIES.
88
+ // has в checkPluginCompatibility). Сверка с одними активными именами
89
+ // отвергала бы игру за то, что движок переименовал возможность — тот
90
+ // самый отказ по возрасту, который план и снимал
91
+ if (!ENGINE_CAPABILITIES.has(name)) {
92
+ violations.push(
93
+ `manifest.requires names '${name}', which this engine does not ` +
94
+ `provide — known capabilities: ${CAPABILITIES.join(', ')}`,
95
+ );
96
+ } else if (ENGINE_CAPABILITIES.isRetired(name)) {
97
+ retired.push(
98
+ `manifest.requires names '${name}', which was retired — it still ` +
99
+ `works (the engine resolves it to ` +
100
+ `'${ENGINE_CAPABILITIES.resolve(name)}' forever), but a new game ` +
101
+ `should declare '${ENGINE_CAPABILITIES.resolve(name)}' itself`,
102
+ );
103
+ }
104
+ }
105
+
106
+ return finish(violations, retired);
46
107
  },
47
108
  };
109
+
110
+ // то же, что делают B5 и C10: выведенное имя — не отказ, а предупреждение.
111
+ // Отвергать за него значило бы отвергать игру за возраст (И1)
112
+ function finish(violations, retired) {
113
+ if (violations.length === 0 && retired.length > 0) {
114
+ return verdict(retired, 'retired capability names still resolve', WARN);
115
+ }
116
+
117
+ return verdict(violations);
118
+ }
@@ -1,11 +1,31 @@
1
- import { assertGameConfigShape } from '../../../lib/gamePlugin.js';
2
- import { ERROR, skip, verdict } from '../result.js';
1
+ import {
2
+ REQUIRED_GAME_CONFIG_PATHS,
3
+ createGameConfigView,
4
+ } from '../../../lib/gameConfigView.js';
5
+ import { ERROR, WARN, skip, verdict } from '../result.js';
3
6
 
4
7
  // Тот же гейт, что стоит на боевом пути загрузки плагина
5
- // (lib/gamePlugin.js): обязательные пути gameConfig плюс связь
8
+ // (lib/gameConfigView.js): обязательные пути gameConfig плюс связь
6
9
  // spectatorTeam ↔ teams (под noSpectators — «ровно одна команда»).
7
10
  // Дублировать его список здесь нельзя — он обязан эволюционировать в одном
8
11
  // месте.
12
+ //
13
+ // Всё, что не в REQUIRED, движок подставляет сам (И2 этапа 2 плана
14
+ // plugin-forward-compat) — это не отказ. Но полагаться на движковое
15
+ // умолчание разработчик должен осознанно, поэтому необъявленные поля
16
+ // перечисляются предупреждением.
17
+ const DEFAULTED = [
18
+ 'roomDefaults.maxPlayers',
19
+ 'parts.weapons',
20
+ 'parts.friendlyFire',
21
+ 'panel.fields',
22
+ 'spectatorTeam',
23
+ ];
24
+
25
+ function getPath(source, dottedPath) {
26
+ return dottedPath.split('.').reduce((value, key) => value?.[key], source);
27
+ }
28
+
9
29
  export default {
10
30
  id: 'B3',
11
31
  name: 'gameConfigShape',
@@ -18,11 +38,32 @@ export default {
18
38
  }
19
39
 
20
40
  try {
21
- assertGameConfigShape(ctx.hostPlugin);
41
+ createGameConfigView(ctx.hostPlugin.gameConfig, ctx.hostPlugin.id);
22
42
  } catch (error) {
23
43
  return verdict([error.message]);
24
44
  }
25
45
 
26
- return verdict([]);
46
+ // noSpectators — своя ветка контракта: spectatorTeam там не бывает
47
+ const declared = ctx.hostPlugin.gameConfig;
48
+ const paths =
49
+ declared?.noSpectators === true
50
+ ? DEFAULTED.filter(path => path !== 'spectatorTeam')
51
+ : DEFAULTED;
52
+
53
+ const relied = paths.filter(path => {
54
+ const value = getPath(declared, path);
55
+
56
+ return value === undefined || value === null;
57
+ });
58
+
59
+ return verdict(
60
+ relied.map(
61
+ path =>
62
+ `gameConfig.${path} is not declared — the engine falls back to ` +
63
+ 'its own default; declare it if the game means something else',
64
+ ),
65
+ `required: ${REQUIRED_GAME_CONFIG_PATHS.join(', ')}`,
66
+ WARN,
67
+ );
27
68
  },
28
69
  };
@@ -17,7 +17,10 @@ export default {
17
17
  return skip('no gameConfig.teams');
18
18
  }
19
19
 
20
- const { spectatorTeam } = ctx.gameConfig;
20
+ // эффективное значение, а не объявленное: движок выводит spectatorTeam
21
+ // из команды `spectators`, и игра вправе его не писать (И2)
22
+ const spectatorTeam = ctx.gameConfigView?.spectatorTeam ?? null;
23
+ const declared = ctx.gameConfig.spectatorTeam;
21
24
  const violations = [];
22
25
 
23
26
  if (ctx.gameConfig.noSpectators === true) {
@@ -33,9 +36,9 @@ export default {
33
36
  return verdict(violations);
34
37
  }
35
38
 
36
- if (teams[spectatorTeam] === undefined) {
39
+ if (declared !== undefined && teams[declared] === undefined) {
37
40
  violations.push(
38
- `spectatorTeam "${spectatorTeam}" is not a key of teams ` +
41
+ `spectatorTeam "${declared}" is not a key of teams ` +
39
42
  `(${Object.keys(teams).join(', ')})`,
40
43
  );
41
44
  }
@@ -1,13 +1,26 @@
1
- import { ERROR, skip, verdict } from '../result.js';
1
+ import { ERROR, WARN, skip, verdict } from '../result.js';
2
2
  import { anchorPattern } from '../../../lib/formPattern.js';
3
+ import {
4
+ formControls,
5
+ ACTIVE_FORM_CONTROLS,
6
+ } from '../../../lib/formControls.js';
3
7
 
4
8
  // roomForm. Поле вне белого списка форма показывает, лобби отправляет, а
5
9
  // хост молча выбрасывает — правило игры, построенное на своей настройке
6
- // комнаты, не работает и ничего об этом не сообщает. Контролов в v3 ровно
7
- // четыре: неизвестный пропускается с console.error. `regExp` едет в манифесте
8
- // строкой и компилируется уже у игрока некомпилируемая ловится здесь.
9
- const HONOURED = ['maps', 'maxPlayers', 'map', 'roundTime', 'mapTime', 'friendlyFire'];
10
- const CONTROLS = ['text', 'select', 'checkbox', 'radio'];
10
+ // комнаты, не работает и ничего об этом не сообщает. Контрол проверяется по
11
+ // реестру (lib/formControls.js): неизвестный пропускается с console.error,
12
+ // выведенный из эксплуатации строится алиасом и потому только WARN — новая
13
+ // игра его писать не должна, старая продолжает работать. `regExp` едет в
14
+ // манифесте строкой и компилируется уже у игрока — некомпилируемая ловится
15
+ // здесь.
16
+ const HONOURED = [
17
+ 'maps',
18
+ 'maxPlayers',
19
+ 'map',
20
+ 'roundTime',
21
+ 'mapTime',
22
+ 'friendlyFire',
23
+ ];
11
24
 
12
25
  export default {
13
26
  id: 'B5',
@@ -23,6 +36,7 @@ export default {
23
36
  }
24
37
 
25
38
  const violations = [];
39
+ const retired = [];
26
40
 
27
41
  for (const field of roomForm) {
28
42
  if (!HONOURED.includes(field.name)) {
@@ -32,10 +46,20 @@ export default {
32
46
  );
33
47
  }
34
48
 
35
- if (!CONTROLS.includes(field.control)) {
49
+ if (!formControls.has(field.control)) {
36
50
  violations.push(
37
51
  `roomForm field "${field.name}": control "${field.control}" does ` +
38
- `not exist (${CONTROLS.join(', ')})`,
52
+ `not exist (${ACTIVE_FORM_CONTROLS.join(', ')})`,
53
+ );
54
+ } else if (formControls.isRetired(field.control)) {
55
+ const entry = formControls.get(field.control);
56
+
57
+ retired.push(
58
+ `roomForm field "${field.name}": control "${field.control}" was ` +
59
+ `retired in plugin API v${entry.retiredIn} — it still works ` +
60
+ `(the engine builds it as "${formControls.resolve(field.control)}" ` +
61
+ `forever), but a new game should declare ` +
62
+ `"${formControls.resolve(field.control)}" itself`,
39
63
  );
40
64
  }
41
65
 
@@ -54,6 +78,14 @@ export default {
54
78
  }
55
79
  }
56
80
 
81
+ if (violations.length === 0 && retired.length > 0) {
82
+ return verdict(
83
+ retired,
84
+ 'retired controls still build, via aliases',
85
+ WARN,
86
+ );
87
+ }
88
+
57
89
  return verdict(violations);
58
90
  },
59
91
  };
@@ -1,5 +1,9 @@
1
- import { ERROR, skip, verdict } from '../result.js';
1
+ import { ERROR, WARN, skip, verdict } from '../result.js';
2
2
  import { resolveValidator } from '../../../lib/validators.js';
3
+ import {
4
+ formControls,
5
+ ACTIVE_FORM_CONTROLS,
6
+ } from '../../../lib/formControls.js';
3
7
 
4
8
  // authSchema. Четыре ошибки, каждая из которых уже случалась:
5
9
  // formId вместо fieldsId (контейнер резолвится в null и экран авторизации
@@ -24,6 +28,7 @@ export default {
24
28
 
25
29
  const { elems = {}, params = [] } = ctx.authSchema;
26
30
  const violations = [];
31
+ const retired = [];
27
32
 
28
33
  if (elems.formId !== undefined) {
29
34
  violations.push(
@@ -48,6 +53,24 @@ export default {
48
53
  );
49
54
  }
50
55
 
56
+ const control = field.options?.control;
57
+
58
+ if (control !== undefined && !formControls.has(control)) {
59
+ violations.push(
60
+ `authSchema param "${field.name}": control "${control}" does not ` +
61
+ `exist (${ACTIVE_FORM_CONTROLS.join(', ')}) — the form throws ` +
62
+ "'unknown control' and the auth screen never renders",
63
+ );
64
+ } else if (control !== undefined && formControls.isRetired(control)) {
65
+ retired.push(
66
+ `authSchema param "${field.name}": control "${control}" was ` +
67
+ `retired in plugin API v${formControls.get(control).retiredIn} — ` +
68
+ `it still works (the engine builds and validates it as ` +
69
+ `"${formControls.resolve(control)}" forever), but a new game ` +
70
+ `should declare "${formControls.resolve(control)}" itself`,
71
+ );
72
+ }
73
+
51
74
  const validatorName = field.options?.validator;
52
75
 
53
76
  // опечатка в имени (как и не-функция под верным именем) = поле не
@@ -73,6 +96,16 @@ export default {
73
96
  );
74
97
  }
75
98
 
99
+ // то же, что делает B5 для roomForm: выведенный контрол — не отказ, а
100
+ // предупреждение, иначе правило отвергало бы игру за возраст (И1)
101
+ if (violations.length === 0 && retired.length > 0) {
102
+ return verdict(
103
+ retired,
104
+ 'retired controls still build and validate, via aliases',
105
+ WARN,
106
+ );
107
+ }
108
+
76
109
  return verdict(violations);
77
110
  },
78
111
  };
@@ -1,20 +1,15 @@
1
1
  import { ERROR, WARN, skip, verdict } from '../result.js';
2
+ import { SERVICES } from '../../../config/clientServices.js';
2
3
 
3
- // Движковых сервисов ровно пять (client/main.js), но пул ими не
4
- // исчерпывается: игра доливает туда свои через ClientPlugin.hooks.services(core)
5
- // — например геометрию предсказанной динамики карты
6
- // (docs/en/plugin-api.md «hooks.services»). Незнакомое имя не ошибка для
7
- // движка: part получает undefined и рисует пустоту — карта без assetsBase
8
- // выглядит как чистый холст без единой строки в консоли, ради этого правило
9
- // и существует.
10
- const SERVICES = [
11
- 'renderer',
12
- 'soundManager',
13
- 'assetsBase',
14
- 'localPlayer',
15
- // места в глобальном топе (snakes-v3 этап 4)
16
- 'accolades',
17
- ];
4
+ // Движковый пул сервисов держит реестр (config/clientServices.js), но пул
5
+ // ими не исчерпывается: игра доливает туда свои через
6
+ // ClientPlugin.hooks.services(core) — например геометрию предсказанной
7
+ // динамики карты (docs/en/plugin-api.md «hooks.services»). Незнакомое имя не
8
+ // ошибка для движка: part получает undefined и рисует пустоту — карта без
9
+ // assetsBase выглядит как чистый холст без единой строки в консоли, ради
10
+ // этого правило и существует. Правило остаётся ERROR: оно работает на этапе
11
+ // разработки игры, а не в рантайме.
12
+ export { SERVICES };
18
13
 
19
14
  export default {
20
15
  id: 'C4',
@@ -0,0 +1,175 @@
1
+ // Разбор `core/src/abi.rs` — единственный раздел слепка плагинной
2
+ // поверхности, который нельзя собрать импортом: методы wasm-ABI живут внутри
3
+ // `macro_rules!`, раскрывающихся уже в крейте игры. Слепок читает файл как
4
+ // текст и вытаскивает имена и нормализованные сигнатуры `pub fn`.
5
+ //
6
+ // Разбор обязан ПАДАТЬ, а не возвращать пустоту: молчаливо пустой раздел
7
+ // слепка пропустит любое нарушение И1/И3 (plan/plugin-forward-compat).
8
+
9
+ // имя макроса → раздел слепка (`abi.game` / `abi.client`)
10
+ const MACRO_SECTIONS = {
11
+ // имена макросов Rust — строками: camelCase к ним неприменим
12
+ 'export_game_core_abi': 'game',
13
+ 'export_client_core_abi': 'client',
14
+ };
15
+
16
+ /**
17
+ * @param {string} source - Содержимое `core/src/abi.rs`.
18
+ * @returns {{game: Object[], client: Object[]}} Методы ABI по разделам,
19
+ * каждый — { name, args, ret }, отсортированные по имени.
20
+ */
21
+ export function parseAbi(source) {
22
+ const blocks = splitMacros(source);
23
+
24
+ if (blocks.length === 0) {
25
+ throw new Error(
26
+ 'abiParse: no macro_rules! block found in core/src/abi.rs — the file ' +
27
+ 'was restructured; fix the parser instead of letting the surface ' +
28
+ 'snapshot go silently empty',
29
+ );
30
+ }
31
+
32
+ const sections = {};
33
+
34
+ for (const block of blocks) {
35
+ const section = MACRO_SECTIONS[block.macro];
36
+
37
+ if (!section) {
38
+ continue; // макрос вне ABI плагина — не часть поверхности
39
+ }
40
+
41
+ const methods = parseMethods(block.body);
42
+
43
+ if (methods.length === 0) {
44
+ throw new Error(
45
+ `abiParse: macro_rules! ${block.macro} yielded no "pub fn" — the ` +
46
+ 'file was restructured; fix the parser',
47
+ );
48
+ }
49
+
50
+ sections[section] = methods.sort((a, b) => a.name.localeCompare(b.name));
51
+ }
52
+
53
+ for (const [macro, section] of Object.entries(MACRO_SECTIONS)) {
54
+ if (!sections[section]) {
55
+ throw new Error(
56
+ `abiParse: macro_rules! ${macro} not found in core/src/abi.rs`,
57
+ );
58
+ }
59
+ }
60
+
61
+ return sections;
62
+ }
63
+
64
+ // тело каждого `macro_rules! <name> { … }` — от имени до парной скобке
65
+ function splitMacros(source) {
66
+ const re = /macro_rules!\s+([a-z_0-9]+)\s*\{/g;
67
+ const blocks = [];
68
+ let match;
69
+
70
+ while ((match = re.exec(source)) !== null) {
71
+ const open = re.lastIndex - 1;
72
+ const close = matchBrace(source, open, '{', '}');
73
+
74
+ blocks.push({
75
+ macro: match[1],
76
+ body: source.slice(open + 1, close),
77
+ });
78
+ }
79
+
80
+ return blocks;
81
+ }
82
+
83
+ // `pub fn name(args) -> ret` внутри тела макроса; тело метода пропускается
84
+ function parseMethods(body) {
85
+ const re = /pub\s+fn\s+([a-z_0-9]+)\s*\(/g;
86
+ const methods = [];
87
+ let match;
88
+
89
+ while ((match = re.exec(body)) !== null) {
90
+ const open = re.lastIndex - 1;
91
+ const close = matchBrace(body, open, '(', ')');
92
+ const tail = body.slice(close + 1);
93
+ const arrow = /^\s*->([^{;]+)/.exec(tail);
94
+
95
+ methods.push({
96
+ name: match[1],
97
+ args: splitArgs(body.slice(open + 1, close)).map(normalizeType),
98
+ ret: arrow ? normalizeType(arrow[1]) : '()',
99
+ });
100
+ }
101
+
102
+ return methods;
103
+ }
104
+
105
+ // индекс скобки, парной открывающей на позиции `open`
106
+ function matchBrace(source, open, openChar, closeChar) {
107
+ let depth = 0;
108
+
109
+ for (let i = open; i < source.length; i += 1) {
110
+ if (source[i] === openChar) {
111
+ depth += 1;
112
+ } else if (source[i] === closeChar) {
113
+ depth -= 1;
114
+
115
+ if (depth === 0) {
116
+ return i;
117
+ }
118
+ }
119
+ }
120
+
121
+ throw new Error(`abiParse: unbalanced ${openChar} at offset ${open}`);
122
+ }
123
+
124
+ // список аргументов по запятым верхнего уровня; получатель (`&self`,
125
+ // `&mut self`) в сигнатуру не входит — он не часть бинарного контракта
126
+ function splitArgs(list) {
127
+ const args = [];
128
+ let depth = 0;
129
+ let current = '';
130
+
131
+ for (const ch of list) {
132
+ if (ch === '<' || ch === '(' || ch === '[') {
133
+ depth += 1;
134
+ } else if (ch === '>' || ch === ')' || ch === ']') {
135
+ depth -= 1;
136
+ }
137
+
138
+ if (ch === ',' && depth === 0) {
139
+ args.push(current);
140
+ current = '';
141
+ continue;
142
+ }
143
+
144
+ current += ch;
145
+ }
146
+
147
+ args.push(current);
148
+
149
+ return args
150
+ .map(arg => arg.trim())
151
+ .filter(arg => arg.length > 0 && !/^&?\s*(mut\s+)?self$/.test(arg))
152
+ .map(arg => {
153
+ const colon = arg.indexOf(':');
154
+
155
+ // аргумент без имени в этих макросах не встречается: имя есть всегда
156
+ return colon === -1 ? arg : arg.slice(colon + 1);
157
+ });
158
+ }
159
+
160
+ // Стабильная строка типа: меняется тогда и только тогда, когда меняется
161
+ // бинарный контракт. Ссылка/`mut`/пробелы/путь до wasm-bindgen на него не
162
+ // влияют; `Result<T, JsError>` сворачивается в `Result<T>` — вариант ошибки
163
+ // у всех методов один.
164
+ function normalizeType(type) {
165
+ const flat = type
166
+ .replaceAll('::wasm_bindgen::', '')
167
+ .replaceAll('wasm_bindgen::', '')
168
+ .replace(/\bmut\b/g, '')
169
+ .replaceAll('&', '')
170
+ .replace(/\s+/g, '');
171
+
172
+ return flat.replace(/^Result<(.*),JsError>$/, 'Result<$1>');
173
+ }
174
+
175
+ export default parseAbi;