From 638a365e489848fd2b5c935867006b5c247ae52d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 18 Jun 2026 15:08:40 -0400 Subject: [PATCH 01/21] feat(compiler): allow declarations to be used as expressions Allow model, enum, union, and scalar declarations to be used in expression position (e.g. alias RHS, property types). In expression position they are anonymous (name is "") and the resulting type has expression: true; they are not registered in the enclosing namespace. A diagnostic is reported when template parameters are used on a declaration in expression position. --- ...arations-as-expressions-2026-4-18-0-0-0.md | 20 +++ packages/compiler/src/core/binder.ts | 44 ++++- packages/compiler/src/core/checker.ts | 72 ++++++-- packages/compiler/src/core/inspector/node.ts | 2 +- packages/compiler/src/core/messages.ts | 7 + packages/compiler/src/core/parser.ts | 41 ++++- packages/compiler/src/core/types.ts | 63 ++++++- .../compiler/src/formatter/print/printer.ts | 41 +++-- packages/compiler/src/server/classify.ts | 8 +- packages/compiler/src/server/completion.ts | 4 +- .../compiler/src/server/symbol-structure.ts | 6 +- packages/compiler/src/typekit/kits/enum.ts | 1 + packages/compiler/src/typekit/kits/model.ts | 1 + .../checker/declaration-expressions.test.ts | 165 ++++++++++++++++++ .../compiler/test/formatter/formatter.test.ts | 57 ++++++ packages/compiler/test/parser.test.ts | 22 ++- .../test/testing/rule-tester-codefix.test.ts | 2 +- 17 files changed, 505 insertions(+), 51 deletions(-) create mode 100644 .chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md create mode 100644 packages/compiler/test/checker/declaration-expressions.test.ts diff --git a/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md b/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md new file mode 100644 index 00000000000..70f209339bf --- /dev/null +++ b/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md @@ -0,0 +1,20 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Allow `model`, `enum`, `union`, and `scalar` declarations to be used as expressions. A declaration used in expression position is anonymous (its `name` is `""`) and its corresponding type has `expression: true`. It is not registered in the enclosing namespace. + +```tsp +alias Foo = enum { + a, + b, +}; + +model Bar { + status: enum { active, inactive }; + unit: scalar extends string; + inner: model { x: string }; +} +``` diff --git a/packages/compiler/src/core/binder.ts b/packages/compiler/src/core/binder.ts index 09a5e5b5108..81a835c008c 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -391,11 +391,29 @@ export function createBinder(program: Program): Binder { declareSymbol(node, SymbolFlags.TemplateParameter | SymbolFlags.Declaration); } + /** + * Whether a declaration node (model/enum/union/scalar) appears in statement + * position (directly under a namespace or file) rather than in expression + * position. Anonymous declarations are always in expression position. + */ + function isDeclarationStatementPosition(node: Node): boolean { + const parent = node.parent; + return ( + parent?.kind === SyntaxKind.NamespaceStatement || + parent?.kind === SyntaxKind.TypeSpecScript || + parent?.kind === SyntaxKind.JsSourceFile + ); + } + function bindModelStatement(node: ModelStatementNode) { const internal = node.modifierFlags & ModifierFlags.Internal ? SymbolFlags.Internal : SymbolFlags.None; - declareSymbol(node, SymbolFlags.Model | SymbolFlags.Declaration | internal); + if (isDeclarationStatementPosition(node)) { + declareSymbol(node, SymbolFlags.Model | SymbolFlags.Declaration | internal); + } else { + bindSymbol(node, SymbolFlags.Model); + } // Initialize locals for type parameters mutate(node).locals = new SymbolTable(); } @@ -415,7 +433,11 @@ export function createBinder(program: Program): Binder { function bindScalarStatement(node: ScalarStatementNode) { const internal = node.modifierFlags & ModifierFlags.Internal ? SymbolFlags.Internal : SymbolFlags.None; - declareSymbol(node, SymbolFlags.Scalar | SymbolFlags.Declaration | internal); + if (isDeclarationStatementPosition(node)) { + declareSymbol(node, SymbolFlags.Scalar | SymbolFlags.Declaration | internal); + } else { + bindSymbol(node, SymbolFlags.Scalar); + } // Initialize locals for type parameters mutate(node).locals = new SymbolTable(); } @@ -434,7 +456,11 @@ export function createBinder(program: Program): Binder { function bindUnionStatement(node: UnionStatementNode) { const internal = node.modifierFlags & ModifierFlags.Internal ? SymbolFlags.Internal : SymbolFlags.None; - declareSymbol(node, SymbolFlags.Union | SymbolFlags.Declaration | internal); + if (isDeclarationStatementPosition(node)) { + declareSymbol(node, SymbolFlags.Union | SymbolFlags.Declaration | internal); + } else { + bindSymbol(node, SymbolFlags.Union); + } mutate(node).locals = new SymbolTable(); } @@ -454,7 +480,11 @@ export function createBinder(program: Program): Binder { function bindEnumStatement(node: EnumStatementNode) { const internal = node.modifierFlags & ModifierFlags.Internal ? SymbolFlags.Internal : SymbolFlags.None; - declareSymbol(node, SymbolFlags.Enum | SymbolFlags.Declaration | internal); + if (isDeclarationStatementPosition(node)) { + declareSymbol(node, SymbolFlags.Enum | SymbolFlags.Declaration | internal); + } else { + bindSymbol(node, SymbolFlags.Enum); + } } function bindEnumMember(node: EnumMemberNode) { @@ -560,7 +590,7 @@ export function createBinder(program: Program): Binder { case SyntaxKind.JsSourceFile: return declareScriptMember(node, flags, name); default: - const key = name ?? node.id.sv; + const key = name ?? node.id?.sv ?? ""; const symbol = createSymbol(node, key, flags, scope?.symbol); mutate(node).symbol = symbol; mutate(scope.locals!).set(key, symbol); @@ -585,7 +615,7 @@ export function createBinder(program: Program): Binder { ) { return; } - const key = name ?? node.id.sv; + const key = name ?? node.id?.sv ?? ""; const symbol = createSymbol(node, key, flags, scope.symbol); mutate(node).symbol = symbol; mutate(scope.symbol.exports)!.set(key, symbol); @@ -604,7 +634,7 @@ export function createBinder(program: Program): Binder { ) { return; } - const key = name ?? node.id.sv; + const key = name ?? node.id?.sv ?? ""; const symbol = createSymbol(node, key, flags, fileNamespace?.symbol); mutate(node).symbol = symbol; mutate(effectiveScope.symbol.exports!).set(key, symbol); diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 5552a5cb762..3d50c60985f 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -2527,7 +2527,12 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if ( node.kind === SyntaxKind.ModelExpression || node.kind === SyntaxKind.IntersectionExpression || - node.kind === SyntaxKind.UnionExpression + node.kind === SyntaxKind.UnionExpression || + ((node.kind === SyntaxKind.ModelStatement || + node.kind === SyntaxKind.EnumStatement || + node.kind === SyntaxKind.UnionStatement || + node.kind === SyntaxKind.ScalarStatement) && + isDeclarationInExpressionPosition(node)) ) { let parent: Node | undefined = node.parent; while (parent !== undefined) { @@ -4996,6 +5001,39 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } + /** + * Determine whether a declaration node (model/enum/union/scalar) appears in + * expression position (e.g. as the value of an alias or a property type) rather + * than as a top-level statement in a namespace or file. Anonymous declarations + * (without an `id`) are always in expression position. + */ + function isDeclarationInExpressionPosition( + node: ModelStatementNode | EnumStatementNode | UnionStatementNode | ScalarStatementNode, + ): boolean { + const parent = node.parent; + return ( + parent === undefined || + (parent.kind !== SyntaxKind.NamespaceStatement && parent.kind !== SyntaxKind.TypeSpecScript) + ); + } + + /** + * A declaration used in expression position is anonymous and cannot be referenced or + * instantiated, so template parameters on it are meaningless. Report a diagnostic when present. + */ + function checkExpressionDeclarationConstraints( + node: ModelStatementNode | UnionStatementNode | ScalarStatementNode, + ): void { + if (node.templateParameters.length > 0 && isDeclarationInExpressionPosition(node)) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "templated-declaration-in-expression", + target: node.templateParameters[0], + }), + ); + } + } + function checkModelStatement(ctx: CheckContext, node: ModelStatementNode): Model { const links = getSymbolLinks(node.symbol); @@ -5011,17 +5049,19 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); + checkExpressionDeclarationConstraints(node); const decorators: DecoratorApplication[] = []; const type: Model = createType({ kind: "Model", - name: node.id.sv, + name: node.id?.sv ?? "", node: node, properties: createRekeyableMap(), namespace: getParentNamespaceType(node), decorators, sourceModels: [], derivedModels: [], + expression: isDeclarationInExpressionPosition(node), }); linkType(ctx, links, type); @@ -5080,7 +5120,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // Hold on to the model type that's being defined so that it // can be referenced - if (ctx.mapper === undefined) { + if (ctx.mapper === undefined && !type.expression) { type.namespace?.models.set(type.name, type); } @@ -5280,6 +5320,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators: [], derivedModels: [], sourceModels: [], + expression: true, }); for (const prop of properties.values()) { @@ -7274,17 +7315,19 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); + checkExpressionDeclarationConstraints(node); const decorators: DecoratorApplication[] = []; const type: Scalar = createType({ kind: "Scalar", - name: node.id.sv, + name: node.id?.sv ?? "", node: node, constructors: new Map(), namespace: getParentNamespaceType(node), decorators, derivedScalars: [], + expression: isDeclarationInExpressionPosition(node), }); linkType(ctx, links, type); @@ -7298,7 +7341,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkScalarConstructors(ctx, type, node, type.constructors); decorators.push(...checkDecorators(ctx, type, node)); - if (ctx.mapper === undefined) { + if (ctx.mapper === undefined && !type.expression) { type.namespace?.scalars.set(type.name, type); } linkMapper(type, ctx.mapper); @@ -7533,10 +7576,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkModifiers(program, node); const enumType: Enum = (links.type = createType({ kind: "Enum", - name: node.id.sv, + name: node.id?.sv ?? "", node, members: createRekeyableMap(), decorators: [], + expression: isDeclarationInExpressionPosition(node), })); const memberNames = new Set(); @@ -7573,7 +7617,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const namespace = getParentNamespaceType(node); enumType.namespace = namespace; - enumType.namespace?.enums.set(enumType.name!, enumType); + if (!enumType.expression) { + enumType.namespace?.enums.set(enumType.name!, enumType); + } enumType.decorators = checkDecorators(ctx, enumType, node); linkMapper(enumType, ctx.mapper); finishType(enumType); @@ -7715,6 +7761,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); + checkExpressionDeclarationConstraints(node); const variants = createRekeyableMap(); const unionType: Union = createType({ @@ -7722,12 +7769,12 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators: [], node, namespace: getParentNamespaceType(node), - name: node.id.sv, + name: node.id?.sv, variants, get options() { return Array.from(this.variants.values()).map((v) => v.type); }, - expression: false, + expression: isDeclarationInExpressionPosition(node), }); linkType(ctx, links, unionType); @@ -7737,12 +7784,14 @@ export function createChecker(program: Program, resolver: NameResolver): Checker linkMapper(unionType, ctx.mapper); - if (ctx.mapper === undefined) { + if (ctx.mapper === undefined && !unionType.expression) { unionType.namespace?.unions.set(unionType.name!, unionType); } lateBindMemberContainer(unionType); - lateBindMembers(unionType); + if (unionType.symbol) { + lateBindMembers(unionType); + } return finishType(unionType, { skipDecorators: ctx.hasFlags(CheckFlags.InTemplateDeclaration), }); @@ -7951,6 +8000,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators: [], derivedModels: [], sourceModels: [], + expression: true, }); } diff --git a/packages/compiler/src/core/inspector/node.ts b/packages/compiler/src/core/inspector/node.ts index af9f783d828..6749c649f58 100644 --- a/packages/compiler/src/core/inspector/node.ts +++ b/packages/compiler/src/core/inspector/node.ts @@ -38,7 +38,7 @@ function printNodeInfoInternal(node: Node): string { case SyntaxKind.AliasStatement: case SyntaxKind.ConstStatement: case SyntaxKind.UnionStatement: - return node.id.sv; + return node.id?.sv ?? ""; default: return ""; } diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 9008e0f6be6..137e03cdb17 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -207,6 +207,13 @@ const diagnostics = { default: paramMessage`Cannot decorate ${"nodeName"}.`, }, }, + "templated-declaration-in-expression": { + severity: "error", + messages: { + default: + "A declaration used as an expression cannot have template parameters as it cannot be referenced or instantiated.", + }, + }, "default-required": { severity: "error", messages: { diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 63ed0076f13..73e80eb6111 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -697,9 +697,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa pos: number, decorators: DecoratorExpressionNode[], modifiers: Modifier[], + allowAnonymous = false, ): UnionStatementNode { parseExpected(Token.UnionKeyword); - const id = parseIdentifier(); + const id = parseDeclarationIdentifier(allowAnonymous); const { items: templateParameters, range: templateParametersRange } = parseTemplateParameterList(); @@ -897,9 +898,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa pos: number, decorators: DecoratorExpressionNode[], modifiers: Modifier[], + allowAnonymous = false, ): ModelStatementNode { parseExpected(Token.ModelKeyword); - const id = parseIdentifier(); + const id = parseDeclarationIdentifier(allowAnonymous); const { items: templateParameters, range: templateParametersRange } = parseTemplateParameterList(); @@ -1102,14 +1104,15 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa pos: number, decorators: DecoratorExpressionNode[], modifiers: Modifier[], + allowAnonymous = false, ): ScalarStatementNode { parseExpected(Token.ScalarKeyword); - const id = parseIdentifier(); + const id = parseDeclarationIdentifier(allowAnonymous); const { items: templateParameters, range: templateParametersRange } = parseTemplateParameterList(); const optionalExtends = parseOptionalScalarExtends(); - const { items: members, range: bodyRange } = parseScalarMembers(); + const { items: members, range: bodyRange } = parseScalarMembers(allowAnonymous); return { kind: SyntaxKind.ScalarStatement, @@ -1133,7 +1136,12 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return undefined; } - function parseScalarMembers(): ListDetail { + function parseScalarMembers(allowAnonymous = false): ListDetail { + // In expression position there is no `;` terminator: only parse a `{ ... }` body + // when present, otherwise the scalar has no members. + if (allowAnonymous && token() !== Token.OpenBrace) { + return createEmptyList(); + } if (token() === Token.Semicolon) { nextToken(); return createEmptyList(); @@ -1163,9 +1171,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa pos: number, decorators: DecoratorExpressionNode[], modifiers: Modifier[], + allowAnonymous = false, ): EnumStatementNode { parseExpected(Token.EnumKeyword); - const id = parseIdentifier(); + const id = parseDeclarationIdentifier(allowAnonymous); const { items: members } = parseList(ListKind.EnumMembers, parseEnumMemberOrSpread); return { kind: SyntaxKind.EnumStatement, @@ -1724,6 +1733,14 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return parseNumericLiteral(); case Token.OpenBrace: return parseModelExpression(); + case Token.ModelKeyword: + return parseModelStatement(tokenPos(), [], [], true); + case Token.EnumKeyword: + return parseEnumStatement(tokenPos(), [], [], true); + case Token.UnionKeyword: + return parseUnionStatement(tokenPos(), [], [], true); + case Token.ScalarKeyword: + return parseScalarStatement(tokenPos(), [], [], true); case Token.OpenBracket: return parseTupleExpression(); case Token.OpenParen: @@ -2002,6 +2019,18 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + /** + * Parse the identifier of a declaration. When {@link allowAnonymous} is true (the + * declaration is being used in expression position) the identifier is optional and + * only parsed when a name is actually present. + */ + function parseDeclarationIdentifier(allowAnonymous: boolean): IdentifierNode | undefined { + if (allowAnonymous && token() !== Token.Identifier) { + return undefined; + } + return parseIdentifier(); + } + function parseIdentifier(options?: { message?: keyof CompilerDiagnostics["token-expected"]; allowStringLiteral?: boolean; // Allow string literals to be used as identifiers for backward-compatibility, but convert to an identifier node. diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 60ee8493360..c671a9e8896 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -271,6 +271,12 @@ export interface Model extends BaseType, DecoratedType, TemplatedTypeBase { namespace?: Namespace; indexer?: ModelIndexer; + /** + * Whether this model was declared in expression position (e.g. an anonymous + * `model { ... }` used as a type) rather than as a named statement. + */ + expression: boolean; + /** * The properties of the model. * @@ -438,6 +444,12 @@ export interface Scalar extends BaseType, DecoratedType, TemplatedTypeBase { */ namespace?: Namespace; + /** + * Whether this scalar was declared in expression position (anonymous `scalar ...`) + * rather than as a named statement. + */ + expression: boolean; + /** * Scalar this scalar extends. */ @@ -502,6 +514,12 @@ export interface Enum extends BaseType, DecoratedType { node?: EnumStatementNode; namespace?: Namespace; + /** + * Whether this enum was declared in expression position (anonymous `enum { ... }`) + * rather than as a named statement. + */ + expression: boolean; + /** * The members of the enum. * @@ -1444,7 +1462,35 @@ export interface DeclarationNode { readonly modifierFlags: ModifierFlags; } -export type Declaration = Extract; +/** + * Declaration node whose identifier is optional. Used by declarations that can also + * appear in expression position (e.g. `alias Foo = enum { a, b }`), in which case they + * may be anonymous (no `id`). + */ +export interface OptionallyNamedDeclarationNode { + /** + * Identifier that this node declares. May be undefined when the declaration is used + * as an anonymous expression. + */ + readonly id?: IdentifierNode; + + /** + * Modifier nodes applied to this declaration. + */ + readonly modifiers: Modifier[]; + + /** + * Combined modifier flags for this declaration. + */ + readonly modifierFlags: ModifierFlags; +} + +export type Declaration = + | Extract + | ModelStatementNode + | ScalarStatementNode + | UnionStatementNode + | EnumStatementNode; export type ScopeNode = | NamespaceStatementNode @@ -1491,6 +1537,10 @@ export type Expression = | ArrayExpressionNode | MemberExpressionNode | ModelExpressionNode + | ModelStatementNode + | EnumStatementNode + | UnionStatementNode + | ScalarStatementNode | ObjectLiteralNode | ArrayLiteralNode | TupleExpressionNode @@ -1561,7 +1611,8 @@ export interface OperationStatementNode extends BaseNode, DeclarationNode, Templ readonly parent?: TypeSpecScriptNode | NamespaceStatementNode | InterfaceStatementNode; } -export interface ModelStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { +export interface ModelStatementNode + extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { readonly kind: SyntaxKind.ModelStatement; readonly properties: readonly (ModelPropertyNode | ModelSpreadPropertyNode)[]; readonly bodyRange: TextRange; @@ -1571,7 +1622,8 @@ export interface ModelStatementNode extends BaseNode, DeclarationNode, TemplateD readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } -export interface ScalarStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { +export interface ScalarStatementNode + extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { readonly kind: SyntaxKind.ScalarStatement; readonly extends?: TypeReferenceNode; readonly decorators: readonly DecoratorExpressionNode[]; @@ -1596,7 +1648,8 @@ export interface InterfaceStatementNode extends BaseNode, DeclarationNode, Templ readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } -export interface UnionStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { +export interface UnionStatementNode + extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { readonly kind: SyntaxKind.UnionStatement; readonly options: readonly UnionVariantNode[]; readonly decorators: readonly DecoratorExpressionNode[]; @@ -1611,7 +1664,7 @@ export interface UnionVariantNode extends BaseNode { readonly parent?: UnionStatementNode; } -export interface EnumStatementNode extends BaseNode, DeclarationNode { +export interface EnumStatementNode extends BaseNode, OptionallyNamedDeclarationNode { readonly kind: SyntaxKind.EnumStatement; readonly members: readonly (EnumMemberNode | EnumSpreadMemberNode)[]; readonly decorators: readonly DecoratorExpressionNode[]; diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index adf307c6c6a..76572e8f1b8 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -44,6 +44,7 @@ import { OperationSignatureDeclarationNode, OperationSignatureReferenceNode, OperationStatementNode, + OptionallyNamedDeclarationNode, ScalarConstructorNode, ScalarStatementNode, Statement, @@ -686,11 +687,11 @@ export function printEnumStatement( print: PrettierChildPrint, ) { const { decorators } = printDecorators(path, options, print, { tryInline: false }); - const id = path.call(print, "id"); + const id = path.node.id ? [" ", path.call(print, "id")] : ""; return [ decorators, printModifiers(path, options, print), - "enum ", + "enum", id, " ", printEnumBlock(path, options, print), @@ -738,13 +739,13 @@ export function printUnionStatement( options: TypeSpecPrettierOptions, print: PrettierChildPrint, ) { - const id = path.call(print, "id"); + const id = path.node.id ? [" ", path.call(print, "id")] : ""; const { decorators } = printDecorators(path, options, print, { tryInline: false }); const generic = printTemplateParameters(path, options, print, "templateParameters"); return [ decorators, printModifiers(path, options, print), - "union ", + "union", id, generic, " ", @@ -1049,7 +1050,7 @@ export function printModelStatement( print: PrettierChildPrint, ) { const node = path.node; - const id = path.call(print, "id"); + const id = node.id ? [" ", path.call(print, "id")] : ""; const heritage = node.extends ? [ifBreak(line, " "), "extends ", path.call(print, "extends")] : ""; @@ -1061,7 +1062,7 @@ export function printModelStatement( return [ printDecorators(path, options, print, { tryInline: false }).decorators, printModifiers(path, options, print), - "model ", + "model", id, generic, group(indent(["", heritage, isBase])), @@ -1231,13 +1232,28 @@ function isModelExpressionInBlock(path: AstPath) { } } +function isInExpressionPosition(path: AstPath): boolean { + const parent = path.getParentNode(); + if (parent === null || parent === undefined) { + return false; + } + switch (parent.kind) { + case SyntaxKind.NamespaceStatement: + case SyntaxKind.TypeSpecScript: + case SyntaxKind.JsSourceFile: + return false; + default: + return true; + } +} + function printScalarStatement( path: AstPath, options: TypeSpecPrettierOptions, print: PrettierChildPrint, ) { const node = path.node; - const id = path.call(print, "id"); + const id = node.id ? [" ", path.call(print, "id")] : ""; const template = printTemplateParameters(path, options, print, "templateParameters"); const heritage = node.extends @@ -1246,11 +1262,16 @@ function printScalarStatement( const nodeHasComments = hasComments(node, CommentCheckFlags.Dangling); const shouldPrintBody = nodeHasComments || !(node.members.length === 0); - const members = shouldPrintBody ? [" ", printScalarBody(path, options, print)] : ";"; + const inExpressionPosition = isInExpressionPosition(path); + const members = shouldPrintBody + ? [" ", printScalarBody(path, options, print)] + : inExpressionPosition + ? "" + : ";"; return [ printDecorators(path, options, print, { tryInline: false }).decorators, printModifiers(path, options, print), - "scalar ", + "scalar", id, template, group(indent(["", heritage])), @@ -1559,7 +1580,7 @@ function printFunctionParameterDeclaration( } export function printModifiers( - path: AstPath, + path: AstPath<(DeclarationNode | OptionallyNamedDeclarationNode) & Node>, options: TypeSpecPrettierOptions, print: PrettierChildPrint, ): Doc { diff --git a/packages/compiler/src/server/classify.ts b/packages/compiler/src/server/classify.ts index 56f0de48010..bc255586ac2 100644 --- a/packages/compiler/src/server/classify.ts +++ b/packages/compiler/src/server/classify.ts @@ -222,10 +222,10 @@ export function getSemanticTokens(ast: TypeSpecScriptNode): SemanticToken[] { classify(node.id, SemanticTokenKind.Struct); break; case SyntaxKind.ModelStatement: - classify(node.id, SemanticTokenKind.Struct); + if (node.id) classify(node.id, SemanticTokenKind.Struct); break; case SyntaxKind.ScalarStatement: - classify(node.id, SemanticTokenKind.Type); + if (node.id) classify(node.id, SemanticTokenKind.Type); break; case SyntaxKind.ScalarConstructor: classify(node.id, SemanticTokenKind.Function); @@ -236,10 +236,10 @@ export function getSemanticTokens(ast: TypeSpecScriptNode): SemanticToken[] { } break; case SyntaxKind.EnumStatement: - classify(node.id, SemanticTokenKind.Enum); + if (node.id) classify(node.id, SemanticTokenKind.Enum); break; case SyntaxKind.UnionStatement: - classify(node.id, SemanticTokenKind.Enum); + if (node.id) classify(node.id, SemanticTokenKind.Enum); break; case SyntaxKind.EnumMember: classify(node.id, SemanticTokenKind.EnumMember); diff --git a/packages/compiler/src/server/completion.ts b/packages/compiler/src/server/completion.ts index b8f297694bd..dfac8e3786e 100644 --- a/packages/compiler/src/server/completion.ts +++ b/packages/compiler/src/server/completion.ts @@ -112,9 +112,9 @@ function addCompletionByLookingBackwardNode( posDetail: PositionDetail, context: CompletionContext, ): boolean { - const getIdentifierEndPos = (n: IdentifierNode) => { + const getIdentifierEndPos = (n: IdentifierNode | undefined) => { // n.pos === n.end, it means it's a missing identifier, just return -1; - return n.pos === n.end ? -1 : n.end; + return n === undefined || n.pos === n.end ? -1 : n.end; }; const map: { [key in SyntaxKind]?: keyof KeywordArea } = { [SyntaxKind.ModelStatement]: "modelHeader", diff --git a/packages/compiler/src/server/symbol-structure.ts b/packages/compiler/src/server/symbol-structure.ts index b67574adcc1..8a22fbbe7ad 100644 --- a/packages/compiler/src/server/symbol-structure.ts +++ b/packages/compiler/src/server/symbol-structure.ts @@ -123,7 +123,7 @@ export function getSymbolStructure(ast: TypeSpecScriptNode): DocumentSymbol[] { const properties: DocumentSymbol[] = [...node.properties.values()] .map(getDocumentSymbolsForNode) .filter(isDefined); - return createDocumentSymbol(node, node.id.sv, SymbolKind.Struct, properties); + return createDocumentSymbol(node, node.id?.sv ?? "", SymbolKind.Struct, properties); } function getForModelSpread(node: ModelSpreadPropertyNode): DocumentSymbol | undefined { @@ -138,7 +138,7 @@ export function getSymbolStructure(ast: TypeSpecScriptNode): DocumentSymbol[] { const members: DocumentSymbol[] = [...node.members.values()] .map(getDocumentSymbolsForNode) .filter(isDefined); - return createDocumentSymbol(node, node.id.sv, SymbolKind.Enum, members); + return createDocumentSymbol(node, node.id?.sv ?? "", SymbolKind.Enum, members); } function getForEnumSpread(node: EnumSpreadMemberNode): DocumentSymbol | undefined { @@ -160,6 +160,6 @@ export function getSymbolStructure(ast: TypeSpecScriptNode): DocumentSymbol[] { const variants: DocumentSymbol[] = [...node.options.values()] .map(getDocumentSymbolsForNode) .filter(isDefined); - return createDocumentSymbol(node, node.id.sv, SymbolKind.Enum, variants); + return createDocumentSymbol(node, node.id?.sv ?? "", SymbolKind.Enum, variants); } } diff --git a/packages/compiler/src/typekit/kits/enum.ts b/packages/compiler/src/typekit/kits/enum.ts index bab70398504..a3e3c4fe077 100644 --- a/packages/compiler/src/typekit/kits/enum.ts +++ b/packages/compiler/src/typekit/kits/enum.ts @@ -78,6 +78,7 @@ defineKit({ name: desc.name, decorators: decoratorApplication(this, desc.decorators), members: createRekeyableMap(), + expression: false, }); if (Array.isArray(desc.members)) { diff --git a/packages/compiler/src/typekit/kits/model.ts b/packages/compiler/src/typekit/kits/model.ts index 9eb22445d94..80dc124abf4 100644 --- a/packages/compiler/src/typekit/kits/model.ts +++ b/packages/compiler/src/typekit/kits/model.ts @@ -154,6 +154,7 @@ defineKit({ derivedModels: desc.derivedModels ?? [], sourceModels: desc.sourceModels ?? [], indexer: desc.indexer, + expression: desc.name === undefined, }); this.program.checker.finishType(model); diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts new file mode 100644 index 00000000000..9168f01e9a6 --- /dev/null +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -0,0 +1,165 @@ +import { ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { Enum, Model, Scalar, Union } from "../../src/core/types.js"; +import { expectDiagnosticEmpty, expectDiagnostics, t } from "../../src/testing/index.js"; +import { Tester } from "../tester.js"; + +describe("compiler: declarations as expressions", () => { + describe("enum", () => { + it("can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: enum { active, inactive }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + strictEqual(type.kind, "Enum"); + strictEqual(type.name, ""); + strictEqual(type.expression, true); + strictEqual(type.members.size, 2); + ok(type.members.has("active")); + ok(type.members.has("inactive")); + }); + + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + status: enum { a, b }; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + strictEqual(ns.enums.size, 0); + }); + }); + + describe("union", () => { + it("keyword form can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: union { string, int32 }; + } + `); + const type = Foo.properties.get("value")!.type as Union; + strictEqual(type.kind, "Union"); + strictEqual(type.expression, true); + strictEqual(type.variants.size, 2); + }); + }); + + describe("scalar", () => { + it("can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + unit: scalar extends string; + } + `); + const type = Foo.properties.get("unit")!.type as Scalar; + strictEqual(type.kind, "Scalar"); + strictEqual(type.name, ""); + strictEqual(type.expression, true); + strictEqual(type.baseScalar?.name, "string"); + }); + + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + unit: scalar extends string; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + strictEqual(ns.scalars.size, 0); + }); + }); + + describe("model", () => { + it("keyword form can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: model { x: string }; + } + `); + const type = Foo.properties.get("value")!.type as Model; + strictEqual(type.kind, "Model"); + strictEqual(type.expression, true); + strictEqual(type.properties.size, 1); + }); + + it("named form can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + nested: model Inner { x: string }; + } + `); + const type = Foo.properties.get("nested")!.type as Model; + strictEqual(type.kind, "Model"); + strictEqual(type.expression, true); + strictEqual(type.properties.size, 1); + }); + + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + value: model { x: string }; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + // Only Foo should be registered, not the anonymous model expression. + strictEqual(ns.models.size, 1); + ok(ns.models.has("Foo")); + }); + }); + + it("can be nested inside another declaration expression", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: model { inner: enum { a, b } }; + } + `); + const model = Foo.properties.get("value")!.type as Model; + strictEqual(model.expression, true); + const inner = model.properties.get("inner")!.type as Enum; + strictEqual(inner.kind, "Enum"); + strictEqual(inner.expression, true); + }); + + it("compiles without diagnostics when used in alias position", async () => { + const diagnostics = await Tester.diagnose(` + alias E = enum { a, b }; + alias U = union { string, int32 }; + alias S = scalar extends string; + alias M = model { x: string }; + `); + expectDiagnosticEmpty(diagnostics); + }); + + describe("template parameters are not allowed in expression position", () => { + it("reports a diagnostic for a templated model expression", async () => { + const diagnostics = await Tester.diagnose(`alias M = model Foo { x: T };`); + expectDiagnostics(diagnostics, { + code: "templated-declaration-in-expression", + }); + }); + + it("reports a diagnostic for a templated union expression", async () => { + const diagnostics = await Tester.diagnose(`alias U = union Foo { x: T };`); + expectDiagnostics(diagnostics, { + code: "templated-declaration-in-expression", + }); + }); + + it("reports a diagnostic for a templated scalar expression", async () => { + const diagnostics = await Tester.diagnose(`alias S = scalar Foo extends string;`); + expectDiagnostics(diagnostics, { + code: "templated-declaration-in-expression", + }); + }); + + it("still allows template parameters in statement position", async () => { + const diagnostics = await Tester.diagnose(`model Foo { x: T }`); + expectDiagnosticEmpty(diagnostics); + }); + }); +}); diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index c4ac8e5900e..6708b50df0a 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -1746,6 +1746,63 @@ alias Foo = (A & B) | (C & D); }); }); + describe("declaration expressions", () => { + it("formats anonymous enum expression", async () => { + await assertFormat({ + code: `alias E = enum { a, b };`, + expected: ` +alias E = enum { + a, + b, +}; +`, + }); + }); + + it("formats anonymous union expression", async () => { + await assertFormat({ + code: `alias U = union { string, int32 };`, + expected: ` +alias U = union { + string, + int32, +}; +`, + }); + }); + + it("formats anonymous model expression", async () => { + await assertFormat({ + code: `alias M = model { x: string };`, + expected: ` +alias M = model { + x: string; +}; +`, + }); + }); + + it("formats anonymous scalar expression without double semicolon", async () => { + await assertFormat({ + code: `alias S = scalar extends string;`, + expected: `alias S = scalar extends string;`, + }); + }); + + it("formats named declaration expression", async () => { + await assertFormat({ + code: `model Foo { nested: model Inner { x: string }; }`, + expected: ` +model Foo { + nested: model Inner { + x: string; + }; +} +`, + }); + }); + }); + describe("enum", () => { it("format simple enum", async () => { await assertFormat({ diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index e3d78e94607..21e410fd99a 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -281,6 +281,26 @@ describe("compiler: parser", () => { parseErrorEach([['union A { @myDec "x" x: number, y: string }', [/';' expected/]]]); }); + describe("declaration expressions", () => { + parseEach([ + // anonymous keyword declarations in expression position + "alias E = enum { a, b };", + "alias U = union { string, int32 };", + "alias S = scalar extends string;", + "alias M = model { x: string };", + // named keyword declarations in expression position + "alias NE = enum Color { red, green };", + "alias NM = model Inner { x: string };", + // nested in model properties + "model A { status: enum { active, inactive } }", + "model A { value: model { x: string } }", + "model A { unit: scalar extends string }", + "model A { value: union { string, int32 } }", + // nested declaration expressions + "alias N = model { inner: enum { a, b } };", + ]); + }); + describe("const statements", () => { parseEach([ `const a = 123;`, @@ -626,7 +646,7 @@ describe("compiler: parser", () => { (node) => { const statement = node.statements[0]; assert(statement.kind === SyntaxKind.ModelStatement, "Model statement expected."); - assert.strictEqual(statement.id.sv, expected); + assert.strictEqual(statement.id?.sv, expected); }, ]; }), diff --git a/packages/compiler/test/testing/rule-tester-codefix.test.ts b/packages/compiler/test/testing/rule-tester-codefix.test.ts index ad96726838e..71bd53f7175 100644 --- a/packages/compiler/test/testing/rule-tester-codefix.test.ts +++ b/packages/compiler/test/testing/rule-tester-codefix.test.ts @@ -53,7 +53,7 @@ it("toEqual with string asserts single-file code fix on main.tsp", async () => { const tester = await createCodeFixRuleTester(({ model, fixContext }) => { const node = model.node!; if (node.kind !== SyntaxKind.ModelStatement) throw new Error("unexpected"); - return fixContext.replaceText(getSourceLocation(node.id), "Bar"); + return fixContext.replaceText(getSourceLocation(node.id!), "Bar"); }); await tester From 053d3ff2428ed9e1e26b0d3ba4c64e334648d87c Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 18 Jun 2026 15:14:42 -0400 Subject: [PATCH 02/21] fix(html-program-viewer): configure rendering for new expression property --- packages/html-program-viewer/src/react/type-config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/html-program-viewer/src/react/type-config.ts b/packages/html-program-viewer/src/react/type-config.ts index 566bca5a228..94773c139fb 100644 --- a/packages/html-program-viewer/src/react/type-config.ts +++ b/packages/html-program-viewer/src/react/type-config.ts @@ -82,11 +82,13 @@ export const TypeConfig: TypeGraphConfig = buildConfig({ properties: "nested-items", sourceModel: "ref", sourceModels: "value", + expression: "value", }, Scalar: { baseScalar: "ref", derivedScalars: "ref", constructors: "nested-items", + expression: "value", }, ModelProperty: { model: "parent", @@ -97,6 +99,7 @@ export const TypeConfig: TypeGraphConfig = buildConfig({ }, Enum: { members: "nested-items", + expression: "value", }, EnumMember: { enum: "parent", From 0b7d1ea4aa26a1d537c04ac187afdf999e1e1d74 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 18 Jun 2026 15:19:17 -0400 Subject: [PATCH 03/21] test(compiler): use expect and flatten top-level describe in decl-expr tests --- .../checker/declaration-expressions.test.ts | 265 +++++++++--------- 1 file changed, 131 insertions(+), 134 deletions(-) diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts index 9168f01e9a6..e44d80e3566 100644 --- a/packages/compiler/test/checker/declaration-expressions.test.ts +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -1,165 +1,162 @@ -import { ok, strictEqual } from "assert"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { Enum, Model, Scalar, Union } from "../../src/core/types.js"; import { expectDiagnosticEmpty, expectDiagnostics, t } from "../../src/testing/index.js"; import { Tester } from "../tester.js"; -describe("compiler: declarations as expressions", () => { - describe("enum", () => { - it("can be used as a property type", async () => { - const { Foo } = await Tester.compile(t.code` - model ${t.model("Foo")} { - status: enum { active, inactive }; - } - `); - const type = Foo.properties.get("status")!.type as Enum; - strictEqual(type.kind, "Enum"); - strictEqual(type.name, ""); - strictEqual(type.expression, true); - strictEqual(type.members.size, 2); - ok(type.members.has("active")); - ok(type.members.has("inactive")); - }); - - it("is not registered in the namespace", async () => { - const { program } = await Tester.compile(` - namespace Ns; - model Foo { - status: enum { a, b }; - } - `); - const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; - strictEqual(ns.enums.size, 0); - }); +describe("enum", () => { + it("can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: enum { active, inactive }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(type.kind).toBe("Enum"); + expect(type.name).toBe(""); + expect(type.expression).toBe(true); + expect(type.members.size).toBe(2); + expect(type.members.has("active")).toBe(true); + expect(type.members.has("inactive")).toBe(true); }); - describe("union", () => { - it("keyword form can be used as a property type", async () => { - const { Foo } = await Tester.compile(t.code` - model ${t.model("Foo")} { - value: union { string, int32 }; - } - `); - const type = Foo.properties.get("value")!.type as Union; - strictEqual(type.kind, "Union"); - strictEqual(type.expression, true); - strictEqual(type.variants.size, 2); - }); + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + status: enum { a, b }; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + expect(ns.enums.size).toBe(0); }); +}); - describe("scalar", () => { - it("can be used as a property type", async () => { - const { Foo } = await Tester.compile(t.code` - model ${t.model("Foo")} { - unit: scalar extends string; - } - `); - const type = Foo.properties.get("unit")!.type as Scalar; - strictEqual(type.kind, "Scalar"); - strictEqual(type.name, ""); - strictEqual(type.expression, true); - strictEqual(type.baseScalar?.name, "string"); - }); - - it("is not registered in the namespace", async () => { - const { program } = await Tester.compile(` - namespace Ns; - model Foo { - unit: scalar extends string; - } - `); - const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; - strictEqual(ns.scalars.size, 0); - }); +describe("union", () => { + it("keyword form can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: union { string, int32 }; + } + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.kind).toBe("Union"); + expect(type.expression).toBe(true); + expect(type.variants.size).toBe(2); }); +}); - describe("model", () => { - it("keyword form can be used as a property type", async () => { - const { Foo } = await Tester.compile(t.code` - model ${t.model("Foo")} { - value: model { x: string }; - } - `); - const type = Foo.properties.get("value")!.type as Model; - strictEqual(type.kind, "Model"); - strictEqual(type.expression, true); - strictEqual(type.properties.size, 1); - }); +describe("scalar", () => { + it("can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + unit: scalar extends string; + } + `); + const type = Foo.properties.get("unit")!.type as Scalar; + expect(type.kind).toBe("Scalar"); + expect(type.name).toBe(""); + expect(type.expression).toBe(true); + expect(type.baseScalar?.name).toBe("string"); + }); - it("named form can be used as a property type", async () => { - const { Foo } = await Tester.compile(t.code` - model ${t.model("Foo")} { - nested: model Inner { x: string }; - } - `); - const type = Foo.properties.get("nested")!.type as Model; - strictEqual(type.kind, "Model"); - strictEqual(type.expression, true); - strictEqual(type.properties.size, 1); - }); + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + unit: scalar extends string; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + expect(ns.scalars.size).toBe(0); + }); +}); - it("is not registered in the namespace", async () => { - const { program } = await Tester.compile(` - namespace Ns; - model Foo { - value: model { x: string }; - } - `); - const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; - // Only Foo should be registered, not the anonymous model expression. - strictEqual(ns.models.size, 1); - ok(ns.models.has("Foo")); - }); +describe("model", () => { + it("keyword form can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: model { x: string }; + } + `); + const type = Foo.properties.get("value")!.type as Model; + expect(type.kind).toBe("Model"); + expect(type.expression).toBe(true); + expect(type.properties.size).toBe(1); }); - it("can be nested inside another declaration expression", async () => { + it("named form can be used as a property type", async () => { const { Foo } = await Tester.compile(t.code` model ${t.model("Foo")} { - value: model { inner: enum { a, b } }; + nested: model Inner { x: string }; } `); - const model = Foo.properties.get("value")!.type as Model; - strictEqual(model.expression, true); - const inner = model.properties.get("inner")!.type as Enum; - strictEqual(inner.kind, "Enum"); - strictEqual(inner.expression, true); + const type = Foo.properties.get("nested")!.type as Model; + expect(type.kind).toBe("Model"); + expect(type.expression).toBe(true); + expect(type.properties.size).toBe(1); }); - it("compiles without diagnostics when used in alias position", async () => { - const diagnostics = await Tester.diagnose(` - alias E = enum { a, b }; - alias U = union { string, int32 }; - alias S = scalar extends string; - alias M = model { x: string }; + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + value: model { x: string }; + } `); - expectDiagnosticEmpty(diagnostics); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + // Only Foo should be registered, not the anonymous model expression. + expect(ns.models.size).toBe(1); + expect(ns.models.has("Foo")).toBe(true); }); +}); - describe("template parameters are not allowed in expression position", () => { - it("reports a diagnostic for a templated model expression", async () => { - const diagnostics = await Tester.diagnose(`alias M = model Foo { x: T };`); - expectDiagnostics(diagnostics, { - code: "templated-declaration-in-expression", - }); - }); +it("can be nested inside another declaration expression", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: model { inner: enum { a, b } }; + } + `); + const model = Foo.properties.get("value")!.type as Model; + expect(model.expression).toBe(true); + const inner = model.properties.get("inner")!.type as Enum; + expect(inner.kind).toBe("Enum"); + expect(inner.expression).toBe(true); +}); + +it("compiles without diagnostics when used in alias position", async () => { + const diagnostics = await Tester.diagnose(` + alias E = enum { a, b }; + alias U = union { string, int32 }; + alias S = scalar extends string; + alias M = model { x: string }; + `); + expectDiagnosticEmpty(diagnostics); +}); - it("reports a diagnostic for a templated union expression", async () => { - const diagnostics = await Tester.diagnose(`alias U = union Foo { x: T };`); - expectDiagnostics(diagnostics, { - code: "templated-declaration-in-expression", - }); +describe("template parameters are not allowed in expression position", () => { + it("reports a diagnostic for a templated model expression", async () => { + const diagnostics = await Tester.diagnose(`alias M = model Foo { x: T };`); + expectDiagnostics(diagnostics, { + code: "templated-declaration-in-expression", }); + }); - it("reports a diagnostic for a templated scalar expression", async () => { - const diagnostics = await Tester.diagnose(`alias S = scalar Foo extends string;`); - expectDiagnostics(diagnostics, { - code: "templated-declaration-in-expression", - }); + it("reports a diagnostic for a templated union expression", async () => { + const diagnostics = await Tester.diagnose(`alias U = union Foo { x: T };`); + expectDiagnostics(diagnostics, { + code: "templated-declaration-in-expression", }); + }); - it("still allows template parameters in statement position", async () => { - const diagnostics = await Tester.diagnose(`model Foo { x: T }`); - expectDiagnosticEmpty(diagnostics); + it("reports a diagnostic for a templated scalar expression", async () => { + const diagnostics = await Tester.diagnose(`alias S = scalar Foo extends string;`); + expectDiagnostics(diagnostics, { + code: "templated-declaration-in-expression", }); }); + + it("still allows template parameters in statement position", async () => { + const diagnostics = await Tester.diagnose(`model Foo { x: T }`); + expectDiagnosticEmpty(diagnostics); + }); }); From 0ceaad6ede5b455b259cb309e4d342c2d3b91cc0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 18 Jun 2026 15:29:20 -0400 Subject: [PATCH 04/21] docs: add html-program-viewer changelog and clarify compiler changelog naming --- .../changes/declarations-as-expressions-2026-4-18-0-0-0.md | 4 ++-- .../html-program-viewer-expression-2026-4-18-0-0-0.md | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/html-program-viewer-expression-2026-4-18-0-0-0.md diff --git a/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md b/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md index 70f209339bf..41888209dc6 100644 --- a/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md +++ b/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md @@ -4,7 +4,7 @@ packages: - "@typespec/compiler" --- -Allow `model`, `enum`, `union`, and `scalar` declarations to be used as expressions. A declaration used in expression position is anonymous (its `name` is `""`) and its corresponding type has `expression: true`. It is not registered in the enclosing namespace. +Allow `model`, `enum`, `union`, and `scalar` declarations to be used as expressions. A declaration used in expression position has its corresponding type marked with `expression: true` and is not registered in the enclosing namespace. It may be named or anonymous (in which case its `name` is `""`). ```tsp alias Foo = enum { @@ -15,6 +15,6 @@ alias Foo = enum { model Bar { status: enum { active, inactive }; unit: scalar extends string; - inner: model { x: string }; + inner: model Inner { x: string }; } ``` diff --git a/.chronus/changes/html-program-viewer-expression-2026-4-18-0-0-0.md b/.chronus/changes/html-program-viewer-expression-2026-4-18-0-0-0.md new file mode 100644 index 00000000000..a8ec73f3112 --- /dev/null +++ b/.chronus/changes/html-program-viewer-expression-2026-4-18-0-0-0.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/html-program-viewer" +--- + +Display the new `expression` property on `Model`, `Enum`, and `Scalar` types in the program viewer. From f8ebdb7e4425bfe9f5007b6771cd1cbf503cc977 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 18 Jun 2026 15:47:01 -0400 Subject: [PATCH 05/21] fix(compiler): don't flatten keyword-form unions used as `|` operands A keyword-form union (`union { a, b }`) used in expression position is marked `expression: true`, which caused checkUnionExpression to flatten its (possibly named) variants into the parent union, silently dropping colliding members. Flatten only unions originating from the `|` operator (UnionExpression node). --- packages/compiler/src/core/checker.ts | 7 ++- .../checker/declaration-expressions.test.ts | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 3d50c60985f..8d4a250fe0f 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -2014,7 +2014,12 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (type === neverType) { continue; } - if (type.kind === "Union" && type.expression) { + // Flatten nested union expressions (e.g. `(a | b) | c` or an alias to a union + // expression). Only the `|`-operator form is flattened: its variants are + // anonymous (symbol-keyed) and cannot collide. Keyword-form unions used in + // expression position (`union { a, b }`) are also `expression: true` but can have + // named variants, so flattening them would silently drop colliding members. + if (type.kind === "Union" && type.node?.kind === SyntaxKind.UnionExpression) { for (const [name, variant] of type.variants) { unionType.variants.set(name, variant); } diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts index e44d80e3566..31905451e7c 100644 --- a/packages/compiler/test/checker/declaration-expressions.test.ts +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -43,6 +43,54 @@ describe("union", () => { expect(type.expression).toBe(true); expect(type.variants.size).toBe(2); }); + + it("keyword form is not flattened when used as a `|` operand", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: union { a: int32 } | float32; + } + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.variants.size).toBe(2); + // The keyword union is a single nested variant, not flattened into `value`. + const nested = [...type.variants.values()].map((v) => v.type).find((t) => t.kind === "Union") as + | Union + | undefined; + expect(nested).toBeDefined(); + expect(nested!.variants.has("a")).toBe(true); + }); + + it("does not silently drop colliding variants from keyword unions in a `|`", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: union { a: int32 } | union { a: string }; + } + `); + const type = Foo.properties.get("value")!.type as Union; + // Both keyword unions are preserved as distinct nested variants (no data loss). + expect(type.variants.size).toBe(2); + }); + + it("still flattens nested union expressions", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: (string | int32) | float32; + } + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.variants.size).toBe(3); + }); + + it("still flattens an alias to a union expression", async () => { + const { Foo } = await Tester.compile(t.code` + alias AB = string | int32; + model ${t.model("Foo")} { + value: AB | float32; + } + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.variants.size).toBe(3); + }); }); describe("scalar", () => { From f18990d1dcfde62a62584f615de09c1bd950347a Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 18 Jun 2026 15:53:17 -0400 Subject: [PATCH 06/21] test(compiler): expand coverage for declarations as expressions Add tests for: - expression: false on statement declarations - name retention on named declaration expressions - named expressions not being referenceable - union namespace non-registration - alias-resolved types, op return/param, union variant usage - member access via alias, decorator rejection - enum values, union named variants, scalar constructors, model spread - parser negatives for interface/op in expression position - formatter named & nested declaration expressions --- .../checker/declaration-expressions.test.ts | 287 ++++++++++++++---- .../compiler/test/formatter/formatter.test.ts | 45 +++ packages/compiler/test/parser.test.ts | 7 + 3 files changed, 278 insertions(+), 61 deletions(-) diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts index 31905451e7c..aa69bd23353 100644 --- a/packages/compiler/test/checker/declaration-expressions.test.ts +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { Enum, Model, Scalar, Union } from "../../src/core/types.js"; +import { getTypeName } from "../../src/index.js"; import { expectDiagnosticEmpty, expectDiagnostics, t } from "../../src/testing/index.js"; import { Tester } from "../tester.js"; @@ -19,6 +20,17 @@ describe("enum", () => { expect(type.members.has("inactive")).toBe(true); }); + it("supports explicit member values", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: enum { active: "a", inactive: "i" }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(type.expression).toBe(true); + expect(type.members.get("active")!.value).toBe("a"); + }); + it("is not registered in the namespace", async () => { const { program } = await Tester.compile(` namespace Ns; @@ -44,52 +56,27 @@ describe("union", () => { expect(type.variants.size).toBe(2); }); - it("keyword form is not flattened when used as a `|` operand", async () => { + it("supports named variants", async () => { const { Foo } = await Tester.compile(t.code` model ${t.model("Foo")} { - value: union { a: int32 } | float32; + value: union { foo: string, bar: int32 }; } `); const type = Foo.properties.get("value")!.type as Union; - expect(type.variants.size).toBe(2); - // The keyword union is a single nested variant, not flattened into `value`. - const nested = [...type.variants.values()].map((v) => v.type).find((t) => t.kind === "Union") as - | Union - | undefined; - expect(nested).toBeDefined(); - expect(nested!.variants.has("a")).toBe(true); - }); - - it("does not silently drop colliding variants from keyword unions in a `|`", async () => { - const { Foo } = await Tester.compile(t.code` - model ${t.model("Foo")} { - value: union { a: int32 } | union { a: string }; - } - `); - const type = Foo.properties.get("value")!.type as Union; - // Both keyword unions are preserved as distinct nested variants (no data loss). - expect(type.variants.size).toBe(2); - }); - - it("still flattens nested union expressions", async () => { - const { Foo } = await Tester.compile(t.code` - model ${t.model("Foo")} { - value: (string | int32) | float32; - } - `); - const type = Foo.properties.get("value")!.type as Union; - expect(type.variants.size).toBe(3); + expect(type.expression).toBe(true); + expect(type.variants.has("foo")).toBe(true); + expect(type.variants.has("bar")).toBe(true); }); - it("still flattens an alias to a union expression", async () => { - const { Foo } = await Tester.compile(t.code` - alias AB = string | int32; - model ${t.model("Foo")} { - value: AB | float32; + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + value: union { string, int32 }; } `); - const type = Foo.properties.get("value")!.type as Union; - expect(type.variants.size).toBe(3); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + expect(ns.unions.size).toBe(0); }); }); @@ -107,6 +94,19 @@ describe("scalar", () => { expect(type.baseScalar?.name).toBe("string"); }); + it("supports constructors", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + unit: scalar extends string { + init fromValue(value: string); + }; + } + `); + const type = Foo.properties.get("unit")!.type as Scalar; + expect(type.expression).toBe(true); + expect(type.constructors.has("fromValue")).toBe(true); + }); + it("is not registered in the namespace", async () => { const { program } = await Tester.compile(` namespace Ns; @@ -132,16 +132,17 @@ describe("model", () => { expect(type.properties.size).toBe(1); }); - it("named form can be used as a property type", async () => { + it("supports spreading another model", async () => { const { Foo } = await Tester.compile(t.code` + model Base { b: string } model ${t.model("Foo")} { - nested: model Inner { x: string }; + value: model { ...Base, x: string }; } `); - const type = Foo.properties.get("nested")!.type as Model; - expect(type.kind).toBe("Model"); + const type = Foo.properties.get("value")!.type as Model; expect(type.expression).toBe(true); - expect(type.properties.size).toBe(1); + expect(type.properties.has("b")).toBe(true); + expect(type.properties.has("x")).toBe(true); }); it("is not registered in the namespace", async () => { @@ -158,27 +159,191 @@ describe("model", () => { }); }); -it("can be nested inside another declaration expression", async () => { - const { Foo } = await Tester.compile(t.code` - model ${t.model("Foo")} { - value: model { inner: enum { a, b } }; - } - `); - const model = Foo.properties.get("value")!.type as Model; - expect(model.expression).toBe(true); - const inner = model.properties.get("inner")!.type as Enum; - expect(inner.kind).toBe("Enum"); - expect(inner.expression).toBe(true); +describe("named declaration expressions", () => { + it("keeps the name on the resulting type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + m: model Inner { x: string }; + e: enum Color { red }; + s: scalar Celsius extends int32; + u: union Choice { string, int32 }; + } + `); + expect((Foo.properties.get("m")!.type as Model).name).toBe("Inner"); + expect((Foo.properties.get("e")!.type as Enum).name).toBe("Color"); + expect((Foo.properties.get("s")!.type as Scalar).name).toBe("Celsius"); + expect((Foo.properties.get("u")!.type as Union).name).toBe("Choice"); + }); + + it("is still marked as an expression", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + m: model Inner { x: string }; + } + `); + const type = Foo.properties.get("m")!.type as Model; + expect(type.name).toBe("Inner"); + expect(type.expression).toBe(true); + }); + + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + m: model Inner { x: string }; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + expect(ns.models.size).toBe(1); + expect(ns.models.has("Foo")).toBe(true); + expect(ns.models.has("Inner")).toBe(false); + }); + + it("cannot be referenced by its name", async () => { + const diagnostics = await Tester.diagnose(` + alias M = model Inner { x: string }; + model Use { y: Inner } + `); + expectDiagnostics(diagnostics, { + code: "invalid-ref", + message: "Unknown identifier Inner", + }); + }); +}); + +describe("statement declarations are not expressions", () => { + it("marks model/enum/union/scalar statements with expression: false", async () => { + const { M, E, S, U } = await Tester.compile(t.code` + model ${t.model("M")} {} + enum ${t.enum("E")} { a } + scalar ${t.scalar("S")} extends string; + union ${t.union("U")} { string } + `); + expect(M.expression).toBe(false); + expect(E.expression).toBe(false); + expect(S.expression).toBe(false); + expect(U.expression).toBe(false); + }); +}); + +describe("usage contexts", () => { + it("resolves through an alias and keeps expression: true", async () => { + const { Foo } = await Tester.compile(t.code` + alias E = enum { a, b }; + alias U = union { string, int32 }; + alias S = scalar extends string; + alias M = model { x: string }; + model ${t.model("Foo")} { + e: E; + u: U; + s: S; + m: M; + } + `); + expect((Foo.properties.get("e")!.type as Enum).expression).toBe(true); + expect((Foo.properties.get("u")!.type as Union).expression).toBe(true); + expect((Foo.properties.get("s")!.type as Scalar).expression).toBe(true); + expect((Foo.properties.get("m")!.type as Model).expression).toBe(true); + }); + + it("can be used as an operation return type", async () => { + const { test } = await Tester.compile(t.code` + op ${t.op("test")}(): enum { a, b }; + `); + const returnType = test.returnType as Enum; + expect(returnType.kind).toBe("Enum"); + expect(returnType.expression).toBe(true); + }); + + it("can be used as an operation parameter type", async () => { + const { test } = await Tester.compile(t.code` + op ${t.op("test")}(value: model { x: string }): void; + `); + const paramType = test.parameters.properties.get("value")!.type as Model; + expect(paramType.kind).toBe("Model"); + expect(paramType.expression).toBe(true); + }); + + it("can be used as a union variant", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: string | enum { a, b }; + } + `); + const union = Foo.properties.get("value")!.type as Union; + expect(union.kind).toBe("Union"); + const variants = [...union.variants.values()]; + const enumVariant = variants.find((v) => (v.type as Enum).kind === "Enum")!; + expect((enumVariant.type as Enum).expression).toBe(true); + }); + + it("can be nested inside another declaration expression", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: model { inner: enum { a, b } }; + } + `); + const model = Foo.properties.get("value")!.type as Model; + expect(model.expression).toBe(true); + const inner = model.properties.get("inner")!.type as Enum; + expect(inner.kind).toBe("Enum"); + expect(inner.expression).toBe(true); + }); + + it("allows member access of an anonymous expression through an alias", async () => { + const { Foo } = await Tester.compile(t.code` + alias E = enum { a, b }; + alias A = E.a; + model ${t.model("Foo")} { + value: A; + } + `); + expect(Foo.properties.get("value")!.type.kind).toBe("EnumMember"); + }); + + it("compiles without diagnostics when used in alias position", async () => { + const diagnostics = await Tester.diagnose(` + alias E = enum { a, b }; + alias U = union { string, int32 }; + alias S = scalar extends string; + alias M = model { x: string }; + `); + expectDiagnosticEmpty(diagnostics); + }); +}); + +describe("type name", () => { + it("renders an anonymous expression with an empty name", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + anon: enum { a, b }; + named: enum Color { red }; + } + `); + expect(getTypeName(Foo.properties.get("anon")!.type)).toBe(""); + expect(getTypeName(Foo.properties.get("named")!.type)).toBe("Color"); + }); }); -it("compiles without diagnostics when used in alias position", async () => { - const diagnostics = await Tester.diagnose(` - alias E = enum { a, b }; - alias U = union { string, int32 }; - alias S = scalar extends string; - alias M = model { x: string }; - `); - expectDiagnosticEmpty(diagnostics); +describe("decorators", () => { + it("cannot decorate the declaration expression itself", async () => { + const diagnostics = await Tester.diagnose(`model Foo { x: @doc("hi") enum { a, b } }`); + expectDiagnostics(diagnostics, { + code: "invalid-decorator-location", + message: "Cannot decorate expression.", + }); + }); + + it("allows decorators on members inside the expression", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: enum { @doc("first") a, b }; + } + `); + const type = Foo.properties.get("value")!.type as Enum; + expect(type.expression).toBe(true); + expect(type.members.has("a")).toBe(true); + }); }); describe("template parameters are not allowed in expression position", () => { diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index 6708b50df0a..a19daf57b66 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -1798,6 +1798,51 @@ model Foo { x: string; }; } +`, + }); + }); + + it("formats named enum expression", async () => { + await assertFormat({ + code: `alias E = enum Color {red, green};`, + expected: ` +alias E = enum Color { + red, + green, +}; +`, + }); + }); + + it("formats named union expression", async () => { + await assertFormat({ + code: `alias U = union Choice {string, int32};`, + expected: ` +alias U = union Choice { + string, + int32, +}; +`, + }); + }); + + it("formats named scalar expression", async () => { + await assertFormat({ + code: `alias S = scalar Celsius extends int32;`, + expected: `alias S = scalar Celsius extends int32;`, + }); + }); + + it("formats nested declaration expressions", async () => { + await assertFormat({ + code: `alias N = model { inner: enum { a, b } };`, + expected: ` +alias N = model { + inner: enum { + a, + b, + }; +}; `, }); }); diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index 21e410fd99a..3f70e9ca9db 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -299,6 +299,13 @@ describe("compiler: parser", () => { // nested declaration expressions "alias N = model { inner: enum { a, b } };", ]); + + // interface and operation are intentionally NOT allowed in expression position + parseErrorEach([ + ["alias I = interface { foo(): void };", [/Keyword cannot be used as identifier/]], + ["alias O = op (): void;", [/Keyword cannot be used as identifier/]], + ["model A { x: interface {} }", [/Keyword cannot be used as identifier/]], + ]); }); describe("const statements", () => { From 44e5e41ba356fa902a4cf1276788f8a3c2bd176f Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 18 Jun 2026 16:19:24 -0400 Subject: [PATCH 07/21] fix(compiler): correct type names for declaration expressions Anonymous declarations used in expression position rendered with a stray namespace prefix (e.g. `Ns.` for enum/scalar, `Ns.{ x: string }` for keyword-form model). Render them inline and un-prefixed, mirroring union expression naming. Also extract a single shared `isDeclarationInExpressionPosition` helper used by both the binder and checker so the two position predicates cannot drift, and add regression tests (type names, keyword-form union as `|` operand, template parameter referenced inside an expression declaration). --- packages/compiler/src/core/binder.ts | 8 +-- packages/compiler/src/core/checker.ts | 17 +----- .../compiler/src/core/helpers/syntax-utils.ts | 27 +++++++++- .../src/core/helpers/type-name-utils.ts | 25 ++++++--- .../checker/declaration-expressions.test.ts | 53 +++++++++++++++++-- 5 files changed, 98 insertions(+), 32 deletions(-) diff --git a/packages/compiler/src/core/binder.ts b/packages/compiler/src/core/binder.ts index 81a835c008c..ced95add3b9 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -1,6 +1,7 @@ import { mutate } from "../utils/misc.js"; import { compilerAssert } from "./diagnostics.js"; import { getLocationContext } from "./helpers/location-context.js"; +import { isDeclarationInExpressionPosition } from "./helpers/syntax-utils.js"; import { visitChildren } from "./parser.js"; import type { Program } from "./program.js"; import { @@ -397,12 +398,7 @@ export function createBinder(program: Program): Binder { * position. Anonymous declarations are always in expression position. */ function isDeclarationStatementPosition(node: Node): boolean { - const parent = node.parent; - return ( - parent?.kind === SyntaxKind.NamespaceStatement || - parent?.kind === SyntaxKind.TypeSpecScript || - parent?.kind === SyntaxKind.JsSourceFile - ); + return !isDeclarationInExpressionPosition(node); } function bindModelStatement(node: ModelStatementNode) { diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 8d4a250fe0f..3d14b7927f3 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -21,6 +21,7 @@ import { validateInheritanceDiscriminatedUnions } from "./helpers/discriminator- import { getLocationContext } from "./helpers/location-context.js"; import { explainStringTemplateNotSerializable } from "./helpers/string-template-utils.js"; import { + isDeclarationInExpressionPosition, printIdentifier, printMemberExpressionPath, typeReferenceToString, @@ -5006,22 +5007,6 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } - /** - * Determine whether a declaration node (model/enum/union/scalar) appears in - * expression position (e.g. as the value of an alias or a property type) rather - * than as a top-level statement in a namespace or file. Anonymous declarations - * (without an `id`) are always in expression position. - */ - function isDeclarationInExpressionPosition( - node: ModelStatementNode | EnumStatementNode | UnionStatementNode | ScalarStatementNode, - ): boolean { - const parent = node.parent; - return ( - parent === undefined || - (parent.kind !== SyntaxKind.NamespaceStatement && parent.kind !== SyntaxKind.TypeSpecScript) - ); - } - /** * A declaration used in expression position is anonymous and cannot be referenced or * instantiated, so template parameters on it are meaningless. Report a diagnostic when present. diff --git a/packages/compiler/src/core/helpers/syntax-utils.ts b/packages/compiler/src/core/helpers/syntax-utils.ts index 44f993a7932..2c1843a853f 100644 --- a/packages/compiler/src/core/helpers/syntax-utils.ts +++ b/packages/compiler/src/core/helpers/syntax-utils.ts @@ -1,6 +1,31 @@ import { CharCode, isIdentifierContinue, isIdentifierStart, utf16CodeUnits } from "../charcode.js"; import { isModifier, Keywords, ReservedKeywords } from "../scanner.js"; -import { IdentifierNode, MemberExpressionNode, SyntaxKind, TypeReferenceNode } from "../types.js"; +import { + IdentifierNode, + MemberExpressionNode, + Node, + SyntaxKind, + TypeReferenceNode, +} from "../types.js"; + +/** + * Determine whether a declaration node (model/enum/union/scalar) appears in expression + * position (e.g. as an alias value or a property type) rather than as a top-level + * statement directly under a namespace or source file. Anonymous declarations (used as + * expressions) are always in expression position. + * + * This is the single source of truth shared by the binder and checker so the two cannot + * drift apart. + */ +export function isDeclarationInExpressionPosition(node: Node): boolean { + const parent = node.parent; + return ( + parent === undefined || + (parent.kind !== SyntaxKind.NamespaceStatement && + parent.kind !== SyntaxKind.TypeSpecScript && + parent.kind !== SyntaxKind.JsSourceFile) + ); +} /** * Print a string as a TypeSpec identifier. If the string is a valid identifier, return it as is otherwise wrap it into backticks. diff --git a/packages/compiler/src/core/helpers/type-name-utils.ts b/packages/compiler/src/core/helpers/type-name-utils.ts index 1ac92a573ad..447eda75a4c 100644 --- a/packages/compiler/src/core/helpers/type-name-utils.ts +++ b/packages/compiler/src/core/helpers/type-name-utils.ts @@ -162,18 +162,31 @@ function getNamespacePrefix(type: Namespace | undefined, options?: TypeNameOptio } function getEnumName(e: Enum, options: TypeNameOptions | undefined): string { - return `${getNamespacePrefix(e.namespace, options)}${getIdentifierName(e.name, options)}`; + // An enum used in expression position is anonymous; render its members inline + // instead of a (namespace-prefixed) name. + if (e.name === "") { + return `{ ${[...e.members.values()].map((m) => m.name).join(", ")} }`; + } + const nsPrefix = e.expression ? "" : getNamespacePrefix(e.namespace, options); + return `${nsPrefix}${getIdentifierName(e.name, options)}`; } function getScalarName(scalar: Scalar, options: TypeNameOptions | undefined): string { - return `${getNamespacePrefix(scalar.namespace, options)}${getIdentifierName( - scalar.name, - options, - )}`; + // A scalar used in expression position is anonymous; render what it extends + // (there is no inline literal syntax for it) instead of a namespace-only name. + if (scalar.name === "") { + return scalar.baseScalar + ? `scalar extends ${getTypeName(scalar.baseScalar, options)}` + : "scalar"; + } + const nsPrefix = scalar.expression ? "" : getNamespacePrefix(scalar.namespace, options); + return `${nsPrefix}${getIdentifierName(scalar.name, options)}`; } function getModelName(model: Model, options: TypeNameOptions | undefined) { - const nsPrefix = getNamespacePrefix(model.namespace, options); + // Declarations used in expression position are anonymous and not addressable, so + // they should not be namespace-qualified (mirrors union expression naming). + const nsPrefix = model.expression ? "" : getNamespacePrefix(model.namespace, options); if (model.name === "" && model.properties.size === 0) { return "{}"; } diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts index aa69bd23353..11790aa1209 100644 --- a/packages/compiler/test/checker/declaration-expressions.test.ts +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -68,6 +68,23 @@ describe("union", () => { expect(type.variants.has("bar")).toBe(true); }); + it("keeps its members when used as a `|` operand instead of being flattened", async () => { + // Regression: a keyword-form union is `expression: true`; it must not be flattened + // into the parent `|` union (which would silently drop colliding named variants). + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: union { a: "a1", b: "b1" } | union { a: "a2", c: "c1" }; + } + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.kind).toBe("Union"); + expect(type.variants.size).toBe(2); + for (const variant of type.variants.values()) { + expect((variant.type as Union).kind).toBe("Union"); + expect((variant.type as Union).variants.size).toBe(2); + } + }); + it("is not registered in the namespace", async () => { const { program } = await Tester.compile(` namespace Ns; @@ -246,6 +263,21 @@ describe("usage contexts", () => { expect((Foo.properties.get("m")!.type as Model).expression).toBe(true); }); + it("can reference an enclosing template parameter", async () => { + const { Bar } = await Tester.compile(t.code` + model Wrapper { + nested: model { item: T }; + } + model ${t.model("Bar")} { + w: Wrapper; + } + `); + const wrapper = Bar.properties.get("w")!.type as Model; + const nested = wrapper.properties.get("nested")!.type as Model; + expect(nested.expression).toBe(true); + expect((nested.properties.get("item")!.type as Scalar).name).toBe("int32"); + }); + it("can be used as an operation return type", async () => { const { test } = await Tester.compile(t.code` op ${t.op("test")}(): enum { a, b }; @@ -313,14 +345,29 @@ describe("usage contexts", () => { }); describe("type name", () => { - it("renders an anonymous expression with an empty name", async () => { + it("renders anonymous expressions inline and is not namespace-qualified", async () => { const { Foo } = await Tester.compile(t.code` + namespace Ns; + model ${t.model("Foo")} { + modelProp: model { x: string }; + enumProp: enum { a, b }; + scalarProp: scalar extends string; + unionProp: union { string, int32 }; + } + `); + expect(getTypeName(Foo.properties.get("modelProp")!.type)).toBe("{ x: string }"); + expect(getTypeName(Foo.properties.get("enumProp")!.type)).toBe("{ a, b }"); + expect(getTypeName(Foo.properties.get("scalarProp")!.type)).toBe("scalar extends string"); + expect(getTypeName(Foo.properties.get("unionProp")!.type)).toBe("string | int32"); + }); + + it("renders a named expression by its name without a namespace prefix", async () => { + const { Foo } = await Tester.compile(t.code` + namespace Ns; model ${t.model("Foo")} { - anon: enum { a, b }; named: enum Color { red }; } `); - expect(getTypeName(Foo.properties.get("anon")!.type)).toBe(""); expect(getTypeName(Foo.properties.get("named")!.type)).toBe("Color"); }); }); From 9c2aaf0ef3c1873df4c93fe850b05aa187ea45e9 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 19 Jun 2026 10:33:17 -0400 Subject: [PATCH 08/21] Add syntax highlighting for declarations in expression position --- grammars/typespec.json | 180 ++++++++++++++---- packages/compiler/src/server/tmlanguage.ts | 69 ++++++- .../compiler/test/server/colorization.test.ts | 109 +++++++++++ 3 files changed, 321 insertions(+), 37 deletions(-) diff --git a/grammars/typespec.json b/grammars/typespec.json index c29ce411b12..9f6eeec075d 100644 --- a/grammars/typespec.json +++ b/grammars/typespec.json @@ -19,7 +19,7 @@ "name": "keyword.operator.assignment.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -40,7 +40,7 @@ "name": "entity.name.type.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#alias-id" @@ -61,7 +61,7 @@ "name": "entity.name.tag.tsp" } }, - "end": "(?=([_$[:alpha:]]|`))|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=([_$[:alpha:]]|`))|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -123,7 +123,7 @@ "name": "variable.name.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#type-annotation" @@ -147,7 +147,7 @@ "name": "entity.name.tag.tsp" } }, - "end": "(?=([_$[:alpha:]]|`))|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=([_$[:alpha:]]|`))|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -174,7 +174,7 @@ "name": "entity.name.function.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -195,7 +195,7 @@ "name": "keyword.directive.name.tsp" } }, - "end": "$|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "$|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#string-literal" @@ -309,6 +309,27 @@ } ] }, + "enum-expression": { + "name": "meta.enum-expression.typespec", + "begin": "\\b(enum)\\b(?:\\s+(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`))?", + "beginCaptures": { + "1": { + "name": "keyword.other.tsp" + }, + "2": { + "name": "entity.name.type.tsp" + } + }, + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", + "patterns": [ + { + "include": "#token" + }, + { + "include": "#enum-body" + } + ] + }, "enum-member": { "name": "meta.enum-member.typespec", "begin": "(?:(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`)\\s*(:?))", @@ -320,7 +341,7 @@ "name": "keyword.operator.type.annotation.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -344,7 +365,7 @@ "name": "entity.name.type.tsp" } }, - "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -387,6 +408,18 @@ { "include": "#tuple-expression" }, + { + "include": "#model-expression-keyword" + }, + { + "include": "#scalar-expression" + }, + { + "include": "#enum-expression" + }, + { + "include": "#union-expression" + }, { "include": "#model-expression" }, @@ -415,7 +448,7 @@ "name": "entity.name.function.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -440,7 +473,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -487,7 +520,7 @@ "name": "keyword.other.tsp" } }, - "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b))", + "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b))", "patterns": [ { "include": "#expression" @@ -508,7 +541,7 @@ "name": "entity.name.function.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -529,7 +562,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -587,6 +620,33 @@ } ] }, + "model-expression-keyword": { + "name": "meta.model-expression-keyword.typespec", + "begin": "\\b(model)\\b(?:\\s+(?!extends\\b|is\\b)(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`))?", + "beginCaptures": { + "1": { + "name": "keyword.other.tsp" + }, + "2": { + "name": "entity.name.type.tsp" + } + }, + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", + "patterns": [ + { + "include": "#token" + }, + { + "include": "#type-parameters" + }, + { + "include": "#model-heritage" + }, + { + "include": "#expression" + } + ] + }, "model-heritage": { "name": "meta.model-heritage.typespec", "begin": "\\b(extends|is)\\b", @@ -595,7 +655,7 @@ "name": "keyword.other.tsp" } }, - "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b))", + "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b))", "patterns": [ { "include": "#expression" @@ -616,7 +676,7 @@ "name": "string.quoted.double.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -643,7 +703,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -682,7 +742,7 @@ "namespace-name": { "name": "meta.namespace-name.typespec", "begin": "(?=([_$[:alpha:]]|`))", - "end": "((?=\\{)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b))", + "end": "((?=\\{)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b))", "patterns": [ { "include": "#identifier-expression" @@ -700,7 +760,7 @@ "name": "keyword.other.tsp" } }, - "end": "((?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b))", + "end": "((?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b))", "patterns": [ { "include": "#token" @@ -760,7 +820,7 @@ "name": "keyword.operator.type.annotation.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -778,7 +838,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -847,7 +907,7 @@ "name": "entity.name.function.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -936,7 +996,7 @@ "name": "entity.name.function.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -946,6 +1006,33 @@ } ] }, + "scalar-expression": { + "name": "meta.scalar-expression.typespec", + "begin": "\\b(scalar)\\b(?:\\s+(?!extends\\b)(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`))?", + "beginCaptures": { + "1": { + "name": "keyword.other.tsp" + }, + "2": { + "name": "entity.name.type.tsp" + } + }, + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", + "patterns": [ + { + "include": "#token" + }, + { + "include": "#type-parameters" + }, + { + "include": "#scalar-extends" + }, + { + "include": "#scalar-body" + } + ] + }, "scalar-extends": { "name": "meta.scalar-extends.typespec", "begin": "\\b(extends)\\b", @@ -954,7 +1041,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -978,7 +1065,7 @@ "name": "entity.name.type.tsp" } }, - "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -1002,7 +1089,7 @@ "name": "keyword.operator.spread.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -1192,7 +1279,7 @@ "name": "keyword.operator.type.annotation.tsp" } }, - "end": "(?=,|;|@|\\)|\\}|=|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|\\)|\\}|=|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -1210,7 +1297,7 @@ "name": "keyword.operator.assignment.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "endCaptures": { "0": { "name": "keyword.operator.assignment.tsp" @@ -1262,7 +1349,7 @@ "name": "entity.name.type.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -1283,7 +1370,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -1298,7 +1385,7 @@ "name": "keyword.operator.assignment.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -1336,7 +1423,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -1378,6 +1465,27 @@ } ] }, + "union-expression": { + "name": "meta.union-expression.typespec", + "begin": "\\b(union)\\b(?:\\s+(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`))?", + "beginCaptures": { + "1": { + "name": "keyword.other.tsp" + }, + "2": { + "name": "entity.name.type.tsp" + } + }, + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", + "patterns": [ + { + "include": "#token" + }, + { + "include": "#union-body" + } + ] + }, "union-statement": { "name": "meta.union-statement.typespec", "begin": "(?:(internal)\\s+)?\\b(union)\\b\\s+(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`)", @@ -1392,7 +1500,7 @@ "name": "entity.name.type.tsp" } }, - "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -1413,7 +1521,7 @@ "name": "keyword.operator.type.annotation.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -1431,7 +1539,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -1452,7 +1560,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" diff --git a/packages/compiler/src/server/tmlanguage.ts b/packages/compiler/src/server/tmlanguage.ts index 025f146a326..7b158d60cdc 100644 --- a/packages/compiler/src/server/tmlanguage.ts +++ b/packages/compiler/src/server/tmlanguage.ts @@ -68,7 +68,10 @@ const identifier = `${simpleIdentifier}|${escapedIdentifier}`; const qualifiedIdentifier = `\\b${identifierStart}(?:${identifierContinue}|\\.${identifierStart})*\\b`; const stringPattern = '\\"(?:[^\\"\\\\]|\\\\.)*\\"'; const modifierKeyword = `\\b(?:extern|internal)\\b`; -const statementKeyword = `\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b`; +// Keywords that begin a statement. Used as a heuristic terminator for expressions. +// `model`, `enum` and `union` are intentionally excluded because they can now appear +// in expression position (declarations-as-expressions) and must not terminate an expression. +const statementKeyword = `\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b`; const universalEnd = `(?=,|;|@|#[a-z]|\\)|\\}|${modifierKeyword}|${statementKeyword})`; const universalEndExceptComma = `(?=;|@|\\)|\\}|${modifierKeyword}|${statementKeyword})`; @@ -712,6 +715,66 @@ const unionStatement: BeginEndRule = { patterns: [token, unionBody], }; +// Declarations used in expression position (e.g. `alias Foo = enum { a, b }`). +// The name is optional since these can be anonymous when used as an expression. +const modelExpressionKeyword: BeginEndRule = { + key: "model-expression-keyword", + scope: meta, + begin: `\\b(model)\\b(?:\\s+(?!extends\\b|is\\b)(${identifier}))?`, + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + "2": { scope: "entity.name.type.tsp" }, + }, + end: `(?<=\\})|${universalEnd}`, + patterns: [ + token, + typeParameters, + modelHeritage, // before expression or `extends` or `is` will look like type name + expression, // enough to match type parameters and body. + ], +}; + +const scalarExpression: BeginEndRule = { + key: "scalar-expression", + scope: meta, + begin: `\\b(scalar)\\b(?:\\s+(?!extends\\b)(${identifier}))?`, + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + "2": { scope: "entity.name.type.tsp" }, + }, + end: `(?<=\\})|${universalEnd}`, + patterns: [ + token, + typeParameters, + scalarExtends, // before expression or `extends` will look like type name + scalarBody, + ], +}; + +const enumExpression: BeginEndRule = { + key: "enum-expression", + scope: meta, + begin: `\\b(enum)\\b(?:\\s+(${identifier}))?`, + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + "2": { scope: "entity.name.type.tsp" }, + }, + end: `(?<=\\})|${universalEnd}`, + patterns: [token, enumBody], +}; + +const unionExpression: BeginEndRule = { + key: "union-expression", + scope: meta, + begin: `\\b(union)\\b(?:\\s+(${identifier}))?`, + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + "2": { scope: "entity.name.type.tsp" }, + }, + end: `(?<=\\})|${universalEnd}`, + patterns: [token, unionBody], +}; + const aliasAssignment: BeginEndRule = { key: "alias-id", scope: meta, @@ -937,6 +1000,10 @@ expression.patterns = [ objectLiteral, tupleLiteral, tupleExpression, + modelExpressionKeyword, + scalarExpression, + enumExpression, + unionExpression, modelExpression, callExpression, identifierExpression, diff --git a/packages/compiler/test/server/colorization.test.ts b/packages/compiler/test/server/colorization.test.ts index 33222abb70c..d350680851b 100644 --- a/packages/compiler/test/server/colorization.test.ts +++ b/packages/compiler/test/server/colorization.test.ts @@ -1091,6 +1091,115 @@ function testColorization(description: string, tokenize: Tokenize) { }); }); + describe("declaration expressions", () => { + it("anonymous enum in alias", async () => { + const tokens = await tokenize("alias Foo = enum { a, b }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.enum, + Token.punctuation.openBrace, + Token.identifiers.variable("a"), + Token.punctuation.comma, + Token.identifiers.variable("b"), + Token.punctuation.closeBrace, + ]); + }); + + it("named enum in alias", async () => { + const tokens = await tokenize("alias Foo = enum Color { red, green }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.enum, + Token.identifiers.type("Color"), + Token.punctuation.openBrace, + Token.identifiers.variable("red"), + Token.punctuation.comma, + Token.identifiers.variable("green"), + Token.punctuation.closeBrace, + ]); + }); + + it("anonymous union in alias", async () => { + const tokens = await tokenize("alias Foo = union { string, int32 }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.union, + Token.punctuation.openBrace, + Token.identifiers.type("string"), + Token.punctuation.comma, + Token.identifiers.type("int32"), + Token.punctuation.closeBrace, + ]); + }); + + it("named union in alias", async () => { + const tokens = await tokenize("alias Foo = union Choice { a: string }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.union, + Token.identifiers.type("Choice"), + Token.punctuation.openBrace, + Token.identifiers.variable("a"), + Token.operators.typeAnnotation, + Token.identifiers.type("string"), + Token.punctuation.closeBrace, + ]); + }); + + it("anonymous scalar in alias", async () => { + const tokens = await tokenize("alias Foo = scalar extends string"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.scalar, + Token.keywords.extends, + Token.identifiers.type("string"), + ]); + }); + + it("anonymous model in alias", async () => { + const tokens = await tokenize("alias Foo = model { x: string }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.model, + Token.punctuation.openBrace, + Token.identifiers.variable("x"), + Token.operators.typeAnnotation, + Token.identifiers.type("string"), + Token.punctuation.closeBrace, + ]); + }); + + it("declaration expression as a model property type", async () => { + const tokens = await tokenize("model Bar { status: enum { active, inactive } }"); + deepStrictEqual(tokens, [ + Token.keywords.model, + Token.identifiers.type("Bar"), + Token.punctuation.openBrace, + Token.identifiers.variable("status"), + Token.operators.typeAnnotation, + Token.keywords.enum, + Token.punctuation.openBrace, + Token.identifiers.variable("active"), + Token.punctuation.comma, + Token.identifiers.variable("inactive"), + Token.punctuation.closeBrace, + Token.punctuation.closeBrace, + ]); + }); + }); + describe("namespaces", () => { it("simple global namespace", async () => { const tokens = await tokenize("namespace Foo;"); From 8262855ba6866248ae41b072ce3a9a0740aeb6e2 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 19 Jun 2026 10:42:59 -0400 Subject: [PATCH 09/21] Update language spec grammar for declarations in expression position --- packages/spec/src/spec.emu.html | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/spec/src/spec.emu.html b/packages/spec/src/spec.emu.html index 1a74c894e72..1a929f5490e 100644 --- a/packages/spec/src/spec.emu.html +++ b/packages/spec/src/spec.emu.html @@ -504,6 +504,10 @@

Syntactic Grammar

ObjectLiteral ArrayLiteral ModelExpression + ModelDeclarationExpression + ScalarDeclarationExpression + EnumDeclarationExpression + UnionDeclarationExpression TupleExpression FunctionTypeExpression : @@ -572,6 +576,19 @@

Syntactic Grammar

ModelExpression : `{` ModelBody? `}` +ModelDeclarationExpression : + `model` Identifier? TemplateParameters? ExtendsModelHeritage? `{` ModelBody? `}` + +ScalarDeclarationExpression : + `scalar` Identifier? TemplateParameters? ScalarExtends? + `scalar` Identifier? TemplateParameters? ScalarExtends? `{` ScalarBody? `}` + +EnumDeclarationExpression : + `enum` Identifier? `{` EnumBody? `}` + +UnionDeclarationExpression : + `union` Identifier? `{` UnionBody? `}` + TupleExpression : `[` ExpressionList? `]` From 75ef483972bde33ba71b6712a8d9808c5159df3d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 19 Jun 2026 11:52:42 -0400 Subject: [PATCH 10/21] fix: handle declaration expressions in emitters and versioning Inline anonymous declaration expressions and hoist named ones across the OpenAPI and JSON Schema emitters, validate keyword-form union expression variants in versioning, and derive the enum typekit `expression` flag from an empty name. --- ...expr-json-schema-inline-2026-4-18-0-0-1.md | 15 +++++ ...ecl-expr-openapi-inline-2026-4-18-0-0-1.md | 16 +++++ .../decl-expr-typekit-enum-2026-4-18-0-0-1.md | 7 ++ ...r-versioning-validation-2026-4-18-0-0-1.md | 7 ++ packages/compiler/src/typekit/kits/enum.ts | 5 +- packages/compiler/test/typekit/enum.test.ts | 18 +++++ .../json-schema/src/json-schema-emitter.ts | 31 +++++++++ .../test/declaration-expressions.test.ts | 57 ++++++++++++++++ packages/openapi/src/helpers.ts | 8 ++- packages/openapi3/src/schema-emitter.ts | 10 +-- .../test/declaration-expressions.test.ts | 38 +++++++++++ packages/versioning/src/validate.ts | 10 ++- .../test/declaration-expressions.test.ts | 65 +++++++++++++++++++ .../test/incompatible-versioning.test.ts | 6 +- 14 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 .chronus/changes/decl-expr-json-schema-inline-2026-4-18-0-0-1.md create mode 100644 .chronus/changes/decl-expr-openapi-inline-2026-4-18-0-0-1.md create mode 100644 .chronus/changes/decl-expr-typekit-enum-2026-4-18-0-0-1.md create mode 100644 .chronus/changes/decl-expr-versioning-validation-2026-4-18-0-0-1.md create mode 100644 packages/json-schema/test/declaration-expressions.test.ts create mode 100644 packages/openapi3/test/declaration-expressions.test.ts create mode 100644 packages/versioning/test/declaration-expressions.test.ts diff --git a/.chronus/changes/decl-expr-json-schema-inline-2026-4-18-0-0-1.md b/.chronus/changes/decl-expr-json-schema-inline-2026-4-18-0-0-1.md new file mode 100644 index 00000000000..7b19a95904d --- /dev/null +++ b/.chronus/changes/decl-expr-json-schema-inline-2026-4-18-0-0-1.md @@ -0,0 +1,15 @@ +--- +changeKind: feature +packages: + - "@typespec/json-schema" +--- + +Support `model`, `enum`, `union`, and `scalar` declarations used in expression position. Anonymous declaration expressions are inlined, while named ones are hoisted into their own schema. + +```tsp +model Foo { + status: enum { active, inactive }; // inlined + unit: scalar extends string; // inlined + inner: model Inner { x: string }; // hoisted as `Inner.json` +} +``` diff --git a/.chronus/changes/decl-expr-openapi-inline-2026-4-18-0-0-1.md b/.chronus/changes/decl-expr-openapi-inline-2026-4-18-0-0-1.md new file mode 100644 index 00000000000..0f419aed19f --- /dev/null +++ b/.chronus/changes/decl-expr-openapi-inline-2026-4-18-0-0-1.md @@ -0,0 +1,16 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi" + - "@typespec/openapi3" +--- + +Support `model`, `enum`, `union`, and `scalar` declarations used in expression position. Anonymous declaration expressions are inlined, while named ones are hoisted into a referenced component. + +```tsp +model Foo { + status: enum { active, inactive }; // inlined + unit: scalar extends string; // inlined + inner: model Inner { x: string }; // hoisted as component `Inner` +} +``` diff --git a/.chronus/changes/decl-expr-typekit-enum-2026-4-18-0-0-1.md b/.chronus/changes/decl-expr-typekit-enum-2026-4-18-0-0-1.md new file mode 100644 index 00000000000..de25cc6b0b8 --- /dev/null +++ b/.chronus/changes/decl-expr-typekit-enum-2026-4-18-0-0-1.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +`$.enum.create` now produces an enum expression (`expression: true`) when given an empty `name`, mirroring `$.model.create`. diff --git a/.chronus/changes/decl-expr-versioning-validation-2026-4-18-0-0-1.md b/.chronus/changes/decl-expr-versioning-validation-2026-4-18-0-0-1.md new file mode 100644 index 00000000000..cc0429ffaf0 --- /dev/null +++ b/.chronus/changes/decl-expr-versioning-validation-2026-4-18-0-0-1.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/versioning" +--- + +Validate the variants of a keyword-form union expression (`union { ... }`) used in expression position like the variants of a named union, so versioning incompatibilities on decorated variants are reported. diff --git a/packages/compiler/src/typekit/kits/enum.ts b/packages/compiler/src/typekit/kits/enum.ts index a3e3c4fe077..1867200f1b1 100644 --- a/packages/compiler/src/typekit/kits/enum.ts +++ b/packages/compiler/src/typekit/kits/enum.ts @@ -11,7 +11,8 @@ import { type UnionKit } from "./union.js"; */ interface EnumDescriptor { /** - * The name of the enum declaration. + * The name of the enum. If a non-empty name is provided, it is an enum + * declaration. An empty string (`""`) produces an enum expression. */ name: string; @@ -78,7 +79,7 @@ defineKit({ name: desc.name, decorators: decoratorApplication(this, desc.decorators), members: createRekeyableMap(), - expression: false, + expression: desc.name === "", }); if (Array.isArray(desc.members)) { diff --git a/packages/compiler/test/typekit/enum.test.ts b/packages/compiler/test/typekit/enum.test.ts index 7d6a6d400f1..df4a804d425 100644 --- a/packages/compiler/test/typekit/enum.test.ts +++ b/packages/compiler/test/typekit/enum.test.ts @@ -47,3 +47,21 @@ it("preserves documentation when copying", async () => { expect(getDoc(program, newEnum.members.get("One")!)).toBe("doc-comment for one"); expect(getDoc(program, newEnum.members.get("Two")!)).toBeUndefined(); }); + +it("creates a named enum as a declaration (expression: false)", () => { + const en = $(program).enum.create({ + name: "Foo", + members: { a: 1, b: 2 }, + }); + expect(en.name).toBe("Foo"); + expect(en.expression).toBe(false); +}); + +it("creates an anonymous enum as an expression (expression: true)", () => { + const en = $(program).enum.create({ + name: "", + members: { a: 1, b: 2 }, + }); + expect(en.name).toBe(""); + expect(en.expression).toBe(true); +}); diff --git a/packages/json-schema/src/json-schema-emitter.ts b/packages/json-schema/src/json-schema-emitter.ts index b89875dc11f..0476defe5e4 100644 --- a/packages/json-schema/src/json-schema-emitter.ts +++ b/packages/json-schema/src/json-schema-emitter.ts @@ -80,6 +80,16 @@ import { import { type JSONSchemaEmitterOptions, reportDiagnostic } from "./lib.js"; import { includeDerivedModel } from "./utils.js"; +/** + * Whether the type is an anonymous declaration expression (e.g. an inline + * `enum { ... }` or `scalar extends string` used as a property type): it is in + * expression position (`expression: true`) and has no name. Such types are inlined + * into the referencing schema rather than hoisted into their own file/`$defs`. + */ +function isAnonymousExpression(type: JsonSchemaDeclaration): boolean { + return type.expression && type.name === ""; +} + /** @internal */ export class JsonSchemaEmitter extends TypeEmitter, JSONSchemaEmitterOptions> { #idDuplicateTracker = new DuplicateTracker(); @@ -730,6 +740,15 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche } #createDeclaration(type: JsonSchemaDeclaration, name: string, schema: ObjectBuilder) { + // An *anonymous* declaration expression (e.g. an inline `enum { ... }` or + // `scalar extends string` used as a property type) has an empty name and is not + // registered in a namespace. It must not be hoisted into an (empty-named) `$defs` + // schema or its own file; returning the schema directly inlines it. A *named* + // declaration expression (e.g. `model Inner { ... }`) keeps its name and is hoisted + // like a regular declaration. + if (isAnonymousExpression(type)) { + return schema; + } const decl = this.emitter.result.declaration(name, schema); const sf = (decl.scope as SourceFileScope).sourceFile; sf.meta.shouldEmit = this.#shouldEmitRootSchema(type); @@ -759,6 +778,10 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche } #shouldEmitRootSchema(type: JsonSchemaDeclaration) { + // Anonymous declaration expressions are inlined, never emitted as a root schema. + if (isAnonymousExpression(type)) { + return false; + } return ( this.emitter.getOptions().emitAllRefs || this.emitter.getOptions().emitAllModels || @@ -1104,6 +1127,11 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche } enumDeclarationContext(en: Enum): Context { + // An anonymous `enum { ... }` expression is inlined into the referencing schema, so + // it must not get its own file scope (which would otherwise be left empty). + if (isAnonymousExpression(en)) { + return {}; + } return this.#newFileScope(en); } @@ -1114,6 +1142,9 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche scalarDeclarationContext(scalar: Scalar): Context { if (this.#isStdType(scalar)) { return {}; + } else if (isAnonymousExpression(scalar)) { + // An anonymous `scalar extends ...` expression is inlined, so no file scope. + return {}; } else { return this.#newFileScope(scalar); } diff --git a/packages/json-schema/test/declaration-expressions.test.ts b/packages/json-schema/test/declaration-expressions.test.ts new file mode 100644 index 00000000000..b1bc46a78ad --- /dev/null +++ b/packages/json-schema/test/declaration-expressions.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { emitSchema } from "./utils.js"; + +describe("declaration expressions", () => { + it("inlines an anonymous enum used as a property type", async () => { + const schemas = await emitSchema(` + model Foo { + status: enum { active, inactive }; + } + `); + + // The anonymous enum must not be emitted as its own (empty-named) schema file. + expect(Object.keys(schemas)).toEqual(["Foo.json"]); + const status = schemas["Foo.json"].properties.status; + expect(status.$ref).toBeUndefined(); + expect(status.enum).toEqual(["active", "inactive"]); + }); + + it("inlines an anonymous scalar used as a property type", async () => { + const schemas = await emitSchema(` + model Foo { + unit: scalar extends string; + } + `); + + expect(Object.keys(schemas)).toEqual(["Foo.json"]); + const unit = schemas["Foo.json"].properties.unit; + expect(unit.$ref).toBeUndefined(); + expect(unit.type).toBe("string"); + }); + + it("inlines an anonymous union (keyword form) used as a property type", async () => { + const schemas = await emitSchema(` + model Foo { + value: union { string, int32 }; + } + `); + + expect(Object.keys(schemas)).toEqual(["Foo.json"]); + const value = schemas["Foo.json"].properties.value; + expect(value.$ref).toBeUndefined(); + }); + + it("hoists a named declaration expression into its own schema", async () => { + const schemas = await emitSchema(` + model Foo { + inner: model Inner { x: string }; + } + `); + + // A named declaration expression keeps its name and is hoisted/referenced. + expect(Object.keys(schemas).sort()).toEqual(["Foo.json", "Inner.json"]); + expect(schemas["Foo.json"].properties.inner).toEqual({ $ref: "Inner.json" }); + expect(schemas["Inner.json"].type).toBe("object"); + expect(schemas["Inner.json"].properties.x.type).toBe("string"); + }); +}); diff --git a/packages/openapi/src/helpers.ts b/packages/openapi/src/helpers.ts index cb79b73abd9..90fb292c141 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -35,6 +35,12 @@ import { ExtensionKey } from "./types.js"; * * A friendly name can be provided by the user using `@friendlyName` * decorator, or chosen by default in simple cases. + * + * Anonymous declaration expressions (e.g. an inline `enum { ... }` or + * `scalar extends string` used as a property type) have an empty `name` and are + * inlined. A *named* declaration expression (e.g. `model Inner { ... }` used as a + * property type) keeps its name and is hoisted into a schema like a regular + * declaration. */ export function shouldInline(program: Program, type: Type): boolean { if (getFriendlyName(program, type)) { @@ -44,7 +50,7 @@ export function shouldInline(program: Program, type: Type): boolean { case "Model": return !type.name || isTemplateInstance(type); case "Scalar": - return program.checker.isStdType(type) || isTemplateInstance(type); + return !type.name || program.checker.isStdType(type) || isTemplateInstance(type); case "Enum": case "Union": return !type.name; diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index 520d8503af6..9668b1639bf 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -929,11 +929,6 @@ export class OpenAPI3SchemaEmitterBase< } #createDeclaration(type: Type, name: string, schema: ObjectBuilder) { - const skipNameValidation = type.kind === "Model" && type.templateMapper !== undefined; - if (!skipNameValidation) { - name = ensureValidComponentFixedFieldKey(this.emitter.getProgram(), type, name); - } - const refUrl = getRef(this.emitter.getProgram(), type); if (refUrl) { return { @@ -945,6 +940,11 @@ export class OpenAPI3SchemaEmitterBase< return this.#inlineType(type, schema); } + const skipNameValidation = type.kind === "Model" && type.templateMapper !== undefined; + if (!skipNameValidation) { + name = ensureValidComponentFixedFieldKey(this.emitter.getProgram(), type, name); + } + const title = getSummary(this.emitter.getProgram(), type); if (title) { setProperty(schema, "title", title); diff --git a/packages/openapi3/test/declaration-expressions.test.ts b/packages/openapi3/test/declaration-expressions.test.ts new file mode 100644 index 00000000000..33658fdaef7 --- /dev/null +++ b/packages/openapi3/test/declaration-expressions.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vitest"; +import { supportedVersions, worksFor } from "./works-for.js"; + +worksFor(supportedVersions, ({ oapiForModel }) => { + it("inlines an anonymous enum used as a property type", async () => { + const res = await oapiForModel("Foo", `model Foo { status: enum { active, inactive }; }`); + const status = res.schemas.Foo.properties.status; + expect(status.$ref).toBeUndefined(); + expect(status.enum).toEqual(["active", "inactive"]); + expect(Object.keys(res.schemas)).toEqual(["Foo"]); + }); + + it("inlines an anonymous scalar used as a property type", async () => { + const res = await oapiForModel("Foo", `model Foo { unit: scalar extends string; }`); + const unit = res.schemas.Foo.properties.unit; + expect(unit.$ref).toBeUndefined(); + expect(unit.type).toBe("string"); + // Regression: an anonymous scalar must not be emitted as an empty-named component. + expect(Object.keys(res.schemas)).toEqual(["Foo"]); + }); + + it("inlines an anonymous union (keyword form) used as a property type", async () => { + const res = await oapiForModel("Foo", `model Foo { value: union { string, int32 }; }`); + const value = res.schemas.Foo.properties.value; + expect(value.$ref).toBeUndefined(); + expect(Object.keys(res.schemas)).toEqual(["Foo"]); + }); + + it("hoists a named declaration expression as a component", async () => { + const res = await oapiForModel("Foo", `model Foo { inner: model Inner { x: string }; }`); + const inner = res.schemas.Foo.properties.inner; + // A named declaration expression keeps its name and is hoisted/referenced. + expect(inner.$ref).toBe("#/components/schemas/Inner"); + expect(res.schemas.Inner.type).toBe("object"); + expect(res.schemas.Inner.properties.x.type).toBe("string"); + expect(Object.keys(res.schemas).sort()).toEqual(["Foo", "Inner"]); + }); +}); diff --git a/packages/versioning/src/validate.ts b/packages/versioning/src/validate.ts index a1cbb8535e6..ae2bab1d07a 100644 --- a/packages/versioning/src/validate.ts +++ b/packages/versioning/src/validate.ts @@ -11,6 +11,7 @@ import { type Type, type TypeNameOptions, } from "@typespec/compiler"; +import { SyntaxKind } from "@typespec/compiler/ast"; import { $added, $removed, @@ -266,13 +267,18 @@ function validateTypeAvailability( } } } else if (type.kind === "Union") { + // Only `|`-operator unions (UnionExpression) have anonymous, symbol-less + // variants with no decorators. Keyword-form unions (`union { ... }`) are also + // `expression: true` when used in expression position, but their variants can be + // named and decorated, so they must go through `validateTargetVersionCompatible`. + const isUnionOperatorExpression = type.node?.kind === SyntaxKind.UnionExpression; for (const variant of type.variants.values()) { - if (type.expression) { + if (isUnionOperatorExpression) { // Union expressions don't have decorators applied, // so we need to check the type directly. typesToCheck.push(variant.type); } else { - // Named unions can have decorators applied, + // Named/keyword unions can have decorators applied, // so we need to check that the variant type is valid // for whatever decoration the variant has. validateTargetVersionCompatible(program, variant, variant.type); diff --git a/packages/versioning/test/declaration-expressions.test.ts b/packages/versioning/test/declaration-expressions.test.ts new file mode 100644 index 00000000000..1fcfb2940ce --- /dev/null +++ b/packages/versioning/test/declaration-expressions.test.ts @@ -0,0 +1,65 @@ +import type { TesterInstance } from "@typespec/compiler/testing"; +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { beforeEach, describe, it } from "vitest"; +import { Tester } from "./test-host.js"; + +// A keyword-form union (`union { ... }`) used in expression position is `expression: true`, +// just like an anonymous `|`-operator union. Its variants can be named and decorated, so +// version-compatibility validation must treat it like a named union (going through +// `validateTargetVersionCompatible`) rather than flattening it like a `|`-operator union. +describe("versioning: declaration expression unions", () => { + let runner: TesterInstance; + + beforeEach(async () => { + runner = await Tester.wrap( + (code) => ` + @versioned(Versions) + namespace TestService { + enum Versions {v1, v2, v3, v4} + ${code} + }`, + ).createInstance(); + }); + + it("validates a keyword-form union expression like a named union", async () => { + const diagnostics = await runner.diagnose(` + @added(Versions.v2) + model Updated {} + + alias KwUnion = union { string, Updated }; + + model Test { + @typeChangedFrom(Versions.v2, KwUnion) + prop: string; + } + `); + + // Regression: before the fix this incorrectly took the `|`-union flatten path and + // reported the type-availability diagnostic instead. + expectDiagnostics(diagnostics, { + code: "@typespec/versioning/incompatible-versioned-reference", + message: + "'TestService.Updated' is referencing versioned type 'TestService.Updated' but is not versioned itself.", + }); + }); + + it("still flattens a `|`-operator union expression", async () => { + const diagnostics = await runner.diagnose(` + @added(Versions.v2) + model Updated {} + + alias OpUnion = string | Updated; + + model Test { + @typeChangedFrom(Versions.v2, OpUnion) + prop: string; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/versioning/incompatible-versioned-reference", + message: + "'TestService.Test.prop' is referencing type 'TestService.Updated' which does not exist in version 'v1'.", + }); + }); +}); diff --git a/packages/versioning/test/incompatible-versioning.test.ts b/packages/versioning/test/incompatible-versioning.test.ts index 9ad0bcc008f..748cd9c911d 100644 --- a/packages/versioning/test/incompatible-versioning.test.ts +++ b/packages/versioning/test/incompatible-versioning.test.ts @@ -78,7 +78,7 @@ describe("versioning: validate incompatible references", () => { expectDiagnostics(diagnostics, { code: "@typespec/versioning/incompatible-versioned-reference", message: - "'TestService.{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", + "'{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", }); }); @@ -122,7 +122,7 @@ describe("versioning: validate incompatible references", () => { expectDiagnostics(diagnostics, { code: "@typespec/versioning/incompatible-versioned-reference", message: - "'TestService.{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", + "'{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", }); }); @@ -713,7 +713,7 @@ describe("versioning: validate incompatible references", () => { expectDiagnostics(diagnostics, { code: "@typespec/versioning/incompatible-versioned-reference", message: - "'TestService.{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", + "'{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", }); }); }); From 843db66723a6b94b6d6259445380033997af00d2 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 24 Jun 2026 13:03:51 -0400 Subject: [PATCH 11/21] refactor(compiler): use dedicated AST nodes for declaration expressions Replace the flag-based design (statement nodes with optional id + position inference) with dedicated SyntaxKind nodes per declaration type: Model/Scalar/Union/EnumDeclarationExpression. The Type-level `expression` field is kept so emitters/versioning are unchanged. --- packages/compiler/src/core/binder.ts | 81 +++++--- packages/compiler/src/core/checker.ts | 107 +++++++--- .../compiler/src/core/helpers/syntax-utils.ts | 27 +-- packages/compiler/src/core/inspector/node.ts | 4 + packages/compiler/src/core/modifiers.ts | 37 +++- packages/compiler/src/core/name-resolver.ts | 48 ++++- packages/compiler/src/core/parser.ts | 189 +++++++++++++++--- packages/compiler/src/core/type-utils.ts | 3 + packages/compiler/src/core/types.ts | 123 +++++++++--- .../src/formatter/print/comment-handler.ts | 12 +- .../compiler/src/formatter/print/printer.ts | 9 + packages/compiler/src/server/classify.ts | 4 + packages/compiler/src/server/completion.ts | 13 ++ .../compiler/src/server/symbol-structure.ts | 6 +- .../compiler/src/server/type-signature.ts | 7 +- 15 files changed, 501 insertions(+), 169 deletions(-) diff --git a/packages/compiler/src/core/binder.ts b/packages/compiler/src/core/binder.ts index ced95add3b9..2e869927e9c 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -1,7 +1,6 @@ import { mutate } from "../utils/misc.js"; import { compilerAssert } from "./diagnostics.js"; import { getLocationContext } from "./helpers/location-context.js"; -import { isDeclarationInExpressionPosition } from "./helpers/syntax-utils.js"; import { visitChildren } from "./parser.js"; import type { Program } from "./program.js"; import { @@ -10,6 +9,7 @@ import { Declaration, DecoratorDeclarationStatementNode, DecoratorImplementations, + EnumDeclarationExpressionNode, EnumMemberNode, EnumStatementNode, FileLibraryMetadata, @@ -20,6 +20,7 @@ import { IntersectionExpressionNode, JsNamespaceDeclarationNode, JsSourceFileNode, + ModelDeclarationExpressionNode, ModelExpressionNode, ModelPropertyNode, ModelStatementNode, @@ -30,6 +31,7 @@ import { NodeFlags, OperationStatementNode, ScalarConstructorNode, + ScalarDeclarationExpressionNode, ScalarStatementNode, ScopeNode, Sym, @@ -38,6 +40,7 @@ import { SyntaxKind, TemplateParameterDeclarationNode, TypeSpecScriptNode, + UnionDeclarationExpressionNode, UnionStatementNode, UnionVariantNode, UsingStatementNode, @@ -302,6 +305,9 @@ export function createBinder(program: Program): Binder { case SyntaxKind.ModelStatement: bindModelStatement(node); break; + case SyntaxKind.ModelDeclarationExpression: + bindModelDeclarationExpression(node); + break; case SyntaxKind.ModelExpression: bindModelExpression(node); break; @@ -314,6 +320,9 @@ export function createBinder(program: Program): Binder { case SyntaxKind.ScalarStatement: bindScalarStatement(node); break; + case SyntaxKind.ScalarDeclarationExpression: + bindScalarDeclarationExpression(node); + break; case SyntaxKind.ScalarConstructor: bindScalarConstructor(node); break; @@ -323,6 +332,9 @@ export function createBinder(program: Program): Binder { case SyntaxKind.UnionStatement: bindUnionStatement(node); break; + case SyntaxKind.UnionDeclarationExpression: + bindUnionDeclarationExpression(node); + break; case SyntaxKind.AliasStatement: bindAliasStatement(node); break; @@ -332,6 +344,9 @@ export function createBinder(program: Program): Binder { case SyntaxKind.EnumStatement: bindEnumStatement(node); break; + case SyntaxKind.EnumDeclarationExpression: + bindEnumDeclarationExpression(node); + break; case SyntaxKind.EnumMember: bindEnumMember(node); break; @@ -392,24 +407,17 @@ export function createBinder(program: Program): Binder { declareSymbol(node, SymbolFlags.TemplateParameter | SymbolFlags.Declaration); } - /** - * Whether a declaration node (model/enum/union/scalar) appears in statement - * position (directly under a namespace or file) rather than in expression - * position. Anonymous declarations are always in expression position. - */ - function isDeclarationStatementPosition(node: Node): boolean { - return !isDeclarationInExpressionPosition(node); - } - function bindModelStatement(node: ModelStatementNode) { const internal = node.modifierFlags & ModifierFlags.Internal ? SymbolFlags.Internal : SymbolFlags.None; - if (isDeclarationStatementPosition(node)) { - declareSymbol(node, SymbolFlags.Model | SymbolFlags.Declaration | internal); - } else { - bindSymbol(node, SymbolFlags.Model); - } + declareSymbol(node, SymbolFlags.Model | SymbolFlags.Declaration | internal); + // Initialize locals for type parameters + mutate(node).locals = new SymbolTable(); + } + + function bindModelDeclarationExpression(node: ModelDeclarationExpressionNode) { + bindSymbol(node, SymbolFlags.Model); // Initialize locals for type parameters mutate(node).locals = new SymbolTable(); } @@ -429,11 +437,13 @@ export function createBinder(program: Program): Binder { function bindScalarStatement(node: ScalarStatementNode) { const internal = node.modifierFlags & ModifierFlags.Internal ? SymbolFlags.Internal : SymbolFlags.None; - if (isDeclarationStatementPosition(node)) { - declareSymbol(node, SymbolFlags.Scalar | SymbolFlags.Declaration | internal); - } else { - bindSymbol(node, SymbolFlags.Scalar); - } + declareSymbol(node, SymbolFlags.Scalar | SymbolFlags.Declaration | internal); + // Initialize locals for type parameters + mutate(node).locals = new SymbolTable(); + } + + function bindScalarDeclarationExpression(node: ScalarDeclarationExpressionNode) { + bindSymbol(node, SymbolFlags.Scalar); // Initialize locals for type parameters mutate(node).locals = new SymbolTable(); } @@ -452,11 +462,12 @@ export function createBinder(program: Program): Binder { function bindUnionStatement(node: UnionStatementNode) { const internal = node.modifierFlags & ModifierFlags.Internal ? SymbolFlags.Internal : SymbolFlags.None; - if (isDeclarationStatementPosition(node)) { - declareSymbol(node, SymbolFlags.Union | SymbolFlags.Declaration | internal); - } else { - bindSymbol(node, SymbolFlags.Union); - } + declareSymbol(node, SymbolFlags.Union | SymbolFlags.Declaration | internal); + mutate(node).locals = new SymbolTable(); + } + + function bindUnionDeclarationExpression(node: UnionDeclarationExpressionNode) { + bindSymbol(node, SymbolFlags.Union); mutate(node).locals = new SymbolTable(); } @@ -476,11 +487,11 @@ export function createBinder(program: Program): Binder { function bindEnumStatement(node: EnumStatementNode) { const internal = node.modifierFlags & ModifierFlags.Internal ? SymbolFlags.Internal : SymbolFlags.None; - if (isDeclarationStatementPosition(node)) { - declareSymbol(node, SymbolFlags.Enum | SymbolFlags.Declaration | internal); - } else { - bindSymbol(node, SymbolFlags.Enum); - } + declareSymbol(node, SymbolFlags.Enum | SymbolFlags.Declaration | internal); + } + + function bindEnumDeclarationExpression(node: EnumDeclarationExpressionNode) { + bindSymbol(node, SymbolFlags.Enum); } function bindEnumMember(node: EnumMemberNode) { @@ -586,7 +597,7 @@ export function createBinder(program: Program): Binder { case SyntaxKind.JsSourceFile: return declareScriptMember(node, flags, name); default: - const key = name ?? node.id?.sv ?? ""; + const key = name ?? node.id.sv; const symbol = createSymbol(node, key, flags, scope?.symbol); mutate(node).symbol = symbol; mutate(scope.locals!).set(key, symbol); @@ -611,7 +622,7 @@ export function createBinder(program: Program): Binder { ) { return; } - const key = name ?? node.id?.sv ?? ""; + const key = name ?? node.id.sv; const symbol = createSymbol(node, key, flags, scope.symbol); mutate(node).symbol = symbol; mutate(scope.symbol.exports)!.set(key, symbol); @@ -630,7 +641,7 @@ export function createBinder(program: Program): Binder { ) { return; } - const key = name ?? node.id?.sv ?? ""; + const key = name ?? node.id.sv; const symbol = createSymbol(node, key, flags, fileNamespace?.symbol); mutate(node).symbol = symbol; mutate(effectiveScope.symbol.exports!).set(key, symbol); @@ -675,15 +686,19 @@ export function createBinder(program: Program): Binder { function hasScope(node: Node): node is ScopeNode { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ModelExpression: case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: case SyntaxKind.ConstStatement: case SyntaxKind.AliasStatement: case SyntaxKind.TypeSpecScript: case SyntaxKind.InterfaceStatement: case SyntaxKind.OperationStatement: case SyntaxKind.UnionStatement: + case SyntaxKind.UnionDeclarationExpression: case SyntaxKind.EnumStatement: + case SyntaxKind.EnumDeclarationExpression: return true; case SyntaxKind.NamespaceStatement: return node.statements !== undefined; diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 3d14b7927f3..365f8d2f943 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -21,7 +21,6 @@ import { validateInheritanceDiscriminatedUnions } from "./helpers/discriminator- import { getLocationContext } from "./helpers/location-context.js"; import { explainStringTemplateNotSerializable } from "./helpers/string-template-utils.js"; import { - isDeclarationInExpressionPosition, printIdentifier, printMemberExpressionPath, typeReferenceToString, @@ -76,6 +75,7 @@ import { DocContent, Entity, Enum, + EnumDeclarationExpressionNode, EnumMember, EnumMemberNode, EnumStatementNode, @@ -109,6 +109,7 @@ import { MixedFunctionParameter, MixedParameterConstraint, Model, + ModelDeclarationExpressionNode, ModelExpressionNode, ModelIndexer, ModelProperty, @@ -133,6 +134,7 @@ import { Scalar, ScalarConstructor, ScalarConstructorNode, + ScalarDeclarationExpressionNode, ScalarStatementNode, ScalarValue, SignatureFunctionParameter, @@ -170,6 +172,7 @@ import { TypeReferenceNode, TypeSpecScriptNode, Union, + UnionDeclarationExpressionNode, UnionExpressionNode, UnionStatementNode, UnionVariant, @@ -1020,20 +1023,28 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return checkModel(ctx, node); case SyntaxKind.ModelStatement: return checkModel(ctx, node); + case SyntaxKind.ModelDeclarationExpression: + return checkModel(ctx, node); case SyntaxKind.ModelProperty: return checkModelProperty(ctx, node); case SyntaxKind.ScalarStatement: return checkScalar(ctx, node); + case SyntaxKind.ScalarDeclarationExpression: + return checkScalar(ctx, node); case SyntaxKind.AliasStatement: return checkAlias(ctx, node); case SyntaxKind.EnumStatement: return checkEnum(ctx, node); + case SyntaxKind.EnumDeclarationExpression: + return checkEnum(ctx, node); case SyntaxKind.EnumMember: return checkEnumMember(ctx, node); case SyntaxKind.InterfaceStatement: return checkInterface(ctx, node); case SyntaxKind.UnionStatement: return checkUnion(ctx, node); + case SyntaxKind.UnionDeclarationExpression: + return checkUnion(ctx, node); case SyntaxKind.UnionVariant: return checkUnionVariant(ctx, node); case SyntaxKind.NamespaceStatement: @@ -1101,13 +1112,16 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node: | ModelStatementNode | ModelExpressionNode + | ModelDeclarationExpressionNode | ScalarStatementNode + | ScalarDeclarationExpressionNode | AliasStatementNode | ConstStatementNode | InterfaceStatementNode | OperationStatementNode | TemplateParameterDeclarationNode - | UnionStatementNode, + | UnionStatementNode + | UnionDeclarationExpressionNode, ): Sym { const symbol = node.kind === SyntaxKind.OperationStatement && @@ -1169,7 +1183,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker let type: TemplateParameter | undefined = links.declaredType as TemplateParameter; if (type === undefined) { if (grandParentNode) { - if (grandParentNode.locals?.has(node.id.sv)) { + if ("locals" in grandParentNode && grandParentNode.locals?.has(node.id.sv)) { reportCheckerDiagnostic( createDiagnostic({ code: "shadow", @@ -2515,15 +2529,19 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node: | AliasStatementNode | ModelStatementNode + | ModelDeclarationExpressionNode | ScalarStatementNode + | ScalarDeclarationExpressionNode | NamespaceStatementNode | JsNamespaceDeclarationNode | UnionExpressionNode | OperationStatementNode | EnumStatementNode + | EnumDeclarationExpressionNode | InterfaceStatementNode | IntersectionExpressionNode | UnionStatementNode + | UnionDeclarationExpressionNode | ModelExpressionNode | DecoratorDeclarationStatementNode | FunctionDeclarationStatementNode, @@ -2534,11 +2552,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node.kind === SyntaxKind.ModelExpression || node.kind === SyntaxKind.IntersectionExpression || node.kind === SyntaxKind.UnionExpression || - ((node.kind === SyntaxKind.ModelStatement || - node.kind === SyntaxKind.EnumStatement || - node.kind === SyntaxKind.UnionStatement || - node.kind === SyntaxKind.ScalarStatement) && - isDeclarationInExpressionPosition(node)) + node.kind === SyntaxKind.ModelDeclarationExpression || + node.kind === SyntaxKind.EnumDeclarationExpression || + node.kind === SyntaxKind.UnionDeclarationExpression || + node.kind === SyntaxKind.ScalarDeclarationExpression ) { let parent: Node | undefined = node.parent; while (parent !== undefined) { @@ -4474,15 +4491,18 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function getMemberKindName(node: Node) { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ModelExpression: return "Model"; case SyntaxKind.ModelProperty: return "ModelProperty"; case SyntaxKind.EnumStatement: + case SyntaxKind.EnumDeclarationExpression: return "Enum"; case SyntaxKind.InterfaceStatement: return "Interface"; case SyntaxKind.UnionStatement: + case SyntaxKind.UnionDeclarationExpression: return "Union"; default: return "Type"; @@ -4999,11 +5019,14 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } - function checkModel(ctx: CheckContext, node: ModelExpressionNode | ModelStatementNode): Model { - if (node.kind === SyntaxKind.ModelStatement) { - return checkModelStatement(ctx, node); - } else { + function checkModel( + ctx: CheckContext, + node: ModelExpressionNode | ModelStatementNode | ModelDeclarationExpressionNode, + ): Model { + if (node.kind === SyntaxKind.ModelExpression) { return checkModelExpression(ctx, node); + } else { + return checkModelStatement(ctx, node); } } @@ -5012,9 +5035,19 @@ export function createChecker(program: Program, resolver: NameResolver): Checker * instantiated, so template parameters on it are meaningless. Report a diagnostic when present. */ function checkExpressionDeclarationConstraints( - node: ModelStatementNode | UnionStatementNode | ScalarStatementNode, + node: + | ModelStatementNode + | ModelDeclarationExpressionNode + | UnionStatementNode + | UnionDeclarationExpressionNode + | ScalarStatementNode + | ScalarDeclarationExpressionNode, ): void { - if (node.templateParameters.length > 0 && isDeclarationInExpressionPosition(node)) { + const isExpression = + node.kind === SyntaxKind.ModelDeclarationExpression || + node.kind === SyntaxKind.UnionDeclarationExpression || + node.kind === SyntaxKind.ScalarDeclarationExpression; + if (isExpression && node.templateParameters.length > 0) { reportCheckerDiagnostic( createDiagnostic({ code: "templated-declaration-in-expression", @@ -5024,7 +5057,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } - function checkModelStatement(ctx: CheckContext, node: ModelStatementNode): Model { + function checkModelStatement( + ctx: CheckContext, + node: ModelStatementNode | ModelDeclarationExpressionNode, + ): Model { const links = getSymbolLinks(node.symbol); if (ctx.mapper === undefined && node.templateParameters.length > 0) { @@ -5051,7 +5087,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators, sourceModels: [], derivedModels: [], - expression: isDeclarationInExpressionPosition(node), + expression: node.kind === SyntaxKind.ModelDeclarationExpression, }); linkType(ctx, links, type); @@ -5226,7 +5262,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkModelProperties( ctx: CheckContext, - node: ModelExpressionNode | ModelStatementNode, + node: ModelExpressionNode | ModelStatementNode | ModelDeclarationExpressionNode, properties: Map, parentModel: Model, ) { @@ -6470,7 +6506,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } function checkClassHeritage( ctx: CheckContext, - model: ModelStatementNode, + model: ModelStatementNode | ModelDeclarationExpressionNode, heritageRef: Expression, ): Model | undefined { if (heritageRef.kind === SyntaxKind.ModelExpression) { @@ -6538,7 +6574,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkModelIs( ctx: CheckContext, - model: ModelStatementNode, + model: ModelStatementNode | ModelDeclarationExpressionNode, isExpr: Expression | undefined, ): Model | undefined { if (!isExpr) return undefined; @@ -6600,7 +6636,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker /** Get the type for the spread target */ function checkSpreadTarget( ctx: CheckContext, - model: ModelStatementNode | ModelExpressionNode, + model: ModelStatementNode | ModelExpressionNode | ModelDeclarationExpressionNode, target: TypeReferenceNode, ): Type | undefined { const modelSymId = getNodeSym(model); @@ -7289,7 +7325,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return decorators; } - function checkScalar(ctx: CheckContext, node: ScalarStatementNode): Scalar { + function checkScalar( + ctx: CheckContext, + node: ScalarStatementNode | ScalarDeclarationExpressionNode, + ): Scalar { const links = getSymbolLinks(node.symbol); if (ctx.mapper === undefined && node.templateParameters.length > 0) { @@ -7317,7 +7356,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker namespace: getParentNamespaceType(node), decorators, derivedScalars: [], - expression: isDeclarationInExpressionPosition(node), + expression: node.kind === SyntaxKind.ScalarDeclarationExpression, }); linkType(ctx, links, type); @@ -7344,7 +7383,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkScalarExtends( ctx: CheckContext, - scalar: ScalarStatementNode, + scalar: ScalarStatementNode | ScalarDeclarationExpressionNode, extendsRef: TypeReferenceNode, ): Scalar | undefined { const symId = getNodeSym(scalar); @@ -7382,7 +7421,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkScalarConstructors( ctx: CheckContext, parentScalar: Scalar, - node: ScalarStatementNode, + node: ScalarStatementNode | ScalarDeclarationExpressionNode, constructors: Map, ) { if (parentScalar.baseScalar) { @@ -7560,7 +7599,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } - function checkEnum(ctx: CheckContext, node: EnumStatementNode): Type { + function checkEnum( + ctx: CheckContext, + node: EnumStatementNode | EnumDeclarationExpressionNode, + ): Type { const links = getSymbolLinks(node.symbol); if (!links.type) { checkModifiers(program, node); @@ -7570,7 +7612,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node, members: createRekeyableMap(), decorators: [], - expression: isDeclarationInExpressionPosition(node), + expression: node.kind === SyntaxKind.EnumDeclarationExpression, })); const memberNames = new Set(); @@ -7736,7 +7778,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return ownMembers; } - function checkUnion(ctx: CheckContext, node: UnionStatementNode) { + function checkUnion( + ctx: CheckContext, + node: UnionStatementNode | UnionDeclarationExpressionNode, + ) { const links = getSymbolLinks(node.symbol); if (ctx.mapper === undefined && node.templateParameters.length > 0) { @@ -7764,7 +7809,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker get options() { return Array.from(this.variants.values()).map((v) => v.type); }, - expression: isDeclarationInExpressionPosition(node), + expression: node.kind === SyntaxKind.UnionDeclarationExpression, }); linkType(ctx, links, unionType); @@ -7790,7 +7835,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkUnionVariants( ctx: CheckContext, parentUnion: Union, - node: UnionStatementNode, + node: UnionStatementNode | UnionDeclarationExpressionNode, variants: Map, ) { for (const variantNode of node.options) { @@ -8714,7 +8759,9 @@ function extractParamDocs(node: OperationStatementNode): Map { return paramDocs; } -function extractPropDocs(node: ModelStatementNode): Map { +function extractPropDocs( + node: ModelStatementNode | ModelDeclarationExpressionNode, +): Map { if (node.docs === undefined) { return new Map(); } diff --git a/packages/compiler/src/core/helpers/syntax-utils.ts b/packages/compiler/src/core/helpers/syntax-utils.ts index 2c1843a853f..44f993a7932 100644 --- a/packages/compiler/src/core/helpers/syntax-utils.ts +++ b/packages/compiler/src/core/helpers/syntax-utils.ts @@ -1,31 +1,6 @@ import { CharCode, isIdentifierContinue, isIdentifierStart, utf16CodeUnits } from "../charcode.js"; import { isModifier, Keywords, ReservedKeywords } from "../scanner.js"; -import { - IdentifierNode, - MemberExpressionNode, - Node, - SyntaxKind, - TypeReferenceNode, -} from "../types.js"; - -/** - * Determine whether a declaration node (model/enum/union/scalar) appears in expression - * position (e.g. as an alias value or a property type) rather than as a top-level - * statement directly under a namespace or source file. Anonymous declarations (used as - * expressions) are always in expression position. - * - * This is the single source of truth shared by the binder and checker so the two cannot - * drift apart. - */ -export function isDeclarationInExpressionPosition(node: Node): boolean { - const parent = node.parent; - return ( - parent === undefined || - (parent.kind !== SyntaxKind.NamespaceStatement && - parent.kind !== SyntaxKind.TypeSpecScript && - parent.kind !== SyntaxKind.JsSourceFile) - ); -} +import { IdentifierNode, MemberExpressionNode, SyntaxKind, TypeReferenceNode } from "../types.js"; /** * Print a string as a TypeSpec identifier. If the string is a valid identifier, return it as is otherwise wrap it into backticks. diff --git a/packages/compiler/src/core/inspector/node.ts b/packages/compiler/src/core/inspector/node.ts index 6749c649f58..7a616a3adb8 100644 --- a/packages/compiler/src/core/inspector/node.ts +++ b/packages/compiler/src/core/inspector/node.ts @@ -33,11 +33,15 @@ function printNodeInfoInternal(node: Node): string { case SyntaxKind.JsNamespaceDeclaration: case SyntaxKind.NamespaceStatement: case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.OperationStatement: case SyntaxKind.EnumStatement: + case SyntaxKind.EnumDeclarationExpression: case SyntaxKind.AliasStatement: case SyntaxKind.ConstStatement: case SyntaxKind.UnionStatement: + case SyntaxKind.UnionDeclarationExpression: + case SyntaxKind.ScalarDeclarationExpression: return node.id?.sv ?? ""; default: return ""; diff --git a/packages/compiler/src/core/modifiers.ts b/packages/compiler/src/core/modifiers.ts index 4473b051a7c..3818ce922a0 100644 --- a/packages/compiler/src/core/modifiers.ts +++ b/packages/compiler/src/core/modifiers.ts @@ -4,7 +4,16 @@ import { compilerAssert } from "./diagnostics.js"; import { createDiagnostic } from "./messages.js"; import { Program } from "./program.js"; -import { Declaration, Modifier, ModifierFlags, SyntaxKind } from "./types.js"; +import { + Declaration, + EnumDeclarationExpressionNode, + ModelDeclarationExpressionNode, + Modifier, + ModifierFlags, + ScalarDeclarationExpressionNode, + SyntaxKind, + UnionDeclarationExpressionNode, +} from "./types.js"; /** * The compatibility of modifiers for a given declaration node type. @@ -32,7 +41,19 @@ const NO_MODIFIERS: ModifierCompatibility = { required: ModifierFlags.None, }; -const SYNTAX_MODIFIERS: Readonly> = { +/** + * Declaration nodes whose modifiers can be checked. Includes the statement + * declarations as well as the declaration-expression nodes (which never carry + * modifiers, but go through the same checking path). + */ +type ModifierCheckableNode = + | Declaration + | ModelDeclarationExpressionNode + | ScalarDeclarationExpressionNode + | UnionDeclarationExpressionNode + | EnumDeclarationExpressionNode; + +const SYNTAX_MODIFIERS: Readonly> = { [SyntaxKind.NamespaceStatement]: NO_MODIFIERS, [SyntaxKind.OperationStatement]: DEFAULT_COMPATIBILITY, [SyntaxKind.ModelStatement]: DEFAULT_COMPATIBILITY, @@ -42,6 +63,10 @@ const SYNTAX_MODIFIERS: Readonly { + function parseScalarMembers(inExpressionPosition = false): ListDetail { // In expression position there is no `;` terminator: only parse a `{ ... }` body // when present, otherwise the scalar has no members. - if (allowAnonymous && token() !== Token.OpenBrace) { + if (inExpressionPosition && token() !== Token.OpenBrace) { return createEmptyList(); } if (token() === Token.Semicolon) { @@ -1171,10 +1257,9 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa pos: number, decorators: DecoratorExpressionNode[], modifiers: Modifier[], - allowAnonymous = false, ): EnumStatementNode { parseExpected(Token.EnumKeyword); - const id = parseDeclarationIdentifier(allowAnonymous); + const id = parseIdentifier(); const { items: members } = parseList(ListKind.EnumMembers, parseEnumMemberOrSpread); return { kind: SyntaxKind.EnumStatement, @@ -1187,6 +1272,21 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + function parseEnumDeclarationExpression(pos: number): EnumDeclarationExpressionNode { + parseExpected(Token.EnumKeyword); + const id = parseOptionalDeclarationExpressionIdentifier(); + const { items: members } = parseList(ListKind.EnumMembers, parseEnumMemberOrSpread); + return { + kind: SyntaxKind.EnumDeclarationExpression, + id, + decorators: [], + modifiers: [], + modifierFlags: ModifierFlags.None, + members, + ...finishNode(pos), + }; + } + function parseEnumMemberOrSpread(pos: number, decorators: DecoratorExpressionNode[]) { return token() === Token.Ellipsis ? parseEnumSpreadMember(pos, decorators) @@ -1734,13 +1834,13 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa case Token.OpenBrace: return parseModelExpression(); case Token.ModelKeyword: - return parseModelStatement(tokenPos(), [], [], true); + return parseModelDeclarationExpression(tokenPos()); case Token.EnumKeyword: - return parseEnumStatement(tokenPos(), [], [], true); + return parseEnumDeclarationExpression(tokenPos()); case Token.UnionKeyword: - return parseUnionStatement(tokenPos(), [], [], true); + return parseUnionDeclarationExpression(tokenPos()); case Token.ScalarKeyword: - return parseScalarStatement(tokenPos(), [], [], true); + return parseScalarDeclarationExpression(tokenPos()); case Token.OpenBracket: return parseTupleExpression(); case Token.OpenParen: @@ -2020,15 +2120,11 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa } /** - * Parse the identifier of a declaration. When {@link allowAnonymous} is true (the - * declaration is being used in expression position) the identifier is optional and - * only parsed when a name is actually present. + * Parse the optional identifier of a declaration used in expression position. The name + * is only parsed when actually present; an anonymous declaration expression has no `id`. */ - function parseDeclarationIdentifier(allowAnonymous: boolean): IdentifierNode | undefined { - if (allowAnonymous && token() !== Token.Identifier) { - return undefined; - } - return parseIdentifier(); + function parseOptionalDeclarationExpressionIdentifier(): IdentifierNode | undefined { + return token() === Token.Identifier ? parseIdentifier() : undefined; } function parseIdentifier(options?: { @@ -3072,6 +3168,15 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitNode(cb, node.is) || visitEach(cb, node.properties) ); + case SyntaxKind.ModelDeclarationExpression: + return ( + visitEach(cb, node.decorators) || + visitNode(cb, node.id) || + visitEach(cb, node.templateParameters) || + visitNode(cb, node.extends) || + visitNode(cb, node.is) || + visitEach(cb, node.properties) + ); case SyntaxKind.ScalarStatement: return ( visitEach(cb, node.modifiers) || @@ -3081,6 +3186,14 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitEach(cb, node.members) || visitNode(cb, node.extends) ); + case SyntaxKind.ScalarDeclarationExpression: + return ( + visitEach(cb, node.decorators) || + visitNode(cb, node.id) || + visitEach(cb, node.templateParameters) || + visitEach(cb, node.members) || + visitNode(cb, node.extends) + ); case SyntaxKind.ScalarConstructor: return visitNode(cb, node.id) || visitEach(cb, node.parameters); case SyntaxKind.UnionStatement: @@ -3091,6 +3204,13 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitEach(cb, node.templateParameters) || visitEach(cb, node.options) ); + case SyntaxKind.UnionDeclarationExpression: + return ( + visitEach(cb, node.decorators) || + visitNode(cb, node.id) || + visitEach(cb, node.templateParameters) || + visitEach(cb, node.options) + ); case SyntaxKind.UnionVariant: return visitEach(cb, node.decorators) || visitNode(cb, node.id) || visitNode(cb, node.value); case SyntaxKind.EnumStatement: @@ -3100,6 +3220,10 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitNode(cb, node.id) || visitEach(cb, node.members) ); + case SyntaxKind.EnumDeclarationExpression: + return ( + visitEach(cb, node.decorators) || visitNode(cb, node.id) || visitEach(cb, node.members) + ); case SyntaxKind.EnumMember: return visitEach(cb, node.decorators) || visitNode(cb, node.id) || visitNode(cb, node.value); case SyntaxKind.EnumSpreadMember: @@ -3444,6 +3568,9 @@ export function getIdentifierContext(id: IdentifierNode): IdentifierContext { case SyntaxKind.ModelStatement: kind = IdentifierKind.ModelStatementProperty; break; + case SyntaxKind.ModelDeclarationExpression: + kind = IdentifierKind.ModelStatementProperty; + break; default: compilerAssert("false", "ModelProperty with unexpected parent kind."); kind = diff --git a/packages/compiler/src/core/type-utils.ts b/packages/compiler/src/core/type-utils.ts index 8e5f471da0c..ef9c11a25c6 100644 --- a/packages/compiler/src/core/type-utils.ts +++ b/packages/compiler/src/core/type-utils.ts @@ -93,8 +93,11 @@ export function isRecordModelType(programOrType: Program | Model, maybeType?: Mo export function getParentTemplateNode(node: Node): (Node & TemplateDeclarationNode) | undefined { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: case SyntaxKind.UnionStatement: + case SyntaxKind.UnionDeclarationExpression: case SyntaxKind.InterfaceStatement: return node.templateParameters.length > 0 ? node : undefined; case SyntaxKind.OperationStatement: diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index c671a9e8896..c89e92ad6bd 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -267,7 +267,12 @@ export interface RecordModelType extends Model { export interface Model extends BaseType, DecoratedType, TemplatedTypeBase { kind: "Model"; name: string; - node?: ModelStatementNode | ModelExpressionNode | IntersectionExpressionNode | ObjectLiteralNode; + node?: + | ModelStatementNode + | ModelDeclarationExpressionNode + | ModelExpressionNode + | IntersectionExpressionNode + | ObjectLiteralNode; namespace?: Namespace; indexer?: ModelIndexer; @@ -438,7 +443,7 @@ export interface TemplateValue extends BaseValue { export interface Scalar extends BaseType, DecoratedType, TemplatedTypeBase { kind: "Scalar"; name: string; - node?: ScalarStatementNode; + node?: ScalarStatementNode | ScalarDeclarationExpressionNode; /** * Namespace the scalar was defined in. */ @@ -511,7 +516,7 @@ export interface Interface extends BaseType, DecoratedType, TemplatedTypeBase { export interface Enum extends BaseType, DecoratedType { kind: "Enum"; name: string; - node?: EnumStatementNode; + node?: EnumStatementNode | EnumDeclarationExpressionNode; namespace?: Namespace; /** @@ -688,7 +693,7 @@ export interface Tuple extends BaseType { export interface Union extends BaseType, DecoratedType, TemplatedTypeBase { kind: "Union"; name?: string; - node?: UnionExpressionNode | UnionStatementNode; + node?: UnionExpressionNode | UnionStatementNode | UnionDeclarationExpressionNode; namespace?: Namespace; /** @@ -1231,6 +1236,10 @@ export enum SyntaxKind { ScalarConstructor, InternalKeyword, FunctionTypeExpression, + ModelDeclarationExpression, + ScalarDeclarationExpression, + UnionDeclarationExpression, + EnumDeclarationExpression, } export const enum NodeFlags { @@ -1361,11 +1370,14 @@ export type Node = */ export type TemplateableNode = | ModelStatementNode + | ModelDeclarationExpressionNode | ScalarStatementNode + | ScalarDeclarationExpressionNode | AliasStatementNode | InterfaceStatementNode | OperationStatementNode - | UnionStatementNode; + | UnionStatementNode + | UnionDeclarationExpressionNode; /** * Node types that can have referencable members @@ -1373,11 +1385,15 @@ export type TemplateableNode = export type MemberContainerNode = | ModelStatementNode | ModelExpressionNode + | ModelDeclarationExpressionNode | InterfaceStatementNode | EnumStatementNode + | EnumDeclarationExpressionNode | UnionStatementNode + | UnionDeclarationExpressionNode | IntersectionExpressionNode - | ScalarStatementNode; + | ScalarStatementNode + | ScalarDeclarationExpressionNode; export type MemberNode = | ModelPropertyNode @@ -1463,9 +1479,9 @@ export interface DeclarationNode { } /** - * Declaration node whose identifier is optional. Used by declarations that can also - * appear in expression position (e.g. `alias Foo = enum { a, b }`), in which case they - * may be anonymous (no `id`). + * Declaration node whose identifier is optional. Used by declaration-expression nodes + * (e.g. `alias Foo = enum { a, b }`), which may be anonymous (no `id`) or carry a name + * that is kept on the resulting type but never registered in a namespace. */ export interface OptionallyNamedDeclarationNode { /** @@ -1485,12 +1501,7 @@ export interface OptionallyNamedDeclarationNode { readonly modifierFlags: ModifierFlags; } -export type Declaration = - | Extract - | ModelStatementNode - | ScalarStatementNode - | UnionStatementNode - | EnumStatementNode; +export type Declaration = Extract; export type ScopeNode = | NamespaceStatementNode @@ -1537,10 +1548,10 @@ export type Expression = | ArrayExpressionNode | MemberExpressionNode | ModelExpressionNode - | ModelStatementNode - | EnumStatementNode - | UnionStatementNode - | ScalarStatementNode + | ModelDeclarationExpressionNode + | EnumDeclarationExpressionNode + | UnionDeclarationExpressionNode + | ScalarDeclarationExpressionNode | ObjectLiteralNode | ArrayLiteralNode | TupleExpressionNode @@ -1611,8 +1622,7 @@ export interface OperationStatementNode extends BaseNode, DeclarationNode, Templ readonly parent?: TypeSpecScriptNode | NamespaceStatementNode | InterfaceStatementNode; } -export interface ModelStatementNode - extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { +export interface ModelStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { readonly kind: SyntaxKind.ModelStatement; readonly properties: readonly (ModelPropertyNode | ModelSpreadPropertyNode)[]; readonly bodyRange: TextRange; @@ -1622,8 +1632,23 @@ export interface ModelStatementNode readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } -export interface ScalarStatementNode +/** + * A `model` declaration used in expression position (e.g. `alias M = model { x: string }` + * or a property type). May carry a name (kept on the resulting type but never registered) + * and is always `expression: true`. Template parameters are syntactically accepted for + * error recovery but rejected by the checker. + */ +export interface ModelDeclarationExpressionNode extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { + readonly kind: SyntaxKind.ModelDeclarationExpression; + readonly properties: readonly (ModelPropertyNode | ModelSpreadPropertyNode)[]; + readonly bodyRange: TextRange; + readonly extends?: Expression; + readonly is?: Expression; + readonly decorators: readonly DecoratorExpressionNode[]; +} + +export interface ScalarStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { readonly kind: SyntaxKind.ScalarStatement; readonly extends?: TypeReferenceNode; readonly decorators: readonly DecoratorExpressionNode[]; @@ -1632,11 +1657,26 @@ export interface ScalarStatementNode readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } +/** + * A `scalar` declaration used in expression position (e.g. `alias S = scalar extends int32`). + * May carry a name (kept on the resulting type but never registered) and is always + * `expression: true`. Template parameters are syntactically accepted for error recovery but + * rejected by the checker. + */ +export interface ScalarDeclarationExpressionNode + extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { + readonly kind: SyntaxKind.ScalarDeclarationExpression; + readonly extends?: TypeReferenceNode; + readonly decorators: readonly DecoratorExpressionNode[]; + readonly members: readonly ScalarConstructorNode[]; + readonly bodyRange: TextRange; +} + export interface ScalarConstructorNode extends BaseNode { readonly kind: SyntaxKind.ScalarConstructor; readonly id: IdentifierNode; readonly parameters: FunctionParameterNode[]; - readonly parent?: ScalarStatementNode; + readonly parent?: ScalarStatementNode | ScalarDeclarationExpressionNode; } export interface InterfaceStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { @@ -1648,35 +1688,58 @@ export interface InterfaceStatementNode extends BaseNode, DeclarationNode, Templ readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } -export interface UnionStatementNode - extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { +export interface UnionStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { readonly kind: SyntaxKind.UnionStatement; readonly options: readonly UnionVariantNode[]; readonly decorators: readonly DecoratorExpressionNode[]; readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } +/** + * A keyword-form `union` declaration used in expression position + * (e.g. `alias U = union { string, int32 }`). May carry a name (kept on the resulting type + * but never registered) and is always `expression: true`. Template parameters are + * syntactically accepted for error recovery but rejected by the checker. + */ +export interface UnionDeclarationExpressionNode + extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { + readonly kind: SyntaxKind.UnionDeclarationExpression; + readonly options: readonly UnionVariantNode[]; + readonly decorators: readonly DecoratorExpressionNode[]; +} + export interface UnionVariantNode extends BaseNode { readonly kind: SyntaxKind.UnionVariant; readonly id?: IdentifierNode; readonly value: Expression; readonly decorators: readonly DecoratorExpressionNode[]; - readonly parent?: UnionStatementNode; + readonly parent?: UnionStatementNode | UnionDeclarationExpressionNode; } -export interface EnumStatementNode extends BaseNode, OptionallyNamedDeclarationNode { +export interface EnumStatementNode extends BaseNode, DeclarationNode { readonly kind: SyntaxKind.EnumStatement; readonly members: readonly (EnumMemberNode | EnumSpreadMemberNode)[]; readonly decorators: readonly DecoratorExpressionNode[]; readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } +/** + * An `enum` declaration used in expression position (e.g. `alias E = enum { a, b }`). + * May carry a name (kept on the resulting type but never registered) and is always + * `expression: true`. + */ +export interface EnumDeclarationExpressionNode extends BaseNode, OptionallyNamedDeclarationNode { + readonly kind: SyntaxKind.EnumDeclarationExpression; + readonly members: readonly (EnumMemberNode | EnumSpreadMemberNode)[]; + readonly decorators: readonly DecoratorExpressionNode[]; +} + export interface EnumMemberNode extends BaseNode { readonly kind: SyntaxKind.EnumMember; readonly id: IdentifierNode; readonly value?: StringLiteralNode | NumericLiteralNode; readonly decorators: readonly DecoratorExpressionNode[]; - readonly parent?: EnumStatementNode; + readonly parent?: EnumStatementNode | EnumDeclarationExpressionNode; } export interface EnumSpreadMemberNode extends BaseNode { @@ -1733,13 +1796,13 @@ export interface ModelPropertyNode extends BaseNode { readonly decorators: readonly DecoratorExpressionNode[]; readonly optional: boolean; readonly default?: Expression; - readonly parent?: ModelStatementNode | ModelExpressionNode; + readonly parent?: ModelStatementNode | ModelExpressionNode | ModelDeclarationExpressionNode; } export interface ModelSpreadPropertyNode extends BaseNode { readonly kind: SyntaxKind.ModelSpreadProperty; readonly target: TypeReferenceNode; - readonly parent?: ModelStatementNode | ModelExpressionNode; + readonly parent?: ModelStatementNode | ModelExpressionNode | ModelDeclarationExpressionNode; } export interface ObjectLiteralNode extends BaseNode { diff --git a/packages/compiler/src/formatter/print/comment-handler.ts b/packages/compiler/src/formatter/print/comment-handler.ts index dc9917d4ce1..a479bb5e224 100644 --- a/packages/compiler/src/formatter/print/comment-handler.ts +++ b/packages/compiler/src/formatter/print/comment-handler.ts @@ -85,14 +85,18 @@ function addCommentBetweenAnnotationsAndNode({ comment }: CommentContext) { enclosingNode && (enclosingNode.kind === SyntaxKind.NamespaceStatement || enclosingNode.kind === SyntaxKind.ModelStatement || + enclosingNode.kind === SyntaxKind.ModelDeclarationExpression || enclosingNode.kind === SyntaxKind.EnumStatement || + enclosingNode.kind === SyntaxKind.EnumDeclarationExpression || enclosingNode.kind === SyntaxKind.OperationStatement || enclosingNode.kind === SyntaxKind.ScalarStatement || + enclosingNode.kind === SyntaxKind.ScalarDeclarationExpression || enclosingNode.kind === SyntaxKind.InterfaceStatement || enclosingNode.kind === SyntaxKind.ModelProperty || enclosingNode.kind === SyntaxKind.EnumMember || enclosingNode.kind === SyntaxKind.UnionVariant || - enclosingNode.kind === SyntaxKind.UnionStatement) + enclosingNode.kind === SyntaxKind.UnionStatement || + enclosingNode.kind === SyntaxKind.UnionDeclarationExpression) ) { util.addTrailingComment(precedingNode, comment); return true; @@ -114,7 +118,8 @@ function addEmptyModelComment({ comment }: CommentContext) { if ( enclosingNode && - enclosingNode.kind === SyntaxKind.ModelStatement && + (enclosingNode.kind === SyntaxKind.ModelStatement || + enclosingNode.kind === SyntaxKind.ModelDeclarationExpression) && enclosingNode.properties.length === 0 && precedingNode && (precedingNode === enclosingNode.is || @@ -141,7 +146,8 @@ function addEmptyScalarComment({ comment }: CommentContext) { if ( enclosingNode && - enclosingNode.kind === SyntaxKind.ScalarStatement && + (enclosingNode.kind === SyntaxKind.ScalarStatement || + enclosingNode.kind === SyntaxKind.ScalarDeclarationExpression) && enclosingNode.members.length === 0 && precedingNode && (precedingNode === enclosingNode.id || precedingNode === enclosingNode.extends) diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 18f0d0dc82a..db26753ee73 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -169,16 +169,24 @@ export function printNode( ); case SyntaxKind.ModelStatement: return printModelStatement(path as AstPath, options, print); + case SyntaxKind.ModelDeclarationExpression: + return printModelStatement(path as AstPath, options, print); case SyntaxKind.ScalarStatement: return printScalarStatement(path as AstPath, options, print); + case SyntaxKind.ScalarDeclarationExpression: + return printScalarStatement(path as AstPath, options, print); case SyntaxKind.ScalarConstructor: return printScalarConstructor(path as AstPath, options, print); case SyntaxKind.AliasStatement: return printAliasStatement(path as AstPath, options, print); case SyntaxKind.EnumStatement: return printEnumStatement(path as AstPath, options, print); + case SyntaxKind.EnumDeclarationExpression: + return printEnumStatement(path as AstPath, options, print); case SyntaxKind.UnionStatement: return printUnionStatement(path as AstPath, options, print); + case SyntaxKind.UnionDeclarationExpression: + return printUnionStatement(path as AstPath, options, print); case SyntaxKind.InterfaceStatement: return printInterfaceStatement(path as AstPath, options, print); // Others. @@ -1208,6 +1216,7 @@ function isModelAValue(path: AstPath): boolean { do { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.AliasStatement: case SyntaxKind.OperationStatement: return false; diff --git a/packages/compiler/src/server/classify.ts b/packages/compiler/src/server/classify.ts index bc255586ac2..f0345c780cf 100644 --- a/packages/compiler/src/server/classify.ts +++ b/packages/compiler/src/server/classify.ts @@ -222,9 +222,11 @@ export function getSemanticTokens(ast: TypeSpecScriptNode): SemanticToken[] { classify(node.id, SemanticTokenKind.Struct); break; case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: if (node.id) classify(node.id, SemanticTokenKind.Struct); break; case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: if (node.id) classify(node.id, SemanticTokenKind.Type); break; case SyntaxKind.ScalarConstructor: @@ -236,9 +238,11 @@ export function getSemanticTokens(ast: TypeSpecScriptNode): SemanticToken[] { } break; case SyntaxKind.EnumStatement: + case SyntaxKind.EnumDeclarationExpression: if (node.id) classify(node.id, SemanticTokenKind.Enum); break; case SyntaxKind.UnionStatement: + case SyntaxKind.UnionDeclarationExpression: if (node.id) classify(node.id, SemanticTokenKind.Enum); break; case SyntaxKind.EnumMember: diff --git a/packages/compiler/src/server/completion.ts b/packages/compiler/src/server/completion.ts index dfac8e3786e..0a02431ccd1 100644 --- a/packages/compiler/src/server/completion.ts +++ b/packages/compiler/src/server/completion.ts @@ -97,7 +97,9 @@ function addCompletionByLookingBackward( preDetail.node, (n) => n.kind === SyntaxKind.ModelStatement || + n.kind === SyntaxKind.ModelDeclarationExpression || n.kind === SyntaxKind.ScalarStatement || + n.kind === SyntaxKind.ScalarDeclarationExpression || n.kind === SyntaxKind.OperationStatement || n.kind === SyntaxKind.InterfaceStatement || n.kind === SyntaxKind.TemplateParameterDeclaration, @@ -118,13 +120,17 @@ function addCompletionByLookingBackwardNode( }; const map: { [key in SyntaxKind]?: keyof KeywordArea } = { [SyntaxKind.ModelStatement]: "modelHeader", + [SyntaxKind.ModelDeclarationExpression]: "modelHeader", [SyntaxKind.ScalarStatement]: "scalarHeader", + [SyntaxKind.ScalarDeclarationExpression]: "scalarHeader", [SyntaxKind.OperationStatement]: "operationHeader", [SyntaxKind.InterfaceStatement]: "interfaceHeader", }; switch (preNode?.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: case SyntaxKind.OperationStatement: case SyntaxKind.InterfaceStatement: const idEndPos = @@ -172,6 +178,7 @@ async function AddCompletionNonTrivia( addKeywordCompletion("namespace", context.completions); break; case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: if (positionInRange(posDetail.position, node.bodyRange)) { addKeywordCompletion("scalarBody", context.completions); } @@ -186,6 +193,7 @@ async function AddCompletionNonTrivia( } break; case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ObjectLiteral: case SyntaxKind.ModelExpression: await addModelCompletion(context, posDetail); @@ -371,6 +379,7 @@ async function addModelCompletion(context: CompletionContext, posDetail: Positio if ( !node || (node.kind !== SyntaxKind.ModelStatement && + node.kind !== SyntaxKind.ModelDeclarationExpression && node.kind !== SyntaxKind.ModelExpression && node.kind !== SyntaxKind.ObjectLiteral) ) { @@ -527,7 +536,9 @@ function addDirectiveCompletion({ completions }: CompletionContext, node: Identi function getCompletionItemKind(program: Program, target: Type): CompletionItemKind { switch (target.node?.kind) { case SyntaxKind.EnumStatement: + case SyntaxKind.EnumDeclarationExpression: case SyntaxKind.UnionStatement: + case SyntaxKind.UnionDeclarationExpression: return CompletionItemKind.Enum; case SyntaxKind.EnumMember: case SyntaxKind.UnionVariant: @@ -535,8 +546,10 @@ function getCompletionItemKind(program: Program, target: Type): CompletionItemKi case SyntaxKind.AliasStatement: return CompletionItemKind.Variable; case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: return CompletionItemKind.Class; case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: return CompletionItemKind.Unit; case SyntaxKind.ModelProperty: return CompletionItemKind.Field; diff --git a/packages/compiler/src/server/symbol-structure.ts b/packages/compiler/src/server/symbol-structure.ts index 8a22fbbe7ad..b67574adcc1 100644 --- a/packages/compiler/src/server/symbol-structure.ts +++ b/packages/compiler/src/server/symbol-structure.ts @@ -123,7 +123,7 @@ export function getSymbolStructure(ast: TypeSpecScriptNode): DocumentSymbol[] { const properties: DocumentSymbol[] = [...node.properties.values()] .map(getDocumentSymbolsForNode) .filter(isDefined); - return createDocumentSymbol(node, node.id?.sv ?? "", SymbolKind.Struct, properties); + return createDocumentSymbol(node, node.id.sv, SymbolKind.Struct, properties); } function getForModelSpread(node: ModelSpreadPropertyNode): DocumentSymbol | undefined { @@ -138,7 +138,7 @@ export function getSymbolStructure(ast: TypeSpecScriptNode): DocumentSymbol[] { const members: DocumentSymbol[] = [...node.members.values()] .map(getDocumentSymbolsForNode) .filter(isDefined); - return createDocumentSymbol(node, node.id?.sv ?? "", SymbolKind.Enum, members); + return createDocumentSymbol(node, node.id.sv, SymbolKind.Enum, members); } function getForEnumSpread(node: EnumSpreadMemberNode): DocumentSymbol | undefined { @@ -160,6 +160,6 @@ export function getSymbolStructure(ast: TypeSpecScriptNode): DocumentSymbol[] { const variants: DocumentSymbol[] = [...node.options.values()] .map(getDocumentSymbolsForNode) .filter(isDefined); - return createDocumentSymbol(node, node.id?.sv ?? "", SymbolKind.Enum, variants); + return createDocumentSymbol(node, node.id.sv, SymbolKind.Enum, variants); } } diff --git a/packages/compiler/src/server/type-signature.ts b/packages/compiler/src/server/type-signature.ts index 32b1ff806b5..5ce94821899 100644 --- a/packages/compiler/src/server/type-signature.ts +++ b/packages/compiler/src/server/type-signature.ts @@ -204,7 +204,12 @@ function getModelSignature(type: Model, includeBody: boolean): string { } return `${type.kind.toLowerCase()} ${getPrintableTypeName(type)}{\n${propDesc.map((d) => `${d};`).join("\n")}\n}`; } else { - if (type.node && type.node.kind === SyntaxKind.ModelStatement && type.node.templateParameters) { + if ( + type.node && + (type.node.kind === SyntaxKind.ModelStatement || + type.node.kind === SyntaxKind.ModelDeclarationExpression) && + type.node.templateParameters + ) { type.node.templateParameters.forEach((t) => { if (t.default) { getRawTextWithCache(t); From 03f837dd19019ec7355ca19f41a9ed8bc422ab0c Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 25 Jun 2026 15:23:42 -0400 Subject: [PATCH 12/21] feat(compiler): support inline and augment decorators on declaration expressions Allow decorators to be applied inline to model/enum/union/scalar declarations used in expression position, and allow augment decorators (@@) to target them via navigation references (e.g. `Foo.x::type`). --- ...ecl-expr-augment-inline-2026-4-18-0-0-2.md | 15 ++ ...-expr-inline-decorators-2026-4-18-0-0-2.md | 14 ++ packages/compiler/src/core/checker.ts | 21 ++- packages/compiler/src/core/parser.ts | 50 +++++-- .../compiler/src/formatter/print/printer.ts | 12 +- .../checker/declaration-expressions.test.ts | 131 +++++++++++++++++- .../compiler/test/formatter/formatter.test.ts | 56 ++++++++ packages/spec/src/spec.emu.html | 10 +- 8 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 .chronus/changes/decl-expr-augment-inline-2026-4-18-0-0-2.md create mode 100644 .chronus/changes/decl-expr-inline-decorators-2026-4-18-0-0-2.md diff --git a/.chronus/changes/decl-expr-augment-inline-2026-4-18-0-0-2.md b/.chronus/changes/decl-expr-augment-inline-2026-4-18-0-0-2.md new file mode 100644 index 00000000000..d3e6aea2253 --- /dev/null +++ b/.chronus/changes/decl-expr-augment-inline-2026-4-18-0-0-2.md @@ -0,0 +1,15 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Allow augment decorators (`@@`) to target `model`, `enum`, `union`, and `scalar` declarations used in expression position (reached via a navigation reference such as `::type`). + +```tsp +model Foo { + status: enum { active, inactive }; +} + +@@doc(Foo.status::type, "the current status"); +``` diff --git a/.chronus/changes/decl-expr-inline-decorators-2026-4-18-0-0-2.md b/.chronus/changes/decl-expr-inline-decorators-2026-4-18-0-0-2.md new file mode 100644 index 00000000000..9e8d293b263 --- /dev/null +++ b/.chronus/changes/decl-expr-inline-decorators-2026-4-18-0-0-2.md @@ -0,0 +1,14 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Allow decorators to be applied inline to `model`, `enum`, `union`, and `scalar` declarations used in expression position. + +```tsp +model Foo { + status: @doc("the current status") enum { active, inactive }; + inner: @doc("nested model") model Inner { x: string }; +} +``` diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 365f8d2f943..096f66ffef7 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -7241,7 +7241,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } else if ( links.finalSymbol?.flags && ~links.finalSymbol.flags & SymbolFlags.Declaration && - ~links.finalSymbol.flags & SymbolFlags.Member + ~links.finalSymbol.flags & SymbolFlags.Member && + !isDeclarationExpressionSym(links.finalSymbol) ) { program.reportDiagnostic( createDiagnostic({ @@ -7272,6 +7273,24 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return errorType; } + /** + * A declaration expression (e.g. the `enum { a, b }` in `model Foo { x: enum { a, b } }`) + * is bound as a non-declaration symbol but is still a real, referenceable type + * (e.g. via `Foo.x::type`) and therefore a valid augment target. Statement-position + * declarations carry {@link SymbolFlags.Declaration} and never reach this check. + */ + function isDeclarationExpressionSym(sym: Sym): boolean { + switch (getSymNode(sym)?.kind) { + case SyntaxKind.ModelDeclarationExpression: + case SyntaxKind.EnumDeclarationExpression: + case SyntaxKind.UnionDeclarationExpression: + case SyntaxKind.ScalarDeclarationExpression: + return true; + default: + return false; + } + } + /** * Check that using statements are targeting valid symbols. */ diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 5955a6af9d2..57d054ddcab 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -722,7 +722,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } - function parseUnionDeclarationExpression(pos: number): UnionDeclarationExpressionNode { + function parseUnionDeclarationExpression( + pos: number, + decorators: DecoratorExpressionNode[] = [], + ): UnionDeclarationExpressionNode { parseExpected(Token.UnionKeyword); const id = parseOptionalDeclarationExpressionIdentifier(); const { items: templateParameters, range: templateParametersRange } = @@ -735,7 +738,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa id, templateParameters, templateParametersRange, - decorators: [], + decorators, modifiers: [], modifierFlags: ModifierFlags.None, options, @@ -946,7 +949,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } - function parseModelDeclarationExpression(pos: number): ModelDeclarationExpressionNode { + function parseModelDeclarationExpression( + pos: number, + decorators: DecoratorExpressionNode[] = [], + ): ModelDeclarationExpressionNode { parseExpected(Token.ModelKeyword); const id = parseOptionalDeclarationExpressionIdentifier(); const { items: templateParameters, range: templateParametersRange } = @@ -961,7 +967,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa is: optionalIs, templateParameters, templateParametersRange, - decorators: [], + decorators, properties, bodyRange, modifiers: [], @@ -1191,7 +1197,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } - function parseScalarDeclarationExpression(pos: number): ScalarDeclarationExpressionNode { + function parseScalarDeclarationExpression( + pos: number, + decorators: DecoratorExpressionNode[] = [], + ): ScalarDeclarationExpressionNode { parseExpected(Token.ScalarKeyword); const id = parseOptionalDeclarationExpressionIdentifier(); const { items: templateParameters, range: templateParametersRange } = @@ -1208,7 +1217,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa extends: optionalExtends, members, bodyRange, - decorators: [], + decorators, modifiers: [], modifierFlags: ModifierFlags.None, ...finishNode(pos), @@ -1272,14 +1281,17 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } - function parseEnumDeclarationExpression(pos: number): EnumDeclarationExpressionNode { + function parseEnumDeclarationExpression( + pos: number, + decorators: DecoratorExpressionNode[] = [], + ): EnumDeclarationExpressionNode { parseExpected(Token.EnumKeyword); const id = parseOptionalDeclarationExpressionIdentifier(); const { items: members } = parseList(ListKind.EnumMembers, parseEnumMemberOrSpread); return { kind: SyntaxKind.EnumDeclarationExpression, id, - decorators: [], + decorators, modifiers: [], modifierFlags: ModifierFlags.None, members, @@ -1845,10 +1857,26 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return parseTupleExpression(); case Token.OpenParen: return parseParenthesizedExpression(); - case Token.At: + case Token.At: { + const pos = tokenPos(); const decorators = parseDecoratorList(); - reportInvalidDecorators(decorators, "expression"); - continue; + // Decorators are only valid in expression position when they decorate a + // declaration expression (the keyword forms below). `pos` starts at the `@` + // so the resulting node span includes the decorators. + switch (token()) { + case Token.ModelKeyword: + return parseModelDeclarationExpression(pos, decorators); + case Token.EnumKeyword: + return parseEnumDeclarationExpression(pos, decorators); + case Token.UnionKeyword: + return parseUnionDeclarationExpression(pos, decorators); + case Token.ScalarKeyword: + return parseScalarDeclarationExpression(pos, decorators); + default: + reportInvalidDecorators(decorators, "expression"); + continue; + } + } case Token.Hash: const directives = parseDirectiveList(); reportInvalidDirective(directives, "expression"); diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index db26753ee73..2e46510e556 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -694,7 +694,9 @@ export function printEnumStatement( options: TypeSpecPrettierOptions, print: PrettierChildPrint, ) { - const { decorators } = printDecorators(path, options, print, { tryInline: false }); + const { decorators } = printDecorators(path, options, print, { + tryInline: isInExpressionPosition(path), + }); const id = path.node.id ? [" ", path.call(print, "id")] : ""; return [ decorators, @@ -748,7 +750,9 @@ export function printUnionStatement( print: PrettierChildPrint, ) { const id = path.node.id ? [" ", path.call(print, "id")] : ""; - const { decorators } = printDecorators(path, options, print, { tryInline: false }); + const { decorators } = printDecorators(path, options, print, { + tryInline: isInExpressionPosition(path), + }); const generic = printTemplateParameters(path, options, print, "templateParameters"); return [ decorators, @@ -1091,7 +1095,7 @@ export function printModelStatement( const shouldPrintBody = nodeHasComments || !(node.properties.length === 0 && node.is); const body = shouldPrintBody ? [" ", printModelPropertiesBlock(path, options, print)] : ";"; return [ - printDecorators(path, options, print, { tryInline: false }).decorators, + printDecorators(path, options, print, { tryInline: isInExpressionPosition(path) }).decorators, printModifiers(path, options, print), "model", id, @@ -1300,7 +1304,7 @@ function printScalarStatement( ? "" : ";"; return [ - printDecorators(path, options, print, { tryInline: false }).decorators, + printDecorators(path, options, print, { tryInline: inExpressionPosition }).decorators, printModifiers(path, options, print), "scalar", id, diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts index 11790aa1209..731b48d56b8 100644 --- a/packages/compiler/test/checker/declaration-expressions.test.ts +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { Enum, Model, Scalar, Union } from "../../src/core/types.js"; -import { getTypeName } from "../../src/index.js"; +import { getDoc, getTypeName } from "../../src/index.js"; import { expectDiagnosticEmpty, expectDiagnostics, t } from "../../src/testing/index.js"; import { Tester } from "../tester.js"; @@ -373,8 +373,61 @@ describe("type name", () => { }); describe("decorators", () => { - it("cannot decorate the declaration expression itself", async () => { - const diagnostics = await Tester.diagnose(`model Foo { x: @doc("hi") enum { a, b } }`); + it("applies a decorator to an anonymous enum expression", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: @doc("the status") enum { active, inactive }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(type.kind).toBe("Enum"); + expect(type.expression).toBe(true); + expect(getDoc(program, type)).toBe("the status"); + }); + + it("applies a decorator to a named model declaration expression", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + inner: @doc("the inner") model Inner { x: string }; + } + `); + const type = Foo.properties.get("inner")!.type as Model; + expect(type.kind).toBe("Model"); + expect(type.name).toBe("Inner"); + expect(type.expression).toBe(true); + expect(getDoc(program, type)).toBe("the inner"); + }); + + it("applies a decorator to a keyword union expression", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: @doc("the value") union { string, int32 }; + } + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.kind).toBe("Union"); + expect(type.expression).toBe(true); + expect(getDoc(program, type)).toBe("the value"); + }); + + it("applies a decorator to a scalar expression", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + unit: @doc("the unit") scalar extends string; + } + `); + const type = Foo.properties.get("unit")!.type as Scalar; + expect(type.kind).toBe("Scalar"); + expect(type.expression).toBe(true); + expect(getDoc(program, type)).toBe("the unit"); + }); + + it("still rejects a decorator before a non-declaration expression", async () => { + const diagnostics = await Tester.diagnose(` + model Foo { + prop: @doc("nope") string; + } + `); expectDiagnostics(diagnostics, { code: "invalid-decorator-location", message: "Cannot decorate expression.", @@ -393,6 +446,78 @@ describe("decorators", () => { }); }); +describe("augment decorators", () => { + it("can augment a named model declaration expression via ::type", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + inner: model Inner { x: string }; + } + @@doc(Foo.inner::type, "augmented"); + `); + const type = Foo.properties.get("inner")!.type as Model; + expect(type.kind).toBe("Model"); + expect(getDoc(program, type)).toBe("augmented"); + }); + + it("can augment an anonymous enum declaration expression via ::type", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: enum { active, inactive }; + } + @@doc(Foo.status::type, "augmented"); + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(type.kind).toBe("Enum"); + expect(getDoc(program, type)).toBe("augmented"); + }); + + it("can augment a union declaration expression via ::type", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: union { string, int32 }; + } + @@doc(Foo.value::type, "augmented"); + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.kind).toBe("Union"); + expect(getDoc(program, type)).toBe("augmented"); + }); + + it("can augment a scalar declaration expression via ::type", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + unit: scalar Celsius extends int32; + } + @@doc(Foo.unit::type, "augmented"); + `); + const type = Foo.properties.get("unit")!.type as Scalar; + expect(type.kind).toBe("Scalar"); + expect(getDoc(program, type)).toBe("augmented"); + }); + + it("still rejects augmenting an anonymous model expression", async () => { + const diagnostics = await Tester.diagnose(` + alias M = { x: string }; + @@doc(M, "nope"); + `); + expectDiagnostics(diagnostics, { + code: "augment-decorator-target", + message: "Cannot augment model expressions.", + }); + }); + + it("still rejects augmenting a union expression", async () => { + const diagnostics = await Tester.diagnose(` + alias U = string | int32; + @@doc(U, "nope"); + `); + expectDiagnostics(diagnostics, { + code: "augment-decorator-target", + message: "Cannot augment union expressions.", + }); + }); +}); + describe("template parameters are not allowed in expression position", () => { it("reports a diagnostic for a templated model expression", async () => { const diagnostics = await Tester.diagnose(`alias M = model Foo { x: T };`); diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index a834d472587..6ddcbd70bcb 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -1891,6 +1891,62 @@ alias N = model { `, }); }); + + it("keeps a decorator inline on an enum expression", async () => { + await assertFormat({ + code: `alias E = @doc("hi")enum { a, b };`, + expected: ` +alias E = @doc("hi") enum { + a, + b, +}; +`, + }); + }); + + it("keeps a decorator inline on a model expression property", async () => { + await assertFormat({ + code: `model Foo { status: @doc("the status") enum { active, inactive }; }`, + expected: ` +model Foo { + status: @doc("the status") enum { + active, + inactive, + }; +} +`, + }); + }); + + it("keeps a decorator inline on a named model expression", async () => { + await assertFormat({ + code: `alias M = @doc("d")model Inner { x: string };`, + expected: ` +alias M = @doc("d") model Inner { + x: string; +}; +`, + }); + }); + + it("keeps a decorator inline on a union expression", async () => { + await assertFormat({ + code: `alias U = @doc("d")union { string, int32 };`, + expected: ` +alias U = @doc("d") union { + string, + int32, +}; +`, + }); + }); + + it("keeps a decorator inline on a scalar expression", async () => { + await assertFormat({ + code: `alias S = @doc("d")scalar Celsius extends int32;`, + expected: `alias S = @doc("d") scalar Celsius extends int32;`, + }); + }); }); describe("enum", () => { diff --git a/packages/spec/src/spec.emu.html b/packages/spec/src/spec.emu.html index 1a929f5490e..76755b3c4bf 100644 --- a/packages/spec/src/spec.emu.html +++ b/packages/spec/src/spec.emu.html @@ -577,17 +577,17 @@

Syntactic Grammar

`{` ModelBody? `}` ModelDeclarationExpression : - `model` Identifier? TemplateParameters? ExtendsModelHeritage? `{` ModelBody? `}` + DecoratorList? `model` Identifier? TemplateParameters? ExtendsModelHeritage? `{` ModelBody? `}` ScalarDeclarationExpression : - `scalar` Identifier? TemplateParameters? ScalarExtends? - `scalar` Identifier? TemplateParameters? ScalarExtends? `{` ScalarBody? `}` + DecoratorList? `scalar` Identifier? TemplateParameters? ScalarExtends? + DecoratorList? `scalar` Identifier? TemplateParameters? ScalarExtends? `{` ScalarBody? `}` EnumDeclarationExpression : - `enum` Identifier? `{` EnumBody? `}` + DecoratorList? `enum` Identifier? `{` EnumBody? `}` UnionDeclarationExpression : - `union` Identifier? `{` UnionBody? `}` + DecoratorList? `union` Identifier? `{` UnionBody? `}` TupleExpression : `[` ExpressionList? `]` From 0c5744709ac4fede36233c12cbb9dac1e8c99afd Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 26 Jun 2026 10:49:57 -0400 Subject: [PATCH 13/21] feat(compiler): declaration expressions in decorator arguments, doc comments, and formatting Support using model/enum/union/scalar declaration expressions in expression-list positions (decorator arguments, template arguments, function/call arguments, tuples), apply doc comments inline like decorators, and keep multiple inline decorators inline when formatting. Also consolidate the per-commit decl-expr changesets into PR-level entries: one @typespec/compiler feature entry each for declaration expressions, decorators on declaration expressions, doc comments, and the $.enum.create typekit change. --- ...ecl-expr-augment-inline-2026-4-18-0-0-2.md | 15 -- ...eclaration-expressions-2026-4-18-0-0-0.md} | 5 + .../decl-expr-decorators-2026-4-18-0-0-2.md | 16 +++ .../decl-expr-doc-comments-2026-4-18-0-0-3.md | 13 ++ ...-expr-inline-decorators-2026-4-18-0-0-2.md | 14 -- packages/compiler/src/core/parser.ts | 133 ++++++++++++++---- packages/compiler/src/core/scanner.ts | 13 ++ .../compiler/src/formatter/print/printer.ts | 45 +++++- .../checker/declaration-expressions.test.ts | 89 +++++++++++- .../compiler/test/formatter/formatter.test.ts | 67 +++++++++ packages/compiler/test/parser.test.ts | 19 +++ 11 files changed, 369 insertions(+), 60 deletions(-) delete mode 100644 .chronus/changes/decl-expr-augment-inline-2026-4-18-0-0-2.md rename .chronus/changes/{declarations-as-expressions-2026-4-18-0-0-0.md => decl-expr-declaration-expressions-2026-4-18-0-0-0.md} (68%) create mode 100644 .chronus/changes/decl-expr-decorators-2026-4-18-0-0-2.md create mode 100644 .chronus/changes/decl-expr-doc-comments-2026-4-18-0-0-3.md delete mode 100644 .chronus/changes/decl-expr-inline-decorators-2026-4-18-0-0-2.md diff --git a/.chronus/changes/decl-expr-augment-inline-2026-4-18-0-0-2.md b/.chronus/changes/decl-expr-augment-inline-2026-4-18-0-0-2.md deleted file mode 100644 index d3e6aea2253..00000000000 --- a/.chronus/changes/decl-expr-augment-inline-2026-4-18-0-0-2.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -changeKind: feature -packages: - - "@typespec/compiler" ---- - -Allow augment decorators (`@@`) to target `model`, `enum`, `union`, and `scalar` declarations used in expression position (reached via a navigation reference such as `::type`). - -```tsp -model Foo { - status: enum { active, inactive }; -} - -@@doc(Foo.status::type, "the current status"); -``` diff --git a/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md b/.chronus/changes/decl-expr-declaration-expressions-2026-4-18-0-0-0.md similarity index 68% rename from .chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md rename to .chronus/changes/decl-expr-declaration-expressions-2026-4-18-0-0-0.md index 41888209dc6..004ed0606e7 100644 --- a/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md +++ b/.chronus/changes/decl-expr-declaration-expressions-2026-4-18-0-0-0.md @@ -6,6 +6,8 @@ packages: Allow `model`, `enum`, `union`, and `scalar` declarations to be used as expressions. A declaration used in expression position has its corresponding type marked with `expression: true` and is not registered in the enclosing namespace. It may be named or anonymous (in which case its `name` is `""`). +They can be used anywhere an expression is expected, including aliases, model properties, decorator arguments, template arguments, function/call arguments, and tuples. + ```tsp alias Foo = enum { a, @@ -17,4 +19,7 @@ model Bar { unit: scalar extends string; inner: model Inner { x: string }; } + +@Versioning.versioned(enum Versions { v1, v2 }) +namespace MyService; ``` diff --git a/.chronus/changes/decl-expr-decorators-2026-4-18-0-0-2.md b/.chronus/changes/decl-expr-decorators-2026-4-18-0-0-2.md new file mode 100644 index 00000000000..5a9ad1f7fb9 --- /dev/null +++ b/.chronus/changes/decl-expr-decorators-2026-4-18-0-0-2.md @@ -0,0 +1,16 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Allow decorators to be applied to `model`, `enum`, `union`, and `scalar` declarations used in expression position. Inline decorators can be applied directly, and augment decorators (`@@`) can target them through a navigation reference (such as `::type`). + +```tsp +model Foo { + status: @doc("the current status") enum { active, inactive }; + inner: @doc("nested model") model Inner { x: string }; +} + +@@doc(Foo.status::type, "the current status"); +``` diff --git a/.chronus/changes/decl-expr-doc-comments-2026-4-18-0-0-3.md b/.chronus/changes/decl-expr-doc-comments-2026-4-18-0-0-3.md new file mode 100644 index 00000000000..10889dec056 --- /dev/null +++ b/.chronus/changes/decl-expr-doc-comments-2026-4-18-0-0-3.md @@ -0,0 +1,13 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Allow a doc comment to be applied inline to a `model`, `enum`, `union`, or `scalar` declaration expression, just like an inline `@doc` decorator. + +```tsp +model Foo { + status: /** the current status */ enum { active, inactive }; +} +``` diff --git a/.chronus/changes/decl-expr-inline-decorators-2026-4-18-0-0-2.md b/.chronus/changes/decl-expr-inline-decorators-2026-4-18-0-0-2.md deleted file mode 100644 index 9e8d293b263..00000000000 --- a/.chronus/changes/decl-expr-inline-decorators-2026-4-18-0-0-2.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -changeKind: feature -packages: - - "@typespec/compiler" ---- - -Allow decorators to be applied inline to `model`, `enum`, `union`, and `scalar` declarations used in expression position. - -```tsp -model Foo { - status: @doc("the current status") enum { active, inactive }; - inner: @doc("nested model") model Inner { x: string }; -} -``` diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 57d054ddcab..7a1d29ea968 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -6,6 +6,7 @@ import { modifiersToFlags } from "./modifiers.js"; import { createScanner, isComment, + isDeclarationExpressionStatementKeyword, isKeyword, isModifier, isPunctuation, @@ -161,6 +162,12 @@ interface ListKind { readonly toleratedDelimiterIsValid: boolean; readonly invalidAnnotationTarget?: string; readonly allowedStatementKeyword: Token; + /** + * When true, the `model`/`enum`/`union`/`scalar` statement keywords are treated as the + * start of a declaration expression element rather than as a sign that the list ended + * (with a missing close token). Set on lists whose elements are full expressions. + */ + readonly allowDeclarationExpression?: boolean; } interface SurroundedListKind extends ListKind { @@ -194,11 +201,13 @@ namespace ListKind { export const DecoratorArguments = { ...OperationParameters, invalidAnnotationTarget: "expression", + allowDeclarationExpression: true, } as const; export const FunctionArguments = { ...OperationParameters, invalidAnnotationTarget: "expression", + allowDeclarationExpression: true, } as const; export const ModelProperties = { @@ -269,6 +278,7 @@ namespace ListKind { export const TemplateArguments = { ...TemplateParameters, + allowDeclarationExpression: true, } as const; export const CallArguments = { @@ -276,6 +286,7 @@ namespace ListKind { allowEmpty: true, open: Token.OpenParen, close: Token.CloseParen, + allowDeclarationExpression: true, } as const; export const Heritage = { @@ -290,6 +301,7 @@ namespace ListKind { allowEmpty: true, open: Token.OpenBracket, close: Token.CloseBracket, + allowDeclarationExpression: true, } as const; export const ArrayLiteral = { @@ -1825,6 +1837,48 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return base; } + /** + * Parse a declaration used in expression position (`model`/`enum`/`union`/`scalar`). + * {@link pos} should point at the start of the node (the first decorator's `@` when + * {@link decorators} are provided, otherwise the keyword). A leading doc comment + * ({@link docs}) is attached to the node so it is applied just like an `@doc` decorator. + */ + function parseDeclarationExpression( + pos: number, + decorators: DecoratorExpressionNode[] = [], + docs: DocNode[] = [], + ): + | ModelDeclarationExpressionNode + | EnumDeclarationExpressionNode + | UnionDeclarationExpressionNode + | ScalarDeclarationExpressionNode { + let node: + | ModelDeclarationExpressionNode + | EnumDeclarationExpressionNode + | UnionDeclarationExpressionNode + | ScalarDeclarationExpressionNode; + switch (token()) { + case Token.ModelKeyword: + node = parseModelDeclarationExpression(pos, decorators); + break; + case Token.EnumKeyword: + node = parseEnumDeclarationExpression(pos, decorators); + break; + case Token.UnionKeyword: + node = parseUnionDeclarationExpression(pos, decorators); + break; + case Token.ScalarKeyword: + node = parseScalarDeclarationExpression(pos, decorators); + break; + default: + compilerAssert(false, "Expected a declaration expression keyword."); + } + if (docs.length > 0) { + mutate(node).docs = docs; + } + return node; + } + function parsePrimaryExpression(): Expression { while (true) { switch (token()) { @@ -1846,36 +1900,34 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa case Token.OpenBrace: return parseModelExpression(); case Token.ModelKeyword: - return parseModelDeclarationExpression(tokenPos()); case Token.EnumKeyword: - return parseEnumDeclarationExpression(tokenPos()); case Token.UnionKeyword: - return parseUnionDeclarationExpression(tokenPos()); - case Token.ScalarKeyword: - return parseScalarDeclarationExpression(tokenPos()); + case Token.ScalarKeyword: { + // A leading doc comment applies to the declaration expression, just like `@doc`. + // `pos` starts at the doc comment so the resulting node span includes it. + const [pos, docs] = parseDocList(); + return parseDeclarationExpression(pos, [], docs); + } case Token.OpenBracket: return parseTupleExpression(); case Token.OpenParen: return parseParenthesizedExpression(); case Token.At: { - const pos = tokenPos(); + // Capture a doc comment that precedes the decorators before they are parsed + // (parsing decorators advances the scanner and clears the pending doc range). + const [docPos, docs] = parseDocList(); + // `pos` starts at the doc comment (if any) or the `@` so the resulting node + // span includes the docs and decorators. + const pos = docs.length > 0 ? docPos : tokenPos(); const decorators = parseDecoratorList(); // Decorators are only valid in expression position when they decorate a // declaration expression (the keyword forms below). `pos` starts at the `@` // so the resulting node span includes the decorators. - switch (token()) { - case Token.ModelKeyword: - return parseModelDeclarationExpression(pos, decorators); - case Token.EnumKeyword: - return parseEnumDeclarationExpression(pos, decorators); - case Token.UnionKeyword: - return parseUnionDeclarationExpression(pos, decorators); - case Token.ScalarKeyword: - return parseScalarDeclarationExpression(pos, decorators); - default: - reportInvalidDecorators(decorators, "expression"); - continue; + if (isDeclarationExpressionStatementKeyword(token())) { + return parseDeclarationExpression(pos, decorators, docs); } + reportInvalidDecorators(decorators, "expression"); + continue; } case Token.Hash: const directives = parseDirectiveList(); @@ -2377,16 +2429,27 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa if (docRanges.length === 0 || options.docs === false) { return [tokenPos(), []]; } + return [docRanges[0].pos, parseDocRanges(docRanges)]; + } + + /** + * Parse the given doc comment ranges into doc nodes. Unlike {@link parseDocList} this does + * not read the pending {@link docRanges}, so it can be used to attach doc comments that were + * captured earlier (e.g. before a list element's decorators were parsed). + */ + function parseDocRanges(ranges: readonly DocRange[]): DocNode[] { + if (ranges.length === 0 || options.docs === false) { + return []; + } const docs: DocNode[] = []; - for (const range of docRanges) { + for (const range of ranges) { const doc = parseRange(ParseMode.Doc, innerDocRange(range), () => parseDoc(range)); docs.push(doc); if (range.comment) { mutate(range.comment).parsedAsDocs = true; } } - - return [docRanges[0].pos, docs]; + return docs; } function parseDoc(range: TextRange): DocNode { @@ -2776,12 +2839,27 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa while (true) { const startingPos = tokenPos(); + // Capture any pending doc comment before `parseAnnotations` parses (and clears the + // pending range for) decorators, so a doc comment preceding a decorated declaration + // expression element can still be attached to it below. + const pendingDocRanges = kind.allowDeclarationExpression ? docRanges : undefined; const { pos, docs, directives, decorators } = parseAnnotations({ skipParsingDocNodes: Boolean(kind.invalidAnnotationTarget), }); + + // A declaration expression element (e.g. `enum { a }`) can carry decorators (and a + // doc comment) even in an expression list. Attach them to the declaration expression + // instead of rejecting them as an invalid annotation target. + const declarationExpressionWithDecorators = + kind.allowDeclarationExpression && + decorators.length > 0 && + isDeclarationExpressionStatementKeyword(token()); + if (kind.invalidAnnotationTarget) { - reportInvalidDecorators(decorators, kind.invalidAnnotationTarget); reportInvalidDirective(directives, kind.invalidAnnotationTarget); + if (!declarationExpressionWithDecorators) { + reportInvalidDecorators(decorators, kind.invalidAnnotationTarget); + } } if (directives.length === 0 && decorators.length === 0 && atEndOfListWithError(kind)) { @@ -2796,7 +2874,13 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa } let item: T; - if (kind.invalidAnnotationTarget) { + if (declarationExpressionWithDecorators) { + const declDocs = pendingDocRanges ? parseDocRanges(pendingDocRanges) : []; + // When a doc comment precedes the decorators, start the node span at the doc + // comment so the doc nodes stay within the parent node's range. + const declPos = declDocs.length > 0 ? pendingDocRanges![0].pos : pos; + item = parseDeclarationExpression(declPos, decorators, declDocs) as unknown as T; + } else if (kind.invalidAnnotationTarget) { item = (parseItem as ParseListItem)(); } else { item = parseItem(pos, decorators); @@ -2896,7 +2980,8 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return ( kind.close !== Token.None && (isStatementKeyword(token()) || token() === Token.EndOfFile) && - token() !== kind.allowedStatementKeyword + token() !== kind.allowedStatementKeyword && + !(kind.allowDeclarationExpression && isDeclarationExpressionStatementKeyword(token())) ); } diff --git a/packages/compiler/src/core/scanner.ts b/packages/compiler/src/core/scanner.ts index 67fd2454300..9050c0effd7 100644 --- a/packages/compiler/src/core/scanner.ts +++ b/packages/compiler/src/core/scanner.ts @@ -576,6 +576,19 @@ export function isStatementKeyword(token: Token) { return token >= Token.__StartStatementKeyword && token < Token.__EndStatementKeyword; } +/** + * Keywords that introduce a declaration which can also be used in expression position + * (e.g. `model { ... }`, `enum { ... }`, `union { ... }`, `scalar extends ...`). + */ +export function isDeclarationExpressionStatementKeyword(token: Token) { + return ( + token === Token.ModelKeyword || + token === Token.EnumKeyword || + token === Token.UnionKeyword || + token === Token.ScalarKeyword + ); +} + export function createScanner( source: string | SourceFile, diagnosticHandler: DiagnosticHandler, diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 2e46510e556..95b3a30e235 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -500,14 +500,14 @@ export function printDecorators( path: AstPath, options: object, print: PrettierChildPrint, - { tryInline }: { tryInline: boolean }, + { tryInline, forceInline }: { tryInline: boolean; forceInline?: boolean }, ): { decorators: Doc; multiline: boolean } { const node = path.node; if (node.decorators.length === 0) { return { decorators: "", multiline: false }; } - const shouldBreak = shouldDecoratorBreakLine(path, options, { tryInline }); + const shouldBreak = shouldDecoratorBreakLine(path, options, { tryInline, forceInline }); const decorators = path.map((x) => [print(x as any), ifBreak(line, " ")], "decorators"); return { @@ -520,10 +520,17 @@ export function printDecorators( function shouldDecoratorBreakLine( path: AstPath, options: object, - { tryInline }: { tryInline: boolean }, + { tryInline, forceInline }: { tryInline: boolean; forceInline?: boolean }, ) { const node = path.node; + // In expression position the declaration is embedded inline within a larger expression, + // so its decorators are always kept on the same line (breaking them would leave them + // misaligned with the surrounding expression). The width-driven group break still applies. + if (forceInline) { + return false; + } + return ( !tryInline || node.decorators.length >= 3 || hasNewlineBetweenOrAfterDecorators(node, options) ); @@ -590,10 +597,29 @@ export function printDocComments(path: AstPath, options: object, print: Pr return ""; } + if (isDeclarationExpressionNode(node)) { + // A declaration expression is embedded inline within a larger expression, so its doc + // comment is kept on the same line (like an inline decorator) instead of being forced + // onto its own line. + return path.map((x) => [print(x as any), " "], "docs"); + } + const docs = path.map((x) => [print(x as any), line], "docs"); return group([...docs, breakParent]); } +function isDeclarationExpressionNode(node: Node): boolean { + switch (node.kind) { + case SyntaxKind.ModelDeclarationExpression: + case SyntaxKind.EnumDeclarationExpression: + case SyntaxKind.UnionDeclarationExpression: + case SyntaxKind.ScalarDeclarationExpression: + return true; + default: + return false; + } +} + export function printDirectives(path: AstPath, options: object, print: PrettierChildPrint) { const node = path.node; if (node.directives === undefined || node.directives.length === 0) { @@ -696,6 +722,7 @@ export function printEnumStatement( ) { const { decorators } = printDecorators(path, options, print, { tryInline: isInExpressionPosition(path), + forceInline: isInExpressionPosition(path), }); const id = path.node.id ? [" ", path.call(print, "id")] : ""; return [ @@ -752,6 +779,7 @@ export function printUnionStatement( const id = path.node.id ? [" ", path.call(print, "id")] : ""; const { decorators } = printDecorators(path, options, print, { tryInline: isInExpressionPosition(path), + forceInline: isInExpressionPosition(path), }); const generic = printTemplateParameters(path, options, print, "templateParameters"); return [ @@ -1094,8 +1122,12 @@ export function printModelStatement( const nodeHasComments = hasComments(node, CommentCheckFlags.Dangling); const shouldPrintBody = nodeHasComments || !(node.properties.length === 0 && node.is); const body = shouldPrintBody ? [" ", printModelPropertiesBlock(path, options, print)] : ";"; + const inExpressionPosition = isInExpressionPosition(path); return [ - printDecorators(path, options, print, { tryInline: isInExpressionPosition(path) }).decorators, + printDecorators(path, options, print, { + tryInline: inExpressionPosition, + forceInline: inExpressionPosition, + }).decorators, printModifiers(path, options, print), "model", id, @@ -1304,7 +1336,10 @@ function printScalarStatement( ? "" : ";"; return [ - printDecorators(path, options, print, { tryInline: inExpressionPosition }).decorators, + printDecorators(path, options, print, { + tryInline: inExpressionPosition, + forceInline: inExpressionPosition, + }).decorators, printModifiers(path, options, print), "scalar", id, diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts index 731b48d56b8..11f1a301aba 100644 --- a/packages/compiler/test/checker/declaration-expressions.test.ts +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { Enum, Model, Scalar, Union } from "../../src/core/types.js"; +import { Enum, Model, Scalar, Type, Union } from "../../src/core/types.js"; import { getDoc, getTypeName } from "../../src/index.js"; -import { expectDiagnosticEmpty, expectDiagnostics, t } from "../../src/testing/index.js"; +import { expectDiagnosticEmpty, expectDiagnostics, mockFile, t } from "../../src/testing/index.js"; import { Tester } from "../tester.js"; describe("enum", () => { @@ -545,3 +545,88 @@ describe("template parameters are not allowed in expression position", () => { expectDiagnosticEmpty(diagnostics); }); }); +describe("as a decorator argument", () => { + async function captureDecoratorArg(code: string) { + let received: Type | undefined; + const result = await Tester.files({ + "test.js": mockFile.js({ + $useType(_: any, _target: Type, value: Type) { + received = value; + }, + }), + }) + .import("./test.js") + .compile(code); + return { received, program: result.program }; + } + + it("passes an anonymous declaration expression to the decorator", async () => { + const { received } = await captureDecoratorArg(` + extern dec useType(target: unknown, value: unknown); + @useType(enum { red, green }) + model Foo {} + `); + const type = received as Enum; + expect(type.kind).toBe("Enum"); + expect(type.expression).toBe(true); + expect(type.members.has("red")).toBe(true); + }); + + it("passes a named declaration expression to the decorator", async () => { + const { received } = await captureDecoratorArg(` + extern dec useType(target: unknown, value: unknown); + @useType(model Inner { x: string }) + model Foo {} + `); + const type = received as Model; + expect(type.kind).toBe("Model"); + expect(type.name).toBe("Inner"); + expect(type.expression).toBe(true); + expect(type.properties.has("x")).toBe(true); + }); + + it("applies a decorator to a declaration expression used as a decorator argument", async () => { + const { received, program } = await captureDecoratorArg(` + extern dec useType(target: unknown, value: unknown); + @useType(@doc("the versions") enum Versions { v1, v2 }) + model Foo {} + `); + const type = received as Enum; + expect(type.kind).toBe("Enum"); + expect(getDoc(program, type)).toBe("the versions"); + }); +}); + +describe("doc comments", () => { + it("applies an inline doc comment to a declaration expression like @doc", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: /** the status */ enum { active, inactive }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(getDoc(program, type)).toBe("the status"); + }); + + it("lets an explicit @doc override the doc comment on a declaration expression", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: /** comment */ @doc("explicit") enum { active, inactive }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(getDoc(program, type)).toBe("explicit"); + }); + + it("does not apply a leading property doc comment to the declaration expression type", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + /** the status property */ + status: enum { active, inactive }; + } + `); + const prop = Foo.properties.get("status")!; + expect(getDoc(program, prop)).toBe("the status property"); + expect(getDoc(program, prop.type)).toBeUndefined(); + }); +}); diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index 6ddcbd70bcb..e974b82c3e1 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -1947,6 +1947,73 @@ alias U = @doc("d") union { expected: `alias S = @doc("d") scalar Celsius extends int32;`, }); }); + + it("keeps multiple decorators inline on an enum expression", async () => { + await assertFormat({ + code: `alias E = @doc("hi") @example(1) @friendlyName("E") enum { a, b };`, + expected: ` +alias E = @doc("hi") @example(1) @friendlyName("E") enum { + a, + b, +}; +`, + }); + }); + + it("keeps multiple decorators inline on a model expression property", async () => { + await assertFormat({ + code: `model Foo { status: @a @b @c enum { active, inactive }; }`, + expected: ` +model Foo { + status: @a @b @c enum { + active, + inactive, + }; +} +`, + }); + }); + + it("keeps a doc comment inline on an enum expression like a decorator", async () => { + await assertFormat({ + code: `model Foo { status: /** the status */ enum { active, inactive }; }`, + expected: ` +model Foo { + status: /** the status */ enum { + active, + inactive, + }; +} +`, + }); + }); + + it("keeps a doc comment inline before decorators on a declaration expression", async () => { + await assertFormat({ + code: `alias E = /** doc */ @a @b enum { x, y };`, + expected: ` +alias E = /** doc */ @a @b enum { + x, + y, +}; +`, + }); + }); + + it("formats a declaration expression used as a decorator argument", async () => { + await assertFormat({ + code: `@useType(enum Versions { v1, v2 })\nmodel Foo {}`, + expected: ` +@useType( + enum Versions { + v1, + v2, + } +) +model Foo {} +`, + }); + }); }); describe("enum", () => { diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index 3f70e9ca9db..98113c787e8 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -298,6 +298,25 @@ describe("compiler: parser", () => { "model A { value: union { string, int32 } }", // nested declaration expressions "alias N = model { inner: enum { a, b } };", + // in decorator arguments + "@useType(enum Versions { v1, v2 }) namespace N {}", + `@example(model { x: 1 }) model A { x: int32; }`, + "@foo(enum { a, b }, model { x: string }) namespace N {}", + "@foo(scalar extends string) namespace N {}", + "@foo(union { string, int32 }) namespace N {}", + // decorators applied to a declaration expression argument + `@useType(@doc("the versions") enum Versions { v1, v2 }) namespace N {}`, + // doc comment applied to a declaration expression argument + `@useType(/** the versions */ enum Versions { v1, v2 }) namespace N {}`, + // in other expression-list positions + "alias T = [enum { a, b }, model { x: string }];", + "model A { x: Template }", + // inline decorators on a declaration expression + `model A { status: @doc("the status") enum { active, inactive } }`, + `model A { status: @a @b @c enum { active, inactive } }`, + // doc comments on a declaration expression (applied like decorators) + `model A { status: /** the status */ enum { active, inactive } }`, + `alias E = /** doc */ @a enum { a, b };`, ]); // interface and operation are intentionally NOT allowed in expression position From d640056ae90f211eb8dc5329f8b54534496b84e6 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 26 Jun 2026 14:15:46 -0400 Subject: [PATCH 14/21] Refine declaration expressions: parser recovery, typekit, printer, docs - Parser: scope declaration-keyword list termination to element start so an unclosed expression list (e.g. @foo(model X {}) no longer swallows the following statement - Typekit: add optional expression flag to model/union/enum descriptors and key isExpresion/isExpression off type.expression - Printer: use isDeclarationExpressionNode instead of parent-position inference - Docs: add 'In expression position' sections to models, enums, unions, scalars --- packages/compiler/src/core/parser.ts | 31 ++++++++++++++-- .../compiler/src/formatter/print/printer.ts | 27 +++----------- packages/compiler/src/typekit/kits/enum.ts | 14 ++++++- packages/compiler/src/typekit/kits/model.ts | 13 ++++++- packages/compiler/src/typekit/kits/union.ts | 13 ++++++- packages/compiler/test/parser.test.ts | 15 ++++++++ packages/compiler/test/typekit/enum.test.ts | 10 +++++ packages/compiler/test/typekit/model.test.ts | 17 +++++++++ packages/compiler/test/typekit/union.test.ts | 17 +++++++++ .../docs/docs/language-basics/enums.md | 33 +++++++++++++++++ .../docs/docs/language-basics/models.md | 37 +++++++++++++++++++ .../docs/docs/language-basics/scalars.md | 18 +++++++++ .../docs/docs/language-basics/unions.md | 27 ++++++++++++++ 13 files changed, 241 insertions(+), 31 deletions(-) diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 7a1d29ea968..f5f370965ee 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -2862,7 +2862,11 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa } } - if (directives.length === 0 && decorators.length === 0 && atEndOfListWithError(kind)) { + if ( + directives.length === 0 && + decorators.length === 0 && + atEndOfListWithError(kind, { atElementStart: true }) + ) { // Error recovery: end surrounded list at statement keyword or end // of file. Note, however, that we must parse a missing element if // there were directives or decorators as we cannot drop those from @@ -2909,7 +2913,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa // If a list *is* surrounded by punctuation, then the list ends when we // reach the close token. break; - } else if (atEndOfListWithError(kind)) { + } else if (atEndOfListWithError(kind, { atElementStart: false })) { // Error recovery: If a list *is* surrounded by punctuation, then // the list ends at statement keyword or end-of-file under the // assumption that the closing delimiter is missing. This check is @@ -2976,12 +2980,31 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return false; } - function atEndOfListWithError(kind: ListKind) { + /** + * Whether the parser is at a position where the current list (when surrounded by + * punctuation) should be ended under the assumption that its closing token is missing — + * i.e. the current token is a statement keyword or end-of-file. + * + * @param atElementStart Whether we are about to parse a new element (`true`) or have just + * parsed one and found neither a delimiter nor the close token (`false`). A + * declaration-expression keyword (`model`/`enum`/`union`/`scalar`) is a valid element + * start in lists that {@link ListKind.allowDeclarationExpression allow declaration + * expressions}, so it must not end the list at the start of an element. After an element, + * a declaration-expression keyword cannot start a new element (a delimiter is required + * first), so it is treated like any other statement keyword and ends the list — restoring + * error recovery for an unclosed delimiter (e.g. `@foo(model X {}` followed by a + * statement). + */ + function atEndOfListWithError(kind: ListKind, { atElementStart }: { atElementStart: boolean }) { return ( kind.close !== Token.None && (isStatementKeyword(token()) || token() === Token.EndOfFile) && token() !== kind.allowedStatementKeyword && - !(kind.allowDeclarationExpression && isDeclarationExpressionStatementKeyword(token())) + !( + atElementStart && + kind.allowDeclarationExpression && + isDeclarationExpressionStatementKeyword(token()) + ) ); } diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 95b3a30e235..284499f563e 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -721,8 +721,8 @@ export function printEnumStatement( print: PrettierChildPrint, ) { const { decorators } = printDecorators(path, options, print, { - tryInline: isInExpressionPosition(path), - forceInline: isInExpressionPosition(path), + tryInline: isDeclarationExpressionNode(path.node), + forceInline: isDeclarationExpressionNode(path.node), }); const id = path.node.id ? [" ", path.call(print, "id")] : ""; return [ @@ -778,8 +778,8 @@ export function printUnionStatement( ) { const id = path.node.id ? [" ", path.call(print, "id")] : ""; const { decorators } = printDecorators(path, options, print, { - tryInline: isInExpressionPosition(path), - forceInline: isInExpressionPosition(path), + tryInline: isDeclarationExpressionNode(path.node), + forceInline: isDeclarationExpressionNode(path.node), }); const generic = printTemplateParameters(path, options, print, "templateParameters"); return [ @@ -1122,7 +1122,7 @@ export function printModelStatement( const nodeHasComments = hasComments(node, CommentCheckFlags.Dangling); const shouldPrintBody = nodeHasComments || !(node.properties.length === 0 && node.is); const body = shouldPrintBody ? [" ", printModelPropertiesBlock(path, options, print)] : ";"; - const inExpressionPosition = isInExpressionPosition(path); + const inExpressionPosition = isDeclarationExpressionNode(node); return [ printDecorators(path, options, print, { tryInline: inExpressionPosition, @@ -1301,21 +1301,6 @@ function isModelExpressionInBlock(path: AstPath) { } } -function isInExpressionPosition(path: AstPath): boolean { - const parent = path.getParentNode(); - if (parent === null || parent === undefined) { - return false; - } - switch (parent.kind) { - case SyntaxKind.NamespaceStatement: - case SyntaxKind.TypeSpecScript: - case SyntaxKind.JsSourceFile: - return false; - default: - return true; - } -} - function printScalarStatement( path: AstPath, options: TypeSpecPrettierOptions, @@ -1329,7 +1314,7 @@ function printScalarStatement( const nodeHasComments = hasComments(node, CommentCheckFlags.Dangling); const shouldPrintBody = nodeHasComments || !(node.members.length === 0); - const inExpressionPosition = isInExpressionPosition(path); + const inExpressionPosition = isDeclarationExpressionNode(node); const members = shouldPrintBody ? [" ", printScalarBody(path, options, print)] : inExpressionPosition diff --git a/packages/compiler/src/typekit/kits/enum.ts b/packages/compiler/src/typekit/kits/enum.ts index 1867200f1b1..02470a794d3 100644 --- a/packages/compiler/src/typekit/kits/enum.ts +++ b/packages/compiler/src/typekit/kits/enum.ts @@ -12,10 +12,20 @@ import { type UnionKit } from "./union.js"; interface EnumDescriptor { /** * The name of the enum. If a non-empty name is provided, it is an enum - * declaration. An empty string (`""`) produces an enum expression. + * declaration. An empty string (`""`) produces an enum expression unless + * {@link EnumDescriptor.expression} is set explicitly. */ name: string; + /** + * Whether the enum is used in expression position (`expression: true`). When + * omitted, this defaults to `true` for an anonymous enum (empty `name`) and + * `false` for a named one. Set this explicitly to create a *named* enum + * declaration expression (a name that is kept on the type but not registered + * in a namespace). + */ + expression?: boolean; + /** * Decorators to apply to the enum. */ @@ -79,7 +89,7 @@ defineKit({ name: desc.name, decorators: decoratorApplication(this, desc.decorators), members: createRekeyableMap(), - expression: desc.name === "", + expression: desc.expression ?? desc.name === "", }); if (Array.isArray(desc.members)) { diff --git a/packages/compiler/src/typekit/kits/model.ts b/packages/compiler/src/typekit/kits/model.ts index 80dc124abf4..aa92dd47fc9 100644 --- a/packages/compiler/src/typekit/kits/model.ts +++ b/packages/compiler/src/typekit/kits/model.ts @@ -27,6 +27,15 @@ export interface ModelDescriptor { */ name?: string; + /** + * Whether the model is used in expression position (`expression: true`). When + * omitted, this defaults to `true` for an anonymous model (no `name`) and + * `false` for a named one. Set this explicitly to create a *named* model + * declaration expression (a name that is kept on the type but not registered + * in a namespace). + */ + expression?: boolean; + /** * Decorators to apply to the Model. */ @@ -154,7 +163,7 @@ defineKit({ derivedModels: desc.derivedModels ?? [], sourceModels: desc.sourceModels ?? [], indexer: desc.indexer, - expression: desc.name === undefined, + expression: desc.expression ?? desc.name === undefined, }); this.program.checker.finishType(model); @@ -166,7 +175,7 @@ defineKit({ }, isExpresion(type) { - return type.name === ""; + return type.expression; }, getEffectiveModel(model, filter?: (property: ModelProperty) => boolean) { return getEffectiveModelType(this.program, model, filter); diff --git a/packages/compiler/src/typekit/kits/union.ts b/packages/compiler/src/typekit/kits/union.ts index 13f040b34fe..7fb5073d4d6 100644 --- a/packages/compiler/src/typekit/kits/union.ts +++ b/packages/compiler/src/typekit/kits/union.ts @@ -20,6 +20,15 @@ export interface UnionDescriptor { */ name?: string; + /** + * Whether the union is used in expression position (`expression: true`). When + * omitted, this defaults to `true` for an anonymous union (no `name`) and + * `false` for a named one. Set this explicitly to create a *named* union + * declaration expression (a name that is kept on the type but not registered + * in a namespace). + */ + expression?: boolean; + /** * Decorators to apply to the union. */ @@ -154,7 +163,7 @@ export const UnionKit = defineKit({ get options() { return Array.from(this.variants.values()).map((v) => v.type); }, - expression: descriptor.name === undefined, + expression: descriptor.expression ?? descriptor.name === undefined, }); if (Array.isArray(descriptor.variants)) { @@ -238,7 +247,7 @@ export const UnionKit = defineKit({ }, isExpression(type) { - return type.name === undefined || type.name === ""; + return type.expression; }, getDiscriminatedUnion: createDiagnosable(function (type) { return getDiscriminatedUnion(this.program, type); diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index 98113c787e8..00e3c6b832c 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -6,6 +6,7 @@ import { formatDiagnostic } from "../src/core/logger/console-sink.js"; import { hasParseError, parse, visitChildren } from "../src/core/parser.js"; import { IdentifierNode, + ModelStatementNode, Node, ParseOptions, SourceFile, @@ -325,6 +326,20 @@ describe("compiler: parser", () => { ["alias O = op (): void;", [/Keyword cannot be used as identifier/]], ["model A { x: interface {} }", [/Keyword cannot be used as identifier/]], ]); + + it("recovers an unclosed expression list at a following declaration keyword", () => { + // The unclosed `@foo(` must not swallow the following `model After` statement: a + // declaration keyword after an element (with no delimiter) ends the list under the + // assumption that the close token is missing. + const tree = parse(`@foo(model Inner { x: string }\nmodel After {}`); + const modelNames = tree.statements + .filter((s): s is ModelStatementNode => s.kind === SyntaxKind.ModelStatement) + .map((s) => s.id.sv); + ok( + modelNames.includes("After"), + `expected 'After' to be recovered as a statement, got [${modelNames.join(", ")}]`, + ); + }); }); describe("const statements", () => { diff --git a/packages/compiler/test/typekit/enum.test.ts b/packages/compiler/test/typekit/enum.test.ts index df4a804d425..e614302203b 100644 --- a/packages/compiler/test/typekit/enum.test.ts +++ b/packages/compiler/test/typekit/enum.test.ts @@ -65,3 +65,13 @@ it("creates an anonymous enum as an expression (expression: true)", () => { expect(en.name).toBe(""); expect(en.expression).toBe(true); }); + +it("creates a named enum declaration expression when expression is set explicitly", () => { + const en = $(program).enum.create({ + name: "Color", + expression: true, + members: { red: 1, green: 2 }, + }); + expect(en.name).toBe("Color"); + expect(en.expression).toBe(true); +}); diff --git a/packages/compiler/test/typekit/model.test.ts b/packages/compiler/test/typekit/model.test.ts index 779ea13b922..0a044e1cb6f 100644 --- a/packages/compiler/test/typekit/model.test.ts +++ b/packages/compiler/test/typekit/model.test.ts @@ -82,3 +82,20 @@ it("can get diagnostics from getDiscriminatedUnion", async () => { code: "missing-discriminator-property", }); }); + +it("creates a named model declaration (expression: false) by default", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + const model = tk.model.create({ name: "Foo", properties: {} }); + expect(model.expression).toBe(false); + expect(tk.model.isExpresion(model)).toBe(false); +}); + +it("creates a named model declaration expression when expression is set explicitly", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + const model = tk.model.create({ name: "Inner", expression: true, properties: {} }); + expect(model.name).toBe("Inner"); + expect(model.expression).toBe(true); + expect(tk.model.isExpresion(model)).toBe(true); +}); diff --git a/packages/compiler/test/typekit/union.test.ts b/packages/compiler/test/typekit/union.test.ts index 734f44bc087..35910475cc3 100644 --- a/packages/compiler/test/typekit/union.test.ts +++ b/packages/compiler/test/typekit/union.test.ts @@ -177,3 +177,20 @@ it("can check if an entity is a union", async () => { expect(tk.union.is(tk.builtin.string)).toBe(false); expect(tk.union.is(tk.value.create("value"))).toBe(false); }); + +it("creates a named union declaration (expression: false) by default", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + const union = tk.union.create({ name: "Foo", variants: { a: "a" } }); + expect(union.expression).toBe(false); + expect(tk.union.isExpression(union)).toBe(false); +}); + +it("creates a named union declaration expression when expression is set explicitly", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + const union = tk.union.create({ name: "Foo", expression: true, variants: { a: "a" } }); + expect(union.name).toBe("Foo"); + expect(union.expression).toBe(true); + expect(tk.union.isExpression(union)).toBe(true); +}); diff --git a/website/src/content/docs/docs/language-basics/enums.md b/website/src/content/docs/docs/language-basics/enums.md index cdfab7c02da..3ecf0482fcb 100644 --- a/website/src/content/docs/docs/language-basics/enums.md +++ b/website/src/content/docs/docs/language-basics/enums.md @@ -78,3 +78,36 @@ You can reference enum members using the `.` operator for identifiers. ```typespec alias North = Direction.North; ``` + +## In expression position + +The `enum` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. + +```typespec +model Task { + // anonymous enum in expression position + status: enum { + active, + inactive, + }; + + // named enum in expression position + priority: enum Priority { + low, + medium, + high, + }; +} +``` + +An enum used in expression position is marked as an expression and is **not** registered in the enclosing namespace, even when it is given a name. The name is kept on the resulting type for display purposes only — it cannot be referenced elsewhere. + +You can apply [decorators](./decorators.md) and doc comments to the declaration inline, and augment it through a navigation reference such as `::type`: + +```typespec +model Task { + status: @doc("The current status") enum { active, inactive }; +} + +@@doc(Task.status::type, "The current status"); +``` diff --git a/website/src/content/docs/docs/language-basics/models.md b/website/src/content/docs/docs/language-basics/models.md index 23c4eb0b9a6..d28dc09c6f6 100644 --- a/website/src/content/docs/docs/language-basics/models.md +++ b/website/src/content/docs/docs/language-basics/models.md @@ -235,3 +235,40 @@ Some model property meta types can be referenced using `::`. | Name | Example | Description | | ---- | ---------------- | ---------------------------------------- | | type | `Pet.name::type` | Reference the type of the model property | + +## In expression position + +The `model` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. This is different from an inline model expression (`{ ... }`) in that it uses the `model` keyword and can therefore carry a name and an `extends`/`is` heritage clause. + +```typespec +model Widget { + // anonymous model in expression position + config: model { + width: int32; + height: int32; + }; + + // named model in expression position + metadata: model Metadata { + author: string; + }; + + // with a heritage clause + point: model extends Position { + z: float64; + }; +} +``` + +A model used in expression position is marked as an expression and is **not** registered in the enclosing namespace, even when it is given a name. The name is kept on the resulting type for display purposes only — it cannot be referenced elsewhere (and a named model expression cannot reference itself recursively). + +You can apply [decorators](./decorators.md) and doc comments to the declaration inline, and augment it through a navigation reference such as `::type`: + +```typespec +model Widget { + /** The current status */ + status: @doc("The current status") model Status { code: int32 }; +} + +@@doc(Widget.status::type, "The current status"); +``` diff --git a/website/src/content/docs/docs/language-basics/scalars.md b/website/src/content/docs/docs/language-basics/scalars.md index 71e88410e03..96897a18a9e 100644 --- a/website/src/content/docs/docs/language-basics/scalars.md +++ b/website/src/content/docs/docs/language-basics/scalars.md @@ -72,3 +72,21 @@ model Event { time: plainTime = plainTime.now(); } ``` + +## In expression position + +The `scalar` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. + +```typespec +model Measurement { + // anonymous scalar in expression position + temperature: scalar extends float64; + + // named scalar in expression position + distance: scalar Meters extends float64; +} +``` + +A scalar used in expression position is marked as an expression and is **not** registered in the enclosing namespace, even when it is given a name. The name is kept on the resulting type for display purposes only — it cannot be referenced elsewhere. + +You can apply [decorators](./decorators.md) and doc comments to the declaration inline, and augment it through a navigation reference such as `::type`. diff --git a/website/src/content/docs/docs/language-basics/unions.md b/website/src/content/docs/docs/language-basics/unions.md index 8a0bbfbbc84..33accce7a85 100644 --- a/website/src/content/docs/docs/language-basics/unions.md +++ b/website/src/content/docs/docs/language-basics/unions.md @@ -35,3 +35,30 @@ union Breed { ``` The above example is equivalent to the `Breed` alias mentioned earlier, with the difference that emitters can recognize `Breed` as a named entity and also identify the `beagle`, `shepherd`, and `retriever` names for the options. This format also allows the application of [decorators](./decorators.md) to each of the options. + +## Keyword unions in expression position + +The `union` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. Unlike a union expression built with the `|` operator, the keyword form can carry a name and named variants. + +```typespec +model Pet { + // anonymous keyword union in expression position + breed: union { + Beagle, + GermanShepherd, + }; + + // named keyword union in expression position + size: union Size { + small: "S", + medium: "M", + large: "L", + }; +} +``` + +A keyword union used in expression position is marked as an expression and is **not** registered in the enclosing namespace, even when it is given a name. The name is kept on the resulting type for display purposes only — it cannot be referenced elsewhere. + +Unlike the `|` operator, a keyword union used as an operand is **not** flattened into the surrounding union. For example, `union { "a", "b" } | "c"` produces a union of the nested `union { "a", "b" }` and `"c"`, preserving the named variants. + +You can apply [decorators](./decorators.md) and doc comments to the declaration inline, and augment it through a navigation reference such as `::type`. From dbbf0dea7eca3055cd8e9d84139c34c19febb579 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 26 Jun 2026 14:40:40 -0400 Subject: [PATCH 15/21] Gate declaration expressions behind declaration-expressions feature flag Declaration expressions (model/enum/union/scalar in expression position) now emit an experimental-feature warning unless the declaration-expressions feature is enabled in tspconfig.yaml, mirroring the function-declarations flag. - Register the declaration-expressions compiler feature and warning message - Report the warning in the checker for declaration expressions only, deduped so a decl-expression in a template instantiated N times warns once - Allow createTester to take default compilerOptions so tests can enable the feature - Add docs notes and update info/completion expectations --- packages/compiler/src/core/checker.ts | 40 ++++++++++++ packages/compiler/src/core/features.ts | 4 ++ packages/compiler/src/core/messages.ts | 2 + packages/compiler/src/testing/tester.ts | 15 +++-- .../checker/declaration-expressions.test.ts | 65 ++++++++++++++++++- .../test/core/cli/actions/info.test.ts | 3 +- .../test/server/completion.tspconfig.test.ts | 13 ++-- .../docs/docs/language-basics/enums.md | 11 ++++ .../docs/docs/language-basics/models.md | 11 ++++ .../docs/docs/language-basics/scalars.md | 11 ++++ .../docs/docs/language-basics/unions.md | 11 ++++ 11 files changed, 173 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 096f66ffef7..b215909ea15 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -1105,6 +1105,36 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } + function reportDeclarationExpressionFeature( + node: + | ModelStatementNode + | ModelDeclarationExpressionNode + | ScalarStatementNode + | ScalarDeclarationExpressionNode + | EnumStatementNode + | EnumDeclarationExpressionNode + | UnionStatementNode + | UnionDeclarationExpressionNode, + ) { + const isExpression = + node.kind === SyntaxKind.ModelDeclarationExpression || + node.kind === SyntaxKind.ScalarDeclarationExpression || + node.kind === SyntaxKind.EnumDeclarationExpression || + node.kind === SyntaxKind.UnionDeclarationExpression; + if (!isExpression) { + return; + } + if (!isCompilerFeatureEnabled(program, "declaration-expressions", node)) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "experimental-feature", + messageId: "declarationExpressions", + target: node, + }), + ); + } + } + /** * Return a fully qualified id of node */ @@ -5076,6 +5106,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } checkTemplateDeclaration(ctx, node); checkExpressionDeclarationConstraints(node); + if (ctx.mapper === undefined) { + reportDeclarationExpressionFeature(node); + } const decorators: DecoratorApplication[] = []; const type: Model = createType({ @@ -7364,6 +7397,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } checkTemplateDeclaration(ctx, node); checkExpressionDeclarationConstraints(node); + if (ctx.mapper === undefined) { + reportDeclarationExpressionFeature(node); + } const decorators: DecoratorApplication[] = []; @@ -7625,6 +7661,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const links = getSymbolLinks(node.symbol); if (!links.type) { checkModifiers(program, node); + reportDeclarationExpressionFeature(node); const enumType: Enum = (links.type = createType({ kind: "Enum", name: node.id?.sv ?? "", @@ -7816,6 +7853,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } checkTemplateDeclaration(ctx, node); checkExpressionDeclarationConstraints(node); + if (ctx.mapper === undefined) { + reportDeclarationExpressionFeature(node); + } const variants = createRekeyableMap(); const unionType: Union = createType({ diff --git a/packages/compiler/src/core/features.ts b/packages/compiler/src/core/features.ts index f1b2449fa3e..24957b3f9ec 100644 --- a/packages/compiler/src/core/features.ts +++ b/packages/compiler/src/core/features.ts @@ -11,6 +11,10 @@ export const compilerFeatures = { description: "Allows use of function declarations without experimental warnings in project code.", }, + "declaration-expressions": { + description: + "Allows use of declaration expressions (named or anonymous model, scalar, enum and union declarations in expression position) without experimental warnings in project code.", + }, } as const satisfies Record; export type CompilerFeatureName = keyof typeof compilerFeatures; diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 137e03cdb17..e819d4bf255 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -274,6 +274,8 @@ const diagnostics = { default: paramMessage`${"feature"} is an experimental feature. It may change in the future or be removed. Use with caution and consider providing feedback on this feature.`, functionDeclarations: "Function declarations are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", + declarationExpressions: + "Declaration expressions are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", }, }, "using-invalid-ref": { diff --git a/packages/compiler/src/testing/tester.ts b/packages/compiler/src/testing/tester.ts index dd50e0082a1..55a962cddcc 100644 --- a/packages/compiler/src/testing/tester.ts +++ b/packages/compiler/src/testing/tester.ts @@ -38,6 +38,11 @@ import type { export interface TesterOptions { libraries: string[]; + /** + * Default compiler options applied to every compilation. Per-call options are merged on top. + */ + compilerOptions?: CompilerOptions; + /** * System host for loading libraries * @internal @@ -48,6 +53,7 @@ export function createTester(base: string, options: TesterOptions): Tester { return createTesterInternal({ fs: once(() => createTesterFs(base, options)), libraries: options.libraries, + compilerOptions: options.compilerOptions, }); } @@ -417,11 +423,10 @@ async function createTesterInstance(params: TesterInternalParams): Promise { it("can be used as a property type", async () => { @@ -630,3 +643,51 @@ describe("doc comments", () => { expect(getDoc(program, prop.type)).toBeUndefined(); }); }); + +describe("experimental feature flag", () => { + it("reports an experimental-feature warning when the feature is not enabled", async () => { + const diagnostics = await BaseTester.diagnose(` + model Foo { + status: enum { active, inactive }; + } + `); + expectDiagnostics(diagnostics, { + code: "experimental-feature", + severity: "warning", + }); + }); + + it("reports the warning for each kind of declaration expression", async () => { + const diagnostics = await BaseTester.diagnose(` + model Foo { + a: model { x: string }; + b: enum { active, inactive }; + c: union { string, int32 }; + d: scalar myStr extends string; + } + `); + const experimental = diagnostics.filter((d) => d.code === "experimental-feature"); + expect(experimental).toHaveLength(4); + }); + + it("reports the warning only once for a declaration expression in a template instantiated multiple times", async () => { + const diagnostics = await BaseTester.diagnose(` + model Foo { + x: model { y: T }; + } + model A is Foo; + model B is Foo; + `); + const experimental = diagnostics.filter((d) => d.code === "experimental-feature"); + expect(experimental).toHaveLength(1); + }); + + it("does not report the warning when the feature is enabled", async () => { + const diagnostics = await Tester.diagnose(` + model Foo { + status: enum { active, inactive }; + } + `); + expectDiagnosticEmpty(diagnostics); + }); +}); diff --git a/packages/compiler/test/core/cli/actions/info.test.ts b/packages/compiler/test/core/cli/actions/info.test.ts index 244a1b86fc3..2b0a6b57121 100644 --- a/packages/compiler/test/core/cli/actions/info.test.ts +++ b/packages/compiler/test/core/cli/actions/info.test.ts @@ -21,7 +21,8 @@ describe("formatCompilerFeatures", () => { expect(output).toEqual([ "Compiler Features", "", - " enabled function-declarations Allows use of function declarations without experimental warnings in project code.", + " enabled function-declarations Allows use of function declarations without experimental warnings in project code.", + " disabled declaration-expressions Allows use of declaration expressions (named or anonymous model, scalar, enum and union declarations in expression position) without experimental warnings in project code.", ]); }); }); diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index e7b58e43bab..22b44b8f00e 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -134,19 +134,19 @@ describe("Test completion items for features", () => { it.each([ { config: `features:\n - ┆`, - expected: ['"function-declarations"'], + expected: ['"declaration-expressions"', '"function-declarations"'], }, { config: `features:\n - "┆"`, - expected: ["function-declarations"], + expected: ["declaration-expressions", "function-declarations"], }, { config: `features:\n - "function┆"`, - expected: ["function-declarations"], + expected: ["declaration-expressions", "function-declarations"], }, { config: `features:\n - function-declarations\n - ┆`, - expected: [], + expected: ['"declaration-expressions"'], }, ])("#%# Test features: $config", async ({ config, expected }) => { await checkCompletionItems(config, true, expected); @@ -156,7 +156,10 @@ describe("Test completion items for features", () => { await checkCompletionItems( `features:\n - ┆`, true, - ["Allows use of function declarations without experimental warnings in project code."], + [ + "Allows use of declaration expressions (named or anonymous model, scalar, enum and union declarations in expression position) without experimental warnings in project code.", + "Allows use of function declarations without experimental warnings in project code.", + ], true, ); }); diff --git a/website/src/content/docs/docs/language-basics/enums.md b/website/src/content/docs/docs/language-basics/enums.md index 3ecf0482fcb..a22a6a7c296 100644 --- a/website/src/content/docs/docs/language-basics/enums.md +++ b/website/src/content/docs/docs/language-basics/enums.md @@ -81,6 +81,17 @@ alias North = Direction.North; ## In expression position +:::warning +Declaration expressions are an experimental TypeSpec feature. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position yields an `experimental-feature` warning. Enable them without the warning by adding `declaration-expressions` to the `features` list in your `tspconfig.yaml`: + +```yaml +kind: project +features: + - declaration-expressions +``` + +::: + The `enum` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. ```typespec diff --git a/website/src/content/docs/docs/language-basics/models.md b/website/src/content/docs/docs/language-basics/models.md index d28dc09c6f6..bcee93d66e5 100644 --- a/website/src/content/docs/docs/language-basics/models.md +++ b/website/src/content/docs/docs/language-basics/models.md @@ -238,6 +238,17 @@ Some model property meta types can be referenced using `::`. ## In expression position +:::warning +Declaration expressions are an experimental TypeSpec feature. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position yields an `experimental-feature` warning. Enable them without the warning by adding `declaration-expressions` to the `features` list in your `tspconfig.yaml`: + +```yaml +kind: project +features: + - declaration-expressions +``` + +::: + The `model` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. This is different from an inline model expression (`{ ... }`) in that it uses the `model` keyword and can therefore carry a name and an `extends`/`is` heritage clause. ```typespec diff --git a/website/src/content/docs/docs/language-basics/scalars.md b/website/src/content/docs/docs/language-basics/scalars.md index 96897a18a9e..2e60c2092d6 100644 --- a/website/src/content/docs/docs/language-basics/scalars.md +++ b/website/src/content/docs/docs/language-basics/scalars.md @@ -75,6 +75,17 @@ model Event { ## In expression position +:::warning +Declaration expressions are an experimental TypeSpec feature. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position yields an `experimental-feature` warning. Enable them without the warning by adding `declaration-expressions` to the `features` list in your `tspconfig.yaml`: + +```yaml +kind: project +features: + - declaration-expressions +``` + +::: + The `scalar` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. ```typespec diff --git a/website/src/content/docs/docs/language-basics/unions.md b/website/src/content/docs/docs/language-basics/unions.md index 33accce7a85..eba92b1a984 100644 --- a/website/src/content/docs/docs/language-basics/unions.md +++ b/website/src/content/docs/docs/language-basics/unions.md @@ -38,6 +38,17 @@ The above example is equivalent to the `Breed` alias mentioned earlier, with the ## Keyword unions in expression position +:::warning +Declaration expressions are an experimental TypeSpec feature. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position yields an `experimental-feature` warning. Enable them without the warning by adding `declaration-expressions` to the `features` list in your `tspconfig.yaml`: + +```yaml +kind: project +features: + - declaration-expressions +``` + +::: + The `union` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. Unlike a union expression built with the `|` operator, the keyword form can carry a name and named variants. ```typespec From 8495a488aa5355308db350a3faaf501c51e912f3 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 29 Jun 2026 14:19:32 -0400 Subject: [PATCH 16/21] Fix model is issue --- packages/compiler/src/core/checker.ts | 100 ++++++++++-------- packages/compiler/src/core/parser.ts | 26 +++-- .../compiler/src/formatter/print/printer.ts | 6 +- .../checker/declaration-expressions.test.ts | 60 +++++++++++ .../compiler/test/formatter/formatter.test.ts | 18 ++++ packages/compiler/test/parser.test.ts | 5 + 6 files changed, 163 insertions(+), 52 deletions(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index b215909ea15..f57aac5cbc9 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -7659,61 +7659,71 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node: EnumStatementNode | EnumDeclarationExpressionNode, ): Type { const links = getSymbolLinks(node.symbol); - if (!links.type) { + + if (links.declaredType && ctx.mapper === undefined) { + // we're not instantiating this enum and we've already checked it + return links.declaredType as Enum; + } + + if (ctx.mapper === undefined) { checkModifiers(program, node); reportDeclarationExpressionFeature(node); - const enumType: Enum = (links.type = createType({ - kind: "Enum", - name: node.id?.sv ?? "", - node, - members: createRekeyableMap(), - decorators: [], - expression: node.kind === SyntaxKind.EnumDeclarationExpression, - })); + } - const memberNames = new Set(); + const enumType: Enum = createType({ + kind: "Enum", + name: node.id?.sv ?? "", + node, + members: createRekeyableMap(), + decorators: [], + expression: node.kind === SyntaxKind.EnumDeclarationExpression, + }); + // Link the type before resolving the parent namespace: resolving the namespace may run + // its decorators which can reference this enum, and we must not create a second instance. + linkType(ctx, links, enumType); - for (const member of node.members) { - if (member.kind === SyntaxKind.EnumMember) { - const memberType = checkEnumMember(ctx, member, enumType); - if (memberNames.has(memberType.name)) { - reportCheckerDiagnostic( - createDiagnostic({ - code: "enum-member-duplicate", - format: { name: memberType.name }, - target: node, - }), - ); - continue; - } - memberNames.add(memberType.name); - enumType.members.set(memberType.name, memberType); - } else { - const members = checkEnumSpreadMember( - ctx, - node.symbol, - enumType, - member.target, - memberNames, + const memberNames = new Set(); + + for (const member of node.members) { + if (member.kind === SyntaxKind.EnumMember) { + const memberType = checkEnumMember(ctx, member, enumType); + if (memberNames.has(memberType.name)) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "enum-member-duplicate", + format: { name: memberType.name }, + target: node, + }), ); - for (const memberType of members) { - linkIndirectMember(ctx, node, memberType); - enumType.members.set(memberType.name, memberType); - } + continue; + } + memberNames.add(memberType.name); + enumType.members.set(memberType.name, memberType); + } else { + const members = checkEnumSpreadMember( + ctx, + node.symbol, + enumType, + member.target, + memberNames, + ); + for (const memberType of members) { + linkIndirectMember(ctx, node, memberType); + enumType.members.set(memberType.name, memberType); } } + } - const namespace = getParentNamespaceType(node); - enumType.namespace = namespace; - if (!enumType.expression) { - enumType.namespace?.enums.set(enumType.name!, enumType); - } - enumType.decorators = checkDecorators(ctx, enumType, node); - linkMapper(enumType, ctx.mapper); - finishType(enumType); + enumType.namespace = getParentNamespaceType(node); + if (ctx.mapper === undefined && !enumType.expression) { + enumType.namespace?.enums.set(enumType.name!, enumType); } + enumType.decorators = checkDecorators(ctx, enumType, node); + linkMapper(enumType, ctx.mapper); - return links.type; + return finishType(enumType, { + skipDecorators: ctx.hasFlags(CheckFlags.InTemplateDeclaration), + }); } function checkInterface(ctx: CheckContext, node: InterfaceStatementNode): Interface { diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index f5f370965ee..97aa3821cd1 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -970,7 +970,12 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const { items: templateParameters, range: templateParametersRange } = parseTemplateParameterList(); - const { extends: optionalExtends, is: optionalIs, properties, bodyRange } = parseModelBody(); + const { + extends: optionalExtends, + is: optionalIs, + properties, + bodyRange, + } = parseModelBody(true); return { kind: SyntaxKind.ModelDeclarationExpression, @@ -988,7 +993,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } - function parseModelBody(): { + function parseModelBody(inExpressionPosition = false): { extends?: Expression; is?: Expression; properties: readonly (ModelPropertyNode | ModelSpreadPropertyNode)[]; @@ -1003,11 +1008,20 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa ModelPropertyNode | ModelSpreadPropertyNode >(); if (optionalIs) { - const tok = expectTokenIsOneOf(Token.Semicolon, Token.OpenBrace); - if (tok === Token.Semicolon) { - nextToken(); + if (inExpressionPosition) { + // In expression position there is no `;` terminator (it belongs to the enclosing + // construct): only parse a `{ ... }` body when present, otherwise the model has no + // own properties. + if (token() === Token.OpenBrace) { + propDetail = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); + } } else { - propDetail = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); + const tok = expectTokenIsOneOf(Token.Semicolon, Token.OpenBrace); + if (tok === Token.Semicolon) { + nextToken(); + } else { + propDetail = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); + } } } else { propDetail = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 284499f563e..b042dd35b0e 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -1121,8 +1121,12 @@ export function printModelStatement( const generic = printTemplateParameters(path, options, print, "templateParameters"); const nodeHasComments = hasComments(node, CommentCheckFlags.Dangling); const shouldPrintBody = nodeHasComments || !(node.properties.length === 0 && node.is); - const body = shouldPrintBody ? [" ", printModelPropertiesBlock(path, options, print)] : ";"; const inExpressionPosition = isDeclarationExpressionNode(node); + const body = shouldPrintBody + ? [" ", printModelPropertiesBlock(path, options, print)] + : inExpressionPosition + ? "" + : ";"; return [ printDecorators(path, options, print, { tryInline: inExpressionPosition, diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts index 77201adaeae..548f0cf1a46 100644 --- a/packages/compiler/test/checker/declaration-expressions.test.ts +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -187,6 +187,46 @@ describe("model", () => { expect(ns.models.size).toBe(1); expect(ns.models.has("Foo")).toBe(true); }); + + it("supports an `is` heritage clause with no body", async () => { + // Regression: an `is` clause with no `{ }` body in expression position must not consume + // the enclosing `;` (which belongs to the alias / property). + const { Foo } = await Tester.compile(t.code` + model Base { a: string; b: int32 } + model ${t.model("Foo")} { + value: model is Base; + } + `); + const type = Foo.properties.get("value")!.type as Model; + expect(type.kind).toBe("Model"); + expect(type.expression).toBe(true); + expect(type.properties.has("a")).toBe(true); + expect(type.properties.has("b")).toBe(true); + }); + + it("supports an `is` heritage clause followed by more properties", async () => { + const { Foo } = await Tester.compile(t.code` + model Base { a: string } + model ${t.model("Foo")} { + value: model is Base; + other: string; + } + `); + expect((Foo.properties.get("value")!.type as Model).properties.has("a")).toBe(true); + expect(Foo.properties.get("other")!.type.kind).toBe("Scalar"); + }); + + it("supports an `is` heritage clause with an additional body", async () => { + const { Foo } = await Tester.compile(t.code` + model Base { a: string } + model ${t.model("Foo")} { + value: model is Base { extra: int32 }; + } + `); + const type = Foo.properties.get("value")!.type as Model; + expect(type.properties.has("a")).toBe(true); + expect(type.properties.has("extra")).toBe(true); + }); }); describe("named declaration expressions", () => { @@ -691,3 +731,23 @@ describe("experimental feature flag", () => { expectDiagnosticEmpty(diagnostics); }); }); + +describe("inside a template", () => { + it("resolves an enum expression decorator argument per instantiation", async () => { + // Regression: an enum declaration expression must be re-checked per template + // instantiation so a decorator that references the template parameter is resolved + // (rather than being frozen at declaration time with the raw template parameter). + const { A, B, program } = await Tester.compile(t.code` + model Box { + e: @doc(T) enum { a }; + } + model ${t.model("A")} is Box<"alpha"> {} + model ${t.model("B")} is Box<"beta"> {} + `); + const aEnum = A.properties.get("e")!.type as Enum; + const bEnum = B.properties.get("e")!.type as Enum; + expect(getDoc(program, aEnum)).toBe("alpha"); + expect(getDoc(program, bEnum)).toBe("beta"); + expect(aEnum).not.toBe(bEnum); + }); +}); diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index e974b82c3e1..ffc5db21699 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -1834,6 +1834,24 @@ alias M = model { }); }); + it("formats a model `is` expression without double semicolon", async () => { + await assertFormat({ + code: `alias M = model is Base;`, + expected: `alias M = model is Base;`, + }); + }); + + it("formats a model `is` expression with a body", async () => { + await assertFormat({ + code: `alias M = model is Base { x: string };`, + expected: ` +alias M = model is Base { + x: string; +}; +`, + }); + }); + it("formats named declaration expression", async () => { await assertFormat({ code: `model Foo { nested: model Inner { x: string }; }`, diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index 00e3c6b832c..674b535ba41 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -292,6 +292,11 @@ describe("compiler: parser", () => { // named keyword declarations in expression position "alias NE = enum Color { red, green };", "alias NM = model Inner { x: string };", + // `is` heritage clause in expression position (must not consume the enclosing `;`) + "alias MI = model is Base;", + "alias MIB = model is Base { extra: int32 };", + "model A { value: model is Base }", + "model A { value: model is Base; other: string }", // nested in model properties "model A { status: enum { active, inactive } }", "model A { value: model { x: string } }", From f7822f311546b408b141183ddc4dda640cd5dc4e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 30 Jun 2026 08:33:40 -0400 Subject: [PATCH 17/21] feat(compiler): break and indent decorators/doc comments on wide declaration expressions When a declaration expression's inline form exceeds the print width, doc comments and decorators now break onto their own lines and the whole block is indented one level. --- .../decl-expr-formatting-2026-4-18-0-0-4.md | 19 +++ .../compiler/src/formatter/print/printer.ts | 113 +++++++++++++----- .../compiler/test/formatter/formatter.test.ts | 66 ++++++++++ 3 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 .chronus/changes/decl-expr-formatting-2026-4-18-0-0-4.md diff --git a/.chronus/changes/decl-expr-formatting-2026-4-18-0-0-4.md b/.chronus/changes/decl-expr-formatting-2026-4-18-0-0-4.md new file mode 100644 index 00000000000..f6745b8413f --- /dev/null +++ b/.chronus/changes/decl-expr-formatting-2026-4-18-0-0-4.md @@ -0,0 +1,19 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Improve formatting of declaration expressions (`model`, `enum`, `union`, and `scalar` used in expression position) that carry doc comments and/or decorators. When the inline form would exceed the print width, the doc comments and decorators are now each placed on their own line and the whole block is indented one level instead of overflowing. + +```tsp +model Foo { + status: + @summary("a fairly long summary text here") + @example("some-default-example-value") + enum { + active, + inactive, + }; +} +``` diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index b042dd35b0e..93e48796f3d 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -74,6 +74,7 @@ import { util } from "./util.js"; const { align, breakParent, + conditionalGroup, group, hardline, ifBreak, @@ -111,6 +112,11 @@ export function printTypeSpec( print: PrettierChildPrint, ): Doc { const node = path.node; + if (isDeclarationExpressionNode(node)) { + // Declaration expressions own the layout of their doc comments and decorators so + // they can break (and indent) together with the declaration when too wide. + return printDeclarationExpression(path, options, print); + } const docs = printDocComments(path, options, print); const directives = shouldPrintDirective(node) ? printDirectives(path, options, print) : ""; const printedNode = printNode(path, options, print); @@ -125,6 +131,61 @@ export function printTypeSpec( return parts; } +/** + * Print a declaration expression (`model`/`enum`/`union`/`scalar` used in expression + * position) together with its doc comments and decorators. + * + * When everything fits on the line the doc comments and decorators are kept inline + * (e.g. `@a @b enum { ... }`). When it does not fit, each doc comment and decorator is + * placed on its own line and the whole block is indented one level relative to the + * surrounding expression. + * + * The declaration body always breaks (it contains hardlines), so a plain `group` would + * always break. A `conditionalGroup` is used instead so the break decision is based on + * the width of the first line only. + */ +function printDeclarationExpression( + path: AstPath, + options: TypeSpecPrettierOptions, + print: PrettierChildPrint, +): Doc { + const node = path.node; + const bare = printNode(path, options, print); + + const docs = node.docs?.length ? path.map((x) => print(x as any), "docs") : []; + const decorators = (node as DecorableNode).decorators?.length + ? path.map((x) => print(x as any), "decorators") + : []; + const prefix = [...docs, ...decorators]; + + // No doc comments or decorators: nothing to lay out, print the bare declaration. + if (prefix.length === 0) { + return bare; + } + + const inline = join(" ", [...prefix, bare]); + + // In argument position (e.g. a decorator argument) the enclosing parentheses already + // provide the line break and indentation, so the block must not add its own. + const broken = isInArgumentPosition(path) + ? join(hardline, [...prefix, bare]) + : indent([hardline, join(hardline, [...prefix, bare])]); + + return conditionalGroup([inline, broken]); +} + +function isInArgumentPosition(path: AstPath): boolean { + const parent = path.getParentNode() as Node | null; + switch (parent?.kind) { + case SyntaxKind.DecoratorExpression: + case SyntaxKind.AugmentDecoratorStatement: + case SyntaxKind.CallExpression: + return true; + default: + return false; + } +} + function shouldPrintDirective(node: Node) { // Model property handle printing directive itself. return node.kind !== SyntaxKind.ModelProperty; @@ -500,14 +561,14 @@ export function printDecorators( path: AstPath, options: object, print: PrettierChildPrint, - { tryInline, forceInline }: { tryInline: boolean; forceInline?: boolean }, + { tryInline }: { tryInline: boolean }, ): { decorators: Doc; multiline: boolean } { const node = path.node; if (node.decorators.length === 0) { return { decorators: "", multiline: false }; } - const shouldBreak = shouldDecoratorBreakLine(path, options, { tryInline, forceInline }); + const shouldBreak = shouldDecoratorBreakLine(path, options, { tryInline }); const decorators = path.map((x) => [print(x as any), ifBreak(line, " ")], "decorators"); return { @@ -520,17 +581,10 @@ export function printDecorators( function shouldDecoratorBreakLine( path: AstPath, options: object, - { tryInline, forceInline }: { tryInline: boolean; forceInline?: boolean }, + { tryInline }: { tryInline: boolean }, ) { const node = path.node; - // In expression position the declaration is embedded inline within a larger expression, - // so its decorators are always kept on the same line (breaking them would leave them - // misaligned with the surrounding expression). The width-driven group break still applies. - if (forceInline) { - return false; - } - return ( !tryInline || node.decorators.length >= 3 || hasNewlineBetweenOrAfterDecorators(node, options) ); @@ -597,13 +651,6 @@ export function printDocComments(path: AstPath, options: object, print: Pr return ""; } - if (isDeclarationExpressionNode(node)) { - // A declaration expression is embedded inline within a larger expression, so its doc - // comment is kept on the same line (like an inline decorator) instead of being forced - // onto its own line. - return path.map((x) => [print(x as any), " "], "docs"); - } - const docs = path.map((x) => [print(x as any), line], "docs"); return group([...docs, breakParent]); } @@ -720,10 +767,10 @@ export function printEnumStatement( options: TypeSpecPrettierOptions, print: PrettierChildPrint, ) { - const { decorators } = printDecorators(path, options, print, { - tryInline: isDeclarationExpressionNode(path.node), - forceInline: isDeclarationExpressionNode(path.node), - }); + const inExpressionPosition = isDeclarationExpressionNode(path.node); + const decorators = inExpressionPosition + ? "" + : printDecorators(path, options, print, { tryInline: false }).decorators; const id = path.node.id ? [" ", path.call(print, "id")] : ""; return [ decorators, @@ -776,11 +823,11 @@ export function printUnionStatement( options: TypeSpecPrettierOptions, print: PrettierChildPrint, ) { + const inExpressionPosition = isDeclarationExpressionNode(path.node); const id = path.node.id ? [" ", path.call(print, "id")] : ""; - const { decorators } = printDecorators(path, options, print, { - tryInline: isDeclarationExpressionNode(path.node), - forceInline: isDeclarationExpressionNode(path.node), - }); + const decorators = inExpressionPosition + ? "" + : printDecorators(path, options, print, { tryInline: false }).decorators; const generic = printTemplateParameters(path, options, print, "templateParameters"); return [ decorators, @@ -1127,11 +1174,11 @@ export function printModelStatement( : inExpressionPosition ? "" : ";"; + const decorators = inExpressionPosition + ? "" + : printDecorators(path, options, print, { tryInline: false }).decorators; return [ - printDecorators(path, options, print, { - tryInline: inExpressionPosition, - forceInline: inExpressionPosition, - }).decorators, + decorators, printModifiers(path, options, print), "model", id, @@ -1324,11 +1371,11 @@ function printScalarStatement( : inExpressionPosition ? "" : ";"; + const decorators = inExpressionPosition + ? "" + : printDecorators(path, options, print, { tryInline: false }).decorators; return [ - printDecorators(path, options, print, { - tryInline: inExpressionPosition, - forceInline: inExpressionPosition, - }).decorators, + decorators, printModifiers(path, options, print), "scalar", id, diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index ffc5db21699..1291c3c0d9b 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -2029,6 +2029,72 @@ alias E = /** doc */ @a @b enum { } ) model Foo {} +`, + }); + }); + + it("breaks and indents decorators on a model property when too wide", async () => { + await assertFormat({ + code: `model Foo { status: @summary("a fairly long summary text here") @example("some-default-example-value") enum { active, inactive }; }`, + expected: ` +model Foo { + status: + @summary("a fairly long summary text here") + @example("some-default-example-value") + enum { + active, + inactive, + }; +} +`, + }); + }); + + it("breaks and indents decorators on an alias value when too wide", async () => { + await assertFormat({ + code: `alias E = @summary("a fairly long summary text goes here now") @example("some-default-value") enum { a, b };`, + expected: ` +alias E = + @summary("a fairly long summary text goes here now") + @example("some-default-value") + enum { + a, + b, + }; +`, + }); + }); + + it("breaks and indents a doc comment together with decorators when too wide", async () => { + await assertFormat({ + code: `model Foo { status: /** the current lifecycle status of the entity */ @example("active") enum { active, inactive }; }`, + expected: ` +model Foo { + status: + /** the current lifecycle status of the entity */ + @example("active") + enum { + active, + inactive, + }; +} +`, + }); + }); + + it("stacks decorators inside a decorator argument when too wide", async () => { + await assertFormat({ + code: `@useType(@summary("a long summary for the versions enum value here") @example("v1") enum Versions { v1, v2 })\nmodel Foo {}`, + expected: ` +@useType( + @summary("a long summary for the versions enum value here") + @example("v1") + enum Versions { + v1, + v2, + } +) +model Foo {} `, }); }); From 0bfae196172a29237c6840ce7b61cc670d6577b7 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 30 Jun 2026 13:10:57 -0400 Subject: [PATCH 18/21] Format --- .../tsp/arm-customization.tsp | 6 ++---- .../generator/http-client-generator-test/tsp/arm.tsp | 11 ++++++----- .../http-client-generator-test/tsp/response.tsp | 5 +++-- .../content/docs/release-notes/typespec-1-11-0.mdx | 11 ++++++----- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/http-client-java/generator/http-client-generator-test/tsp/arm-customization.tsp b/packages/http-client-java/generator/http-client-generator-test/tsp/arm-customization.tsp index fe67b54a891..b964ba2e376 100644 --- a/packages/http-client-java/generator/http-client-generator-test/tsp/arm-customization.tsp +++ b/packages/http-client-java/generator/http-client-generator-test/tsp/arm-customization.tsp @@ -23,10 +23,8 @@ enum Versions { v2023_12_01_preview: "2023-12-01-preview", } -model Vault is Azure.ResourceManager.Legacy.TrackedResourceWithOptionalLocation< - VaultProperties, - false -> { +model Vault + is Azure.ResourceManager.Legacy.TrackedResourceWithOptionalLocation { ...ResourceNameParameter< Resource = Vault, KeyName = "vaultName", diff --git a/packages/http-client-java/generator/http-client-generator-test/tsp/arm.tsp b/packages/http-client-java/generator/http-client-generator-test/tsp/arm.tsp index 501cbd428ce..f1b0c1d62d5 100644 --- a/packages/http-client-java/generator/http-client-generator-test/tsp/arm.tsp +++ b/packages/http-client-java/generator/http-client-generator-test/tsp/arm.tsp @@ -389,11 +389,12 @@ interface ImmutableResourceModel { Azure.ResourceManager.Foundations.DefaultBaseParameters >, NginxConfigurationRequest, - | NginxConfigurationResponse - | ArmResourceCreatedResponse< - NginxConfigurationResponse, - LroHeaders = ArmAsyncOperationHeader - >, + + | NginxConfigurationResponse + | ArmResourceCreatedResponse< + NginxConfigurationResponse, + LroHeaders = ArmAsyncOperationHeader + >, ErrorResponse, OptionalRequestBody = true >; diff --git a/packages/http-client-java/generator/http-client-generator-test/tsp/response.tsp b/packages/http-client-java/generator/http-client-generator-test/tsp/response.tsp index d2918bc3a64..05e89b8d150 100644 --- a/packages/http-client-java/generator/http-client-generator-test/tsp/response.tsp +++ b/packages/http-client-java/generator/http-client-generator-test/tsp/response.tsp @@ -26,8 +26,9 @@ op RpcOperationWithAdditionalResponse< TErrorResponse = Azure.Core.Foundations.ErrorResponse > is Foundations.Operation< TParams & Azure.Core.Traits.Private.TraitProperties, - | (TResponse & Azure.Core.Traits.Private.TraitProperties) - | TAdditionalResponse, + + | (TResponse & Azure.Core.Traits.Private.TraitProperties) + | TAdditionalResponse, Traits, TErrorResponse >; diff --git a/website/src/content/docs/release-notes/typespec-1-11-0.mdx b/website/src/content/docs/release-notes/typespec-1-11-0.mdx index a28beb27fda..23ed7633339 100644 --- a/website/src/content/docs/release-notes/typespec-1-11-0.mdx +++ b/website/src/content/docs/release-notes/typespec-1-11-0.mdx @@ -31,11 +31,12 @@ model Example { description: string; } -model CreateAndReadExample is FilterVisibility< - Example, - #{ all: #[Lifecycle.Create, Lifecycle.Read] }, - "CreateAndRead{name}" ->; +model CreateAndReadExample + is FilterVisibility< + Example, + #{ all: #[Lifecycle.Create, Lifecycle.Read] }, + "CreateAndRead{name}" + >; ``` :::caution From 7ff1415996b02f5a2e0d839d06608c52bbba3cad Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 30 Jun 2026 13:24:10 -0400 Subject: [PATCH 19/21] Format --- .../tsp/arm-customization.tsp | 6 ++++-- .../generator/http-client-generator-test/tsp/arm.tsp | 11 +++++------ .../http-client-generator-test/tsp/response.tsp | 5 ++--- .../src/content/docs/docs/language-basics/enums.md | 5 ++++- .../src/content/docs/docs/language-basics/models.md | 4 +++- .../content/docs/release-notes/typespec-1-11-0.mdx | 11 +++++------ 6 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/http-client-java/generator/http-client-generator-test/tsp/arm-customization.tsp b/packages/http-client-java/generator/http-client-generator-test/tsp/arm-customization.tsp index b964ba2e376..fe67b54a891 100644 --- a/packages/http-client-java/generator/http-client-generator-test/tsp/arm-customization.tsp +++ b/packages/http-client-java/generator/http-client-generator-test/tsp/arm-customization.tsp @@ -23,8 +23,10 @@ enum Versions { v2023_12_01_preview: "2023-12-01-preview", } -model Vault - is Azure.ResourceManager.Legacy.TrackedResourceWithOptionalLocation { +model Vault is Azure.ResourceManager.Legacy.TrackedResourceWithOptionalLocation< + VaultProperties, + false +> { ...ResourceNameParameter< Resource = Vault, KeyName = "vaultName", diff --git a/packages/http-client-java/generator/http-client-generator-test/tsp/arm.tsp b/packages/http-client-java/generator/http-client-generator-test/tsp/arm.tsp index f1b0c1d62d5..501cbd428ce 100644 --- a/packages/http-client-java/generator/http-client-generator-test/tsp/arm.tsp +++ b/packages/http-client-java/generator/http-client-generator-test/tsp/arm.tsp @@ -389,12 +389,11 @@ interface ImmutableResourceModel { Azure.ResourceManager.Foundations.DefaultBaseParameters >, NginxConfigurationRequest, - - | NginxConfigurationResponse - | ArmResourceCreatedResponse< - NginxConfigurationResponse, - LroHeaders = ArmAsyncOperationHeader - >, + | NginxConfigurationResponse + | ArmResourceCreatedResponse< + NginxConfigurationResponse, + LroHeaders = ArmAsyncOperationHeader + >, ErrorResponse, OptionalRequestBody = true >; diff --git a/packages/http-client-java/generator/http-client-generator-test/tsp/response.tsp b/packages/http-client-java/generator/http-client-generator-test/tsp/response.tsp index 05e89b8d150..d2918bc3a64 100644 --- a/packages/http-client-java/generator/http-client-generator-test/tsp/response.tsp +++ b/packages/http-client-java/generator/http-client-generator-test/tsp/response.tsp @@ -26,9 +26,8 @@ op RpcOperationWithAdditionalResponse< TErrorResponse = Azure.Core.Foundations.ErrorResponse > is Foundations.Operation< TParams & Azure.Core.Traits.Private.TraitProperties, - - | (TResponse & Azure.Core.Traits.Private.TraitProperties) - | TAdditionalResponse, + | (TResponse & Azure.Core.Traits.Private.TraitProperties) + | TAdditionalResponse, Traits, TErrorResponse >; diff --git a/website/src/content/docs/docs/language-basics/enums.md b/website/src/content/docs/docs/language-basics/enums.md index a22a6a7c296..17340795fc4 100644 --- a/website/src/content/docs/docs/language-basics/enums.md +++ b/website/src/content/docs/docs/language-basics/enums.md @@ -117,7 +117,10 @@ You can apply [decorators](./decorators.md) and doc comments to the declaration ```typespec model Task { - status: @doc("The current status") enum { active, inactive }; + status: @doc("The current status") enum { + active, + inactive, + }; } @@doc(Task.status::type, "The current status"); diff --git a/website/src/content/docs/docs/language-basics/models.md b/website/src/content/docs/docs/language-basics/models.md index bcee93d66e5..94fc9bada2a 100644 --- a/website/src/content/docs/docs/language-basics/models.md +++ b/website/src/content/docs/docs/language-basics/models.md @@ -278,7 +278,9 @@ You can apply [decorators](./decorators.md) and doc comments to the declaration ```typespec model Widget { /** The current status */ - status: @doc("The current status") model Status { code: int32 }; + status: @doc("The current status") model Status { + code: int32; + }; } @@doc(Widget.status::type, "The current status"); diff --git a/website/src/content/docs/release-notes/typespec-1-11-0.mdx b/website/src/content/docs/release-notes/typespec-1-11-0.mdx index 23ed7633339..a28beb27fda 100644 --- a/website/src/content/docs/release-notes/typespec-1-11-0.mdx +++ b/website/src/content/docs/release-notes/typespec-1-11-0.mdx @@ -31,12 +31,11 @@ model Example { description: string; } -model CreateAndReadExample - is FilterVisibility< - Example, - #{ all: #[Lifecycle.Create, Lifecycle.Read] }, - "CreateAndRead{name}" - >; +model CreateAndReadExample is FilterVisibility< + Example, + #{ all: #[Lifecycle.Create, Lifecycle.Read] }, + "CreateAndRead{name}" +>; ``` :::caution From 62f14a8c75a6cd8aa5ef1a058e0c365e9c2a365d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 30 Jun 2026 13:30:52 -0400 Subject: [PATCH 20/21] fix test --- packages/json-schema/test/utils.ts | 3 +++ packages/openapi3/test/test-host.ts | 3 +++ packages/versioning/test/test-host.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/packages/json-schema/test/utils.ts b/packages/json-schema/test/utils.ts index 2acd510cd6a..7035326c89d 100644 --- a/packages/json-schema/test/utils.ts +++ b/packages/json-schema/test/utils.ts @@ -5,6 +5,9 @@ import type { JSONSchemaEmitterOptions } from "../src/lib.js"; export const ApiTester = createTester(resolvePath(import.meta.dirname, ".."), { libraries: ["@typespec/json-schema"], + compilerOptions: { + configFile: { features: ["declaration-expressions"] } as any, + }, }) .import("@typespec/json-schema") .using("JsonSchema"); diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index 2a3dde4136c..b63fa0f88d8 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -22,6 +22,9 @@ export const ApiTester = createTester(resolvePath(import.meta.dirname, ".."), { "@typespec/sse", "@typespec/openapi3", ], + compilerOptions: { + configFile: { features: ["declaration-expressions"] } as any, + }, }); export const SimpleTester = ApiTester.import( diff --git a/packages/versioning/test/test-host.ts b/packages/versioning/test/test-host.ts index 2740ddb0fd7..87eb74a3264 100644 --- a/packages/versioning/test/test-host.ts +++ b/packages/versioning/test/test-host.ts @@ -3,6 +3,9 @@ import { createTester } from "@typespec/compiler/testing"; export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { libraries: ["@typespec/versioning"], + compilerOptions: { + configFile: { features: ["declaration-expressions"] } as any, + }, }) .importLibraries() .using("Versioning"); From 5d4825ea3a5162459a70a61a39e44ea5012a389e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 10 Jul 2026 11:43:04 -0400 Subject: [PATCH 21/21] test: add features option to Tester for enabling compiler features --- packages/compiler/src/testing/tester.ts | 24 ++++++++++++++++++- .../checker/declaration-expressions.test.ts | 4 +--- packages/json-schema/test/utils.ts | 4 +--- packages/openapi3/test/test-host.ts | 4 +--- packages/versioning/test/test-host.ts | 4 +--- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/testing/tester.ts b/packages/compiler/src/testing/tester.ts index 55a962cddcc..5f75950108a 100644 --- a/packages/compiler/src/testing/tester.ts +++ b/packages/compiler/src/testing/tester.ts @@ -1,6 +1,8 @@ import { realpath } from "fs/promises"; import { pathToFileURL } from "url"; +import { TypeSpecConfig } from "../config/types.js"; import { compilerAssert } from "../core/diagnostics.js"; +import { CompilerFeatureName } from "../core/features.js"; import { getEntityName } from "../core/helpers/type-name-utils.js"; import { NodeHost } from "../core/node-host.js"; import { CompilerOptions } from "../core/options.js"; @@ -43,6 +45,12 @@ export interface TesterOptions { */ compilerOptions?: CompilerOptions; + /** + * Compiler features to enable for every compilation (e.g. experimental features gated behind a + * `features` entry in `tspconfig.yaml`). Equivalent to setting `features` in the project config. + */ + features?: CompilerFeatureName[]; + /** * System host for loading libraries * @internal @@ -53,10 +61,24 @@ export function createTester(base: string, options: TesterOptions): Tester { return createTesterInternal({ fs: once(() => createTesterFs(base, options)), libraries: options.libraries, - compilerOptions: options.compilerOptions, + compilerOptions: resolveTesterCompilerOptions(options), }); } +function resolveTesterCompilerOptions(options: TesterOptions): CompilerOptions | undefined { + if (options.features === undefined) { + return options.compilerOptions; + } + // The tester compiles directly without resolving a `tspconfig.yaml`, so the enabled features have + // to be injected into the synthetic `configFile` the compiler reads them from. Only `features` is + // relevant here, hence the cast to the otherwise more complete `TypeSpecConfig`. + const configFile = { + ...options.compilerOptions?.configFile, + features: [...(options.compilerOptions?.configFile?.features ?? []), ...options.features], + } as TypeSpecConfig; + return { ...options.compilerOptions, configFile }; +} + function once(fn: () => Promise): () => Promise { let load: Promise | undefined; return () => { diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts index 548f0cf1a46..066f429e0ac 100644 --- a/packages/compiler/test/checker/declaration-expressions.test.ts +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -12,9 +12,7 @@ import { Tester as BaseTester } from "../tester.js"; */ const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { libraries: [], - compilerOptions: { - configFile: { features: ["declaration-expressions"] } as any, - }, + features: ["declaration-expressions"], }); describe("enum", () => { diff --git a/packages/json-schema/test/utils.ts b/packages/json-schema/test/utils.ts index 7035326c89d..96aecbe83f7 100644 --- a/packages/json-schema/test/utils.ts +++ b/packages/json-schema/test/utils.ts @@ -5,9 +5,7 @@ import type { JSONSchemaEmitterOptions } from "../src/lib.js"; export const ApiTester = createTester(resolvePath(import.meta.dirname, ".."), { libraries: ["@typespec/json-schema"], - compilerOptions: { - configFile: { features: ["declaration-expressions"] } as any, - }, + features: ["declaration-expressions"], }) .import("@typespec/json-schema") .using("JsonSchema"); diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index b63fa0f88d8..f8076ca3a98 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -22,9 +22,7 @@ export const ApiTester = createTester(resolvePath(import.meta.dirname, ".."), { "@typespec/sse", "@typespec/openapi3", ], - compilerOptions: { - configFile: { features: ["declaration-expressions"] } as any, - }, + features: ["declaration-expressions"], }); export const SimpleTester = ApiTester.import( diff --git a/packages/versioning/test/test-host.ts b/packages/versioning/test/test-host.ts index 87eb74a3264..06ac4a5070c 100644 --- a/packages/versioning/test/test-host.ts +++ b/packages/versioning/test/test-host.ts @@ -3,9 +3,7 @@ import { createTester } from "@typespec/compiler/testing"; export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { libraries: ["@typespec/versioning"], - compilerOptions: { - configFile: { features: ["declaration-expressions"] } as any, - }, + features: ["declaration-expressions"], }) .importLibraries() .using("Versioning");