Skip to content

Commit 57670ce

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 1c7862f commit 57670ce

File tree

5 files changed

+303
-0
lines changed

5 files changed

+303
-0
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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` generates 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 if node_methods.empty?
49+
50+
root.create_path(constant) do |cop_klass|
51+
node_methods.each do |name|
52+
create_method_from_def(cop_klass, constant.instance_method(name))
53+
end
54+
end
55+
end
56+
57+
private
58+
59+
sig { returns(T::Array[Extensions::RuboCop::MethodName]) }
60+
def node_methods
61+
constant.__tapioca_node_methods
62+
end
63+
end
64+
end
65+
end
66+
end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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_methods << name
22+
__tapioca_node_methods << :"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_methods << name
30+
__tapioca_node_methods << :"without_defaults_#{name}" unless defaults.empty?
31+
32+
super
33+
end
34+
35+
sig { returns(T::Array[MethodName]) }
36+
def __tapioca_node_methods
37+
@__tapioca_node_methods = T.let(@__tapioca_node_methods, T.nilable(T::Array[MethodName]))
38+
@__tapioca_node_methods ||= []
39+
end
40+
41+
::RuboCop::Cop::Base.singleton_class.prepend(self)
42+
end
43+
end
44+
end
45+
end
46+
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
@@ -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: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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 = T.let(
14+
Runtime::Reflection
15+
.descendants_of(::RuboCop::Cop::Base)
16+
.filter_map { |constant| Runtime::Reflection.name_of(constant) },
17+
T::Array[String],
18+
)
19+
20+
class << self
21+
extend T::Sig
22+
23+
sig { override.returns(String) }
24+
def target_class_file
25+
# Against convention, RuboCop uses "rubocop" in its file names, so we do too.
26+
super.gsub("rubo_cop", "rubocop")
27+
end
28+
end
29+
30+
describe "Tapioca::Dsl::Compilers::RuboCop" do
31+
sig { void }
32+
def before_setup
33+
require "tapioca/dsl/extensions/rubocop"
34+
super
35+
end
36+
37+
describe "initialize" do
38+
it "gathered constants exclude irrelevant classes" do
39+
add_ruby_file("content.rb", <<~RUBY)
40+
class Unrelated
41+
end
42+
RUBY
43+
assert_empty(relevant_gathered_constants)
44+
end
45+
46+
it "gathers constants inheriting RuboCop::Cop::Base in gems" do
47+
# Sample of miscellaneous constants that should be found from Rubocop and plugins
48+
missing_constants = [
49+
"RuboCop::Cop::Bundler::GemVersion",
50+
"RuboCop::Cop::Cop",
51+
"RuboCop::Cop::Gemspec::DependencyVersion",
52+
"RuboCop::Cop::Lint::Void",
53+
"RuboCop::Cop::Metrics::ClassLength",
54+
"RuboCop::Cop::Migration::DepartmentName",
55+
"RuboCop::Cop::Naming::MethodName",
56+
"RuboCop::Cop::Security::CompoundHash",
57+
"RuboCop::Cop::Sorbet::ValidSigil",
58+
"RuboCop::Cop::Style::YodaCondition",
59+
] - gathered_constants
60+
61+
assert_empty(missing_constants, "expected constants to be gathered")
62+
end
63+
64+
it "gathers constants inheriting from RuboCop::Cop::Base in the host app" do
65+
add_ruby_file("content.rb", <<~RUBY)
66+
class MyCop < ::RuboCop::Cop::Base
67+
end
68+
69+
class MyLegacyCop < ::RuboCop::Cop::Cop
70+
end
71+
72+
module ::RuboCop
73+
module Cop
74+
module MyApp
75+
class MyNamespacedCop < Base
76+
end
77+
end
78+
end
79+
end
80+
RUBY
81+
82+
assert_equal(
83+
["MyCop", "MyLegacyCop", "RuboCop::Cop::MyApp::MyNamespacedCop"],
84+
relevant_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+
sig { returns(T::Array[String]) }
159+
def relevant_gathered_constants
160+
gathered_constants - EXISTING_CONSTANTS
161+
end
162+
end
163+
end
164+
end
165+
end
166+
end

0 commit comments

Comments
 (0)