Skip to content

Commit dbc15ce

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 dbc15ce

File tree

5 files changed

+317
-0
lines changed

5 files changed

+317
-0
lines changed

lib/tapioca/dsl/compilers/rubocop.rb

+67
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

lib/tapioca/dsl/extensions/rubocop.rb

+41
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

+24
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

+1
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)
+184
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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+
# .extenders_of(::RuboCop::AST::NodePattern::Macros)
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 "rubocop"
34+
require "rubocop-sorbet"
35+
require "tapioca/dsl/extensions/rubocop"
36+
super
37+
end
38+
39+
describe "initialize" do
40+
it "gathered constants exclude irrelevant classes" do
41+
gathered_constants = gather_constants do
42+
add_ruby_file("content.rb", <<~RUBY)
43+
class Unrelated
44+
end
45+
RUBY
46+
end
47+
assert_empty(gathered_constants)
48+
end
49+
50+
it "gathers constants extending RuboCop::AST::NodePattern::Macros in gems" do
51+
# Sample of miscellaneous constants that should be found from Rubocop and plugins
52+
missing_constants = [
53+
"RuboCop::Cop::Bundler::GemVersion",
54+
"RuboCop::Cop::Cop",
55+
"RuboCop::Cop::Gemspec::DependencyVersion",
56+
"RuboCop::Cop::Lint::Void",
57+
"RuboCop::Cop::Metrics::ClassLength",
58+
"RuboCop::Cop::Migration::DepartmentName",
59+
"RuboCop::Cop::Naming::MethodName",
60+
"RuboCop::Cop::Security::CompoundHash",
61+
"RuboCop::Cop::Sorbet::ValidSigil",
62+
"RuboCop::Cop::Style::YodaCondition",
63+
] - gathered_constants
64+
65+
assert_empty(missing_constants, "expected constants to be gathered")
66+
end
67+
68+
it "gathers constants extending RuboCop::AST::NodePattern::Macros in the host app" do
69+
gathered_constants = gather_constants do
70+
add_ruby_file("content.rb", <<~RUBY)
71+
class MyCop < ::RuboCop::Cop::Base
72+
end
73+
74+
class MyLegacyCop < ::RuboCop::Cop::Cop
75+
end
76+
77+
module MyMacroModule
78+
extend ::RuboCop::AST::NodePattern::Macros
79+
end
80+
81+
module ::RuboCop
82+
module Cop
83+
module MyApp
84+
class MyNamespacedCop < Base
85+
end
86+
end
87+
end
88+
end
89+
RUBY
90+
end
91+
92+
assert_equal(
93+
["MyCop", "MyLegacyCop", "MyMacroModule", "RuboCop::Cop::MyApp::MyNamespacedCop"],
94+
gathered_constants,
95+
)
96+
end
97+
end
98+
99+
describe "decorate" do
100+
it "generates empty RBI when no DSL used" do
101+
add_ruby_file("content.rb", <<~RUBY)
102+
class MyCop < ::RuboCop::Cop::Base
103+
def on_send(node);end
104+
end
105+
RUBY
106+
107+
expected = <<~RBI
108+
# typed: strong
109+
RBI
110+
111+
assert_equal(expected, rbi_for(:MyCop))
112+
end
113+
114+
it "generates correct RBI file" do
115+
add_ruby_file("content.rb", <<~RUBY)
116+
class MyCop < ::RuboCop::Cop::Base
117+
def_node_matcher :some_matcher, "(...)"
118+
def_node_matcher :some_matcher_with_params, "(%1 %two ...)"
119+
def_node_matcher :some_matcher_with_params_and_defaults, "(%1 %two ...)", two: :default
120+
def_node_matcher :some_predicate_matcher?, "(...)"
121+
def_node_search :some_search, "(...)"
122+
def_node_search :some_search_with_params, "(%1 %two ...)"
123+
def_node_search :some_search_with_params_and_defaults, "(%1 %two ...)", two: :default
124+
125+
def on_send(node);end
126+
end
127+
RUBY
128+
129+
expected = <<~RBI
130+
# typed: strong
131+
132+
class MyCop
133+
sig { params(param0: T.untyped).returns(T.untyped) }
134+
def some_matcher(param0 = T.unsafe(nil)); end
135+
136+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
137+
def some_matcher_with_params(param0 = T.unsafe(nil), param1, two:); end
138+
139+
sig { params(args: T.untyped, values: T.untyped).returns(T.untyped) }
140+
def some_matcher_with_params_and_defaults(*args, **values); end
141+
142+
sig { params(param0: T.untyped).returns(T.untyped) }
143+
def some_predicate_matcher?(param0 = T.unsafe(nil)); end
144+
145+
sig { params(param0: T.untyped).returns(T.untyped) }
146+
def some_search(param0); end
147+
148+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
149+
def some_search_with_params(param0, param1, two:); end
150+
151+
sig { params(args: T.untyped, values: T.untyped).returns(T.untyped) }
152+
def some_search_with_params_and_defaults(*args, **values); end
153+
154+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
155+
def without_defaults_some_matcher_with_params_and_defaults(param0 = T.unsafe(nil), param1, two:); end
156+
157+
sig { params(param0: T.untyped, param1: T.untyped, two: T.untyped).returns(T.untyped) }
158+
def without_defaults_some_search_with_params_and_defaults(param0, param1, two:); end
159+
end
160+
RBI
161+
162+
assert_equal(expected, rbi_for(:MyCop))
163+
end
164+
end
165+
166+
private
167+
168+
# Gathers constants introduced in the given block excluding constants that already existed prior to the block.
169+
sig { params(block: T.proc.void).returns(T::Array[String]) }
170+
def gather_constants(&block)
171+
existing_constants = T.let(
172+
Runtime::Reflection
173+
.extenders_of(::RuboCop::AST::NodePattern::Macros)
174+
.filter_map { |constant| Runtime::Reflection.name_of(constant) },
175+
T::Array[String],
176+
)
177+
yield
178+
gathered_constants - existing_constants
179+
end
180+
end
181+
end
182+
end
183+
end
184+
end

0 commit comments

Comments
 (0)