Как стать автором
Обновить

Практическое применение трансформации AST-деревьев на примере Putout

JavaScript *Node.JS *Компиляторы *
Tutorial

Введение


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

Теги:
Хабы:
Всего голосов 13: ↑12 и ↓1 +11
Просмотры 10K
Комментарии 5
Комментарии Комментарии 5