diff --git a/lib/tapioca/dsl/compilers/rubocop_node_pattern.rb b/lib/tapioca/dsl/compilers/rubocop_node_pattern.rb new file mode 100644 index 000000000..efc2310a4 --- /dev/null +++ b/lib/tapioca/dsl/compilers/rubocop_node_pattern.rb @@ -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 diff --git a/spec/tapioca/dsl/compilers/rubocop_node_pattern_spec.rb b/spec/tapioca/dsl/compilers/rubocop_node_pattern_spec.rb new file mode 100644 index 000000000..a886058aa --- /dev/null +++ b/spec/tapioca/dsl/compilers/rubocop_node_pattern_spec.rb @@ -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