Rubocop. Фиксим баг

На днях у меня получилось влезть коготком в один из самых интересных проектов на Ruby - Rubocop. Если кто-то не сталкивался - это Ruby-линтер. Нет, не совсем так. Это целый фреймворк для разработки своих правил и проверок - он умеет статически анализировать исходных код на Ruby, проверяет (по умолчанию) правила из Ruby Style Guide и может даже автоматически корректировать/исправлять исходники - убирать лишние пробелы, заменять unless на if итд.

Парсеры, лексеры, синтаксический анализ, AST итд интересовали меня еще со времен университета и сейчас появилась возможность немножко позаниматься этой темой.

Для разработчиков новых правил и автокоррекций (cop‘ов в терминах Rubocop) есть официальная документация. Но она, как обычно, весьма краткая и бедному разработчику приходится лезть в потроха и разбираться самому. Неудивительно, что в сети периодически появляются статьи с примерами разработки новых cop‘ов. Привел ссылки на такие статьи в конце поста.

В этом посте я расскажу о своем первом опыте с Rubocop. Я исправил багу в одном из cop‘ов для Minitest - есть такая библиотека unit-тестов, конкурент RSpec’а. И пришлось немного повозиться и поразбираться как же работают Rubocop, cop‘ы, шаблоны и как это тестировать.

И так, начнем.

Предыстория

В одном из open source проектов, которыми я занимался, для тестов использовали Minitest. Это странный выбор как по мне, так как RSpec умеет и больше и лучше. Да и вообще - кто слышал об этом Minitest? Я хотел исправить многочисленные deprecation warning‘и, которые появились после выхода очередной минорной версии Minitest, и попробовал автокоррекцию RuboCop. Для Minitest в Rubocop есть отдельный плагин rubocop-minitest.

К моему удивлению, автокоррекция исправила далеко не все места, где выдавались warning‘и. Это определенно была бага. “Отличная возможность познакомиться с Rubocop” - подумал я. И вместо того, чтобы зарепортить issue в проекте, начал фиксить багу сам.

Автокоррекцию выполнял cop Minitest/GlobalExpectations. Он находит вызовы deprecated методов (глобальных матчеров must_equal, wont_match итд) и заменяет на новый DSL, например:

# bad
musts.must_equal expected_musts

# good
_(musts).must_equal expected_musts

Давайте разберем как Minitest/GlobalExpectations работает.

Cop Minitest/GlobalExpectations

Точка входа в cop (source) - это callback on_send:

def on_send(node)
  return unless global_expectation?(node)

  message = format(MSG, preferred: preferred_receiver(node))
  add_offense(node, location: node.receiver.source_range, message: message)
end

Rubocop использует паттерн Visitor, а каждый cop реализует один или несколько callback‘ов для конкретных типов узлов AST дерева. Rubocop обходит AST дерево и по очереди вызывает callback‘и из всех cop‘ов которые соответствуют текущему узлу. on_send callback будет вызван для всех узлов типа send. Узел send, как можно догадаться, соответствует вызову Ruby-метода. Узел передается в callback аргументом node.

Каждому токену в Ruby коде соответствует свой тип узла AST дерева и, соответственно, свой callback. Приведем примеры:

  • on_def - узел с определением метода
  • on_class - узел с определением класса
  • on_module - узел с определением модуля
  • on_block - узел с литералом блока {} или do/end
  • on_if - узел с if выражением
  • on_ensure - узел с секцией ensure
  • on_const - узел с константой (FooBar)
  • on_hash - узел с литералом Hash
  • on_array - узел с литералом Array

Полный список callback‘ов можно найти в документации gem‘а parser (тыц)

AST дерево для узла send выглядит примерно так:

В этом дереве корень - узел send. У него есть дочерние узлы:

  • объект, на котором вызван метод, receiver
  • имя метода
  • аргумент (или список аргументов)

Например, для выражения obj.must_equal expected с вызовом метода и передачей аргумента получится следующее AST дерево:

(send
  (send nil :obj)
  :must_equal
  (send nil :expected))

где:

  • receiver - это (send nil :obj)
  • имя метода - :must_equal
  • и аргумент - (send nil :expected))

Логика метода on_send очень проста. Он проверяет является ли текущий send-узел вызовом глобального матчера - must_be_empty, must_equal, must_be_close_to, must_be_within_delta

return unless global_expectation?(node)

Если проверка успешная и найден deprecated метод, то cop регистрирует ошибку (offence):

add_offense(node, location: node.receiver.source_range, message: message)

Метод global_expectation? довольно интересный. Он определен необычным способом используя “макрос” def_node_matcher:

def_node_matcher :global_expectation?, <<~PATTERN
  (send {
    (send _ _)
    ({lvar ivar cvar gvar} _)
    (send {(send _ _) ({lvar ivar cvar gvar} _)} _ _)
  } {#{MATCHERS_STR}} ...)
PATTERN

Где MATCHERS_STR - это перечисленные через пробел Minitest матчеры - :must_be_empty, :must_equal, :must_be_close_to

Этот макроc генерирует метод с именем global_expectation?, который проверяет соответствует ли узел заданному шаблону. В Rubocop реализован свой собственный механизм шаблонов, похожий на регулярные выражения, который применяется к AST дереву.

Приведенный шаблон соответствует узлу send, у которого receiver соответствует следующему шаблону:

{
  (send _ _)
  ({lvar ivar cvar gvar} _)
  (send {(send _ _) ({lvar ivar cvar gvar} _)} _ _)
}

{} означает логическое ИЛИ т.е. receiver это или вызов метода (без аргумента) или переменная (foo, @foo, @@foo) или цепочка из нескольких вызовов.

Далее идет шаблон для имени метода:

{#{MATCHERS_STR}}

Это разворачивается в следующий список имен матчеров:

{:must_be_empty :must_equal :must_be_close_to ...}

Далее идут аргументы. Они должны соответствовать подшаблону ..., что означает любая последовательность узлов в том числе и пустая.

В чем же была ошибка?

Приведенный выше шаблон для receiver слишком специфичный и упускает целый ряд выражений. Для следующих выражения, например, он не сработает:

response[1]['X-Runtime'].must_match /[\d\.]+/

::File.read(::File.join(@def_disk_cache, 'path', 'to', 'blah.html')).must_equal @def_value.first

Rack::Contrib.must_respond_to(:release)

Рассмотрим последний из примеров выше:

Rack::Contrib.must_respond_to(:release)

Ему соответствует следующее AST дерево:

(send
  (const
    (const nil :Rack) :Contrib)
  :must_respond_to
  (sym :release))

Receiver (const (const nil :Rack) :Contrib) ни коим образом не соответствует подшаблону для receiver‘а. Это и не вызов метода, и не переменная и не цепочка вызовов.

Решение

Решение было достаточно простым. Самый тривиальный и общий шаблон вполне неплохо справляется:

(send !(send nil? :_ _) {#{MATCHERS_STR}} ...)

Он проверяет, что вызывается глобальный матчер и receiver не похож на новый DSL в формате _(musts).must_equal expected_musts.

Конечно, дальше возникают нюансы и не все так уж просто. Есть два типа матчеров - для результата выражения и для блока кода. Например:

obj.foo.must_equal :bar

и

-> { obj.foo }.must_raise ArgumentError

AST деревья для этих выражений сильно отличаются и для них нужны разные шаблоны:

(send
  (send
    (send nil :obj) :foo) :must_equal
  (sym :bar))

и

(send
  (block
    (lambda)
    (args)
    (send
      (send nil :obj) :foo)) :must_raise
  (const nil :ArgumentError))

В новом DSL надо оборачивать проверяемое выражение в _(obj). Но поддерживаются и алиасы для _ - методы value и expect, которые могут сделать код нагляднее и читабельнее:

_(obj.foo).must_equal :bar
value(obj.foo).must_equal :bar
expect(obj.foo).must_equal :bar

Поэтому итоговые решение выглядело немного сложнее:

# There are aliases for the `_` method - `expect` and `value`
DSL_METHODS_LIST = %w[_ value expect].map do |n|
  ":#{n}"
end.join(' ').freeze

def_node_matcher :value_global_expectation?, <<~PATTERN
  (send !(send nil? {#{DSL_METHODS_LIST}} _) {#{VALUE_MATCHERS_STR}} _)
PATTERN

def_node_matcher :block_global_expectation?, <<~PATTERN
  (send
    [
      !(send nil? {#{DSL_METHODS_LIST}} _)
      !(block (send nil? {#{DSL_METHODS_LIST}}) _ _)
    ]
    {#{BLOCK_MATCHERS_STR}}
    _
  )
PATTERN

def on_send(node)
  return unless value_global_expectation?(node) || block_global_expectation?(node)

  message = format(MSG, preferred: preferred_receiver(node))
  add_offense(node, location: node.receiver.source_range, message: message)
end

Шаблоны для AST

Давайте немного поговорим о механизме шаблонов. Документация рекомендует использовать именно его для работы с AST, хотя всегда остается возможность манипулировать узлами напрямую. Интерпретатор шаблонов реализован в классе NodePattern (source) и был недавно вынесен в отдельный gem rubocop-ast.

По шаблонам на данный момент можно почитать только два официальных документа:

В них очень мало примеров и мне пришлось потыкаться вслепую и экспериментировать занимаясь новым шаблоном. Покопавшись в документации и исходниках я набросал вот такой скриптик, чтобы проверять соответствует ли шаблон Ruby коду или нет:

require 'rubocop'

source = "-> { obj.foo }.must_raise ArgumentError"
pattern = '(send _ :must_raise _)'

processed_source = RuboCop::AST::ProcessedSource.new(source, 2.7)
node_pattern = RuboCop::NodePattern.new(pattern)
node_pattern.match(processed_source.ast) # => true | nil

С помощью класса RuboCop::AST::ProcessedSource парсим Ruby код. Результирующее AST дерево можно получить вызвав метод ast. Создаем шаблон RuboCop::NodePattern и далее вызов метода match вернет true в случае успеха и nil иначе.

Заключение

Багу я пофиксил и мой PR вмержили. Пусть это и не основной репозиторий Rubocop’а, а всего лишь официальный плагин, все равно это маленький win.

Несмотря на то, что я таки познакомился с Rubocop, не получилось поманипулировать AST узлами напрямую без шаблонов. Остались вопросы по типам AST-узлов, порядку обхода AST-дерева итд. Это все не описано в документации и здесь Rubocop сильно полагается на gem parser. Абстракции все таки текут и в таком популярном проекте как Rubocop.

Статьи о разработке новых cop’ов:

Ссылки