diff --git a/adev/src/content/guide/templates/expression-syntax.md b/adev/src/content/guide/templates/expression-syntax.md index ce01924d9f6f..df9547b8b0f7 100644 --- a/adev/src/content/guide/templates/expression-syntax.md +++ b/adev/src/content/guide/templates/expression-syntax.md @@ -47,27 +47,37 @@ For example, `@for` blocks make several local variables corresponding to informa Angular supports the following operators from standard JavaScript. -| Operator | Example(s) | -| --------------------- | ---------------------------------------- | -| Add / Concatenate | `1 + 2` | -| Subtract | `52 - 3` | -| Multiply | `41 * 6` | -| Divide | `20 / 4` | -| Remainder (Modulo) | `17 % 5` | -| Exponentiation | `10 ** 3` | -| Parenthesis | `9 * (8 + 4)` | -| Conditional (Ternary) | `a > b ? true : false` | -| And (Logical) | `&&` | -| Or (Logical) | `\|\|` | -| Not (Logical) | `!` | -| Nullish Coalescing | `possiblyNullValue ?? 'default'` | -| Comparison Operators | `<`, `<=`, `>`, `>=`, `==`, `===`, `!==` | -| Unary Negation | `-x` | -| Unary Plus | `+y` | -| Property Accessor | `person['name']` | -| typeof | `typeof 42` | -| void | `void 1` | -| in | `'model' in car` | +| Operator | Example(s) | +| ------------------------------- | ---------------------------------------- | +| Add / Concatenate | `1 + 2` | +| Subtract | `52 - 3` | +| Multiply | `41 * 6` | +| Divide | `20 / 4` | +| Remainder (Modulo) | `17 % 5` | +| Exponentiation | `10 ** 3` | +| Parenthesis | `9 * (8 + 4)` | +| Conditional (Ternary) | `a > b ? true : false` | +| And (Logical) | `&&` | +| Or (Logical) | `\|\|` | +| Not (Logical) | `!` | +| Nullish Coalescing | `possiblyNullValue ?? 'default'` | +| Comparison Operators | `<`, `<=`, `>`, `>=`, `==`, `===`, `!==` | +| Unary Negation | `-x` | +| Unary Plus | `+y` | +| Property Accessor | `person['name']` | +| typeof | `typeof 42` | +| void | `void 1` | +| in | `'model' in car` | +| Assignment | `a = b` | +| Addition Assignment | `a += b` | +| Subtraction Assignment | `a -= b` | +| Multiplication Assignment | `a *= b` | +| Division Assignment | `a /= b` | +| Remainder Assignment | `a %= b` | +| Exponentiation Assignment | `a **= b` | +| Logical AND Assignment | `a &&= b` | +| Logical OR Assignment | `a \|\|= b` | +| Nullish Coalescing Assignment | `a ??= b` | Angular expressions additionally also support the following non-standard operators: @@ -84,13 +94,9 @@ NOTE: Optional chaining behaves differently from the standard JavaScript version | Operator | Example(s) | | --------------------- | --------------------------------- | | All bitwise operators | `&`, `&=`, `~`, `\|=`, `^=`, etc. | -| Assignment operators | `=` | | Object destructuring | `const { name } = person` | | Array destructuring | `const [firstItem] = items` | | Comma operator | `x = (x++, x)` | -| in | `'model' in car` | -| typeof | `typeof 42` | -| void | `void 1` | | instanceof | `car instanceof Automobile` | | new | `new Car()` | diff --git a/packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts b/packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts index b7ab005fe8a6..f2244a412265 100644 --- a/packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts +++ b/packages/compiler-cli/linker/babel/src/ast/babel_ast_factory.ts @@ -37,9 +37,13 @@ export class BabelAstFactory implements AstFactory { createArrayLiteral = t.arrayExpression; - createAssignment(target: t.Expression, value: t.Expression): t.Expression { + createAssignment( + target: t.Expression, + operator: BinaryOperator, + value: t.Expression, + ): t.Expression { assert(target, isLExpression, 'must be a left hand side expression'); - return t.assignmentExpression('=', target, value); + return t.assignmentExpression(operator, target, value); } createBinaryExpression( @@ -52,6 +56,17 @@ export class BabelAstFactory implements AstFactory { case '||': case '??': return t.logicalExpression(operator, leftOperand, rightOperand); + case '=': + case '+=': + case '-=': + case '*=': + case '/=': + case '%=': + case '**=': + case '&&=': + case '||=': + case '??=': + throw new Error(`Unexpected assignment operator ${operator}`); default: return t.binaryExpression(operator, leftOperand, rightOperand); } diff --git a/packages/compiler-cli/linker/babel/test/ast/babel_ast_factory_spec.ts b/packages/compiler-cli/linker/babel/test/ast/babel_ast_factory_spec.ts index c4bb1227fa68..2fa1d074ab92 100644 --- a/packages/compiler-cli/linker/babel/test/ast/babel_ast_factory_spec.ts +++ b/packages/compiler-cli/linker/babel/test/ast/babel_ast_factory_spec.ts @@ -62,7 +62,7 @@ describe('BabelAstFactory', () => { it('should create an assignment node using the target and value expressions', () => { const target = expression.ast`x`; const value = expression.ast`42`; - const assignment = factory.createAssignment(target, value); + const assignment = factory.createAssignment(target, '=', value); expect(generate(assignment).code).toEqual('x = 42'); }); }); diff --git a/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts b/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts index 6217999ba6e5..5ae5715fd632 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts @@ -33,9 +33,10 @@ export interface AstFactory { * Create an assignment expression (e.g. `lhsExpr = rhsExpr`). * * @param target an expression that evaluates to the left side of the assignment. + * @param operator binary assignment operator that will be applied. * @param value an expression that evaluates to the right side of the assignment. */ - createAssignment(target: TExpression, value: TExpression): TExpression; + createAssignment(target: TExpression, operator: BinaryOperator, value: TExpression): TExpression; /** * Create a binary expression (e.g. `lhs && rhs`). @@ -317,7 +318,17 @@ export type BinaryOperator = | '||' | '+' | '??' - | 'in'; + | 'in' + | '=' + | '+=' + | '-=' + | '*=' + | '/=' + | '%=' + | '**=' + | '&&=' + | '||=' + | '??='; /** * The original location of the start or end of a node created by the `AstFactory`. diff --git a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts index a007ddf93adf..90611f7d5efa 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts @@ -45,6 +45,16 @@ const BINARY_OPERATORS = /* @__PURE__ */ new Map { items: [target, value], generate, } = setupExpressions(`x`, `42`); - const assignment = factory.createAssignment(target, value); + const assignment = factory.createAssignment(target, '=', value); expect(generate(assignment)).toEqual('x = 42'); }); }); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts index bcf06f5aec7d..149475ecd5e2 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts @@ -85,6 +85,16 @@ const BINARY_OPS = new Map([ ['|', ts.SyntaxKind.BarToken], ['??', ts.SyntaxKind.QuestionQuestionToken], ['in', ts.SyntaxKind.InKeyword], + ['=', ts.SyntaxKind.EqualsToken], + ['+=', ts.SyntaxKind.PlusEqualsToken], + ['-=', ts.SyntaxKind.MinusEqualsToken], + ['*=', ts.SyntaxKind.AsteriskEqualsToken], + ['/=', ts.SyntaxKind.SlashEqualsToken], + ['%=', ts.SyntaxKind.PercentEqualsToken], + ['**=', ts.SyntaxKind.AsteriskAsteriskEqualsToken], + ['&&=', ts.SyntaxKind.AmpersandAmpersandEqualsToken], + ['||=', ts.SyntaxKind.BarBarEqualsToken], + ['??=', ts.SyntaxKind.QuestionQuestionEqualsToken], ]); /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts index 411da3147ae8..ea85e50e5572 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts @@ -808,7 +808,7 @@ export class SymbolBuilder { // Also skipping SafePropertyReads as it breaks nullish coalescing not nullable extended diagnostic if ( expression instanceof Binary && - expression.operation === '=' && + Binary.isAssignmentOperation(expression.operation) && expression.left instanceof PropertyRead ) { withSpan = expression.left.nameSpan; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index a53ca2744f95..bf488368b322 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -3117,7 +3117,7 @@ class TcbExpressionTranslator { return targetExpression; } else if ( ast instanceof Binary && - ast.operation === '=' && + Binary.isAssignmentOperation(ast.operation) && ast.left instanceof PropertyRead && ast.left.receiver instanceof ImplicitReceiver ) { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts index f62a58dca3b5..19da9be656cd 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts @@ -77,7 +77,7 @@ class ExpressionsSemanticsVisitor extends RecursiveAstVisitor { } override visitBinary(ast: Binary, context: TmplAstNode): void { - if (ast.operation === '=' && ast.left instanceof PropertyRead) { + if (Binary.isAssignmentOperation(ast.operation) && ast.left instanceof PropertyRead) { this.checkForIllegalWriteInEventBinding(ast.left, context); } else { super.visitBinary(ast, context); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index affcba21ffbb..1120c54cb364 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -75,6 +75,19 @@ describe('type check blocks', () => { expect(tcb('{{!(void a === "object")}}')).toContain('!(((void (((this).a))) === ("object")))'); }); + it('should handle assignment expressions', () => { + expect(tcb('')).toContain('(((this).a)) = (((this).b));'); + expect(tcb('')).toContain('(((this).a)) += (((this).b));'); + expect(tcb('')).toContain('(((this).a)) -= (((this).b));'); + expect(tcb('')).toContain('(((this).a)) *= (((this).b));'); + expect(tcb('')).toContain('(((this).a)) /= (((this).b));'); + expect(tcb('')).toContain('(((this).a)) %= (((this).b));'); + expect(tcb('')).toContain('(((this).a)) **= (((this).b));'); + expect(tcb('')).toContain('(((this).a)) &&= (((this).b));'); + expect(tcb('')).toContain('(((this).a)) ||= (((this).b));'); + expect(tcb('')).toContain('(((this).a)) ??= (((this).b));'); + }); + it('should handle exponentiation expressions', () => { expect(tcb('{{a * b ** c + d}}')).toContain( '(((((this).a)) * ((((this).b)) ** (((this).c)))) + (((this).d)))', diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/GOLDEN_PARTIAL.js index 32f4b186d624..60b23880949b 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/GOLDEN_PARTIAL.js @@ -76,20 +76,18 @@ export class IdentityPipe { transform(value) { return value; } + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); + static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, isStandalone: true, name: "identity" }); } -IdentityPipe.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); -IdentityPipe.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, isStandalone: true, name: "identity" }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, decorators: [{ type: Pipe, args: [{ name: 'identity' }] }] }); export class MyApp { - constructor() { - this.foo = { bar: 'baz' }; - } -} -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: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` + foo = { bar: 'baz' }; + number = 1; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` {{ 1 + 2 }} {{ (1 % 2) + 3 / 4 * 5 ** 6 }} {{ +1 }} @@ -100,7 +98,17 @@ MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0- {{ void 'test' }} {{ (-1) ** 3 }} {{ 'bar' in foo }} + + + + + + + + + `, isInline: true, dependencies: [{ kind: "pipe", type: IdentityPipe, name: "identity" }] }); +} i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{ type: Component, args: [{ @@ -115,6 +123,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE {{ void 'test' }} {{ (-1) ** 3 }} {{ 'bar' in foo }} + + + + + + + + + `, imports: [IdentityPipe], }] @@ -133,6 +150,7 @@ export declare class MyApp { foo: { bar?: string; }; + number: number; static ɵfac: i0.ɵɵFactoryDeclaration; static ɵcmp: i0.ɵɵComponentDeclaration; } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/TEST_CASES.json index b7a640d75204..793a16e8d364 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/TEST_CASES.json @@ -19,7 +19,7 @@ { "description": "should handle binary and unary operators", "compilerOptions": { - "target": "es2020" + "target": "es2022" }, "inputFiles": ["operators.ts"], "expectations": [ diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators.ts index f3f896c2d131..73568857a180 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators.ts @@ -19,9 +19,19 @@ export class IdentityPipe { {{ void 'test' }} {{ (-1) ** 3 }} {{ 'bar' in foo }} + + + + + + + + + `, imports: [IdentityPipe], }) export class MyApp { foo: {bar?: string} = {bar: 'baz'}; + number = 1; } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_template.js index b8136e6ece31..8f28a8091c8b 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_template.js @@ -1,20 +1,38 @@ template: function MyApp_Template(rf, $ctx$) { if (rf & 1) { - $i0$.ɵɵtext(0); - i0.ɵɵpipe(1, "identity"); - } if (rf & 2) { - i0.ɵɵtextInterpolateV([" ", + … + $r3$.ɵɵlistener("click", function MyApp_Template_button_click_2_listener() { return $ctx$.number += 1; }); + … + $r3$.ɵɵlistener("click", function MyApp_Template_button_click_3_listener() { return $ctx$.number -= 1; }); + … + $r3$.ɵɵlistener("click", function MyApp_Template_button_click_4_listener() { return $ctx$.number *= 1; }); + … + $r3$.ɵɵlistener("click", function MyApp_Template_button_click_5_listener() { return $ctx$.number /= 1; }); + … + $r3$.ɵɵlistener("click", function MyApp_Template_button_click_6_listener() { return $ctx$.number %= 1; }); + … + $r3$.ɵɵlistener("click", function MyApp_Template_button_click_7_listener() { return $ctx$.number **= 1; }); + … + $r3$.ɵɵlistener("click", function MyApp_Template_button_click_8_listener() { return $ctx$.number &&= 1; }); + … + $r3$.ɵɵlistener("click", function MyApp_Template_button_click_9_listener() { return $ctx$.number ||= 1; }); + … + $r3$.ɵɵlistener("click", function MyApp_Template_button_click_10_listener() { return $ctx$.number ??= 1; }); + … + } + if (rf & 2) { + $r3$.ɵɵtextInterpolateV([" ", 1 + 2, " ", 1 % 2 + 3 / 4 * 5 ** 6, " ", +1, " ", - typeof i0.ɵɵpureFunction0(12, _c0) === "object", " ", - !(typeof i0.ɵɵpureFunction0(13, _c0) === "object"), " ", - typeof (ctx.foo == null ? null : ctx.foo.bar) === "string", " ", - i0.ɵɵpipeBind1(1, 10, typeof (ctx.foo == null ? null : ctx.foo.bar)), " ", + typeof $r3$.ɵɵpureFunction0(12, _c0) === "object", " ", + !(typeof $r3$.ɵɵpureFunction0(13, _c0) === "object"), " ", + typeof ($ctx$.foo == null ? null : $ctx$.foo.bar) === "string", " ", + $r3$.ɵɵpipeBind1(1, 10, typeof ($ctx$.foo == null ? null : $ctx$.foo.bar)), " ", void "test", " ", (-1) ** 3, " ", - "bar" in ctx.foo, " " + "bar" in $ctx$.foo, " " ]); } - } +} \ No newline at end of file diff --git a/packages/compiler/src/expression_parser/ast.ts b/packages/compiler/src/expression_parser/ast.ts index 84132c2ea1d6..3493017210f3 100644 --- a/packages/compiler/src/expression_parser/ast.ts +++ b/packages/compiler/src/expression_parser/ast.ts @@ -277,6 +277,21 @@ export class Binary extends AST { override visit(visitor: AstVisitor, context: any = null): any { return visitor.visitBinary(this, context); } + + static isAssignmentOperation(op: string): boolean { + return ( + op === '=' || + op === '+=' || + op === '-=' || + op === '*=' || + op === '/=' || + op === '%=' || + op === '**=' || + op === '&&=' || + op === '||=' || + op === '??=' + ); + } } /** diff --git a/packages/compiler/src/expression_parser/lexer.ts b/packages/compiler/src/expression_parser/lexer.ts index e88fa3f2460f..986eee4344fd 100644 --- a/packages/compiler/src/expression_parser/lexer.ts +++ b/packages/compiler/src/expression_parser/lexer.ts @@ -294,13 +294,17 @@ class _Scanner { case chars.$HASH: return this.scanPrivateIdentifier(); case chars.$PLUS: + return this.scanComplexOperator(start, '+', chars.$EQ, '='); case chars.$MINUS: + return this.scanComplexOperator(start, '-', chars.$EQ, '='); case chars.$SLASH: + return this.scanComplexOperator(start, '/', chars.$EQ, '='); case chars.$PERCENT: + return this.scanComplexOperator(start, '%', chars.$EQ, '='); case chars.$CARET: - return this.scanOperator(start, String.fromCharCode(peek)); + return this.scanOperator(start, '^'); case chars.$STAR: - return this.scanComplexOperator(start, '*', chars.$STAR, '*'); + return this.scanStar(start); case chars.$QUESTION: return this.scanQuestion(start); case chars.$LT: @@ -317,9 +321,9 @@ class _Scanner { '=', ); case chars.$AMPERSAND: - return this.scanComplexOperator(start, '&', chars.$AMPERSAND, '&'); + return this.scanComplexOperator(start, '&', chars.$AMPERSAND, '&', chars.$EQ, '='); case chars.$BAR: - return this.scanComplexOperator(start, '|', chars.$BAR, '|'); + return this.scanComplexOperator(start, '|', chars.$BAR, '|', chars.$EQ, '='); case chars.$NBSP: while (chars.isWhitespace(this.peek)) this.advance(); return this.scanToken(); @@ -483,13 +487,23 @@ class _Scanner { private scanQuestion(start: number): Token { this.advance(); - let str: string = '?'; - // Either `a ?? b` or 'a?.b'. - if (this.peek === chars.$QUESTION || this.peek === chars.$PERIOD) { - str += this.peek === chars.$PERIOD ? '.' : '?'; + let operator = '?'; + // `a ?? b` or `a ??= b`. + if (this.peek === chars.$QUESTION) { + operator += '?'; + this.advance(); + + // @ts-expect-error + if (this.peek === chars.$EQ) { + operator += '='; + this.advance(); + } + } else if (this.peek === chars.$PERIOD) { + // `a?.b` + operator += '.'; this.advance(); } - return newOperatorToken(start, this.index, str); + return newOperatorToken(start, this.index, operator); } private scanTemplateLiteralPart(start: number): Token { @@ -568,6 +582,28 @@ class _Scanner { buffer += String.fromCharCode(unescapedCode); return buffer; } + + private scanStar(start: number): Token { + this.advance(); + // `*`, `**`, `**=` or `*=` + let operator = '*'; + + if (this.peek === chars.$STAR) { + operator += '*'; + this.advance(); + + // @ts-expect-error + if (this.peek === chars.$EQ) { + operator += '='; + this.advance(); + } + } else if (this.peek === chars.$EQ) { + operator += '='; + this.advance(); + } + + return newOperatorToken(start, this.index, operator); + } } function isIdentifierStart(code: number): boolean { diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index 2284c506d4b7..d7f3aaa36e0e 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -704,6 +704,10 @@ class _ParseAST { } } + private isAssignmentOperator(token: Token): boolean { + return token.type === TokenType.Operator && Binary.isAssignmentOperation(token.strValue); + } + private expectOperator(operator: string) { if (this.consumeOptionalOperator(operator)) return; this.error(`Missing expected operator ${operator}`); @@ -1214,7 +1218,8 @@ class _ParseAST { const nameSpan = this.sourceSpan(nameStart); if (isSafe) { - if (this.consumeOptionalOperator('=')) { + if (this.isAssignmentOperator(this.next)) { + this.advance(); this.error("The '?.' operator cannot be used in the assignment"); return new EmptyExpr(this.span(start), this.sourceSpan(start)); } else { @@ -1227,7 +1232,10 @@ class _ParseAST { ); } } else { - if (this.consumeOptionalOperator('=')) { + if (this.isAssignmentOperator(this.next)) { + const operation = this.next.strValue; + this.advance(); + if (!(this.parseFlags & ParseFlags.Action)) { this.error('Bindings cannot contain assignments'); return new EmptyExpr(this.span(start), this.sourceSpan(start)); @@ -1240,7 +1248,7 @@ class _ParseAST { id, ); const value = this.parseConditional(); - return new Binary(this.span(start), this.sourceSpan(start), '=', receiver, value); + return new Binary(this.span(start), this.sourceSpan(start), operation, receiver, value); } else { return new PropertyRead( this.span(start), @@ -1365,7 +1373,10 @@ class _ParseAST { } this.rbracketsExpected--; this.expectCharacter(chars.$RBRACKET); - if (this.consumeOptionalOperator('=')) { + if (this.isAssignmentOperator(this.next)) { + const operation = this.next.strValue; + this.advance(); + if (isSafe) { this.error("The '?.' operator cannot be used in the assignment"); } else { @@ -1376,7 +1387,13 @@ class _ParseAST { key, ); const value = this.parseConditional(); - return new Binary(this.span(start), this.sourceSpan(start), '=', binaryReceiver, value); + return new Binary( + this.span(start), + this.sourceSpan(start), + operation, + binaryReceiver, + value, + ); } } else { return isSafe @@ -1607,7 +1624,7 @@ class _ParseAST { * none of the calling productions are not expecting the closing token else we will never * make progress in the case of an extraneous group closing symbol (such as a stray ')'). * That is, we skip a closing symbol if we are not in a grouping production. - * - '=' in a `Writable` context + * - Assignment in a `Writable` context * - In this context, we are able to recover after seeing the `=` operator, which * signals the presence of an independent rvalue expression following the `=` operator. * @@ -1623,7 +1640,7 @@ class _ParseAST { (this.rparensExpected <= 0 || !n.isCharacter(chars.$RPAREN)) && (this.rbracesExpected <= 0 || !n.isCharacter(chars.$RBRACE)) && (this.rbracketsExpected <= 0 || !n.isCharacter(chars.$RBRACKET)) && - (!(this.context & ParseContextFlags.Writable) || !n.isOperator('=')) + (!(this.context & ParseContextFlags.Writable) || !this.isAssignmentOperator(n)) ) { if (this.next.isError()) { this.errors.push( diff --git a/packages/compiler/src/output/abstract_emitter.ts b/packages/compiler/src/output/abstract_emitter.ts index 397938cfc07a..8bd76ebc557a 100644 --- a/packages/compiler/src/output/abstract_emitter.ts +++ b/packages/compiler/src/output/abstract_emitter.ts @@ -22,6 +22,39 @@ class _EmittedLine { constructor(public indent: number) {} } +const BINARY_OPERATORS = new Map([ + [o.BinaryOperator.And, '&&'], + [o.BinaryOperator.Bigger, '>'], + [o.BinaryOperator.BiggerEquals, '>='], + [o.BinaryOperator.BitwiseOr, '|'], + [o.BinaryOperator.BitwiseAnd, '&'], + [o.BinaryOperator.Divide, '/'], + [o.BinaryOperator.Assign, '='], + [o.BinaryOperator.Equals, '=='], + [o.BinaryOperator.Identical, '==='], + [o.BinaryOperator.Lower, '<'], + [o.BinaryOperator.LowerEquals, '<='], + [o.BinaryOperator.Minus, '-'], + [o.BinaryOperator.Modulo, '%'], + [o.BinaryOperator.Exponentiation, '**'], + [o.BinaryOperator.Multiply, '*'], + [o.BinaryOperator.NotEquals, '!='], + [o.BinaryOperator.NotIdentical, '!=='], + [o.BinaryOperator.NullishCoalesce, '??'], + [o.BinaryOperator.Or, '||'], + [o.BinaryOperator.Plus, '+'], + [o.BinaryOperator.In, 'in'], + [o.BinaryOperator.AdditionAssignment, '+='], + [o.BinaryOperator.SubtractionAssignment, '-='], + [o.BinaryOperator.MultiplicationAssignment, '*='], + [o.BinaryOperator.DivisionAssignment, '/='], + [o.BinaryOperator.RemainderAssignment, '%='], + [o.BinaryOperator.ExponentiationAssignment, '**='], + [o.BinaryOperator.AndAssignment, '&&='], + [o.BinaryOperator.OrAssignment, '||='], + [o.BinaryOperator.NullishCoalesceAssignment, '??='], +]); + export class EmitterVisitorContext { static createRoot(): EmitterVisitorContext { return new EmitterVisitorContext(0); @@ -379,78 +412,14 @@ export abstract class AbstractEmitterVisitor implements o.StatementVisitor, o.Ex } visitBinaryOperatorExpr(ast: o.BinaryOperatorExpr, ctx: EmitterVisitorContext): any { - let opStr: string; - switch (ast.operator) { - case o.BinaryOperator.Assign: - opStr = '='; - break; - case o.BinaryOperator.Equals: - opStr = '=='; - break; - case o.BinaryOperator.Identical: - opStr = '==='; - break; - case o.BinaryOperator.NotEquals: - opStr = '!='; - break; - case o.BinaryOperator.NotIdentical: - opStr = '!=='; - break; - case o.BinaryOperator.And: - opStr = '&&'; - break; - case o.BinaryOperator.BitwiseOr: - opStr = '|'; - break; - case o.BinaryOperator.BitwiseAnd: - opStr = '&'; - break; - case o.BinaryOperator.Or: - opStr = '||'; - break; - case o.BinaryOperator.Plus: - opStr = '+'; - break; - case o.BinaryOperator.Minus: - opStr = '-'; - break; - case o.BinaryOperator.Divide: - opStr = '/'; - break; - case o.BinaryOperator.Multiply: - opStr = '*'; - break; - case o.BinaryOperator.Modulo: - opStr = '%'; - break; - case o.BinaryOperator.Exponentiation: - opStr = '**'; - break; - case o.BinaryOperator.Lower: - opStr = '<'; - break; - case o.BinaryOperator.LowerEquals: - opStr = '<='; - break; - case o.BinaryOperator.Bigger: - opStr = '>'; - break; - case o.BinaryOperator.BiggerEquals: - opStr = '>='; - break; - case o.BinaryOperator.NullishCoalesce: - opStr = '??'; - break; - case o.BinaryOperator.In: - opStr = 'in'; - break; - default: - throw new Error(`Unknown operator ${ast.operator}`); + const operator = BINARY_OPERATORS.get(ast.operator); + if (!operator) { + throw new Error(`Unknown operator ${ast.operator}`); } const parens = ast !== this.lastIfCondition; if (parens) ctx.print(ast, `(`); ast.lhs.visitExpression(this, ctx); - ctx.print(ast, ` ${opStr} `); + ctx.print(ast, ` ${operator} `); ast.rhs.visitExpression(this, ctx); if (parens) ctx.print(ast, `)`); return null; diff --git a/packages/compiler/src/output/output_ast.ts b/packages/compiler/src/output/output_ast.ts index 5f74e8a39a86..2e5bfd433753 100644 --- a/packages/compiler/src/output/output_ast.ts +++ b/packages/compiler/src/output/output_ast.ts @@ -143,6 +143,15 @@ export enum BinaryOperator { NullishCoalesce, Exponentiation, In, + AdditionAssignment, + SubtractionAssignment, + MultiplicationAssignment, + DivisionAssignment, + RemainderAssignment, + ExponentiationAssignment, + AndAssignment, + OrAssignment, + NullishCoalesceAssignment, } export function nullSafeIsEquivalent( @@ -1164,6 +1173,22 @@ export class BinaryOperatorExpr extends Expression { this.sourceSpan, ); } + + isAssignment(): boolean { + const op = this.operator; + return ( + op === BinaryOperator.Assign || + op === BinaryOperator.AdditionAssignment || + op === BinaryOperator.SubtractionAssignment || + op === BinaryOperator.MultiplicationAssignment || + op === BinaryOperator.DivisionAssignment || + op === BinaryOperator.RemainderAssignment || + op === BinaryOperator.ExponentiationAssignment || + op === BinaryOperator.AndAssignment || + op === BinaryOperator.OrAssignment || + op === BinaryOperator.NullishCoalesceAssignment + ); + } } export class ReadPropExpr extends Expression { diff --git a/packages/compiler/src/template/pipeline/src/conversion.ts b/packages/compiler/src/template/pipeline/src/conversion.ts index 424c0e67fa50..e29a2e1b0c7c 100644 --- a/packages/compiler/src/template/pipeline/src/conversion.ts +++ b/packages/compiler/src/template/pipeline/src/conversion.ts @@ -31,6 +31,15 @@ export const BINARY_OPERATORS = new Map([ ['||', o.BinaryOperator.Or], ['+', o.BinaryOperator.Plus], ['in', o.BinaryOperator.In], + ['+=', o.BinaryOperator.AdditionAssignment], + ['-=', o.BinaryOperator.SubtractionAssignment], + ['*=', o.BinaryOperator.MultiplicationAssignment], + ['/=', o.BinaryOperator.DivisionAssignment], + ['%=', o.BinaryOperator.RemainderAssignment], + ['**=', o.BinaryOperator.ExponentiationAssignment], + ['&&=', o.BinaryOperator.AndAssignment], + ['||=', o.BinaryOperator.OrAssignment], + ['??=', o.BinaryOperator.NullishCoalesceAssignment], ]); export function namespaceForKey(namespacePrefixKey: string | null): ir.Namespace { diff --git a/packages/compiler/test/expression_parser/lexer_spec.ts b/packages/compiler/test/expression_parser/lexer_spec.ts index 3a0742f8af76..776116956cf5 100644 --- a/packages/compiler/test/expression_parser/lexer_spec.ts +++ b/packages/compiler/test/expression_parser/lexer_spec.ts @@ -405,6 +405,19 @@ describe('lexer', () => { ); }); + it('should tokenize assignment operators', () => { + expectOperatorToken(lex('=')[0], 0, 1, '='); + expectOperatorToken(lex('+=')[0], 0, 2, '+='); + expectOperatorToken(lex('-=')[0], 0, 2, '-='); + expectOperatorToken(lex('*=')[0], 0, 2, '*='); + expectOperatorToken(lex('/=')[0], 0, 2, '/='); + expectOperatorToken(lex('%=')[0], 0, 2, '%='); + expectOperatorToken(lex('**=')[0], 0, 3, '**='); + expectOperatorToken(lex('&&=')[0], 0, 3, '&&='); + expectOperatorToken(lex('||=')[0], 0, 3, '||='); + expectOperatorToken(lex('??=')[0], 0, 3, '??='); + }); + describe('template literals', () => { it('should tokenize template literal with no interpolations', () => { const tokens: Token[] = lex('`hello world`'); diff --git a/packages/compiler/test/expression_parser/parser_spec.ts b/packages/compiler/test/expression_parser/parser_spec.ts index ca6f135ea44a..29f0aa130777 100644 --- a/packages/compiler/test/expression_parser/parser_spec.ts +++ b/packages/compiler/test/expression_parser/parser_spec.ts @@ -134,6 +134,32 @@ describe('parser', () => { checkAction(''); }); + it('should parse assignment operators with property reads', () => { + checkAction('a = b'); + checkAction('a += b'); + checkAction('a -= b'); + checkAction('a *= b'); + checkAction('a /= b'); + checkAction('a %= b'); + checkAction('a **= b'); + checkAction('a &&= b'); + checkAction('a ||= b'); + checkAction('a ??= b'); + }); + + it('should parse assignment operators with keyed reads', () => { + checkAction('a[0] = b'); + checkAction('a[0] += b'); + checkAction('a[0] -= b'); + checkAction('a[0] *= b'); + checkAction('a[0] /= b'); + checkAction('a[0] %= b'); + checkAction('a[0] **= b'); + checkAction('a[0] &&= b'); + checkAction('a[0] ||= b'); + checkAction('a[0] ??= b'); + }); + describe('literals', () => { it('should parse array', () => { checkAction('[1][0]'); diff --git a/packages/core/test/acceptance/integration_spec.ts b/packages/core/test/acceptance/integration_spec.ts index a52eb9294aa6..c2370a31e06d 100644 --- a/packages/core/test/acceptance/integration_spec.ts +++ b/packages/core/test/acceptance/integration_spec.ts @@ -2757,11 +2757,209 @@ describe('acceptance integration tests', () => { expect(fixture.nativeElement.textContent).toEqual('256'); }); + it('should support addition assignment operator in templates', () => { + @Component({template: ''}) + class TestComponent { + a = 2; + b = 3; + c = 4; + } + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.nativeElement.querySelector('button'); + fixture.detectChanges(); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(9); + expect(fixture.componentInstance.b).toBe(7); + expect(fixture.componentInstance.c).toBe(4); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(20); + expect(fixture.componentInstance.b).toBe(11); + expect(fixture.componentInstance.c).toBe(4); + }); + + it('should support subtraction assignment operator in templates', () => { + @Component({template: ''}) + class TestComponent { + a = 2; + b = 3; + c = 4; + } + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.nativeElement.querySelector('button'); + fixture.detectChanges(); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(3); + expect(fixture.componentInstance.b).toBe(-1); + expect(fixture.componentInstance.c).toBe(4); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(8); + expect(fixture.componentInstance.b).toBe(-5); + expect(fixture.componentInstance.c).toBe(4); + }); + + it('should support multiplication assignment operator in templates', () => { + @Component({template: ''}) + class TestComponent { + a = 2; + b = 3; + c = 4; + } + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.nativeElement.querySelector('button'); + fixture.detectChanges(); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(24); + expect(fixture.componentInstance.b).toBe(12); + expect(fixture.componentInstance.c).toBe(4); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(1152); + expect(fixture.componentInstance.b).toBe(48); + expect(fixture.componentInstance.c).toBe(4); + }); + + it('should support division assignment operator in templates', () => { + @Component({template: ''}) + class TestComponent { + a = 4; + b = 8; + c = 16; + } + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.nativeElement.querySelector('button'); + fixture.detectChanges(); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(8); + expect(fixture.componentInstance.b).toBe(0.5); + expect(fixture.componentInstance.c).toBe(16); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(256); + expect(fixture.componentInstance.b).toBe(0.03125); + expect(fixture.componentInstance.c).toBe(16); + }); + + it('should support remainder assignment operator in templates', () => { + @Component({template: ''}) + class TestComponent { + a = 4; + b = 3; + c = 2; + } + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.nativeElement.querySelector('button'); + fixture.detectChanges(); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(0); + expect(fixture.componentInstance.b).toBe(1); + expect(fixture.componentInstance.c).toBe(2); + }); + + it('should support exponentiation assignment operator in templates', () => { + @Component({template: ''}) + class TestComponent { + a = 0.5; + b = 2; + c = 3; + } + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.nativeElement.querySelector('button'); + fixture.detectChanges(); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(0.00390625); + expect(fixture.componentInstance.b).toBe(8); + expect(fixture.componentInstance.c).toBe(3); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(0); + expect(fixture.componentInstance.b).toBe(512); + expect(fixture.componentInstance.c).toBe(3); + }); + + it('should support logical and assignment operator in templates', () => { + @Component({template: ''}) + class TestComponent { + a = 0; + b = 2; + } + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.nativeElement.querySelector('button'); + fixture.detectChanges(); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(0); + + fixture.componentInstance.a = 1; + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(2); + }); + + it('should support logical or assignment operator in templates', () => { + @Component({template: ''}) + class TestComponent { + a = 0; + b = 2; + } + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.nativeElement.querySelector('button'); + fixture.detectChanges(); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(2); + + fixture.componentInstance.a = 1; + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(1); + }); + + it('should support nullish coalescing assignment operator in templates', () => { + @Component({template: ''}) + class TestComponent { + a: number | null = 0; + b = 1; + } + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.nativeElement.querySelector('button'); + fixture.detectChanges(); + + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a).toBe(0); + + fixture.componentInstance.a = null; + button.click(); + fixture.detectChanges(); + expect(fixture.componentInstance.a!).toBe(1); + }); + it('should support tagged template literals with no interpolations in expressions', () => { @Component({ standalone: true, template: ` -

:{{ caps\`Hello, World!\` }}:{{ excited?.caps(3)\`Uncomfortably excited\` }}:

+

:{{ caps\`Hello, World!\` }}:{{ excited?.caps(3)\`Uncomfortably excited\` }}:

{{ greet\`Hi, I'm \${name}, and I'm \${age}\` }}

`, })