diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 6d6b43daf1e..4e13e343d7e 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) { @@ -6830,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); @@ -7530,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/parser.ts b/packages/compiler/src/core/parser.ts index 63ed0076f13..25c27f97081 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -108,6 +108,9 @@ import { UsingStatementNode, ValueOfExpressionNode, VoidKeywordNode, + WhenBlockStatementNode, + WhenClauseNode, + WhenExpressionNode, } from "./types.js"; /** @@ -466,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; } @@ -499,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) { @@ -544,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; } @@ -934,6 +947,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa bodyRange: propDetail.range, modifiers, modifierFlags: modifiersToFlags(modifiers), + when: parseOptionalWhenClause(), ...finishNode(pos), }; } @@ -1046,6 +1060,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 +1068,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa value, optional, default: defaultValue, + when: whenClause, ...finishNode(pos), }; } @@ -1595,10 +1611,95 @@ 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), + }; + } + + /** + * 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), }; } @@ -2980,7 +3081,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 +3129,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 +3143,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 +3257,12 @@ 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); + 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 a465e894c58..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[]; } /** @@ -1176,6 +1190,9 @@ export enum SyntaxKind { ScalarConstructor, InternalKeyword, FunctionTypeExpression, + WhenExpression, + WhenClause, + WhenBlockStatement, } export const enum NodeFlags { @@ -1299,7 +1316,10 @@ export type Node = | ObjectLiteralPropertyNode | ObjectLiteralSpreadPropertyNode | ScalarConstructorNode - | ArrayLiteralNode; + | ArrayLiteralNode + | WhenClauseNode + | WhenExpressionNode + | WhenBlockStatementNode; /** * Node that can be used as template @@ -1388,7 +1408,8 @@ export type Statement = | ConstStatementNode | CallExpressionNode | EmptyStatementNode - | InvalidStatementNode; + | InvalidStatementNode + | WhenBlockStatementNode; export interface DeclarationNode { /** @@ -1432,6 +1453,45 @@ 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[]; +} + +/** + * 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 { @@ -1531,6 +1591,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 +1704,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/core/when-scope.ts b/packages/compiler/src/core/when-scope.ts new file mode 100644 index 00000000000..5250372b47e --- /dev/null +++ b/packages/compiler/src/core/when-scope.ts @@ -0,0 +1,207 @@ +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"; + +/** + * 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; +} + +/** + * Scope filter constructors for use with `applyScopes`. + */ +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 }; +} + +/** + * 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 + */ +export function applyScopes(program: Program, scopes: EmitterScope[]): Program { + // Merge all scope filters into one + const mergedScope = mergeScopes(scopes); + + // 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)); + } + const clonedStateSets = new Map>(); + for (const [key, set] of program.stateSets) { + clonedStateSets.set(key, new Set(set)); + } + + // 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 { + // If we have a cloned/overlay version, use it + let map = clonedStateMaps.get(key); + 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) 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 + 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. + */ +export function decoratorMatchesScope( + decorator: DecoratorApplication, + scope: EmitterScope, +): boolean { + if (!decorator.when || decorator.when.length === 0) { + return true; + } + return decorator.when.every((condition) => conditionMatchesScope(condition, 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": + return true; + case "member": + return true; + default: + return true; + } +} + +/** + * Filter a type's decorators by scope. + */ +export function getDecoratorsByScope( + type: Type & { decorators: DecoratorApplication[] }, + scope: EmitterScope, +): DecoratorApplication[] { + return type.decorators.filter((d) => decoratorMatchesScope(d, scope)); +} + +/** + * Create an emitter scope from configuration. + */ +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/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 0a104e021d7..33f23d7512a 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -311,6 +311,9 @@ export function printNode( case SyntaxKind.JsSourceFile: case SyntaxKind.JsNamespaceDeclaration: 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 new file mode 100644 index 00000000000..dad88c96d28 --- /dev/null +++ b/packages/compiler/test/when-clause.test.ts @@ -0,0 +1,184 @@ +import { deepStrictEqual, ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { parse, hasParseError } from "../src/core/parser.js"; +import { + DecoratorExpressionNode, + ModelPropertyNode, + ModelStatementNode, + SyntaxKind, + WhenBlockStatementNode, + 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); + }); + }); + + 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) { + 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; +} diff --git a/packages/compiler/test/when-integration.test.ts b/packages/compiler/test/when-integration.test.ts new file mode 100644 index 00000000000..fca6976d937 --- /dev/null +++ b/packages/compiler/test/when-integration.test.ts @@ -0,0 +1,119 @@ +import { ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { Model } from "../src/core/types.js"; +import { + applyScopes, + createEmitterScope, + emitter, + 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("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 {} + `); + + const barType: Model = program.checker.getGlobalNamespaceType().models.get("Bar")!; + ok(barType, "Bar model should exist"); + + // The type has 2 decorator applications stored + strictEqual(barType.decorators.length, 2); + + // 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"); + }); + + 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") + model Foo {} + `); + + const fooType: Model = program.checker.getGlobalNamespaceType().models.get("Foo")!; + + // Base program only has unconditional state + strictEqual(getDoc(program, fooType), "default"); + + // Create a scoped program for C# emitter + const scopedProgram = applyScopes(program, [emitter("@typespec/http-client-csharp")]); + + // 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("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") + model Baz {} + `); + + const bazType: Model = program.checker.getGlobalNamespaceType().models.get("Baz")!; + + // Apply Python scope — csharp-scoped decorator should NOT execute + const scopedProgram = applyScopes(program, [emitter("@typespec/http-client-python")]); + strictEqual(getDoc(scopedProgram, bazType), "default"); + }); + + 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") + @doc("python-doc") when emitter("@typespec/http-client-python") + model Multi {} + `); + + 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")]); + + strictEqual(getDoc(csharpProgram, multiType), "csharp-doc"); + strictEqual(getDoc(pythonProgram, multiType), "python-doc"); + + // Base program unchanged + strictEqual(getDoc(program, multiType), "default"); + }); + + 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") + @doc("Default") + model Foo {} + `); + + const fooType: Model = program.checker.getGlobalNamespaceType().models.get("Foo")!; + ok(fooType, "Foo model should exist"); + strictEqual(fooType.decorators.length, 3); + + const csharpDecs = getDecoratorsByScope( + fooType, + createEmitterScope({ emitter: "@typespec/http-client-csharp" }), + ); + strictEqual(csharpDecs.length, 2); + + const pyDecs = getDecoratorsByScope( + fooType, + createEmitterScope({ emitter: "@typespec/http-client-python" }), + ); + strictEqual(pyDecs.length, 2); + + 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; +} 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);