From 9f5cc7c808458f312543c7c044fe7a361cdc7798 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sun, 27 Jun 2021 12:24:51 +0200 Subject: [PATCH] feat(compiler): support number separators in templates (#42672) As of ES2021, JavaScript allows using underscores as separators inside numbers, in order to make them more readable (e.g. `1_000_000` vs `1000000`). TypeScript has had support for separators for a while so these changes expand the template parser to handle them as well. PR Close #42672 --- .../value_composition/GOLDEN_PARTIAL.js | 50 +++++++++++++++++++ .../value_composition/TEST_CASES.json | 14 ++++++ .../value_composition/number_separator.js | 9 ++++ .../value_composition/number_separator.ts | 16 ++++++ .../compiler/src/expression_parser/lexer.ts | 24 +++++++-- .../test/expression_parser/lexer_spec.ts | 30 +++++++++++ .../core/test/acceptance/integration_spec.ts | 13 +++++ 7 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/number_separator.js create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/number_separator.ts diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/GOLDEN_PARTIAL.js index 991afb8d46..82763e7fc1 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/GOLDEN_PARTIAL.js @@ -631,3 +631,53 @@ export declare class MyModule { static ɵinj: i0.ɵɵInjectorDeclaration; } +/**************************************************************************************************** + * PARTIAL FILE: number_separator.js + ****************************************************************************************************/ +import { Component, NgModule } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyApp { + constructor() { + this.multiplier = 5; + } +} +MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component }); +MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "my-app", ngImport: i0, template: ` +
Total: \${{ 1_000_000 * multiplier }}
+ Remaining: \${{ 123_456.78_9 / 2 }} + `, isInline: true }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{ + type: Component, + args: [{ + selector: 'my-app', + template: ` +
Total: \${{ 1_000_000 * multiplier }}
+ Remaining: \${{ 123_456.78_9 / 2 }} + ` + }] + }] }); +export class MyModule { +} +MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); +MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp] }); +MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{ + type: NgModule, + args: [{ declarations: [MyApp] }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: number_separator.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyApp { + multiplier: number; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} +export declare class MyModule { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/TEST_CASES.json index e4de00987e..e12c8c9116 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/TEST_CASES.json @@ -272,6 +272,20 @@ ] } ] + }, + { + "description": "should support number literals with separators", + "inputFiles": [ + "number_separator.ts" + ], + "expectations": [ + { + "failureMessage": "Invalid number literal", + "files": [ + "number_separator.js" + ] + } + ] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/number_separator.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/number_separator.js new file mode 100644 index 0000000000..4ca574216f --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/number_separator.js @@ -0,0 +1,9 @@ +template: function MyApp_Template(rf, ctx) { + // ... + if (rf & 2) { + $r3$.ɵɵadvance(1); + $r3$.ɵɵtextInterpolate1("Total: $", 1000000 * ctx.multiplier, ""); + $r3$.ɵɵadvance(2); + $r3$.ɵɵtextInterpolate1("Remaining: $", 123456.789 / 2, ""); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/number_separator.ts b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/number_separator.ts new file mode 100644 index 0000000000..26b7ba7c75 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/number_separator.ts @@ -0,0 +1,16 @@ +import {Component, NgModule} from '@angular/core'; + +@Component({ + selector: 'my-app', + template: ` +
Total: \${{ 1_000_000 * multiplier }}
+ Remaining: \${{ 123_456.78_9 / 2 }} + ` +}) +export class MyApp { + multiplier = 5; +} + +@NgModule({declarations: [MyApp]}) +export class MyModule { +} diff --git a/packages/compiler/src/expression_parser/lexer.ts b/packages/compiler/src/expression_parser/lexer.ts index 99c5a5bcd5..39372a4b07 100644 --- a/packages/compiler/src/expression_parser/lexer.ts +++ b/packages/compiler/src/expression_parser/lexer.ts @@ -303,12 +303,24 @@ class _Scanner { } scanNumber(start: number): Token { - let simple: boolean = (this.index === start); + let simple = (this.index === start); + let hasSeparators = false; this.advance(); // Skip initial digit. while (true) { if (chars.isDigit(this.peek)) { // Do nothing. - } else if (this.peek == chars.$PERIOD) { + } else if (this.peek === chars.$_) { + // Separators are only valid when they're surrounded by digits. E.g. `1_0_1` is + // valid while `_101` and `101_` are not. The separator can't be next to the decimal + // point or another separator either. Note that it's unlikely that we'll hit a case where + // the underscore is at the start, because that's a valid identifier and it will be picked + // up earlier in the parsing. We validate for it anyway just in case. + if (!chars.isDigit(this.input.charCodeAt(this.index - 1)) || + !chars.isDigit(this.input.charCodeAt(this.index + 1))) { + return this.error('Invalid numeric separator', 0); + } + hasSeparators = true; + } else if (this.peek === chars.$PERIOD) { simple = false; } else if (isExponentStart(this.peek)) { this.advance(); @@ -320,8 +332,12 @@ class _Scanner { } this.advance(); } - const str: string = this.input.substring(start, this.index); - const value: number = simple ? parseIntAutoRadix(str) : parseFloat(str); + + let str = this.input.substring(start, this.index); + if (hasSeparators) { + str = str.replace(/_/g, ''); + } + const value = simple ? parseIntAutoRadix(str) : parseFloat(str); return newNumberToken(start, this.index, value); } diff --git a/packages/compiler/test/expression_parser/lexer_spec.ts b/packages/compiler/test/expression_parser/lexer_spec.ts index 4bd2a638a4..068cfdeb52 100644 --- a/packages/compiler/test/expression_parser/lexer_spec.ts +++ b/packages/compiler/test/expression_parser/lexer_spec.ts @@ -295,6 +295,36 @@ function expectErrorToken(token: Token, index: any, end: number, message: string it('should tokenize ?? as operator', () => { expectOperatorToken(lex('??')[0], 0, 2, '??'); }); + + it('should tokenize number with separator', () => { + expectNumberToken(lex('123_456')[0], 0, 7, 123_456); + expectNumberToken(lex('1_000_000_000')[0], 0, 13, 1_000_000_000); + expectNumberToken(lex('123_456.78')[0], 0, 10, 123_456.78); + expectNumberToken(lex('123_456_789.123_456_789')[0], 0, 23, 123_456_789.123_456_789); + expectNumberToken(lex('1_2_3_4')[0], 0, 7, 1_2_3_4); + expectNumberToken(lex('1_2_3_4.5_6_7_8')[0], 0, 15, 1_2_3_4.5_6_7_8); + }); + + it('should tokenize number starting with an underscore as an identifier', () => { + expectIdentifierToken(lex('_123')[0], 0, 4, '_123'); + expectIdentifierToken(lex('_123_')[0], 0, 5, '_123_'); + expectIdentifierToken(lex('_1_2_3_')[0], 0, 7, '_1_2_3_'); + }); + + it('should throw error for invalid number separators', () => { + expectErrorToken( + lex('123_')[0], 3, 3, + 'Lexer Error: Invalid numeric separator at column 3 in expression [123_]'); + expectErrorToken( + lex('12__3')[0], 2, 2, + 'Lexer Error: Invalid numeric separator at column 2 in expression [12__3]'); + expectErrorToken( + lex('1_2_3_.456')[0], 5, 5, + 'Lexer Error: Invalid numeric separator at column 5 in expression [1_2_3_.456]'); + expectErrorToken( + lex('1_2_3._456')[0], 6, 6, + 'Lexer Error: Invalid numeric separator at column 6 in expression [1_2_3._456]'); + }); }); }); } diff --git a/packages/core/test/acceptance/integration_spec.ts b/packages/core/test/acceptance/integration_spec.ts index ce00467707..93bf1405d8 100644 --- a/packages/core/test/acceptance/integration_spec.ts +++ b/packages/core/test/acceptance/integration_spec.ts @@ -2061,6 +2061,19 @@ describe('acceptance integration tests', () => { expect(fixture.componentInstance.directive.value).toEqual({a: 1, b: 2, someProp: 3}); }); + it('should handle numeric separators in templates', () => { + @Component({template: 'Balance: ${{ 1_000_000 * multiplier }}'}) + class App { + multiplier = 5; + } + + TestBed.configureTestingModule({declarations: [App]}); + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Balance: $5000000'); + }); + describe('tView.firstUpdatePass', () => { function isFirstUpdatePass() { const lView = getLView();