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

View File

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

View File

@ -67,7 +67,7 @@ export function buildLocalizeReplacement(
* @param call The AST node of the call to process. * @param call The AST node of the call to process.
*/ */
export function unwrapMessagePartsFromLocalizeCall(call: NodePath<t.CallExpression>): export function unwrapMessagePartsFromLocalizeCall(call: NodePath<t.CallExpression>):
TemplateStringsArray { [TemplateStringsArray, (ɵSourceLocation | undefined)[]] {
let cooked = call.get('arguments')[0]; let cooked = call.get('arguments')[0];
if (cooked === undefined) { if (cooked === undefined) {
@ -137,34 +137,44 @@ export function unwrapMessagePartsFromLocalizeCall(call: NodePath<t.CallExpressi
raw = arg2 !== undefined ? arg2 : cooked; raw = arg2 !== undefined ? arg2 : cooked;
} }
const cookedStrings = unwrapStringLiteralArray(cooked.node); const [cookedStrings] = unwrapStringLiteralArray(cooked);
const rawStrings = unwrapStringLiteralArray(raw.node); const [rawStrings, rawLocations] = unwrapStringLiteralArray(raw);
return ɵmakeTemplateObject(cookedStrings, rawStrings); return [ɵmakeTemplateObject(cookedStrings, rawStrings), rawLocations];
} }
export function unwrapSubstitutionsFromLocalizeCall(call: t.CallExpression): t.Expression[] { export function unwrapSubstitutionsFromLocalizeCall(call: NodePath<t.CallExpression>):
const expressions = call.arguments.splice(1); [t.Expression[], (ɵSourceLocation | undefined)[]] {
const expressions = call.get('arguments').splice(1);
if (!isArrayOfExpressions(expressions)) { if (!isArrayOfExpressions(expressions)) {
const badExpression = expressions.find(expression => !t.isExpression(expression))!; const badExpression = expressions.find(expression => !expression.isExpression())!;
throw new BabelParseError( throw new BabelParseError(
badExpression, badExpression.node,
'Invalid substitutions for `$localize` (expected all substitution arguments to be expressions).'); '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[]): export function unwrapMessagePartsFromTemplateLiteral(elements: NodePath<t.TemplateElement>[]):
TemplateStringsArray { [TemplateStringsArray, (ɵSourceLocation | undefined)[]] {
const cooked = elements.map(q => { const cooked = elements.map(q => {
if (q.value.cooked === undefined) { if (q.node.value.cooked === undefined) {
throw new BabelParseError( 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); const raw = elements.map(q => q.node.value.raw);
return ɵmakeTemplateObject(cooked, 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. * Extract the string values from an `array` of string literals.
* @param array The array to unwrap. * @param array The array to unwrap.
*/ */
export function unwrapStringLiteralArray(array: t.Expression): string[] { export function unwrapStringLiteralArray(array: NodePath<t.Expression>):
if (!isStringLiteralArray(array)) { [string[], (ɵSourceLocation | undefined)[]] {
if (!isStringLiteralArray(array.node)) {
throw new BabelParseError( 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? * Are all the given `nodes` expressions?
* @param nodes The nodes to test. * @param nodes The nodes to test.
*/ */
export function isArrayOfExpressions(nodes: t.Node[]): nodes is t.Expression[] { export function isArrayOfExpressions(paths: NodePath<t.Node>[]): paths is NodePath<t.Expression>[] {
return nodes.every(element => t.isExpression(element)); return paths.every(element => element.isExpression());
} }
/** Options that affect how the `makeEsXXXTranslatePlugin()` functions work. */ /** Options that affect how the `makeEsXXXTranslatePlugin()` functions work. */
@ -361,7 +373,8 @@ export function getLocation(startPath: NodePath, endPath?: NodePath): ɵSourceLo
return { return {
start: getLineAndColumn(startLocation.start), start: getLineAndColumn(startLocation.start),
end: getLineAndColumn(endLocation.end), 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 { function getFileFromPath(path: NodePath|undefined): AbsoluteFsPath|null {
const opts = path?.hub.file.opts; const opts = path?.hub.file.opts;
return opts?.filename ? return opts?.filename ?
resolve(opts.generatorOpts.sourceRoot, relative(opts.cwd, opts.filename)) : resolve(opts.generatorOpts.sourceRoot ?? opts.cwd, relative(opts.cwd, opts.filename)) :
null; 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. // Note we want 0-based line numbers but Babel returns 1-based.
return {line: loc.line - 1, column: loc.column}; 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 { try {
const tag = path.get('tag'); const tag = path.get('tag');
if (isLocalize(tag, localizeName)) { if (isLocalize(tag, localizeName)) {
const messageParts = unwrapMessagePartsFromTemplateLiteral(path.node.quasi.quasis); const [messageParts] =
unwrapMessagePartsFromTemplateLiteral(path.get('quasi').get('quasis'));
const translated = translate( const translated = translate(
diagnostics, translations, messageParts, path.node.quasi.expressions, diagnostics, translations, messageParts, path.node.quasi.expressions,
missingTranslation); missingTranslation);

View File

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

View File

@ -36,11 +36,50 @@ runInEachFileSystem(() => {
description: 'description', description: 'description',
meaning: 'meaning', meaning: 'meaning',
messageParts: ['a', 'b', 'c'], 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', text: 'a{$PH}b{$PH_1}c',
placeholderNames: ['PH', 'PH_1'], placeholderNames: ['PH', 'PH_1'],
substitutions: jasmine.any(Object), 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: [], 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({ expect(messages[1]).toEqual({
@ -49,11 +88,51 @@ runInEachFileSystem(() => {
description: '', description: '',
meaning: '', meaning: '',
messageParts: ['a', 'b', 'c'], 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', text: 'a{$custom-placeholder}b{$PH_1}c',
placeholderNames: ['custom-placeholder', 'PH_1'], placeholderNames: ['custom-placeholder', 'PH_1'],
substitutions: jasmine.any(Object), 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: [], 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({ expect(messages[2]).toEqual({
@ -65,8 +144,47 @@ runInEachFileSystem(() => {
text: 'a{$PH}b{$PH_1}c', text: 'a{$PH}b{$PH_1}c',
placeholderNames: ['PH', 'PH_1'], placeholderNames: ['PH', 'PH_1'],
substitutions: jasmine.any(Object), 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: [], 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,9 +12,10 @@ import {NodePath, TransformOptions, transformSync} from '@babel/core';
import generate from '@babel/generator'; import generate from '@babel/generator';
import template from '@babel/template'; 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'; import {isGlobalIdentifier, isNamedIdentifier, isStringLiteralArray, isArrayOfExpressions, unwrapStringLiteralArray, unwrapMessagePartsFromLocalizeCall, wrapInParensIfNecessary, buildLocalizeReplacement, unwrapSubstitutionsFromLocalizeCall, unwrapMessagePartsFromTemplateLiteral, getLocation} from '../src/source_file_utils';
runInEachFileSystem(() => {
describe('utils', () => { describe('utils', () => {
describe('isNamedIdentifier()', () => { describe('isNamedIdentifier()', () => {
it('should return true if the expression is an identifier with name `$localize`', () => { it('should return true if the expression is an identifier with name `$localize`', () => {
@ -73,30 +74,93 @@ describe('utils', () => {
}); });
describe('unwrapMessagePartsFromLocalizeCall', () => { describe('unwrapMessagePartsFromLocalizeCall', () => {
it('should return an array of string literals from a direct call to a tag function', () => { 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 localizeCall = getLocalizeCall(`$localize(['a', 'b\\t', 'c'], 1, 2)`);
const parts = unwrapMessagePartsFromLocalizeCall(localizeCall); const [parts, locations] = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']); 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 an array of string literals from a downleveled tagged template', () => { it('should return an array of string literals and locations from a downleveled tagged template',
() => {
let localizeCall = getLocalizeCall( let localizeCall = getLocalizeCall(
`$localize(__makeTemplateObject(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']), 1, 2)`); `$localize(__makeTemplateObject(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']), 1, 2)`);
const parts = unwrapMessagePartsFromLocalizeCall(localizeCall); const [parts, locations] = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b\t', 'c']); expect(parts).toEqual(['a', 'b\t', 'c']);
expect(parts.raw).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'`,
},
]);
}); });
it('should return an array of string literals from a lazy load template helper', () => { it('should return an array of string literals and locations from a lazy load template helper',
() => {
let localizeCall = getLocalizeCall(` let localizeCall = getLocalizeCall(`
function _templateObject() { function _templateObject() {
var e = _taggedTemplateLiteral(['a', 'b', 'c'], ['a', 'b', 'c']); var e = _taggedTemplateLiteral(['a', 'b\\t', 'c'], ['a', 'b\\\\t', 'c']);
return _templateObject = function() { return e }, e return _templateObject = function() { return e }, e
} }
$localize(_templateObject(), 1, 2)`); $localize(_templateObject(), 1, 2)`);
const parts = unwrapMessagePartsFromLocalizeCall(localizeCall); const [parts, locations] = unwrapMessagePartsFromLocalizeCall(localizeCall);
expect(parts).toEqual(['a', 'b', 'c']); expect(parts).toEqual(['a', 'b\t', 'c']);
expect(parts.raw).toEqual(['a', 'b', '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 remove a lazy load template helper', () => { it('should remove a lazy load template helper', () => {
@ -116,27 +180,53 @@ describe('utils', () => {
}); });
describe('unwrapSubstitutionsFromLocalizeCall', () => { describe('unwrapSubstitutionsFromLocalizeCall', () => {
it('should return the substitutions from a direct call to a tag function', () => { it('should return the substitutions and locations 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 call = getLocalizeCall(`$localize(['a', 'b\t', 'c'], 1, 2)`);
const substitutions = unwrapSubstitutionsFromLocalizeCall(call); const [substitutions, locations] = unwrapSubstitutionsFromLocalizeCall(call);
expect(substitutions.map(s => (s as NumericLiteral).value)).toEqual([1, 2]); 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'
},
]);
}); });
it('should return the substitutions from a downleveled tagged template', () => { it('should return the substitutions and locations from a downleveled tagged template', () => {
const ast = template.ast const call = getLocalizeCall(
`$localize(__makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']), 1, 2)` as `$localize(__makeTemplateObject(['a', 'b', 'c'], ['a', 'b', 'c']), 1, 2)`);
ExpressionStatement; const [substitutions, locations] = unwrapSubstitutionsFromLocalizeCall(call);
const call = ast.expression as CallExpression; expect((substitutions as NumericLiteral[]).map(s => s.value)).toEqual([1, 2]);
const substitutions = unwrapSubstitutionsFromLocalizeCall(call); expect(locations).toEqual([
expect(substitutions.map(s => (s as NumericLiteral).value)).toEqual([1, 2]); {
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'
},
]);
}); });
}); });
describe('unwrapMessagePartsFromTemplateLiteral', () => { describe('unwrapMessagePartsFromTemplateLiteral', () => {
it('should return a TemplateStringsArray built from the template literal elements', () => { it('should return a TemplateStringsArray built from the template literal elements', () => {
const taggedTemplate = getTaggedTemplate('$localize `a${1}b\\t${2}c`;'); const taggedTemplate = getTaggedTemplate('$localize `a${1}b\\t${2}c`;');
expect(unwrapMessagePartsFromTemplateLiteral(taggedTemplate.node.quasi.quasis)) expect(unwrapMessagePartsFromTemplateLiteral(taggedTemplate.get('quasi').get('quasis'))[0])
.toEqual(ɵmakeTemplateObject(['a', 'b\t', 'c'], ['a', 'b\\t', 'c'])); .toEqual(ɵmakeTemplateObject(['a', 'b\t', 'c'], ['a', 'b\\t', 'c']));
}); });
}); });
@ -157,14 +247,36 @@ describe('utils', () => {
describe('unwrapStringLiteralArray', () => { describe('unwrapStringLiteralArray', () => {
it('should return an array of string from an array expression', () => { it('should return an array of string from an array expression', () => {
const ast = template.ast`['a', 'b', 'c']` as ExpressionStatement; const array = getFirstExpression(`['a', 'b', 'c']`);
expect(unwrapStringLiteralArray(ast.expression)).toEqual(['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'`,
},
]);
}); });
it('should throw an error if any elements of the array are not literal strings', () => { it('should throw an error if any elements of the array are not literal strings', () => {
const ast = template.ast`['a', 2, 'c']` as ExpressionStatement; const array = getFirstExpression(`['a', 2, 'c']`);
expect(() => unwrapStringLiteralArray(ast.expression)) expect(() => unwrapStringLiteralArray(array))
.toThrowError('Unexpected messageParts for `$localize` (expected an array of strings).'); .toThrowError(
'Unexpected messageParts for `$localize` (expected an array of strings).');
}); });
}); });
@ -187,17 +299,16 @@ describe('utils', () => {
describe('isArrayOfExpressions()', () => { describe('isArrayOfExpressions()', () => {
it('should return true if all the nodes are expressions', () => { it('should return true if all the nodes are expressions', () => {
const ast = template.ast`function foo(a, b, c) {}` as FunctionDeclaration; const call = getFirstExpression<CallExpression>('foo(a, b, c);');
expect(isArrayOfExpressions(ast.params)).toBe(true); expect(isArrayOfExpressions(call.get('arguments'))).toBe(true);
}); });
it('should return false if any of the nodes is not an expression', () => { it('should return false if any of the nodes is not an expression', () => {
const ast = template.ast`function foo(a, b, ...c) {}` as FunctionDeclaration; const call = getFirstExpression<CallExpression>('foo(a, b, ...c);');
expect(isArrayOfExpressions(ast.params)).toBe(false); expect(isArrayOfExpressions(call.get('arguments'))).toBe(false);
}); });
}); });
runInEachFileSystem(() => {
describe('getLocation()', () => { describe('getLocation()', () => {
it('should return a plain object containing the start, end and file of a NodePath', () => { it('should return a plain object containing the start, end and file of a NodePath', () => {
const taggedTemplate = getTaggedTemplate('const x = $localize `message`;', { const taggedTemplate = getTaggedTemplate('const x = $localize `message`;', {
@ -214,7 +325,8 @@ describe('utils', () => {
}); });
it('should return `undefined` if the NodePath has no filename', () => { 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); const location = getLocation(taggedTemplate);
expect(location).toBeUndefined(); expect(location).toBeUndefined();
}); });
@ -224,24 +336,38 @@ describe('utils', () => {
function getTaggedTemplate( function getTaggedTemplate(
code: string, options?: TransformOptions): NodePath<TaggedTemplateExpression> { code: string, options?: TransformOptions): NodePath<TaggedTemplateExpression> {
const {expressions, plugin} = collectExpressionsPlugin(); return getExpressions<TaggedTemplateExpression>(code, options)
transformSync(code, {...options, plugins: [plugin]}); .find(e => e.isTaggedTemplateExpression())!;
return expressions.find(e => e.isTaggedTemplateExpression()) as any;
} }
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 expressions: NodePath<Expression>[] = [];
const visitor = { transformSync(code, {
code: false,
filename: '/test/file.js',
plugins: [{
visitor: {
Expression: (path: NodePath<Expression>) => { Expression: (path: NodePath<Expression>) => {
expressions.push(path); expressions.push(path);
} }
}; }
return {expressions, plugin: {visitor}}; }],
...options
});
return expressions as NodePath<T>[];
} }
function getLocalizeCall(code: string): NodePath<CallExpression> { function getLocalizeCall(code: string): NodePath<CallExpression> {
let callPaths: NodePath<CallExpression>[] = []; let callPaths: NodePath<CallExpression>[] = [];
transformSync(code, { transformSync(code, {
code: false,
filename: '/test/file.js',
plugins: [{ plugins: [{
visitor: { visitor: {
CallExpression(path) { CallExpression(path) {