Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
refactor(compiler): add expression serializer
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}}`).
  • Loading branch information
dgp1130 committed Oct 12, 2024
commit 1cd56497114347e0c3742e15d88a8e4b2467935a
165 changes: 165 additions & 0 deletions packages/compiler/src/expression_parser/serializer.ts
Original file line number Diff line number Diff line change
@@ -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;
}
123 changes: 123 additions & 0 deletions packages/compiler/test/expression_parser/serializer_spec.ts
Original file line number Diff line number Diff line change
@@ -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)');
});
});
});