@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,52 @@
1
+ import { xmlDocLines } from './naming.js';
2
+
3
+ export interface SdkAggregatorClient {
4
+ className: string;
5
+ propertyName: string;
6
+ }
7
+
8
+ /**
9
+ * Generate the SDK entry point: one property per generated client, all sharing a single
10
+ * [SdkHttp] and therefore a single `HttpClient`.
11
+ *
12
+ * The Python SDK gives each sub-client its own connection pool; that is a bug worth not repeating,
13
+ * since a caller holding one SDK expects one set of connections.
14
+ */
15
+ export function generateSdkCs(namespaceName: string, sdkName: string, clients: readonly SdkAggregatorClient[]): string {
16
+ const lines: string[] = ['// <auto-generated/>', '// Generated by @contractkit/plugin-csharp. Do not edit manually.', '#nullable enable', ''];
17
+ lines.push('using System;');
18
+ if (clients.length > 0) lines.push(`using ${namespaceName}.Clients;`);
19
+ lines.push(`using ${namespaceName}.Runtime;`);
20
+ lines.push('');
21
+ lines.push(`namespace ${namespaceName};`);
22
+ lines.push('');
23
+ lines.push(
24
+ ...xmlDocLines(
25
+ 'Entry point to the generated SDK.\n\n' +
26
+ 'Holds one SdkHttp, shared by every client, so the SDK keeps a single connection pool.\n' +
27
+ 'Disposing it disposes the underlying HttpClient, unless you supplied your own.',
28
+ '',
29
+ ),
30
+ );
31
+ lines.push(`public sealed class ${sdkName} : IDisposable`);
32
+ lines.push('{');
33
+ lines.push(` public ${sdkName}(SdkOptions options)`);
34
+ lines.push(' {');
35
+ lines.push(' Http = new SdkHttp(options);');
36
+ for (const client of clients) lines.push(` ${client.propertyName} = new ${client.className}(Http);`);
37
+ lines.push(' }');
38
+ lines.push('');
39
+ lines.push(' public SdkHttp Http { get; }');
40
+ for (const client of clients) {
41
+ lines.push('');
42
+ lines.push(` public ${client.className} ${client.propertyName} { get; }`);
43
+ }
44
+ lines.push('');
45
+ lines.push(' public void Dispose()');
46
+ lines.push(' {');
47
+ lines.push(' Http.Dispose();');
48
+ lines.push(' }');
49
+ lines.push('}');
50
+ lines.push('');
51
+ return lines.join('\n');
52
+ }
package/src/hoist.ts ADDED
@@ -0,0 +1,402 @@
1
+ import type { ContractRootNode, ContractTypeNode, FieldNode, ModelNode } from '@contractkit/core';
2
+ import { collectTypeRefs, resolveEffectiveFields } from '@contractkit/core';
3
+ import { sanitizeCSharpTypeName, toCSharpTypeName } from './naming.js';
4
+
5
+ /**
6
+ * C# needs a name for every shape a caller can hold. The `.ck` language does not: a union, an enum,
7
+ * or an object literal can appear anonymously inside a field. This pass walks every model in the
8
+ * project and assigns each such node a stable C# declaration, so the type renderer can emit a name
9
+ * and the file emitter can emit the declaration behind it.
10
+ *
11
+ * It runs once over all contract roots rather than per file, because a discriminated union declared
12
+ * in one file makes its member records, which may live in any other file, implement its interface.
13
+ */
14
+
15
+ export type HoistKind = 'enum' | 'record' | 'plainUnion' | 'discriminatedUnion' | 'tuple';
16
+
17
+ export interface HoistedMember {
18
+ /** The C# type of the member: a model name, or a hoisted declaration's name. */
19
+ typeName: string;
20
+ /** Nested record name inside a plain union's abstract record (`OfPayment`). */
21
+ wrapperName?: string;
22
+ /** Discriminator value for a discriminated union member. */
23
+ tag?: string;
24
+ type: ContractTypeNode;
25
+ }
26
+
27
+ export interface HoistedDecl {
28
+ kind: HoistKind;
29
+ name: string;
30
+ /** The `.ck` file whose models file carries this declaration. */
31
+ ownerFile: string;
32
+ /** Whether a distinct `<Name>Input` twin has to be emitted alongside it. */
33
+ needsInput: boolean;
34
+ /** Rendered references become `Name?` — the union had a `null` member. */
35
+ nullable?: boolean;
36
+ members?: HoistedMember[];
37
+ discriminator?: string;
38
+ fields?: FieldNode[];
39
+ values?: string[];
40
+ items?: ContractTypeNode[];
41
+ description?: string;
42
+ }
43
+
44
+ export interface HoistResult {
45
+ /** The declaration standing in for an anonymous node, keyed by AST node identity. */
46
+ byNode: Map<ContractTypeNode, HoistedDecl>;
47
+ byName: Map<string, HoistedDecl>;
48
+ /** Declarations each `.ck` file's models file has to emit, in collection order. */
49
+ byFile: Map<string, HoistedDecl[]>;
50
+ /** Model record name → the union interfaces it must declare it implements. */
51
+ memberships: Map<string, string[]>;
52
+ }
53
+
54
+ export interface HoistOptions {
55
+ modelIndex: ReadonlyMap<string, ModelNode>;
56
+ modelsWithInput: ReadonlySet<string>;
57
+ warn?: (message: string, file: string) => void;
58
+ }
59
+
60
+ /** Analyse every model in the project and name the anonymous types that need a C# declaration. */
61
+ export function collectHoistedTypes(roots: readonly ContractRootNode[], opts: HoistOptions): HoistResult {
62
+ const state: State = {
63
+ ...opts,
64
+ byNode: new Map(),
65
+ byName: new Map(),
66
+ byFile: new Map(),
67
+ memberships: new Map(),
68
+ taken: new Set(roots.flatMap(r => r.models.map(m => m.name))),
69
+ };
70
+
71
+ for (const root of roots) {
72
+ for (const model of root.models) {
73
+ if (model.type) {
74
+ // A model alias occupies a name already, so only a union claims it here: everything
75
+ // else an alias can hold is emitted directly as that model's own declaration.
76
+ walkType(model.type, model.name, root.file, state, true, model.description);
77
+ }
78
+ for (const field of model.fields) {
79
+ walkType(field.type, `${model.name}${toCSharpTypeName(field.name)}`, root.file, state, false, field.description);
80
+ }
81
+ }
82
+ }
83
+
84
+ return { byNode: state.byNode, byName: state.byName, byFile: state.byFile, memberships: state.memberships };
85
+ }
86
+
87
+ interface State extends HoistOptions {
88
+ byNode: Map<ContractTypeNode, HoistedDecl>;
89
+ byName: Map<string, HoistedDecl>;
90
+ byFile: Map<string, HoistedDecl[]>;
91
+ memberships: Map<string, string[]>;
92
+ /** Every name already claimed by a model or an earlier hoist, so a new one cannot collide. */
93
+ taken: Set<string>;
94
+ }
95
+
96
+ /**
97
+ * Walk one type, hoisting the nodes that need a name and recursing into the rest.
98
+ *
99
+ * @param atAliasRoot - True when the node is a model's own `type`, i.e. it already has a name.
100
+ * Only unions are claimed there; other shapes are emitted by the model generator itself.
101
+ */
102
+ function walkType(type: ContractTypeNode, path: string, ownerFile: string, state: State, atAliasRoot: boolean, description?: string): void {
103
+ switch (type.kind) {
104
+ case 'union':
105
+ hoistPlainUnion(type, path, ownerFile, state, atAliasRoot, description);
106
+ return;
107
+ case 'discriminatedUnion':
108
+ hoistDiscriminatedUnion(type, path, ownerFile, state, atAliasRoot, description);
109
+ return;
110
+ case 'enum':
111
+ if (!atAliasRoot) {
112
+ hoist(
113
+ type,
114
+ { kind: 'enum', name: claimFor(path, state, false), ownerFile, needsInput: false, values: type.values, description },
115
+ state,
116
+ );
117
+ }
118
+ return;
119
+ case 'inlineObject':
120
+ if (!atAliasRoot) hoistRecord(type, type.fields, path, ownerFile, state, description);
121
+ else type.fields.forEach(f => walkType(f.type, `${path}${toCSharpTypeName(f.name)}`, ownerFile, state, false, f.description));
122
+ return;
123
+ case 'intersection': {
124
+ if (atAliasRoot) {
125
+ type.members.forEach(m => walkType(m, path, ownerFile, state, true));
126
+ return;
127
+ }
128
+ const { fields } = resolveEffectiveFields(type, state.modelIndex);
129
+ hoistRecord(type, fields, path, ownerFile, state, description);
130
+ return;
131
+ }
132
+ case 'tuple':
133
+ type.items.forEach((item, i) => walkType(item, `${path}Item${i}`, ownerFile, state, false));
134
+ // Every arity is hoisted. `ValueTuple` does not serialize as a JSON array, and a
135
+ // property-level `[JsonConverter]` cannot reach a tuple nested inside a `List<>`. A
136
+ // hoisted record carries a type-level converter, which travels everywhere the type does.
137
+ hoist(
138
+ type,
139
+ {
140
+ kind: 'tuple',
141
+ name: claimFor(path, state, false),
142
+ ownerFile,
143
+ needsInput: type.items.some(t => typeNeedsInput(t, state)),
144
+ items: type.items,
145
+ description,
146
+ },
147
+ state,
148
+ );
149
+ return;
150
+ case 'array':
151
+ walkType(type.item, path, ownerFile, state, false);
152
+ return;
153
+ case 'record':
154
+ walkType(type.value, path, ownerFile, state, false);
155
+ return;
156
+ case 'lazy':
157
+ walkType(type.inner, path, ownerFile, state, atAliasRoot, description);
158
+ return;
159
+ default:
160
+ return;
161
+ }
162
+ }
163
+
164
+ function hoistRecord(node: ContractTypeNode, fields: FieldNode[], path: string, ownerFile: string, state: State, description?: string): void {
165
+ const name = claimFor(path, state, false);
166
+ for (const f of fields) walkType(f.type, `${name}${toCSharpTypeName(f.name)}`, ownerFile, state, false, f.description);
167
+ hoist(
168
+ node,
169
+ {
170
+ kind: 'record',
171
+ name,
172
+ ownerFile,
173
+ needsInput: fields.some(f => f.visibility !== 'normal' || typeNeedsInput(f.type, state)),
174
+ fields,
175
+ description,
176
+ },
177
+ state,
178
+ );
179
+ }
180
+
181
+ /**
182
+ * A plain union becomes an abstract record with one nested member record per member, so a caller can
183
+ * switch over it. Two shapes are recognised first because C# expresses them natively: a union whose
184
+ * only non-null member is `T` is just `T?`, and a union of string literals is an enum.
185
+ */
186
+ function hoistPlainUnion(
187
+ type: ContractTypeNode & { kind: 'union' },
188
+ path: string,
189
+ ownerFile: string,
190
+ state: State,
191
+ atAliasRoot: boolean,
192
+ description?: string,
193
+ ): void {
194
+ const nullable = type.members.some(m => m.kind === 'scalar' && m.name === 'null');
195
+ const members = type.members.filter(m => !(m.kind === 'scalar' && m.name === 'null'));
196
+
197
+ // `union(T, null)` is C#'s own nullable type; an abstract record would only get in the way.
198
+ if (members.length <= 1) {
199
+ if (members[0]) walkType(members[0], path, ownerFile, state, false);
200
+ return;
201
+ }
202
+
203
+ if (members.every(m => m.kind === 'literal' && typeof m.value === 'string')) {
204
+ const values = members.map(m => String((m as ContractTypeNode & { kind: 'literal' }).value));
205
+ hoist(type, { kind: 'enum', name: claimFor(path, state, atAliasRoot), ownerFile, needsInput: false, nullable, values, description }, state);
206
+ return;
207
+ }
208
+
209
+ const name = claimFor(path, state, atAliasRoot);
210
+ const used = new Set<string>();
211
+ const hoisted: HoistedMember[] = [];
212
+ for (const member of members) {
213
+ walkType(member, `${name}${toCSharpTypeName(memberLabel(member, state))}`, ownerFile, state, false);
214
+ const typeName = memberTypeName(member, state);
215
+ hoisted.push({ typeName, wrapperName: uniqueIn(`Of${toCSharpTypeName(memberLabel(member, state))}`, used), type: member });
216
+ }
217
+
218
+ hoist(
219
+ type,
220
+ {
221
+ kind: 'plainUnion',
222
+ name,
223
+ ownerFile,
224
+ needsInput: members.some(m => typeNeedsInput(m, state)),
225
+ nullable,
226
+ members: hoisted,
227
+ description,
228
+ },
229
+ state,
230
+ );
231
+ }
232
+
233
+ /**
234
+ * A discriminated union becomes an interface its member records implement directly, with a converter
235
+ * that dispatches on the tag. An interface rather than an abstract base: a record has single
236
+ * inheritance, and one contract can belong to several unions. Members must be model refs or inline
237
+ * objects, and the discriminator field must be a `literal` — an `enum` discriminator is legal in the
238
+ * source language but leaves no statically known tag, so the union degrades to a raw JSON value.
239
+ */
240
+ function hoistDiscriminatedUnion(
241
+ type: ContractTypeNode & { kind: 'discriminatedUnion' },
242
+ path: string,
243
+ ownerFile: string,
244
+ state: State,
245
+ atAliasRoot: boolean,
246
+ description?: string,
247
+ ): void {
248
+ const name = claimFor(path, state, atAliasRoot);
249
+ const members: HoistedMember[] = [];
250
+
251
+ for (const member of type.members) {
252
+ const { fields } = resolveEffectiveFields(member, state.modelIndex);
253
+ const discriminatorField = fields.find(f => f.name === type.discriminator);
254
+ const tagType = discriminatorField?.type.kind === 'lazy' ? discriminatorField.type.inner : discriminatorField?.type;
255
+ if (!tagType || tagType.kind !== 'literal') {
256
+ state.warn?.(
257
+ `Discriminated union '${name}' has a member whose '${type.discriminator}' is not a literal, so its tag is not known at build time; ` +
258
+ `emitting a raw JSON value instead of an interface.`,
259
+ ownerFile,
260
+ );
261
+ release(name, state, atAliasRoot);
262
+ return;
263
+ }
264
+
265
+ const tag = String(tagType.value);
266
+ if (member.kind === 'ref') {
267
+ members.push({ typeName: member.name, tag, type: member });
268
+ } else {
269
+ // An inline member has no record of its own yet; name it after the tag it carries.
270
+ const memberPath = `${name}${toCSharpTypeName(tag)}`;
271
+ hoistRecord(member, fields, memberPath, ownerFile, state, undefined);
272
+ const decl = state.byNode.get(member);
273
+ if (!decl) {
274
+ release(name, state, atAliasRoot);
275
+ return;
276
+ }
277
+ members.push({ typeName: decl.name, tag, type: member });
278
+ }
279
+ }
280
+
281
+ if (members.length === 0) {
282
+ release(name, state, atAliasRoot);
283
+ return;
284
+ }
285
+
286
+ const decl: HoistedDecl = {
287
+ kind: 'discriminatedUnion',
288
+ name,
289
+ ownerFile,
290
+ needsInput: type.members.some(m => typeNeedsInput(m, state)),
291
+ members,
292
+ discriminator: type.discriminator,
293
+ description,
294
+ };
295
+ hoist(type, decl, state);
296
+
297
+ // The member records declare the interface, wherever in the project they are generated.
298
+ for (const member of members) {
299
+ const list = state.memberships.get(member.typeName) ?? [];
300
+ if (!list.includes(name)) list.push(name);
301
+ state.memberships.set(member.typeName, list);
302
+ }
303
+ }
304
+
305
+ /** A short label for a union member, used to name its member record and any nested hoist. */
306
+ function memberLabel(type: ContractTypeNode, state: State): string {
307
+ switch (type.kind) {
308
+ case 'ref':
309
+ return type.name;
310
+ case 'scalar':
311
+ return type.name;
312
+ case 'array':
313
+ return `${memberLabel(type.item, state)}List`;
314
+ case 'record':
315
+ return `${memberLabel(type.value, state)}Map`;
316
+ case 'literal':
317
+ return typeof type.value === 'string' ? type.value : String(type.value);
318
+ case 'lazy':
319
+ return memberLabel(type.inner, state);
320
+ default: {
321
+ const decl = state.byNode.get(type);
322
+ return decl ? decl.name : 'Member';
323
+ }
324
+ }
325
+ }
326
+
327
+ /** The C# type a union member is wrapped around, once any nested hoisting has happened. */
328
+ function memberTypeName(type: ContractTypeNode, state: State): string {
329
+ const decl = state.byNode.get(type);
330
+ if (decl) return decl.name;
331
+ if (type.kind === 'ref') return type.name;
332
+ return '';
333
+ }
334
+
335
+ /**
336
+ * Whether rendering `type` for a request body differs from rendering it for a response, i.e. it
337
+ * reaches a model that has a distinct `Input` variant. Drives whether a hoisted declaration needs
338
+ * an `Input` twin of its own.
339
+ */
340
+ function typeNeedsInput(type: ContractTypeNode, state: State): boolean {
341
+ const refs = new Set<string>();
342
+ collectTypeRefs(type, refs);
343
+ if ([...refs].some(r => state.modelsWithInput.has(r))) return true;
344
+ return hasVisibilityField(type);
345
+ }
346
+
347
+ function hasVisibilityField(type: ContractTypeNode): boolean {
348
+ switch (type.kind) {
349
+ case 'inlineObject':
350
+ return type.fields.some(f => f.visibility !== 'normal' || hasVisibilityField(f.type));
351
+ case 'array':
352
+ return hasVisibilityField(type.item);
353
+ case 'record':
354
+ return hasVisibilityField(type.value);
355
+ case 'lazy':
356
+ return hasVisibilityField(type.inner);
357
+ case 'tuple':
358
+ return type.items.some(hasVisibilityField);
359
+ case 'union':
360
+ case 'intersection':
361
+ case 'discriminatedUnion':
362
+ return type.members.some(hasVisibilityField);
363
+ default:
364
+ return false;
365
+ }
366
+ }
367
+
368
+ function hoist(node: ContractTypeNode, decl: HoistedDecl, state: State): void {
369
+ state.byNode.set(node, decl);
370
+ state.byName.set(decl.name, decl);
371
+ const list = state.byFile.get(decl.ownerFile) ?? [];
372
+ list.push(decl);
373
+ state.byFile.set(decl.ownerFile, list);
374
+ }
375
+
376
+ /**
377
+ * Reserve a C# declaration name, suffixing until it is free. The path arrives already composed
378
+ * from PascalCase parts, so it is only sanitized — re-casing it would fold `MV` back to `Mv`.
379
+ *
380
+ * A union that *is* a model's declared type keeps that model's name: it already owns it, and the
381
+ * model generator emits nothing else under it.
382
+ */
383
+ function claimFor(path: string, state: State, atAliasRoot: boolean): string {
384
+ if (atAliasRoot) return path;
385
+ return uniqueIn(sanitizeCSharpTypeName(path), state.taken);
386
+ }
387
+
388
+ /** Give a reserved name back, for a hoist that turned out not to be expressible. */
389
+ function release(name: string, state: State, atAliasRoot: boolean): void {
390
+ if (!atAliasRoot) state.taken.delete(name);
391
+ }
392
+
393
+ function uniqueIn(base: string, taken: Set<string>): string {
394
+ if (!taken.has(base)) {
395
+ taken.add(base);
396
+ return base;
397
+ }
398
+ let n = 2;
399
+ while (taken.has(`${base}${n}`)) n++;
400
+ taken.add(`${base}${n}`);
401
+ return `${base}${n}`;
402
+ }