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..e778b864941 --- /dev/null +++ b/.chronus/changes/ef-declaration-overrides-2026-8-5.md @@ -0,0 +1,22 @@ +--- +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. + +Override precedence is resolved per override kind, so a type-level override that only defines `reference` does not shadow a kind-level `declaration` override, and vice versa. + +```tsx +const overrides = Experimental_ComponentOverridesConfig().forTypeKind("ModelProperty", { + declaration: (props) => + props.type.name === "id" ? ( + + ) : ( + props.default + ), +}); +``` 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..a29e05ad61f 100644 --- a/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx +++ b/packages/emitter-framework/src/core/components/overrides/component-overrides.tsx @@ -11,11 +11,7 @@ import type { UnionVariant, } from "@typespec/compiler"; import { useTsp } from "../../context/index.js"; -import { - type Experimental_ComponentOverridesConfig, - getOverrideForType, - getOverridesForTypeKind, -} from "./config.js"; +import { type Experimental_ComponentOverridesConfig, getOverrideComponent } from "./config.js"; import { type ComponentOverridesContext, OverridesContext, useOverrides } from "./context.js"; export interface Experimental_OverrideEmitPropsBase { @@ -51,26 +47,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,25 +136,65 @@ 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(); - const descriptor = - getOverrideForType($.program, props.type, options.overrides) ?? - getOverridesForTypeKind($.program, props.type.kind, options.overrides); - if (!descriptor) { - return <>{props.children}; + if ("reference" in props && props.reference) { + const CustomComponent = getOverrideComponent( + $.program, + props.type, + "reference", + options.overrides, + ); + if (CustomComponent) { + return ; + } } - if ("reference" in props && props.reference && descriptor.reference) { - const CustomComponent = descriptor.reference; - return ; + if ("declaration" in props && props.declaration) { + const CustomComponent = getOverrideComponent( + $.program, + props.type, + "declaration", + options.overrides, + ); + if (CustomComponent) { + 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..98f8f8b4e27 100644 --- a/packages/emitter-framework/src/core/components/overrides/config.ts +++ b/packages/emitter-framework/src/core/components/overrides/config.ts @@ -1,10 +1,21 @@ 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"); +/** + * The ways a type can be rendered, and therefore overridden. + */ +export type Experimental_OverrideKind = keyof Experimental_ComponentOverridesConfigBase; + +type OverrideComponent = + Experimental_ComponentOverridesConfigBase[K]; + export type Experimental_ComponentOverridesConfig = Experimental_ComponentOverridesClass; export const Experimental_ComponentOverridesConfig = function () { return new Experimental_ComponentOverridesClass(); @@ -14,19 +25,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); @@ -34,52 +54,62 @@ export class Experimental_ComponentOverridesClass { } /** + * Look up the override for a single override kind, walking up the scalar hierarchy when the + * type is a scalar. Resolution is per override kind, so a derived scalar that only overrides + * `reference` does not hide a `declaration` override registered on its base scalar. + * * @internal */ - [getOverrideForTypeSym](program: Program, type: Type) { - const options = this.#typeEmitOptions.get(type); - if (options || !$(program).scalar.is(type) /** || isBuiltIn(program, type) */) { - return options; - } - - // have a scalar, it's not a built-in scalar, and didn't find options, so - // see if we have options for a base scalar. - let currentScalar: Scalar | undefined = type; - while ( - currentScalar && - // !isBuiltIn(program, currentScalar) && - !this.#typeEmitOptions.has(currentScalar) - ) { - currentScalar = currentScalar?.baseScalar; + [getOverrideForTypeSym]( + program: Program, + type: Type, + overrideKind: K, + ): OverrideComponent { + const own = this.#typeEmitOptions.get(type)?.[overrideKind]; + if (own || !$(program).scalar.is(type) /** || isBuiltIn(program, type) */) { + return own; } - if (!currentScalar) { - return undefined; + // have a scalar, it's not a built-in scalar, and didn't find an override, so + // see if a base scalar has one. + let currentScalar: Scalar | undefined = type.baseScalar; + while (currentScalar /** && !isBuiltIn(program, currentScalar) */) { + const inherited = this.#typeEmitOptions.get(currentScalar)?.[overrideKind]; + if (inherited) { + return inherited; + } + currentScalar = currentScalar.baseScalar; } - return this.#typeEmitOptions.get(currentScalar); + return undefined; } /** * @internal */ - [getOverrideForTypeKindSym](program: Program, typeKind: Type["kind"]) { - return this.#typeKindEmitOptions.get(typeKind); + [getOverrideForTypeKindSym]( + typeKind: Type["kind"], + overrideKind: K, + ): OverrideComponent { + return this.#typeKindEmitOptions.get(typeKind)?.[overrideKind]; } } -export function getOverrideForType( +/** + * Resolve the component that overrides how `type` is rendered for the given override kind. + * + * Precedence is resolved independently per override kind: a type-level override that only + * defines `reference` does not prevent a kind-level `declaration` override from applying, and + * vice versa. + */ +export function getOverrideComponent( program: Program, type: Type, + overrideKind: K, options?: Experimental_ComponentOverridesConfig, -) { - return options?.[getOverrideForTypeSym](program, type); -} - -export function getOverridesForTypeKind( - program: Program, - typeKind: Type["kind"], - options?: Experimental_ComponentOverridesConfig, -) { - return options?.[getOverrideForTypeKindSym](program, typeKind); +): OverrideComponent { + return ( + options?.[getOverrideForTypeSym](program, type, overrideKind) ?? + options?.[getOverrideForTypeKindSym](type.kind, overrideKind) + ); } 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..eabf1bbe681 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,196 @@ 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 + } + `); + }); +}); + +describe("override precedence", () => { + it("does not let a type-level reference override hide a kind-level declaration override", async () => { + const { TestModel } = await runner.compile(t.code` + model ${t.model("TestModel")} {} + `); + + const overrides = Experimental_ComponentOverridesConfig() + .forType(TestModel, { reference: () => "Unrelated" }) + .forTypeKind("Model", { + declaration: (props) => , + }); + + expect( + + + + + , + ).toRenderTo(`class Renamed {}`); + }); + + it("does not let a type-level declaration override hide a kind-level reference override", async () => { + const { Container, TestModel } = await runner.compile(t.code` + model ${t.model("TestModel")} {} + model ${t.model("Container")} { + prop: TestModel; + } + `); + + const overrides = Experimental_ComponentOverridesConfig() + .forType(TestModel, { declaration: () => "unreachable" }) + .forTypeKind("Model", { reference: () => "Referenced" }); + + expect( + + + + + , + ).toRenderTo(d` + class Container + { + public required Referenced Prop { get; set; } + } + `); + }); + + it("resolves each override kind independently up the scalar hierarchy", async () => { + const { Container, myBase, myDerived } = await runner.compile(t.code` + scalar ${t.scalar("myBase")} extends string; + scalar ${t.scalar("myDerived")} extends myBase; + model ${t.model("Container")} { + prop: myDerived; + } + `); + + // `myDerived` only overrides `declaration`, so the `reference` override on its base + // scalar must still be found. + const overrides = Experimental_ComponentOverridesConfig() + .forType(myDerived, { declaration: () => "unreachable" }) + .forType(myBase, { reference: () => "BaseRef" }); + + expect( + + + + + , + ).toRenderTo(d` + class Container + { + public required BaseRef Prop { get; set; } + } + `); + }); +}); diff --git a/packages/emitter-framework/src/csharp/components/class/declaration.tsx b/packages/emitter-framework/src/csharp/components/class/declaration.tsx index bade989ad42..65c109690c0 100644 --- a/packages/emitter-framework/src/csharp/components/class/declaration.tsx +++ b/packages/emitter-framework/src/csharp/components/class/declaration.tsx @@ -2,7 +2,7 @@ import { For, type Children } from "@alloy-js/core"; import * as cs from "@alloy-js/csharp"; import type { Interface, Model } 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"; @@ -28,6 +28,19 @@ interface ClassMethodsProps { } export function ClassDeclaration(props: ClassDeclarationProps): Children { + return ( + + + + ); +} + +function ClassDeclarationBody(props: ClassDeclarationProps): Children { const { $ } = useTsp(); const namePolicy = cs.useCSharpNamePolicy(); diff --git a/packages/emitter-framework/src/csharp/components/enum/declaration.tsx b/packages/emitter-framework/src/csharp/components/enum/declaration.tsx index e49a5474c47..9e57afbbd1f 100644 --- a/packages/emitter-framework/src/csharp/components/enum/declaration.tsx +++ b/packages/emitter-framework/src/csharp/components/enum/declaration.tsx @@ -1,4 +1,4 @@ -import { useTsp } from "#core/context/tsp-context.js"; +import { Experimental_OverridableComponent, useTsp } from "#core/index.js"; import { type Children, For } from "@alloy-js/core"; import * as cs from "@alloy-js/csharp"; import type { Enum, Union } from "@typespec/compiler"; @@ -12,6 +12,19 @@ export interface EnumDeclarationProps extends Omit + + + ); +} + +function EnumDeclarationBody(props: EnumDeclarationProps): Children { const { $ } = useTsp(); let type: Enum; if ($.union.is(props.type)) { diff --git a/packages/emitter-framework/src/csharp/components/property/property.tsx b/packages/emitter-framework/src/csharp/components/property/property.tsx index 8863892e06f..7030e0e408f 100644 --- a/packages/emitter-framework/src/csharp/components/property/property.tsx +++ b/packages/emitter-framework/src/csharp/components/property/property.tsx @@ -9,7 +9,7 @@ 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"; @@ -28,6 +28,19 @@ 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);