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/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..f9d9338417b --- /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` and `Uri` while the matching properties were `DateTime` and `string`, 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/csharp/components/class/declaration.tsx b/packages/emitter-framework/src/csharp/components/class/declaration.tsx index 65c109690c0..adb2c84ed0a 100644 --- a/packages/emitter-framework/src/csharp/components/class/declaration.tsx +++ b/packages/emitter-framework/src/csharp/components/class/declaration.tsx @@ -1,6 +1,6 @@ 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 { Experimental_OverridableComponent, useTsp } from "../../../core/index.js"; import { Property } from "../property/property.jsx"; @@ -15,10 +15,18 @@ export interface ClassDeclarationProps extends Omit - - ) : 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 9e57afbbd1f..fd1e654d661 100644 --- a/packages/emitter-framework/src/csharp/components/enum/declaration.tsx +++ b/packages/emitter-framework/src/csharp/components/enum/declaration.tsx @@ -1,14 +1,42 @@ -import { Experimental_OverridableComponent, useTsp } from "#core/index.js"; -import { type Children, For } from "@alloy-js/core"; +import { Experimental_OverridableComponent } from "#core/components/index.js"; +import { useTsp } from "#core/context/tsp-context.js"; +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 { @@ -26,44 +54,75 @@ export function EnumDeclaration(props: EnumDeclarationProps): Children { 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 7030e0e408f..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"; @@ -15,8 +15,16 @@ 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 @@ -42,14 +50,15 @@ export function Property(props: PropertyProps): Children { 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) { @@ -61,11 +70,11 @@ function PropertyBody(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; @@ -74,9 +83,9 @@ function PropertyBody(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) { @@ -92,18 +101,19 @@ function PropertyBody(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/http-server-csharp/src/components/enums/enums.tsx b/packages/http-server-csharp/src/components/enums/enums.tsx index 69233496bd8..f132a5c310e 100644 --- a/packages/http-server-csharp/src/components/enums/enums.tsx +++ b/packages/http-server-csharp/src/components/enums/enums.tsx @@ -1,8 +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 { Serialization } from "@alloy-js/csharp/global/System/Text/Json"; import { type Enum, type Namespace as TspNamespace, @@ -10,50 +7,46 @@ import { type Union, } from "@typespec/compiler"; import { useTsp } from "@typespec/emitter-framework"; -import { getDocComments } from "@typespec/emitter-framework/csharp"; +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,8 +68,8 @@ 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 ( @@ -87,36 +80,14 @@ export function Enums(props: EnumsProps): Children { const subNsParts = getSubNamespaceParts(info.namespace, props.serviceNamespace); const enumDecl = ( - <> - - - - - {(member) => ( - <> - - - - - - )} - - - + ); const wrappedContent = subNsParts.reduceRight( 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 5e2510630c3..0654895392b 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 { Serialization } from "@alloy-js/csharp/global/System/Text/Json"; -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 { getDocComments } from "@typespec/emitter-framework/csharp"; +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 { 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"; @@ -100,39 +77,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 && } @@ -148,123 +115,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/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..b51476ec2c7 --- /dev/null +++ b/packages/http-server-csharp/src/components/type-expression/scalar-overrides.ts @@ -0,0 +1,59 @@ +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; + } + // Falls back to `object` (with a diagnostic) for scalars with no C# equivalent. + 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/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/utils/attributes.tsx b/packages/http-server-csharp/src/utils/attributes.tsx index 4df4be6e5d0..aabea3f6628 100644 --- a/packages/http-server-csharp/src/utils/attributes.tsx +++ b/packages/http-server-csharp/src/utils/attributes.tsx @@ -15,68 +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 { 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 @@ -106,36 +65,36 @@ export function getPropertyAttributes(program: Program, property: ModelProperty) } // 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": @@ -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,16 +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 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 (