Введение


Каждый день при работе над кодом, на пути к реализации полезного для пользователя функционала, становятся вынужденные (неизбежные, либо же просто желательные) изменения кода. Это может быть рефакторинг, обновление библиотеки или фреймворка до новой мажорной версии, обновление синтаксиса 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;

  1. Объединить свойства деструктуризации:

// было
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)


  1. Alternator
    11.02.2019 13:52

    Eslint умеет исправлять ошибки, хотя его API и несколько ограничено/непривычно по сравнению с трансформациями babel

    Выделение метода report выглядит неоднозначным. Я так понимаю в него передается нода, найденная методом find? Этого может не хватить, ведь при нахождении ноды могли быть еще контекстные условия, почему эта нода нашлась, и чем она отличается от других нод собранных плагином. т.е. если плагин хочет вывести несколько разных сообщений, то ему придется дублировать логику в report

    Плагин сам вызывает traverse. Тут есть и плюсы и минусы:
    1. Каждый плагин выполняет свой traverse, что медленнее чем один комбинированный traverse, выполненый ядром
    2. Трансформации делаются последовательно, и не могут конфликтовать с другими плагинами.
    Параллельные трансформации — не самая легкая в разработке и отладке вещь.
    2.1. Но и взаимодействовать они также не могут, и их работа зависит от порядка. Например если сперва вызывается плагин удаляющий пустые блоки, а только потом удаление debugger/console.log, после чего могут остаться пустые блоки, которые уже не будут удалены


    1. mn3m0n1c_3n3m1 Автор
      11.02.2019 17:27

      Eslint умеет исправлять ошибки, хотя его API и несколько ограничено/непривычно по сравнению с трансформациями babel

      API Eslint значительно более ограничены и ориентированы в основном на форматирование, в то время как babel содержит, например такую удобную вещь как template, что значительно упрощает создание новых узлов.


      Выделение метода report выглядит неоднозначным. Я так понимаю в него передается нода, найденная методом find? Этого может не хватить, ведь при нахождении ноды могли быть еще контекстные условия, почему эта нода нашлась, и чем она отличается от других нод собранных плагином. т.е. если плагин хочет вывести несколько разных сообщений, то ему придется дублировать логику в report

      Все верно, придется дублировать логику, либо вызывать те функции, которые вызывались во-время поиска ноды, что способствует упрощению и переиспользованию кода. С другой стороны если правило достаточно просто такой проблемы не будет возникать. Сейчас идет работа над тем, что бы плагин содержал больше одного правила, это будет способствовать упрощению правил.


      Плагин сам вызывает traverse. Тут есть и плюсы и минусы:
      1. Каждый плагин выполняет свой traverse, что медленнее чем один комбинированный traverse, выполненый ядром

      Да, это действительно медленее, вы уверены, что babel и eslint объединяют параметры всех визиторов и обходят их за один проход? В этом может быть смысл. Идея каждому плагину использовать свой traverse возникла при разработке плагина remove-unused-variables, принцип работы сводится к тому, что бы найти все переменные, и определить использовались они или нет, после чего те которые использовались откидываются. Для того, что бы выполнить такую операцию необходимо обойти все AST-дерево сперва. После чего фильтровать. Если плагин будет предоставлять только опции обхода, он не сможет узнать, что обход дерева закончен.


      1. Трансформации делаются последовательно, и не могут конфликтовать с другими плагинами.
        Параллельные трансформации — не самая легкая в разработке и отладке вещь.

      Да, все верно, трансформации последовательны, в этом идея, что бы они происходили одна за другой, таким образом правила могут быть очень компактными.


      2.1. Но и взаимодействовать они также не могут, и их работа зависит от порядка. Например если сперва вызывается плагин удаляющий пустые блоки, а только потом удаление debugger/console.log, после чего могут остаться пустые блоки, которые уже не будут удалены

      Есть идея во-время исправлений проходить по коду до тех пор, пока не будут исправлены все ошибки (либо до какого-то количества раз, например 10), как это реализовано в eslint. Сейчас же, если после одного прохода putout останется что исправить, его можно будет вызвать еще раз, и он улучшит ситуацию.


  1. Alternator
    11.02.2019 18:59

    Да, это действительно медленее, вы уверены, что babel и eslint объединяют параметры всех визиторов и обходят их за один проход?

    Для babel 5-ой версии параллельный обход всеми плагинами был штатным способом.
    И был отдельный ключ(не помню для CLI, или babelRc), который гонял плагины последовательно
    И в сложных плагинах(когда замена производилась не для текущей ноды) паралельный обход создавал трудности
    Хотя кроме воркэраунда с опцией был еще вариант повесить плагин на Program:exit, и совершить траверс самостоятельно, но это не оптимально и не рекомендовалось

    Кстати апи по мержу визиторов в один удобно было использовать для разбития одного плагина на несколько логических частей для упрощение поддержки

    А вот в текущем babel, и eslint я не знаю как сейчас организована работа


    1. mn3m0n1c_3n3m1 Автор
      12.02.2019 11:52

      Для babel 5-ой версии параллельный обход всеми плагинами был штатным способом.
      И был отдельный ключ(не помню для CLI, или babelRc), который гонял плагины последовательно
      И в сложных плагинах(когда замена производилась не для текущей ноды) паралельный обход создавал трудности

      Это действительно создает трудности, в прочем, для putout в данный момент времени, мало смысла в паралельной обработке, поскольку, работает он достаточно быстро, и разрабатывается на компьютере, на котором прирост скорости от использования redrun имеет огромное значение, если учесть что для большинства пользователей ускорение оказалось не особо ощутимым, putout у них будет летать :). В прочем, в случае медленной работы у пользователей, можно будет подумать об ускорении. Что вы скажете о скорости? У вас быстро файлы трансформируются?


      Кстати апи по мержу визиторов в один удобно было использовать для разбития одного плагина на несколько логических частей для упрощение поддержки

      Как выглядел этот апи? Я не нашел об этом информации.


      1. Alternator
        12.02.2019 18:10

        Что вы скажете о скорости? У вас быстро файлы трансформируются?

        Когда я плотно этим занимался, я не задумывался и не сравнивал скорость этих режимов.
        Просто принял его как данность, и исправлял плагин с учетом коллизий
        Сейчас у меня нету проектов где я плотно использую babel, поэтому особо не на чем сейчас проверить
        Можете сами поэксперементировать. Опция для переключения режима — passPerPreset

        Как выглядел этот апи? Я не нашел об этом информации.

        visitors.merge из пакета `babel-traverse`
        Документации по нему я не видел. Наверно откопал в дебаге, или подсмотрел в базовых плагинах