refactor(localize): add placeholder locations in extracted messages (#38536)

Some translation file formats would like to be able to render the
text of placeholders taken from the original source files. This commit
adds this information to the extracted messages so that it can be
used in translation file serializers.

PR Close #38536
This commit is contained in:
Pete Bacon Darwin 2020-08-19 15:31:55 +01:00 committed by Misko Hevery
parent b8351f3b10
commit db3a21b382
7 changed files with 471 additions and 199 deletions

View File

@ -9,7 +9,7 @@ import {ɵParsedMessage, ɵparseMessage} from '@angular/localize';
import {NodePath, PluginObj} from '@babel/core';
import {TaggedTemplateExpression} from '@babel/types';
import {getLocation, isGlobalIdentifier, isNamedIdentifier, unwrapMessagePartsFromTemplateLiteral} from '../../source_file_utils';
import {getLocation, isGlobalIdentifier, isNamedIdentifier, unwrapExpressionsFromTemplateLiteral, unwrapMessagePartsFromTemplateLiteral} from '../../source_file_utils';
export function makeEs2015ExtractPlugin(
messages: ɵParsedMessage[], localizeName = '$localize'): PluginObj {
@ -18,9 +18,14 @@ export function makeEs2015ExtractPlugin(
TaggedTemplateExpression(path: NodePath<TaggedTemplateExpression>) {
const tag = path.get('tag');
if (isNamedIdentifier(tag, localizeName) && isGlobalIdentifier(tag)) {
const messageParts = unwrapMessagePartsFromTemplateLiteral(path.node.quasi.quasis);
const location = getLocation(path.get('quasi'));
const message = ɵparseMessage(messageParts, path.node.quasi.expressions, location);
const quasiPath = path.get('quasi');
const [messageParts, messagePartLocations] =
unwrapMessagePartsFromTemplateLiteral(quasiPath.get('quasis'));
const [expressions, expressionLocations] =
unwrapExpressionsFromTemplateLiteral(quasiPath);
const location = getLocation(quasiPath);
const message = ɵparseMessage(
messageParts, expressions, location, messagePartLocations, expressionLocations);
messages.push(message);
}
}

View File

@ -18,10 +18,12 @@ export function makeEs5ExtractPlugin(
CallExpression(callPath: NodePath<CallExpression>) {
const calleePath = callPath.get('callee');
if (isNamedIdentifier(calleePath, localizeName) && isGlobalIdentifier(calleePath)) {
const messageParts = unwrapMessagePartsFromLocalizeCall(callPath);
const expressions = unwrapSubstitutionsFromLocalizeCall(callPath.node);
const location = getLocation(callPath);
const message = ɵparseMessage(messageParts, expressions, location);
const [messageParts, messagePartLocations] = unwrapMessagePartsFromLocalizeCall(callPath);
const [expressions, expressionLocations] = unwrapSubstitutionsFromLocalizeCall(callPath);
const [messagePartsArg, expressionsArg] = callPath.get('arguments');
const location = getLocation(messagePartsArg, expressionsArg);
const message = ɵparseMessage(
messageParts, expressions, location, messagePartLocations, expressionLocations);
messages.push(message);
}
}

View File

@ -67,7 +67,7 @@ export function buildLocalizeReplacement(
* @param call The AST node of the call to process.
*/
export function unwrapMessagePartsFromLocalizeCall(call: NodePath<t.CallExpression>):
TemplateStringsArray {
[TemplateStringsArray, (ɵSourceLocation | undefined)[]] {
let cooked = call.get('arguments')[0];
if (cooked === undefined) {
@ -137,34 +137,44 @@ export function unwrapMessagePartsFromLocalizeCall(call: NodePath<t.CallExpressi
raw = arg2 !== undefined ? arg2 : cooked;
}
const cookedStrings = unwrapStringLiteralArray(cooked.node);
const rawStrings = unwrapStringLiteralArray(raw.node);
return ɵmakeTemplateObject(cookedStrings, rawStrings);
const [cookedStrings] = unwrapStringLiteralArray(cooked);
const [rawStrings, rawLocations] = unwrapStringLiteralArray(raw);
return [ɵmakeTemplateObject(cookedStrings, rawStrings), rawLocations];
}
export function unwrapSubstitutionsFromLocalizeCall(call: t.CallExpression): t.Expression[] {
const expressions = call.arguments.splice(1);
export function unwrapSubstitutionsFromLocalizeCall(call: NodePath<t.CallExpression>):
[t.Expression[], (ɵSourceLocation | undefined)[]] {
const expressions = call.get('arguments').splice(1);
if (!isArrayOfExpressions(expressions)) {
const badExpression = expressions.find(expression => !t.isExpression(expression))!;
const badExpression = expressions.find(expression => !expression.isExpression())!;
throw new BabelParseError(
badExpression,
badExpression.node,
'Invalid substitutions for `$localize` (expected all substitution arguments to be expressions).');
}
return expressions;
return [
expressions.map(path => path.node), expressions.map(expression => getLocation(expression))
];
}
export function unwrapMessagePartsFromTemplateLiteral(elements: t.TemplateElement[]):
TemplateStringsArray {
export function unwrapMessagePartsFromTemplateLiteral(elements: NodePath<t.TemplateElement>[]):
[TemplateStringsArray, (ɵSourceLocation | undefined)[]] {
const cooked = elements.map(q => {
if (q.value.cooked === undefined) {
if (q.node.value.cooked === undefined) {
throw new BabelParseError(
q, `Unexpected undefined message part in "${elements.map(q => q.value.cooked)}"`);
q.node,
`Unexpected undefined message part in "${elements.map(q => q.node.value.cooked)}"`);
}
return q.value.cooked;
return q.node.value.cooked;
});
const raw = elements.map(q => q.value.raw);
return ɵmakeTemplateObject(cooked, raw);
const raw = elements.map(q => q.node.value.raw);
const locations = elements.map(q => getLocation(q));
return [ɵmakeTemplateObject(cooked, raw), locations];
}
export function unwrapExpressionsFromTemplateLiteral(quasi: NodePath<t.TemplateLiteral>):
[t.Expression[], (ɵSourceLocation | undefined)[]] {
return [quasi.node.expressions, quasi.get('expressions').map(e => getLocation(e))];
}
/**
@ -186,12 +196,14 @@ export function wrapInParensIfNecessary(expression: t.Expression): t.Expression
* Extract the string values from an `array` of string literals.
* @param array The array to unwrap.
*/
export function unwrapStringLiteralArray(array: t.Expression): string[] {
if (!isStringLiteralArray(array)) {
export function unwrapStringLiteralArray(array: NodePath<t.Expression>):
[string[], (ɵSourceLocation | undefined)[]] {
if (!isStringLiteralArray(array.node)) {
throw new BabelParseError(
array, 'Unexpected messageParts for `$localize` (expected an array of strings).');
array.node, 'Unexpected messageParts for `$localize` (expected an array of strings).');
}
return array.elements.map((str: t.StringLiteral) => str.value);
const elements = array.get('elements') as NodePath<t.StringLiteral>[];
return [elements.map(str => str.node.value), elements.map(str => getLocation(str))];
}
/**
@ -295,8 +307,8 @@ export function isStringLiteralArray(node: t.Node): node is t.Expression&
* Are all the given `nodes` expressions?
* @param nodes The nodes to test.
*/
export function isArrayOfExpressions(nodes: t.Node[]): nodes is t.Expression[] {
return nodes.every(element => t.isExpression(element));
export function isArrayOfExpressions(paths: NodePath<t.Node>[]): paths is NodePath<t.Expression>[] {
return paths.every(element => element.isExpression());
}
/** Options that affect how the `makeEsXXXTranslatePlugin()` functions work. */
@ -361,7 +373,8 @@ export function getLocation(startPath: NodePath, endPath?: NodePath): ɵSourceLo
return {
start: getLineAndColumn(startLocation.start),
end: getLineAndColumn(endLocation.end),
file
file,
text: getText(startPath),
};
}
@ -375,7 +388,7 @@ export function serializeLocationPosition(location: ɵSourceLocation): string {
function getFileFromPath(path: NodePath|undefined): AbsoluteFsPath|null {
const opts = path?.hub.file.opts;
return opts?.filename ?
resolve(opts.generatorOpts.sourceRoot, relative(opts.cwd, opts.filename)) :
resolve(opts.generatorOpts.sourceRoot ?? opts.cwd, relative(opts.cwd, opts.filename)) :
null;
}
@ -383,3 +396,10 @@ function getLineAndColumn(loc: {line: number, column: number}): {line: number, c
// Note we want 0-based line numbers but Babel returns 1-based.
return {line: loc.line - 1, column: loc.column};
}
function getText(path: NodePath): string|undefined {
if (path.node.start === null || path.node.end === null) {
return undefined;
}
return path.hub.file.code.substring(path.node.start, path.node.end);
}

View File

@ -23,7 +23,8 @@ export function makeEs2015TranslatePlugin(
try {
const tag = path.get('tag');
if (isLocalize(tag, localizeName)) {
const messageParts = unwrapMessagePartsFromTemplateLiteral(path.node.quasi.quasis);
const [messageParts] =
unwrapMessagePartsFromTemplateLiteral(path.get('quasi').get('quasis'));
const translated = translate(
diagnostics, translations, messageParts, path.node.quasi.expressions,
missingTranslation);

View File

@ -23,8 +23,8 @@ export function makeEs5TranslatePlugin(
try {
const calleePath = callPath.get('callee');
if (isLocalize(calleePath, localizeName)) {
const messageParts = unwrapMessagePartsFromLocalizeCall(callPath);
const expressions = unwrapSubstitutionsFromLocalizeCall(callPath.node);
const [messageParts] = unwrapMessagePartsFromLocalizeCall(callPath);
const [expressions] = unwrapSubstitutionsFromLocalizeCall(callPath);
const translated =
translate(diagnostics, translations, messageParts, expressions, missingTranslation);
callPath.replaceWith(buildLocalizeReplacement(translated[0], translated[1]));

View File

@ -36,11 +36,50 @@ runInEachFileSystem(() => {
description: 'description',
meaning: 'meaning',
messageParts: ['a', 'b', 'c'],
messagePartLocations: [
{
start: {line: 0, column: 10},
end: {line: 0, column: 32},
file: absoluteFrom('/root/path/relative/path.js'),
text: ':meaning|description:a',
},
{
start: {line: 0, column: 36},
end: {line: 0, column: 37},
file: absoluteFrom('/root/path/relative/path.js'),
text: 'b',
},
{
start: {line: 0, column: 41},
end: {line: 0, column: 42},
file: absoluteFrom('/root/path/relative/path.js'),
text: 'c',
}
],
text: 'a{$PH}b{$PH_1}c',
placeholderNames: ['PH', 'PH_1'],
substitutions: jasmine.any(Object),
substitutionLocations: {
PH: {
start: {line: 0, column: 34},
end: {line: 0, column: 35},
file: absoluteFrom('/root/path/relative/path.js'),
text: '1'
},
PH_1: {
start: {line: 0, column: 39},
end: {line: 0, column: 40},
file: absoluteFrom('/root/path/relative/path.js'),
text: '2'
}
},
legacyIds: [],
location: {start: {line: 0, column: 9}, end: {line: 0, column: 43}, file},
location: {
start: {line: 0, column: 9},
end: {line: 0, column: 43},
file,
text: '`:meaning|description:a${1}b${2}c`',
},
});
expect(messages[1]).toEqual({
@ -49,11 +88,51 @@ runInEachFileSystem(() => {
description: '',
meaning: '',
messageParts: ['a', 'b', 'c'],
messagePartLocations: [
{
start: {line: 1, column: 69},
end: {line: 1, column: 72},
file: absoluteFrom('/root/path/relative/path.js'),
text: '"a"',
},
{
start: {line: 1, column: 74},
end: {line: 1, column: 97},
file: absoluteFrom('/root/path/relative/path.js'),
text: '":custom-placeholder:b"',
},
{
start: {line: 1, column: 99},
end: {line: 1, column: 102},
file: absoluteFrom('/root/path/relative/path.js'),
text: '"c"',
}
],
text: 'a{$custom-placeholder}b{$PH_1}c',
placeholderNames: ['custom-placeholder', 'PH_1'],
substitutions: jasmine.any(Object),
substitutionLocations: {
'custom-placeholder': {
start: {line: 1, column: 106},
end: {line: 1, column: 107},
file: absoluteFrom('/root/path/relative/path.js'),
text: '1'
},
PH_1: {
start: {line: 1, column: 109},
end: {line: 1, column: 110},
file: absoluteFrom('/root/path/relative/path.js'),
text: '2'
}
},
legacyIds: [],
location: {start: {line: 1, column: 0}, end: {line: 1, column: 111}, file},
location: {
start: {line: 1, column: 10},
end: {line: 1, column: 107},
file,
text:
'__makeTemplateObject(["a", ":custom-placeholder:b", "c"], ["a", ":custom-placeholder:b", "c"])',
},
});
expect(messages[2]).toEqual({
@ -65,8 +144,47 @@ runInEachFileSystem(() => {
text: 'a{$PH}b{$PH_1}c',
placeholderNames: ['PH', 'PH_1'],
substitutions: jasmine.any(Object),
substitutionLocations: {
PH: {
start: {line: 2, column: 26},
end: {line: 2, column: 27},
file: absoluteFrom('/root/path/relative/path.js'),
text: '1'
},
PH_1: {
start: {line: 2, column: 31},
end: {line: 2, column: 32},
file: absoluteFrom('/root/path/relative/path.js'),
text: '2'
}
},
messagePartLocations: [
{
start: {line: 2, column: 10},
end: {line: 2, column: 24},
file: absoluteFrom('/root/path/relative/path.js'),
text: ':@@custom-id:a'
},
{
start: {line: 2, column: 28},
end: {line: 2, column: 29},
file: absoluteFrom('/root/path/relative/path.js'),
text: 'b'
},
{
start: {line: 2, column: 33},
end: {line: 2, column: 34},
file: absoluteFrom('/root/path/relative/path.js'),
text: 'c'
}
],
legacyIds: [],
location: {start: {line: 2, column: 9}, end: {line: 2, column: 35}, file},
location: {
start: {line: 2, column: 9},
end: {line: 2, column: 35},
file,
text: '`:@@custom-id:a${1}b${2}c`'
},
});
});
});

View File

@ -12,192 +12,303 @@ import {NodePath, TransformOptions, transformSync} from '@babel/core';
import generate from '@babel/generator';
import template from '@babel/template';
import {Expression, Identifier, TaggedTemplateExpression, ExpressionStatement, FunctionDeclaration, CallExpression, isParenthesizedExpression, numericLiteral, binaryExpression, NumericLiteral} from '@babel/types';
import {Expression, Identifier, TaggedTemplateExpression, ExpressionStatement, CallExpression, isParenthesizedExpression, numericLiteral, binaryExpression, NumericLiteral} from '@babel/types';
import {isGlobalIdentifier, isNamedIdentifier, isStringLiteralArray, isArrayOfExpressions, unwrapStringLiteralArray, unwrapMessagePartsFromLocalizeCall, wrapInParensIfNecessary, buildLocalizeReplacement, unwrapSubstitutionsFromLocalizeCall, unwrapMessagePartsFromTemplateLiteral, getLocation} from '../src/source_file_utils';
describe('utils', () => {
describe('isNamedIdentifier()', () => {
it('should return true if the expression is an identifier with name `$localize`', () => {
const taggedTemplate = getTaggedTemplate('$localize ``;');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(true);
runInEachFileSystem(() => {
describe('utils', () => {
describe('isNamedIdentifier()', () => {
it('should return true if the expression is an identifier with name `$localize`', () => {
const taggedTemplate = getTaggedTemplate('$localize ``;');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(true);
});
it('should return false if the expression is an identifier without the name `$localize`',
() => {
const taggedTemplate = getTaggedTemplate('other ``;');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(false);
});
it('should return false if the expression is not an identifier', () => {
const taggedTemplate = getTaggedTemplate('$localize() ``;');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(false);
});
});
it('should return false if the expression is an identifier without the name `$localize`',
() => {
const taggedTemplate = getTaggedTemplate('other ``;');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(false);
});
describe('isGlobalIdentifier()', () => {
it('should return true if the identifier is at the top level and not declared', () => {
const taggedTemplate = getTaggedTemplate('$localize ``;');
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(true);
});
it('should return false if the expression is not an identifier', () => {
const taggedTemplate = getTaggedTemplate('$localize() ``;');
expect(isNamedIdentifier(taggedTemplate.get('tag'), '$localize')).toBe(false);
});
});
it('should return true if the identifier is in a block scope and not declared', () => {
const taggedTemplate = getTaggedTemplate('function foo() { $localize ``; } foo();');
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(true);
});
describe('isGlobalIdentifier()', () => {
it('should return true if the identifier is at the top level and not declared', () => {
const taggedTemplate = getTaggedTemplate('$localize ``;');
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(true);
it('should return false if the identifier is declared locally', () => {
const taggedTemplate = getTaggedTemplate('function $localize() {} $localize ``;');
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(false);
});
it('should return false if the identifier is a function parameter', () => {
const taggedTemplate = getTaggedTemplate('function foo($localize) { $localize ``; }');
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(false);
});
});
it('should return true if the identifier is in a block scope and not declared', () => {
const taggedTemplate = getTaggedTemplate('function foo() { $localize ``; } foo();');
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(true);
describe('buildLocalizeReplacement', () => {
it('should interleave the `messageParts` with the `substitutions`', () => {
const messageParts = ɵmakeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']);
const substitutions = [numericLiteral(1), numericLiteral(2)];
const expression = buildLocalizeReplacement(messageParts, substitutions);
expect(generate(expression).code).toEqual('"a" + 1 + "b" + 2 + "c"');
});
it('should wrap "binary expression" substitutions in parentheses', () => {
const messageParts = ɵmakeTemplateObject(['a', 'b'], ['a', 'b']);
const binary = binaryExpression('+', numericLiteral(1), numericLiteral(2));
const expression = buildLocalizeReplacement(messageParts, [binary]);
expect(generate(expression).code).toEqual('"a" + (1 + 2) + "b"');
});
});
it('should return false if the identifier is declared locally', () => {
const taggedTemplate = getTaggedTemplate('function $localize() {} $localize ``;');
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(false);
});
describe('unwrapMessagePartsFromLocalizeCall', () => {
it('should return an array of string literals and locations from a direct call to a tag function',
() => {
const localizeCall = getLocalizeCall(`$localize(['a', 'b\\t', 'c'], 1, 2)`);
const [parts, locations] = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']);
expect(locations).toEqual([
{
start: {line: 0, column: 11},
end: {line: 0, column: 14},
file: absoluteFrom('/test/file.js'),
text: `'a'`,
},
{
start: {line: 0, column: 16},
end: {line: 0, column: 21},
file: absoluteFrom('/test/file.js'),
text: `'b\\t'`,
},
{
start: {line: 0, column: 23},
end: {line: 0, column: 26},
file: absoluteFrom('/test/file.js'),
text: `'c'`,
},
]);
});
it('should return false if the identifier is a function parameter', () => {
const taggedTemplate = getTaggedTemplate('function foo($localize) { $localize ``; }');
expect(isGlobalIdentifier(taggedTemplate.get('tag') as NodePath<Identifier>)).toBe(false);
});
});
it('should return an array of string literals and locations from a downleveled tagged template',
() => {
let localizeCall = getLocalizeCall(
`$localize(__makeTemplateObject(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']), 1, 2)`);
const [parts, locations] = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']);
expect(parts.raw).toEqual(['a', 'b\\t', 'c']);
expect(locations).toEqual([
{
start: {line: 0, column: 51},
end: {line: 0, column: 54},
file: absoluteFrom('/test/file.js'),
text: `'a'`,
},
{
start: {line: 0, column: 56},
end: {line: 0, column: 62},
file: absoluteFrom('/test/file.js'),
text: `'b\\\\t'`,
},
{
start: {line: 0, column: 64},
end: {line: 0, column: 67},
file: absoluteFrom('/test/file.js'),
text: `'c'`,
},
]);
});
describe('buildLocalizeReplacement', () => {
it('should interleave the `messageParts` with the `substitutions`', () => {
const messageParts = ɵmakeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']);
const substitutions = [numericLiteral(1), numericLiteral(2)];
const expression = buildLocalizeReplacement(messageParts, substitutions);
expect(generate(expression).code).toEqual('"a" + 1 + "b" + 2 + "c"');
});
it('should return an array of string literals and locations from a lazy load template helper',
() => {
let localizeCall = getLocalizeCall(`
function _templateObject() {
var e = _taggedTemplateLiteral(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']);
return _templateObject = function() { return e }, e
}
$localize(_templateObject(), 1, 2)`);
const [parts, locations] = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']);
expect(parts.raw).toEqual(['a', 'b\\t', 'c']);
expect(locations).toEqual([
{
start: {line: 2, column: 61},
end: {line: 2, column: 64},
file: absoluteFrom('/test/file.js'),
text: `'a'`,
},
{
start: {line: 2, column: 66},
end: {line: 2, column: 72},
file: absoluteFrom('/test/file.js'),
text: `'b\\\\t'`,
},
{
start: {line: 2, column: 74},
end: {line: 2, column: 77},
file: absoluteFrom('/test/file.js'),
text: `'c'`,
},
]);
});
it('should wrap "binary expression" substitutions in parentheses', () => {
const messageParts = ɵmakeTemplateObject(['a', 'b'], ['a', 'b']);
const binary = binaryExpression('+', numericLiteral(1), numericLiteral(2));
const expression = buildLocalizeReplacement(messageParts, [binary]);
expect(generate(expression).code).toEqual('"a" + (1 + 2) + "b"');
});
});
describe('unwrapMessagePartsFromLocalizeCall', () => {
it('should return an array of string literals from a direct call to a tag function', () => {
const localizeCall = getLocalizeCall(`$localize(['a', 'b\\t', 'c'], 1, 2)`);
const parts = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']);
});
it('should return an array of string literals from a downleveled tagged template', () => {
let localizeCall = getLocalizeCall(
`$localize(__makeTemplateObject(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']), 1, 2)`);
const parts = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']);
expect(parts.raw).toEqual(['a', 'b\\t', 'c']);
});
it('should return an array of string literals from a lazy load template helper', () => {
let localizeCall = getLocalizeCall(`
it('should remove a lazy load template helper', () => {
let localizeCall = getLocalizeCall(`
function _templateObject() {
var e = _taggedTemplateLiteral(['a', 'b', 'c'], ['a', 'b', 'c']);
return _templateObject = function() { return e }, e
}
$localize(_templateObject(), 1, 2)`);
const parts = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b', 'c']);
expect(parts.raw).toEqual(['a', 'b', 'c']);
const localizeStatement = localizeCall.parentPath as NodePath<ExpressionStatement>;
const statements = localizeStatement.container as object[];
expect(statements.length).toEqual(2);
unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(statements.length).toEqual(1);
expect(statements[0]).toBe(localizeStatement.node);
});
});
it('should remove a lazy load template helper', () => {
let localizeCall = getLocalizeCall(`
function _templateObject() {
var e = _taggedTemplateLiteral(['a', 'b', 'c'], ['a', 'b', 'c']);
return _templateObject = function() { return e }, e
}
$localize(_templateObject(), 1, 2)`);
const localizeStatement = localizeCall.parentPath as NodePath<ExpressionStatement>;
const statements = localizeStatement.container as object[];
expect(statements.length).toEqual(2);
unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(statements.length).toEqual(1);
expect(statements[0]).toBe(localizeStatement.node);
});
});
describe('unwrapSubstitutionsFromLocalizeCall', () => {
it('should return the substitutions and locations from a direct call to a tag function',
() => {
const call = getLocalizeCall(`$localize(['a', 'b\t', 'c'], 1, 2)`);
const [substitutions, locations] = unwrapSubstitutionsFromLocalizeCall(call);
expect((substitutions as NumericLiteral[]).map(s => s.value)).toEqual([1, 2]);
expect(locations).toEqual([
{
start: {line: 0, column: 28},
end: {line: 0, column: 29},
file: absoluteFrom('/test/file.js'),
text: '1'
},
{
start: {line: 0, column: 31},
end: {line: 0, column: 32},
file: absoluteFrom('/test/file.js'),
text: '2'
},
]);
});
describe('unwrapSubstitutionsFromLocalizeCall', () => {
it('should return the substitutions from a direct call to a tag function', () => {
const ast = template.ast`$localize(['a', 'b\t', 'c'], 1, 2)` as ExpressionStatement;
const call = ast.expression as CallExpression;
const substitutions = unwrapSubstitutionsFromLocalizeCall(call);
expect(substitutions.map(s => (s as NumericLiteral).value)).toEqual([1, 2]);
it('should return the substitutions and locations from a downleveled tagged template', () => {
const call = getLocalizeCall(
`$localize(__makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']), 1, 2)`);
const [substitutions, locations] = unwrapSubstitutionsFromLocalizeCall(call);
expect((substitutions as NumericLiteral[]).map(s => s.value)).toEqual([1, 2]);
expect(locations).toEqual([
{
start: {line: 0, column: 66},
end: {line: 0, column: 67},
file: absoluteFrom('/test/file.js'),
text: '1'
},
{
start: {line: 0, column: 69},
end: {line: 0, column: 70},
file: absoluteFrom('/test/file.js'),
text: '2'
},
]);
});
});
it('should return the substitutions from a downleveled tagged template', () => {
const ast = template.ast
`$localize(__makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']), 1, 2)` as
ExpressionStatement;
const call = ast.expression as CallExpression;
const substitutions = unwrapSubstitutionsFromLocalizeCall(call);
expect(substitutions.map(s => (s as NumericLiteral).value)).toEqual([1, 2]);
});
});
describe('unwrapMessagePartsFromTemplateLiteral', () => {
it('should return a TemplateStringsArray built from the template literal elements', () => {
const taggedTemplate = getTaggedTemplate('$localize `a${1}b\\t${2}c`;');
expect(unwrapMessagePartsFromTemplateLiteral(taggedTemplate.node.quasi.quasis))
.toEqual(ɵmakeTemplateObject(['a', 'b\t', 'c'], ['a', 'b\\t', 'c']));
});
});
describe('wrapInParensIfNecessary', () => {
it('should wrap the expression in parentheses if it is binary', () => {
const ast = template.ast`a + b` as ExpressionStatement;
const wrapped = wrapInParensIfNecessary(ast.expression);
expect(isParenthesizedExpression(wrapped)).toBe(true);
describe('unwrapMessagePartsFromTemplateLiteral', () => {
it('should return a TemplateStringsArray built from the template literal elements', () => {
const taggedTemplate = getTaggedTemplate('$localize `a${1}b\\t${2}c`;');
expect(unwrapMessagePartsFromTemplateLiteral(taggedTemplate.get('quasi').get('quasis'))[0])
.toEqual(ɵmakeTemplateObject(['a', 'b\t', 'c'], ['a', 'b\\t', 'c']));
});
});
it('should return the expression untouched if it is not binary', () => {
const ast = template.ast`a` as ExpressionStatement;
const wrapped = wrapInParensIfNecessary(ast.expression);
expect(isParenthesizedExpression(wrapped)).toBe(false);
});
});
describe('wrapInParensIfNecessary', () => {
it('should wrap the expression in parentheses if it is binary', () => {
const ast = template.ast`a + b` as ExpressionStatement;
const wrapped = wrapInParensIfNecessary(ast.expression);
expect(isParenthesizedExpression(wrapped)).toBe(true);
});
describe('unwrapStringLiteralArray', () => {
it('should return an array of string from an array expression', () => {
const ast = template.ast`['a', 'b', 'c']` as ExpressionStatement;
expect(unwrapStringLiteralArray(ast.expression)).toEqual(['a', 'b', 'c']);
it('should return the expression untouched if it is not binary', () => {
const ast = template.ast`a` as ExpressionStatement;
const wrapped = wrapInParensIfNecessary(ast.expression);
expect(isParenthesizedExpression(wrapped)).toBe(false);
});
});
it('should throw an error if any elements of the array are not literal strings', () => {
const ast = template.ast`['a', 2, 'c']` as ExpressionStatement;
expect(() => unwrapStringLiteralArray(ast.expression))
.toThrowError('Unexpected messageParts for `$localize` (expected an array of strings).');
});
});
describe('unwrapStringLiteralArray', () => {
it('should return an array of string from an array expression', () => {
const array = getFirstExpression(`['a', 'b', 'c']`);
const [expressions, locations] = unwrapStringLiteralArray(array);
expect(expressions).toEqual(['a', 'b', 'c']);
expect(locations).toEqual([
{
start: {line: 0, column: 1},
end: {line: 0, column: 4},
file: absoluteFrom('/test/file.js'),
text: `'a'`,
},
{
start: {line: 0, column: 6},
end: {line: 0, column: 9},
file: absoluteFrom('/test/file.js'),
text: `'b'`,
},
{
start: {line: 0, column: 11},
end: {line: 0, column: 14},
file: absoluteFrom('/test/file.js'),
text: `'c'`,
},
]);
});
describe('isStringLiteralArray()', () => {
it('should return true if the ast is an array of strings', () => {
const ast = template.ast`['a', 'b', 'c']` as ExpressionStatement;
expect(isStringLiteralArray(ast.expression)).toBe(true);
it('should throw an error if any elements of the array are not literal strings', () => {
const array = getFirstExpression(`['a', 2, 'c']`);
expect(() => unwrapStringLiteralArray(array))
.toThrowError(
'Unexpected messageParts for `$localize` (expected an array of strings).');
});
});
it('should return false if the ast is not an array', () => {
const ast = template.ast`'a'` as ExpressionStatement;
expect(isStringLiteralArray(ast.expression)).toBe(false);
describe('isStringLiteralArray()', () => {
it('should return true if the ast is an array of strings', () => {
const ast = template.ast`['a', 'b', 'c']` as ExpressionStatement;
expect(isStringLiteralArray(ast.expression)).toBe(true);
});
it('should return false if the ast is not an array', () => {
const ast = template.ast`'a'` as ExpressionStatement;
expect(isStringLiteralArray(ast.expression)).toBe(false);
});
it('should return false if at least on of the array elements is not a string', () => {
const ast = template.ast`['a', 1, 'b']` as ExpressionStatement;
expect(isStringLiteralArray(ast.expression)).toBe(false);
});
});
it('should return false if at least on of the array elements is not a string', () => {
const ast = template.ast`['a', 1, 'b']` as ExpressionStatement;
expect(isStringLiteralArray(ast.expression)).toBe(false);
});
});
describe('isArrayOfExpressions()', () => {
it('should return true if all the nodes are expressions', () => {
const call = getFirstExpression<CallExpression>('foo(a, b, c);');
expect(isArrayOfExpressions(call.get('arguments'))).toBe(true);
});
describe('isArrayOfExpressions()', () => {
it('should return true if all the nodes are expressions', () => {
const ast = template.ast`function foo(a, b, c) {}` as FunctionDeclaration;
expect(isArrayOfExpressions(ast.params)).toBe(true);
it('should return false if any of the nodes is not an expression', () => {
const call = getFirstExpression<CallExpression>('foo(a, b, ...c);');
expect(isArrayOfExpressions(call.get('arguments'))).toBe(false);
});
});
it('should return false if any of the nodes is not an expression', () => {
const ast = template.ast`function foo(a, b, ...c) {}` as FunctionDeclaration;
expect(isArrayOfExpressions(ast.params)).toBe(false);
});
});
runInEachFileSystem(() => {
describe('getLocation()', () => {
it('should return a plain object containing the start, end and file of a NodePath', () => {
const taggedTemplate = getTaggedTemplate('const x = $localize `message`;', {
@ -214,7 +325,8 @@ describe('utils', () => {
});
it('should return `undefined` if the NodePath has no filename', () => {
const taggedTemplate = getTaggedTemplate('const x = $localize ``;', {sourceRoot: '/root'});
const taggedTemplate = getTaggedTemplate(
'const x = $localize ``;', {sourceRoot: '/root', filename: undefined});
const location = getLocation(taggedTemplate);
expect(location).toBeUndefined();
});
@ -224,24 +336,38 @@ describe('utils', () => {
function getTaggedTemplate(
code: string, options?: TransformOptions): NodePath<TaggedTemplateExpression> {
const {expressions, plugin} = collectExpressionsPlugin();
transformSync(code, {...options, plugins: [plugin]});
return expressions.find(e => e.isTaggedTemplateExpression()) as any;
return getExpressions<TaggedTemplateExpression>(code, options)
.find(e => e.isTaggedTemplateExpression())!;
}
function collectExpressionsPlugin() {
function getFirstExpression<T extends Expression>(
code: string, options?: TransformOptions): NodePath<T> {
return getExpressions<T>(code, options)[0];
}
function getExpressions<T extends Expression>(
code: string, options?: TransformOptions): NodePath<T>[] {
const expressions: NodePath<Expression>[] = [];
const visitor = {
Expression: (path: NodePath<Expression>) => {
expressions.push(path);
}
};
return {expressions, plugin: {visitor}};
transformSync(code, {
code: false,
filename: '/test/file.js',
plugins: [{
visitor: {
Expression: (path: NodePath<Expression>) => {
expressions.push(path);
}
}
}],
...options
});
return expressions as NodePath<T>[];
}
function getLocalizeCall(code: string): NodePath<CallExpression> {
let callPaths: NodePath<CallExpression>[] = [];
transformSync(code, {
code: false,
filename: '/test/file.js',
plugins: [{
visitor: {
CallExpression(path) {