diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index 863c8609934be..feb3c3c3c78b9 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -244,10 +244,10 @@ export class Parser { atInterpolation = true; } else { - // parse from starting {{ to ending }} + // parse from starting {{ to ending }} while ignoring content inside quotes. const fullStart = i; const exprStart = fullStart + interpStart.length; - const exprEnd = input.indexOf(interpEnd, exprStart); + const exprEnd = this._getExpressiondEndIndex(input, interpEnd, exprStart); if (exprEnd === -1) { // Could not find the end of the interpolation; do not parse an expression. // Instead we should extend the content on the last raw string. @@ -340,10 +340,39 @@ export class Parser { return errLocation.length; } + + /** + * Finds the index of the end of an interpolation expression + * while ignoring comments and quoted content. + */ + private _getExpressiondEndIndex(input: string, expressionEnd: string, start: number): number { + let currentQuote: string|null = null; + let escapeCount = 0; + for (let i = start; i < input.length; i++) { + const char = input[i]; + // Skip the characters inside quotes. Note that we only care about the + // outer-most quotes matching up and we need to account for escape characters. + if (isQuote(input.charCodeAt(i)) && (currentQuote === null || currentQuote === char) && + escapeCount % 2 === 0) { + currentQuote = currentQuote === null ? char : null; + } else if (currentQuote === null) { + if (input.startsWith(expressionEnd, i)) { + return i; + } + // Nothing else in the expression matters after we've + // hit a comment so look directly for the end token. + if (input.startsWith('//', i)) { + return input.indexOf(expressionEnd, i); + } + } + escapeCount = char === '\\' ? escapeCount + 1 : 0; + } + return -1; + } } export class IvyParser extends Parser { - simpleExpressionChecker = IvySimpleExpressionChecker; // + simpleExpressionChecker = IvySimpleExpressionChecker; } /** Describes a stateful context an expression parser is in. */ diff --git a/packages/compiler/test/expression_parser/parser_spec.ts b/packages/compiler/test/expression_parser/parser_spec.ts index 7140f5d9bdb11..2acde1d89fa22 100644 --- a/packages/compiler/test/expression_parser/parser_spec.ts +++ b/packages/compiler/test/expression_parser/parser_spec.ts @@ -838,6 +838,37 @@ describe('parser', () => { expect(ast.expressions[0].name).toEqual('a'); }); + it('should parse interpolation inside quotes', () => { + 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 interpolation with interpolation characters inside quotes', () => { + checkInterpolation('{{"{{a}}"}}', '{{ "{{a}}" }}'); + checkInterpolation('{{"{{"}}', '{{ "{{" }}'); + checkInterpolation('{{"}}"}}', '{{ "}}" }}'); + checkInterpolation('{{"{"}}', '{{ "{" }}'); + checkInterpolation('{{"}"}}', '{{ "}" }}'); + }); + + it('should parse interpolation with escaped quotes', () => { + checkInterpolation(`{{'It\\'s just Angular'}}`, `{{ "It's just Angular" }}`); + checkInterpolation(`{{'It\\'s {{ just Angular'}}`, `{{ "It's {{ just Angular" }}`); + checkInterpolation(`{{'It\\'s }} just Angular'}}`, `{{ "It's }} just Angular" }}`); + }); + + it('should parse interpolation with escaped backslashes', () => { + checkInterpolation(`{{foo.split('\\\\')}}`, `{{ foo.split("\\") }}`); + checkInterpolation(`{{foo.split('\\\\\\\\')}}`, `{{ foo.split("\\\\") }}`); + checkInterpolation(`{{foo.split('\\\\\\\\\\\\')}}`, `{{ foo.split("\\\\\\") }}`); + }); + + it('should not parse interpolation with mismatching quotes', () => { + expect(parseInterpolation(`{{ "{{a}}' }}`)).toBeNull(); + }); + it('should parse prefix/suffix with multiple interpolation', () => { const originalExp = 'before {{ a }} middle {{ b }} after'; const ast = parseInterpolation(originalExp)!.ast; @@ -895,6 +926,10 @@ describe('parser', () => { it('should retain // in nested, unterminated strings', () => { checkInterpolation(`{{ "a\'b\`" //comment}}`, `{{ "a\'b\`" }}`); }); + + it('should ignore quotes inside a comment', () => { + checkInterpolation(`"{{name // " }}"`, `"{{ name }}"`); + }); }); }); @@ -1075,8 +1110,11 @@ function parseSimpleBindingIvy( } function checkInterpolation(exp: string, expected?: string) { - const ast = parseInterpolation(exp)!; + const ast = parseInterpolation(exp); if (expected == null) expected = exp; + if (ast === null) { + throw Error(`Failed to parse expression "${exp}"`); + } expect(unparse(ast)).toEqual(expected); validate(ast); } diff --git a/packages/compiler/test/template_parser/template_parser_spec.ts b/packages/compiler/test/template_parser/template_parser_spec.ts index d43238c102f16..69a0c3e75441b 100644 --- a/packages/compiler/test/template_parser/template_parser_spec.ts +++ b/packages/compiler/test/template_parser/template_parser_spec.ts @@ -540,6 +540,54 @@ describe('TemplateParser', () => { expect(humanizeTplAst(parse('{{a}}', []))).toEqual([[BoundTextAst, '{{ a }}']]); }); + it('should parse bound text nodes inside quotes', () => { + expect(humanizeTplAst(parse('"{{a}}"', []))).toEqual([[BoundTextAst, '"{{ a }}"']]); + }); + + it('should parse bound text nodes with interpolations inside quotes', () => { + expect(humanizeTplAst(parse('{{ "{{a}}" }}', []))).toEqual([[BoundTextAst, '{{ "{{a}}" }}']]); + expect(humanizeTplAst(parse('{{"{{"}}', []))).toEqual([[BoundTextAst, '{{ "{{" }}']]); + expect(humanizeTplAst(parse('{{"}}"}}', []))).toEqual([[BoundTextAst, '{{ "}}" }}']]); + expect(humanizeTplAst(parse('{{"{"}}', []))).toEqual([[BoundTextAst, '{{ "{" }}']]); + expect(humanizeTplAst(parse('{{"}"}}', []))).toEqual([[BoundTextAst, '{{ "}" }}']]); + }); + + it('should parse bound text nodes with escaped quotes', () => { + expect(humanizeTplAst(parse(`{{'It\\'s just Angular'}}`, []))).toEqual([ + [BoundTextAst, `{{ "It's just Angular" }}`] + ]); + + expect(humanizeTplAst(parse(`{{'It\\'s {{ just Angular'}}`, []))).toEqual([ + [BoundTextAst, `{{ "It's {{ just Angular" }}`] + ]); + + expect(humanizeTplAst(parse(`{{'It\\'s }} just Angular'}}`, []))).toEqual([ + [BoundTextAst, `{{ "It's }} just Angular" }}`] + ]); + }); + + it('should not parse bound text nodes with mismatching quotes', () => { + expect(humanizeTplAst(parse(`{{ "{{a}}' }}`, []))).toEqual([[TextAst, `{{ "{{a}}' }}`]]); + }); + + it('should parse interpolation with escaped backslashes', () => { + expect(humanizeTplAst(parse(`{{foo.split('\\\\')}}`, []))).toEqual([ + [BoundTextAst, `{{ foo.split("\\") }}`] + ]); + expect(humanizeTplAst(parse(`{{foo.split('\\\\\\\\')}}`, []))).toEqual([ + [BoundTextAst, `{{ foo.split("\\\\") }}`] + ]); + expect(humanizeTplAst(parse(`{{foo.split('\\\\\\\\\\\\')}}`, []))).toEqual([ + [BoundTextAst, `{{ foo.split("\\\\\\") }}`] + ]); + }); + + it('should ignore quotes inside a comment', () => { + expect(humanizeTplAst(parse(`"{{name // " }}"`, []))).toEqual([ + [BoundTextAst, `"{{ name }}"`] + ]); + }); + it('should parse with custom interpolation config', inject([TemplateParser], (parser: TemplateParser) => { const component = CompileDirectiveMetadata.create({ diff --git a/packages/core/test/acceptance/text_spec.ts b/packages/core/test/acceptance/text_spec.ts index 4d25f93333552..952006c5a9a2a 100644 --- a/packages/core/test/acceptance/text_spec.ts +++ b/packages/core/test/acceptance/text_spec.ts @@ -171,4 +171,18 @@ describe('text instructions', () => { // `Symbol(hello)_p.sc8s398cplk`, whereas the native one is `Symbol(hello)`. expect(fixture.nativeElement.textContent).toContain('Symbol(hello)'); }); + + it('should handle binding syntax used inside quoted text', () => { + @Component({ + template: `{{'Interpolations look like {{this}}'}}`, + }) + class App { + } + + TestBed.configureTestingModule({declarations: [App]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Interpolations look like {{this}}'); + }); });