From db3a21b382e564b285a62ffdb120955a8d3f0d94 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Wed, 19 Aug 2020 15:31:55 +0100 Subject: [PATCH] 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 --- .../source_files/es2015_extract_plugin.ts | 13 +- .../source_files/es5_extract_plugin.ts | 10 +- .../src/tools/src/source_file_utils.ts | 68 ++- .../source_files/es2015_translate_plugin.ts | 3 +- .../source_files/es5_translate_plugin.ts | 4 +- .../src/tools/test/extract/extractor_spec.ts | 124 ++++- .../src/tools/test/source_file_utils_spec.ts | 448 +++++++++++------- 7 files changed, 471 insertions(+), 199 deletions(-) diff --git a/packages/localize/src/tools/src/extract/source_files/es2015_extract_plugin.ts b/packages/localize/src/tools/src/extract/source_files/es2015_extract_plugin.ts index cada054ef4..9c4efee6ae 100644 --- a/packages/localize/src/tools/src/extract/source_files/es2015_extract_plugin.ts +++ b/packages/localize/src/tools/src/extract/source_files/es2015_extract_plugin.ts @@ -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) { 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); } } diff --git a/packages/localize/src/tools/src/extract/source_files/es5_extract_plugin.ts b/packages/localize/src/tools/src/extract/source_files/es5_extract_plugin.ts index 4312f1e3c3..1814114c9c 100644 --- a/packages/localize/src/tools/src/extract/source_files/es5_extract_plugin.ts +++ b/packages/localize/src/tools/src/extract/source_files/es5_extract_plugin.ts @@ -18,10 +18,12 @@ export function makeEs5ExtractPlugin( CallExpression(callPath: NodePath) { 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); } } diff --git a/packages/localize/src/tools/src/source_file_utils.ts b/packages/localize/src/tools/src/source_file_utils.ts index ed20ac156a..07d7618b5c 100644 --- a/packages/localize/src/tools/src/source_file_utils.ts +++ b/packages/localize/src/tools/src/source_file_utils.ts @@ -67,7 +67,7 @@ export function buildLocalizeReplacement( * @param call The AST node of the call to process. */ export function unwrapMessagePartsFromLocalizeCall(call: NodePath): - TemplateStringsArray { + [TemplateStringsArray, (ɵSourceLocation | undefined)[]] { let cooked = call.get('arguments')[0]; if (cooked === undefined) { @@ -137,34 +137,44 @@ export function unwrapMessagePartsFromLocalizeCall(call: NodePath): + [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[]): + [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.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): + [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[]; + 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[]): paths is NodePath[] { + 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); +} diff --git a/packages/localize/src/tools/src/translate/source_files/es2015_translate_plugin.ts b/packages/localize/src/tools/src/translate/source_files/es2015_translate_plugin.ts index 0f8a9d01cf..1c9735e2cc 100644 --- a/packages/localize/src/tools/src/translate/source_files/es2015_translate_plugin.ts +++ b/packages/localize/src/tools/src/translate/source_files/es2015_translate_plugin.ts @@ -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); diff --git a/packages/localize/src/tools/src/translate/source_files/es5_translate_plugin.ts b/packages/localize/src/tools/src/translate/source_files/es5_translate_plugin.ts index 32db7e890a..7bcd78f620 100644 --- a/packages/localize/src/tools/src/translate/source_files/es5_translate_plugin.ts +++ b/packages/localize/src/tools/src/translate/source_files/es5_translate_plugin.ts @@ -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])); diff --git a/packages/localize/src/tools/test/extract/extractor_spec.ts b/packages/localize/src/tools/test/extract/extractor_spec.ts index b45e9cfda3..48d380915c 100644 --- a/packages/localize/src/tools/test/extract/extractor_spec.ts +++ b/packages/localize/src/tools/test/extract/extractor_spec.ts @@ -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`' + }, }); }); }); diff --git a/packages/localize/src/tools/test/source_file_utils_spec.ts b/packages/localize/src/tools/test/source_file_utils_spec.ts index b245d8c8cd..582c051119 100644 --- a/packages/localize/src/tools/test/source_file_utils_spec.ts +++ b/packages/localize/src/tools/test/source_file_utils_spec.ts @@ -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)).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)).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)).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)).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)).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)).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)).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)).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; + 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; - 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('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('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 { - const {expressions, plugin} = collectExpressionsPlugin(); - transformSync(code, {...options, plugins: [plugin]}); - return expressions.find(e => e.isTaggedTemplateExpression()) as any; + return getExpressions(code, options) + .find(e => e.isTaggedTemplateExpression())!; } -function collectExpressionsPlugin() { +function getFirstExpression( + code: string, options?: TransformOptions): NodePath { + return getExpressions(code, options)[0]; +} + +function getExpressions( + code: string, options?: TransformOptions): NodePath[] { const expressions: NodePath[] = []; - const visitor = { - Expression: (path: NodePath) => { - expressions.push(path); - } - }; - return {expressions, plugin: {visitor}}; + transformSync(code, { + code: false, + filename: '/test/file.js', + plugins: [{ + visitor: { + Expression: (path: NodePath) => { + expressions.push(path); + } + } + }], + ...options + }); + return expressions as NodePath[]; } function getLocalizeCall(code: string): NodePath { let callPaths: NodePath[] = []; transformSync(code, { + code: false, + filename: '/test/file.js', plugins: [{ visitor: { CallExpression(path) {