diff --git a/packages/compiler/src/ast/index.ts b/packages/compiler/src/ast/index.ts index 8c126a4291e..e090af73163 100644 --- a/packages/compiler/src/ast/index.ts +++ b/packages/compiler/src/ast/index.ts @@ -38,6 +38,7 @@ export type { CallExpressionNode, ConstStatementNode, DeclarationNode, + DecoratedExpressionNode, DecoratorDeclarationStatementNode, DecoratorExpressionNode, DirectiveExpressionNode, diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 412963497fb..181c3303e8e 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -56,6 +56,7 @@ import { DecoratorContext, DecoratorDeclarationStatementNode, DecoratorExpressionNode, + DecoratedExpressionNode, DecoratorValidatorCallbacks, Diagnostic, DiagnosticTarget, @@ -1037,6 +1038,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return checkCallExpression(ctx, node); case SyntaxKind.TypeOfExpression: return checkTypeOfExpression(ctx, node); + case SyntaxKind.DecoratedExpression: + return checkDecoratedExpression(ctx, node); case SyntaxKind.AugmentDecoratorStatement: return checkAugmentDecorator(ctx, node); case SyntaxKind.UsingStatement: @@ -3927,6 +3930,32 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return type; } + function checkDecoratedExpression( + ctx: CheckContext, + node: DecoratedExpressionNode, + ): Type | Value | IndeterminateEntity | null { + const targetResult = checkNode(ctx, node.target); + if (targetResult === null) { + return null; + } + + // Apply decorators to the resolved type + if (typeof targetResult === "object" && "entityKind" in targetResult) { + if (targetResult.entityKind === "Type" && "decorators" in targetResult) { + const type = targetResult as Type & { decorators: DecoratorApplication[] }; + for (const decNode of node.decorators) { + const decorator = checkDecoratorApplication(ctx, type, decNode); + if (decorator) { + type.decorators.unshift(decorator); + applyDecoratorToType(program, decorator, type); + } + } + } + } + + return targetResult; + } + /** Find the indexer that applies to this model. Either defined on itself or from a base model */ function findIndexer(model: Model): ModelIndexer | undefined { let current: Model | undefined = model; @@ -4748,7 +4777,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker model: ModelStatementNode, heritageRef: Expression, ): Model | undefined { - if (heritageRef.kind === SyntaxKind.ModelExpression) { + // Unwrap decorated expression to check the target + const innerRef = + heritageRef.kind === SyntaxKind.DecoratedExpression ? heritageRef.target : heritageRef; + if (innerRef.kind === SyntaxKind.ModelExpression) { reportCheckerDiagnostic( createDiagnostic({ code: "extend-model", @@ -4759,8 +4791,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return undefined; } if ( - heritageRef.kind !== SyntaxKind.TypeReference && - heritageRef.kind !== SyntaxKind.ArrayExpression + innerRef.kind !== SyntaxKind.TypeReference && + innerRef.kind !== SyntaxKind.ArrayExpression ) { reportCheckerDiagnostic( createDiagnostic({ @@ -4773,7 +4805,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const modelSymId = getNodeSym(model); pendingResolutions.start(modelSymId, ResolutionKind.BaseType); - const target = resolver.getNodeLinks(heritageRef).resolvedSymbol; + const target = resolver.getNodeLinks(innerRef).resolvedSymbol; if (target && pendingResolutions.has(target, ResolutionKind.BaseType)) { if (ctx.mapper === undefined) { reportCheckerDiagnostic( @@ -4786,7 +4818,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } return undefined; } - const heritageType = getTypeForNode(heritageRef, ctx); + const heritageType = getTypeForNode(innerRef, ctx); pendingResolutions.finish(modelSymId, ResolutionKind.BaseType); if (isErrorType(heritageType)) { compilerAssert(program.hasError(), "Should already have reported an error.", heritageRef); @@ -4808,6 +4840,17 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ); } + // Apply decorators from the decorated expression to the resolved type + if (heritageRef.kind === SyntaxKind.DecoratedExpression) { + for (const decNode of heritageRef.decorators) { + const decorator = checkDecoratorApplication(ctx, heritageType, decNode); + if (decorator) { + heritageType.decorators.unshift(decorator); + applyDecoratorToType(program, decorator, heritageType); + } + } + } + return heritageType; } @@ -4821,7 +4864,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const modelSymId = getNodeSym(model); pendingResolutions.start(modelSymId, ResolutionKind.BaseType); let isType; - if (isExpr.kind === SyntaxKind.ModelExpression) { + // Unwrap decorated expression to check the target + const innerExpr = + isExpr.kind === SyntaxKind.DecoratedExpression ? isExpr.target : isExpr; + if (innerExpr.kind === SyntaxKind.ModelExpression) { reportCheckerDiagnostic( createDiagnostic({ code: "is-model", @@ -4830,10 +4876,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker }), ); return undefined; - } else if (isExpr.kind === SyntaxKind.ArrayExpression) { - isType = checkArrayExpression(ctx, isExpr); - } else if (isExpr.kind === SyntaxKind.TypeReference) { - const target = resolver.getNodeLinks(isExpr).resolvedSymbol; + } else if (innerExpr.kind === SyntaxKind.ArrayExpression) { + isType = checkArrayExpression(ctx, innerExpr); + } else if (innerExpr.kind === SyntaxKind.TypeReference) { + const target = resolver.getNodeLinks(innerExpr).resolvedSymbol; if (target && pendingResolutions.has(target, ResolutionKind.BaseType)) { if (ctx.mapper === undefined) { reportCheckerDiagnostic( @@ -4846,7 +4892,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } return undefined; } - isType = getTypeForNode(isExpr, ctx); + isType = getTypeForNode(innerExpr, ctx); } else { reportCheckerDiagnostic(createDiagnostic({ code: "is-model", target: isExpr })); return undefined; @@ -4866,6 +4912,17 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return undefined; } + // Apply decorators from the decorated expression to the resolved type + if (isExpr.kind === SyntaxKind.DecoratedExpression) { + for (const decNode of isExpr.decorators) { + const decorator = checkDecoratorApplication(ctx, isType, decNode); + if (decorator) { + isType.decorators.unshift(decorator); + applyDecoratorToType(program, decorator, isType); + } + } + } + return isType; } diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index e977aa02fe2..d888ebf6be0 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -28,6 +28,7 @@ import { Comment, ConstStatementNode, DeclarationNode, + DecoratedExpressionNode, DecoratorDeclarationStatementNode, DecoratorExpressionNode, Diagnostic, @@ -1684,9 +1685,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa case Token.OpenParen: return parseParenthesizedExpression(); case Token.At: - const decorators = parseDecoratorList(); - reportInvalidDecorators(decorators, "expression"); - continue; + return parseDecoratedExpression(); case Token.Hash: const directives = parseDirectiveList(); reportInvalidDirective(directives, "expression"); @@ -1707,6 +1706,18 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa } } + function parseDecoratedExpression(): DecoratedExpressionNode { + const pos = tokenPos(); + const decorators = parseDecoratorList(); + const target = parseExpression(); + return { + kind: SyntaxKind.DecoratedExpression, + decorators, + target, + ...finishNode(pos), + }; + } + function parseExternKeyword(): ExternKeywordNode { const pos = tokenPos(); parseExpected(Token.ExternKeyword); @@ -2951,6 +2962,8 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined return visitNode(cb, node.base) || visitNode(cb, node.id); case SyntaxKind.ModelExpression: return visitEach(cb, node.properties); + case SyntaxKind.DecoratedExpression: + return visitEach(cb, node.decorators) || visitNode(cb, node.target); case SyntaxKind.ModelProperty: return ( visitEach(cb, node.decorators) || diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 1a8cb3b9a4c..4ea28d42674 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -1066,6 +1066,7 @@ export enum SyntaxKind { ConstStatement, CallExpression, ScalarConstructor, + DecoratedExpression, } export const enum NodeFlags { @@ -1357,7 +1358,8 @@ export type Expression = | StringTemplateExpressionNode | VoidKeywordNode | NeverKeywordNode - | AnyKeywordNode; + | AnyKeywordNode + | DecoratedExpressionNode; export type ReferenceExpression = | TypeReferenceNode @@ -1512,6 +1514,12 @@ export interface ModelExpressionNode extends BaseNode { readonly bodyRange: TextRange; } +export interface DecoratedExpressionNode extends BaseNode { + readonly kind: SyntaxKind.DecoratedExpression; + readonly decorators: readonly DecoratorExpressionNode[]; + readonly target: Expression; +} + export interface ArrayExpressionNode extends BaseNode { readonly kind: SyntaxKind.ArrayExpression; readonly elementType: Expression; diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index fe9834ce1c4..de312e9b76e 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -15,6 +15,7 @@ import { CallExpressionNode, Comment, ConstStatementNode, + DecoratedExpressionNode, DecoratorDeclarationStatementNode, DecoratorExpressionNode, DirectiveExpressionNode, @@ -189,6 +190,8 @@ export function printNode( return printBooleanLiteral(path as AstPath, options); case SyntaxKind.ModelExpression: return printModelExpression(path as AstPath, options, print); + case SyntaxKind.DecoratedExpression: + return printDecoratedExpression(path as AstPath, options, print); case SyntaxKind.ModelProperty: return printModelProperty(path as AstPath, options, print); case SyntaxKind.DecoratorExpression: @@ -937,6 +940,15 @@ export function printModelExpression( } } +export function printDecoratedExpression( + path: AstPath, + options: TypeSpecPrettierOptions, + print: PrettierChildPrint, +) { + const decorators = path.map((x) => [print(x as any), " "], "decorators"); + return group([...decorators, path.call(print, "target")]); +} + export function printObjectLiteral( path: AstPath, options: TypeSpecPrettierOptions, diff --git a/packages/compiler/test/checker/model.test.ts b/packages/compiler/test/checker/model.test.ts index 5149d7e810a..c138c5d7f59 100644 --- a/packages/compiler/test/checker/model.test.ts +++ b/packages/compiler/test/checker/model.test.ts @@ -1350,4 +1350,123 @@ describe("compiler: models", () => { }); }); }); + + describe("decorated expressions", () => { + it("applies @doc decorator to inline model expression", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + @test model A { + prop: @doc("inline doc") { name: string }; + } + `, + ); + const { A } = (await testHost.compile("main.tsp")) as { + A: Model; + }; + + const propType = A.properties.get("prop")!.type as Model; + strictEqual(propType.kind, "Model"); + strictEqual(getDoc(testHost.program, propType), "inline doc"); + }); + + it("applies @doc decorator to model expression in alias", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + alias MyModel = @doc("alias doc") { name: string }; + @test model A { + prop: MyModel; + } + `, + ); + const { A } = (await testHost.compile("main.tsp")) as { + A: Model; + }; + + const propType = A.properties.get("prop")!.type as Model; + strictEqual(propType.kind, "Model"); + strictEqual(getDoc(testHost.program, propType), "alias doc"); + }); + + it("applies decorator to type reference expression", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + model Base { name: string } + @test model A { + prop: @doc("ref doc") Base; + } + `, + ); + const { A } = (await testHost.compile("main.tsp")) as { + A: Model; + }; + + const propType = A.properties.get("prop")!.type as Model; + strictEqual(propType.kind, "Model"); + strictEqual(getDoc(testHost.program, propType), "ref doc"); + }); + + it("applies decorator to is expression", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + model Base { name: string } + @test model A is @doc("is doc") Base { } + `, + ); + const { A } = (await testHost.compile("main.tsp")) as { + A: Model; + }; + + strictEqual(A.sourceModel?.kind, "Model"); + strictEqual(getDoc(testHost.program, A.sourceModel!), "is doc"); + }); + + it("applies custom decorator to inline model expression", async () => { + let decoratedType: Model | undefined; + + testHost.addJsFile("dec.js", { + $myDec(p: any, t: Model) { + decoratedType = t; + }, + }); + + testHost.addTypeSpecFile( + "main.tsp", + ` + import "./dec.js"; + @test model A { + prop: @myDec { x: int32 }; + } + `, + ); + const { A } = (await testHost.compile("main.tsp")) as { + A: Model; + }; + + ok(decoratedType); + strictEqual(decoratedType!.kind, "Model"); + ok(decoratedType!.properties.has("x")); + }); + + it("applies decorator to union expression", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + @test model A { + prop: @doc("union doc") (string | int32); + } + `, + ); + const { A } = (await testHost.compile("main.tsp")) as { + A: Model; + }; + + const propType = A.properties.get("prop")!.type; + strictEqual(propType.kind, "Union"); + strictEqual(getDoc(testHost.program, propType), "union doc"); + }); + }); }); diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index 1e8d360c19d..5ffbef6e2b8 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -234,6 +234,17 @@ describe("compiler: parser", () => { parseEach(['model Car { engine: { type: "v8" } }']); }); + describe("decorated expressions", () => { + parseEach([ + 'model A { prop: @doc("inline") { name: string } }', + 'alias B = @doc("alias") { x: int32 };', + "model C { prop: @myDec SomeModel }", + "model D is @myDec Base { }", + 'model E { prop: @doc("union") (string | int32) }', + "alias F = @myDec string[];", + ]); + }); + describe("tuple model expressions", () => { parseEach([ 'namespace A { op b(param: [number, string]): [1, "hi"]; }', diff --git a/packages/samples/specs/documentation/docs.tsp b/packages/samples/specs/documentation/docs.tsp index 96986fab78d..f9158ab07a8 100644 --- a/packages/samples/specs/documentation/docs.tsp +++ b/packages/samples/specs/documentation/docs.tsp @@ -48,3 +48,8 @@ model NotFoundWithDocsResp { @statusCode _: 404; details: string; } + +// Example of decorating inline model expressions +model Contact { + address: @doc("Mailing address") { street: string; city: string; zip: string }; +} diff --git a/packages/samples/test/output/documentation/@typespec/openapi3/openapi.yaml b/packages/samples/test/output/documentation/@typespec/openapi3/openapi.yaml index 755b42d3dcb..e73f6b2dc72 100644 --- a/packages/samples/test/output/documentation/@typespec/openapi3/openapi.yaml +++ b/packages/samples/test/output/documentation/@typespec/openapi3/openapi.yaml @@ -86,6 +86,24 @@ paths: $ref: '#/components/schemas/Error' components: schemas: + Contact: + type: object + required: + - address + properties: + address: + type: object + properties: + street: + type: string + city: + type: string + zip: + type: string + required: + - street + - city + - zip Error: type: object required: diff --git a/website/src/content/docs/docs/language-basics/decorators.md b/website/src/content/docs/docs/language-basics/decorators.md index 92c820853a0..ab9d8501755 100644 --- a/website/src/content/docs/docs/language-basics/decorators.md +++ b/website/src/content/docs/docs/language-basics/decorators.md @@ -7,7 +7,7 @@ llmstxt: true Decorators in TypeSpec allow developers to attach metadata to types within a TypeSpec program. They can also be used to compute types based on their inputs. Decorators form the core of TypeSpec's extensibility, providing the flexibility to describe a wide variety of APIs and associated metadata such as documentation, constraints, samples, and more. -The vast majority of TypeSpec declarations may be decorated, including [namespaces](./namespaces.md), [interfaces](./interfaces.md), [operations](./operations.md) and their parameters, [scalars](./scalars.md), and [models](./models.md) and their members. In general, any declaration that creates a Type can be decorated. Notably, [aliases](./alias.md) cannot be decorated, as they do not create new Types, nor can any type expressions such as unions that use the `|` syntax or anonymous models, as they are not declarations. +The vast majority of TypeSpec declarations may be decorated, including [namespaces](./namespaces.md), [interfaces](./interfaces.md), [operations](./operations.md) and their parameters, [scalars](./scalars.md), and [models](./models.md) and their members. In general, any declaration that creates a Type can be decorated. [Aliases](./alias.md) cannot be decorated directly, as they do not create new Types, but the expressions they reference can be. Type expressions such as anonymous models, union expressions, and type references can also be decorated inline. Decorators are defined using JavaScript functions that are exported from a standard ECMAScript module. When a JavaScript file is imported, TypeSpec will look for any exported functions prefixed with `$`, and make them available as decorators within the TypeSpec syntax. When a decorated declaration is evaluated by TypeSpec, the decorator function is invoked, passing along a reference to the current compilation, an object representing the type it is attached to, and any arguments the user provided to the decorator. @@ -32,6 +32,29 @@ If no arguments are provided, the parentheses can be omitted. model Dog {} ``` +## Decorating expressions + +Decorators can be applied directly to type expressions such as inline models, type references, and union expressions. The decorator is placed before the expression it decorates. + +```typespec +model Pet { + owner: @doc("The pet owner's info") { name: string; phone: string }; +} +``` + +This also works with aliases: + +```typespec +alias Address = @doc("A mailing address") { street: string; city: string }; +``` + +Decorators on expressions work with `is` and `extends` as well: + +```typespec +model Base { id: string } +model Extended is @tag("extended") Base { extra: string } +``` + ## Augmenting decorators Decorators can also be applied from a different location by referring to the type being decorated. For this, you can declare an augment decorator using the `@@` prefix. The first argument of an augment decorator is the type reference that should be decorated. As the augment decorator is a statement, it must end with a semicolon (`;`). diff --git a/website/src/content/docs/docs/language-basics/models.md b/website/src/content/docs/docs/language-basics/models.md index 23c4eb0b9a6..14941975a95 100644 --- a/website/src/content/docs/docs/language-basics/models.md +++ b/website/src/content/docs/docs/language-basics/models.md @@ -235,3 +235,15 @@ Some model property meta types can be referenced using `::`. | Name | Example | Description | | ---- | ---------------- | ---------------------------------------- | | type | `Pet.name::type` | Reference the type of the model property | + +## Decorating inline models + +Decorators can be applied to inline model expressions and other type expressions used in property types, aliases, and `is`/`extends` clauses. Place the decorator before the expression: + +```typespec +model Pet { + owner: @doc("Owner contact info") { name: string; phone: string }; +} + +alias Address = @doc("A mailing address") { street: string; city: string }; +```