diff --git a/common/changes/@typespec/compiler/emitter-framework-ref-context_2023-11-30-18-46.json b/common/changes/@typespec/compiler/emitter-framework-ref-context_2023-11-30-18-46.json new file mode 100644 index 0000000000..89c03a5fea --- /dev/null +++ b/common/changes/@typespec/compiler/emitter-framework-ref-context_2023-11-30-18-46.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@typespec/compiler", + "comment": "[API] Emitter framework: `emitTypeReference` function takes an optional reference context that can be used to patch the context for the target.", + "type": "none" + } + ], + "packageName": "@typespec/compiler" +} diff --git a/packages/compiler/src/emitter-framework/asset-emitter.ts b/packages/compiler/src/emitter-framework/asset-emitter.ts index e1b423b0a4..bee07f7a60 100644 --- a/packages/compiler/src/emitter-framework/asset-emitter.ts +++ b/packages/compiler/src/emitter-framework/asset-emitter.ts @@ -22,6 +22,7 @@ import { EmitEntity, EmitterResult, EmitterState, + EmitTypeReferenceOptions, LexicalTypeStackEntry, NamespaceScope, NoEmit, @@ -205,107 +206,122 @@ export function createAssetEmitter( return sourceFile; }, - emitTypeReference(target): EmitEntity { - if (target.kind === "ModelProperty") { - return invokeTypeEmitter("modelPropertyReference", target); - } else if (target.kind === "EnumMember") { - return invokeTypeEmitter("enumMemberReference", target); - } - - const oldIncomingReferenceContext = incomingReferenceContext; - const oldIncomingReferenceContextTarget = incomingReferenceContextTarget; + emitTypeReference(target, options?: EmitTypeReferenceOptions): EmitEntity { + return withPatchedReferenceContext(options?.referenceContext, () => { + const oldIncomingReferenceContext = incomingReferenceContext; + const oldIncomingReferenceContextTarget = incomingReferenceContextTarget; - incomingReferenceContext = context.referenceContext ?? null; - incomingReferenceContextTarget = incomingReferenceContext ? target : null; + incomingReferenceContext = context.referenceContext ?? null; + incomingReferenceContextTarget = incomingReferenceContext ? target : null; - const entity = this.emitType(target); + let result; + if (target.kind === "ModelProperty") { + result = invokeTypeEmitter("modelPropertyReference", target); + } else if (target.kind === "EnumMember") { + result = invokeTypeEmitter("enumMemberReference", target); + } - incomingReferenceContext = oldIncomingReferenceContext; - incomingReferenceContextTarget = oldIncomingReferenceContextTarget; + if (result) { + incomingReferenceContext = oldIncomingReferenceContext; + incomingReferenceContextTarget = oldIncomingReferenceContextTarget; + return result; + } - let placeholder: Placeholder | null = null; + const entity = this.emitType(target); - if (entity.kind === "circular") { - let waiting = waitingCircularRefs.get(entity.emitEntityKey); - if (!waiting) { - waiting = []; - waitingCircularRefs.set(entity.emitEntityKey, waiting); - } + incomingReferenceContext = oldIncomingReferenceContext; + incomingReferenceContextTarget = oldIncomingReferenceContextTarget; - const typeChainSnapshot = referenceTypeChain; - waiting.push({ - state: { - lexicalTypeStack, - context, - }, - cb: (resolvedEntity) => - invokeReference( - this, - resolvedEntity, - true, - resolveReferenceCycle(typeChainSnapshot, entity, typeToEmitEntity as any) - ), - }); + let placeholder: Placeholder | null = null; - placeholder = new Placeholder(); - return this.result.rawCode(placeholder); - } else { - return invokeReference(this, entity, false); - } + if (entity.kind === "circular") { + let waiting = waitingCircularRefs.get(entity.emitEntityKey); + if (!waiting) { + waiting = []; + waitingCircularRefs.set(entity.emitEntityKey, waiting); + } - function invokeReference( - assetEmitter: AssetEmitter, - entity: EmitEntity, - circular: boolean, - cycle?: ReferenceCycle - ): EmitEntity { - let ref; - const scope = currentScope(); + const typeChainSnapshot = referenceTypeChain; + waiting.push({ + state: { + lexicalTypeStack, + context, + }, + cb: (resolvedEntity) => + invokeReference( + this, + resolvedEntity, + true, + resolveReferenceCycle(typeChainSnapshot, entity, typeToEmitEntity as any) + ), + }); - if (circular) { - ref = typeEmitter.circularReference(entity, scope, cycle!); + placeholder = new Placeholder(); + return this.result.rawCode(placeholder); } else { - if (entity.kind !== "declaration") { - return entity; - } - compilerAssert( - scope, - "Emit context must have a scope set in order to create references to declarations." - ); - const { pathUp, pathDown, commonScope } = resolveDeclarationReferenceScope(entity, scope); - ref = typeEmitter.reference(entity, pathUp, pathDown, commonScope); + return invokeReference(this, entity, false); } - if (!(ref instanceof EmitterResult)) { - ref = assetEmitter.result.rawCode(ref) as RawCode; - } + function invokeReference( + assetEmitter: AssetEmitter, + entity: EmitEntity, + circular: boolean, + cycle?: ReferenceCycle + ): EmitEntity { + let ref; + const scope = currentScope(); + + if (circular) { + ref = typeEmitter.circularReference(entity, scope, cycle!); + } else { + if (entity.kind !== "declaration") { + return entity; + } + compilerAssert( + scope, + "Emit context must have a scope set in order to create references to declarations." + ); + const { pathUp, pathDown, commonScope } = resolveDeclarationReferenceScope( + entity, + scope + ); + ref = typeEmitter.reference(entity, pathUp, pathDown, commonScope); + } - if (placeholder) { - // this should never happen as this function shouldn't be called until - // the target declaration is finished being emitted. - compilerAssert(ref.kind !== "circular", "TypeEmitter `reference` returned circular emit"); - - // this could presumably be allowed if we want. - compilerAssert( - ref.kind === "none" || !(ref.value instanceof Placeholder), - "TypeEmitter's `reference` method cannot return a placeholder." - ); - - switch (ref.kind) { - case "code": - case "declaration": - placeholder.setValue(ref.value as T); - break; - case "none": - // this cast is incorrect, think about what should happen - // if reference returns noEmit... - placeholder.setValue("" as T); - break; + if (!(ref instanceof EmitterResult)) { + ref = assetEmitter.result.rawCode(ref) as RawCode; } - } - return ref; - } + if (placeholder) { + // this should never happen as this function shouldn't be called until + // the target declaration is finished being emitted. + compilerAssert( + ref.kind !== "circular", + "TypeEmitter `reference` returned circular emit" + ); + + // this could presumably be allowed if we want. + compilerAssert( + ref.kind === "none" || !(ref.value instanceof Placeholder), + "TypeEmitter's `reference` method cannot return a placeholder." + ); + + switch (ref.kind) { + case "code": + case "declaration": + placeholder.setValue(ref.value as T); + break; + case "none": + // this cast is incorrect, think about what should happen + // if reference returns noEmit... + placeholder.setValue("" as T); + break; + } + } + + return ref; + } + }); }, emitDeclarationName(type): string | undefined { @@ -696,6 +712,29 @@ export function createAssetEmitter( referenceTypeChain = oldRefTypeStack; } + function withPatchedReferenceContext( + referenceContext: Record | undefined, + cb: () => T + ): T { + if (referenceContext !== undefined) { + const oldContext = context; + + context = stateInterner.intern({ + lexicalContext: context.lexicalContext, + referenceContext: stateInterner.intern({ + ...context.referenceContext, + ...referenceContext, + }), + }); + + const result = cb(); + context = oldContext; + return result; + } else { + return cb(); + } + } + /** * Invoke the callback with the given context. */ diff --git a/packages/compiler/src/emitter-framework/types.ts b/packages/compiler/src/emitter-framework/types.ts index 6321037cef..aa3822ac9e 100644 --- a/packages/compiler/src/emitter-framework/types.ts +++ b/packages/compiler/src/emitter-framework/types.ts @@ -13,11 +13,16 @@ import { } from "../core/index.js"; import { Placeholder } from "./placeholder.js"; import { TypeEmitter } from "./type-emitter.js"; + type AssetEmitterOptions = { noEmit: boolean; emitterOutputDir: string; } & TOptions; +export interface EmitTypeReferenceOptions { + readonly referenceContext?: Record; +} + export interface AssetEmitter> { /** * Get the current emitter context as set by the TypeEmitter's various @@ -28,7 +33,7 @@ export interface AssetEmitter; getProgram(): Program; - emitTypeReference(type: Type): EmitEntity; + emitTypeReference(type: Type, context?: EmitTypeReferenceOptions): EmitEntity; emitDeclarationName(type: TypeSpecDeclaration): string | undefined; emitType(type: Type, context?: Partial): EmitEntity; emitProgram(options?: { emitGlobalNamespace?: boolean; emitTypeSpecNamespace?: boolean }): void; diff --git a/packages/compiler/test/emitter-framework/context.test.ts b/packages/compiler/test/emitter-framework/context.test.ts index a41eaadc7d..cacf202594 100644 --- a/packages/compiler/test/emitter-framework/context.test.ts +++ b/packages/compiler/test/emitter-framework/context.test.ts @@ -1,9 +1,12 @@ -import assert from "assert"; -import { Model, ModelProperty, Namespace, Program } from "../../src/core/index.js"; +import assert, { deepStrictEqual, ok, strictEqual } from "assert"; +import { Model, ModelProperty, Namespace, Program, Type } from "../../src/core/index.js"; import { + AssetEmitter, CodeTypeEmitter, Context, + EmitEntity, EmitterOutput, + TypeEmitter, createAssetEmitter, } from "../../src/emitter-framework/index.js"; import { emitTypeSpec, getHostForTypeSpecFile } from "./host.js"; @@ -342,4 +345,162 @@ describe("emitter-framework: emitter context", () => { ); }); }); + + describe("setting context via emitTypeReference", () => { + async function emitType( + Emitter: typeof TypeEmitter, + code: string, + ref: string, + referenceContext?: Record + ): Promise> { + const host = await getHostForTypeSpecFile(code); + const emitter = createAssetEmitter(host.program, Emitter, { + emitterOutputDir: "tsp-output", + options: {}, + } as any); + const type = host.program.resolveTypeReference(ref)[0]!; + ok(type, `Expected to have found reference ${ref}`); + return emitter.emitType(type, { referenceContext }); + } + + function objTypeReference( + emitter: AssetEmitter, + target: Type, + contextValue: string | undefined + ) { + return ( + emitter.emitTypeReference(target, { + referenceContext: contextValue ? { contextValue } : {}, + }) as any + ).value; + } + + it("set reference context value when calling emitTypeReference", async () => { + class TestEmitter extends TypeEmitter { + modelDeclaration(model: Model, name: string): EmitterOutput { + if (model.name === "Foo") { + const prop = model.properties.get("prop")!.type; + + return { + context1: objTypeReference(this.emitter, prop, "context1"), + context2: objTypeReference(this.emitter, prop, "context2"), + noSet: objTypeReference(this.emitter, prop, undefined), + }; + } + return this.emitter.getContext().contextValue; + } + } + + const result = await emitType( + TestEmitter, + ` + model Foo { prop: Bar } + model Bar {} + `, + "Foo" + ); + strictEqual(result.kind, "code"); + deepStrictEqual(result.value, { + context1: "context1", + context2: "context2", + noSet: undefined, + }); + }); + + it("set reference context on model properties ", async () => { + class TestEmitter extends TypeEmitter { + modelDeclaration(model: Model, name: string): EmitterOutput { + if (model.name === "Foo") { + const prop = model.properties.get("prop")!; + + return { + context1: objTypeReference(this.emitter, prop, "context1"), + context2: objTypeReference(this.emitter, prop, "context2"), + noSet: objTypeReference(this.emitter, prop, undefined), + }; + } + return this.emitter.getContext().contextValue; + } + } + + const result = await emitType( + TestEmitter, + ` + model Foo { prop: Bar } + model Bar {} + `, + "Foo" + ); + strictEqual(result.kind, "code"); + deepStrictEqual(result.value, { + context1: "context1", + context2: "context2", + noSet: undefined, + }); + }); + + it("merge with incoming reference context", async () => { + class TestEmitter extends TypeEmitter { + modelDeclaration(model: Model, name: string): EmitterOutput { + if (model.name === "Foo") { + const prop = model.properties.get("prop")!.type; + return { + context1: objTypeReference(this.emitter, prop, "context1"), + }; + } + return { + contextValue: this.emitter.getContext().contextValue, + incoming: this.emitter.getContext().incoming, + }; + } + } + + const result = await emitType( + TestEmitter, + ` + model Foo { prop: Bar } + model Bar {} + `, + "Foo", + { incoming: "incoming-value" } + ); + strictEqual(result.kind, "code"); + deepStrictEqual(result.value, { + context1: { + contextValue: "context1", + incoming: "incoming-value", + }, + }); + }); + + it("ReferenceContext hook always wins", async () => { + class TestEmitter extends TypeEmitter { + modelDeclarationReferenceContext(model: Model, name: string): Context { + return { contextValue: "context-override" }; + } + modelDeclaration(model: Model, name: string): EmitterOutput { + if (model.name === "Foo") { + const prop = model.properties.get("prop")!.type; + return { + context1: objTypeReference(this.emitter, prop, "context1"), + }; + } + return this.emitter.getContext().contextValue; + } + } + + const result = await emitType( + TestEmitter, + ` + model Foo { prop: Bar } + model Bar {} + `, + "Foo" + ); + strictEqual(result.kind, "code"); + deepStrictEqual(result.value, { + context1: "context-override", + }); + }); + }); });