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.
@@ -0,0 +1,100 @@
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 the modifier `if` / `unless` form.
7
+ #
8
+ # @example
9
+ # return true if task.state_task_completed? # NO
10
+ #
11
+ # @example
12
+ # if task.state_task_completed? # YES
13
+ # return true
14
+ # end
15
+
16
+ require_relative "support/prism_source"
17
+
18
+ module RuboCop
19
+ module Nathank
20
+ class NoModifierIf < ::RuboCop::Cop::Base
21
+ include PrismSource
22
+ extend ::RuboCop::Cop::AutoCorrector
23
+
24
+ MSG = "Do not use modifier `%<keyword>s`; use the full `%<keyword>s ... end` block form."
25
+
26
+ def on_new_investigation
27
+ self.prism_each(::Prism::IfNode, ::Prism::UnlessNode).each do |node|
28
+ if self.modifier_form?(node)
29
+ range = self.prism_node_range(node)
30
+
31
+ add_offense(range, message: format(MSG, keyword: self.keyword(node))) do |corrector|
32
+ if self.safe_to_unwrap?(node)
33
+ corrector.replace(range, self.block_form(node))
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ # A modifier has its keyword written after what it guards, and so has no `end` to close.
43
+ # A ternary has no keyword at all, and is not this cop's business.
44
+ def modifier_form?(node)
45
+ if node.is_a?(::Prism::UnlessNode)
46
+ keyword = node.keyword_loc
47
+ else
48
+ keyword = node.if_keyword_loc
49
+ end
50
+
51
+ !keyword.nil? && node.end_keyword_loc.nil?
52
+ end
53
+
54
+ def keyword(node)
55
+ if node.is_a?(::Prism::UnlessNode)
56
+ "unless"
57
+ else
58
+ "if"
59
+ end
60
+ end
61
+
62
+ # Two shapes are left for a human.
63
+ def safe_to_unwrap?(node)
64
+ self.own_line?(node) && !self.prism_heredoc?(node)
65
+ end
66
+
67
+ def own_line?(node)
68
+ line = processed_source.lines[node.location.start_line - 1].to_s
69
+
70
+ line[0...self.prism_column(node)].strip.empty?
71
+ end
72
+
73
+ def block_form(node)
74
+ indent = " " * self.prism_column(node)
75
+ body = node.statements
76
+
77
+ "#{self.keyword(node)} #{self.prism_source(node.predicate)}\n" \
78
+ "#{self.reindent(body, indent)}\n" \
79
+ "#{indent}end"
80
+ end
81
+
82
+ # Move the body to a new indent.
83
+ def reindent(node, indent)
84
+ target = indent.length + 2
85
+ delta = target - self.prism_column(node)
86
+ lines = self.prism_source(node).lines
87
+
88
+ rest = lines[1..].to_a.map{|line|
89
+ if delta.negative?
90
+ line.chomp.sub(/\A {0,#{-delta}}/, "")
91
+ else
92
+ "#{" " * delta}#{line.chomp}"
93
+ end
94
+ }
95
+
96
+ [("#{" " * target}#{lines.first.chomp}")].concat(rest).join("\n")
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,208 @@
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 the `return` keyword.
7
+ #
8
+ # @example
9
+ # if cond → if cond
10
+ # return X X
11
+ # end else
12
+ # rest rest
13
+ # end
14
+
15
+ require_relative "support/prism_source"
16
+
17
+ module RuboCop
18
+ module Nathank
19
+ class NoReturn < ::RuboCop::Cop::Base
20
+ include PrismSource
21
+ extend ::RuboCop::Cop::AutoCorrector
22
+
23
+ MSG = "Do not use `return`; let the method's last expression be the return value."
24
+
25
+ def on_new_investigation
26
+ # One guard per body per pass — two corrections inside the same body would overlap and clobber each other. `rubocop -a` re-runs the cop until it stops changing anything, so chained guards still unwind, one per pass.
27
+ @corrected_bodies = {}
28
+
29
+ self.prism_each(::Prism::ReturnNode).each do |node|
30
+ self.report(node)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def report(node)
37
+ add_offense(self.prism_node_range(node)) do |corrector|
38
+ guard = self.correctable_guard(node)
39
+
40
+ if guard
41
+ body, index = guard
42
+
43
+ if @corrected_bodies[body.object_id].nil?
44
+ @corrected_bodies[body.object_id] = true
45
+ corrector.replace(self.replaced_range(body, index), self.rewritten(body, index))
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ # The guard's body and index, when the node is one.
52
+ #
53
+ # Prism writes a `StatementsNode` between a conditional and its body, so the guard is the `return`'s grandparent.
54
+ def correctable_guard(node)
55
+ statements = self.prism_parent(node)
56
+
57
+ if !statements.is_a?(::Prism::StatementsNode)
58
+ nil
59
+ else
60
+ if_node = self.prism_parent(statements)
61
+
62
+ if self.unguarded?(if_node, statements, node)
63
+ nil
64
+ else
65
+ body = self.prism_parent(if_node)
66
+
67
+ if !body.is_a?(::Prism::StatementsNode) || !self.tail_position?(body)
68
+ nil
69
+ else
70
+ index = body.body.index(if_node)
71
+
72
+ if index.nil? || index == body.body.size - 1
73
+ nil
74
+ else
75
+ [body, index]
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ # Every shape which is not a plain `if cond / return X / end` guard.
83
+ def unguarded?(if_node, statements, node)
84
+ !self.conditional?(if_node) ||
85
+ self.modifier_form?(if_node) ||
86
+ self.ternary?(if_node) ||
87
+ self.elsif?(if_node) ||
88
+ !if_node.statements.equal?(statements) ||
89
+ statements.body != [node] ||
90
+ !self.else_branch(if_node).nil?
91
+ end
92
+
93
+ def conditional?(node)
94
+ node.is_a?(::Prism::IfNode) || node.is_a?(::Prism::UnlessNode)
95
+ end
96
+
97
+ # A modifier has its keyword written after what it guards, and so has no `end` to close.
98
+ def modifier_form?(node)
99
+ !self.keyword_location(node).nil? && node.end_keyword_loc.nil?
100
+ end
101
+
102
+ # A ternary has no keyword at all.
103
+ def ternary?(node)
104
+ self.keyword_location(node).nil?
105
+ end
106
+
107
+ # An `elsif` is a conditional whose keyword says so.
108
+ def elsif?(node)
109
+ location = self.keyword_location(node)
110
+
111
+ !location.nil? && self.prism_range(location).source == "elsif"
112
+ end
113
+
114
+ def keyword_location(node)
115
+ if node.is_a?(::Prism::UnlessNode)
116
+ node.keyword_loc
117
+ else
118
+ node.if_keyword_loc
119
+ end
120
+ end
121
+
122
+ # What follows the then-branch: an `else`, or a chained `elsif`.
123
+ def else_branch(node)
124
+ if node.is_a?(::Prism::UnlessNode)
125
+ node.else_clause
126
+ else
127
+ node.subsequent
128
+ end
129
+ end
130
+
131
+ def keyword(node)
132
+ if node.is_a?(::Prism::UnlessNode)
133
+ "unless"
134
+ else
135
+ "if"
136
+ end
137
+ end
138
+
139
+ # Whether this node is the last thing its enclosing method evaluates.
140
+ def tail_position?(node)
141
+ parent = self.prism_parent(node)
142
+
143
+ if parent.nil?
144
+ false
145
+ elsif parent.is_a?(::Prism::DefNode)
146
+ parent.body.equal?(node)
147
+ elsif parent.is_a?(::Prism::StatementsNode)
148
+ parent.body.last.equal?(node) && self.tail_position?(parent)
149
+ elsif parent.is_a?(::Prism::ElseNode)
150
+ parent.statements.equal?(node) && self.tail_position?(parent)
151
+ elsif self.conditional?(parent) && !self.ternary?(parent)
152
+ parent.statements.equal?(node) && self.tail_position?(parent)
153
+ else
154
+ false
155
+ end
156
+ end
157
+
158
+ def replaced_range(body, index)
159
+ self.prism_span(body.body[index].location, body.body.last.location)
160
+ end
161
+
162
+ def rewritten(body, index)
163
+ if_node = body.body[index]
164
+ indent = " " * self.prism_column(if_node)
165
+ guarded = self.returned_value(if_node)
166
+ rest = body.body[(index + 1)..]
167
+
168
+ if guarded
169
+ guard_value = self.reindent(guarded, indent)
170
+ else
171
+ guard_value = "#{indent} nil"
172
+ end
173
+ rest_source = rest.map{|statement| self.reindent(statement, indent)}.join("\n")
174
+
175
+ # `unless cond / return X / end` means "X unless cond", so the branches swap rather than the condition being negated.
176
+ if self.keyword(if_node) == "unless"
177
+ first, second = rest_source, guard_value
178
+ else
179
+ first, second = guard_value, rest_source
180
+ end
181
+
182
+ "if #{self.prism_source(if_node.predicate)}\n#{first}\n#{indent}else\n#{second}\n#{indent}end"
183
+ end
184
+
185
+ # The value the guard returns, or nil for a bare `return`.
186
+ def returned_value(if_node)
187
+ if_node.statements.body.first.arguments&.arguments&.first
188
+ end
189
+
190
+ # Move a statement to a new indent.
191
+ def reindent(node, indent)
192
+ target = indent.length + 2
193
+ delta = target - self.prism_column(node)
194
+ lines = self.prism_source(node).lines
195
+
196
+ rest = lines[1..].to_a.map{|line|
197
+ if delta.negative?
198
+ line.chomp.sub(/\A {0,#{-delta}}/, "")
199
+ else
200
+ "#{" " * delta}#{line.chomp}"
201
+ end
202
+ }
203
+
204
+ [("#{" " * target}#{lines.first.chomp}")].concat(rest).join("\n")
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Nathan Kidd.
5
+
6
+ require "lint_roller"
7
+ require_relative "version"
8
+
9
+ module RuboCop
10
+ module Nathank
11
+ # Represents a LintRoller plugin that provides RuboCop rules for Nathank projects.
12
+ # This plugin integrates custom RuboCop cops and configuration into the LintRoller system.
13
+ class Plugin < LintRoller::Plugin
14
+ # Initialize the plugin with version information.
15
+ def initialize(...)
16
+ super
17
+ @version = RuboCop::Nathank::VERSION
18
+ end
19
+
20
+ # Get information about this plugin for the LintRoller system.
21
+ # @returns [LintRoller::About] Plugin metadata including name, version, and description.
22
+ def about
23
+ LintRoller::About.new(
24
+ name: "rubocop-nathank",
25
+ version: @version,
26
+ homepage: "https://github.com/n-at-han-k/rubocop-nathank",
27
+ description: "RuboCop rules for Nathank projects."
28
+ )
29
+ end
30
+
31
+ # Define the rules configuration for this plugin.
32
+ # @parameter context [Object] The LintRoller context object.
33
+ # @returns [LintRoller::Rules] Rules configuration specifying the path to the default RuboCop config.
34
+ def rules(context)
35
+ LintRoller::Rules.new(
36
+ type: :path,
37
+ config_format: :rubocop,
38
+ value: File.expand_path("config.yaml", __dir__),
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ require_relative "support/prism_source"
7
+
8
+ module RuboCop
9
+ module Nathank
10
+ # Requires a method whose body is a single expression to use the endless method form.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # def event_type
15
+ # "m.room.message"
16
+ # end
17
+ # # good
18
+ # def event_type = "m.room.message"
19
+ class SingleLineMethodsForEvents < ::RuboCop::Cop::Base
20
+ include PrismSource
21
+ extend ::RuboCop::Cop::AutoCorrector
22
+
23
+ MSG = "A method whose body is one expression must use the endless form: `def name = expression`."
24
+
25
+ # Expressions which can follow ` = ` unchanged. Anything with its own `end`, or with lower precedence than the assignment, is left as a block: what it would parse as is not what it says as a body.
26
+ ACCEPTED_BODIES = [
27
+ ::Prism::ArrayNode,
28
+ ::Prism::CallNode,
29
+ ::Prism::ClassVariableReadNode,
30
+ ::Prism::ConstantPathNode,
31
+ ::Prism::ConstantReadNode,
32
+ ::Prism::FalseNode,
33
+ ::Prism::FloatNode,
34
+ ::Prism::GlobalVariableReadNode,
35
+ ::Prism::HashNode,
36
+ ::Prism::ImaginaryNode,
37
+ ::Prism::InstanceVariableReadNode,
38
+ ::Prism::IntegerNode,
39
+ ::Prism::InterpolatedStringNode,
40
+ ::Prism::InterpolatedSymbolNode,
41
+ ::Prism::InterpolatedXStringNode,
42
+ ::Prism::LocalVariableReadNode,
43
+ ::Prism::NilNode,
44
+ ::Prism::RangeNode,
45
+ ::Prism::RationalNode,
46
+ ::Prism::RegularExpressionNode,
47
+ ::Prism::SelfNode,
48
+ ::Prism::StringNode,
49
+ ::Prism::SymbolNode,
50
+ ::Prism::TrueNode,
51
+ ::Prism::XStringNode,
52
+ ].freeze
53
+
54
+ def on_new_investigation
55
+ self.prism_each(::Prism::DefNode).each do |node|
56
+ if self.rewriteable?(node)
57
+ self.report(node)
58
+ end
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # The one expression a method's body is, when that is what it is.
65
+ def sole_expression(node)
66
+ statements = self.prism_statements(node.body)
67
+
68
+ if statements.size == 1
69
+ statements.first
70
+ else
71
+ nil
72
+ end
73
+ end
74
+
75
+ # A method is rewritten only when it is not endless, not a setter, and has one non-heredoc body expression on one line.
76
+ def rewriteable?(node)
77
+ body = self.sole_expression(node)
78
+
79
+ !self.endless?(node) &&
80
+ !self.setter?(node.name) &&
81
+ !body.nil? &&
82
+ ACCEPTED_BODIES.any?{|klass| body.is_a?(klass)} &&
83
+ self.single_line?(node, body) &&
84
+ !self.prism_heredoc?(body)
85
+ end
86
+
87
+ # An endless method is written with `=` rather than a body closed by `end`.
88
+ def endless?(node)
89
+ !node.equal_loc.nil?
90
+ end
91
+
92
+ # The offense is reported either way; the correction is withheld when a comment sits inside the method, since replacing the method would take the comment with it.
93
+ def report(node)
94
+ range = self.prism_node_range(node)
95
+
96
+ if self.comment?(node)
97
+ add_offense(range)
98
+ else
99
+ add_offense(range) do |corrector|
100
+ corrector.replace(range, self.endless_form(node))
101
+ end
102
+ end
103
+ end
104
+
105
+ # `def foo=(value)` and `def []=(key, value)` have no endless form.
106
+ def setter?(name)
107
+ name == :[]= || /\A[a-zA-Z_]\w*=\z/.match?(name.to_s)
108
+ end
109
+
110
+ # The body on one line, and the signature on one line too, so that the rewritten method is still a single line.
111
+ def single_line?(node, body)
112
+ body.location.start_line == body.location.end_line &&
113
+ !self.signature_source(node).include?("\n")
114
+ end
115
+
116
+ # The source of the signature, e.g. `def build(**fields)`, without the gap before the body.
117
+ def signature_source(node)
118
+ source = processed_source.buffer.source
119
+
120
+ # byteslice, because every offset here is one of Prism's and those count bytes, where `String#[]` counts characters.
121
+ source.byteslice(node.location.start_offset...self.signature_end(node))
122
+ end
123
+
124
+ def signature_end(node)
125
+ ending = node.name_loc.end_offset
126
+
127
+ if parameters = node.parameters
128
+ [ending, parameters.location.end_offset].max
129
+ else
130
+ ending
131
+ end
132
+ end
133
+
134
+ def comment?(node)
135
+ self.prism_comments.any? do |comment|
136
+ (node.location.start_line..node.location.end_line).cover?(comment.location.start_line)
137
+ end
138
+ end
139
+
140
+ def endless_form(node)
141
+ "#{self.signature(node)} = #{self.prism_source(self.sole_expression(node))}"
142
+ end
143
+
144
+ def signature(node)
145
+ "#{self.receiver_prefix(node)}#{node.name}#{self.arguments_suffix(node)}"
146
+ end
147
+
148
+ def receiver_prefix(node)
149
+ if node.receiver
150
+ "def #{self.prism_source(node.receiver)}."
151
+ else
152
+ "def "
153
+ end
154
+ end
155
+
156
+ # Bare arguments are ambiguous before ` = `; parenthesize them.
157
+ def arguments_suffix(node)
158
+ parameters = node.parameters
159
+
160
+ if parameters.nil?
161
+ ""
162
+ else
163
+ "(#{self.prism_source(parameters)})"
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end