From b6ec2387b31ad1f51b95c8fac8f2a60b2de855f6 Mon Sep 17 00:00:00 2001 From: Yegor Jbanov Date: Mon, 23 Nov 2015 17:58:12 -0800 Subject: [PATCH] feat(templates): introduce quoted expressions to support 3rd-party expression languages A quoted expression is: quoted expression = prefix `:` uninterpretedExpression prefix = identifier uninterpretedExpression = arbitrary string Example: "route:/some/route" Quoted expressions are parsed into a new AST node type Quote. The `prefix` part of the node must be a legal identifier. The `uninterpretedExpression` part of the node is an arbitrary string that Angular does not interpret. This feature is meant to be used together with template AST transformers introduced in https://github.com/angular/angular/commit/a43ed79ee7d49ec55a0adea9b587ed67780c870c. The transformer would interpret the quoted expression and convert it into a standard AST no longer containing quoted expressions. Angular will continue compiling the resulting AST normally. --- .../src/core/change_detection/parser/ast.ts | 27 ++++++++++ .../core/change_detection/parser/parser.ts | 52 ++++++++++++++----- .../change_detection/proto_change_detector.ts | 7 +++ .../change_detection/parser/parser_spec.ts | 11 +++- .../core/change_detection/parser/unparser.ts | 3 ++ 5 files changed, 85 insertions(+), 15 deletions(-) diff --git a/modules/angular2/src/core/change_detection/parser/ast.ts b/modules/angular2/src/core/change_detection/parser/ast.ts index f3a382bd4f..071506ab2c 100644 --- a/modules/angular2/src/core/change_detection/parser/ast.ts +++ b/modules/angular2/src/core/change_detection/parser/ast.ts @@ -5,6 +5,27 @@ export class AST { toString(): string { return "AST"; } } +/** + * Represents a quoted expression of the form: + * + * quote = prefix `:` uninterpretedExpression + * prefix = identifier + * uninterpretedExpression = arbitrary string + * + * A quoted expression is meant to be pre-processed by an AST transformer that + * converts it into another AST that no longer contains quoted expressions. + * It is meant to allow third-party developers to extend Angular template + * expression language. The `uninterpretedExpression` part of the quote is + * therefore not interpreted by the Angular's own expression parser. + */ +export class Quote extends AST { + constructor(public prefix: string, public uninterpretedExpression: string, public location: any) { + super(); + } + visit(visitor: AstVisitor): any { return visitor.visitQuote(this); } + toString(): string { return "Quote"; } +} + export class EmptyExpr extends AST { visit(visitor: AstVisitor) { // do nothing @@ -138,6 +159,7 @@ export interface AstVisitor { visitPrefixNot(ast: PrefixNot): any; visitPropertyRead(ast: PropertyRead): any; visitPropertyWrite(ast: PropertyWrite): any; + visitQuote(ast: Quote): any; visitSafeMethodCall(ast: SafeMethodCall): any; visitSafePropertyRead(ast: SafePropertyRead): any; } @@ -210,6 +232,7 @@ export class RecursiveAstVisitor implements AstVisitor { asts.forEach(ast => ast.visit(this)); return null; } + visitQuote(ast: Quote): any { return null; } } export class AstTransformer implements AstVisitor { @@ -285,4 +308,8 @@ export class AstTransformer implements AstVisitor { } visitChain(ast: Chain): AST { return new Chain(this.visitAll(ast.expressions)); } + + visitQuote(ast: Quote): AST { + return new Quote(ast.prefix, ast.uninterpretedExpression, ast.location); + } } diff --git a/modules/angular2/src/core/change_detection/parser/parser.ts b/modules/angular2/src/core/change_detection/parser/parser.ts index f82015e6b7..80fe041176 100644 --- a/modules/angular2/src/core/change_detection/parser/parser.ts +++ b/modules/angular2/src/core/change_detection/parser/parser.ts @@ -41,7 +41,8 @@ import { FunctionCall, TemplateBinding, ASTWithSource, - AstVisitor + AstVisitor, + Quote } from './ast'; @@ -73,17 +74,46 @@ export class Parser { } parseBinding(input: string, location: any): ASTWithSource { - this._checkNoInterpolation(input, location); - var tokens = this._lexer.tokenize(input); - var ast = new _ParseAST(input, location, tokens, this._reflector, false).parseChain(); + var ast = this._parseBindingAst(input, location); return new ASTWithSource(ast, input, location); } parseSimpleBinding(input: string, location: string): ASTWithSource { + var ast = this._parseBindingAst(input, location); + if (!SimpleExpressionChecker.check(ast)) { + throw new ParseException( + 'Host binding expression can only contain field access and constants', input, location); + } + return new ASTWithSource(ast, input, location); + } + + private _parseBindingAst(input: string, location: string): AST { + // Quotes expressions use 3rd-party expression language. We don't want to use + // our lexer or parser for that, so we check for that ahead of time. + var quote = this._parseQuote(input, location); + + if (isPresent(quote)) { + return quote; + } + this._checkNoInterpolation(input, location); var tokens = this._lexer.tokenize(input); - var ast = new _ParseAST(input, location, tokens, this._reflector, false).parseSimpleBinding(); - return new ASTWithSource(ast, input, location); + return new _ParseAST(input, location, tokens, this._reflector, false).parseChain(); + } + + private _parseQuote(input: string, location: any): AST { + if (isBlank(input)) return null; + var prefixSeparatorIndex = input.indexOf(':'); + if (prefixSeparatorIndex == -1) return null; + var prefix = input.substring(0, prefixSeparatorIndex); + var uninterpretedExpression = input.substring(prefixSeparatorIndex + 1); + + // while we do not interpret the expression, we do interpret the prefix + var prefixTokens = this._lexer.tokenize(prefix); + + // quote prefix must be a single legal identifier + if (prefixTokens.length != 1 || !prefixTokens[0].isIdentifier()) return null; + return new Quote(prefixTokens[0].strValue, uninterpretedExpression, location); } parseTemplateBindings(input: string, location: any): TemplateBinding[] { @@ -216,14 +246,6 @@ export class _ParseAST { return n.toString(); } - parseSimpleBinding(): AST { - var ast = this.parseChain(); - if (!SimpleExpressionChecker.check(ast)) { - this.error(`Simple binding expression can only contain field access and constants'`); - } - return ast; - } - parseChain(): AST { var exprs = []; while (this.index < this.tokens.length) { @@ -664,4 +686,6 @@ class SimpleExpressionChecker implements AstVisitor { } visitChain(ast: Chain) { this.simple = false; } + + visitQuote(ast: Quote) { this.simple = false; } } diff --git a/modules/angular2/src/core/change_detection/proto_change_detector.ts b/modules/angular2/src/core/change_detection/proto_change_detector.ts index e535317272..c25cc54867 100644 --- a/modules/angular2/src/core/change_detection/proto_change_detector.ts +++ b/modules/angular2/src/core/change_detection/proto_change_detector.ts @@ -22,6 +22,7 @@ import { LiteralPrimitive, MethodCall, PrefixNot, + Quote, SafePropertyRead, SafeMethodCall } from './parser/ast'; @@ -291,6 +292,12 @@ class _ConvertAstIntoProtoRecords implements AstVisitor { return this._addRecord(RecordType.Chain, "chain", null, args, null, 0); } + visitQuote(ast: Quote): void { + throw new BaseException( + `Caught uninterpreted expression at ${ast.location}: ${ast.uninterpretedExpression}. ` + + `Expression prefix ${ast.prefix} did not match a template transformer to interpret the expression.`); + } + private _visitAll(asts: any[]) { var res = ListWrapper.createFixedSize(asts.length); for (var i = 0; i < asts.length; ++i) { diff --git a/modules/angular2/test/core/change_detection/parser/parser_spec.ts b/modules/angular2/test/core/change_detection/parser/parser_spec.ts index 2893d45458..06af623177 100644 --- a/modules/angular2/test/core/change_detection/parser/parser_spec.ts +++ b/modules/angular2/test/core/change_detection/parser/parser_spec.ts @@ -230,6 +230,15 @@ export function main() { expectBindingError('"Foo"|1234').toThrowError(new RegExp('identifier or keyword')); expectBindingError('"Foo"|"uppercase"').toThrowError(new RegExp('identifier or keyword')); }); + + it('should parse quoted expressions', () => { checkBinding('a:b', 'a:b'); }); + + it('should ignore whitespace around quote prefix', () => { checkBinding(' a :b', 'a:b'); }); + + it('should refuse prefixes that are not single identifiers', () => { + expectBindingError('a + b:c').toThrowError(); + expectBindingError('1:c').toThrowError(); + }); }); it('should store the source in the result', @@ -414,7 +423,7 @@ export function main() { it("should throw when the given expression is not just a field name", () => { expect(() => parseSimpleBinding("name + 1")) .toThrowErrorWith( - 'Simple binding expression can only contain field access and constants'); + 'Host binding expression can only contain field access and constants'); }); it('should throw when encountering interpolation', () => { diff --git a/modules/angular2/test/core/change_detection/parser/unparser.ts b/modules/angular2/test/core/change_detection/parser/unparser.ts index 0260eedbaa..2ea8ab9133 100644 --- a/modules/angular2/test/core/change_detection/parser/unparser.ts +++ b/modules/angular2/test/core/change_detection/parser/unparser.ts @@ -18,6 +18,7 @@ import { LiteralPrimitive, MethodCall, PrefixNot, + Quote, SafePropertyRead, SafeMethodCall } from 'angular2/src/core/change_detection/parser/ast'; @@ -187,5 +188,7 @@ export class Unparser implements AstVisitor { this._expression += ')'; } + visitQuote(ast: Quote) { this._expression += `${ast.prefix}:${ast.uninterpretedExpression}`; } + private _visit(ast: AST) { ast.visit(this); } }