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/.chronus/changes/http-server-csharp-models-on-ef-2026-8-5.md b/.chronus/changes/http-server-csharp-models-on-ef-2026-8-5.md new file mode 100644 index 00000000000..0cc23afee95 --- /dev/null +++ b/.chronus/changes/http-server-csharp-models-on-ef-2026-8-5.md @@ -0,0 +1,9 @@ +--- +changeKind: internal +packages: + - "@typespec/http-server-csharp" +--- + +Build model, property and enum generation on `@typespec/emitter-framework` components + +The emitter carried private forks of the framework's `ClassDeclaration`, `Property` and `EnumDeclaration`. They are now consumed directly, with the emitter's own behavior expressed as a declaration override and component props. Generated output is unchanged. 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..64880c0f389 100644 --- a/packages/http-server-csharp/src/components/enums/enums.tsx +++ b/packages/http-server-csharp/src/components/enums/enums.tsx @@ -1,7 +1,5 @@ -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 { type Enum, type Namespace as TspNamespace, @@ -9,51 +7,46 @@ 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 { + EnumDeclaration as EfEnumDeclaration, + getDocComments, + type EnumDeclarationMember, +} 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"; -/** Normalized member info shared by both enums and union-enums. */ -interface EnumMemberInfo { - name: string; - serializedValue: string; - docSource: Type; - memberRefkey?: Refkey; -} - -/** Normalized enum info that abstracts over Enum and union-as-enum types. */ +/** Normalized declaration info that abstracts over `Enum` and union-as-enum types. */ interface EnumInfo { name: string; type: Enum | Union; namespace: TspNamespace | undefined; - members: EnumMemberInfo[]; + members: EnumDeclarationMember[]; } -function normalizeEnum(en: Enum): EnumInfo { +function normalizeEnum($: ReturnType["$"], en: Enum): EnumInfo { return { name: en.name, type: en, namespace: en.namespace, - members: Array.from(en.members.entries()).map(([key, value]) => ({ + members: Array.from(en.members.entries()).map(([key, member]) => ({ name: key, - serializedValue: typeof value.value === "string" ? value.value : key, - docSource: value, + jsonValue: typeof member.value === "string" ? member.value : key, + doc: getDocComments($, member), })), }; } -function normalizeUnionEnum(union: Union): EnumInfo { +function normalizeUnionEnum($: ReturnType["$"], union: Union): EnumInfo { return { name: union.name!, type: union, namespace: union.namespace, members: getUnionEnumMembers(union).map(({ name, value, variant }) => ({ name, - serializedValue: value, - docSource: variant, - memberRefkey: efRefkey(union, name), + jsonValue: value, + doc: getDocComments($, variant), + refkey: efRefkey(union, name), })), }; } @@ -75,47 +68,24 @@ export function Enums(props: EnumsProps): Children { const { $ } = useTsp(); const allEnums: EnumInfo[] = [ - ...props.enums.map(normalizeEnum), - ...props.unionEnums.map(normalizeUnionEnum), + ...props.enums.map((en) => normalizeEnum($, en)), + ...props.unionEnums.map((union) => normalizeUnionEnum($, union)), ]; return ( {(info) => { - const namePolicy = cs.useCSharpNamePolicy(); const subNsParts = getSubNamespaceParts(info.namespace, props.serviceNamespace); const enumDecl = ( - <> - - - - - {(member) => ( - <> - - - - - - )} - - - + ); const wrappedContent = subNsParts.reduceRight( 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..7510ff5a3af 100644 --- a/packages/http-server-csharp/src/components/models/model-helpers.ts +++ b/packages/http-server-csharp/src/components/models/model-helpers.ts @@ -7,12 +7,12 @@ import { type ModelProperty, type Program, type Type, - type Union, type Value, } from "@typespec/compiler"; 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. */ @@ -142,46 +142,6 @@ export function hasNonIntegerValues(en: Enum): boolean { return false; } -/** Returns true if the TypeSpec type maps to a C# value type (struct). */ -export function isValueType($: ReturnType["$"], type: Type): boolean { - // Handle literal types - if (type.kind === "Boolean" || type.kind === "Number") return true; - if (type.kind === "String") return false; - - if ($.scalar.is(type)) { - const baseName = $.scalar.getStdBase(type)?.name ?? type.name; - const valueTypes = new Set([ - "int8", - "int16", - "int32", - "int64", - "uint8", - "uint16", - "uint32", - "uint64", - "safeint", - "float32", - "float64", - "decimal", - "decimal128", - "boolean", - "numeric", - "integer", - "float", - "plainDate", - "plainTime", - "utcDateTime", - "offsetDateTime", - "duration", - "unixTimestamp32", - ]); - return valueTypes.has(baseName); - } - if ($.enum.is(type)) return true; - if (type.kind === "Union" && isUnionEnum(type as Union)) return true; - return false; -} - /** Returns true if any property of the model uses Record (mapped to JsonObject). */ export function modelNeedsJsonNodes($: ReturnType["$"], model: Model): boolean { for (const prop of model.properties.values()) { @@ -246,34 +206,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..3f08a03544c 100644 --- a/packages/http-server-csharp/src/components/models/models.tsx +++ b/packages/http-server-csharp/src/components/models/models.tsx @@ -1,37 +1,14 @@ -import { code, For, type Children } 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 { - isErrorModel, - isVoidType, - type Model, - type ModelProperty, - type Namespace as TspNamespace, -} from "@typespec/compiler"; +import { isErrorModel, type Model, type Namespace as TspNamespace } from "@typespec/compiler"; import { useTsp } from "@typespec/emitter-framework"; +import { ClassDeclaration as EfClassDeclaration } 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"; +import { efRefkey } from "../type-expression/type-expression.jsx"; import { getErrorConstructor } from "./error-models.jsx"; -import { - getDefaultValueString, - getEnumDefaultInitializer, - getLiteralValue, - getModelEmitName, - getScalarForLiteral, - getUnionVariantInitializer, - hasNonIntegerValues, - hasPropertyInChain, - isDuplicateExceptionName, - isValueType, - modelNeedsJsonNodes, -} from "./model-helpers.js"; +import { getModelEmitName, modelNeedsJsonNodes } from "./model-helpers.js"; // Re-export public API used by other modules export { getAnonymousModelName } from "./anonymous-models.js"; @@ -99,39 +76,29 @@ function ServerClassDeclaration(props: ServerClassDeclarationProps): Children { const { $ } = useTsp(); const namePolicy = cs.useCSharpNamePolicy(); const className = namePolicy.getName(props.emitName ?? props.type.name, "class"); - const refkeys = efRefkey(props.type); const isError = isErrorModel($.program, props.type); - const properties = Array.from(props.type.properties.entries()).filter( - ([_, p]) => !isVoidType(p.type), + // `@statusCode` is carried by the generated exception, not by a property. + const properties = Array.from(props.type.properties.values()).filter( + (p) => !(isError && isStatusCode($.program, p)), ); - // Determine base type - let baseType: Children | undefined; - if (props.type.baseModel) { - baseType = ; - } else if (isError) { - baseType = "HttpServiceException"; - } + const errorConstructor = isError ? getErrorConstructor($, props.type, className) : undefined; - // Generate constructor for error models - const errorConstructor = isError - ? getErrorConstructor($.program, props.type, className) - : undefined; - - // For error models with base model, check if base is also an error (child constructor) + // An error model that is itself subclassed needs a constructor its children can chain to. const hasChildConstructor = isError && props.type.derivedModels && props.type.derivedModels.length > 0; return ( - {errorConstructor} {errorConstructor && } @@ -147,126 +114,6 @@ function ServerClassDeclaration(props: ServerClassDeclarationProps): Children { /> )} {hasChildConstructor && } - - {([_, property]) => { - // Skip statusCode properties for error models - if (isError && isStatusCode($.program, property)) return undefined; - return ( - - ); - }} - - - ); -} - -interface ServerPropertyProps { - type: ModelProperty; - errorClassName?: string; - baseModel?: Model; -} - -/** - * Server-specific property that matches old emitter output. - * No `required`, no `[JsonPropertyName]`, no nullable `?` for reference types. - */ -function ServerProperty(props: ServerPropertyProps): Children { - const { $ } = useTsp(); - const namePolicy = cs.useCSharpNamePolicy(); - const propType = props.type.type; - const attrs = getPropertyAttributes($.program, props.type); - - // Determine property name, handling error model conflicts - let propName = props.type.name; - if (props.errorClassName) { - const csharpPropName = namePolicy.getName(propName, "class-property"); - if (csharpPropName === props.errorClassName || isDuplicateExceptionName(csharpPropName)) { - propName = csharpPropName === "Value" ? "ValueName" : `${csharpPropName}Prop`; - } - } - - // Add JsonPropertyName if the C# name differs from the original TypeSpec name - const csharpName = namePolicy.getName(propName, "class-property"); - if (csharpName !== props.type.name) { - attrs.unshift( - , - ); - } - - // Check if this property overrides a base model property (discriminator pattern) - const isOverride = props.baseModel ? hasPropertyInChain(props.baseModel, props.type.name) : false; - - // Check for union variant type (e.g., kind: PetType.Dog) — used as enum member initializer - const unionVariantInit = getUnionVariantInitializer(propType, namePolicy); - - // Check for enum default value (e.g., variety: WolfBreed = WolfBreed.dire) - const enumDefaultInit = getEnumDefaultInitializer(props.type, namePolicy); - - // For error models, properties get values from constructor, not as literals - const isErrorProp = !!props.errorClassName; - - // Check for literal values (the type itself is a literal) - const { collectionType } = useEmitterOptions(); - const literalInfo = isErrorProp - ? undefined - : (unionVariantInit ?? getLiteralValue(propType, collectionType)); - // Check for default values - const defaultValue = isErrorProp - ? undefined - : (enumDefaultInit ?? - (props.type.defaultValue ? getDefaultValueString(props.type.defaultValue) : undefined)); - - const initializer = literalInfo ?? defaultValue; - const isLiteralOnly = literalInfo !== undefined && defaultValue === undefined; - - // Check if the property type is a non-integer enum (C# enums can only be integers) - const isFloatEnum = - $.enum.is(propType) && hasNonIntegerValues(propType as import("@typespec/compiler").Enum); - - // For error model properties with literal types, use the scalar base type - // But not for union variant types — those should resolve to the enum type - const resolveToScalar = (isLiteralOnly && !unionVariantInit) || isErrorProp; - const resolvedType = resolveToScalar ? getScalarForLiteral(propType) : propType; - const needsNullable = props.type.optional && (isFloatEnum || isValueType($, resolvedType)); - - // Check if this is a @uniqueItems array → ISet - const isUniqueItems = getUniqueItems($.program, props.type); - const isArrayType = propType.kind === "Model" && $.array.is(propType); - - let typeExpr: Children; - if (isFloatEnum) { - typeExpr = code`double`; - } else if (isUniqueItems && isArrayType && propType.indexer?.value) { - typeExpr = ( - <> - ISet< - - > - - ); - } else { - typeExpr = ; - } - - return ( - 0 ? attrs : undefined} - get - set={!isLiteralOnly} - initializer={initializer} - /> + ); } diff --git a/packages/http-server-csharp/src/components/models/server-property.tsx b/packages/http-server-csharp/src/components/models/server-property.tsx new file mode 100644 index 00000000000..9f5daf8f026 --- /dev/null +++ b/packages/http-server-csharp/src/components/models/server-property.tsx @@ -0,0 +1,142 @@ +import { code, 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, type Enum, type Model, type ModelProperty } from "@typespec/compiler"; +import type { + Experimental_OverrideDeclarationComponent, + Experimental_OverrideDeclareProps, +} from "@typespec/emitter-framework"; +import { useTsp } from "@typespec/emitter-framework"; +import { isCSharpValueType, type PropertyProps } from "@typespec/emitter-framework/csharp"; +import { getUniqueItems } from "@typespec/json-schema"; +import { useEmitterOptions } from "../../context/emitter-options-context.js"; +import { getPropertyAttributes } from "../../utils/attributes.jsx"; +import { TypeExpression } from "../type-expression/type-expression.jsx"; +import { + getDefaultValueString, + getEnumDefaultInitializer, + getLiteralValue, + getModelEmitName, + getScalarForLiteral, + getUnionVariantInitializer, + hasNonIntegerValues, + hasPropertyInChain, + isDuplicateExceptionName, +} from "./model-helpers.js"; + +/** + * Renders a property the way the pre-Alloy emitter did, rather than the way the framework + * would by default: + * + * - no `required` keyword + * - `[JsonPropertyName]` only when the C# name differs from the wire name + * - no nullable `?` suffix on reference types (they are already nullable under + * `#nullable enable`) + * - `new` rather than `override`/`virtual` for discriminator properties + * - literal-typed properties become get-only properties with an initializer + * + * It is registered as a `ModelProperty` declaration override so that every property the + * framework emits — including the ones it renders from inside `ClassDeclaration` — picks it + * up. The framework's own rendering is still reached through `props.Declaration`, so the + * doc comments, name policy and nullable-union unwrapping are not reimplemented here. + */ +export const ServerPropertyOverride: Experimental_OverrideDeclarationComponent< + ModelProperty, + PropertyProps +> = (props: Experimental_OverrideDeclareProps): Children => { + const { $ } = useTsp(); + const { collectionType } = useEmitterOptions(); + const namePolicy = cs.useCSharpNamePolicy(); + + const property = props.type; + const propType = property.type; + const declaringModel: Model | undefined = property.model; + const isErrorProp = declaringModel ? isErrorModel($.program, declaringModel) : false; + const attrs = getPropertyAttributes($, property); + + // Error models derive from `HttpServiceException`, so a property whose C# name collides + // with the class name or with an inherited exception member has to be renamed. + let propName = property.name; + if (isErrorProp && declaringModel) { + const errorClassName = namePolicy.getName(getModelEmitName($.program, declaringModel), "class"); + const csharpPropName = namePolicy.getName(propName, "class-property"); + if (csharpPropName === errorClassName || isDuplicateExceptionName(csharpPropName)) { + propName = csharpPropName === "Value" ? "ValueName" : `${csharpPropName}Prop`; + } + } + + // Only carry the wire name when the C# name policy actually changed it. + const csharpName = namePolicy.getName(propName, "class-property"); + if (csharpName !== property.name) { + attrs.unshift( + , + ); + } + + // Discriminator properties redeclare a base property; the old emitter used `new`. + const isOverride = declaringModel?.baseModel + ? hasPropertyInChain(declaringModel.baseModel, property.name) + : false; + + // e.g. `kind: PetType.Dog` — the property is pinned to a single enum member. + const unionVariantInit = getUnionVariantInitializer(propType, namePolicy); + // e.g. `variety: WolfBreed = WolfBreed.dire` + const enumDefaultInit = getEnumDefaultInitializer(property, namePolicy); + + // Error model properties are populated by the generated constructor instead. + const literalInfo = isErrorProp + ? undefined + : (unionVariantInit ?? getLiteralValue(propType, collectionType)); + const defaultValue = isErrorProp + ? undefined + : (enumDefaultInit ?? + (property.defaultValue ? getDefaultValueString(property.defaultValue) : undefined)); + + const initializer = literalInfo ?? defaultValue; + const isLiteralOnly = literalInfo !== undefined && defaultValue === undefined; + + // C# enums are integral, so an enum with fractional values has to widen to `double`. + const isFloatEnum = $.enum.is(propType) && hasNonIntegerValues(propType as Enum); + + // A literal-typed property is declared as its scalar base; a union variant keeps its enum. + const resolveToScalar = (isLiteralOnly && !unionVariantInit) || isErrorProp; + const resolvedType = resolveToScalar ? getScalarForLiteral(propType) : propType; + const needsNullable = property.optional && (isFloatEnum || isCSharpValueType($, resolvedType)); + + const isUniqueItems = getUniqueItems($.program, property); + const isArrayType = propType.kind === "Model" && $.array.is(propType); + + let csharpType: Children; + if (isFloatEnum) { + csharpType = code`double`; + } else if (isUniqueItems && isArrayType && propType.indexer?.value) { + csharpType = ( + <> + ISet< + + > + + ); + } else { + csharpType = ; + } + + return ( + 0 ? attrs : undefined} + get + set={!isLiteralOnly} + initializer={initializer} + /> + ); +}; 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/components/type-expression/type-expression.test.tsx b/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx index 712cbc6010f..0d6f98299e3 100644 --- a/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx +++ b/packages/http-server-csharp/src/components/type-expression/type-expression.test.tsx @@ -7,7 +7,7 @@ import { t, type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { Experimental_ComponentOverrides, Output } from "@typespec/emitter-framework"; import { beforeEach, describe, expect, it } from "vitest"; -import { createServerScalarOverrides, efRefkey, TypeExpression } from "./type-expression.jsx"; +import { createServerOverrides, efRefkey, TypeExpression } from "./type-expression.jsx"; let runner: TesterInstance; @@ -17,7 +17,7 @@ beforeEach(async () => { function Wrapper(props: { children: Children }) { const policy = createCSharpNamePolicy(); - const overrides = createServerScalarOverrides($(runner.program)); + const overrides = createServerOverrides($(runner.program)); return ( diff --git a/packages/http-server-csharp/src/components/type-expression/type-expression.tsx b/packages/http-server-csharp/src/components/type-expression/type-expression.tsx index be6a9860b29..a0849ea51c9 100644 --- a/packages/http-server-csharp/src/components/type-expression/type-expression.tsx +++ b/packages/http-server-csharp/src/components/type-expression/type-expression.tsx @@ -1,17 +1,20 @@ import { code, type Children } from "@alloy-js/core"; -import { isStdNamespace, type Namespace, type Scalar, type Type } from "@typespec/compiler"; +import { isStdNamespace, type Namespace, type Type } from "@typespec/compiler"; import type { Typekit } from "@typespec/compiler/typekit"; import { Experimental_ComponentOverridesConfig, useTsp } from "@typespec/emitter-framework"; +import type { PropertyProps } from "@typespec/emitter-framework/csharp"; import { efRefkey, TypeExpression as EfTypeExpression, getNullableUnionInnerType, + isCSharpValueType, } from "@typespec/emitter-framework/csharp"; import { getUniqueItems } from "@typespec/json-schema"; import { useEmitterOptions } from "../../context/emitter-options-context.js"; import { isUnionEnum } from "../enums/enums.jsx"; -import { isValueType } from "../models/model-helpers.js"; import { getAnonymousModelName } from "../models/models.jsx"; +import { ServerPropertyOverride } from "../models/server-property.jsx"; +import { getServerScalarOverrides } from "./scalar-overrides.js"; export interface TypeExpressionProps { type: Type; @@ -21,8 +24,17 @@ export interface TypeExpressionProps { export { efRefkey } from "@typespec/emitter-framework/csharp"; /** - * Wrapper around emitter-framework's TypeExpression that handles - * additional type kinds the server emitter encounters. + * Wrapper around emitter-framework's TypeExpression. + * + * Only the cases where the server emitter genuinely diverges from the framework are handled + * here — union-as-enum resolution, the `collection-type` option, `@uniqueItems`, and the + * `Record` → `JsonObject` mapping. Everything else is delegated to the framework. + * + * Note that any type kind the framework resolves by *recursing* into a contained type has to + * be handled here rather than delegated: the framework recurses into its own + * `TypeExpression`, so the divergences above would be lost for the nested type. Scalars are + * the exception — those are redirected through {@link createServerScalarOverrides}, which the + * framework applies at every level. */ export function TypeExpression(props: TypeExpressionProps): Children { const { $ } = useTsp(); @@ -37,63 +49,25 @@ export function TypeExpression(props: TypeExpressionProps): Children { return code`${efRefkey(type.union)}`; } return ; - case "Enum": - try { - return ; - } catch { - return code`${type.name ?? "object"}`; - } + case "ModelProperty": + return ; case "EnumMember": { - // A property typed as a specific enum member (e.g. `kind: Color.red`) uses - // the parent enum type in C#. Std-lib enums (e.g. auth `AuthType`) are not - // emitted, so fall back to the member's underlying primitive value type. + // Std-lib enums (e.g. auth `AuthType`) are never emitted, so a reference to one of + // their members has to fall back to the member's underlying primitive value type. if (isInStdLibNamespace(type.enum.namespace)) { if (typeof type.value === "number") { return Number.isInteger(type.value) ? code`int` : code`double`; } return code`string`; } - return code`${efRefkey(type.enum)}`; + break; } case "Tuple": - // Tuple of values — use the type of the first element as array + // A tuple is emitted as a collection of its first element's type. if (type.values.length > 0) { - const { collectionType } = useEmitterOptions(); - if (collectionType === "enumerable") { - return ( - <> - IEnumerable< - - > - - ); - } - return ( - <> - - [] - - ); + return ; } - return code`object[]`; - case "StringTemplate": - case "String": - return code`string`; - case "Boolean": - return code`bool`; - case "Number": - // Use double for non-integer values, int for integers - return Number.isInteger(type.value) ? code`int` : code`double`; - case "Intrinsic": - if (type.name === "unknown") return code`object`; - if (type.name === "void") return code`void`; - if (type.name === "null") return code`object`; - if (type.name === "never") return code`void`; - return code`object`; - case "TemplateParameter": - return code`${(type.node as any)?.id?.sv ?? "T"}`; - case "ModelProperty": - return ; + break; case "Model": // Handle Record → IDictionary or JsonObject for Record if ($.record.is(type)) { @@ -112,62 +86,66 @@ export function TypeExpression(props: TypeExpressionProps): Children { if ($.array.is(type)) { const elementType = type.indexer!.value; if (getUniqueItems($.program, type)) { - return ( - <> - ISet< - - > - - ); - } - const { collectionType } = useEmitterOptions(); - // Byte arrays always stay as T[] regardless of collection type - const isByteArray = - elementType.kind === "Scalar" && - (elementType.name === "uint8" || - elementType.name === "int8" || - $.scalar.getStdBase(elementType)?.name === "uint8" || - $.scalar.getStdBase(elementType)?.name === "int8"); - if (collectionType === "enumerable" && !isByteArray) { - return ( - <> - IEnumerable< - - > - - ); + return ; } - return ( - <> - - [] - - ); + return ; } // Handle anonymous models — use refkey to link to their generated class if (type.name === "" && getAnonymousModelName(type)) { return code`${efRefkey(type)}`; } - // Fall through to EF for regular models - try { - return ; - } catch { - return code`${type.name ?? "object"}`; - } - case "Scalar": - // Handle scalars - try EF first, fall back to our mapping - try { - return ; - } catch { - return code`object`; - } - default: - try { - return ; - } catch { - return code`object`; - } + break; } + + return ; +} + +/** + * Renders `ISet`, used for arrays marked with `@uniqueItems`. + */ +export function SetExpression(props: { elementType: Type }): Children { + return ( + <> + ISet< + + > + + ); +} + +/** + * Renders a sequence of `elementType` honouring the `collection-type` emitter option: + * `IEnumerable` when set to `enumerable`, otherwise `T[]`. + * + * Byte arrays always stay as `T[]` — they are handled as binary payloads, not sequences. + */ +function CollectionExpression(props: { elementType: Type }): Children { + const { $ } = useTsp(); + const { collectionType } = useEmitterOptions(); + const elementType = props.elementType; + + const isByteArray = + elementType.kind === "Scalar" && + (elementType.name === "uint8" || + elementType.name === "int8" || + $.scalar.getStdBase(elementType)?.name === "uint8" || + $.scalar.getStdBase(elementType)?.name === "int8"); + + if (collectionType === "enumerable" && !isByteArray) { + return ( + <> + IEnumerable< + + > + + ); + } + return ( + <> + + [] + + ); } /** @@ -199,7 +177,7 @@ function resolveUnionType($: Typekit, union: import("@typespec/compiler").Union) return code`object`; } // Nullable value type → T? - if (isValueType($, innerType)) { + if (isCSharpValueType($, innerType)) { return ( <> ? @@ -233,40 +211,27 @@ function resolveUnionType($: Typekit, union: import("@typespec/compiler").Union) return code`object`; } -// --- Server-specific scalar overrides --- +// --- Server-specific framework overrides --- /** - * Server-specific scalar overrides for TypeExpression. - * Differences from EF defaults: - * - `plainDate` → `DateTime` (not `DateOnly`) - * - `plainTime` → `DateTime` (not `TimeOnly`) - * - `url` → `string` (not `Uri`) - * - Use CLR type names (SByte, Byte, Int16, etc.) instead of C# keywords - * - `safeint` → `long` (not `int`) + * Builds the {@link Experimental_ComponentOverridesConfig} the emitter installs at the root. + * + * - scalars render the server's C# names (the mapping itself lives in `scalar-overrides.ts` + * so that non-rendering call sites resolve the exact same names) + * - model properties render the way the pre-Alloy emitter declared them */ -export function createServerScalarOverrides($: Typekit): Experimental_ComponentOverridesConfig { +export function createServerOverrides($: Typekit): Experimental_ComponentOverridesConfig { const overrides = new Experimental_ComponentOverridesConfig(); - const scalarOverrides: [Scalar, string][] = [ - // Date/time overrides - [$.builtin.plainDate, "DateTime"], - [$.builtin.plainTime, "DateTime"], - [$.builtin.url, "string"], - // CLR type name overrides (match old emitter output) - [$.builtin.int8, "SByte"], - [$.builtin.uint8, "Byte"], - [$.builtin.int16, "Int16"], - [$.builtin.uint16, "UInt16"], - [$.builtin.uint32, "UInt32"], - [$.builtin.uint64, "UInt64"], - [$.builtin.safeInt, "long"], - ]; - - for (const [scalar, csType] of scalarOverrides) { + for (const [scalar, csType] of getServerScalarOverrides($)) { overrides.forType(scalar, { - reference: (props) => code`${csType}` as Children, + reference: () => code`${csType}` as Children, }); } + overrides.forTypeKind<"ModelProperty", PropertyProps>("ModelProperty", { + declaration: ServerPropertyOverride, + }); + return overrides; } 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/emitter.tsx b/packages/http-server-csharp/src/emitter.tsx index 972b28a6bb3..72707935eb6 100644 --- a/packages/http-server-csharp/src/emitter.tsx +++ b/packages/http-server-csharp/src/emitter.tsx @@ -13,7 +13,7 @@ import { ControllersAndInterfaces } from "./components/render-root.jsx"; import { Documentation } from "./components/scaffolding/documentation.jsx"; import { MockHelpers, MockImplementations } from "./components/scaffolding/mock-scaffolding.jsx"; import { JsonConverters } from "./components/serialization/json-converters.jsx"; -import { createServerScalarOverrides } from "./components/type-expression/type-expression.jsx"; +import { createServerOverrides } from "./components/type-expression/type-expression.jsx"; import { EmitterOptions } from "./context/emitter-options-context.js"; import { reportEmitterDiagnostics } from "./diagnostics.js"; import type { CSharpServiceEmitterOptions } from "./lib.js"; @@ -27,7 +27,7 @@ import { resolveServiceTypes } from "./service-resolution.js"; export async function $onEmit(context: EmitContext) { const tk = $(context.program); const canonicalizer = new HttpCanonicalizer(tk); - const scalarOverrides = createServerScalarOverrides(tk); + const serverOverrides = createServerOverrides(tk); const options = context.options; const collectionType = options["collection-type"] ?? "array"; const emitMocks = @@ -66,7 +66,7 @@ export async function $onEmit(context: EmitContext) const output = ( - + 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(""); -}