@contractkit/plugin-csharp 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.turbo/turbo-build$colon$ci.log +13 -0
  2. package/.turbo/turbo-build.log +12 -0
  3. package/.turbo/turbo-format.log +34 -0
  4. package/.turbo/turbo-test.log +17 -0
  5. package/CHANGELOG.md +1 -0
  6. package/LICENSE +21 -0
  7. package/README.md +173 -0
  8. package/dist/codegen-client.d.ts +35 -0
  9. package/dist/codegen-client.d.ts.map +1 -0
  10. package/dist/codegen-models.d.ts +75 -0
  11. package/dist/codegen-models.d.ts.map +1 -0
  12. package/dist/codegen-sdk.d.ts +13 -0
  13. package/dist/codegen-sdk.d.ts.map +1 -0
  14. package/dist/hoist.d.ts +53 -0
  15. package/dist/hoist.d.ts.map +1 -0
  16. package/dist/index.d.ts +30 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +2569 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/naming.d.ts +89 -0
  21. package/dist/naming.d.ts.map +1 -0
  22. package/dist/runtime-converters.d.ts +15 -0
  23. package/dist/runtime-converters.d.ts.map +1 -0
  24. package/dist/runtime.d.ts +10 -0
  25. package/dist/runtime.d.ts.map +1 -0
  26. package/dist/scaffold.d.ts +26 -0
  27. package/dist/scaffold.d.ts.map +1 -0
  28. package/eslint.config.js +6 -0
  29. package/package.json +48 -0
  30. package/src/codegen-client.ts +680 -0
  31. package/src/codegen-models.ts +909 -0
  32. package/src/codegen-sdk.ts +52 -0
  33. package/src/hoist.ts +402 -0
  34. package/src/index.ts +373 -0
  35. package/src/naming.ts +262 -0
  36. package/src/runtime-converters.ts +147 -0
  37. package/src/runtime.ts +381 -0
  38. package/src/scaffold.ts +41 -0
  39. package/tests/codegen-client.test.ts +275 -0
  40. package/tests/codegen-models.test.ts +410 -0
  41. package/tests/helpers.ts +202 -0
  42. package/tests/hoist.test.ts +92 -0
  43. package/tests/index.test.ts +124 -0
  44. package/tests/naming.test.ts +133 -0
  45. package/tests/runtime.test.ts +104 -0
  46. package/tests/scaffold.test.ts +28 -0
  47. package/tsconfig.json +9 -0
  48. package/vitest.config.ts +14 -0
@@ -0,0 +1,909 @@
1
+ import type { ContractRootNode, ContractTypeNode, FieldNode, ModelNode, ScalarTypeNode } from '@contractkit/core';
2
+ import { buildModelIndex, computeModelsWithInput, resolveEffectiveFields, topoSortModels } from '@contractkit/core';
3
+ import type { HoistedDecl, HoistResult } from './hoist.js';
4
+ import { quoteCSharpString, safeMemberName, toCSharpEnumMemberName, toCSharpPropertyName, xmlDocLines } from './naming.js';
5
+
6
+ // ─── Public entry point ────────────────────────────────────────────────────
7
+
8
+ export interface CSharpModelCodegenOptions {
9
+ /** Root namespace the SDK is generated into. Models land in `<namespace>.Models`. */
10
+ namespace: string;
11
+ /** Model names that have a distinct `Input` variant, including ones declared in other files. */
12
+ modelsWithInput?: ReadonlySet<string>;
13
+ /**
14
+ * Every model in the project, for flattening bases and intersections. Defaults to an index of
15
+ * this root's own models, which is enough for a single-file project and for unit tests.
16
+ */
17
+ modelIndex?: ReadonlyMap<string, ModelNode>;
18
+ /** Names assigned to anonymous types by {@link collectHoistedTypes}, across the whole project. */
19
+ hoisted?: HoistResult;
20
+ warn?: (message: string) => void;
21
+ }
22
+
23
+ /**
24
+ * The `using` block every generated models file carries.
25
+ *
26
+ * There is no import tracker, unlike the Kotlin plugin: every type the models can name is in the
27
+ * base class library, so the set is fixed. An unused `using` is not a compiler warning, and pinning
28
+ * the block keeps the output stable and free of the ordering churn a tracker would produce.
29
+ */
30
+ const MODEL_USINGS = [
31
+ 'using System;',
32
+ 'using System.Collections.Generic;',
33
+ 'using System.Numerics;',
34
+ 'using System.Text.Json;',
35
+ 'using System.Text.Json.Serialization;',
36
+ ] as const;
37
+
38
+ /**
39
+ * Generate the C# models file for one contract root: a `sealed record` per model, plus `<Name>Input`
40
+ * variants, enums, aliases, and the records, interfaces and converters standing in for the unions
41
+ * and anonymous shapes this file owns.
42
+ *
43
+ * Every model in the project shares the single `<namespace>.Models` namespace, so a reference to a
44
+ * model declared in another `.ck` file needs no import and resolves by name alone. That is also what
45
+ * lets an interface declared in one file be implemented by a record generated in another.
46
+ */
47
+ export function generateCSharpModels(root: ContractRootNode, opts: CSharpModelCodegenOptions): string {
48
+ const modelsWithInput = resolveModelsWithInput(root.models, opts.modelsWithInput);
49
+ const modelIndex = opts.modelIndex ?? buildModelIndex(root.models);
50
+
51
+ const ctx: RenderContext = {
52
+ namespace: opts.namespace,
53
+ modelsWithInput,
54
+ modelIndex,
55
+ hoisted: opts.hoisted,
56
+ globalAliases: [],
57
+ warn: opts.warn,
58
+ };
59
+
60
+ const bodies: string[] = [];
61
+ const append = (lines: string[]): void => {
62
+ // A model whose type is a union emits nothing here — the hoisting pass owns the declaration
63
+ // named after it — so the blank separator has to be conditional or it leaves a gap behind.
64
+ if (lines.length === 0) return;
65
+ bodies.push('', ...lines);
66
+ };
67
+ for (const model of topoSortModels(root.models)) append(generateModel(model, ctx));
68
+ for (const decl of opts.hoisted?.byFile.get(root.file) ?? []) append(generateHoisted(decl, ctx));
69
+
70
+ return renderFile(`${opts.namespace}.Models`, ctx.globalAliases, [...MODEL_USINGS], bodies);
71
+ }
72
+
73
+ /**
74
+ * The complete set of model names that need a distinct `Input` variant: the ones passed in, plus
75
+ * the transitive closure over `models`.
76
+ *
77
+ * The hoisting pass and the renderer both have to agree on this — a hoisted shape whose Input twin
78
+ * one of them thinks is unnecessary would leave the other referring to a type nobody emitted.
79
+ */
80
+ export function resolveModelsWithInput(models: readonly ModelNode[], external: ReadonlySet<string> = new Set()): Set<string> {
81
+ const seed = new Set(external);
82
+ return new Set([...seed, ...computeModelsWithInput([...models], seed)]);
83
+ }
84
+
85
+ // ─── Render context ────────────────────────────────────────────────────────
86
+
87
+ interface RenderContext {
88
+ namespace: string;
89
+ modelsWithInput: ReadonlySet<string>;
90
+ modelIndex: ReadonlyMap<string, ModelNode>;
91
+ hoisted?: HoistResult;
92
+ /** `global using` alias lines this file has to emit above its own `using` block. */
93
+ globalAliases: string[];
94
+ /** When set, type names render fully qualified, as a `global using` alias target must be. */
95
+ qualify?: boolean;
96
+ warn?: (message: string) => void;
97
+ }
98
+
99
+ /** Build a rendering context for a file outside the models namespace, such as a client. */
100
+ export function createRenderContext(opts: CSharpModelCodegenOptions & { modelsWithInput: ReadonlySet<string> }): RenderContext {
101
+ return {
102
+ namespace: opts.namespace,
103
+ modelsWithInput: opts.modelsWithInput,
104
+ modelIndex: opts.modelIndex ?? new Map(),
105
+ hoisted: opts.hoisted,
106
+ globalAliases: [],
107
+ warn: opts.warn,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Assemble a generated C# file: header, nullable context, global aliases, usings, namespace, bodies.
113
+ *
114
+ * `// <auto-generated/>` turns the nullable context off, so `#nullable enable` follows it
115
+ * explicitly. A `global using` alias has to precede every ordinary `using` in its file, which is
116
+ * why the aliases are collected during rendering and emitted here rather than inline.
117
+ */
118
+ export function renderFile(namespaceName: string, globalAliases: readonly string[], usings: readonly string[], bodies: string[]): string {
119
+ const lines: string[] = ['// <auto-generated/>', '// Generated by @contractkit/plugin-csharp. Do not edit manually.', '#nullable enable', ''];
120
+ if (globalAliases.length > 0) {
121
+ lines.push(...[...globalAliases].sort());
122
+ lines.push('');
123
+ }
124
+ lines.push(...usings);
125
+ lines.push('');
126
+ lines.push(`namespace ${namespaceName};`);
127
+ lines.push(...bodies);
128
+ lines.push('');
129
+ return lines.join('\n');
130
+ }
131
+
132
+ // ─── Type rendering ────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Render a ContractKit type as its C# type expression. Never returns a nullable type unless the type
136
+ * itself is one — the caller appends `?` from the field's own `optional`/`nullable` flags.
137
+ *
138
+ * @param forInput - When true, a reference to a model or hoisted shape with an Input variant renders
139
+ * as `<Name>Input`.
140
+ * @throws {Error} Via the scalar renderer, if a scalar has no C# mapping.
141
+ */
142
+ export function renderCSharpType(type: ContractTypeNode, ctx: RenderContext, forInput = false): string {
143
+ const decl = ctx.hoisted?.byNode.get(type);
144
+ if (decl) return hoistedTypeName(decl, ctx, forInput);
145
+
146
+ switch (type.kind) {
147
+ case 'scalar':
148
+ return renderScalar(type.name, ctx);
149
+ case 'literal':
150
+ return literalCSharpType(type.value, ctx);
151
+ case 'array':
152
+ return `${qualify('List', 'System.Collections.Generic.List', ctx)}<${renderCSharpType(type.item, ctx, forInput)}>`;
153
+ case 'record': {
154
+ const key = renderCSharpType(type.key, ctx, forInput);
155
+ const value = renderCSharpType(type.value, ctx, forInput);
156
+ const stringType = qualify('string', 'System.String', ctx);
157
+ if (key !== stringType) {
158
+ ctx.warn?.(
159
+ `A record key of type '${key}' is not representable as a JSON object key; emitting Dictionary<string, ${value}>. ` +
160
+ `Parse the key yourself, or declare the key as a string.`,
161
+ );
162
+ }
163
+ return `${qualify('Dictionary', 'System.Collections.Generic.Dictionary', ctx)}<${stringType}, ${value}>`;
164
+ }
165
+ case 'tuple':
166
+ // Unreachable in the real pipeline: the hoisting pass gives every tuple a record of its
167
+ // own, so the `byNode` lookup above has already returned.
168
+ return jsonElement(ctx);
169
+ case 'ref': {
170
+ const name = forInput && ctx.modelsWithInput.has(type.name) ? `${type.name}Input` : type.name;
171
+ return ctx.qualify ? `${ctx.namespace}.Models.${name}` : name;
172
+ }
173
+ case 'lazy':
174
+ return renderCSharpType(type.inner, ctx, forInput);
175
+ case 'union': {
176
+ // A union with at most one non-null member never gets a declaration: it is either C#'s
177
+ // own nullable type or nothing at all.
178
+ const nonNull = type.members.filter(m => !isNullScalar(m));
179
+ const nullable = nonNull.length !== type.members.length;
180
+ if (nonNull.length === 0) return `${qualify('object', 'System.Object', ctx)}?`;
181
+ if (nonNull.length === 1) {
182
+ const inner = renderCSharpType(nonNull[0]!, ctx, forInput);
183
+ return nullable && !inner.endsWith('?') ? `${inner}?` : inner;
184
+ }
185
+ return jsonElement(ctx);
186
+ }
187
+ case 'enum':
188
+ case 'inlineObject':
189
+ case 'intersection':
190
+ case 'discriminatedUnion':
191
+ // Reached only when the shape could not be given a name — a discriminated union whose
192
+ // tag is not statically known, or a caller that skipped the hoisting pass.
193
+ return jsonElement(ctx);
194
+ }
195
+ }
196
+
197
+ function hoistedTypeName(decl: HoistedDecl, ctx: RenderContext, forInput: boolean): string {
198
+ const bare = forInput && decl.needsInput ? `${decl.name}Input` : decl.name;
199
+ const name = ctx.qualify ? `${ctx.namespace}.Models.${bare}` : bare;
200
+ return decl.nullable ? `${name}?` : name;
201
+ }
202
+
203
+ /** Pick the short or the fully-qualified spelling, depending on where the type is being written. */
204
+ function qualify(short: string, full: string, ctx: RenderContext): string {
205
+ return ctx.qualify ? full : short;
206
+ }
207
+
208
+ function jsonElement(ctx: RenderContext): string {
209
+ return qualify('JsonElement', 'System.Text.Json.JsonElement', ctx);
210
+ }
211
+
212
+ function isNullScalar(type: ContractTypeNode): boolean {
213
+ return type.kind === 'scalar' && type.name === 'null';
214
+ }
215
+
216
+ /**
217
+ * Map a ContractKit scalar to its C# type.
218
+ *
219
+ * @throws {Error} When a scalar has no mapping, so a scalar added to core fails the build here
220
+ * rather than emitting C# that does not compile.
221
+ */
222
+ export function renderScalar(name: ScalarTypeNode['name'], ctx: RenderContext): string {
223
+ switch (name) {
224
+ case 'string':
225
+ case 'email':
226
+ case 'url':
227
+ case 'interval':
228
+ return qualify('string', 'System.String', ctx);
229
+ case 'number':
230
+ return qualify('double', 'System.Double', ctx);
231
+ // `int` is a JS safe integer in the source language, which overflows C#'s 32-bit int.
232
+ case 'int':
233
+ return qualify('long', 'System.Int64', ctx);
234
+ case 'bigint':
235
+ return qualify('BigInteger', 'System.Numerics.BigInteger', ctx);
236
+ // Carried as a quoted string by DecimalStringConverter, never as a JSON number.
237
+ case 'decimal':
238
+ return qualify('decimal', 'System.Decimal', ctx);
239
+ case 'boolean':
240
+ return qualify('bool', 'System.Boolean', ctx);
241
+ case 'date':
242
+ return qualify('DateOnly', 'System.DateOnly', ctx);
243
+ case 'time':
244
+ return qualify('TimeOnly', 'System.TimeOnly', ctx);
245
+ case 'datetime':
246
+ return qualify('DateTimeOffset', 'System.DateTimeOffset', ctx);
247
+ // Carried as ISO 8601 by IsoTimeSpanConverter, not the BCL's own `d.hh:mm:ss`.
248
+ case 'duration':
249
+ return qualify('TimeSpan', 'System.TimeSpan', ctx);
250
+ case 'uuid':
251
+ return qualify('Guid', 'System.Guid', ctx);
252
+ case 'binary':
253
+ return qualify('byte[]', 'System.Byte[]', ctx);
254
+ case 'null':
255
+ return `${qualify('object', 'System.Object', ctx)}?`;
256
+ case 'unknown':
257
+ case 'json':
258
+ case 'object':
259
+ return jsonElement(ctx);
260
+ default: {
261
+ const _exhaustive: never = name;
262
+ throw new Error(`plugin-csharp: unmapped scalar '${String(_exhaustive)}' — add a case`);
263
+ }
264
+ }
265
+ }
266
+
267
+ function literalCSharpType(value: string | number | boolean, ctx: RenderContext): string {
268
+ if (typeof value === 'string') return qualify('string', 'System.String', ctx);
269
+ if (typeof value === 'boolean') return qualify('bool', 'System.Boolean', ctx);
270
+ return Number.isInteger(value) ? qualify('long', 'System.Int64', ctx) : qualify('double', 'System.Double', ctx);
271
+ }
272
+
273
+ // ─── Default values ────────────────────────────────────────────────────────
274
+
275
+ /**
276
+ * Render a contract default as a C# expression of the field's own type. Returns `undefined` when the
277
+ * value cannot be expressed, so the field is emitted as `required` rather than with an initializer
278
+ * that will not compile.
279
+ */
280
+ function renderDefault(value: string | number | boolean, type: ContractTypeNode, ctx: RenderContext): string | undefined {
281
+ const inner = type.kind === 'lazy' ? type.inner : type;
282
+
283
+ if (typeof value === 'boolean') return String(value);
284
+
285
+ if (typeof value === 'number') {
286
+ if (inner.kind === 'scalar') {
287
+ switch (inner.name) {
288
+ case 'int':
289
+ return `${value}L`;
290
+ case 'number':
291
+ return `${value}d`;
292
+ case 'decimal':
293
+ return `${value}m`;
294
+ case 'bigint':
295
+ return Number.isSafeInteger(value) ? `new BigInteger(${value})` : `BigInteger.Parse("${value}")`;
296
+ }
297
+ }
298
+ return Number.isInteger(value) ? `${value}L` : `${value}d`;
299
+ }
300
+
301
+ // A string default against an enum names one of its members. When the enum was hoisted into a
302
+ // real C# enum, that is expressible; a bare inline enum has no type to qualify.
303
+ if (inner.kind === 'enum') {
304
+ const decl = ctx.hoisted?.byNode.get(inner);
305
+ if (!decl || !inner.values.includes(value)) return undefined;
306
+ return `${decl.name}.${enumMemberNames(inner.values).get(value)}`;
307
+ }
308
+
309
+ // The same default written against a NAMED enum contract — `rating: Rating = "neutral"`, where
310
+ // `contract Rating: enum(...)` — arrives here as a ref rather than as the enum node.
311
+ if (inner.kind === 'ref') {
312
+ const target = ctx.modelIndex.get(inner.name);
313
+ const targetType = target?.type?.kind === 'lazy' ? target.type.inner : target?.type;
314
+ if (targetType?.kind !== 'enum' || !targetType.values.includes(value)) return undefined;
315
+ return `${inner.name}.${enumMemberNames(targetType.values).get(value)}`;
316
+ }
317
+
318
+ if (inner.kind === 'scalar') {
319
+ switch (inner.name) {
320
+ case 'decimal':
321
+ return /^-?\d+(\.\d+)?$/.test(value) ? `${value}m` : undefined;
322
+ case 'bigint':
323
+ return /^-?\d+$/.test(value) ? `BigInteger.Parse(${quoteCSharpString(value)})` : undefined;
324
+ case 'string':
325
+ case 'email':
326
+ case 'url':
327
+ case 'interval':
328
+ return quoteCSharpString(value);
329
+ default:
330
+ // date/uuid/datetime and friends have no literal syntax; leave the field required.
331
+ return undefined;
332
+ }
333
+ }
334
+ return quoteCSharpString(value);
335
+ }
336
+
337
+ // ─── Wire key casing ───────────────────────────────────────────────────────
338
+
339
+ /** The key casing a contract's `format(input=)` / `format(output=)` names. */
340
+ type WireCase = NonNullable<ModelNode['outputCase']>;
341
+
342
+ /**
343
+ * A field name as it travels, which is not always the name the contract declares it under.
344
+ *
345
+ * The two transforms are spelled exactly as `plugin-typescript` spells them, deliberately: the
346
+ * server parses and emits through that plugin's schemas, so a C# client that disagreed with it about
347
+ * where an underscore goes would be wrong in a way no test in either package could see.
348
+ */
349
+ function applyWireCase(name: string, wireCase: WireCase | undefined): string {
350
+ if (!wireCase || wireCase === 'camel') return name;
351
+ if (wireCase === 'snake') return name.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`);
352
+ return name.charAt(0).toUpperCase() + name.slice(1);
353
+ }
354
+
355
+ /** A case that actually renames something. `camel` is the identity and is treated as absent. */
356
+ function renamingCase(wireCase: WireCase | undefined): WireCase | undefined {
357
+ return wireCase && wireCase !== 'camel' ? wireCase : undefined;
358
+ }
359
+
360
+ /**
361
+ * Which casing one generated record's keys travel in.
362
+ *
363
+ * A response is decoded through `format(output=)` and a request is encoded through `format(input=)`,
364
+ * so a model split into a read record and an `Input` twin takes one each. A model that is NOT split
365
+ * is one record used in both directions, and a `[JsonPropertyName]` cannot spell two different key
366
+ * sets — so a contract asking for two is reported rather than silently resolved in whichever
367
+ * direction happens to be rendered.
368
+ */
369
+ function wireCaseFor(model: ModelNode, forInput: boolean, split: boolean, ctx: RenderContext): WireCase | undefined {
370
+ const input = renamingCase(model.inputCase);
371
+ const output = renamingCase(model.outputCase);
372
+ if (split) return forInput ? input : output;
373
+ if (input && output && input !== output) {
374
+ ctx.warn?.(
375
+ `Contract '${model.name}' sets format(input=${input}) and format(output=${output}), but nothing about it splits into an Input variant, ` +
376
+ `so one C# record carries both directions and can only spell one set of keys. The generated keys follow the output casing; ` +
377
+ `a request built from this record will send the wrong ones.`,
378
+ );
379
+ return output;
380
+ }
381
+ return output ?? input;
382
+ }
383
+
384
+ /**
385
+ * Whether a type puts an anonymous object under a renamed model.
386
+ *
387
+ * Such an object is hoisted into a record of its own, which is rendered without the owning model's
388
+ * casing — the hoisting pass records no owner to take it from. That is a real gap rather than a
389
+ * decision, so it is reported at the one place the owner is still known.
390
+ */
391
+ function containsInlineObject(type: ContractTypeNode | undefined): boolean {
392
+ if (!type) return false;
393
+ switch (type.kind) {
394
+ case 'inlineObject':
395
+ return true;
396
+ case 'lazy':
397
+ return containsInlineObject(type.inner);
398
+ case 'array':
399
+ return containsInlineObject(type.item);
400
+ case 'record':
401
+ return containsInlineObject(type.value);
402
+ case 'tuple':
403
+ return type.items.some(containsInlineObject);
404
+ case 'union':
405
+ case 'discriminatedUnion':
406
+ case 'intersection':
407
+ return (type.members ?? []).some(containsInlineObject);
408
+ default:
409
+ return false;
410
+ }
411
+ }
412
+
413
+ /** Report the gap above, once per model rather than once per field. */
414
+ function warnUncasedNesting(model: ModelNode, fields: readonly FieldNode[], wireCase: WireCase | undefined, ctx: RenderContext): void {
415
+ if (!wireCase) return;
416
+ if (!fields.some(f => containsInlineObject(f.type)) && !containsInlineObject(model.type)) return;
417
+ ctx.warn?.(
418
+ `Contract '${model.name}' is declared format(${model.outputCase ? 'output' : 'input'}=${wireCase}) and holds an anonymous object. ` +
419
+ `The record hoisted out of that object keeps its declared key names, so its keys will not be ${wireCase}-cased. ` +
420
+ `Name the shape as its own contract to fix it.`,
421
+ );
422
+ }
423
+
424
+ // ─── Model generation ──────────────────────────────────────────────────────
425
+
426
+ function generateModel(model: ModelNode, ctx: RenderContext): string[] {
427
+ if (model.type) return generateAliasModel(model, ctx);
428
+
429
+ const effective = effectiveFieldsFor(model, ctx);
430
+ const needsSplit = ctx.modelsWithInput.has(model.name) || effective.some(f => f.visibility !== 'normal');
431
+
432
+ if (!needsSplit) return generateRecordForModel(model.name, effective, ctx, false, model, false);
433
+
434
+ const readFields = effective.filter(f => f.visibility !== 'writeonly');
435
+ const inputFields = effective.filter(f => f.visibility !== 'readonly');
436
+ return [
437
+ ...generateRecordForModel(model.name, readFields, ctx, false, model, true),
438
+ '',
439
+ ...generateRecordForModel(`${model.name}Input`, inputFields, ctx, true, model, true),
440
+ ];
441
+ }
442
+
443
+ /**
444
+ * Bases are flattened rather than expressed as C# inheritance. A record can inherit, but a base's
445
+ * `required` properties would then be re-declared by the override rule the contract language
446
+ * applies, and a sealed leaf is what the serializer wants. `resolveEffectiveFields` applies the same
447
+ * later-wins override rule the inheritance validator enforces.
448
+ */
449
+ function effectiveFieldsFor(model: ModelNode, ctx: RenderContext): FieldNode[] {
450
+ if (!model.bases || model.bases.length === 0) return model.fields;
451
+ const { fields, unresolved } = resolveEffectiveFields(model.name, ctx.modelIndex);
452
+ for (const name of unresolved) {
453
+ ctx.warn?.(`Contract '${model.name}' extends '${name}', which is not defined; its fields are missing from the generated record.`);
454
+ }
455
+ return fields;
456
+ }
457
+
458
+ function generateAliasModel(model: ModelNode, ctx: RenderContext): string[] {
459
+ const type = model.type!;
460
+ const inner = type.kind === 'lazy' ? type.inner : type;
461
+
462
+ // A union alias is emitted by the hoisting pass, which owns the declaration named after it.
463
+ if (ctx.hoisted?.byNode.has(inner)) return [];
464
+
465
+ if (inner.kind === 'enum') return generateEnum(model.name, inner.values, ctx, model.description, model.deprecated);
466
+
467
+ // An intersection or inline object at model level names a real shape, so it becomes a record
468
+ // rather than an alias to an opaque JSON object.
469
+ if (inner.kind === 'intersection' || inner.kind === 'inlineObject') {
470
+ const { fields, unresolved } = resolveEffectiveFields(inner, ctx.modelIndex);
471
+ for (const name of unresolved) {
472
+ ctx.warn?.(`Contract '${model.name}' references '${name}', which is not defined; its fields are missing from the generated record.`);
473
+ }
474
+ const needsSplit = ctx.modelsWithInput.has(model.name) || fields.some(f => f.visibility !== 'normal');
475
+ if (!needsSplit) return generateRecordForModel(model.name, fields, ctx, false, model, false);
476
+ return [
477
+ ...generateRecordForModel(
478
+ model.name,
479
+ fields.filter(f => f.visibility !== 'writeonly'),
480
+ ctx,
481
+ false,
482
+ model,
483
+ true,
484
+ ),
485
+ '',
486
+ ...generateRecordForModel(
487
+ `${model.name}Input`,
488
+ fields.filter(f => f.visibility !== 'readonly'),
489
+ ctx,
490
+ true,
491
+ model,
492
+ true,
493
+ ),
494
+ ];
495
+ }
496
+
497
+ // Everything else is a name for an existing type, which C# spells as a using alias. The target
498
+ // has to be fully qualified: a global alias is resolved without the file's own using block.
499
+ addAlias(model.name, type, ctx, false);
500
+ if (ctx.modelsWithInput.has(model.name)) addAlias(`${model.name}Input`, type, ctx, true);
501
+ return [];
502
+ }
503
+
504
+ /**
505
+ * Record one `global using X = Y;`. A nullable reference type is illegal as an alias target, so the
506
+ * `?` is dropped and the loss reported rather than emitting a file that does not compile.
507
+ */
508
+ function addAlias(name: string, type: ContractTypeNode, ctx: RenderContext, forInput: boolean): void {
509
+ const target = renderCSharpType(type, { ...ctx, qualify: true }, forInput);
510
+ let aliased = target;
511
+ if (aliased.endsWith('?') && !isNullableValueType(type, ctx)) {
512
+ aliased = aliased.slice(0, -1);
513
+ ctx.warn?.(
514
+ `Contract '${name}' aliases a nullable type, which C# cannot express as a using alias; ` +
515
+ `'${name}' is generated as '${aliased}'. Declare the nullability at each use site instead.`,
516
+ );
517
+ }
518
+ ctx.globalAliases.push(`global using ${name} = ${aliased};`);
519
+ }
520
+
521
+ /** Whether `type` renders as a nullable *value* type, which is a legal alias target. */
522
+ function isNullableValueType(type: ContractTypeNode, ctx: RenderContext): boolean {
523
+ const inner = type.kind === 'lazy' ? type.inner : type;
524
+ if (inner.kind !== 'union') return false;
525
+ const nonNull = inner.members.filter(m => !isNullScalar(m));
526
+ if (nonNull.length !== 1) return false;
527
+ return VALUE_TYPES.has(renderCSharpType(nonNull[0]!, { ...ctx, qualify: false }, false));
528
+ }
529
+
530
+ /** The C# spellings that are value types, so `T?` is `Nullable<T>` rather than a nullable reference. */
531
+ const VALUE_TYPES: ReadonlySet<string> = new Set([
532
+ 'bool',
533
+ 'byte',
534
+ 'decimal',
535
+ 'double',
536
+ 'long',
537
+ 'BigInteger',
538
+ 'DateOnly',
539
+ 'TimeOnly',
540
+ 'DateTimeOffset',
541
+ 'TimeSpan',
542
+ 'Guid',
543
+ 'JsonElement',
544
+ ]);
545
+
546
+ function enumMemberNames(values: string[]): Map<string, string> {
547
+ const out = new Map<string, string>();
548
+ const used = new Set<string>();
549
+ for (const value of values) out.set(value, uniqueName(toCSharpEnumMemberName(value), used));
550
+ return out;
551
+ }
552
+
553
+ /**
554
+ * A C# enum whose members carry their wire spelling.
555
+ *
556
+ * `JsonStringEnumConverter<T>` plus `[JsonStringEnumMemberName]` is what makes the wire value travel
557
+ * without a converter of the generator's own. Both are framework features, so nothing reflective is
558
+ * generated for an enum.
559
+ */
560
+ function generateEnum(name: string, values: string[], ctx: RenderContext, description?: string, deprecated?: boolean): string[] {
561
+ const entries = enumMemberNames(values);
562
+ const lines: string[] = [];
563
+ lines.push(...docLines(description, deprecated, ''));
564
+ lines.push(`[JsonConverter(typeof(JsonStringEnumConverter<${name}>))]`);
565
+ lines.push(`public enum ${name}`);
566
+ lines.push('{');
567
+ values.forEach((value, index) => {
568
+ if (index > 0) lines.push('');
569
+ lines.push(` [JsonStringEnumMemberName(${quoteCSharpString(value)})]`);
570
+ lines.push(` ${entries.get(value)},`);
571
+ });
572
+ lines.push('}');
573
+ // An enum has no fields, so no visibility can differ between reading and writing it.
574
+ if (ctx.modelsWithInput.has(name)) ctx.globalAliases.push(`global using ${name}Input = ${ctx.namespace}.Models.${name};`);
575
+ return lines;
576
+ }
577
+
578
+ /** The union interfaces a generated record has to declare it implements. */
579
+ function supertypesFor(readName: string, ctx: RenderContext, forInput: boolean): string[] {
580
+ const unions = ctx.hoisted?.memberships.get(readName) ?? [];
581
+ return unions.map(union => {
582
+ const decl = ctx.hoisted?.byName.get(union);
583
+ return forInput && decl?.needsInput ? `${union}Input` : union;
584
+ });
585
+ }
586
+
587
+ function generateRecordForModel(
588
+ name: string,
589
+ fields: FieldNode[],
590
+ ctx: RenderContext,
591
+ forInput: boolean,
592
+ model: ModelNode,
593
+ split: boolean,
594
+ ): string[] {
595
+ const readName = forInput && name.endsWith('Input') ? name.slice(0, -'Input'.length) : name;
596
+ const wireCase = wireCaseFor(model, forInput, split, ctx);
597
+ // Once per model rather than once per generated record, so a split model does not say it twice.
598
+ if (!forInput) warnUncasedNesting(model, fields, wireCase, ctx);
599
+ return renderRecord(name, fields, ctx, forInput, supertypesFor(readName, ctx, forInput), model.description, model.deprecated, wireCase);
600
+ }
601
+
602
+ function renderRecord(
603
+ name: string,
604
+ fields: FieldNode[],
605
+ ctx: RenderContext,
606
+ forInput: boolean,
607
+ supertypes: string[],
608
+ description?: string,
609
+ deprecated?: boolean,
610
+ wireCase?: WireCase,
611
+ ): string[] {
612
+ const lines: string[] = [];
613
+ lines.push(...docLines(description, deprecated, ''));
614
+ const implementsClause = supertypes.length > 0 ? ` : ${supertypes.join(', ')}` : '';
615
+
616
+ // A contract with no visible fields still has to produce a serializable type.
617
+ if (fields.length === 0) {
618
+ lines.push(`public sealed record ${name}${implementsClause};`);
619
+ return lines;
620
+ }
621
+
622
+ lines.push(`public sealed record ${name}${implementsClause}`);
623
+ lines.push('{');
624
+ fields.forEach((field, index) => {
625
+ if (index > 0) lines.push('');
626
+ lines.push(...renderField(field, ctx, forInput, name, wireCase));
627
+ });
628
+ lines.push('}');
629
+ return lines;
630
+ }
631
+
632
+ /**
633
+ * One property.
634
+ *
635
+ * The `optional` and `nullable` flags are kept apart, which the Kotlin plugin cannot do: its
636
+ * `explicitNulls = false` is one global switch, so a required-nullable null is dropped from the
637
+ * payload along with the absent optionals. Here each property says for itself whether a null is
638
+ * written, so `x: T | null` sends `null` and `x?: T` sends nothing.
639
+ *
640
+ * Every property is `required` or carries an initializer, so the record is fully assigned under
641
+ * `#nullable enable` and the generated SDK compiles with warnings as errors. `required` is never
642
+ * combined with `[JsonIgnore]`, which System.Text.Json rejects at run time.
643
+ */
644
+ function renderField(field: FieldNode, ctx: RenderContext, forInput: boolean, ownerTypeName: string, wireCase?: WireCase): string[] {
645
+ const propName = safeMemberName(toCSharpPropertyName(field.name), ownerTypeName);
646
+ const wireName = applyWireCase(field.name, wireCase);
647
+
648
+ let typeStr = renderCSharpType(field.type, ctx, forInput);
649
+ if ((field.optional || field.nullable) && !typeStr.endsWith('?')) typeStr += '?';
650
+
651
+ let initializer = field.default !== undefined ? renderDefault(field.default, field.type, ctx) : undefined;
652
+ // A `literal()` field carries exactly one value, so it defaults to it rather than being asked
653
+ // for at every call site. The property is ordinary, so the value always reaches the wire.
654
+ if (initializer === undefined && !field.optional && !field.nullable) {
655
+ const inner = field.type.kind === 'lazy' ? field.type.inner : field.type;
656
+ if (inner.kind === 'literal') initializer = renderDefault(inner.value, inner, ctx);
657
+ }
658
+
659
+ const isRequired = !field.optional && initializer === undefined;
660
+
661
+ const lines: string[] = [];
662
+ lines.push(...docLines(field.description, field.deprecated, ' '));
663
+ lines.push(` [JsonPropertyName(${quoteCSharpString(wireName)})]`);
664
+ if (field.optional) lines.push(' [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]');
665
+ const suffix = initializer !== undefined ? ` = ${initializer};` : '';
666
+ lines.push(` public ${isRequired ? 'required ' : ''}${typeStr} ${propName} { get; init; }${suffix}`);
667
+ return lines;
668
+ }
669
+
670
+ // ─── Hoisted declarations ──────────────────────────────────────────────────
671
+
672
+ /** Emit the declaration standing in for one anonymous type, plus its Input twin when it needs one. */
673
+ function generateHoisted(decl: HoistedDecl, ctx: RenderContext): string[] {
674
+ const read = generateHoistedVariant(decl, ctx, false);
675
+ if (!decl.needsInput) return read;
676
+ return [...read, '', ...generateHoistedVariant(decl, ctx, true)];
677
+ }
678
+
679
+ function generateHoistedVariant(decl: HoistedDecl, ctx: RenderContext, forInput: boolean): string[] {
680
+ const name = forInput ? `${decl.name}Input` : decl.name;
681
+ switch (decl.kind) {
682
+ case 'enum':
683
+ return generateEnum(name, decl.values ?? [], ctx, decl.description);
684
+ case 'record':
685
+ return renderRecord(
686
+ name,
687
+ (decl.fields ?? []).filter(f => (forInput ? f.visibility !== 'readonly' : f.visibility !== 'writeonly')),
688
+ ctx,
689
+ forInput,
690
+ supertypesFor(decl.name, ctx, forInput),
691
+ decl.description,
692
+ );
693
+ case 'tuple':
694
+ return generateTupleRecord(decl, name, ctx, forInput);
695
+ case 'plainUnion':
696
+ return generatePlainUnion(decl, name, ctx, forInput);
697
+ case 'discriminatedUnion':
698
+ return generateDiscriminatedUnion(decl, name, ctx, forInput);
699
+ }
700
+ }
701
+
702
+ /** `element.Deserialize<T>(options)!`, the read expression a generated converter uses per member. */
703
+ function deserializeExpr(type: ContractTypeNode, ctx: RenderContext, forInput: boolean): string {
704
+ return `element.Deserialize<${renderCSharpType(type, ctx, forInput)}>(options)!`;
705
+ }
706
+
707
+ /**
708
+ * A contract tuple. It travels as a JSON array, which no BCL type does: `ValueTuple` serializes as
709
+ * an object, and a property-level `[JsonConverter]` cannot reach a tuple nested inside a `List<>`.
710
+ * A record with a type-level converter travels correctly wherever the type appears.
711
+ */
712
+ function generateTupleRecord(decl: HoistedDecl, name: string, ctx: RenderContext, forInput: boolean): string[] {
713
+ const items = decl.items ?? [];
714
+ const converterName = `${name}Converter`;
715
+ const parameters = items.map((item, index) => `${renderCSharpType(item, ctx, forInput)} Item${index}`).join(', ');
716
+
717
+ const lines: string[] = [];
718
+ lines.push(...docLines(decl.description, undefined, ''));
719
+ lines.push(`[JsonConverter(typeof(${converterName}))]`);
720
+ lines.push(`public sealed record ${name}(${parameters});`);
721
+ lines.push('');
722
+ lines.push(`/// <summary>Reads and writes <see cref="${name}"/> as a JSON array.</summary>`);
723
+ lines.push(`public sealed class ${converterName} : JsonConverter<${name}>`);
724
+ lines.push('{');
725
+ lines.push(` public override ${name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)`);
726
+ lines.push(' {');
727
+ lines.push(' using var document = JsonDocument.ParseValue(ref reader);');
728
+ lines.push(' var array = document.RootElement;');
729
+ lines.push(` if (array.ValueKind != JsonValueKind.Array || array.GetArrayLength() != ${items.length})`);
730
+ lines.push(' {');
731
+ lines.push(` throw new JsonException("Expected a JSON array of ${items.length} elements for ${name}.");`);
732
+ lines.push(' }');
733
+ lines.push('');
734
+ lines.push(` return new ${name}(`);
735
+ items.forEach((item, index) => {
736
+ const expr = `array[${index}].Deserialize<${renderCSharpType(item, ctx, forInput)}>(options)!`;
737
+ lines.push(` ${expr}${index === items.length - 1 ? '' : ','}`);
738
+ });
739
+ lines.push(' );');
740
+ lines.push(' }');
741
+ lines.push('');
742
+ lines.push(` public override void Write(Utf8JsonWriter writer, ${name} value, JsonSerializerOptions options)`);
743
+ lines.push(' {');
744
+ lines.push(' writer.WriteStartArray();');
745
+ items.forEach((_, index) => lines.push(` JsonSerializer.Serialize(writer, value.Item${index}, options);`));
746
+ lines.push(' writer.WriteEndArray();');
747
+ lines.push(' }');
748
+ lines.push('}');
749
+ return lines;
750
+ }
751
+
752
+ /**
753
+ * A plain `union(A | B)` becomes an abstract record with one nested member record per member, so
754
+ * callers get a closed set to switch over instead of an untyped JSON value. The private constructor
755
+ * is what closes it: only the nested records can derive from it.
756
+ *
757
+ * Decoding tries each member in declaration order and takes the first that parses, which is exactly
758
+ * what Zod's `z.union` does on the server. Anything else would let the client and the service
759
+ * disagree about a payload both of them accept.
760
+ */
761
+ function generatePlainUnion(decl: HoistedDecl, name: string, ctx: RenderContext, forInput: boolean): string[] {
762
+ const converterName = `${name}Converter`;
763
+ const members = decl.members ?? [];
764
+
765
+ const lines: string[] = [];
766
+ lines.push(...docLines(decl.description, undefined, ''));
767
+ lines.push(`[JsonConverter(typeof(${converterName}))]`);
768
+ lines.push(`public abstract record ${name}`);
769
+ lines.push('{');
770
+ lines.push(` private ${name}() { }`);
771
+ for (const member of members) {
772
+ lines.push('');
773
+ lines.push(` public sealed record ${member.wrapperName}(${renderCSharpType(member.type, ctx, forInput)} Value) : ${name};`);
774
+ }
775
+ lines.push('}');
776
+ lines.push('');
777
+ lines.push(`/// <summary>Reads <see cref="${name}"/> by trying each member in declaration order.</summary>`);
778
+ lines.push(`public sealed class ${converterName} : JsonConverter<${name}>`);
779
+ lines.push('{');
780
+ lines.push(` public override ${name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)`);
781
+ lines.push(' {');
782
+ lines.push(' using var document = JsonDocument.ParseValue(ref reader);');
783
+ lines.push(' var element = document.RootElement;');
784
+ lines.push('');
785
+ for (const member of members) {
786
+ lines.push(' try');
787
+ lines.push(' {');
788
+ lines.push(` return new ${name}.${member.wrapperName}(${deserializeExpr(member.type, ctx, forInput)});`);
789
+ lines.push(' }');
790
+ lines.push(' catch (JsonException)');
791
+ lines.push(' {');
792
+ lines.push(' // Not this member; fall through to the next.');
793
+ lines.push(' }');
794
+ lines.push('');
795
+ }
796
+ lines.push(` throw new JsonException("No ${name} member matched the payload.");`);
797
+ lines.push(' }');
798
+ lines.push('');
799
+ lines.push(` public override void Write(Utf8JsonWriter writer, ${name} value, JsonSerializerOptions options)`);
800
+ lines.push(' {');
801
+ lines.push(' switch (value)');
802
+ lines.push(' {');
803
+ for (const member of members) {
804
+ lines.push(` case ${name}.${member.wrapperName} member:`);
805
+ lines.push(' JsonSerializer.Serialize(writer, member.Value, options);');
806
+ lines.push(' break;');
807
+ }
808
+ lines.push(' default:');
809
+ lines.push(` throw new JsonException($"Unknown ${name} member {value.GetType().Name}.");`);
810
+ lines.push(' }');
811
+ lines.push(' }');
812
+ lines.push('}');
813
+ return lines;
814
+ }
815
+
816
+ /**
817
+ * A `discriminated(by=tag, A | B)` becomes an interface its member records implement, with a
818
+ * converter that dispatches on the tag value.
819
+ *
820
+ * An interface rather than an abstract base record: a record has single inheritance, and the
821
+ * hoisting pass allows one contract to belong to several unions. It is also why the tag stays a real
822
+ * property on each member rather than becoming `[JsonPolymorphic]` metadata, which System.Text.Json
823
+ * refuses to pair with a property of the same name.
824
+ */
825
+ function generateDiscriminatedUnion(decl: HoistedDecl, name: string, ctx: RenderContext, forInput: boolean): string[] {
826
+ const converterName = `${name}Converter`;
827
+ const members = (decl.members ?? []).map(member => ({ ...member, recordName: memberRecordName(member.typeName, ctx, forInput) }));
828
+ const discriminator = decl.discriminator ?? '';
829
+
830
+ const lines: string[] = [];
831
+ lines.push(...docLines(decl.description, undefined, ''));
832
+ lines.push(`[JsonConverter(typeof(${converterName}))]`);
833
+ lines.push(`public interface ${name}`);
834
+ lines.push('{');
835
+ lines.push('}');
836
+ lines.push('');
837
+ lines.push(`/// <summary>Reads <see cref="${name}"/> by dispatching on its '${discriminator}' tag.</summary>`);
838
+ lines.push(`public sealed class ${converterName} : JsonConverter<${name}>`);
839
+ lines.push('{');
840
+ lines.push(` public override ${name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)`);
841
+ lines.push(' {');
842
+ lines.push(' using var document = JsonDocument.ParseValue(ref reader);');
843
+ lines.push(' var element = document.RootElement;');
844
+ lines.push(
845
+ ` var tag = element.TryGetProperty(${quoteCSharpString(discriminator)}, out var tagElement) && tagElement.ValueKind == JsonValueKind.String`,
846
+ );
847
+ lines.push(' ? tagElement.GetString()');
848
+ lines.push(' : null;');
849
+ lines.push('');
850
+ lines.push(' return tag switch');
851
+ lines.push(' {');
852
+ for (const member of members) {
853
+ lines.push(` ${quoteCSharpString(member.tag ?? '')} => element.Deserialize<${member.recordName}>(options)!,`);
854
+ }
855
+ lines.push(` _ => throw new JsonException($"Unknown ${name} ${discriminator}: {tag}"),`);
856
+ lines.push(' };');
857
+ lines.push(' }');
858
+ lines.push('');
859
+ lines.push(` public override void Write(Utf8JsonWriter writer, ${name} value, JsonSerializerOptions options)`);
860
+ lines.push(' {');
861
+ lines.push(' switch (value)');
862
+ lines.push(' {');
863
+ for (const member of members) {
864
+ lines.push(` case ${member.recordName} member:`);
865
+ lines.push(' JsonSerializer.Serialize(writer, member, options);');
866
+ lines.push(' break;');
867
+ }
868
+ lines.push(' default:');
869
+ lines.push(` throw new JsonException($"Unknown ${name} member {value.GetType().Name}.");`);
870
+ lines.push(' }');
871
+ lines.push(' }');
872
+ lines.push('}');
873
+ return lines;
874
+ }
875
+
876
+ /** The concrete record name of a union member, in the read or input variant. */
877
+ function memberRecordName(typeName: string, ctx: RenderContext, forInput: boolean): string {
878
+ if (!forInput) return typeName;
879
+ const decl = ctx.hoisted?.byName.get(typeName);
880
+ if (decl) return decl.needsInput ? `${typeName}Input` : typeName;
881
+ return ctx.modelsWithInput.has(typeName) ? `${typeName}Input` : typeName;
882
+ }
883
+
884
+ // ─── Shared helpers ────────────────────────────────────────────────────────
885
+
886
+ /**
887
+ * XML doc for a declaration. Deprecation is a `<remarks>` line rather than `[Obsolete]`: an obsolete
888
+ * model would raise CS0618 in every generated converter and client that names it, which the
889
+ * compile check treats as an error. Operations, which nothing generated calls, do get `[Obsolete]`.
890
+ */
891
+ function docLines(description: string | undefined, deprecated: boolean | undefined, indent: string): string[] {
892
+ const lines: string[] = [];
893
+ if (description) lines.push(...xmlDocLines(description, indent));
894
+ if (deprecated) lines.push(...xmlDocLines('Deprecated in the contract.', indent, 'remarks'));
895
+ return lines;
896
+ }
897
+
898
+ function uniqueName(name: string, used: Set<string>): string {
899
+ if (!used.has(name)) {
900
+ used.add(name);
901
+ return name;
902
+ }
903
+ let n = 2;
904
+ while (used.has(`${name}${n}`)) n++;
905
+ used.add(`${name}${n}`);
906
+ return `${name}${n}`;
907
+ }
908
+
909
+ export type { RenderContext };