react/scripts/babel/transform-test-gate-pragma.js

337 lines
8.8 KiB
JavaScript

'use strict';
/* eslint-disable no-for-of-loops/no-for-of-loops */
const getComments = require('./getComments');
function transform(babel) {
const {types: t} = babel;
// A very stupid subset of pseudo-JavaScript, used to run tests conditionally
// based on the environment.
//
// Input:
// @gate a && (b || c)
// test('some test', () => {/*...*/})
//
// Output:
// @gate a && (b || c)
// _test_gate(ctx => ctx.a && (ctx.b || ctx.c), 'some test', () => {/*...*/});
//
// expression → binary ( ( "||" | "&&" ) binary)* ;
// binary → unary ( ( "==" | "!=" | "===" | "!==" ) unary )* ;
// unary → "!" primary
// | primary ;
// primary → NAME | STRING | BOOLEAN
// | "(" expression ")" ;
function tokenize(code) {
const tokens = [];
let i = 0;
while (i < code.length) {
let char = code[i];
// Double quoted strings
if (char === '"') {
let string = '';
i++;
do {
if (i > code.length) {
throw Error('Missing a closing quote');
}
char = code[i++];
if (char === '"') {
break;
}
string += char;
} while (true);
tokens.push({type: 'string', value: string});
continue;
}
// Single quoted strings
if (char === "'") {
let string = '';
i++;
do {
if (i > code.length) {
throw Error('Missing a closing quote');
}
char = code[i++];
if (char === "'") {
break;
}
string += char;
} while (true);
tokens.push({type: 'string', value: string});
continue;
}
// Whitespace
if (/\s/.test(char)) {
if (char === '\n') {
return tokens;
}
i++;
continue;
}
const next3 = code.substring(i, i + 3);
if (next3 === '===') {
tokens.push({type: '=='});
i += 3;
continue;
}
if (next3 === '!==') {
tokens.push({type: '!='});
i += 3;
continue;
}
const next2 = code.substring(i, i + 2);
switch (next2) {
case '&&':
case '||':
case '==':
case '!=':
tokens.push({type: next2});
i += 2;
continue;
case '//':
// This is the beginning of a line comment. The rest of the line
// is ignored.
return tokens;
}
switch (char) {
case '(':
case ')':
case '!':
tokens.push({type: char});
i++;
continue;
}
// Names
const nameRegex = /[a-zA-Z_$][0-9a-zA-Z_$]*/y;
nameRegex.lastIndex = i;
const match = nameRegex.exec(code);
if (match !== null) {
const name = match[0];
switch (name) {
case 'true': {
tokens.push({type: 'boolean', value: true});
break;
}
case 'false': {
tokens.push({type: 'boolean', value: false});
break;
}
default: {
tokens.push({type: 'name', name});
}
}
i += name.length;
continue;
}
throw Error('Invalid character: ' + char);
}
return tokens;
}
function parse(code, ctxIdentifier) {
const tokens = tokenize(code);
let i = 0;
function parseExpression() {
let left = parseBinary();
while (true) {
const token = tokens[i];
if (token !== undefined) {
switch (token.type) {
case '||':
case '&&': {
i++;
const right = parseBinary();
if (right === null) {
throw Error('Missing expression after ' + token.type);
}
left = t.logicalExpression(token.type, left, right);
continue;
}
}
}
break;
}
return left;
}
function parseBinary() {
let left = parseUnary();
while (true) {
const token = tokens[i];
if (token !== undefined) {
switch (token.type) {
case '==':
case '!=': {
i++;
const right = parseUnary();
if (right === null) {
throw Error('Missing expression after ' + token.type);
}
left = t.binaryExpression(token.type, left, right);
continue;
}
}
}
break;
}
return left;
}
function parseUnary() {
const token = tokens[i];
if (token !== undefined) {
if (token.type === '!') {
i++;
const argument = parseUnary();
return t.unaryExpression('!', argument);
}
}
return parsePrimary();
}
function parsePrimary() {
const token = tokens[i];
switch (token.type) {
case 'boolean': {
i++;
return t.booleanLiteral(token.value);
}
case 'name': {
i++;
return t.memberExpression(ctxIdentifier, t.identifier(token.name));
}
case 'string': {
i++;
return t.stringLiteral(token.value);
}
case '(': {
i++;
const expression = parseExpression();
const closingParen = tokens[i];
if (closingParen === undefined || closingParen.type !== ')') {
throw Error('Expected closing )');
}
i++;
return expression;
}
default: {
throw Error('Unexpected token: ' + token.type);
}
}
}
const program = parseExpression();
if (tokens[i] !== undefined) {
throw Error('Unexpected token');
}
return program;
}
function buildGateCondition(comments) {
let conditions = null;
for (const line of comments) {
const commentStr = line.value.trim();
if (commentStr.startsWith('@gate ')) {
const code = commentStr.slice(6);
const ctxIdentifier = t.identifier('ctx');
const condition = parse(code, ctxIdentifier);
if (conditions === null) {
conditions = [condition];
} else {
conditions.push(condition);
}
}
}
if (conditions !== null) {
let condition = conditions[0];
for (let i = 1; i < conditions.length; i++) {
const right = conditions[i];
condition = t.logicalExpression('&&', condition, right);
}
return condition;
} else {
return null;
}
}
return {
name: 'test-gate-pragma',
visitor: {
ExpressionStatement(path) {
const statement = path.node;
const expression = statement.expression;
if (expression.type === 'CallExpression') {
const callee = expression.callee;
switch (callee.type) {
case 'Identifier': {
if (
callee.name === 'test' ||
callee.name === 'it' ||
callee.name === 'fit'
) {
const comments = getComments(path);
if (comments !== undefined) {
const condition = buildGateCondition(comments);
if (condition !== null) {
callee.name =
callee.name === 'fit' ? '_test_gate_focus' : '_test_gate';
expression.arguments = [
t.arrowFunctionExpression(
[t.identifier('ctx')],
condition
),
...expression.arguments,
];
}
}
}
break;
}
case 'MemberExpression': {
if (
callee.object.type === 'Identifier' &&
(callee.object.name === 'test' ||
callee.object.name === 'it') &&
callee.property.type === 'Identifier' &&
callee.property.name === 'only'
) {
const comments = getComments(path);
if (comments !== undefined) {
const condition = buildGateCondition(comments);
if (condition !== null) {
statement.expression = t.callExpression(
t.identifier('_test_gate_focus'),
[
t.arrowFunctionExpression(
[t.identifier('ctx')],
condition
),
...expression.arguments,
]
);
}
}
}
break;
}
}
}
return;
},
},
};
}
module.exports = transform;