@contractkit/plugin-csharp 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-csharp",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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": {
@@ -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;
@@ -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', () => {