Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/http-server-csharp"
---

Update the union definition to include unnamed string literals and null
106 changes: 106 additions & 0 deletions packages/http-server-csharp/src/components/enums/enums.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { t, type TesterInstance } from "@typespec/compiler/testing";
import { Output } from "@typespec/emitter-framework";
import { EnumDeclaration } from "@typespec/emitter-framework/csharp";
import { beforeEach, describe, expect, it } from "vitest";
import { isUnionEnum } from "./enums.jsx";

let runner: TesterInstance;

Expand Down Expand Up @@ -97,3 +98,108 @@ describe("EnumDeclaration", () => {
`);
});
});

describe("isUnionEnum", () => {
it("returns true for extensible union with string base and named variants", async () => {
const { ReasoningEffort } = await runner.compile(t.code`
union ${t.union("ReasoningEffort")} {
string,
none: "none",
low: "low",
medium: "medium",
high: "high",
}
`);

expect(isUnionEnum(ReasoningEffort)).toBe(true);
});

it("returns true for fixed union with named variants only", async () => {
const { Priority } = await runner.compile(t.code`
union ${t.union("Priority")} {
low: "low",
medium: "medium",
high: "high",
}
`);

expect(isUnionEnum(Priority)).toBe(true);
});

it("returns true for union with unnamed string literals", async () => {
const { Priority } = await runner.compile(t.code`
union ${t.union("Priority")} {
"low",
"medium",
"high",
}
`);

expect(isUnionEnum(Priority)).toBe(true);
});

it("returns true for union with unnamed string literals and null", async () => {
const { ReasoningEffort } = await runner.compile(t.code`
union ${t.union("ReasoningEffort")} {
"none",
"minimal",
"low",
"medium",
"high",
null,
}
`);

expect(isUnionEnum(ReasoningEffort)).toBe(true);
});

it("returns true for union with named variants and null", async () => {
const { ReasoningEffort } = await runner.compile(t.code`
union ${t.union("ReasoningEffort")} {
none: "none",
medium: "medium",
high: "high",
null,
}
`);

expect(isUnionEnum(ReasoningEffort)).toBe(true);
});

it("returns true for extensible union with string base, named variants, and null", async () => {
const { ReasoningEffort } = await runner.compile(t.code`
union ${t.union("ReasoningEffort")} {
string,
none: "none",
medium: "medium",
high: "high",
null,
}
`);

expect(isUnionEnum(ReasoningEffort)).toBe(true);
});

it("returns false for union with non-string variant types", async () => {
const { Mixed } = await runner.compile(t.code`
model Foo { x: string; }
union ${t.union("Mixed")} {
Foo,
"bar",
}
`);

expect(isUnionEnum(Mixed)).toBe(false);
});

it("returns true for anonymous inline union of string literals", async () => {
const { ReasoningEffort } = await runner.compile(t.code`
union ${t.union("ReasoningEffort")} {
"none" | "minimal" | "low" | "medium" | "high",
null,
}
`);

expect(isUnionEnum(ReasoningEffort)).toBe(true);
});
});
52 changes: 47 additions & 5 deletions packages/http-server-csharp/src/components/enums/enums.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,35 +133,62 @@ export function Enums(props: EnumsProps): Children {
);
}

/**
* Returns true if an anonymous inline union contains only string literal variants.
*/
function isInlineStringLiteralUnion(type: Type): boolean {
if (type.kind !== "Union" || type.name) return false;
for (const variant of type.variants.values()) {
if (variant.type.kind !== "String") return false;
}
return type.variants.size > 0;
}

/**
* Returns true if a named union can be represented as a C# enum.
* Requires: named union, every named variant has a string value,
* and optionally one unnamed scalar `string` variant (open/extensible).
* and optionally one unnamed scalar `string` variant (open/extensible)
* and/or a `null` variant. Also supports inline anonymous unions of
* string literals (e.g., `"a" | "b" | "c"` as a single variant).
*/
export function isUnionEnum(union: Union): boolean {
if (!union.name) return false;

const variants = Array.from(union.variants.values());
let hasNamedStringVariant = false;
let hasStringVariant = false;

for (const variant of variants) {
// Allow a single open string scalar variant (extensible union)
if (variant.type.kind === "Scalar" && variant.type.name === "string") {
continue;
}
// Allow null variant (nullable union)
if (variant.type.kind === "Intrinsic" && variant.type.name === "null") {
continue;
}
// Named variant with a string literal value
if (variant.type.kind === "String" && variant.name && typeof variant.name === "string") {
hasNamedStringVariant = true;
hasStringVariant = true;
continue;
}
// Unnamed variant with a string literal value (e.g., union { "low", "medium", "high" })
if (variant.type.kind === "String" && typeof variant.name === "symbol") {
hasStringVariant = true;
continue;
}
// Inline anonymous union of string literals (e.g., "a" | "b" | "c" as a single variant)
if (isInlineStringLiteralUnion(variant.type)) {
hasStringVariant = true;
continue;
}
// Any other variant type means it's not a simple enum
return false;
}

return hasNamedStringVariant;
return hasStringVariant;
}

/** Gets the named string variants of a union-as-enum (skipping the open `string` variant). */
/** Gets the named string variants of a union-as-enum (skipping the open `string` and `null` variants). */
export function getUnionEnumMembers(
union: Union,
): { name: string; value: string; variant: import("@typespec/compiler").UnionVariant }[] {
Expand All @@ -172,7 +199,22 @@ export function getUnionEnumMembers(
}[] = [];
for (const variant of union.variants.values()) {
if (variant.type.kind === "String" && variant.name && typeof variant.name === "string") {
// Named variant with explicit key (e.g., none: "none")
members.push({ name: variant.name, value: variant.type.value, variant });
} else if (variant.type.kind === "String" && typeof variant.name === "symbol") {
// Unnamed string literal variant (e.g., "none") — derive name from the value
members.push({ name: variant.type.value, value: variant.type.value, variant });
} else if (isInlineStringLiteralUnion(variant.type)) {
// Inline anonymous union of string literals — flatten into individual members
for (const innerVariant of (variant.type as Union).variants.values()) {
if (innerVariant.type.kind === "String") {
members.push({
name: innerVariant.type.value,
value: innerVariant.type.value,
variant: innerVariant,
});
}
}
}
}
return members;
Expand Down
Loading