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) {