Rubocop. Разбираем AST-деревья
В самом-самом начале когда я только начинал заниматься Rubocop мне очень не хватало информации и конкретных примеров. Сразу же возникли вопросы, а какие узлы AST-дерева существуют, какие иерархии они могут образовывать, какие могут быть дочерние узлы, как перемещаться по дереву, в каком порядке вызываются callback‘и и обрабатываются узлы дерева, какой жизненный цикл у объекта-cop‘а и как кешировать промежуточные результаты… Документация не сильно помогала и пришлось нырять самому.
Первая проблема была связана с узлами AST-дерева и его структурой - какое AST-поддерево соответствует той или иной синтаксической конструкции Ruby, какие дочерние узлы и в каком порядке идут у конкретного типа узла. Поэтому в этом посте мы разберем несколько типов узлом и примеров AST-дерева.
Для AST-деревьев я буду использовать нотацию gem‘a parser, который под капотом и используется в Rubocop. AST-дерево представляет собой S-выражение и имеет следующий вид:
(parent-node child-node child-node ...)
где первым идет родительский узел, а затем его дочерние узлы.
Давайте рассмотрим несколько примеров и начнем с самого простого - литералов.
Литералы
Литерал базовых скалярных типов представляется двумя узлами - тип и значение:
Давайте перечислим все базовые литералы:
(int 1)
(float 2.0)
(rational (5/1))
(complex (0+1i))
(str "a")
(sym :b)
Такие константы как nil
, true
и false
являются исключениями и
представлены только своим значением:
(nil)
(true)
(false)
Литералы коллекций (массивов и хешей) имеют уже составную структуру. Элементы массива становятся непосредственными дочерними узлами:
[1, 2]
(array
(int 1)
(int 2))
А в хеше каждая пара ключ-значение представлена дочерним узлом pair:
{ a: 1 }
(hash
(pair
(sym :a)
(int 1)))
Арифметическое выражение
Так как арифметические операции (например “+” и “*”) в Ruby это методы
классов Integer
/Float
/итд, то в AST-дереве они представлены узлами
send - “вызов метода”:
Ruby выражение:
1 + 2 * 3
S-выражение для дерева:
(send
(int 1) :+
(send
(int 2) :*
(int 3)))
Выражение со скобками
Хорошо нам знакомые круглые скобки ()
в Ruby означают список
выражений. А итоговое значение этого списка - это значение последнего в
нем выражения. Поэтому в AST-дереве выражение в скобках представлено
узлом begin, который может содержать список выражений. Рассмотрим
пример.
Ruby выражение:
(1 + 2) * 3
S-выражение для дерева:
(send
(begin
(send
(int 1) :+
(int 2))) :*
(int 3))
Вызов метода
Вызов метода представляется узлом send, в котором должны быть следующие дочерние узлы:
- объект, на котором вызывается метод,
- имя метода
- и список аргументов.
Структура AST-дерева для вызова метода с одним аргументом:
Если объект, на котором вызывается метод, не литерал, константа или локальная переменная, то это вызов метода, который тоже представляется узлом send. Если у метода нет явного receiver‘а - указывают nil.
Рассмотрим примеры.
Вызов метода без аргументов
Ruby выражение:
post.title
S-выражение для дерева:
(send
(send nil :post) :title)
Само AST-дерево:
Вызов метода с аргументом
Ruby выражение:
post.title(:html)
S-выражение для дерева:
(send
(send nil :post) :title
(sym :html))
Вызов метода на литерале
Ruby выражение:
5.div(2.0)
S-выражение для дерева:
(send
(int 5) :div
(float 2.0))
AST-дерево:
Вызов метода с блоком
Когда метод вызывается с блоком, это представляется в AST особым образом. Родительским узлом становится узел block. А уже в него помещаются узлы:
- узел send с вызовом метода
- аргументы блока и
- тело блока.
Структура AST-дерева для вызова с блоком без аргументов:
Ruby выражение:
title do
end
S-выражение для дерева:
(block
(send nil :title)
(args) nil)
Если блок принимает один аргумент
Если в блоке только один аргумент, то он представляется не узлом arg, а procarg0.
Ruby выражение:
title do |t|
end
S-выражение для дерева:
(block
(send nil :title)
(args
(procarg0
(arg :t))) nil)
Разработчикам пришлось ввести новый узел procarg0 по вполне определенной причине. Это сделано потому, что в Ruby аргументы блока трактуются по разному в зависимости от их количества - один или несколько.
Если в блоке один аргумент и передали аргументом массив - аргументу присваивается этот массив. Если в блоке несколько аргументов, то происходит деструкция массива и аргументам блока присвоятся значения элементов этого массива. Прикольно, не правда ли.
И полагаться просто на количество аргументов нельзя, так как возможен
вариант неявного rest-аргумента. Если к единственному аргументу
добавить символ “,”, например |t,|
, то получиться, что аргумент один,
но все равно переданное значение-массив будет деструктурировано.
Проиллюстрируем это примерами.
Пример №1. В блоке один аргумент - ему присваивается значение-массив:
block = proc { |a| puts "=> #{a}" }
block.call([1, 2])
# => [1, 2]
Пример №2. Два аргумента - аргументам присваиваются значения элементов массива - 1 и 2:
block = proc { |a, b| puts "=> #{a} | #{b}" }
block.call([1, 2])
# => 1 | 2
Пример №3. Один явный аргумент + неявный rest-аргумент. Явному первому аргументу присвоится значение первого элемента массива - 1:
block = proc { |a,| puts "=> #{a}" }
block.call([1, 2])
# => 1
Чтобы различить первый пример и последний как раз и нужен специальный дополнительный узел procarg0.
Если блок принимает несколько аргументов
Ruby выражение:
title do |t, f|
end
S-выражение для дерева:
(block
(send nil :title)
(args
(arg :t)
(arg :f)) nil)
Если блок не пустой
Ruby выражение:
title do
foo
end
S-выражение для дерева:
(block
(send nil :title)
(args)
(send nil :foo))
Если несколько выражений в блоке
Если в блоке несколько выражений, то они оборачиваются в узел begin.
Ruby выражение:
title do
foo
"Post #1"
end
S-выражение для дерева:
(block
(send nil :title)
(args)
(begin
(send nil :foo)
(str "Post #1")))
Константы
Константа представляется одним родительским узлом const и двумя дочерними:
- пространство имен
- имя константы
Структура AST-дерева для константы:
Константа без пространства имен
Если у константы нет явного пространства имен, тогда используется nil.
Ruby выражение:
A
S-выражение для дерева:
(const nil :A)
AST-дерево:
Константа с пространством имен
Ruby выражение:
A::B
S-выражение для дерева:
(const
(const nil :A) :B)
AST-дерево:
Длинная цепочка имен
Ruby выражение:
A::B::C
S-выражение для дерева:
(const
(const
(const nil :A) :B) :C)
Константа с приставкой ::
Ruby выражение:
::A
S-выражение для дерева:
(const
(cbase) :A)
Декларация класса
Класс представляется узлом class, со следующими дочерними узлами:
- имя класса
- родительский класс и
- тело класса.
Структура AST-дерева для класса:
Если родительский класс не указан то используют nil. Аналогично, если тело класса пустое - используется тоже nil.
Ruby выражение:
class A
end
S-выражение для дерева:
(class
(const nil :A) nil nil)
AST-дерево:
Наследует базовый класс
Ruby выражение:
class A < B
end
S-выражение для дерева:
(class
(const nil :A)
(const nil :B) nil)
Одно выражение в теле класса
Ruby выражение:
class A
def initialize
end
end
S-выражение для дерева:
(class
(const nil :A) nil
(def :initialize
(args) nil))
Несколько выражений в теле класса
Если в классе только одно выражение - оно представляет тело класса. Если выражений больше одного - они оборачиваются в узел begin.
Ruby выражение:
class A
def initialize
end
def empty?
true
end
end
S-выражение для дерева:
(class
(const nil :A) nil
(begin
(def :initialize
(args) nil)
(def :empty?
(args)
(true))))
Как сгенерировать AST-дерево
Давайте отвлечемся от примеров и рассмотрим способы, как же получить AST-дерево для какого-то конкретного Ruby-кода.
Есть несколько вариантов. Один из них - shell-команда ruby-parse
,
которая идет в составе gem‘а parser.
Пример вызова этой команды для простого арифметического выражения:
$ ruby-parse -e "1 + 2"
(send
(int 1) :+
(int 2))
Второй вариант - вызвать парсер напрямую из Ruby-кода (пример из документации):
require 'rubocop'
code = '!something.empty?'
source = RuboCop::ProcessedSource.new(code, RUBY_VERSION.to_f)
node = source.ast
s(:send,
s(:send,
s(:send, nil, :something), :empty?), :!)
PS
Мы рассмотрели лишь несколько узлов AST-дерева и опустили подробности. Но теперь мы можем сами генерировать AST-деревья и изучать их глубже.
Полный список типов узлов с примерами можно найти в документации gem‘а parser. Также очень полезна секция Documentation в его Readme файле.