rubocop-style-compact_nesting 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9522330e63c14fdd73354334b2d9dfefd06d49df980caf688fe2bd4275334670
4
+ data.tar.gz: f6b7dec706d52314d6da24f9dc511e3d212e7ec1a3577d2c8ae4714798ad5105
5
+ SHA512:
6
+ metadata.gz: a69a975b93152147a509dabe4b55762126a018e9d08bd06e6b4710da937aaabe4ba81b76bb439ae1f70c07dbf7d785078e2b6262061bdbfe518a03705c5778d7
7
+ data.tar.gz: 1d07834409a49eae53ff01740770b7b6f16d742eb03adaf2fc94593529aa5abbe2c85762b171d7501b663b8e177a48861428be8ce2c7a975c69803a1be39e19a
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-06-01
4
+
5
+ ### Added
6
+ - Initial release.
7
+ - `Style/CompactModuleNesting` cop with safe autocorrect:
8
+ - Collapses outer module wrappers around an innermost class into a single
9
+ compact `module A::B` with the class nested separately.
10
+ - Splits `class A::B::C` into `module A::B; class C; end; end`.
11
+ - Collapses pure module-only chains into a single compact
12
+ `module A::B::C` wrapping the body directly.
13
+ - Flags files with more than one top-level module/class.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ramon GR
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,114 @@
1
+ # rubocop-style-compact_nesting
2
+
3
+ A RuboCop plugin that enforces a hybrid module/class nesting style:
4
+
5
+ - All namespace segments are **collapsed onto a single `module` line** using
6
+ `::`.
7
+ - When the innermost definition in a wrapper chain is a `class`, that class
8
+ is **nested separately** inside the compact `module` wrapper.
9
+ - When the chain is entirely modules, every segment is collapsed into a
10
+ single compact `module A::B::C` wrapping the body directly.
11
+
12
+ ## Canonical form
13
+
14
+ ```ruby
15
+ module A::B::C
16
+ class D
17
+ # ...
18
+ end
19
+ end
20
+ ```
21
+
22
+ ## Examples
23
+
24
+ ```ruby
25
+ # bad
26
+ module A
27
+ module B
28
+ class C
29
+ # ...
30
+ end
31
+ end
32
+ end
33
+
34
+ # good
35
+ module A::B
36
+ class C
37
+ # ...
38
+ end
39
+ end
40
+ ```
41
+
42
+ ```ruby
43
+ # bad
44
+ class A::B::C
45
+ # ...
46
+ end
47
+
48
+ # good
49
+ module A::B
50
+ class C
51
+ # ...
52
+ end
53
+ end
54
+ ```
55
+
56
+ ```ruby
57
+ # good (no namespace)
58
+ class Foo
59
+ # ...
60
+ end
61
+ ```
62
+
63
+ ```ruby
64
+ # bad
65
+ module A
66
+ module B
67
+ module C
68
+ end
69
+ end
70
+ end
71
+
72
+ # good (module-only chain collapses to one compact module)
73
+ module A::B::C
74
+ end
75
+ ```
76
+
77
+ ## Installation
78
+
79
+ ```ruby
80
+ # Gemfile
81
+ gem 'rubocop-style-compact_nesting', require: false
82
+ ```
83
+
84
+ ```yaml
85
+ # .rubocop.yml
86
+ plugins:
87
+ - rubocop-style-compact_nesting
88
+
89
+ # This cop conflicts with the built-in Style/ClassAndModuleChildren.
90
+ Style/ClassAndModuleChildren:
91
+ Enabled: false
92
+ ```
93
+
94
+ Requires RuboCop `>= 1.72` and Ruby `>= 3.1`.
95
+
96
+ ## Rules
97
+
98
+ ### `Style/CompactModuleNesting`
99
+
100
+ - Detects chains of wrapper modules whose body is a single nested
101
+ module/class ending in a `class`, and rewrites them to one compact outer
102
+ `module A::B::C` with a separately nested innermost `class D`.
103
+ - Rewrites `class A::B::C` to `module A::B; class C; end; end`.
104
+ - Collapses pure module-only chains into a single compact
105
+ `module A::B::C` wrapping the body directly.
106
+ - Flags (without autocorrect) files that define more than one top-level
107
+ module/class.
108
+ - Ignores bare top-level classes/modules with no namespace.
109
+ - Ignores wrapper modules whose body contains anything besides a single
110
+ nested definition (e.g. constants, methods, or sibling classes).
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,9 @@
1
+ Style/CompactModuleNesting:
2
+ Description: >-
3
+ Enforce a compact outer module namespace with a separately nested
4
+ innermost class or module.
5
+ Enabled: true
6
+ Severity: convention
7
+ VersionAdded: '0.1'
8
+ SafeAutoCorrect: true
9
+ StyleGuide: ~
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Style
6
+ # Enforces a compact outer module namespace with a separately nested
7
+ # innermost class.
8
+ #
9
+ # When the innermost definition in a wrapper chain is a `class`, the
10
+ # outer modules are collapsed into a single compact `module A::B`
11
+ # wrapping the class. When the chain is entirely modules, every
12
+ # segment is collapsed into a single compact `module A::B::C`
13
+ # wrapping the body directly.
14
+ #
15
+ # The canonical form is one `module A::B::C` line wrapping a single
16
+ # nested innermost `class D`:
17
+ #
18
+ # module A::B::C
19
+ # class D
20
+ # end
21
+ # end
22
+ #
23
+ # @example
24
+ # # bad
25
+ # module A
26
+ # module B
27
+ # class C
28
+ # end
29
+ # end
30
+ # end
31
+ #
32
+ # # bad
33
+ # class A::B::C
34
+ # end
35
+ #
36
+ # # good
37
+ # module A::B
38
+ # class C
39
+ # end
40
+ # end
41
+ #
42
+ # # good (no namespace)
43
+ # class Foo
44
+ # end
45
+ class CompactModuleNesting < Base
46
+ extend AutoCorrector
47
+
48
+ MSG_NESTING = 'Use compact outer module nesting: `%<canonical>s`.'
49
+ MSG_MULTIPLE_ROOTS = 'Only one top-level module or class is allowed per file.'
50
+
51
+ def on_module(node)
52
+ handle(node)
53
+ end
54
+
55
+ def on_class(node)
56
+ handle(node)
57
+ end
58
+
59
+ def on_new_investigation
60
+ check_multiple_top_level_definitions
61
+ end
62
+
63
+ private
64
+
65
+ # Process only outermost definitions; nested wrappers are reached via the
66
+ # collected chain.
67
+ def handle(node)
68
+ return if nested_inside_definition?(node)
69
+
70
+ chain = collect_wrapper_chain(node)
71
+ return unless chain
72
+
73
+ segments = chain[:segments]
74
+ innermost = chain[:innermost]
75
+
76
+ if innermost.class_type?
77
+ # Skip when there's no nested innermost AND no class-path to split.
78
+ return if node.equal?(innermost) && !requires_split?(node, segments, innermost)
79
+
80
+ return unless segments.size >= 2
81
+
82
+ outer_segments = segments[0..-2]
83
+ inner_segment = segments.last
84
+ inner_keyword = :class
85
+
86
+ return if canonical?(node, innermost, outer_segments, inner_segment, inner_keyword)
87
+
88
+ register_offense(node, innermost, outer_segments, inner_segment, inner_keyword)
89
+ else
90
+ # Module-only chain: collapse all segments into a single
91
+ # `module A::B::C` wrapping the body directly.
92
+ return if node.equal?(innermost)
93
+ return unless segments.size >= 2
94
+
95
+ register_offense(node, innermost, segments, nil, nil)
96
+ end
97
+ end
98
+
99
+ def nested_inside_definition?(node)
100
+ parent = node.parent
101
+ return false unless parent
102
+
103
+ parent.module_type? || parent.class_type?
104
+ end
105
+
106
+ # Walk a chain of `module`/`class` nodes whose body is a single nested
107
+ # definition, gathering all namespace segments. Returns nil if the
108
+ # outermost is not part of any namespaced structure worth checking.
109
+ #
110
+ # Returns a hash: { segments: [String, ...], innermost: Node }
111
+ def collect_wrapper_chain(node)
112
+ segments = []
113
+ current = node
114
+
115
+ loop do
116
+ const_path = constant_path(current.identifier)
117
+ return nil unless const_path
118
+
119
+ segments.concat(const_path)
120
+
121
+ body = current.body
122
+ if body && (body.module_type? || body.class_type?) && current.module_type?
123
+ # Only modules may act as namespace wrappers in our style; a class
124
+ # cannot wrap another definition this way.
125
+ current = body
126
+ next
127
+ end
128
+
129
+ return { segments: segments, innermost: current }
130
+ end
131
+ end
132
+
133
+ # Splits a const node like `A::B::C` into ['A', 'B', 'C'].
134
+ # Returns nil for `self::Foo`, `cbase`-rooted (`::A::B` is fine), or
135
+ # anything else non-trivial.
136
+ def constant_path(const_node)
137
+ return nil unless const_node&.const_type?
138
+
139
+ parts = []
140
+ current = const_node
141
+
142
+ while current&.const_type?
143
+ parts.unshift(current.short_name.to_s)
144
+ scope = current.namespace
145
+ if scope.nil?
146
+ return parts
147
+ elsif scope.cbase_type?
148
+ # leading `::` — treat as absolute, still valid namespace path
149
+ return parts
150
+ elsif scope.const_type?
151
+ current = scope
152
+ else
153
+ # e.g. `self::Foo`
154
+ return nil
155
+ end
156
+ end
157
+
158
+ parts
159
+ end
160
+
161
+ def requires_split?(node, segments, innermost)
162
+ # `class A::B::C` (single class node with multi-segment path) needs
163
+ # to be split into `module A::B` + `class C`.
164
+ return false unless segments.size >= 2
165
+
166
+ node.equal?(innermost) && node.class_type?
167
+ end
168
+
169
+ def canonical?(outer, innermost, outer_segments, inner_segment, inner_keyword)
170
+ return false unless outer.module_type?
171
+ return false unless innermost.send(:"#{inner_keyword}_type?")
172
+
173
+ outer_path = constant_path(outer.identifier)
174
+ inner_path = constant_path(innermost.identifier)
175
+
176
+ outer_path == outer_segments &&
177
+ inner_path == [inner_segment] &&
178
+ outer.body.equal?(innermost) &&
179
+ outer_segments.any?
180
+ end
181
+
182
+ def register_offense(node, innermost, outer_segments, inner_segment, inner_keyword)
183
+ canonical_source = build_canonical_source(node, innermost, outer_segments, inner_segment, inner_keyword)
184
+ first_line = canonical_source.lines.first.strip
185
+
186
+ add_offense(node, message: format(MSG_NESTING, canonical: first_line)) do |corrector|
187
+ corrector.replace(node, canonical_source)
188
+ end
189
+ end
190
+
191
+ def build_canonical_source(node, innermost, outer_segments, inner_segment, inner_keyword)
192
+ base_indent = ' ' * node.loc.expression.column
193
+ outer_const = outer_segments.join('::')
194
+
195
+ lines = ["module #{outer_const}"]
196
+
197
+ if inner_keyword
198
+ inner_indent = "#{base_indent} "
199
+ body_source = inner_body_source(innermost, inner_indent)
200
+
201
+ lines << "#{inner_indent}#{inner_keyword} #{inner_segment}"
202
+ lines << body_source unless body_source.empty?
203
+ lines << "#{inner_indent}end"
204
+ else
205
+ body_source = inner_body_source(innermost, base_indent)
206
+ lines << body_source unless body_source.empty?
207
+ end
208
+
209
+ lines << "#{base_indent}end"
210
+
211
+ # First line already sits at `base_indent` in the source we're
212
+ # replacing; subsequent lines need explicit indentation, which we've
213
+ # baked in above. Just join.
214
+ lines.join("\n")
215
+ end
216
+
217
+ # Re-indents the body of the innermost definition so it sits two spaces
218
+ # deeper than the new inner keyword line. Returns "" if there is no
219
+ # body.
220
+ def inner_body_source(innermost, inner_indent)
221
+ body = innermost.body
222
+ return '' unless body
223
+
224
+ body_indent = "#{inner_indent} "
225
+ original = body.source
226
+ original_col = body.loc.expression.column
227
+ shift = body_indent.length - original_col
228
+
229
+ original.lines.map.with_index do |line, idx|
230
+ if idx.zero?
231
+ "#{body_indent}#{line.chomp}"
232
+ elsif line.strip.empty?
233
+ line.chomp
234
+ elsif shift.positive?
235
+ "#{' ' * shift}#{line.chomp}"
236
+ elsif shift.negative?
237
+ line.chomp.sub(/\A {0,#{-shift}}/, '')
238
+ else
239
+ line.chomp
240
+ end
241
+ end.join("\n")
242
+ end
243
+
244
+ def check_multiple_top_level_definitions
245
+ root = processed_source.ast
246
+ return unless root
247
+
248
+ top_defs =
249
+ if root.begin_type?
250
+ root.children.select { |c| c.respond_to?(:type) && (c.class_type? || c.module_type?) }
251
+ elsif root.class_type? || root.module_type?
252
+ [root]
253
+ else
254
+ []
255
+ end
256
+
257
+ return if top_defs.size <= 1
258
+
259
+ top_defs.drop(1).each do |extra|
260
+ add_offense(extra, message: MSG_MULTIPLE_ROOTS)
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../cop/style/compact_module_nesting'
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lint_roller'
4
+
5
+ module RuboCop
6
+ module Style
7
+ module CompactNesting
8
+ # A RuboCop plugin (LintRoller) that registers the
9
+ # `Style/CompactModuleNesting` cop and its default configuration.
10
+ class Plugin < LintRoller::Plugin
11
+ def about
12
+ LintRoller::About.new(
13
+ name: 'rubocop-style-compact_nesting',
14
+ version: VERSION,
15
+ homepage: 'https://github.com/ramongr/rubocop-style-compact_nesting',
16
+ description: 'Enforce compact outer module nesting with a separately ' \
17
+ 'nested innermost class/module.'
18
+ )
19
+ end
20
+
21
+ def supported?(context)
22
+ context.engine == :rubocop
23
+ end
24
+
25
+ def rules(_context)
26
+ project_root = Pathname.new(__dir__).join('..', '..', '..', '..').expand_path
27
+
28
+ LintRoller::Rules.new(
29
+ type: :path,
30
+ config_format: :rubocop,
31
+ value: project_root.join('config', 'default.yml')
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Style
5
+ module CompactNesting
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ require_relative 'rubocop/style/compact_nesting/version'
6
+ require_relative 'rubocop/style/compact_nesting/plugin'
7
+ require_relative 'rubocop/style/compact_nesting/cops'
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubocop-style-compact_nesting
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ramon Rodrigues
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: lint_roller
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rubocop
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.72'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '2.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '1.72'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '2.0'
46
+ description: |
47
+ A RuboCop plugin providing Style/CompactModuleNesting, which enforces a
48
+ hybrid module/class nesting style: all namespace segments collapsed onto a
49
+ single `module A::B::C` line, with the innermost class or module nested
50
+ separately inside it.
51
+ email:
52
+ - cerberus.ramon@gmail.com
53
+ executables: []
54
+ extensions: []
55
+ extra_rdoc_files: []
56
+ files:
57
+ - CHANGELOG.md
58
+ - LICENSE.txt
59
+ - README.md
60
+ - config/default.yml
61
+ - lib/rubocop-style-compact_nesting.rb
62
+ - lib/rubocop/cop/style/compact_module_nesting.rb
63
+ - lib/rubocop/style/compact_nesting/cops.rb
64
+ - lib/rubocop/style/compact_nesting/plugin.rb
65
+ - lib/rubocop/style/compact_nesting/version.rb
66
+ homepage: https://github.com/ramongr/rubocop-style-compact_nesting
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ homepage_uri: https://github.com/ramongr/rubocop-style-compact_nesting
71
+ source_code_uri: https://github.com/ramongr/rubocop-style-compact_nesting
72
+ changelog_uri: https://github.com/ramongr/rubocop-style-compact_nesting/blob/main/CHANGELOG.md
73
+ rubygems_mfa_required: 'true'
74
+ default_lint_roller_plugin: RuboCop::Style::CompactNesting::Plugin
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">"
81
+ - !ruby/object:Gem::Version
82
+ version: 3.1.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 3.6.9
90
+ specification_version: 4
91
+ summary: RuboCop cop enforcing compact outer module nesting with a separately nested
92
+ innermost class/module.
93
+ test_files: []