codama-renderers-dart 0.5.0 → 0.5.3

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 (48) hide show
  1. package/{readme.md → README.md} +38 -42
  2. package/dist/index.browser.cjs +628 -147
  3. package/dist/index.browser.cjs.map +1 -1
  4. package/dist/index.browser.js +628 -148
  5. package/dist/index.browser.js.map +1 -1
  6. package/dist/index.node.cjs +628 -147
  7. package/dist/index.node.cjs.map +1 -1
  8. package/dist/index.node.js +628 -148
  9. package/dist/index.node.js.map +1 -1
  10. package/dist/index.react-native.js +628 -148
  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/instructionPage.d.ts.map +1 -1
  14. package/dist/types/fragments/typePage.d.ts.map +1 -1
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/index.d.ts.map +1 -1
  17. package/dist/types/utils/discriminators.d.ts +10 -0
  18. package/dist/types/utils/discriminators.d.ts.map +1 -0
  19. package/dist/types/utils/exactDecoder.d.ts +17 -0
  20. package/dist/types/utils/exactDecoder.d.ts.map +1 -0
  21. package/dist/types/utils/formatCode.d.ts +1 -1
  22. package/dist/types/utils/formatCode.d.ts.map +1 -1
  23. package/dist/types/utils/index.d.ts +4 -0
  24. package/dist/types/utils/index.d.ts.map +1 -1
  25. package/dist/types/utils/nameTransformers.d.ts +5 -0
  26. package/dist/types/utils/nameTransformers.d.ts.map +1 -1
  27. package/dist/types/utils/normalizeRootNode.d.ts +11 -0
  28. package/dist/types/utils/normalizeRootNode.d.ts.map +1 -0
  29. package/dist/types/utils/valueNodes.d.ts +16 -0
  30. package/dist/types/utils/valueNodes.d.ts.map +1 -0
  31. package/dist/types/visitors/getTypeManifestVisitor.d.ts +1 -1
  32. package/dist/types/visitors/getTypeManifestVisitor.d.ts.map +1 -1
  33. package/dist/types/visitors/renderVisitor.d.ts.map +1 -1
  34. package/package.json +21 -18
  35. package/src/fragments/accountPage.ts +90 -17
  36. package/src/fragments/instructionPage.ts +112 -79
  37. package/src/fragments/typePage.ts +134 -31
  38. package/src/index.ts +1 -0
  39. package/src/utils/discriminators.ts +121 -0
  40. package/src/utils/exactDecoder.ts +82 -0
  41. package/src/utils/formatCode.ts +7 -11
  42. package/src/utils/index.ts +4 -0
  43. package/src/utils/nameTransformers.ts +28 -1
  44. package/src/utils/normalizeRootNode.ts +76 -0
  45. package/src/utils/valueNodes.ts +87 -0
  46. package/src/visitors/getRenderMapVisitor.ts +1 -1
  47. package/src/visitors/getTypeManifestVisitor.ts +37 -14
  48. package/src/visitors/renderVisitor.ts +4 -1
@@ -7,16 +7,20 @@ import { visit } from "@codama/visitors-core";
7
7
 
8
8
  import type { Fragment } from "../utils/fragment.js";
9
9
  import {
10
- emptyFragment,
11
10
  fragment,
12
11
  fragmentFromString,
13
12
  mergeFragments,
14
13
  use,
15
14
  } from "../utils/fragment.js";
16
15
  import type { RenderScope } from "../utils/options.js";
17
- import { camelCase, pascalCase } from "../utils/nameTransformers.js";
16
+ import { camelCase } from "../utils/nameTransformers.js";
17
+ import { getDiscriminatorValidationFragment } from "../utils/discriminators.js";
18
+ import { getTopLevelDecoderFragment } from "../utils/exactDecoder.js";
19
+ import {
20
+ getDartValueFragment,
21
+ isConstDartValueNode,
22
+ } from "../utils/valueNodes.js";
18
23
  import { getDiscriminatorConstantsFragment } from "./discriminatorConstants.js";
19
- import { WELL_KNOWN_ADDRESSES } from "../utils/wellKnownAddresses.js";
20
24
 
21
25
  /**
22
26
  * Generate a full Dart file for an instruction.
@@ -31,19 +35,55 @@ export function getInstructionPageFragment(
31
35
  const parseFnName = scope.nameApi.instructionParseFunction(name);
32
36
 
33
37
  const accounts = node.accounts ?? [];
34
- const args = (node.arguments ?? []).filter(
35
- (arg) => !isDiscriminatorArg(arg, node),
36
- );
37
38
  const allArgs = node.arguments ?? [];
39
+ const optionalAccountStrategy: string =
40
+ node.optionalAccountStrategy ?? "programId";
41
+ if (
42
+ optionalAccountStrategy !== "programId" &&
43
+ optionalAccountStrategy !== "omitted"
44
+ ) {
45
+ throw new Error(
46
+ `Unsupported optional account strategy on instruction "${node.name}": ${optionalAccountStrategy}`,
47
+ );
48
+ }
49
+ const args = allArgs.filter(
50
+ (arg) =>
51
+ !isDiscriminatorArg(arg, node) && !isOmittedDefaultArgument(arg),
52
+ );
38
53
 
39
54
  // Build the instruction data class
40
55
  const dataClassName = `${typeName}InstructionData`;
41
56
 
42
57
  // Visit each arg type once and collect manifests for reuse
43
- const allArgManifests = allArgs.map((arg) => ({
58
+ const allArgManifests = (node.arguments ?? []).map((arg) => ({
44
59
  arg,
45
60
  manifest: visit(arg.type, scope.typeManifestVisitor),
46
61
  }));
62
+ const argDefaultValues = new Map(
63
+ allArgManifests.flatMap(({ arg, manifest }) => {
64
+ if (arg.defaultValue == null) return [];
65
+ const needsRenderedDefault =
66
+ isOmittedDefaultArgument(arg) ||
67
+ isDiscriminatorArg(arg, node) ||
68
+ arg.defaultValue.kind !== "accountBumpValueNode";
69
+ if (!needsRenderedDefault) return [];
70
+ return [
71
+ [
72
+ arg,
73
+ getDartValueFragment(arg.defaultValue, manifest.type.content),
74
+ ] as const,
75
+ ];
76
+ }),
77
+ );
78
+ for (const arg of allArgs.filter((candidate) =>
79
+ isDiscriminatorArg(candidate, node),
80
+ )) {
81
+ if (!argDefaultValues.has(arg)) {
82
+ throw new Error(
83
+ `Field discriminator "${arg.name}" on instruction "${node.name}" must reference a field with a default value.`,
84
+ );
85
+ }
86
+ }
47
87
 
48
88
  // Data fields (non-discriminator arguments)
49
89
  const dataFieldDecls = allArgManifests
@@ -56,20 +96,25 @@ export function getInstructionPageFragment(
56
96
  const dataCtorParams = allArgManifests
57
97
  .map(({ arg, manifest }) => {
58
98
  const fieldName = camelCase(arg.name as string);
99
+ if (isOmittedDefaultArgument(arg)) return "";
59
100
  if (isDiscriminatorArg(arg, node)) {
60
101
  return isConstDefaultValue(arg.defaultValue)
61
- ? ` this.${fieldName} = ${getDiscriminatorDefault(arg, node)},`
102
+ ? ` this.${fieldName} = ${argDefaultValues.get(arg)!.content},`
62
103
  : ` ${manifest.type.content}? ${fieldName},`;
63
104
  }
64
105
  return ` required this.${fieldName},`;
65
106
  })
107
+ .filter(Boolean)
66
108
  .join("\n");
67
109
 
68
110
  const dataCtorInitializers = allArgManifests
69
111
  .map(({ arg }) => {
70
112
  const fieldName = camelCase(arg.name as string);
113
+ if (isOmittedDefaultArgument(arg)) {
114
+ return ` ${fieldName} = ${argDefaultValues.get(arg)!.content}`;
115
+ }
71
116
  if (isDiscriminatorArg(arg, node) && !isConstDefaultValue(arg.defaultValue)) {
72
- return ` ${fieldName} = ${fieldName} ?? ${getDiscriminatorDefault(arg, node)}`;
117
+ return ` ${fieldName} = ${fieldName} ?? ${argDefaultValues.get(arg)!.content}`;
73
118
  }
74
119
  return null;
75
120
  })
@@ -90,13 +135,17 @@ export function getInstructionPageFragment(
90
135
  .join("\n");
91
136
 
92
137
  const toMapFields = allArgs
93
- .map(
94
- (arg) =>
95
- ` '${arg.name as string}': value.${camelCase(arg.name as string)},`,
96
- )
138
+ .map((arg) => {
139
+ const value =
140
+ isOmittedDefaultArgument(arg) || isDiscriminatorArg(arg, node)
141
+ ? argDefaultValues.get(arg)!.content
142
+ : `value.${camelCase(arg.name as string)}`;
143
+ return ` '${arg.name as string}': ${value},`;
144
+ })
97
145
  .join("\n");
98
146
 
99
147
  const fromMapFields = allArgManifests
148
+ .filter(({ arg }) => !isOmittedDefaultArgument(arg))
100
149
  .map(({ arg, manifest }) => {
101
150
  const typeStr = manifest.type.content;
102
151
  const isNullable = typeStr.endsWith("?");
@@ -105,7 +154,20 @@ export function getInstructionPageFragment(
105
154
  })
106
155
  .join("\n");
107
156
 
108
- const dataCtorKeyword = dataCtorInitializers ? "" : "const ";
157
+ const dataCtorKeyword = allArgs.every(
158
+ (arg) =>
159
+ !isOmittedDefaultArgument(arg) ||
160
+ isConstDartValueNode(arg.defaultValue!),
161
+ ) && allArgs.every(
162
+ (arg) =>
163
+ !isDiscriminatorArg(arg, node) ||
164
+ isConstDefaultValue(arg.defaultValue),
165
+ )
166
+ ? "const "
167
+ : "";
168
+ const dataCtorSignature = dataCtorParams
169
+ ? `${dataCtorKeyword}${dataClassName}({\n${dataCtorParams}\n })${dataCtorInitializers ? ` :\n${dataCtorInitializers}` : ""};`
170
+ : `${dataCtorKeyword}${dataClassName}()${dataCtorInitializers ? ` :\n${dataCtorInitializers}` : ""};`;
109
171
  const dataEncoderName = `get${typeName}InstructionDataEncoder`;
110
172
  const dataDecoderName = `get${typeName}InstructionDataDecoder`;
111
173
  const dataCodecName = `get${typeName}InstructionDataCodec`;
@@ -116,6 +178,13 @@ export function getInstructionPageFragment(
116
178
  const hasProgramAddressCollision = argNames.has('programAddress') || accountNames.has('programAddress');
117
179
  const instrProgramParam = hasProgramAddressCollision ? 'instructionProgramAddress' : 'programAddress';
118
180
 
181
+ // The generated local holding the constructed instruction data must not
182
+ // collide with an account or argument parameter name.
183
+ let instructionDataLocal = 'instructionData';
184
+ while (argNames.has(instructionDataLocal) || accountNames.has(instructionDataLocal)) {
185
+ instructionDataLocal = `${instructionDataLocal}_`;
186
+ }
187
+
119
188
  // Build the instruction builder function
120
189
  const accountParams = accounts
121
190
  .map((acc) => {
@@ -155,7 +224,10 @@ export function getInstructionPageFragment(
155
224
  const role = getAccountRole(acc);
156
225
  const isOptional = acc.isOptional ?? false;
157
226
  if (isOptional) {
158
- return ` if (${fieldName} != null) AccountMeta(address: ${fieldName}, role: ${role}),`;
227
+ if (optionalAccountStrategy === "omitted") {
228
+ return ` if (${fieldName} != null) AccountMeta(address: ${fieldName}, role: ${role}),`;
229
+ }
230
+ return ` if (${fieldName} != null) AccountMeta(address: ${fieldName}, role: ${role}) else AccountMeta(address: ${instrProgramParam}, role: AccountRole.readonly),`;
159
231
  }
160
232
  return ` AccountMeta(address: ${fieldName}, role: ${role}),`;
161
233
  })
@@ -165,19 +237,31 @@ export function getInstructionPageFragment(
165
237
  const dataConstruction = allArgs
166
238
  .map((arg) => {
167
239
  const fieldName = camelCase(arg.name as string);
168
- if (isDiscriminatorArg(arg, node)) {
240
+ if (
241
+ isOmittedDefaultArgument(arg) ||
242
+ isDiscriminatorArg(arg, node)
243
+ ) {
169
244
  return ""; // Use default
170
245
  }
171
- // Use 'arg_<name>' prefix to reference builder params without shadowing.
172
- const typeStr2 = argManifestMap.get(arg)?.type.content;
173
246
  const skipDefault = arg.defaultValue?.kind === "accountBumpValueNode";
174
- return ` ${fieldName}: ${fieldName}${arg.defaultValue != null && !skipDefault ? ` ?? ${getDefaultValue(arg, typeStr2)}` : ""},`;
247
+ return ` ${fieldName}: ${fieldName}${arg.defaultValue != null && !skipDefault ? ` ?? ${argDefaultValues.get(arg)!.content}` : ""},`;
175
248
  })
176
249
  .filter(Boolean)
177
250
  .join("\n");
178
251
 
179
252
  // Discriminator
180
253
  const discFragment = getDiscriminatorConstantsFragment(node, scope);
254
+ const discriminatorValidation = getDiscriminatorValidationFragment(
255
+ node,
256
+ scope,
257
+ );
258
+ const topLevelDecoder = getTopLevelDecoderFragment({
259
+ typeName: dataClassName,
260
+ description: `${name} instruction decoder`,
261
+ discriminatorValidation,
262
+ fromMapFields,
263
+ requireExactConsumption: true,
264
+ });
181
265
 
182
266
  const parts: Fragment[] = [
183
267
  fragment`// Auto-generated. Do not edit.
@@ -205,9 +289,7 @@ ${use("AccountRole", "solanaInstructions")}`,
205
289
  parts.push(fragment`
206
290
  @immutable
207
291
  class ${fragmentFromString(dataClassName)} {
208
- ${fragmentFromString(dataCtorKeyword)}${fragmentFromString(dataClassName)}({
209
- ${fragmentFromString(dataCtorParams)}
210
- })${fragmentFromString(dataCtorInitializers ? ` :\n${dataCtorInitializers}` : "")};
292
+ ${fragmentFromString(dataCtorSignature)}
211
293
 
212
294
  ${fragmentFromString(dataFieldDecls)}
213
295
  }`);
@@ -232,12 +314,7 @@ Decoder<${fragmentFromString(dataClassName)}> ${fragmentFromString(dataDecoderNa
232
314
  ${fragmentFromString(decFields)}
233
315
  ]);
234
316
 
235
- return transformDecoder(
236
- structDecoder,
237
- (Map<String, Object?> map, Uint8List bytes, int offset) => ${fragmentFromString(dataClassName)}(
238
- ${fragmentFromString(fromMapFields)}
239
- ),
240
- );
317
+ ${topLevelDecoder}
241
318
  }
242
319
 
243
320
  Codec<${fragmentFromString(dataClassName)}, ${fragmentFromString(dataClassName)}> ${fragmentFromString(dataCodecName)}() {
@@ -252,7 +329,7 @@ Instruction ${fragmentFromString(instrFnName)}({
252
329
  ${fragmentFromString(accountParams)}
253
330
  ${fragmentFromString(argParams)}
254
331
  }) {
255
- final instructionData = ${fragmentFromString(dataClassName)}(
332
+ final ${instructionDataLocal} = ${fragmentFromString(dataClassName)}(
256
333
  ${fragmentFromString(dataConstruction)}
257
334
  );
258
335
 
@@ -261,7 +338,7 @@ ${fragmentFromString(dataConstruction)}
261
338
  accounts: [
262
339
  ${fragmentFromString(accountMetas)}
263
340
  ],
264
- data: ${fragmentFromString(dataEncoderName)}().encode(instructionData),
341
+ data: ${fragmentFromString(dataEncoderName)}().encode(${instructionDataLocal}),
265
342
  );
266
343
  }`);
267
344
 
@@ -280,6 +357,9 @@ ${fragmentFromString(dataClassName)} ${fragmentFromString(parseFnName)}(Instruct
280
357
  result.imports.mergeWith(manifest.decoder.imports);
281
358
  result.imports.mergeWith(manifest.type.imports);
282
359
  }
360
+ for (const defaultValue of argDefaultValues.values()) {
361
+ result.imports.mergeWith(defaultValue.imports);
362
+ }
283
363
 
284
364
  return result;
285
365
  }
@@ -305,23 +385,6 @@ function isDiscriminatorArg(
305
385
  );
306
386
  }
307
387
 
308
- function getDiscriminatorDefault(
309
- arg: InstructionArgumentNode,
310
- _node: InstructionNode,
311
- ): string {
312
- if (arg.defaultValue) {
313
- if (arg.defaultValue.kind === "numberValueNode") {
314
- return String(arg.defaultValue.number);
315
- }
316
- if (arg.defaultValue.kind === "bytesValueNode") {
317
- const data = arg.defaultValue.data as string;
318
- const bytes = data.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) ?? [];
319
- return `Uint8List.fromList([${bytes.join(", ")}])`;
320
- }
321
- }
322
- return "0";
323
- }
324
-
325
388
  function isConstDefaultValue(defaultValue: InstructionArgumentNode["defaultValue"]): boolean {
326
389
  if (!defaultValue) return false;
327
390
  switch (defaultValue.kind) {
@@ -335,36 +398,6 @@ function isConstDefaultValue(defaultValue: InstructionArgumentNode["defaultValue
335
398
  }
336
399
  }
337
400
 
338
- function getDefaultValue(
339
- arg: InstructionArgumentNode,
340
- typeStr?: string,
341
- ): string {
342
- if (!arg.defaultValue) return "null";
343
- const isBigInt = typeStr === "BigInt";
344
- const dv = arg.defaultValue;
345
- switch (dv.kind) {
346
- case "numberValueNode":
347
- // Dart's `BigInt` requires a constructor; `0` is not assignable to `BigInt`.
348
- return isBigInt ? `BigInt.from(${dv.number})` : String(dv.number);
349
- case "booleanValueNode":
350
- return String(dv.boolean);
351
- case "stringValueNode":
352
- return `'${dv.string}'`;
353
- case "publicKeyValueNode": {
354
- const wellKnownName = WELL_KNOWN_ADDRESSES.get(dv.publicKey);
355
- if (wellKnownName) {
356
- use(wellKnownName, "solanaAddresses");
357
- return wellKnownName;
358
- }
359
- return `Address('${dv.publicKey}')`;
360
- }
361
- case "noneValueNode":
362
- return "null";
363
- case "bytesValueNode": {
364
- const bytes = dv.data.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) ?? [];
365
- return `Uint8List.fromList([${bytes.join(", ")}])`;
366
- }
367
- default:
368
- return "null";
369
- }
401
+ function isOmittedDefaultArgument(arg: InstructionArgumentNode): boolean {
402
+ return arg.defaultValue != null && arg.defaultValueStrategy === "omitted";
370
403
  }
@@ -1,4 +1,9 @@
1
- import type { DefinedTypeNode, StructFieldTypeNode, TypeNode } from "@codama/nodes";
1
+ import type {
2
+ DefinedTypeNode,
3
+ EnumVariantTypeNode,
4
+ StructFieldTypeNode,
5
+ TypeNode,
6
+ } from "@codama/nodes";
2
7
  import { resolveNestedTypeNode } from "@codama/nodes";
3
8
  import { visit } from "@codama/visitors-core";
4
9
 
@@ -30,13 +35,14 @@ export function getTypePageFragment(
30
35
 
31
36
  // Check what kind of type this is
32
37
  if (typeNode.kind === "enumTypeNode") {
33
- const isScalar = typeNode.variants.every(
38
+ const variants = typeNode.variants ?? [];
39
+ const isScalar = variants.every(
34
40
  (v) => v.kind === "enumEmptyVariantTypeNode",
35
41
  );
36
42
  if (isScalar) {
37
- return getScalarEnumPageFragment(node, scope);
43
+ return getScalarEnumPageFragment(node, variants, scope);
38
44
  }
39
- return getDataEnumPageFragment(node, scope);
45
+ return getDataEnumPageFragment(node, variants, scope);
40
46
  }
41
47
 
42
48
  if (typeNode.kind === "structTypeNode") {
@@ -52,6 +58,7 @@ export function getTypePageFragment(
52
58
  */
53
59
  function getScalarEnumPageFragment(
54
60
  node: DefinedTypeNode,
61
+ enumVariants: EnumVariantTypeNode[],
55
62
  scope: RenderScope,
56
63
  ): Fragment {
57
64
  const name = node.name as string;
@@ -59,7 +66,7 @@ function getScalarEnumPageFragment(
59
66
  const enumNode = node.type;
60
67
  if (enumNode.kind !== "enumTypeNode") return emptyFragment();
61
68
 
62
- const variants = enumNode.variants.map((v) => {
69
+ const variants = enumVariants.map((v) => {
63
70
  const variantName = scope.nameApi.enumVariant(v.name as string);
64
71
  return variantName;
65
72
  });
@@ -73,6 +80,18 @@ function getScalarEnumPageFragment(
73
80
  const resolvedSize = resolveNestedTypeNode(enumNode.size);
74
81
  const sizeType = resolvedSize.format ?? "u8";
75
82
  const sizeCodecName = getNumberCodecName(sizeType);
83
+ const isU64 = sizeType === "u64";
84
+ const encodedVariantIndex = isU64
85
+ ? "BigInt.from(value.index)"
86
+ : "value.index";
87
+ const decodedVariant = isU64
88
+ ? `(BigInt value, Uint8List bytes, int offset) {
89
+ if (value.isNegative || value >= BigInt.from(${typeName}.values.length)) {
90
+ throw RangeError('Invalid ${typeName} discriminator: ' + value.toString());
91
+ }
92
+ return ${typeName}.values[value.toInt()];
93
+ }`
94
+ : `(int value, Uint8List bytes, int offset) => ${typeName}.values[value]`;
76
95
 
77
96
  return fragment`// Auto-generated. Do not edit.
78
97
  // ignore_for_file: type=lint
@@ -94,14 +113,14 @@ ${fragmentFromString(variantLines)}
94
113
  Encoder<${fragmentFromString(typeName)}> ${fragmentFromString(encoderName)}() {
95
114
  return transformEncoder(
96
115
  get${fragmentFromString(sizeCodecName)}Encoder(),
97
- (${fragmentFromString(typeName)} value) => value.index,
116
+ (${fragmentFromString(typeName)} value) => ${fragmentFromString(encodedVariantIndex)},
98
117
  );
99
118
  }
100
119
 
101
120
  Decoder<${fragmentFromString(typeName)}> ${fragmentFromString(decoderName)}() {
102
121
  return transformDecoder(
103
122
  get${fragmentFromString(sizeCodecName)}Decoder(),
104
- (int value, Uint8List bytes, int offset) => ${fragmentFromString(typeName)}.values[value],
123
+ ${fragmentFromString(decodedVariant)},
105
124
  );
106
125
  }
107
126
 
@@ -115,6 +134,7 @@ Codec<${fragmentFromString(typeName)}, ${fragmentFromString(typeName)}> ${fragme
115
134
  */
116
135
  function getDataEnumPageFragment(
117
136
  node: DefinedTypeNode,
137
+ variants: EnumVariantTypeNode[],
118
138
  scope: RenderScope,
119
139
  ): Fragment {
120
140
  const name = node.name as string;
@@ -127,11 +147,12 @@ function getDataEnumPageFragment(
127
147
  const decoderVariants: string[] = [];
128
148
  const encodeCases: string[] = [];
129
149
  const decodeCases: string[] = [];
150
+ let pageHasListEnums = false;
130
151
  // Collect all field type manifests from variants so we can merge their imports
131
152
  const allVariantManifests: { encoder: Fragment; decoder: Fragment; type: Fragment }[] = [];
132
153
 
133
- for (let i = 0; i < enumNode.variants.length; i++) {
134
- const variant = enumNode.variants[i];
154
+ for (let i = 0; i < variants.length; i++) {
155
+ const variant = variants[i];
135
156
  const variantName = pascalCase(variant.name as string);
136
157
  const variantClassName = scope.nameApi.sealedClassVariant(
137
158
  name,
@@ -163,7 +184,7 @@ function getDataEnumPageFragment(
163
184
  decodeCases.push(`case ${i}: return const ${variantClassName}();`);
164
185
  } else if (variant.kind === "enumStructVariantTypeNode") {
165
186
  const resolvedStruct = resolveNestedTypeNode(variant.struct);
166
- const fields = resolvedStruct.fields;
187
+ const fields = resolvedStruct.fields ?? [];
167
188
 
168
189
  // Visit each field type once and collect manifests
169
190
  const fieldManifests = fields.map((f: StructFieldTypeNode) => ({
@@ -192,12 +213,18 @@ function getDataEnumPageFragment(
192
213
  fields.length === 0
193
214
  ? "true"
194
215
  : fields
195
- .map((f: StructFieldTypeNode) => `${camelCase(f.name as string)} == other.${camelCase(f.name as string)}`)
216
+ .map((f: StructFieldTypeNode, index) => {
217
+ const fieldName = camelCase(f.name as string);
218
+ return isListManifest(fieldManifests[index].manifest)
219
+ ? `_listEquals(${fieldName}, other.${fieldName})`
220
+ : `${fieldName} == other.${fieldName}`;
221
+ })
196
222
  .join(" &&\n ");
197
223
 
198
- const hashExpression = getHashExpression(
199
- fields.map((f: StructFieldTypeNode) => camelCase(f.name as string)),
200
- );
224
+ const hashExpression = getValueHashExpression(fields, fieldManifests);
225
+ if (fieldManifests.some(({ manifest }) => isListManifest(manifest))) {
226
+ pageHasListEnums = true;
227
+ }
201
228
 
202
229
  const toStringFields = fields
203
230
  .map((f: StructFieldTypeNode) => `${camelCase(f.name as string)}: $${camelCase(f.name as string)}`)
@@ -271,7 +298,7 @@ ${fieldDecls}
271
298
  decodeCases.push(`case ${i}: return ${variantClassName}(${fromMapFields.replace(/\n/g, ' ').replace(/,$/, '')});`);
272
299
  } else if (variant.kind === "enumTupleVariantTypeNode") {
273
300
  const resolvedTuple = resolveNestedTypeNode(variant.tuple);
274
- const items = resolvedTuple.items;
301
+ const items = resolvedTuple.items ?? [];
275
302
  if (items.length === 1) {
276
303
  const manifest = visit(items[0], scope.typeManifestVisitor);
277
304
  allVariantManifests.push(manifest);
@@ -331,7 +358,7 @@ sealed class ${fragmentFromString(typeName)} {
331
358
  const ${fragmentFromString(typeName)}();
332
359
  }
333
360
 
334
- ${fragmentFromString(variantClasses.join("\n\n"))}
361
+ ${fragmentFromString(variantClasses.join("\n\n"))}${pageHasListEnums ? LIST_VALUE_HELPERS : ""}
335
362
 
336
363
  Encoder<${fragmentFromString(typeName)}> ${fragmentFromString(encoderName)}() {
337
364
  return transformEncoder<Map<String, Object?>, ${fragmentFromString(typeName)}>(
@@ -384,7 +411,8 @@ function getStructPageFragment(
384
411
  const structNode = node.type;
385
412
  if (structNode.kind !== "structTypeNode") return emptyFragment();
386
413
 
387
- const fields = structNode.fields;
414
+ const fields = structNode.fields ?? [];
415
+ let pageHasListStructs = false;
388
416
 
389
417
  // Visit each field type once and collect manifests for reuse
390
418
  const fieldManifests = fields.map((f: StructFieldTypeNode) => ({
@@ -409,19 +437,17 @@ function getStructPageFragment(
409
437
  const eqChecks =
410
438
  fields.length === 0
411
439
  ? "true"
412
- : fields
413
- .map(
414
- (f: StructFieldTypeNode) =>
415
- `${camelCase(f.name as string)} == other.${camelCase(f.name as string)}`,
416
- )
440
+ : fieldManifests
441
+ .map(({ field: f, manifest }, index) => {
442
+ const fieldName = camelCase(f.name as string);
443
+ return isListManifest(manifest)
444
+ ? `_listEquals(${fieldName}, other.${fieldName})`
445
+ : `${fieldName} == other.${fieldName}`;
446
+ })
417
447
  .join(" &&\n ");
418
-
419
- const hashFields =
420
- fields.length === 0
421
- ? "runtimeType"
422
- : fields
423
- .map((f: StructFieldTypeNode) => camelCase(f.name as string))
424
- .join(", ");
448
+ if (fieldManifests.some(({ manifest }) => isListManifest(manifest))) {
449
+ pageHasListStructs = true;
450
+ }
425
451
 
426
452
  const toStringFields = fields
427
453
  .map(
@@ -491,12 +517,14 @@ ${fragmentFromString(fieldDecls)}
491
517
  ${fragmentFromString(eqChecks)};
492
518
 
493
519
  @override
494
- int get hashCode => ${fragmentFromString(getHashExpression(fields.map((f: StructFieldTypeNode) => camelCase(f.name as string))))};
520
+ int get hashCode => ${fragmentFromString(
521
+ getValueHashExpression(fields, fieldManifests),
522
+ )};
495
523
 
496
524
  @override
497
525
  String toString() => '${fragmentFromString(typeName)}(${fragmentFromString(toStringFields)})';
498
526
  }
499
-
527
+ ${pageHasListStructs ? LIST_VALUE_HELPERS : ""}
500
528
  Encoder<${fragmentFromString(typeName)}> ${fragmentFromString(encoderName)}() {
501
529
  final structEncoder = getStructEncoder(<(String, Encoder<Object?>)>[
502
530
  ${fragmentFromString(encFields)}
@@ -575,6 +603,81 @@ Codec<${fragmentFromString(typeName)}, ${fragmentFromString(typeName)}> ${fragme
575
603
  }`;
576
604
  }
577
605
 
606
+ /**
607
+ * Returns true when the rendered field type is a Dart list or byte sequence,
608
+ * whose reference equality must be replaced with a deep comparison in
609
+ * generated value classes.
610
+ */
611
+ function isListManifest(manifest: { type: Fragment }): boolean {
612
+ return manifest.type.content.startsWith("List<") ||
613
+ manifest.type.content === "Uint8List";
614
+ }
615
+
616
+ /**
617
+ * Private Dart helpers emitted into pages whose value classes contain
618
+ * list-typed fields, so `==` and `hashCode` match recursive structural value
619
+ * equality instead of list reference identity. The recursive branch matters
620
+ * for generated types such as `List<Uint8List>`.
621
+ */
622
+ const LIST_VALUE_HELPERS = `
623
+ bool _listEquals<T>(List<T>? a, List<T>? b) {
624
+ if (identical(a, b)) return true;
625
+ if (a == null || b == null) return a == b;
626
+ if (a.length != b.length) return false;
627
+ for (var i = 0; i < a.length; i++) {
628
+ final left = a[i];
629
+ final right = b[i];
630
+ if (left is List<Object?> && right is List<Object?>) {
631
+ if (!_listEquals(left, right)) return false;
632
+ } else if (left != right) {
633
+ return false;
634
+ }
635
+ }
636
+ return true;
637
+ }
638
+
639
+ Object? _deepHash(Object? value) {
640
+ if (value is List<Object?>) {
641
+ return Object.hashAll(value.map(_deepHash));
642
+ }
643
+ return value;
644
+ }
645
+
646
+ int _listHashCode<T>(List<T>? a) {
647
+ if (a == null) return 0;
648
+ return Object.hashAll(a.map(_deepHash));
649
+ }
650
+ `;
651
+
652
+ /**
653
+ * Builds the `hashCode` expression for a class whose fields render as the
654
+ * given lowercase names. List-typed fields compare structurally through the
655
+ * private `_listHashCode` helper emitted alongside the class.
656
+ */
657
+ function getValueHashExpression(
658
+ fields: StructFieldTypeNode[],
659
+ fieldManifests: { field: StructFieldTypeNode; manifest: { type: Fragment } }[],
660
+ ): string {
661
+ const fieldNames = fields.map((f: StructFieldTypeNode) =>
662
+ camelCase(f.name as string),
663
+ );
664
+ const hasListFields = fieldManifests.some(({ manifest }) =>
665
+ isListManifest(manifest),
666
+ );
667
+ if (!hasListFields) {
668
+ return getHashExpression(fieldNames);
669
+ }
670
+ if (fieldNames.length === 1) {
671
+ return `_listHashCode(${fieldNames[0]})`;
672
+ }
673
+ const hashArgs = fields.map((f: StructFieldTypeNode, index) =>
674
+ isListManifest(fieldManifests[index].manifest)
675
+ ? `_listHashCode(${camelCase(f.name as string)})`
676
+ : camelCase(f.name as string),
677
+ );
678
+ return `Object.hash(${hashArgs.join(", ")})`;
679
+ }
680
+
578
681
  function getHashExpression(fields: string[]): string {
579
682
  if (fields.length === 0) return "runtimeType.hashCode";
580
683
  if (fields.length === 1) return `${fields[0]}.hashCode`;
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export { getRenderMapVisitor } from "./visitors/getRenderMapVisitor.js";
3
3
  export { getTypeManifestVisitor } from "./visitors/getTypeManifestVisitor.js";
4
4
  export { DartImportMap, DART_EXTERNAL_PACKAGE_MAP } from "./utils/importMap.js";
5
5
  export { createDartNameApi } from "./utils/nameTransformers.js";
6
+ export { normalizeRootNode } from "./utils/normalizeRootNode.js";
6
7
  export type { DartNameApi } from "./utils/nameTransformers.js";
7
8
  export type { RenderOptions, GetRenderMapOptions, RenderScope } from "./utils/options.js";
8
9
  export type { TypeManifest } from "./utils/typeManifest.js";