Skip to content

Commit a3fc3da

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 e46f9e2 commit a3fc3da

File tree

5 files changed

+307
-0
lines changed

5 files changed

+307
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
return unless defined?(RuboCop::AST::NodePattern::Macros)
5+
6+
module Tapioca
7+
module Dsl
8+
module Compilers
9+
# `Tapioca::Dsl::Compilers::RuboCop` generates types for RuboCop cops.
10+
# RuboCop uses macros to define methods leveraging "AST node patterns".
11+
# For example, in this cop
12+
#
13+
# class MyCop < Base
14+
# def_node_matcher :matches_some_pattern?, "..."
15+
#
16+
# def on_send(node)
17+
# return unless matches_some_pattern?(node)
18+
# # ...
19+
# end
20+
# end
21+
#
22+
# the use of `def_node_matcher` will generate the method
23+
# `matches_some_pattern?`, for which this compiler will generate a `sig`.
24+
#
25+
# More complex uses are also supported, including:
26+
#
27+
# - Usage of `def_node_search`
28+
# - Parameter specification
29+
# - Default parameter specification, including generating sigs for
30+
# `without_defaults_*` methods
31+
class RuboCop < Compiler
32+
ConstantType = type_member do
33+
{ fixed: T.all(Module, Extensions::RuboCop) }
34+
end
35+
36+
class << self
37+
extend T::Sig
38+
sig { override.returns(T::Array[T.all(Module, Extensions::RuboCop)]) }
39+
def gather_constants
40+
T.cast(
41+
extenders_of(::RuboCop::AST::NodePattern::Macros).select { |constant| name_of(constant) },
42+
T::Array[T.all(Module, Extensions::RuboCop)],
43+
)
44+
end
45+
end
46+
47+
sig { override.void }
48+
def decorate
49+
return if node_methods.empty?
50+
51+
root.create_path(constant) do |cop_klass|
52+
node_methods.each do |name|
53+
create_method_from_def(cop_klass, constant.instance_method(name))
54+
end
55+
end
56+
end
57+
58+
private
59+
60+
sig { returns(T::Array[Extensions::RuboCop::MethodName]) }
61+
def node_methods
62+
constant.__tapioca_node_methods
63+
end
64+
end
65+
end
66+
end
67+
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
return unless defined?(RuboCop::AST::NodePattern::Macros)
5+
6+
module Tapioca
7+
module Dsl
8+
module Compilers
9+
module Extensions
10+
module RuboCop
11+
extend T::Sig
12+
13+
MethodName = T.type_alias { T.any(String, Symbol) }
14+
15+
sig { params(name: MethodName, _args: T.untyped, defaults: T.untyped).returns(MethodName) }
16+
def def_node_matcher(name, *_args, **defaults)
17+
__tapioca_node_methods << name
18+
__tapioca_node_methods << :"without_defaults_#{name}" unless defaults.empty?
19+
20+
super
21+
end
22+
23+
sig { params(name: MethodName, _args: T.untyped, defaults: T.untyped).returns(MethodName) }
24+
def def_node_search(name, *_args, **defaults)
25+
__tapioca_node_methods << name
26+
__tapioca_node_methods << :"without_defaults_#{name}" unless defaults.empty?
27+
28+
super
29+
end
30+
31+
sig { returns(T::Array[MethodName]) }
32+
def __tapioca_node_methods
33+
@__tapioca_node_methods ||= T.let([], T.nilable(T::Array[MethodName]))
34+
end
35+
36+
::RuboCop::AST::NodePattern::Macros.prepend(self)
37+
end
38+
end
39+
end
40+
end
41+
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` generates 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
@@ -35,6 +35,7 @@ In the following section you will find all available DSL compilers:
3535
* [MixedInClassAttributes](compiler_mixedinclassattributes.md)
3636
* [Protobuf](compiler_protobuf.md)
3737
* [RailsGenerators](compiler_railsgenerators.md)
38+
* [RuboCop](compiler_rubocop.md)
3839
* [SidekiqWorker](compiler_sidekiqworker.md)
3940
* [SmartProperties](compiler_smartproperties.md)
4041
* [StateMachines](compiler_statemachines.md)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "spec_helper"
5+
6+
module Tapioca
7+
module Dsl
8+
module Compilers
9+
class RuboCopSpec < ::DslSpec
10+
class << self
11+
extend T::Sig
12+
13+
sig { override.returns(String) }
14+
def target_class_file
15+
# Against convention, RuboCop uses "rubocop" in its file names, so we do too.
16+
super.gsub("rubo_cop", "rubocop")
17+
end
18+
end
19+
20+
describe "Tapioca::Dsl::Compilers::RuboCop" do
21+
sig { void }
22+
def before_setup
23+
require "rubocop"
24+
require "rubocop-sorbet"
25+
require "tapioca/dsl/extensions/rubocop"
26+
super
27+
end
28+
29+
describe "initialize" do
30+
it "gathered constants exclude irrelevant classes" do
31+
gathered_constants = gather_constants do
32+
add_ruby_file("content.rb", <<~RUBY)
33+
class Unrelated
34+
end
35+
RUBY
36+
end
37+
assert_empty(gathered_constants)
38+
end
39+
40+
it "gathers constants extending RuboCop::AST::NodePattern::Macros 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 extending RuboCop::AST::NodePattern::Macros in the host app" do
59+
gathered_constants = gather_constants do
60+
add_ruby_file("content.rb", <<~RUBY)
61+
class MyCop < ::RuboCop::Cop::Base
62+
end
63+
64+
class MyLegacyCop < ::RuboCop::Cop::Cop
65+
end
66+
67+
module MyMacroModule
68+
extend ::RuboCop::AST::NodePattern::Macros
69+
end
70+
71+
module ::RuboCop
72+
module Cop
73+
module MyApp
74+
class MyNamespacedCop < Base
75+
end
76+
end
77+
end
78+
end
79+
RUBY
80+
end
81+
82+
assert_equal(
83+
["MyCop", "MyLegacyCop", "MyMacroModule", "RuboCop::Cop::MyApp::MyNamespacedCop"],
84+
gathered_constants,
85+
)
86+
end
87+
end
88+
89+
describe "decorate" do
90+
it "generates empty RBI when no DSL used" do
91+
add_ruby_file("content.rb", <<~RUBY)
92+
class MyCop < ::RuboCop::Cop::Base
93+
def on_send(node);end
94+
end
95+
RUBY
96+
97+
expected = <<~RBI
98+
# typed: strong
99+
RBI
100+
101+
assert_equal(expected, rbi_for(:MyCop))
102+
end
103+
104+
it "generates correct RBI file" do
105+
add_ruby_file("content.rb", <<~RUBY)
106+
class MyCop < ::RuboCop::Cop::Base
107+
def_node_matcher :some_matcher, "(...)"
108+
def_node_matcher :some_matcher_with_params, "(%1 %two ...)"
109+
def_node_matcher :some_matcher_with_params_and_defaults, "(%1 %two ...)", two: :default
110+
def_node_matcher :some_predicate_matcher?, "(...)"
111+
def_node_search :some_search, "(...)"
112+
def_node_search :some_search_with_params, "(%1 %two ...)"
113+
def_node_search :some_search_with_params_and_defaults, "(%1 %two ...)", two: :default
114+
115+
def on_send(node);end
116+
end
117+
RUBY
118+
119+
expected = <<~RBI
120+
# typed: strong
121+
122+
class MyCop
123+
sig { params(param0: T.untyped).returns(T.untyped) }
124+
def some_matcher(param0 = T.unsafe(nil)); end
125+
126+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
127+
def some_matcher_with_params(param0 = T.unsafe(nil), param1, two:); end
128+
129+
sig { params(args: T.untyped, values: T.untyped).returns(T.untyped) }
130+
def some_matcher_with_params_and_defaults(*args, **values); end
131+
132+
sig { params(param0: T.untyped).returns(T.untyped) }
133+
def some_predicate_matcher?(param0 = T.unsafe(nil)); end
134+
135+
sig { params(param0: T.untyped).returns(T.untyped) }
136+
def some_search(param0); end
137+
138+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
139+
def some_search_with_params(param0, param1, two:); end
140+
141+
sig { params(args: T.untyped, values: T.untyped).returns(T.untyped) }
142+
def some_search_with_params_and_defaults(*args, **values); end
143+
144+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
145+
def without_defaults_some_matcher_with_params_and_defaults(param0 = T.unsafe(nil), param1, two:); end
146+
147+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
148+
def without_defaults_some_search_with_params_and_defaults(param0, param1, two:); end
149+
end
150+
RBI
151+
152+
assert_equal(expected, rbi_for(:MyCop))
153+
end
154+
end
155+
156+
private
157+
158+
# Gathers constants introduced in the given block excluding constants that already existed prior to the block.
159+
sig { params(block: T.proc.void).returns(T::Array[String]) }
160+
def gather_constants(&block)
161+
existing_constants = T.let(
162+
Runtime::Reflection
163+
.extenders_of(::RuboCop::AST::NodePattern::Macros)
164+
.filter_map { |constant| Runtime::Reflection.name_of(constant) },
165+
T::Array[String],
166+
)
167+
yield
168+
gathered_constants - existing_constants
169+
end
170+
end
171+
end
172+
end
173+
end
174+
end

0 commit comments

Comments
 (0)