Practical application of transformation of AST-trees on the example of Putout
- Tutorial
Introduction
Every day when working on the code, on the way to implementing useful functionality for the user, there are forced (inevitable, or simply desirable) changes to the code. This may be refactoring, updating a library or framework to a new major version, updating JavaScript syntax (which is not uncommon recently). Even if the library is part of a working draft - changes are inevitable. Most of these changes are routine. There is nothing interesting for the developer on the one hand, on the other it does not bring anything to the business, and on the third, in the update process, you need to be very careful not to break the wood and break the functionality. Thus, we come to the conclusion that it is better to shift this routine onto the shoulders of the programs, what would they all do themselves, and the person, in turn, whether everything is properly controlled. This is what today's article will be about.
AST
For programmatic processing of the code, it is necessary to translate it into a special presentation with which it would be convenient for the programs to work. Such a representation exists, it is called Abstract Syntax Tree (AST).
In order to get it, use parsers. The resulting AST can be transformed as you like, and then to save the result you need a code generator. Let us consider in more detail each of the steps. Let's start with the parser.
Parser
And so we have the code:
a + b
Usually parsers are divided into two parts:
- Lexical analysis
Splits the code into tokens, each of which describes a part of the code:
[{
"type": "Identifier",
"value": "a"
}, {
"type": "Punctuator",
"value": "+",
}, {
"type": "Identifier",
"value": "b"
}]
- Syntax analysis.
Builds a syntax tree of tokens:
{
"type": "BinaryExpression",
"left": {
"type": "Identifier",
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"name": "b"
}
}
And here we already have the very idea with which you can work programmatically. It should be clarified that there are a large number of parsers JavaScript
, here are some of them:
- babel-parser - the parser that uses
babel
; - espree - the parser that uses
eslint
; - acorn is the parser on which the previous two are based;
- esprima is a popular parser that supports JavaScript up to EcmaScript 2017;
- cherow is a new player among JavaScript parsers, claiming to be the fastest;
There is a standard JavaScript parsers, it is called ESTree and determines which nodes should parse as it should.
For a more detailed analysis of the implementation process of the parser (as well as the transformer and generator) you can read super-tiny-compiler .
Transformer
In order to convert an AST tree, you can use the Visitor pattern , for example, using the @ babel / traverse library . The following code will output the names of all the JavaScript code identifiers from the variable code
.
import * as parser from"@babel/parser";
import traverse from"@babel/traverse";
const code = `function square(n) {
return n * n;
}`;
const ast = parser.parse(code);
traverse(ast, {
Identifier(path) {
console.log(path.node.name);
}
});
Generator
You can generate code, for example, using @ babel / generator , thus:
import {parse} from'@babel/parser';
import generate from'@babel/generator';
const code = 'class Example {}';
const ast = parse(code);
const output = generate(ast, code);
And so, at this stage, the reader had to get a basic idea of what is needed to transform JavaScript code, and with what tools this is implemented.
It is necessary to add such an online tool as astexplorer , it combines a large number of parsers, transformers and generators.
Putout
Putout is a code transformer with plug-in support. In fact, it is a cross between eslint and babel , combining the advantages of both tools.
As it eslint
putout
shows problem areas in the code, but in contrast, it eslint
putout
changes the behavior of the code, that is, it is able to correct all errors that it can find.
As well as babel
putout
converts the code, but tries to change it minimally, so it can be used to work with the code that is stored in the repository.
Another worth mentioning is the prettier , it is a formatting tool, and it differs radically.
Jscodeshiftputout
is not very far from it , but it does not support plugins, does not display error messages, and also uses ast-types instead of @ babel / types .
Appearance history
In the process of work it helps me a lot with my hints eslint
. But sometimes you want more from him. For example, to remove a debugger , fix test.only , and also delete unused variables. The last point formed the basis putout
, during the development process, it became clear that this is not very simple and many other transformations are much easier to implement. Thus, it putout
gradually grew from one function to the plugin system. Removing unused variables is still the most difficult process, but this does not prevent us from developing and maintaining many other equally useful transformations.
How Putout Works from the Inside
Work putout
can be divided into two parts: the engine and plugins. This architecture allows you to not be distracted by the transformation when working with the engine, and when working on plug-ins you will focus on their purpose.
Built-in plugins
The work putout
is based on the plugin system. Each plugin represents one rule. Using the built-in rules, you can do the following:
Find and remove:
- unused variables
debugger
- call
test.only
- call
test.skip
- call
console.log
- call
process.exit
- empty blocks
- empty patterns
Find and split variable declarations:
// былоvar one, two; // станетvar one; var two;
Convert
esm
tocommonjs
:
// былоimport one from'one';
// станетconst one = require('one');
- Apply destructuring:
// былоconst name = user.name;
// станетconst {name} = user;
- Combine unstructure properties:
// былоconst {name} = user;
const {password} = user;
// станетconst {
name,
password
} = user;
Each plug-in is built according to the Unix Philosophy , that is, they are as simple as possible, each performs one action, making them easy to combine, because they, in essence, are filters.
For example, having the following code:
const name = user.name;
const password = user.password;
It is first converted into the following using apply-destructuring :
const {name} = user;
const {password} = user;
After that, using merge-destructuring-properties is converted to:
const {
name,
password
} = user;
Thus, plug-ins can work both separately and together. When creating your own plugins, it is recommended to adhere to this rule, and implement a plug-in with minimal functionality that does only what you need, and the plug-in and user plug-ins take care of the rest.
Usage example
After we are familiar with the built-in rules, we can consider an example of use putout
.
Create a file example.js
with the following contents:
const x = 1, y = 2;
const name = user.name;
const password = user.password;
console.log(name, password);
Now run putout
, passing as an argument example.js
:
coderaiser@cloudcmd:~/example$ putout example.js
/home/coderaiser/example/example.js
1:6 error "x" is defined but never used remove-unused-variables
1:13 error "y" is defined but never used remove-unused-variables
6:0 error Unexpected "console" call remove-console
1:0 error variables should be declared separately split-variable-declarations
3:6 error Object destructuring should be used apply-destructuring
4:6 error Object destructuring should be used apply-destructuring
6 errors in 1 files
fixable with the `--fix` option
We will receive information containing 6 errors, discussed in more detail above, now we will correct them, and see what happened:
coderaiser@cloudcmd:~/example$ putout example.js --fix
coderaiser@cloudcmd:~/example$ cat example.js
const {
name,
password
} = user;
As a result of the correction, unused variables and calls console.log
were removed, and destructuring was also applied.
Settings
The default settings are not always and not everyone can come up, therefore, putout
supports the configuration file .putout.json
, it consists of the following sections:
- Rules
- Ignore
- Match
- Plugins
Rules
The section rules
contains a system of rules. The rules, by default, are set as follows:
{
"rules": {
"remove-unused-variables": true,
"remove-debugger": true,
"remove-only": true,
"remove-skip": true,
"remove-process-exit": false,
"remove-console": true,
"split-variable-declarations": true,
"remove-empty": true,
"remove-empty-pattern": true,
"convert-esm-to-commonjs": false,
"apply-destructuring": true,
"merge-destructuring-properties": true
}
}
In order to enable it remove-process-exit
enough to put it in true
the file .putout.json
:
{
"rules": {
"remove-process-exit": true
}
}
This will be enough to report all the calls process.exit
found in the code, and delete them if the parameter is used --fix
.
Ignore
If you need to add some folders to the list of exceptions, it’s enough to add a section ignore
:
{
"ignore": [
"test/fixture"
]
}
Match
In case of need of an extensive system of rules, for example, include process.exit
for the directory bin
, it is enough to use the section match
:
{
"match": {
"bin": {
"remove-process-exit": true,
}
}
}
Plugins
In the case of using plugins that are not embedded and have a prefix putout-plugin-
, they must be included in the section plugins
before activating in the section rules
. For example, to connect the plugin putout-plugin-add-hello-world
and enable the rule add-hello-world
, it is enough to specify:
{
"rules": {
"add-hello-world": true
},
"plugins": [
"add-hello-world"
]
}
Putout engine
The engine putout
is a command line tool that reads settings, parses files, loads and launches plugins for execution, and then writes the result of the plugins.
It uses the recast library , which helps to accomplish a very important task: after parsing and transformation, collect the code in a state as close as possible to the previous one.
For parsing, a ESTree
compatible parser is used (currently babel
with a plugin estree
, but changes are possible in the future), and tools for transformation babel
. Why exactly babel
? It's simple. The fact is that this is a very popular product, much more popular than other similar tools, and it develops much more rapidly. Each new proposal in the standard EcmaScript can not do without a babel-plugin . There babel
is also a book called Babel Handbook in which all the features and tools for circumventing and transforming an AST tree are described quite well.
Own plugin for Putout
The plugin system is putout
quite simple, and very similar to the eslint plugins , as well as the babel plugins . True, instead of one function, the putout
plugin should export 3. This is done to increase the reuse of the code, because duplicating the functionality in 3 functions is not very convenient, it is much easier to put it into separate functions and just call it in the right places.
Plugin structure
So the Putout
plugin consists of 3 functions:
report
- returns the message;find
- looks for places with errors and returns them;fix
- corrects these places;
The main point to remember when creating a plugin for putout
this is its name, it should start with putout-plugin-
. Then there can be the name of the operation which carries a plug, such as plug-in remove-wrong
must be named as follows: putout-plugin-remove-wrong
.
The same should be added to package.json
, in the section the keywords
words: putout
and putout-plugin
, and to peerDependencies
indicate "putout": ">=3.10"
, or the version that will be the last at the time of writing the plugin.
Example plugin for Putout
For example, let's write a plugin that will remove a word debugger
from the code. Such a plugin is already there, it is @ putout / plugin-remove-debugger and it is simple enough to consider it now.
It looks like this:
// возвращаем ошибку соответствующую каждому из найденых узловmodule.exports.report = () =>'Unexpected "debugger" statement';
// в этой функции ищем узлы, содержащией debugger с помощью паттерна Visitormodule.exports.find = (ast, {traverse}) => {
const places = [];
traverse(ast, {
DebuggerStatement(path) {
places.push(path);
}
});
return places;
};
// удаляем код, найденный в предыдущем шагеmodule.exports.fix = (path) => {
path.remove();
};
If the rule is remove-debugger
enabled .putout.json
, the plugin @putout/plugin-remove-debugger
will be loaded. First, find
a function traverse
will be called which by means of the function will bypass the nodes of the AST-tree and save all the necessary places.
The next step putout
will turn to report
for getting the message you want.
If the flag --fix
is used, the function of fix
the plug-in will be called , and the transformation will be performed, in this case, the node is deleted.
Sample plugin test
In order to simplify the testing of plugins, the @ putout / test tool was written . At its core, this is nothing more than a wrapper over a tape , with several methods for convenience and ease of testing.
The test for the plugin remove-debugger
may look like this:
const removeDebugger = require('..');
const test = require('@putout/test')(__dirname, {
'remove-debugger': removeDebugger,
});
// проверяем что бы сообщение было именно таким
test('remove debugger: report', (t) => {
t.reportCode('debugger', 'Unexpected "debugger" statement');
t.end();
});
// проверяем результат трансформации
test('remove debugger: transformCode', (t) => {
t.transformCode('debugger', '');
t.end();
});
Codemods
Not any transformation needs to be used every day, for one-time transformations it is enough to do everything the same, only instead of publishing to npm
place in a folder ~/.putout
. At startup, it putout
will look into this folder, pick up and start transformations.
Here is an example transformation that replaces the connection tape
and try-to-tape with a supertape call : convert-tape-to-supertape .
eslint-plugin-putout
Finally, it’s worth adding one thing: putout
trying to change the code minimally, but if it happens to a friend that some formatting rules break, you are always ready to help eslint --fix
, and for this purpose there is a special plugin eslint-plugin-putout . It can brighten up many formatting errors, and of course it can be configured in accordance with the preferences of developers on a particular project. Connect it easily:
{
"extends": [
"plugin:putout/recommended",
],
"plugins": [
"putout"
]
}
So far there is only one rule in it: one-line-destructuring
it does the following:
// былоconst {
one
} = hello;
// станетconst {one} = hello;
There are still a lot of included rules eslint
that can be found in more detail .
Conclusion
I want to thank the reader for paying attention to this text. I sincerely hope that the topic of AST transformations will become more popular, and articles about this fascinating process will appear more often. I would be very grateful for any comments and suggestions related to the future direction of development putout
. Create an issue , send a pool of requests , test, write what rules you would like to see, and how to convert your code programmatically, we will work together to improve the AST transformation tool.