rubocop-nathank 1.0.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 +30 -0
- data/lib/rubocop/nathank/begin_block_assignment.rb +40 -0
- data/lib/rubocop/nathank/comment_structure.rb +305 -0
- data/lib/rubocop/nathank/config.yaml +52 -0
- data/lib/rubocop/nathank/no_conditional_assignment.rb +256 -0
- data/lib/rubocop/nathank/no_modifier_if.rb +100 -0
- data/lib/rubocop/nathank/no_return.rb +208 -0
- data/lib/rubocop/nathank/plugin.rb +43 -0
- data/lib/rubocop/nathank/single_line_methods_for_events.rb +168 -0
- data/lib/rubocop/nathank/support/prism_source.rb +234 -0
- data/lib/rubocop/nathank/use_tap.rb +100 -0
- data/lib/rubocop/nathank/version.rb +10 -0
- data/lib/rubocop/nathank.rb +23 -0
- data/lib/rubocop-nathank.rb +6 -0
- data/license.md +21 -0
- data/readme.md +54 -0
- data/releases.md +6 -0
- metadata +114 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d1cbe27ef830beab287c9fe5f094cb0779bd11624b305c70c5dc9cf213c06693
|
|
4
|
+
data.tar.gz: d2056f2799a73989349c29d89cde6437d79d4084043325176bb6162ddc55c20f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f56ce9201f12e91a550d6024fdeffb1d7bef352e1424b1577ddc6b6cfa17cbfc6566d9ec2536cc12cea23c94df7e4f68b2cf20216fe1726e35dd3e7e0053fd62
|
|
7
|
+
data.tar.gz: b5c2e4a4460a4c06713cd78f98bd04688cac75acdce2faf4d08c13d4f598021720b69b4557ca9acae7aa022b193d133c92f6fdc61626cac251d2961dd98df6a9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-10-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `lib/rubocop/nathank.rb` now `require "rubocop"` itself, so loading the plugin
|
|
15
|
+
no longer assumes RuboCop has already been required.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Gem renamed from `rubocop-nathan-k` to `rubocop-nathank`. Update `bundle add`
|
|
20
|
+
and the `plugins:` entry in `.rubocop.yml` to the new name.
|
|
21
|
+
- Department renamed from `NathanK` to `Nathank`. Rename any `.rubocop.yml`
|
|
22
|
+
entries accordingly: `Nathank/BeginBlockAssignment`, `Nathank/CommentStructure`,
|
|
23
|
+
`Nathank/NoConditionalAssignment`, `Nathank/NoModifierIf`, `Nathank/NoReturn`,
|
|
24
|
+
`Nathank/SingleLineMethodsForEvents`, `Nathank/UseTap`.
|
|
25
|
+
- Namespace renamed from `RuboCop::NathanK` to `RuboCop::Nathank`; the
|
|
26
|
+
lint_roller plugin class is now `RuboCop::Nathank::Plugin`, referenced by the
|
|
27
|
+
gemspec's `default_lint_roller_plugin` metadata.
|
|
28
|
+
|
|
29
|
+
[Unreleased]: https://github.com/n-at-han-k/rubocop-nathank/compare/v1.0.0...HEAD
|
|
30
|
+
[1.0.0]: https://github.com/n-at-han-k/rubocop-nathank/releases/tag/v1.0.0
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
# Flags a `begin ... end` assigned to a variable.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# begin
|
|
10
|
+
# lvar = ...
|
|
11
|
+
# end
|
|
12
|
+
|
|
13
|
+
require_relative "support/prism_source"
|
|
14
|
+
|
|
15
|
+
module RuboCop
|
|
16
|
+
module Nathank
|
|
17
|
+
class BeginBlockAssignment < ::RuboCop::Cop::Base
|
|
18
|
+
include PrismSource
|
|
19
|
+
|
|
20
|
+
MSG = "Do not assign a `begin...end` expression to a variable; " \
|
|
21
|
+
"move the assignment inside the begin block."
|
|
22
|
+
|
|
23
|
+
# Every way a variable is written to with a single value on the right.
|
|
24
|
+
WRITES = [
|
|
25
|
+
::Prism::LocalVariableWriteNode,
|
|
26
|
+
::Prism::InstanceVariableWriteNode,
|
|
27
|
+
::Prism::ClassVariableWriteNode,
|
|
28
|
+
::Prism::GlobalVariableWriteNode,
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
def on_new_investigation
|
|
32
|
+
self.prism_each(*WRITES).each do |node|
|
|
33
|
+
if node.value.is_a?(::Prism::BeginNode)
|
|
34
|
+
add_offense(self.prism_node_range(node))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
# Checks the structure of a comment, which decode parses into three parts: the summary, the annotations, and the summary belonging to each annotation.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# # Connect to the given endpoint, returning the HTTP client and reference. <- summary
|
|
10
|
+
# # @parameter endpoint [Endpoint] used to connect to the remote system. <- annotation
|
|
11
|
+
# # @returns [Tuple(Client, Reference)] the client and reference.
|
|
12
|
+
#
|
|
13
|
+
# Four things are checked, each measured against how async-rest, async-discord and async-ollama document themselves:
|
|
14
|
+
#
|
|
15
|
+
# A paragraph occupies one line. None of the 99 summary paragraphs in those three projects is wrapped, and async-rest's run to 544 characters.
|
|
16
|
+
#
|
|
17
|
+
# A summary is short, and no longer than the code it describes earns. Across 76 documented methods the median summary is 4 to 9 words whatever the length of the method, so the allowance starts at ten words and grows by three per line of body, to a ceiling of thirty.
|
|
18
|
+
#
|
|
19
|
+
# A module's summary is ten words or fewer. The only commented module in async-rest is exactly ten.
|
|
20
|
+
#
|
|
21
|
+
# A documented method says what it takes and returns. A summary with no annotations describes the method in prose and leaves the signature undocumented, which is what annotations are for. A method with no comment at all is not this cop's business: that is documentation coverage, which decode reports on separately.
|
|
22
|
+
#
|
|
23
|
+
# Nothing here is corrected automatically. A comment which breaks these rules has to be rewritten rather than reflowed, and a correction which only moved the words around would hide that.
|
|
24
|
+
#
|
|
25
|
+
# Each check is configured on its own, because they are not all worth applying everywhere:
|
|
26
|
+
#
|
|
27
|
+
# @example
|
|
28
|
+
# Local/CommentStructure:
|
|
29
|
+
# Annotations:
|
|
30
|
+
# Include:
|
|
31
|
+
# - "lib/**/*.rb"
|
|
32
|
+
# Length:
|
|
33
|
+
# Exclude:
|
|
34
|
+
# - "bin/**/*"
|
|
35
|
+
# Wrapping:
|
|
36
|
+
# Enabled: true
|
|
37
|
+
#
|
|
38
|
+
# A check with no `Include` runs everywhere; given one, it runs only where a path matches. `Exclude` then removes paths from whatever is left. `Annotations` defaults to `lib` alone: a script in `bin` and a case in `test` have no interface for anyone else to call, so annotating them documents nothing.
|
|
39
|
+
#
|
|
40
|
+
# What a declaration says is read from the run of comment lines touching it. Where an offence is reported is read from the wider span the library attributes to the declaration, less any magic comment, because decode reports a part's position within that span rather than the line it came from.
|
|
41
|
+
|
|
42
|
+
require "decode/language/ruby/generic"
|
|
43
|
+
|
|
44
|
+
require_relative "support/prism_source"
|
|
45
|
+
|
|
46
|
+
module RuboCop
|
|
47
|
+
module Nathank
|
|
48
|
+
class CommentStructure < ::RuboCop::Cop::Base
|
|
49
|
+
include PrismSource
|
|
50
|
+
|
|
51
|
+
MSG_WRAPPED_SUMMARY = "Do not wrap a summary paragraph: a paragraph is one line, however long."
|
|
52
|
+
|
|
53
|
+
MSG_WRAPPED_ANNOTATION = "Do not wrap the summary of `@%<directive>s`: an annotation's summary is one line."
|
|
54
|
+
|
|
55
|
+
MSG_TOO_LONG = "This summary is %<words>d words; %<allowed>d is the most %<subject>s may have."
|
|
56
|
+
|
|
57
|
+
MSG_NO_ANNOTATIONS = "This method is described in prose but its parameters and return value are not: document them with annotations, or say nothing."
|
|
58
|
+
|
|
59
|
+
TAGS = Decode::Language::Ruby::Generic::TAGS
|
|
60
|
+
|
|
61
|
+
# Each check, and the paths it applies to when the configuration names none.
|
|
62
|
+
CHECKS = {
|
|
63
|
+
"Wrapping" => nil,
|
|
64
|
+
"Length" => nil,
|
|
65
|
+
"Annotations" => ["lib/**/*.rb"].freeze,
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
GLOB = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
69
|
+
|
|
70
|
+
HEADER = /\A#\s*(frozen_string_literal:|Released under|Copyright,|!)/
|
|
71
|
+
|
|
72
|
+
# What a Ruby file says to the interpreter rather than to a reader.
|
|
73
|
+
MAGIC = /\A#\s*(frozen_string_literal:|encoding:|coding:|warn_indent:|shareable_constant_value:|!|-\*-)/
|
|
74
|
+
|
|
75
|
+
TERMINAL = [".", ":", "?", "!"].freeze
|
|
76
|
+
|
|
77
|
+
# Multi-line content is the point of these, so their summaries are left alone.
|
|
78
|
+
FREEFORM = [Decode::Comment::Example].freeze
|
|
79
|
+
|
|
80
|
+
def on_new_investigation
|
|
81
|
+
if self.applies?("Wrapping")
|
|
82
|
+
self.blocks.each do |block|
|
|
83
|
+
self.check_wrapping(block)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if self.applies?("Length")
|
|
88
|
+
self.prism_each(::Prism::ModuleNode).each do |node|
|
|
89
|
+
self.check_summary(node, "a module summary", self.cop_config.fetch("ModuleWords", 10))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
self.prism_each(::Prism::ClassNode).each do |node|
|
|
93
|
+
self.check_summary(node, "a class summary", self.cop_config.fetch("MaximumWords", 30))
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
self.prism_each(::Prism::DefNode).each do |node|
|
|
98
|
+
self.check_method(node)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Whether a check applies to the file being inspected.
|
|
105
|
+
def applies?(check)
|
|
106
|
+
configuration = self.cop_config[check] || {}
|
|
107
|
+
|
|
108
|
+
if configuration["Enabled"] == false
|
|
109
|
+
false
|
|
110
|
+
else
|
|
111
|
+
path = ::RuboCop::PathUtil.relative_path(processed_source.file_path.to_s)
|
|
112
|
+
|
|
113
|
+
included = configuration["Include"] || CHECKS.fetch(check)
|
|
114
|
+
excluded = configuration["Exclude"] || []
|
|
115
|
+
|
|
116
|
+
self.matches?(path, included, true) and !self.matches?(path, excluded, false)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @parameter default [Boolean] what an absent or empty list of patterns means.
|
|
121
|
+
def matches?(path, patterns, default)
|
|
122
|
+
if patterns.nil? or patterns.empty?
|
|
123
|
+
default
|
|
124
|
+
else
|
|
125
|
+
patterns.any?{|pattern| File.fnmatch?(pattern, path, GLOB)}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def check_method(node)
|
|
130
|
+
lines = node.location.end_line - node.location.start_line + 1
|
|
131
|
+
base = self.cop_config.fetch("BaseWords", 10)
|
|
132
|
+
per_line = self.cop_config.fetch("WordsPerLine", 3)
|
|
133
|
+
ceiling = self.cop_config.fetch("MaximumWords", 30)
|
|
134
|
+
|
|
135
|
+
allowed = [base + (per_line * lines), ceiling].min
|
|
136
|
+
|
|
137
|
+
if self.applies?("Length")
|
|
138
|
+
self.check_summary(node, "a summary of #{lines} #{lines == 1 ? "line" : "lines"} of code", allowed)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if self.applies?("Annotations")
|
|
142
|
+
self.check_annotations(node)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The comment which documents a declaration, as the parts decode reads it as.
|
|
147
|
+
def parts_of(node)
|
|
148
|
+
comments = self.documenting(node)
|
|
149
|
+
|
|
150
|
+
if comments.empty?
|
|
151
|
+
nil
|
|
152
|
+
else
|
|
153
|
+
self.parse(comments)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The comment above a declaration, without the file's own header.
|
|
158
|
+
def documenting(node)
|
|
159
|
+
self.prism_leading_comments(node).reject{|comment| self.prism_source(comment).match?(HEADER)}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Every comment the declaration owns, which is what the parts are indexed against.
|
|
163
|
+
def owned(node)
|
|
164
|
+
self.prism_owned_comments(node).drop_while{|comment| self.prism_source(comment).match?(MAGIC)}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def check_summary(node, subject, allowed)
|
|
168
|
+
parts = self.parts_of(node)
|
|
169
|
+
|
|
170
|
+
if parts
|
|
171
|
+
summary = parts.map(&:first).grep(Decode::Comment::Text)
|
|
172
|
+
words = summary.sum{|text| text.line.split.size}
|
|
173
|
+
|
|
174
|
+
if words > allowed
|
|
175
|
+
message = format(MSG_TOO_LONG, words: words, allowed: allowed, subject: subject)
|
|
176
|
+
|
|
177
|
+
add_offense(self.summary_range(node, parts, summary.first), message: message)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def check_annotations(node)
|
|
183
|
+
parts = self.parts_of(node)
|
|
184
|
+
|
|
185
|
+
if parts and parts.any?{|part, _| part.is_a?(Decode::Comment::Text)}
|
|
186
|
+
annotations = parts.map(&:first).reject{|part| part.is_a?(Decode::Comment::Text)}
|
|
187
|
+
|
|
188
|
+
if annotations.empty?
|
|
189
|
+
add_offense(self.prism_range(node.name_loc), message: MSG_NO_ANNOTATIONS)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Consecutive comment lines: a block is what documents one thing, so a wrap can only happen inside one.
|
|
195
|
+
def blocks
|
|
196
|
+
[].tap do |found|
|
|
197
|
+
current = []
|
|
198
|
+
|
|
199
|
+
self.prism_comments.each do |comment|
|
|
200
|
+
text = self.prism_source(comment)
|
|
201
|
+
|
|
202
|
+
if text.match?(HEADER) or text.start_with?("# rubocop:")
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if current.empty? or comment.location.start_line == current.last.location.start_line + 1
|
|
207
|
+
current << comment
|
|
208
|
+
else
|
|
209
|
+
found << current
|
|
210
|
+
current = [comment]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
unless current.empty?
|
|
215
|
+
found << current
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def check_wrapping(block)
|
|
221
|
+
nodes = self.parse(block)
|
|
222
|
+
|
|
223
|
+
nodes.each_with_index do |(node, lines), index|
|
|
224
|
+
if node.is_a?(Decode::Comment::Text)
|
|
225
|
+
following = nodes[index + 1]&.first
|
|
226
|
+
|
|
227
|
+
if following.is_a?(Decode::Comment::Text) and !self.terminated?(node.line)
|
|
228
|
+
add_offense(self.prism_range(block[lines.first.first].location), message: MSG_WRAPPED_SUMMARY)
|
|
229
|
+
end
|
|
230
|
+
elsif !FREEFORM.include?(node.class)
|
|
231
|
+
summary = lines.drop(1).reject{|line| line.last.strip.start_with?("@")}
|
|
232
|
+
|
|
233
|
+
if summary.size > 1
|
|
234
|
+
message = format(MSG_WRAPPED_ANNOTATION, directive: node.directive)
|
|
235
|
+
|
|
236
|
+
add_offense(self.prism_range(block[summary.first.first].location), message: message)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# decode consumes the lines it parses and does not report where they came from, so they are handed over through something which records what it gave away.
|
|
243
|
+
def parse(block)
|
|
244
|
+
source = Consumable.new(block.each_with_index.map{|comment, index| [index, self.text_of(comment)]})
|
|
245
|
+
nodes = []
|
|
246
|
+
cursor = 0
|
|
247
|
+
|
|
248
|
+
TAGS.parse(source) do |node|
|
|
249
|
+
if node
|
|
250
|
+
nodes << [node, source.log[cursor..]]
|
|
251
|
+
cursor = source.log.size
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
nodes
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The comment without its `#`, keeping the indentation which tells decode what is nested.
|
|
259
|
+
def text_of(comment)
|
|
260
|
+
self.prism_source(comment).sub(/\A#\s?/, "")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Where to report a summary which is too long: the comment line it starts on, or the declaration itself when the summary cannot be traced back to one.
|
|
264
|
+
def summary_range(node, parts, text)
|
|
265
|
+
comments = self.owned(node)
|
|
266
|
+
entry = text && parts.find{|part, _| part.equal?(text)}
|
|
267
|
+
|
|
268
|
+
if entry and comments[entry.last.first.first]
|
|
269
|
+
self.prism_range(comments[entry.last.first.first].location)
|
|
270
|
+
else
|
|
271
|
+
self.prism_node_range(node)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def terminated?(line)
|
|
276
|
+
text = line.strip
|
|
277
|
+
|
|
278
|
+
text.empty? or TERMINAL.any?{|mark| text.end_with?(mark)}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# What decode shifts lines off, which remembers them.
|
|
282
|
+
class Consumable
|
|
283
|
+
def initialize(entries)
|
|
284
|
+
@entries = entries
|
|
285
|
+
@log = []
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
attr :log
|
|
289
|
+
|
|
290
|
+
def first = @entries.first&.last
|
|
291
|
+
|
|
292
|
+
def shift
|
|
293
|
+
entry = @entries.shift
|
|
294
|
+
|
|
295
|
+
if entry
|
|
296
|
+
@log << entry
|
|
297
|
+
entry.last
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def empty? = @entries.empty?
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This is the default configuration file for rubocop-nathank.
|
|
4
|
+
# You can override any of these settings in your .rubocop.yml file.
|
|
5
|
+
|
|
6
|
+
Nathank/BeginBlockAssignment:
|
|
7
|
+
Description: "Bans assigning a `begin...end` expression; the assignment goes inside the block."
|
|
8
|
+
Enabled: true
|
|
9
|
+
VersionAdded: "0.1.0"
|
|
10
|
+
|
|
11
|
+
Nathank/CommentStructure:
|
|
12
|
+
Description: "How a comment is built: unwrapped paragraphs, a summary no longer than the code earns, and annotations on a documented interface."
|
|
13
|
+
Enabled: true
|
|
14
|
+
VersionAdded: "0.1.0"
|
|
15
|
+
MaximumWords: 30
|
|
16
|
+
BaseWords: 10
|
|
17
|
+
WordsPerLine: 3
|
|
18
|
+
ModuleWords: 10
|
|
19
|
+
Wrapping:
|
|
20
|
+
Enabled: true
|
|
21
|
+
Length:
|
|
22
|
+
Enabled: true
|
|
23
|
+
Annotations:
|
|
24
|
+
Enabled: true
|
|
25
|
+
Include:
|
|
26
|
+
- "lib/**/*.rb"
|
|
27
|
+
- "fixtures/**/*.rb"
|
|
28
|
+
|
|
29
|
+
Nathank/NoConditionalAssignment:
|
|
30
|
+
Description: "Bans assigning an `if` / `case` expression; assign inside each branch instead."
|
|
31
|
+
Enabled: true
|
|
32
|
+
VersionAdded: "0.1.0"
|
|
33
|
+
|
|
34
|
+
Nathank/NoModifierIf:
|
|
35
|
+
Description: "Bans modifier `if` / `unless`, so a guard cannot hide off the right edge of a long line."
|
|
36
|
+
Enabled: true
|
|
37
|
+
VersionAdded: "0.1.0"
|
|
38
|
+
|
|
39
|
+
Nathank/NoReturn:
|
|
40
|
+
Description: "Bans `return`. The method's last expression is its return value, and guards are structural."
|
|
41
|
+
Enabled: true
|
|
42
|
+
VersionAdded: "0.1.0"
|
|
43
|
+
|
|
44
|
+
Nathank/SingleLineMethodsForEvents:
|
|
45
|
+
Description: "A one-expression method body uses the endless form."
|
|
46
|
+
Enabled: true
|
|
47
|
+
VersionAdded: "0.1.0"
|
|
48
|
+
|
|
49
|
+
Nathank/UseTap:
|
|
50
|
+
Description: "Requires `tap` where a method assigns a local on its first line and returns it on its last."
|
|
51
|
+
Enabled: true
|
|
52
|
+
VersionAdded: "0.1.0"
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
# Bans assigning an `if` / `unless` / `case` expression.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# status = case task.state # NO
|
|
10
|
+
# when :done then :ok
|
|
11
|
+
# else :pending
|
|
12
|
+
# end
|
|
13
|
+
# obj.status = cond ? :ok : :pending # NO
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# status = :pending # YES
|
|
17
|
+
# case task.state
|
|
18
|
+
# when :done
|
|
19
|
+
# status = :ok
|
|
20
|
+
# end
|
|
21
|
+
|
|
22
|
+
require_relative "support/prism_source"
|
|
23
|
+
|
|
24
|
+
module RuboCop
|
|
25
|
+
module Nathank
|
|
26
|
+
class NoConditionalAssignment < ::RuboCop::Cop::Base
|
|
27
|
+
include PrismSource
|
|
28
|
+
extend ::RuboCop::Cop::AutoCorrector
|
|
29
|
+
|
|
30
|
+
MSG = "Do not assign a `%<keyword>s` expression to a variable; " \
|
|
31
|
+
"assign inside each branch of the %<keyword>s."
|
|
32
|
+
|
|
33
|
+
# Every way a name is written to with a single value on the right.
|
|
34
|
+
WRITES = [
|
|
35
|
+
::Prism::ClassVariableAndWriteNode,
|
|
36
|
+
::Prism::ClassVariableOperatorWriteNode,
|
|
37
|
+
::Prism::ClassVariableOrWriteNode,
|
|
38
|
+
::Prism::ClassVariableWriteNode,
|
|
39
|
+
::Prism::ConstantAndWriteNode,
|
|
40
|
+
::Prism::ConstantOperatorWriteNode,
|
|
41
|
+
::Prism::ConstantOrWriteNode,
|
|
42
|
+
::Prism::ConstantPathAndWriteNode,
|
|
43
|
+
::Prism::ConstantPathOperatorWriteNode,
|
|
44
|
+
::Prism::ConstantPathOrWriteNode,
|
|
45
|
+
::Prism::ConstantPathWriteNode,
|
|
46
|
+
::Prism::ConstantWriteNode,
|
|
47
|
+
::Prism::GlobalVariableAndWriteNode,
|
|
48
|
+
::Prism::GlobalVariableOperatorWriteNode,
|
|
49
|
+
::Prism::GlobalVariableOrWriteNode,
|
|
50
|
+
::Prism::GlobalVariableWriteNode,
|
|
51
|
+
::Prism::InstanceVariableAndWriteNode,
|
|
52
|
+
::Prism::InstanceVariableOperatorWriteNode,
|
|
53
|
+
::Prism::InstanceVariableOrWriteNode,
|
|
54
|
+
::Prism::InstanceVariableWriteNode,
|
|
55
|
+
::Prism::LocalVariableAndWriteNode,
|
|
56
|
+
::Prism::LocalVariableOperatorWriteNode,
|
|
57
|
+
::Prism::LocalVariableOrWriteNode,
|
|
58
|
+
::Prism::LocalVariableWriteNode,
|
|
59
|
+
::Prism::MultiWriteNode,
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
# The writes whose target is a plain name, and so can be repeated in every branch.
|
|
63
|
+
SIMPLE = [
|
|
64
|
+
::Prism::ClassVariableWriteNode,
|
|
65
|
+
::Prism::ConstantWriteNode,
|
|
66
|
+
::Prism::GlobalVariableWriteNode,
|
|
67
|
+
::Prism::InstanceVariableWriteNode,
|
|
68
|
+
::Prism::LocalVariableWriteNode,
|
|
69
|
+
].freeze
|
|
70
|
+
|
|
71
|
+
CONDITIONALS = [
|
|
72
|
+
::Prism::CaseMatchNode,
|
|
73
|
+
::Prism::CaseNode,
|
|
74
|
+
::Prism::IfNode,
|
|
75
|
+
::Prism::UnlessNode,
|
|
76
|
+
].freeze
|
|
77
|
+
|
|
78
|
+
def on_new_investigation
|
|
79
|
+
self.prism_each(*WRITES).each do |node|
|
|
80
|
+
self.check(node, node.value)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
self.prism_each(::Prism::CallNode).each do |node|
|
|
84
|
+
if self.assignment_method?(node)
|
|
85
|
+
self.check(node, self.last_argument(node))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def check(node, rhs)
|
|
93
|
+
if self.conditional?(rhs)
|
|
94
|
+
range = self.prism_node_range(node)
|
|
95
|
+
|
|
96
|
+
add_offense(range, message: format(MSG, keyword: self.keyword(rhs))) do |corrector|
|
|
97
|
+
if self.safe_to_push_inside?(node, rhs)
|
|
98
|
+
corrector.replace(range, self.pushed_inside(node, rhs))
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def conditional?(rhs)
|
|
105
|
+
!rhs.nil? && CONDITIONALS.any?{|klass| rhs.is_a?(klass)}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# `obj.status = value` is a call whose name ends in `=`.
|
|
109
|
+
def assignment_method?(node)
|
|
110
|
+
node.name.to_s.end_with?("=") && !node.name.to_s.end_with?("==") && !node.arguments.nil?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def last_argument(node)
|
|
114
|
+
node.arguments&.arguments&.last
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The target must be re-evaluable without side effects.
|
|
118
|
+
def safe_to_push_inside?(node, rhs)
|
|
119
|
+
# `case/in` pattern matching is left alone — its branch headers are patterns, not expressions, and reproducing them is a different job.
|
|
120
|
+
!rhs.is_a?(::Prism::CaseMatchNode) &&
|
|
121
|
+
self.own_line?(node) &&
|
|
122
|
+
self.simple_target?(node) &&
|
|
123
|
+
!self.prism_heredoc?(node) &&
|
|
124
|
+
self.branches(rhs).all?{|_condition, body| body.nil? || !self.prism_source(body).include?("\n")}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def simple_target?(node)
|
|
128
|
+
if SIMPLE.any?{|klass| node.is_a?(klass)}
|
|
129
|
+
true
|
|
130
|
+
elsif node.is_a?(::Prism::CallNode)
|
|
131
|
+
receiver = node.receiver
|
|
132
|
+
|
|
133
|
+
node.arguments&.arguments&.one? &&
|
|
134
|
+
receiver.is_a?(::Prism::CallNode) &&
|
|
135
|
+
(receiver.arguments.nil? || receiver.arguments.arguments.empty?)
|
|
136
|
+
else
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def own_line?(node)
|
|
142
|
+
line = processed_source.lines[node.location.start_line - 1].to_s
|
|
143
|
+
|
|
144
|
+
line[0...self.prism_column(node)].strip.empty?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The assignment prefix repeated in every branch — "x = ", "@y = ", "obj.z = ".
|
|
148
|
+
def target_source(node)
|
|
149
|
+
if node.is_a?(::Prism::CallNode)
|
|
150
|
+
"#{self.prism_source(node.receiver)}.#{node.name.to_s.chomp("=")} = "
|
|
151
|
+
else
|
|
152
|
+
"#{node.name} = "
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Each branch as `[condition_source, body_node]`, in source order.
|
|
157
|
+
def branches(rhs)
|
|
158
|
+
if rhs.is_a?(::Prism::CaseNode) || rhs.is_a?(::Prism::CaseMatchNode)
|
|
159
|
+
self.case_branches(rhs)
|
|
160
|
+
else
|
|
161
|
+
self.if_branches(rhs)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def if_branches(node, acc = [])
|
|
166
|
+
acc << [self.prism_source(node.predicate), node.statements]
|
|
167
|
+
|
|
168
|
+
following = self.else_branch(node)
|
|
169
|
+
|
|
170
|
+
if following.is_a?(::Prism::IfNode) && !self.ternary?(following)
|
|
171
|
+
self.if_branches(following, acc)
|
|
172
|
+
else
|
|
173
|
+
acc << [nil, following.is_a?(::Prism::ElseNode) ? following.statements : following]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
acc
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def case_branches(node)
|
|
180
|
+
node.conditions.map{|when_node| [when_node, when_node.statements]} +
|
|
181
|
+
[[nil, node.else_clause&.statements]]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# What follows the then-branch: an `else`, or a chained `elsif`.
|
|
185
|
+
def else_branch(node)
|
|
186
|
+
if node.is_a?(::Prism::UnlessNode)
|
|
187
|
+
node.else_clause
|
|
188
|
+
else
|
|
189
|
+
node.subsequent
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def pushed_inside(node, rhs)
|
|
194
|
+
indent = " " * self.prism_column(node)
|
|
195
|
+
target = self.target_source(node)
|
|
196
|
+
|
|
197
|
+
if rhs.is_a?(::Prism::CaseNode)
|
|
198
|
+
self.rendered_case(rhs, indent, target)
|
|
199
|
+
else
|
|
200
|
+
self.rendered_if(rhs, indent, target)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def rendered_if(rhs, indent, target)
|
|
205
|
+
parts = self.if_branches(rhs).each_with_index.map{|(condition, body), index|
|
|
206
|
+
if condition.nil?
|
|
207
|
+
header = "#{indent}else"
|
|
208
|
+
elsif index.zero?
|
|
209
|
+
header = "#{indent}if #{condition}"
|
|
210
|
+
else
|
|
211
|
+
header = "#{indent}elsif #{condition}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
"#{header}\n#{indent} #{target}#{body ? self.prism_source(body) : "nil"}"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# The replacement begins at the node's own column, so the first line must NOT carry the indent again.
|
|
218
|
+
"#{parts.join("\n").sub(/\A#{indent}/, "")}\n#{indent}end"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def rendered_case(rhs, indent, target)
|
|
222
|
+
head = ["case", rhs.predicate && self.prism_source(rhs.predicate)].compact.join(" ")
|
|
223
|
+
|
|
224
|
+
parts = rhs.conditions.map{|when_node|
|
|
225
|
+
conditions = when_node.conditions.map{|condition| self.prism_source(condition)}.join(", ")
|
|
226
|
+
|
|
227
|
+
"#{indent}when #{conditions}\n#{indent} #{target}#{when_node.statements ? self.prism_source(when_node.statements) : "nil"}"
|
|
228
|
+
}
|
|
229
|
+
else_body = rhs.else_clause&.statements
|
|
230
|
+
parts << "#{indent}else\n#{indent} #{target}#{else_body ? self.prism_source(else_body) : "nil"}"
|
|
231
|
+
|
|
232
|
+
"#{head}\n#{parts.join("\n")}\n#{indent}end"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# A ternary has no keyword at all.
|
|
236
|
+
def ternary?(node)
|
|
237
|
+
node.is_a?(::Prism::IfNode) && node.if_keyword_loc.nil?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# `keyword` is "ternary" on a ternary — it has no `if` token to point at.
|
|
241
|
+
def keyword(rhs)
|
|
242
|
+
if rhs.is_a?(::Prism::IfNode)
|
|
243
|
+
if self.ternary?(rhs)
|
|
244
|
+
"ternary"
|
|
245
|
+
else
|
|
246
|
+
"if"
|
|
247
|
+
end
|
|
248
|
+
elsif rhs.is_a?(::Prism::UnlessNode)
|
|
249
|
+
"unless"
|
|
250
|
+
else
|
|
251
|
+
"case"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|