@contractkit/plugin-csharp 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/naming.d.ts CHANGED
@@ -36,6 +36,18 @@ export declare function toCSharpPropertyName(name: string): string;
36
36
  * regularly — a path parameter named `event` or `params` is ordinary in a contract.
37
37
  */
38
38
  export declare function toCSharpParameterName(name: string): string;
39
+ /**
40
+ * A C# parameter or local name for each declared name, keyed by that name.
41
+ *
42
+ * Each goes through {@link toCSharpParameterName}, then gains a `_` until it collides with neither
43
+ * one of `taken` nor another name's result, and is keyword-escaped last. The comparison is on the
44
+ * unescaped spelling, since `@class` and `class` are the same identifier to the compiler. Returns
45
+ * the plain conversion in the common case, so existing output stays byte-identical.
46
+ *
47
+ * @param taken Unescaped identifiers the surrounding generated code already binds or reads, which
48
+ * a declared name must not duplicate or shadow.
49
+ */
50
+ export declare function bindCSharpParameterNames(names: readonly string[], taken: Iterable<string>): Map<string, string>;
39
51
  /**
40
52
  * Make a property name safe inside `ownerTypeName`.
41
53
  *
@@ -1 +1 @@
1
- {"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../src/naming.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,WAAW,CAAC,MAAM,CA8E9C,CAAC;AAQH;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMzD;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ1D;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAGlF;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrD;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM3D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOzD;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,SAAY,GAAG,MAAM,EAAE,CAMnF;AAED,yFAAyF;AACzF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvD"}
1
+ {"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../src/naming.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,WAAW,CAAC,MAAM,CA8E9C,CAAC;AAQH;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMzD;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ1D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAU/G;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAGlF;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrD;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM3D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOzD;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,SAAY,GAAG,MAAM,EAAE,CAMnF;AAED,yFAAyF;AACzF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-csharp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "ContractKit built-in plugin: C#/.NET SDK client generation (System.Text.Json + HttpClient, no NuGet dependencies)",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -12,6 +12,7 @@ import { classifyContentType, observableResponses, resolveModifiers } from '@con
12
12
  import type { HoistResult } from './hoist.js';
13
13
  import { createRenderContext, renderCSharpType, renderFile, type RenderContext } from './codegen-models.js';
14
14
  import {
15
+ bindCSharpParameterNames,
15
16
  deriveCSharpFileBase,
16
17
  quoteCSharpString,
17
18
  safeMemberName,
@@ -193,11 +194,14 @@ function observableOf(shape: ResponseShape): OpResponseNode[] {
193
194
  function generateMethod(route: OpRouteNode, op: OpOperationNode, ctx: RenderContext, methodName: string): string[] {
194
195
  const base = methodBase(methodName);
195
196
  const shape = responseShape(op);
196
- const returnType = returnTypeFor(shape, base, ctx);
197
+ const returnType = returnTypeFor(shape, op, base, ctx);
197
198
  const observable = observableOf(shape);
198
199
  const expectStatuses = observable.filter(r => r.statusCode < 200 || r.statusCode >= 300).map(r => r.statusCode);
199
200
 
200
- const params = buildMethodParams(route, op, ctx);
201
+ const pathBindings = bindPathParams(route);
202
+ const params = buildMethodParams(route, op, ctx, pathBindings);
203
+ // Everything the body can see by name: a response-header pattern variable must not redeclare one.
204
+ const bound = [...METHOD_LOCALS, ...params.map(p => p.name.replace(/^@/, ''))];
201
205
  const signature = [...params.map(p => `${p.type} ${p.name}${p.optional ? ' = null' : ''}`), 'CancellationToken cancellationToken = default'].join(
202
206
  ', ',
203
207
  );
@@ -209,7 +213,7 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, ctx: RenderCont
209
213
  lines.push(`public async ${returnType === 'void' ? 'Task' : `Task<${returnType}>`} ${methodName}(${signature})`);
210
214
  lines.push('{');
211
215
 
212
- const callArgs: string[] = [`HttpMethod.${httpMethodConstant(op.method)}`, buildPathExpression(route.path, route.params)];
216
+ const callArgs: string[] = [`HttpMethod.${httpMethodConstant(op.method)}`, buildPathExpression(route.path, route.params, pathBindings)];
213
217
  if (op.query) callArgs.push('query: http.Params(query)');
214
218
  if (op.headers) callArgs.push('headers: http.Params(customHeaders)');
215
219
  const content = bodyArgument(op);
@@ -222,18 +226,18 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, ctx: RenderCont
222
226
  callArgs.forEach((arg, index) => {
223
227
  lines.push(` ${arg}${index === callArgs.length - 1 ? ').ConfigureAwait(false);' : ','}`);
224
228
  });
225
- lines.push(...returnStatements(shape, base, ctx, where(route, op)));
229
+ lines.push(...returnStatements(shape, op, base, ctx, where(route, op), bound));
226
230
  lines.push('}');
227
231
  return lines;
228
232
  }
229
233
 
230
234
  /** What a method hands back. Declared before the body so the two cannot drift apart. */
231
- function returnTypeFor(shape: ResponseShape, base: string, ctx: RenderContext): string {
235
+ function returnTypeFor(shape: ResponseShape, op: OpOperationNode, base: string, ctx: RenderContext): string {
232
236
  if (shape.kind !== 'simple') return `${base}Response`;
233
237
  const response = shape.response;
234
238
  const body = response?.bodies[0];
235
239
  const headers = response?.headers ?? [];
236
- if (!body) return headers.length > 0 ? `${base}Headers` : 'void';
240
+ if (!body) return headers.length > 0 ? headersRecordName(op, base) : 'void';
237
241
  const dataType = bodyCSharpType(body, ctx);
238
242
  // A declared response header changes the return shape: the body alone cannot carry it.
239
243
  return headers.length > 0 ? `${base}Result` : dataType;
@@ -264,19 +268,26 @@ function bodyReadExpr(body: OpResponseBodyNode, ctx: RenderContext): string {
264
268
  }
265
269
 
266
270
  /** The statements after `ExecuteAsync`, which turn the response into the declared return type. */
267
- function returnStatements(shape: ResponseShape, base: string, ctx: RenderContext, place: string): string[] {
271
+ function returnStatements(
272
+ shape: ResponseShape,
273
+ op: OpOperationNode,
274
+ base: string,
275
+ ctx: RenderContext,
276
+ place: string,
277
+ bound: readonly string[],
278
+ ): string[] {
268
279
  if (shape.kind === 'simple') {
269
280
  const response = shape.response;
270
281
  const body = response?.bodies[0];
271
282
  const headers = response?.headers ?? [];
272
283
  if (headers.length === 0) return body ? [` return ${bodyReadExpr(body, ctx)};`] : [];
273
- const lines = readHeaderLines(headers, `${base}Headers`, ctx, place, ' ');
284
+ const lines = readHeaderLines(headers, headersRecordName(op, base), ctx, place, ' ', bound);
274
285
  return body ? [...lines, ` return new ${base}Result(${bodyReadExpr(body, ctx)}, headers);`] : [...lines, ' return headers;'];
275
286
  }
276
287
 
277
288
  if (shape.kind === 'multiMime') {
278
289
  const headers = shape.response.headers ?? [];
279
- const lines = headers.length > 0 ? readHeaderLines(headers, `${base}Headers`, ctx, place, ' ') : [];
290
+ const lines = headers.length > 0 ? readHeaderLines(headers, headersRecordName(op, base), ctx, place, ' ', bound) : [];
280
291
  lines.push(...mimeSwitch(shape.response, base, undefined, ctx, ' ', headers.length > 0));
281
292
  return lines;
282
293
  }
@@ -288,13 +299,13 @@ function returnStatements(shape: ResponseShape, base: string, ctx: RenderContext
288
299
  for (const response of rest) {
289
300
  lines.push(` case ${response.statusCode}:`);
290
301
  lines.push(' {');
291
- lines.push(...statusBranch(response, base, response.statusCode, ctx, place, ' '));
302
+ lines.push(...statusBranch(response, op, base, response.statusCode, ctx, place, ' ', bound));
292
303
  lines.push(' }');
293
304
  lines.push('');
294
305
  }
295
306
  lines.push(' default:');
296
307
  lines.push(' {');
297
- lines.push(...statusBranch(fallback!, base, fallback!.statusCode, ctx, place, ' '));
308
+ lines.push(...statusBranch(fallback!, op, base, fallback!.statusCode, ctx, place, ' ', bound));
298
309
  lines.push(' }');
299
310
  lines.push(' }');
300
311
  return lines;
@@ -306,10 +317,19 @@ function returnStatements(shape: ResponseShape, base: string, ctx: RenderContext
306
317
  * Every branch is braced. Two branches each declaring `headers` would otherwise collide, since a
307
318
  * declaration in a switch section is scoped to the whole switch block rather than to its own case.
308
319
  */
309
- function statusBranch(response: OpResponseNode, base: string, statusCode: number, ctx: RenderContext, place: string, indent: string): string[] {
320
+ function statusBranch(
321
+ response: OpResponseNode,
322
+ op: OpOperationNode,
323
+ base: string,
324
+ statusCode: number,
325
+ ctx: RenderContext,
326
+ place: string,
327
+ indent: string,
328
+ bound: readonly string[],
329
+ ): string[] {
310
330
  const lines: string[] = [];
311
331
  const headers = response.headers ?? [];
312
- if (headers.length > 0) lines.push(...readHeaderLines(headers, headersRecordName(base, statusCode), ctx, place, indent));
332
+ if (headers.length > 0) lines.push(...readHeaderLines(headers, headersRecordName(op, base, statusCode), ctx, place, indent, bound));
313
333
  lines.push(...mimeSwitch(response, base, statusCode, ctx, indent, headers.length > 0));
314
334
  return lines;
315
335
  }
@@ -371,8 +391,22 @@ function bodyArgument(op: OpOperationNode): string | undefined {
371
391
 
372
392
  // ─── Response declarations ─────────────────────────────────────────────────
373
393
 
374
- function headersRecordName(base: string, statusCode?: number): string {
375
- return statusCode === undefined ? `${base}Headers` : `${base}${statusCode}Headers`;
394
+ /**
395
+ * The name of a response-headers record: `<Method><Status>Headers` when the status is part of the
396
+ * value, otherwise `<Method>Headers`.
397
+ *
398
+ * The request-headers record claims `<Method>Headers` first, since it is the one a caller builds by
399
+ * name, so an operation that declares both gets `<Method>ResponseHeaders` for its response side.
400
+ * Two records of one name in one namespace is CS0101.
401
+ */
402
+ function headersRecordName(op: OpOperationNode, base: string, statusCode?: number): string {
403
+ if (statusCode !== undefined) return `${base}${statusCode}Headers`;
404
+ return declaresRequestHeadersRecord(op) ? `${base}ResponseHeaders` : `${base}Headers`;
405
+ }
406
+
407
+ /** Whether the operation's `headers:` block gets a generated `<Method>Headers` record. */
408
+ function declaresRequestHeadersRecord(op: OpOperationNode): boolean {
409
+ return op.headers?.kind === 'params' && op.headers.nodes.length > 0;
376
410
  }
377
411
 
378
412
  /**
@@ -415,12 +449,12 @@ function responseDeclarations(route: OpRouteNode, op: OpOperationNode, ctx: Rend
415
449
  const response = shape.response;
416
450
  const headers = response?.headers ?? [];
417
451
  if (headers.length === 0) return lines;
418
- headerRecord(headers, headersRecordName(base));
452
+ headerRecord(headers, headersRecordName(op, base));
419
453
  const body = response?.bodies[0];
420
454
  if (body) {
421
455
  lines.push('');
422
456
  lines.push(...xmlDocLines(`The body of ${place}, with the response headers the contract declares.`, ''));
423
- lines.push(`public sealed record ${base}Result(${bodyCSharpType(body, ctx)} Data, ${headersRecordName(base)} Headers);`);
457
+ lines.push(`public sealed record ${base}Result(${bodyCSharpType(body, ctx)} Data, ${headersRecordName(op, base)} Headers);`);
424
458
  }
425
459
  return lines;
426
460
  }
@@ -429,7 +463,7 @@ function responseDeclarations(route: OpRouteNode, op: OpOperationNode, ctx: Rend
429
463
  const withStatus = shape.kind === 'multiStatus';
430
464
  for (const response of responses) {
431
465
  const headers = response.headers ?? [];
432
- if (headers.length > 0) headerRecord(headers, headersRecordName(base, withStatus ? response.statusCode : undefined));
466
+ if (headers.length > 0) headerRecord(headers, headersRecordName(op, base, withStatus ? response.statusCode : undefined));
433
467
  }
434
468
 
435
469
  lines.push('');
@@ -453,7 +487,7 @@ function responseDeclarations(route: OpRouteNode, op: OpOperationNode, ctx: Rend
453
487
  const name = leafRecordName(response, body, statusCode);
454
488
  const parameters: string[] = [];
455
489
  if (body) parameters.push(`${bodyCSharpType(body, ctx)} Data`);
456
- if (headers.length > 0) parameters.push(`${headersRecordName(base, statusCode)} Headers`);
490
+ if (headers.length > 0) parameters.push(`${headersRecordName(op, base, statusCode)} Headers`);
457
491
  lines.push('');
458
492
  lines.push(` public sealed record ${name}(${parameters.join(', ')}) : ${base}Response;`);
459
493
  }
@@ -515,14 +549,27 @@ function describeHeaderType(type: { kind: string; name?: string }): string {
515
549
  }
516
550
 
517
551
  /** The lines that build one response-headers value out of the response. */
518
- function readHeaderLines(headers: OpResponseHeaderNode[], typeName: string, ctx: RenderContext, place: string, indent: string): string[] {
552
+ function readHeaderLines(
553
+ headers: OpResponseHeaderNode[],
554
+ typeName: string,
555
+ ctx: RenderContext,
556
+ place: string,
557
+ indent: string,
558
+ bound: readonly string[],
559
+ ): string[] {
560
+ // Pattern variables share the statement's scope, and the method's: each needs a name nothing
561
+ // else in either binds.
562
+ const locals = bindCSharpParameterNames(
563
+ headers.filter(h => h.optional).map(h => h.name),
564
+ bound,
565
+ );
519
566
  const args = headers.map(header => {
520
567
  const reader = headerReader(header, place);
521
568
  const name = quoteCSharpString(header.name);
522
569
  // A required header the service omitted is a broken contract, not a null the caller has to
523
570
  // handle; an optional one simply stays absent.
524
571
  if (!header.optional) return reader.read(`http.RequireHeader(response, ${name})`);
525
- const local = toCSharpParameterName(header.name);
572
+ const local = locals.get(header.name)!;
526
573
  return `response.Header(${name}) is { } ${local} ? ${reader.read(local)} : null`;
527
574
  });
528
575
  const lines: string[] = [`${indent}var headers = new ${typeName}(`];
@@ -564,7 +611,7 @@ const PATH_PLACEHOLDER = /\{([a-zA-Z_$][a-zA-Z0-9_$.-]*)\}/g;
564
611
  * values that came from the caller are percent-encoded. `params` says where a value lives: spread
565
612
  * across the signature, or behind one `pathParams` argument when the route declares a model.
566
613
  */
567
- export function buildPathExpression(path: string, params?: ParamSource): string {
614
+ export function buildPathExpression(path: string, params?: ParamSource, bindings?: ReadonlyMap<string, string>): string {
568
615
  const args = path
569
616
  .split('/')
570
617
  .filter(Boolean)
@@ -572,7 +619,10 @@ export function buildPathExpression(path: string, params?: ParamSource): string
572
619
  PATH_PLACEHOLDER.lastIndex = 0;
573
620
  const match = PATH_PLACEHOLDER.exec(raw);
574
621
  if (!match || match[0] !== raw) return quoteCSharpString(raw);
575
- const value = params && params.kind !== 'params' ? `pathParams.${toCSharpPropertyName(match[1]!)}` : toCSharpParameterName(match[1]!);
622
+ const value =
623
+ params && params.kind !== 'params'
624
+ ? `pathParams.${toCSharpPropertyName(match[1]!)}`
625
+ : (bindings?.get(match[1]!) ?? toCSharpParameterName(match[1]!));
576
626
  return `http.Segment(${value})`;
577
627
  });
578
628
  return `http.Path(${args.join(', ')})`;
@@ -580,6 +630,27 @@ export function buildPathExpression(path: string, params?: ParamSource): string
580
630
 
581
631
  // ─── Parameters ────────────────────────────────────────────────────────────
582
632
 
633
+ /**
634
+ * Identifiers a generated method binds or reads besides its path parameters: the other arguments
635
+ * {@link buildMethodParams} can declare, the trailing `CancellationToken`, the locals the body
636
+ * declares, and the client's own `http` constructor parameter. A path parameter under one of these
637
+ * names would duplicate an argument (CS0100), clash with a local (CS0136), or hide `http`.
638
+ */
639
+ const METHOD_LOCALS = ['body', 'query', 'customHeaders', 'pathParams', 'cancellationToken', 'response', 'headers', 'http'] as const;
640
+
641
+ /**
642
+ * The C# parameter name each inline path parameter is spread into the signature under, keyed by its
643
+ * declared name. Keyword-escaped (`@class`), and suffixed when it lands on one of
644
+ * {@link METHOD_LOCALS} (`body_`). Empty when the route has no inline params.
645
+ */
646
+ function bindPathParams(route: OpRouteNode): Map<string, string> {
647
+ if (route.params?.kind !== 'params') return new Map();
648
+ return bindCSharpParameterNames(
649
+ route.params.nodes.map(n => n.name),
650
+ METHOD_LOCALS,
651
+ );
652
+ }
653
+
583
654
  interface MethodParam {
584
655
  name: string;
585
656
  type: string;
@@ -594,13 +665,13 @@ interface MethodParam {
594
665
  * order cannot always survive. The relative order within each group is kept, and a trailing
595
666
  * `CancellationToken` is appended by the caller.
596
667
  */
597
- function buildMethodParams(route: OpRouteNode, op: OpOperationNode, ctx: RenderContext): MethodParam[] {
668
+ function buildMethodParams(route: OpRouteNode, op: OpOperationNode, ctx: RenderContext, pathBindings: ReadonlyMap<string, string>): MethodParam[] {
598
669
  const params: MethodParam[] = [];
599
670
 
600
671
  if (route.params) {
601
672
  if (route.params.kind === 'params') {
602
673
  for (const node of route.params.nodes) {
603
- params.push({ name: toCSharpParameterName(node.name), type: renderCSharpType(node.type, ctx, true), optional: false });
674
+ params.push({ name: pathBindings.get(node.name)!, type: renderCSharpType(node.type, ctx, true), optional: false });
604
675
  }
605
676
  } else {
606
677
  // Not `params`, which is a C# keyword: the argument would have to be written `@params`.
@@ -276,8 +276,21 @@ function literalCSharpType(value: string | number | boolean, ctx: RenderContext)
276
276
  * Render a contract default as a C# expression of the field's own type. Returns `undefined` when the
277
277
  * value cannot be expressed, so the field is emitted as `required` rather than with an initializer
278
278
  * that will not compile.
279
+ *
280
+ * `memberNames` are the property names of the record the initializer sits in. Inside that record a
281
+ * simple name resolves to a member before a type, so `public Rating? Rating { get; init; } =
282
+ * Rating.Neutral;` reads the instance property and fails with CS0236. C#'s rule that lets a member
283
+ * share its type's name applies only when the member's type is exactly that type, and `Rating?` is
284
+ * `Nullable<Rating>`. An enum a member would shadow is written from the global namespace instead.
279
285
  */
280
- function renderDefault(value: string | number | boolean, type: ContractTypeNode, ctx: RenderContext): string | undefined {
286
+ function renderDefault(
287
+ value: string | number | boolean,
288
+ type: ContractTypeNode,
289
+ ctx: RenderContext,
290
+ memberNames: ReadonlySet<string> = new Set(),
291
+ ): string | undefined {
292
+ const enumType = (name: string): string => (memberNames.has(name) ? `global::${ctx.namespace}.Models.${name}` : name);
293
+
281
294
  const inner = type.kind === 'lazy' ? type.inner : type;
282
295
 
283
296
  if (typeof value === 'boolean') return String(value);
@@ -303,7 +316,7 @@ function renderDefault(value: string | number | boolean, type: ContractTypeNode,
303
316
  if (inner.kind === 'enum') {
304
317
  const decl = ctx.hoisted?.byNode.get(inner);
305
318
  if (!decl || !inner.values.includes(value)) return undefined;
306
- return `${decl.name}.${enumMemberNames(inner.values).get(value)}`;
319
+ return `${enumType(decl.name)}.${enumMemberNames(inner.values).get(value)}`;
307
320
  }
308
321
 
309
322
  // The same default written against a NAMED enum contract — `rating: Rating = "neutral"`, where
@@ -312,7 +325,7 @@ function renderDefault(value: string | number | boolean, type: ContractTypeNode,
312
325
  const target = ctx.modelIndex.get(inner.name);
313
326
  const targetType = target?.type?.kind === 'lazy' ? target.type.inner : target?.type;
314
327
  if (targetType?.kind !== 'enum' || !targetType.values.includes(value)) return undefined;
315
- return `${inner.name}.${enumMemberNames(targetType.values).get(value)}`;
328
+ return `${enumType(inner.name)}.${enumMemberNames(targetType.values).get(value)}`;
316
329
  }
317
330
 
318
331
  if (inner.kind === 'scalar') {
@@ -619,11 +632,12 @@ function renderRecord(
619
632
  return lines;
620
633
  }
621
634
 
635
+ const memberNames = new Set(fields.map(field => memberName(field, name)));
622
636
  lines.push(`public sealed record ${name}${implementsClause}`);
623
637
  lines.push('{');
624
638
  fields.forEach((field, index) => {
625
639
  if (index > 0) lines.push('');
626
- lines.push(...renderField(field, ctx, forInput, name, wireCase));
640
+ lines.push(...renderField(field, ctx, forInput, name, memberNames, wireCase));
627
641
  });
628
642
  lines.push('}');
629
643
  return lines;
@@ -641,19 +655,31 @@ function renderRecord(
641
655
  * `#nullable enable` and the generated SDK compiles with warnings as errors. `required` is never
642
656
  * combined with `[JsonIgnore]`, which System.Text.Json rejects at run time.
643
657
  */
644
- function renderField(field: FieldNode, ctx: RenderContext, forInput: boolean, ownerTypeName: string, wireCase?: WireCase): string[] {
645
- const propName = safeMemberName(toCSharpPropertyName(field.name), ownerTypeName);
658
+ /** The C# property name a field is emitted under inside `ownerTypeName`. */
659
+ function memberName(field: FieldNode, ownerTypeName: string): string {
660
+ return safeMemberName(toCSharpPropertyName(field.name), ownerTypeName);
661
+ }
662
+
663
+ function renderField(
664
+ field: FieldNode,
665
+ ctx: RenderContext,
666
+ forInput: boolean,
667
+ ownerTypeName: string,
668
+ memberNames: ReadonlySet<string>,
669
+ wireCase?: WireCase,
670
+ ): string[] {
671
+ const propName = memberName(field, ownerTypeName);
646
672
  const wireName = applyWireCase(field.name, wireCase);
647
673
 
648
674
  let typeStr = renderCSharpType(field.type, ctx, forInput);
649
675
  if ((field.optional || field.nullable) && !typeStr.endsWith('?')) typeStr += '?';
650
676
 
651
- let initializer = field.default !== undefined ? renderDefault(field.default, field.type, ctx) : undefined;
677
+ let initializer = field.default !== undefined ? renderDefault(field.default, field.type, ctx, memberNames) : undefined;
652
678
  // A `literal()` field carries exactly one value, so it defaults to it rather than being asked
653
679
  // for at every call site. The property is ordinary, so the value always reaches the wire.
654
680
  if (initializer === undefined && !field.optional && !field.nullable) {
655
681
  const inner = field.type.kind === 'lazy' ? field.type.inner : field.type;
656
- if (inner.kind === 'literal') initializer = renderDefault(inner.value, inner, ctx);
682
+ if (inner.kind === 'literal') initializer = renderDefault(inner.value, inner, ctx, memberNames);
657
683
  }
658
684
 
659
685
  const isRequired = !field.optional && initializer === undefined;
package/src/naming.ts CHANGED
@@ -141,6 +141,29 @@ export function toCSharpParameterName(name: string): string {
141
141
  return escapeCSharpIdentifier(result);
142
142
  }
143
143
 
144
+ /**
145
+ * A C# parameter or local name for each declared name, keyed by that name.
146
+ *
147
+ * Each goes through {@link toCSharpParameterName}, then gains a `_` until it collides with neither
148
+ * one of `taken` nor another name's result, and is keyword-escaped last. The comparison is on the
149
+ * unescaped spelling, since `@class` and `class` are the same identifier to the compiler. Returns
150
+ * the plain conversion in the common case, so existing output stays byte-identical.
151
+ *
152
+ * @param taken Unescaped identifiers the surrounding generated code already binds or reads, which
153
+ * a declared name must not duplicate or shadow.
154
+ */
155
+ export function bindCSharpParameterNames(names: readonly string[], taken: Iterable<string>): Map<string, string> {
156
+ const unavailable = new Set<string>(taken);
157
+ const bindings = new Map<string, string>();
158
+ for (const name of names) {
159
+ let local = toCSharpParameterName(name).replace(/^@/, '');
160
+ while (unavailable.has(local)) local += '_';
161
+ unavailable.add(local);
162
+ bindings.set(name, escapeCSharpIdentifier(local));
163
+ }
164
+ return bindings;
165
+ }
166
+
144
167
  /**
145
168
  * Make a property name safe inside `ownerTypeName`.
146
169
  *
@@ -171,6 +171,37 @@ describe('parameters', () => {
171
171
  expect(signature).toContain('ListQuery? query = null');
172
172
  });
173
173
 
174
+ it('escapes a path param named after a C# keyword, in the signature and the path alike', () => {
175
+ const root = opRoot([opRoute('/seats/{class}', [opOperation('get', { sdk: 'getSeat' })], [opParam('class', scalarType('string'))])]);
176
+ const out = render(root, { contracts });
177
+ expect(out).toContain('public async Task GetSeatAsync(string @class, CancellationToken cancellationToken = default)');
178
+ expect(out).toContain('http.Path("seats", http.Segment(@class))');
179
+ });
180
+
181
+ it('suffixes a path param named like the request body argument, which would otherwise be CS0100', () => {
182
+ const root = opRoot([
183
+ opRoute(
184
+ '/notes/{body}',
185
+ [opOperation('put', { sdk: 'putNote', request: opRequest('Payment') })],
186
+ [opParam('body', scalarType('string'))],
187
+ ),
188
+ ]);
189
+ const out = render(root, { contracts });
190
+ expect(out).toContain('PutNoteAsync(string body_, Payment body, CancellationToken cancellationToken = default)');
191
+ expect(out).toContain('http.Path("notes", http.Segment(body_))');
192
+ expect(out).toContain('content: http.JsonContent(body, "application/json")');
193
+ });
194
+
195
+ it.each(['query', 'customHeaders', 'cancellationToken', 'response', 'headers', 'http', 'pathParams'])(
196
+ 'suffixes a path param named `%s`, which the method already binds or reads',
197
+ name => {
198
+ const root = opRoot([opRoute(`/things/{${name}}`, [opOperation('get', { sdk: 'getThing' })], [opParam(name, scalarType('string'))])]);
199
+ const out = render(root, { contracts });
200
+ expect(out).toContain(`GetThingAsync(string ${name}_, CancellationToken cancellationToken = default)`);
201
+ expect(out).toContain(`http.Segment(${name}_)`);
202
+ },
203
+ );
204
+
174
205
  it('names a route-level params model pathParams, since params is a C# keyword', () => {
175
206
  const root = opRoot([opRoute('/refunds/{paymentId}', [opOperation('get', { sdk: 'refund' })], paramRef('PaymentRef'))]);
176
207
  const out = render(root, { contracts: [contractRoot([model('PaymentRef', [field('paymentId', scalarType('uuid'))])])] });
@@ -269,7 +300,79 @@ describe('response headers', () => {
269
300
  expect(withHeaders([{ name: 'h', optional: false, type: scalarType('bigint') }])).toContain('BigInteger.Parse(');
270
301
  });
271
302
 
303
+ it('renames an optional header pattern variable that would redeclare a parameter', () => {
304
+ const root = opRoot([
305
+ opRoute(
306
+ '/payments/{from}',
307
+ [
308
+ opOperation('get', {
309
+ sdk: 'get',
310
+ responses: [{ ...opResponse(200, 'Payment'), headers: [{ name: 'from', optional: true, type: scalarType('string') }] }],
311
+ }),
312
+ ],
313
+ [opParam('from', scalarType('string'))],
314
+ ),
315
+ ]);
316
+ const out = render(root, { contracts });
317
+ // `is { } from` in the method that takes `string from` is CS0136.
318
+ expect(out).toContain('response.Header("from") is { } from_ ? from_ : null');
319
+ });
320
+
272
321
  it('rejects a header type that cannot come off the wire as text', () => {
273
322
  expect(() => withHeaders([{ name: 'h', optional: false, type: scalarType('json') }])).toThrow(/cannot be read from an HTTP header/);
274
323
  });
275
324
  });
325
+
326
+ describe('request and response headers on one operation', () => {
327
+ const contracts = [contractRoot([model('Payment', [field('id', scalarType('uuid'))])])];
328
+ const requestHeaders = [opParam('from', scalarType('string'), { optional: true })];
329
+ const responseHeaders = [{ name: 'x-request-id', optional: false, type: scalarType('string') }];
330
+
331
+ it('gives the response side its own record name, so the namespace does not declare GetHeaders twice', () => {
332
+ const root = opRoot([
333
+ opRoute('/payments', [
334
+ opOperation('get', { sdk: 'get', headers: requestHeaders, responses: [{ ...opResponse(200, 'Payment'), headers: responseHeaders }] }),
335
+ ]),
336
+ ]);
337
+ const out = render(root, { contracts });
338
+ expect(out.match(/public sealed record GetHeaders\b/g)).toHaveLength(1);
339
+ expect(out).toContain('GetHeaders? customHeaders = null');
340
+ expect(out).toContain('public sealed record GetResponseHeaders(string XRequestId);');
341
+ expect(out).toContain('public sealed record GetResult(Payment Data, GetResponseHeaders Headers);');
342
+ expect(out).toContain('var headers = new GetResponseHeaders(');
343
+ });
344
+
345
+ it('returns the renamed record when the response carries headers and no body', () => {
346
+ const root = opRoot([
347
+ opRoute('/payments', [
348
+ opOperation('head', { sdk: 'get', headers: requestHeaders, responses: [{ ...opResponse(204), headers: responseHeaders }] }),
349
+ ]),
350
+ ]);
351
+ expect(render(root, { contracts })).toContain('public async Task<GetResponseHeaders> GetAsync(');
352
+ });
353
+
354
+ it('keeps GetHeaders for the response side when the operation declares no request headers', () => {
355
+ const root = opRoot([
356
+ opRoute('/payments', [opOperation('get', { sdk: 'get', responses: [{ ...opResponse(200, 'Payment'), headers: responseHeaders }] })]),
357
+ ]);
358
+ expect(render(root, { contracts })).toContain('public sealed record GetHeaders(string XRequestId);');
359
+ });
360
+
361
+ it('leaves per-status header records alone, since they never collide', () => {
362
+ const root = opRoot([
363
+ opRoute('/payments', [
364
+ opOperation('get', {
365
+ sdk: 'get',
366
+ headers: requestHeaders,
367
+ responses: [
368
+ { ...opResponse(200, 'Payment'), headers: responseHeaders },
369
+ { ...opResponse(202, 'Payment'), headers: responseHeaders },
370
+ ],
371
+ }),
372
+ ]),
373
+ ]);
374
+ const out = render(root, { contracts });
375
+ expect(out).toContain('public sealed record Get200Headers(string XRequestId);');
376
+ expect(out).toContain('public sealed record Get202Headers(string XRequestId);');
377
+ });
378
+ });
@@ -154,6 +154,21 @@ describe('optional, nullable, default and literal', () => {
154
154
  ]);
155
155
  expect(render(root)).toContain('public Rating R { get; init; } = Rating.Neutral;');
156
156
  });
157
+
158
+ it('qualifies the enum when a property of the same record shares its name', () => {
159
+ // `Rating?` is `Nullable<Rating>`, so C#'s same-name rule does not apply and a bare
160
+ // `Rating.Neutral` reads the property: CS0236 at build time.
161
+ const root = contractRoot([
162
+ model('Rating', [], { type: enumType('good', 'neutral') }),
163
+ model('M', [field('rating', refType('Rating'), { optional: true, default: 'neutral' })]),
164
+ ]);
165
+ expect(render(root)).toContain('public Rating? Rating { get; init; } = global::Acme.Sdk.Models.Rating.Neutral;');
166
+ });
167
+
168
+ it('qualifies a hoisted enum shadowed by a sibling property', () => {
169
+ const out = render(one('M', field('status', enumType('pending', 'done'), { default: 'done' }), field('mStatus', scalarType('string'))));
170
+ expect(out).toContain('public MStatus Status { get; init; } = global::Acme.Sdk.Models.MStatus.Done;');
171
+ });
157
172
  });
158
173
 
159
174
  describe('naming', () => {
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  CSHARP_KEYWORDS,
4
+ bindCSharpParameterNames,
4
5
  deriveCSharpFileBase,
5
6
  escapeCSharpIdentifier,
6
7
  escapeXml,
@@ -59,6 +60,31 @@ describe('toCSharpParameterName', () => {
59
60
  });
60
61
  });
61
62
 
63
+ describe('bindCSharpParameterNames', () => {
64
+ it('converts and escapes like toCSharpParameterName when nothing collides', () => {
65
+ expect(bindCSharpParameterNames(['invoice-id', 'class'], [])).toEqual(
66
+ new Map([
67
+ ['invoice-id', 'invoiceId'],
68
+ ['class', '@class'],
69
+ ]),
70
+ );
71
+ });
72
+
73
+ it('suffixes a name that lands on one already taken', () => {
74
+ expect(bindCSharpParameterNames(['body'], ['body']).get('body')).toBe('body_');
75
+ });
76
+
77
+ it('compares the unescaped spelling, since @class and class are one identifier', () => {
78
+ expect(bindCSharpParameterNames(['class'], ['class']).get('class')).toBe('class_');
79
+ });
80
+
81
+ it('keeps two names distinct when a suffix would collapse them onto one', () => {
82
+ const bindings = bindCSharpParameterNames(['body', 'body_'], ['body']);
83
+ expect(bindings.get('body')).toBe('body_');
84
+ expect(bindings.get('body_')).toBe('body__');
85
+ });
86
+ });
87
+
62
88
  describe('safeMemberName', () => {
63
89
  it('renames a member that matches its enclosing type, which C# rejects', () => {
64
90
  expect(safeMemberName('Invoice', 'Invoice')).toBe('InvoiceValue');