Skip to content

Commit

Permalink
feat(compiler-cli): simulate narrowing of signal reads in templates
Browse files Browse the repository at this point in the history
  • Loading branch information
JoostK committed Apr 21, 2024
1 parent b1dffa4 commit 96fbc35
Show file tree
Hide file tree
Showing 6 changed files with 591 additions and 35 deletions.
90 changes: 86 additions & 4 deletions packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ts from 'typescript';
import {TypeCheckingConfig} from '../api';

import {addParseSpanInfo, wrapForDiagnostics, wrapForTypeChecker} from './diagnostics';
import {isPotentialSignalCall, PotentialSignalCall} from './signal_calls';
import {tsCastToAny, tsNumericExpression} from './ts_util';

export const NULL_AS_ANY = ts.factory.createAsExpression(
Expand Down Expand Up @@ -44,21 +45,52 @@ const BINARY_OPS = new Map<string, ts.BinaryOperator>([
['??', ts.SyntaxKind.QuestionQuestionToken],
]);

export interface SignalCallNarrowing {
/**
* Declares a variable in the TCB to assign the result of the provided call expression to and
* returns the `ts.Identifier` of the variable. If the call expression is not suitable for signal
* narrowing then `null` is returned.
*/
allocateDeclaration(call: PotentialSignalCall): ts.Identifier|null;

/**
* Attempts to resolve a local TCB variable that has been initialized with the provided call
* expression. If the call expression hasn't been assigned into a variable then `null` is
* returned.
*/
resolveDeclaration(call: PotentialSignalCall): ts.Identifier|null;
}

export enum EmitMode {
/**
* Regular emit mode.
*/
Regular,

/**
* In this mode, call expression are being assigned into a local variable declaration for each
* uniquely identified call expression.
*/
Narrowing,
}

/**
* Convert an `AST` to TypeScript code directly, without going through an intermediate `Expression`
* AST.
*/
export function astToTypescript(
ast: AST, maybeResolve: (ast: AST) => (ts.Expression | null),
config: TypeCheckingConfig): ts.Expression {
const translator = new AstTranslator(maybeResolve, config);
signalCallOutlining: SignalCallNarrowing, config: TypeCheckingConfig,
mode: EmitMode): ts.Expression {
const translator = new AstTranslator(maybeResolve, signalCallOutlining, config, mode);
return translator.translate(ast);
}

class AstTranslator implements AstVisitor {
constructor(
private maybeResolve: (ast: AST) => (ts.Expression | null),
private config: TypeCheckingConfig) {}
private signalCallNarrowing: SignalCallNarrowing, private config: TypeCheckingConfig,
private mode: EmitMode) {}

translate(ast: AST): ts.Expression {
// Skip over an `ASTWithSource` as its `visit` method calls directly into its ast's `visit`,
Expand Down Expand Up @@ -324,6 +356,26 @@ class AstTranslator implements AstVisitor {
visitCall(ast: Call): ts.Expression {
const args = ast.args.map(expr => this.translate(expr));

// If the call may be a signal call, attempt to reuse a reference to an assigned variable in the
// TCB that corresponds with the same signal call in a higher scope.
let signalAssignmentTarget: ts.Identifier|null = null;
if (isPotentialSignalCall(ast)) {
const declaration = this.signalCallNarrowing.resolveDeclaration(ast);
if (declaration !== null) {
// A local declaration that captures the call's type exists, use it to enable narrowing of
// signal calls. Note that doing so means that any diagnostic within this call expression
// will remain unreported, under the assumption that the same diagnostic are being reported
// in the initial assignment to the local variable.
return declaration;
}

// If the expression is being emitted in a narrowing position, allocate a local declaration
// for the call expression and assign its value into it.
if (this.mode === EmitMode.Narrowing) {
signalAssignmentTarget = this.signalCallNarrowing.allocateDeclaration(ast);
}
}

let expr: ts.Expression;
const receiver = ast.receiver;

Expand Down Expand Up @@ -353,14 +405,44 @@ class AstTranslator implements AstVisitor {
node = ts.factory.createCallExpression(expr, undefined, args);
}

if (signalAssignmentTarget !== null) {
node = ts.factory.createAssignment(signalAssignmentTarget, node);
}

addParseSpanInfo(node, ast.sourceSpan);
return node;
}

visitSafeCall(ast: SafeCall): ts.Expression {
const args = ast.args.map(expr => this.translate(expr));

// If the call may be a signal call, attempt to reuse a reference to an assigned variable in the
// TCB that corresponds with the same signal call in a higher scope.
let signalAssignmentTarget: ts.Identifier|null = null;
if (isPotentialSignalCall(ast)) {
const declaration = this.signalCallNarrowing.resolveDeclaration(ast);
if (declaration !== null) {
// A local declaration that captures the call's type exists, use it to enable narrowing of
// signal calls. Note that doing so means that any diagnostic within this call expression
// will remain unreported, under the assumption that the same diagnostic are being reported
// in the initial assignment to the local variable.
return declaration;
}

// If the expression is being emitted in a narrowing position, allocate a local declaration
// for the call expression and assign its value into it.
if (this.mode === EmitMode.Narrowing) {
signalAssignmentTarget = this.signalCallNarrowing.allocateDeclaration(ast);
}
}

const expr = wrapForDiagnostics(this.translate(ast.receiver));
const node = this.convertToSafeCall(ast, expr, args);
let node = this.convertToSafeCall(ast, expr, args);

if (signalAssignmentTarget !== null) {
node = ts.factory.createAssignment(signalAssignmentTarget, node);
}

addParseSpanInfo(node, ast.sourceSpan);
return node;
}
Expand Down
232 changes: 232 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/src/signal_calls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {AST, AstVisitor, ASTWithSource, Binary, BindingPipe, Call, Chain, Conditional, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralPrimitive, NonNullAssert, PrefixNot, PropertyRead, PropertyWrite, SafeCall, SafeKeyedRead, SafePropertyRead, ThisReceiver, Unary,} from '@angular/compiler';

/**
* The types of nodes potentially corresponding with a signal call in a template. Any call
* expression without arguments is potentially a signal call, as signal calls cannot be
* differentiated from regular method calls syntactically.
*/
export type PotentialSignalCall = (Call|SafeCall)&{args: []};

/**
* Tests whether the given call expression may correspond with a signal call.
*/
export function isPotentialSignalCall(ast: Call|SafeCall): ast is PotentialSignalCall {
return ast.args.length === 0;
}

/**
* Represents a string that identifies a call expression that may represent a signal call.
*/
export type SignalCallIdentity = string&{__brand: 'SignalCallIdentity'};

/**
* Computes the string identity of the provided call expression. Any expression that is witnessed
* when evaluating the call expression is included in the identity. For implicit reads (e.g.
* property accesses with `ImplicitReceiver` as receiver) an external identity is requested, to
* ensure that each local template variable is assigned its own unique identifier. This is important
* for scenarios like
*
* ```
* @Component({
* template: `
* @if (persons[selectedPerson](); as selectedPerson) {
* {{ persons[selectedPerson]() }}
* }`
* })
* export class ShadowCmp {
* persons = {
* admin: signal('admin');
* guest: signal('guest');
* };
* selectedPerson: keyof ShadowCmp['persons'];
* }
* ```
*
* Here, the local alias `selectedPerson` shadows the `ShadowCmp.selectedPerson` field, such that
* the call expression in the @if's condition expression is not equivalent to the syntactically
* identical expression within its block. Consequently, different identities have to be computed for
* both call expressions, which is achieved by letting `identifyImplicitRead` resolve any implicit
* accesses to a unique identifier for local template variables.
*
* @param call The call expression to compute the identity for.
* @param recurse Callback function to compute the identity of nested signal calls.
* @param identifyImplicitRead Callback function to determine the identity of implicit reads.
*/
export function computeSignalCallIdentity(
call: PotentialSignalCall,
recurse: (receiverCall: PotentialSignalCall) => string | null,
identifyImplicitRead: (expr: AST) => string | null,
): SignalCallIdentity|null {
return call.receiver.visit(new SignalCallIdentification(recurse, identifyImplicitRead));
}

class SignalCallIdentification implements AstVisitor {
constructor(
private recurse: (receiverCall: PotentialSignalCall) => string | null,
private identifyImplicitRead: (expr: AST) => string | null,
) {}

visitUnary(ast: Unary): string|null {
const expr = this.forAst(ast.expr);
if (expr === null) {
return null;
}
return `${ast.operator}${expr}`;
}

visitBinary(ast: Binary): string|null {
const left = this.forAst(ast.left);
const right = this.forAst(ast.right);
if (left === null || right === null) {
return null;
}
return `${left}${ast.operation}${right}`;
}

visitChain(ast: Chain): string|null {
return null;
}

visitConditional(ast: Conditional): string|null {
return null;
}

visitThisReceiver(ast: ThisReceiver): string|null {
return 'this';
}

visitImplicitReceiver(ast: ImplicitReceiver): string|null {
return 'this';
}

visitInterpolation(ast: Interpolation): string|null {
return null;
}

visitKeyedRead(ast: KeyedRead): string|null {
const receiver = this.forAst(ast.receiver);
const key = this.forAst(ast.key);
if (receiver === null || key === null) {
return null;
}
return `${receiver}[${key}]`;
}

visitKeyedWrite(ast: KeyedWrite): string|null {
return null;
}

visitLiteralArray(ast: LiteralArray): string|null {
return null;
}

visitLiteralMap(ast: LiteralMap): string|null {
return null;
}

visitLiteralPrimitive(ast: LiteralPrimitive): string|null {
return `${ast.value}`;
}

visitPipe(ast: BindingPipe): string|null {
// Don't enable narrowing when pipes are being evaluated.
return null;
}

visitPrefixNot(ast: PrefixNot): string|null {
const expression = this.forAst(ast.expression);
if (expression === null) {
return expression;
}
return `!${expression}`;
}

visitNonNullAssert(ast: NonNullAssert): string|null {
return this.forAst(ast.expression);
}

visitPropertyRead(ast: PropertyRead): string|null {
const receiver = this.identifyReceiver(ast);
if (receiver === null) {
return null;
}
return `${receiver}.${ast.name}`;
}

visitPropertyWrite(ast: PropertyWrite): string|null {
return null;
}

visitSafePropertyRead(ast: SafePropertyRead): string|null {
const receiver = this.identifyReceiver(ast);
if (receiver === null) {
return null;
}
return `${receiver}?.${ast.name}`;
}

visitSafeKeyedRead(ast: SafeKeyedRead): string|null {
const receiver = this.forAst(ast.receiver)
if (receiver === null) {
return null;
}
return `${receiver}?.[${this.forAst(ast.key)}]`;
}

visitCall(ast: Call): string|null {
if (ast.args.length > 0) {
// Don't enable narrowing for complex calls.
return null;
}
const receiver = this.forAst(ast.receiver);
if (receiver === null) {
return null;
}
return `${receiver}()`;
}

visitSafeCall(ast: SafeCall): string|null {
if (ast.args.length > 0) {
// Don't enable narrowing for complex calls.
return null;
}
const receiver = this.forAst(ast.receiver);
if (receiver === null) {
return null;
}
return `${receiver}?.()`;
}

visitASTWithSource(ast: ASTWithSource): string|null {
return this.forAst(ast.ast);
}

private identifyReceiver(ast: PropertyRead|SafePropertyRead): string|null {
const receiver = ast.receiver;
if (receiver instanceof ImplicitReceiver && !(receiver instanceof ThisReceiver)) {
const implicitIdentity = this.identifyImplicitRead(ast);
if (implicitIdentity !== null) {
return implicitIdentity;
}
}
return this.forAst(receiver);
}

private forAst(ast: AST): string|null {
if ((ast instanceof Call || ast instanceof SafeCall) && isPotentialSignalCall(ast)) {
const result = this.recurse(ast);
if (result !== null) {
return ${result}`;
}
}
return ast.visit(this);
}
}
14 changes: 11 additions & 3 deletions packages/compiler-cli/src/ngtsc/typecheck/src/ts_util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,22 @@ export function tsDeclareVariable(id: ts.Identifier, type: ts.TypeNode): ts.Vari
const initializer: ts.Expression = ts.factory.createAsExpression(
ts.factory.createNonNullExpression(ts.factory.createNull()), type);

return tsCreateVariable(id, initializer);
}

/**
* Create a `ts.VariableDeclaration` with `let` semantics without an initializer, optionally with an
* explicit type.
*/
export function tsDeclareLetVariable(id: ts.Identifier, type?: ts.TypeNode): ts.VariableStatement {
const decl = ts.factory.createVariableDeclaration(
/* name */ id,
/* exclamationToken */ undefined,
/* type */ undefined,
/* initializer */ initializer);
/* type */ type,
/* initializer */ undefined);
return ts.factory.createVariableStatement(
/* modifiers */ undefined,
/* declarationList */[decl]);
/* declarationList */ ts.factory.createVariableDeclarationList([decl], ts.NodeFlags.Let));
}

/**
Expand Down

0 comments on commit 96fbc35

Please sign in to comment.