rubocop-canon 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: ec9b67b96a586164140535f5e47bae25d02443a7f37094b0dd6fe8c2256d7d57
4
+ data.tar.gz: 79bebd07f1aec3b30061849a9c0166640eef0728756dc6eb477d47e17c5a9c69
5
+ SHA512:
6
+ metadata.gz: 49faab8220e3b5c1fe4004566a664c19051848136a628245e7e30da3e5b072fed9b349089681831cd5efac7052e40dd788986e9827ee16569617c26a304b541c
7
+ data.tar.gz: d01f901f2b9748cf4617eb75a633930dd8c9049c1c45bd4c6379cce308ac1678f5fc41facb34a4d5da664b9ea1dbcfb5fbad15232eb90437921cf79f1fdea79b
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 skiftle
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # rubocop-canon
2
+
3
+ Deterministic RuboCop cops that reduce Ruby code to canonical form. Given any input, there is exactly one correct output.
4
+
5
+ ## Cops
6
+
7
+ | Cop | What it does |
8
+ |-----|-------------|
9
+ | `Canon/KeywordShorthand` | `foo(bar: bar)` becomes `foo(bar:)` |
10
+ | `Canon/SortHash` | `{b: 1, a: 2}` becomes `{a: 2, b: 1}` |
11
+ | `Canon/SortKeywords` | `method(z: 1, a: 2)` becomes `method(a: 2, z: 1)` |
12
+ | `Canon/SortMethodArguments` | `attr_reader :z, :a` becomes `attr_reader :a, :z` |
13
+ | `Canon/SortMethodDefinition` | `def foo(z:, a:)` becomes `def foo(a:, z:)` |
14
+
15
+ ## Installation
16
+
17
+ Add to your Gemfile:
18
+
19
+ ```ruby
20
+ gem 'rubocop-canon', require: false
21
+ ```
22
+
23
+ Add to your `.rubocop.yml`:
24
+
25
+ ```yaml
26
+ plugins:
27
+ - rubocop-canon
28
+ ```
29
+
30
+ ## Configuration
31
+
32
+ `Canon/SortHash` and the three sort cops accept:
33
+
34
+ ```yaml
35
+ Canon/SortHash:
36
+ ShorthandsFirst: true # shorthand pairs sort before expanded
37
+ ExcludeMethods: # skip hashes inside these methods
38
+ - enum
39
+
40
+ Canon/SortKeywords:
41
+ ShorthandsFirst: true
42
+ Methods: # only check these methods (required)
43
+ - attribute
44
+ - belongs_to
45
+
46
+ Canon/SortMethodArguments:
47
+ Methods: # only check these methods (required)
48
+ - attr_reader
49
+ - delegate
50
+ ```
51
+
52
+ `Canon/SortKeywords` and `Canon/SortMethodArguments` are disabled by default. They require a `Methods` list to function.
53
+
54
+ ## License
55
+
56
+ MIT
@@ -0,0 +1,29 @@
1
+ Canon/KeywordShorthand:
2
+ Description: 'Use Ruby 3.1+ keyword shorthand where a local variable matches the key name.'
3
+ Enabled: true
4
+ VersionAdded: '0.1'
5
+
6
+ Canon/SortHash:
7
+ Description: 'Sort hash keys alphabetically.'
8
+ Enabled: true
9
+ VersionAdded: '0.1'
10
+ ShorthandsFirst: false
11
+ ExcludeMethods: []
12
+
13
+ Canon/SortKeywords:
14
+ Description: 'Sort keyword arguments in method calls alphabetically.'
15
+ Enabled: false
16
+ VersionAdded: '0.1'
17
+ ShorthandsFirst: false
18
+ Methods: []
19
+
20
+ Canon/SortMethodArguments:
21
+ Description: 'Sort symbol arguments in method calls alphabetically.'
22
+ Enabled: false
23
+ VersionAdded: '0.1'
24
+ Methods: []
25
+
26
+ Canon/SortMethodDefinition:
27
+ Description: 'Sort keyword arguments in method definitions alphabetically.'
28
+ Enabled: true
29
+ VersionAdded: '0.1'
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lint_roller'
4
+
5
+ module RuboCop
6
+ module Canon
7
+ class Plugin < LintRoller::Plugin
8
+ def about
9
+ LintRoller::About.new(
10
+ description: 'Deterministic RuboCop cops for canonical Ruby form.',
11
+ homepage: 'https://github.com/skiftle/rubocop-canon',
12
+ name: 'rubocop-canon',
13
+ version: VERSION,
14
+ )
15
+ end
16
+
17
+ def supported?(context)
18
+ context.engine == :rubocop
19
+ end
20
+
21
+ def rules(_context)
22
+ project_root = Pathname.new(__dir__).join('../../..')
23
+ LintRoller::Rules.new(
24
+ config_format: :rubocop,
25
+ type: :path,
26
+ value: project_root.join('config', 'default.yml'),
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Canon
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Canon
6
+ # Enforces Ruby 3 keyword shorthand (`foo(bar:)`) where a keyword argument
7
+ # uses a local variable with the same name (`foo(bar: bar)`).
8
+ #
9
+ # Safe-by-design: only symbol keys and local variables with matching names
10
+ # are considered. Comments on the same line are ignored to avoid unintended
11
+ # changes.
12
+ #
13
+ # Requires TargetRubyVersion >= 3.1.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # DomainIssueMapper.call(@record, locale_key: locale_key, root_path: root_path)
18
+ # options = { locale_key: locale_key, root_path: root_path }
19
+ #
20
+ # # good
21
+ # DomainIssueMapper.call(@record, locale_key:, root_path:)
22
+ # options = { locale_key:, root_path: }
23
+ #
24
+ class KeywordShorthand < Base
25
+ extend AutoCorrector
26
+
27
+ MSG = 'Use Ruby 3 keyword shorthand `%<name>s:` instead of `%<name>s: %<name>s`.'
28
+
29
+ def on_pair(node)
30
+ return unless shorthand_candidate?(node)
31
+ return if comment_on_line?(node)
32
+
33
+ name = key_name(node)
34
+ add_offense(node, message: format(MSG, name:)) do |corrector|
35
+ corrector.replace(node.source_range, "#{name}:")
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def shorthand_candidate?(node)
42
+ return false unless node.colon?
43
+ return false unless node.key.sym_type?
44
+ return false unless node.value.lvar_type?
45
+ return false unless key_name(node) == value_name(node)
46
+ return false if already_shorthand?(node)
47
+ return false if last_kwarg_before_modifier?(node)
48
+ return false if last_kwarg_before_block?(node)
49
+
50
+ true
51
+ end
52
+
53
+ def last_kwarg_before_modifier?(node)
54
+ hash_node = node.parent
55
+ return false unless hash_node&.hash_type?
56
+
57
+ send_node = hash_node.parent
58
+ return false unless send_node&.send_type? || send_node&.csend_type?
59
+
60
+ parent_of_send = send_node.parent
61
+ return false unless parent_of_send
62
+ return false unless %i[if while until].include?(parent_of_send.type)
63
+ return false unless parent_of_send.loc.respond_to?(:keyword) && parent_of_send.loc.keyword.source != 'elsif'
64
+
65
+ modifier_form = parent_of_send.loc.respond_to?(:end) && parent_of_send.loc.end.nil?
66
+ return false unless modifier_form
67
+
68
+ node == hash_node.pairs.last
69
+ end
70
+
71
+ def last_kwarg_before_block?(node)
72
+ hash_node = node.parent
73
+ return false unless hash_node&.hash_type?
74
+
75
+ send_node = hash_node.parent
76
+ return false unless send_node&.send_type? || send_node&.csend_type?
77
+
78
+ parent_of_send = send_node.parent
79
+ return false unless parent_of_send&.block_type?
80
+
81
+ node == hash_node.pairs.last
82
+ end
83
+
84
+ def already_shorthand?(node)
85
+ node.source.strip.end_with?(':')
86
+ end
87
+
88
+ def key_name(node)
89
+ node.key.value.to_s
90
+ end
91
+
92
+ def value_name(node)
93
+ node.value.children.first.to_s
94
+ end
95
+
96
+ def comment_on_line?(node)
97
+ line = node.loc.line
98
+ processed_source.comments.any? { |comment| comment.loc.line == line }
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Canon
6
+ # Enforces sorted hash literals.
7
+ #
8
+ # Sorts hash keys alphabetically without changing structure.
9
+ # Single-line hashes stay single-line, multiline stay multiline.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # {b: 1, a: 2}
14
+ #
15
+ # # good (sorted)
16
+ # {a: 2, b: 1}
17
+ #
18
+ # # bad (unsorted multiline)
19
+ # {
20
+ # c: 3,
21
+ # a: 1,
22
+ # b: 2,
23
+ # }
24
+ #
25
+ # # good (sorted multiline)
26
+ # {
27
+ # a: 1,
28
+ # b: 2,
29
+ # c: 3,
30
+ # }
31
+ class SortHash < Base
32
+ extend AutoCorrector
33
+
34
+ MSG = 'Sort hash keys alphabetically.'
35
+
36
+ def on_hash(node)
37
+ return unless processable?(node)
38
+
39
+ pairs = node.pairs
40
+ return if pairs.size < 2
41
+
42
+ sorted_pairs = sort_pairs(pairs)
43
+ return if pairs == sorted_pairs
44
+
45
+ add_offense(node) do |corrector|
46
+ next if ancestor_unsorted_hash?(node)
47
+
48
+ if already_multiline?(node)
49
+ if implicit_kwargs?(node)
50
+ corrector.replace(node.loc.expression, rebuild_multiline_implicit(node, sorted_pairs))
51
+ else
52
+ corrector.replace(node.loc.expression, rebuild_multiline(node, sorted_pairs))
53
+ end
54
+ else
55
+ corrector.replace(content_range(node), rebuild_single_line(node, sorted_pairs))
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def processable?(node)
63
+ pairs = node.pairs
64
+ return false if pairs.empty?
65
+ return false unless all_symbol_keys?(pairs)
66
+ return false if kwsplat?(node)
67
+ return false if duplicate_keys?(pairs)
68
+ return false if excluded_method?(node)
69
+
70
+ true
71
+ end
72
+
73
+ def excluded_method?(node)
74
+ parent = node.parent
75
+ return false unless parent&.send_type?
76
+
77
+ excluded_methods.include?(parent.method_name.to_s)
78
+ end
79
+
80
+ def excluded_methods
81
+ cop_config['ExcludeMethods'] || []
82
+ end
83
+
84
+ def all_symbol_keys?(pairs)
85
+ pairs.all? { |pair| pair.key.sym_type? }
86
+ end
87
+
88
+ def kwsplat?(node)
89
+ node.children.any? { |child| child.is_a?(Parser::AST::Node) && child.kwsplat_type? }
90
+ end
91
+
92
+ def duplicate_keys?(pairs)
93
+ keys = pairs.map { |pair| key_name(pair) }
94
+ keys.size != keys.uniq.size
95
+ end
96
+
97
+ def multiline_value?(pairs)
98
+ pairs.any? { |pair| pair.value.loc.first_line != pair.value.loc.last_line }
99
+ end
100
+
101
+ def implicit_kwargs?(node)
102
+ node.loc.begin.nil?
103
+ end
104
+
105
+ def single_line?(node)
106
+ node.loc.expression.first_line == node.loc.expression.last_line
107
+ end
108
+
109
+ def already_multiline?(node)
110
+ !single_line?(node)
111
+ end
112
+
113
+ def key_name(pair)
114
+ pair.key.value.to_s
115
+ end
116
+
117
+ def sort_pairs(pairs)
118
+ if shorthands_first?
119
+ pairs.sort_by { |pair| [shorthand?(pair) ? 0 : 1, key_name(pair)] }
120
+ else
121
+ pairs.sort_by { |pair| key_name(pair) }
122
+ end
123
+ end
124
+
125
+ def shorthands_first?
126
+ cop_config['ShorthandsFirst'] == true
127
+ end
128
+
129
+ def shorthand?(pair)
130
+ pair.source.match?(/\A\w+:\z/)
131
+ end
132
+
133
+ def content_range(node)
134
+ if node.loc.begin
135
+ Parser::Source::Range.new(
136
+ node.loc.expression.source_buffer,
137
+ node.loc.begin.end_pos,
138
+ node.loc.end.begin_pos,
139
+ )
140
+ else
141
+ node.loc.expression
142
+ end
143
+ end
144
+
145
+ def rebuild_single_line(node, sorted_pairs)
146
+ content = sorted_pairs.map(&:source).join(', ')
147
+ return content unless node.loc.begin
148
+
149
+ original_content = content_range(node).source
150
+ has_leading_space = original_content.start_with?(' ')
151
+ has_trailing_space = original_content.end_with?(' ')
152
+
153
+ result = content
154
+ result = " #{result}" if has_leading_space
155
+ result = "#{result} " if has_trailing_space
156
+ result
157
+ end
158
+
159
+ def rebuild_multiline(node, sorted_pairs)
160
+ indent = base_indentation(node)
161
+ pair_indent = pair_indentation(node)
162
+
163
+ lines = ["{\n"]
164
+ sorted_pairs.each_with_index do |pair, index|
165
+ trailing_comma = index < sorted_pairs.size - 1 || trailing_comma?(node)
166
+ lines << "#{pair_indent}#{pair.source}#{trailing_comma ? ',' : ''}\n"
167
+ end
168
+ lines << "#{indent}}"
169
+
170
+ lines.join
171
+ end
172
+
173
+ def rebuild_multiline_implicit(node, sorted_pairs)
174
+ first_pair = node.pairs.first
175
+ indent = ' ' * first_pair.loc.column
176
+
177
+ sorted_pairs.map.with_index do |pair, index|
178
+ if index.zero?
179
+ pair.source
180
+ else
181
+ "#{indent}#{pair.source}"
182
+ end
183
+ end.join(",\n")
184
+ end
185
+
186
+ def base_indentation(node)
187
+ source_line = node.loc.expression.source_buffer.source_line(node.loc.line)
188
+ source_line[/\A\s*/]
189
+ end
190
+
191
+ def pair_indentation(node)
192
+ first_pair = node.pairs.first
193
+ source_line = first_pair.loc.expression.source_buffer.source_line(first_pair.loc.line)
194
+ source_line[/\A\s*/]
195
+ end
196
+
197
+ def trailing_comma?(node)
198
+ last_pair = node.pairs.last
199
+ source_after_last = node.loc.expression.source[last_pair.loc.expression.end_pos - node.loc.expression.begin_pos..]
200
+ source_after_last&.match?(/\A\s*,/)
201
+ end
202
+
203
+ def ancestor_unsorted_hash?(node)
204
+ node.ancestors.any? do |ancestor|
205
+ next false unless ancestor.hash_type?
206
+
207
+ pairs = ancestor.pairs
208
+ next false if pairs.size < 2
209
+ next false unless all_symbol_keys?(pairs)
210
+ next false if kwsplat?(ancestor)
211
+ next false if duplicate_keys?(pairs)
212
+
213
+ sort_pairs(pairs) != pairs
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Canon
6
+ # Enforces sorted keyword arguments in method calls.
7
+ #
8
+ # Sorts keyword arguments alphabetically without changing structure.
9
+ # Single-line calls stay single-line, multiline stay multiline.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # attribute :name, zebra: true, alpha: false
14
+ #
15
+ # # good (sorted)
16
+ # attribute :name, alpha: false, zebra: true
17
+ #
18
+ # # bad (unsorted multiline)
19
+ # attribute :name,
20
+ # zebra: true,
21
+ # alpha: false
22
+ #
23
+ # # good (sorted multiline)
24
+ # attribute :name,
25
+ # alpha: false,
26
+ # zebra: true
27
+ class SortKeywords < Base
28
+ extend AutoCorrector
29
+
30
+ MSG = 'Sort keyword arguments alphabetically.'
31
+
32
+ def on_send(node)
33
+ return unless dsl_method?(node)
34
+ return if node.receiver
35
+
36
+ kwargs = extract_kwargs(node)
37
+ return if kwargs.nil? || kwargs.size < 2
38
+
39
+ sorted_kwargs = sort_pairs(kwargs)
40
+ return if kwargs == sorted_kwargs
41
+
42
+ add_offense(node) do |corrector|
43
+ corrector.replace(kwargs_range(kwargs), rebuild(kwargs, sorted_kwargs))
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def dsl_method?(node)
50
+ methods.include?(node.method_name.to_s)
51
+ end
52
+
53
+ def methods
54
+ cop_config['Methods'] || []
55
+ end
56
+
57
+ def extract_kwargs(node)
58
+ return nil if node.arguments.empty?
59
+
60
+ last_arg = node.arguments.last
61
+ return last_arg.pairs if last_arg.hash_type? && last_arg.pairs.any?
62
+
63
+ nil
64
+ end
65
+
66
+ def key_name(pair)
67
+ pair.key.value.to_s
68
+ end
69
+
70
+ def sort_pairs(pairs)
71
+ if shorthands_first?
72
+ pairs.sort_by { |pair| [shorthand?(pair) ? 0 : 1, key_name(pair)] }
73
+ else
74
+ pairs.sort_by { |pair| key_name(pair) }
75
+ end
76
+ end
77
+
78
+ def shorthands_first?
79
+ cop_config['ShorthandsFirst'] == true
80
+ end
81
+
82
+ def shorthand?(pair)
83
+ pair.source.match?(/\A\w+:\z/)
84
+ end
85
+
86
+ def kwargs_range(kwargs)
87
+ Parser::Source::Range.new(
88
+ kwargs.first.loc.expression.source_buffer,
89
+ kwargs.first.loc.expression.begin_pos,
90
+ kwargs.last.loc.expression.end_pos,
91
+ )
92
+ end
93
+
94
+ def rebuild(original_kwargs, sorted_kwargs)
95
+ if multiline?(original_kwargs)
96
+ rebuild_multiline(original_kwargs, sorted_kwargs)
97
+ else
98
+ rebuild_single_line(sorted_kwargs)
99
+ end
100
+ end
101
+
102
+ def multiline?(kwargs)
103
+ kwargs.first.loc.line != kwargs.last.loc.line
104
+ end
105
+
106
+ def rebuild_single_line(sorted_kwargs)
107
+ sorted_kwargs.map(&:source).join(', ')
108
+ end
109
+
110
+ def rebuild_multiline(original_kwargs, sorted_kwargs)
111
+ first_pair = original_kwargs.first
112
+ indent = ' ' * first_pair.loc.column
113
+
114
+ sorted_kwargs.map.with_index do |pair, index|
115
+ if index.zero?
116
+ pair.source
117
+ else
118
+ "#{indent}#{pair.source}"
119
+ end
120
+ end.join(",\n")
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Canon
6
+ # Enforces sorted symbol arguments in method calls.
7
+ #
8
+ # Applies to methods like attr_reader, attr_accessor, delegate.
9
+ # Sorts symbol arguments alphabetically without changing structure.
10
+ # Single-line calls stay single-line, multiline stay multiline.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # attr_reader :zebra, :alpha
15
+ #
16
+ # # good (sorted)
17
+ # attr_reader :alpha, :zebra
18
+ #
19
+ # # bad (unsorted multiline)
20
+ # attr_reader :zebra,
21
+ # :alpha
22
+ #
23
+ # # good (sorted multiline)
24
+ # attr_reader :alpha,
25
+ # :zebra
26
+ class SortMethodArguments < Base
27
+ extend AutoCorrector
28
+
29
+ MSG = 'Sort symbol arguments alphabetically.'
30
+
31
+ def on_send(node)
32
+ return unless methods.include?(node.method_name.to_s)
33
+ return if node.receiver
34
+
35
+ symbol_args = node.arguments.select(&:sym_type?)
36
+ return unless symbol_args.size > 1
37
+
38
+ sorted_names = symbol_args.map { |arg| arg.value.to_s }.sort
39
+ actual_names = symbol_args.map { |arg| arg.value.to_s }
40
+
41
+ return if actual_names == sorted_names
42
+
43
+ add_offense(node.loc.selector) do |corrector|
44
+ corrector.replace(node.loc.expression, build_replacement(node, sorted_names))
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def methods
51
+ cop_config['Methods'] || []
52
+ end
53
+
54
+ def single_line?(node)
55
+ expr = node.loc.expression
56
+ expr.first_line == expr.last_line
57
+ end
58
+
59
+ def build_replacement(node, sorted_names)
60
+ if single_line?(node)
61
+ build_single_line(node, sorted_names)
62
+ else
63
+ build_multiline(node, sorted_names)
64
+ end
65
+ end
66
+
67
+ def build_single_line(node, sorted_names)
68
+ trailing_kwargs = node.arguments.reject(&:sym_type?)
69
+ symbols = sorted_names.map { |name| ":#{name}" }.join(', ')
70
+
71
+ if trailing_kwargs.empty?
72
+ "#{node.method_name} #{symbols}"
73
+ else
74
+ kwargs = trailing_kwargs.map(&:source).join(', ')
75
+ "#{node.method_name} #{symbols}, #{kwargs}"
76
+ end
77
+ end
78
+
79
+ def build_multiline(node, sorted_names)
80
+ method_name = node.method_name.to_s
81
+ first_line_prefix = "#{method_name} "
82
+ cont_indent = ' ' * (node.loc.column + first_line_prefix.length)
83
+
84
+ lines = sorted_names.map.with_index do |name, index|
85
+ prefix = index.zero? ? first_line_prefix : cont_indent
86
+ comma = index < sorted_names.size - 1 ? ',' : ''
87
+ "#{prefix}:#{name}#{comma}"
88
+ end
89
+
90
+ trailing_kwargs = node.arguments.reject(&:sym_type?)
91
+ if trailing_kwargs.any?
92
+ lines[-1] += ','
93
+ trailing_kwargs.each do |kwarg|
94
+ lines << "#{cont_indent}#{kwarg.source}"
95
+ end
96
+ end
97
+
98
+ lines.join("\n")
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Canon
6
+ # Enforces sorted keyword arguments in method definitions.
7
+ #
8
+ # Only applies to single-line kwargs. Multiline kwargs are ignored
9
+ # to preserve intentional formatting.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # def foo(zebra:, alpha:)
14
+ #
15
+ # # good (sorted)
16
+ # def foo(alpha:, zebra:)
17
+ class SortMethodDefinition < Base
18
+ extend AutoCorrector
19
+
20
+ MSG = 'Sort keyword arguments alphabetically.'
21
+
22
+ def on_def(node)
23
+ check_method_definition(node)
24
+ end
25
+
26
+ def on_defs(node)
27
+ check_method_definition(node)
28
+ end
29
+
30
+ private
31
+
32
+ def check_method_definition(node)
33
+ kwargs = extract_kwargs(node.arguments)
34
+ return if kwargs.size < 2
35
+ return if kwrestarg?(node.arguments)
36
+ return unless single_line?(kwargs)
37
+
38
+ sorted_names = kwargs.map { |arg| arg.name.to_s }.sort
39
+ actual_names = kwargs.map { |arg| arg.name.to_s }
40
+
41
+ return if sorted_names == actual_names
42
+
43
+ add_offense(node.loc.keyword) do |corrector|
44
+ replace_range = kwargs_range(kwargs)
45
+ corrector.replace(replace_range, rebuild_kwargs(node.arguments, sorted_names))
46
+ end
47
+ end
48
+
49
+ def extract_kwargs(args)
50
+ args.select { |arg| arg.kwoptarg_type? || arg.kwarg_type? }
51
+ end
52
+
53
+ def kwrestarg?(args)
54
+ args.any?(&:kwrestarg_type?)
55
+ end
56
+
57
+ def single_line?(items)
58
+ return true if items.empty?
59
+
60
+ items.first.loc.line == items.last.loc.line
61
+ end
62
+
63
+ def kwargs_range(kwargs)
64
+ Parser::Source::Range.new(
65
+ kwargs.first.loc.expression.source_buffer,
66
+ kwargs.first.loc.expression.begin_pos,
67
+ kwargs.last.loc.expression.end_pos,
68
+ )
69
+ end
70
+
71
+ def rebuild_kwargs(args, sorted_names)
72
+ kwargs_hash = {}
73
+
74
+ args.each do |arg|
75
+ next unless arg.kwoptarg_type? || arg.kwarg_type?
76
+
77
+ kwargs_hash[arg.name.to_s] = arg.source
78
+ end
79
+
80
+ sorted_names.map { |name| kwargs_hash[name] }.join(', ')
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+ require_relative 'rubocop/canon/version'
5
+ require_relative 'rubocop/canon/plugin'
6
+ require_relative 'rubocop/cop/canon/keyword_shorthand'
7
+ require_relative 'rubocop/cop/canon/sort_hash'
8
+ require_relative 'rubocop/cop/canon/sort_keywords'
9
+ require_relative 'rubocop/cop/canon/sort_method_arguments'
10
+ require_relative 'rubocop/cop/canon/sort_method_definition'
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubocop-canon
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - skiftle
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: lint_roller
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rubocop
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 1.75.0
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '2.0'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 1.75.0
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ description:
48
+ email:
49
+ executables: []
50
+ extensions: []
51
+ extra_rdoc_files: []
52
+ files:
53
+ - LICENSE.txt
54
+ - README.md
55
+ - config/default.yml
56
+ - lib/rubocop-canon.rb
57
+ - lib/rubocop/canon/plugin.rb
58
+ - lib/rubocop/canon/version.rb
59
+ - lib/rubocop/cop/canon/keyword_shorthand.rb
60
+ - lib/rubocop/cop/canon/sort_hash.rb
61
+ - lib/rubocop/cop/canon/sort_keywords.rb
62
+ - lib/rubocop/cop/canon/sort_method_arguments.rb
63
+ - lib/rubocop/cop/canon/sort_method_definition.rb
64
+ homepage: https://github.com/skiftle/rubocop-canon
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ default_lint_roller_plugin: RuboCop::Canon::Plugin
69
+ homepage_uri: https://github.com/skiftle/rubocop-canon
70
+ rubygems_mfa_required: 'true'
71
+ source_code_uri: https://github.com/skiftle/rubocop-canon
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.2'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.4.1
88
+ signing_key:
89
+ specification_version: 4
90
+ summary: Deterministic RuboCop cops for canonical Ruby form
91
+ test_files: []