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