refactor(compiler): add expression serializer (#58176)

This serializes the expression AST back into a string. This is useful to normalize whitespace in expressions so i18n messages are not affected by insignificant changes (such as going from `{{ foo }}` to `{{\n  foo\n}}`).

PR Close #58176
This commit is contained in:
Doug Parker 2024-10-11 17:54:38 -07:00 committed by Paul Gschwendtner
parent 7f0b49d8c4
commit 474163cd1d
2 changed files with 288 additions and 0 deletions

View file

@ -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, Right>(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, Right>(left: Left[], right: Right[]): Array<Left | Right> {
const result: Array<Left | Right> = [];
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;
}

View file

@ -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)');
});
});
});