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,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ # Reads the file under inspection with Prism, which ships with Ruby, instead of through the parser gem syntax tree RuboCop hands a cop.
7
+ #
8
+ # A cop which includes this does not use the `on_<type>` callbacks: those are driven by the parser gem tree, so a cop which reads a different tree walks it itself from `on_new_investigation`. What it gains is one parse of the file by the same Prism which runs the program, and a uniform walk over every node.
9
+ #
10
+ # Offences are still reported against `Parser::Source::Range`, because that is what RuboCop and its correctors take. THE TWO DO NOT MEASURE THE SAME WAY: a Prism location counts BYTES and a `Parser::Source::Range` counts CHARACTERS, so a range built from `start_offset` is correct only until the first multibyte character in the file and silently wrong after it. Prism gives both, and `prism_range` takes the character pair.
11
+ #
12
+ # Prism does not track parentage - a node does not name the node it is written inside - so the walk records the parent of each node as it descends.
13
+
14
+ require "prism"
15
+
16
+ module RuboCop
17
+ module Nathank
18
+ module PrismSource
19
+ # @returns [Prism::ParseResult] the file, parsed once however many times this is called.
20
+ def prism_result
21
+ self.prism_state.fetch(:result)
22
+ end
23
+
24
+ # Every node in the file, outermost first, in source order.
25
+ #
26
+ # @returns [Array(Prism::Node)] the nodes.
27
+ def prism_nodes
28
+ self.prism_state.fetch(:nodes)
29
+ end
30
+
31
+ # @parameter classes [Array(Class)] the Prism node classes wanted.
32
+ # @returns [Array(Prism::Node)] the nodes which are one of them.
33
+ def prism_each(*classes)
34
+ if classes.empty?
35
+ self.prism_nodes
36
+ else
37
+ self.prism_nodes.select{|node| classes.any?{|klass| node.is_a?(klass)}}
38
+ end
39
+ end
40
+
41
+ # @parameter node [Prism::Node] the node whose parent is wanted.
42
+ # @returns [Prism::Node | nil] the node it is written inside, or nil at the root.
43
+ def prism_parent(node)
44
+ self.prism_state.fetch(:parents)[node]
45
+ end
46
+
47
+ # The nodes a node is written inside, innermost first.
48
+ #
49
+ # @returns [Array(Prism::Node)] the ancestors.
50
+ def prism_ancestors(node)
51
+ [].tap do |found|
52
+ current = self.prism_parent(node)
53
+
54
+ while current
55
+ found << current
56
+ current = self.prism_parent(current)
57
+ end
58
+ end
59
+ end
60
+
61
+ # @returns [Array(Prism::Node)] the nodes written directly inside the given node.
62
+ def prism_children(node)
63
+ node.compact_child_nodes
64
+ end
65
+
66
+ # Everything written inside the given node, at any depth, outermost first.
67
+ #
68
+ # @parameter classes [Array(Class)] the Prism node classes wanted, or none for all of them.
69
+ # @returns [Array(Prism::Node)] the nodes.
70
+ def prism_descendants(node, *classes)
71
+ [].tap do |found|
72
+ pending = node.compact_child_nodes.dup
73
+
74
+ until pending.empty?
75
+ current = pending.shift
76
+
77
+ if classes.empty? or classes.any?{|klass| current.is_a?(klass)}
78
+ found << current
79
+ end
80
+
81
+ pending.concat(current.compact_child_nodes)
82
+ end
83
+ end
84
+ end
85
+
86
+ # Whether the given node contains a heredoc, whose content lives below the line it opens on.
87
+ def prism_heredoc?(node)
88
+ [node].concat(self.prism_descendants(node)).any? do |current|
89
+ case current
90
+ when ::Prism::StringNode, ::Prism::InterpolatedStringNode,
91
+ ::Prism::XStringNode, ::Prism::InterpolatedXStringNode
92
+ current.heredoc?
93
+ else
94
+ false
95
+ end
96
+ end
97
+ end
98
+
99
+ # The statements of a body, whether it holds one or many.
100
+ #
101
+ # @parameter body [Prism::StatementsNode | nil] the body of a method, module, or block.
102
+ # @returns [Array(Prism::Node)] its statements, which is empty when there is no body.
103
+ def prism_statements(body)
104
+ if body.nil?
105
+ []
106
+ else
107
+ body.body
108
+ end
109
+ end
110
+
111
+ # A Prism location as the range RuboCop reports an offence against.
112
+ #
113
+ # @parameter location [Prism::Location] where the offence is.
114
+ # @returns [Parser::Source::Range] the same place, measured for RuboCop.
115
+ def prism_range(location)
116
+ # CHARACTER OFFSETS, NOT `start_offset`. A `Parser::Source::Range` indexes the buffer by character and a Prism location counts bytes, so a range built from the byte pair lands further and further past where it should as the multibyte characters above it accumulate. A corrector handed such a range replaces the wrong text and writes a file which no longer parses.
117
+ ::Parser::Source::Range.new(
118
+ processed_source.buffer,
119
+ location.start_character_offset,
120
+ location.end_character_offset,
121
+ )
122
+ end
123
+
124
+ # One range reaching from the start of the first location to the end of the last, for an offence over a run of statements rather than a single node.
125
+ #
126
+ # @returns [Parser::Source::Range] the span.
127
+ def prism_span(first, last)
128
+ ::Parser::Source::Range.new(
129
+ processed_source.buffer,
130
+ first.start_character_offset,
131
+ last.end_character_offset,
132
+ )
133
+ end
134
+
135
+ # @returns [Parser::Source::Range] the whole of the given node.
136
+ def prism_node_range(node)
137
+ self.prism_range(node.location)
138
+ end
139
+
140
+ # The column a node starts at, counted the way a Ruby String is indexed.
141
+ #
142
+ # @returns [Integer] the character column.
143
+ def prism_column(node)
144
+ # `Prism::Location#start_column` counts bytes, so indexing a line from `processed_source.lines` with it cuts in the wrong place once anything above or before it on that line is multibyte.
145
+ node.location.start_character_column
146
+ end
147
+
148
+ # @returns [String] the source of the given node.
149
+ def prism_source(node)
150
+ node.location.slice
151
+ end
152
+
153
+ # @returns [Array(Prism::InlineComment)] every comment in the file, in source order.
154
+ def prism_comments
155
+ self.prism_result.comments
156
+ end
157
+
158
+ # The run of comment lines written directly above a declaration, which is what documents it.
159
+ #
160
+ # @returns [Array(Prism::InlineComment)] the comments, in source order.
161
+ def prism_leading_comments(node)
162
+ self.prism_state.fetch(:leading)[node] ||= self.contiguous_above(node)
163
+ end
164
+
165
+ # Every comment a declaration owns, which is the same run: a comment belongs to whatever it is written above.
166
+ #
167
+ # @returns [Array(Prism::InlineComment)] the comments, in source order.
168
+ def prism_owned_comments(node)
169
+ self.prism_leading_comments(node)
170
+ end
171
+
172
+ private
173
+
174
+ # Everything read from the file, discarded when RuboCop moves to the next one.
175
+ def prism_state
176
+ source = processed_source
177
+
178
+ unless @prism_state and @prism_source.equal?(source)
179
+ @prism_source = source
180
+ @prism_state = self.read(source)
181
+ end
182
+
183
+ @prism_state
184
+ end
185
+
186
+ def read(source)
187
+ result = ::Prism.parse(source.buffer.source)
188
+
189
+ nodes = []
190
+ parents = {}.compare_by_identity
191
+
192
+ self.descend(result.value, nil, nodes, parents)
193
+
194
+ by_line = {}
195
+
196
+ result.comments.each do |comment|
197
+ by_line[comment.location.start_line] = comment
198
+ end
199
+
200
+ {
201
+ result: result,
202
+ nodes: nodes,
203
+ parents: parents,
204
+ by_line: by_line,
205
+ leading: {}.compare_by_identity,
206
+ }
207
+ end
208
+
209
+ # The tree, recorded flat with the parent of each node, since Prism does not say.
210
+ def descend(node, parent, nodes, parents)
211
+ nodes << node
212
+ parents[node] = parent
213
+
214
+ node.compact_child_nodes.each do |child|
215
+ self.descend(child, node, nodes, parents)
216
+ end
217
+ end
218
+
219
+ # The run of comment lines ending on the line above the node, stopping at the first gap.
220
+ def contiguous_above(node)
221
+ by_line = self.prism_state.fetch(:by_line)
222
+ found = []
223
+ line = node.location.start_line - 1
224
+
225
+ while comment = by_line[line]
226
+ found.unshift(comment)
227
+ line -= 1
228
+ end
229
+
230
+ found
231
+ end
232
+ end
233
+ end
234
+ end
@@ -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
+ # Requires `tap` where a method assigns and returns a local.
7
+ #
8
+ # @example
9
+ # def require_existing_column! # NO
10
+ # column = params[:id].to_s
11
+ # unless connection.columns(@table).any? { |c| c.name == column }
12
+ # render json: { error: "unknown column" }, status: :not_found
13
+ # end
14
+ # column
15
+ # end
16
+ #
17
+ # @example
18
+ # def require_existing_column! # YES
19
+ # params[:id].to_s.tap do |column|
20
+ # unless connection.columns(@table).any? { |c| c.name == column }
21
+ # render json: { error: "unknown column" }, status: :not_found
22
+ # end
23
+ # end
24
+ # end
25
+
26
+ require_relative "support/prism_source"
27
+
28
+ module RuboCop
29
+ module Nathank
30
+ class UseTap < ::RuboCop::Cop::Base
31
+ include PrismSource
32
+
33
+ MSG = "Assigning `%<name>s` and returning it at the end is `tap`; " \
34
+ "write `<expression>.tap do |%<name>s| ... end`."
35
+
36
+ # Every way a local can be written to after the opening assignment.
37
+ REASSIGNMENTS = [
38
+ ::Prism::LocalVariableWriteNode,
39
+ ::Prism::LocalVariableOperatorWriteNode,
40
+ ::Prism::LocalVariableOrWriteNode,
41
+ ::Prism::LocalVariableAndWriteNode,
42
+ ::Prism::MultiWriteNode,
43
+ ].freeze
44
+
45
+ def on_new_investigation
46
+ self.prism_each(::Prism::DefNode).each do |node|
47
+ self.check(node.body)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def check(body)
54
+ if self.tap_shaped?(body)
55
+ add_offense(self.prism_node_range(body), message: format(MSG, name: self.assigned_name(body)))
56
+ end
57
+ end
58
+
59
+ # First statement assigns a local; last statement is that same local, read and nothing else.
60
+ def tap_shaped?(body)
61
+ body.is_a?(::Prism::StatementsNode) &&
62
+ body.body.size >= 2 &&
63
+ body.body.first.is_a?(::Prism::LocalVariableWriteNode) &&
64
+ self.returns_assigned?(body) &&
65
+ !self.reassigned?(body)
66
+ end
67
+
68
+ def assigned_name(body)
69
+ body.body.first.name
70
+ end
71
+
72
+ def returns_assigned?(body)
73
+ last = body.body.last
74
+
75
+ last.is_a?(::Prism::LocalVariableReadNode) && last.name == self.assigned_name(body)
76
+ end
77
+
78
+ # `tap` yields the object the receiver evaluated to, so a body that points the name at something else partway through is a different program and must not be flagged.
79
+ def reassigned?(body)
80
+ name = self.assigned_name(body)
81
+
82
+ body.body[1..-2].any? do |statement|
83
+ [statement].concat(self.prism_descendants(statement)).any? do |write|
84
+ REASSIGNMENTS.any?{|klass| write.is_a?(klass)} && self.writes_name?(write, name)
85
+ end
86
+ end
87
+ end
88
+
89
+ def writes_name?(node, name)
90
+ if node.is_a?(::Prism::MultiWriteNode)
91
+ node.lefts.concat(node.rights).any? do |target|
92
+ target.respond_to?(:name) && target.name == name
93
+ end
94
+ else
95
+ node.name == name
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Nathan Kidd.
5
+
6
+ module RuboCop
7
+ module Nathank
8
+ VERSION = "1.0.0"
9
+ end
10
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Nathan Kidd.
5
+
6
+ require "rubocop"
7
+
8
+ require_relative "nathank/version"
9
+ require_relative "nathank/plugin"
10
+ require_relative "nathank/begin_block_assignment"
11
+ require_relative "nathank/comment_structure"
12
+ require_relative "nathank/no_conditional_assignment"
13
+ require_relative "nathank/no_modifier_if"
14
+ require_relative "nathank/no_return"
15
+ require_relative "nathank/single_line_methods_for_events"
16
+ require_relative "nathank/use_tap"
17
+
18
+ # @namespace
19
+ module RuboCop
20
+ # @namespace
21
+ module Nathank
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Nathan Kidd.
5
+
6
+ require_relative "rubocop/nathank"
data/license.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright, 2026, by Nathan Kidd.
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.md ADDED
@@ -0,0 +1,54 @@
1
+ # RuboCop::Nathank
2
+
3
+ House style, as a RuboCop plugin.
4
+
5
+ [![Development Status](https://github.com/n-at-han-k/rubocop-nathank/workflows/Test/badge.svg)](https://github.com/n-at-han-k/rubocop-nathank/actions?workflow=Test)
6
+
7
+ ## Installation
8
+
9
+ bundle add rubocop-nathank
10
+
11
+ ## Usage
12
+
13
+ ``` yaml
14
+ # .rubocop.yml
15
+ plugins:
16
+ - rubocop-nathank
17
+ ```
18
+
19
+ ## Available Cops
20
+
21
+ * `Nathank/BeginBlockAssignment` - no assigning a `begin...end`; the assignment goes inside.
22
+ * `Nathank/NoConditionalAssignment` - no assigning an `if` / `case`; assign inside each branch.
23
+ * `Nathank/NoModifierIf` - no modifier `if` / `unless`, so a guard cannot hide off the right edge.
24
+ * `Nathank/NoReturn` - no `return`; the last expression is the return value.
25
+ * `Nathank/UseTap` - `tap` where a method assigns a local and returns it.
26
+ * `Nathank/SingleLineMethodsForEvents` - a one-expression body uses the endless form.
27
+ * `Nathank/CommentStructure` - how a comment is built: unwrapped paragraphs, a summary no longer
28
+ than the code earns, and annotations on a documented interface.
29
+
30
+ Each one reads the file under inspection with Prism rather than the parser gem tree RuboCop hands a
31
+ cop, so it walks the same tree which runs the program. The defaults, and what `CommentStructure`
32
+ takes, are in [lib/rubocop/nathank/config.yaml](lib/rubocop/nathank/config.yaml).
33
+
34
+ ## Releases
35
+
36
+ Please see [releases.md](releases.md) for all releases.
37
+
38
+ ## Contributing
39
+
40
+ We welcome contributions to this project.
41
+
42
+ 1. Fork it.
43
+ 2. Create your feature branch (`git checkout -b my-new-feature`).
44
+ 3. Commit your changes (`git commit -am 'Add some feature'`).
45
+ 4. Push to the branch (`git push origin my-new-feature`).
46
+ 5. Create new Pull Request.
47
+
48
+ ### Developer Certificate of Origin
49
+
50
+ In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
51
+
52
+ ### Community Guidelines
53
+
54
+ This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
data/releases.md ADDED
@@ -0,0 +1,6 @@
1
+ # Releases
2
+
3
+ ## v0.1.0
4
+
5
+ - Initial release: the house-style cops, moved out of the `cops/` directories which shared them
6
+ by copy. The department is now `Nathank` rather than `Local`.
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubocop-nathank
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan Kidd
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: decode
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: lint_roller
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: prism
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '1.72'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '1.72'
68
+ email:
69
+ - nathankidd@hey.com
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - CHANGELOG.md
75
+ - lib/rubocop-nathank.rb
76
+ - lib/rubocop/nathank.rb
77
+ - lib/rubocop/nathank/begin_block_assignment.rb
78
+ - lib/rubocop/nathank/comment_structure.rb
79
+ - lib/rubocop/nathank/config.yaml
80
+ - lib/rubocop/nathank/no_conditional_assignment.rb
81
+ - lib/rubocop/nathank/no_modifier_if.rb
82
+ - lib/rubocop/nathank/no_return.rb
83
+ - lib/rubocop/nathank/plugin.rb
84
+ - lib/rubocop/nathank/single_line_methods_for_events.rb
85
+ - lib/rubocop/nathank/support/prism_source.rb
86
+ - lib/rubocop/nathank/use_tap.rb
87
+ - lib/rubocop/nathank/version.rb
88
+ - license.md
89
+ - readme.md
90
+ - releases.md
91
+ homepage: https://github.com/n-at-han-k/rubocop-nathank
92
+ licenses:
93
+ - MIT
94
+ metadata:
95
+ source_code_uri: https://github.com/n-at-han-k/rubocop-nathank
96
+ default_lint_roller_plugin: RuboCop::Nathank::Plugin
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '3.3'
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 3.7.2
112
+ specification_version: 4
113
+ summary: House style, as a RuboCop plugin.
114
+ test_files: []