Главная » Хабрахабр » Работа с абстрактными синтаксическими деревьями JavaScript 

Работа с абстрактными синтаксическими деревьями JavaScript 

Зачем парсить свой код? Например, для того, чтобы найти забытый console.log перед коммитом. А что делать, если вам надо изменить сигнатуру функции в сотнях вхождений в коде? Справятся ли тут регулярные выражения? В этой статье будет показано, какие возможности перед разработчиком открывают абстрактные синтаксические деревья.

Под катом — видео и текстовая расшифровка доклада Кирилла Черкашина (z6Dabrata) с конференции HolyJS 2018 Piter.

Об авторе
Кирилл родился в Москве, сейчас живет в Нью-Йорке и работает в Firebase. Обучает Angular не только в Google, но и во всем мире. Организатор самого большого Angular-митапа в мире — AngularNYC (а также VueNYC и ReactNYC). В свободное от программирования время увлекается танго, книгами и приятными беседами.

Ножовка или дерево?

Начнем с примера: допустим, вы отладили программу и отправили внесенные изменения в git, после чего спокойно отправились спать. Утром оказалось, что коллеги скачали себе ваши изменения и, так как вы накануне забыли убрать вывод отладочной информации в консоль, она у них выводится и засоряет вывод. С подобной проблемой сталкивались многие.

Давайте пока попробуем решить это посредством регулярных выражений, написав некую функцию findConsoleLog. Есть инструменты, такие как EsLint, позволяющие исправить ситуацию, но в образовательных целях давайте попробуем найти решение самостоятельно.
Какой инструмент применить для того, чтобы удалить все console.log() из кода?
Выбираем между регулярными выражениями и использованием Абстрактных ситаксических деревьев (АСД). На входе в качестве аргумента она будет получать код программы и выводить 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 from 'babylon';
parse("console.log('holy')");

В результате его работы получается JSON-файл размером около 300 строк. Исключим из их числа строки со служебной информацией. Нас интересует раздел body. Метаинформация нас тоже не интересует. В итоге получается около 100 строк. По сравнению с тем, какую структуру генерирует браузер для одной переменной body (около 300 строк) – это немного.

Рассмотрим несколько примеров, как представляются различные литералы в коде в синтаксическом дереве:

Это выражение, в котором есть Numeric Literal, числовой литерал.

В нем есть объект, у которого есть свойство. Уже знакомое нам выражение console.log.

В то же время у вызывающего выражения есть имя – log. Если log – это вызов функции, тогда описание выглядит следующим образом: есть выражение вызова, у него есть аргументы – числовые литералы.

Литералы бывают разными: числа, строки, регулярные выражения, boolean, null.
Вернемся к вызову «console.log»

Из него понятно, что у объекта console внутри есть свойство, которое называется log. Это выражение вызова, внутри которого есть Member Expression.

Обход АСД

Теперь попробуем поработать с этой структурой в коде. Для обхода дерева будет использована библиотека babel-traverse.

Такой код получается при анализе синтаксического дерева программы и поиске вхождений «console.log»: Даны те же 17 тестов.

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, если требуемое сочетание узлов и их названий найдено.

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

Чтобы не допускать ошибок при поиске в дереве из-за неправильного наименования, например, вместо path.parent.type === 'CallExpression' вы случайно написали path.parent.type === 'callExpression' , с babel-types можно писать так: Есть неприятный нюанс, который легко исправить с помощью библиотеки 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();.

Мы заменяем его вывод на код, который сгенерирует babel-generator, вот так:
hasConsoleLog => babelGenerator(ast).code Из функции removeConsoleLog у нас все еще возвращается булево значение.

Кстати, если мы хотим получить карту кода, мы можем вызвать для этого объекта свойство sourceMaps. Babel-generator получает измененное абстрактное синтаксическое дерево в качестве параметра, возвращает объект со свойством code, внутри этого объекта сгенерированный заново код без console.log.

А если нужно найти debugger?

На этот раз для выполнения задачи мы будем использовать ASTexplorer. Debugger относится к типу узлов debugger statement. Нам не нужно смотреть всю структуру, так как это особый вид узла, достаточно просто найти debugger statement. Мы напишем плагин для ESLint (на ASTexplorer).

Можно выбрать, в каком формате вы хотите его получить: JSON или в формате древа. ASTexplorer устроен таким образом, что вы пишите кода слева, а справа получаете готовое АСД.

В данном инструменте используется другой парсер АСД. Так как мы используем 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

  1. Все трансформации Babel используют этот API: плагины и пресеты.
  2. Часть процесса добавления новой функциональности в ECMAScript — создание плагина для Babel. Это нужно для того, чтобы люди могли протестировать новую функциональность. Если пройти по ссылке, можно увидеть, что внутри точно также используются возможности АСД. Например logical-assignment-operator.
  3. Babel Generator теряет форматирование при генерации кода. Отчасти это хорошо, так как если этот инструмент используется в команде разработки, то после генерации кода из АСД он будет выглядеть одинаково у всех. Но если вы хотите сохранить свое форматирование, можете использовать один из этих инструментов: Recast или Babel CodeMod.
  4. По этой ссылке вы можете найти огромное количество информации по Babel Awesome Babel.
  5. Babel — это проект с открытым исходным кодом, над ним работает команда волонтеров. Вы можете помочь. Существует три способа это сделать: денежная помощь, можно поддержать сайт patreon, на котором трудится Henry Zhu — один из ключевых контрибьюторов babel, помочь с кодом на сайте opencollective.com/babel.

Бонус

Как еще можно найти наш console.log в коде? Использовать вашу IDE! С помощью инструмента «найти и заменить», предварительно выбрав, в каких местах кода искать.
Также в Intellij IDEA есть инструмент «структурный поиск», который может помочь найти нужные места в коде, к слову, он использует АСД.

После доклада можно будет пообщаться с Кириллом и обсудить все интересующие вопросы в дискуссионной зоне. 24-25 ноября Кирилл выступит на московской HolyJS с докладом «JavaScript *LOVES* binary data»: опустимся на уровень бинарных данных, покопаемся в бинарных файлах на примере *.gif-файлов и разберемся с сериализующими фреймворками, такими как Protobuf или Thrift.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Перевод] Создание собственной цветовой палитры

Адаптировано из нашей будущей книги «Рефакторинг UI» Когда выбираешь цвет, настраиваешь несколько параметров с музыкальными словечками типа «триада» или «четвёртый мажор» — и получаете пять идеальных цветов для своего веб-сайта? Видели эти модные генераторы цветовой палитры? Такой вычислительный и научный ...

Как настроить установку переменных окружения Nuxt.js в рантайме, или Как сделать всё не как все и не пожалеть

(Иллюстрация) В предыдущем раунде битвы с этим фреймворком они показали, как запустить проект на Nuxt так, чтобы все были счастливы. Senior web developer’ы Антон и Алексей продолжают рассказ о непростой борьбе с Nuxt. В новой статье поговорим о реальном применении ...