Skip to content

Commit

Permalink
Implement RuboCop DSL compiler
Browse files Browse the repository at this point in the history
This generates RBI signatures for use of Rubocop's Node Pattern macros
(`def_node_matcher` & `def_node_search`).
  • Loading branch information
sambostock committed Oct 27, 2022
1 parent 50e65d7 commit a4e75e0
Show file tree
Hide file tree
Showing 5 changed files with 319 additions and 0 deletions.
83 changes: 83 additions & 0 deletions lib/tapioca/dsl/compilers/rubocop.rb
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions lib/tapioca/dsl/extensions/rubocop.rb
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions manual/compiler_rubocop.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions manual/compilers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
159 changes: 159 additions & 0 deletions spec/tapioca/dsl/compilers/rubocop_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit a4e75e0

Please sign in to comment.