Skip to content

Commit a4e75e0

Browse files
committed
Implement RuboCop DSL compiler
This generates RBI signatures for use of Rubocop's Node Pattern macros (`def_node_matcher` & `def_node_search`).
1 parent 50e65d7 commit a4e75e0

File tree

5 files changed

+319
-0
lines changed

5 files changed

+319
-0
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
begin
5+
require "rubocop"
6+
rescue LoadError
7+
return
8+
end
9+
10+
module Tapioca
11+
module Dsl
12+
module Compilers
13+
# `Tapioca::Dsl::Compilers::RuboCop` generate types for RuboCop cops.
14+
# RuboCop uses macros to define methods leveraging "AST node patterns".
15+
# For example, in this cop
16+
#
17+
# class MyCop < Base
18+
# def_node_matcher :matches_some_pattern?, "..."
19+
#
20+
# def on_send(node)
21+
# return unless matches_some_pattern?(node)
22+
# # ...
23+
# end
24+
# end
25+
#
26+
# the use of `def_node_matcher` will generate the method
27+
# `matches_some_pattern?`, for which this compiler will generate a `sig`.
28+
#
29+
# More complex uses are also supported, including:
30+
#
31+
# - Usage of `def_node_search`
32+
# - Parameter specification
33+
# - Default parameter specification, including generating sigs for
34+
# `without_defaults_*` methods
35+
class RuboCop < Compiler
36+
ConstantType = type_member { { fixed: T.all(T.class_of(::RuboCop::Cop::Base), Extensions::RuboCop) } }
37+
38+
class << self
39+
extend T::Sig
40+
sig { override.returns(T::Enumerable[Class]) }
41+
def gather_constants
42+
descendants_of(::RuboCop::Cop::Base).select { |constant| name_of(constant) }
43+
end
44+
end
45+
46+
sig { override.void }
47+
def decorate
48+
return unless used_macros?
49+
50+
root.create_path(constant) do |cop_klass|
51+
node_matchers.each do |name|
52+
create_method_from_def(cop_klass, constant.instance_method(name))
53+
end
54+
55+
node_searches.each do |name|
56+
create_method_from_def(cop_klass, constant.instance_method(name))
57+
end
58+
end
59+
end
60+
61+
private
62+
63+
sig { returns(T::Boolean) }
64+
def used_macros?
65+
return true unless node_matchers.empty?
66+
return true unless node_searches.empty?
67+
68+
false
69+
end
70+
71+
sig { returns(T::Array[Extensions::RuboCop::MethodName]) }
72+
def node_matchers
73+
constant.__tapioca_node_matchers
74+
end
75+
76+
sig { returns(T::Array[Extensions::RuboCop::MethodName]) }
77+
def node_searches
78+
constant.__tapioca_node_searches
79+
end
80+
end
81+
end
82+
end
83+
end
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
begin
5+
require "rubocop"
6+
rescue LoadError
7+
return
8+
end
9+
10+
module Tapioca
11+
module Dsl
12+
module Compilers
13+
module Extensions
14+
module RuboCop
15+
extend T::Sig
16+
17+
MethodName = T.type_alias { T.any(String, Symbol) }
18+
19+
sig { params(name: MethodName, _args: T.untyped, defaults: T.untyped).returns(MethodName) }
20+
def def_node_matcher(name, *_args, **defaults)
21+
__tapioca_node_matchers << name
22+
__tapioca_node_matchers << :"without_defaults_#{name}" unless defaults.empty?
23+
24+
super
25+
end
26+
27+
sig { params(name: MethodName, _args: T.untyped, defaults: T.untyped).returns(MethodName) }
28+
def def_node_search(name, *_args, **defaults)
29+
__tapioca_node_searches << name
30+
__tapioca_node_searches << :"without_defaults_#{name}" unless defaults.empty?
31+
32+
super
33+
end
34+
35+
sig { returns(T::Array[MethodName]) }
36+
def __tapioca_node_matchers
37+
@__tapioca_node_matchers = T.let(@__tapioca_node_matchers, T.nilable(T::Array[MethodName]))
38+
@__tapioca_node_matchers ||= []
39+
end
40+
41+
sig { returns(T::Array[MethodName]) }
42+
def __tapioca_node_searches
43+
@__tapioca_node_searches = T.let(@__tapioca_node_searches, T.nilable(T::Array[MethodName]))
44+
@__tapioca_node_searches ||= []
45+
end
46+
47+
::RuboCop::Cop::Base.singleton_class.prepend(self)
48+
end
49+
end
50+
end
51+
end
52+
end

manual/compiler_rubocop.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## RuboCop
2+
3+
`Tapioca::Dsl::Compilers::RuboCop` generate types for RuboCop cops.
4+
RuboCop uses macros to define methods leveraging "AST node patterns".
5+
For example, in this cop
6+
7+
class MyCop < Base
8+
def_node_matcher :matches_some_pattern?, "..."
9+
10+
def on_send(node)
11+
return unless matches_some_pattern?(node)
12+
# ...
13+
end
14+
end
15+
16+
the use of `def_node_matcher` will generate the method
17+
`matches_some_pattern?`, for which this compiler will generate a `sig`.
18+
19+
More complex uses are also supported, including:
20+
21+
- Usage of `def_node_search`
22+
- Parameter specification
23+
- Default parameter specification, including generating sigs for
24+
`without_defaults_*` methods

manual/compilers.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ In the following section you will find all available DSL compilers:
2828
* [MixedInClassAttributes](compiler_mixedinclassattributes.md)
2929
* [Protobuf](compiler_protobuf.md)
3030
* [RailsGenerators](compiler_railsgenerators.md)
31+
* [RuboCop](compiler_rubocop.md)
3132
* [SidekiqWorker](compiler_sidekiqworker.md)
3233
* [SmartProperties](compiler_smartproperties.md)
3334
* [StateMachines](compiler_statemachines.md)
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "spec_helper"
5+
require "rubocop"
6+
require "rubocop-sorbet"
7+
8+
module Tapioca
9+
module Dsl
10+
module Compilers
11+
class RuboCopSpec < ::DslSpec
12+
# Collect constants from gems, before defining any in tests.
13+
EXISTING_CONSTANTS = Runtime::Reflection
14+
.descendants_of(::RuboCop::Cop::Base)
15+
.filter_map { |constant| Runtime::Reflection.name_of(constant) }
16+
17+
class << self
18+
def target_class_file
19+
# Against convention, RuboCop uses "rubocop" in its file names, so we do too.
20+
super.gsub("rubo_cop", "rubocop")
21+
end
22+
end
23+
24+
describe "Tapioca::Dsl::Compilers::RuboCop" do
25+
sig { void }
26+
def before_setup
27+
require "tapioca/dsl/extensions/rubocop"
28+
super
29+
end
30+
31+
describe "initialize" do
32+
it "gathered constants exclude irrelevant classes" do
33+
add_ruby_file("content.rb", <<~RUBY)
34+
class Unrelated
35+
end
36+
RUBY
37+
assert_empty(relevant_gathered_constants)
38+
end
39+
40+
it "gathers constants inheriting RuboCop::Cop::Base in gems" do
41+
# Sample of miscellaneous constants that should be found from Rubocop and plugins
42+
missing_constants = [
43+
"RuboCop::Cop::Bundler::GemVersion",
44+
"RuboCop::Cop::Cop",
45+
"RuboCop::Cop::Gemspec::DependencyVersion",
46+
"RuboCop::Cop::Lint::Void",
47+
"RuboCop::Cop::Metrics::ClassLength",
48+
"RuboCop::Cop::Migration::DepartmentName",
49+
"RuboCop::Cop::Naming::MethodName",
50+
"RuboCop::Cop::Security::CompoundHash",
51+
"RuboCop::Cop::Sorbet::ValidSigil",
52+
"RuboCop::Cop::Style::YodaCondition",
53+
] - gathered_constants
54+
55+
assert_empty(missing_constants, "expected constants to be gathered")
56+
end
57+
58+
it "gathers constants inheriting from RuboCop::Cop::Base in the host app" do
59+
add_ruby_file("content.rb", <<~RUBY)
60+
class MyCop < ::RuboCop::Cop::Base
61+
end
62+
63+
class MyLegacyCop < ::RuboCop::Cop::Cop
64+
end
65+
66+
module ::RuboCop
67+
module Cop
68+
module MyApp
69+
class MyNamespacedCop < Base
70+
end
71+
end
72+
end
73+
end
74+
RUBY
75+
76+
assert_equal(
77+
["MyCop", "MyLegacyCop", "RuboCop::Cop::MyApp::MyNamespacedCop"],
78+
relevant_gathered_constants,
79+
)
80+
end
81+
end
82+
83+
describe "decorate" do
84+
it "generates empty RBI when no DSL used" do
85+
add_ruby_file("content.rb", <<~RUBY)
86+
class MyCop < ::RuboCop::Cop::Base
87+
def on_send(node);end
88+
end
89+
RUBY
90+
91+
expected = <<~RBI
92+
# typed: strong
93+
RBI
94+
95+
assert_equal(expected, rbi_for(:MyCop))
96+
end
97+
98+
it "generates correct RBI file" do
99+
add_ruby_file("content.rb", <<~RUBY)
100+
class MyCop < ::RuboCop::Cop::Base
101+
def_node_matcher :some_matcher, "(...)"
102+
def_node_matcher :some_matcher_with_params, "(%1 %two ...)"
103+
def_node_matcher :some_matcher_with_params_and_defaults, "(%1 %two ...)", two: :default
104+
def_node_matcher :some_predicate_matcher?, "(...)"
105+
def_node_search :some_search, "(...)"
106+
def_node_search :some_search_with_params, "(%1 %two ...)"
107+
def_node_search :some_search_with_params_and_defaults, "(%1 %two ...)", two: :default
108+
109+
def on_send(node);end
110+
end
111+
RUBY
112+
113+
expected = <<~RBI
114+
# typed: strong
115+
116+
class MyCop
117+
sig { params(param0: T.untyped).returns(T.untyped) }
118+
def some_matcher(param0 = T.unsafe(nil)); end
119+
120+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
121+
def some_matcher_with_params(param0 = T.unsafe(nil), param1, two:); end
122+
123+
sig { params(args: T.untyped, values: T.untyped).returns(T.untyped) }
124+
def some_matcher_with_params_and_defaults(*args, **values); end
125+
126+
sig { params(param0: T.untyped).returns(T.untyped) }
127+
def some_predicate_matcher?(param0 = T.unsafe(nil)); end
128+
129+
sig { params(param0: T.untyped).returns(T.untyped) }
130+
def some_search(param0); end
131+
132+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
133+
def some_search_with_params(param0, param1, two:); end
134+
135+
sig { params(args: T.untyped, values: T.untyped).returns(T.untyped) }
136+
def some_search_with_params_and_defaults(*args, **values); end
137+
138+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
139+
def without_defaults_some_matcher_with_params_and_defaults(param0 = T.unsafe(nil), param1, two:); end
140+
141+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
142+
def without_defaults_some_search_with_params_and_defaults(param0, param1, two:); end
143+
end
144+
RBI
145+
146+
assert_equal(expected, rbi_for(:MyCop))
147+
end
148+
end
149+
150+
private
151+
152+
def relevant_gathered_constants
153+
gathered_constants - EXISTING_CONSTANTS
154+
end
155+
end
156+
end
157+
end
158+
end
159+
end

0 commit comments

Comments
 (0)