diff --git a/.chronus/changes/scoped-decorators-when-clause-2026-1-15.md b/.chronus/changes/scoped-decorators-when-clause-2026-1-15.md new file mode 100644 index 00000000000..3cd0787152b --- /dev/null +++ b/.chronus/changes/scoped-decorators-when-clause-2026-1-15.md @@ -0,0 +1,22 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add experimental `when` clauses on `auto` decorator applications, letting a single spec carry different metadata per emitter, language, or target. Enable with the `scoped-decorators` feature flag. + +```tsp +@clientName("Widget") when language("csharp") | language("java") +@clientName("widget") when language("python") +@clientName("Thing") +model Widget {} +``` + +Emitters read the value for their own scope; `EmitContext.scope` is prefilled with the emitter's package name and `EmitContext.createScope()` narrows it: + +```ts +const value = getAutoDecoratorValue(program, "MyLib.clientName", model, context.scope); +``` + +Decorator arguments are still validated in every scope — only the stored value is conditioned — and the unscoped `getAutoDecoratorValue(program, fqn, target)` overload is unchanged. diff --git a/grammars/typespec.json b/grammars/typespec.json index c29ce411b12..b28adb012e4 100644 --- a/grammars/typespec.json +++ b/grammars/typespec.json @@ -567,6 +567,9 @@ } }, "patterns": [ + { + "include": "#when-clause" + }, { "include": "#model-property" }, @@ -803,6 +806,9 @@ { "include": "#token" }, + { + "include": "#when-clause" + }, { "include": "#decorator" }, @@ -1017,6 +1023,9 @@ { "include": "#directive" }, + { + "include": "#when-clause" + }, { "include": "#augment-decorator-statement" }, @@ -1458,6 +1467,27 @@ "include": "#expression" } ] + }, + "when-clause": { + "name": "meta.when-clause.typespec", + "begin": "\\b(when)\\b", + "beginCaptures": { + "1": { + "name": "keyword.other.tsp" + } + }, + "end": "(?=(?:\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`)\\s*\\??\\s*:)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "patterns": [ + { + "include": "#token" + }, + { + "include": "#parenthesized-expression" + }, + { + "include": "#identifier-expression" + } + ] } } } \ No newline at end of file diff --git a/packages/compiler/src/core/auto-decorator.ts b/packages/compiler/src/core/auto-decorator.ts index befbf984b0b..45516625f2e 100644 --- a/packages/compiler/src/core/auto-decorator.ts +++ b/packages/compiler/src/core/auto-decorator.ts @@ -1,8 +1,16 @@ // Copyright (c) Microsoft Corporation // Licensed under the MIT License. +import { Realm } from "../experimental/realm.js"; import { validateDecoratorUniqueOnNode } from "./decorator-utils.js"; import type { Program } from "./program.js"; +import { + addScopedDecoratorEntry, + getScopedDecoratorEntries, + resolveScopedDecoratorValue, + type Scope, + type ScopeConditionSet, +} from "./scope.js"; import { getFullyQualifiedSymbolName } from "./type-utils.js"; import type { DecoratorContext, DecoratorDeclarationStatementNode, Sym, Type } from "./types.js"; @@ -16,6 +24,33 @@ export function getAutoDecoratorStateKey(decoratorFqn: string): symbol { return Symbol.for(`dec:${decoratorFqn}`); } +/** + * Build the `{ paramName: value }` record an auto decorator stores from its arguments. + * @internal + */ +export function buildAutoDecoratorData( + node: DecoratorDeclarationStatementNode, + args: unknown[], +): Record { + const paramNames = node.parameters.map((p) => p.id.sv); + const lastParamIsRest = + node.parameters.length > 0 && node.parameters[node.parameters.length - 1].rest; + + const data: Record = {}; + if (lastParamIsRest) { + for (let i = 0; i < paramNames.length - 1; i++) { + data[paramNames[i]] = args[i]; + } + // The rest parameter collects all remaining arguments into an array. + data[paramNames[paramNames.length - 1]] = args.slice(paramNames.length - 1); + } else { + for (let i = 0; i < paramNames.length; i++) { + data[paramNames[i]] = args[i]; + } + } + return data; +} + /** * Build the auto-generated implementation for an `auto dec` declaration. * @@ -29,9 +64,6 @@ export function createAutoDecoratorImplementation( node: DecoratorDeclarationStatementNode, ): (ctx: DecoratorContext, target: Type, ...args: unknown[]) => void { const fqn = getFullyQualifiedSymbolName(symbol); - const paramNames = node.parameters.map((p) => p.id.sv); - const lastParamIsRest = - node.parameters.length > 0 && node.parameters[node.parameters.length - 1].rest; const impl = (context: DecoratorContext, target: Type, ...args: unknown[]) => { // Warn (but still store, so duplicates are last-write-wins like extern @@ -40,19 +72,7 @@ export function createAutoDecoratorImplementation( validateDecoratorUniqueOnNode(context, target, impl); } - const data: Record = {}; - if (lastParamIsRest) { - for (let i = 0; i < paramNames.length - 1; i++) { - data[paramNames[i]] = args[i]; - } - // The rest parameter collects all remaining arguments into an array. - data[paramNames[paramNames.length - 1]] = args.slice(paramNames.length - 1); - } else { - for (let i = 0; i < paramNames.length; i++) { - data[paramNames[i]] = args[i]; - } - } - setAutoDecorator(context.program, fqn, target, data); + setAutoDecorator(context.program, fqn, target, buildAutoDecoratorData(node, args)); }; // The function name drives the `@` text in the duplicate-decorator // diagnostic; mirror the extern `$name` convention so the helper strips it. @@ -60,6 +80,30 @@ export function createAutoDecoratorImplementation( return impl; } +/** + * Build the implementation for a *conditioned* (`when`-scoped) `auto dec` application. + * + * Unlike the unscoped implementation this does not warn on duplicates: several scoped + * applications on the same target are the whole point of the feature. The value is written + * to a separate state map so the unscoped `getAutoDecoratorValue` contract is unaffected. + * @internal + */ +export function createScopedAutoDecoratorImplementation( + symbol: Sym, + node: DecoratorDeclarationStatementNode, + scope: ScopeConditionSet, +): (ctx: DecoratorContext, target: Type, ...args: unknown[]) => void { + const fqn = getFullyQualifiedSymbolName(symbol); + const impl = (context: DecoratorContext, target: Type, ...args: unknown[]) => { + addScopedDecoratorEntry(context.program, fqn, target, { + value: buildAutoDecoratorData(node, args), + scope, + }); + }; + Object.defineProperty(impl, "name", { value: `$${node.id.sv}` }); + return impl; +} + /** * Programmatically apply an auto decorator to a target, storing its argument values. * @@ -98,15 +142,32 @@ export function hasAutoDecorator(program: Program, decoratorFqn: string, target: * @param program - The current program. * @param decoratorFqn - The fully-qualified name of the decorator (e.g., "MyLib.myDec"). * @param target - The type to get the value for. + * @param scope - Optional scope used to resolve `when`-conditioned applications. A scoped + * application whose condition matches takes precedence over the unscoped value. * @returns The stored record, or `undefined` if the decorator was not applied. */ export function getAutoDecoratorValue( program: Program, decoratorFqn: string, target: Type, + scope?: Scope, ): Record | undefined { + // Realm state maps only resolve state for types the realm owns, so a clone carries none of + // the state recorded against the type it was cloned from. Walk back to that source type. + const resolved = Realm.sourceOf(target); + + if (scope !== undefined) { + const entries = getScopedDecoratorEntries(program, decoratorFqn, resolved); + if (entries !== undefined) { + const value = resolveScopedDecoratorValue(entries, scope); + if (value !== undefined) { + return value; + } + } + } + const key = getAutoDecoratorStateKey(decoratorFqn); - return program.stateMap(key).get(target) as Record | undefined; + return program.stateMap(key).get(resolved) as Record | undefined; } /** diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 9d989e33d61..655ed3d2081 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -4,7 +4,10 @@ import { $ } from "../typekit/index.js"; import { DuplicateTracker } from "../utils/duplicate-tracker.js"; import type { Mutable } from "../utils/misc.js"; import { MultiKeyMap, createRekeyableMap, isArray, mutate } from "../utils/misc.js"; -import { createAutoDecoratorImplementation } from "./auto-decorator.js"; +import { + createAutoDecoratorImplementation, + createScopedAutoDecoratorImplementation, +} from "./auto-decorator.js"; import { createSymbol, getSymNode } from "./binder.js"; import { createChangeIdentifierCodeFix } from "./compiler-code-fixes/change-identifier.codefix.js"; import { @@ -41,6 +44,7 @@ import { visitChildren, } from "./parser.js"; import type { Program } from "./program.js"; +import { isWhenFilterName, whenFilterNames, type ScopeCondition } from "./scope.js"; import { createTypeRelationChecker } from "./type-relation-checker.js"; import { getFullyQualifiedSymbolName, @@ -178,6 +182,7 @@ import type { Value, ValueWithTemplate, VoidType, + WhenClauseNode, } from "./types.js"; import { IdentifierKind, @@ -6949,13 +6954,151 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } const impl = sym.value ?? symbolLinks.declaredType?.implementation; + const scopedImpl = checkWhenClause(ctx, decNode, sym, symbolLinks.declaredType); return { definition: symbolLinks.declaredType, - decorator: impl ?? ((...args: any[]) => {}), + decorator: scopedImpl ?? impl ?? ((...args: any[]) => {}), node: decNode, args, }; } + + /** + * Validate a decorator's `when` clause and, if valid, build the conditioned implementation + * that stores the decorator's arguments against the resolved scope instead of unconditionally. + * + * Returns `undefined` when there is no clause or the clause is invalid — the decorator's + * arguments are still validated either way, only the *storage* is conditioned. + */ + function checkWhenClause( + ctx: CheckContext, + decNode: DecoratorExpressionNode | AugmentDecoratorStatementNode, + sym: Sym, + declaration: Decorator | undefined, + ): ((ctx: DecoratorContext, target: Type, ...args: unknown[]) => void) | undefined { + const when = decNode.when; + if (when === undefined) { + return undefined; + } + + if (!isCompilerFeatureEnabled(program, "scoped-decorators", when)) { + reportCheckerDiagnostic( + createDiagnostic({ code: "scoped-decorator-disabled", target: when }), + ); + return undefined; + } + + if (decNode.kind === SyntaxKind.AugmentDecoratorStatement) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "when-clause-not-allowed", + messageId: "augment", + target: when, + }), + ); + return undefined; + } + + const declNode = sym.declarations.find( + (x): x is DecoratorDeclarationStatementNode => + x.kind === SyntaxKind.DecoratorDeclarationStatement, + ); + if (declaration?.declarationKind !== "auto" || declNode === undefined) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "when-clause-not-allowed", + format: { decorator: `@${sym.name}` }, + target: when, + }), + ); + return undefined; + } + + const conditions = checkWhenConditions(when); + if (conditions === undefined) { + return undefined; + } + + return createScopedAutoDecoratorImplementation(sym, declNode, { conditions }); + } + + /** Resolve the closed condition grammar into scope dimensions. Returns `undefined` on error. */ + function checkWhenConditions(when: WhenClauseNode): ScopeCondition[] | undefined { + const conditions: ScopeCondition[] = []; + let hasError = false; + + for (const condition of when.conditions) { + if (condition.kind !== SyntaxKind.CallExpression) { + // Enum member references (e.g. `Lifecycle.read`) are reserved for the visibility + // dimension, which is not part of phase 1. + reportCheckerDiagnostic( + createDiagnostic({ + code: "unknown-when-filter", + format: { name: getIdentifierText(condition), expected: whenFilterNames.join(", ") }, + target: condition, + }), + ); + hasError = true; + continue; + } + + const name = + condition.target.kind === SyntaxKind.Identifier ? condition.target.sv : undefined; + if (name === undefined || !isWhenFilterName(name)) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "unknown-when-filter", + format: { + name: getIdentifierText(condition.target), + expected: whenFilterNames.join(", "), + }, + target: condition.target, + }), + ); + hasError = true; + continue; + } + + if (condition.arguments.length !== 1) { + reportCheckerDiagnostic( + createDiagnostic({ code: "invalid-when-condition", format: { name }, target: condition }), + ); + hasError = true; + continue; + } + + const arg = condition.arguments[0]; + if (arg.kind !== SyntaxKind.StringLiteral) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "invalid-when-condition", + messageId: "notAValue", + format: { name }, + target: arg, + }), + ); + hasError = true; + continue; + } + + conditions.push({ dimension: name, value: arg.value }); + } + + return hasError ? undefined : conditions; + } + + function getIdentifierText(node: Node): string { + switch (node.kind) { + case SyntaxKind.Identifier: + return node.sv; + case SyntaxKind.MemberExpression: + return `${getIdentifierText(node.base)}${node.selector}${node.id.sv}`; + case SyntaxKind.CallExpression: + return getIdentifierText(node.target); + default: + return ""; + } + } /** Check the decorator target is valid */ function checkDecoratorTarget(targetType: Type, declaration: Decorator, decoratorNode: Node) { diff --git a/packages/compiler/src/core/features.ts b/packages/compiler/src/core/features.ts index af9a75d3453..64309e5fa26 100644 --- a/packages/compiler/src/core/features.ts +++ b/packages/compiler/src/core/features.ts @@ -15,6 +15,10 @@ export const compilerFeatures = { description: "Allows use of auto decorator declarations without experimental warnings in project code.", }, + "scoped-decorators": { + description: + "Allows use of `when` clauses on `auto` decorator applications, conditioning the stored metadata on the querying scope (emitter, language, target).", + }, "type-info-provider": { description: "Enables the experimental `$provideTypeInfo` provider allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index c91a2528634..4fc13da0739 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -158,6 +158,8 @@ const diagnostics = { statement: "Statement expected.", property: "Property expected.", enumMember: "Enum member expected.", + whenCondition: + "'when' condition expected. A condition must be a filter call (e.g. `emitter(\"name\")`) or an enum member reference (e.g. `Lifecycle.read`).", typeofTarget: "Typeof expects a value literal or value reference.", }, }, @@ -276,6 +278,33 @@ const diagnostics = { "Auto decorator declarations require the 'auto-decorators' feature to be enabled. Add 'auto-decorators' to the 'features' list in your tspconfig.yaml.", }, }, + "scoped-decorator-disabled": { + severity: "error", + messages: { + default: + "'when' clauses on decorators require the 'scoped-decorators' feature to be enabled. Add 'scoped-decorators' to the 'features' list in your tspconfig.yaml.", + }, + }, + "when-clause-not-allowed": { + severity: "error", + messages: { + default: paramMessage`'when' clause is only allowed on 'auto' decorators. '${"decorator"}' is declared with 'extern dec', which may have side effects that cannot be deferred to query time.`, + augment: "'when' clause is not supported on augment decorators.", + }, + }, + "unknown-when-filter": { + severity: "error", + messages: { + default: paramMessage`Unknown 'when' filter '${"name"}'. Expected one of: ${"expected"}.`, + }, + }, + "invalid-when-condition": { + severity: "error", + messages: { + default: paramMessage`Filter '${"name"}' expects a single string argument.`, + notAValue: paramMessage`Filter '${"name"}' expects a string literal argument.`, + }, + }, "using-invalid-ref": { severity: "error", messages: { diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 749ec169d17..5ec0ccee271 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -7,6 +7,7 @@ import { modifiersToFlags } from "./modifiers.js"; import { createScanner, isComment, + isContextualKeyword, isKeyword, isModifier, isPunctuation, @@ -106,6 +107,8 @@ import type { UsingStatementNode, ValueOfExpressionNode, VoidKeywordNode, + WhenClauseNode, + WhenConditionNode, } from "./types.js"; import { IdentifierKind, ModifierFlags, NodeFlags, SyntaxKind } from "./types.js"; @@ -1531,6 +1534,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa allowReservedIdentifierInMember: true, }); const { items: args } = parseOptionalList(ListKind.DecoratorArguments, parseExpression); + const when = parseOptionalWhenClause(); if (args.length === 0) { error({ code: "augment-decorator-target" }); const emptyList = createEmptyList(); @@ -1566,6 +1570,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa target, targetType: targetEntity, arguments: decoratorArgs, + when, ...finishNode(pos), }; } @@ -1596,14 +1601,61 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa allowReservedIdentifierInMember: true, }); const { items: args } = parseOptionalList(ListKind.DecoratorArguments, parseExpression); + const when = parseOptionalWhenClause(); return { kind: SyntaxKind.DecoratorExpression, arguments: args, target, + when, ...finishNode(pos), }; } + /** + * Parse an optional trailing `when (| )*` clause. + * + * The clause is a suffix so that it binds to the decorator it follows. The condition + * grammar is closed (identifier, member expression or call) rather than a general + * expression, which is what makes the clause terminate unambiguously: the token that + * follows can only be `@`, `#`, a doc comment, or the start of the decorated entity, + * none of which can continue a condition. + */ + function parseOptionalWhenClause(): WhenClauseNode | undefined { + const pos = tokenPos(); + if (!parseOptional(Token.WhenKeyword)) { + return undefined; + } + + const conditions: WhenConditionNode[] = [parseWhenCondition()]; + while (parseOptional(Token.Bar)) { + conditions.push(parseWhenCondition()); + } + + return { + kind: SyntaxKind.WhenClause, + conditions, + ...finishNode(pos), + }; + } + + function parseWhenCondition(): WhenConditionNode { + const pos = tokenPos(); + const target = parseIdentifierOrMemberExpression({ + message: "whenCondition", + allowReservedIdentifierInMember: true, + }); + if (token() === Token.OpenParen) { + const { items: args } = parseList(ListKind.FunctionArguments, parseExpression); + return { + kind: SyntaxKind.CallExpression, + target, + arguments: args, + ...finishNode(pos), + }; + } + return target; + } + function parseDirectiveExpression(): DirectiveExpressionNode { const pos = tokenPos(); parseExpected(Token.Hash); @@ -2020,7 +2072,10 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa allowReservedIdentifier?: boolean; }): IdentifierNode { if (isKeyword(token())) { - if (!(isModifier(token()) && options?.allowReservedIdentifier)) { + if (!( + (isModifier(token()) || isContextualKeyword(token())) && + options?.allowReservedIdentifier + )) { error({ code: "reserved-identifier" }); return createMissingIdentifier(); } @@ -2989,10 +3044,15 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined return ( visitNode(cb, node.target) || visitNode(cb, node.targetType) || - visitEach(cb, node.arguments) + visitEach(cb, node.arguments) || + visitNode(cb, node.when) ); case SyntaxKind.DecoratorExpression: - return visitNode(cb, node.target) || visitEach(cb, node.arguments); + return ( + visitNode(cb, node.target) || visitEach(cb, node.arguments) || visitNode(cb, node.when) + ); + case SyntaxKind.WhenClause: + return visitEach(cb, node.conditions); case SyntaxKind.CallExpression: return visitNode(cb, node.target) || visitEach(cb, node.arguments); case SyntaxKind.DirectiveExpression: diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index ac8d28a7d9f..45b3099dff6 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -36,6 +36,7 @@ import type { CompilerOptions } from "./options.js"; import { parse, parseStandaloneTypeReference } from "./parser.js"; import { getDirectoryPath, joinPaths, resolvePath } from "./path-utils.js"; import { createPerfReporter, perf } from "./perf.js"; +import type { Scope } from "./scope.js"; import type { SourceLoader, SourceResolution } from "./source-loader.js"; import { createSourceLoader, @@ -1118,11 +1119,14 @@ async function emit( */ async function runEmitter(emitter: EmitterRef, program: Program): Promise { const perfReporter = createPerfReporter(); + const scope: Scope = { emitter: emitter.metadata.name }; const context: EmitContext = { program, emitterOutputDir: emitter.emitterOutputDir, options: emitter.options, perf: perfReporter, + scope, + createScope: (overrides) => ({ ...scope, ...overrides }), }; try { await emitter.emitFunction(context); diff --git a/packages/compiler/src/core/scanner.ts b/packages/compiler/src/core/scanner.ts index 9301b4e0732..68ec6189f49 100644 --- a/packages/compiler/src/core/scanner.ts +++ b/packages/compiler/src/core/scanner.ts @@ -159,6 +159,7 @@ export enum Token { UnknownKeyword, ValueOfKeyword, TypeOfKeyword, + WhenKeyword, // Add new non-statement keyword above /** @internal */ __EndKeyword, @@ -311,6 +312,7 @@ export const TokenDisplay = getTokenDisplayTable([ [Token.UnknownKeyword, "'unknown'"], [Token.ExternKeyword, "'extern'"], [Token.AutoKeyword, "'auto'"], + [Token.WhenKeyword, "'when'"], // Reserved keywords [Token.StatemachineKeyword, "'statemachine'"], @@ -385,6 +387,7 @@ export const Keywords: ReadonlyMap = new Map([ ["extern", Token.ExternKeyword], ["auto", Token.AutoKeyword], ["internal", Token.InternalKeyword], + ["when", Token.WhenKeyword], // Reserved keywords ["statemachine", Token.StatemachineKeyword], @@ -571,6 +574,15 @@ export function isModifier(token: Token) { return token >= Token.__StartModifierKeyword && token < Token.__EndModifierKeyword; } +/** + * Keywords that only have meaning in a specific syntactic position and remain + * usable as identifiers everywhere else, so that promoting them does not break + * existing specs. + */ +export function isContextualKeyword(token: Token) { + return token === Token.WhenKeyword; +} + export function isStatementKeyword(token: Token) { return token >= Token.__StartStatementKeyword && token < Token.__EndStatementKeyword; } diff --git a/packages/compiler/src/core/scope.ts b/packages/compiler/src/core/scope.ts new file mode 100644 index 00000000000..bd479c54023 --- /dev/null +++ b/packages/compiler/src/core/scope.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT License. + +import type { Program } from "./program.js"; +import type { Type } from "./types.js"; + +/** + * The scope dimensions a `when` clause can filter on. + * + * Deliberately a closed set. Dimensions are unordered and independent; a scope matches a + * condition when the condition's dimension value equals the scope's value for that dimension. + */ +export interface Scope { + /** Fully qualified emitter package name, e.g. `@typespec/http-client-csharp`. */ + readonly emitter?: string; + /** Target language, e.g. `csharp`. */ + readonly language?: string; + /** Kind of artifact being produced, e.g. `client` or `server`. */ + readonly target?: string; +} + +/** Names of the built-in `when` filters, in declaration order. */ +export const whenFilterNames = ["emitter", "language", "target"] as const; + +export type WhenFilterName = (typeof whenFilterNames)[number]; + +const whenFilterNameSet = new Set(whenFilterNames); + +export function isWhenFilterName(name: string): name is WhenFilterName { + return whenFilterNameSet.has(name); +} + +/** + * A single resolved condition: one dimension constrained to one value. + * @internal + */ +export interface ScopeCondition { + readonly dimension: WhenFilterName; + readonly value: string; +} + +/** + * A resolved `when` clause. The conditions are alternatives — the clause matches a scope + * if *any* condition matches (the `|` in the source is a disjunction). + * @internal + */ +export interface ScopeConditionSet { + readonly conditions: readonly ScopeCondition[]; +} + +/** + * One conditioned value stored for an auto decorator. + * @internal + */ +export interface ScopedDecoratorEntry { + /** The `{ paramName: value }` record the decorator would have stored unconditionally. */ + readonly value: Record; + /** `undefined` for an unscoped application, which acts as the default. */ + readonly scope: ScopeConditionSet | undefined; +} + +/** + * State key for the conditioned entries of an auto decorator. + * + * Deliberately a *separate* key from the unscoped auto decorator state key: the unscoped state map + * keeps its existing `Record` shape so that `getAutoDecoratorValue` retains + * its contract and the `auto` <-> `extern` migration story is unaffected. + * @internal + */ +export function getScopedDecoratorStateKey(decoratorFqn: string): symbol { + return Symbol.for(`dec-scoped:${decoratorFqn}`); +} + +/** Does `condition` hold for `scope`? */ +function matchesCondition(scope: Scope, condition: ScopeCondition): boolean { + return scope[condition.dimension] === condition.value; +} + +/** Does any alternative in `set` hold for `scope`? */ +function matchesScope(scope: Scope, set: ScopeConditionSet): boolean { + return set.conditions.some((c) => matchesCondition(scope, c)); +} + +/** + * Record a conditioned auto decorator application. + * @internal + */ +export function addScopedDecoratorEntry( + program: Program, + decoratorFqn: string, + target: Type, + entry: ScopedDecoratorEntry, +): void { + const stateMap = program.stateMap(getScopedDecoratorStateKey(decoratorFqn)); + const existing = stateMap.get(target) as ScopedDecoratorEntry[] | undefined; + if (existing === undefined) { + stateMap.set(target, [entry]); + } else { + existing.push(entry); + } +} + +/** + * Get every conditioned entry recorded for an auto decorator on a target, in source order. + * @internal + */ +export function getScopedDecoratorEntries( + program: Program, + decoratorFqn: string, + target: Type, +): readonly ScopedDecoratorEntry[] | undefined { + return program.stateMap(getScopedDecoratorStateKey(decoratorFqn)).get(target) as + ScopedDecoratorEntry[] | undefined; +} + +/** + * Resolve the value of an auto decorator for a given scope. + * + * An unscoped application is the default; a scoped application whose condition matches + * overrides it. When several scoped applications match, the last one in source order wins, + * mirroring the last-write-wins behavior of repeated unscoped applications. + * @internal + */ +export function resolveScopedDecoratorValue( + entries: readonly ScopedDecoratorEntry[], + scope: Scope | undefined, +): Record | undefined { + let result: Record | undefined; + for (const entry of entries) { + if (entry.scope === undefined) { + // Unscoped default: only fills in if nothing scoped has matched yet. + result ??= entry.value; + } else if (scope !== undefined && matchesScope(scope, entry.scope)) { + result = entry.value; + } + } + return result; +} diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 518eed2a991..c0d8a0a117d 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -5,6 +5,7 @@ import type { FileRef } from "./file-ref.js"; import type { Numeric } from "./numeric.js"; import type { Program } from "./program.js"; import type { TokenFlags } from "./scanner.js"; +import type { Scope } from "./scope.js"; // prettier-ignore export type MarshalledValue = @@ -1209,6 +1210,7 @@ export enum SyntaxKind { InternalKeyword, AutoKeyword, FunctionTypeExpression, + WhenClause, } export const enum NodeFlags { @@ -1316,6 +1318,7 @@ export type Node = | ModelSpreadPropertyNode | DecoratorExpressionNode | DirectiveExpressionNode + | WhenClauseNode | Statement | Expression | FunctionParameterNode @@ -1465,6 +1468,27 @@ export interface DecoratorExpressionNode extends BaseNode { readonly kind: SyntaxKind.DecoratorExpression; readonly target: IdentifierNode | MemberExpressionNode; readonly arguments: readonly Expression[]; + /** Condition restricting the scopes this decorator applies in. */ + readonly when?: WhenClauseNode; +} + +/** + * A condition in a {@link WhenClauseNode}. + * + * The grammar is deliberately closed rather than a general expression: it is what + * guarantees the clause terminates unambiguously in decorator position. + */ +export type WhenConditionNode = IdentifierNode | MemberExpressionNode | CallExpressionNode; + +/** + * A `when` clause scoping the thing it is attached to. + * + * Conditions are alternatives, separated by `|`. A comma-separated list is not used + * because `,` is the model-property and operation-parameter separator. + */ +export interface WhenClauseNode extends BaseNode { + readonly kind: SyntaxKind.WhenClause; + readonly conditions: readonly WhenConditionNode[]; } export interface AugmentDecoratorStatementNode extends BaseNode { @@ -1472,6 +1496,8 @@ export interface AugmentDecoratorStatementNode extends BaseNode { readonly target: IdentifierNode | MemberExpressionNode; readonly targetType: TypeReferenceNode; readonly arguments: readonly Expression[]; + /** Condition restricting the scopes this decorator applies in. */ + readonly when?: WhenClauseNode; readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } @@ -2868,6 +2894,30 @@ export interface EmitContext> { * The information will be displayed when the compiler is run with `--stats` flag. */ readonly perf: PerfReporter; + + /** + * The scope this emitter is running under, used to resolve `when`-conditioned metadata. + * + * The `emitter` dimension is populated automatically from the emitter's package name. Emitters + * that serve a specific language or artifact kind should narrow it further with + * {@link EmitContext.createScope}. + */ + readonly scope: Scope; + + /** + * Derive a scope from this emitter's scope, overriding the given dimensions. + * + * Use this when a single emitter produces output for more than one scope — for example a + * client emitter that targets several languages, or one that emits both client and server + * artifacts from the same program. + * + * @example + * ```ts + * const csharp = context.createScope({ language: "csharp", target: "client" }); + * const name = getAutoDecoratorValue(program, "MyLib.clientName", model, csharp); + * ``` + */ + readonly createScope: (overrides: Partial) => Scope; } export interface Timer { diff --git a/packages/compiler/src/experimental/realm.ts b/packages/compiler/src/experimental/realm.ts index c7a37220cff..0d97cf9f695 100644 --- a/packages/compiler/src/experimental/realm.ts +++ b/packages/compiler/src/experimental/realm.ts @@ -226,6 +226,7 @@ export class Realm { const clone = this.typekit.type.clone(type); this.#types.add(clone); Realm.realmForType.set(clone, this); + Realm.sourceForType.set(clone, type); return clone; } @@ -236,6 +237,31 @@ export class Realm { } static realmForType = singleton("Realm.realmForType", () => new WeakMap()); + + /** + * Maps a realm-owned clone back to the type it was cloned from. + * + * Realm state maps only resolve state for types the realm owns, so a clone has none of the + * decorator state recorded against its source. Provenance lets a query walk back to the type + * the state was actually stored against, which is what makes scoped metadata composable with + * realm-based transforms such as versioning. + */ + static sourceForType = singleton("Realm.sourceForType", () => new WeakMap()); + + /** + * Walk a type back through any realm clones to the original program-owned type. + * + * Returns `type` itself when it is not a realm clone. + */ + static sourceOf(type: Type): Type { + let current = type; + let source = Realm.sourceForType.get(current); + while (source !== undefined) { + current = source; + source = Realm.sourceForType.get(current); + } + return current; + } } /** diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 51491a656ab..dd6f9d5cdd1 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -61,6 +61,7 @@ import type { UnionVariantNode, UsingStatementNode, ValueOfExpressionNode, + WhenClauseNode, } from "../../core/types.js"; import { NodeFlags, SyntaxKind } from "../../core/types.js"; import type { FlattenedNamespaceStatementNode } from "../types.js"; @@ -305,6 +306,8 @@ export function printNode( return printConstStatement(path as AstPath, options, print); case SyntaxKind.CallExpression: return printCallExpression(path as AstPath, options, print); + case SyntaxKind.WhenClause: + return printWhenClauseNode(path as AstPath, options, print); case SyntaxKind.StringTemplateSpan: case SyntaxKind.StringTemplateHead: case SyntaxKind.StringTemplateMiddle: @@ -539,7 +542,23 @@ export function printDecorator( node.target.kind === SyntaxKind.Identifier ? printIdentifier(node.target, "allow-reserved") : path.call(print, "target"); - return ["@", name, args]; + return ["@", name, args, printWhenClause(path, print)]; +} + +/** Print the trailing `when` clause of a decorator, if any. */ +function printWhenClause( + path: AstPath, + print: PrettierChildPrint, +): Doc { + return path.node.when === undefined ? "" : [" ", path.call(print, "when")]; +} + +export function printWhenClauseNode( + path: AstPath, + options: TypeSpecPrettierOptions, + print: PrettierChildPrint, +): Doc { + return group(["when ", indent(join([line, "| "], path.map(print, "conditions")))]); } export function printAugmentDecorator( @@ -553,7 +572,7 @@ export function printAugmentDecorator( ? printIdentifier(node.target, "allow-reserved") : path.call(print, "target"); const args = printAugmentDecoratorArgs(path, options, print); - return ["@@", target, args, ";"]; + return ["@@", target, args, printWhenClause(path, print), ";"]; } function printAugmentDecoratorArgs( diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 65735c4b6f1..403d38035e9 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -115,6 +115,8 @@ export { NodeHost } from "./core/node-host.js"; export { isNumeric, Numeric } from "./core/numeric.js"; export type { CompilerOptions } from "./core/options.js"; export { getPositionBeforeTrivia } from "./core/parser-utils.js"; +export { isWhenFilterName, whenFilterNames } from "./core/scope.js"; +export type { Scope, WhenFilterName } from "./core/scope.js"; export { $defaultVisibility, $discriminator, diff --git a/packages/compiler/src/server/tmlanguage.ts b/packages/compiler/src/server/tmlanguage.ts index 92b268fef58..27b522b18f7 100644 --- a/packages/compiler/src/server/tmlanguage.ts +++ b/packages/compiler/src/server/tmlanguage.ts @@ -312,6 +312,25 @@ const identifierExpression: MatchRule = { match: identifier, }; +/** + * A `when` clause suffix on a decorator: `@dec(...) when emitter("x") | language("y")`. + * + * `when` is a contextual keyword, so this rule has to work out where the clause ends. A clause + * is followed either by something `universalEnd` already covers (a statement keyword, `@`, `}`, + * `;`, …) or — when the decorator is on a model property — by the property name, which is always + * followed by `:` or `?:`. + */ +const whenClause: BeginEndRule = { + key: "when-clause", + scope: meta, + begin: `\\b(when)\\b`, + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + }, + end: `(?=(?:${identifier})\\s*\\??\\s*:)|${universalEnd}`, + patterns: [token, parenthesizedExpression, identifierExpression], +}; + const valueOfExpression: BeginEndRule = { key: "valueof", scope: meta, @@ -498,6 +517,7 @@ const modelExpression: BeginEndRule = { // modelProperty must come before token or quoted property name will be // considered an arbitrarily positioned string literal and not match as part // of modelProperty begin. + whenClause, modelProperty, token, directive, @@ -572,7 +592,7 @@ const operationParameters: BeginEndRule = { endCaptures: { "0": { scope: "punctuation.parenthesis.close.tsp" }, }, - patterns: [token, decorator, modelProperty, spreadExpression, punctuationComma], + patterns: [token, whenClause, decorator, modelProperty, spreadExpression, punctuationComma], }; const scalarExtends: BeginEndRule = { @@ -941,6 +961,7 @@ expression.patterns = [ statement.patterns = [ token, directive, + whenClause, augmentDecoratorStatement, decorator, modelStatement, diff --git a/packages/compiler/test/checker/scoped-decorator-storage.test.ts b/packages/compiler/test/checker/scoped-decorator-storage.test.ts new file mode 100644 index 00000000000..c96b8c54af1 --- /dev/null +++ b/packages/compiler/test/checker/scoped-decorator-storage.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { getAutoDecoratorValue } from "../../src/core/auto-decorator.js"; +import type { CompilerOptions } from "../../src/core/options.js"; +import type { Program } from "../../src/core/program.js"; +import type { Scope } from "../../src/core/scope.js"; +import type { Model } from "../../src/core/types.js"; +import { Realm } from "../../src/experimental/realm.js"; +import { createTestHost, expectDiagnosticEmpty, type TestHost } from "../../src/testing/index.js"; + +const features = ["auto-decorators", "scoped-decorators"]; + +function projectOptions(): CompilerOptions { + return { + configFile: { + projectRoot: ".", + kind: "project", + features, + diagnostics: [], + outputDir: "tsp-output", + }, + }; +} + +let host: TestHost; + +beforeEach(async () => { + host = await createTestHost(); +}); + +/** Compile `code` with a `@clientName(name: string)` auto decorator in scope. */ +async function compileWithClientName( + code: string, +): Promise<{ program: Program; Foo: Model; value: (scope?: Scope) => unknown }> { + host.addTypeSpecFile( + "main.tsp", + `auto dec clientName(target: unknown, name: valueof string); + ${code}`, + ); + await host.compile("main.tsp", projectOptions()); + const program = host.program; + expectDiagnosticEmpty(program.diagnostics); + + const Foo = program.getGlobalNamespaceType().models.get("Foo")!; + expect(Foo).toBeDefined(); + return { + program, + Foo, + value: (scope?: Scope) => getAutoDecoratorValue(program, "clientName", Foo, scope)?.["name"], + }; +} + +describe("resolution", () => { + it("returns the scoped value when the scope matches", async () => { + const { value } = await compileWithClientName(` + @clientName("CsharpFoo") when emitter("@typespec/http-client-csharp") + model Foo {}`); + + expect(value({ emitter: "@typespec/http-client-csharp" })).toBe("CsharpFoo"); + }); + + it("returns undefined when no scope matches and there is no unscoped default", async () => { + const { value } = await compileWithClientName(` + @clientName("CsharpFoo") when emitter("@typespec/http-client-csharp") + model Foo {}`); + + expect(value({ emitter: "@typespec/http-client-python" })).toBeUndefined(); + }); + + it("falls back to the unscoped value when no scope matches", async () => { + const { value } = await compileWithClientName(` + @clientName("DefaultFoo") + @clientName("CsharpFoo") when emitter("@typespec/http-client-csharp") + model Foo {}`); + + expect(value({ emitter: "@typespec/http-client-python" })).toBe("DefaultFoo"); + expect(value({ emitter: "@typespec/http-client-csharp" })).toBe("CsharpFoo"); + }); + + it("ignores scoped values entirely when queried without a scope", async () => { + const { value } = await compileWithClientName(` + @clientName("DefaultFoo") + @clientName("CsharpFoo") when emitter("@typespec/http-client-csharp") + model Foo {}`); + + expect(value()).toBe("DefaultFoo"); + }); + + it("resolves each alternative in a '|' condition list", async () => { + const { value } = await compileWithClientName(` + @clientName("DotNetFoo") when emitter("@typespec/http-client-csharp") | language("csharp") + model Foo {}`); + + expect(value({ emitter: "@typespec/http-client-csharp" })).toBe("DotNetFoo"); + expect(value({ language: "csharp" })).toBe("DotNetFoo"); + expect(value({ language: "python" })).toBeUndefined(); + }); + + it("keeps independent values per dimension", async () => { + const { value } = await compileWithClientName(` + @clientName("CsharpFoo") when language("csharp") + @clientName("PythonFoo") when language("python") + model Foo {}`); + + expect(value({ language: "csharp" })).toBe("CsharpFoo"); + expect(value({ language: "python" })).toBe("PythonFoo"); + }); + + it("the topmost application wins, matching unscoped last-write-wins semantics", async () => { + // Decorators are applied bottom-up (`checkDecorators` prepends each one), so the + // topmost application is the last one to run — same as repeated unscoped auto decorators. + const { value } = await compileWithClientName(` + @clientName("Topmost") when language("csharp") + @clientName("Lower") when language("csharp") + model Foo {}`); + + expect(value({ language: "csharp" })).toBe("Topmost"); + }); + + it("does not report duplicate-decorator for repeated scoped applications", async () => { + // compileWithClientName asserts there are no diagnostics at all, which is the point: + // several scoped applications of the same decorator are the whole feature. + await compileWithClientName(` + @clientName("CsharpFoo") when language("csharp") + @clientName("PythonFoo") when language("python") + model Foo {}`); + }); + + it("matches only when every dimension in the condition is satisfied", async () => { + const { value } = await compileWithClientName(` + @clientName("Scoped") when target("client") + model Foo {}`); + + expect(value({ target: "client" })).toBe("Scoped"); + expect(value({ emitter: "client" })).toBeUndefined(); + }); +}); + +describe("composition with realm clones", () => { + it("resolves scoped state through a realm clone", async () => { + const { program, Foo, value } = await compileWithClientName(` + @clientName("CsharpFoo") when language("csharp") + model Foo {}`); + + const realm = new Realm(program, "test"); + const clone = realm.clone(Foo); + + expect(clone).not.toBe(Foo); + expect( + getAutoDecoratorValue(program, "clientName", clone, { language: "csharp" })?.["name"], + ).toBe("CsharpFoo"); + // Sanity: unscoped lookups now resolve through clones too. + expect(value({ language: "csharp" })).toBe("CsharpFoo"); + }); + + it("resolves scoped state through a chain of clones", async () => { + const { program, Foo } = await compileWithClientName(` + @clientName("CsharpFoo") when language("csharp") + model Foo {}`); + + const first = new Realm(program, "first"); + const second = new Realm(program, "second"); + const clone = second.clone(first.clone(Foo)); + + expect( + getAutoDecoratorValue(program, "clientName", clone, { language: "csharp" })?.["name"], + ).toBe("CsharpFoo"); + }); +}); diff --git a/packages/compiler/test/checker/when-clause.test.ts b/packages/compiler/test/checker/when-clause.test.ts new file mode 100644 index 00000000000..f660447263e --- /dev/null +++ b/packages/compiler/test/checker/when-clause.test.ts @@ -0,0 +1,141 @@ +import { describe, it } from "vitest"; +import type { CompilerOptions } from "../../src/core/options.js"; +import { + createTestHost, + expectDiagnosticEmpty, + expectDiagnostics, +} from "../../src/testing/index.js"; + +/** Root project config enabling the given features for the consumer's own files. */ +function projectOptions(features: string[]): CompilerOptions { + return { + configFile: { + projectRoot: ".", + kind: "project", + features, + diagnostics: [], + outputDir: "tsp-output", + }, + }; +} + +const ALL_FEATURES = ["auto-decorators", "scoped-decorators"]; + +async function diagnoseCode(code: string, features: string[] = ALL_FEATURES) { + const host = await createTestHost(); + host.addTypeSpecFile("main.tsp", code); + return await host.diagnose("main.tsp", projectOptions(features)); +} + +describe("when clause is restricted to auto decorators", () => { + it("allows a when clause on an auto decorator", async () => { + const diagnostics = await diagnoseCode(` + auto dec myName(target: unknown, value: valueof string); + @myName("Cs") when emitter("csharp") + model Foo {} + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("rejects a when clause on an extern decorator", async () => { + const diagnostics = await diagnoseCode(` + @doc("d") when emitter("csharp") + model Foo {} + `); + expectDiagnostics(diagnostics, { + code: "when-clause-not-allowed", + }); + }); + + it("rejects a when clause on an augment decorator", async () => { + // Augment decorators are deliberately not supported in phase 1. + const diagnostics = await diagnoseCode(` + auto dec myName(target: unknown, value: valueof string); + model Foo {} + @@myName(Foo, "Cs") when emitter("csharp"); + `); + expectDiagnostics(diagnostics, { + code: "when-clause-not-allowed", + }); + }); + + it("still validates decorator arguments when a when clause is present", async () => { + // Validation is unconditional; only storage is conditioned. + const diagnostics = await diagnoseCode(` + auto dec myName(target: unknown, value: valueof string); + @myName(123) when emitter("csharp") + model Foo {} + `); + expectDiagnostics(diagnostics, { code: "invalid-argument" }); + }); + + it("still validates decorator arity when a when clause is present", async () => { + const diagnostics = await diagnoseCode(` + auto dec myName(target: unknown, value: valueof string); + @myName("a", "b") when emitter("csharp") + model Foo {} + `); + expectDiagnostics(diagnostics, { code: "invalid-argument-count" }); + }); + + it("requires the scoped-decorators feature", async () => { + const diagnostics = await diagnoseCode( + ` + auto dec myName(target: unknown, value: valueof string); + @myName("Cs") when emitter("csharp") + model Foo {} + `, + ["auto-decorators"], + ); + expectDiagnostics(diagnostics, { code: "scoped-decorator-disabled" }); + }); + + it("does not report anything for an unconditioned auto decorator", async () => { + const diagnostics = await diagnoseCode(` + auto dec myName(target: unknown, value: valueof string); + @myName("Bar") + model Foo {} + `); + expectDiagnosticEmpty(diagnostics); + }); +}); + +describe("when condition resolution", () => { + it("rejects an unknown filter", async () => { + const diagnostics = await diagnoseCode(` + auto dec myName(target: unknown, value: valueof string); + @myName("Cs") when notAFilter("x") + model Foo {} + `); + expectDiagnostics(diagnostics, { code: "unknown-when-filter" }); + }); + + it("rejects a filter with the wrong argument count", async () => { + const diagnostics = await diagnoseCode(` + auto dec myName(target: unknown, value: valueof string); + @myName("Cs") when emitter() + model Foo {} + `); + expectDiagnostics(diagnostics, { code: "invalid-when-condition" }); + }); + + it("rejects a non-string filter argument", async () => { + const diagnostics = await diagnoseCode(` + auto dec myName(target: unknown, value: valueof string); + @myName("Cs") when emitter(123) + model Foo {} + `); + expectDiagnostics(diagnostics, { code: "invalid-when-condition" }); + }); + + it("accepts every built-in filter", async () => { + const diagnostics = await diagnoseCode(` + auto dec myName(target: unknown, value: valueof string); + @myName("A") when emitter("@typespec/http-client-csharp") + @myName("B") when language("csharp") + @myName("C") when target("client") + model Foo {} + `); + expectDiagnosticEmpty(diagnostics); + }); +}); diff --git a/packages/compiler/test/core/cli/actions/info.test.ts b/packages/compiler/test/core/cli/actions/info.test.ts index 5a0b9dcf695..80e7eb6f8ca 100644 --- a/packages/compiler/test/core/cli/actions/info.test.ts +++ b/packages/compiler/test/core/cli/actions/info.test.ts @@ -22,6 +22,7 @@ it("lists available compiler features and marks enabled features", () => { "", " enabled function-declarations Allows use of function declarations without experimental warnings in project code.", " disabled auto-decorators Allows use of auto decorator declarations without experimental warnings in project code.", + " disabled scoped-decorators Allows use of `when` clauses on `auto` decorator applications, conditioning the stored metadata on the querying scope (emitter, language, target).", " disabled type-info-provider Enables the experimental `$provideTypeInfo` provider allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", ]); }); diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index dce7cb8fe2c..73118403128 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -2549,6 +2549,77 @@ extern fn foo( }); describe("decorators", () => { + describe("when clause", () => { + it("normalizes spacing around a when clause", async () => { + await assertFormat({ + code: ` +@clientName("Foo") when emitter("csharp") +model Foo {} + `, + expected: ` +@clientName("Foo") when emitter("csharp") +model Foo {} + `, + }); + }); + + it("keeps alternatives on one line separated by '|'", async () => { + await assertFormat({ + code: ` +@clientName("Foo") when emitter("cs")|language("cs") | target("client") +model Foo {} + `, + expected: ` +@clientName("Foo") when emitter("cs") | language("cs") | target("client") +model Foo {} + `, + }); + }); + + it("breaks alternatives onto their own lines when too long", async () => { + await assertFormat({ + code: ` +@clientName("Foo") when emitter("@typespec/http-client-csharp") | language("csharp") | target("client") +model Foo {} + `, + expected: ` +@clientName("Foo") when emitter("@typespec/http-client-csharp") + | language("csharp") + | target("client") +model Foo {} + `, + }); + }); + + it("formats a when clause on a model property", async () => { + await assertFormat({ + code: ` +model Foo { + @clientName("Bar") when emitter("csharp") prop: string; +} + `, + expected: ` +model Foo { + @clientName("Bar") when emitter("csharp") prop: string; +} + `, + }); + }); + + it("formats a when clause with a member expression condition", async () => { + await assertFormat({ + code: ` +@clientName("Foo") when Lifecycle.Read +model Foo {} + `, + expected: ` +@clientName("Foo") when Lifecycle.Read +model Foo {} + `, + }); + }); + }); + it("keep simple decorators inline", async () => { await assertFormat({ code: ` diff --git a/packages/compiler/test/scanner.test.ts b/packages/compiler/test/scanner.test.ts index be15bd6f05e..af72c3922c1 100644 --- a/packages/compiler/test/scanner.test.ts +++ b/packages/compiler/test/scanner.test.ts @@ -400,6 +400,7 @@ it("provides friendly token display and classification", () => { Token.AutoKeyword, Token.ValueOfKeyword, Token.TypeOfKeyword, + Token.WhenKeyword, // `fn` can be either a statement or the start of an expr depending on context. Token.FnKeyword, ]; diff --git a/packages/compiler/test/server/colorization.test.ts b/packages/compiler/test/server/colorization.test.ts index 374d563fd7e..1ed1b78cc96 100644 --- a/packages/compiler/test/server/colorization.test.ts +++ b/packages/compiler/test/server/colorization.test.ts @@ -473,6 +473,22 @@ function testColorization(description: string, tokenize: Tokenize) { Token.punctuation.closeParen, ]); }); + it("decorator with a when clause", async () => { + const tokens = await tokenize(`@foo when emitter("csharp")`); + deepStrictEqual(tokens, [ + Token.identifiers.tag("@"), + Token.identifiers.tag("foo"), + Token.keywords.other("when"), + // The TextMate grammar cannot tell a condition from any other identifier; + // the semantic classifier sees the call expression. + tokenize === tokenizeTMLanguage + ? Token.identifiers.type("emitter") + : Token.identifiers.functionName("emitter"), + Token.punctuation.openParen, + Token.literals.stringQuoted("csharp"), + Token.punctuation.closeParen, + ]); + }); }); describe("augment decorators", () => { diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index f7fa5d55191..dbb31fdf8ab 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -134,19 +134,34 @@ describe("Test completion items for features", () => { it.each([ { config: `features:\n - ┆`, - expected: ['"auto-decorators"', '"function-declarations"', '"type-info-provider"'], + expected: [ + '"auto-decorators"', + '"function-declarations"', + '"scoped-decorators"', + '"type-info-provider"', + ], }, { config: `features:\n - "┆"`, - expected: ["auto-decorators", "function-declarations", "type-info-provider"], + expected: [ + "auto-decorators", + "function-declarations", + "scoped-decorators", + "type-info-provider", + ], }, { config: `features:\n - "function┆"`, - expected: ["auto-decorators", "function-declarations", "type-info-provider"], + expected: [ + "auto-decorators", + "function-declarations", + "scoped-decorators", + "type-info-provider", + ], }, { config: `features:\n - function-declarations\n - ┆`, - expected: ['"auto-decorators"', '"type-info-provider"'], + expected: ['"auto-decorators"', '"scoped-decorators"', '"type-info-provider"'], }, ])("#%# Test features: $config", async ({ config, expected }) => { await checkCompletionItems(config, true, expected); @@ -159,6 +174,7 @@ describe("Test completion items for features", () => { [ "Allows use of auto decorator declarations without experimental warnings in project code.", "Allows use of function declarations without experimental warnings in project code.", + "Allows use of `when` clauses on `auto` decorator applications, conditioning the stored metadata on the querying scope (emitter, language, target).", "Enables the experimental `$provideTypeInfo` provider allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", ], true, diff --git a/packages/compiler/test/typekit/utils.ts b/packages/compiler/test/typekit/utils.ts index 825e5033fdf..cfbe678b712 100644 --- a/packages/compiler/test/typekit/utils.ts +++ b/packages/compiler/test/typekit/utils.ts @@ -15,6 +15,8 @@ export async function createContextMock(program?: Program): Promise ({ ...overrides }), }; } diff --git a/packages/compiler/test/when-clause.test.ts b/packages/compiler/test/when-clause.test.ts new file mode 100644 index 00000000000..73d1239c893 --- /dev/null +++ b/packages/compiler/test/when-clause.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { hasParseError, parse } from "../src/core/parser.js"; +import type { + DecoratorExpressionNode, + ModelPropertyNode, + ModelStatementNode, + TypeSpecScriptNode, +} from "../src/core/types.js"; +import { SyntaxKind } from "../src/core/types.js"; + +function parseOk(code: string): TypeSpecScriptNode { + const script = parse(code); + expect( + script.parseDiagnostics.map((d) => `${d.code}: ${d.message}`), + "expected no parse diagnostics", + ).toEqual([]); + expect(hasParseError(script), "expected no parse errors").toBeFalsy(); + return script; +} + +function firstModel(script: TypeSpecScriptNode): ModelStatementNode { + const stmt = script.statements[0]; + expect(stmt.kind).toBe(SyntaxKind.ModelStatement); + return stmt as ModelStatementNode; +} + +function decoratorsOf(code: string): readonly DecoratorExpressionNode[] { + return firstModel(parseOk(code)).decorators; +} + +describe("when clause on decorators: parsing", () => { + it("parses a single condition", () => { + const [dec] = decoratorsOf(`@name("Cs") when emitter("csharp") model Foo {}`); + expect(dec.when).toBeDefined(); + expect(dec.when!.kind).toBe(SyntaxKind.WhenClause); + expect(dec.when!.conditions).toHaveLength(1); + expect(dec.when!.conditions[0].kind).toBe(SyntaxKind.CallExpression); + }); + + it("parses a member expression condition", () => { + const [dec] = decoratorsOf(`@name("R") when Lifecycle.read model Foo {}`); + expect(dec.when!.conditions).toHaveLength(1); + expect(dec.when!.conditions[0].kind).toBe(SyntaxKind.MemberExpression); + }); + + it("parses a bare identifier condition", () => { + const [dec] = decoratorsOf(`@name("R") when Everywhere model Foo {}`); + expect(dec.when!.conditions[0].kind).toBe(SyntaxKind.Identifier); + }); + + it("parses alternatives separated by `|`", () => { + const [dec] = decoratorsOf( + `@name("Cs") when emitter("csharp") | emitter("java") | Lifecycle.read model Foo {}`, + ); + expect(dec.when!.conditions).toHaveLength(3); + expect(dec.when!.conditions.map((c) => c.kind)).toEqual([ + SyntaxKind.CallExpression, + SyntaxKind.CallExpression, + SyntaxKind.MemberExpression, + ]); + }); + + it("leaves `when` undefined on an unconditioned decorator", () => { + const [dec] = decoratorsOf(`@name("Bar") model Foo {}`); + expect(dec.when).toBeUndefined(); + }); + + it("attaches the clause to the preceding decorator only", () => { + const decs = decoratorsOf(`@doc("d") @name("Cs") when emitter("csharp") model Foo {}`); + expect(decs).toHaveLength(2); + expect(decs[0].when).toBeUndefined(); + expect(decs[1].when).toBeDefined(); + }); + + it("allows a following decorator after a when clause", () => { + const decs = decoratorsOf(`@name("Cs") when emitter("csharp") @doc("d") model Foo {}`); + expect(decs).toHaveLength(2); + expect(decs[0].when).toBeDefined(); + expect(decs[1].when).toBeUndefined(); + }); + + it("allows each decorator in a list to carry its own clause", () => { + const decs = decoratorsOf( + `@name("Cs") when emitter("csharp")\n@name("Py") when emitter("python")\nmodel Foo {}`, + ); + expect(decs).toHaveLength(2); + expect(decs[0].when).toBeDefined(); + expect(decs[1].when).toBeDefined(); + }); + + it("supports a decorator with no arguments", () => { + const [dec] = decoratorsOf(`@internal when emitter("csharp") model Foo {}`); + expect(dec.when!.conditions).toHaveLength(1); + }); + + it("parses on a model property, terminated by the property name", () => { + const model = firstModel( + parseOk(`model Foo {\n @name("Cs") when emitter("csharp")\n prop: string;\n}`), + ); + const prop = model.properties[0] as ModelPropertyNode; + expect(prop.kind).toBe(SyntaxKind.ModelProperty); + expect(prop.id.sv).toBe("prop"); + expect(prop.decorators[0].when).toBeDefined(); + }); + + it("parses on a model property with a member-expression condition", () => { + const model = firstModel( + parseOk(`model Foo {\n @name("R") when Lifecycle.read\n prop: string;\n}`), + ); + const prop = model.properties[0] as ModelPropertyNode; + expect(prop.id.sv).toBe("prop"); + expect(prop.decorators[0].when!.conditions[0].kind).toBe(SyntaxKind.MemberExpression); + }); + + it("parses on an operation parameter", () => { + const script = parseOk(`op foo(@name("Cs") when emitter("csharp") p: string): void;`); + expect(script.statements[0].kind).toBe(SyntaxKind.OperationStatement); + }); + + it("parses on an augment decorator statement", () => { + const script = parseOk(`@@name(Foo, "Cs") when emitter("csharp");`); + const stmt = script.statements[0]; + expect(stmt.kind).toBe(SyntaxKind.AugmentDecoratorStatement); + expect((stmt as any).when).toBeDefined(); + }); + + it("is visited by visitChildren (no orphaned nodes)", () => { + // parseOk asserts hasParseError() agrees with parseDiagnostics, which walks + // children — an unvisited node would put those two out of sync. + parseOk(`@name("Cs") when emitter("csharp") | Lifecycle.read model Foo {}`); + }); +}); + +describe("when clause on decorators: `when` as a keyword", () => { + it("rejects `when` as a declaration name", () => { + const script = parse(`model when {}`); + expect(script.parseDiagnostics.length).toBeGreaterThan(0); + }); + + it("still allows `when` as a model property name", () => { + // Property names accept keywords, matching how other keywords behave. + parseOk(`model Foo { when: string; }`); + }); + + it("still allows `when` as an enum member name", () => { + parseOk(`enum Foo { when }`); + }); +}); + +describe("when clause on decorators: rejected syntax", () => { + function parseError(code: string) { + const script = parse(code); + expect( + script.parseDiagnostics.length, + `expected parse diagnostics for ${JSON.stringify(code)}`, + ).toBeGreaterThan(0); + return script.parseDiagnostics; + } + + it("rejects comma-separated condition lists", () => { + // `,` is the model-property and op-parameter separator; a comma list is ambiguous. + parseError(`model Foo {\n @name("a") when emitter("x"), emitter("y")\n prop: string;\n}`); + }); + + it("rejects an empty condition", () => { + parseError(`@name("a") when model Foo {}`); + }); + + it("rejects a dangling `|`", () => { + parseError(`@name("a") when emitter("x") | model Foo {}`); + }); + + it("rejects a string literal as a condition", () => { + parseError(`@name("a") when "csharp" model Foo {}`); + }); + + it("rejects a trailing when clause on a statement", () => { + // Deliberately unsupported: see the syntax spike — the trailing form makes + // `model Foo {} when c` vs `model Foo {} when c { ... }` silently different. + parseError(`model Foo {} when emitter("x")`); + }); +}); diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 9784ffbb8ab..17875cdabbb 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -176,6 +176,7 @@ export async function getOpenAPI3( program: Program, options: Omit = {}, ): Promise { + const scope = { emitter: "@typespec/openapi3" }; const context: EmitContext = { program, @@ -184,6 +185,8 @@ export async function getOpenAPI3( options: options, perf: createPerfReporter(), + scope, + createScope: (overrides) => ({ ...scope, ...overrides }), }; const resolvedOptions = resolveOptions(context);