Skip to content

Commit 38a554a

Browse files
C# server emitter union failures (#10999)
I have identified some issues with the C# server emitter that result in files for unions not being emitted. I found this issues when emitting from this spec: [ReasoningEffort in openai-openapi-pr](https://github.com/microsoft/openai-openapi-pr/blob/38fda90904da7cd6bdc5a5983de8ddb564233883/packages/openai-typespec/src/common/models.tsp#L236) ### Failing scenarios: * null in any named union * unnamed string literals (ex: `"none", "minimal", "low",` instead of `none: "none", minimal: "minimal", low: "low"`) * inline unions with string literals (ex. "none" | "minimal" | "low") ### Passing scenarios: * string base + named variants without null * inline unions for properties
1 parent 8e06de6 commit 38a554a

3 files changed

Lines changed: 268 additions & 6 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/http-server-csharp"
5+
---
6+
7+
Update the union definition to include unnamed string literals and null

packages/http-server-csharp/src/components/enums/enums.test.tsx

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { Tester } from "#test/tester.js";
22
import { type Children } from "@alloy-js/core";
3-
import { createCSharpNamePolicy, SourceFile } from "@alloy-js/csharp";
3+
import {
4+
Attribute,
5+
createCSharpNamePolicy,
6+
EnumDeclaration as CsEnumDeclaration,
7+
EnumMember,
8+
SourceFile,
9+
} from "@alloy-js/csharp";
10+
import { type Union } from "@typespec/compiler";
411
import { t, type TesterInstance } from "@typespec/compiler/testing";
512
import { Output } from "@typespec/emitter-framework";
613
import { EnumDeclaration } from "@typespec/emitter-framework/csharp";
714
import { beforeEach, describe, expect, it } from "vitest";
15+
import { getUnionEnumMembers, isUnionEnum } from "./enums.jsx";
816

917
let runner: TesterInstance;
1018

@@ -21,6 +29,26 @@ function Wrapper(props: { children: Children }) {
2129
);
2230
}
2331

32+
/**
33+
* Renders a union-as-enum using the same logic as the Enums component,
34+
* but without the file/namespace/useTsp wrapping.
35+
*/
36+
function UnionEnumDecl(props: { union: Union }) {
37+
const members = getUnionEnumMembers(props.union);
38+
return (
39+
<CsEnumDeclaration name={props.union.name!} public>
40+
{members.map((member, i) => (
41+
<>
42+
<Attribute name="JsonStringEnumMemberName" args={[`"${member.value}"`]} />
43+
{"\n"}
44+
<EnumMember name={member.name} />
45+
{i < members.length - 1 ? ",\n" : ""}
46+
</>
47+
))}
48+
</CsEnumDeclaration>
49+
);
50+
}
51+
2452
describe("EnumDeclaration", () => {
2553
it("renders a simple enum", async () => {
2654
const { Color } = await runner.compile(t.code`
@@ -97,3 +125,188 @@ describe("EnumDeclaration", () => {
97125
`);
98126
});
99127
});
128+
129+
describe("isUnionEnum", () => {
130+
it("returns true for extensible union with string base and named variants", async () => {
131+
const { ReasoningEffort } = await runner.compile(t.code`
132+
union ${t.union("ReasoningEffort")} {
133+
string,
134+
none: "none",
135+
low: "low",
136+
medium: "medium",
137+
high: "high",
138+
}
139+
`);
140+
141+
expect(isUnionEnum(ReasoningEffort)).toBe(true);
142+
});
143+
144+
it("returns true for fixed union with named variants only", async () => {
145+
const { Priority } = await runner.compile(t.code`
146+
union ${t.union("Priority")} {
147+
low: "low",
148+
medium: "medium",
149+
high: "high",
150+
}
151+
`);
152+
153+
expect(isUnionEnum(Priority)).toBe(true);
154+
});
155+
156+
it("returns false for union with non-string variant types", async () => {
157+
const { Mixed } = await runner.compile(t.code`
158+
model Foo { x: string; }
159+
union ${t.union("Mixed")} {
160+
Foo,
161+
"bar",
162+
}
163+
`);
164+
165+
expect(isUnionEnum(Mixed)).toBe(false);
166+
});
167+
});
168+
169+
describe("union-as-enum rendering", () => {
170+
it("renders union with unnamed string literals", async () => {
171+
const { Priority } = await runner.compile(t.code`
172+
union ${t.union("Priority")} {
173+
"low",
174+
"medium",
175+
"high",
176+
}
177+
`);
178+
179+
expect(
180+
<Wrapper>
181+
<UnionEnumDecl union={Priority} />
182+
</Wrapper>,
183+
).toRenderTo(`
184+
public enum Priority
185+
{
186+
[JsonStringEnumMemberName("low")]
187+
Low,
188+
[JsonStringEnumMemberName("medium")]
189+
Medium,
190+
[JsonStringEnumMemberName("high")]
191+
High
192+
}
193+
`);
194+
});
195+
196+
it("renders union with unnamed string literals and null (null is skipped)", async () => {
197+
const { ReasoningEffort } = await runner.compile(t.code`
198+
union ${t.union("ReasoningEffort")} {
199+
"none",
200+
"minimal",
201+
"low",
202+
"medium",
203+
"high",
204+
null,
205+
}
206+
`);
207+
208+
expect(
209+
<Wrapper>
210+
<UnionEnumDecl union={ReasoningEffort} />
211+
</Wrapper>,
212+
).toRenderTo(`
213+
public enum ReasoningEffort
214+
{
215+
[JsonStringEnumMemberName("none")]
216+
None,
217+
[JsonStringEnumMemberName("minimal")]
218+
Minimal,
219+
[JsonStringEnumMemberName("low")]
220+
Low,
221+
[JsonStringEnumMemberName("medium")]
222+
Medium,
223+
[JsonStringEnumMemberName("high")]
224+
High
225+
}
226+
`);
227+
});
228+
229+
it("renders union with named variants and null (null is skipped)", async () => {
230+
const { ReasoningEffort } = await runner.compile(t.code`
231+
union ${t.union("ReasoningEffort")} {
232+
none: "none",
233+
medium: "medium",
234+
high: "high",
235+
null,
236+
}
237+
`);
238+
239+
expect(
240+
<Wrapper>
241+
<UnionEnumDecl union={ReasoningEffort} />
242+
</Wrapper>,
243+
).toRenderTo(`
244+
public enum ReasoningEffort
245+
{
246+
[JsonStringEnumMemberName("none")]
247+
None,
248+
[JsonStringEnumMemberName("medium")]
249+
Medium,
250+
[JsonStringEnumMemberName("high")]
251+
High
252+
}
253+
`);
254+
});
255+
256+
it("renders extensible union with string base, named variants, and null", async () => {
257+
const { ReasoningEffort } = await runner.compile(t.code`
258+
union ${t.union("ReasoningEffort")} {
259+
string,
260+
none: "none",
261+
medium: "medium",
262+
high: "high",
263+
null,
264+
}
265+
`);
266+
267+
expect(
268+
<Wrapper>
269+
<UnionEnumDecl union={ReasoningEffort} />
270+
</Wrapper>,
271+
).toRenderTo(`
272+
public enum ReasoningEffort
273+
{
274+
[JsonStringEnumMemberName("none")]
275+
None,
276+
[JsonStringEnumMemberName("medium")]
277+
Medium,
278+
[JsonStringEnumMemberName("high")]
279+
High
280+
}
281+
`);
282+
});
283+
284+
it("renders union with inline anonymous union of string literals and null", async () => {
285+
const { ReasoningEffort } = await runner.compile(t.code`
286+
union ${t.union("ReasoningEffort")} {
287+
"none" | "minimal" | "low" | "medium" | "high",
288+
null,
289+
}
290+
`);
291+
292+
expect(
293+
<Wrapper>
294+
<UnionEnumDecl union={ReasoningEffort} />
295+
</Wrapper>,
296+
).toRenderTo(`
297+
public enum ReasoningEffort
298+
{
299+
[JsonStringEnumMemberName("none")]
300+
None,
301+
[JsonStringEnumMemberName("minimal")]
302+
Minimal,
303+
[JsonStringEnumMemberName("low")]
304+
Low,
305+
[JsonStringEnumMemberName("medium")]
306+
Medium,
307+
[JsonStringEnumMemberName("high")]
308+
High
309+
}
310+
`);
311+
});
312+
});

packages/http-server-csharp/src/components/enums/enums.tsx

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,35 +133,62 @@ export function Enums(props: EnumsProps): Children {
133133
);
134134
}
135135

136+
/**
137+
* Returns true if an anonymous inline union contains only string literal variants.
138+
*/
139+
function isInlineStringLiteralUnion(type: Type): boolean {
140+
if (type.kind !== "Union" || type.name) return false;
141+
for (const variant of type.variants.values()) {
142+
if (variant.type.kind !== "String") return false;
143+
}
144+
return type.variants.size > 0;
145+
}
146+
136147
/**
137148
* Returns true if a named union can be represented as a C# enum.
138149
* Requires: named union, every named variant has a string value,
139-
* and optionally one unnamed scalar `string` variant (open/extensible).
150+
* and optionally one unnamed scalar `string` variant (open/extensible)
151+
* and/or a `null` variant. Also supports inline anonymous unions of
152+
* string literals (e.g., `"a" | "b" | "c"` as a single variant).
140153
*/
141154
export function isUnionEnum(union: Union): boolean {
142155
if (!union.name) return false;
143156

144157
const variants = Array.from(union.variants.values());
145-
let hasNamedStringVariant = false;
158+
let hasStringVariant = false;
146159

147160
for (const variant of variants) {
148161
// Allow a single open string scalar variant (extensible union)
149162
if (variant.type.kind === "Scalar" && variant.type.name === "string") {
150163
continue;
151164
}
165+
// Allow null variant (nullable union)
166+
if (variant.type.kind === "Intrinsic" && variant.type.name === "null") {
167+
continue;
168+
}
152169
// Named variant with a string literal value
153170
if (variant.type.kind === "String" && variant.name && typeof variant.name === "string") {
154-
hasNamedStringVariant = true;
171+
hasStringVariant = true;
172+
continue;
173+
}
174+
// Unnamed variant with a string literal value (e.g., union { "low", "medium", "high" })
175+
if (variant.type.kind === "String" && typeof variant.name === "symbol") {
176+
hasStringVariant = true;
177+
continue;
178+
}
179+
// Inline anonymous union of string literals (e.g., "a" | "b" | "c" as a single variant)
180+
if (isInlineStringLiteralUnion(variant.type)) {
181+
hasStringVariant = true;
155182
continue;
156183
}
157184
// Any other variant type means it's not a simple enum
158185
return false;
159186
}
160187

161-
return hasNamedStringVariant;
188+
return hasStringVariant;
162189
}
163190

164-
/** Gets the named string variants of a union-as-enum (skipping the open `string` variant). */
191+
/** Gets the named string variants of a union-as-enum (skipping the open `string` and `null` variants). */
165192
export function getUnionEnumMembers(
166193
union: Union,
167194
): { name: string; value: string; variant: import("@typespec/compiler").UnionVariant }[] {
@@ -172,7 +199,22 @@ export function getUnionEnumMembers(
172199
}[] = [];
173200
for (const variant of union.variants.values()) {
174201
if (variant.type.kind === "String" && variant.name && typeof variant.name === "string") {
202+
// Named variant with explicit key (e.g., none: "none")
175203
members.push({ name: variant.name, value: variant.type.value, variant });
204+
} else if (variant.type.kind === "String" && typeof variant.name === "symbol") {
205+
// Unnamed string literal variant (e.g., "none") — derive name from the value
206+
members.push({ name: variant.type.value, value: variant.type.value, variant });
207+
} else if (isInlineStringLiteralUnion(variant.type)) {
208+
// Inline anonymous union of string literals — flatten into individual members
209+
for (const innerVariant of (variant.type as Union).variants.values()) {
210+
if (innerVariant.type.kind === "String") {
211+
members.push({
212+
name: innerVariant.type.value,
213+
value: innerVariant.type.value,
214+
variant: innerVariant,
215+
});
216+
}
217+
}
176218
}
177219
}
178220
return members;

0 commit comments

Comments
 (0)