diff --git a/packages/compiler/src/expression_parser/serializer.ts b/packages/compiler/src/expression_parser/serializer.ts new file mode 100644 index 00000000000..5b92fa8093a --- /dev/null +++ b/packages/compiler/src/expression_parser/serializer.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import * as expr from './ast'; + +/** Serializes the given AST into a normalized string format. */ +export function serialize(expression: expr.ASTWithSource): string { + return expression.visit(new SerializeExpressionVisitor()); +} + +class SerializeExpressionVisitor implements expr.AstVisitor { + visitUnary(ast: expr.Unary, context: any): string { + return `${ast.operator}${ast.expr.visit(this, context)}`; + } + + visitBinary(ast: expr.Binary, context: any): string { + return `${ast.left.visit(this, context)} ${ast.operation} ${ast.right.visit(this, context)}`; + } + + visitChain(ast: expr.Chain, context: any): string { + return ast.expressions.map((e) => e.visit(this, context)).join('; '); + } + + visitConditional(ast: expr.Conditional, context: any): string { + return `${ast.condition.visit(this, context)} ? ${ast.trueExp.visit( + this, + context, + )} : ${ast.falseExp.visit(this, context)}`; + } + + visitThisReceiver(): string { + return 'this'; + } + + visitImplicitReceiver(): string { + return ''; + } + + visitInterpolation(ast: expr.Interpolation, context: any): string { + return interleave( + ast.strings, + ast.expressions.map((e) => e.visit(this, context)), + ).join(''); + } + + visitKeyedRead(ast: expr.KeyedRead, context: any): string { + return `${ast.receiver.visit(this, context)}[${ast.key.visit(this, context)}]`; + } + + visitKeyedWrite(ast: expr.KeyedWrite, context: any): string { + return `${ast.receiver.visit(this, context)}[${ast.key.visit( + this, + context, + )}] = ${ast.value.visit(this, context)}`; + } + + visitLiteralArray(ast: expr.LiteralArray, context: any): string { + return `[${ast.expressions.map((e) => e.visit(this, context)).join(', ')}]`; + } + + visitLiteralMap(ast: expr.LiteralMap, context: any): string { + return `{${zip( + ast.keys.map((literal) => (literal.quoted ? `'${literal.key}'` : literal.key)), + ast.values.map((value) => value.visit(this, context)), + ) + .map(([key, value]) => `${key}: ${value}`) + .join(', ')}}`; + } + + visitLiteralPrimitive(ast: expr.LiteralPrimitive): string { + if (ast.value === null) return 'null'; + + switch (typeof ast.value) { + case 'number': + case 'boolean': + return ast.value.toString(); + case 'undefined': + return 'undefined'; + case 'string': + return `'${ast.value.replace(/'/g, `\\'`)}'`; + default: + throw new Error(`Unsupported primitive type: ${ast.value}`); + } + } + + visitPipe(ast: expr.BindingPipe, context: any): string { + return `${ast.exp.visit(this, context)} | ${ast.name}`; + } + + visitPrefixNot(ast: expr.PrefixNot, context: any): string { + return `!${ast.expression.visit(this, context)}`; + } + + visitNonNullAssert(ast: expr.NonNullAssert, context: any): string { + return `${ast.expression.visit(this, context)}!`; + } + + visitPropertyRead(ast: expr.PropertyRead, context: any): string { + if (ast.receiver instanceof expr.ImplicitReceiver) { + return ast.name; + } else { + return `${ast.receiver.visit(this, context)}.${ast.name}`; + } + } + + visitPropertyWrite(ast: expr.PropertyWrite, context: any): string { + if (ast.receiver instanceof expr.ImplicitReceiver) { + return `${ast.name} = ${ast.value.visit(this, context)}`; + } else { + return `${ast.receiver.visit(this, context)}.${ast.name} = ${ast.value.visit(this, context)}`; + } + } + + visitSafePropertyRead(ast: expr.SafePropertyRead, context: any): string { + return `${ast.receiver.visit(this, context)}?.${ast.name}`; + } + + visitSafeKeyedRead(ast: expr.SafeKeyedRead, context: any): string { + return `${ast.receiver.visit(this, context)}?.[${ast.key.visit(this, context)}]`; + } + + visitCall(ast: expr.Call, context: any): string { + return `${ast.receiver.visit(this, context)}(${ast.args + .map((e) => e.visit(this, context)) + .join(', ')})`; + } + + visitSafeCall(ast: expr.SafeCall, context: any): string { + return `${ast.receiver.visit(this, context)}?.(${ast.args + .map((e) => e.visit(this, context)) + .join(', ')})`; + } + + visitASTWithSource(ast: expr.ASTWithSource, context: any): string { + return ast.ast.visit(this, context); + } +} + +/** Zips the two input arrays into a single array of pairs of elements at the same index. */ +function zip(left: Left[], right: Right[]): Array<[Left, Right]> { + if (left.length !== right.length) throw new Error('Array lengths must match'); + + return left.map((l, i) => [l, right[i]]); +} + +/** + * Interleaves the two arrays, starting with the first item on the left, then the first item + * on the right, second item from the left, and so on. When the first array's items are exhausted, + * the remaining items from the other array are included with no interleaving. + */ +function interleave(left: Left[], right: Right[]): Array { + const result: Array = []; + + for (let index = 0; index < Math.max(left.length, right.length); index++) { + if (index < left.length) result.push(left[index]); + if (index < right.length) result.push(right[index]); + } + + return result; +} diff --git a/packages/compiler/test/expression_parser/serializer_spec.ts b/packages/compiler/test/expression_parser/serializer_spec.ts new file mode 100644 index 00000000000..b66446f7fa8 --- /dev/null +++ b/packages/compiler/test/expression_parser/serializer_spec.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import * as expr from '../../src/expression_parser/ast'; +import {Lexer} from '../../src/expression_parser/lexer'; +import {Parser} from '../../src/expression_parser/parser'; +import {serialize} from '../../src/expression_parser/serializer'; + +const parser = new Parser(new Lexer()); + +function parse(expression: string): expr.ASTWithSource { + return parser.parseBinding(expression, /* location */ '', /* absoluteOffset */ 0); +} + +function parseAction(expression: string): expr.ASTWithSource { + return parser.parseAction(expression, /* location */ '', /* absoluteOffset */ 0); +} + +describe('serializer', () => { + describe('serialize', () => { + it('serializes unary plus', () => { + expect(serialize(parse(' + 1234 '))).toBe('+1234'); + }); + + it('serializes unary negative', () => { + expect(serialize(parse(' - 1234 '))).toBe('-1234'); + }); + + it('serializes binary operations', () => { + expect(serialize(parse(' 1234 + 4321 '))).toBe('1234 + 4321'); + }); + + it('serializes chains', () => { + expect(serialize(parseAction(' 1234; 4321 '))).toBe('1234; 4321'); + }); + + it('serializes conditionals', () => { + expect(serialize(parse(' cond ? 1234 : 4321 '))).toBe('cond ? 1234 : 4321'); + }); + + it('serializes `this`', () => { + expect(serialize(parse(' this '))).toBe('this'); + }); + + it('serializes keyed reads', () => { + expect(serialize(parse(' foo [bar] '))).toBe('foo[bar]'); + }); + + it('serializes keyed write', () => { + expect(serialize(parse(' foo [bar] = baz '))).toBe('foo[bar] = baz'); + }); + + it('serializes array literals', () => { + expect(serialize(parse(' [ foo, bar, baz ] '))).toBe('[foo, bar, baz]'); + }); + + it('serializes object literals', () => { + expect(serialize(parse(' { foo: bar, baz: test } '))).toBe('{foo: bar, baz: test}'); + }); + + it('serializes primitives', () => { + expect(serialize(parse(` 'test' `))).toBe(`'test'`); + expect(serialize(parse(' "test" '))).toBe(`'test'`); + expect(serialize(parse(' true '))).toBe('true'); + expect(serialize(parse(' false '))).toBe('false'); + expect(serialize(parse(' 1234 '))).toBe('1234'); + expect(serialize(parse(' null '))).toBe('null'); + expect(serialize(parse(' undefined '))).toBe('undefined'); + }); + + it('escapes string literals', () => { + expect(serialize(parse(` 'Hello, \\'World\\'...' `))).toBe(`'Hello, \\'World\\'...'`); + expect(serialize(parse(` 'Hello, \\"World\\"...' `))).toBe(`'Hello, "World"...'`); + }); + + it('serializes pipes', () => { + expect(serialize(parse(' foo | pipe '))).toBe('foo | pipe'); + }); + + it('serializes not prefixes', () => { + expect(serialize(parse(' ! foo '))).toBe('!foo'); + }); + + it('serializes non-null assertions', () => { + expect(serialize(parse(' foo ! '))).toBe('foo!'); + }); + + it('serializes property reads', () => { + expect(serialize(parse(' foo . bar '))).toBe('foo.bar'); + }); + + it('serializes property writes', () => { + expect(serialize(parseAction(' foo . bar = baz '))).toBe('foo.bar = baz'); + }); + + it('serializes safe property reads', () => { + expect(serialize(parse(' foo ?. bar '))).toBe('foo?.bar'); + }); + + it('serializes safe keyed reads', () => { + expect(serialize(parse(' foo ?. [ bar ] '))).toBe('foo?.[bar]'); + }); + + it('serializes calls', () => { + expect(serialize(parse(' foo ( ) '))).toBe('foo()'); + expect(serialize(parse(' foo ( bar ) '))).toBe('foo(bar)'); + expect(serialize(parse(' foo ( bar , ) '))).toBe('foo(bar, )'); + expect(serialize(parse(' foo ( bar , baz ) '))).toBe('foo(bar, baz)'); + }); + + it('serializes safe calls', () => { + expect(serialize(parse(' foo ?. ( ) '))).toBe('foo?.()'); + expect(serialize(parse(' foo ?. ( bar ) '))).toBe('foo?.(bar)'); + expect(serialize(parse(' foo ?. ( bar , ) '))).toBe('foo?.(bar, )'); + expect(serialize(parse(' foo ?. ( bar , baz ) '))).toBe('foo?.(bar, baz)'); + }); + }); +});