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.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DynamicMethods
6
+ # Finds code evaluated from a string with `class_eval`, `module_eval`,
7
+ # `instance_eval`, or `eval`. A search for `def name` does not find a
8
+ # method a string defines.
9
+ #
10
+ # The autocorrection writes the defs the string holds in place of the
11
+ # call, when the string is a literal or interpolates only the variable
12
+ # of an `each` over a literal list, holds nothing but defs, and is
13
+ # evaluated on the class whose body calls it. A def from a string eval is
14
+ # public even after a bare `private`, so a call after one is refused.
15
+ # Otherwise the offense says what the string defines, or where the code
16
+ # comes from, and why it was not rewritten.
17
+ #
18
+ # The autocorrection is unsafe: it trusts that `class_eval` and
19
+ # `Array#each` are Ruby's own.
20
+ #
21
+ # @example
22
+ # # bad
23
+ # %w[width height].each do |name|
24
+ # class_eval <<~RUBY, __FILE__, __LINE__ + 1
25
+ # def #{name} = @size.fetch(:#{name})
26
+ # RUBY
27
+ # end
28
+ #
29
+ # # good
30
+ # def width = @size.fetch(:width)
31
+ #
32
+ # def height = @size.fetch(:height)
33
+ class StringEval < Base
34
+ extend AutoCorrector
35
+ include FrozenStringLiteral
36
+ include DynamicMethods::Reporting
37
+
38
+ RESTRICT_ON_SEND = %i[class_eval module_eval instance_eval eval].freeze
39
+
40
+ def on_send(node)
41
+ # `instance_eval(&block)` runs a block, whose defs a search finds.
42
+ return if node.block_node || !node.arguments? || node.first_argument.block_pass_type?
43
+ return if node.method?(:eval) && !node.receiver.nil?
44
+
45
+ report(node, node) do
46
+ raise RuboCop::DynamicMethods::Rewrite::Refusal, refusal(node) unless RuboCop::DynamicMethods::Rewrite.statement?(node)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # The lead of a refused offense says what is known about the code:
53
+ # what it evaluates, and what it defines when the string says.
54
+ def message_for(node, plan)
55
+ return super if plan
56
+
57
+ callee = "`#{node.receiver ? "#{node.receiver.source}." : ""}#{node.method_name}`"
58
+ code = node.first_argument
59
+ case code.type
60
+ when :str
61
+ names = defined_names(code.value)
62
+ return "#{callee} evaluates a string that holds no def; run the code without a string, so a reader can search it." if names&.empty?
63
+ return "#{callee} evaluates a string that defines #{names.map { "`#{_1}`" }.join(", ")}; define #{names.size == 1 ? "it" : "them"} with `def`, so a search finds #{names.size == 1 ? "it" : "them"}." if names
64
+ when :dstr
65
+ names = pattern_names(code)
66
+ if names&.any?
67
+ return "#{callee} evaluates a string that defines methods named #{names.map { "`#{_1}`" }.join(", ")}; " \
68
+ "write them with `def`, so a search finds them."
69
+ end
70
+ part = code.children.find { !_1.str_type? }
71
+ return "#{callee} evaluates a string that interpolates `#{part.source}`, so only running it shows which methods it defines." if part
72
+ end
73
+ "#{callee} evaluates code from `#{code.source.lines.first.strip}`, so only running it shows which methods it defines."
74
+ end
75
+
76
+ # The def names of an interpolated string, with each interpolation
77
+ # kept as written, such as `#{name}?`; nil when they cannot be read.
78
+ def pattern_names(dstr)
79
+ holes = {}
80
+ flat = lambda do |node|
81
+ node.children.map do |part|
82
+ next part.value if part.str_type?
83
+ next flat.call(part) if part.dstr_type?
84
+ return unless part.begin_type?
85
+
86
+ holes["zq#{holes.size}zq"] = part.source
87
+ "zq#{holes.size - 1}zq"
88
+ end.join
89
+ end
90
+ names = defined_names(flat.call(dstr))
91
+ names&.map { |name| name.gsub(/zq\d+zq/) { holes.fetch(_1) } }
92
+ end
93
+
94
+ # The names of the defs in the code, or nil when it is not Ruby.
95
+ def defined_names(code)
96
+ parsed = RuboCop::ProcessedSource.new(code, target_ruby_version, "(eval)")
97
+ return unless parsed.valid_syntax?
98
+
99
+ parsed.ast&.each_node(:def, :defs)&.map { _1.defs_type? ? "self.#{_1.method_name}" : _1.method_name.to_s } || []
100
+ end
101
+
102
+ def refusal(node)
103
+ return "`#{node.method_name}` runs the string on another receiver" if %i[eval instance_eval].include?(node.method_name)
104
+
105
+ "it evaluates the string on `#{node.receiver.source}`"
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lint_roller"
4
+ require "pathname"
5
+
6
+ module RuboCop
7
+ module DynamicMethods
8
+ # Tells RuboCop where the cops' default configuration is.
9
+ class Plugin < LintRoller::Plugin
10
+ def about
11
+ LintRoller::About.new(
12
+ name: "rubocop-dynamic_methods",
13
+ version: VERSION,
14
+ homepage: "https://github.com/meganemura/rubocop-dynamic_methods",
15
+ description: "Rewrites dynamically defined methods as plain defs you can search for."
16
+ )
17
+ end
18
+
19
+ def supported?(context)
20
+ context.engine == :rubocop
21
+ end
22
+
23
+ def rules(_context)
24
+ LintRoller::Rules.new(
25
+ type: :path,
26
+ config_format: :rubocop,
27
+ value: Pathname.new(__dir__).join("../../../config/default.yml").expand_path
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end