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
a43ed79ee7. 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.
This commit is contained in:
Yegor Jbanov 2015-11-23 17:58:12 -08:00 committed by vsavkin
parent cf157b99d3
commit b6ec2387b3
5 changed files with 85 additions and 15 deletions

View File

@ -5,6 +5,27 @@ export class AST {
toString(): string { return "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 { export class EmptyExpr extends AST {
visit(visitor: AstVisitor) { visit(visitor: AstVisitor) {
// do nothing // do nothing
@ -138,6 +159,7 @@ export interface AstVisitor {
visitPrefixNot(ast: PrefixNot): any; visitPrefixNot(ast: PrefixNot): any;
visitPropertyRead(ast: PropertyRead): any; visitPropertyRead(ast: PropertyRead): any;
visitPropertyWrite(ast: PropertyWrite): any; visitPropertyWrite(ast: PropertyWrite): any;
visitQuote(ast: Quote): any;
visitSafeMethodCall(ast: SafeMethodCall): any; visitSafeMethodCall(ast: SafeMethodCall): any;
visitSafePropertyRead(ast: SafePropertyRead): any; visitSafePropertyRead(ast: SafePropertyRead): any;
} }
@ -210,6 +232,7 @@ export class RecursiveAstVisitor implements AstVisitor {
asts.forEach(ast => ast.visit(this)); asts.forEach(ast => ast.visit(this));
return null; return null;
} }
visitQuote(ast: Quote): any { return null; }
} }
export class AstTransformer implements AstVisitor { 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)); } visitChain(ast: Chain): AST { return new Chain(this.visitAll(ast.expressions)); }
visitQuote(ast: Quote): AST {
return new Quote(ast.prefix, ast.uninterpretedExpression, ast.location);
}
} }

View File

@ -41,7 +41,8 @@ import {
FunctionCall, FunctionCall,
TemplateBinding, TemplateBinding,
ASTWithSource, ASTWithSource,
AstVisitor AstVisitor,
Quote
} from './ast'; } from './ast';
@ -73,17 +74,46 @@ export class Parser {
} }
parseBinding(input: string, location: any): ASTWithSource { parseBinding(input: string, location: any): ASTWithSource {
this._checkNoInterpolation(input, location); var ast = this._parseBindingAst(input, location);
var tokens = this._lexer.tokenize(input);
var ast = new _ParseAST(input, location, tokens, this._reflector, false).parseChain();
return new ASTWithSource(ast, input, location); return new ASTWithSource(ast, input, location);
} }
parseSimpleBinding(input: string, location: string): ASTWithSource { 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); this._checkNoInterpolation(input, location);
var tokens = this._lexer.tokenize(input); var tokens = this._lexer.tokenize(input);
var ast = new _ParseAST(input, location, tokens, this._reflector, false).parseSimpleBinding(); return new _ParseAST(input, location, tokens, this._reflector, false).parseChain();
return new ASTWithSource(ast, input, location); }
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[] { parseTemplateBindings(input: string, location: any): TemplateBinding[] {
@ -216,14 +246,6 @@ export class _ParseAST {
return n.toString(); 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 { parseChain(): AST {
var exprs = []; var exprs = [];
while (this.index < this.tokens.length) { while (this.index < this.tokens.length) {
@ -664,4 +686,6 @@ class SimpleExpressionChecker implements AstVisitor {
} }
visitChain(ast: Chain) { this.simple = false; } visitChain(ast: Chain) { this.simple = false; }
visitQuote(ast: Quote) { this.simple = false; }
} }

View File

@ -22,6 +22,7 @@ import {
LiteralPrimitive, LiteralPrimitive,
MethodCall, MethodCall,
PrefixNot, PrefixNot,
Quote,
SafePropertyRead, SafePropertyRead,
SafeMethodCall SafeMethodCall
} from './parser/ast'; } from './parser/ast';
@ -291,6 +292,12 @@ class _ConvertAstIntoProtoRecords implements AstVisitor {
return this._addRecord(RecordType.Chain, "chain", null, args, null, 0); 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[]) { private _visitAll(asts: any[]) {
var res = ListWrapper.createFixedSize(asts.length); var res = ListWrapper.createFixedSize(asts.length);
for (var i = 0; i < asts.length; ++i) { for (var i = 0; i < asts.length; ++i) {

View File

@ -230,6 +230,15 @@ export function main() {
expectBindingError('"Foo"|1234').toThrowError(new RegExp('identifier or keyword')); expectBindingError('"Foo"|1234').toThrowError(new RegExp('identifier or keyword'));
expectBindingError('"Foo"|"uppercase"').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', 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", () => { it("should throw when the given expression is not just a field name", () => {
expect(() => parseSimpleBinding("name + 1")) expect(() => parseSimpleBinding("name + 1"))
.toThrowErrorWith( .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', () => { it('should throw when encountering interpolation', () => {

View File

@ -18,6 +18,7 @@ import {
LiteralPrimitive, LiteralPrimitive,
MethodCall, MethodCall,
PrefixNot, PrefixNot,
Quote,
SafePropertyRead, SafePropertyRead,
SafeMethodCall SafeMethodCall
} from 'angular2/src/core/change_detection/parser/ast'; } from 'angular2/src/core/change_detection/parser/ast';
@ -187,5 +188,7 @@ export class Unparser implements AstVisitor {
this._expression += ')'; this._expression += ')';
} }
visitQuote(ast: Quote) { this._expression += `${ast.prefix}:${ast.uninterpretedExpression}`; }
private _visit(ast: AST) { ast.visit(this); } private _visit(ast: AST) { ast.visit(this); }
} }