Skip to content

Commit

Permalink
🚀 Merging TemplateLiterals can result in StringLiterals (#27569)
Browse files Browse the repository at this point in the history
* Merges can result in StringLiterals instead of TemplateLiterals
* Convert single quasi template literals to string literals
* Fix up number literals
* comment on functionality
* Add test for merging identifiers, refactor to use babel evaluate where safe, and add test condition for escaped template literal segment from a string
* Use cooked value for stringliterals
* Address PR comments
* escape value when merging literal into template
* Escaping for expression string in raw of quasi collapse
* Move away from subpath removal since Babel 8 is unlikely to allow it
  • Loading branch information
kristoferbaxter committed Apr 6, 2020
1 parent 3c8db36 commit 83e96f1
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 93 deletions.
Expand Up @@ -14,16 +14,11 @@
* limitations under the License.
*/

const MERGEABLE_TYPES = ['StringLiteral', 'NumericLiteral', 'TemplateLiteral'];
const ESCAPE_REGEX = /\${|\\|`/g;

module.exports = function ({types: t}) {
const cloneNodes = (nodes) => nodes.map((node) => t.cloneNode(node));
const escapeStringForTemplateLiteral = (value) =>
String(value).replace(/\x60/g, '\\`');
const canMergeBinaryExpression = (binaryExpression) =>
MERGEABLE_TYPES.includes(binaryExpression.left.type) &&
MERGEABLE_TYPES.includes(binaryExpression.right.type) &&
binaryExpression.operator === '+';
const escapeValue = (value) => String(value).replace(ESCAPE_REGEX, '\\$&');

function whichCloneQuasi(clonedQuasis, index) {
for (let i = index; i >= 0; i--) {
Expand All @@ -34,79 +29,78 @@ module.exports = function ({types: t}) {
}
}

function joinTemplateLiterals(leftPath, rightPath) {
const {node: leftNode} = leftPath;
const {node: rightNode} = rightPath;

// First merge the first member of the right quasi into the last quasi on the left.
const fromQuasi = rightNode.quasis[0];
const toQuasi = leftNode.quasis[leftNode.quasis.length - 1];
toQuasi.value.raw += fromQuasi.value.raw;
toQuasi.value.cooked += fromQuasi.value.cooked;

// Merge the right remaining quasis and expressions to ensure merged left is valid.
leftNode.quasis.push(...rightNode.quasis.slice(1));
leftNode.expressions.push(...rightNode.expressions);

// Now the left contains the values of the right, so remove the right.
rightPath.remove();
}

function joinMaybeTemplateLiteral(path) {
const left = path.get('left');
const right = path.get('right');
if (left.isTemplateLiteral()) {
if (right.isTemplateLiteral()) {
// When both sides are template literals, bypass `babel.evaluate` since it cannot handle this condition.
joinTemplateLiterals(left, right);
return;
}

const e = right.evaluate();
if (e.confident) {
const quasi = left.node.quasis[left.node.quasis.length - 1];
quasi.value.raw += escapeValue(e.value);
quasi.value.cooked += e.value;
right.remove();
}
} else if (right.isTemplateLiteral()) {
const e = left.evaluate();
if (e.confident) {
const quasi = right.node.quasis[0];
quasi.value.raw = escapeValue(e.value) + quasi.value.raw;
quasi.value.cooked = e.value + quasi.value.cooked;
left.remove();
}
}
}

return {
name: 'flatten-stringish-literals',
visitor: {
BinaryExpression: {
exit(path) {
if (!canMergeBinaryExpression(path.node)) {
if (path.node.operator !== '+') {
return;
}

const {left, right} = path.node;
if (t.isTemplateLiteral(right)) {
const rightQuasis = cloneNodes(right.quasis);

if (t.isTemplateLiteral(left)) {
const leftQuasis = cloneNodes(left.quasis);
const finalLeftQuasi = leftQuasis[leftQuasis.length - 1];
leftQuasis[leftQuasis.length - 1].value = {
raw: finalLeftQuasi.value.raw + rightQuasis[0].value.raw,
cooked: finalLeftQuasi.value.cooked + rightQuasis[0].value.raw,
};
rightQuasis[0] = null;

path.replaceWith(
t.templateLiteral(
[...leftQuasis, ...rightQuasis.filter(Boolean)],
[
...cloneNodes(left.expressions),
...cloneNodes(right.expressions),
]
)
);
return;
}

// Left is a literal, containing a value to merge into the right.
const leftValue = escapeStringForTemplateLiteral(left.value);
rightQuasis[0].value = {
raw: leftValue + rightQuasis[0].value.raw,
cooked: leftValue + rightQuasis[0].value.cooked,
};
path.replaceWith(
t.templateLiteral(rightQuasis, cloneNodes(right.expressions))
);
}

// Right is a literal containing a value to merge into the left.
if (t.isTemplateLiteral(left)) {
const rightValue = escapeStringForTemplateLiteral(right.value);
const leftQuasis = cloneNodes(left.quasis);
const finalLeftQuasi = leftQuasis[leftQuasis.length - 1];
leftQuasis[leftQuasis.length - 1].value = {
raw: finalLeftQuasi.value.raw + rightValue,
cooked: finalLeftQuasi.value.cooked + rightValue,
};

path.replaceWith(
t.templateLiteral(leftQuasis, cloneNodes(left.expressions))
);
const e = path.evaluate();
if (e.confident) {
path.replaceWith(t.valueToNode(e.value));
path.skip();
return;
}

// Merge two string literals
if (t.isStringLiteral(left) && t.isStringLiteral(right)) {
const newLiteral = t.cloneNode(left);
newLiteral.value = left.value + String(right.value);
path.replaceWith(newLiteral);
}
joinMaybeTemplateLiteral(path);
},
},

TemplateLiteral(path) {
// Convert any items inside a template literal that are static literals.
// `foo{'123'}bar` => `foo123bar`
const {expressions, quasis} = path.node;
const newQuasis = cloneNodes(quasis);
const newExpressions = cloneNodes(expressions);
let newQuasis = cloneNodes(quasis);
let newExpressions = cloneNodes(expressions);
let conversions = 0;

for (let index = expressions.length; index >= 0; index--) {
Expand All @@ -123,7 +117,7 @@ module.exports = function ({types: t}) {
const {value: previousValue} = newQuasis[modifyIndex];

newQuasis[modifyIndex] = t.templateElement({
raw: previousValue.raw + value + changedValue.raw,
raw: previousValue.raw + escapeValue(value) + changedValue.raw,
cooked: previousValue.cooked + value + changedValue.cooked,
});
newQuasis[index + 1] = null;
Expand All @@ -132,16 +126,20 @@ module.exports = function ({types: t}) {
}
}

if (conversions === 0) {
newQuasis = newQuasis.filter(Boolean);
if (newQuasis.length === 1) {
// When the remaining number of quasis is one.
// Replace the TemplateLiteral with a StringLiteral.
// `foo` => 'foo'
path.replaceWith(t.stringLiteral(newQuasis[0].value.cooked));
return;
}

path.replaceWith(
t.templateLiteral(
newQuasis.filter(Boolean),
newExpressions.filter(Boolean)
)
);
if (conversions > 0) {
// Otherwise, any conversions of members requires replacing the existing TemplateLiteral
newExpressions = newExpressions.filter(Boolean);
path.replaceWith(t.templateLiteral(newQuasis, newExpressions));
}
},
},
};
Expand Down
Expand Up @@ -24,7 +24,15 @@ let numberStart = 1 + `/foo`;
let stringStart = '1' + `/foo`;
let numberEnd = `foo/` + 1;
let stringEnd = `foo/` + '1';
let illegalCharacter = `Invalid share providers configuration for in bookend. ` + 'Value must be `true` or a params object.';
let illegalCharacterString = `Invalid share providers configuration for in bookend. ` + 'Value must be `true` or a params object.';
let illegalCharacterTemplate = `Invalid ${x}` + 'Value must be `true` or a params object.';
let illegalEscapeValue = `Invalid ${x}` + '${foo}';

inverted: {
let illegalCharacterString = 'Value must be `true` or a params object. ' + `Invalid share providers configuration for in bookend.`;
let illegalCharacterTemplate = 'Value must be `true` or a params object. ' + `Invalid ${x}`;
let illegalEscapeValue = '${foo}' + `Invalid ${x}`;
}

let stringLiterals = '1' + '2';
let numberLiterals = 1 + 2;
Expand Down
Expand Up @@ -13,17 +13,26 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
let add = `/rtv/bar`;
let multipleAdd = `/rtv/bar/`;
let subtract = `/rtv` - `r`;
let multiply = `/rtv` * `r`;
let divide = `/rtv` / `r`;
let numberStart = `1/foo`;
let stringStart = `1/foo`;
let numberEnd = `foo/1`;
let stringEnd = `foo/1`;
let illegalCharacter = `Invalid share providers configuration for in bookend. Value must be \`true\` or a params object.`;
let add = "/rtv/bar";
let multipleAdd = "/rtv/bar/";
let subtract = "/rtv" - "r";
let multiply = "/rtv" * "r";
let divide = "/rtv" / "r";
let numberStart = "1/foo";
let stringStart = "1/foo";
let numberEnd = "foo/1";
let stringEnd = "foo/1";
let illegalCharacterString = "Invalid share providers configuration for in bookend. Value must be `true` or a params object.";
let illegalCharacterTemplate = `Invalid ${x}Value must be \`true\` or a params object.`;
let illegalEscapeValue = `Invalid ${x}\${foo}`;

inverted: {
let illegalCharacterString = "Value must be `true` or a params object. Invalid share providers configuration for in bookend.";
let illegalCharacterTemplate = `Value must be \`true\` or a params object. Invalid ${x}`;
let illegalEscapeValue = `\${foo}Invalid ${x}`;
}

let stringLiterals = "12";
let numberLiterals = 1 + 2;
let booleanLiterals = false + true;
let numberLiterals = 3;
let booleanLiterals = 1;
let identifiers = `${foo}${bar}`;
Expand Up @@ -19,4 +19,7 @@ let start = `${'123'}/foo`;
let middle = `/rtv/${'012003312116250'}/log-messages.simple.json`;
let end = `rtv/${'123'}`;
let number = `${123}/foo`;
let boolean = `${true}/foo`;
let boolean = `${true}/foo`;
let preventEscaping = `\n`;

let xss = `\u1234\n${'`;\nalert("XSS")`;\n'}${foo}`;
Expand Up @@ -13,9 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
let none = `/rtv/100/log-messages.simple.json`;
let start = `123/foo`;
let middle = `/rtv/012003312116250/log-messages.simple.json`;
let end = `rtv/123`;
let number = `123/foo`;
let boolean = `true/foo`;
let none = "/rtv/100/log-messages.simple.json";
let start = "123/foo";
let middle = "/rtv/012003312116250/log-messages.simple.json";
let end = "rtv/123";
let number = "123/foo";
let boolean = "true/foo";
let preventEscaping = "\n";
let xss = `\u1234\n\`;
alert("XSS")\`;
${foo}`;

0 comments on commit 83e96f1

Please sign in to comment.