From 638a365e489848fd2b5c935867006b5c247ae52d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 18 Jun 2026 15:08:40 -0400 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 24d23ff336c607409a2f6e96347a8b816b167a3e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 19 Jun 2026 12:17:37 -0400 Subject: [PATCH 11/12] feat(compiler): allow inline decorators on declaration expressions --- ...-expr-inline-decorators-2026-4-18-0-0-2.md | 14 +++++ packages/compiler/src/core/parser.ts | 22 ++++++- .../compiler/src/formatter/print/printer.ts | 12 ++-- .../checker/declaration-expressions.test.ts | 59 ++++++++++++++++++- .../compiler/test/formatter/formatter.test.ts | 56 ++++++++++++++++++ packages/spec/src/spec.emu.html | 10 ++-- 6 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 .chronus/changes/decl-expr-inline-decorators-2026-4-18-0-0-2.md 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/parser.ts b/packages/compiler/src/core/parser.ts index 73e80eb6111..951975980b7 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -1745,10 +1745,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 parseModelStatement(pos, decorators, [], true); + case Token.EnumKeyword: + return parseEnumStatement(pos, decorators, [], true); + case Token.UnionKeyword: + return parseUnionStatement(pos, decorators, [], true); + case Token.ScalarKeyword: + return parseScalarStatement(pos, decorators, [], true); + 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 18f0d0dc82a..ca0bb302d25 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -686,7 +686,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, @@ -740,7 +742,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, @@ -1083,7 +1087,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, @@ -1291,7 +1295,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..7f4a1eaf883 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.", 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 3de49f4b201316e7843bedfd4d8e7710568a25c7 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Mon, 22 Jun 2026 14:50:21 -0400 Subject: [PATCH 12/12] feat(compiler): allow augment decorators on declaration expressions --- ...ecl-expr-augment-inline-2026-4-18-0-0-2.md | 15 ++++ packages/compiler/src/core/checker.ts | 22 +++++- .../checker/declaration-expressions.test.ts | 72 +++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 .chronus/changes/decl-expr-augment-inline-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/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 3d14b7927f3..a5a3a78df58 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -7205,7 +7205,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({ @@ -7236,6 +7237,25 @@ 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 { + const symNode = getSymNode(sym); + switch (symNode?.kind) { + case SyntaxKind.ModelStatement: + case SyntaxKind.EnumStatement: + case SyntaxKind.UnionStatement: + case SyntaxKind.ScalarStatement: + return isDeclarationInExpressionPosition(symNode); + default: + return false; + } + } + /** * Check that using statements are targeting valid symbols. */ diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts index 7f4a1eaf883..731b48d56b8 100644 --- a/packages/compiler/test/checker/declaration-expressions.test.ts +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -446,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 };`);