packages/babel-plugin-fbt/src/babel-processors/FbtFunctionCallProcessor.js (607 lines of code) (raw):
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* @emails oncall+i18n_fbt_js
* @flow strict-local
* @format
*/
/*eslint max-len: ["error", 100]*/
'use strict';
import type {AnyStringVariationArg} from '../fbt-nodes/FbtArguments';
import type {Options as FbtElementNodeOptions} from '../fbt-nodes/FbtElementNode';
import type {AnyFbtNode} from '../fbt-nodes/FbtNode';
import type {FbtCallSiteOptions, JSModuleNameType} from '../FbtConstants';
import type {TableJSFBTTree, TableJSFBTTreeLeaf} from '../index';
import type {ObjectWithJSFBT, PluginOptions} from '../index.js';
import type {NodePathOf} from '@babel/core';
import typeof BabelTypes from '@babel/types';
const {StringVariationArgsMap} = require('../fbt-nodes/FbtArguments');
const FbtElementNode = require('../fbt-nodes/FbtElementNode');
const FbtImplicitParamNode = require('../fbt-nodes/FbtImplicitParamNode');
const FbtNodeType = require('../fbt-nodes/FbtNodeType');
const FbtParamNode = require('../fbt-nodes/FbtParamNode');
const {SENTINEL} = require('../FbtConstants');
const FbtNodeChecker = require('../FbtNodeChecker');
const {
convertToStringArrayNodeIfNeeded,
createFbtRuntimeArgCallExpression,
enforceBoolean,
enforceString,
errorAt,
varDump,
} = require('../FbtUtil');
const JSFbtBuilder = require('../JSFbtBuilder');
const addLeafToTree = require('../utils/addLeafToTree');
const {
arrayExpression,
assignmentExpression,
callExpression,
clone,
cloneDeep,
identifier,
isBlockStatement,
isProgram,
jsxExpressionContainer,
memberExpression,
sequenceExpression,
stringLiteral,
variableDeclaration,
variableDeclarator,
} = require('@babel/types');
const {Buffer} = require('buffer');
const invariant = require('invariant');
const nullthrows = require('nullthrows');
type NodePath = NodePathOf<BabelNodeCallExpression>;
export type FbtFunctionCallPhrase = {|
...FbtCallSiteOptions,
...ObjectWithJSFBT,
|};
export type SentinelPayload = {|
...ObjectWithJSFBT,
project: string,
|};
export type MetaPhrase = {|
compactStringVariations: CompactStringVariations,
// FbtNode abstraction whose phrase's data comes from
fbtNode: FbtElementNode | FbtImplicitParamNode,
// Phrase data
phrase: FbtFunctionCallPhrase,
// Index of the outer-phrase (assuming that the current phrase is an inner-phrase)
// If the current phrase is the top-level phrase, it won't be defined.
parentIndex: ?number,
|};
type CompactStringVariations = {|
// Compacted string variation argument list
array: $ReadOnlyArray<AnyStringVariationArg>,
// Mapping of the original item indexes so that:
// For the output array item at index `k`, the original SVArgument index is `indexMap[k]`
indexMap: $ReadOnlyArray<number>,
|};
// In the final fbt runtime call, runtime arguments that create string variation
// will become identifiers(references to local variables) if there exist string variations
// AND inner strings.
type StringVariationRuntimeArgumentBabelNodes =
| Array<BabelNodeIdentifier>
| Array<BabelNodeCallExpression>;
const emptyArgsCombinations: [[]] = [[]];
const STRING_VARIATION_RUNTIME_ARGUMENT_IDENTIFIER_PREFIX = 'fbt_sv_arg';
/**
* This class provides utility methods to process the babel node of the standard fbt function call
* (i.e. `fbt(...)`)
*/
class FbtFunctionCallProcessor {
defaultFbtOptions: FbtCallSiteOptions;
fileSource: string;
moduleName: JSModuleNameType;
node: $PropertyType<NodePath, 'node'>;
nodeChecker: FbtNodeChecker;
path: NodePath;
pluginOptions: PluginOptions;
t: BabelTypes;
constructor({
babelTypes,
defaultFbtOptions,
fileSource,
nodeChecker,
path,
pluginOptions,
}: {
babelTypes: BabelTypes,
defaultFbtOptions: FbtCallSiteOptions,
fileSource: string,
nodeChecker: FbtNodeChecker,
path: NodePath,
pluginOptions: PluginOptions,
}): void {
this.defaultFbtOptions = defaultFbtOptions;
this.fileSource = fileSource;
this.moduleName = nodeChecker.moduleName;
this.node = path.node;
this.nodeChecker = nodeChecker;
this.path = path;
this.pluginOptions = pluginOptions;
this.t = babelTypes;
}
static create({
babelTypes,
defaultFbtOptions,
fileSource,
path,
pluginOptions,
}: {
babelTypes: BabelTypes,
defaultFbtOptions: FbtCallSiteOptions,
fileSource: string,
path: NodePath,
pluginOptions: PluginOptions,
}): ?FbtFunctionCallProcessor {
const nodeChecker = FbtNodeChecker.forFbtFunctionCall(path.node);
return nodeChecker != null
? new FbtFunctionCallProcessor({
babelTypes,
defaultFbtOptions,
fileSource,
nodeChecker,
path,
pluginOptions,
})
: null;
}
_assertJSModuleWasAlreadyRequired(): this {
const {moduleName, path} = this;
if (!this.nodeChecker.isJSModuleBound<typeof path.node>(path)) {
throw errorAt(
path.node,
`${moduleName} is not bound. Did you forget to require('${moduleName}')?`,
);
}
return this;
}
_assertHasEnoughArguments(): this {
const {moduleName, node} = this;
if (node.arguments.length < 2) {
throw errorAt(
node,
`Expected ${moduleName} calls to have at least two arguments. ` +
`Only ${node.arguments.length} was given.`,
);
}
return this;
}
_createFbtRuntimeCallForMetaPhrase(
metaPhrases: $ReadOnlyArray<MetaPhrase>,
metaPhraseIndex: number,
stringVariationRuntimeArgs: StringVariationRuntimeArgumentBabelNodes,
): BabelNodeCallExpression {
const {phrase} = metaPhrases[metaPhraseIndex];
const {pluginOptions} = this;
const argsOutput = JSON.stringify(
({
jsfbt: phrase.jsfbt,
project: phrase.project,
}: SentinelPayload),
);
const encodedOutput =
pluginOptions.fbtBase64 === true
? Buffer.from(argsOutput).toString('base64')
: argsOutput;
const fbtSentinel = pluginOptions.fbtSentinel ?? SENTINEL;
const args = [stringLiteral(fbtSentinel + encodedOutput + fbtSentinel)];
const fbtRuntimeArgs = this._createFbtRuntimeArgumentsForMetaPhrase(
metaPhrases,
metaPhraseIndex,
stringVariationRuntimeArgs,
);
if (fbtRuntimeArgs.length > 0) {
args.push(arrayExpression(fbtRuntimeArgs));
}
return callExpression(
memberExpression(identifier(this.moduleName), identifier('_')),
args,
);
}
_createRootFbtRuntimeCall(
metaPhrases: $ReadOnlyArray<MetaPhrase>,
): BabelNodeCallExpression | BabelNodeSequenceExpression {
const stringVariationRuntimeArgs =
this._createRuntimeArgsFromStringVariantNodes(metaPhrases[0]);
if (!this._hasStringVariationAndContainsInnerString(metaPhrases)) {
return this._createFbtRuntimeCallForMetaPhrase(
metaPhrases,
0,
stringVariationRuntimeArgs,
);
}
this._throwIfStringVariationArgsMayCauseSideEffects(metaPhrases);
const stringVariationRuntimeArgIdentifiers =
this._generateUniqueIdentifiersForRuntimeArgs(
stringVariationRuntimeArgs.length,
);
const fbtRuntimeCall = this._createFbtRuntimeCallForMetaPhrase(
metaPhrases,
0,
stringVariationRuntimeArgIdentifiers,
);
this._injectVariableDeclarationsForStringVariationArguments(
stringVariationRuntimeArgIdentifiers,
);
return this._wrapFbtRuntimeCallInSequenceExpression(
stringVariationRuntimeArgs,
fbtRuntimeCall,
stringVariationRuntimeArgIdentifiers,
);
}
/**
* String variation arguments are not allowed to contain anything that may
* cause side-effects. Side-effects are mostly introduced by but not limited to
* method calls and class instantiations. Please refer to the JSDoc of
* FbtNode#throwIfAnyArgumentContainsFunctionCallOrClassInstantiation for
* examples.
*/
_throwIfStringVariationArgsMayCauseSideEffects(
metaPhrases: $ReadOnlyArray<MetaPhrase>,
) {
metaPhrases[0].compactStringVariations.array.map(svArg =>
svArg.fbtNode.throwIfAnyArgumentContainsFunctionCallOrClassInstantiation(
this.path.context.scope,
),
);
}
_injectVariableDeclarationsForStringVariationArguments(
identifiersForStringVariationRuntimeArgs: $ReadOnlyArray<BabelNodeIdentifier>,
): void {
// Find the first ancestor block statement node or the program root node
let curPath = this.path;
while (!isBlockStatement(curPath.node) && !isProgram(curPath.node)) {
curPath = nullthrows(
curPath.parentPath,
'curPath can not be null. Otherwise, it means we reached the root' +
' of Babel AST in the previous iteration and therefore we would have exited the loop.',
);
}
const blockOrProgramPath = curPath;
const blockOrProgramNode = blockOrProgramPath.node;
invariant(
isBlockStatement(blockOrProgramNode) || isProgram(blockOrProgramNode),
"According to the above loop's condition, " +
'blockOrProgramNode must be either a block statement or a program node ',
);
// Replace the blockStatement/program node with
// a new blockStatement/program with injected declarations
const declarations = variableDeclaration(
'var',
identifiersForStringVariationRuntimeArgs.map(identifier =>
variableDeclarator(identifier),
),
);
const cloned = clone(blockOrProgramNode);
cloned.body = [declarations, ...cloned.body];
blockOrProgramPath.replaceWith(cloned);
}
/**
* Pre-assign those arguments that create string variations to local variables,
* and use references to these variables in fbt call. Note: Local variables
* will be auto-declared in sequenceExpression.
*
* E.g.
* Before:
* fbt._()
*
* After:
* (identifier_0 = runtimeArg1, identifier_1 = runtimeArg2, fbt._())
*/
_wrapFbtRuntimeCallInSequenceExpression(
runtimeArgs: $ReadOnlyArray<BabelNodeCallExpression>,
fbtRuntimeCall: BabelNodeCallExpression,
identifiersForStringVariationRuntimeArgs: $ReadOnlyArray<BabelNodeIdentifier>,
): BabelNodeSequenceExpression {
invariant(
runtimeArgs.length == identifiersForStringVariationRuntimeArgs.length,
'Expect exactly one identifier for each string variation runtime argument. ' +
'Instead we get %s identifiers and %s arguments.',
identifiersForStringVariationRuntimeArgs.length,
runtimeArgs.length,
);
const expressions = runtimeArgs
.map((runtimeArg, i) =>
assignmentExpression(
'=',
identifiersForStringVariationRuntimeArgs[i],
runtimeArg,
),
)
.concat(fbtRuntimeCall);
return sequenceExpression(expressions);
}
_hasStringVariationAndContainsInnerString(
metaPhrases: $ReadOnlyArray<MetaPhrase>,
): boolean {
const fbtElement = metaPhrases[0].fbtNode;
invariant(
fbtElement instanceof FbtElementNode,
'Expected a FbtElementNode for top level string but received: %s',
varDump(fbtElement),
);
const doesNotContainInnerString = fbtElement.children.every(child => {
return !(child instanceof FbtImplicitParamNode);
});
if (doesNotContainInnerString) {
return false;
}
return metaPhrases[0].compactStringVariations.array.length > 0;
}
_generateUniqueIdentifiersForRuntimeArgs(
count: number,
): Array<BabelNodeIdentifier> {
const identifiers = [];
for (
let identifierSuffix = 0, numIdentifierCreated = 0;
numIdentifierCreated < count;
identifierSuffix++
) {
const name = `${STRING_VARIATION_RUNTIME_ARGUMENT_IDENTIFIER_PREFIX}_${identifierSuffix}`;
if (this.path.context.scope.getBinding(name) == null) {
identifiers.push(identifier(name));
numIdentifierCreated++;
}
}
return identifiers;
}
/**
* Consolidate a list of string variation arguments under the following conditions:
*
* Enum variation arguments are consolidated to avoid creating duplicates of string variations
* (from a candidate values POV)
*
* Other types of variation arguments are accepted as-is.
*/
_compactStringVariationArgs(
args: $ReadOnlyArray<AnyStringVariationArg>,
): CompactStringVariations {
const indexMap = [];
const array = args.filter((arg, i) => {
if (arg.isCollapsible) {
return false;
}
indexMap.push(i);
return true;
});
return {
array,
indexMap,
};
}
_getPhraseParentIndex(
fbtNode: AnyFbtNode,
list: $ReadOnlyArray<AnyFbtNode>,
): ?number {
if (fbtNode.parent == null) {
return null;
}
const parentIndex = list.indexOf(fbtNode.parent);
invariant(
parentIndex > -1,
'Unable to find parent fbt node: node=%s',
varDump(fbtNode),
);
return parentIndex;
}
/**
* Generates a list of meta-phrases from a given FbtElement node
*/
_metaPhrases(fbtElement: FbtElementNode): $ReadOnlyArray<MetaPhrase> {
const stringVariationArgs = fbtElement.getArgsForStringVariationCalc();
const jsfbtBuilder = new JSFbtBuilder(
this.fileSource,
stringVariationArgs,
this.pluginOptions.reactNativeMode,
);
const argsCombinations = jsfbtBuilder.getStringVariationCombinations();
const compactStringVariations = this._compactStringVariationArgs(
argsCombinations[0] || [],
);
const jsfbtMetadata = jsfbtBuilder.buildMetadata(
compactStringVariations.array,
);
const sharedPhraseOptions = this._getSharedPhraseOptions(fbtElement);
return [fbtElement, ...fbtElement.getImplicitParamNodes()].map(
(fbtNode, _index, list) => {
try {
const phrase = {
...sharedPhraseOptions,
jsfbt: {
// the order of JSFBT props matter for unit tests
t: {},
m: jsfbtMetadata,
},
};
const svArgsMapList = [];
(argsCombinations.length
? argsCombinations
: emptyArgsCombinations
).forEach(argsCombination => {
// collect text/description pairs
const svArgsMap = new StringVariationArgsMap(argsCombination);
const argValues = compactStringVariations.indexMap.map(
originIndex => nullthrows(argsCombination[originIndex]?.value),
);
const leaf = ({
desc: fbtNode.getDescription(svArgsMap),
text: fbtNode.getText(svArgsMap),
}: TableJSFBTTreeLeaf);
const tokenAliases = fbtNode.getTokenAliases(svArgsMap);
if (tokenAliases != null) {
leaf.tokenAliases = tokenAliases;
}
if (fbtNode instanceof FbtElementNode) {
// gather list of svArgsMap for all args combination for later sanity checks
svArgsMapList.push(svArgsMap);
} else if (this.pluginOptions.generateOuterTokenName === true) {
leaf.outerTokenName = fbtNode.getTokenName(svArgsMap);
}
if (argValues.length) {
addLeafToTree<TableJSFBTTreeLeaf, TableJSFBTTree>(
phrase.jsfbt.t,
argValues,
leaf,
);
} else {
// jsfbt only contains one leaf
phrase.jsfbt.t = leaf;
}
});
if (fbtNode instanceof FbtElementNode) {
fbtNode.assertNoOverallTokenNameCollision(svArgsMapList);
}
return {
compactStringVariations,
fbtNode,
parentIndex: this._getPhraseParentIndex(fbtNode, list),
phrase,
};
} catch (error) {
throw errorAt(fbtNode.node, error);
}
},
);
}
/**
* Process current `fbt()` callsite (BabelNode) to generate:
* - an `fbt._()` callsite or a sequencExpression that eventually returns an `fbt._()` callsite
* - a list of meta-phrases describing the collected text strings from this fbt() callsite
*/
convertToFbtRuntimeCall(): {
// Client-side fbt._() call(or the sequencExpression that contains it)
// usable in a web browser generated from the given fbt() callsite
callNode: BabelNodeCallExpression | BabelNodeSequenceExpression,
// List of phrases collected from the fbt() callsite
metaPhrases: $ReadOnlyArray<MetaPhrase>,
} {
const fbtElement = this._convertToFbtNode();
const metaPhrases = this._metaPhrases(fbtElement);
const callNode = this._createRootFbtRuntimeCall(metaPhrases);
return {
callNode,
metaPhrases,
};
}
/**
* fbt constructs are not allowed to be direct children of fbt constructs.
* For example it is not okay to have
* <fbt desc='desc'>
* <fbt:param name="outer">
* <fbt:param name="inner">
* variable
* </fbt:param>
* </fbt:param>
* </fbt>
* However, the next example is okay because the inner `fbt:param` sits inside
* an inner fbt.
* <fbt desc='outer string'>
* <fbt:param name="outer">
* <fbt desc='inner string'>
* <fbt:param name="inner">
* variable
* </fbt:param>
* </fbt>
* </fbt:param>
* </fbt>
*/
throwIfExistsNestedFbtConstruct(): void {
this.path.traverse(
{
CallExpression(path: NodePathOf<BabelNodeCallExpression>) {
// eslint-disable-next-line max-len
// $FlowFixMe[object-this-reference] Babel transforms run with the plugin context by default
const nodeChecker = (this.nodeChecker: FbtNodeChecker);
const constructs = [
FbtNodeType.Enum,
FbtNodeType.Name,
FbtNodeType.Param,
FbtNodeType.Plural,
FbtNodeType.Pronoun,
FbtNodeType.SameParam,
];
const childFbtConstructName =
nodeChecker.getFbtConstructNameFromFunctionCall(path.node);
if (!constructs.includes(childFbtConstructName)) {
return;
}
let parentPath = path.parentPath;
while (parentPath != null) {
const parentNode = parentPath.node;
if (
FbtNodeChecker.forFbtFunctionCall(parentNode) != null ||
// children JSX fbt aren't yet converted to function call by JSXFbtProcessor
FbtNodeChecker.forJSXFbt(parentNode) != null
) {
return;
}
const parentFbtConstructName =
nodeChecker.getFbtConstructNameFromFunctionCall(parentNode);
if (constructs.includes(parentFbtConstructName)) {
throw errorAt(
parentNode,
`Expected fbt constructs to not nest inside fbt constructs, ` +
`but found ` +
`${nodeChecker.moduleName}.${(nullthrows(
childFbtConstructName,
): string)} ` +
`nest inside ` +
`${nodeChecker.moduleName}.${(nullthrows(
parentFbtConstructName,
): string)}`,
);
}
parentPath = parentPath.parentPath;
}
},
},
{
nodeChecker: this.nodeChecker,
},
);
}
/**
* Converts current fbt() BabelNode to an FbtNode equivalent
*/
_convertToFbtNode(): FbtElementNode {
this._assertJSModuleWasAlreadyRequired();
this._assertHasEnoughArguments();
const {moduleName, node} = this;
const {arguments: fbtCallArgs} = node;
const fbtContentsNode = convertToStringArrayNodeIfNeeded(
moduleName,
fbtCallArgs[0],
);
fbtCallArgs[0] = fbtContentsNode;
const elementNode = FbtElementNode.fromBabelNode({moduleName, node});
if (elementNode == null) {
throw errorAt(
node,
`${moduleName}: unable to create FbtElementNode from given Babel node`,
);
}
return elementNode;
}
_createFbtRuntimeArgumentsForMetaPhrase(
metaPhrases: $ReadOnlyArray<MetaPhrase>,
metaPhraseIndex: number,
stringVariationRuntimeArgs: StringVariationRuntimeArgumentBabelNodes,
): Array<BabelNodeCallExpression | BabelNodeIdentifier> {
const metaPhrase = metaPhrases[metaPhraseIndex];
// Runtime arguments of a string fall into 3 categories:
// 1. Each string variation argument must correspond to a runtime argument
// 2. Non string variation arguments(i.e. those fbt.param() calls that do not
// have gender or number option) should also be counted as runtime arguments.
// 3. Each inner string of current string should be associated with a
// runtime argument
return [
...stringVariationRuntimeArgs,
...this._createRuntimeArgsFromNonStringVariantNodes(metaPhrase.fbtNode),
...this._createRuntimeArgsFromImplicitParamNodes(
metaPhrases,
metaPhraseIndex,
stringVariationRuntimeArgs,
),
];
}
_createRuntimeArgsFromStringVariantNodes(
metaPhrase: MetaPhrase,
): Array<BabelNodeCallExpression> {
const fbtRuntimeArgs = [];
const {compactStringVariations} = metaPhrase;
for (const stringVariation of compactStringVariations.array) {
const fbtRuntimeArg = stringVariation.fbtNode.getFbtRuntimeArg();
if (fbtRuntimeArg) {
fbtRuntimeArgs.push(fbtRuntimeArg);
}
}
return fbtRuntimeArgs;
}
_createRuntimeArgsFromNonStringVariantNodes(
fbtNode: FbtImplicitParamNode | FbtElementNode,
): Array<BabelNodeCallExpression> {
const fbtRuntimeArgs = [];
for (const child of fbtNode.children) {
if (
child instanceof FbtParamNode &&
child.options.gender == null &&
child.options.number == null
) {
fbtRuntimeArgs.push(child.getFbtRuntimeArg());
}
}
return fbtRuntimeArgs;
}
_createRuntimeArgsFromImplicitParamNodes(
metaPhrases: $ReadOnlyArray<MetaPhrase>,
metaPhraseIndex: number,
runtimeArgsFromStringVariationNodes: StringVariationRuntimeArgumentBabelNodes,
): Array<BabelNodeCallExpression> {
const fbtRuntimeArgs = [];
for (const [
innerMetaPhraseIndex,
innerMetaPhrase,
] of metaPhrases.entries()) {
if (innerMetaPhrase.parentIndex != metaPhraseIndex) {
continue;
}
const innerMetaPhraseFbtNode = innerMetaPhrase.fbtNode;
invariant(
innerMetaPhraseFbtNode instanceof FbtImplicitParamNode,
'Expected the inner meta phrase to be associated with a FbtImplicitParamNode instead of %s',
varDump(innerMetaPhraseFbtNode),
);
const babelNode = cloneDeep(innerMetaPhraseFbtNode.node);
babelNode.children = [
jsxExpressionContainer(
this._createFbtRuntimeCallForMetaPhrase(
metaPhrases,
innerMetaPhraseIndex,
runtimeArgsFromStringVariationNodes,
),
),
];
const fbtParamRuntimeArg = createFbtRuntimeArgCallExpression(
innerMetaPhraseFbtNode,
[stringLiteral(innerMetaPhraseFbtNode.getOuterTokenAlias()), babelNode],
);
fbtRuntimeArgs.push(fbtParamRuntimeArg);
}
return fbtRuntimeArgs;
}
/**
* Combine options of the fbt element level with default options
* @returns only options that are considered "defined".
* I.e. Options whose value is `false` or nullish will be skipped.
*/
_getSharedPhraseOptions({options: fbtElementOptions}: FbtElementNode): {|
...$ObjMap<FbtElementNodeOptions, <T>(T) => ?T>,
project: string,
|} {
const {defaultFbtOptions} = this;
const ret = {
author:
(fbtElementOptions.author ??
enforceString.orNull(defaultFbtOptions.author)) ||
null,
common:
(fbtElementOptions.common ??
enforceBoolean.orNull(defaultFbtOptions.common)) ||
null,
doNotExtract:
(fbtElementOptions.doNotExtract ??
enforceBoolean.orNull(defaultFbtOptions.doNotExtract)) ||
null,
preserveWhitespace:
(fbtElementOptions.preserveWhitespace ??
enforceBoolean.orNull(defaultFbtOptions.preserveWhitespace)) ||
null,
subject: fbtElementOptions.subject,
project:
fbtElementOptions.project || enforceString(defaultFbtOptions.project),
};
// delete nullish options
for (const k in ret) {
if (ret[k] == null) {
delete ret[k];
}
}
return ret;
}
}
module.exports = FbtFunctionCallProcessor;