Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions lib/tapioca/dsl/compilers/rubocop_node_pattern.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
# typed: strict
# frozen_string_literal: true

return unless defined?(RuboCop::Cop::Base)

module Tapioca
module Dsl
module Compilers
# `Tapioca::Dsl::Compilers::RubocopNodePattern` generates RBI files for subclasses of
# [`RuboCop::Cop::Base`](https://docs.rubocop.org/rubocop-ast/node_pattern.html)
# that use `def_node_matcher` or `def_node_search` macros.
#
# For example, with the following cop:
#
# ~~~rb
# class MyCop < RuboCop::Cop::Base
# def_node_matcher :using_bang?, <<~PATTERN
# (send nil? :bang)
# PATTERN
#
# def_node_search :find_lvar_nodes, <<~PATTERN
# (lvar $_)
# PATTERN
# end
# ~~~
#
# this compiler will produce the RBI file `my_cop.rbi` with the following content:
#
# ~~~rbi
# # my_cop.rbi
# # typed: true
# class MyCop
# sig { params(param0: ::RuboCop::AST::Node).returns(T::Boolean) }
# def using_bang?(param0 = T.unsafe(nil)); end
#
# sig { params(param0: ::RuboCop::AST::Node, block: T.nilable(T.proc.params(node: ::RuboCop::AST::Node).void)).returns(T::Enumerator[::RuboCop::AST::Node]) }
# def find_lvar_nodes(param0, &block); end
# end
# ~~~
#: [ConstantType = singleton(::RuboCop::Cop::Base)]
class RubocopNodePattern < Compiler
# @override
#: -> void
def decorate
methods = macro_methods_for(constant)
return if methods.empty?

root.create_path(constant) do |scope|
methods.each do |method_name, macro_type, capture_count, extra_arg_count|
parameters = build_parameters(macro_type, extra_arg_count)
return_type = build_return_type(method_name.to_s, macro_type, capture_count)

if macro_type == :def_node_search && !method_name.to_s.end_with?("?")
parameters << create_block_param("block", type: "T.nilable(T.proc.params(node: ::RuboCop::AST::Node).void)")
end

scope.create_method(
method_name.to_s,
parameters: parameters,
return_type: return_type,
)
end
end
end

class << self
# @override
#: -> Enumerable[Module[top]]
def gather_constants
descendants_of(::RuboCop::Cop::Base)
end
end

private

#: (Module constant) -> Array[[Symbol, Symbol, Integer, Integer]]
def macro_methods_for(constant)
results = [] #: Array[[Symbol, Symbol, Integer, Integer]]

constant.instance_methods(false).each do |method_name|
method_def = constant.instance_method(method_name)
params = method_def.parameters

next unless node_pattern_method?(params)

macro_type = detect_macro_type(params)
next unless macro_type

pattern = extract_pattern(method_def)
next unless pattern

capture_count = count_captures(pattern)
extra_arg_count = count_extra_args(params)

results << [method_name, macro_type, capture_count, extra_arg_count]
end

results.sort_by(&:first)
end

#: (Array[[Symbol, Symbol]] params) -> bool
def node_pattern_method?(params)
return false if params.empty?

params.any? { |_, name| name.to_s.start_with?("param") }
end

#: (Array[[Symbol, Symbol]] params) -> Symbol?
def detect_macro_type(params)
first_param = params.first
return unless first_param

case first_param
in [:opt, :param0]
:def_node_matcher
in [:req, :param0]
:def_node_search
else
nil
end
end

#: (UnboundMethod method_def) -> String?
def extract_pattern(method_def)
source_file, source_line = method_def.source_location
return unless source_file && source_line

begin
lines = File.readlines(source_file)
line = lines[source_line - 1]
return unless line

if line.match?(/def_node_matcher|def_node_search/)
pattern = extract_pattern_from_source(lines, source_line - 1)
return pattern
end
rescue Errno::ENOENT, Errno::EACCES
nil
end
end

#: (Array[String] lines, Integer start_index) -> String?
def extract_pattern_from_source(lines, start_index)
line = lines[start_index]
return unless line

if line.include?("<<~")
heredoc_lines = []
terminator = line[/<<~(\w+)/, 1]
return unless terminator

i = start_index + 1
while i < lines.length
current = lines[i]
break if current&.strip == terminator

heredoc_lines << current if current
i += 1
end
heredoc_lines.join.strip
elsif (match = line.match(/['"](.*)['"]\s*$/))
match[1]
end
end

#: (String pattern) -> Integer
def count_captures(pattern)
RuboCop::AST::NodePattern.new(pattern).captures
rescue StandardError
0
end

#: (Array[[Symbol, Symbol]] params) -> Integer
def count_extra_args(params)
params.count { |_, name| name.to_s.match?(/\Aparam[1-9]\d*\z/) }
end

#: (Symbol macro_type, Integer extra_arg_count) -> Array[RBI::TypedParam]
def build_parameters(macro_type, extra_arg_count)
parameters = [] #: Array[RBI::TypedParam]

if macro_type == :def_node_matcher
parameters << create_opt_param("param0", type: "::RuboCop::AST::Node", default: "T.unsafe(nil)")
else
parameters << create_param("param0", type: "::RuboCop::AST::Node")
end

extra_arg_count.times do |i|
parameters << create_param("param#{i + 1}", type: "T.untyped")
end

parameters
end

#: (String method_name, Symbol macro_type, Integer capture_count) -> String
def build_return_type(method_name, macro_type, capture_count)
if macro_type == :def_node_matcher
if capture_count == 0
"T::Boolean"
else
"T.untyped"
end
elsif method_name.end_with?("?")
"T::Boolean"
else
"T::Enumerator[::RuboCop::AST::Node]"
end
end
end
end
end
end
166 changes: 166 additions & 0 deletions spec/tapioca/dsl/compilers/rubocop_node_pattern_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# typed: strict
# frozen_string_literal: true

require "spec_helper"

module Tapioca
module Dsl
module Compilers
class RubocopNodePatternSpec < ::DslSpec
describe "Tapioca::Dsl::Compilers::RubocopNodePattern" do
#: -> void
def before_setup
require "rubocop"
end

describe "initialize" do
it "gathers RuboCop::Cop::Base subclasses" do
add_ruby_file("cop.rb", <<~RUBY)
class TestCop < RuboCop::Cop::Base
def_node_matcher :using_bang?, <<~PATTERN
(send nil? :bang)
PATTERN
end
RUBY

assert_includes(gathered_constants, "TestCop")
end

it "gathers subclasses that define only regular methods" do
add_ruby_file("cop.rb", <<~RUBY)
class PlainCop < RuboCop::Cop::Base
def on_send(node)
end
end
RUBY

assert_includes(gathered_constants, "PlainCop")
end
end

describe "decorate" do
it "generates RBI for def_node_matcher with zero captures" do
add_ruby_file("cop.rb", <<~RUBY)
class MatcherCop < RuboCop::Cop::Base
def_node_matcher :bang_method?, <<~PATTERN
(send nil? :bang)
PATTERN
end
RUBY

expected = <<~RBI
# typed: strong

class MatcherCop
sig { params(param0: ::RuboCop::AST::Node).returns(T::Boolean) }
def bang_method?(param0 = T.unsafe(nil)); end
end
RBI

assert_equal(expected, rbi_for(:MatcherCop))
end

it "generates RBI for def_node_matcher with captures" do
add_ruby_file("cop.rb", <<~RUBY)
class CaptureCop < RuboCop::Cop::Base
def_node_matcher :captured_name, <<~PATTERN
(send nil? $_)
PATTERN
end
RUBY

expected = <<~RBI
# typed: strong

class CaptureCop
sig { params(param0: ::RuboCop::AST::Node).returns(T.untyped) }
def captured_name(param0 = T.unsafe(nil)); end
end
RBI

assert_equal(expected, rbi_for(:CaptureCop))
end

it "generates RBI for def_node_search predicate method" do
add_ruby_file("cop.rb", <<~RUBY)
class SearchCop < RuboCop::Cop::Base
def_node_search :has_lvar?, <<~PATTERN
(lvar _)
PATTERN
end
RUBY

expected = <<~RBI
# typed: strong

class SearchCop
sig { params(param0: ::RuboCop::AST::Node).returns(T::Boolean) }
def has_lvar?(param0); end
end
RBI

assert_equal(expected, rbi_for(:SearchCop))
end

it "generates RBI for def_node_search enumerator method" do
add_ruby_file("cop.rb", <<~RUBY)
class EnumSearchCop < RuboCop::Cop::Base
def_node_search :find_lvars, <<~PATTERN
(lvar $_)
PATTERN
end
RUBY

expected = <<~RBI
# typed: strong

class EnumSearchCop
sig { params(param0: ::RuboCop::AST::Node, block: T.nilable(T.proc.params(node: ::RuboCop::AST::Node).void)).returns(T::Enumerator[::RuboCop::AST::Node]) }
def find_lvars(param0, &block); end
end
RBI

assert_equal(expected, rbi_for(:EnumSearchCop))
end

it "handles extra pattern arguments" do
add_ruby_file("cop.rb", <<~RUBY)
class ExtraArgsCop < RuboCop::Cop::Base
def_node_matcher :method_call?, <<~PATTERN
(send nil? %1)
PATTERN
end
RUBY

expected = <<~RBI
# typed: strong

class ExtraArgsCop
sig { params(param0: ::RuboCop::AST::Node, param1: T.untyped).returns(T::Boolean) }
def method_call?(param0 = T.unsafe(nil), param1); end
end
RBI

assert_equal(expected, rbi_for(:ExtraArgsCop))
end

it "generates empty RBI for cop with no macro methods" do
add_ruby_file("cop.rb", <<~RUBY)
class EmptyCop < RuboCop::Cop::Base
def on_send(node)
end
end
RUBY

expected = <<~RBI
# typed: strong
RBI

assert_equal(expected, rbi_for(:EmptyCop))
end
end
end
end
end
end
end
Loading