diff --git a/lib/tapioca/dsl/compilers/rubocop.rb b/lib/tapioca/dsl/compilers/rubocop.rb new file mode 100644 index 0000000000..c42c87de7b --- /dev/null +++ b/lib/tapioca/dsl/compilers/rubocop.rb @@ -0,0 +1,83 @@ +# typed: strict +# frozen_string_literal: true + +begin + require "rubocop" +rescue LoadError + return +end + +module Tapioca + module Dsl + module Compilers + # `Tapioca::Dsl::Compilers::RuboCop` generate 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 { { fixed: T.all(T.class_of(::RuboCop::Cop::Base), Extensions::RuboCop) } } + + class << self + extend T::Sig + sig { override.returns(T::Enumerable[Class]) } + def gather_constants + descendants_of(::RuboCop::Cop::Base).select { |constant| name_of(constant) } + end + end + + sig { override.void } + def decorate + return unless used_macros? + + root.create_path(constant) do |cop_klass| + node_matchers.each do |name| + create_method_from_def(cop_klass, constant.instance_method(name)) + end + + node_searches.each do |name| + create_method_from_def(cop_klass, constant.instance_method(name)) + end + end + end + + private + + sig { returns(T::Boolean) } + def used_macros? + return true unless node_matchers.empty? + return true unless node_searches.empty? + + false + end + + sig { returns(T::Array[Extensions::RuboCop::MethodName]) } + def node_matchers + constant.__tapioca_node_matchers + end + + sig { returns(T::Array[Extensions::RuboCop::MethodName]) } + def node_searches + constant.__tapioca_node_searches + 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 0000000000..402b227263 --- /dev/null +++ b/lib/tapioca/dsl/extensions/rubocop.rb @@ -0,0 +1,52 @@ +# typed: strict +# frozen_string_literal: true + +begin + require "rubocop" +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_matchers << name + __tapioca_node_matchers << :"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_searches << name + __tapioca_node_searches << :"without_defaults_#{name}" unless defaults.empty? + + super + end + + sig { returns(T::Array[MethodName]) } + def __tapioca_node_matchers + @__tapioca_node_matchers = T.let(@__tapioca_node_matchers, T.nilable(T::Array[MethodName])) + @__tapioca_node_matchers ||= [] + end + + sig { returns(T::Array[MethodName]) } + def __tapioca_node_searches + @__tapioca_node_searches = T.let(@__tapioca_node_searches, T.nilable(T::Array[MethodName])) + @__tapioca_node_searches ||= [] + end + + ::RuboCop::Cop::Base.singleton_class.prepend(self) + end + end + end + end +end diff --git a/manual/compiler_rubocop.md b/manual/compiler_rubocop.md new file mode 100644 index 0000000000..37201e14c2 --- /dev/null +++ b/manual/compiler_rubocop.md @@ -0,0 +1,24 @@ +## RuboCop + +`Tapioca::Dsl::Compilers::RuboCop` generate 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 bb57ffa356..10dde0344e 100644 --- a/manual/compilers.md +++ b/manual/compilers.md @@ -28,6 +28,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 0000000000..c5c1d2c6f5 --- /dev/null +++ b/spec/tapioca/dsl/compilers/rubocop_spec.rb @@ -0,0 +1,159 @@ +# typed: strict +# frozen_string_literal: true + +require "spec_helper" +require "rubocop" +require "rubocop-sorbet" + +module Tapioca + module Dsl + module Compilers + class RuboCopSpec < ::DslSpec + # Collect constants from gems, before defining any in tests. + EXISTING_CONSTANTS = Runtime::Reflection + .descendants_of(::RuboCop::Cop::Base) + .filter_map { |constant| Runtime::Reflection.name_of(constant) } + + class << self + 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 "tapioca/dsl/extensions/rubocop" + super + end + + describe "initialize" do + it "gathered constants exclude irrelevant classes" do + add_ruby_file("content.rb", <<~RUBY) + class Unrelated + end + RUBY + assert_empty(relevant_gathered_constants) + end + + it "gathers constants inheriting RuboCop::Cop::Base 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 inheriting from RuboCop::Cop::Base in the host app" do + add_ruby_file("content.rb", <<~RUBY) + class MyCop < ::RuboCop::Cop::Base + end + + class MyLegacyCop < ::RuboCop::Cop::Cop + end + + module ::RuboCop + module Cop + module MyApp + class MyNamespacedCop < Base + end + end + end + end + RUBY + + assert_equal( + ["MyCop", "MyLegacyCop", "RuboCop::Cop::MyApp::MyNamespacedCop"], + relevant_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 + + def relevant_gathered_constants + gathered_constants - EXISTING_CONSTANTS + end + end + end + end + end +end