Работа с абстрактными синтаксическими деревьями JavaScript
Зачем парсить свой код? Например, для того, чтобы найти забытый console.log перед коммитом. А что делать, если вам надо изменить сигнатуру функции в сотнях вхождений в коде? Справятся ли тут регулярные выражения? В этой статье будет показано, какие возможности перед разработчиком открывают абстрактные синтаксические деревья.
Под катом — видео и текстовая расшифровка доклада Кирилла Черкашина (z6Dabrata) с конференции HolyJS 2018 Piter.
Об авторе
Кирилл родился в Москве, сейчас живет в Нью-Йорке и работает в Firebase. Обучает Angular не только в Google, но и во всем мире. Организатор самого большого Angular-митапа в мире — AngularNYC (а также VueNYC и ReactNYC). В свободное от программирования время увлекается танго, книгами и приятными беседами.
Ножовка или дерево?
Начнем с примера: допустим, вы отладили программу и отправили внесенные изменения в git, после чего спокойно отправились спать. Утром оказалось, что коллеги скачали себе ваши изменения и, так как вы накануне забыли убрать вывод отладочной информации в консоль, она у них выводится и засоряет вывод. С подобной проблемой сталкивались многие.
Есть инструменты, такие как EsLint, позволяющие исправить ситуацию, но в образовательных целях давайте попробуем найти решение самостоятельно.
Какой инструмент применить для того, чтобы удалить все console.log()
из кода?
Выбираем между регулярными выражениями и использованием Абстрактных ситаксических деревьев (АСД). Давайте пока попробуем решить это посредством регулярных выражений, написав некую функцию findConsoleLog
. На входе в качестве аргумента она будет получать код программы и выводить true в случае, если console.log () найден где-то в тексте программы.
function findConsoleLog(code) {
return !!code.match(/console.log/);
}
Я написал 17 тестов, пытаясь придумать различные способы сломать нашу функцию. Этот список далеко не полный.
Самый простой тест пройден.
А если вдруг какая-либо функция содержит в своем названии строку «console.log»?
function findConsoleLog(code) {
return !!code.match(/\bconsole.log/);
}
Добавили символ, который обозначает, что console.log
должно встречаться в начале слова.
Пройдено лишь два теста, но что если console.log
находится в комментарии и его не нужно удалять?
Перепишем так, чтобы парсер не трогал комментарии.
function findConsoleLog(code) {
return !!code
.replace(/\/\/.*/)
.match(/\bconsole.log/);
}
Исключаем удаление «console.log» из строк:
function findConsoleLog(code) {
return !!code
.replace(/\/\/.*|'.*'/, '')
.match(/\bconsole.log/);
}
Не забываем, что у нас есть еще пробелы и другие символы, которые могут не дать пройти некоторым тестам:
Несмотря на то, что затея оказалась не совсем простой, все 17 тестов, используя регулярные выражения, пройти можно. Вот так, в данном случае, будет выглядеть код решения:
function findConsoleLog(code) {
return code
.replace(/\/\/.*|'.*?[^\\]'|".*?"|`[\s\S]*`|\/\*[\s\S]*\*\//)
.match(/\bconsole\s*.log\(/);
}
Проблема в том, что этот код не покрывает все возможные случаи, и поддерживать его довольно сложно.
Рассмотрим, как решить эту задачу при помощи АСД.
Как выращиваются деревья?
Абстрактное синтаксическое дерево получается в результате работы парсера с кодом вашего приложения. Для демонстрации был использован парсер @babel/parser.
В качестве примера возьмем строку console.log(‘holy’)
, пропустим ее через парсер.
import { parse } from 'babylon';
parse("console.log('holy')");
В результате его работы получается JSON-файл размером около 300 строк. Исключим из их числа строки со служебной информацией. Нас интересует раздел body. Метаинформация нас тоже не интересует. В итоге получается около 100 строк. По сравнению с тем, какую структуру генерирует браузер для одной переменной body (около 300 строк) — это немного.
Рассмотрим несколько примеров, как представляются различные литералы в коде в синтаксическом дереве:
Это выражение, в котором есть Numeric Literal, числовой литерал.
Уже знакомое нам выражение console.log. В нем есть объект, у которого есть свойство.
Если log — это вызов функции, тогда описание выглядит следующим образом: есть выражение вызова, у него есть аргументы — числовые литералы. В то же время у вызывающего выражения есть имя — log.
Литералы бывают разными: числа, строки, регулярные выражения, boolean, null.
Вернемся к вызову «console.log»
Это выражение вызова, внутри которого есть Member Expression. Из него понятно, что у объекта console внутри есть свойство, которое называется log.
Обход АСД
Теперь попробуем поработать с этой структурой в коде. Для обхода дерева будет использована библиотека babel-traverse.
Даны те же 17 тестов. Такой код получается при анализе синтаксического дерева программы и поиске вхождений «console.log»:
function traverseConsoleLog(code, {babylon, babelTraverse, types, log}) {
const ast = babylon.parse(code);
let hasConsoleLog = false;
babelTraverse(ast, {
MemberExpression(path){
if (
path.node.property.type === 'Identifier' &&
path.node.property.name === 'log' &&
path.node.object.type === 'Identifier' &&
path.node.object.name === 'console' &&
path.parent.type === 'CallExpression' &&
path.Parentkey === 'callee'
) {
hasConsoleLog = true;
}
}
})
return hasConsoleLog;
}
Разберем, что здесь написано. const ast = babylon.parse(code);
в переменную ast парсим синтаксическое дерево из кода. Далее даем библиотеке babel-parse это дерево на обработку. Ищем в нем узлы и свойства с совпадающими именами внутри выражений вызовов. Выставляем переменную hasConsoleLog в true, если требуемое сочетание узлов и их названий найдено.
Мы может перемещаться по дереву, брать родителей узлов, потомков, искать, какие у них есть аргументы и свойства, смотреть названия этих свойств, типы — это очень удобно.
Есть неприятный нюанс, который легко исправить с помощью библиотеки babel-types. Чтобы не допускать ошибок при поиске в дереве из-за неправильного наименования, например, вместо path.parent.type === 'CallExpression'
вы случайно написали path.parent.type === 'callExpression'
, с babel-types можно писать так:
// Before
path.node.property.type === 'Identifier'
path.node.property.name === 'log'
// with babel types
import {isIdentifier} from 'babel-types';
isIdentifier(path.node.property, {name: log}) // вместо написания имени узла просто передаем список ожидаемых параметров, в случае, если опечатаемся в isIdentifier, нам будет сразу показана ошибка
Перепишем предыдущий код с использованием babel-types:
function traverseConsoleLogSolved2(code, {babylon, babelTraverse, types}) {
const ast = babylon.parse(code);
let hasConsoleLog = false;
babelTraverse(ast, {
MemberExpression(path) {
if (
types.isIdentifier(path.node.object, { name: 'console'}) &&
types.isIdentifier(path.node.property, { name: 'log'}) &&
types.isCallExpression(path.parent) &&
path.parentKey === 'callee'
) {
hasConsoleLog = true;
}
}
});
return hasConsoleLog;
}
Трансформируем АСД с помощью babel-traverse
Для сокращения трудозатрат нам нужно, чтобы console.log
сразу удалялся из кода — вместо сигнала о том, что он есть в коде.
Так как нам нужно удалить не сам MemberExpression, а его родителя, на месте hasConsoleLog = true;
мы пишем path.parentPath.remove();
.
Из функции removeConsoleLog
у нас все еще возвращается булево значение. Мы заменяем его вывод на код, который сгенерирует babel-generator, вот так: hasConsoleLog
=> babelGenerator(ast).code
Babel-generator получает измененное абстрактное синтаксическое дерево в качестве параметра, возвращает объект со свойством code, внутри этого объекта сгенерированный заново код без console.log
. Кстати, если мы хотим получить карту кода, мы можем вызвать для этого объекта свойство sourceMaps.
А если нужно найти debugger?
На этот раз для выполнения задачи мы будем использовать ASTexplorer. Debugger относится к типу узлов debugger statement. Нам не нужно смотреть всю структуру, так как это особый вид узла, достаточно просто найти debugger statement. Мы напишем плагин для ESLint (на ASTexplorer).
ASTexplorer устроен таким образом, что вы пишите кода слева, а справа получаете готовое АСД. Можно выбрать, в каком формате вы хотите его получить: JSON или в формате древа.
Так как мы используем ESLint, он выполнит за нас всю работу по поиску файлов и отдаст нам нужный файл, чтобы мы могли найти в нем строку debugger. В данном инструменте используется другой парсер АСД. Впрочем и самих АСД в JavaScript существует несколько видов. Чем-то напоминает прошлое, когда разные браузеры по-разному реализовывали спецификацию. Таким образом, мы реализуем поиск debugger«а:
export default function(context) {
return {
DebuggerStatement(node) { //обратите внимание, что в случае с console.log мы получали переменную path, теперь же это - нечто среднее, которое содержит все свойства path и все свойства узла
context.report(node, ‘LOL Debugger!!!’); // просто заставляем ESLint отрапортовать, что найден debugger, node передается в функцию для того, чтобы можно было понять, где конкретно найден debugger
}
}
}
Проверка работы написанного плагина:
Точно так же можно удалить debugger из кода.
Чем еще полезны АСД
Я лично использую АСД для упрощения работы с Angular и другими фронтенд фреймворками. Можно нажатием одной кнопки что-то импортировать, расширять, добавлять интерфейс, метод, декоратор и что-либо еще. Хотя речь в данном случае идет о Javascript, тем не менее, в TypeScript тоже есть свои АСД, разница только в отличии названий типов узлов и структуре. В том же ASTExplorer можно выбрать в качестве языка TypeScript.
Итак:
- У нас больше контроля над кодом, проще рефакторинг, codemods. Например, перед коммитом можно нажатием одной клавиши отформатировать весь код в соответствии с гайдлайнами. Codemods подразумевает автоматическое приведение кода в соответствии с нужной версией фреймворка.
- Меньше споров про оформление кода.
- Можно создавать игровые проекты. Например, автоматически давать программисту обратную связь о коде, который он пишет.
- Лучшее понимание JavaScript.
Несколько полезных ссылок для Babel
- Все трансформации Babel используют этот API: плагины и пресеты.
- Часть процесса добавления новой функциональности в ECMAScript — создание плагина для Babel. Это нужно для того, чтобы люди могли протестировать новую функциональность. Если пройти по ссылке, можно увидеть, что внутри точно также используются возможности АСД. Например logical-assignment-operator.
- Babel Generator теряет форматирование при генерации кода. Отчасти это хорошо, так как если этот инструмент используется в команде разработки, то после генерации кода из АСД он будет выглядеть одинаково у всех. Но если вы хотите сохранить свое форматирование, можете использовать один из этих инструментов: Recast или Babel CodeMod.
- По этой ссылке вы можете найти огромное количество информации по Babel Awesome Babel.
- Babel — это проект с открытым исходным кодом, над ним работает команда волонтеров. Вы можете помочь. Существует три способа это сделать: денежная помощь, можно поддержать сайт patreon, на котором трудится Henry Zhu — один из ключевых контрибьюторов babel, помочь с кодом на сайте opencollective.com/babel.
Бонус
Как еще можно найти наш console.log
в коде? Использовать вашу IDE! С помощью инструмента «найти и заменить», предварительно выбрав в каких местах кода искать.
Также в Intellij IDEA есть инструмент «структурный поиск», который может помочь найти нужные места в коде, к слову, он использует АСД.
24–25 ноября Кирилл выступит на московской HolyJS с докладом «JavaScript *LOVES* binary data»: опустимся на уровень бинарных данных, покопаемся в бинарных файлах на примере *.gif-файлов и разберемся с сериализующими фреймворками, такими как Protobuf или Thrift. После доклада можно будет пообщаться с Кириллом и обсудить все интересующие вопросы в дискуссионной зоне.