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('

Heading 1

'); - expect(mappings).toContain( + expectMapping( + mappings, {source: '

', generated: 'i0.ɵɵelementStart(0, "h1")', sourceUrl: '../test.ts'}); - expect(mappings).toContain({ + expectMapping(mappings, { source: 'Heading 1', generated: 'i0.ɵɵtext(1, "Heading 1")', sourceUrl: '../test.ts' }); - expect(mappings).toContain( - {source: '

', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); + expectMapping( + mappings, {source: '', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); }); it('should map void element', () => { const mappings = compileAndMap('
'); - expect(mappings).toContain( + expectMapping( + mappings, {source: '
', generated: 'i0.ɵɵelement(0, "hr")', sourceUrl: '../test.ts'}); }); }); @@ -52,38 +54,40 @@ runInEachFileSystem((os) => { describe('(interpolations)', () => { it('should map a mix of interpolated and static content', () => { const mappings = compileAndMap('

Hello {{ name }}

'); - expect(mappings).toContain( + expectMapping( + mappings, {source: '

', generated: 'i0.ɵɵelementStart(0, "h3")', sourceUrl: '../test.ts'}); - expect(mappings).toContain({ + expectMapping(mappings, { source: 'Hello {{ name }}', generated: 'i0.ɵɵtextInterpolate1("Hello ", ctx.name, "")', sourceUrl: '../test.ts' }); - expect(mappings).toContain( - {source: '

', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); + expectMapping( + mappings, {source: '', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); }); it('should map a complex interpolated expression', () => { const mappings = compileAndMap('

{{ greeting + " " + name }}

'); - expect(mappings).toContain( + expectMapping( + mappings, {source: '

', generated: 'i0.ɵɵelementStart(0, "h2")', sourceUrl: '../test.ts'}); - expect(mappings).toContain({ + expectMapping(mappings, { source: '{{ greeting + " " + name }}', generated: 'i0.ɵɵtextInterpolate(ctx.greeting + " " + ctx.name)', sourceUrl: '../test.ts' }); - expect(mappings).toContain( - {source: '

', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); + expectMapping( + mappings, {source: '', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); }); it('should map interpolated properties', () => { const mappings = compileAndMap('
'); - expect(mappings).toContain({ + expectMapping(mappings, { source: '
', generated: 'i0.ɵɵelement(0, "div", 0)', sourceUrl: '../test.ts' }); - expect(mappings).toContain({ + expectMapping(mappings, { source: 'id="{{name}}"', generated: 'i0.ɵɵpropertyInterpolate("id", ctx.name)', sourceUrl: '../test.ts' @@ -92,14 +96,16 @@ runInEachFileSystem((os) => { it('should map interpolation with pipe', () => { const mappings = compileAndMap('
{{200.3 | percent : 2 }}
'); - expect(mappings).toContain( + expectMapping( + mappings, {source: '
', 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('
'); - expect(mappings).toContain({ + expectMapping(mappings, { source: '
', generated: 'i0.ɵɵelement(0, "div", 0)', sourceUrl: '../test.ts' }); - expect(mappings).toContain({ + expectMapping(mappings, { source: '[attr]="name"', generated: 'i0.ɵɵproperty("attr", ctx.name)', sourceUrl: '../test.ts' @@ -122,12 +128,12 @@ runInEachFileSystem((os) => { it('should map a complex input binding expression', () => { const mappings = compileAndMap('
'); - expect(mappings).toContain({ + expectMapping(mappings, { source: '
', generated: 'i0.ɵɵelement(0, "div", 0)', sourceUrl: '../test.ts' }); - expect(mappings).toContain({ + expectMapping(mappings, { source: '[attr]="greeting + name"', generated: 'i0.ɵɵproperty("attr", ctx.greeting + ctx.name)', sourceUrl: '../test.ts' @@ -136,12 +142,12 @@ runInEachFileSystem((os) => { it('should map a longhand input binding expression', () => { const mappings = compileAndMap('
'); - expect(mappings).toContain({ + expectMapping(mappings, { source: '
', generated: 'i0.ɵɵelement(0, "div", 0)', sourceUrl: '../test.ts' }); - expect(mappings).toContain({ + expectMapping(mappings, { source: 'bind-attr="name"', generated: 'i0.ɵɵproperty("attr", ctx.name)', sourceUrl: '../test.ts' @@ -150,72 +156,80 @@ runInEachFileSystem((os) => { it('should map a simple output binding expression', () => { const mappings = compileAndMap(''); - expect(mappings).toContain({ + expectMapping(mappings, { source: '', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); }); it('should map a complex output binding expression', () => { const mappings = compileAndMap( ``); - expect(mappings).toContain({ + expectMapping(mappings, { source: `', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); }); it('should map a longhand output binding expression', () => { const mappings = compileAndMap(''); - expect(mappings).toContain({ + expectMapping(mappings, { source: '', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); }); it('should map a two-way binding expression', () => { const mappings = compileAndMap('Name: '); - expect(mappings).toContain({ + expectMapping(mappings, { source: '', generated: 'i0.ɵɵelementStart(1, "input", 0)', sourceUrl: '../test.ts' }); // TODO: improve mappings here - expect(mappings).toContain({ + expectMapping(mappings, { source: '[(ngModel)]="name"', generated: 'i0.ɵɵlistener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { return ctx.name = $event; })', sourceUrl: '../test.ts' }); - expect(mappings).toContain({ + expectMapping(mappings, { source: '', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts' @@ -224,19 +238,19 @@ runInEachFileSystem((os) => { it('should map a longhand two-way binding expression', () => { const mappings = compileAndMap('Name: '); - expect(mappings).toContain({ + expectMapping(mappings, { source: '', generated: 'i0.ɵɵelementStart(1, "input", 0)', sourceUrl: '../test.ts' }); // TODO: improve mappings here - expect(mappings).toContain({ + expectMapping(mappings, { source: 'bindon-ngModel="name"', generated: 'i0.ɵɵlistener("ngModelChange", function TestCmp_Template_input_ngModelChange_1_listener($event) { return ctx.name = $event; })', sourceUrl: '../test.ts' }); - expect(mappings).toContain({ + expectMapping(mappings, { source: '', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts' @@ -245,7 +259,7 @@ runInEachFileSystem((os) => { it('should map a class input binding', () => { const mappings = compileAndMap('
Message
'); - expect(mappings).toContain({ + expectMapping(mappings, { source: '
', 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('
{{ name }}
'); - expect(mappings).toContain({ + expectMapping(mappings, { source: '
', 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) => { `
\n` + ``); - expect(mappings).toContain( + expectMapping( + mappings, {source: '
', 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( '
{{ item }}
'); - expect(mappings).toContain({ + expectMapping(mappings, { source: '
', 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) => { `

\n` + `
`); - expect(mappings).toContain( + expectMapping( + mappings, {source: '

', generated: 'i0.ɵɵelementStart(0, "h3")', sourceUrl: '../test.ts'}); - expect(mappings).toContain({ + expectMapping(mappings, { source: '', generated: 'i0.ɵɵprojection(1)', sourceUrl: '../test.ts' }); - expect(mappings).toContain( - {source: '

', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); - expect(mappings).toContain( + expectMapping( + mappings, {source: '', generated: 'i0.ɵɵelementEnd()', sourceUrl: '../test.ts'}); + expectMapping( + mappings, {source: '
', 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(`
Hello, World!
`); - expect(mappings).toContain({ + expectMapping(mappings, { source: '
', 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('
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'}); // 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('
this is a test
'); - expect(mappings).toContain({ + expectMapping(mappings, { generated: 'i0.ɵɵelementStart(0, "div", 0)', source: '
', 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"'); }); });