Введение
Каждый день при работе над кодом, на пути к реализации полезного для пользователя функционала, становятся вынужденные (неизбежные, либо же просто желательные) изменения кода. Это может быть рефакторинг, обновление библиотеки или фреймворка до новой мажорной версии, обновление синтаксиса JavaScript (что в последнее время совсем не редкость). Даже если библиотека является частью рабочего проекта — изменения неизбежны. Большинство таких изменений — это рутина. В них нет ничего интересного для разработчика с одной стороны, с другой это не приносит ничего бизнесу, а с третьей, в процессе обновления нужно быть очень внимательным что бы не наломать дров и не поломать функционал. Таким образом мы приходим к тому, что такую рутину лучше переложить на плечи программ, что бы они все делали сами, а человек, в свою очередь, контролировал все ли правильно сделано. Вот об этом и пойдет речь в сегодняшней статье.
AST
Для программной обработки кода необходимо его перевести в особое представление, с которым было бы удобно работать программам. Такое представление существует, оно называется Абстрактное Синтаксическое Дерево (Abstract Syntax Tree, AST).
Для того, что бы его получить, используют парсеры. Полученный AST можно трансформировать как угодно, а что бы потом сохранить результат нужен кодогенератор. Рассмотрим детальнее каждый из шагов. Начнем с парсера.
ПАРСЕР
И так у нас есть код:
a + b
Обычно парсеры делятся на две части:
- Лексический анализ
Разбивает код на токены, каждый из которых описывает часть кода:
[{
"type": "Identifier",
"value": "a"
}, {
"type": "Punctuator",
"value": "+",
}, {
"type": "Identifier",
"value": "b"
}]
- Синтаксический анализ.
Строит из токенов синтаксическое дерево:
{
"type": "BinaryExpression",
"left": {
"type": "Identifier",
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"name": "b"
}
}
И вот у нас уже есть то самое представление, с которым можно программно работать. Стоит уточнить, что существует большое количество парсеров JavaScript
, вот некоторые из них:
- babel-parser — парсер, который использует
babel
; - espree — парсер, который использует
eslint
; - acorn — парсер, на котором основаны предыдущие два;
- esprima — популярный парсер, поддерживающий JavaScript вплоть до EcmaScript 2017;
- cherow — новый игрок среди JavaScript-парсеров, заявляющий, что он самый быстрый;
Существует стандарт JavaScript парсеров, он называется ESTree и определяет то, какие узлы как должны парсится.
Для более детально разбора процесса реализации парсера (а так же трансформатора и генератора) можно почитать super-tiny-compiler.
Трансформатор
Для того, что бы преобразовать AST-дерево можно использовать паттерн Visitor, с помощью, например, библиотеки @babel/traverse. Следующий код выведет имена всех идентификаторов JavaScript кода из переменной 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);
}
});
Генератор
Генерировать код можно, к примеру, с помощью @babel/generator, таким образом:
import {parse} from '@babel/parser';
import generate from '@babel/generator';
const code = 'class Example {}';
const ast = parse(code);
const output = generate(ast, code);
И так, на данном этапе читатель должен был получить базовое представление о том, что нужно для трансформации JavaScript кода, и с помощью каких инструментов это реализуется.
Стоит еще добавить такой онлайн инструмент как astexplorer, он совмещает в себе большое количество парсеров, трансформаторов и генераторов.
Putout
Putout — это трансформатор кода с поддержкой плагинов. По сути это нечто среднее между eslint и babel, объединяющее в себе достоинства обоих инструментов.
Как eslint
putout
показывает проблемные места в коде, но в отличие от eslint
putout
меняет поведение кода, то есть способен исправлять все ошибки которые сможет найти.
Как и babel
putout
преобразовывает код, но старается его минимально менять, таким образом его можно применять для работы с кодом, который хранится в репозитории.
Еще стоит упомянуть prettier, это инструмент форматирования, и отличается он кардинальным образом.
Очень не далеко от putout
находится jscodeshift, но он не поддерживает плагины, не показывает сообщения об ошибках, а так же использует ast-types вместо @babel/types.
История появления
В процессе работы мне очень помогает своими подсказками eslint
. Но иногда от него хочется большего. К примеру, что бы он удалял debugger, исправлял test.only, а так же удалял неиспользуемые переменные. Последний пункт лег в основу putout
, в процессе разработки, стало понятно, что это очень не просто и многие другие трансформации осуществить гораздо проще. Таким образом putout
плавно перерос из одной функции в систему плагинов. Удаление неиспользуемых переменных и сейчас является самым сложным процессом, но это совсем не мешает развивать и поддерживать многие другие не менее полезные трансформации.
Как Putout устроен изнутри
Работу putout
можно поделить на две части: движок и плагины. Такая архитектура позволяет при работе с движком не отвлекаться на трансформации, а при работе над плагинами максимально сосредоточится над их предназначением.
Встроенные плагины
Работа putout
строится на системе плагинов. Каждый плагин представляет собой одно правило. С помощью встроенных правил можно сделать следующее:
Найти и удалить:
- не используемые переменные
debugger
- вызов
test.only
- вызов
test.skip
- вызов
console.log
- вызов
process.exit
- пустые блоки
- пустые паттерны
Найти и разбить объявление переменных:
// было var one, two; // станет var one; var two;
Конвертировать
esm
вcommonjs
:
// было
import one from 'one';
// станет
const one = require('one');
- Применить деструктуризацию:
// было
const name = user.name;
// станет
const {name} = user;
- Объединить свойства деструктуризации:
// было
const {name} = user;
const {password} = user;
// станет
const {
name,
password
} = user;
Каждый плагин строится согласно Философии Unix, то есть они максимально просты, каждый выполняет одно действие, благодаря чему их легко комбинировать, ведь они, по своей сути, являются фильтрами.
К примеру, имея следующий код:
const name = user.name;
const password = user.password;
Он вначале с помощью apply-destructuring преобразуется в:
const {name} = user;
const {password} = user;
После чего, с помощью merge-destructuring-properties преобразуется в:
const {
name,
password
} = user;
Таким образом плагины могут работать как отдельно, так и вместе. При создании собственных плагинов рекомендуется придерживаться этого правила, и реализовывать плагин с минимальным функционалом, делающий только то, что нужно, а обо всем остальном позаботятся встроенные и пользовательские плагины.
Пример использования
После того как мы ознакомились со встроенными правилами, мы можем рассмотреть пример использования putout
.
Создадим файл example.js
со следующим содержимым:
const x = 1, y = 2;
const name = user.name;
const password = user.password;
console.log(name, password);
Теперь запустим putout
, передав в качестве аргумента 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
Мы получим информацию содержащую 6 ошибок, рассмотренных более детально выше, теперь исправим их, и посмотрим, что получилось:
coderaiser@cloudcmd:~/example$ putout example.js --fix
coderaiser@cloudcmd:~/example$ cat example.js
const {
name,
password
} = user;
В результате исправления неиспользуемые переменные и вызовы console.log
были удалены, так же была применена деструктуризация.
Настройки
Настройки по умолчанию не всегда и не всем могут подойти, поэтому putout
поддерживает конфигурационный файл .putout.json
, он состоит из следующих разделов:
- Rules
- Ignore
- Match
- Plugins
Rules
Секция rules
содержит систему правил. Правила, по умолчанию, выставлены следующим образом:
{
"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
}
}
Для того что бы включить remove-process-exit
достаточно выставить его в true
в файле .putout.json
:
{
"rules": {
"remove-process-exit": true
}
}
Этого будет достаточно для того, что бы сообщать обо всех вызовах process.exit
найденные в коде, и удалять их в случае использования параметра --fix
.
Ignore
Если какие-то папки необходимо добавить в список исключений, достаточно добавить секцию ignore
:
{
"ignore": [
"test/fixture"
]
}
Match
В случае необходимости разветвленной системы правил, например, включить process.exit
для каталога bin
, достаточно воспользоваться секцией match
:
{
"match": {
"bin": {
"remove-process-exit": true,
}
}
}
Plugins
В случае использования плагинов, которые не встроены и имеют префикс putout-plugin-
, их необходимо включить в секцию plugins
, прежде чем активировать в разделе rules
. К примеру для подключения плагина putout-plugin-add-hello-world
и включения правила add-hello-world
, достаточно указать:
{
"rules": {
"add-hello-world": true
},
"plugins": [
"add-hello-world"
]
}
Движок Putout
Движок putout
это инструмент командной строки, который читает настройки, парсит файлы, загружает и запускает на выполнение плагины, после чего записывает результат работы плагинов.
Он использует библиотеку recast, которая помогает осуществить очень важную задачу: после парсинга и трансформации собрать код в состояние, максимально похожее на прежнее.
Для парсинга используется ESTree
-совместимый парсер (в данный момент babel
с плагином estree
, но в будущем возможны изменения), а для трансформации инструменты babel
. Почему именно babel
? Все просто. Дело в том, что это очень популярный продукт, значительно популярнее чем остальные похожие инструменты, и развивается он гораздо стремительнее. Каждое новое предложение в стандарт EcmaScript не обходится без babel-плагина. Еще у babel
есть книга Babel Handbook в которой очень неплохо описаны все возможности и инструменты для обхода и трансформации AST-дерева.
Свой плагин для Putout
Система плагинов putout
достаточно проста, и очень похожа на плагины eslint, а так же плагины babel. Правда вместо одной функции putout
-плагин должен экспортировать 3. Сделано это для того, что бы увеличить переиспользование кода, ведь дублировать функционал в 3-ех функциях не очень удобно, гораздо проще его вынести в отдельные функции и просто вызывать в нужных местах.
Структура плагина
Итак Putout
плагин состоит из 3-ех функций:
report
— возвращает сообщение;find
— ищет места с ошибками и возвращает их;fix
— исправляет эти места;
Основной момент который стоит помнить при создании плагина для putout
это его название, оно должно начинаться с putout-plugin-
. Дальше может идти название операции которую плагин осуществляет, например плагин remove-wrong
должен называться так: putout-plugin-remove-wrong
.
Так же следует добавить в package.json
, в секцию keywords
слова: putout
и putout-plugin
, а в peerDependencies
указать "putout": ">=3.10"
, или той версии которая будет последней на момент написания плагина.
Пример плагина для Putout
Давайте для примера напишем плагин который будет удалять слово debugger
из кода. Такой плагин уже есть, это @putout/plugin-remove-debugger и он достаточно прост, что бы его сейчас рассмотреть.
Выглядит он таким образом:
// возвращаем ошибку соответствующую каждому из найденых узлов
module.exports.report = () => 'Unexpected "debugger" statement';
// в этой функции ищем узлы, содержащией debugger с помощью паттерна Visitor
module.exports.find = (ast, {traverse}) => {
const places = [];
traverse(ast, {
DebuggerStatement(path) {
places.push(path);
}
});
return places;
};
// удаляем код, найденный в предыдущем шаге
module.exports.fix = (path) => {
path.remove();
};
Если правило remove-debugger
включено в .putout.json
, плагин @putout/plugin-remove-debugger
будет загружен. Сперва вызовется функция find
которая с помощью функции traverse
обойдет узлы AST-дерева и сохранит все нужные места.
Следующим шагом putout
обратится к report
для получения нужного сообщения.
В случае использования флага --fix
будет вызвана функция fix
у плагина, и выполнится трансформация, в данном случае — удаление узла.
Пример теста плагина
Для того, что бы упростить тестирование плагинов был написан инструмент @putout/test. По своей сути это ни что иное, как обертка над tape, с несколькими методами для удобства и упрощения тестирования.
Тест для плагина remove-debugger
может выглядит таким образом:
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
Не любую трансформацию нужно использовать каждый день, для разовых трансформаций достаточно сделать все тоже самое, только вместо публикации в npm
разместить в папке ~/.putout
. При запуске putout
посмотрит в эту папку, подхватит и запустит трансформации.
Вот пример трансформации, который заменяет подключение tape
и try-to-tape вызовом supertape: convert-tape-to-supertape.
eslint-plugin-putout
Напоследок стоит добавить один момент: putout
старается минимально менять код, но если в друг так случится, что некоторые правила форматирования поломаются, на помощь всегда готов прийти eslint --fix
, и для этой цели есть специальный плагин eslint-plugin-putout. Он может скрасить многие ошибки форматирования, и конечно же может быть настроен в соответствии с предпочтениями разработчиков на конкретном проекте. Подключить его легко:
{
"extends": [
"plugin:putout/recommended",
],
"plugins": [
"putout"
]
}
Пока что в нем только одно правило: one-line-destructuring
, делает оно следующее:
// было
const {
one
} = hello;
// станет
const {one} = hello;
Еще есть много включенных правил eslint
, с которыми можно ознакомится более детально.
Заключение
Хочу поблагодарить читателя за уделенное этому тексту внимание. Искренне надеюсь, что тема AST-трансформаций станет более популярна, и статьи об этом увлекательном процессе будут появляться чаще. Буду очень признателен любым замечаниям и предложениям связанным с дальнейшим направлением развития putout
. Создавайте issue, присылайте пул реквесты, тестируйте, пишите какие правила хотели бы видеть, и как преобразовывать программно свой код, будем совместными усилиями работать над улучшением инструмента трансформации AST.
Комментарии (5)
Alternator
11.02.2019 18:59Да, это действительно медленее, вы уверены, что babel и eslint объединяют параметры всех визиторов и обходят их за один проход?
Для babel 5-ой версии параллельный обход всеми плагинами был штатным способом.
И был отдельный ключ(не помню для CLI, или babelRc), который гонял плагины последовательно
И в сложных плагинах(когда замена производилась не для текущей ноды) паралельный обход создавал трудности
Хотя кроме воркэраунда с опцией был еще вариант повесить плагин на Program:exit, и совершить траверс самостоятельно, но это не оптимально и не рекомендовалось
Кстати апи по мержу визиторов в один удобно было использовать для разбития одного плагина на несколько логических частей для упрощение поддержки
А вот в текущем babel, и eslint я не знаю как сейчас организована работаmn3m0n1c_3n3m1 Автор
12.02.2019 11:52Для babel 5-ой версии параллельный обход всеми плагинами был штатным способом.
И был отдельный ключ(не помню для CLI, или babelRc), который гонял плагины последовательно
И в сложных плагинах(когда замена производилась не для текущей ноды) паралельный обход создавал трудностиЭто действительно создает трудности, в прочем, для
putout
в данный момент времени, мало смысла в паралельной обработке, поскольку, работает он достаточно быстро, и разрабатывается на компьютере, на котором прирост скорости от использования redrun имеет огромное значение, если учесть что для большинства пользователей ускорение оказалось не особо ощутимым,putout
у них будет летать :). В прочем, в случае медленной работы у пользователей, можно будет подумать об ускорении. Что вы скажете о скорости? У вас быстро файлы трансформируются?
Кстати апи по мержу визиторов в один удобно было использовать для разбития одного плагина на несколько логических частей для упрощение поддержки
Как выглядел этот апи? Я не нашел об этом информации.
Alternator
12.02.2019 18:10Что вы скажете о скорости? У вас быстро файлы трансформируются?
Когда я плотно этим занимался, я не задумывался и не сравнивал скорость этих режимов.
Просто принял его как данность, и исправлял плагин с учетом коллизий
Сейчас у меня нету проектов где я плотно использую babel, поэтому особо не на чем сейчас проверить
Можете сами поэксперементировать. Опция для переключения режима — passPerPreset
Как выглядел этот апи? Я не нашел об этом информации.
visitors.merge из пакета `babel-traverse`
Документации по нему я не видел. Наверно откопал в дебаге, или подсмотрел в базовых плагинах
Alternator
Eslint умеет исправлять ошибки, хотя его API и несколько ограничено/непривычно по сравнению с трансформациями babel
Выделение метода report выглядит неоднозначным. Я так понимаю в него передается нода, найденная методом find? Этого может не хватить, ведь при нахождении ноды могли быть еще контекстные условия, почему эта нода нашлась, и чем она отличается от других нод собранных плагином. т.е. если плагин хочет вывести несколько разных сообщений, то ему придется дублировать логику в report
Плагин сам вызывает traverse. Тут есть и плюсы и минусы:
1. Каждый плагин выполняет свой traverse, что медленнее чем один комбинированный traverse, выполненый ядром
2. Трансформации делаются последовательно, и не могут конфликтовать с другими плагинами.
Параллельные трансформации — не самая легкая в разработке и отладке вещь.
2.1. Но и взаимодействовать они также не могут, и их работа зависит от порядка. Например если сперва вызывается плагин удаляющий пустые блоки, а только потом удаление debugger/console.log, после чего могут остаться пустые блоки, которые уже не будут удалены
mn3m0n1c_3n3m1 Автор
API Eslint значительно более ограничены и ориентированы в основном на форматирование, в то время как
babel
содержит, например такую удобную вещь как template, что значительно упрощает создание новых узлов.Все верно, придется дублировать логику, либо вызывать те функции, которые вызывались во-время поиска ноды, что способствует упрощению и переиспользованию кода. С другой стороны если правило достаточно просто такой проблемы не будет возникать. Сейчас идет работа над тем, что бы плагин содержал больше одного правила, это будет способствовать упрощению правил.
Да, это действительно медленее, вы уверены, что
babel
иeslint
объединяют параметры всех визиторов и обходят их за один проход? В этом может быть смысл. Идея каждому плагину использовать свойtraverse
возникла при разработке плагина remove-unused-variables, принцип работы сводится к тому, что бы найти все переменные, и определить использовались они или нет, после чего те которые использовались откидываются. Для того, что бы выполнить такую операцию необходимо обойти все AST-дерево сперва. После чего фильтровать. Если плагин будет предоставлять только опции обхода, он не сможет узнать, что обход дерева закончен.Да, все верно, трансформации последовательны, в этом идея, что бы они происходили одна за другой, таким образом правила могут быть очень компактными.
Есть идея во-время исправлений проходить по коду до тех пор, пока не будут исправлены все ошибки (либо до какого-то количества раз, например 10), как это реализовано в eslint. Сейчас же, если после одного прохода
putout
останется что исправить, его можно будет вызвать еще раз, и он улучшит ситуацию.