rubocop-dynamic_methods 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b25a5992bc7edae3c7c0160c0f43449309ec46607b70c09651dc87ad4908cb3d
4
+ data.tar.gz: 0277bc047e63ef53c97e1eef882359c7d601b249540703d915b691990e8dd868
5
+ SHA512:
6
+ metadata.gz: 457b6361aaff81eeb9ebc37403d588b9a75612e1c8175f6ad93b4ad2eba11422983134b5c76867243933b0a22bb2a5e7852eee70fdb339363b320e2d54d5d011
7
+ data.tar.gz: 310ed2394f0beecbf6e241e1a16fa89b40d0cb178d80335fb9d2d41e6b5fc48e73716e605c6332f66bb93a5b466b8eb60fffa54dfc98895c037f907d3d38d4f4
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-11-13
4
+
5
+ First release. rubocop-dynamic_methods finds methods that Ruby metaprogramming defines and writes them as plain `def`s, so a search for `def name` finds them.
6
+
7
+ - `DynamicMethods/DefineMethod` reports `define_method` and `define_singleton_method`, and autocorrects them to `def name` and `def self.name`.
8
+ - `DynamicMethods/StringEval` reports `class_eval`, `module_eval`, `instance_eval`, and `eval` with a string, and autocorrects a `class_eval` or `module_eval` string that holds only defs to those defs.
9
+ - `DynamicMethods/MethodMissing` reports `def method_missing`.
10
+ - `DynamicMethods/Delegation`, disabled by default, reports `delegate`, `delegate_missing_to`, `def_delegator`, and `def_delegators` with the names they define. It does not autocorrect, because a def is not the same method.
11
+ - The autocorrection runs only when the defs are the same methods: a call directly in a class or module body, or in an `each` over a literal Array, a literal Hash, a constant set once to one of those before the loop, or the `keys` or `values` of such a Hash. An unfrozen constant counts only when no code between its assignment and the loop can change it. Otherwise the offense ends with `Not autocorrected:` and the reason.
12
+ - A call in a branch of an `if` or `unless` in a class body is rewritten in that branch. An `if` that calls a bare `private` is refused with that reason.
13
+ - The autocorrection is unsafe: it trusts that `define_method`, `class_eval`, and `Array#each` are Ruby's own.
14
+ - A block of `class_eval`, `module_eval`, `class_exec`, `module_exec`, `Class.new`, `Module.new`, `Struct.new`, or `Data.define` counts as a class body. A block of any other method, such as `included do`, is refused with that reason.
15
+ - A string eval whose code holds a string literal is not autocorrected when the file's `# frozen_string_literal:` setting differs from the code's own, because the literal would change between mutable and frozen.
16
+ - A loop that also calls `alias_method`, `attr_*`, `private name`, `ruby2_keywords`, or other methods with the loop variable is unrolled: each call is copied once per value, and a name built from the loop variable becomes a Symbol. A loop that assigns a local, calls a bare `private`, or uses `next`, `break`, or `return` is refused with that reason.
17
+ - A class method that defines methods (`def self.record(name)` with `define_method(name)` in it) is written out at each call in the same class body that gives it literal arguments, so `record :and_return` becomes `def and_return`. The class method stays. A define_method inside any other method says that only the method's callers know which names it defines.
18
+ - A string eval that is not rewritten says what it evaluates: the defs a literal string holds, the def names an interpolated string makes (such as `#{name}?`), or the code it comes from. `instance_eval(&block)` is not reported, since it runs a block.
19
+ - A `define_method` whose body is not a literal block says what the body is: a copy of a named method such as `Kernel#send`, a block passed in from the caller, `&:name`, or a lambda.
20
+ - `bin/check-corpus ROOT` checks the autocorrection on a folder of unpacked gems without loading them.
21
+ - A lint_roller plugin, for RuboCop 1.72 or later, on Ruby 3.2 or later.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 meganemura
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.ja.md ADDED
@@ -0,0 +1,147 @@
1
+ # rubocop-dynamic_methods
2
+
3
+ Ruby のメタプログラミングで定義されたメソッドを見つける RuboCop の cop です。
4
+ `paid?` が `define_method` や文字列 eval で作られていると、`def paid?` を検索しても何も見つかりません。
5
+ 読み手はまずコーディングエージェント、次に人です。
6
+ この cop はそうしたメソッドを見つけ、ふつうの `def` に書き直します。
7
+
8
+ ```ruby
9
+ # frozen_string_literal: true
10
+
11
+ class Order
12
+ STATES = %i[draft paid].freeze
13
+
14
+ STATES.each do |state|
15
+ define_method(:"#{state}?") { @state == state }
16
+ end
17
+ end
18
+ ```
19
+
20
+ ```sh
21
+ $ rubocop -A --only DynamicMethods order.rb
22
+ order.rb:7:5: C: [Corrected] DynamicMethods/DefineMethod: Define draft?, paid? with def, so a search for def name finds them.
23
+ ```
24
+
25
+ ```ruby
26
+ # frozen_string_literal: true
27
+
28
+ class Order
29
+ STATES = %i[draft paid].freeze
30
+
31
+ def draft?
32
+ @state == :draft
33
+ end
34
+
35
+ def paid?
36
+ @state == :paid
37
+ end
38
+ end
39
+ ```
40
+
41
+ ## はじめかた
42
+
43
+ ```ruby
44
+ # Gemfile
45
+ gem "rubocop-dynamic_methods", require: false
46
+ ```
47
+
48
+ ```yaml
49
+ # .rubocop.yml
50
+ plugins:
51
+ - rubocop-dynamic_methods
52
+ ```
53
+
54
+ RuboCop 1.72 以降と Ruby 3.2 以降で動きます。
55
+
56
+ ## cop の一覧
57
+
58
+ | cop | 見つけるもの | 自動修正 |
59
+ | --- | --- | --- |
60
+ | `DynamicMethods/DefineMethod` | `define_method` と `define_singleton_method` | `def name` と `def self.name` に書き直す |
61
+ | `DynamicMethods/StringEval` | 文字列を渡した `class_eval`、`module_eval`、`instance_eval`、`eval` | 文字列の中の def に書き直す |
62
+ | `DynamicMethods/MethodMissing` | `def method_missing` | しない |
63
+ | `DynamicMethods/Delegation`(初期値は無効) | `delegate`、`delegate_missing_to`、`def_delegator`、`def_delegators` | しない |
64
+
65
+ `DynamicMethods/Delegation` は警告を出すだけです。
66
+ 委譲はよく使われる書き方で、def にすると同じメソッドにならないからです。
67
+ Forwardable は private メソッドも呼べますし、どちらもどんな引数でも受け取ります。
68
+ 使うときは、`.rubocop.yml` の `DynamicMethods/Delegation` に `Enabled: true` を書きます。
69
+
70
+ ## 同じになるときだけ直す
71
+
72
+ 自動修正は、Ruby が同じメソッドを定義するときだけ def を書きます。
73
+ 同じとは、名前、引数、本体、公開範囲が同じことです。
74
+ 確かめられないときは、理由を書いた警告だけを出し、コードはそのままにします。
75
+
76
+ ```ruby
77
+ class Feed
78
+ ELEMENTS = %w[title link]
79
+ include Parsing
80
+
81
+ ELEMENTS.each do |name|
82
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
83
+ def #{name}; @values[:#{name}]; end
84
+ RUBY
85
+ end
86
+ end
87
+ ```
88
+
89
+ ```text
90
+ feed.rb:6:5: C: DynamicMethods/StringEval: `class_eval` evaluates a string that defines methods named `#{name}`; write them with `def`, so a search finds them. Not autocorrected: `ELEMENTS` is not frozen, and `include Parsing` runs between its assignment and the loop.
91
+ ```
92
+
93
+ 理由は、次に def を書く人やエージェントに向けて書いています。
94
+
95
+ RuboCop はソースを読むだけで実行しません。
96
+ そのため、ソースだけで名前がすべて決まるときにだけ書き直せます。
97
+
98
+ - 呼び出しがレシーバーなしか `self` で、クラスやモジュールの本体に直接あること。
99
+ `class_eval`、`module_eval`、`class_exec`、`module_exec`、`Class.new`、`Module.new`、`Struct.new`、`Data.define` のブロックも、クラスの本体として扱います。
100
+ そこでの def は、同じモジュールに、同じ公開範囲と定数の探し方で定義されるからです。
101
+ `included do` のような他のメソッドのブロックは扱いません。
102
+ どこに定義されるかは、そのブロックを実行するコードにしか分からないからです。
103
+ または、そこにあるリテラルの Array か Hash に対する `each` の中にあること。
104
+ そこにある `if` や `unless` の枝の中も同じに扱い、def は枝の中に書きます。
105
+ 定数は、同じ本体の中でループより前に一度だけ、リテラルの Array か Hash を入れていればリテラルとして扱います。
106
+ `LIST.keys` と `LIST.values` も同じです。
107
+ freeze していないリストでは、代入からループまでの間に、def、リテラルの定数、`attr_reader :a` のような呼び出ししか置けません。
108
+ そうすれば、間でリストが変わることはないからです。
109
+ `Base::NAMES` のような他のモジュールの定数は書き直しません。
110
+ 別のファイルで変えられるからです。
111
+ - `def self.delegate_to_metadata(key)` のようにメソッドを定義するクラスメソッドは、同じクラスの本体でリテラルを渡して呼んでいる場所に展開します。
112
+ `delegate_to_metadata :file_path` は `def file_path` になります。
113
+ クラスメソッドそのものは、他から呼ばれるかもしれないので残します。
114
+ 引数なしの `private` のあとの呼び出しは書き直しません。
115
+ 他のメソッドの中で定義したメソッドは public になるからです。
116
+ - そのループの中の他の呼び出し(`alias_method`、`attr_reader`、`private`、`ruby2_keywords` など)は、値ごとに、前の def のあとへ写します。
117
+ ループ変数から作った名前は、`alias_method :refute_text, :assert_text` のように Symbol にします。
118
+ ループがローカル変数に代入するとき、引数なしの `private` を呼ぶとき、`next`、`break`、`return` を使うときは書き直しません。
119
+ ブロックとクラスの本体では、これらの意味が違うからです。
120
+ - ループの値をリテラルに置き換えるのは、読むたびに同じオブジェクトになるときだけです。
121
+ Symbol、小さい Integer、nil、true、false、そして `# frozen_string_literal: true` のもとでの String です。
122
+ - ブロックが外側のローカル変数を共有しているときは書き直しません。
123
+ def では意味が変わるものを使っているときも書き直しません。
124
+ 番号付きパラメーター、`it`、`yield`、`binding`、`local_variables`、`block_given?`、`eval`、引数なしの `super`、ループの外の `break` と `redo` です。
125
+ メソッドを抜ける `next` は `return` に書き直します。
126
+ - 引数なしの `private`、`protected`、`module_function` のあとにある文字列 eval は書き直しません。
127
+ 文字列 eval で作った def は、そこでも public になるからです。
128
+ 文字列に def 以外のものがあるときも書き直しません。
129
+ 文字列の中のコードに文字列リテラルがあり、ファイルの `# frozen_string_literal:` で freeze するかどうかが変わるときも書き直しません。
130
+ このマジックコメントは、文字列 eval でコンパイルするコードには効かないからです。
131
+
132
+ 自動修正は RuboCop でいう unsafe です。
133
+ `define_method`、`class_eval`、`Array#each` が Ruby 本来のものだと仮定しているからです。
134
+ `rubocop -A` で実行してください。
135
+
136
+ 決まりの全体は [docs/decisions/0001-exact-or-refused.md](docs/decisions/0001-exact-or-refused.md) にあります。
137
+
138
+ ## 書き直しの確かめ方
139
+
140
+ `test/hegel/rewrite_test.rb` は hegeltest でクラスを作り、cop で直し、元のクラスと直したクラスを両方読み込みます。
141
+ そして Ruby が答える内容を比べます。
142
+ 公開範囲ごとのメソッド名、引数、戻り値、呼び出しで設定されるインスタンス変数です。
143
+
144
+ ## ライセンス
145
+
146
+ MIT です。
147
+ [LICENSE](LICENSE) を見てください。
data/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # rubocop-dynamic_methods
2
+
3
+ RuboCop cops for methods that Ruby metaprogramming defines.
4
+ A reader, a coding agent first and a person second, searches for `def paid?` and finds nothing when `paid?` comes from `define_method` or a string eval.
5
+ These cops find those methods and write them as plain `def`s.
6
+
7
+ ```ruby
8
+ # frozen_string_literal: true
9
+
10
+ class Order
11
+ STATES = %i[draft paid].freeze
12
+
13
+ STATES.each do |state|
14
+ define_method(:"#{state}?") { @state == state }
15
+ end
16
+ end
17
+ ```
18
+
19
+ ```sh
20
+ $ rubocop -A --only DynamicMethods order.rb
21
+ order.rb:7:5: C: [Corrected] DynamicMethods/DefineMethod: Define draft?, paid? with def, so a search for def name finds them.
22
+ ```
23
+
24
+ ```ruby
25
+ # frozen_string_literal: true
26
+
27
+ class Order
28
+ STATES = %i[draft paid].freeze
29
+
30
+ def draft?
31
+ @state == :draft
32
+ end
33
+
34
+ def paid?
35
+ @state == :paid
36
+ end
37
+ end
38
+ ```
39
+
40
+ ## Quick start
41
+
42
+ ```ruby
43
+ # Gemfile
44
+ gem "rubocop-dynamic_methods", require: false
45
+ ```
46
+
47
+ ```yaml
48
+ # .rubocop.yml
49
+ plugins:
50
+ - rubocop-dynamic_methods
51
+ ```
52
+
53
+ The plugin needs RuboCop 1.72 or later and Ruby 3.2 or later.
54
+
55
+ ## The cops
56
+
57
+ | Cop | Finds | Autocorrects |
58
+ | --- | --- | --- |
59
+ | `DynamicMethods/DefineMethod` | `define_method` and `define_singleton_method` | to `def name` and `def self.name` |
60
+ | `DynamicMethods/StringEval` | `class_eval`, `module_eval`, `instance_eval`, and `eval` with a string | to the defs the string holds |
61
+ | `DynamicMethods/MethodMissing` | `def method_missing` | no |
62
+ | `DynamicMethods/Delegation` (disabled by default) | `delegate`, `delegate_missing_to`, `def_delegator`, `def_delegators` | no |
63
+
64
+ `DynamicMethods/Delegation` only reports: delegation is common style, and a def is not the same method, because Forwardable reaches private methods and both take any arguments.
65
+ Turn it on with `Enabled: true` under `DynamicMethods/Delegation` in `.rubocop.yml`.
66
+
67
+ ## Exact or not at all
68
+
69
+ The autocorrection writes defs only when Ruby would define the same methods: the same names, parameters, bodies, and visibility.
70
+ When it cannot be sure, the offense says why and the code stays as it is:
71
+
72
+ ```ruby
73
+ class Feed
74
+ ELEMENTS = %w[title link]
75
+ include Parsing
76
+
77
+ ELEMENTS.each do |name|
78
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
79
+ def #{name}; @values[:#{name}]; end
80
+ RUBY
81
+ end
82
+ end
83
+ ```
84
+
85
+ ```text
86
+ feed.rb:6:5: C: DynamicMethods/StringEval: `class_eval` evaluates a string that defines methods named `#{name}`; write them with `def`, so a search finds them. Not autocorrected: `ELEMENTS` is not frozen, and `include Parsing` runs between its assignment and the loop.
87
+ ```
88
+
89
+ The reason is written for whoever writes the defs next, an agent or a person.
90
+
91
+ RuboCop reads the source and never runs it, so a rewrite is possible only when the source fixes every name:
92
+
93
+ - The call has no receiver, or `self`, and sits directly in a class or module body, or in an `each` over a literal Array or Hash there.
94
+ A branch of an `if` or `unless` there counts too, and the def stays in the branch.
95
+ A block of `class_eval`, `module_eval`, `class_exec`, `module_exec`, `Class.new`, `Module.new`, `Struct.new`, or `Data.define` counts as a class body: a def there defines on the same module, with the same visibility and constants.
96
+ A block of any other method, such as `included do`, does not, because only the code that runs it knows where it defines.
97
+ A constant counts as a literal list when the same body sets it once, before the loop, to a literal Array or Hash, and `LIST.keys` and `LIST.values` count too.
98
+ If the list is not frozen, only defs, literal constants, and calls such as `attr_reader :a` may run between the assignment and the loop, so nothing can change the list.
99
+ A constant of another module, such as `Base::NAMES`, is refused, because another file can change it.
100
+ - A class method that defines methods, such as `def self.delegate_to_metadata(key)`, is written out at each call in the same class body that gives it literal arguments: `delegate_to_metadata :file_path` becomes `def file_path`.
101
+ The class method stays for other callers.
102
+ A call after a bare `private` is refused, because a method that another method defines is public.
103
+ - The other calls in such a loop, such as `alias_method`, `attr_reader`, `private`, or `ruby2_keywords`, are copied once per value, after the def they follow.
104
+ A name built from the loop variable becomes a Symbol, as in `alias_method :refute_text, :assert_text`.
105
+ The loop is refused when it assigns a local, calls a bare `private`, or uses `next`, `break`, or `return`, because a block and a class body treat those differently.
106
+ - A loop value becomes a literal only when the literal is the same object on every read: a Symbol, a small Integer, nil, true, false, or a String under `# frozen_string_literal: true`.
107
+ - A block is refused when it shares a local with the code around it, or uses something a def treats differently: numbered parameters, `it`, `yield`, `binding`, `local_variables`, `block_given?`, `eval`, `super` with no arguments, `break` or `redo` outside a loop.
108
+ A `next` that leaves the method becomes `return`.
109
+ - A string eval is refused after a bare `private`, `protected`, or `module_function`, because the defs it makes are public even there.
110
+ It is also refused when the string holds more than defs, or holds a string literal that the file's `# frozen_string_literal:` comment would freeze or unfreeze: that comment does not reach code a string eval compiles.
111
+
112
+ The autocorrection is unsafe in RuboCop's sense: it trusts that `define_method`, `class_eval`, and `Array#each` are Ruby's own. Run it with `rubocop -A`.
113
+
114
+ [docs/decisions/0001-exact-or-refused.md](docs/decisions/0001-exact-or-refused.md) has the full rule.
115
+
116
+ ## How the rewrite is checked
117
+
118
+ `test/hegel/rewrite_test.rb` generates classes with hegeltest, corrects them with the cops, loads the original and the corrected class, and compares what Ruby reports: the method names by visibility, the parameters, the return values, and the instance variables a call sets.
119
+
120
+ ## License
121
+
122
+ MIT. See [LICENSE](LICENSE).
123
+
124
+ [Japanese](README.ja.md)
@@ -0,0 +1,24 @@
1
+ DynamicMethods:
2
+ DocumentationBaseURL: https://github.com/meganemura/rubocop-dynamic_methods/blob/main/README.md
3
+
4
+ DynamicMethods/DefineMethod:
5
+ Description: Define methods with `def` instead of `define_method`, so a search for `def name` finds them.
6
+ Enabled: true
7
+ SafeAutoCorrect: false
8
+ VersionAdded: '0.1'
9
+
10
+ DynamicMethods/StringEval:
11
+ Description: Write the defs a string eval holds as code, so a search for `def name` finds them.
12
+ Enabled: true
13
+ SafeAutoCorrect: false
14
+ VersionAdded: '0.1'
15
+
16
+ DynamicMethods/MethodMissing:
17
+ Description: Define the methods `method_missing` answers with `def`.
18
+ Enabled: true
19
+ VersionAdded: '0.1'
20
+
21
+ DynamicMethods/Delegation:
22
+ Description: Report the methods `delegate` and Forwardable define, which a search for `def name` does not find.
23
+ Enabled: false
24
+ VersionAdded: '0.1'
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DynamicMethods
6
+ # Finds methods defined with `define_method` or `define_singleton_method`.
7
+ # A search for `def name` does not find them.
8
+ #
9
+ # The autocorrection writes a plain `def` when the result is the same
10
+ # method: the same name, parameters, body, and visibility. That holds
11
+ # for a call directly in a class or module body (or a block Ruby runs as
12
+ # one, such as `Class.new do`), or in an `each` over a
13
+ # literal list there. Otherwise the offense names the reason, and the
14
+ # method is left for a person or an agent to write.
15
+ #
16
+ # The autocorrection is unsafe: it trusts that `define_method` and
17
+ # `Array#each` are Ruby's own.
18
+ #
19
+ # @example
20
+ # # bad
21
+ # %i[draft paid].each do |state|
22
+ # define_method(:"#{state}?") { @state == state }
23
+ # end
24
+ #
25
+ # # good
26
+ # def draft?
27
+ # @state == :draft
28
+ # end
29
+ #
30
+ # def paid?
31
+ # @state == :paid
32
+ # end
33
+ class DefineMethod < Base
34
+ extend AutoCorrector
35
+ include FrozenStringLiteral
36
+ include DynamicMethods::Reporting
37
+
38
+ RESTRICT_ON_SEND = %i[define_method define_singleton_method].freeze
39
+
40
+ def on_send(node)
41
+ return unless node.block_node || node.arguments?
42
+
43
+ statement = node.block_node || node
44
+ report(node, statement) do
45
+ raise RuboCop::DynamicMethods::Rewrite::Refusal, refusal(node) unless node.block_node && RuboCop::DynamicMethods::Rewrite.statement?(statement)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def refusal(node)
52
+ return "it defines the method on `#{node.receiver.source}`" unless RuboCop::DynamicMethods::Rewrite.implicit?(node)
53
+ return body_refusal(node.arguments[1]) if node.arguments.size > 1
54
+
55
+ "the body is not a block"
56
+ end
57
+
58
+ # What a body that is not a literal block is, so the reader knows
59
+ # what a def would have to do instead.
60
+ def body_refusal(body)
61
+ if body.block_pass_type?
62
+ inner = body.children.first
63
+ return "the body is `&#{inner.value.inspect}`, which calls `#{inner.value}` on the method's first argument" if inner&.sym_type?
64
+ return copied(inner) || "the body is the block in `#{inner&.source}`, which the code that runs this passes in"
65
+ end
66
+ return "the body is a lambda, not a block" if body.block_type? && body.send_node.lambda_or_proc?
67
+ copied(body) || "the body is `#{body.source}`, not a block"
68
+ end
69
+
70
+ # `Kernel.instance_method(:send)` or `Time.method(:now)`: the method is
71
+ # copied as it is, and a def would be a new method that calls it.
72
+ def copied(node)
73
+ return unless node&.send_type? && %i[instance_method method].include?(node.method_name) && node.first_argument
74
+ unless %i[sym str].include?(node.first_argument.type)
75
+ owner = node.receiver ? " of `#{node.receiver.source.delete_prefix("::")}`" : ""
76
+ return "it copies a method#{owner} chosen by `#{node.first_argument.source}`, as it is"
77
+ end
78
+
79
+ name = node.first_argument.value.to_s
80
+ name = "#{node.receiver.source.delete_prefix("::")}#{node.method?(:instance_method) ? "#" : "."}#{name}" if node.receiver
81
+ "it copies the method `#{name}` as it is, and a def would be a new method that calls it"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DynamicMethods
6
+ # Finds methods that delegation defines: ActiveSupport's `delegate` and
7
+ # `delegate_missing_to`, and Forwardable's `def_delegator` and
8
+ # `def_delegators`. A search for `def name` does not find them.
9
+ #
10
+ # It only reports, and it is disabled by default: delegation is common,
11
+ # accepted style, and a def is not the same method. Forwardable calls
12
+ # through `__send__`, so it reaches private methods, and both take any
13
+ # arguments where a def would name them.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # delegate :name, :email, to: :user
18
+ #
19
+ # # good
20
+ # def name = user.name
21
+ #
22
+ # def email = user.email
23
+ class Delegation < Base
24
+ RESTRICT_ON_SEND = %i[delegate delegate_missing_to def_delegator def_delegators
25
+ def_instance_delegator def_instance_delegators].freeze
26
+
27
+ def on_send(node)
28
+ return unless node.receiver.nil? || node.receiver.self_type?
29
+ return unless node.arguments?
30
+
31
+ message = case node.method_name
32
+ when :delegate then delegate(node)
33
+ when :delegate_missing_to then missing(node)
34
+ when :def_delegator, :def_instance_delegator then delegator(node)
35
+ else delegators(node)
36
+ end
37
+ add_offense(node.loc.selector, message:) if message
38
+ end
39
+
40
+ private
41
+
42
+ # `delegate :a, :b, to: :x, prefix: true` defines `x_a` and `x_b`.
43
+ def delegate(node)
44
+ options = node.last_argument if node.last_argument.hash_type?
45
+ target = option(options, :to)
46
+ return unless target
47
+
48
+ prefix = option(options, :prefix)
49
+ prefix = if prefix&.true_type? then literal(target)
50
+ elsif prefix && %i[sym str].include?(prefix.type) then prefix.value.to_s
51
+ elsif prefix && !prefix.false_type? then prefix.source
52
+ end
53
+ methods = node.arguments.reject(&:hash_type?)
54
+ names = methods.map { |method| [prefix, literal(method)].compact.join("_") }
55
+ report(node.method_name, names, literal(target))
56
+ end
57
+
58
+ def missing(node)
59
+ "`delegate_missing_to` answers every method that `#{node.first_argument.source.delete_prefix(":")}` answers, " \
60
+ "through `method_missing`, so no search finds them. Define the ones callers use with `def`."
61
+ end
62
+
63
+ # `def_delegator :@x, :a, :b` defines `b`, or `a` when no alias is given.
64
+ def delegator(node)
65
+ accessor, method, name = node.arguments
66
+ return unless method
67
+
68
+ report(node.method_name, [literal(name || method)], literal(accessor))
69
+ end
70
+
71
+ def delegators(node)
72
+ accessor, *methods = node.arguments
73
+ return if methods.empty?
74
+
75
+ report(node.method_name, methods.map { literal(_1) }, literal(accessor))
76
+ end
77
+
78
+ def report(spell, names, target)
79
+ listed = names.map { _1.start_with?("the names in ") ? _1 : "`#{_1}`" }
80
+ listed = names.size > 3 ? "#{listed.first(3).join(", ")} and #{names.size - 3} more" : listed.join(", ")
81
+ one = names.size == 1 && !names.first.start_with?("the names in ")
82
+ "`#{spell}` defines #{listed}, which #{one ? "forwards" : "forward"} to `#{target}`; " \
83
+ "a search for `def name` does not find #{one ? "it" : "them"}."
84
+ end
85
+
86
+ def option(hash, key)
87
+ hash&.pairs&.find { _1.key.sym_type? && _1.key.value == key }&.value
88
+ end
89
+
90
+ # A name as written, or the code that makes it.
91
+ def literal(node)
92
+ return "the names in `#{node.children.first.source}`" if node.splat_type?
93
+
94
+ %i[sym str].include?(node.type) ? node.value.to_s : node.source
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DynamicMethods
6
+ # Finds `method_missing`. The methods it answers have no `def` at all,
7
+ # so no search finds them, and no rewrite can list them. Define each
8
+ # method it answers with `def`, or with a call this department can
9
+ # rewrite.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # def method_missing(name, *args)
14
+ # name.end_with?("?") ? @state == name.to_s.chomp("?").to_sym : super
15
+ # end
16
+ #
17
+ # # good
18
+ # def draft? = @state == :draft
19
+ class MethodMissing < Base
20
+ MSG = "Define the methods `method_missing` answers with `def`, so a search for their names finds them."
21
+
22
+ def on_def(node)
23
+ add_offense(node.loc.name) if node.method?(:method_missing)
24
+ end
25
+ alias on_defs on_def
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DynamicMethods
6
+ # The offense both rewriting cops add: the names a rewrite defines, or
7
+ # the reason there is none, which tells an agent what to write by hand.
8
+ module Reporting
9
+ def on_new_investigation
10
+ @rewrite = nil
11
+ @corrected_ranges = nil
12
+ super
13
+ end
14
+
15
+ private
16
+
17
+ def report(node, statement)
18
+ plan = begin
19
+ yield
20
+ rewrite.plan(statement)
21
+ rescue RuboCop::DynamicMethods::Rewrite::Refusal => e
22
+ e
23
+ end
24
+ if plan.is_a?(Exception)
25
+ add_offense(node.loc.selector, message: "#{message_for(node, nil)} Not autocorrected: #{plan.message}.")
26
+ elsif corrected_ranges.include?(plan.range)
27
+ add_offense(node.loc.selector, message: message_for(node, plan))
28
+ else
29
+ corrected_ranges << plan.range
30
+ add_offense(node.loc.selector, message: message_for(node, plan)) do |corrector|
31
+ plan.edits.each { |range, text| corrector.replace(range, text) }
32
+ end
33
+ end
34
+ end
35
+
36
+ def message_for(_node, plan)
37
+ return "Define this method with `def`, so a search for its name finds it." if plan.nil?
38
+
39
+ names = plan.names.map { "`#{_1}`" }
40
+ "Define #{names.size > 3 ? "#{names.first(3).join(", ")} and #{names.size - 3} more" : names.join(", ")} with `def`, so a search for `def name` finds #{names.size == 1 ? "it" : "them"}."
41
+ end
42
+
43
+ def rewrite
44
+ @rewrite ||= RuboCop::DynamicMethods::Rewrite.new(
45
+ processed_source,
46
+ ruby_version: target_ruby_version,
47
+ frozen_strings: frozen_string_literals_enabled?
48
+ )
49
+ end
50
+
51
+ def corrected_ranges
52
+ @corrected_ranges ||= []
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end