@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/LICENSE +21 -0
- package/README.md +159 -31
- package/dist/cli.js.map +1 -1
- package/dist/csharp.d.ts +65 -0
- package/dist/csharp.d.ts.map +1 -0
- package/dist/csharp.js +147 -0
- package/dist/csharp.js.map +1 -0
- package/dist/emitter.d.ts +8 -13
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +298 -183
- package/dist/emitter.js.map +1 -1
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +2 -2
- package/dist/generate.js.map +1 -1
- package/dist/ir.d.ts +39 -6
- package/dist/ir.d.ts.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +193 -44
- package/dist/parser.js.map +1 -1
- package/dist/variants.d.ts +63 -0
- package/dist/variants.d.ts.map +1 -0
- package/dist/variants.js +63 -0
- package/dist/variants.js.map +1 -0
- package/package.json +17 -7
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 {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
93
|
-
* also be emitted as top-level
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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(
|
|
211
|
-
const
|
|
212
|
-
const propName =
|
|
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 =
|
|
222
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
393
|
+
return numericCsType(node);
|
|
299
394
|
return "bool";
|
|
300
395
|
case "unknown":
|
|
301
396
|
return "JsonElement";
|
|
302
397
|
case "literal":
|
|
303
|
-
return
|
|
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
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
437
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|