Writing a plugin for Babel

Let's start with the “skeleton”. A plugin is a function that returns an object with visitors. By argument, an object with modules from babel-core is passed to it . In the future, we will need the babel-types module .
exportdefaultfunction({types: t}) {
return {
visitor: {}
};
}
A visitor is a method of an object
visitorwhose name corresponds to the node type of an abstract syntax tree (ASD), for example, FunctionDeclarationor StringLiteral(a complete list ) into which the path to the node is passed. We are interested in type nodes Identifier.exportdefaultfunction({types: t}) {
return {
visitor: {
Identifier(path, {opts: options}) {
}
}
};
}
Also, the visitor has access to the plugin settings in the property of the
.optssecond argument. Through them we will pass the names of the variables and the paths to the modules for which the import will be created. It will look like this: .babelrc
{
plugins: [[
"babel-plugin-auto-import",
{ declarations: [{name: "React", path: "react"}] }
]]
}
Bypass ASD. The way. Knots
Babel accepts some code (in the form of a string), which is divided into tokens, from which the ASD is built . Then the plugins change the ASD, and a new code is generated from it, which is fed to the output. To manipulate ASD, plugins use paths. You can also check through the paths what type of node this path represents. There are format methods for this
.["is" + тип узла](). Eg path.isIdentifier(). A path can search among child paths using a method .find(callback), and among parent paths using a method .findParent(callback). The property .parentPathstores a link to the parent path. Let's start writing the plugin itself. And first of all, you need to filter the identifiers. Type of
Identifierwidely used in various types of nodes. We need only some of them. Suppose we have code like this:React.Component
The SDA for this code looks like this:
{
type: "MemberExpression",
object: {
type: "Identifier",
name: "React"
},
property: {
type: "Identifier",
name: "Component"
},
computed: false
}
A node is an object with a property
.typeand some other properties specific to each type. Consider the root node MemberExpression. It has three properties. ObjectIs the expression to the left of the point. In this case, this is an identifier. The property computedindicates whether the identifier or some expression will be on the right, for example x["a" + b]. Property- in fact, that to the right of the point. If we run our plugin framework now, the method
Identifierwill be called twice: for identifiers Reactand, Componentrespectively. The plugin should process the identifier React, but skip the identifier Component. To do this, the identifier path must obtain the parent path and, if it is a type node MemberExpression, check whether the identifier is a property.objectat MemberExpression. Take out the verification in a separate function:exportdefaultfunction({types: t}) {
return {
visitor: {
Identifier(path, {opts: options}) {
if (!isCorrectIdentifier(path))
return;
}
}
};
functionisCorrectIdentifier(path) {
let {parentPath} = path;
if (parentPath.isMemberExpression() && parentPath.get("object") == path)
returntrue;
}
}
There will be many such checks in the final version - each case has its own check. But they all work on the same principle.
functionisCorrectIdentifier(path) {
let {parentPath} = path;
if (parentPath.isArrayExpression())
returntrue;
elseif (parentPath.isArrowFunctionExpression())
returntrue;
elseif (parentPath.isAssignmentExpression() && parentPath.get("right") == path)
returntrue;
elseif (parentPath.isAwaitExpression())
returntrue;
elseif (parentPath.isBinaryExpression())
returntrue;
elseif (parentPath.bindExpression && parentPath.bindExpression())
returntrue;
elseif (parentPath.isCallExpression())
returntrue;
elseif (parentPath.isClassDeclaration() && parentPath.get("superClass") == path)
returntrue;
elseif (parentPath.isClassExpression() && parentPath.get("superClass") == path)
returntrue;
elseif (parentPath.isConditionalExpression())
returntrue;
elseif (parentPath.isDecorator())
returntrue;
elseif (parentPath.isDoWhileStatement())
returntrue;
elseif (parentPath.isExpressionStatement())
returntrue;
elseif (parentPath.isExportDefaultDeclaration())
returntrue;
elseif (parentPath.isForInStatement())
returntrue;
elseif (parentPath.isForStatement())
returntrue;
elseif (parentPath.isIfStatement())
returntrue;
elseif (parentPath.isLogicalExpression())
returntrue;
elseif (parentPath.isMemberExpression() && parentPath.get("object") == path)
returntrue;
elseif (parentPath.isNewExpression())
returntrue;
elseif (parentPath.isObjectProperty() && parentPath.get("value") == path)
return !parentPath.node.shorthand;
elseif (parentPath.isReturnStatement())
returntrue;
elseif (parentPath.isSpreadElement())
returntrue;
elseif (parentPath.isSwitchStatement())
returntrue;
elseif (parentPath.isTaggedTemplateExpression())
returntrue;
elseif (parentPath.isThrowStatement())
returntrue;
elseif (parentPath.isUnaryExpression())
returntrue;
elseif (parentPath.isVariableDeclarator() && parentPath.get("init") == path)
returntrue;
returnfalse;
}
Variable Scope
The next step is to check if our identifier is declared as a local variable or is global. There is one useful property in the paths for this -
scope. With it, we will iterate over all areas of visibility, starting with the current one. The variables of the current scope are in the property .bindings. A reference to the parent scope is in the property .parent. It remains to recursively go through all the variables of all scopes and check if our identifier is found there.exportdefaultfunction({types: t}) {
return {
visitor: {
Identifier(path, {opts: options}) {
if (!isCorrectIdentifier(path))
return;
let {node: identifier, scope} = path;
if (isDefined(identifier, scope))
return;
}
}
};
// ...functionisDefined(identifier, {bindings, parent}) {
let variables = Object.keys(bindings);
if (variables.some(has, identifier))
returntrue;
return parent ? isDefined(identifier, parent) : false;
}
functionhas(identifier) {
let {name} = this;
return identifier == name;
}
}
Fine! Now we are sure that you can work with the identifier. Take from the
optionsdeclaration of “global” variables and process them:let {declarations} = options;
declarations.some(declaration => {
if (declaration.name == identifier.name) {
let program = path.findParent(path => path.isProgram());
insertImport(program, declaration);
returntrue;
}
});
Modification of ASD
And so we got to the change in the ASD. But before you start inserting new imports, we get all the existing ones. To do this, we use the method
.reduceto get an array with paths of the type ImportDeclaration:functioninsertImport(program, { name, path }) {
let programBody = program.get("body");
let currentImportDeclarations =
programBody.reduce(currentPath => {
if (currentPath.isImportDeclaration())
list.push(currentPath);
return list;
}, []);
}
Now let's check if our identifier is already connected:
let importDidAppend =
currentImportDeclarations.some(({node: importDeclaration}) => {
if (importDeclaration.source.value == path) {
return importDeclaration.specifiers.some(specifier => specifier.local.name == name);
}
});
If the module is not connected, create a new import node and paste it into the program.
To create nodes, use the babel-types module . The link to it is in the variable
t. Each node has its own method. We need to create importDeclaration. We look at the documentation and see that to create an import, qualifiers (i.e. the names of the imported variables) and the path to the module are required. First, create a qualifier. Our plugin connects the modules as exported by default (
export default ...). Then create a node with the path to the module. This is a simple type string StringLiteral.let specifier = t.importDefaultSpecifier(t.identifier(name));
let pathToModule = t.stringLiteral(path);
Well, we have everything to create an import:
let importDeclaration = t.importDeclaration([specifier], pathToModule);
It remains to insert the node into the ASD. For this we need a way. The path can be replaced with a node using the method
.replaceWith(node), or an array of nodes using the method .replaceWithMultiple([...nodes]). It can be deleted using the method .remove(). The methods are used for insertion .insertBefore(node)and .insertAfter(node), to insert the node before or after the path, respectively. In our case, the import must be inserted into the so-called container. The node
programhas a property .bodythat contains an array of expressions that represent the program. To insert nodes into such “container” arrays, paths have special methods pushContainerand unshiftContainer. We use the last one:program.unshiftContainer("body", importNode);
The plugin is ready. We got acquainted with the basic Babel APIs, examined the principles of the device and the operation of plugins. The plugin we made is a simplified version that does not work correctly. But with the knowledge gained, you can easily read the full plugin code . I hope the article was interesting, and the experience gained useful. So tnank you!