@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/.turbo/turbo-build$colon$ci.log +4 -4
- package/.turbo/turbo-test$colon$ci.log +13 -13
- package/CHANGELOG.md +11 -0
- package/dist/codegen-models.d.ts.map +1 -1
- package/dist/index.js +13 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/codegen-models.ts +34 -8
- package/tests/codegen-models.test.ts +15 -0
package/package.json
CHANGED
package/src/codegen-models.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
645
|
-
|
|
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', () => {
|