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 файле.