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 28, 2022
1 parent 1c7862f commit 57670ce
Show file tree
Hide file tree
Showing 5 changed files with 303 additions and 0 deletions.
66 changes: 66 additions & 0 deletions lib/tapioca/dsl/compilers/rubocop.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# typed: strict
# frozen_string_literal: true

begin
require "rubocop"
rescue LoadError
return
end

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 { { 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 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
46 changes: 46 additions & 0 deletions lib/tapioca/dsl/extensions/rubocop.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 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_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(@__tapioca_node_methods, T.nilable(T::Array[MethodName]))
@__tapioca_node_methods ||= []
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` 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
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
166 changes: 166 additions & 0 deletions spec/tapioca/dsl/compilers/rubocop_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"
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 = T.let(
Runtime::Reflection
.descendants_of(::RuboCop::Cop::Base)
.filter_map { |constant| Runtime::Reflection.name_of(constant) },
T::Array[String],
)

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 "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

sig { returns(T::Array[String]) }
def relevant_gathered_constants
gathered_constants - EXISTING_CONSTANTS
end
end
end
end
end
end

0 comments on commit 57670ce

Please sign in to comment.