From 4662878a1fe9b6248ba03f869f9de2b81eb1d531 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 19 Apr 2018 17:23:27 -0700 Subject: [PATCH] refactor(compiler): refactor template binding parsing (#23460) A long time ago Angular used to support both those attribute notations: - `*attr='binding'` - `template=`attr: binding` Because the last notation has been dropped we can refactor the binding parsing. Source maps will benefit from that as no `attr:` prefix is added artificialy any more. PR Close #23460 --- .../common/test/directives/ng_for_spec.ts | 4 +- .../compiler/src/expression_parser/ast.ts | 2 +- .../compiler/src/expression_parser/parser.ts | 66 +- .../src/render3/r3_template_transform.ts | 8 +- .../src/template_parser/binding_parser.ts | 11 +- .../src/template_parser/template_parser.ts | 12 +- .../test/expression_parser/parser_spec.ts | 1027 ++++++++--------- 7 files changed, 557 insertions(+), 573 deletions(-) diff --git a/packages/common/test/directives/ng_for_spec.ts b/packages/common/test/directives/ng_for_spec.ts index 541d1863cc..683333d0b6 100644 --- a/packages/common/test/directives/ng_for_spec.ts +++ b/packages/common/test/directives/ng_for_spec.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {CommonModule, NgForOf} from '@angular/common'; -import {Component, Directive} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {Component} from '@angular/core'; import {ComponentFixture, TestBed, async} from '@angular/core/testing'; import {By} from '@angular/platform-browser/src/dom/debug/by'; import {expect} from '@angular/platform-browser/testing/src/matchers'; diff --git a/packages/compiler/src/expression_parser/ast.ts b/packages/compiler/src/expression_parser/ast.ts index 2a579cdd3a..46c4bbcebf 100644 --- a/packages/compiler/src/expression_parser/ast.ts +++ b/packages/compiler/src/expression_parser/ast.ts @@ -215,7 +215,7 @@ export class ASTWithSource extends AST { export class TemplateBinding { constructor( public span: ParseSpan, public key: string, public keyIsVar: boolean, public name: string, - public expression: ASTWithSource) {} + public expression: ASTWithSource|null) {} } export interface AstVisitor { diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index d847cfbe39..f69afa45b2 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -98,19 +98,11 @@ export class Parser { return new Quote(new ParseSpan(0, input.length), prefix, uninterpretedExpression, location); } - parseTemplateBindings(prefixToken: string|null, input: string, location: any): + parseTemplateBindings(tplKey: string, tplValue: string, location: any): TemplateBindingParseResult { - const tokens = this._lexer.tokenize(input); - if (prefixToken) { - // Prefix the tokens with the tokens from prefixToken but have them take no space (0 index). - const prefixTokens = this._lexer.tokenize(prefixToken).map(t => { - t.index = 0; - return t; - }); - tokens.unshift(...prefixTokens); - } - return new _ParseAST(input, location, tokens, input.length, false, this.errors, 0) - .parseTemplateBindings(); + const tokens = this._lexer.tokenize(tplValue); + return new _ParseAST(tplValue, location, tokens, tplValue.length, false, this.errors, 0) + .parseTemplateBindings(tplKey); } parseInterpolation( @@ -686,48 +678,49 @@ export class _ParseAST { return result.toString(); } - parseTemplateBindings(): TemplateBindingParseResult { + // Parses the AST for `` + parseTemplateBindings(tplKey: string): TemplateBindingParseResult { + let firstBinding = true; const bindings: TemplateBinding[] = []; - let prefix: string = null !; const warnings: string[] = []; - while (this.index < this.tokens.length) { + do { const start = this.inputIndex; - let keyIsVar: boolean = this.peekKeywordLet(); - if (keyIsVar) { - this.advance(); + let rawKey: string; + let key: string; + let isVar: boolean = false; + if (firstBinding) { + rawKey = key = tplKey; + firstBinding = false; + } else { + isVar = this.peekKeywordLet(); + if (isVar) this.advance() + rawKey = this.expectTemplateBindingKey(); + key = isVar ? rawKey : tplKey + rawKey[0].toUpperCase() + rawKey.substring(1); + this.optionalCharacter(chars.$COLON); } - let rawKey = this.expectTemplateBindingKey(); - let key = rawKey; - if (!keyIsVar) { - if (prefix == null) { - prefix = key; - } else { - key = prefix + key[0].toUpperCase() + key.substring(1); - } - } - this.optionalCharacter(chars.$COLON); + let name: string = null !; - let expression: ASTWithSource = null !; - if (keyIsVar) { + let expression: ASTWithSource|null = null; + if (isVar) { if (this.optionalOperator('=')) { name = this.expectTemplateBindingKey(); } else { name = '\$implicit'; } } else if (this.peekKeywordAs()) { - const letStart = this.inputIndex; this.advance(); // consume `as` name = rawKey; key = this.expectTemplateBindingKey(); // read local var name - keyIsVar = true; + isVar = true; } else if (this.next !== EOF && !this.peekKeywordLet()) { const start = this.inputIndex; const ast = this.parsePipe(); const source = this.input.substring(start - this.offset, this.inputIndex - this.offset); expression = new ASTWithSource(ast, source, this.location, this.errors); } - bindings.push(new TemplateBinding(this.span(start), key, keyIsVar, name, expression)); - if (this.peekKeywordAs() && !keyIsVar) { + + bindings.push(new TemplateBinding(this.span(start), key, isVar, name, expression)); + if (this.peekKeywordAs() && !isVar) { const letStart = this.inputIndex; this.advance(); // consume `as` const letName = this.expectTemplateBindingKey(); // read local var name @@ -736,8 +729,9 @@ export class _ParseAST { if (!this.optionalCharacter(chars.$SEMICOLON)) { this.optionalCharacter(chars.$COMMA); } - } - return new TemplateBindingParseResult(bindings, warnings, this.errors); + } while (this.index < this.tokens.length) + + return new TemplateBindingParseResult(bindings, warnings, this.errors); } error(message: string, index: number|null = null) { diff --git a/packages/compiler/src/render3/r3_template_transform.ts b/packages/compiler/src/render3/r3_template_transform.ts index 7fce7119c6..138975c59c 100644 --- a/packages/compiler/src/render3/r3_template_transform.ts +++ b/packages/compiler/src/render3/r3_template_transform.ts @@ -107,16 +107,16 @@ export class HtmlToTemplateTransform implements html.Visitor { } isTemplateBinding = true; elementHasInlineTemplate = true; - const templateBindingsSource = attribute.value; - const prefixToken = normalizedName.substring(TEMPLATE_ATTR_PREFIX.length) + ':'; + const templateValue = attribute.value; + const templateKey = normalizedName.substring(TEMPLATE_ATTR_PREFIX.length); const oldVariables: VariableAst[] = []; inlineTemplateSourceSpan = attribute.valueSpan || attribute.sourceSpan; this.bindingParser.parseInlineTemplateBinding( - prefixToken !, templateBindingsSource !, attribute.sourceSpan, - templateMatchableAttributes, templateBoundProperties, oldVariables); + templateKey, templateValue, attribute.sourceSpan, templateMatchableAttributes, + templateBoundProperties, oldVariables); templateVariables.push( ...oldVariables.map(v => new t.Variable(v.name, v.value, v.sourceSpan))); diff --git a/packages/compiler/src/template_parser/binding_parser.ts b/packages/compiler/src/template_parser/binding_parser.ts index 3e71c4f5f5..66168a429c 100644 --- a/packages/compiler/src/template_parser/binding_parser.ts +++ b/packages/compiler/src/template_parser/binding_parser.ts @@ -130,11 +130,12 @@ export class BindingParser { } } - // Parse an inline template binding. ie `` + // Parse an inline template binding. ie `` parseInlineTemplateBinding( - prefixToken: string, value: string, sourceSpan: ParseSourceSpan, + tplKey: string, tplValue: string, sourceSpan: ParseSourceSpan, targetMatchableAttrs: string[][], targetProps: BoundProperty[], targetVars: VariableAst[]) { - const bindings = this._parseTemplateBindings(prefixToken, value, sourceSpan); + const bindings = this._parseTemplateBindings(tplKey, tplValue, sourceSpan); + for (let i = 0; i < bindings.length; i++) { const binding = bindings[i]; if (binding.keyIsVar) { @@ -149,12 +150,12 @@ export class BindingParser { } } - private _parseTemplateBindings(prefixToken: string, value: string, sourceSpan: ParseSourceSpan): + private _parseTemplateBindings(tplKey: string, tplValue: string, sourceSpan: ParseSourceSpan): TemplateBinding[] { const sourceInfo = sourceSpan.start.toString(); try { - const bindingsResult = this._exprParser.parseTemplateBindings(prefixToken, value, sourceInfo); + const bindingsResult = this._exprParser.parseTemplateBindings(tplKey, tplValue, sourceInfo); this._reportExpressionParserErrors(bindingsResult.errors, sourceSpan); bindingsResult.templateBindings.forEach((binding) => { if (binding.expression) { diff --git a/packages/compiler/src/template_parser/template_parser.ts b/packages/compiler/src/template_parser/template_parser.ts index 8f5607dd96..8880fe4037 100644 --- a/packages/compiler/src/template_parser/template_parser.ts +++ b/packages/compiler/src/template_parser/template_parser.ts @@ -289,16 +289,16 @@ class TemplateParseVisitor implements html.Visitor { isTemplateElement, attr, matchableAttrs, elementOrDirectiveProps, events, elementOrDirectiveRefs, elementVars); - let templateBindingsSource: string|undefined; - let prefixToken: string|undefined; + let templateValue: string|undefined; + let templateKey: string|undefined; const normalizedName = this._normalizeAttributeName(attr.name); if (normalizedName.startsWith(TEMPLATE_ATTR_PREFIX)) { - templateBindingsSource = attr.value; - prefixToken = normalizedName.substring(TEMPLATE_ATTR_PREFIX.length) + ':'; + templateValue = attr.value; + templateKey = normalizedName.substring(TEMPLATE_ATTR_PREFIX.length); } - const hasTemplateBinding = templateBindingsSource != null; + const hasTemplateBinding = templateValue != null; if (hasTemplateBinding) { if (hasInlineTemplates) { this._reportError( @@ -307,7 +307,7 @@ class TemplateParseVisitor implements html.Visitor { } hasInlineTemplates = true; this._bindingParser.parseInlineTemplateBinding( - prefixToken !, templateBindingsSource !, attr.sourceSpan, templateMatchableAttrs, + templateKey !, templateValue !, attr.sourceSpan, templateMatchableAttrs, templateElementOrDirectiveProps, templateElementVars); } diff --git a/packages/compiler/test/expression_parser/parser_spec.ts b/packages/compiler/test/expression_parser/parser_spec.ts index cdd38fb72f..6090e8a73f 100644 --- a/packages/compiler/test/expression_parser/parser_spec.ts +++ b/packages/compiler/test/expression_parser/parser_spec.ts @@ -15,595 +15,584 @@ import {expect} from '@angular/platform-browser/testing/src/matchers'; import {unparse} from './unparser'; import {validate} from './validator'; -(function() { - function createParser() { return new Parser(new Lexer()); } +describe('parser', () => { + describe('parseAction', () => { + it('should parse numbers', () => { checkAction('1'); }); - function parseAction(text: string, location: any = null): ASTWithSource { - return createParser().parseAction(text, location); - } + it('should parse strings', () => { + checkAction('\'1\'', '"1"'); + checkAction('"1"'); + }); - function parseBinding(text: string, location: any = null): ASTWithSource { - return createParser().parseBinding(text, location); - } + it('should parse null', () => { checkAction('null'); }); - function parseTemplateBindingsResult( - text: string, location: any = null, prefix?: string): TemplateBindingParseResult { - return createParser().parseTemplateBindings(prefix || null, text, location); - } - function parseTemplateBindings( - text: string, location: any = null, prefix?: string): TemplateBinding[] { - return parseTemplateBindingsResult(text, location, prefix).templateBindings; - } + it('should parse undefined', () => { checkAction('undefined'); }); - function parseInterpolation(text: string, location: any = null): ASTWithSource|null { - return createParser().parseInterpolation(text, location); - } + it('should parse unary - expressions', () => { + checkAction('-1', '0 - 1'); + checkAction('+1', '1 - 0'); + checkAction(`-'1'`, `0 - "1"`); + checkAction(`+'1'`, `"1" - 0`); + }); - function splitInterpolation(text: string, location: any = null): SplitInterpolation|null { - return createParser().splitInterpolation(text, location); - } + it('should parse unary ! expressions', () => { + checkAction('!true'); + checkAction('!!true'); + checkAction('!!!true'); + }); - function parseSimpleBinding(text: string, location: any = null): ASTWithSource { - return createParser().parseSimpleBinding(text, location); - } + it('should parse postfix ! expression', () => { + checkAction('true!'); + checkAction('a!.b'); + checkAction('a!!!!.b'); + }); - function checkInterpolation(exp: string, expected?: string) { - const ast = parseInterpolation(exp) !; - if (expected == null) expected = exp; - expect(unparse(ast)).toEqual(expected); - validate(ast); - } + it('should parse multiplicative expressions', + () => { checkAction('3*4/2%5', '3 * 4 / 2 % 5'); }); - function checkBinding(exp: string, expected?: string) { - const ast = parseBinding(exp); - if (expected == null) expected = exp; - expect(unparse(ast)).toEqual(expected); - validate(ast); - } + it('should parse additive expressions', () => { checkAction('3 + 6 - 2'); }); - function checkAction(exp: string, expected?: string) { - const ast = parseAction(exp); - if (expected == null) expected = exp; - expect(unparse(ast)).toEqual(expected); - validate(ast); - } + it('should parse relational expressions', () => { + checkAction('2 < 3'); + checkAction('2 > 3'); + checkAction('2 <= 2'); + checkAction('2 >= 2'); + }); - function expectError(ast: {errors: ParserError[]}, message: string) { - for (const error of ast.errors) { - if (error.message.indexOf(message) >= 0) { - return; - } - } - const errMsgs = ast.errors.map(err => err.message).join('\n'); - throw Error( - `Expected an error containing "${message}" to be reported, but got the errors:\n` + - errMsgs); - } + it('should parse equality expressions', () => { + checkAction('2 == 3'); + checkAction('2 != 3'); + }); - function expectActionError(text: string, message: string) { - expectError(validate(parseAction(text)), message); - } + it('should parse strict equality expressions', () => { + checkAction('2 === 3'); + checkAction('2 !== 3'); + }); - function expectBindingError(text: string, message: string) { - expectError(validate(parseBinding(text)), message); - } + it('should parse expressions', () => { + checkAction('true && true'); + checkAction('true || false'); + }); - describe('parser', () => { - describe('parseAction', () => { - it('should parse numbers', () => { checkAction('1'); }); + it('should parse grouped expressions', () => { checkAction('(1 + 2) * 3', '1 + 2 * 3'); }); - it('should parse strings', () => { - checkAction('\'1\'', '"1"'); - checkAction('"1"'); + it('should ignore comments in expressions', () => { checkAction('a //comment', 'a'); }); + + it('should retain // in string literals', + () => { checkAction(`"http://www.google.com"`, `"http://www.google.com"`); }); + + it('should parse an empty string', () => { checkAction(''); }); + + describe('literals', () => { + it('should parse array', () => { + checkAction('[1][0]'); + checkAction('[[1]][0][0]'); + checkAction('[]'); + checkAction('[].length'); + checkAction('[1, 2].length'); }); - it('should parse null', () => { checkAction('null'); }); - - it('should parse undefined', () => { checkAction('undefined'); }); - - it('should parse unary - expressions', () => { - checkAction('-1', '0 - 1'); - checkAction('+1', '1 - 0'); - checkAction(`-'1'`, `0 - "1"`); - checkAction(`+'1'`, `"1" - 0`); + it('should parse map', () => { + checkAction('{}'); + checkAction('{a: 1, "b": 2}[2]'); + checkAction('{}["a"]'); }); - it('should parse unary ! expressions', () => { - checkAction('!true'); - checkAction('!!true'); - checkAction('!!!true'); - }); - - it('should parse postfix ! expression', () => { - checkAction('true!'); - checkAction('a!.b'); - checkAction('a!!!!.b'); - }); - - it('should parse multiplicative expressions', - () => { checkAction('3*4/2%5', '3 * 4 / 2 % 5'); }); - - it('should parse additive expressions', () => { checkAction('3 + 6 - 2'); }); - - it('should parse relational expressions', () => { - checkAction('2 < 3'); - checkAction('2 > 3'); - checkAction('2 <= 2'); - checkAction('2 >= 2'); - }); - - it('should parse equality expressions', () => { - checkAction('2 == 3'); - checkAction('2 != 3'); - }); - - it('should parse strict equality expressions', () => { - checkAction('2 === 3'); - checkAction('2 !== 3'); - }); - - it('should parse expressions', () => { - checkAction('true && true'); - checkAction('true || false'); - }); - - it('should parse grouped expressions', () => { checkAction('(1 + 2) * 3', '1 + 2 * 3'); }); - - it('should ignore comments in expressions', () => { checkAction('a //comment', 'a'); }); - - it('should retain // in string literals', - () => { checkAction(`"http://www.google.com"`, `"http://www.google.com"`); }); - - it('should parse an empty string', () => { checkAction(''); }); - - describe('literals', () => { - it('should parse array', () => { - checkAction('[1][0]'); - checkAction('[[1]][0][0]'); - checkAction('[]'); - checkAction('[].length'); - checkAction('[1, 2].length'); - }); - - it('should parse map', () => { - checkAction('{}'); - checkAction('{a: 1, "b": 2}[2]'); - checkAction('{}["a"]'); - }); - - it('should only allow identifier, string, or keyword as map key', () => { - expectActionError('{(:0}', 'expected identifier, keyword, or string'); - expectActionError('{1234:0}', 'expected identifier, keyword, or string'); - }); - }); - - describe('member access', () => { - it('should parse field access', () => { - checkAction('a'); - checkAction('this.a', 'a'); - checkAction('a.a'); - }); - - it('should only allow identifier or keyword as member names', () => { - expectActionError('x.(', 'identifier or keyword'); - expectActionError('x. 1234', 'identifier or keyword'); - expectActionError('x."foo"', 'identifier or keyword'); - }); - - it('should parse safe field access', () => { - checkAction('a?.a'); - checkAction('a.a?.a'); - }); - }); - - describe('method calls', () => { - it('should parse method calls', () => { - checkAction('fn()'); - checkAction('add(1, 2)'); - checkAction('a.add(1, 2)'); - checkAction('fn().add(1, 2)'); - }); - }); - - describe('functional calls', () => { - it('should parse function calls', () => { checkAction('fn()(1, 2)'); }); - }); - - describe('conditional', () => { - it('should parse ternary/conditional expressions', () => { - checkAction('7 == 3 + 4 ? 10 : 20'); - checkAction('false ? 10 : 20'); - }); - - it('should report incorrect ternary operator syntax', () => { - expectActionError('true?1', 'Conditional expression true?1 requires all 3 expressions'); - }); - }); - - describe('assignment', () => { - it('should support field assignments', () => { - checkAction('a = 12'); - checkAction('a.a.a = 123'); - checkAction('a = 123; b = 234;'); - }); - - it('should report on safe field assignments', - () => { expectActionError('a?.a = 123', 'cannot be used in the assignment'); }); - - it('should support array updates', () => { checkAction('a[0] = 200'); }); - }); - - it('should error when using pipes', - () => { expectActionError('x|blah', 'Cannot have a pipe'); }); - - it('should store the source in the result', - () => { expect(parseAction('someExpr', 'someExpr')); }); - - it('should store the passed-in location', - () => { expect(parseAction('someExpr', 'location').location).toBe('location'); }); - - it('should report when encountering interpolation', () => { - expectActionError('{{a()}}', 'Got interpolation ({{}}) where expression was expected'); + it('should only allow identifier, string, or keyword as map key', () => { + expectActionError('{(:0}', 'expected identifier, keyword, or string'); + expectActionError('{1234:0}', 'expected identifier, keyword, or string'); }); }); - describe('general error handling', () => { - it('should report an unexpected token', - () => { expectActionError('[1,2] trac', 'Unexpected token \'trac\''); }); + describe('member access', () => { + it('should parse field access', () => { + checkAction('a'); + checkAction('this.a', 'a'); + checkAction('a.a'); + }); - it('should report reasonable error for unconsumed tokens', - () => { expectActionError(')', 'Unexpected token ) at column 1 in [)]'); }); + it('should only allow identifier or keyword as member names', () => { + expectActionError('x.(', 'identifier or keyword'); + expectActionError('x. 1234', 'identifier or keyword'); + expectActionError('x."foo"', 'identifier or keyword'); + }); - it('should report a missing expected token', () => { - expectActionError('a(b', 'Missing expected ) at the end of the expression [a(b]'); + it('should parse safe field access', () => { + checkAction('a?.a'); + checkAction('a.a?.a'); }); }); - describe('parseBinding', () => { - describe('pipes', () => { - it('should parse pipes', () => { - checkBinding('a(b | c)', 'a((b | c))'); - checkBinding('a.b(c.d(e) | f)', 'a.b((c.d(e) | f))'); - checkBinding('[1, 2, 3] | a', '([1, 2, 3] | a)'); - checkBinding('{a: 1, "b": 2} | c', '({a: 1, "b": 2} | c)'); - checkBinding('a[b] | c', '(a[b] | c)'); - checkBinding('a?.b | c', '(a?.b | c)'); - checkBinding('true | a', '(true | a)'); - checkBinding('a | b:c | d', '((a | b:c) | d)'); - checkBinding('a | b:(c | d)', '(a | b:(c | d))'); - }); - - it('should only allow identifier or keyword as formatter names', () => { - expectBindingError('"Foo"|(', 'identifier or keyword'); - expectBindingError('"Foo"|1234', 'identifier or keyword'); - expectBindingError('"Foo"|"uppercase"', 'identifier or keyword'); - }); - - it('should parse quoted expressions', () => { checkBinding('a:b', 'a:b'); }); - - it('should not crash when prefix part is not tokenizable', - () => { 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', ''); - expectBindingError('1:c', ''); - }); + describe('method calls', () => { + it('should parse method calls', () => { + checkAction('fn()'); + checkAction('add(1, 2)'); + checkAction('a.add(1, 2)'); + checkAction('fn().add(1, 2)'); }); - - it('should store the source in the result', - () => { expect(parseBinding('someExpr').source).toBe('someExpr'); }); - - it('should store the passed-in location', - () => { expect(parseBinding('someExpr', 'location').location).toBe('location'); }); - - it('should report chain expressions', - () => { expectError(parseBinding('1;2'), 'contain chained expression'); }); - - it('should report assignment', - () => { expectError(parseBinding('a=2'), 'contain assignments'); }); - - it('should report when encountering interpolation', () => { - expectBindingError('{{a.b}}', 'Got interpolation ({{}}) where expression was expected'); - }); - - it('should parse conditional expression', () => { checkBinding('a < b ? a : b'); }); - - it('should ignore comments in bindings', () => { checkBinding('a //comment', 'a'); }); - - it('should retain // in string literals', - () => { checkBinding(`"http://www.google.com"`, `"http://www.google.com"`); }); - - it('should retain // in : microsyntax', () => { checkBinding('one:a//b', 'one:a//b'); }); - }); - describe('parseTemplateBindings', () => { + describe('functional calls', () => { + it('should parse function calls', () => { checkAction('fn()(1, 2)'); }); + }); - function keys(templateBindings: any[]) { - return templateBindings.map(binding => binding.key); - } - - function keyValues(templateBindings: any[]) { - return templateBindings.map(binding => { - if (binding.keyIsVar) { - return 'let ' + binding.key + (binding.name == null ? '=null' : '=' + binding.name); - } else { - return binding.key + (binding.expression == null ? '' : `=${binding.expression}`); - } - }); - } - - function keySpans(source: string, templateBindings: TemplateBinding[]) { - return templateBindings.map( - binding => source.substring(binding.span.start, binding.span.end)); - } - - function exprSources(templateBindings: any[]) { - return templateBindings.map( - binding => binding.expression != null ? binding.expression.source : null); - } - - it('should parse an empty string', () => { expect(parseTemplateBindings('')).toEqual([]); }); - - it('should parse a string without a value', - () => { expect(keys(parseTemplateBindings('a'))).toEqual(['a']); }); - - it('should only allow identifier, string, or keyword including dashes as keys', () => { - let bindings = parseTemplateBindings('a:\'b\''); - expect(keys(bindings)).toEqual(['a']); - - bindings = parseTemplateBindings('\'a\':\'b\''); - expect(keys(bindings)).toEqual(['a']); - - bindings = parseTemplateBindings('"a":\'b\''); - expect(keys(bindings)).toEqual(['a']); - - bindings = parseTemplateBindings('a-b:\'c\''); - expect(keys(bindings)).toEqual(['a-b']); - - expectError(parseTemplateBindingsResult('(:0'), 'expected identifier, keyword, or string'); - - expectError( - parseTemplateBindingsResult('1234:0'), 'expected identifier, keyword, or string'); + describe('conditional', () => { + it('should parse ternary/conditional expressions', () => { + checkAction('7 == 3 + 4 ? 10 : 20'); + checkAction('false ? 10 : 20'); }); - it('should detect expressions as value', () => { - let bindings = parseTemplateBindings('a:b'); - expect(exprSources(bindings)).toEqual(['b']); + it('should report incorrect ternary operator syntax', () => { + expectActionError('true?1', 'Conditional expression true?1 requires all 3 expressions'); + }); + }); - bindings = parseTemplateBindings('a:1+1'); - expect(exprSources(bindings)).toEqual(['1+1']); + describe('assignment', () => { + it('should support field assignments', () => { + checkAction('a = 12'); + checkAction('a.a.a = 123'); + checkAction('a = 123; b = 234;'); }); - it('should detect names as value', () => { - const bindings = parseTemplateBindings('a:let b'); - expect(keyValues(bindings)).toEqual(['a', 'let b=\$implicit']); - }); + it('should report on safe field assignments', + () => { expectActionError('a?.a = 123', 'cannot be used in the assignment'); }); - it('should allow space and colon as separators', () => { - let bindings = parseTemplateBindings('a:b'); - expect(keys(bindings)).toEqual(['a']); - expect(exprSources(bindings)).toEqual(['b']); + it('should support array updates', () => { checkAction('a[0] = 200'); }); + }); - bindings = parseTemplateBindings('a b'); - expect(keys(bindings)).toEqual(['a']); - expect(exprSources(bindings)).toEqual(['b']); - }); + it('should error when using pipes', + () => { expectActionError('x|blah', 'Cannot have a pipe'); }); - it('should allow multiple pairs', () => { - const bindings = parseTemplateBindings('a 1 b 2'); - expect(keys(bindings)).toEqual(['a', 'aB']); - expect(exprSources(bindings)).toEqual(['1 ', '2']); - }); + it('should store the source in the result', + () => { expect(parseAction('someExpr', 'someExpr')); }); - it('should store the sources in the result', () => { - const bindings = parseTemplateBindings('a 1,b 2'); - expect(bindings[0].expression.source).toEqual('1'); - expect(bindings[1].expression.source).toEqual('2'); - }); + it('should store the passed-in location', + () => { expect(parseAction('someExpr', 'location').location).toBe('location'); }); - it('should store the passed-in location', () => { - const bindings = parseTemplateBindings('a 1,b 2', 'location'); - expect(bindings[0].expression.location).toEqual('location'); - }); + it('should report when encountering interpolation', () => { + expectActionError('{{a()}}', 'Got interpolation ({{}}) where expression was expected'); + }); + }); - it('should support let notation', () => { - let bindings = parseTemplateBindings('let i'); - expect(keyValues(bindings)).toEqual(['let i=\$implicit']); + describe('general error handling', () => { + it('should report an unexpected token', + () => { expectActionError('[1,2] trac', 'Unexpected token \'trac\''); }); - bindings = parseTemplateBindings('let i'); - expect(keyValues(bindings)).toEqual(['let i=\$implicit']); + it('should report reasonable error for unconsumed tokens', + () => { expectActionError(')', 'Unexpected token ) at column 1 in [)]'); }); - bindings = parseTemplateBindings('let a; let b'); - expect(keyValues(bindings)).toEqual(['let a=\$implicit', 'let b=\$implicit']); - - bindings = parseTemplateBindings('let a; let b;'); - expect(keyValues(bindings)).toEqual(['let a=\$implicit', 'let b=\$implicit']); - - bindings = parseTemplateBindings('let i-a = k-a'); - expect(keyValues(bindings)).toEqual(['let i-a=k-a']); - - bindings = parseTemplateBindings('keyword let item; let i = k'); - expect(keyValues(bindings)).toEqual(['keyword', 'let item=\$implicit', 'let i=k']); - - bindings = parseTemplateBindings('keyword: let item; let i = k'); - expect(keyValues(bindings)).toEqual(['keyword', 'let item=\$implicit', 'let i=k']); - - bindings = parseTemplateBindings('directive: let item in expr; let a = b', 'location'); - expect(keyValues(bindings)).toEqual([ - 'directive', 'let item=\$implicit', 'directiveIn=expr in location', 'let a=b' - ]); - }); - - it('should support as notation', () => { - let bindings = parseTemplateBindings('ngIf exp as local', 'location'); - expect(keyValues(bindings)).toEqual(['ngIf=exp in location', 'let local=ngIf']); - - bindings = parseTemplateBindings('ngFor let item of items as iter; index as i', 'L'); - expect(keyValues(bindings)).toEqual([ - 'ngFor', 'let item=$implicit', 'ngForOf=items in L', 'let iter=ngForOf', 'let i=index' - ]); - }); + it('should report a missing expected token', () => { + expectActionError('a(b', 'Missing expected ) at the end of the expression [a(b]'); + }); + }); + describe('parseBinding', () => { + describe('pipes', () => { it('should parse pipes', () => { - const bindings = parseTemplateBindings('key value|pipe'); - const ast = bindings[0].expression.ast; - expect(ast).toBeAnInstanceOf(BindingPipe); + checkBinding('a(b | c)', 'a((b | c))'); + checkBinding('a.b(c.d(e) | f)', 'a.b((c.d(e) | f))'); + checkBinding('[1, 2, 3] | a', '([1, 2, 3] | a)'); + checkBinding('{a: 1, "b": 2} | c', '({a: 1, "b": 2} | c)'); + checkBinding('a[b] | c', '(a[b] | c)'); + checkBinding('a?.b | c', '(a?.b | c)'); + checkBinding('true | a', '(true | a)'); + checkBinding('a | b:c | d', '((a | b:c) | d)'); + checkBinding('a | b:(c | d)', '(a | b:(c | d))'); }); - describe('spans', () => { - it('should should support let', () => { - const source = 'let i'; - expect(keySpans(source, parseTemplateBindings(source))).toEqual(['let i']); - }); + it('should only allow identifier or keyword as formatter names', () => { + expectBindingError('"Foo"|(', 'identifier or keyword'); + expectBindingError('"Foo"|1234', 'identifier or keyword'); + expectBindingError('"Foo"|"uppercase"', 'identifier or keyword'); + }); - it('should support multiple lets', () => { - const source = 'let item; let i=index; let e=even;'; - expect(keySpans(source, parseTemplateBindings(source))).toEqual([ - 'let item', 'let i=index', 'let e=even' - ]); - }); + it('should parse quoted expressions', () => { checkBinding('a:b', 'a:b'); }); - it('should support a prefix', () => { - const source = 'let person of people'; - const prefix = 'ngFor'; - const bindings = parseTemplateBindings(source, null, prefix); - expect(keyValues(bindings)).toEqual([ - 'ngFor', 'let person=$implicit', 'ngForOf=people in null' - ]); - expect(keySpans(source, bindings)).toEqual(['', 'let person ', 'of people']); - }); + it('should not crash when prefix part is not tokenizable', + () => { 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', ''); + expectBindingError('1:c', ''); }); }); - describe('parseInterpolation', () => { - it('should return null if no interpolation', - () => { expect(parseInterpolation('nothing')).toBe(null); }); + it('should store the source in the result', + () => { expect(parseBinding('someExpr').source).toBe('someExpr'); }); - it('should parse no prefix/suffix interpolation', () => { - const ast = parseInterpolation('{{a}}') !.ast as Interpolation; - expect(ast.strings).toEqual(['', '']); - expect(ast.expressions.length).toEqual(1); - expect(ast.expressions[0].name).toEqual('a'); - }); + it('should store the passed-in location', + () => { expect(parseBinding('someExpr', 'location').location).toBe('location'); }); - it('should parse prefix/suffix with multiple interpolation', () => { - const originalExp = 'before {{ a }} middle {{ b }} after'; - const ast = parseInterpolation(originalExp) !.ast; - expect(unparse(ast)).toEqual(originalExp); - validate(ast); - }); + it('should report chain expressions', + () => { expectError(parseBinding('1;2'), 'contain chained expression'); }); - it('should report empty interpolation expressions', () => { - expectError( - parseInterpolation('{{}}') !, - 'Blank expressions are not allowed in interpolated strings'); - - expectError( - parseInterpolation('foo {{ }}') !, - 'Parser Error: Blank expressions are not allowed in interpolated strings'); - }); - - it('should parse conditional expression', - () => { checkInterpolation('{{ a < b ? a : b }}'); }); - - it('should parse expression with newline characters', () => { - checkInterpolation(`{{ 'foo' +\n 'bar' +\r 'baz' }}`, `{{ "foo" + "bar" + "baz" }}`); - }); - - it('should support custom interpolation', () => { - const parser = new Parser(new Lexer()); - const ast = - parser.parseInterpolation('{% a %}', null, {start: '{%', end: '%}'}) !.ast as any; - expect(ast.strings).toEqual(['', '']); - expect(ast.expressions.length).toEqual(1); - expect(ast.expressions[0].name).toEqual('a'); - }); - - describe('comments', () => { - it('should ignore comments in interpolation expressions', - () => { checkInterpolation('{{a //comment}}', '{{ a }}'); }); - - it('should retain // in single quote strings', () => { - checkInterpolation(`{{ 'http://www.google.com' }}`, `{{ "http://www.google.com" }}`); - }); - - it('should retain // in double quote strings', () => { - checkInterpolation(`{{ "http://www.google.com" }}`, `{{ "http://www.google.com" }}`); - }); - - it('should ignore comments after string literals', - () => { checkInterpolation(`{{ "a//b" //comment }}`, `{{ "a//b" }}`); }); - - it('should retain // in complex strings', () => { - checkInterpolation( - `{{"//a\'//b\`//c\`//d\'//e" //comment}}`, `{{ "//a\'//b\`//c\`//d\'//e" }}`); - }); - - it('should retain // in nested, unterminated strings', - () => { checkInterpolation(`{{ "a\'b\`" //comment}}`, `{{ "a\'b\`" }}`); }); - }); + it('should report assignment', + () => { expectError(parseBinding('a=2'), 'contain assignments'); }); + it('should report when encountering interpolation', () => { + expectBindingError('{{a.b}}', 'Got interpolation ({{}}) where expression was expected'); }); - describe('parseSimpleBinding', () => { - it('should parse a field access', () => { - const p = parseSimpleBinding('name'); - expect(unparse(p)).toEqual('name'); - validate(p); - }); + it('should parse conditional expression', () => { checkBinding('a < b ? a : b'); }); - it('should report when encountering pipes', () => { - expectError( - validate(parseSimpleBinding('a | somePipe')), - 'Host binding expression cannot contain pipes'); - }); + it('should ignore comments in bindings', () => { checkBinding('a //comment', 'a'); }); - it('should report when encountering interpolation', () => { - expectError( - validate(parseSimpleBinding('{{exp}}')), - 'Got interpolation ({{}}) where expression was expected'); - }); + it('should retain // in string literals', + () => { checkBinding(`"http://www.google.com"`, `"http://www.google.com"`); }); - it('should report when encountering field write', () => { - expectError(validate(parseSimpleBinding('a = b')), 'Bindings cannot contain assignments'); + it('should retain // in : microsyntax', () => { checkBinding('one:a//b', 'one:a//b'); }); + + }); + + describe('parseTemplateBindings', () => { + + function keys(templateBindings: any[]) { return templateBindings.map(binding => binding.key); } + + function keyValues(templateBindings: any[]) { + return templateBindings.map(binding => { + if (binding.keyIsVar) { + return 'let ' + binding.key + (binding.name == null ? '=null' : '=' + binding.name); + } else { + return binding.key + (binding.expression == null ? '' : `=${binding.expression}`); + } }); + } + + function keySpans(source: string, templateBindings: TemplateBinding[]) { + return templateBindings.map( + binding => source.substring(binding.span.start, binding.span.end)); + } + + function exprSources(templateBindings: any[]) { + return templateBindings.map( + binding => binding.expression != null ? binding.expression.source : null); + } + + it('should parse a key without a value', + () => { expect(keys(parseTemplateBindings('a', ''))).toEqual(['a']); }); + + it('should allow string including dashes as keys', () => { + let bindings = parseTemplateBindings('a', 'b'); + expect(keys(bindings)).toEqual(['a']); + + bindings = parseTemplateBindings('a-b', 'c'); + expect(keys(bindings)).toEqual(['a-b']); }); - describe('wrapLiteralPrimitive', () => { - it('should wrap a literal primitive', () => { - expect(unparse(validate(createParser().wrapLiteralPrimitive('foo', null)))) - .toEqual('"foo"'); - }); + it('should detect expressions as value', () => { + let bindings = parseTemplateBindings('a', 'b'); + expect(exprSources(bindings)).toEqual(['b']); + + bindings = parseTemplateBindings('a', '1+1'); + expect(exprSources(bindings)).toEqual(['1+1']); }); - describe('error recovery', () => { - function recover(text: string, expected?: string) { - const expr = validate(parseAction(text)); - expect(unparse(expr)).toEqual(expected || text); - } - it('should be able to recover from an extra paren', () => recover('((a)))', 'a')); - it('should be able to recover from an extra bracket', () => recover('[[a]]]', '[[a]]')); - it('should be able to recover from a missing )', () => recover('(a;b', 'a; b;')); - it('should be able to recover from a missing ]', () => recover('[a,b', '[a, b]')); - it('should be able to recover from a missing selector', () => recover('a.')); - it('should be able to recover from a missing selector in a array literal', - () => recover('[[a.], b, c]')); + it('should detect names as value', () => { + const bindings = parseTemplateBindings('a', 'let b'); + expect(keyValues(bindings)).toEqual(['a', 'let b=$implicit']); }); - describe('offsets', () => { - it('should retain the offsets of an interpolation', () => { - const interpolations = splitInterpolation('{{a}} {{b}} {{c}}') !; - expect(interpolations.offsets).toEqual([2, 9, 16]); + it('should allow space and colon as separators', () => { + let bindings = parseTemplateBindings('a', 'b'); + expect(keys(bindings)).toEqual(['a']); + expect(exprSources(bindings)).toEqual(['b']); + }); + + it('should allow multiple pairs', () => { + const bindings = parseTemplateBindings('a', '1 b 2'); + expect(keys(bindings)).toEqual(['a', 'aB']); + expect(exprSources(bindings)).toEqual(['1 ', '2']); + }); + + it('should store the sources in the result', () => { + const bindings = parseTemplateBindings('a', '1,b 2'); + expect(bindings[0].expression !.source).toEqual('1'); + expect(bindings[1].expression !.source).toEqual('2'); + }); + + it('should store the passed-in location', () => { + const bindings = parseTemplateBindings('a', '1,b 2', 'location'); + expect(bindings[0].expression !.location).toEqual('location'); + }); + + it('should support let notation', () => { + let bindings = parseTemplateBindings('key', 'let i'); + expect(keyValues(bindings)).toEqual(['key', 'let i=$implicit']); + + bindings = parseTemplateBindings('key', 'let a; let b'); + expect(keyValues(bindings)).toEqual([ + 'key', + 'let a=$implicit', + 'let b=$implicit', + ]); + + bindings = parseTemplateBindings('key', 'let a; let b;'); + expect(keyValues(bindings)).toEqual([ + 'key', + 'let a=$implicit', + 'let b=$implicit', + ]); + + bindings = parseTemplateBindings('key', 'let i-a = k-a'); + expect(keyValues(bindings)).toEqual([ + 'key', + 'let i-a=k-a', + ]); + + bindings = parseTemplateBindings('key', 'let item; let i = k'); + expect(keyValues(bindings)).toEqual([ + 'key', + 'let item=$implicit', + 'let i=k', + ]); + + bindings = parseTemplateBindings('directive', 'let item in expr; let a = b', 'location'); + expect(keyValues(bindings)).toEqual([ + 'directive', + 'let item=$implicit', + 'directiveIn=expr in location', + 'let a=b', + ]); + }); + + it('should support as notation', () => { + let bindings = parseTemplateBindings('ngIf', 'exp as local', 'location'); + expect(keyValues(bindings)).toEqual(['ngIf=exp in location', 'let local=ngIf']); + + bindings = parseTemplateBindings('ngFor', 'let item of items as iter; index as i', 'L'); + expect(keyValues(bindings)).toEqual([ + 'ngFor', 'let item=$implicit', 'ngForOf=items in L', 'let iter=ngForOf', 'let i=index' + ]); + }); + + it('should parse pipes', () => { + const bindings = parseTemplateBindings('key', 'value|pipe'); + const ast = bindings[0].expression !.ast; + expect(ast).toBeAnInstanceOf(BindingPipe); + }); + + describe('spans', () => { + it('should should support let', () => { + const source = 'let i'; + expect(keySpans(source, parseTemplateBindings('key', 'let i'))).toEqual(['', 'let i']); }); - it('should retain the offsets into the expression AST of interpolations', () => { - const source = parseInterpolation('{{a}} {{b}} {{c}}') !; - const interpolation = source.ast as Interpolation; - expect(interpolation.expressions.map(e => e.span.start)).toEqual([2, 9, 16]); + it('should support multiple lets', () => { + const source = 'let item; let i=index; let e=even;'; + expect(keySpans(source, parseTemplateBindings('key', source))).toEqual([ + '', 'let item', 'let i=index', 'let e=even' + ]); + }); + + it('should support a prefix', () => { + const source = 'let person of people'; + const prefix = 'ngFor'; + const bindings = parseTemplateBindings(prefix, source); + expect(keyValues(bindings)).toEqual([ + 'ngFor', 'let person=$implicit', 'ngForOf=people in null' + ]); + expect(keySpans(source, bindings)).toEqual(['', 'let person ', 'of people']); }); }); }); -})(); + + describe('parseInterpolation', () => { + it('should return null if no interpolation', + () => { expect(parseInterpolation('nothing')).toBe(null); }); + + it('should parse no prefix/suffix interpolation', () => { + const ast = parseInterpolation('{{a}}') !.ast as Interpolation; + expect(ast.strings).toEqual(['', '']); + expect(ast.expressions.length).toEqual(1); + expect(ast.expressions[0].name).toEqual('a'); + }); + + it('should parse prefix/suffix with multiple interpolation', () => { + const originalExp = 'before {{ a }} middle {{ b }} after'; + const ast = parseInterpolation(originalExp) !.ast; + expect(unparse(ast)).toEqual(originalExp); + validate(ast); + }); + + it('should report empty interpolation expressions', () => { + expectError( + parseInterpolation('{{}}') !, + 'Blank expressions are not allowed in interpolated strings'); + + expectError( + parseInterpolation('foo {{ }}') !, + 'Parser Error: Blank expressions are not allowed in interpolated strings'); + }); + + it('should parse conditional expression', () => { checkInterpolation('{{ a < b ? a : b }}'); }); + + it('should parse expression with newline characters', () => { + checkInterpolation(`{{ 'foo' +\n 'bar' +\r 'baz' }}`, `{{ "foo" + "bar" + "baz" }}`); + }); + + it('should support custom interpolation', () => { + const parser = new Parser(new Lexer()); + const ast = parser.parseInterpolation('{% a %}', null, {start: '{%', end: '%}'}) !.ast as any; + expect(ast.strings).toEqual(['', '']); + expect(ast.expressions.length).toEqual(1); + expect(ast.expressions[0].name).toEqual('a'); + }); + + describe('comments', () => { + it('should ignore comments in interpolation expressions', + () => { checkInterpolation('{{a //comment}}', '{{ a }}'); }); + + it('should retain // in single quote strings', () => { + checkInterpolation(`{{ 'http://www.google.com' }}`, `{{ "http://www.google.com" }}`); + }); + + it('should retain // in double quote strings', () => { + checkInterpolation(`{{ "http://www.google.com" }}`, `{{ "http://www.google.com" }}`); + }); + + it('should ignore comments after string literals', + () => { checkInterpolation(`{{ "a//b" //comment }}`, `{{ "a//b" }}`); }); + + it('should retain // in complex strings', () => { + checkInterpolation( + `{{"//a\'//b\`//c\`//d\'//e" //comment}}`, `{{ "//a\'//b\`//c\`//d\'//e" }}`); + }); + + it('should retain // in nested, unterminated strings', + () => { checkInterpolation(`{{ "a\'b\`" //comment}}`, `{{ "a\'b\`" }}`); }); + }); + + }); + + describe('parseSimpleBinding', () => { + it('should parse a field access', () => { + const p = parseSimpleBinding('name'); + expect(unparse(p)).toEqual('name'); + validate(p); + }); + + it('should report when encountering pipes', () => { + expectError( + validate(parseSimpleBinding('a | somePipe')), + 'Host binding expression cannot contain pipes'); + }); + + it('should report when encountering interpolation', () => { + expectError( + validate(parseSimpleBinding('{{exp}}')), + 'Got interpolation ({{}}) where expression was expected'); + }); + + it('should report when encountering field write', () => { + expectError(validate(parseSimpleBinding('a = b')), 'Bindings cannot contain assignments'); + }); + }); + + describe('wrapLiteralPrimitive', () => { + it('should wrap a literal primitive', () => { + expect(unparse(validate(createParser().wrapLiteralPrimitive('foo', null)))).toEqual('"foo"'); + }); + }); + + describe('error recovery', () => { + function recover(text: string, expected?: string) { + const expr = validate(parseAction(text)); + expect(unparse(expr)).toEqual(expected || text); + } + it('should be able to recover from an extra paren', () => recover('((a)))', 'a')); + it('should be able to recover from an extra bracket', () => recover('[[a]]]', '[[a]]')); + it('should be able to recover from a missing )', () => recover('(a;b', 'a; b;')); + it('should be able to recover from a missing ]', () => recover('[a,b', '[a, b]')); + it('should be able to recover from a missing selector', () => recover('a.')); + it('should be able to recover from a missing selector in a array literal', + () => recover('[[a.], b, c]')); + }); + + describe('offsets', () => { + it('should retain the offsets of an interpolation', () => { + const interpolations = splitInterpolation('{{a}} {{b}} {{c}}') !; + expect(interpolations.offsets).toEqual([2, 9, 16]); + }); + + it('should retain the offsets into the expression AST of interpolations', () => { + const source = parseInterpolation('{{a}} {{b}} {{c}}') !; + const interpolation = source.ast as Interpolation; + expect(interpolation.expressions.map(e => e.span.start)).toEqual([2, 9, 16]); + }); + }); +}); + +function createParser() { + return new Parser(new Lexer()); +} + +function parseAction(text: string, location: any = null): ASTWithSource { + return createParser().parseAction(text, location); +} + +function parseBinding(text: string, location: any = null): ASTWithSource { + return createParser().parseBinding(text, location); +} + +function parseTemplateBindingsResult( + key: string, value: string, location: any = null): TemplateBindingParseResult { + return createParser().parseTemplateBindings(key, value, location); +} +function parseTemplateBindings( + key: string, value: string, location: any = null): TemplateBinding[] { + return parseTemplateBindingsResult(key, value, location).templateBindings; +} + +function parseInterpolation(text: string, location: any = null): ASTWithSource|null { + return createParser().parseInterpolation(text, location); +} + +function splitInterpolation(text: string, location: any = null): SplitInterpolation|null { + return createParser().splitInterpolation(text, location); +} + +function parseSimpleBinding(text: string, location: any = null): ASTWithSource { + return createParser().parseSimpleBinding(text, location); +} + +function checkInterpolation(exp: string, expected?: string) { + const ast = parseInterpolation(exp) !; + if (expected == null) expected = exp; + expect(unparse(ast)).toEqual(expected); + validate(ast); +} + +function checkBinding(exp: string, expected?: string) { + const ast = parseBinding(exp); + if (expected == null) expected = exp; + expect(unparse(ast)).toEqual(expected); + validate(ast); +} + +function checkAction(exp: string, expected?: string) { + const ast = parseAction(exp); + if (expected == null) expected = exp; + expect(unparse(ast)).toEqual(expected); + validate(ast); +} + +function expectError(ast: {errors: ParserError[]}, message: string) { + for (const error of ast.errors) { + if (error.message.indexOf(message) >= 0) { + return; + } + } + const errMsgs = ast.errors.map(err => err.message).join('\n'); + throw Error( + `Expected an error containing "${message}" to be reported, but got the errors:\n` + errMsgs); +} + +function expectActionError(text: string, message: string) { + expectError(validate(parseAction(text)), message); +} + +function expectBindingError(text: string, message: string) { + expectError(validate(parseBinding(text)), message); +} \ No newline at end of file