feat(ivy): introduce a new compiler API for operating on templates (#26203)

This commit introduces the "t2" API, which processes parsed template ASTs
and performs a number of functions such as binding (the process of
semantically interpreting cross-references within the template) and
directive matching. The API is modeled on TypeScript's TypeChecker API,
with oracle methods that give access to collected metadata.

This work is a prerequisite for the upcoming template type-checking
functionality, and will also become the basis for a refactored
TemplateDefinitionBuilder.

PR Close #26203
This commit is contained in:
Alex Rickabaugh
2018-09-21 15:42:07 -07:00
committed by Jason Aden
parent a2da485d90
commit 9ed4e3df60
5 changed files with 798 additions and 0 deletions

View File

@ -0,0 +1,139 @@
/**
* @license
* Copyright Google Inc. 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} from '../../expression_parser/ast';
import {BoundAttribute, BoundEvent, Element, Node, Reference, Template, TextAttribute, Variable} from '../r3_ast';
/*
* t2 is the replacement for the `TemplateDefinitionBuilder`. It handles the operations of
* analyzing Angular templates, extracting semantic info, and ultimately producing a template
* definition function which renders the template using Ivy instructions.
*
* t2 data is also utilized by the template type-checking facilities to understand a template enough
* to generate type-checking code for it.
*/
/**
* A logical target for analysis, which could contain a template or other types of bindings.
*/
export interface Target { template?: Node[]; }
/**
* Metadata regarding a directive that's needed to match it against template elements. This is
* provided by a consumer of the t2 APIs.
*/
export interface DirectiveMeta {
/**
* Name of the directive class (used for debugging).
*/
name: string;
/**
* Whether the directive is a component.
*/
isComponent: boolean;
/**
* Set of inputs which this directive claims.
*
* Goes from property names to field names.
*/
inputs: {[property: string]: string};
/**
* Set of outputs which this directive claims.
*
* Goes from property names to field names.
*/
outputs: {[property: string]: string};
/**
* Name under which the directive is exported, if any (exportAs in Angular).
*
* Null otherwise
*/
exportAs: string|null;
}
/**
* Interface to the binding API, which processes a template and returns an object similar to the
* `ts.TypeChecker`.
*
* The returned `BoundTarget` has an API for extracting information about the processed target.
*/
export interface TargetBinder<D extends DirectiveMeta> { bind(target: Target): BoundTarget<D>; }
/**
* Result of performing the binding operation against a `Target`.
*
* The original `Target` is accessible, as well as a suite of methods for extracting binding
* information regarding the `Target`.
*
* @param DirectiveT directive metadata type
*/
export interface BoundTarget<DirectiveT extends DirectiveMeta> {
/**
* Get the original `Target` that was bound.
*/
readonly target: Target;
/**
* For a given template node (either an `Element` or a `Template`), get the set of directives
* which matched the node, if any.
*/
getDirectivesOfNode(node: Element|Template): DirectiveT[]|null;
/**
* For a given `Reference`, get the reference's target - either an `Element`, a `Template`, or
* a directive on a particular node.
*/
getReferenceTarget(ref: Reference): {directive: DirectiveT, node: Element|Template}|Element
|Template|null;
/**
* For a given binding, get the entity to which the binding is being made.
*
* This will either be a directive or the node itself.
*/
getConsumerOfBinding(binding: BoundAttribute|BoundEvent|TextAttribute): DirectiveT|Element
|Template|null;
/**
* If the given `AST` expression refers to a `Reference` or `Variable` within the `Target`, then
* return that.
*
* Otherwise, returns `null`.
*
* This is only defined for `AST` expressions that read or write to a property of an
* `ImplicitReceiver`.
*/
getExpressionTarget(expr: AST): Reference|Variable|null;
/**
* Given a particular `Reference` or `Variable`, get the `Template` which created it.
*
* All `Variable`s are defined on templates, so this will always return a value for a `Variable`
* from the `Target`. For `Reference`s this only returns a value if the `Reference` points to a
* `Template`. Returns `null` otherwise.
*/
getTemplateOfSymbol(symbol: Reference|Variable): Template|null;
/**
* Get the nesting level of a particular `Template`.
*
* This starts at 1 for top-level `Template`s within the `Target` and increases for `Template`s
* nested at deeper levels.
*/
getNestingLevel(template: Template): number;
/**
* Get a list of all the directives used by the target.
*/
getUsedDirectives(): DirectiveT[];
}

View File

@ -0,0 +1,528 @@
/**
* @license
* Copyright Google Inc. 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, ImplicitReceiver, MethodCall, PropertyRead, PropertyWrite, RecursiveAstVisitor, SafeMethodCall, SafePropertyRead} from '../../expression_parser/ast';
import {CssSelector, SelectorMatcher} from '../../selector';
import {BoundAttribute, BoundEvent, BoundText, Content, Element, Node, Reference, Template, Text, TextAttribute, Variable, Visitor} from '../r3_ast';
import {BoundTarget, DirectiveMeta, Target, TargetBinder} from './t2_api';
import {getAttrsForDirectiveMatching} from './util';
/**
* Processes `Target`s with a given set of directives and performs a binding operation, which
* returns an object similar to TypeScript's `ts.TypeChecker` that contains knowledge about the
* target.
*/
export class R3TargetBinder<DirectiveT extends DirectiveMeta> implements TargetBinder<DirectiveT> {
constructor(private directiveMatcher: SelectorMatcher<DirectiveT>) {}
/**
* Perform a binding operation on the given `Target` and return a `BoundTarget` which contains
* metadata about the types referenced in the template.
*/
bind(target: Target): BoundTarget<DirectiveT> {
if (!target.template) {
// TODO(alxhub): handle targets which contain things like HostBindings, etc.
throw new Error('Binding without a template not yet supported');
}
// First, parse the template into a `Scope` structure. This operation captures the syntactic
// scopes in the template and makes them available for later use.
const scope = Scope.apply(target.template);
// Next, perform directive matching on the template using the `DirectiveBinder`. This returns:
// - directives: Map of nodes (elements & ng-templates) to the directives on them.
// - bindings: Map of inputs, outputs, and attributes to the directive/element that claims
// them. TODO(alxhub): handle multiple directives claiming an input/output/etc.
// - references: Map of #references to their targets.
const {directives, bindings, references} =
DirectiveBinder.apply(target.template, this.directiveMatcher);
// Finally, run the TemplateBinder to bind references, variables, and other entities within the
// template. This extracts all the metadata that doesn't depend on directive matching.
const {expressions, symbols, nestingLevel} = TemplateBinder.apply(target.template, scope);
return new R3BoundTarget(
target, directives, bindings, references, expressions, symbols, nestingLevel);
}
}
/**
* Represents a binding scope within a template.
*
* Any variables, references, or other named entities declared within the template will
* be captured and available by name in `namedEntities`. Additionally, child templates will
* be analyzed and have their child `Scope`s available in `childScopes`.
*/
class Scope implements Visitor {
/**
* Named members of the `Scope`, such as `Reference`s or `Variable`s.
*/
readonly namedEntities = new Map<string, Reference|Variable>();
/**
* Child `Scope`s for immediately nested `Template`s.
*/
readonly childScopes = new Map<Template, Scope>();
private constructor(readonly parentScope?: Scope) {}
/**
* Process a template (either as a `Template` sub-template with variables, or a plain array of
* template `Node`s) and construct its `Scope`.
*/
static apply(template: Template|Node[]): Scope {
const scope = new Scope();
scope.ingest(template);
return scope;
}
/**
* Internal method to process the template and populate the `Scope`.
*/
private ingest(template: Template|Node[]): void {
if (template instanceof Template) {
// Variables on an <ng-template> are defined in the inner scope.
template.variables.forEach(node => this.visitVariable(node));
// Process the nodes of the template.
template.children.forEach(node => node.visit(this));
} else {
// No overarching `Template` instance, so process the nodes directly.
template.forEach(node => node.visit(this));
}
}
visitElement(element: Element) {
// `Element`s in the template may have `Reference`s which are captured in the scope.
element.references.forEach(node => this.visitReference(node));
// Recurse into the `Element`'s children.
element.children.forEach(node => node.visit(this));
}
visitTemplate(template: Template) {
// References on a <ng-template> are defined in the outer scope, so capture them before
// processing the template's child scope.
template.references.forEach(node => this.visitReference(node));
// Next, create an inner scope and process the template within it.
const scope = new Scope(this);
scope.ingest(template);
this.childScopes.set(template, scope);
}
visitVariable(variable: Variable) {
// Declare the variable if it's not already.
this.maybeDeclare(variable);
}
visitReference(reference: Reference) {
// Declare the variable if it's not already.
this.maybeDeclare(reference);
}
// Unused visitors.
visitContent(content: Content) {}
visitBoundAttribute(attr: BoundAttribute) {}
visitBoundEvent(event: BoundEvent) {}
visitBoundText(text: BoundText) {}
visitText(text: Text) {}
visitTextAttribute(attr: TextAttribute) {}
private maybeDeclare(thing: Reference|Variable) {
// Declare something with a name, as long as that name isn't taken.
if (!this.namedEntities.has(thing.name)) {
this.namedEntities.set(thing.name, thing);
}
}
/**
* Look up a variable within this `Scope`.
*
* This can recurse into a parent `Scope` if it's available.
*/
lookup(name: string): Reference|Variable|null {
if (this.namedEntities.has(name)) {
// Found in the local scope.
return this.namedEntities.get(name) !;
} else if (this.parentScope !== undefined) {
// Not in the local scope, but there's a parent scope so check there.
return this.parentScope.lookup(name);
} else {
// At the top level and it wasn't found.
return null;
}
}
/**
* Get the child scope for a `Template`.
*
* This should always be defined.
*/
getChildScope(template: Template): Scope {
const res = this.childScopes.get(template);
if (res === undefined) {
throw new Error(`Assertion error: child scope for ${template} not found`);
}
return res;
}
}
/**
* Processes a template and matches directives on nodes (elements and templates).
*
* Usually used via the static `apply()` method.
*/
class DirectiveBinder<DirectiveT extends DirectiveMeta> implements Visitor {
constructor(
private matcher: SelectorMatcher<DirectiveT>,
private directives: Map<Element|Template, DirectiveT[]>,
private bindings: Map<BoundAttribute|BoundEvent|TextAttribute, DirectiveT|Element|Template>,
private references:
Map<Reference, {directive: DirectiveT, node: Element|Template}|Element|Template>) {}
/**
* Process a template (list of `Node`s) and perform directive matching against each node.
*
* @param template the list of template `Node`s to match (recursively).
* @param selectorMatcher a `SelectorMatcher` containing the directives that are in scope for
* this template.
* @returns three maps which contain information about directives in the template: the
* `directives` map which lists directives matched on each node, the `bindings` map which
* indicates which directives claimed which bindings (inputs, outputs, etc), and the `references`
* map which resolves #references (`Reference`s) within the template to the named directive or
* template node.
*/
static apply<DirectiveT extends DirectiveMeta>(
template: Node[], selectorMatcher: SelectorMatcher<DirectiveT>): {
directives: Map<Element|Template, DirectiveT[]>,
bindings: Map<BoundAttribute|BoundEvent|TextAttribute, DirectiveT|Element|Template>,
references: Map<Reference, {directive: DirectiveT, node: Element|Template}|Element|Template>,
} {
const directives = new Map<Element|Template, DirectiveT[]>();
const bindings =
new Map<BoundAttribute|BoundEvent|TextAttribute, DirectiveT|Element|Template>();
const references =
new Map<Reference, {directive: DirectiveT, node: Element | Template}|Element|Template>();
const matcher = new DirectiveBinder(selectorMatcher, directives, bindings, references);
matcher.ingest(template);
return {directives, bindings, references};
}
private ingest(template: Node[]): void { template.forEach(node => node.visit(this)); }
visitElement(element: Element): void { this.visitElementOrTemplate(element.name, element); }
visitTemplate(template: Template): void { this.visitElementOrTemplate('ng-template', template); }
visitElementOrTemplate(tag: string, node: Element|Template): void {
// First, determine the HTML shape of the node for the purpose of directive matching.
// Do this by building up a `CssSelector` for the node.
const cssSelector = new CssSelector();
cssSelector.setElement(tag);
// Add attributes to the CSS selector.
const attrs = getAttrsForDirectiveMatching(node);
Object.getOwnPropertyNames(attrs).forEach((name) => {
const value = attrs[name];
cssSelector.addAttribute(name, value);
// Treat the 'class' attribute specially.
if (name.toLowerCase() === 'class') {
const classes = value.trim().split(/\s+/g);
classes.forEach(className => cssSelector.addClassName(className));
}
});
// Next, use the `SelectorMatcher` to get the list of directives on the node.
const directives: DirectiveT[] = [];
this.matcher.match(cssSelector, (_, directive) => directives.push(directive));
if (directives.length > 0) {
this.directives.set(node, directives);
}
// Resolve any references that are created on this node.
node.references.forEach(ref => {
let dirTarget: DirectiveT|null = null;
// If the reference expression is empty, then it matches the "primary" directive on the node
// (if there is one). Otherwise it matches the host node itself (either an element or
// <ng-template> node).
if (ref.value.trim() === '') {
// This could be a reference to a component if there is one.
dirTarget = directives.find(dir => dir.isComponent) || null;
} else {
// This is a reference to a directive exported via exportAs. One should exist.
dirTarget = directives.find(dir => dir.exportAs === ref.value) || null;
// Check if a matching directive was found, and error if it wasn't.
if (dirTarget === null) {
// TODO(alxhub): Return an error value here that can be used for template validation.
throw new Error(`Assertion error: failed to find directive with exportAs: ${ref.value}`);
}
}
if (dirTarget !== null) {
// This reference points to a directive.
this.references.set(ref, {directive: dirTarget, node});
} else {
// This reference points to the node itself.
this.references.set(ref, node);
}
});
// Associate bindings on the node with directives or with the node itself.
// Inputs:
[...node.attributes, ...node.inputs].forEach(binding => {
let dir = directives.find(dir => dir.inputs.hasOwnProperty(binding.name));
if (dir !== undefined) {
this.bindings.set(binding, dir);
} else {
this.bindings.set(binding, node);
}
});
// Outputs:
node.outputs.forEach(binding => {
let dir = directives.find(dir => dir.outputs.hasOwnProperty(binding.name));
if (dir !== undefined) {
this.bindings.set(binding, dir);
} else {
this.bindings.set(binding, node);
}
});
// Recurse into the node's children.
node.children.forEach(child => child.visit(this));
}
// Unused visitors.
visitContent(content: Content): void {}
visitVariable(variable: Variable): void {}
visitReference(reference: Reference): void {}
visitTextAttribute(attribute: TextAttribute): void {}
visitBoundAttribute(attribute: BoundAttribute): void {}
visitBoundEvent(attribute: BoundEvent): void {}
visitBoundAttributeOrEvent(node: BoundAttribute|BoundEvent) {}
visitText(text: Text): void {}
visitBoundText(text: BoundText): void {}
}
/**
* Processes a template and extract metadata about expressions and symbols within.
*
* This is a companion to the `DirectiveBinder` that doesn't require knowledge of directives matched
* within the template in order to operate.
*
* Expressions are visited by the superclass `RecursiveAstVisitor`, with custom logic provided
* by overridden methods from that visitor.
*/
class TemplateBinder extends RecursiveAstVisitor implements Visitor {
private visitNode: (node: Node) => void;
private constructor(
private bindings: Map<AST, Reference|Variable>,
private symbols: Map<Reference|Variable, Template>,
private nestingLevel: Map<Template, number>, private scope: Scope,
private template: Template|null, private level: number) {
super();
// Save a bit of processing time by constructing this closure in advance.
this.visitNode = (node: Node) => node.visit(this);
}
/**
* Process a template and extract metadata about expressions and symbols within.
*
* @param template the nodes of the template to process
* @param scope the `Scope` of the template being processed.
* @returns three maps which contain metadata about the template: `expressions` which interprets
* special `AST` nodes in expressions as pointing to references or variables declared within the
* template, `symbols` which maps those variables and references to the nested `Template` which
* declares them, if any, and `nestingLevel` which associates each `Template` with a integer
* nesting level (how many levels deep within the template structure the `Template` is), starting
* at 1.
*/
static apply(template: Node[], scope: Scope): {
expressions: Map<AST, Reference|Variable>,
symbols: Map<Variable|Reference, Template>,
nestingLevel: Map<Template, number>,
} {
const expressions = new Map<AST, Reference|Variable>();
const symbols = new Map<Variable|Reference, Template>();
const nestingLevel = new Map<Template, number>();
// The top-level template has nesting level 0.
const binder = new TemplateBinder(
expressions, symbols, nestingLevel, scope, template instanceof Template ? template : null,
0);
binder.ingest(template);
return {expressions, symbols, nestingLevel};
}
private ingest(template: Template|Node[]): void {
if (template instanceof Template) {
// For <ng-template>s, process inputs, outputs, variables, and child nodes. References were
// processed in the scope of the containing template.
template.inputs.forEach(this.visitNode);
template.outputs.forEach(this.visitNode);
template.variables.forEach(this.visitNode);
template.children.forEach(this.visitNode);
// Set the nesting level.
this.nestingLevel.set(template, this.level);
} else {
// Visit each node from the top-level template.
template.forEach(this.visitNode);
}
}
visitElement(element: Element) {
// Vist the inputs, outputs, and children of the element.
element.inputs.forEach(this.visitNode);
element.outputs.forEach(this.visitNode);
element.children.forEach(this.visitNode);
}
visitTemplate(template: Template) {
// First, visit the inputs, outputs of the template node.
template.inputs.forEach(this.visitNode);
template.outputs.forEach(this.visitNode);
// References are also evaluated in the outer context.
template.references.forEach(this.visitNode);
// Next, recurse into the template using its scope, and bumping the nesting level up by one.
const childScope = this.scope.getChildScope(template);
const binder = new TemplateBinder(
this.bindings, this.symbols, this.nestingLevel, childScope, template, this.level + 1);
binder.ingest(template);
}
visitVariable(variable: Variable) {
// Register the `Variable` as a symbol in the current `Template`.
if (this.template !== null) {
this.symbols.set(variable, this.template);
}
}
visitReference(reference: Reference) {
// Register the `Reference` as a symbol in the current `Template`.
if (this.template !== null) {
this.symbols.set(reference, this.template);
}
}
// Unused template visitors
visitText(text: Text) {}
visitContent(content: Content) {}
visitTextAttribute(attribute: TextAttribute) {}
// The remaining visitors are concerned with processing AST expressions within template bindings
visitBoundAttribute(attribute: BoundAttribute) { attribute.value.visit(this); }
visitBoundEvent(event: BoundEvent) { event.handler.visit(this); }
visitBoundText(text: BoundText) { text.value.visit(this); }
// These five types of AST expressions can refer to expression roots, which could be variables
// or references in the current scope.
visitPropertyRead(ast: PropertyRead, context: any): any {
this.maybeMap(context, ast, ast.name);
return super.visitPropertyRead(ast, context);
}
visitSafePropertyRead(ast: SafePropertyRead, context: any): any {
this.maybeMap(context, ast, ast.name);
return super.visitSafePropertyRead(ast, context);
}
visitPropertyWrite(ast: PropertyWrite, context: any): any {
this.maybeMap(context, ast, ast.name);
return super.visitPropertyWrite(ast, context);
}
visitMethodCall(ast: MethodCall, context: any): any {
this.maybeMap(context, ast, ast.name);
return super.visitMethodCall(ast, context);
}
visitSafeMethodCall(ast: SafeMethodCall, context: any): any {
this.maybeMap(context, ast, ast.name);
return super.visitSafeMethodCall(ast, context);
}
private maybeMap(
scope: Scope, ast: PropertyRead|SafePropertyRead|PropertyWrite|MethodCall|SafeMethodCall,
name: string): void {
// If the receiver of the expression isn't the `ImplicitReceiver`, this isn't the root of an
// `AST` expression that maps to a `Variable` or `Reference`.
if (!(ast.receiver instanceof ImplicitReceiver)) {
return;
}
// Check whether the name exists in the current scope. If so, map it. Otherwise, the name is
// probably a property on the top-level component context.
let target = this.scope.lookup(name);
if (target !== null) {
this.bindings.set(ast, target);
}
}
}
/**
* Metadata container for a `Target` that allows queries for specific bits of metadata.
*
* See `BoundTarget` for documentation on the individual methods.
*/
export class R3BoundTarget<DirectiveT extends DirectiveMeta> implements BoundTarget<DirectiveT> {
constructor(
readonly target: Target, private directives: Map<Element|Template, DirectiveT[]>,
private bindings: Map<BoundAttribute|BoundEvent|TextAttribute, DirectiveT|Element|Template>,
private references:
Map<BoundAttribute|BoundEvent|Reference|TextAttribute,
{directive: DirectiveT, node: Element|Template}|Element|Template>,
private exprTargets: Map<AST, Reference|Variable>,
private symbols: Map<Reference|Variable, Template>,
private nestingLevel: Map<Template, number>) {}
getDirectivesOfNode(node: Element|Template): DirectiveT[]|null {
return this.directives.get(node) || null;
}
getReferenceTarget(ref: Reference): {directive: DirectiveT, node: Element|Template}|Element
|Template|null {
return this.references.get(ref) || null;
}
getConsumerOfBinding(binding: BoundAttribute|BoundEvent|TextAttribute): DirectiveT|Element
|Template|null {
return this.bindings.get(binding) || null;
}
getExpressionTarget(expr: AST): Reference|Variable|null {
return this.exprTargets.get(expr) || null;
}
getTemplateOfSymbol(symbol: Reference|Variable): Template|null {
return this.symbols.get(symbol) || null;
}
getNestingLevel(template: Template): number { return this.nestingLevel.get(template) || 0; }
getUsedDirectives(): DirectiveT[] {
const set = new Set<DirectiveT>();
this.directives.forEach(dirs => dirs.forEach(dir => set.add(dir)));
return Array.from(set.values());
}
}