diff --git a/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts b/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts
index c5711c476988a..017abd819041f 100644
--- a/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts
+++ b/packages/compiler-cli/test/ngtsc/template_mapping_spec.ts
@@ -31,20 +31,22 @@ runInEachFileSystem((os) => {
describe('(element creation)', () => {
it('should map simple element with content', () => {
const mappings = compileAndMap('
', generated: 'i0.ɵɵelementStart(0, "div")', sourceUrl: '../test.ts'});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '{{200.3 | percent : 2 }}',
generated: 'i0.ɵɵtextInterpolate(i0.ɵɵpipeBind2(2, 1, 200.3, 2))',
sourceUrl: '../test.ts'
});
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{source: '
', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'});
});
});
@@ -107,12 +113,12 @@ runInEachFileSystem((os) => {
describe('(property bindings)', () => {
it('should map a simple input binding expression', () => {
const mappings = compileAndMap('',
generated: 'i0.ɵɵelementStart(0, "div")',
sourceUrl: '../test.ts'
@@ -253,10 +267,12 @@ runInEachFileSystem((os) => {
// TODO: Add better mappings for binding
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{source: 'Message', generated: 'i0.ɵɵtext(1, "Message")', sourceUrl: '../test.ts'});
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{source: '
', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'});
});
});
@@ -265,7 +281,7 @@ runInEachFileSystem((os) => {
it('should map *ngIf scenario', () => {
const mappings = compileAndMap('',
generated: 'i0.ɵɵelementStart(0, "div")',
sourceUrl: '../test.ts'
@@ -273,12 +289,13 @@ runInEachFileSystem((os) => {
// TODO - map the bindings better
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{source: '
', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'});
// TODO: the `ctx_r...` appears to be dependent upon previous tests!!!
- // expect(mappings).toContain({
+ // expectMapping(mappings, {
// source: '{{ name }}',
// generated: 'i0.ɵɵtextInterpolate(ctx_r0.name)',
// sourceUrl: '../test.ts'
@@ -292,17 +309,19 @@ runInEachFileSystem((os) => {
` ', generated: 'i0.ɵɵelementStart(0, "div")', sourceUrl: '../test.ts'});
// TODO - map the bindings better
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{source: '
', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'});
// TODO: the `ctx_r...` appears to be dependent upon previous tests!!!
- // expect(mappings).toContain({
+ // expectMapping(mappings, {
// source: '{{ name }}',
// generated: 'i0.ɵɵtextInterpolate(ctx_r0.name)',
// sourceUrl: '../test.ts'
@@ -313,7 +332,7 @@ runInEachFileSystem((os) => {
const mappings = compileAndMap(
'',
generated: 'i0.ɵɵelementStart(0, "div")',
sourceUrl: '../test.ts'
@@ -321,7 +340,8 @@ runInEachFileSystem((os) => {
// TODO - map the bindings better
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{source: '
', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'});
});
@@ -339,23 +359,26 @@ runInEachFileSystem((os) => {
`', generated: 'i0.ɵɵelementStart(2, "div")', sourceUrl: '../test.ts'});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '',
generated: 'i0.ɵɵprojection(3, 1)',
sourceUrl: '../test.ts'
});
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{source: '
', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'});
});
});
@@ -363,12 +386,12 @@ runInEachFileSystem((os) => {
describe('$localize', () => {
it('should create simple i18n message source-mapping', () => {
const mappings = compileAndMap(`',
generated: 'i0.ɵɵelementStart(0, "div")',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: 'Hello, World!',
generated: '`Hello, World!`',
sourceUrl: '../test.ts',
@@ -377,66 +400,155 @@ runInEachFileSystem((os) => {
it('should create placeholder source-mappings', () => {
const mappings = compileAndMap(`
Hello, {{name}}!
`);
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '
',
generated: 'i0.ɵɵelementStart(0, "div")',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '
',
generated: 'i0.ɵɵelementEnd()',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: 'Hello, ',
generated: '`Hello, ${',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '{{name}}',
generated: '"\\uFFFD0\\uFFFD"',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '!',
generated: '}:INTERPOLATION:!`',
sourceUrl: '../test.ts',
});
});
+ it('should correctly handle collapsed whitespace in interpolation placeholder source-mappings',
+ () => {
+ const mappings = compileAndMap(
+ `
pre-body {{body_value}} post-body
`);
+ expectMapping(mappings, {
+ source: '
',
+ generated: 'i0.ɵɵelementStart(0, "div", 0)',
+ sourceUrl: '../test.ts',
+ });
+ expectMapping(mappings, {
+ source: '
',
+ generated: 'i0.ɵɵelementEnd()',
+ sourceUrl: '../test.ts',
+ });
+ expectMapping(mappings, {
+ source: ' pre-body ',
+ generated: '` pre-body ${',
+ sourceUrl: '../test.ts',
+ });
+ expectMapping(mappings, {
+ source: '{{body_value}}',
+ generated: '"\\uFFFD0\\uFFFD"',
+ sourceUrl: '../test.ts',
+ });
+ expectMapping(mappings, {
+ source: ' post-body',
+ generated: '}:INTERPOLATION: post-body`',
+ sourceUrl: '../test.ts',
+ });
+ });
+
+ it('should correctly handle collapsed whitespace in element placeholder source-mappings',
+ () => {
+ const mappings =
+ compileAndMap(`
\n pre-p\n
\n in-p\n
\n post-p\n
`);
+ // $localize expressions
+ expectMapping(mappings, {
+ sourceUrl: '../test.ts',
+ source: 'pre-p\n ',
+ generated: '` pre-p ${',
+ });
+ expectMapping(mappings, {
+ sourceUrl: '../test.ts',
+ source: '
\n ',
+ generated: '"\\uFFFD#2\\uFFFD"',
+ });
+ expectMapping(mappings, {
+ sourceUrl: '../test.ts',
+ source: 'in-p\n ',
+ generated: '}:START_PARAGRAPH: in-p ${',
+ });
+ expectMapping(mappings, {
+ sourceUrl: '../test.ts',
+ source: '
\n ',
+ generated: '"\\uFFFD/#2\\uFFFD"',
+ });
+ expectMapping(mappings, {
+ sourceUrl: '../test.ts',
+ source: 'post-p\n',
+ generated: '}:CLOSE_PARAGRAPH: post-p\n`',
+ });
+ // ivy instructions
+ expectMapping(mappings, {
+ sourceUrl: '../test.ts',
+ source: '
\n ',
+ generated: 'i0.ɵɵelementStart(0, "div")',
+ });
+ expectMapping(mappings, {
+ sourceUrl: '../test.ts',
+ source: '
\n ',
+ generated: 'i0.ɵɵi18nStart(1, 0)',
+ });
+ expectMapping(mappings, {
+ sourceUrl: '../test.ts',
+ source: '
\n in-p\n
',
+ generated: 'i0.ɵɵelement(2, "p")',
+ });
+ expectMapping(mappings, {
+ sourceUrl: '../test.ts',
+ source: '
',
+ generated: 'i0.ɵɵi18nEnd()',
+ });
+ expectMapping(mappings, {
+ sourceUrl: '../test.ts',
+ source: '
',
+ generated: 'i0.ɵɵelementEnd()',
+ });
+ });
+
it('should create tag (container) placeholder source-mappings', () => {
const mappings = compileAndMap(`
Hello, World!
`);
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '
',
generated: 'i0.ɵɵelementStart(0, "div")',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '
',
generated: 'i0.ɵɵelementEnd()',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: 'Hello, ',
generated: '`Hello, ${',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '
',
generated: '"\\uFFFD#2\\uFFFD"',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: 'World',
generated: '}:START_BOLD_TEXT:World${',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '',
generated: '"\\uFFFD/#2\\uFFFD"',
sourceUrl: '../test.ts',
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
source: '!',
generated: '}:CLOSE_BOLD_TEXT:!`',
sourceUrl: '../test.ts',
@@ -448,24 +560,26 @@ runInEachFileSystem((os) => {
const mappings = compileAndMap('
this is a test
{{ 1 + 2 }}
');
// Creation mode
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{generated: 'i0.ɵɵelementStart(0, "div")', source: '
', sourceUrl: '../test.ts'});
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵtext(1, "this is a test")',
source: 'this is a test',
sourceUrl: '../test.ts'
});
- expect(mappings).toContain(
- {generated: 'i0.ɵɵelementEnd()', source: '
', sourceUrl: '../test.ts'});
- expect(mappings).toContain(
+ expectMapping(
+ mappings, {generated: 'i0.ɵɵelementEnd()', source: '
', sourceUrl: '../test.ts'});
+ expectMapping(
+ mappings,
{generated: 'i0.ɵɵelementStart(2, "div")', source: '', sourceUrl: '../test.ts'});
- expect(mappings).toContain(
- {generated: 'i0.ɵɵtext(3)', source: '{{ 1 + 2 }}', sourceUrl: '../test.ts'});
- expect(mappings).toContain(
- {generated: 'i0.ɵɵelementEnd()', source: '
', sourceUrl: '../test.ts'});
+ expectMapping(
+ mappings, {generated: 'i0.ɵɵtext(3)', source: '{{ 1 + 2 }}', sourceUrl: '../test.ts'});
+ expectMapping(
+ mappings, {generated: 'i0.ɵɵelementEnd()', source: '', sourceUrl: '../test.ts'});
// Update mode
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵtextInterpolate(1 + 2)',
source: '{{ 1 + 2 }}',
sourceUrl: '../test.ts'
@@ -476,25 +590,27 @@ runInEachFileSystem((os) => {
const mappings = compileAndMap('', sourceUrl: '../test.ts'});
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵtext(1, "this is a test")',
source: 'this is a test',
sourceUrl: '../test.ts'
});
- expect(mappings).toContain(
- {generated: 'i0.ɵɵelementEnd()', source: '
', sourceUrl: '../test.ts'});
- expect(mappings).toContain(
+ expectMapping(
+ mappings, {generated: 'i0.ɵɵelementEnd()', source: '', sourceUrl: '../test.ts'});
+ expectMapping(
+ mappings,
{generated: 'i0.ɵɵelementStart(2, "div")', source: '', sourceUrl: '../test.ts'});
- expect(mappings).toContain(
- {generated: 'i0.ɵɵtext(3)', source: '{{ 1 + 2 }}', sourceUrl: '../test.ts'});
- expect(mappings).toContain(
- {generated: 'i0.ɵɵelementEnd()', source: '
', sourceUrl: '../test.ts'});
+ expectMapping(
+ mappings, {generated: 'i0.ɵɵtext(3)', source: '{{ 1 + 2 }}', sourceUrl: '../test.ts'});
+ expectMapping(
+ mappings, {generated: 'i0.ɵɵelementEnd()', source: '', sourceUrl: '../test.ts'});
// TODO(benlesh): We need to circle back and prevent the extra parens from being generated.
// Update mode
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵtextInterpolate(1 + 2)',
source: '{{ 1 + 2 }}',
sourceUrl: '../test.ts'
@@ -506,7 +622,7 @@ runInEachFileSystem((os) => {
// Note that the escaped double quotes, which need un-escaping to be parsed correctly.
const mappings = compileAndMap('',
sourceUrl: '../test.ts'
@@ -525,30 +641,33 @@ runInEachFileSystem((os) => {
compileAndMap('
this is a test
{{ 1 + 2 }}
', './dir/test.html');
// Creation mode
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵelementStart(0, "div")',
source: '
',
sourceUrl: '../dir/test.html'
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵtext(1, "this is a test")',
source: 'this is a test',
sourceUrl: '../dir/test.html'
});
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{generated: 'i0.ɵɵelementEnd()', source: '
', sourceUrl: '../dir/test.html'});
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵelementStart(2, "div")',
source: '
',
sourceUrl: '../dir/test.html'
});
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{generated: 'i0.ɵɵtext(3)', source: '{{ 1 + 2 }}', sourceUrl: '../dir/test.html'});
- expect(mappings).toContain(
+ expectMapping(
+ mappings,
{generated: 'i0.ɵɵelementEnd()', source: '
', sourceUrl: '../dir/test.html'});
// Update mode
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵtextInterpolate(1 + 2)',
source: '{{ 1 + 2 }}',
sourceUrl: '../dir/test.html'
@@ -560,39 +679,39 @@ runInEachFileSystem((os) => {
'
this is a test
{{ 1 + 2 }}
', 'extraRootDir/test.html');
// Creation mode
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵelementStart(0, "div")',
source: '
',
sourceUrl: '../extraRootDir/test.html'
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵtext(1, "this is a test")',
source: 'this is a test',
sourceUrl: '../extraRootDir/test.html'
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵelementEnd()',
source: '
',
sourceUrl: '../extraRootDir/test.html'
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵelementStart(2, "div")',
source: '
',
sourceUrl: '../extraRootDir/test.html'
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵtext(3)',
source: '{{ 1 + 2 }}',
sourceUrl: '../extraRootDir/test.html'
});
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵelementEnd()',
source: '
',
sourceUrl: '../extraRootDir/test.html'
});
// Update mode
- expect(mappings).toContain({
+ expectMapping(mappings, {
generated: 'i0.ɵɵtextInterpolate(1 + 2)',
source: '{{ 1 + 2 }}',
sourceUrl: '../extraRootDir/test.html'
@@ -638,5 +757,42 @@ runInEachFileSystem((os) => {
return value + padding;
}
}
+
+ function expectMapping(mappings: SegmentMapping[], expected: SegmentMapping): void {
+ if (mappings.some(
+ m => m.generated === expected.generated && m.source === expected.source &&
+ m.sourceUrl === expected.sourceUrl)) {
+ return;
+ }
+ const matchingGenerated = mappings.filter(m => m.generated === expected.generated);
+ const matchingSource = mappings.filter(m => m.source === expected.source);
+
+ const message = [
+ 'Expected mappings to contain the following mapping',
+ prettyPrintMapping(expected),
+ ];
+ if (matchingGenerated.length > 0) {
+ message.push('');
+ message.push('There are the following mappings that match the generated text:');
+ matchingGenerated.forEach(m => message.push(prettyPrintMapping(m)));
+ }
+ if (matchingSource.length > 0) {
+ message.push('');
+ message.push('There are the following mappings that match the source text:');
+ matchingSource.forEach(m => message.push(prettyPrintMapping(m)));
+ }
+
+ fail(message.join('\n'));
+ }
+
+ function prettyPrintMapping(mapping: SegmentMapping): string {
+ return [
+ '{',
+ ` generated: ${JSON.stringify(mapping.generated)}`,
+ ` source: ${JSON.stringify(mapping.source)}`,
+ ` sourceUrl: ${JSON.stringify(mapping.sourceUrl)}`,
+ '}',
+ ].join('\n');
+ }
});
});
diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts
index 2f488d2931f43..e87c2c3b61eee 100644
--- a/packages/compiler/src/expression_parser/parser.ts
+++ b/packages/compiler/src/expression_parser/parser.ts
@@ -13,10 +13,14 @@ import {escapeRegExp} from '../util';
import {AbsoluteSourceSpan, AST, AstVisitor, ASTWithSource, Binary, BindingPipe, Chain, Conditional, EmptyExpr, ExpressionBinding, FunctionCall, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralMapKey, LiteralPrimitive, MethodCall, NonNullAssert, ParserError, ParseSpan, PrefixNot, PropertyRead, PropertyWrite, Quote, RecursiveAstVisitor, SafeMethodCall, SafePropertyRead, TemplateBinding, TemplateBindingIdentifier, ThisReceiver, Unary, VariableBinding} from './ast';
import {EOF, isIdentifier, isQuote, Lexer, Token, TokenType} from './lexer';
+export interface InterpolationPiece {
+ text: string;
+ start: number;
+ end: number;
+}
export class SplitInterpolation {
constructor(
- public strings: string[], public stringSpans: {start: number, end: number}[],
- public expressions: string[], public expressionsSpans: {start: number, end: number}[],
+ public strings: InterpolationPiece[], public expressions: InterpolationPiece[],
public offsets: number[]) {}
}
@@ -48,7 +52,7 @@ export class Parser {
simpleExpressionChecker = SimpleExpressionChecker;
parseAction(
- input: string, location: any, absoluteOffset: number,
+ input: string, location: string, absoluteOffset: number,
interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ASTWithSource {
this._checkNoInterpolation(input, location, interpolationConfig);
const sourceToLex = this._stripComments(input);
@@ -61,7 +65,7 @@ export class Parser {
}
parseBinding(
- input: string, location: any, absoluteOffset: number,
+ input: string, location: string, absoluteOffset: number,
interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ASTWithSource {
const ast = this._parseBindingAst(input, location, absoluteOffset, interpolationConfig);
return new ASTWithSource(ast, input, location, absoluteOffset, this.errors);
@@ -85,7 +89,7 @@ export class Parser {
return new ASTWithSource(ast, input, location, absoluteOffset, this.errors);
}
- private _reportError(message: string, input: string, errLocation: string, ctxLocation?: any) {
+ private _reportError(message: string, input: string, errLocation: string, ctxLocation?: string) {
this.errors.push(new ParserError(message, input, errLocation, ctxLocation));
}
@@ -109,7 +113,7 @@ export class Parser {
.parseChain();
}
- private _parseQuote(input: string|null, location: any, absoluteOffset: number): AST|null {
+ private _parseQuote(input: string|null, location: string, absoluteOffset: number): AST|null {
if (input == null) return null;
const prefixSeparatorIndex = input.indexOf(':');
if (prefixSeparatorIndex == -1) return null;
@@ -161,25 +165,27 @@ export class Parser {
}
parseInterpolation(
- input: string, location: any, absoluteOffset: number,
+ input: string, location: string, absoluteOffset: number,
interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ASTWithSource|null {
- const split = this.splitInterpolation(input, location, interpolationConfig);
- if (split == null) return null;
+ const {strings, expressions, offsets} =
+ this.splitInterpolation(input, location, interpolationConfig);
+ if (expressions.length === 0) return null;
- const expressions: AST[] = [];
+ const expressionNodes: AST[] = [];
- for (let i = 0; i < split.expressions.length; ++i) {
- const expressionText = split.expressions[i];
+ for (let i = 0; i < expressions.length; ++i) {
+ const expressionText = expressions[i].text;
const sourceToLex = this._stripComments(expressionText);
const tokens = this._lexer.tokenize(sourceToLex);
const ast = new _ParseAST(
input, location, absoluteOffset, tokens, sourceToLex.length, false,
- this.errors, split.offsets[i] + (expressionText.length - sourceToLex.length))
+ this.errors, offsets[i] + (expressionText.length - sourceToLex.length))
.parseChain();
- expressions.push(ast);
+ expressionNodes.push(ast);
}
- return this.createInterpolationAst(split.strings, expressions, input, location, absoluteOffset);
+ return this.createInterpolationAst(
+ strings.map(s => s.text), expressionNodes, input, location, absoluteOffset);
}
/**
@@ -187,7 +193,7 @@ export class Parser {
* element that would normally appear within the interpolation prefix and suffix (`{{` and `}}`).
* This is used for parsing the switch expression in ICUs.
*/
- parseInterpolationExpression(expression: string, location: any, absoluteOffset: number):
+ parseInterpolationExpression(expression: string, location: string, absoluteOffset: number):
ASTWithSource {
const sourceToLex = this._stripComments(expression);
const tokens = this._lexer.tokenize(sourceToLex);
@@ -217,13 +223,10 @@ export class Parser {
*/
splitInterpolation(
input: string, location: string,
- interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): SplitInterpolation
- |null {
- const strings: string[] = [];
- const expressions: string[] = [];
+ interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): SplitInterpolation {
+ const strings: InterpolationPiece[] = [];
+ const expressions: InterpolationPiece[] = [];
const offsets: number[] = [];
- const stringSpans: {start: number, end: number}[] = [];
- const expressionSpans: {start: number, end: number}[] = [];
let i = 0;
let atInterpolation = false;
let extendLastString = false;
@@ -236,9 +239,8 @@ export class Parser {
if (i === -1) {
i = input.length;
}
- const part = input.substring(start, i);
- strings.push(part);
- stringSpans.push({start, end: i});
+ const text = input.substring(start, i);
+ strings.push({text, start, end: i});
atInterpolation = true;
} else {
@@ -255,17 +257,16 @@ export class Parser {
}
const fullEnd = exprEnd + interpEnd.length;
- const part = input.substring(exprStart, exprEnd);
- if (part.trim().length > 0) {
- expressions.push(part);
+ const text = input.substring(exprStart, exprEnd);
+ if (text.trim().length > 0) {
+ expressions.push({text, start: fullStart, end: fullEnd});
} else {
this._reportError(
'Blank expressions are not allowed in interpolated strings', input,
`at column ${i} in`, location);
- expressions.push('$implicit');
+ expressions.push({text: '$implicit', start: fullStart, end: fullEnd});
}
offsets.push(exprStart);
- expressionSpans.push({start: fullStart, end: fullEnd});
i = fullEnd;
atInterpolation = false;
@@ -274,19 +275,18 @@ export class Parser {
if (!atInterpolation) {
// If we are now at a text section, add the remaining content as a raw string.
if (extendLastString) {
- strings[strings.length - 1] += input.substring(i);
- stringSpans[stringSpans.length - 1].end = input.length;
+ const piece = strings[strings.length - 1];
+ piece.text += input.substring(i);
+ piece.end = input.length;
} else {
- strings.push(input.substring(i));
- stringSpans.push({start: i, end: input.length});
+ strings.push({text: input.substring(i), start: i, end: input.length});
}
}
- return expressions.length === 0 ?
- null :
- new SplitInterpolation(strings, stringSpans, expressions, expressionSpans, offsets);
+ return new SplitInterpolation(strings, expressions, offsets);
}
- wrapLiteralPrimitive(input: string|null, location: any, absoluteOffset: number): ASTWithSource {
+ wrapLiteralPrimitive(input: string|null, location: string, absoluteOffset: number):
+ ASTWithSource {
const span = new ParseSpan(0, input == null ? 0 : input.length);
return new ASTWithSource(
new LiteralPrimitive(span, span.toAbsolute(absoluteOffset), input), input, location,
@@ -316,7 +316,7 @@ export class Parser {
}
private _checkNoInterpolation(
- input: string, location: any, interpolationConfig: InterpolationConfig): void {
+ input: string, location: string, interpolationConfig: InterpolationConfig): void {
const regexp = _getInterpolateRegExp(interpolationConfig);
const parts = input.split(regexp);
if (parts.length > 1) {
@@ -374,7 +374,7 @@ export class _ParseAST {
index: number = 0;
constructor(
- public input: string, public location: any, public absoluteOffset: number,
+ public input: string, public location: string, public absoluteOffset: number,
public tokens: Token[], public inputLength: number, public parseAction: boolean,
private errors: ParserError[], private offset: number) {}
diff --git a/packages/compiler/src/i18n/i18n_parser.ts b/packages/compiler/src/i18n/i18n_parser.ts
index c22941f16f686..67791e7c7b030 100644
--- a/packages/compiler/src/i18n/i18n_parser.ts
+++ b/packages/compiler/src/i18n/i18n_parser.ts
@@ -7,7 +7,7 @@
*/
import {Lexer as ExpressionLexer} from '../expression_parser/lexer';
-import {Parser as ExpressionParser} from '../expression_parser/parser';
+import {InterpolationPiece, Parser as ExpressionParser} from '../expression_parser/parser';
import * as html from '../ml_parser/ast';
import {getHtmlTagDefinition} from '../ml_parser/html_tags';
import {InterpolationConfig} from '../ml_parser/interpolation_config';
@@ -105,12 +105,13 @@ class _I18nVisitor implements html.Visitor {
}
visitAttribute(attribute: html.Attribute, context: I18nMessageVisitorContext): i18n.Node {
- const node = this._visitTextWithInterpolation(attribute.value, attribute.sourceSpan, context);
+ const node = this._visitTextWithInterpolation(
+ attribute.value, attribute.valueSpan || attribute.sourceSpan, context, attribute.i18n);
return context.visitNodeFn(attribute, node);
}
visitText(text: html.Text, context: I18nMessageVisitorContext): i18n.Node {
- const node = this._visitTextWithInterpolation(text.value, text.sourceSpan, context);
+ const node = this._visitTextWithInterpolation(text.value, text.sourceSpan, context, text.i18n);
return context.visitNodeFn(text, node);
}
@@ -155,60 +156,143 @@ class _I18nVisitor implements html.Visitor {
throw new Error('Unreachable code');
}
+ /**
+ * Split the, potentially interpolated, text up into text and placeholder pieces.
+ *
+ * @param text The potentially interpolated string to be split.
+ * @param sourceSpan The span of the whole of the `text` string.
+ * @param context The current context of the visitor, used to compute and store placeholders.
+ * @param previousI18n Any i18n metadata associated with this `text` from a previous pass.
+ */
private _visitTextWithInterpolation(
- text: string, sourceSpan: ParseSourceSpan, context: I18nMessageVisitorContext): i18n.Node {
- const splitInterpolation = this._expressionParser.splitInterpolation(
+ text: string, sourceSpan: ParseSourceSpan, context: I18nMessageVisitorContext,
+ previousI18n: i18n.I18nMeta|undefined): i18n.Node {
+ const {strings, expressions} = this._expressionParser.splitInterpolation(
text, sourceSpan.start.toString(), this._interpolationConfig);
- if (!splitInterpolation) {
- // No expression, return a single text
+ // No expressions, return a single text.
+ if (expressions.length === 0) {
return new i18n.Text(text, sourceSpan);
}
- // Return a group of text + expressions
+ // Return a sequence of `Text` and `Placeholder` nodes grouped in a `Container`.
const nodes: i18n.Node[] = [];
- const container = new i18n.Container(nodes, sourceSpan);
- const {start: sDelimiter, end: eDelimiter} = this._interpolationConfig;
-
- for (let i = 0; i < splitInterpolation.strings.length - 1; i++) {
- const expression = splitInterpolation.expressions[i];
- const baseName = _extractPlaceholderName(expression) || 'INTERPOLATION';
- const phName = context.placeholderRegistry.getPlaceholderName(baseName, expression);
-
- if (splitInterpolation.strings[i].length) {
- // No need to add empty strings
- const stringSpan = getOffsetSourceSpan(sourceSpan, splitInterpolation.stringSpans[i]);
- nodes.push(new i18n.Text(splitInterpolation.strings[i], stringSpan));
- }
-
- const expressionSpan =
- getOffsetSourceSpan(sourceSpan, splitInterpolation.expressionsSpans[i]);
- nodes.push(new i18n.Placeholder(expression, phName, expressionSpan));
- context.placeholderToContent[phName] = {
- text: sDelimiter + expression + eDelimiter,
- sourceSpan: expressionSpan,
- };
+ for (let i = 0; i < strings.length - 1; i++) {
+ this._addText(nodes, strings[i], sourceSpan);
+ this._addPlaceholder(nodes, context, expressions[i], sourceSpan);
}
-
// The last index contains no expression
- const lastStringIdx = splitInterpolation.strings.length - 1;
- if (splitInterpolation.strings[lastStringIdx].length) {
- const stringSpan =
- getOffsetSourceSpan(sourceSpan, splitInterpolation.stringSpans[lastStringIdx]);
- nodes.push(new i18n.Text(splitInterpolation.strings[lastStringIdx], stringSpan));
+ this._addText(nodes, strings[strings.length - 1], sourceSpan);
+
+ // Whitespace removal may have invalidated the interpolation source-spans.
+ reusePreviousSourceSpans(nodes, previousI18n);
+
+ return new i18n.Container(nodes, sourceSpan);
+ }
+
+ /**
+ * Create a new `Text` node from the `textPiece` and add it to the `nodes` collection.
+ *
+ * @param nodes The nodes to which the created `Text` node should be added.
+ * @param textPiece The text and relative span information for this `Text` node.
+ * @param interpolationSpan The span of the whole interpolated text.
+ */
+ private _addText(
+ nodes: i18n.Node[], textPiece: InterpolationPiece, interpolationSpan: ParseSourceSpan): void {
+ if (textPiece.text.length > 0) {
+ // No need to add empty strings
+ const stringSpan = getOffsetSourceSpan(interpolationSpan, textPiece);
+ nodes.push(new i18n.Text(textPiece.text, stringSpan));
+ }
+ }
+
+ /**
+ * Create a new `Placeholder` node from the `expression` and add it to the `nodes` collection.
+ *
+ * @param nodes The nodes to which the created `Text` node should be added.
+ * @param context The current context of the visitor, used to compute and store placeholders.
+ * @param expression The expression text and relative span information for this `Placeholder`
+ * node.
+ * @param interpolationSpan The span of the whole interpolated text.
+ */
+ private _addPlaceholder(
+ nodes: i18n.Node[], context: I18nMessageVisitorContext, expression: InterpolationPiece,
+ interpolationSpan: ParseSourceSpan): void {
+ const sourceSpan = getOffsetSourceSpan(interpolationSpan, expression);
+ const baseName = extractPlaceholderName(expression.text) || 'INTERPOLATION';
+ const phName = context.placeholderRegistry.getPlaceholderName(baseName, expression.text);
+ const text = this._interpolationConfig.start + expression.text + this._interpolationConfig.end;
+ context.placeholderToContent[phName] = {text, sourceSpan};
+ nodes.push(new i18n.Placeholder(expression.text, phName, sourceSpan));
+ }
+}
+
+/**
+ * Re-use the source-spans from `previousI18n` metadata for the `nodes`.
+ *
+ * Whitespace removal can invalidate the source-spans of interpolation nodes, so we
+ * reuse the source-span stored from a previous pass before the whitespace was removed.
+ *
+ * @param nodes The `Text` and `Placeholder` nodes to be processed.
+ * @param previousI18n Any i18n metadata for these `nodes` stored from a previous pass.
+ */
+function reusePreviousSourceSpans(nodes: i18n.Node[], previousI18n: i18n.I18nMeta|undefined): void {
+ if (previousI18n instanceof i18n.Message) {
+ // The `previousI18n` is an i18n `Message`, so we are processing an `Attribute` with i18n
+ // metadata. The `Message` should consist only of a single `Container` that contains the
+ // parts (`Text` and `Placeholder`) to process.
+ assertSingleContainerMessage(previousI18n);
+ previousI18n = previousI18n.nodes[0];
+ }
+
+ if (previousI18n instanceof i18n.Container) {
+ // The `previousI18n` is a `Container`, which means that this is a second i18n extraction pass
+ // after whitespace has been removed from the AST ndoes.
+ assertEquivalentNodes(previousI18n.children, nodes);
+
+ // Reuse the source-spans from the first pass.
+ for (let i = 0; i < nodes.length; i++) {
+ nodes[i].sourceSpan = previousI18n.children[i].sourceSpan;
}
- return container;
}
}
+/**
+ * Asserts that the `message` contains exactly one `Container` node.
+ */
+function assertSingleContainerMessage(message: i18n.Message): void {
+ const nodes = message.nodes;
+ if (nodes.length !== 1 || !(nodes[0] instanceof i18n.Container)) {
+ throw new Error(
+ 'Unexpected previous i18n message - expected it to consist of only a single `Container` node.');
+ }
+}
+
+/**
+ * Asserts that the `previousNodes` and `node` collections have the same number of elements and
+ * corresponding elements have the same node type.
+ */
+function assertEquivalentNodes(previousNodes: i18n.Node[], nodes: i18n.Node[]): void {
+ if (previousNodes.length !== nodes.length) {
+ throw new Error('The number of i18n message children changed between first and second pass.');
+ }
+ if (previousNodes.some((node, i) => nodes[i].constructor !== node.constructor)) {
+ throw new Error(
+ 'The types of the i18n message children changed between first and second pass.');
+ }
+}
+
+/**
+ * Create a new `ParseSourceSpan` from the `sourceSpan`, offset by the `start` and `end` values.
+ */
function getOffsetSourceSpan(
- sourceSpan: ParseSourceSpan, {start, end}: {start: number, end: number}): ParseSourceSpan {
+ sourceSpan: ParseSourceSpan, {start, end}: InterpolationPiece): ParseSourceSpan {
return new ParseSourceSpan(sourceSpan.fullStart.moveBy(start), sourceSpan.fullStart.moveBy(end));
}
const _CUSTOM_PH_EXP =
/\/\/[\s\S]*i18n[\s\S]*\([\s\S]*ph[\s\S]*=[\s\S]*("|')([\s\S]*?)\1[\s\S]*\)/g;
-function _extractPlaceholderName(input: string): string {
+function extractPlaceholderName(input: string): string {
return input.split(_CUSTOM_PH_EXP)[2];
}
diff --git a/packages/compiler/test/expression_parser/ast_spec.ts b/packages/compiler/test/expression_parser/ast_spec.ts
index 9b4518924bbe4..c49ff622f4130 100644
--- a/packages/compiler/test/expression_parser/ast_spec.ts
+++ b/packages/compiler/test/expression_parser/ast_spec.ts
@@ -12,7 +12,7 @@ import {ImplicitReceiver, MethodCall, PropertyRead} from '@angular/compiler/src/
describe('RecursiveAstVisitor', () => {
it('should visit every node', () => {
const parser = new Parser(new Lexer());
- const ast = parser.parseBinding('x.y()', null /* location */, 0 /* absoluteOffset */);
+ const ast = parser.parseBinding('x.y()', '', 0 /* absoluteOffset */);
const visitor = new Visitor();
const path: AST[] = [];
visitor.visit(ast.ast, path);
diff --git a/packages/compiler/test/expression_parser/parser_spec.ts b/packages/compiler/test/expression_parser/parser_spec.ts
index e763fd42fa0db..4b96ffa70e421 100644
--- a/packages/compiler/test/expression_parser/parser_spec.ts
+++ b/packages/compiler/test/expression_parser/parser_spec.ts
@@ -855,8 +855,7 @@ describe('parser', () => {
it('should support custom interpolation', () => {
const parser = new Parser(new Lexer());
- const ast =
- parser.parseInterpolation('{% a %}', null, 0, {start: '{%', end: '%}'})!.ast as any;
+ const ast = parser.parseInterpolation('{% a %}', '', 0, {start: '{%', end: '%}'})!.ast as any;
expect(ast.strings).toEqual(['', '']);
expect(ast.expressions.length).toEqual(1);
expect(ast.expressions[0].name).toEqual('a');
@@ -978,8 +977,7 @@ describe('parser', () => {
describe('wrapLiteralPrimitive', () => {
it('should wrap a literal primitive', () => {
- expect(unparse(validate(createParser().wrapLiteralPrimitive('foo', null, 0))))
- .toEqual('"foo"');
+ expect(unparse(validate(createParser().wrapLiteralPrimitive('foo', '', 0)))).toEqual('"foo"');
});
});