diff --git a/.chronus/changes/data-decorators-2026-2-30-17-38-53.md b/.chronus/changes/data-decorators-2026-2-30-17-38-53.md new file mode 100644 index 00000000000..aff2f00c104 --- /dev/null +++ b/.chronus/changes/data-decorators-2026-2-30-17-38-53.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: internal +packages: + - "@typespec/html-program-viewer" +--- + +Data decorators diff --git a/.chronus/changes/data-decorators-2026-3-30.md b/.chronus/changes/data-decorators-2026-3-30.md new file mode 100644 index 00000000000..4a7996d8c6b --- /dev/null +++ b/.chronus/changes/data-decorators-2026-3-30.md @@ -0,0 +1,16 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Added `data` decorator modifier for declaring decorators that auto-store their arguments as metadata without requiring a JavaScript implementation. + +```typespec +data dec label(target: Model, value: valueof string); + +@label("my-model") +model Foo {} +``` + +Added compiler API `hasDataDecorator`, `getDataDecoratorValue`, and `getDataDecoratorTargets` for reading data decorator values by FQN. diff --git a/.chronus/changes/data-decorators-tspd-2026-3-30.md b/.chronus/changes/data-decorators-tspd-2026-3-30.md new file mode 100644 index 00000000000..74ecfce1547 --- /dev/null +++ b/.chronus/changes/data-decorators-tspd-2026-3-30.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/tspd" +--- + +`tspd gen-extern-signature` now generates typed accessor functions for `data` decorators (e.g., `isMyFlag`, `getMyLabel`). diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 66a1658d6c0..1440f06675a 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -1,4 +1,5 @@ import { Realm } from "../experimental/realm.js"; +import { getDataDecoratorStateKey } from "../lib/data-decorator.js"; import { docFromCommentDecorator, getIndexer } from "../lib/intrinsic/decorators.js"; import { $ } from "../typekit/index.js"; import { DuplicateTracker } from "../utils/duplicate-tracker.js"; @@ -107,6 +108,7 @@ import { ModelProperty, ModelPropertyNode, ModelStatementNode, + ModifierFlags, Namespace, NamespaceStatementNode, NeverType, @@ -2099,8 +2101,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ); const name = node.id.sv; - const implementation = symbol.value; - if (implementation === undefined) { + const isData = (node.modifierFlags & ModifierFlags.Data) !== 0; + let implementation = symbol.value; + if (isData) { + implementation = createDataDecoratorImplementation(symbol, node); + } else if (implementation === undefined) { reportCheckerDiagnostic(createDiagnostic({ code: "missing-implementation", target: node })); } const decoratorType: Decorator = createType({ @@ -2111,6 +2116,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker target: checkFunctionParameter(ctx, node.target, true), parameters: node.parameters.map((param) => checkFunctionParameter(ctx, param, true)), implementation: implementation ?? (() => {}), + declarationKind: isData ? "data" : "extern", }); namespace.decoratorDeclarations.set(name, decoratorType); @@ -2120,6 +2126,36 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return decoratorType; } + function createDataDecoratorImplementation( + symbol: Sym, + node: DecoratorDeclarationStatementNode, + ): (ctx: DecoratorContext, target: Type, ...args: unknown[]) => void { + const fqn = getFullyQualifiedSymbolName(symbol); + const stateKey = getDataDecoratorStateKey(fqn); + const paramNames = node.parameters.map((p) => p.id.sv); + + if (paramNames.length === 0) { + // No args beyond target — store `true` as a boolean flag in stateMap + return (context: DecoratorContext, target: Type) => { + context.program.stateMap(stateKey).set(target, true); + }; + } else if (paramNames.length === 1) { + // Single arg — store value directly + return (context: DecoratorContext, target: Type, value: unknown) => { + context.program.stateMap(stateKey).set(target, value); + }; + } else { + // Multiple args — store as named record + return (context: DecoratorContext, target: Type, ...args: unknown[]) => { + const data: Record = {}; + for (let i = 0; i < paramNames.length; i++) { + data[paramNames[i]] = args[i]; + } + context.program.stateMap(stateKey).set(target, data); + }; + } + } + function checkFunctionDeclaration( ctx: CheckContext, node: FunctionDeclarationStatementNode, @@ -5746,9 +5782,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return undefined; } + const impl = sym.value ?? symbolLinks.declaredType?.implementation; return { definition: symbolLinks.declaredType, - decorator: sym.value ?? ((...args: any[]) => {}), + decorator: impl ?? ((...args: any[]) => {}), node: decNode, args, }; diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 71e73369579..a593513f3f2 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -552,6 +552,8 @@ const diagnostics = { messages: { default: paramMessage`Modifier '${"modifier"}' is invalid.`, "missing-required": paramMessage`Declaration of type '${"nodeKind"}' is missing required modifier '${"modifier"}'.`, + "missing-required-one-of": paramMessage`Declaration of type '${"nodeKind"}' is missing one of the required modifiers: ${"modifiers"}.`, + "mutually-exclusive": paramMessage`Modifiers '${"modifierA"}' and '${"modifierB"}' cannot be used together.`, "not-allowed": paramMessage`Modifier '${"modifier"}' cannot be used on declarations of type '${"nodeKind"}'.`, }, }, diff --git a/packages/compiler/src/core/modifiers.ts b/packages/compiler/src/core/modifiers.ts index 6e4b49f4941..e9b95957469 100644 --- a/packages/compiler/src/core/modifiers.ts +++ b/packages/compiler/src/core/modifiers.ts @@ -12,8 +12,10 @@ import { Declaration, Modifier, ModifierFlags, SyntaxKind } from "./types.js"; interface ModifierCompatibility { /** A set of modifier flags that are allowed on the node type. */ readonly allowed: ModifierFlags; - /** A set of modifier flags that are _required_ on the node type. */ + /** At least one of these modifier flags must be present. */ readonly required: ModifierFlags; + /** Pairs of modifier flags that cannot be used together. */ + readonly mutuallyExclusive?: readonly [ModifierFlags, ModifierFlags][]; } /** @@ -44,7 +46,8 @@ const SYNTAX_MODIFIERS: Readonly `'${n}'`).join(" or "), + nodeKind: getDeclarationKindText(node.kind), + }, target: node, }), ); } } + if (compatibility.mutuallyExclusive) { + for (const [a, b] of compatibility.mutuallyExclusive) { + if (node.modifierFlags & a && node.modifierFlags & b) { + isValid = false; + + const nameA = getNamesOfModifierFlags(a)[0]; + const nameB = getNamesOfModifierFlags(b)[0]; + program.reportDiagnostic( + createDiagnostic({ + code: "invalid-modifier", + messageId: "mutually-exclusive", + format: { modifierA: nameA, modifierB: nameB }, + target: node, + }), + ); + } + } + } + return isValid; } @@ -148,6 +181,8 @@ function modifierToFlag(modifier: Modifier): ModifierFlags { return ModifierFlags.Extern; case SyntaxKind.InternalKeyword: return ModifierFlags.Internal; + case SyntaxKind.DataKeyword: + return ModifierFlags.Data; default: compilerAssert(false, `Unknown modifier kind: ${(modifier as Modifier).kind}`); } @@ -159,6 +194,8 @@ function getTextForModifier(modifier: Modifier): string { return "extern"; case SyntaxKind.InternalKeyword: return "internal"; + case SyntaxKind.DataKeyword: + return "data"; default: compilerAssert(false, `Unknown modifier kind: ${(modifier as Modifier).kind}`); } @@ -172,6 +209,9 @@ function getNamesOfModifierFlags(flags: ModifierFlags): string[] { if (flags & ModifierFlags.Internal) { names.push("internal"); } + if (flags & ModifierFlags.Data) { + names.push("data"); + } return names; } diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 63ed0076f13..0f15c0e3409 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -29,6 +29,7 @@ import { CallExpressionNode, Comment, ConstStatementNode, + DataKeywordNode, Declaration, DeclarationNode, DecoratorDeclarationStatementNode, @@ -461,6 +462,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa case Token.ConstKeyword: case Token.ExternKeyword: case Token.InternalKeyword: + case Token.DataKeyword: case Token.FnKeyword: case Token.DecKeyword: item = parseDeclaration(pos, decorators, docs, directives); @@ -532,6 +534,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa case Token.ConstKeyword: case Token.ExternKeyword: case Token.InternalKeyword: + case Token.DataKeyword: case Token.FnKeyword: case Token.DecKeyword: item = parseDeclaration(pos, decorators, docs, directives); @@ -1770,6 +1773,15 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + function parseDataKeyword(): DataKeywordNode { + const pos = tokenPos(); + parseExpected(Token.DataKeyword); + return { + kind: SyntaxKind.DataKeyword, + ...finishNode(pos), + }; + } + function parseVoidKeyword(): VoidKeywordNode { const pos = tokenPos(); parseExpected(Token.VoidKeyword); @@ -2090,6 +2102,8 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return parseExternKeyword(); case Token.InternalKeyword: return parseInternalKeyword(); + case Token.DataKeyword: + return parseDataKeyword(); default: return undefined; } @@ -2101,7 +2115,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa ): DecoratorDeclarationStatementNode { const modifierFlags = modifiersToFlags(modifiers); parseExpected(Token.DecKeyword); - const id = parseIdentifier(); + const id = parseIdentifier({ allowReservedIdentifier: true }); const allParamListDetail = parseFunctionParameters(); let [target, ...parameters] = allParamListDetail.items; if (target === undefined) { @@ -3167,6 +3181,7 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined case SyntaxKind.NeverKeyword: case SyntaxKind.ExternKeyword: case SyntaxKind.InternalKeyword: + case SyntaxKind.DataKeyword: case SyntaxKind.UnknownKeyword: case SyntaxKind.JsSourceFile: case SyntaxKind.JsNamespaceDeclaration: diff --git a/packages/compiler/src/core/scanner.ts b/packages/compiler/src/core/scanner.ts index 67fd2454300..fda053c5589 100644 --- a/packages/compiler/src/core/scanner.ts +++ b/packages/compiler/src/core/scanner.ts @@ -139,6 +139,7 @@ export enum Token { ExternKeyword = __StartModifierKeyword, InternalKeyword, + DataKeyword, /** @internal */ __EndModifierKeyword, /////////////////////////////////////////////////////////////// @@ -310,6 +311,7 @@ export const TokenDisplay = getTokenDisplayTable([ [Token.NeverKeyword, "'never'"], [Token.UnknownKeyword, "'unknown'"], [Token.ExternKeyword, "'extern'"], + [Token.DataKeyword, "'data'"], // Reserved keywords [Token.StatemachineKeyword, "'statemachine'"], @@ -383,6 +385,7 @@ export const Keywords: ReadonlyMap = new Map([ ["never", Token.NeverKeyword], ["unknown", Token.UnknownKeyword], ["extern", Token.ExternKeyword], + ["data", Token.DataKeyword], ["internal", Token.InternalKeyword], // Reserved keywords diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 29560bd3609..4f4315a07d7 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -721,6 +721,8 @@ export interface Decorator extends BaseType { target: MixedFunctionParameter; parameters: MixedFunctionParameter[]; implementation: (ctx: DecoratorContext, target: Type, ...args: unknown[]) => void; + /** How this decorator was declared. */ + declarationKind: "extern" | "data"; } /** @@ -1175,6 +1177,7 @@ export enum SyntaxKind { CallExpression, ScalarConstructor, InternalKeyword, + DataKeyword, FunctionTypeExpression, } @@ -1741,6 +1744,10 @@ export interface InternalKeywordNode extends BaseNode { readonly kind: SyntaxKind.InternalKeyword; } +export interface DataKeywordNode extends BaseNode { + readonly kind: SyntaxKind.DataKeyword; +} + export interface VoidKeywordNode extends BaseNode { readonly kind: SyntaxKind.VoidKeyword; } @@ -1797,11 +1804,12 @@ export const enum ModifierFlags { None, Extern = 1 << 1, Internal = 1 << 2, + Data = 1 << 3, - All = Extern | Internal, + All = Extern | Internal | Data, } -export type Modifier = ExternKeywordNode | InternalKeywordNode; +export type Modifier = ExternKeywordNode | InternalKeywordNode | DataKeywordNode; /** * Represent a decorator declaration diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 5ceb7b70abc..73765ad59f1 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -259,6 +259,8 @@ export function printNode( return "extern"; case SyntaxKind.InternalKeyword: return "internal"; + case SyntaxKind.DataKeyword: + return "data"; case SyntaxKind.VoidKeyword: return "void"; case SyntaxKind.NeverKeyword: diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 99122b7b8d0..9b19304c33c 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -105,6 +105,11 @@ export { NodeHost } from "./core/node-host.js"; export { isNumeric, Numeric } from "./core/numeric.js"; export type { CompilerOptions } from "./core/options.js"; export { getPositionBeforeTrivia } from "./core/parser-utils.js"; +export { + getDataDecoratorTargets, + getDataDecoratorValue, + hasDataDecorator, +} from "./lib/data-decorator.js"; export { $defaultVisibility, $discriminator, diff --git a/packages/compiler/src/lib/data-decorator.ts b/packages/compiler/src/lib/data-decorator.ts new file mode 100644 index 00000000000..dc6b575b3ff --- /dev/null +++ b/packages/compiler/src/lib/data-decorator.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT License. + +import type { Program } from "../core/program.js"; +import type { Type } from "../core/types.js"; + +/** + * Get the state key for a data decorator given its fully-qualified name. + * @internal + */ +export function getDataDecoratorStateKey(decoratorFqn: string): symbol { + return Symbol.for(`data-dec:${decoratorFqn}`); +} + +/** + * Check if a data decorator has been applied to a target. + * @param program - The current program. + * @param decoratorFqn - The fully-qualified name of the decorator (e.g., "MyLib.myDec"). + * @param target - The type to check. + */ +export function hasDataDecorator(program: Program, decoratorFqn: string, target: Type): boolean { + const key = getDataDecoratorStateKey(decoratorFqn); + return program.stateMap(key).has(target); +} + +/** + * Get the stored value for a data decorator applied to a target. + * For no-arg data decorators, returns `true` if applied, `undefined` otherwise. + * For single-arg data decorators, returns the value directly. + * For multi-arg data decorators, returns a record of `{ paramName: value }`. + * @param program - The current program. + * @param decoratorFqn - The fully-qualified name of the decorator (e.g., "MyLib.myDec"). + * @param target - The type to get the value for. + * @returns The stored value, or `undefined` if the decorator was not applied. + */ +export function getDataDecoratorValue( + program: Program, + decoratorFqn: string, + target: Type, +): unknown | undefined { + const key = getDataDecoratorStateKey(decoratorFqn); + return program.stateMap(key).get(target); +} + +/** + * Get all targets that have a specific data decorator applied, along with their stored values. + * @param program - The current program. + * @param decoratorFqn - The fully-qualified name of the decorator (e.g., "MyLib.myDec"). + * @returns A map of target types to their stored values. + */ +export function getDataDecoratorTargets( + program: Program, + decoratorFqn: string, +): Map { + const key = getDataDecoratorStateKey(decoratorFqn); + return program.stateMap(key); +} diff --git a/packages/compiler/test/checker/decorators.test.ts b/packages/compiler/test/checker/decorators.test.ts index cd46988fc77..6dcf71aba12 100644 --- a/packages/compiler/test/checker/decorators.test.ts +++ b/packages/compiler/test/checker/decorators.test.ts @@ -111,13 +111,24 @@ describe("compiler: checker: decorators", () => { }); }); - it("errors if decorator is missing extern modifier", async () => { + it("errors if decorator is missing extern or data modifier", async () => { const diagnostics = await DecTester.diagnose(` dec testDec(target: unknown); `); expectDiagnostics(diagnostics, { code: "invalid-modifier", - message: "Declaration of type 'dec' is missing required modifier 'extern'.", + message: + "Declaration of type 'dec' is missing one of the required modifiers: 'extern' or 'data'.", + }); + }); + + it("errors if both extern and data modifiers are used", async () => { + const diagnostics = await DecTester.diagnose(` + data extern dec testDec(target: unknown); + `); + expectDiagnostics(diagnostics, { + code: "invalid-modifier", + message: "Modifiers 'extern' and 'data' cannot be used together.", }); }); @@ -142,6 +153,83 @@ describe("compiler: checker: decorators", () => { }); }); + describe("data decorators", () => { + it("data decorator does not require an implementation", async () => { + const { program } = await Tester.using("TypeSpec.Reflection").compile(` + data dec myFlag(target: Model); + `); + + const dec = program.getGlobalNamespaceType().decoratorDeclarations.get("myFlag"); + ok(dec); + strictEqual(dec.declarationKind, "data"); + ok(dec.implementation, "should have auto-generated implementation"); + }); + + it("data decorator with no args stores in stateMap", async () => { + const { program } = await Tester.using("TypeSpec.Reflection").compile(` + data dec myFlag(target: Model); + + @myFlag + model Foo {} + `); + + const Foo = program.getGlobalNamespaceType().models.get("Foo")!; + ok(Foo, "Foo should exist"); + const { getDataDecoratorValue } = await import("../../src/lib/data-decorator.js"); + strictEqual(getDataDecoratorValue(program, "myFlag", Foo), true); + }); + + it("data decorator with single arg stores value in stateMap", async () => { + const { program } = await Tester.using("TypeSpec.Reflection").compile(` + data dec myLabel(target: Model, label: valueof string); + + @myLabel("hello") + model Foo {} + `); + + const Foo = program.getGlobalNamespaceType().models.get("Foo")!; + const { getDataDecoratorValue } = await import("../../src/lib/data-decorator.js"); + strictEqual(getDataDecoratorValue(program, "myLabel", Foo), "hello"); + }); + + it("data decorator with multiple args stores named record in stateMap", async () => { + const { program } = await Tester.using("TypeSpec.Reflection").compile(` + data dec myMeta(target: Model, name: valueof string, version: valueof int32); + + @myMeta("test", 42) + model Foo {} + `); + + const Foo = program.getGlobalNamespaceType().models.get("Foo")!; + const { getDataDecoratorValue } = await import("../../src/lib/data-decorator.js"); + const value = getDataDecoratorValue(program, "myMeta", Foo) as any; + deepStrictEqual(value, { name: "test", version: 42 }); + }); + + it("data decorator in namespace uses FQN for state key", async () => { + const { program } = await Tester.using("TypeSpec.Reflection").compile(` + namespace MyLib { + data dec myLabel(target: Model, label: valueof string); + } + + @MyLib.myLabel("world") + model Foo {} + `); + + const Foo = program.getGlobalNamespaceType().models.get("Foo")!; + const { getDataDecoratorValue } = await import("../../src/lib/data-decorator.js"); + strictEqual(getDataDecoratorValue(program, "MyLib.myLabel", Foo), "world"); + }); + + it("internal data dec is valid", async () => { + const diagnostics = await Tester.using("TypeSpec.Reflection").diagnose(` + #suppress "experimental-feature" + internal data dec myDec(target: unknown); + `); + strictEqual(diagnostics.length, 0); + }); + }); + describe("usage", () => { let calledArgs: any[] | undefined; const UsageTester = Tester.files({ diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index 1360a82e543..916052a66bb 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -2975,6 +2975,28 @@ internal extern dec foo(target: Type, arg1: StringLiteral); }); }); + it("format data dec", async () => { + await assertFormat({ + code: ` +data dec foo(target: Type, arg1: StringLiteral); +`, + expected: ` +data dec foo(target: Type, arg1: StringLiteral); +`, + }); + }); + + it("format internal data dec", async () => { + await assertFormat({ + code: ` +internal data dec foo(target: Type, arg1: StringLiteral); +`, + expected: ` +internal data dec foo(target: Type, arg1: StringLiteral); +`, + }); + }); + it("format internal extern fn", async () => { await assertFormat({ code: ` diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index e3d78e94607..ab89deaa460 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -866,6 +866,10 @@ describe("compiler: parser", () => { parseEach([ "dec myDec(target: Type);", "extern dec myDec(target: Type);", + "data dec myDec(target: Type);", + "data dec myDec(target: Type, arg1: StringLiteral);", + "internal data dec myDec(target: Type);", + "namespace Lib { data dec myDec(target: Type);}", "namespace Lib { extern dec myDec(target: Type);}", "extern dec myDec(target: Type, arg1: StringLiteral);", "extern dec myDec(target: Type, optional?: StringLiteral);", diff --git a/packages/compiler/test/scanner.test.ts b/packages/compiler/test/scanner.test.ts index 9dfa8f645fa..15754bdd6a4 100644 --- a/packages/compiler/test/scanner.test.ts +++ b/packages/compiler/test/scanner.test.ts @@ -397,6 +397,7 @@ describe("compiler: scanner", () => { Token.UnknownKeyword, Token.ExternKeyword, Token.InternalKeyword, + Token.DataKeyword, Token.ValueOfKeyword, Token.TypeOfKeyword, // `fn` can be either a statement or the start of an expr depending on context. diff --git a/packages/html-program-viewer/src/react/type-config.ts b/packages/html-program-viewer/src/react/type-config.ts index 8bda40b820a..68c2caa8366 100644 --- a/packages/html-program-viewer/src/react/type-config.ts +++ b/packages/html-program-viewer/src/react/type-config.ts @@ -118,6 +118,7 @@ export const TypeConfig: TypeGraphConfig = buildConfig({ parameters: "nested-items", implementation: "skip", target: "ref", + declarationKind: "value", }, ScalarConstructor: { scalar: "parent", diff --git a/packages/tspd/src/gen-extern-signatures/components/data-decorator-accessors.tsx b/packages/tspd/src/gen-extern-signatures/components/data-decorator-accessors.tsx new file mode 100644 index 00000000000..c9fb13d5989 --- /dev/null +++ b/packages/tspd/src/gen-extern-signatures/components/data-decorator-accessors.tsx @@ -0,0 +1,102 @@ +import { code, For } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { typespecCompiler } from "../external-packages/compiler.js"; +import { DecoratorSignature } from "../types.js"; +import { ParameterTsType, TargetParameterTsType } from "./decorator-signature-type.js"; + +export interface DataDecoratorAccessorsProps { + decorators: DecoratorSignature[]; + namespaceName: string; +} + +/** + * Generate typed accessor functions for data decorators. + * These are thin wrappers around the compiler's generic data decorator API. + */ +export function DataDecoratorAccessors(props: Readonly) { + const dataDecorators = props.decorators.filter((d) => d.isData); + if (dataDecorators.length === 0) { + return undefined; + } + + return ( + + {(signature) => ( + + )} + + ); +} + +interface DataDecoratorAccessorProps { + signature: DecoratorSignature; + namespaceName: string; +} + +function DataDecoratorAccessor(props: Readonly) { + const decorator = props.signature.decorator; + const name = decorator.name.slice(1); // remove @ + const capitalizedName = name[0].toUpperCase() + name.slice(1); + const fqn = props.namespaceName ? `${props.namespaceName}.${name}` : name; + const params = decorator.parameters; + const targetType = ; + + if (params.length === 0) { + // No-arg data decorator — generate `is*` function + return ( + + {code`return ${typespecCompiler.hasDataDecorator}(program, "${fqn}", ${decorator.target.name});`} + + ); + } + + // Decorators with args — generate `get*` function + let returnType; + if (params.length === 1) { + const param = params[0]; + returnType = ( + <> + + {" | undefined"} + + ); + } else { + // Multi-arg — return type is an interface with named properties + returnType = ( + <> + {"{"} + + {(param) => ( + <> + {" "} + {param.name}: + + )} + + {" } | undefined"} + + ); + } + + return ( + + {code`return ${typespecCompiler.getDataDecoratorValue}(program, "${fqn}", ${decorator.target.name}) as any;`} + + ); +} diff --git a/packages/tspd/src/gen-extern-signatures/components/decorator-signature-type.tsx b/packages/tspd/src/gen-extern-signatures/components/decorator-signature-type.tsx index 58d98921bb5..48f851e2077 100644 --- a/packages/tspd/src/gen-extern-signatures/components/decorator-signature-type.tsx +++ b/packages/tspd/src/gen-extern-signatures/components/decorator-signature-type.tsx @@ -117,7 +117,7 @@ export function ParameterTsType({ constraint }: ParameterTsTypeProps) { return typespecCompiler.Type; } -function TargetParameterTsType(props: { type: Type | undefined }) { +export function TargetParameterTsType(props: { type: Type | undefined }) { const type = props.type; if (type === undefined) { return typespecCompiler.Type; diff --git a/packages/tspd/src/gen-extern-signatures/components/entity-signature-tests.tsx b/packages/tspd/src/gen-extern-signatures/components/entity-signature-tests.tsx index 939b289db50..2bc681259a2 100644 --- a/packages/tspd/src/gen-extern-signatures/components/entity-signature-tests.tsx +++ b/packages/tspd/src/gen-extern-signatures/components/entity-signature-tests.tsx @@ -1,6 +1,6 @@ import { Refkey, Show } from "@alloy-js/core"; import * as ts from "@alloy-js/typescript"; -import { EntitySignature } from "../types.js"; +import { DecoratorSignature, EntitySignature } from "../types.js"; export interface EntitySignatureTests { namespaceName: string; @@ -19,12 +19,14 @@ export function EntitySignatureTests({ dollarFunctionsRefKey, dollarFunctionsTypeRefKey, }: Readonly) { - const hasDecorators = entities.some((e) => e.kind === "Decorator"); + const hasExternDecorators = entities.some( + (e): e is DecoratorSignature => e.kind === "Decorator" && !e.isData, + ); const hasFunctions = entities.some((e) => e.kind === "Function"); return ( <> - + e.kind === "Decorator"); + const externDecorators = decorators.filter((d) => !d.isData); + const dataDecorators = decorators.filter((d) => d.isData); const functions = entities.filter((e): e is FunctionSignature => e.kind === "Function"); return ( - - - 0}> - - - - {(signature) => } - - - - - - 0}> - - - - {(signature) => } - + <> + + + 0}> + + + + {(signature) => } + + + + + + 0}> + + + + {(signature) => } + + + + + + + 0}> - + - + ); } diff --git a/packages/tspd/src/gen-extern-signatures/external-packages/compiler.ts b/packages/tspd/src/gen-extern-signatures/external-packages/compiler.ts index 80a0013da31..a65e5f37a46 100644 --- a/packages/tspd/src/gen-extern-signatures/external-packages/compiler.ts +++ b/packages/tspd/src/gen-extern-signatures/external-packages/compiler.ts @@ -24,6 +24,8 @@ export const typespecCompiler = createPackage({ "Numeric", "ScalarValue", "DecoratorValidatorCallbacks", + "getDataDecoratorValue", + "hasDataDecorator", ], }, }, diff --git a/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts b/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts index 0fd36a7f075..995ae0d3c98 100644 --- a/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts +++ b/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts @@ -194,6 +194,7 @@ function resolveDecoratorSignature(decorator: Decorator): DecoratorSignature { name: decorator.name, jsName: "$" + decorator.name.slice(1), typeName: decorator.name[1].toUpperCase() + decorator.name.slice(2) + "Decorator", + isData: decorator.declarationKind === "data", }; } diff --git a/packages/tspd/src/gen-extern-signatures/types.ts b/packages/tspd/src/gen-extern-signatures/types.ts index cb01eb3d7ca..d0d39f265fb 100644 --- a/packages/tspd/src/gen-extern-signatures/types.ts +++ b/packages/tspd/src/gen-extern-signatures/types.ts @@ -15,6 +15,9 @@ export interface DecoratorSignature { typeName: string; decorator: Decorator; + + /** Whether this is a data decorator (declared with `data dec`). */ + isData: boolean; } export interface FunctionSignature { diff --git a/packages/tspd/test/gen-extern-signature/decorators-signatures.test.ts b/packages/tspd/test/gen-extern-signature/decorators-signatures.test.ts index 27ff5c8eb74..7a5678e78e6 100644 --- a/packages/tspd/test/gen-extern-signature/decorators-signatures.test.ts +++ b/packages/tspd/test/gen-extern-signature/decorators-signatures.test.ts @@ -423,3 +423,75 @@ function importLine(imports: string[]) { const all = new Set(["DecoratorContext", "DecoratorValidatorCallbacks", ...imports]); return `import type { ${[...all].sort().join(", ")} } from "@typespec/compiler";`; } + +function dataImportLine(typeImports: string[], valueImports: string[]) { + const all = [...valueImports, ...typeImports.map((t) => `type ${t}`)]; + all.sort((a, b) => { + const nameA = a.replace("type ", ""); + const nameB = b.replace("type ", ""); + return nameA.localeCompare(nameB); + }); + return `import { ${all.join(", ")} } from "@typespec/compiler";`; +} + +describe("data decorator accessors", () => { + it("generate accessor for no-arg data decorator (boolean flag)", async () => { + await expectSignatures({ + code: `data dec myFlag(target: Model);`, + expected: ` +${dataImportLine(["Model", "Program"], ["hasDataDecorator"])} + +export function isMyFlag(program: Program, target: Model): boolean { + return hasDataDecorator(program, "myFlag", target); +} + `, + }); + }); + + it("generate accessor for single-arg data decorator", async () => { + await expectSignatures({ + code: `data dec myLabel(target: Model, label: valueof string);`, + expected: ` +${dataImportLine(["Model", "Program"], ["getDataDecoratorValue"])} + +export function getMyLabel(program: Program, target: Model): string | undefined { + return getDataDecoratorValue(program, "myLabel", target) as any; +} + `, + }); + }); + + it("generate accessor for multi-arg data decorator", async () => { + await expectSignatures({ + code: `data dec myMeta(target: Model, name: valueof string, version: valueof int32);`, + expected: ` +${dataImportLine(["Model", "Program"], ["getDataDecoratorValue"])} + +export function getMyMeta(program: Program, target: Model): { name: string; version: number } | undefined { + return getDataDecoratorValue(program, "myMeta", target) as any; +} + `, + }); + }); + + it("does not generate $decorators type for data decorators", async () => { + const result = await generateDecoratorSignatures(`data dec myFlag(target: Model);`); + expect(result).not.toContain("Decorators"); + expect(result).not.toContain("$myFlag"); + }); + + it("generates both extern and data decorator outputs when mixed", async () => { + const result = await generateDecoratorSignatures(` + extern dec externDec(target: Model); + data dec dataFlag(target: Model); + `); + // Verify extern decorator parts + expect(result).toContain("ExternDecDecorator"); + expect(result).toContain("externDec: ExternDecDecorator"); + // Verify data decorator parts + expect(result).toContain("isDataFlag"); + expect(result).toContain("hasDataDecorator"); + // Verify no $decorators type for data decorators + expect(result).not.toContain("dataFlag: "); + }); +}); diff --git a/website/src/content/docs/docs/extending-typespec/create-decorators.md b/website/src/content/docs/docs/extending-typespec/create-decorators.md index 71c3ae95d09..bb8fb372b43 100644 --- a/website/src/content/docs/docs/extending-typespec/create-decorators.md +++ b/website/src/content/docs/docs/extending-typespec/create-decorators.md @@ -8,6 +8,77 @@ TypeSpec decorators are implemented as JavaScript functions. The process of crea 1. [Declare the decorator signature in TypeSpec](#declare-the-decorator-signature) (optional but recommended) 2. [Implement the decorator in JavaScript](#javascript-decorator-implementation) +Alternatively, for decorators that simply store metadata, you can use [data decorators](#data-decorators) which require no JavaScript implementation at all. + +## Data decorators + +Data decorators are a simplified way to declare decorators that only store metadata. They are declared with the `data` modifier and require no JavaScript implementation — the compiler auto-generates the storage logic. + +```typespec +// A boolean flag (no parameters beyond the target) +data dec tracked(target: unknown); + +// A single value +data dec label(target: Model, value: valueof string); + +// Multiple values (stored as a named record) +data dec serviceInfo(target: Model, name: valueof string, version: valueof int32); +``` + +### How data is stored + +Data decorator arguments are stored automatically in the program's state map, keyed by the decorator's fully-qualified name: + +- **No parameters** (flag): stores `true` +- **Single parameter**: stores the value directly +- **Multiple parameters**: stores a record with parameter names as keys, e.g. `{ name: "hello", version: 1 }` + +### Reading data decorator values + +The compiler provides a generic API to read data decorator values without any generated code: + +```ts +import { hasDataDecorator, getDataDecoratorValue } from "@typespec/compiler"; + +// Check if a flag decorator was applied +if (hasDataDecorator(program, "MyLib.tracked", type)) { + // ... +} + +// Get the stored value +const label = getDataDecoratorValue(program, "MyLib.label", type) as string; + +// Get a multi-arg record +const info = getDataDecoratorValue(program, "MyLib.serviceInfo", type) as { + name: string; + version: number; +}; +``` + +### Generated typed accessors + +When using `tspd gen-extern-signature`, typed accessor functions are generated for data decorators: + +```ts +// Generated for: data dec tracked(target: Model); +export function isTracked(program: Program, target: Model): boolean; + +// Generated for: data dec label(target: Model, value: valueof string); +export function getLabel(program: Program, target: Model): string | undefined; +``` + +### Combining with `internal` + +Data decorators can be combined with the `internal` modifier: + +```typespec +internal data dec myInternalFlag(target: Model); +``` + +:::note +`data` and `extern` are mutually exclusive — a decorator is either auto-implemented (data) or externally implemented (extern). +::: + ## Declare the decorator signature While this step is optional, it offers significant benefits: diff --git a/website/src/content/docs/docs/language-basics/decorators.md b/website/src/content/docs/docs/language-basics/decorators.md index 92c820853a0..f017a890504 100644 --- a/website/src/content/docs/docs/language-basics/decorators.md +++ b/website/src/content/docs/docs/language-basics/decorators.md @@ -62,3 +62,9 @@ model Dog { ## Creating decorators For more information on creating decorators, see [Creating Decorators](../extending-typespec/create-decorators.md). + +For decorators that simply attach metadata without custom logic, TypeSpec provides [data decorators](../extending-typespec/create-decorators.md#data-decorators) which require no JavaScript implementation: + +```typespec +data dec label(target: Model, value: valueof string); +```