Skip to content

Commit

Permalink
Emitter framework handle circular references (#2550)
Browse files Browse the repository at this point in the history
Emitter framework deals with circular reference for you and this works
great if the circular flow involve declarations that can be referenced.
But sometimes there is issues where we can't have a declaration and so a
circular reference becomes impossible. Adding a new `circularReference`
hook that gets called when we resolve a reference that is point back to
itself in the resolution stack. By default it will throw resolve the
declaration ref if the target is a declaration or throw an error.

An emitter can decide to override this function to provide a different
handling in either cases. A target language might not even support
circular reference at all.

---------

Co-authored-by: Mark Cowlishaw <markcowl@microsoft.com>
  • Loading branch information
timotheeguerin and markcowl authored Oct 20, 2023
1 parent 2353586 commit ad2aaca
Show file tree
Hide file tree
Showing 6 changed files with 277 additions and 53 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@typespec/compiler",
"comment": "Add new hook for handling circular references",
"type": "none"
}
],
"packageName": "@typespec/compiler"
}
119 changes: 66 additions & 53 deletions packages/compiler/src/emitter-framework/asset-emitter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
compilerAssert,
EmitContext,
getTypeName,
isTemplateDeclaration,
joinPaths,
Model,
Expand All @@ -10,6 +11,7 @@ import {
} from "../core/index.js";
import { CustomKeyMap } from "./custom-key-map.js";
import { Placeholder } from "./placeholder.js";
import { resolveDeclarationReferenceScope } from "./ref-scope.js";
import { TypeEmitter } from "./type-emitter.js";
import {
AssetEmitter,
Expand All @@ -23,6 +25,7 @@ import {
NamespaceScope,
NoEmit,
RawCode,
ReferenceChainEntry,
Scope,
SourceFile,
SourceFileScope,
Expand Down Expand Up @@ -96,6 +99,7 @@ export function createAssetEmitter<T, TOptions extends object>(
// model, but in the type graph we will consider it to be lexically inside
// whatever references the alias.
let lexicalTypeStack: LexicalTypeStackEntry[] = [];
let referenceTypeChain: ReferenceChainEntry[] = [];

// Internally, context is is split between lexicalContext and
// referenceContext because when a reference is made, we carry over
Expand Down Expand Up @@ -219,54 +223,43 @@ export function createAssetEmitter<T, TOptions extends object>(
waitingCircularRefs.set(entity.emitEntityKey, waiting);
}

const circularChain = getCircularChain(referenceTypeChain, entity);
waiting.push({
state: {
lexicalTypeStack,
context,
},
cb: (entity) => invokeReference(this, entity),
cb: (entity) => invokeReference(this, entity, true, circularChain),
});

placeholder = new Placeholder();
return this.result.rawCode(placeholder);
} else {
return invokeReference(this, entity);
return invokeReference(this, entity, false);
}

function invokeReference(
assetEmitter: AssetEmitter<T, TOptions>,
entity: EmitEntity<T>
entity: EmitEntity<T>,
circular: boolean,
circularChain?: ReferenceChainEntry[]
): EmitEntity<T> {
if (entity.kind !== "declaration") {
return entity;
}

let ref;
const scope = currentScope();
compilerAssert(
scope,
"Emit context must have a scope set in order to create references to declarations."
);
const targetScope = entity.scope;
const targetChain = scopeChain(targetScope);
const currentChain = scopeChain(scope);
let diffStart = 0;
while (
targetChain[diffStart] &&
currentChain[diffStart] &&
targetChain[diffStart] === currentChain[diffStart]
) {
diffStart++;
}

const pathUp: Scope<T>[] = currentChain.slice(diffStart);
const pathDown: Scope<T>[] = targetChain.slice(diffStart);

let ref = typeEmitter.reference(
entity,
pathUp,
pathDown,
targetChain[diffStart - 1] ?? null
);
if (circular) {
ref = typeEmitter.circularReference(entity, scope, circularChain!);
} 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 (!(ref instanceof EmitterResult)) {
ref = assetEmitter.result.rawCode(ref) as RawCode<T>;
Expand Down Expand Up @@ -298,16 +291,6 @@ export function createAssetEmitter<T, TOptions extends object>(

return ref;
}

function scopeChain(scope: Scope<T> | null) {
const chain = [];
while (scope) {
chain.unshift(scope);
scope = scope.parentScope;
}

return chain;
}
},

emitDeclarationName(type): string | undefined {
Expand Down Expand Up @@ -528,6 +511,30 @@ export function createAssetEmitter<T, TOptions extends object>(
}
}

function isInternalMethod(
method: TypeEmitterMethod
): method is Exclude<
TypeEmitterMethod,
| "interfaceDeclarationOperations"
| "interfaceOperationDeclaration"
| "operationParameters"
| "operationReturnType"
| "modelProperties"
| "enumMembers"
| "tupleLiteralValues"
| "unionVariants"
> {
return (
method === "interfaceDeclarationOperations" ||
method === "interfaceOperationDeclaration" ||
method === "operationParameters" ||
method === "operationReturnType" ||
method === "modelProperties" ||
method === "enumMembers" ||
method === "tupleLiteralValues" ||
method === "unionVariants"
);
}
/**
* This helper takes a type and sets the `context` state to what it should
* be in order to invoke the type emitter method for that type. This needs
Expand All @@ -543,18 +550,7 @@ export function createAssetEmitter<T, TOptions extends object>(

// if we've walked into a new declaration, reset the lexical type stack
// to the lexical containers of the current type.
if (
isDeclaration(type) &&
type.kind !== "Intrinsic" &&
method !== "interfaceDeclarationOperations" &&
method !== "interfaceOperationDeclaration" &&
method !== "operationParameters" &&
method !== "operationReturnType" &&
method !== "modelProperties" &&
method !== "enumMembers" &&
method !== "tupleLiteralValues" &&
method !== "unionVariants"
) {
if (isDeclaration(type) && type.kind !== "Intrinsic" && !isInternalMethod(method)) {
newTypeStack = [stackEntryInterner.intern({ method, args: stackEntryInterner.intern(args) })];
let ns = type.namespace;
while (ns) {
Expand All @@ -571,6 +567,10 @@ export function createAssetEmitter<T, TOptions extends object>(
];
}

if (!isInternalMethod(method)) {
referenceTypeChain = [...referenceTypeChain, stackEntryInterner.intern({ type })];
}

lexicalTypeStack = newTypeStack;

if (!programContext) {
Expand Down Expand Up @@ -654,12 +654,14 @@ export function createAssetEmitter<T, TOptions extends object>(
) {
const oldContext = context;
const oldTypeStack = lexicalTypeStack;
const oldRefTypeStack = referenceTypeChain;

setContextForType(method, args);
cb();

context = oldContext;
lexicalTypeStack = oldTypeStack;
referenceTypeChain = oldRefTypeStack;
}

/**
Expand Down Expand Up @@ -843,3 +845,14 @@ const noReferenceContext = new Set<string>([
function keyHasReferenceContext(key: keyof TypeEmitter<any, any>): boolean {
return !noReferenceContext.has(key);
}

function getCircularChain(stack: ReferenceChainEntry[], entity: CircularEmit) {
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i].type === entity.emitEntityKey[1]) {
return stack.slice(i);
}
}
throw new Error(
`Couldn't resolve the circular reference stack for ${getTypeName(entity.emitEntityKey[1])}`
);
}
40 changes: 40 additions & 0 deletions packages/compiler/src/emitter-framework/ref-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Declaration, Scope } from "./types.js";

export function scopeChain<T>(scope: Scope<T> | null) {
const chain = [];
while (scope) {
chain.unshift(scope);
scope = scope.parentScope;
}

return chain;
}

/**
* Resolve relative scopes between the current scope and the target declaration.
* @param target The target declaration
* @param currentScope Current scope
* @returns
*/
export function resolveDeclarationReferenceScope<T>(
target: Declaration<T>,
currentScope: Scope<T>
) {
const targetScope = target.scope;
const targetChain = scopeChain(targetScope);
const currentChain = scopeChain(currentScope);
let diffStart = 0;
while (
targetChain[diffStart] &&
currentChain[diffStart] &&
targetChain[diffStart] === currentChain[diffStart]
) {
diffStart++;
}

const pathUp: Scope<T>[] = currentChain.slice(diffStart);
const pathDown: Scope<T>[] = targetChain.slice(diffStart);

const commonScope = targetChain[diffStart - 1] ?? null;
return { pathUp, pathDown, commonScope };
}
25 changes: 25 additions & 0 deletions packages/compiler/src/emitter-framework/type-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ import {
} from "../core/index.js";
import { code, StringBuilder } from "./builders/string-builder.js";
import { Placeholder } from "./placeholder.js";
import { resolveDeclarationReferenceScope } from "./ref-scope.js";
import {
AssetEmitter,
Context,
Declaration,
EmitEntity,
EmittedSourceFile,
ReferenceChainEntry,
Scope,
SourceFile,
TypeSpecDeclaration,
Expand Down Expand Up @@ -710,6 +712,29 @@ export class TypeEmitter<T, TOptions extends object = Record<string, never>> {
return this.emitter.result.none();
}

/**
* Handle circular references. When this method is called it means we are resolving a circular reference.
* By default if the target is a declaration it will call to {@link reference} otherwise it means we have an inline reference
* @param target Reference target.
* @param scope Current scope.
* @returns Resolved reference entity.
*/
circularReference(
target: EmitEntity<T>,
scope: Scope<T> | undefined,
circularChain: ReferenceChainEntry[]
): EmitEntity<T> | T {
if (target.kind !== "declaration") {
throw new Error("Circular references to non-declarations are not supported by this emitter.");
}
compilerAssert(
scope,
"Emit context must have a scope set in order to create references to declarations."
);
const { pathUp, pathDown, commonScope } = resolveDeclarationReferenceScope(target, scope);
return this.reference(target, pathUp, pathDown, commonScope);
}

declarationName(declarationType: TypeSpecDeclaration): string | undefined {
compilerAssert(
declarationType.name !== undefined,
Expand Down
8 changes: 8 additions & 0 deletions packages/compiler/src/emitter-framework/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export type TypeEmitterMethod = keyof Omit<
| "sourceFile"
| "declarationName"
| "reference"
| "circularReference"
| "emitValue"
| "writeOutput"
| EndingWith<keyof TypeEmitter<any, any>, "Context">
Expand All @@ -184,6 +185,13 @@ export interface LexicalTypeStackEntry {
args: any[];
}

/**
* Represent an entry in the reference chain.
*/
export interface ReferenceChainEntry {
type: Type;
}

export interface EmitterState {
lexicalTypeStack: LexicalTypeStackEntry[];
context: ContextState;
Expand Down
Loading

0 comments on commit ad2aaca

Please sign in to comment.