codama-renderers-dart 0.5.1 → 0.5.4

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 (37) hide show
  1. package/{readme.md → README.md} +16 -43
  2. package/dist/index.browser.cjs +345 -124
  3. package/dist/index.browser.cjs.map +1 -1
  4. package/dist/index.browser.js +346 -125
  5. package/dist/index.browser.js.map +1 -1
  6. package/dist/index.node.cjs +345 -124
  7. package/dist/index.node.cjs.map +1 -1
  8. package/dist/index.node.js +346 -125
  9. package/dist/index.node.js.map +1 -1
  10. package/dist/index.react-native.js +346 -125
  11. package/dist/index.react-native.js.map +1 -1
  12. package/dist/types/fragments/accountPage.d.ts.map +1 -1
  13. package/dist/types/fragments/errorPage.d.ts.map +1 -1
  14. package/dist/types/fragments/instructionPage.d.ts.map +1 -1
  15. package/dist/types/fragments/pdaPage.d.ts.map +1 -1
  16. package/dist/types/fragments/programPage.d.ts.map +1 -1
  17. package/dist/types/utils/nameTransformers.d.ts +5 -0
  18. package/dist/types/utils/nameTransformers.d.ts.map +1 -1
  19. package/dist/types/utils/valueNodes.d.ts +4 -0
  20. package/dist/types/utils/valueNodes.d.ts.map +1 -1
  21. package/dist/types/visitors/getRenderMapVisitor.d.ts.map +1 -1
  22. package/dist/types/visitors/getTypeManifestVisitor.d.ts +1 -1
  23. package/dist/types/visitors/getTypeManifestVisitor.d.ts.map +1 -1
  24. package/dist/types/visitors/renderVisitor.d.ts.map +1 -1
  25. package/package.json +7 -7
  26. package/src/fragments/accountPage.ts +2 -1
  27. package/src/fragments/errorPage.ts +8 -5
  28. package/src/fragments/instructionPage.ts +54 -17
  29. package/src/fragments/pdaPage.ts +7 -14
  30. package/src/fragments/programPage.ts +3 -2
  31. package/src/fragments/typePage.ts +102 -19
  32. package/src/utils/formatCode.ts +2 -2
  33. package/src/utils/nameTransformers.ts +39 -1
  34. package/src/utils/valueNodes.ts +24 -1
  35. package/src/visitors/getRenderMapVisitor.ts +30 -5
  36. package/src/visitors/getTypeManifestVisitor.ts +123 -81
  37. package/src/visitors/renderVisitor.ts +11 -13
@@ -6,19 +6,50 @@ import type { CamelCaseString } from "@codama/nodes";
6
6
 
7
7
  /** Convert a string to PascalCase */
8
8
  export function pascalCase(str: string): string {
9
+ assertSafeName(str);
10
+
9
11
  return str
10
12
  .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ""))
11
13
  .replace(/^(.)/, (c) => c.toUpperCase());
12
14
  }
13
15
 
16
+ /**
17
+ * Dart reserved words (plus `async`/`await`/`yield` where they cannot be
18
+ * plain identifiers). A lowercase declaration named with one of these must
19
+ * be escaped, e.g. an account named `null` becomes a `null_` parameter.
20
+ */
21
+ const DART_RESERVED_WORDS = new Set([
22
+ "abstract", "as", "assert", "async", "augment", "await", "base",
23
+ "break", "case", "catch", "class", "const", "continue", "covariant",
24
+ "default", "deferred", "do", "dynamic", "else", "enum", "export",
25
+ "extends", "extension", "external", "factory", "false", "final",
26
+ "finally", "for", "get", "hide", "if", "implements", "import", "in",
27
+ "interface", "is", "late", "library", "mixin", "native", "new", "null",
28
+ "of", "on", "operator", "part", "required", "rethrow", "return",
29
+ "sealed", "set", "show", "static", "super", "switch", "sync", "this",
30
+ "throw", "true", "try", "typedef", "var", "void", "when", "while",
31
+ "with", "yield",
32
+ ]);
33
+
34
+ /**
35
+ * Escape a lowercase identifier if it collides with a Dart reserved word
36
+ * by appending `_` (e.g. an account named `null` renders as `null_`).
37
+ */
38
+ export function escapeDartIdentifier(name: string): string {
39
+ return DART_RESERVED_WORDS.has(name) ? `${name}_` : name;
40
+ }
41
+
14
42
  /** Convert a string to camelCase */
15
43
  export function camelCase(str: string): string {
16
44
  const pascal = pascalCase(str);
17
- return pascal.charAt(0).toLowerCase() + pascal.slice(1);
45
+ const cased = pascal.charAt(0).toLowerCase() + pascal.slice(1);
46
+ return escapeDartIdentifier(cased);
18
47
  }
19
48
 
20
49
  /** Convert a string to snake_case */
21
50
  export function snakeCase(str: string): string {
51
+ assertSafeName(str);
52
+
22
53
  return str
23
54
  .replace(/([A-Z])/g, "_$1")
24
55
  .replace(/[-\s]+/g, "_")
@@ -111,3 +142,10 @@ export function createDartNameApi(): DartNameApi {
111
142
  export function fromCamelCaseString(str: CamelCaseString): string {
112
143
  return str as string;
113
144
  }
145
+
146
+ /** Reject IDL names that could escape generated identifiers, strings, or paths. */
147
+ function assertSafeName(name: string): void {
148
+ if (!/^[a-zA-Z0-9_ -]*$/.test(name)) {
149
+ throw new Error(`Invalid Dart node name: ${JSON.stringify(name)}`);
150
+ }
151
+ }
@@ -23,12 +23,25 @@ export function getDartValueFragment(
23
23
  ): Fragment {
24
24
  switch (value.kind) {
25
25
  case "numberValueNode":
26
+ // Serialized IDLs bypass TypeScript types; never interpolate unvalidated data.
27
+ if (typeof value.number !== "number" || !Number.isFinite(value.number)) {
28
+ throw new Error("Invalid Dart number: expected a finite number");
29
+ }
30
+
31
+ if (dartType === "BigInt" && !Number.isSafeInteger(value.number)) {
32
+ throw new Error("Invalid Dart BigInt: expected a safe integer");
33
+ }
34
+
26
35
  return fragmentFromString(
27
36
  dartType === "BigInt"
28
37
  ? `BigInt.from(${value.number})`
29
38
  : String(value.number),
30
39
  );
31
40
  case "booleanValueNode":
41
+ if (typeof value.boolean !== "boolean") {
42
+ throw new Error("Invalid Dart boolean: expected a boolean");
43
+ }
44
+
32
45
  return fragmentFromString(String(value.boolean));
33
46
  case "stringValueNode":
34
47
  return fragmentFromString(toDartStringLiteral(value.string));
@@ -74,7 +87,8 @@ export function isConstDartValueNode(value: RenderableValueNode): boolean {
74
87
  }
75
88
  }
76
89
 
77
- function toDartStringLiteral(value: string): string {
90
+ /** Escape IDL text so it remains data inside a Dart string literal. */
91
+ export function toDartStringLiteral(value: string): string {
78
92
  return `'${value
79
93
  .replaceAll("\\", "\\\\")
80
94
  .replaceAll("\n", "\\n")
@@ -85,3 +99,12 @@ function toDartStringLiteral(value: string): string {
85
99
  .replaceAll("$", "\\$")
86
100
  .replaceAll("'", "\\'")}'`;
87
101
  }
102
+
103
+ /** Reject executable or lossy sizes from serialized IDLs before generating Dart. */
104
+ export function getNonNegativeInteger(value: number): string {
105
+ if (!Number.isSafeInteger(value) || value < 0) {
106
+ throw new Error("Expected a non-negative safe integer value");
107
+ }
108
+
109
+ return String(value);
110
+ }
@@ -89,10 +89,13 @@ export function getRenderMapVisitor(
89
89
  ...(node.additionalPrograms ?? []).map((p) => visit(p, self)),
90
90
  ];
91
91
 
92
- const mergedProgramMap = mergeRenderMaps(programMaps);
92
+ const mergedProgramMap = mergeRenderMapsWithoutCollisions(programMaps);
93
93
  const categoryMaps = getSharedCategoryIndexMaps(mergedProgramMap);
94
94
 
95
- return mergeRenderMaps([mergedProgramMap, ...categoryMaps]);
95
+ return mergeRenderMapsWithoutCollisions([
96
+ mergedProgramMap,
97
+ ...categoryMaps,
98
+ ]);
96
99
  },
97
100
 
98
101
  visitProgram(node: ProgramNode, { self }) {
@@ -173,7 +176,7 @@ export function getRenderMapVisitor(
173
176
  maps.push(rootMap);
174
177
  }
175
178
 
176
- return mergeRenderMaps(maps);
179
+ return mergeRenderMapsWithoutCollisions(maps);
177
180
  },
178
181
 
179
182
  visitAccount(node: AccountNode, { self }) {
@@ -213,6 +216,30 @@ export function getRenderMapVisitor(
213
216
  );
214
217
  }
215
218
 
219
+ /**
220
+ * Merges generated files while rejecting ambiguous output paths.
221
+ *
222
+ * Silent overwrites could replace signer checks or data layouts when an
223
+ * untrusted IDL contains duplicate names.
224
+ */
225
+ function mergeRenderMapsWithoutCollisions(
226
+ maps: RenderMap<Fragment>[],
227
+ ): RenderMap<Fragment> {
228
+ const paths = new Set<string>();
229
+
230
+ for (const map of maps) {
231
+ for (const path of map.keys()) {
232
+ if (paths.has(path)) {
233
+ throw new Error(`Duplicate generated path "${path}"`);
234
+ }
235
+
236
+ paths.add(path);
237
+ }
238
+ }
239
+
240
+ return mergeRenderMaps(maps);
241
+ }
242
+
216
243
  function getSharedCategoryIndexMaps(
217
244
  renderMap: RenderMap<Fragment>,
218
245
  ): RenderMap<Fragment>[] {
@@ -233,8 +260,6 @@ function getSharedCategoryIndexMaps(
233
260
  if (!match) continue;
234
261
 
235
262
  const [, category, fileName] = match;
236
- if (fileName === categoryIndexNames[category]) continue;
237
-
238
263
  if (!categories.has(category)) {
239
264
  categories.set(category, new Set());
240
265
  }
@@ -5,6 +5,7 @@ import {
5
5
  type BooleanTypeNode,
6
6
  type BytesTypeNode,
7
7
  type CamelCaseString,
8
+ type ConstantValueNode,
8
9
  type DateTimeTypeNode,
9
10
  type DefinedTypeLinkNode,
10
11
  type DefinedTypeNode,
@@ -31,6 +32,7 @@ import {
31
32
  type StructFieldTypeNode,
32
33
  type StructTypeNode,
33
34
  type TupleTypeNode,
35
+ type TypeNode,
34
36
  type ZeroableOptionTypeNode,
35
37
  isNode,
36
38
  resolveNestedTypeNode,
@@ -55,6 +57,7 @@ import {
55
57
  use,
56
58
  } from "../utils/index.js";
57
59
  import { type DartNameApi, snakeCase } from "../utils/nameTransformers.js";
60
+ import { getDartValueFragment, getNonNegativeInteger, toDartStringLiteral } from "../utils/valueNodes.js";
58
61
 
59
62
  /**
60
63
  * Type alias for the type manifest visitor.
@@ -121,32 +124,12 @@ export function getTypeManifestVisitor(input: {
121
124
  );
122
125
 
123
126
  return extendVisitor(baseVisitor, {
124
- visitNumberType(node: NumberTypeNode, { self }) {
125
- const { format, endian } = node;
126
- const endianSuffix =
127
- endian === "be"
128
- ? ", NumberCodecConfig(endian: Endian.big)"
129
- : "";
130
- const endianDecoderSuffix =
131
- endian === "be"
132
- ? ", NumberCodecConfig(endian: Endian.big)"
133
- : "";
134
-
135
- // Map format to Dart type and codec
136
- const dartType = getNumberDartType(format);
137
- const codecName = getNumberCodecName(format);
138
-
127
+ visitNumberType(node: NumberTypeNode) {
139
128
  return {
140
129
  // `BigInt` and other number types live in `dart:core` and need no import.
141
- type: fragmentFromString(dartType),
142
- encoder: fragment`${use(
143
- `get${codecName}Encoder`,
144
- "solanaCodecsNumbers",
145
- )}(${endianSuffix ? fragmentFromString(endianSuffix.slice(2)) : fragmentFromString("")})`,
146
- decoder: fragment`${use(
147
- `get${codecName}Decoder`,
148
- "solanaCodecsNumbers",
149
- )}(${endianDecoderSuffix ? fragmentFromString(endianDecoderSuffix.slice(2)) : fragmentFromString("")})`,
130
+ type: fragmentFromString(getNumberDartType(node.format)),
131
+ encoder: getNumberCodecExpression(node, "Encoder"),
132
+ decoder: getNumberCodecExpression(node, "Decoder"),
150
133
  value: emptyTypeManifest().value,
151
134
  isEnum: false,
152
135
  };
@@ -239,7 +222,7 @@ export function getTypeManifestVisitor(input: {
239
222
  encoder: fragment`${use(
240
223
  "getArrayEncoder",
241
224
  "solanaCodecsDataStructures",
242
- )}<${itemType}>(${itemEncoder}${encoderSizeExpr})`,
225
+ )}(${itemEncoder}${encoderSizeExpr})`,
243
226
  decoder: fragment`${use(
244
227
  "getArrayDecoder",
245
228
  "solanaCodecsDataStructures",
@@ -305,19 +288,33 @@ export function getTypeManifestVisitor(input: {
305
288
  }
306
289
 
307
290
  const encoderListStr = encoderList.map((e) => e.content).join(", ");
291
+ const decoderListStr = decoderList.map((d) => d.content).join(", ");
292
+ // Two-element tuples render through the typed getTuple2 helpers so the
293
+ // generated value type is the Dart record `(T1, T2)` rather than a
294
+ // heterogeneous List<Object?>. Arity-2 is the only shape that currently
295
+ // appears in supported IDLs; wider tuples stay on the untyped helpers.
296
+ let tupleEncoderFn = "getTupleEncoder";
297
+ let tupleDecoderFn = "getTupleDecoder";
298
+ let tupleEncoderArgs = `[${encoderListStr}]`;
299
+ let tupleDecoderArgs = `[${decoderListStr}]`;
300
+ if (itemManifests.length === 2) {
301
+ tupleEncoderFn = "getTuple2Encoder";
302
+ tupleDecoderFn = "getTuple2Decoder";
303
+ tupleEncoderArgs = encoderListStr;
304
+ tupleDecoderArgs = decoderListStr;
305
+ }
308
306
  const encoderFrag = fragment`${use(
309
- "getTupleEncoder",
307
+ tupleEncoderFn,
310
308
  "solanaCodecsDataStructures",
311
- )}([${fragmentFromString(encoderListStr)}])`;
309
+ )}(${tupleEncoderArgs})`;
312
310
  for (const e of encoderList) {
313
311
  encoderFrag.imports.mergeWith(e.imports);
314
312
  }
315
313
 
316
- const decoderListStr = decoderList.map((d) => d.content).join(", ");
317
314
  const decoderFrag = fragment`${use(
318
- "getTupleDecoder",
315
+ tupleDecoderFn,
319
316
  "solanaCodecsDataStructures",
320
- )}([${fragmentFromString(decoderListStr)}])`;
317
+ )}(${tupleDecoderArgs})`;
321
318
  for (const d of decoderList) {
322
319
  decoderFrag.imports.mergeWith(d.imports);
323
320
  }
@@ -341,7 +338,7 @@ export function getTypeManifestVisitor(input: {
341
338
 
342
339
  // Build encoder fields list
343
340
  const encoderFields = fields
344
- .map((f, i) => `('${fieldNames[i]}', ${f.encoder.content})`)
341
+ .map((f, i) => `(${toDartStringLiteral(fieldNames[i])}, ${f.encoder.content})`)
345
342
  .join(", ");
346
343
  const encoderFrag = fragment`${use(
347
344
  "getStructEncoder",
@@ -352,7 +349,7 @@ export function getTypeManifestVisitor(input: {
352
349
  }
353
350
 
354
351
  const decoderFields = fields
355
- .map((f, i) => `('${fieldNames[i]}', ${f.decoder.content})`)
352
+ .map((f, i) => `(${toDartStringLiteral(fieldNames[i])}, ${f.decoder.content})`)
356
353
  .join(", ");
357
354
  const decoderFrag = fragment`${use(
358
355
  "getStructDecoder",
@@ -428,10 +425,10 @@ export function getTypeManifestVisitor(input: {
428
425
  const itemTypeStr = itemManifest.type.content;
429
426
  const resolvedPrefix = resolveNestedTypeNode(node.prefix);
430
427
  const prefixExpr = resolvedPrefix.format !== "u8"
431
- ? `, prefix: ${getNumberCodecExpression(resolvedPrefix.format, "Encoder")}`
428
+ ? fragment`, prefix: ${getNumberCodecExpression(resolvedPrefix, "Encoder")}`
432
429
  : "";
433
430
  const prefixDecoderExpr = resolvedPrefix.format !== "u8"
434
- ? `, prefix: ${getNumberCodecExpression(resolvedPrefix.format, "Decoder")}`
431
+ ? fragment`, prefix: ${getNumberCodecExpression(resolvedPrefix, "Decoder")}`
435
432
  : "";
436
433
  const noneValueExpr = node.fixed
437
434
  ? ", noneValue: const ZeroesNoneValue()"
@@ -442,11 +439,14 @@ export function getTypeManifestVisitor(input: {
442
439
  encoder: fragment`${use(
443
440
  "getNullableEncoder",
444
441
  "solanaCodecsDataStructures",
445
- )}<${fragmentFromString(itemTypeStr)}>(${itemManifest.encoder}${fragmentFromString(prefixExpr)}${fragmentFromString(noneValueExpr)})`,
442
+ )}<${fragmentFromString(itemTypeStr)}>(${use(
443
+ "transformEncoder",
444
+ "solanaCodecsCore",
445
+ )}(${itemManifest.encoder}, (${fragmentFromString(itemTypeStr)} value) => value)${prefixExpr}${fragmentFromString(noneValueExpr)})`,
446
446
  decoder: fragment`${use(
447
447
  "getNullableDecoder",
448
448
  "solanaCodecsDataStructures",
449
- )}<${fragmentFromString(itemTypeStr)}>(${itemManifest.decoder}${fragmentFromString(prefixDecoderExpr)}${fragmentFromString(noneValueExpr)})`,
449
+ )}<${fragmentFromString(itemTypeStr)}>(${itemManifest.decoder}${prefixDecoderExpr}${fragmentFromString(noneValueExpr)})`,
450
450
  value: emptyTypeManifest().value,
451
451
  isEnum: false,
452
452
  };
@@ -504,11 +504,11 @@ export function getTypeManifestVisitor(input: {
504
504
  encoder: fragment`${use(
505
505
  "fixEncoderSize",
506
506
  "solanaCodecsCore",
507
- )}(${innerManifest.encoder}, ${fragmentFromString(String(node.size))}, allowTruncation: false)`,
507
+ )}(${innerManifest.encoder}, ${fragmentFromString(getNonNegativeInteger(node.size))}, allowTruncation: false)`,
508
508
  decoder: fragment`${use(
509
509
  "fixDecoderSize",
510
510
  "solanaCodecsCore",
511
- )}(${innerManifest.decoder}, ${fragmentFromString(String(node.size))})`,
511
+ )}(${innerManifest.decoder}, ${fragmentFromString(getNonNegativeInteger(node.size))})`,
512
512
  value: innerManifest.value,
513
513
  isEnum: innerManifest.isEnum,
514
514
  };
@@ -527,12 +527,12 @@ export function getTypeManifestVisitor(input: {
527
527
  let prefixManifest;
528
528
  if (
529
529
  node.prefix.kind === "numberTypeNode" &&
530
- bigIntFormats.has((node.prefix as any).format)
530
+ bigIntFormats.has(node.prefix.format)
531
531
  ) {
532
- const codecName = getNumberCodecName((node.prefix as any).format);
532
+ const numberManifest = visit(node.prefix, self);
533
533
  prefixManifest = {
534
- encoder: fragment`transformEncoder(${use(`get${codecName}Encoder`, "solanaCodecsNumbers")}(), (size) => BigInt.from(size))`,
535
- decoder: fragment`transformDecoder(${use(`get${codecName}Decoder`, "solanaCodecsNumbers")}(), (size, _, __) => size.toInt())`,
534
+ encoder: fragment`${use("transformEncoder", "solanaCodecsCore")}(${numberManifest.encoder}, (size) => BigInt.from(size))`,
535
+ decoder: fragment`${use("transformDecoder", "solanaCodecsCore")}(${numberManifest.decoder}, (size, _, __) => size.toInt())`,
536
536
  };
537
537
  } else {
538
538
  prefixManifest = visit(node.prefix, self);
@@ -643,13 +643,11 @@ export function getTypeManifestVisitor(input: {
643
643
  },
644
644
 
645
645
  visitPreOffsetType(node: PreOffsetTypeNode, { self }) {
646
- // Pre-offset types pass through to inner type in Dart
647
- return visit(node.type, self);
646
+ return getOffsetManifest(node, self);
648
647
  },
649
648
 
650
649
  visitPostOffsetType(node: PostOffsetTypeNode, { self }) {
651
- // Post-offset types pass through to inner type in Dart
652
- return visit(node.type, self);
650
+ return getOffsetManifest(node, self);
653
651
  },
654
652
 
655
653
  visitSolAmountType(node: SolAmountTypeNode, { self }) {
@@ -760,22 +758,26 @@ function getNumberCodecName(format: string): string {
760
758
  }
761
759
 
762
760
  function getNumberCodecExpression(
763
- format: string,
761
+ node: NumberTypeNode,
764
762
  codecType: "Encoder" | "Decoder",
765
- ): string {
766
- const name = getNumberCodecName(format);
767
- return `get${name}${codecType}()`;
763
+ ): Fragment {
764
+ const name = getNumberCodecName(node.format);
765
+ const config = node.endian === "be"
766
+ ? fragment`${use("NumberCodecConfig", "solanaCodecsNumbers")}(endian: ${use("Endian", "dartTypedData")}.big)`
767
+ : "";
768
+
769
+ return fragment`${use(`get${name}${codecType}`, "solanaCodecsNumbers")}(${config})`;
768
770
  }
769
771
 
770
772
  function getArraySizeExpression(
771
773
  node: ArrayTypeNode,
772
774
  codecType: "Encoder" | "Decoder",
773
- ): string {
775
+ ): string | Fragment {
774
776
  if (!("count" in node) || !node.count) return "";
775
777
  const count = node.count;
776
778
  switch (count.kind) {
777
779
  case "fixedCountNode":
778
- return `, size: FixedArraySize(${count.value})`;
780
+ return `, size: FixedArraySize(${getNonNegativeInteger(count.value)})`;
779
781
  case "remainderCountNode":
780
782
  return ", size: RemainderArraySize()";
781
783
  case "prefixedCountNode": {
@@ -783,7 +785,7 @@ function getArraySizeExpression(
783
785
  if (resolvedPrefix.format === "u32" && (!resolvedPrefix.endian || resolvedPrefix.endian === "le")) {
784
786
  return ""; // Default
785
787
  }
786
- return `, size: PrefixedArraySize(${getNumberCodecExpression(resolvedPrefix.format, codecType)})`;
788
+ return fragment`, size: PrefixedArraySize(${getNumberCodecExpression(resolvedPrefix, codecType)})`;
787
789
  }
788
790
  default:
789
791
  return "";
@@ -793,12 +795,12 @@ function getArraySizeExpression(
793
795
  function getMapSizeExpression(
794
796
  node: MapTypeNode,
795
797
  codecType: "Encoder" | "Decoder",
796
- ): string {
798
+ ): string | Fragment {
797
799
  if (!("count" in node) || !node.count) return "";
798
800
  const count = node.count;
799
801
  switch (count.kind) {
800
802
  case "fixedCountNode":
801
- return `, size: FixedArraySize(${count.value})`;
803
+ return `, size: FixedArraySize(${getNonNegativeInteger(count.value)})`;
802
804
  case "remainderCountNode":
803
805
  return ", size: RemainderArraySize()";
804
806
  case "prefixedCountNode": {
@@ -806,7 +808,7 @@ function getMapSizeExpression(
806
808
  if (resolvedPrefix.format === "u32" && (!resolvedPrefix.endian || resolvedPrefix.endian === "le")) {
807
809
  return "";
808
810
  }
809
- return `, size: PrefixedArraySize(${getNumberCodecExpression(resolvedPrefix.format, codecType)})`;
811
+ return fragment`, size: PrefixedArraySize(${getNumberCodecExpression(resolvedPrefix, codecType)})`;
810
812
  }
811
813
  default:
812
814
  return "";
@@ -816,12 +818,12 @@ function getMapSizeExpression(
816
818
  function getSetSizeExpression(
817
819
  node: SetTypeNode,
818
820
  codecType: "Encoder" | "Decoder",
819
- ): string {
821
+ ): string | Fragment {
820
822
  if (!("count" in node) || !node.count) return "";
821
823
  const count = node.count;
822
824
  switch (count.kind) {
823
825
  case "fixedCountNode":
824
- return `, size: FixedArraySize(${count.value})`;
826
+ return `, size: FixedArraySize(${getNonNegativeInteger(count.value)})`;
825
827
  case "remainderCountNode":
826
828
  return ", size: RemainderArraySize()";
827
829
  case "prefixedCountNode": {
@@ -829,7 +831,7 @@ function getSetSizeExpression(
829
831
  if (resolvedPrefix.format === "u32" && (!resolvedPrefix.endian || resolvedPrefix.endian === "le")) {
830
832
  return "";
831
833
  }
832
- return `, size: PrefixedArraySize(${getNumberCodecExpression(resolvedPrefix.format, codecType)})`;
834
+ return fragment`, size: PrefixedArraySize(${getNumberCodecExpression(resolvedPrefix, codecType)})`;
833
835
  }
834
836
  default:
835
837
  return "";
@@ -842,7 +844,7 @@ type HiddenAffixManifest = {
842
844
  };
843
845
 
844
846
  function getHiddenAffixManifest(
845
- node: any,
847
+ node: ConstantValueNode | TypeNode,
846
848
  self: Visitor<TypeManifest>,
847
849
  ): HiddenAffixManifest {
848
850
  if (node?.kind !== "constantValueNode") {
@@ -850,40 +852,80 @@ function getHiddenAffixManifest(
850
852
  return { encoder: manifest.encoder, decoder: manifest.decoder };
851
853
  }
852
854
 
853
- const constantBytes = getConstantBytesExpression(node);
855
+ const constantBytes = getConstantBytesExpression(node, self);
854
856
  return {
855
857
  encoder: fragment`${use("getConstantEncoder", "solanaCodecsDataStructures")}(${constantBytes})`,
856
858
  decoder: fragment`${use("getConstantDecoder", "solanaCodecsDataStructures")}(${constantBytes})`,
857
859
  };
858
860
  }
859
861
 
860
- function getConstantBytesExpression(node: any): Fragment {
861
- const valueNode = node.value;
862
- const typeNode = node.type;
863
-
864
- if (valueNode?.kind !== "numberValueNode") {
862
+ /** Serialize affix constants using the same validated codec as ordinary fields. */
863
+ function getConstantBytesExpression(
864
+ node: ConstantValueNode,
865
+ self: Visitor<TypeManifest>,
866
+ ): Fragment {
867
+ if (node.value.kind !== "numberValueNode") {
865
868
  throw new Error(
866
- `Unsupported hidden affix constant value kind: ${valueNode?.kind}`,
869
+ `Unsupported hidden affix constant value kind: ${node.value.kind}`,
867
870
  );
868
871
  }
869
872
 
870
- const value = Number(valueNode.number);
873
+ const manifest = visit(node.type, self);
871
874
 
872
- if (typeNode?.kind === "numberTypeNode") {
873
- return fragment`${use(`get${getNumberCodecName(typeNode.format)}Encoder`, "solanaCodecsNumbers")}().encode(${value})`;
875
+ if (!["int", "BigInt", "double"].includes(manifest.type.content)) {
876
+ throw new Error(`Unsupported hidden affix constant type kind: ${node.type.kind}`);
874
877
  }
875
878
 
876
- if (typeNode?.kind === "preOffsetTypeNode") {
877
- const inner = typeNode.type;
878
- if (inner?.kind !== "numberTypeNode") {
879
- throw new Error(
880
- `Unsupported hidden affix pre-offset inner type kind: ${inner?.kind}`,
881
- );
882
- }
883
- return fragment`${use("padLeftEncoder", "solanaCodecsCore")}(${use(`get${getNumberCodecName(inner.format)}Encoder`, "solanaCodecsNumbers")}(), ${typeNode.offset}).encode(${value})`;
879
+ const value = getDartValueFragment(node.value, manifest.type.content);
880
+
881
+ return fragment`${manifest.encoder}.encode(${value})`;
882
+ }
883
+
884
+ /** Preserve the IDL's cursor strategy instead of silently discarding offsets. */
885
+ function getOffsetManifest(
886
+ node: PreOffsetTypeNode | PostOffsetTypeNode,
887
+ self: Visitor<TypeManifest>,
888
+ ): TypeManifest {
889
+ if (!Number.isSafeInteger(node.offset)) {
890
+ throw new Error("Expected a safe integer for a codec offset");
884
891
  }
885
892
 
886
- throw new Error(
887
- `Unsupported hidden affix constant type kind: ${typeNode?.kind}`,
888
- );
893
+ const manifest = visit(node.type, self);
894
+ const pre = node.kind === "preOffsetTypeNode";
895
+ const key = pre ? "preOffset" : "postOffset";
896
+
897
+ if (node.strategy === "padded") {
898
+ const side = pre ? "Left" : "Right";
899
+ const offset = getNonNegativeInteger(node.offset);
900
+
901
+ return {
902
+ ...manifest,
903
+ encoder: fragment`${use(`pad${side}Encoder`, "solanaCodecsCore")}(${manifest.encoder}, ${offset})`,
904
+ decoder: fragment`${use(`pad${side}Decoder`, "solanaCodecsCore")}(${manifest.decoder}, ${offset})`,
905
+ };
906
+ }
907
+
908
+ let expression: string;
909
+
910
+ switch (node.strategy) {
911
+ case "absolute":
912
+ expression = node.offset < 0 ? `scope.wrapBytes(${node.offset})` : String(node.offset);
913
+ break;
914
+ case "relative":
915
+ expression = `scope.${key} + ${node.offset}`;
916
+ break;
917
+ case "preOffset":
918
+ expression = `scope.preOffset + ${node.offset}`;
919
+ break;
920
+ default:
921
+ throw new Error(`Unsupported offset strategy: ${node.strategy}`);
922
+ }
923
+
924
+ const config = fragment`${use("OffsetConfig", "solanaCodecsCore")}(${key}: (scope) => ${expression})`;
925
+
926
+ return {
927
+ ...manifest,
928
+ encoder: fragment`${use("offsetEncoder", "solanaCodecsCore")}(${manifest.encoder}, ${config})`,
929
+ decoder: fragment`${use("offsetDecoder", "solanaCodecsCore")}(${manifest.decoder}, ${config})`,
930
+ };
889
931
  }
@@ -32,11 +32,6 @@ export function renderVisitor(
32
32
  return rootNodeVisitor((root: RootNode) => {
33
33
  const normalizedRoot = normalizeRootNode(root);
34
34
 
35
- // 1. Optionally delete the output directory
36
- if (deleteFolderBeforeRendering && existsSync(outputDir)) {
37
- deleteDirectory(outputDir);
38
- }
39
-
40
35
  // 2. Build the render map
41
36
  const renderMap = visit(
42
37
  normalizedRoot,
@@ -52,16 +47,10 @@ export function renderVisitor(
52
47
  }
53
48
  }
54
49
 
55
- // 4. Resolve imports and write files
50
+ // Resolve every file before deleting previous output, so invalid IDLs fail safely.
51
+ const files: [string, string][] = [];
56
52
  for (const [filePath, frag] of renderMap.entries()) {
57
53
  const fullPath = join(outputDir, filePath);
58
- const dir = dirname(fullPath);
59
-
60
- // Ensure directory exists
61
- if (!existsSync(dir)) {
62
- mkdirSync(dir, { recursive: true });
63
- }
64
-
65
54
  // Compute per-file internal import map (relative paths to defined types)
66
55
  const fileDir = dirname(filePath);
67
56
  const internalMap: Record<string, string> = {};
@@ -81,6 +70,15 @@ export function renderVisitor(
81
70
 
82
71
  // Resolve fragment content with imports
83
72
  const content = resolveFragmentContent(frag, dependencyMap ?? {}, internalMap);
73
+ files.push([fullPath, content]);
74
+ }
75
+
76
+ if (deleteFolderBeforeRendering && existsSync(outputDir)) {
77
+ deleteDirectory(outputDir);
78
+ }
79
+
80
+ for (const [fullPath, content] of files) {
81
+ mkdirSync(dirname(fullPath), { recursive: true });
84
82
  writeFileSync(fullPath, content, "utf-8");
85
83
  }
86
84