From aeef90ff03ab359d9382c759619d089d7c37d440 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 5 May 2026 09:07:18 -0400 Subject: [PATCH 1/6] Parsing of when clause --- packages/compiler/src/core/parser.ts | 81 +++++++++- packages/compiler/src/core/types.ts | 34 ++++- .../compiler/src/formatter/print/printer.ts | 2 + packages/compiler/test/when-clause.test.ts | 138 ++++++++++++++++++ 4 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/when-clause.test.ts diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 63ed0076f13..e5bbe70648d 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -108,6 +108,8 @@ import { UsingStatementNode, ValueOfExpressionNode, VoidKeywordNode, + WhenClauseNode, + WhenExpressionNode, } from "./types.js"; /** @@ -934,6 +936,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa bodyRange: propDetail.range, modifiers, modifierFlags: modifiersToFlags(modifiers), + when: parseOptionalWhenClause(), ...finishNode(pos), }; } @@ -1046,6 +1049,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const hasDefault = parseOptional(Token.Equals); const defaultValue = hasDefault ? parseExpression() : undefined; + const whenClause = parseOptionalWhenClause(); return { kind: SyntaxKind.ModelProperty, id, @@ -1053,6 +1057,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa value, optional, default: defaultValue, + when: whenClause, ...finishNode(pos), }; } @@ -1595,10 +1600,74 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa allowReservedIdentifierInMember: true, }); const { items: args } = parseOptionalList(ListKind.DecoratorArguments, parseExpression); + const whenClause = parseOptionalWhenClause(); return { kind: SyntaxKind.DecoratorExpression, arguments: args, target, + when: whenClause, + ...finishNode(pos), + }; + } + + /** + * Parse an optional `when` clause after a decorator, statement, or property. + * Returns undefined if no `when` keyword is present. + * Uses contextual keyword detection to avoid breaking `when` used as an identifier. + * + * Grammar: + * WhenClause ::= 'when' WhenExpression (',' WhenExpression)* + */ + function parseOptionalWhenClause(): WhenClauseNode | undefined { + if (!isWhenKeyword()) { + return undefined; + } + const pos = tokenPos(); + nextToken(); // consume 'when' + + const conditions: WhenExpressionNode[] = []; + conditions.push(parseWhenExpression()); + + while (parseOptional(Token.Comma)) { + conditions.push(parseWhenExpression()); + } + + return { + kind: SyntaxKind.WhenClause, + conditions, + ...finishNode(pos), + }; + } + + /** + * Check if the current token is the contextual keyword `when`. + * `when` is not a reserved keyword — it can still be used as an identifier. + */ + function isWhenKeyword(): boolean { + return token() === Token.Identifier && tokenValue() === "when"; + } + + /** + * Parse a single when condition expression. + * Either a filter call like `emitter("name")` or an enum member reference like `Lifecycle.read`. + * + * Grammar: + * WhenExpression ::= Identifier('.' Identifier)* ('(' Expression (',' Expression)* ')')? + */ + function parseWhenExpression(): WhenExpressionNode { + const pos = tokenPos(); + const target = parseIdentifierOrMemberExpression({ + allowReservedIdentifier: true, + allowReservedIdentifierInMember: true, + }); + + // Parse optional arguments (for filter calls like `emitter("name")`) + const { items: args } = parseOptionalList(ListKind.DecoratorArguments, parseExpression); + + return { + kind: SyntaxKind.WhenExpression, + target, + arguments: args, ...finishNode(pos), }; } @@ -2980,7 +3049,7 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitEach(cb, node.arguments) ); 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.CallExpression: return visitNode(cb, node.target) || visitEach(cb, node.arguments); case SyntaxKind.DirectiveExpression: @@ -3028,7 +3097,8 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitEach(cb, node.decorators) || visitNode(cb, node.id) || visitNode(cb, node.value) || - visitNode(cb, node.default) + visitNode(cb, node.default) || + visitNode(cb, node.when) ); case SyntaxKind.ModelSpreadProperty: return visitNode(cb, node.target); @@ -3041,7 +3111,8 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitEach(cb, node.templateParameters) || visitNode(cb, node.extends) || visitNode(cb, node.is) || - visitEach(cb, node.properties) + visitEach(cb, node.properties) || + visitNode(cb, node.when) ); case SyntaxKind.ScalarStatement: return ( @@ -3154,6 +3225,10 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined return visitNode(cb, node.target); case SyntaxKind.ArrayLiteral: return visitEach(cb, node.values); + case SyntaxKind.WhenClause: + return visitEach(cb, node.conditions); + case SyntaxKind.WhenExpression: + return visitNode(cb, node.target) || visitEach(cb, node.arguments); // no children for the rest of these. case SyntaxKind.StringTemplateHead: case SyntaxKind.StringTemplateMiddle: diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index a465e894c58..1897452fcc9 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -1176,6 +1176,8 @@ export enum SyntaxKind { ScalarConstructor, InternalKeyword, FunctionTypeExpression, + WhenExpression, + WhenClause, } export const enum NodeFlags { @@ -1299,7 +1301,9 @@ export type Node = | ObjectLiteralPropertyNode | ObjectLiteralSpreadPropertyNode | ScalarConstructorNode - | ArrayLiteralNode; + | ArrayLiteralNode + | WhenClauseNode + | WhenExpressionNode; /** * Node that can be used as template @@ -1432,6 +1436,32 @@ export interface DecoratorExpressionNode extends BaseNode { readonly kind: SyntaxKind.DecoratorExpression; readonly target: IdentifierNode | MemberExpressionNode; readonly arguments: readonly Expression[]; + readonly when?: WhenClauseNode; +} + +/** + * A `when` clause that conditionally scopes a decorator, statement, or property. + * Contains one or more conditions (AND semantics when multiple). + * + * Examples: + * - `when emitter("@typespec/http-client-csharp")` + * - `when emitter("csharp"), target("client")` + */ +export interface WhenClauseNode extends BaseNode { + readonly kind: SyntaxKind.WhenClause; + readonly conditions: readonly WhenExpressionNode[]; +} + +/** + * A single condition within a `when` clause. + * Either a filter call like `emitter("name")` or an enum member reference like `Lifecycle.read`. + */ +export interface WhenExpressionNode extends BaseNode { + readonly kind: SyntaxKind.WhenExpression; + /** The filter/predicate identifier (e.g., `emitter`, `language`, `target`, `since`, `between`) or a member expression */ + readonly target: IdentifierNode | MemberExpressionNode; + /** Arguments to the filter call, if any (e.g., the string `"@typespec/http-client-csharp"`) */ + readonly arguments: readonly Expression[]; } export interface AugmentDecoratorStatementNode extends BaseNode { @@ -1531,6 +1561,7 @@ export interface ModelStatementNode extends BaseNode, DeclarationNode, TemplateD readonly extends?: Expression; readonly is?: Expression; readonly decorators: readonly DecoratorExpressionNode[]; + readonly when?: WhenClauseNode; readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } @@ -1643,6 +1674,7 @@ export interface ModelPropertyNode extends BaseNode { readonly decorators: readonly DecoratorExpressionNode[]; readonly optional: boolean; readonly default?: Expression; + readonly when?: WhenClauseNode; readonly parent?: ModelStatementNode | ModelExpressionNode; } diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 0a104e021d7..ffcfffc200b 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -311,6 +311,8 @@ export function printNode( case SyntaxKind.JsSourceFile: case SyntaxKind.JsNamespaceDeclaration: case SyntaxKind.InvalidStatement: + case SyntaxKind.WhenClause: + case SyntaxKind.WhenExpression: return getRawText(node, options); default: // Dummy const to ensure we handle all node types. diff --git a/packages/compiler/test/when-clause.test.ts b/packages/compiler/test/when-clause.test.ts new file mode 100644 index 00000000000..347112c606e --- /dev/null +++ b/packages/compiler/test/when-clause.test.ts @@ -0,0 +1,138 @@ +import { deepStrictEqual, ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { parse, hasParseError } from "../src/core/parser.js"; +import { + DecoratorExpressionNode, + ModelPropertyNode, + ModelStatementNode, + SyntaxKind, + WhenClauseNode, +} from "../src/core/types.js"; + +describe("compiler: when clause", () => { + describe("decorator with when clause", () => { + it("parses single condition on decorator", () => { + const ast = parseSuccessfully( + `@name("CsBar") when emitter("@typespec/http-client-csharp") model Bar {}`, + ); + const model = ast.statements[0] as ModelStatementNode; + strictEqual(model.kind, SyntaxKind.ModelStatement); + strictEqual(model.decorators.length, 1); + + const dec = model.decorators[0]; + ok(dec.when, "decorator should have a when clause"); + strictEqual(dec.when.kind, SyntaxKind.WhenClause); + strictEqual(dec.when.conditions.length, 1); + + const cond = dec.when.conditions[0]; + strictEqual(cond.kind, SyntaxKind.WhenExpression); + strictEqual(cond.target.kind, SyntaxKind.Identifier); + if (cond.target.kind === SyntaxKind.Identifier) { + strictEqual(cond.target.sv, "emitter"); + } + strictEqual(cond.arguments.length, 1); + }); + + it("parses multiple conditions on decorator (AND semantics)", () => { + const ast = parseSuccessfully( + `@name("Foo") when emitter("csharp"), target("client") model Bar {}`, + ); + const model = ast.statements[0] as ModelStatementNode; + const dec = model.decorators[0]; + ok(dec.when); + strictEqual(dec.when.conditions.length, 2); + strictEqual(dec.when.conditions[0].kind, SyntaxKind.WhenExpression); + strictEqual(dec.when.conditions[1].kind, SyntaxKind.WhenExpression); + }); + + it("parses enum member reference as condition", () => { + const ast = parseSuccessfully(`@visibility when Lifecycle.read model Foo {}`); + const model = ast.statements[0] as ModelStatementNode; + const dec = model.decorators[0]; + ok(dec.when); + strictEqual(dec.when.conditions.length, 1); + const cond = dec.when.conditions[0]; + // Enum member ref has no arguments + strictEqual(cond.arguments.length, 0); + // Target is a member expression + strictEqual(cond.target.kind, SyntaxKind.MemberExpression); + }); + + it("decorator without when clause still works", () => { + const ast = parseSuccessfully(`@name("Foo") model Bar {}`); + const model = ast.statements[0] as ModelStatementNode; + const dec = model.decorators[0]; + strictEqual(dec.when, undefined); + }); + }); + + describe("model property with when clause", () => { + it("parses property with when clause", () => { + const ast = parseSuccessfully(`model Foo { name: string when Lifecycle.read; }`); + const model = ast.statements[0] as ModelStatementNode; + const prop = model.properties[0] as ModelPropertyNode; + ok(prop.when, "property should have a when clause"); + strictEqual(prop.when.conditions.length, 1); + strictEqual(prop.when.conditions[0].target.kind, SyntaxKind.MemberExpression); + }); + + it("parses property with filter call condition", () => { + const ast = parseSuccessfully(`model Foo { id: string when since(Versions.v2); }`); + const model = ast.statements[0] as ModelStatementNode; + const prop = model.properties[0] as ModelPropertyNode; + ok(prop.when); + strictEqual(prop.when.conditions.length, 1); + const cond = prop.when.conditions[0]; + if (cond.target.kind === SyntaxKind.Identifier) { + strictEqual(cond.target.sv, "since"); + } + strictEqual(cond.arguments.length, 1); + }); + + it("property without when clause still works", () => { + const ast = parseSuccessfully(`model Foo { name: string; }`); + const model = ast.statements[0] as ModelStatementNode; + const prop = model.properties[0] as ModelPropertyNode; + strictEqual(prop.when, undefined); + }); + }); + + describe("model statement with when clause", () => { + it("parses model with trailing when clause", () => { + const ast = parseSuccessfully(`model Foo {} when since(Version.v2)`); + const model = ast.statements[0] as ModelStatementNode; + ok(model.when, "model should have a when clause"); + strictEqual(model.when.conditions.length, 1); + }); + + it("parses model with between condition", () => { + const ast = parseSuccessfully(`model Foo {} when between(Versions.v2, Versions.v3)`); + const model = ast.statements[0] as ModelStatementNode; + ok(model.when); + const cond = model.when.conditions[0]; + if (cond.target.kind === SyntaxKind.Identifier) { + strictEqual(cond.target.sv, "between"); + } + strictEqual(cond.arguments.length, 2); + }); + }); + + describe("when used as identifier (backward compatibility)", () => { + it("allows 'when' as a model property name", () => { + const ast = parseSuccessfully(`model Foo { when: string; }`); + const model = ast.statements[0] as ModelStatementNode; + const prop = model.properties[0] as ModelPropertyNode; + strictEqual(prop.id.sv, "when"); + strictEqual(prop.when, undefined); + }); + }); +}); + +function parseSuccessfully(code: string) { + const ast = parse(code); + if (hasParseError(ast)) { + const errors = ast.parseDiagnostics.map((d) => d.message).join("\n"); + throw new Error(`Unexpected parse errors:\n${errors}`); + } + return ast; +} From ac5f74a78b3c7ba0fbdeb0d6da22985d57424ba1 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 5 May 2026 09:55:51 -0400 Subject: [PATCH 2/6] CHeck --- packages/compiler/src/core/checker.ts | 56 +++++++ packages/compiler/src/core/parser.ts | 36 ++++- packages/compiler/src/core/types.ts | 34 +++- packages/compiler/src/core/when-scope.ts | 104 ++++++++++++ .../compiler/src/formatter/print/printer.ts | 1 + packages/compiler/test/when-clause.test.ts | 46 ++++++ .../compiler/test/when-integration.test.ts | 79 +++++++++ packages/compiler/test/when-scope.test.ts | 153 ++++++++++++++++++ 8 files changed, 506 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/src/core/when-scope.ts create mode 100644 packages/compiler/test/when-integration.test.ts create mode 100644 packages/compiler/test/when-scope.test.ts diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 6d6b43daf1e..937acf3e4e9 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -172,6 +172,8 @@ import { Value, ValueWithTemplate, VoidType, + WhenCondition, + WhenExpressionNode, } from "./types.js"; export type CreateTypeProps = Omit; @@ -5759,8 +5761,62 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorator: sym.value ?? ((...args: any[]) => {}), node: decNode, args, + when: resolveWhenConditions(decNode), }; } + + /** + * Resolve the `when` clause on a decorator into structured conditions. + * Returns undefined if no `when` clause is present. + */ + function resolveWhenConditions( + decNode: DecoratorExpressionNode | AugmentDecoratorStatementNode, + ): WhenCondition[] | undefined { + if (decNode.kind !== SyntaxKind.DecoratorExpression || !decNode.when) { + return undefined; + } + const conditions: WhenCondition[] = []; + for (const condNode of decNode.when.conditions) { + const condition = resolveWhenExpression(condNode); + if (condition) { + conditions.push(condition); + } + } + return conditions.length > 0 ? conditions : undefined; + } + + /** + * Resolve a single when condition expression into a WhenCondition. + */ + function resolveWhenExpression( + node: WhenExpressionNode, + ): WhenCondition | undefined { + // Determine the filter kind from the target identifier + if (node.target.kind === SyntaxKind.Identifier) { + const name = node.target.sv; + const knownFilters = ["emitter", "language", "target", "since", "between"]; + if (knownFilters.includes(name)) { + // Extract raw string args for simple matching + const rawArgs: string[] = []; + for (const arg of node.arguments) { + if (arg.kind === SyntaxKind.StringLiteral) { + rawArgs.push(arg.value); + } + } + return { + kind: name as WhenCondition["kind"], + args: [], + rawArgs: rawArgs.length > 0 ? rawArgs : undefined, + }; + } + } + // Treat as enum member reference (e.g., Lifecycle.read) + return { + kind: "member", + args: [], + }; + } + /** Check the decorator target is valid */ function checkDecoratorTarget(targetType: Type, declaration: Decorator, decoratorNode: Node) { diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index e5bbe70648d..25c27f97081 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -108,6 +108,7 @@ import { UsingStatementNode, ValueOfExpressionNode, VoidKeywordNode, + WhenBlockStatementNode, WhenClauseNode, WhenExpressionNode, } from "./types.js"; @@ -468,6 +469,11 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa item = parseDeclaration(pos, decorators, docs, directives); break; default: + if (isWhenKeyword()) { + reportInvalidDecorators(decorators, "when block statement"); + item = parseWhenBlockStatement(pos); + break; + } item = parseInvalidStatement(pos, decorators); break; } @@ -501,7 +507,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return stmts; } - function parseStatementList(): Statement[] { + function parseStatementList(): Statement[] { const stmts: Statement[] = []; while (token() !== Token.CloseBrace) { @@ -546,6 +552,11 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa item = parseEmptyStatement(pos); break; default: + if (isWhenKeyword()) { + reportInvalidDecorators(decorators, "when block statement"); + item = parseWhenBlockStatement(pos); + break; + } item = parseInvalidStatement(pos, decorators); break; } @@ -1672,6 +1683,27 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + /** + * Parse a `when` block statement: `when condition { ...statements }` + * The 'when' identifier has already been verified but not consumed. + */ + function parseWhenBlockStatement(pos: number): WhenBlockStatementNode { + // parseOptionalWhenClause will consume 'when' and parse conditions + const whenClause = parseOptionalWhenClause()!; + + // Parse the block body: { ...statements } + parseExpected(Token.OpenBrace); + const statements = parseStatementList(); + parseExpected(Token.CloseBrace); + + return { + kind: SyntaxKind.WhenBlockStatement, + when: whenClause, + statements, + ...finishNode(pos), + }; + } + function parseDirectiveExpression(): DirectiveExpressionNode { const pos = tokenPos(); parseExpected(Token.Hash); @@ -3229,6 +3261,8 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined return visitEach(cb, node.conditions); case SyntaxKind.WhenExpression: return visitNode(cb, node.target) || visitEach(cb, node.arguments); + case SyntaxKind.WhenBlockStatement: + return visitNode(cb, node.when) || visitEach(cb, node.statements); // no children for the rest of these. case SyntaxKind.StringTemplateHead: case SyntaxKind.StringTemplateMiddle: diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 1897452fcc9..4db2967b48d 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -46,6 +46,20 @@ export interface DecoratorApplication { decorator: DecoratorFunction; args: DecoratorArgument[]; node?: DecoratorExpressionNode | AugmentDecoratorStatementNode; + /** Conditions under which this decorator applies (from `when` clause). Empty means unconditional. */ + when?: WhenCondition[]; +} + +/** + * A resolved condition from a `when` clause, ready for runtime filtering. + */ +export interface WhenCondition { + /** The kind of filter: emitter name, language, target kind, version predicate, or enum member */ + readonly kind: "emitter" | "language" | "target" | "since" | "between" | "member"; + /** The argument values (e.g., emitter name string, version refs) */ + readonly args: (Type | Value)[]; + /** Raw string values for simple string args (for efficient matching) */ + readonly rawArgs?: string[]; } /** @@ -1178,6 +1192,7 @@ export enum SyntaxKind { FunctionTypeExpression, WhenExpression, WhenClause, + WhenBlockStatement, } export const enum NodeFlags { @@ -1303,7 +1318,8 @@ export type Node = | ScalarConstructorNode | ArrayLiteralNode | WhenClauseNode - | WhenExpressionNode; + | WhenExpressionNode + | WhenBlockStatementNode; /** * Node that can be used as template @@ -1392,7 +1408,8 @@ export type Statement = | ConstStatementNode | CallExpressionNode | EmptyStatementNode - | InvalidStatementNode; + | InvalidStatementNode + | WhenBlockStatementNode; export interface DeclarationNode { /** @@ -1464,6 +1481,19 @@ export interface WhenExpressionNode extends BaseNode { readonly arguments: readonly Expression[]; } +/** + * A `when` block statement that conditionally includes declarations. + * `when condition { ...statements }` + * + * Semantically equivalent to applying the `when` clause to each statement in the block. + */ +export interface WhenBlockStatementNode extends BaseNode { + readonly kind: SyntaxKind.WhenBlockStatement; + readonly when: WhenClauseNode; + readonly statements: readonly Statement[]; + readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; +} + export interface AugmentDecoratorStatementNode extends BaseNode { readonly kind: SyntaxKind.AugmentDecoratorStatement; readonly target: IdentifierNode | MemberExpressionNode; diff --git a/packages/compiler/src/core/when-scope.ts b/packages/compiler/src/core/when-scope.ts new file mode 100644 index 00000000000..cbab1a7002c --- /dev/null +++ b/packages/compiler/src/core/when-scope.ts @@ -0,0 +1,104 @@ +import type { DecoratorApplication, Type, WhenCondition } from "./types.js"; + +/** + * An emitter scope defines the context for filtering scoped decorators. + * Emitters create a scope to query metadata that's conditioned with `when` clauses. + */ +export interface EmitterScope { + /** The emitter package name (e.g., "@typespec/http-client-csharp") */ + readonly emitter?: string; + /** The target language (e.g., "csharp", "python", "java") */ + readonly language?: string; + /** The emitter kind (e.g., "client", "server") */ + readonly target?: string; +} + +/** + * Check if a decorator application matches the given scope. + * A decorator matches if: + * - It has no `when` conditions (unconditional), OR + * - All of its `when` conditions are satisfied by the scope + * + * @param decorator The decorator application to check + * @param scope The emitter scope to match against + * @returns true if the decorator is active in the given scope + */ +export function decoratorMatchesScope( + decorator: DecoratorApplication, + scope: EmitterScope, +): boolean { + if (!decorator.when || decorator.when.length === 0) { + return true; // Unconditional decorator always matches + } + + // All conditions must match (AND semantics) + return decorator.when.every((condition) => conditionMatchesScope(condition, scope)); +} + +/** + * Check if a single when condition matches the given scope. + */ +function conditionMatchesScope(condition: WhenCondition, scope: EmitterScope): boolean { + switch (condition.kind) { + case "emitter": + if (!scope.emitter || !condition.rawArgs) return false; + return condition.rawArgs.includes(scope.emitter); + case "language": + if (!scope.language || !condition.rawArgs) return false; + return condition.rawArgs.includes(scope.language); + case "target": + if (!scope.target || !condition.rawArgs) return false; + return condition.rawArgs.includes(scope.target); + case "since": + case "between": + // Version-based conditions require version projection (mutator-based) + // For POC, these always match (would need version context) + return true; + case "member": + // Enum member conditions require visibility context + // For POC, these always match (would need visibility filter) + return true; + default: + return true; + } +} + +/** + * Filter a type's decorators by scope, returning only those that are active. + * + * @param type The type whose decorators to filter + * @param scope The emitter scope + * @returns Decorators that are unconditional or match the scope + */ +export function getDecoratorsByScope( + type: Type & { decorators: DecoratorApplication[] }, + scope: EmitterScope, +): DecoratorApplication[] { + return type.decorators.filter((d) => decoratorMatchesScope(d, scope)); +} + +/** + * Get the first decorator matching a specific name that's active in the scope. + * + * @param type The type to query + * @param decoratorName The decorator function or namespace-qualified name + * @param scope The emitter scope + * @returns The matching decorator application, or undefined + */ +export function getScopedDecorator( + type: Type & { decorators: DecoratorApplication[] }, + decoratorFn: Function, + scope: EmitterScope, +): DecoratorApplication | undefined { + return type.decorators.find( + (d) => d.decorator === decoratorFn && decoratorMatchesScope(d, scope), + ); +} + +/** + * Create an emitter scope from configuration. + * Used by emitters in their `$onEmit` function. + */ +export function createEmitterScope(options: EmitterScope): EmitterScope { + return { ...options }; +} diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index ffcfffc200b..33f23d7512a 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -313,6 +313,7 @@ export function printNode( case SyntaxKind.InvalidStatement: case SyntaxKind.WhenClause: case SyntaxKind.WhenExpression: + case SyntaxKind.WhenBlockStatement: return getRawText(node, options); default: // Dummy const to ensure we handle all node types. diff --git a/packages/compiler/test/when-clause.test.ts b/packages/compiler/test/when-clause.test.ts index 347112c606e..dad88c96d28 100644 --- a/packages/compiler/test/when-clause.test.ts +++ b/packages/compiler/test/when-clause.test.ts @@ -6,6 +6,7 @@ import { ModelPropertyNode, ModelStatementNode, SyntaxKind, + WhenBlockStatementNode, WhenClauseNode, } from "../src/core/types.js"; @@ -126,6 +127,51 @@ describe("compiler: when clause", () => { strictEqual(prop.when, undefined); }); }); + + describe("when block statement", () => { + it("parses when block with single model", () => { + const ast = parseSuccessfully(`when since(Version.v2) { model Foo {} }`); + const block = ast.statements[0] as WhenBlockStatementNode; + strictEqual(block.kind, SyntaxKind.WhenBlockStatement); + ok(block.when); + strictEqual(block.when.conditions.length, 1); + strictEqual(block.statements.length, 1); + strictEqual(block.statements[0].kind, SyntaxKind.ModelStatement); + }); + + it("parses when block with multiple declarations", () => { + const ast = parseSuccessfully(`when emitter("csharp") { + model Foo {} + model Bar {} + op doStuff(): void; + }`); + const block = ast.statements[0] as WhenBlockStatementNode; + strictEqual(block.statements.length, 3); + }); + + it("parses nested when blocks", () => { + const ast = parseSuccessfully(`when emitter("csharp") { + when target("client") { + model Foo {} + } + }`); + const outer = ast.statements[0] as WhenBlockStatementNode; + strictEqual(outer.statements.length, 1); + const inner = outer.statements[0] as WhenBlockStatementNode; + strictEqual(inner.kind, SyntaxKind.WhenBlockStatement); + strictEqual(inner.statements.length, 1); + }); + + it("parses when block with decorators on contained declarations", () => { + const ast = parseSuccessfully(`when since(Version.v2) { + @doc("A new model") + model Foo {} + }`); + const block = ast.statements[0] as WhenBlockStatementNode; + const model = block.statements[0] as ModelStatementNode; + strictEqual(model.decorators.length, 1); + }); + }); }); function parseSuccessfully(code: string) { diff --git a/packages/compiler/test/when-integration.test.ts b/packages/compiler/test/when-integration.test.ts new file mode 100644 index 00000000000..72d2ada2e12 --- /dev/null +++ b/packages/compiler/test/when-integration.test.ts @@ -0,0 +1,79 @@ +import { ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { Model } from "../src/core/types.js"; +import { createEmitterScope, getDecoratorsByScope } from "../src/core/when-scope.js"; +import { Tester } from "./tester.js"; + +describe("compiler: when clause integration", () => { + it("decorator with when clause carries condition through to type", async () => { + const [{ program }, diagnostics] = await Tester.compileAndDiagnose(` + @doc("always") + @doc("csharp-only") when emitter("@typespec/http-client-csharp") + model Bar {} + `); + + // Expect duplicate-decorator warnings (since we use @doc twice) — that's fine for POC + const barType: Model = program.checker.getGlobalNamespaceType().models.get("Bar")!; + ok(barType, "Bar model should exist"); + + // The type should have 2 decorator applications + strictEqual(barType.decorators.length, 2); + + // Decorators are stored closest-to-declaration first + // @doc("csharp-only") when emitter("...") is closest, @doc("always") is outermost + + // First decorator (closest to model) has a when condition + ok(barType.decorators[0].when); + strictEqual(barType.decorators[0].when.length, 1); + strictEqual(barType.decorators[0].when[0].kind, "emitter"); + strictEqual(barType.decorators[0].when[0].rawArgs![0], "@typespec/http-client-csharp"); + + // Second decorator (outermost) is unconditional + strictEqual(barType.decorators[1].when, undefined); + + // Query by scope + const csharpScope = createEmitterScope({ emitter: "@typespec/http-client-csharp" }); + const pythonScope = createEmitterScope({ emitter: "@typespec/http-client-python" }); + + const csharpDecs = getDecoratorsByScope(barType, csharpScope); + strictEqual(csharpDecs.length, 2); // both: unconditional + csharp-scoped + + const pythonDecs = getDecoratorsByScope(barType, pythonScope); + strictEqual(pythonDecs.length, 1); // only unconditional + }); + + it("emitter scope query works with multiple scoped decorators", async () => { + const [{ program }] = await Tester.compileAndDiagnose(` + @doc("CsFoo") when emitter("@typespec/http-client-csharp") + @doc("PyFoo") when emitter("@typespec/http-client-python") + @doc("Default") + model Foo {} + `); + + const fooType: Model = program.checker.getGlobalNamespaceType().models.get("Foo")!; + ok(fooType, "Foo model should exist"); + + strictEqual(fooType.decorators.length, 3); + + // C# emitter should see default + csharp-scoped + const csharpDecs = getDecoratorsByScope( + fooType, + createEmitterScope({ emitter: "@typespec/http-client-csharp" }), + ); + strictEqual(csharpDecs.length, 2); + + // Python emitter should see default + python-scoped + const pyDecs = getDecoratorsByScope( + fooType, + createEmitterScope({ emitter: "@typespec/http-client-python" }), + ); + strictEqual(pyDecs.length, 2); + + // Java emitter should see only default + const javaDecs = getDecoratorsByScope( + fooType, + createEmitterScope({ emitter: "@typespec/http-client-java" }), + ); + strictEqual(javaDecs.length, 1); + }); +}); diff --git a/packages/compiler/test/when-scope.test.ts b/packages/compiler/test/when-scope.test.ts new file mode 100644 index 00000000000..3e8ca1b9c59 --- /dev/null +++ b/packages/compiler/test/when-scope.test.ts @@ -0,0 +1,153 @@ +import { ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { + createEmitterScope, + decoratorMatchesScope, + getDecoratorsByScope, +} from "../src/core/when-scope.js"; +import type { DecoratorApplication, WhenCondition } from "../src/core/types.js"; + +describe("compiler: when scope system", () => { + describe("decoratorMatchesScope", () => { + it("unconditional decorator always matches", () => { + const dec = createMockDecorator(); + const scope = createEmitterScope({ emitter: "@typespec/http-client-csharp" }); + strictEqual(decoratorMatchesScope(dec, scope), true); + }); + + it("matches emitter condition with correct emitter", () => { + const dec = createMockDecorator([ + { kind: "emitter", args: [], rawArgs: ["@typespec/http-client-csharp"] }, + ]); + const scope = createEmitterScope({ emitter: "@typespec/http-client-csharp" }); + strictEqual(decoratorMatchesScope(dec, scope), true); + }); + + it("does not match emitter condition with wrong emitter", () => { + const dec = createMockDecorator([ + { kind: "emitter", args: [], rawArgs: ["@typespec/http-client-csharp"] }, + ]); + const scope = createEmitterScope({ emitter: "@typespec/http-client-python" }); + strictEqual(decoratorMatchesScope(dec, scope), false); + }); + + it("matches language condition", () => { + const dec = createMockDecorator([{ kind: "language", args: [], rawArgs: ["csharp"] }]); + const scope = createEmitterScope({ language: "csharp" }); + strictEqual(decoratorMatchesScope(dec, scope), true); + }); + + it("does not match language condition with wrong language", () => { + const dec = createMockDecorator([{ kind: "language", args: [], rawArgs: ["csharp"] }]); + const scope = createEmitterScope({ language: "python" }); + strictEqual(decoratorMatchesScope(dec, scope), false); + }); + + it("matches target condition", () => { + const dec = createMockDecorator([{ kind: "target", args: [], rawArgs: ["client"] }]); + const scope = createEmitterScope({ target: "client" }); + strictEqual(decoratorMatchesScope(dec, scope), true); + }); + + it("multiple conditions require all to match (AND semantics)", () => { + const dec = createMockDecorator([ + { kind: "emitter", args: [], rawArgs: ["@typespec/http-client-csharp"] }, + { kind: "target", args: [], rawArgs: ["client"] }, + ]); + + // Both match + strictEqual( + decoratorMatchesScope( + dec, + createEmitterScope({ emitter: "@typespec/http-client-csharp", target: "client" }), + ), + true, + ); + + // Only emitter matches + strictEqual( + decoratorMatchesScope( + dec, + createEmitterScope({ emitter: "@typespec/http-client-csharp", target: "server" }), + ), + false, + ); + + // Only target matches + strictEqual( + decoratorMatchesScope( + dec, + createEmitterScope({ emitter: "@typespec/http-client-python", target: "client" }), + ), + false, + ); + }); + + it("does not match when scope is missing required dimension", () => { + const dec = createMockDecorator([ + { kind: "emitter", args: [], rawArgs: ["@typespec/http-client-csharp"] }, + ]); + // No emitter in scope + strictEqual(decoratorMatchesScope(dec, createEmitterScope({})), false); + }); + + it("version conditions match for now (POC)", () => { + const dec = createMockDecorator([{ kind: "since", args: [], rawArgs: ["v2"] }]); + strictEqual(decoratorMatchesScope(dec, createEmitterScope({})), true); + }); + }); + + describe("getDecoratorsByScope", () => { + it("returns all unconditional decorators", () => { + const type = createMockType([createMockDecorator(), createMockDecorator()]); + const result = getDecoratorsByScope(type, createEmitterScope({})); + strictEqual(result.length, 2); + }); + + it("filters out decorators that don't match scope", () => { + const type = createMockType([ + createMockDecorator(), // unconditional + createMockDecorator([ + { kind: "emitter", args: [], rawArgs: ["@typespec/http-client-csharp"] }, + ]), + createMockDecorator([ + { kind: "emitter", args: [], rawArgs: ["@typespec/http-client-python"] }, + ]), + ]); + + const csharpScope = createEmitterScope({ emitter: "@typespec/http-client-csharp" }); + const result = getDecoratorsByScope(type, csharpScope); + strictEqual(result.length, 2); // unconditional + csharp-scoped + }); + + it("returns only unconditional when no scope dimensions match", () => { + const type = createMockType([ + createMockDecorator(), // unconditional + createMockDecorator([ + { kind: "emitter", args: [], rawArgs: ["@typespec/http-client-csharp"] }, + ]), + ]); + + const scope = createEmitterScope({ emitter: "@typespec/http-client-java" }); + const result = getDecoratorsByScope(type, scope); + strictEqual(result.length, 1); // only unconditional + }); + }); +}); + +function createMockDecorator(when?: WhenCondition[]): DecoratorApplication { + return { + decorator: () => {}, + args: [], + when, + }; +} + +function createMockType(decorators: DecoratorApplication[]) { + return { + decorators, + entityKind: "Type" as const, + kind: "Model" as const, + isFinished: true, + } as any; +} From a291d5974481df96c4a09197af6ac34e48865395 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 5 May 2026 10:13:41 -0400 Subject: [PATCH 3/6] better --- packages/compiler/src/core/checker.ts | 6 +- packages/compiler/src/core/when-scope.ts | 55 +++++++- .../compiler/test/when-integration.test.ts | 117 ++++++++++++++---- 3 files changed, 155 insertions(+), 23 deletions(-) diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 937acf3e4e9..4e13e343d7e 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -6886,6 +6886,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ): ValidatorFn[] { const postSelfValidators: ValidatorFn[] = []; for (const decApp of typeDef.decorators) { + // Skip scoped decorators — they are deferred until an emitter applies a scope view + if (decApp.when) { + continue; + } const validators = applyDecoratorToType(program, decApp, typeDef); if (validators?.onTargetFinish) { postSelfValidators.push(validators.onTargetFinish); @@ -7586,7 +7590,7 @@ function getDocContent(content: readonly DocContent[]) { return docs.join(""); } -function applyDecoratorToType( +export function applyDecoratorToType( program: Program, decApp: DecoratorApplication, target: Type, diff --git a/packages/compiler/src/core/when-scope.ts b/packages/compiler/src/core/when-scope.ts index cbab1a7002c..96ec865c3f4 100644 --- a/packages/compiler/src/core/when-scope.ts +++ b/packages/compiler/src/core/when-scope.ts @@ -1,3 +1,5 @@ +import { applyDecoratorToType } from "./checker.js"; +import type { Program } from "./program.js"; import type { DecoratorApplication, Type, WhenCondition } from "./types.js"; /** @@ -13,6 +15,18 @@ export interface EmitterScope { readonly target?: string; } +/** + * Tracks which scoped decorators have already been applied to avoid double-execution. + */ +const appliedScopes = new WeakMap>(); + +/** + * Compute a cache key for a scope to track applied state. + */ +function scopeKey(scope: EmitterScope): string { + return `${scope.emitter ?? ""}|${scope.language ?? ""}|${scope.target ?? ""}`; +} + /** * Check if a decorator application matches the given scope. * A decorator matches if: @@ -81,7 +95,7 @@ export function getDecoratorsByScope( * Get the first decorator matching a specific name that's active in the scope. * * @param type The type to query - * @param decoratorName The decorator function or namespace-qualified name + * @param decoratorFn The decorator function or namespace-qualified name * @param scope The emitter scope * @returns The matching decorator application, or undefined */ @@ -95,6 +109,45 @@ export function getScopedDecorator( ); } +/** + * Apply scoped decorators to a type for a given scope. + * This executes the JS implementation of scoped decorators that match the scope. + * Decorators are only executed once per scope (tracked internally). + * + * This is the primary API for emitters to "activate" conditional decorators. + * Call this in your emitter's `$onEmit` before reading decorator state (e.g., `getDoc`). + * + * @param program The program instance + * @param type The type to apply scoped decorators to + * @param scope The emitter scope to apply + */ +export function applyScopedDecorators( + program: Program, + type: Type & { decorators: DecoratorApplication[] }, + scope: EmitterScope, +): void { + const key = scopeKey(scope); + + for (const decApp of type.decorators) { + if (!decApp.when) continue; // unconditional — already applied during checking + if (!decoratorMatchesScope(decApp, scope)) continue; // doesn't match this scope + + // Check if already applied for this scope + let applied = appliedScopes.get(decApp); + if (applied?.has(key)) continue; + + // Execute the decorator + applyDecoratorToType(program, decApp, type); + + // Mark as applied + if (!applied) { + applied = new Set(); + appliedScopes.set(decApp, applied); + } + applied.add(key); + } +} + /** * Create an emitter scope from configuration. * Used by emitters in their `$onEmit` function. diff --git a/packages/compiler/test/when-integration.test.ts b/packages/compiler/test/when-integration.test.ts index 72d2ada2e12..3101c04a499 100644 --- a/packages/compiler/test/when-integration.test.ts +++ b/packages/compiler/test/when-integration.test.ts @@ -1,48 +1,88 @@ import { ok, strictEqual } from "assert"; import { describe, it } from "vitest"; import { Model } from "../src/core/types.js"; -import { createEmitterScope, getDecoratorsByScope } from "../src/core/when-scope.js"; +import { + applyScopedDecorators, + createEmitterScope, + getDecoratorsByScope, +} from "../src/core/when-scope.js"; +import { getDoc } from "../src/lib/decorators.js"; import { Tester } from "./tester.js"; describe("compiler: when clause integration", () => { - it("decorator with when clause carries condition through to type", async () => { - const [{ program }, diagnostics] = await Tester.compileAndDiagnose(` + it("scoped decorators are NOT executed during checking", async () => { + const [{ program }] = await Tester.compileAndDiagnose(` @doc("always") @doc("csharp-only") when emitter("@typespec/http-client-csharp") model Bar {} `); - // Expect duplicate-decorator warnings (since we use @doc twice) — that's fine for POC const barType: Model = program.checker.getGlobalNamespaceType().models.get("Bar")!; ok(barType, "Bar model should exist"); - // The type should have 2 decorator applications + // The type has 2 decorator applications stored strictEqual(barType.decorators.length, 2); - // Decorators are stored closest-to-declaration first - // @doc("csharp-only") when emitter("...") is closest, @doc("always") is outermost - - // First decorator (closest to model) has a when condition - ok(barType.decorators[0].when); - strictEqual(barType.decorators[0].when.length, 1); - strictEqual(barType.decorators[0].when[0].kind, "emitter"); - strictEqual(barType.decorators[0].when[0].rawArgs![0], "@typespec/http-client-csharp"); + // But only the unconditional @doc("always") was executed — so getDoc returns "always" + const doc = getDoc(program, barType); + strictEqual(doc, "always", "Only unconditional @doc should be applied during checking"); + }); - // Second decorator (outermost) is unconditional - strictEqual(barType.decorators[1].when, undefined); + it("applyScopedDecorators executes matching scoped decorators", async () => { + const [{ program }] = await Tester.compileAndDiagnose(` + @doc("default") + @doc("csharp-doc") when emitter("@typespec/http-client-csharp") + model Foo {} + `); - // Query by scope + const fooType: Model = program.checker.getGlobalNamespaceType().models.get("Foo")!; + + // Before applying scope, only "default" is active + strictEqual(getDoc(program, fooType), "default"); + + // Apply the C# scope — this executes the scoped @doc("csharp-doc") const csharpScope = createEmitterScope({ emitter: "@typespec/http-client-csharp" }); + applyScopedDecorators(program, fooType, csharpScope); + + // Now @doc("csharp-doc") has been executed — it overwrites "default" in state + strictEqual(getDoc(program, fooType), "csharp-doc"); + }); + + it("applyScopedDecorators does not execute non-matching scoped decorators", async () => { + const [{ program }] = await Tester.compileAndDiagnose(` + @doc("default") + @doc("csharp-doc") when emitter("@typespec/http-client-csharp") + model Baz {} + `); + + const bazType: Model = program.checker.getGlobalNamespaceType().models.get("Baz")!; + + // Apply Python scope — the csharp-scoped decorator should NOT execute const pythonScope = createEmitterScope({ emitter: "@typespec/http-client-python" }); + applyScopedDecorators(program, bazType, pythonScope); - const csharpDecs = getDecoratorsByScope(barType, csharpScope); - strictEqual(csharpDecs.length, 2); // both: unconditional + csharp-scoped + // Doc stays as "default" + strictEqual(getDoc(program, bazType), "default"); + }); - const pythonDecs = getDecoratorsByScope(barType, pythonScope); - strictEqual(pythonDecs.length, 1); // only unconditional + it("applyScopedDecorators is idempotent (no double execution)", async () => { + const [{ program }] = await Tester.compileAndDiagnose(` + @doc("default") + @doc("csharp-doc") when emitter("@typespec/http-client-csharp") + model Qux {} + `); + + const quxType: Model = program.checker.getGlobalNamespaceType().models.get("Qux")!; + const csharpScope = createEmitterScope({ emitter: "@typespec/http-client-csharp" }); + + // Apply twice — should not throw or cause issues + applyScopedDecorators(program, quxType, csharpScope); + applyScopedDecorators(program, quxType, csharpScope); + + strictEqual(getDoc(program, quxType), "csharp-doc"); }); - it("emitter scope query works with multiple scoped decorators", async () => { + it("getDecoratorsByScope filters correctly", async () => { const [{ program }] = await Tester.compileAndDiagnose(` @doc("CsFoo") when emitter("@typespec/http-client-csharp") @doc("PyFoo") when emitter("@typespec/http-client-python") @@ -76,4 +116,39 @@ describe("compiler: when clause integration", () => { ); strictEqual(javaDecs.length, 1); }); + + it("demonstrates state pollution issue when multiple scopes applied", async () => { + const [{ program }] = await Tester.compileAndDiagnose(` + @doc("default") + @doc("csharp-doc") when emitter("@typespec/http-client-csharp") + @doc("python-doc") when emitter("@typespec/http-client-python") + model Multi {} + `); + + const multiType: Model = program.checker.getGlobalNamespaceType().models.get("Multi")!; + + // Initially only default + strictEqual(getDoc(program, multiType), "default"); + + // C# emitter applies its scope + applyScopedDecorators( + program, + multiType, + createEmitterScope({ emitter: "@typespec/http-client-csharp" }), + ); + strictEqual(getDoc(program, multiType), "csharp-doc"); + + // Python emitter applies its scope — this OVERWRITES the state! + // This demonstrates the state pollution issue: shared state maps don't isolate per-scope + applyScopedDecorators( + program, + multiType, + createEmitterScope({ emitter: "@typespec/http-client-python" }), + ); + strictEqual( + getDoc(program, multiType), + "python-doc", + "State pollution: python-doc overwrites csharp-doc in shared state", + ); + }); }); From 42a5c84b630baa008ba9dd7a9cbf09790ac389dc Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 5 May 2026 10:23:46 -0400 Subject: [PATCH 4/6] use in openapi3 --- packages/compiler/src/core/when-scope.ts | 214 +++++++++++------- packages/compiler/src/experimental/index.ts | 10 + .../compiler/test/when-integration.test.ts | 89 +++----- packages/openapi3/src/openapi.ts | 12 +- 4 files changed, 176 insertions(+), 149 deletions(-) diff --git a/packages/compiler/src/core/when-scope.ts b/packages/compiler/src/core/when-scope.ts index 96ec865c3f4..66066fc2e43 100644 --- a/packages/compiler/src/core/when-scope.ts +++ b/packages/compiler/src/core/when-scope.ts @@ -1,5 +1,6 @@ import { applyDecoratorToType } from "./checker.js"; import type { Program } from "./program.js"; +import { navigateProgram } from "./semantic-walker.js"; import type { DecoratorApplication, Type, WhenCondition } from "./types.js"; /** @@ -16,42 +17,151 @@ export interface EmitterScope { } /** - * Tracks which scoped decorators have already been applied to avoid double-execution. + * Scope filter constructors for use with `applyScopes`. */ -const appliedScopes = new WeakMap>(); +export function emitter(name: string): EmitterScope { + return { emitter: name }; +} + +export function language(name: string): EmitterScope { + return { language: name }; +} + +export function target(name: string): EmitterScope { + return { target: name }; +} /** - * Compute a cache key for a scope to track applied state. + * Create a scoped view of a program by applying the given scopes. + * Returns a Program with isolated state maps where matching scoped decorators + * have been executed. + * + * Usage in an emitter: + * ```ts + * export async function $onEmit(context: EmitContext) { + * const scopedProgram = applyScopes(context.program, [emitter("@typespec/openapi3")]); + * // getDoc(scopedProgram, type) now returns scope-appropriate values + * } + * ``` + * + * @param program The base program + * @param scopes The scopes to apply (merged into a single scope for matching) + * @returns A Program-compatible object with isolated state */ -function scopeKey(scope: EmitterScope): string { - return `${scope.emitter ?? ""}|${scope.language ?? ""}|${scope.target ?? ""}`; +export function applyScopes(program: Program, scopes: EmitterScope[]): Program { + // Merge all scope filters into one + const mergedScope = mergeScopes(scopes); + + // Clone state maps for isolation + const clonedStateMaps = new Map>(); + for (const [key, map] of program.stateMaps) { + clonedStateMaps.set(key, new Map(map)); + } + const clonedStateSets = new Map>(); + for (const [key, set] of program.stateSets) { + clonedStateSets.set(key, new Set(set)); + } + + // Create the scoped program proxy + const scopedProgram: Program = Object.create(program); + scopedProgram.stateMaps = clonedStateMaps; + scopedProgram.stateSets = clonedStateSets; + scopedProgram.stateMap = function (key: symbol): Map { + let map = clonedStateMaps.get(key); + if (!map) { + map = new Map(); + clonedStateMaps.set(key, map); + } + return map; + }; + scopedProgram.stateSet = function (key: symbol): Set { + let set = clonedStateSets.get(key); + if (!set) { + set = new Set(); + clonedStateSets.set(key, set); + } + return set; + }; + + // Walk all types and execute matching scoped decorators + navigateProgram(program, { + model(model) { + executeMatchingScopedDecorators(scopedProgram, model, mergedScope); + }, + modelProperty(prop) { + executeMatchingScopedDecorators(scopedProgram, prop, mergedScope); + }, + operation(op) { + executeMatchingScopedDecorators(scopedProgram, op, mergedScope); + }, + interface(iface) { + executeMatchingScopedDecorators(scopedProgram, iface, mergedScope); + }, + enum(en) { + executeMatchingScopedDecorators(scopedProgram, en, mergedScope); + }, + enumMember(member) { + executeMatchingScopedDecorators(scopedProgram, member, mergedScope); + }, + union(u) { + executeMatchingScopedDecorators(scopedProgram, u, mergedScope); + }, + unionVariant(v) { + executeMatchingScopedDecorators(scopedProgram, v, mergedScope); + }, + scalar(s) { + executeMatchingScopedDecorators(scopedProgram, s, mergedScope); + }, + namespace(ns) { + executeMatchingScopedDecorators(scopedProgram, ns, mergedScope); + }, + }); + + return scopedProgram; +} + +/** + * Execute scoped decorators on a type that match the given scope. + */ +function executeMatchingScopedDecorators( + scopedProgram: Program, + type: Type & { decorators: DecoratorApplication[] }, + scope: EmitterScope, +): void { + for (const decApp of type.decorators) { + if (!decApp.when) continue; + if (!decoratorMatchesScope(decApp, scope)) continue; + applyDecoratorToType(scopedProgram, decApp, type); + } +} + +/** + * Merge multiple scope filters into one (union of all constraints). + */ +function mergeScopes(scopes: EmitterScope[]): EmitterScope { + if (scopes.length === 1) return scopes[0]; + const merged: EmitterScope = {}; + for (const s of scopes) { + if (s.emitter) (merged as any).emitter = s.emitter; + if (s.language) (merged as any).language = s.language; + if (s.target) (merged as any).target = s.target; + } + return merged; } /** * Check if a decorator application matches the given scope. - * A decorator matches if: - * - It has no `when` conditions (unconditional), OR - * - All of its `when` conditions are satisfied by the scope - * - * @param decorator The decorator application to check - * @param scope The emitter scope to match against - * @returns true if the decorator is active in the given scope */ export function decoratorMatchesScope( decorator: DecoratorApplication, scope: EmitterScope, ): boolean { if (!decorator.when || decorator.when.length === 0) { - return true; // Unconditional decorator always matches + return true; } - - // All conditions must match (AND semantics) return decorator.when.every((condition) => conditionMatchesScope(condition, scope)); } -/** - * Check if a single when condition matches the given scope. - */ function conditionMatchesScope(condition: WhenCondition, scope: EmitterScope): boolean { switch (condition.kind) { case "emitter": @@ -65,12 +175,8 @@ function conditionMatchesScope(condition: WhenCondition, scope: EmitterScope): b return condition.rawArgs.includes(scope.target); case "since": case "between": - // Version-based conditions require version projection (mutator-based) - // For POC, these always match (would need version context) return true; case "member": - // Enum member conditions require visibility context - // For POC, these always match (would need visibility filter) return true; default: return true; @@ -78,11 +184,7 @@ function conditionMatchesScope(condition: WhenCondition, scope: EmitterScope): b } /** - * Filter a type's decorators by scope, returning only those that are active. - * - * @param type The type whose decorators to filter - * @param scope The emitter scope - * @returns Decorators that are unconditional or match the scope + * Filter a type's decorators by scope. */ export function getDecoratorsByScope( type: Type & { decorators: DecoratorApplication[] }, @@ -91,66 +193,8 @@ export function getDecoratorsByScope( return type.decorators.filter((d) => decoratorMatchesScope(d, scope)); } -/** - * Get the first decorator matching a specific name that's active in the scope. - * - * @param type The type to query - * @param decoratorFn The decorator function or namespace-qualified name - * @param scope The emitter scope - * @returns The matching decorator application, or undefined - */ -export function getScopedDecorator( - type: Type & { decorators: DecoratorApplication[] }, - decoratorFn: Function, - scope: EmitterScope, -): DecoratorApplication | undefined { - return type.decorators.find( - (d) => d.decorator === decoratorFn && decoratorMatchesScope(d, scope), - ); -} - -/** - * Apply scoped decorators to a type for a given scope. - * This executes the JS implementation of scoped decorators that match the scope. - * Decorators are only executed once per scope (tracked internally). - * - * This is the primary API for emitters to "activate" conditional decorators. - * Call this in your emitter's `$onEmit` before reading decorator state (e.g., `getDoc`). - * - * @param program The program instance - * @param type The type to apply scoped decorators to - * @param scope The emitter scope to apply - */ -export function applyScopedDecorators( - program: Program, - type: Type & { decorators: DecoratorApplication[] }, - scope: EmitterScope, -): void { - const key = scopeKey(scope); - - for (const decApp of type.decorators) { - if (!decApp.when) continue; // unconditional — already applied during checking - if (!decoratorMatchesScope(decApp, scope)) continue; // doesn't match this scope - - // Check if already applied for this scope - let applied = appliedScopes.get(decApp); - if (applied?.has(key)) continue; - - // Execute the decorator - applyDecoratorToType(program, decApp, type); - - // Mark as applied - if (!applied) { - applied = new Set(); - appliedScopes.set(decApp, applied); - } - applied.add(key); - } -} - /** * Create an emitter scope from configuration. - * Used by emitters in their `$onEmit` function. */ export function createEmitterScope(options: EmitterScope): EmitterScope { return { ...options }; diff --git a/packages/compiler/src/experimental/index.ts b/packages/compiler/src/experimental/index.ts index ba9304d39a3..2effd8206aa 100644 --- a/packages/compiler/src/experimental/index.ts +++ b/packages/compiler/src/experimental/index.ts @@ -1,4 +1,14 @@ export { createSourceLoader as unsafe_createSourceLoader } from "../core/source-loader.js"; +export { + applyScopes, + createEmitterScope, + decoratorMatchesScope, + emitter, + getDecoratorsByScope, + language, + target, +} from "../core/when-scope.js"; +export type { EmitterScope } from "../core/when-scope.js"; export { MutableType as unsafe_MutableType, Mutator as unsafe_Mutator, diff --git a/packages/compiler/test/when-integration.test.ts b/packages/compiler/test/when-integration.test.ts index 3101c04a499..fca6976d937 100644 --- a/packages/compiler/test/when-integration.test.ts +++ b/packages/compiler/test/when-integration.test.ts @@ -2,8 +2,9 @@ import { ok, strictEqual } from "assert"; import { describe, it } from "vitest"; import { Model } from "../src/core/types.js"; import { - applyScopedDecorators, + applyScopes, createEmitterScope, + emitter, getDecoratorsByScope, } from "../src/core/when-scope.js"; import { getDoc } from "../src/lib/decorators.js"; @@ -28,7 +29,7 @@ describe("compiler: when clause integration", () => { strictEqual(doc, "always", "Only unconditional @doc should be applied during checking"); }); - it("applyScopedDecorators executes matching scoped decorators", async () => { + it("applyScopes returns a scoped program with executed scoped decorators", async () => { const [{ program }] = await Tester.compileAndDiagnose(` @doc("default") @doc("csharp-doc") when emitter("@typespec/http-client-csharp") @@ -37,18 +38,20 @@ describe("compiler: when clause integration", () => { const fooType: Model = program.checker.getGlobalNamespaceType().models.get("Foo")!; - // Before applying scope, only "default" is active + // Base program only has unconditional state strictEqual(getDoc(program, fooType), "default"); - // Apply the C# scope — this executes the scoped @doc("csharp-doc") - const csharpScope = createEmitterScope({ emitter: "@typespec/http-client-csharp" }); - applyScopedDecorators(program, fooType, csharpScope); + // Create a scoped program for C# emitter + const scopedProgram = applyScopes(program, [emitter("@typespec/http-client-csharp")]); - // Now @doc("csharp-doc") has been executed — it overwrites "default" in state - strictEqual(getDoc(program, fooType), "csharp-doc"); + // Scoped program has the scoped @doc executed + strictEqual(getDoc(scopedProgram, fooType), "csharp-doc"); + + // Original program is NOT affected (isolation) + strictEqual(getDoc(program, fooType), "default"); }); - it("applyScopedDecorators does not execute non-matching scoped decorators", async () => { + it("applyScopes does not execute non-matching scoped decorators", async () => { const [{ program }] = await Tester.compileAndDiagnose(` @doc("default") @doc("csharp-doc") when emitter("@typespec/http-client-csharp") @@ -57,29 +60,30 @@ describe("compiler: when clause integration", () => { const bazType: Model = program.checker.getGlobalNamespaceType().models.get("Baz")!; - // Apply Python scope — the csharp-scoped decorator should NOT execute - const pythonScope = createEmitterScope({ emitter: "@typespec/http-client-python" }); - applyScopedDecorators(program, bazType, pythonScope); - - // Doc stays as "default" - strictEqual(getDoc(program, bazType), "default"); + // Apply Python scope — csharp-scoped decorator should NOT execute + const scopedProgram = applyScopes(program, [emitter("@typespec/http-client-python")]); + strictEqual(getDoc(scopedProgram, bazType), "default"); }); - it("applyScopedDecorators is idempotent (no double execution)", async () => { + it("two emitters get different state from same program", async () => { const [{ program }] = await Tester.compileAndDiagnose(` @doc("default") @doc("csharp-doc") when emitter("@typespec/http-client-csharp") - model Qux {} + @doc("python-doc") when emitter("@typespec/http-client-python") + model Multi {} `); - const quxType: Model = program.checker.getGlobalNamespaceType().models.get("Qux")!; - const csharpScope = createEmitterScope({ emitter: "@typespec/http-client-csharp" }); + const multiType: Model = program.checker.getGlobalNamespaceType().models.get("Multi")!; + + // Each emitter gets its own scoped view — no pollution + const csharpProgram = applyScopes(program, [emitter("@typespec/http-client-csharp")]); + const pythonProgram = applyScopes(program, [emitter("@typespec/http-client-python")]); - // Apply twice — should not throw or cause issues - applyScopedDecorators(program, quxType, csharpScope); - applyScopedDecorators(program, quxType, csharpScope); + strictEqual(getDoc(csharpProgram, multiType), "csharp-doc"); + strictEqual(getDoc(pythonProgram, multiType), "python-doc"); - strictEqual(getDoc(program, quxType), "csharp-doc"); + // Base program unchanged + strictEqual(getDoc(program, multiType), "default"); }); it("getDecoratorsByScope filters correctly", async () => { @@ -92,63 +96,24 @@ describe("compiler: when clause integration", () => { const fooType: Model = program.checker.getGlobalNamespaceType().models.get("Foo")!; ok(fooType, "Foo model should exist"); - strictEqual(fooType.decorators.length, 3); - // C# emitter should see default + csharp-scoped const csharpDecs = getDecoratorsByScope( fooType, createEmitterScope({ emitter: "@typespec/http-client-csharp" }), ); strictEqual(csharpDecs.length, 2); - // Python emitter should see default + python-scoped const pyDecs = getDecoratorsByScope( fooType, createEmitterScope({ emitter: "@typespec/http-client-python" }), ); strictEqual(pyDecs.length, 2); - // Java emitter should see only default const javaDecs = getDecoratorsByScope( fooType, createEmitterScope({ emitter: "@typespec/http-client-java" }), ); strictEqual(javaDecs.length, 1); }); - - it("demonstrates state pollution issue when multiple scopes applied", async () => { - const [{ program }] = await Tester.compileAndDiagnose(` - @doc("default") - @doc("csharp-doc") when emitter("@typespec/http-client-csharp") - @doc("python-doc") when emitter("@typespec/http-client-python") - model Multi {} - `); - - const multiType: Model = program.checker.getGlobalNamespaceType().models.get("Multi")!; - - // Initially only default - strictEqual(getDoc(program, multiType), "default"); - - // C# emitter applies its scope - applyScopedDecorators( - program, - multiType, - createEmitterScope({ emitter: "@typespec/http-client-csharp" }), - ); - strictEqual(getDoc(program, multiType), "csharp-doc"); - - // Python emitter applies its scope — this OVERWRITES the state! - // This demonstrates the state pollution issue: shared state maps don't isolate per-scope - applyScopedDecorators( - program, - multiType, - createEmitterScope({ emitter: "@typespec/http-client-python" }), - ); - strictEqual( - getDoc(program, multiType), - "python-doc", - "State pollution: python-doc overwrites csharp-doc in shared state", - ); - }); }); diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index c2496d88f45..ae46b200952 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -144,9 +144,17 @@ const defaultOptions = { } as const; export async function $onEmit(context: EmitContext) { - const options = resolveOptions(context); + // POC: Apply scopes to get a program view with openapi3-specific scoped decorators executed + const { applyScopes, emitter: emitterScope } = await import( + /* @ts-ignore - POC import until compiler is rebuilt */ + "@typespec/compiler/experimental" + ); + const scopedProgram = applyScopes(context.program, [emitterScope("@typespec/openapi3")]); + const scopedContext = { ...context, program: scopedProgram }; + + const options = resolveOptions(scopedContext); for (const specVersion of options.openapiVersions) { - const emitter = createOAPIEmitter(context, options, specVersion); + const emitter = createOAPIEmitter(scopedContext, options, specVersion); const { perf } = await emitter.emitOpenAPI(); for (const [key, duration] of Object.entries(perf)) { context.perf.report(key, duration); From 4603dff7538596fb439505d5b8ce84dc17533921 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 5 May 2026 11:10:39 -0400 Subject: [PATCH 5/6] fix --- packages/compiler/src/core/when-scope.ts | 25 +++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/core/when-scope.ts b/packages/compiler/src/core/when-scope.ts index 66066fc2e43..2a4472a6650 100644 --- a/packages/compiler/src/core/when-scope.ts +++ b/packages/compiler/src/core/when-scope.ts @@ -52,7 +52,7 @@ export function applyScopes(program: Program, scopes: EmitterScope[]): Program { // Merge all scope filters into one const mergedScope = mergeScopes(scopes); - // Clone state maps for isolation + // Clone state maps for isolation (captures state from checking phase) const clonedStateMaps = new Map>(); for (const [key, map] of program.stateMaps) { clonedStateMaps.set(key, new Map(map)); @@ -67,20 +67,23 @@ export function applyScopes(program: Program, scopes: EmitterScope[]): Program { scopedProgram.stateMaps = clonedStateMaps; scopedProgram.stateSets = clonedStateSets; scopedProgram.stateMap = function (key: symbol): Map { + // If we have a cloned/overlay version, use it let map = clonedStateMaps.get(key); - if (!map) { - map = new Map(); - clonedStateMaps.set(key, map); - } - return map; + if (map) return map; + // Fall back to original program for lazily-initialized state (e.g., HTTP operations) + // Clone it on first access so scoped writes don't pollute the original + const originalMap = program.stateMap(key); + const cloned = new Map(originalMap); + clonedStateMaps.set(key, cloned); + return cloned; }; scopedProgram.stateSet = function (key: symbol): Set { let set = clonedStateSets.get(key); - if (!set) { - set = new Set(); - clonedStateSets.set(key, set); - } - return set; + if (set) return set; + const originalSet = program.stateSet(key); + const cloned = new Set(originalSet); + clonedStateSets.set(key, cloned); + return cloned; }; // Walk all types and execute matching scoped decorators From 461e246c07305cdb5a5752990cf4f07afa85bb52 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 5 May 2026 13:36:41 -0400 Subject: [PATCH 6/6] fix --- packages/compiler/src/core/when-scope.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/compiler/src/core/when-scope.ts b/packages/compiler/src/core/when-scope.ts index 2a4472a6650..5250372b47e 100644 --- a/packages/compiler/src/core/when-scope.ts +++ b/packages/compiler/src/core/when-scope.ts @@ -63,7 +63,10 @@ export function applyScopes(program: Program, scopes: EmitterScope[]): Program { } // Create the scoped program proxy + // Use Object.create for prototype delegation, but set own 'projectRoot' so that + // typekit's `Object.hasOwn(arg, "projectRoot")` check correctly identifies this as a Program. const scopedProgram: Program = Object.create(program); + (scopedProgram as any).projectRoot = program.projectRoot; scopedProgram.stateMaps = clonedStateMaps; scopedProgram.stateSets = clonedStateSets; scopedProgram.stateMap = function (key: symbol): Map {