diff --git a/adev/src/content/guide/templates/expression-syntax.md b/adev/src/content/guide/templates/expression-syntax.md index 129f08e920d..5bd2b91f17e 100644 --- a/adev/src/content/guide/templates/expression-syntax.md +++ b/adev/src/content/guide/templates/expression-syntax.md @@ -53,6 +53,7 @@ Angular supports the following operators from standard JavaScript. | 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) | `&&` | 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 bd41660677e..c4bb1227fa6 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 @@ -82,6 +82,13 @@ describe('BabelAstFactory', () => { expect(t.isLogicalExpression(expr)).toBe(true); expect(generate(expr).code).toEqual('17 && 42'); }); + + it('should create a binary operation node for exponentiation', () => { + const left = expression.ast`2`; + const right = expression.ast`3`; + const expr = factory.createBinaryExpression(left, '**', right); + expect(generate(expr).code).toEqual('2 ** 3'); + }); }); describe('createBlock()', () => { 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 ad136fce4f8..5c9bcea216e 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 @@ -311,6 +311,7 @@ export type BinaryOperator = | '-' | '%' | '*' + | '**' | '!=' | '!==' | '||' diff --git a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts index 75d7ebe7d5e..8c4bf2c271c 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts @@ -43,6 +43,7 @@ const BINARY_OPERATORS = new Map([ [o.BinaryOperator.Or, '||'], [o.BinaryOperator.Plus, '+'], [o.BinaryOperator.NullishCoalesce, '??'], + [o.BinaryOperator.Exponentiation, '**'], ]); export type RecordWrappedNodeFn = (node: o.WrappedNodeExpr) => void; diff --git a/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts b/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts index 903ff6f7c5a..46b39f7c5e2 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/typescript_ast_factory.ts @@ -54,6 +54,7 @@ const BINARY_OPERATORS: Record = { '-': ts.SyntaxKind.MinusToken, '%': ts.SyntaxKind.PercentToken, '*': ts.SyntaxKind.AsteriskToken, + '**': ts.SyntaxKind.AsteriskAsteriskToken, '!=': ts.SyntaxKind.ExclamationEqualsToken, '!==': ts.SyntaxKind.ExclamationEqualsEqualsToken, '||': ts.SyntaxKind.BarBarToken, diff --git a/packages/compiler-cli/src/ngtsc/translator/test/typescript_ast_factory_spec.ts b/packages/compiler-cli/src/ngtsc/translator/test/typescript_ast_factory_spec.ts index 9228fdbca74..ef2cbe6fec3 100644 --- a/packages/compiler-cli/src/ngtsc/translator/test/typescript_ast_factory_spec.ts +++ b/packages/compiler-cli/src/ngtsc/translator/test/typescript_ast_factory_spec.ts @@ -73,6 +73,15 @@ describe('TypeScriptAstFactory', () => { const assignment = factory.createBinaryExpression(left, '+', right); expect(generate(assignment)).toEqual('17 + 42'); }); + + it('should create a binary operation node for exponentiation', () => { + const { + items: [left, right], + generate, + } = setupExpressions(`2`, `3`); + const assignment = factory.createBinaryExpression(left, '**', right); + expect(generate(assignment)).toEqual('2 ** 3'); + }); }); describe('createDynamicImport()', () => { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts index 76557206d81..b7d2361f21f 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts @@ -76,6 +76,7 @@ const BINARY_OPS = new Map([ ['==', ts.SyntaxKind.EqualsEqualsToken], ['===', ts.SyntaxKind.EqualsEqualsEqualsToken], ['*', ts.SyntaxKind.AsteriskToken], + ['**', ts.SyntaxKind.AsteriskAsteriskToken], ['/', ts.SyntaxKind.SlashToken], ['%', ts.SyntaxKind.PercentToken], ['!=', ts.SyntaxKind.ExclamationEqualsToken], 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 f06617f530e..3e5a4d789b6 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,12 @@ describe('type check blocks', () => { expect(tcb('{{!(void a === "object")}}')).toContain('!((void (((this).a))) === ("object"))'); }); + it('should handle exponentiation expressions', () => { + expect(tcb('{{a * b ** c + d}}')).toContain( + '(((((this).a)) * ((((this).b)) ** (((this).c)))) + (((this).d)))', + ); + }); + it('should handle attribute values for directive inputs', () => { const TEMPLATE = `
`; const DIRECTIVES: TestDeclaration[] = [ 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 1ef165ce89a..e8143d38840 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 @@ -91,7 +91,7 @@ export class MyApp { 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: ` {{ 1 + 2 }} - {{ (1 % 2) + 3 / 4 * 5 }} + {{ (1 % 2) + 3 / 4 * 5 ** 6 }} {{ +1 }} {{ typeof {} === 'object' }} {{ !(typeof {} === 'object') }} @@ -104,7 +104,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE args: [{ template: ` {{ 1 + 2 }} - {{ (1 % 2) + 3 / 4 * 5 }} + {{ (1 % 2) + 3 / 4 * 5 ** 6 }} {{ +1 }} {{ typeof {} === 'object' }} {{ !(typeof {} === 'object') }} 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 aed83968a2b..b7a640d7520 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 @@ -3,9 +3,7 @@ "cases": [ { "description": "should be able to generate the TODO example", - "inputFiles": [ - "todo_example.ts" - ], + "inputFiles": ["todo_example.ts"], "expectations": [ { "files": [ @@ -20,9 +18,10 @@ }, { "description": "should handle binary and unary operators", - "inputFiles": [ - "operators.ts" - ], + "compilerOptions": { + "target": "es2020" + }, + "inputFiles": ["operators.ts"], "expectations": [ { "files": [ 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 9c269984199..6d7ae7ce251 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 @@ -10,7 +10,7 @@ export class IdentityPipe { @Component({ template: ` {{ 1 + 2 }} - {{ (1 % 2) + 3 / 4 * 5 }} + {{ (1 % 2) + 3 / 4 * 5 ** 6 }} {{ +1 }} {{ typeof {} === 'object' }} {{ !(typeof {} === 'object') }} 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 d623674094a..dfc2c044bcf 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 @@ -5,7 +5,7 @@ template: function MyApp_Template(rf, $ctx$) { } if (rf & 2) { i0.ɵɵtextInterpolate8(" ", 1 + 2, " ", - 1 % 2 + 3 / 4 * 5, " ", + 1 % 2 + 3 / 4 * 5 ** 6, " ", +1, " ", typeof i0.ɵɵpureFunction0(10, _c0) === "object", " ", !(typeof i0.ɵɵpureFunction0(11, _c0) === "object"), " ", diff --git a/packages/compiler/src/expression_parser/lexer.ts b/packages/compiler/src/expression_parser/lexer.ts index 269d475adec..a0178c55462 100644 --- a/packages/compiler/src/expression_parser/lexer.ts +++ b/packages/compiler/src/expression_parser/lexer.ts @@ -291,11 +291,12 @@ class _Scanner { return this.scanPrivateIdentifier(); case chars.$PLUS: case chars.$MINUS: - case chars.$STAR: case chars.$SLASH: case chars.$PERCENT: case chars.$CARET: return this.scanOperator(start, String.fromCharCode(peek)); + case chars.$STAR: + return this.scanComplexOperator(start, '*', chars.$STAR, '*'); case chars.$QUESTION: return this.scanQuestion(start); case chars.$LT: diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index 64afc256f1f..cc9d653e4af 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -515,6 +515,7 @@ enum ParseContextFlags { } class _ParseAST { + private lastUnary: Unary | PrefixNot | TypeofExpression | VoidExpression | null = null; private rparensExpected = 0; private rbracketsExpected = 0; private rbracesExpected = 0; @@ -928,7 +929,7 @@ class _ParseAST { private parseMultiplicative(): AST { // '*', '%', '/' const start = this.inputIndex; - let result = this.parsePrefix(); + let result = this.parseExponentiation(); while (this.next.type == TokenType.Operator) { const operator = this.next.strValue; switch (operator) { @@ -936,7 +937,7 @@ class _ParseAST { case '%': case '/': this.advance(); - let right = this.parsePrefix(); + const right = this.parseExponentiation(); result = new Binary(this.span(start), this.sourceSpan(start), operator, result, right); continue; } @@ -945,6 +946,26 @@ class _ParseAST { return result; } + private parseExponentiation(): AST { + // '**' + const start = this.inputIndex; + let result = this.parsePrefix(); + while (this.next.type == TokenType.Operator && this.next.strValue === '**') { + // This aligns with Javascript semantics which require any unary operator preceeding the + // exponentiation operation to be explicitly grouped as either applying to the base or result + // of the exponentiation operation. + if (result === this.lastUnary) { + this.error( + 'Unary operator used immediately before exponentiation expression. Parenthesis must be used to disambiguate operator precedence', + ); + } + this.advance(); + const right = this.parsePrefix(); + result = new Binary(this.span(start), this.sourceSpan(start), '**', result, right); + } + return result; + } + private parsePrefix(): AST { if (this.next.type == TokenType.Operator) { const start = this.inputIndex; @@ -954,26 +975,42 @@ class _ParseAST { case '+': this.advance(); result = this.parsePrefix(); - return Unary.createPlus(this.span(start), this.sourceSpan(start), result); + return (this.lastUnary = Unary.createPlus( + this.span(start), + this.sourceSpan(start), + result, + )); case '-': this.advance(); result = this.parsePrefix(); - return Unary.createMinus(this.span(start), this.sourceSpan(start), result); + return (this.lastUnary = Unary.createMinus( + this.span(start), + this.sourceSpan(start), + result, + )); case '!': this.advance(); result = this.parsePrefix(); - return new PrefixNot(this.span(start), this.sourceSpan(start), result); + return (this.lastUnary = new PrefixNot(this.span(start), this.sourceSpan(start), result)); } } else if (this.next.isKeywordTypeof()) { this.advance(); const start = this.inputIndex; let result = this.parsePrefix(); - return new TypeofExpression(this.span(start), this.sourceSpan(start), result); + return (this.lastUnary = new TypeofExpression( + this.span(start), + this.sourceSpan(start), + result, + )); } else if (this.next.isKeywordVoid()) { this.advance(); const start = this.inputIndex; let result = this.parsePrefix(); - return new VoidExpression(this.span(start), this.sourceSpan(start), result); + return (this.lastUnary = new VoidExpression( + this.span(start), + this.sourceSpan(start), + result, + )); } return this.parseCallChain(); } @@ -1010,6 +1047,7 @@ class _ParseAST { this.rparensExpected++; const result = this.parsePipe(); this.rparensExpected--; + this.lastUnary = null; this.expectCharacter(chars.$RPAREN); return result; } else if (this.next.isKeywordNull()) { diff --git a/packages/compiler/src/output/abstract_emitter.ts b/packages/compiler/src/output/abstract_emitter.ts index b81dd7e0364..d8aff150953 100644 --- a/packages/compiler/src/output/abstract_emitter.ts +++ b/packages/compiler/src/output/abstract_emitter.ts @@ -456,6 +456,9 @@ export abstract class AbstractEmitterVisitor implements o.StatementVisitor, o.Ex case o.BinaryOperator.Modulo: opStr = '%'; break; + case o.BinaryOperator.Exponentiation: + opStr = '**'; + break; case o.BinaryOperator.Lower: opStr = '<'; break; diff --git a/packages/compiler/src/output/output_ast.ts b/packages/compiler/src/output/output_ast.ts index 84a126dce4e..f303f645dcb 100644 --- a/packages/compiler/src/output/output_ast.ts +++ b/packages/compiler/src/output/output_ast.ts @@ -140,6 +140,7 @@ export enum BinaryOperator { Bigger, BiggerEquals, NullishCoalesce, + Exponentiation, } export function nullSafeIsEquivalent( @@ -261,6 +262,9 @@ export abstract class Expression { modulo(rhs: Expression, sourceSpan?: ParseSourceSpan | null): BinaryOperatorExpr { return new BinaryOperatorExpr(BinaryOperator.Modulo, this, rhs, null, sourceSpan); } + power(rhs: Expression, sourceSpan?: ParseSourceSpan | null): BinaryOperatorExpr { + return new BinaryOperatorExpr(BinaryOperator.Exponentiation, this, rhs, null, sourceSpan); + } and(rhs: Expression, sourceSpan?: ParseSourceSpan | null): BinaryOperatorExpr { return new BinaryOperatorExpr(BinaryOperator.And, this, rhs, null, sourceSpan); } diff --git a/packages/compiler/src/template/pipeline/src/conversion.ts b/packages/compiler/src/template/pipeline/src/conversion.ts index b34fcd7d38a..d94825c4db9 100644 --- a/packages/compiler/src/template/pipeline/src/conversion.ts +++ b/packages/compiler/src/template/pipeline/src/conversion.ts @@ -22,6 +22,7 @@ export const BINARY_OPERATORS = new Map([ ['<=', o.BinaryOperator.LowerEquals], ['-', o.BinaryOperator.Minus], ['%', o.BinaryOperator.Modulo], + ['**', o.BinaryOperator.Exponentiation], ['*', o.BinaryOperator.Multiply], ['!=', o.BinaryOperator.NotEquals], ['!==', o.BinaryOperator.NotIdentical], diff --git a/packages/compiler/test/expression_parser/lexer_spec.ts b/packages/compiler/test/expression_parser/lexer_spec.ts index f1126afe133..c6fdd1eaf09 100644 --- a/packages/compiler/test/expression_parser/lexer_spec.ts +++ b/packages/compiler/test/expression_parser/lexer_spec.ts @@ -303,6 +303,15 @@ describe('lexer', () => { expectNumberToken(lex('0.5')[0], 0, 3, 0.5); }); + it('should tokenize multiplication and exponentiation', () => { + const tokens: Token[] = lex('1 * 2 ** 3'); + expectNumberToken(tokens[0], 0, 1, 1); + expectOperatorToken(tokens[1], 2, 3, '*'); + expectNumberToken(tokens[2], 4, 5, 2); + expectOperatorToken(tokens[3], 6, 8, '**'); + expectNumberToken(tokens[4], 9, 10, 3); + }); + it('should tokenize number with exponent', () => { let tokens: Token[] = lex('0.5E-10'); expect(tokens.length).toEqual(1); diff --git a/packages/compiler/test/expression_parser/parser_spec.ts b/packages/compiler/test/expression_parser/parser_spec.ts index c042d77f667..7118b3d75ca 100644 --- a/packages/compiler/test/expression_parser/parser_spec.ts +++ b/packages/compiler/test/expression_parser/parser_spec.ts @@ -66,6 +66,10 @@ describe('parser', () => { checkAction('a.b!()'); }); + it('should parse exponentiation expressions', () => { + checkAction('1*2**3', '1 * 2 ** 3'); + }); + it('should parse multiplicative expressions', () => { checkAction('3*4/2%5', '3 * 4 / 2 % 5'); }); diff --git a/packages/compiler/test/expression_parser/serializer_spec.ts b/packages/compiler/test/expression_parser/serializer_spec.ts index 75ee67557e8..bf254c8c6ad 100644 --- a/packages/compiler/test/expression_parser/serializer_spec.ts +++ b/packages/compiler/test/expression_parser/serializer_spec.ts @@ -35,6 +35,10 @@ describe('serializer', () => { expect(serialize(parse(' 1234 + 4321 '))).toBe('1234 + 4321'); }); + it('serializes exponentiation', () => { + expect(serialize(parse(' 1 * 2 ** 3 '))).toBe('1 * 2 ** 3'); + }); + it('serializes chains', () => { expect(serialize(parseAction(' 1234; 4321 '))).toBe('1234; 4321'); }); diff --git a/packages/core/test/acceptance/integration_spec.ts b/packages/core/test/acceptance/integration_spec.ts index 4ba201dca84..12172b5c36a 100644 --- a/packages/core/test/acceptance/integration_spec.ts +++ b/packages/core/test/acceptance/integration_spec.ts @@ -2757,6 +2757,36 @@ describe('acceptance integration tests', () => { expect(fixture.componentInstance.e!.defaultPrevented).toBe(false); }); + it('should have correct operator precedence', () => { + @Component({ + template: '{{1 + 10 ** -2 * 3}}', + }) + class TestComponent {} + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('1.03'); + }); + + it('should throw on ambiguous unary operator in exponentiation expression', () => { + @Component({ + template: '{{1 + -10 ** -2 * 3}}', + }) + class TestComponent {} + expect(() => TestBed.createComponent(TestComponent)).toThrowError( + /Unary operator used immediately before exponentiation expression. Parenthesis must be used to disambiguate operator precedence/, + ); + }); + + it('should not throw on unambiguous unary operator in exponentiation expression', () => { + @Component({ + template: '{{1 + (-10) ** -2 * 3}} | {{1 + -(10 ** -2) * 3}}', + }) + class TestComponent {} + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toEqual('1.03 | 0.97'); + }); + describe('tView.firstUpdatePass', () => { function isFirstUpdatePass() { const lView = getLView();