diff --git a/.chronus/changes/ef-csharp-authorable-declarations-2026-8-5.md b/.chronus/changes/ef-csharp-authorable-declarations-2026-8-5.md
new file mode 100644
index 00000000000..1861b9330f9
--- /dev/null
+++ b/.chronus/changes/ef-csharp-authorable-declarations-2026-8-5.md
@@ -0,0 +1,18 @@
+---
+changeKind: feature
+packages:
+ - "@typespec/emitter-framework"
+---
+
+Let emitters author the C# declaration components instead of forking them
+
+- `ClassDeclaration` accepts an explicit `properties` list and extra members as `children`.
+- `Property` accepts every Alloy property prop, plus `name` and `csharpType` overrides.
+- `EnumDeclaration` accepts an explicit `members` list and a `jsonAttributes` prop.
+- `JsonConverter` accepts `doc`, access modifiers, extra members, an explicit `csharpType`, and a `readReturns` override.
+
+```tsx
+
+
+
+```
diff --git a/.chronus/changes/ef-csharp-type-expression-total-2026-8-5.md b/.chronus/changes/ef-csharp-type-expression-total-2026-8-5.md
new file mode 100644
index 00000000000..5890ab4c847
--- /dev/null
+++ b/.chronus/changes/ef-csharp-type-expression-total-2026-8-5.md
@@ -0,0 +1,9 @@
+---
+changeKind: fix
+packages:
+ - "@typespec/emitter-framework"
+---
+
+Make the C# `TypeExpression` handle every type kind instead of throwing
+
+`Tuple`, `StringTemplate`, `EnumMember`, `ModelProperty`, `UnionVariant`, template parameters and the full `Intrinsic` set are now supported, and an unsupported type reports a diagnostic and falls back to `object` rather than throwing. Also fixes the C# components reporting a TypeScript diagnostic for unsupported scalars, and corrects the C# expressions for the `null` and `never` intrinsics.
diff --git a/.chronus/changes/ef-declaration-overrides-2026-8-5.md b/.chronus/changes/ef-declaration-overrides-2026-8-5.md
new file mode 100644
index 00000000000..7d0ba6fbc44
--- /dev/null
+++ b/.chronus/changes/ef-declaration-overrides-2026-8-5.md
@@ -0,0 +1,20 @@
+---
+changeKind: feature
+packages:
+ - "@typespec/emitter-framework"
+---
+
+Support declaration overrides in `Experimental_ComponentOverrides`
+
+Only `reference` overrides were dispatched, so an emitter could customize how a type is referenced but not how it is declared, forcing it to fork the framework's declaration components. The C# `ClassDeclaration`, `Property` and `EnumDeclaration` now render through the override point.
+
+```tsx
+const overrides = Experimental_ComponentOverridesConfig().forTypeKind("ModelProperty", {
+ declaration: (props) =>
+ props.type.name === "id" ? (
+
+ ) : (
+ props.default
+ ),
+});
+```
diff --git a/.chronus/changes/http-server-csharp-alloy-naming-2026-8-5.md b/.chronus/changes/http-server-csharp-alloy-naming-2026-8-5.md
new file mode 100644
index 00000000000..f40a55dbec7
--- /dev/null
+++ b/.chronus/changes/http-server-csharp-alloy-naming-2026-8-5.md
@@ -0,0 +1,9 @@
+---
+changeKind: internal
+packages:
+ - "@typespec/http-server-csharp"
+---
+
+Use Alloy's C# keyword handling and `System.Text.Json` symbols instead of local copies
+
+Deletes the emitter's own 217-line C# keyword table and its re-declaration of the `System.Text.Json.Serialization` attributes, which are both provided by `@alloy-js/csharp`. Namespace segments that collide with common BCL type names are still renamed, now in a dedicated `getCSharpNamespaceName` helper.
diff --git a/.chronus/changes/http-server-csharp-error-model-scalars-2026-8-5.md b/.chronus/changes/http-server-csharp-error-model-scalars-2026-8-5.md
new file mode 100644
index 00000000000..9ef6944b720
--- /dev/null
+++ b/.chronus/changes/http-server-csharp-error-model-scalars-2026-8-5.md
@@ -0,0 +1,9 @@
+---
+changeKind: fix
+packages:
+ - "@typespec/http-server-csharp"
+---
+
+Fix generated error model constructors and numeric constraint attributes using the wrong C# types
+
+Error model constructors declared parameters such as `DateOnly`, `Uri` and `sbyte` while the matching properties were `DateTime`, `string` and `SByte`, producing code that did not compile. `NumericConstraintAttribute` had the same mismatch, which stopped the converter from binding.
diff --git a/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx b/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx
index a56e352a756..16bf05a0cf1 100644
--- a/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx
+++ b/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx
@@ -51,26 +51,54 @@ export interface Experimental_OverrideReferenceProps<
member?: ModelProperty;
}
+/**
+ * Fallback props type for declaration overrides.
+ *
+ * Declaration props are language specific (`cs.ClassDeclarationProps`, `ts.VarDeclarationProps`,
+ * ...) and cannot be derived from the TypeSpec type, so they default to a permissive record.
+ * Pass the concrete props type explicitly to
+ * {@link Experimental_ComponentOverridesClass.forType} /
+ * {@link Experimental_ComponentOverridesClass.forTypeKind} to get full type checking.
+ */
+export type Experimental_DefaultDeclarationProps = Record;
+
export interface Experimental_OverrideDeclareProps<
TCustomType extends Type,
+ TDeclarationProps = Experimental_DefaultDeclarationProps,
> extends Experimental_OverrideEmitPropsBase {
- Declaration: ComponentDefinition>;
- declarationProps: Experimental_CustomTypeToProps;
+ /**
+ * The component that produces the default declaration. Call it with (a modified copy of)
+ * {@link declarationProps} to reuse the framework's rendering.
+ */
+ Declaration: ComponentDefinition;
+ /** The props the framework would have used to render the declaration. */
+ declarationProps: TDeclarationProps;
}
-export type Experimental_OverrideDeclarationComponent =
- ComponentDefinition>;
+export type Experimental_OverrideDeclarationComponent<
+ TCustomType extends Type,
+ TDeclarationProps = Experimental_DefaultDeclarationProps,
+> = ComponentDefinition>;
export type Experimental_OverrideReferenceComponent = ComponentDefinition<
Experimental_OverrideReferenceProps
>;
-export interface Experimental_ComponentOverridesConfigBase {
+export interface Experimental_ComponentOverridesConfigBase<
+ TCustomType extends Type,
+ TDeclarationProps = Experimental_DefaultDeclarationProps,
+> {
/**
* Override when this type is referenced.
* e.g. When used in
*/
reference?: Experimental_OverrideReferenceComponent;
+
+ /**
+ * Override when this type is declared.
+ * e.g. When used in
+ */
+ declaration?: Experimental_OverrideDeclarationComponent;
}
export interface Experimental_ComponentOverridesProps {
@@ -112,11 +140,32 @@ export interface Experimental_OverridableComponentReferenceProps<
member?: ModelProperty;
}
-export type Experimental_OverridableComponentProps =
- Experimental_OverridableComponentReferenceProps;
+export interface Experimental_OverridableComponentDeclarationProps<
+ T extends Type,
+ TDeclarationProps,
+> extends Experimental_OverrideTypeComponentCommonProps {
+ /**
+ * Pass when rendering a declaration of the provided type or type kind.
+ */
+ declaration: true;
+
+ /**
+ * The component that produces the default declaration.
+ */
+ Declaration: ComponentDefinition;
+
+ /**
+ * The props the framework would have used to render the declaration.
+ */
+ declarationProps: TDeclarationProps;
+}
+
+export type Experimental_OverridableComponentProps =
+ | Experimental_OverridableComponentReferenceProps
+ | Experimental_OverridableComponentDeclarationProps;
-export function Experimental_OverridableComponent(
- props: Experimental_OverridableComponentProps,
+export function Experimental_OverridableComponent(
+ props: Experimental_OverridableComponentProps,
) {
const options = useOverrides();
const { $ } = useTsp();
@@ -133,5 +182,17 @@ export function Experimental_OverridableComponent(
return ;
}
+ if ("declaration" in props && props.declaration && descriptor.declaration) {
+ const CustomComponent = descriptor.declaration;
+ return (
+
+ );
+ }
+
return <>{props.children}>;
}
diff --git a/packages/emitter-framework/src/core/components/overrides/config.ts b/packages/emitter-framework/src/core/components/overrides/config.ts
index ea7047f14e4..0ae64d5e337 100644
--- a/packages/emitter-framework/src/core/components/overrides/config.ts
+++ b/packages/emitter-framework/src/core/components/overrides/config.ts
@@ -1,6 +1,9 @@
import type { Program, Scalar, Type } from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
-import type { Experimental_ComponentOverridesConfigBase } from "./component-overrides.jsx";
+import type {
+ Experimental_ComponentOverridesConfigBase,
+ Experimental_DefaultDeclarationProps,
+} from "./component-overrides.jsx";
const getOverrideForTypeSym: unique symbol = Symbol.for("ef-ts:getOverrideForType");
const getOverrideForTypeKindSym: unique symbol = Symbol.for("ef-ts:getOverrideForTypeKind");
@@ -14,19 +17,28 @@ export const Experimental_ComponentOverridesConfig = function () {
};
export class Experimental_ComponentOverridesClass {
- #typeEmitOptions: Map> = new Map();
- #typeKindEmitOptions: Map> =
+ #typeEmitOptions: Map> = new Map();
+ #typeKindEmitOptions: Map> =
new Map();
- forType(type: T, options: Experimental_ComponentOverridesConfigBase) {
+ forType(
+ type: T,
+ options: Experimental_ComponentOverridesConfigBase,
+ ) {
this.#typeEmitOptions.set(type, options);
return this;
}
- forTypeKind(
+ forTypeKind<
+ const TKind extends Type["kind"],
+ TDeclarationProps = Experimental_DefaultDeclarationProps,
+ >(
typeKind: TKind,
- options: Experimental_ComponentOverridesConfigBase>,
+ options: Experimental_ComponentOverridesConfigBase<
+ Extract,
+ TDeclarationProps
+ >,
) {
this.#typeKindEmitOptions.set(typeKind, options);
diff --git a/packages/emitter-framework/src/csharp/components/class/declaration.test.tsx b/packages/emitter-framework/src/csharp/components/class/declaration.test.tsx
index 3f008f01893..fa265a16875 100644
--- a/packages/emitter-framework/src/csharp/components/class/declaration.test.tsx
+++ b/packages/emitter-framework/src/csharp/components/class/declaration.test.tsx
@@ -359,3 +359,119 @@ describe("with doc comments", () => {
`);
});
});
+
+describe("declaration overrides", () => {
+ it("replaces a class declaration entirely", async () => {
+ const { TestModel } = await runner.compile(t.code`
+ model ${t.model("TestModel")} {
+ Prop1: string;
+ }
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig().forTypeKind("Model", {
+ declaration: () => "class Replaced {}",
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(`class Replaced {}`);
+ });
+
+ it("re-renders the default declaration with modified props", async () => {
+ const { TestModel } = await runner.compile(t.code`
+ model ${t.model("TestModel")} {}
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig().forTypeKind("Model", {
+ declaration: (props) => (
+
+ ),
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(`partial class Renamed {}`);
+ });
+
+ it("falls back to the default when only a reference override is configured", async () => {
+ const { TestModel } = await runner.compile(t.code`
+ model ${t.model("TestModel")} {}
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig().forTypeKind("Model", {
+ reference: () => "Nope",
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(`class TestModel {}`);
+ });
+
+ it("overrides a property declaration", async () => {
+ const { TestModel } = await runner.compile(t.code`
+ model ${t.model("TestModel")} {
+ Prop1: string;
+ Prop2: int32;
+ }
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig().forTypeKind("ModelProperty", {
+ declaration: (props) =>
+ props.type.name === "Prop1" ? "public string Custom { get; }" : props.default,
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(d`
+ class TestModel
+ {
+ public string Custom { get; }
+
+ public required int Prop2 { get; set; }
+ }
+ `);
+ });
+
+ it("overrides an enum declaration", async () => {
+ const { TestEnum } = await runner.compile(t.code`
+ enum ${t.enum("TestEnum")} {
+ A,
+ B,
+ }
+ `);
+
+ const overrides = Experimental_ComponentOverridesConfig().forTypeKind("Enum", {
+ declaration: (props) => ,
+ });
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(d`
+ enum RenamedEnum
+ {
+ A,
+ B
+ }
+ `);
+ });
+});
diff --git a/packages/emitter-framework/src/csharp/components/class/declaration.tsx b/packages/emitter-framework/src/csharp/components/class/declaration.tsx
index bade989ad42..adb2c84ed0a 100644
--- a/packages/emitter-framework/src/csharp/components/class/declaration.tsx
+++ b/packages/emitter-framework/src/csharp/components/class/declaration.tsx
@@ -1,8 +1,8 @@
import { For, type Children } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
-import type { Interface, Model } from "@typespec/compiler";
+import type { Interface, Model, ModelProperty } from "@typespec/compiler";
import { isVoidType } from "@typespec/compiler";
-import { useTsp } from "../../../core/index.js";
+import { Experimental_OverridableComponent, useTsp } from "../../../core/index.js";
import { Property } from "../property/property.jsx";
import { TypeExpression } from "../type-expression.jsx";
import { getDocComments } from "../utils/doc-comments.jsx";
@@ -15,10 +15,18 @@ export interface ClassDeclarationProps extends Omit
+
+
+ );
+}
+
+function ClassDeclarationBody(props: ClassDeclarationProps): Children {
const { $ } = useTsp();
+ const { type, name, jsonAttributes, properties, children, refkey, baseType, ...classProps } =
+ props;
const namePolicy = cs.useCSharpNamePolicy();
- const className = props.name ?? namePolicy.getName(props.type.name, "class");
+ const className = name ?? namePolicy.getName(type.name, "class");
- const refkeys = declarationRefkeys(props.refkey, props.type)[0]; // TODO: support multiple refkeys for declarations in alloy
+ const refkeys = declarationRefkeys(refkey, type)[0]; // TODO: support multiple refkeys for declarations in alloy
return (
- <>
-
- ) : undefined)
- }
- doc={getDocComments($, props.type)}
- >
- {props.type.kind === "Model" && (
-
- )}
- {props.type.kind === "Interface" && }
-
- >
+
+ ) : undefined)
+ }
+ doc={getDocComments($, type)}
+ {...classProps}
+ >
+ {children}
+ {type.kind === "Model" && (
+
+ )}
+ {type.kind === "Interface" && }
+
);
}
function ClassProperties(props: ClassPropertiesProps): Children {
// Ignore 'void' type properties which is not valid in csharp
- const properties = Array.from(props.type.properties.entries()).filter(
- ([_, p]) => !isVoidType(p.type),
+ const properties = (props.properties ?? Array.from(props.type.properties.values())).filter(
+ (p) => !isVoidType(p.type),
);
return (
- {([name, property]) => }
+ {(property) => }
);
}
diff --git a/packages/emitter-framework/src/csharp/components/enum/declaration.test.tsx b/packages/emitter-framework/src/csharp/components/enum/declaration.test.tsx
index 565fb66032b..c71550d44b8 100644
--- a/packages/emitter-framework/src/csharp/components/enum/declaration.test.tsx
+++ b/packages/emitter-framework/src/csharp/components/enum/declaration.test.tsx
@@ -256,3 +256,62 @@ it("renders an enum with a type-level doc comment", async () => {
}
`);
});
+
+it("adds json serialization attributes", async () => {
+ const { TestEnum } = await runner.compile(t.code`
+ enum ${t.enum("TestEnum")} {
+ Value1: "value-1";
+ Value2: "value-2";
+ }
+ `);
+
+ expect(
+
+
+ ,
+ ).toRenderTo(`
+ using System.Text.Json.Serialization;
+
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ enum TestEnum
+ {
+ [JsonStringEnumMemberName("value-1")]
+ Value1,
+ [JsonStringEnumMemberName("value-2")]
+ Value2
+ }
+ `);
+});
+
+it("renders an explicit member list", async () => {
+ const { TestEnum } = await runner.compile(t.code`
+ enum ${t.enum("TestEnum")} {
+ Value1;
+ Value2;
+ }
+ `);
+
+ expect(
+
+
+ ,
+ ).toRenderTo(`
+ using System.Text.Json.Serialization;
+
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ enum TestEnum
+ {
+ [JsonStringEnumMemberName("onlyMe")]
+ OnlyMe,
+ [JsonStringEnumMemberName("andMe")]
+ AndMe
+ }
+ `);
+});
diff --git a/packages/emitter-framework/src/csharp/components/enum/declaration.tsx b/packages/emitter-framework/src/csharp/components/enum/declaration.tsx
index e49a5474c47..fd1e654d661 100644
--- a/packages/emitter-framework/src/csharp/components/enum/declaration.tsx
+++ b/packages/emitter-framework/src/csharp/components/enum/declaration.tsx
@@ -1,56 +1,128 @@
+import { Experimental_OverridableComponent } from "#core/components/index.js";
import { useTsp } from "#core/context/tsp-context.js";
-import { type Children, For } from "@alloy-js/core";
+import { code, For, REFKEYABLE, type Children, type Refkey } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
+import { Serialization } from "@alloy-js/csharp/global/System/Text/Json";
import type { Enum, Union } from "@typespec/compiler";
import { reportDiagnostic } from "../../../lib.js";
import { getDocComments } from "../utils/doc-comments.jsx";
import { declarationRefkeys, efRefkey } from "../utils/refkey.js";
+/** A single member of a generated C# enum. */
+export interface EnumDeclarationMember {
+ /** Member name, before the C# name policy is applied. */
+ name: string;
+ /** Refkey references to this member resolve to. */
+ refkey?: Refkey;
+ /** Doc comment for the member. */
+ doc?: Children;
+ /**
+ * Name this member serializes to in JSON. Only used when
+ * {@link EnumDeclarationProps.jsonAttributes} is set. Defaults to the member name.
+ */
+ jsonValue?: string;
+}
+
export interface EnumDeclarationProps extends Omit {
name?: string;
type: Union | Enum;
+ /**
+ * The members to render. Defaults to every member of the enum, or every variant of the
+ * union.
+ */
+ members?: EnumDeclarationMember[];
+ /**
+ * If set the enum will add the json serialization attributes (using System.Text.Json):
+ * `[JsonConverter(typeof(JsonStringEnumConverter))]` on the enum and
+ * `[JsonStringEnumMemberName]` on each member.
+ */
+ jsonAttributes?: boolean;
}
export function EnumDeclaration(props: EnumDeclarationProps): Children {
+ return (
+
+
+
+ );
+}
+
+function EnumDeclarationBody(props: EnumDeclarationProps): Children {
const { $ } = useTsp();
- let type: Enum;
- if ($.union.is(props.type)) {
- if (!$.union.isValidEnum(props.type)) {
- throw new Error("The provided union type cannot be represented as an enum");
- }
- type = $.enum.createFromUnion(props.type);
- } else {
- type = props.type;
- }
+ const { type: tspType, name, members, jsonAttributes, refkey, ...enumProps } = props;
- if (!props.type.name) {
- reportDiagnostic($.program, { code: "type-declaration-missing-name", target: props.type });
+ if (!tspType.name) {
+ reportDiagnostic($.program, { code: "type-declaration-missing-name", target: tspType });
}
- const refkeys = declarationRefkeys(props.refkey, props.type)[0]; // TODO: support multiple refkeys for declarations in alloy
- const name = props.name ?? cs.useCSharpNamePolicy().getName(props.type.name!, "enum");
- const members = Array.from(type.members.entries());
+ const refkeys = declarationRefkeys(refkey, tspType)[0]; // TODO: support multiple refkeys for declarations in alloy
+ const enumName = name ?? cs.useCSharpNamePolicy().getName(tspType.name!, "enum");
+ const enumMembers = members ?? defaultMembers($, tspType);
return (
<>
-
-
- {([key, value]) => {
- return (
- <>
-
-
- >
- );
- }}
+ {jsonAttributes && (
+ <>
+
+
+ >
+ )}
+
+
+ {(member) => (
+ <>
+
+ {jsonAttributes && (
+ <>
+
+
+ >
+ )}
+
+ >
+ )}
>
);
}
+
+function defaultMembers(
+ $: ReturnType["$"],
+ tspType: Union | Enum,
+): EnumDeclarationMember[] {
+ let type: Enum;
+ if ($.union.is(tspType)) {
+ if (!$.union.isValidEnum(tspType)) {
+ throw new Error("The provided union type cannot be represented as an enum");
+ }
+ type = $.enum.createFromUnion(tspType);
+ } else {
+ type = tspType;
+ }
+
+ return Array.from(type.members.entries()).map(([key, member]) => ({
+ name: key,
+ refkey: $.union.is(tspType) ? efRefkey(tspType.variants.get(key)) : efRefkey(member),
+ doc: getDocComments($, member),
+ jsonValue: typeof member.value === "string" ? member.value : key,
+ }));
+}
diff --git a/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx b/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx
index ae26ef0efde..e97976c2fbc 100644
--- a/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx
+++ b/packages/emitter-framework/src/csharp/components/json-converter/json-converter.tsx
@@ -8,10 +8,31 @@ import { type Type } from "@typespec/compiler";
import { capitalize } from "@typespec/compiler/casing";
import { TypeExpression } from "../type-expression.jsx";
-interface JsonConverterProps {
+export interface JsonConverterProps {
name: string | Namekey;
- type: Type;
+ /** The TypeSpec type being converted. Required unless {@link csharpType} is set. */
+ type?: Type;
+ /**
+ * The C# type being converted. Defaults to the C# expression for {@link type}. Set this
+ * for converters of types that have no TypeSpec equivalent (e.g. `DateTimeOffset`).
+ */
+ csharpType?: Children;
refkey?: Refkey;
+ /** Doc comment for the generated class. */
+ doc?: Children;
+ /** Emit the class as `public`. Defaults to `internal`. */
+ public?: boolean;
+ /** Emit the class as `internal`. Defaults to `true` unless {@link public} is set. */
+ internal?: boolean;
+ /** Emit the class as `sealed`. Defaults to `true`. */
+ sealed?: boolean;
+ /** Extra class members rendered before `Read` and `Write`. */
+ children?: Children;
+ /**
+ * Return type of `Read`. Defaults to the converted type. Set this to make the converter
+ * return a nullable value.
+ */
+ readReturns?: Children;
/** Decode and return value from reader*/
decodeAndReturn: (reader: Namekey, typeToConvert: Namekey, options: Namekey) => Children;
/** Encode the given value and send to writer*/
@@ -29,16 +50,22 @@ export function JsonConverter(props: JsonConverterProps) {
const writeParamWriter: Namekey = namekey("writer");
const writeParamValue: Namekey = namekey("value");
const writeParamOptions: Namekey = namekey("options");
- const propTypeExpression = code`${()}`;
+ if (!props.type && !props.csharpType) {
+ throw new Error("JsonConverter requires either a `type` or a `csharpType`.");
+ }
+ const propTypeExpression = props.csharpType ?? code`${()}`;
return (
`}
>
+ {props.children}
{code`${props.decodeAndReturn(readParamReader, readParamTypeToConvert, readParamOptions)}`}
diff --git a/packages/emitter-framework/src/csharp/components/property/property.test.tsx b/packages/emitter-framework/src/csharp/components/property/property.test.tsx
index 1ebf1bf380d..a10d98b7163 100644
--- a/packages/emitter-framework/src/csharp/components/property/property.test.tsx
+++ b/packages/emitter-framework/src/csharp/components/property/property.test.tsx
@@ -281,3 +281,62 @@ describe("jsonAttributes", () => {
`);
});
});
+
+describe("overriding the framework defaults", () => {
+ it("uses an alternative name", async () => {
+ const { prop1 } = await tester.compile(t.code`
+ model TestModel {
+ ${t.modelProperty("prop1")}: string;
+ }
+ `);
+
+ expect(
+
+
+ ,
+ ).toRenderTo(`
+ class Test
+ {
+ public required string Renamed { get; set; }
+ }
+ `);
+ });
+
+ it("uses an alternative C# type", async () => {
+ const { prop1 } = await tester.compile(t.code`
+ model TestModel {
+ ${t.modelProperty("prop1")}: string[];
+ }
+ `);
+
+ expect(
+
+
+ ,
+ ).toRenderTo(`
+ class Test
+ {
+ public required ISet Prop1 { get; set; }
+ }
+ `);
+ });
+
+ it("forwards Alloy property props and lets them win over the defaults", async () => {
+ const { prop1 } = await tester.compile(t.code`
+ model TestModel {
+ ${t.modelProperty("prop1")}: string;
+ }
+ `);
+
+ expect(
+
+
+ ,
+ ).toRenderTo(`
+ class Test
+ {
+ public string Prop1 { get; } = "fixed";
+ }
+ `);
+ });
+});
diff --git a/packages/emitter-framework/src/csharp/components/property/property.tsx b/packages/emitter-framework/src/csharp/components/property/property.tsx
index 8863892e06f..b4be45fa2cf 100644
--- a/packages/emitter-framework/src/csharp/components/property/property.tsx
+++ b/packages/emitter-framework/src/csharp/components/property/property.tsx
@@ -1,4 +1,4 @@
-import { code, REFKEYABLE, type Children } from "@alloy-js/core";
+import { code, REFKEYABLE, type Children, type Namekey } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
import { Attribute } from "@alloy-js/csharp";
import { Serialization } from "@alloy-js/csharp/global/System/Text/Json";
@@ -9,14 +9,22 @@ import {
type ModelProperty,
type Type,
} from "@typespec/compiler";
-import { useTsp } from "../../../core/index.js";
+import { Experimental_OverridableComponent, useTsp } from "../../../core/index.js";
import { useJsonConverterResolver } from "../json-converter/json-converter-resolver.jsx";
import { TypeExpression } from "../type-expression.jsx";
import { getDocComments } from "../utils/doc-comments.jsx";
import { getNullableUnionInnerType } from "../utils/nullable-util.js";
-export interface PropertyProps {
+export interface PropertyProps extends Omit {
+ /** The TypeSpec property to create the C# property from. */
type: ModelProperty;
+ /** Set an alternative name for the property. Otherwise default to the TypeSpec property name. */
+ name?: Namekey | string;
+ /**
+ * Set an alternative C# type for the property. Otherwise default to rendering
+ * {@link PropertyProps.type}, unwrapping a nullable union if there is one.
+ */
+ csharpType?: Children;
/** If set the property will add the json serialization attributes(using System.Text.Json.Serialization).
* - the JsonPropertyName attribute
* - the JsonConverter attribute if the property has encoding and a JsonConverterResolver context is available
@@ -28,15 +36,29 @@ export interface PropertyProps {
* Create a C# property declaration from a TypeSpec property type.
*/
export function Property(props: PropertyProps): Children {
+ return (
+
+
+
+ );
+}
+
+function PropertyBody(props: PropertyProps): Children {
const { $ } = useTsp();
- const result = preprocessPropertyType(props.type);
+ const { type: tspProperty, name, csharpType, jsonAttributes, ...propertyProps } = props;
+ const result = preprocessPropertyType(tspProperty);
let overrideType: "" | "override" | "new" = "";
let isVirtual = false;
- if (props.type.model) {
- if (props.type.model.baseModel) {
- const base = props.type.model.baseModel;
- const baseProperty = getProperty(base, props.type.name);
+ if (tspProperty.model) {
+ if (tspProperty.model.baseModel) {
+ const base = tspProperty.model.baseModel;
+ const baseProperty = getProperty(base, tspProperty.name);
if (baseProperty) {
const baseResult = preprocessPropertyType(baseProperty);
if (baseResult.nullable === result.nullable && baseResult.type === result.type) {
@@ -48,11 +70,11 @@ export function Property(props: PropertyProps): Children {
}
if (
overrideType === "" &&
- props.type.model.derivedModels &&
- props.type.model.derivedModels.length > 0
+ tspProperty.model.derivedModels &&
+ tspProperty.model.derivedModels.length > 0
) {
- isVirtual = props.type.model.derivedModels.some((derived) => {
- const derivedProperty = derived.properties.get(props.type.name);
+ isVirtual = tspProperty.model.derivedModels.some((derived) => {
+ const derivedProperty = derived.properties.get(tspProperty.name);
if (derivedProperty) {
const derivedResult = preprocessPropertyType(derivedProperty);
return derivedResult.nullable === result.nullable && derivedResult.type === result.type;
@@ -61,9 +83,9 @@ export function Property(props: PropertyProps): Children {
}
}
const attributes = [];
- if (props.jsonAttributes) {
- attributes.push();
- const encodeData = getEncode($.program, props.type);
+ if (jsonAttributes) {
+ attributes.push();
+ const encodeData = getEncode($.program, tspProperty);
if (encodeData) {
const JsonConverterResolver = useJsonConverterResolver();
if (JsonConverterResolver) {
@@ -79,18 +101,19 @@ export function Property(props: PropertyProps): Children {
return (
}
+ name={name ?? tspProperty.name}
+ type={csharpType ?? }
override={overrideType === "override"}
new={overrideType === "new"}
public
virtual={isVirtual}
- required={!props.type.optional}
+ required={!tspProperty.optional}
nullable={result.nullable}
- doc={getDocComments($, props.type)}
+ doc={getDocComments($, tspProperty)}
attributes={attributes}
get
set
+ {...propertyProps}
/>
);
}
diff --git a/packages/emitter-framework/src/csharp/components/type-expression.test.tsx b/packages/emitter-framework/src/csharp/components/type-expression.test.tsx
index 8a6354aa648..c9d9e5f498c 100644
--- a/packages/emitter-framework/src/csharp/components/type-expression.test.tsx
+++ b/packages/emitter-framework/src/csharp/components/type-expression.test.tsx
@@ -6,6 +6,7 @@ import { t, type TesterInstance } from "@typespec/compiler/testing";
import { beforeEach, describe, expect, it } from "vitest";
import { Output } from "../../core/index.js";
import { ClassDeclaration } from "./class/declaration.js";
+import { EnumDeclaration } from "./enum/declaration.jsx";
import { TypeExpression } from "./type-expression.jsx";
let runner: TesterInstance;
@@ -143,3 +144,54 @@ describe("Literal types", () => {
`);
});
});
+
+describe("types with no direct C# equivalent", () => {
+ it.each([
+ ["string template", "string", `"a-\${string}"`],
+ ["tuple", "int[]", "[int32, int32]"],
+ ["unknown", "object", "unknown"],
+ ["void", "void", "void"],
+ ["never", "void", "never"],
+ ["null", "object", "null"],
+ ])("%s => %s", async (_label, csType, tspType) => {
+ const type = await compileType(tspType);
+ expect(
+
+
+ ,
+ ).toRenderTo(csType);
+ });
+
+ it("falls back to object instead of throwing", async () => {
+ const type = await compileType("int32 | boolean");
+ expect(
+
+
+ ,
+ ).toRenderTo("object");
+ });
+});
+
+it("renders an enum member using the enum it belongs to", async () => {
+ const { test, Color } = await runner.compile(t.code`
+ enum ${t.enum("Color")} { red, blue }
+ model Test {
+ ${t.modelProperty("test")}: Color.red;
+ }
+ `);
+
+ expect(
+
+
+
+
+ ,
+ ).toRenderTo(`
+ enum Color
+ {
+ red,
+ blue
+ }
+ Color
+ `);
+});
diff --git a/packages/emitter-framework/src/csharp/components/type-expression.tsx b/packages/emitter-framework/src/csharp/components/type-expression.tsx
index d4a1932ea30..bd9d480f00a 100644
--- a/packages/emitter-framework/src/csharp/components/type-expression.tsx
+++ b/packages/emitter-framework/src/csharp/components/type-expression.tsx
@@ -1,16 +1,10 @@
import { Experimental_OverridableComponent } from "#core/index.js";
import { code, type Children } from "@alloy-js/core";
import { Reference } from "@alloy-js/csharp";
-import {
- getTypeName,
- isVoidType,
- type IntrinsicType,
- type Scalar,
- type Type,
-} from "@typespec/compiler";
+import { getTypeName, type IntrinsicType, type Scalar, type Type } from "@typespec/compiler";
import type { Typekit } from "@typespec/compiler/typekit";
import { useTsp } from "../../core/index.js";
-import { reportTypescriptDiagnostic } from "../../typescript/lib.js";
+import { reportDiagnostic } from "../../lib.js";
import { getNullableUnionInnerType } from "./utils/nullable-util.js";
import { efRefkey } from "./utils/refkey.js";
@@ -21,50 +15,86 @@ export interface TypeExpressionProps {
export function TypeExpression(props: TypeExpressionProps): Children {
return (
- {() => {
- if (props.type.kind === "Union") {
- const nullabletype = getNullableUnionInnerType(props.type);
- if (nullabletype) {
- return code`${()}?`;
- }
- }
- const { $ } = useTsp();
- if (isDeclaration($, props.type)) {
- return ;
- }
- if ($.scalar.is(props.type)) {
- return getScalarIntrinsicExpression($, props.type);
- } else if ($.array.is(props.type)) {
- return code`${()}[]`;
- } else if ($.record.is(props.type)) {
- return code`IDictionary)}>`;
- } else if ($.literal.isString(props.type)) {
- // c# doesn't have literal types, so we map them to their corresponding C# types in general
- return code`string`;
- } else if ($.literal.isNumeric(props.type)) {
- return Number.isInteger(props.type.value) ? code`int` : code`double`;
- } else if ($.literal.isBoolean(props.type)) {
- return code`bool`;
- } else if (isVoidType(props.type)) {
- return code`void`;
- }
-
- throw new Error(
- `Unsupported type for TypeExpression: ${props.type.kind} (${getTypeName(props.type)})`,
- );
- }}
+ {() => }
);
}
+/**
+ * Resolves a TypeSpec type to the C# type expression that represents it.
+ *
+ * This never throws: type kinds with no C# equivalent report a diagnostic and fall back to
+ * `object`, so that a single unsupported type does not abort the whole emit.
+ */
+function TypeExpressionBody(props: TypeExpressionProps): Children {
+ const { $ } = useTsp();
+ const type = props.type;
+
+ switch (type.kind) {
+ // Wrappers that carry the type we actually want to render.
+ case "ModelProperty":
+ case "UnionVariant":
+ return ;
+
+ // C# has no way to type something as one specific enum member, so a property typed
+ // `kind: Color.red` is rendered using the enum the member belongs to.
+ case "EnumMember":
+ return ;
+
+ case "Union": {
+ const innerType = getNullableUnionInnerType(type);
+ if (innerType) {
+ return code`${()}?`;
+ }
+ break; // Named unions are declarations; anything else falls through.
+ }
+
+ // C# has no tuple-of-values type; an array of the element type is the closest match.
+ case "Tuple":
+ return type.values.length > 0
+ ? code`${()}[]`
+ : code`object[]`;
+
+ case "StringTemplate":
+ return "string";
+
+ case "TemplateParameter":
+ return getTypeName(type);
+
+ case "Intrinsic":
+ return getScalarIntrinsicExpression($, type);
+ }
+
+ if (isDeclaration($, type)) {
+ return ;
+ }
+ if ($.scalar.is(type)) {
+ return getScalarIntrinsicExpression($, type);
+ } else if ($.array.is(type)) {
+ return code`${()}[]`;
+ } else if ($.record.is(type)) {
+ return code`IDictionary)}>`;
+ } else if ($.literal.isString(type)) {
+ // c# doesn't have literal types, so we map them to their corresponding C# types in general
+ return code`string`;
+ } else if ($.literal.isNumeric(type)) {
+ return Number.isInteger(type.value) ? code`int` : code`double`;
+ } else if ($.literal.isBoolean(type)) {
+ return code`bool`;
+ }
+
+ reportDiagnostic($.program, { code: "csharp-unsupported-type", target: type });
+ return "object";
+}
+
const intrinsicNameToCSharpType = new Map([
// Core types
["unknown", "object"], // Matches C#'s `object`
["string", "string"], // Matches C#'s `string`
["boolean", "bool"], // Matches C#'s `bool`
- ["null", "null"], // Matches C#'s `null`
+ ["null", "object"], // C# has no null type; `object` is the only thing null inhabits
["void", "void"], // Matches C#'s `void`
- ["never", null], // No direct equivalent in C#
+ ["never", "void"], // C# has no bottom type; `void` is the closest equivalent
["bytes", "byte[]"], // Matches C#'s `byte[]`
// Numeric types
@@ -96,10 +126,7 @@ const intrinsicNameToCSharpType = new Map([
["url", "Uri"], // Matches C#'s `Uri`
]);
-export function getScalarIntrinsicExpression(
- $: Typekit,
- type: Scalar | IntrinsicType,
-): string | null {
+export function getScalarIntrinsicExpression($: Typekit, type: Scalar | IntrinsicType): string {
let intrinsicName: string;
if ($.scalar.isUtcDateTime(type) || $.scalar.extendsUtcDateTime(type)) {
@@ -114,7 +141,7 @@ export function getScalarIntrinsicExpression(
const csType = intrinsicNameToCSharpType.get(intrinsicName);
if (!csType) {
- reportTypescriptDiagnostic($.program, { code: "typescript-unsupported-scalar", target: type });
+ reportDiagnostic($.program, { code: "csharp-unsupported-scalar", target: type });
return "object"; // Fallback to object if unsupported
}
@@ -127,10 +154,7 @@ function isDeclaration($: Typekit, type: Type): boolean {
case "Interface":
case "Enum":
case "Operation":
- case "EnumMember":
return true;
- case "UnionVariant":
- return false;
case "Model":
if ($.array.is(type) || $.record.is(type)) {
diff --git a/packages/emitter-framework/src/csharp/components/utils/index.ts b/packages/emitter-framework/src/csharp/components/utils/index.ts
index 2d6469f152e..1c3a62a5934 100644
--- a/packages/emitter-framework/src/csharp/components/utils/index.ts
+++ b/packages/emitter-framework/src/csharp/components/utils/index.ts
@@ -1,3 +1,4 @@
export { getDocComments } from "./doc-comments.jsx";
export { getNullableUnionInnerType } from "./nullable-util.js";
export { declarationRefkeys, efRefkey } from "./refkey.js";
+export { isCSharpValueType } from "./value-type.js";
diff --git a/packages/emitter-framework/src/csharp/components/utils/value-type.ts b/packages/emitter-framework/src/csharp/components/utils/value-type.ts
new file mode 100644
index 00000000000..ec32e9d5cf7
--- /dev/null
+++ b/packages/emitter-framework/src/csharp/components/utils/value-type.ts
@@ -0,0 +1,64 @@
+import type { Type } from "@typespec/compiler";
+import type { Typekit } from "@typespec/compiler/typekit";
+
+/**
+ * TypeSpec std scalars whose C# representation is a value type (struct) rather than a
+ * reference type. Anything not listed here — notably `string`, `bytes` and `url` — maps to
+ * a C# reference type.
+ */
+const valueTypeScalarNames: ReadonlySet = new Set([
+ "numeric",
+ "integer",
+ "float",
+ "int8",
+ "int16",
+ "int32",
+ "int64",
+ "uint8",
+ "uint16",
+ "uint32",
+ "uint64",
+ "safeint",
+ "float32",
+ "float64",
+ "decimal",
+ "decimal128",
+ "boolean",
+ "plainDate",
+ "plainTime",
+ "utcDateTime",
+ "offsetDateTime",
+ "duration",
+ "unixTimestamp32",
+]);
+
+/**
+ * Returns true when the TypeSpec type is emitted as a C# value type (struct).
+ *
+ * This is what decides whether an optional value needs an explicit `?` suffix: reference
+ * types are already nullable under `#nullable enable`, value types are not.
+ */
+export function isCSharpValueType($: Typekit, type: Type): boolean {
+ switch (type.kind) {
+ case "Boolean":
+ case "Number":
+ return true;
+ case "String":
+ case "StringTemplate":
+ return false;
+ case "Enum":
+ return true;
+ case "EnumMember":
+ return true;
+ case "Union":
+ // A union that maps onto a C# enum is a value type; any other union degrades to
+ // `object`, which is not.
+ return Boolean(type.name) && $.union.isValidEnum(type);
+ case "ModelProperty":
+ return isCSharpValueType($, type.type);
+ case "Scalar":
+ return valueTypeScalarNames.has($.scalar.getStdBase(type)?.name ?? type.name);
+ default:
+ return false;
+ }
+}
diff --git a/packages/emitter-framework/src/lib.ts b/packages/emitter-framework/src/lib.ts
index 7c8e3aa3ccf..86efc0bad82 100644
--- a/packages/emitter-framework/src/lib.ts
+++ b/packages/emitter-framework/src/lib.ts
@@ -10,6 +10,20 @@ export const $lib = createTypeSpecLibrary({
severity: "error",
description: "A type declaration must have a name",
},
+ "csharp-unsupported-scalar": {
+ severity: "warning",
+ messages: {
+ default: "Unsupported scalar type, falling back to object",
+ },
+ description: "This scalar has no C# equivalent",
+ },
+ "csharp-unsupported-type": {
+ severity: "warning",
+ messages: {
+ default: "Unsupported type, falling back to object",
+ },
+ description: "This type has no C# equivalent",
+ },
},
});
diff --git a/packages/http-server-csharp/src/components/controller-action/controller-action.tsx b/packages/http-server-csharp/src/components/controller-action/controller-action.tsx
index 6588a190d1e..a79b1f3ef76 100644
--- a/packages/http-server-csharp/src/components/controller-action/controller-action.tsx
+++ b/packages/http-server-csharp/src/components/controller-action/controller-action.tsx
@@ -3,9 +3,9 @@ import * as cs from "@alloy-js/csharp";
import { Attribute } from "@alloy-js/csharp";
import { isErrorModel, isVoidType } from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
+import { getDocComments } from "@typespec/emitter-framework/csharp";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
import { AspNetMvc } from "../../utils/csharp-libs.jsx";
-import { getDocComments } from "../../utils/doc-comments.jsx";
import { getHttpVerbAttribute, getRouteTemplate } from "../../utils/http-helpers.js";
import type { RequestModelInfo } from "../request-models.jsx";
import { TypeExpression } from "../type-expression/type-expression.jsx";
diff --git a/packages/http-server-csharp/src/components/enums/enums.tsx b/packages/http-server-csharp/src/components/enums/enums.tsx
index 8fdcf3d9fa7..e4a2c01f4b3 100644
--- a/packages/http-server-csharp/src/components/enums/enums.tsx
+++ b/packages/http-server-csharp/src/components/enums/enums.tsx
@@ -2,6 +2,7 @@ import type { Refkey } from "@alloy-js/core";
import { For, type Children } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
import { Attribute } from "@alloy-js/csharp";
+import { Serialization } from "@alloy-js/csharp/global/System/Text/Json";
import {
type Enum,
type Namespace as TspNamespace,
@@ -9,8 +10,7 @@ import {
type Union,
} from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
-import { JsonSerialization } from "../../utils/csharp-libs.jsx";
-import { getDocComments } from "../../utils/doc-comments.jsx";
+import { getDocComments } from "@typespec/emitter-framework/csharp";
import { getSubNamespaceParts } from "../../utils/namespace-utils.js";
import { CSharpFile } from "../csharp-file.jsx";
import { efRefkey } from "../type-expression/type-expression.jsx";
@@ -88,7 +88,7 @@ export function Enums(props: EnumsProps): Children {
const enumDecl = (
<>
@@ -103,7 +103,7 @@ export function Enums(props: EnumsProps): Children {
<>
diff --git a/packages/http-server-csharp/src/components/interfaces/interfaces.tsx b/packages/http-server-csharp/src/components/interfaces/interfaces.tsx
index c49fc290cbc..6d9374001e0 100644
--- a/packages/http-server-csharp/src/components/interfaces/interfaces.tsx
+++ b/packages/http-server-csharp/src/components/interfaces/interfaces.tsx
@@ -3,9 +3,9 @@ import * as cs from "@alloy-js/csharp";
import type { Interface, Operation } from "@typespec/compiler";
import { isTemplateDeclaration, isVoidType } from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
+import { getDocComments } from "@typespec/emitter-framework/csharp";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
import { getUniqueItems } from "@typespec/json-schema";
-import { getDocComments } from "../../utils/doc-comments.jsx";
import { getSuccessReturnType } from "../../utils/return-type-helpers.js";
import { TypeExpression } from "../type-expression/type-expression.jsx";
diff --git a/packages/http-server-csharp/src/components/models/error-models.tsx b/packages/http-server-csharp/src/components/models/error-models.tsx
index c0b7b0e3cc4..9a65d076d2f 100644
--- a/packages/http-server-csharp/src/components/models/error-models.tsx
+++ b/packages/http-server-csharp/src/components/models/error-models.tsx
@@ -1,7 +1,8 @@
import { type Children } from "@alloy-js/core";
import type { ParameterProps } from "@alloy-js/csharp";
import * as cs from "@alloy-js/csharp";
-import { isErrorModel, type Model, type Program } from "@typespec/compiler";
+import { isErrorModel, type Model } from "@typespec/compiler";
+import type { Typekit } from "@typespec/compiler/typekit";
import { getHeaderFieldName, isHeader, isStatusCode } from "@typespec/http";
import {
getAllProperties,
@@ -13,18 +14,20 @@ import {
} from "./model-helpers.js";
/** Generates the constructor for an error model. */
-export function getErrorConstructor(program: Program, model: Model, className: string): Children {
- const statusCode = getErrorStatusCode(program, model);
- const isChild = model.baseModel && isErrorModel(program, model.baseModel);
+export function getErrorConstructor($: Typekit, model: Model, className: string): Children {
+ const statusCode = getErrorStatusCode($.program, model);
+ const isChild = model.baseModel && isErrorModel($.program, model.baseModel);
const namePolicy = cs.createCSharpNamePolicy();
// For child error models, only use own properties (not inherited)
// For root error models, use all properties including inherited
- const props = isChild ? Array.from(model.properties.values()) : getAllProperties(program, model);
+ const props = isChild
+ ? Array.from(model.properties.values())
+ : getAllProperties($.program, model);
// Separate properties into required and optional/default
const sortedProps = props
- .filter((p) => !isStatusCode(program, p))
+ .filter((p) => !isStatusCode($.program, p))
.map((prop) => {
const defaultValue = prop.defaultValue ? getDefaultValueString(prop.defaultValue) : undefined;
const literalValue = getLiteralValue(prop.type);
@@ -53,13 +56,13 @@ export function getErrorConstructor(program: Program, model: Model, className: s
propName = propName === "Value" ? "ValueName" : `${propName}Prop`;
}
- const csharpType = getCSharpTypeString(program, prop.type);
+ const csharpType = getCSharpTypeString($, prop.type);
const defaultStr = defaultValue ? defaultValue : prop.optional ? "default" : undefined;
parameters.push({ name: prop.name, type: csharpType, default: defaultStr });
bodyParts.push(`${propName} = ${prop.name};`);
- if (isHeader(program, prop)) {
- const headerName = getHeaderFieldName(program, prop);
+ if (isHeader($.program, prop)) {
+ const headerName = getHeaderFieldName($.program, prop);
headerParts.push(`{"${headerName}", ${prop.name}}`);
} else {
valueParts.push(`${prop.name} = ${prop.name}`);
diff --git a/packages/http-server-csharp/src/components/models/model-helpers.ts b/packages/http-server-csharp/src/components/models/model-helpers.ts
index 094d5189b84..c1e6f28f4cd 100644
--- a/packages/http-server-csharp/src/components/models/model-helpers.ts
+++ b/packages/http-server-csharp/src/components/models/model-helpers.ts
@@ -13,6 +13,7 @@ import {
import type { useTsp } from "@typespec/emitter-framework";
import { isStatusCode } from "@typespec/http";
import { getUnionEnumMembers, isUnionEnum } from "../enums/enums.jsx";
+import { getServerScalarName } from "../type-expression/scalar-overrides.js";
import { assignAnonymousName } from "./anonymous-models.js";
/** Gets the string representation of a literal or default value. */
@@ -246,34 +247,16 @@ export function getErrorStatusCode(
return { value: minVal ?? "default" };
}
-/** Gets a simple C# type name string for a TypeSpec type. */
-export function getCSharpTypeString(program: Program, type: Type): string {
+/**
+ * Gets a simple C# type name string for a TypeSpec type.
+ *
+ * Used where a type name is needed as plain text rather than a rendered reference, such as
+ * error-model constructor parameters. Scalars resolve through {@link getServerScalarName} so
+ * the parameter type always agrees with the type of the property it is assigned to.
+ */
+export function getCSharpTypeString($: ReturnType["$"], type: Type): string {
if (type.kind === "Scalar") {
- const scalarMap: Record = {
- string: "string",
- int8: "sbyte",
- int16: "short",
- int32: "int",
- int64: "long",
- uint8: "byte",
- uint16: "ushort",
- uint32: "uint",
- uint64: "ulong",
- float32: "float",
- float64: "double",
- boolean: "bool",
- plainDate: "DateOnly",
- plainTime: "TimeOnly",
- utcDateTime: "DateTimeOffset",
- offsetDateTime: "DateTimeOffset",
- duration: "TimeSpan",
- bytes: "byte[]",
- decimal: "decimal",
- decimal128: "decimal",
- url: "Uri",
- safeint: "long",
- };
- return scalarMap[type.name] ?? type.name;
+ return getServerScalarName($, type);
}
if (type.kind === "String") return "string";
if (type.kind === "Boolean") return "bool";
diff --git a/packages/http-server-csharp/src/components/models/models.tsx b/packages/http-server-csharp/src/components/models/models.tsx
index 4e8bdffe46c..22b680be61a 100644
--- a/packages/http-server-csharp/src/components/models/models.tsx
+++ b/packages/http-server-csharp/src/components/models/models.tsx
@@ -1,6 +1,7 @@
import { code, For, type Children } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
import { Attribute } from "@alloy-js/csharp";
+import { Serialization } from "@alloy-js/csharp/global/System/Text/Json";
import {
isErrorModel,
isVoidType,
@@ -9,12 +10,11 @@ import {
type Namespace as TspNamespace,
} from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
+import { getDocComments } from "@typespec/emitter-framework/csharp";
import { isStatusCode } from "@typespec/http";
import { getUniqueItems } from "@typespec/json-schema";
import { useEmitterOptions } from "../../context/emitter-options-context.js";
import { getPropertyAttributes } from "../../utils/attributes.jsx";
-import { JsonSerialization } from "../../utils/csharp-libs.jsx";
-import { getDocComments } from "../../utils/doc-comments.jsx";
import { getSubNamespaceParts } from "../../utils/namespace-utils.js";
import { CSharpFile } from "../csharp-file.jsx";
import { efRefkey, TypeExpression } from "../type-expression/type-expression.jsx";
@@ -116,9 +116,7 @@ function ServerClassDeclaration(props: ServerClassDeclarationProps): Children {
}
// Generate constructor for error models
- const errorConstructor = isError
- ? getErrorConstructor($.program, props.type, className)
- : undefined;
+ const errorConstructor = isError ? getErrorConstructor($, props.type, className) : undefined;
// For error models with base model, check if base is also an error (child constructor)
const hasChildConstructor =
@@ -178,7 +176,7 @@ function ServerProperty(props: ServerPropertyProps): Children {
const { $ } = useTsp();
const namePolicy = cs.useCSharpNamePolicy();
const propType = props.type.type;
- const attrs = getPropertyAttributes($.program, props.type);
+ const attrs = getPropertyAttributes($, props.type);
// Determine property name, handling error model conflicts
let propName = props.type.name;
@@ -193,10 +191,7 @@ function ServerProperty(props: ServerPropertyProps): Children {
const csharpName = namePolicy.getName(propName, "class-property");
if (csharpName !== props.type.name) {
attrs.unshift(
- ,
+ ,
);
}
diff --git a/packages/http-server-csharp/src/components/request-models.tsx b/packages/http-server-csharp/src/components/request-models.tsx
index f5434c769e8..de0aaa73d34 100644
--- a/packages/http-server-csharp/src/components/request-models.tsx
+++ b/packages/http-server-csharp/src/components/request-models.tsx
@@ -1,11 +1,11 @@
import { For, type Children } from "@alloy-js/core";
import * as cs from "@alloy-js/csharp";
import { Attribute } from "@alloy-js/csharp";
+import { Serialization } from "@alloy-js/csharp/global/System/Text/Json";
import { isVoidType } from "@typespec/compiler";
import { useTsp } from "@typespec/emitter-framework";
+import { getDocComments } from "@typespec/emitter-framework/csharp";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
-import { JsonSerialization } from "../utils/csharp-libs.jsx";
-import { getDocComments } from "../utils/doc-comments.jsx";
import { CSharpFile } from "./csharp-file.jsx";
import { TypeExpression } from "./type-expression/type-expression.jsx";
@@ -72,7 +72,7 @@ function RequestModelClass(props: RequestModelClassProps): Children {
if (propName !== property.name) {
attrs.push(
,
);
diff --git a/packages/http-server-csharp/src/components/type-expression/scalar-overrides.test.ts b/packages/http-server-csharp/src/components/type-expression/scalar-overrides.test.ts
new file mode 100644
index 00000000000..04b1865a0be
--- /dev/null
+++ b/packages/http-server-csharp/src/components/type-expression/scalar-overrides.test.ts
@@ -0,0 +1,60 @@
+import { Tester } from "#test/tester.js";
+import { type TesterInstance } from "@typespec/compiler/testing";
+import { $ } from "@typespec/compiler/typekit";
+import { beforeEach, expect, it } from "vitest";
+import { getServerScalarName } from "./scalar-overrides.js";
+
+let runner: TesterInstance;
+
+beforeEach(async () => {
+ runner = await Tester.createInstance();
+});
+
+async function scalarName(ref: string): Promise {
+ await runner.compile(`
+ model Test { test: ${ref}; }
+ `);
+ const tk = $(runner.program);
+ const model = runner.program.resolveTypeReference("Test")[0];
+ const scalar = (model as any).properties.get("test").type;
+ return getServerScalarName(tk, scalar);
+}
+
+it.each([
+ // Server overrides of the emitter-framework defaults.
+ ["plainDate", "DateTime"],
+ ["plainTime", "DateTime"],
+ ["url", "string"],
+ ["safeint", "long"],
+ ["int8", "SByte"],
+ ["uint8", "Byte"],
+ ["int16", "Int16"],
+ ["uint16", "UInt16"],
+ ["uint32", "UInt32"],
+ ["uint64", "UInt64"],
+ // Inherited from the emitter-framework defaults.
+ ["string", "string"],
+ ["int32", "int"],
+ ["int64", "long"],
+ ["float32", "float"],
+ ["float64", "double"],
+ ["boolean", "bool"],
+ ["bytes", "byte[]"],
+ ["decimal", "decimal"],
+ ["utcDateTime", "DateTimeOffset"],
+ ["offsetDateTime", "DateTimeOffset"],
+ ["duration", "TimeSpan"],
+])("%s => %s", async (tspType, csType) => {
+ expect(await scalarName(tspType)).toBe(csType);
+});
+
+it("resolves custom scalars through the base they extend", async () => {
+ await runner.compile(`
+ scalar myDate extends plainDate;
+ model Test { test: myDate; }
+ `);
+ const tk = $(runner.program);
+ const model = runner.program.resolveTypeReference("Test")[0];
+ const scalar = (model as any).properties.get("test").type;
+ expect(getServerScalarName(tk, scalar)).toBe("DateTime");
+});
diff --git a/packages/http-server-csharp/src/components/type-expression/scalar-overrides.ts b/packages/http-server-csharp/src/components/type-expression/scalar-overrides.ts
new file mode 100644
index 00000000000..589b522546d
--- /dev/null
+++ b/packages/http-server-csharp/src/components/type-expression/scalar-overrides.ts
@@ -0,0 +1,58 @@
+import type { Scalar } from "@typespec/compiler";
+import type { Typekit } from "@typespec/compiler/typekit";
+import { getScalarIntrinsicExpression } from "@typespec/emitter-framework/csharp";
+
+/**
+ * The scalars whose C# representation differs from the emitter-framework defaults:
+ *
+ * - `plainDate` / `plainTime` → `DateTime` (not `DateOnly` / `TimeOnly`)
+ * - `url` → `string` (not `Uri`)
+ * - `safeint` → `long` (not `int`)
+ * - sized integers use CLR type names (`SByte`, `Int16`, …) rather than C# keywords
+ *
+ * These reproduce the output of the pre-Alloy emitter and are deliberate, not oversights.
+ */
+export function getServerScalarOverrides($: Typekit): [Scalar, string][] {
+ return [
+ [$.builtin.plainDate, "DateTime"],
+ [$.builtin.plainTime, "DateTime"],
+ [$.builtin.url, "string"],
+ [$.builtin.int8, "SByte"],
+ [$.builtin.uint8, "Byte"],
+ [$.builtin.int16, "Int16"],
+ [$.builtin.uint16, "UInt16"],
+ [$.builtin.uint32, "UInt32"],
+ [$.builtin.uint64, "UInt64"],
+ [$.builtin.safeInt, "long"],
+ ];
+}
+
+/**
+ * Resolves the C# type name for a scalar, applying the server overrides on top of the
+ * emitter-framework defaults.
+ *
+ * This is the single source of truth for scalar naming. Anywhere a C# type *name* is needed
+ * outside of a rendering context — constraint attribute type arguments, error constructor
+ * parameter types — must go through here so that the name always agrees with what
+ * `TypeExpression` renders for the same scalar.
+ */
+export function getServerScalarName($: Typekit, scalar: Scalar): string {
+ const overrides = new Map(getServerScalarOverrides($));
+ // Custom scalars (`scalar myDate extends plainDate`) inherit their base's mapping.
+ let current: Scalar | undefined = scalar;
+ while (current) {
+ const override = overrides.get(current);
+ if (override) return override;
+ current = current.baseScalar;
+ }
+ return getScalarIntrinsicExpression($, scalar);
+}
+
+/**
+ * Like {@link getServerScalarName}, but returns undefined for scalars that do not derive
+ * from a TypeSpec std scalar, where no meaningful C# type name can be produced.
+ */
+export function tryGetServerScalarName($: Typekit, scalar: Scalar): string | undefined {
+ if (!$.scalar.getStdBase(scalar)) return undefined;
+ return getServerScalarName($, scalar);
+}
diff --git a/packages/http-server-csharp/src/diagnostics.ts b/packages/http-server-csharp/src/diagnostics.ts
index 352bbce82ea..4d7ed86e413 100644
--- a/packages/http-server-csharp/src/diagnostics.ts
+++ b/packages/http-server-csharp/src/diagnostics.ts
@@ -1,10 +1,10 @@
+import { isValidCSharpIdentifier } from "@alloy-js/csharp";
import type { Interface, Model, Program } from "@typespec/compiler";
import { isTemplateDeclaration, type Namespace as TspNamespace } from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
import { assignAnonymousName } from "./components/models/anonymous-models.js";
import { reportDiagnostic } from "./lib.js";
-import { isValidCSharpIdentifier } from "./utils/naming.js";
/**
* Reports diagnostic warnings for models, scalars, and operations.
diff --git a/packages/http-server-csharp/src/service-discovery.ts b/packages/http-server-csharp/src/service-discovery.ts
index 1006defa819..de13f4f6cb8 100644
--- a/packages/http-server-csharp/src/service-discovery.ts
+++ b/packages/http-server-csharp/src/service-discovery.ts
@@ -8,7 +8,7 @@ import {
type Namespace as TspNamespace,
} from "@typespec/compiler";
import type { useTsp } from "@typespec/emitter-framework";
-import { getCSharpIdentifier, NameCasingType } from "./utils/naming.js";
+import { getCSharpNamespaceName } from "./utils/namespace-utils.js";
/**
* Collects the namespaces whose declarations are emitted even when nothing references them.
@@ -142,6 +142,5 @@ export function getServiceNamespaceName(
const serviceNs = findServiceNs(globalNs);
if (!serviceNs) return undefined;
- const fullName = getFullName(serviceNs);
- return getCSharpIdentifier(fullName, NameCasingType.Namespace);
+ return getCSharpNamespaceName(getFullName(serviceNs));
}
diff --git a/packages/http-server-csharp/src/service-resolution.test.ts b/packages/http-server-csharp/src/service-resolution.test.ts
index f8566dd9716..fad170a0036 100644
--- a/packages/http-server-csharp/src/service-resolution.test.ts
+++ b/packages/http-server-csharp/src/service-resolution.test.ts
@@ -141,3 +141,15 @@ it("emits every namespace when no service is declared", async () => {
expect(resolution.models.map((m) => m.name).sort()).toEqual(["Standalone", "Widget"]);
});
+
+it("pascal-cases each part of the service namespace name", async () => {
+ const resolution = await resolve(`
+ @service
+ namespace my_service.sub_models {
+ model Widget { id: string; }
+ op read(): Widget;
+ }
+ `);
+
+ expect(resolution.serviceNamespaceName).toBe("MyService.SubModels");
+});
diff --git a/packages/http-server-csharp/src/utils/attributes.tsx b/packages/http-server-csharp/src/utils/attributes.tsx
index 620a2567cc9..aabea3f6628 100644
--- a/packages/http-server-csharp/src/utils/attributes.tsx
+++ b/packages/http-server-csharp/src/utils/attributes.tsx
@@ -1,5 +1,6 @@
-import { type Children } from "@alloy-js/core";
+import { code, type Children } from "@alloy-js/core";
import { Attribute } from "@alloy-js/csharp";
+import { Serialization } from "@alloy-js/csharp/global/System/Text/Json";
import {
getEncode,
getMaxItems,
@@ -14,69 +15,27 @@ import {
isArrayModelType,
resolveEncodedName,
type ModelProperty,
- type Program,
type Scalar,
type Type,
} from "@typespec/compiler";
+import type { Typekit } from "@typespec/compiler/typekit";
import { isUnionEnum } from "../components/enums/enums.jsx";
-import { JsonSerialization } from "./csharp-libs.jsx";
+import { tryGetServerScalarName } from "../components/type-expression/scalar-overrides.js";
-/**
- * Maps a TypeSpec scalar name to the C# type name used in attributes.
- * This follows the old emitter's mapping.
- */
-function scalarToCSharpTypeName(program: Program, scalar: Scalar): string | undefined {
- const stdBase = getStdBase(program, scalar);
- if (!stdBase) return undefined;
- const map: Record = {
- int8: "SByte",
- uint8: "Byte",
- int16: "Int16",
- int32: "int",
- int64: "long",
- uint16: "UInt16",
- uint32: "UInt32",
- uint64: "UInt64",
- safeint: "long",
- float32: "float",
- float64: "double",
- decimal: "decimal",
- decimal128: "decimal",
- numeric: "double",
- integer: "int",
- float: "double",
- boolean: "bool",
- string: "string",
- bytes: "byte[]",
- plainDate: "DateTime",
- plainTime: "DateTime",
- utcDateTime: "DateTimeOffset",
- offsetDateTime: "DateTimeOffset",
- duration: "TimeSpan",
- url: "string",
- };
- return map[stdBase.name];
-}
-
-function getStdBase(program: Program, scalar: Scalar): Scalar | undefined {
- if (program.checker.isStdType(scalar)) return scalar;
- if (scalar.baseScalar) return getStdBase(program, scalar.baseScalar);
- return undefined;
+function getStdBase($: Typekit, scalar: Scalar): Scalar | undefined {
+ return $.scalar.getStdBase(scalar) ?? undefined;
}
type WireEncoding = { encoding: string; type: Type };
-function getScalarEncoding(
- program: Program,
- type: Scalar | ModelProperty,
-): WireEncoding | undefined {
- const encode = getEncode(program, type);
+function getScalarEncoding($: Typekit, type: Scalar | ModelProperty): WireEncoding | undefined {
+ const encode = getEncode($.program, type);
if (encode) return { encoding: encode.encoding ?? "string", type: encode.type };
if (type.kind === "ModelProperty" && type.type.kind === "Scalar") {
- return getScalarEncoding(program, type.type);
+ return getScalarEncoding($, type.type);
}
if (type.kind === "Scalar" && type.baseScalar) {
- return getScalarEncoding(program, type.baseScalar);
+ return getScalarEncoding($, type.baseScalar);
}
return undefined;
}
@@ -85,11 +44,11 @@ function getScalarEncoding(
* Get all C# attributes for a model property.
* Returns an array of attribute strings like `[JsonConverter(typeof(TimeSpanDurationConverter))]`
*/
-export function getPropertyAttributes(program: Program, property: ModelProperty): Children[] {
+export function getPropertyAttributes($: Typekit, property: ModelProperty): Children[] {
const attrs: Children[] = [];
// Encoding attributes (JsonConverter)
- const encodingAttrs = getEncodingAttributes(program, property);
+ const encodingAttrs = getEncodingAttributes($, property);
attrs.push(...encodingAttrs);
// JsonStringEnumConverter for enum and union-as-enum properties
@@ -99,49 +58,49 @@ export function getPropertyAttributes(program: Program, property: ModelProperty)
) {
attrs.push(
,
);
}
// Constraint attributes
- const numericAttr = getNumericConstraintAttribute(program, property);
+ const numericAttr = getNumericConstraintAttribute($, property);
if (numericAttr) attrs.push(numericAttr);
- const stringAttr = getStringConstraintAttribute(program, property);
+ const stringAttr = getStringConstraintAttribute($, property);
if (stringAttr) attrs.push(stringAttr);
- const arrayAttr = getArrayConstraintAttribute(program, property);
+ const arrayAttr = getArrayConstraintAttribute($, property);
if (arrayAttr) attrs.push(arrayAttr);
// JsonPropertyName (only when encoded name differs)
- const nameAttr = getEncodedNameAttribute(program, property);
+ const nameAttr = getEncodedNameAttribute($, property);
if (nameAttr) attrs.push(nameAttr);
// SafeInt constraint
if (property.type.kind === "Scalar") {
- const safeIntAttr = getSafeIntAttribute(program, property.type);
+ const safeIntAttr = getSafeIntAttribute($, property.type);
if (safeIntAttr) attrs.push(safeIntAttr);
}
return attrs;
}
-function getEncodingAttributes(program: Program, property: ModelProperty): Children[] {
+function getEncodingAttributes($: Typekit, property: ModelProperty): Children[] {
const result: Children[] = [];
if (property.type.kind !== "Scalar") return result;
- const stdBase = getStdBase(program, property.type);
+ const stdBase = getStdBase($, property.type);
if (!stdBase) return result;
- const encoding = getScalarEncoding(program, property);
+ const encoding = getScalarEncoding($, property);
switch (stdBase.name) {
case "duration":
result.push(
,
);
@@ -149,7 +108,7 @@ function getEncodingAttributes(program: Program, property: ModelProperty): Child
case "unixTimestamp32":
result.push(
,
);
@@ -158,7 +117,7 @@ function getEncodingAttributes(program: Program, property: ModelProperty): Child
if (encoding && encoding.encoding.toLowerCase() === "base64url") {
result.push(
,
);
@@ -169,7 +128,7 @@ function getEncodingAttributes(program: Program, property: ModelProperty): Child
if (encoding && encoding.encoding.toLowerCase() === "unixtimestamp") {
result.push(
,
);
@@ -180,16 +139,13 @@ function getEncodingAttributes(program: Program, property: ModelProperty): Child
return result;
}
-function getNumericConstraintAttribute(
- program: Program,
- property: ModelProperty,
-): Children | undefined {
+function getNumericConstraintAttribute($: Typekit, property: ModelProperty): Children | undefined {
if (property.type.kind !== "Scalar") return undefined;
- const minVal = getMinValue(program, property);
- const maxVal = getMaxValue(program, property);
- const minExcl = getMinValueExclusive(program, property);
- const maxExcl = getMaxValueExclusive(program, property);
+ const minVal = getMinValue($.program, property);
+ const maxVal = getMaxValue($.program, property);
+ const minExcl = getMinValueExclusive($.program, property);
+ const maxExcl = getMaxValueExclusive($.program, property);
if (
minVal === undefined &&
@@ -200,7 +156,7 @@ function getNumericConstraintAttribute(
return undefined;
}
- const csharpType = scalarToCSharpTypeName(program, property.type);
+ const csharpType = tryGetServerScalarName($, property.type);
if (!csharpType) return undefined;
const params: string[] = [];
@@ -215,13 +171,10 @@ function getNumericConstraintAttribute(
return `} args={params} />;
}
-function getStringConstraintAttribute(
- program: Program,
- property: ModelProperty,
-): Children | undefined {
- const minLen = getMinLength(program, property);
- const maxLen = getMaxLength(program, property);
- const pattern = getPattern(program, property);
+function getStringConstraintAttribute($: Typekit, property: ModelProperty): Children | undefined {
+ const minLen = getMinLength($.program, property);
+ const maxLen = getMaxLength($.program, property);
+ const pattern = getPattern($.program, property);
if (minLen === undefined && maxLen === undefined && pattern === undefined) return undefined;
@@ -233,12 +186,9 @@ function getStringConstraintAttribute(
return ;
}
-function getArrayConstraintAttribute(
- program: Program,
- property: ModelProperty,
-): Children | undefined {
- const minItems = getMinItems(program, property);
- const maxItems = getMaxItems(program, property);
+function getArrayConstraintAttribute($: Typekit, property: ModelProperty): Children | undefined {
+ const minItems = getMinItems($.program, property);
+ const maxItems = getMaxItems($.program, property);
if (minItems === undefined && maxItems === undefined) return undefined;
if (property.type.kind !== "Model" || !isArrayModelType(property.type)) return undefined;
@@ -246,7 +196,7 @@ function getArrayConstraintAttribute(
const elementType = property.type.indexer.value;
if (elementType.kind !== "Scalar") return undefined;
- const csharpType = scalarToCSharpTypeName(program, elementType);
+ const csharpType = tryGetServerScalarName($, elementType);
if (!csharpType) return undefined;
const params: string[] = [];
@@ -256,18 +206,16 @@ function getArrayConstraintAttribute(
return `} args={params} />;
}
-function getEncodedNameAttribute(program: Program, property: ModelProperty): Children | undefined {
- const encodedName = resolveEncodedName(program, property, "application/json");
+function getEncodedNameAttribute($: Typekit, property: ModelProperty): Children | undefined {
+ const encodedName = resolveEncodedName($.program, property, "application/json");
if (encodedName !== property.name) {
- return (
-
- );
+ return ;
}
return undefined;
}
-function getSafeIntAttribute(program: Program, scalar: Scalar): Children | undefined {
- const stdBase = getStdBase(program, scalar);
+function getSafeIntAttribute($: Typekit, scalar: Scalar): Children | undefined {
+ const stdBase = getStdBase($, scalar);
if (!stdBase || stdBase.name !== "safeint") return undefined;
return (
([
+ ...csharpKeywords,
+ ...csharpContextualKeywords,
+ "boolean",
+ "type",
+]);
+
+/**
+ * Builds the C# namespace name for a dotted TypeSpec namespace path.
+ *
+ * Alloy's name policy escapes keywords with a leading `@`, which is legal but unpleasant in
+ * a namespace, and it does not consider the shadowing cases above at all. So reserved
+ * segments are renamed (`Type` → `TypeName`) before the name policy casing is applied.
+ */
+export function getCSharpNamespaceName(dottedName: string): string {
+ const namePolicy = createCSharpNamePolicy();
+ return dottedName
+ .split(".")
+ .map((part) =>
+ namePolicy.getName(
+ namespaceReservedWords.has(part.toLowerCase()) ? `${part}Name` : part,
+ "namespace",
+ ),
+ )
+ .join(".");
+}
+
/**
* Gets the sub-namespace path of a type's namespace relative to the service namespace.
* For example, if the service namespace is "Microsoft.Contoso" and the type is in
diff --git a/packages/http-server-csharp/src/utils/naming.test.ts b/packages/http-server-csharp/src/utils/naming.test.ts
deleted file mode 100644
index d4d3445344b..00000000000
--- a/packages/http-server-csharp/src/utils/naming.test.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- getCSharpIdentifier,
- getValidChar,
- isValidCSharpIdentifier,
- NameCasingType,
- replaceCSharpReservedWord,
- transformInvalidIdentifier,
-} from "./naming.js";
-
-describe("getCSharpIdentifier", () => {
- it("converts to PascalCase for class context", () => {
- expect(getCSharpIdentifier("my-model", NameCasingType.Class)).toBe("MyModel");
- });
-
- it("converts to PascalCase for property context", () => {
- expect(getCSharpIdentifier("some-property", NameCasingType.Property)).toBe("SomeProperty");
- });
-
- it("converts to camelCase for parameter context", () => {
- expect(getCSharpIdentifier("some-param", NameCasingType.Parameter)).toBe("someParam");
- });
-
- it("converts to camelCase for variable context", () => {
- expect(getCSharpIdentifier("my-variable", NameCasingType.Variable)).toBe("myVariable");
- });
-
- it("handles namespace context with dots", () => {
- expect(getCSharpIdentifier("my-service.models", NameCasingType.Namespace)).toBe(
- "MyService.Models",
- );
- });
-
- it("replaces reserved words", () => {
- expect(getCSharpIdentifier("class", NameCasingType.Class)).toBe("ClassName");
- expect(getCSharpIdentifier("interface", NameCasingType.Class)).toBe("InterfaceName");
- expect(getCSharpIdentifier("namespace", NameCasingType.Class)).toBe("NamespaceName");
- });
-
- it("replaces contextual keywords", () => {
- expect(getCSharpIdentifier("async", NameCasingType.Class)).toBe("AsyncName");
- expect(getCSharpIdentifier("value", NameCasingType.Class)).toBe("ValueName");
- expect(getCSharpIdentifier("record", NameCasingType.Class)).toBe("RecordName");
- });
-
- it("returns Placeholder for undefined", () => {
- expect(getCSharpIdentifier(undefined as any)).toBe("Placeholder");
- });
-});
-
-describe("isValidCSharpIdentifier", () => {
- it("accepts valid identifiers", () => {
- expect(isValidCSharpIdentifier("MyClass")).toBe(true);
- expect(isValidCSharpIdentifier("_private")).toBe(true);
- expect(isValidCSharpIdentifier("name123")).toBe(true);
- });
-
- it("rejects invalid identifiers", () => {
- expect(isValidCSharpIdentifier("123start")).toBe(false);
- expect(isValidCSharpIdentifier("has-dash")).toBe(false);
- expect(isValidCSharpIdentifier("has space")).toBe(false);
- });
-
- it("accepts dots in namespace mode", () => {
- expect(isValidCSharpIdentifier("My.Namespace.Here", true)).toBe(true);
- });
-
- it("rejects dots in non-namespace mode", () => {
- expect(isValidCSharpIdentifier("My.Class", false)).toBe(false);
- });
-});
-
-describe("replaceCSharpReservedWord", () => {
- it("replaces reserved words case-insensitively", () => {
- expect(replaceCSharpReservedWord("class")).toBe("ClassName");
- expect(replaceCSharpReservedWord("CLASS")).toBe("ClassName");
- });
-
- it("does not replace non-reserved words", () => {
- expect(replaceCSharpReservedWord("myModel")).toBe("myModel");
- });
-});
-
-describe("getValidChar", () => {
- it("keeps valid starting characters", () => {
- expect(getValidChar("A", 0)).toBe("A");
- expect(getValidChar("_", 0)).toBe("_");
- });
-
- it("replaces invalid starting characters", () => {
- expect(getValidChar("1", 0)).toBe("Generated_1");
- expect(getValidChar("-", 0)).toBe("Generated_");
- });
-
- it("replaces non-word characters at other positions", () => {
- expect(getValidChar("-", 1)).toBe("_");
- expect(getValidChar(" ", 2)).toBe("_");
- });
-
- it("keeps valid characters at other positions", () => {
- expect(getValidChar("a", 1)).toBe("a");
- expect(getValidChar("3", 2)).toBe("3");
- });
-});
-
-describe("transformInvalidIdentifier", () => {
- it("transforms invalid identifier to valid one", () => {
- expect(transformInvalidIdentifier("1foo-bar")).toBe("Generated_1foo_bar");
- });
-
- it("keeps already valid identifiers", () => {
- expect(transformInvalidIdentifier("ValidName")).toBe("ValidName");
- });
-});
diff --git a/packages/http-server-csharp/src/utils/naming.ts b/packages/http-server-csharp/src/utils/naming.ts
deleted file mode 100644
index 00937eb5d24..00000000000
--- a/packages/http-server-csharp/src/utils/naming.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import { camelCase, pascalCase } from "change-case";
-
-/** C# reserved keywords that must be escaped in identifiers. */
-const reservedWords: string[] = [
- "abstract",
- "as",
- "base",
- "bool",
- "boolean",
- "break",
- "byte",
- "case",
- "catch",
- "char",
- "checked",
- "class",
- "const",
- "continue",
- "decimal",
- "default",
- "do",
- "double",
- "else",
- "enum",
- "event",
- "explicit",
- "extern",
- "false",
- "finally",
- "fixed",
- "float",
- "for",
- "foreach",
- "goto",
- "if",
- "implicit",
- "in",
- "int",
- "interface",
- "internal",
- "is",
- "lock",
- "long",
- "namespace",
- "new",
- "null",
- "object",
- "operator",
- "out",
- "override",
- "params",
- "private",
- "protected",
- "public",
- "readonly",
- "ref",
- "return",
- "sbyte",
- "sealed",
- "short",
- "sizeof",
- "stackalloc",
- "static",
- "string",
- "struct",
- "switch",
- "this",
- "throw",
- "true",
- "try",
- "type",
- "typeof",
- "uint",
- "ulong",
- "unchecked",
- "unsafe",
- "ushort",
- "using",
- "virtual",
- "void",
- "volatile",
- "while",
-];
-
-/** C# contextual keywords that are reserved in certain contexts. */
-const contextualWords: string[] = [
- "add",
- "allows",
- "alias",
- "and",
- "ascending",
- "args",
- "async",
- "await",
- "by",
- "descending",
- "dynamic",
- "equals",
- "field",
- "file",
- "from",
- "get",
- "global",
- "group",
- "init",
- "into",
- "join",
- "let",
- "managed",
- "nameof",
- "nint",
- "not",
- "notnull",
- "nuint",
- "on",
- "or",
- "orderby",
- "partial",
- "record",
- "remove",
- "required",
- "scoped",
- "select",
- "set",
- "unmanaged",
- "value",
- "var",
- "when",
- "where",
- "with",
- "yield",
-];
-
-const reservedMap: Map = new Map(
- [...reservedWords, ...contextualWords].map((w) => [w, `${pascalCase(w)}Name`]),
-);
-
-export enum NameCasingType {
- Class,
- Constant,
- Method,
- Namespace,
- Parameter,
- Property,
- Variable,
-}
-
-/**
- * Checks if a string is a valid C# identifier.
- * Optionally allows dots for namespace identifiers.
- */
-export function isValidCSharpIdentifier(identifier: string, isNamespace: boolean = false): boolean {
- if (!isNamespace) return identifier?.match(/^[A-Za-z_][\w]*$/) !== null;
- return identifier?.match(/^[A-Za-z_][\w.]*$/) !== null;
-}
-
-/**
- * Replaces C# reserved words with safe alternatives (e.g., "class" → "ClassName").
- */
-export function replaceCSharpReservedWord(identifier: string, context?: NameCasingType): string {
- const check = reservedMap.get(identifier.toLowerCase());
- if (check !== undefined) {
- return getCSharpIdentifier(check, context, false);
- }
- return identifier;
-}
-
-/**
- * Converts a name to a valid C# identifier with appropriate casing.
- */
-export function getCSharpIdentifier(
- name: string,
- context: NameCasingType = NameCasingType.Class,
- checkReserved: boolean = true,
-): string {
- if (name === undefined) return "Placeholder";
- if (checkReserved) {
- name = replaceCSharpReservedWord(name, context);
- }
- switch (context) {
- case NameCasingType.Namespace: {
- const parts: string[] = [];
- for (const part of name.split(".")) {
- parts.push(getCSharpIdentifier(part, NameCasingType.Class));
- }
- return parts.join(".");
- }
- case NameCasingType.Parameter:
- case NameCasingType.Variable:
- return camelCase(name);
- default:
- return pascalCase(name);
- }
-}
-
-/**
- * Replaces an invalid character at a given position with a safe alternative.
- */
-export function getValidChar(target: string, position: number): string {
- if (position === 0) {
- if (target.match(/[A-Za-z_]/)) return target;
- return `Generated_${target.match(/\w/) ? target : ""}`;
- }
- if (!target.match(/[\w]/)) return "_";
- return target;
-}
-
-/**
- * Transforms an invalid identifier into a valid one by replacing bad characters.
- */
-export function transformInvalidIdentifier(name: string): string {
- const chars: string[] = [];
- for (let i = 0; i < name.length; ++i) {
- chars.push(getValidChar(name.charAt(i), i));
- }
- return chars.join("");
-}