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