mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
7f0b49d8c4
commit
474163cd1d
2 changed files with 288 additions and 0 deletions
165
packages/compiler/src/expression_parser/serializer.ts
Normal file
165
packages/compiler/src/expression_parser/serializer.ts
Normal 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;
|
||||
}
|
||||
123
packages/compiler/test/expression_parser/serializer_spec.ts
Normal file
123
packages/compiler/test/expression_parser/serializer_spec.ts
Normal 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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue