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

Ссылки