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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +56 -0
- data/config/default.yml +29 -0
- data/lib/rubocop/canon/plugin.rb +31 -0
- data/lib/rubocop/canon/version.rb +7 -0
- data/lib/rubocop/cop/canon/keyword_shorthand.rb +103 -0
- data/lib/rubocop/cop/canon/sort_hash.rb +219 -0
- data/lib/rubocop/cop/canon/sort_keywords.rb +125 -0
- data/lib/rubocop/cop/canon/sort_method_arguments.rb +103 -0
- data/lib/rubocop/cop/canon/sort_method_definition.rb +85 -0
- data/lib/rubocop-canon.rb +10 -0
- metadata +91 -0
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
|
data/config/default.yml
ADDED
|
@@ -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,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: []
|