@game-infra/valibot-to-csharp 0.0.0 → 0.2.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.
package/dist/emitter.js CHANGED
@@ -1,4 +1,9 @@
1
+ import { EXTENSION_DATA_PROPERTY, literalCsType, numericCsType, propertyNameIn, STRING_OR_STRING_LIST_SOURCE, } from "./csharp.js";
1
2
  import { csStringLiteral, stripSchemaSuffix, toPascalCase } from "./naming.js";
3
+ import { describeMember, inferTagField, isTagField, isVariantLike, sharedFields, } from "./variants.js";
4
+ // The file header lives with the rest of the C# spelling rules; re-exported here so callers
5
+ // keep reaching for it beside `emitModule`, which is the only place it is ever used.
6
+ export { emitFileHeader } from "./csharp.js";
2
7
  /**
3
8
  * Turn a {@link SchemaModule} into a C# source file body. The caller wraps
4
9
  * this with the using-block header (see {@link emitFileHeader}) and writes it
@@ -36,94 +41,113 @@ export function emitModule(module, opts) {
36
41
  }
37
42
  const body = ctx.typeDecls.join("\n\n");
38
43
  const wrapped = `\nnamespace ${opts.namespace};\n\n${body}\n`;
39
- return { source: wrapped, usesStringOrStringList: ctx.usesStringOrStringList };
40
- }
41
- function isVariantLike(node) {
42
- if (node.kind === "variant")
43
- return true;
44
- if (node.kind === "union") {
45
- // Heuristic: unions of all-object-literals with a shared discriminator.
46
- // Re-classifying here would require the context; keep it simple and let
47
- // the emitter decide. This ordering only affects when emitNamed runs for
48
- // the base, not correctness.
49
- return (node.members.length > 0 && node.members.every((m) => m.kind === "object" || m.kind === "ref"));
50
- }
51
- return false;
52
- }
53
- /**
54
- * Render the file-level header: auto-generated banner + using-block. Split out
55
- * so callers that want to embed the emitter output in larger files can skip it.
56
- *
57
- * @example
58
- * ```ts
59
- * import { emitFileHeader } from "@game-infra/valibot-to-csharp";
60
- *
61
- * const header = emitFileHeader([]);
62
- * // "// <auto-generated>..." plus the System.Text.Json using-block
63
- * ```
64
- */
65
- export function emitFileHeader(notes) {
66
- const lines = [
67
- "// <auto-generated>",
68
- "// This file was generated by @game-infra/valibot-to-csharp.",
69
- "// Do not edit by hand; regenerate from the valibot source instead.",
70
- "// </auto-generated>",
71
- ];
72
- for (const note of notes) {
73
- lines.push(`// ${note}`);
74
- }
75
- lines.push("");
76
- lines.push("using System.Collections.Generic;");
77
- lines.push("using System.Text.Json;");
78
- lines.push("using System.Text.Json.Serialization;");
79
- lines.push("");
80
- return lines.join("\n");
44
+ return {
45
+ source: wrapped,
46
+ usesStringOrStringList: ctx.usesStringOrStringList,
47
+ notes: ctx.notes,
48
+ };
81
49
  }
82
50
  class EmitContext {
83
51
  module;
84
52
  opts;
85
53
  typeDecls = [];
54
+ notes = [];
86
55
  usesStringOrStringList = false;
56
+ /** Ref names currently being inlined, so a self-referential alias terminates. */
57
+ resolvingRefs = new Set();
87
58
  /** Names already emitted, to avoid duplicates on cross-references. */
88
59
  emittedTypeNames = new Set();
89
60
  /** Track named schemas already fully emitted, keyed by source const name. */
90
61
  emittedNamed = new Set();
91
62
  /**
92
- * Named schemas that will be emitted as variant members. They must not
93
- * also be emitted as top-level standalone records.
63
+ * Named schemas that will be emitted as variant members, mapped to the C# name of the
64
+ * polymorphic base they derive from. They must not also be emitted as top-level
65
+ * standalone records, and a field referencing one is typed as the base.
94
66
  */
95
- variantMemberNames = new Set();
67
+ variantMemberBases = new Map();
68
+ /** Notes already recorded, so one limitation hit repeatedly is reported once. */
69
+ reportedNotes = new Set();
96
70
  constructor(module, opts) {
97
71
  this.module = module;
98
72
  this.opts = opts;
99
73
  }
100
- /** Pre-pass: mark every named schema referenced as a variant member. */
74
+ /** Record something the emitter could not render faithfully. Deduplicated on the text. */
75
+ note(message) {
76
+ if (this.reportedNotes.has(message))
77
+ return;
78
+ this.reportedNotes.add(message);
79
+ this.notes.push(message);
80
+ }
81
+ /** Pre-pass: map every named schema referenced as a variant member to its base type. */
101
82
  markVariantMembers() {
102
83
  for (const named of this.module.schemas.values()) {
103
- const collected = this.collectVariantMembers(named.schema);
104
- for (const n of collected)
105
- this.variantMemberNames.add(n);
84
+ const members = this.polymorphicMembersOf(named.schema);
85
+ if (!members)
86
+ continue;
87
+ const baseTypeName = this.csTypeNameForNamed(named.name);
88
+ for (const member of members) {
89
+ if (member.kind !== "ref")
90
+ continue;
91
+ const claimed = this.variantMemberBases.get(member.name);
92
+ if (claimed !== undefined && claimed !== baseTypeName) {
93
+ // A C# record derives from one base, so a schema belonging to two variants has no
94
+ // faithful rendering at all. Say so rather than let the second claim win in silence.
95
+ this.note(`"${member.name}" is a member of both ${claimed} and ${baseTypeName}; ` +
96
+ `C# allows one base, so it derives from ${claimed}`);
97
+ continue;
98
+ }
99
+ this.variantMemberBases.set(member.name, baseTypeName);
100
+ }
106
101
  }
107
102
  }
108
- collectVariantMembers(node) {
109
- if (node.kind === "variant") {
110
- return this.refsInMembers(node.members);
111
- }
112
- if (node.kind === "union") {
113
- const shape = this.classifyUnion(node.members);
114
- if (shape.kind === "discriminated") {
115
- return this.refsInMembers(node.members);
116
- }
103
+ /**
104
+ * The members of a named schema that emits as a polymorphic base, or null if it does not
105
+ * emit as one. A `variant(...)` always does; a `union([...])` only when every member
106
+ * resolves to an object sharing one discriminator.
107
+ */
108
+ polymorphicMembersOf(node) {
109
+ if (node.kind === "variant")
110
+ return node.members;
111
+ if (node.kind === "union" && this.classifyUnion(node.members).kind === "discriminated") {
112
+ return node.members;
117
113
  }
118
- return [];
114
+ return null;
119
115
  }
120
- refsInMembers(members) {
121
- const out = [];
122
- for (const m of members) {
123
- if (m.kind === "ref")
124
- out.push(m.name);
116
+ /**
117
+ * A field with the `optional`/`nullable` wrappers hiding behind named aliases folded in.
118
+ *
119
+ * The parser peels a wrapper written on the field itself, but `note: NoteSchema` where
120
+ * `NoteSchema = nullable(string())` puts one behind a reference, and an alias of that alias
121
+ * puts it two references away. A field that lost its `?` on the way is a `null` the C# type
122
+ * swears cannot arrive.
123
+ *
124
+ * Only an alias that declares no C# type of its own is followed — the same rule
125
+ * {@link toCsType} inlines by — so a reference to a record, an enum or a variant is left
126
+ * alone to resolve to its own named type.
127
+ */
128
+ effectiveField(field) {
129
+ let { optional, nullable, schema } = field;
130
+ const followed = new Set();
131
+ for (;;) {
132
+ if (schema.kind === "optional") {
133
+ optional = true;
134
+ schema = schema.inner;
135
+ continue;
136
+ }
137
+ if (schema.kind === "nullable") {
138
+ nullable = true;
139
+ schema = schema.inner;
140
+ continue;
141
+ }
142
+ if (schema.kind !== "ref" || followed.has(schema.name))
143
+ break;
144
+ const target = this.module.schemas.get(schema.name);
145
+ if (!target || this.declaresNamedType(target.schema))
146
+ break;
147
+ followed.add(schema.name);
148
+ schema = target.schema;
125
149
  }
126
- return out;
150
+ return { ...field, optional, nullable, schema };
127
151
  }
128
152
  /** Top-level emit for a named schema. */
129
153
  emitNamed(named) {
@@ -133,7 +157,7 @@ class EmitContext {
133
157
  // If this named schema is going to be emitted as a derived record of a
134
158
  // discriminated union, skip the standalone emit. It'll be produced by
135
159
  // the variant's own emitter pass.
136
- if (this.variantMemberNames.has(named.name) && named.schema.kind === "object") {
160
+ if (this.variantMemberBases.has(named.name) && named.schema.kind === "object") {
137
161
  return;
138
162
  }
139
163
  const typeName = this.csTypeNameForNamed(named.name);
@@ -193,23 +217,49 @@ class EmitContext {
193
217
  if (this.emittedTypeNames.has(name))
194
218
  return;
195
219
  this.emittedTypeNames.add(name);
196
- if (obj.fields.length === 0) {
197
- this.typeDecls.push(`public sealed record ${name}();`);
220
+ const fieldLines = obj.fields.map((f) => this.emitField(f, name));
221
+ const propertyNames = obj.fields.map((f) => propertyNameIn(name, f.name));
222
+ const tail = this.recordTail(obj, propertyNames);
223
+ if (fieldLines.length === 0) {
224
+ this.typeDecls.push(`public sealed record ${name}()${tail}`);
198
225
  return;
199
226
  }
200
- const lines = [];
201
- lines.push(`public sealed record ${name}(`);
202
- const fieldLines = [];
203
- for (const f of obj.fields) {
204
- fieldLines.push(this.emitField(f, name));
205
- }
206
- lines.push(fieldLines.map((l) => ` ${l}`).join(",\n"));
207
- lines.push(");");
208
- this.typeDecls.push(lines.join("\n"));
227
+ this.typeDecls.push([
228
+ `public sealed record ${name}(`,
229
+ fieldLines.map((l) => ` ${l}`).join(",\n"),
230
+ `)${tail}`,
231
+ ].join("\n"));
232
+ }
233
+ /**
234
+ * What closes a record declaration: a bare `;`, or a body holding the extension-data bag
235
+ * a `looseObject` needs.
236
+ *
237
+ * The bag is declared in the body rather than as a positional parameter because
238
+ * `System.Text.Json` refuses an extension-data property bound to a constructor parameter,
239
+ * and every positional record parameter is one.
240
+ */
241
+ recordTail(obj, propertyNames) {
242
+ if (!obj.preservesUnknownKeys)
243
+ return ";";
244
+ const taken = new Set(propertyNames);
245
+ let propertyName = EXTENSION_DATA_PROPERTY;
246
+ for (let i = 2; taken.has(propertyName); i++)
247
+ propertyName = `${EXTENSION_DATA_PROPERTY}${i}`;
248
+ return [
249
+ "",
250
+ "{",
251
+ " /// <summary>",
252
+ " /// JSON members this schema does not declare. A looseObject keeps them, so they are",
253
+ " /// carried here rather than dropped on the way back out.",
254
+ " /// </summary>",
255
+ " [JsonExtensionData]",
256
+ ` public Dictionary<string, JsonElement>? ${propertyName} { get; set; }`,
257
+ "}",
258
+ ].join("\n");
209
259
  }
210
- emitField(f, parentTypeName) {
211
- const optionalOrNullable = f.optional || f.nullable;
212
- const propName = toPascalCase(f.name);
260
+ emitField(rawField, parentTypeName) {
261
+ const f = this.effectiveField(rawField);
262
+ const propName = propertyNameIn(parentTypeName, f.name);
213
263
  // Nested anonymous types get the enclosing type prepended to their hint
214
264
  // (e.g. AddScore.params → AddScoreParams) so sibling objects don't
215
265
  // collide on identically-named inline fields.
@@ -218,8 +268,16 @@ class EmitContext {
218
268
  });
219
269
  for (const d of inlineDecls)
220
270
  this.typeDecls.push(d);
221
- const suffix = optionalOrNullable ? "?" : "";
222
- return `[property: JsonPropertyName(${csStringLiteral(f.name)})] ${csType}${suffix} ${propName}`;
271
+ const suffix = f.optional || f.nullable ? "?" : "";
272
+ const attributes = [`JsonPropertyName(${csStringLiteral(f.name)})`];
273
+ // `optional(X)` means the key may be ABSENT and `nullable(X)` means it may
274
+ // be `null`; both are `X?` in C#, and writing `null` for the first is the
275
+ // one that a strict schema on the other end refuses. Only the optional
276
+ // field is therefore dropped when it has no value.
277
+ if (f.optional && !f.nullable) {
278
+ attributes.push("JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)");
279
+ }
280
+ return `[property: ${attributes.join(", ")}] ${csType}${suffix} ${propName}`;
223
281
  }
224
282
  emitEnum(name, values) {
225
283
  if (this.emittedTypeNames.has(name))
@@ -241,43 +299,80 @@ class EmitContext {
241
299
  if (this.emittedTypeNames.has(name))
242
300
  return;
243
301
  this.emittedTypeNames.add(name);
244
- const members = this.resolveVariantMembers(variant.members);
302
+ const members = this.resolveVariantMembers(variant.members, variant.discriminator);
245
303
  if (members.length === 0) {
246
304
  // No resolvable members: emit a placeholder so cross-refs resolve.
247
305
  this.typeDecls.push([
248
306
  `// TODO: variant ${name} had no resolvable object members.`,
249
- `public abstract record ${name}(string ${toPascalCase(variant.discriminator)});`,
307
+ `public abstract record ${name}(string ${propertyNameIn(name, variant.discriminator)});`,
250
308
  ].join("\n"));
251
309
  return;
252
310
  }
311
+ // Fields every member declares identically belong on the base, so a caller can read the
312
+ // envelope of a union without switching on all twelve of its members first.
313
+ const shared = sharedFields(members, variant.discriminator);
314
+ const tagName = propertyNameIn(name, variant.discriminator);
253
315
  // Base abstract record with polymorphism attributes.
254
316
  const baseLines = [];
255
317
  baseLines.push(`[JsonPolymorphic(TypeDiscriminatorPropertyName = ${csStringLiteral(variant.discriminator)})]`);
256
318
  for (const m of members) {
257
319
  baseLines.push(`[JsonDerivedType(typeof(${m.typeName}), ${csStringLiteral(m.tag)})]`);
258
320
  }
259
- baseLines.push(`public abstract record ${name}([property: JsonPropertyName(${csStringLiteral(variant.discriminator)})] string ${toPascalCase(variant.discriminator)});`);
321
+ // `[JsonIgnore]`, not `[JsonPropertyName]`: `System.Text.Json` writes the discriminator itself
322
+ // from `JsonPolymorphic`, and a property claiming the same JSON name is a hard failure on both
323
+ // serialize and deserialize ("conflicts with an existing metadata property name"). The property
324
+ // stays on the record so C# can read the tag without a cast; only the duplicate write goes.
325
+ const baseParams = [
326
+ ...shared.map((f) => this.emitField(f, name)),
327
+ `[property: JsonIgnore] string ${tagName}`,
328
+ ];
329
+ if (shared.length === 0) {
330
+ baseLines.push(`public abstract record ${name}(${baseParams[0]});`);
331
+ }
332
+ else {
333
+ baseLines.push(`public abstract record ${name}(`);
334
+ baseLines.push(baseParams.map((l) => ` ${l}`).join(",\n"));
335
+ baseLines.push(");");
336
+ }
260
337
  this.typeDecls.push(baseLines.join("\n"));
261
- // Derived records, one per member.
338
+ // Derived records, one per member. A shared field is a plain pass-through parameter: it is
339
+ // already a property on the base, so re-declaring it (attributes and all) would shadow it.
340
+ const sharedNames = new Set(shared.map((f) => f.name));
341
+ const passThrough = shared.map((f) => propertyNameIn(name, f.name));
262
342
  for (const m of members) {
263
343
  if (this.emittedTypeNames.has(m.typeName))
264
344
  continue;
265
345
  this.emittedTypeNames.add(m.typeName);
266
- // Fields minus the discriminator.
267
- const fields = m.schema.fields.filter((f) => f.name !== variant.discriminator);
268
- const lines = [];
269
- if (fields.length === 0) {
270
- lines.push(`public sealed record ${m.typeName}() : ${name}(${csStringLiteral(m.tag)});`);
346
+ const own = m.schema.fields.filter((f) => f.name !== variant.discriminator && !sharedNames.has(f.name));
347
+ const parameters = [
348
+ ...shared.map((f) => `${this.csFieldType(f, name)} ${propertyNameIn(name, f.name)}`),
349
+ ...own.map((f) => this.emitField(f, m.typeName)),
350
+ ];
351
+ const call = `${name}(${[...passThrough, csStringLiteral(m.tag)].join(", ")})`;
352
+ const propertyNames = [...passThrough, ...own.map((f) => propertyNameIn(m.typeName, f.name))];
353
+ const tail = this.recordTail(m.schema, propertyNames);
354
+ if (parameters.length === 0) {
355
+ this.typeDecls.push(`public sealed record ${m.typeName}() : ${call}${tail}`);
271
356
  }
272
357
  else {
273
- lines.push(`public sealed record ${m.typeName}(`);
274
- const fieldLines = fields.map((f) => this.emitField(f, m.typeName));
275
- lines.push(fieldLines.map((l) => ` ${l}`).join(",\n"));
276
- lines.push(`) : ${name}(${csStringLiteral(m.tag)});`);
358
+ this.typeDecls.push([
359
+ `public sealed record ${m.typeName}(`,
360
+ parameters.map((l) => ` ${l}`).join(",\n"),
361
+ `) : ${call}${tail}`,
362
+ ].join("\n"));
277
363
  }
278
- this.typeDecls.push(lines.join("\n"));
279
364
  }
280
365
  }
366
+ /** The C# type of a field, without its attributes; for a base pass-through parameter. */
367
+ csFieldType(rawField, parentTypeName) {
368
+ const f = this.effectiveField(rawField);
369
+ const { csType, inlineDecls } = this.csExpression(f.schema, {
370
+ parentHint: `${parentTypeName}${toPascalCase(f.name)}`,
371
+ });
372
+ for (const d of inlineDecls)
373
+ this.typeDecls.push(d);
374
+ return f.optional || f.nullable ? `${csType}?` : csType;
375
+ }
281
376
  /**
282
377
  * Render a SchemaNode as an inline C# type reference, emitting any
283
378
  * nested-type declarations required to support it. Returns both the textual
@@ -295,32 +390,51 @@ class EmitContext {
295
390
  if (node.type === "string")
296
391
  return "string";
297
392
  if (node.type === "number")
298
- return "double";
393
+ return numericCsType(node);
299
394
  return "bool";
300
395
  case "unknown":
301
396
  return "JsonElement";
302
397
  case "literal":
303
- return "string";
398
+ return literalCsType(node.value);
304
399
  case "ref": {
305
400
  // Try to resolve the referenced named schema so we know what C#
306
401
  // shape it ultimately maps to (important for enums / variants
307
402
  // whose C# name isn't simply the PascalCase of the const).
308
403
  const resolved = this.module.schemas.get(node.name);
309
- if (resolved) {
310
- // Named unions that collapse to string|string[] should be
311
- // substituted with the shared helper type.
312
- if (resolved.schema.kind === "union") {
313
- const shape = this.classifyUnion(resolved.schema.members);
314
- if (shape.kind === "stringOrList") {
315
- this.usesStringOrStringList = true;
316
- return "StringOrStringList";
317
- }
318
- }
404
+ if (!resolved) {
405
+ // Unknown reference. Best guess: treat as a C# type of the same
406
+ // stripped name (the author likely knows what they're doing).
407
+ return stripSchemaSuffix(node.name);
408
+ }
409
+ // A variant member only reaches the wire correctly through its base: the
410
+ // discriminator is written by `JsonPolymorphic` on the base and by nothing else, so
411
+ // a field declared as the derived record serialises without the tag the schema on
412
+ // the other end requires. The base deserialises straight back into the member.
413
+ const baseTypeName = this.variantMemberBases.get(node.name);
414
+ if (baseTypeName !== undefined) {
415
+ this.note(`"${node.name}" is a member of a variant, so fields referencing it are typed ` +
416
+ `as ${baseTypeName} — the discriminator only round-trips through the base`);
417
+ return baseTypeName;
418
+ }
419
+ // A named alias of a shape that declares no C# type of its own (an `array`, a
420
+ // `record`, a bare primitive, an `optional`/`nullable` wrapper, another alias)
421
+ // would otherwise name a type nothing ever writes. Inline the shape it stands for.
422
+ if (this.declaresNamedType(resolved.schema))
423
+ return this.csTypeNameForNamed(node.name);
424
+ if (this.resolvingRefs.has(node.name)) {
425
+ // Only an alias cycle with no declared type anywhere in it gets here
426
+ // (`const A = array(B); const B = array(A)`), which has no C# spelling at all.
427
+ this.note(`"${node.name}" aliases itself through shapes that declare no type of their ` +
428
+ "own; the generated reference to it will not resolve");
319
429
  return this.csTypeNameForNamed(node.name);
320
430
  }
321
- // Unknown reference. Best guess: treat as a C# type of the same
322
- // stripped name (the author likely knows what they're doing).
323
- return stripSchemaSuffix(node.name);
431
+ this.resolvingRefs.add(node.name);
432
+ try {
433
+ return this.toCsType(resolved.schema, ctx, decls);
434
+ }
435
+ finally {
436
+ this.resolvingRefs.delete(node.name);
437
+ }
324
438
  }
325
439
  case "array": {
326
440
  const inner = this.toCsType(node.inner, ctx, decls);
@@ -388,11 +502,41 @@ class EmitContext {
388
502
  return override;
389
503
  return stripSchemaSuffix(sourceName);
390
504
  }
505
+ /**
506
+ * Whether a named schema declares a standalone C# type. Exactly the inverse of the cases
507
+ * {@link emitNamed} declines to emit anything for, and it has to stay that way: a
508
+ * reference to a schema that declares nothing has to be replaced by the shape it stands
509
+ * for, or the generated file names a type nothing writes (CS0246).
510
+ */
511
+ declaresNamedType(schema) {
512
+ switch (schema.kind) {
513
+ case "object":
514
+ case "picklist":
515
+ case "variant":
516
+ return true;
517
+ case "union":
518
+ // A string|string[] union is consumed through the shared helper record, so it
519
+ // declares nothing of its own. Every other shape emits an enum, a polymorphic base,
520
+ // or the JsonElement fallback record.
521
+ return this.classifyUnion(schema.members).kind !== "stringOrList";
522
+ case "primitive":
523
+ case "literal":
524
+ case "array":
525
+ case "record":
526
+ case "unknown":
527
+ case "ref":
528
+ case "nullable":
529
+ case "optional":
530
+ return false;
531
+ }
532
+ }
391
533
  classifyUnion(members) {
392
534
  if (members.length === 0)
393
535
  return { kind: "generic" };
394
- // All literals → enum.
395
- if (members.every((m) => m.kind === "literal")) {
536
+ // All string literals → enum. Number and boolean literals have no enum
537
+ // member name to carry them, so a mixed union falls through to the
538
+ // discriminated/generic checks below rather than emitting a bogus enum.
539
+ if (members.every((m) => m.kind === "literal" && typeof m.value === "string")) {
396
540
  return {
397
541
  kind: "enum",
398
542
  values: members.map((m) => m.value),
@@ -433,19 +577,37 @@ class EmitContext {
433
577
  }
434
578
  return { kind: "generic" };
435
579
  }
436
- /** Resolve the members argument of a `variant(...)` (or synthesised from a union). */
437
- resolveVariantMembers(members) {
580
+ /**
581
+ * Resolve the members argument of a `variant(discriminator, ...)`, or of a union that
582
+ * classified as discriminated on an inferred one.
583
+ *
584
+ * Two members carrying the same tag are an `InvalidOperationException` the moment
585
+ * `System.Text.Json` builds the polymorphic converter, so the duplicate is dropped and
586
+ * reported rather than emitted as C# that cannot be constructed.
587
+ */
588
+ resolveVariantMembers(members, discriminator) {
438
589
  const resolved = [];
590
+ const tagsSeen = new Map();
439
591
  for (const m of members) {
440
- const r = this.resolveObjectMember(m);
441
- if (r) {
442
- resolved.push({
443
- tag: r.tag,
444
- typeName: r.typeName,
445
- schema: r.schema,
446
- sourceName: r.sourceName,
447
- });
592
+ const r = this.resolveObjectMember(m, discriminator);
593
+ if (!r) {
594
+ this.note(`${describeMember(m)} of the variant on "${discriminator}" declares no ` +
595
+ `literal "${discriminator}" field and was dropped`);
596
+ continue;
448
597
  }
598
+ const claimedBy = tagsSeen.get(r.tag);
599
+ if (claimedBy !== undefined) {
600
+ this.note(`${describeMember(m)} repeats the "${discriminator}" tag "${r.tag}" already ` +
601
+ `taken by ${claimedBy}, and was dropped`);
602
+ continue;
603
+ }
604
+ tagsSeen.set(r.tag, r.typeName);
605
+ resolved.push({
606
+ tag: r.tag,
607
+ typeName: r.typeName,
608
+ schema: r.schema,
609
+ sourceName: r.sourceName,
610
+ });
449
611
  }
450
612
  return resolved;
451
613
  }
@@ -453,8 +615,14 @@ class EmitContext {
453
615
  * Normalise a union/variant member into { discriminator tag, C# type name,
454
616
  * object schema }. Follows refs to named object schemas. Returns null if
455
617
  * the member doesn't look like `object({ type: literal('X'), ... })`.
618
+ *
619
+ * `declaredDiscriminator` is the field name a `variant(...)` states outright, and it is
620
+ * taken at its word: guessing a tag from any string literal on the member instead means a
621
+ * shared envelope's own `type: literal('envelope')` becomes the tag for every member of a
622
+ * `variant('op', ...)`, which is a duplicate `[JsonDerivedType]` value and an `op` key
623
+ * that never reaches the wire. Only a bare `union([...])`, which declares nothing, infers.
456
624
  */
457
- resolveObjectMember(node) {
625
+ resolveObjectMember(node, declaredDiscriminator) {
458
626
  let obj = null;
459
627
  let sourceName;
460
628
  if (node.kind === "object") {
@@ -469,26 +637,12 @@ class EmitContext {
469
637
  }
470
638
  if (!obj)
471
639
  return null;
472
- // Find a field whose schema is a literal. Any field will do, but we
473
- // prefer `type`.
474
- let discField = null;
475
- for (const f of obj.fields) {
476
- if (f.schema.kind === "literal" && f.name === "type") {
477
- discField = f;
478
- break;
479
- }
480
- }
481
- if (!discField) {
482
- for (const f of obj.fields) {
483
- if (f.schema.kind === "literal") {
484
- discField = f;
485
- break;
486
- }
487
- }
488
- }
640
+ const discField = declaredDiscriminator === undefined
641
+ ? inferTagField(obj)
642
+ : (obj.fields.find((f) => f.name === declaredDiscriminator && isTagField(f)) ?? null);
489
643
  if (!discField || discField.schema.kind !== "literal")
490
644
  return null;
491
- const tag = discField.schema.value;
645
+ const tag = String(discField.schema.value);
492
646
  const typeName = sourceName
493
647
  ? stripSchemaSuffix(sourceName)
494
648
  : this.freshTypeName(toPascalCase(tag));
@@ -523,51 +677,12 @@ class EmitContext {
523
677
  }
524
678
  return false;
525
679
  }
680
+ /** Append the hand-written `StringOrStringList` helper, once per module that reaches for it. */
526
681
  emitStringOrStringList() {
527
682
  if (this.emittedTypeNames.has("StringOrStringList"))
528
683
  return;
529
684
  this.emittedTypeNames.add("StringOrStringList");
530
- const body = `[JsonConverter(typeof(StringOrStringListConverter))]
531
- public sealed record StringOrStringList(IReadOnlyList<string> Values)
532
- {
533
- public static implicit operator StringOrStringList(string value) => new(new[] { value });
534
- public static implicit operator StringOrStringList(string[] values) => new(values);
535
- }
536
-
537
- public sealed class StringOrStringListConverter : JsonConverter<StringOrStringList>
538
- {
539
- public override StringOrStringList Read(ref Utf8JsonReader reader, System.Type typeToConvert, JsonSerializerOptions options)
540
- {
541
- if (reader.TokenType == JsonTokenType.String)
542
- {
543
- var single = reader.GetString() ?? string.Empty;
544
- return new StringOrStringList(new[] { single });
545
- }
546
- if (reader.TokenType == JsonTokenType.StartArray)
547
- {
548
- var list = new List<string>();
549
- while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
550
- {
551
- list.Add(reader.GetString() ?? string.Empty);
552
- }
553
- return new StringOrStringList(list);
554
- }
555
- throw new JsonException("Expected string or array of strings for StringOrStringList.");
556
- }
557
-
558
- public override void Write(Utf8JsonWriter writer, StringOrStringList value, JsonSerializerOptions options)
559
- {
560
- if (value.Values.Count == 1)
561
- {
562
- writer.WriteStringValue(value.Values[0]);
563
- return;
564
- }
565
- writer.WriteStartArray();
566
- foreach (var v in value.Values) writer.WriteStringValue(v);
567
- writer.WriteEndArray();
568
- }
569
- }`;
570
- this.typeDecls.push(body);
685
+ this.typeDecls.push(STRING_OR_STRING_LIST_SOURCE);
571
686
  }
572
687
  }
573
688
  //# sourceMappingURL=emitter.js.map