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 +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.ja.md +147 -0
- data/README.md +124 -0
- data/config/default.yml +24 -0
- data/lib/rubocop/cop/dynamic_methods/define_method.rb +86 -0
- data/lib/rubocop/cop/dynamic_methods/delegation.rb +99 -0
- data/lib/rubocop/cop/dynamic_methods/method_missing.rb +29 -0
- data/lib/rubocop/cop/dynamic_methods/reporting.rb +57 -0
- data/lib/rubocop/cop/dynamic_methods/string_eval.rb +110 -0
- data/lib/rubocop/dynamic_methods/plugin.rb +32 -0
- data/lib/rubocop/dynamic_methods/rewrite.rb +822 -0
- data/lib/rubocop/dynamic_methods/version.rb +7 -0
- data/lib/rubocop/dynamic_methods.rb +13 -0
- data/lib/rubocop-dynamic_methods.rb +4 -0
- metadata +92 -0
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)
|
data/config/default.yml
ADDED
|
@@ -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
|