diff --git a/lib/tapioca/dsl/compilers/rubocop.rb b/lib/tapioca/dsl/compilers/rubocop.rb new file mode 100644 index 000000000..dcfdcb62c --- /dev/null +++ b/lib/tapioca/dsl/compilers/rubocop.rb @@ -0,0 +1,67 @@ +# typed: strict +# frozen_string_literal: true + +return unless defined?(RuboCop::AST::NodePattern::Macros) + +module Tapioca + module Dsl + module Compilers + # `Tapioca::Dsl::Compilers::RuboCop` generates types for RuboCop cops. + # RuboCop uses macros to define methods leveraging "AST node patterns". + # For example, in this cop + # + # class MyCop < Base + # def_node_matcher :matches_some_pattern?, "..." + # + # def on_send(node) + # return unless matches_some_pattern?(node) + # # ... + # end + # end + # + # the use of `def_node_matcher` will generate the method + # `matches_some_pattern?`, for which this compiler will generate a `sig`. + # + # More complex uses are also supported, including: + # + # - Usage of `def_node_search` + # - Parameter specification + # - Default parameter specification, including generating sigs for + # `without_defaults_*` methods + class RuboCop < Compiler + ConstantType = type_member do + { fixed: T.all(Module, Extensions::RuboCop) } + end + + class << self + extend T::Sig + sig { override.returns(T::Array[T.all(Module, Extensions::RuboCop)]) } + def gather_constants + T.cast( + extenders_of(::RuboCop::AST::NodePattern::Macros).select { |constant| name_of(constant) }, + T::Array[T.all(Module, Extensions::RuboCop)], + ) + end + end + + sig { override.void } + def decorate + return if node_methods.empty? + + root.create_path(constant) do |cop_klass| + node_methods.each do |name| + create_method_from_def(cop_klass, constant.instance_method(name)) + end + end + end + + private + + sig { returns(T::Array[Extensions::RuboCop::MethodName]) } + def node_methods + constant.__tapioca_node_methods + end + end + end + end +end diff --git a/lib/tapioca/dsl/extensions/rubocop.rb b/lib/tapioca/dsl/extensions/rubocop.rb new file mode 100644 index 000000000..ac5486ea6 --- /dev/null +++ b/lib/tapioca/dsl/extensions/rubocop.rb @@ -0,0 +1,45 @@ +# typed: strict +# frozen_string_literal: true + +begin + require "rubocop-ast" +rescue LoadError + return +end + +module Tapioca + module Dsl + module Compilers + module Extensions + module RuboCop + extend T::Sig + + MethodName = T.type_alias { T.any(String, Symbol) } + + sig { params(name: MethodName, _args: T.untyped, defaults: T.untyped).returns(MethodName) } + def def_node_matcher(name, *_args, **defaults) + __tapioca_node_methods << name + __tapioca_node_methods << :"without_defaults_#{name}" unless defaults.empty? + + super + end + + sig { params(name: MethodName, _args: T.untyped, defaults: T.untyped).returns(MethodName) } + def def_node_search(name, *_args, **defaults) + __tapioca_node_methods << name + __tapioca_node_methods << :"without_defaults_#{name}" unless defaults.empty? + + super + end + + sig { returns(T::Array[MethodName]) } + def __tapioca_node_methods + @__tapioca_node_methods ||= T.let([], T.nilable(T::Array[MethodName])) + end + + ::RuboCop::AST::NodePattern::Macros.prepend(self) + end + end + end + end +end diff --git a/lib/tapioca/runtime/reflection.rb b/lib/tapioca/runtime/reflection.rb index 966cd1ebb..5d9c56f8c 100644 --- a/lib/tapioca/runtime/reflection.rb +++ b/lib/tapioca/runtime/reflection.rb @@ -172,6 +172,33 @@ def descendants_of(klass) T.unsafe(result) end + # Returns an array with all modules which extend the supplied module + # (i.e. all modules whose singleton class, or ancestor thereof, includes the supplied module). + # + # module M; end + # extenders_of(M) # => [] + # + # module E + # extend M + # end + # extenders_of(M) # => [E] + # + # class P + # extend M + # end + # extenders_of(M) # => [E, P] + # + # class C < P; end + # extenders_of(M) # => [E, P, C] + sig { params(mod: Module).returns(T::Array[Module]) } + def extenders_of(mod) + result = ObjectSpace.each_object(Module).select do |m| + T.cast(m, Module).singleton_class.included_modules.include?(mod) + end + + T.cast(result, T::Array[Module]) + end + # Examines the call stack to identify the closest location where a "require" is performed # by searching for the label "". If none is found, it returns the location # labeled "
", which is the original call site. diff --git a/manual/compiler_rubocop.md b/manual/compiler_rubocop.md new file mode 100644 index 000000000..1a23a8aa4 --- /dev/null +++ b/manual/compiler_rubocop.md @@ -0,0 +1,24 @@ +## RuboCop + +`Tapioca::Dsl::Compilers::RuboCop` generates types for RuboCop cops. +RuboCop uses macros to define methods leveraging "AST node patterns". +For example, in this cop + + class MyCop < Base + def_node_matcher :matches_some_pattern?, "..." + + def on_send(node) + return unless matches_some_pattern?(node) + # ... + end + end + +the use of `def_node_matcher` will generate the method +`matches_some_pattern?`, for which this compiler will generate a `sig`. + +More complex uses are also supported, including: + +- Usage of `def_node_search` +- Parameter specification +- Default parameter specification, including generating sigs for + `without_defaults_*` methods diff --git a/manual/compilers.md b/manual/compilers.md index 44e8f976d..66c364f9a 100644 --- a/manual/compilers.md +++ b/manual/compilers.md @@ -35,6 +35,7 @@ In the following section you will find all available DSL compilers: * [MixedInClassAttributes](compiler_mixedinclassattributes.md) * [Protobuf](compiler_protobuf.md) * [RailsGenerators](compiler_railsgenerators.md) +* [RuboCop](compiler_rubocop.md) * [SidekiqWorker](compiler_sidekiqworker.md) * [SmartProperties](compiler_smartproperties.md) * [StateMachines](compiler_statemachines.md) diff --git a/spec/tapioca/dsl/compilers/rubocop_spec.rb b/spec/tapioca/dsl/compilers/rubocop_spec.rb new file mode 100644 index 000000000..bb9afc291 --- /dev/null +++ b/spec/tapioca/dsl/compilers/rubocop_spec.rb @@ -0,0 +1,174 @@ +# typed: strict +# frozen_string_literal: true + +require "spec_helper" + +module Tapioca + module Dsl + module Compilers + class RuboCopSpec < ::DslSpec + class << self + extend T::Sig + + sig { override.returns(String) } + def target_class_file + # Against convention, RuboCop uses "rubocop" in its file names, so we do too. + super.gsub("rubo_cop", "rubocop") + end + end + + describe "Tapioca::Dsl::Compilers::RuboCop" do + sig { void } + def before_setup + require "rubocop" + require "rubocop-sorbet" + require "tapioca/dsl/extensions/rubocop" + super + end + + describe "initialize" do + it "gathered constants exclude irrelevant classes" do + gathered_constants = gather_constants do + add_ruby_file("content.rb", <<~RUBY) + class Unrelated + end + RUBY + end + assert_empty(gathered_constants) + end + + it "gathers constants extending RuboCop::AST::NodePattern::Macros in gems" do + # Sample of miscellaneous constants that should be found from Rubocop and plugins + missing_constants = [ + "RuboCop::Cop::Bundler::GemVersion", + "RuboCop::Cop::Cop", + "RuboCop::Cop::Gemspec::DependencyVersion", + "RuboCop::Cop::Lint::Void", + "RuboCop::Cop::Metrics::ClassLength", + "RuboCop::Cop::Migration::DepartmentName", + "RuboCop::Cop::Naming::MethodName", + "RuboCop::Cop::Security::CompoundHash", + "RuboCop::Cop::Sorbet::ValidSigil", + "RuboCop::Cop::Style::YodaCondition", + ] - gathered_constants + + assert_empty(missing_constants, "expected constants to be gathered") + end + + it "gathers constants extending RuboCop::AST::NodePattern::Macros in the host app" do + gathered_constants = gather_constants do + add_ruby_file("content.rb", <<~RUBY) + class MyCop < ::RuboCop::Cop::Base + end + + class MyLegacyCop < ::RuboCop::Cop::Cop + end + + module MyMacroModule + extend ::RuboCop::AST::NodePattern::Macros + end + + module ::RuboCop + module Cop + module MyApp + class MyNamespacedCop < Base + end + end + end + end + RUBY + end + + assert_equal( + ["MyCop", "MyLegacyCop", "MyMacroModule", "RuboCop::Cop::MyApp::MyNamespacedCop"], + gathered_constants, + ) + end + end + + describe "decorate" do + it "generates empty RBI when no DSL used" do + add_ruby_file("content.rb", <<~RUBY) + class MyCop < ::RuboCop::Cop::Base + def on_send(node);end + end + RUBY + + expected = <<~RBI + # typed: strong + RBI + + assert_equal(expected, rbi_for(:MyCop)) + end + + it "generates correct RBI file" do + add_ruby_file("content.rb", <<~RUBY) + class MyCop < ::RuboCop::Cop::Base + def_node_matcher :some_matcher, "(...)" + def_node_matcher :some_matcher_with_params, "(%1 %two ...)" + def_node_matcher :some_matcher_with_params_and_defaults, "(%1 %two ...)", two: :default + def_node_matcher :some_predicate_matcher?, "(...)" + def_node_search :some_search, "(...)" + def_node_search :some_search_with_params, "(%1 %two ...)" + def_node_search :some_search_with_params_and_defaults, "(%1 %two ...)", two: :default + + def on_send(node);end + end + RUBY + + expected = <<~RBI + # typed: strong + + class MyCop + sig { params(param0: T.untyped).returns(T.untyped) } + def some_matcher(param0 = T.unsafe(nil)); end + + sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) } + def some_matcher_with_params(param0 = T.unsafe(nil), param1, two:); end + + sig { params(args: T.untyped, values: T.untyped).returns(T.untyped) } + def some_matcher_with_params_and_defaults(*args, **values); end + + sig { params(param0: T.untyped).returns(T.untyped) } + def some_predicate_matcher?(param0 = T.unsafe(nil)); end + + sig { params(param0: T.untyped).returns(T.untyped) } + def some_search(param0); end + + sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) } + def some_search_with_params(param0, param1, two:); end + + sig { params(args: T.untyped, values: T.untyped).returns(T.untyped) } + def some_search_with_params_and_defaults(*args, **values); end + + sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) } + def without_defaults_some_matcher_with_params_and_defaults(param0 = T.unsafe(nil), param1, two:); end + + sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) } + def without_defaults_some_search_with_params_and_defaults(param0, param1, two:); end + end + RBI + + assert_equal(expected, rbi_for(:MyCop)) + end + end + + private + + # Gathers constants introduced in the given block excluding constants that already existed prior to the block. + sig { params(block: T.proc.void).returns(T::Array[String]) } + def gather_constants(&block) + existing_constants = T.let( + Runtime::Reflection + .extenders_of(::RuboCop::AST::NodePattern::Macros) + .filter_map { |constant| Runtime::Reflection.name_of(constant) }, + T::Array[String], + ) + yield + gathered_constants - existing_constants + end + end + end + end + end +end