rubocop-tablecop 0.2.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,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Tablecop
6
+ # Aligns consecutive assignment statements on the `=` operator.
7
+ #
8
+ # Handles simple assignments (`=`), compound assignments (`||=`, `&&=`, `+=`, etc.),
9
+ # and constant assignments. Skips lines containing heredocs entirely to avoid
10
+ # infinite loops with Layout/SpaceAroundOperators.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # x = 1
15
+ # foo = 2
16
+ # barbaz = 3
17
+ #
18
+ # # good
19
+ # x = 1
20
+ # foo = 2
21
+ # barbaz = 3
22
+ #
23
+ # @example Compound operators
24
+ # # bad
25
+ # data ||= attrs
26
+ # options = default
27
+ #
28
+ # # good
29
+ # data ||= attrs
30
+ # options = default
31
+ #
32
+ class AlignAssignments < Base
33
+ extend AutoCorrector
34
+
35
+ MSG = "Align assignment with other assignments in group"
36
+
37
+ # Assignment types we handle
38
+ ASSIGNMENT_TYPES = %i[lvasgn ivasgn cvasgn gvasgn casgn op_asgn or_asgn and_asgn].freeze
39
+
40
+ def on_new_investigation
41
+ return if processed_source.blank?
42
+
43
+ groups = find_assignment_groups
44
+ groups.each { |group| check_group(group) }
45
+ end
46
+
47
+ private
48
+
49
+ def find_assignment_groups
50
+ assignments = find_assignments
51
+ return [] if assignments.empty?
52
+
53
+ groups = []
54
+ current_group = [assignments.first]
55
+
56
+ assignments.each_cons(2) do |prev, curr|
57
+ if consecutive_lines?(prev, curr) && same_indent?(prev, curr)
58
+ current_group << curr
59
+ else
60
+ groups << current_group if current_group.size > 1
61
+ current_group = [curr]
62
+ end
63
+ end
64
+
65
+ groups << current_group if current_group.size > 1
66
+ groups
67
+ end
68
+
69
+ def find_assignments
70
+ return [] unless processed_source.ast
71
+
72
+ assignments = []
73
+
74
+ processed_source.ast.each_node(*ASSIGNMENT_TYPES) do |node|
75
+ # Skip if this assignment contains a heredoc
76
+ next if contains_heredoc?(node)
77
+
78
+ # Skip multi-assignment (a, b = ...)
79
+ # These have parent :mlhs (multiple left-hand side)
80
+ next if node.parent&.type == :masgn || node.parent&.type == :mlhs
81
+
82
+ # Skip inner lvasgn/ivasgn that are part of op_asgn/or_asgn/and_asgn
83
+ # (these compound assignments have the simple asgn as first child)
84
+ next if node.parent&.type&.to_s&.end_with?("_asgn") &&
85
+ %i[lvasgn ivasgn cvasgn gvasgn].include?(node.type)
86
+
87
+ # Skip array/hash element assignment (hash[:key] = val)
88
+ # These are actually send nodes with :[]= method
89
+ next if node.type == :send && node.method_name == :[]=
90
+
91
+ # Only include if it's on a single line
92
+ next unless node.single_line?
93
+
94
+ # Skip assignments inside blocks - they shouldn't align with
95
+ # assignments outside the block
96
+ next if inside_block?(node)
97
+
98
+ assignments << node
99
+ end
100
+
101
+ assignments.sort_by(&:first_line)
102
+ end
103
+
104
+ def consecutive_lines?(prev_node, curr_node)
105
+ curr_node.first_line == prev_node.last_line + 1
106
+ end
107
+
108
+ def same_indent?(node1, node2)
109
+ indent(node1) == indent(node2)
110
+ end
111
+
112
+ def indent(node)
113
+ processed_source.lines[node.first_line - 1] =~ /\S/
114
+ end
115
+
116
+ def check_group(group)
117
+ eq_cols = group.map { |node| equals_column(node) }
118
+ max_eq_col = eq_cols.max
119
+
120
+ return unless can_align_all?(group, eq_cols, max_eq_col)
121
+
122
+ group.each_with_index do |node, idx|
123
+ padding_needed = max_eq_col - eq_cols[idx]
124
+ next if padding_needed <= 0
125
+
126
+ register_offense(node, padding_needed)
127
+ end
128
+ end
129
+
130
+ def equals_column(node)
131
+ case node.type
132
+ when :lvasgn, :ivasgn, :cvasgn, :gvasgn
133
+ # Simple assignment: variable = value
134
+ # The operator loc is not directly available, find it from source
135
+ find_operator_column(node)
136
+ when :casgn
137
+ # Constant assignment: CONST = value
138
+ find_operator_column(node)
139
+ when :op_asgn, :or_asgn, :and_asgn
140
+ # Compound assignment: var += val, var ||= val, var &&= val
141
+ node.loc.operator.column
142
+ else
143
+ find_operator_column(node)
144
+ end
145
+ end
146
+
147
+ def find_operator_column(node)
148
+ # For simple assignments, find the = in the source
149
+ line = processed_source.lines[node.first_line - 1]
150
+
151
+ # Find the first = that's part of an assignment (not ==, !=, etc.)
152
+ # Start after the LHS
153
+ lhs_end = lhs_end_column(node)
154
+
155
+ # Search for = after LHS
156
+ rest_of_line = line[lhs_end..]
157
+ eq_match = rest_of_line&.match(/\s*(=)(?!=)/)
158
+
159
+ if eq_match
160
+ lhs_end + eq_match.begin(1)
161
+ else
162
+ # Fallback: use the LHS end position
163
+ lhs_end
164
+ end
165
+ end
166
+
167
+ def lhs_end_column(node)
168
+ case node.type
169
+ when :lvasgn
170
+ node.loc.name.end_pos - processed_source.buffer.line_range(node.first_line).begin_pos
171
+ when :ivasgn, :cvasgn, :gvasgn
172
+ node.loc.name.end_pos - processed_source.buffer.line_range(node.first_line).begin_pos
173
+ when :casgn
174
+ node.loc.name.end_pos - processed_source.buffer.line_range(node.first_line).begin_pos
175
+ else
176
+ 0
177
+ end
178
+ end
179
+
180
+ def padding_insert_position(node)
181
+ case node.type
182
+ when :op_asgn, :or_asgn, :and_asgn
183
+ node.loc.operator.begin_pos
184
+ else
185
+ # For simple assignments, insert before the =
186
+ line = processed_source.lines[node.first_line - 1]
187
+ line_start = processed_source.buffer.line_range(node.first_line).begin_pos
188
+ lhs_end = lhs_end_column(node)
189
+
190
+ rest_of_line = line[lhs_end..]
191
+ eq_match = rest_of_line&.match(/\s*(=)(?!=)/)
192
+
193
+ if eq_match
194
+ line_start + lhs_end + eq_match.begin(1)
195
+ else
196
+ line_start + lhs_end
197
+ end
198
+ end
199
+ end
200
+
201
+ def can_align_all?(group, eq_cols, max_eq_col)
202
+ group.each_with_index.all? do |node, idx|
203
+ padding = max_eq_col - eq_cols[idx]
204
+ line_length(node) + padding <= max_line_length
205
+ end
206
+ end
207
+
208
+ def line_length(node)
209
+ processed_source.lines[node.first_line - 1].length
210
+ end
211
+
212
+ def contains_heredoc?(node)
213
+ return false unless node
214
+
215
+ node.each_descendant(:str, :dstr, :xstr).any? do |str_node|
216
+ str_node.heredoc?
217
+ end
218
+ end
219
+
220
+ def inside_block?(node)
221
+ node.each_ancestor(:block, :numblock).any?
222
+ end
223
+
224
+ def register_offense(node, padding_needed)
225
+ add_offense(node) do |corrector|
226
+ pos = padding_insert_position(node)
227
+ corrector.insert_before(
228
+ Parser::Source::Range.new(processed_source.buffer, pos, pos),
229
+ " " * padding_needed
230
+ )
231
+ end
232
+ end
233
+
234
+ def max_line_length
235
+ config.for_cop("Layout/LineLength")["Max"] || 120
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Tablecop
6
+ # Aligns contiguous single-line method definitions so their bodies start
7
+ # at the same column. Aligns on `=` for endless methods; traditional
8
+ # one-liners align as if they had an invisible `=`.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # def foo = 1
13
+ # def barbaz = 2
14
+ #
15
+ # # good
16
+ # def foo = 1
17
+ # def barbaz = 2
18
+ #
19
+ # @example Mixed endless and traditional
20
+ # # bad
21
+ # def foo = 1
22
+ # def bar() 2 end
23
+ #
24
+ # # good
25
+ # def foo = 1
26
+ # def bar() 2 end
27
+ #
28
+ class AlignMethods < Base
29
+ extend AutoCorrector
30
+
31
+ MSG = "Align method body with other methods in group"
32
+
33
+ def on_new_investigation
34
+ return if processed_source.blank?
35
+
36
+ groups = find_method_groups
37
+ groups.each { |group| check_group(group) }
38
+ end
39
+
40
+ private
41
+
42
+ def find_method_groups
43
+ methods = single_line_methods
44
+ return [] if methods.empty?
45
+
46
+ groups = []
47
+ current_group = [methods.first]
48
+
49
+ methods.each_cons(2) do |prev, curr|
50
+ if consecutive_lines?(prev, curr) && same_indent?(prev, curr)
51
+ current_group << curr
52
+ else
53
+ groups << current_group if current_group.size > 1
54
+ current_group = [curr]
55
+ end
56
+ end
57
+
58
+ groups << current_group if current_group.size > 1
59
+ groups
60
+ end
61
+
62
+ def single_line_methods
63
+ return [] unless processed_source.ast
64
+
65
+ processed_source.ast.each_descendant(:def).select(&:single_line?).sort_by(&:first_line)
66
+ end
67
+
68
+ def consecutive_lines?(prev_node, curr_node)
69
+ curr_node.first_line == prev_node.last_line + 1
70
+ end
71
+
72
+ def same_indent?(node1, node2)
73
+ node1.loc.keyword.column == node2.loc.keyword.column
74
+ end
75
+
76
+ def check_group(group)
77
+ # Calculate the column of `=` (or where `=` would be) for each method
78
+ eq_cols = group.map { |node| equals_column(node) }
79
+ max_eq_col = eq_cols.max
80
+
81
+ # Check if alignment would exceed line length for any method
82
+ return unless can_align_all?(group, eq_cols, max_eq_col)
83
+
84
+ group.each_with_index do |node, idx|
85
+ padding_needed = max_eq_col - eq_cols[idx]
86
+ next if padding_needed <= 0
87
+
88
+ register_offense(node, padding_needed)
89
+ end
90
+ end
91
+
92
+ # Returns the column where `=` is (endless) or would be (traditional)
93
+ def equals_column(node)
94
+ if node.endless?
95
+ node.loc.assignment.column
96
+ else
97
+ # For traditional: body column - 2 (pretend there's ` = ` before body)
98
+ # Actually, we want to align bodies, so use body column directly
99
+ # but we insert padding before body, effectively making ` = ` align
100
+ node.body.loc.expression.column - 2
101
+ end
102
+ end
103
+
104
+ # Position to insert padding
105
+ def padding_insert_position(node)
106
+ if node.endless?
107
+ node.loc.assignment.begin_pos
108
+ else
109
+ node.body.loc.expression.begin_pos
110
+ end
111
+ end
112
+
113
+ def can_align_all?(group, eq_cols, max_eq_col)
114
+ group.each_with_index.all? do |node, idx|
115
+ padding = max_eq_col - eq_cols[idx]
116
+ line_length(node) + padding <= max_line_length
117
+ end
118
+ end
119
+
120
+ def line_length(node)
121
+ processed_source.lines[node.first_line - 1].length
122
+ end
123
+
124
+ def register_offense(node, padding_needed)
125
+ add_offense(node) do |corrector|
126
+ pos = padding_insert_position(node)
127
+ corrector.insert_before(
128
+ Parser::Source::Range.new(processed_source.buffer, pos, pos),
129
+ " " * padding_needed
130
+ )
131
+ end
132
+ end
133
+
134
+ def max_line_length
135
+ config.for_cop("Layout/LineLength")["Max"] || 120
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Tablecop
6
+ # Checks for multi-line `when` clauses that could be condensed to a single
7
+ # line using the `then` keyword, and aligns `then` keywords across siblings.
8
+ #
9
+ # This cop encourages a table-like, vertically-aligned style for case
10
+ # statements where each when clause fits on one line.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # case foo
15
+ # when 1
16
+ # "one"
17
+ # when 200
18
+ # "two hundred"
19
+ # end
20
+ #
21
+ # # good (aligned)
22
+ # case foo
23
+ # when 1 then "one"
24
+ # when 200 then "two hundred"
25
+ # end
26
+ #
27
+ # # also good (body too complex for single line)
28
+ # case foo
29
+ # when 1
30
+ # do_something
31
+ # do_something_else
32
+ # end
33
+ #
34
+ class CondenseWhen < Base
35
+ extend AutoCorrector
36
+
37
+ MSG = "Condense `when` to single line with aligned `then`"
38
+
39
+ def on_case(node)
40
+ when_nodes = node.when_branches
41
+ return if when_nodes.empty?
42
+
43
+ # Analyze which whens can be condensed
44
+ condensable = when_nodes.map { |w| [w, condensable?(w)] }
45
+
46
+ # If none can be condensed, nothing to do
47
+ return unless condensable.any? { |_, can| can }
48
+
49
+ # Calculate alignment width from all condensable whens
50
+ max_condition_width = calculate_max_condition_width(condensable)
51
+
52
+ # Check if alignment would exceed line length for any condensable when
53
+ use_alignment = can_align_all?(condensable, max_condition_width, node)
54
+
55
+ # Register offenses and corrections for each condensable when
56
+ condensable.each do |when_node, can_condense|
57
+ next unless can_condense
58
+ next if when_node.single_line? # Already condensed
59
+
60
+ register_offense(when_node, max_condition_width, use_alignment, node)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def condensable?(node)
67
+ # Must have a body
68
+ return false unless node.body
69
+
70
+ # Body must be a simple single expression (not begin block with multiple statements)
71
+ return false if node.body.begin_type? && node.body.children.size > 1
72
+
73
+ # No heredocs
74
+ return false if contains_heredoc?(node.body)
75
+
76
+ # No multi-line strings
77
+ return false if contains_multiline_string?(node.body)
78
+
79
+ # No control flow that can't be safely condensed
80
+ # (if/unless/case with else, multi-statement blocks, etc.)
81
+ return false if contains_complex_control_flow?(node.body)
82
+
83
+ # No comments between when and body
84
+ return false if comment_between?(node)
85
+
86
+ # Conditions must be on one line
87
+ return false unless conditions_single_line?(node)
88
+
89
+ # Check if condensed form would exceed line length (without alignment)
90
+ single_line = build_single_line(node, 0)
91
+ base_indent = node.loc.keyword.column
92
+ return false if (base_indent + single_line.length) > max_line_length
93
+
94
+ true
95
+ end
96
+
97
+ def calculate_max_condition_width(condensable)
98
+ condensable
99
+ .select { |_, can| can }
100
+ .map { |w, _| condition_width(w) }
101
+ .max || 0
102
+ end
103
+
104
+ def condition_width(when_node)
105
+ when_node.conditions.map(&:source).join(", ").length
106
+ end
107
+
108
+ def can_align_all?(condensable, max_width, case_node)
109
+ base_indent = case_node.loc.keyword.column
110
+
111
+ condensable.all? do |when_node, can_condense|
112
+ next true unless can_condense
113
+ next true if when_node.single_line?
114
+
115
+ # Check if aligned version fits
116
+ single_line = build_single_line(when_node, max_width)
117
+ (base_indent + single_line.length) <= max_line_length
118
+ end
119
+ end
120
+
121
+ def build_single_line(when_node, pad_to_width)
122
+ conditions_source = when_node.conditions.map(&:source).join(", ")
123
+ body_source = when_node.body.source.gsub(/\s*\n\s*/, " ").strip
124
+
125
+ if pad_to_width > 0
126
+ padding = " " * (pad_to_width - conditions_source.length)
127
+ "when #{conditions_source}#{padding} then #{body_source}"
128
+ else
129
+ "when #{conditions_source} then #{body_source}"
130
+ end
131
+ end
132
+
133
+ def register_offense(when_node, max_width, use_alignment, _case_node)
134
+ add_offense(when_node) do |corrector|
135
+ width = use_alignment ? max_width : 0
136
+ single_line = build_single_line(when_node, width)
137
+ corrector.replace(when_node, single_line)
138
+ end
139
+ end
140
+
141
+ def conditions_single_line?(node)
142
+ return true if node.conditions.size == 1
143
+
144
+ first_cond = node.conditions.first
145
+ last_cond = node.conditions.last
146
+ first_cond.first_line == last_cond.last_line
147
+ end
148
+
149
+ def contains_heredoc?(node)
150
+ return false unless node
151
+
152
+ # Check the node itself if it's a string type
153
+ return true if node.respond_to?(:heredoc?) && node.heredoc?
154
+
155
+ node.each_descendant(:str, :dstr, :xstr).any?(&:heredoc?)
156
+ end
157
+
158
+ def contains_multiline_string?(node)
159
+ return false unless node
160
+
161
+ node.each_descendant(:str, :dstr).any? do |str_node|
162
+ next false if str_node.heredoc?
163
+
164
+ str_node.source.include?("\n")
165
+ end
166
+ end
167
+
168
+ def contains_complex_control_flow?(node)
169
+ return false unless node
170
+
171
+ # Multi-line if/unless/case can't be condensed safely
172
+ if node.if_type? || node.case_type?
173
+ return true unless node.single_line?
174
+ end
175
+
176
+ # Check for multi-statement blocks
177
+ node.each_descendant(:block, :numblock) do |block_node|
178
+ return true if block_node.body&.begin_type?
179
+ end
180
+
181
+ # Check descendants for complex control flow
182
+ node.each_descendant(:if, :case) do |control_node|
183
+ return true unless control_node.single_line?
184
+ end
185
+
186
+ false
187
+ end
188
+
189
+ def comment_between?(when_node)
190
+ return false unless when_node.body
191
+
192
+ comments = processed_source.comments
193
+ when_line = when_node.loc.keyword.line
194
+ body_line = when_node.body.first_line
195
+
196
+ comments.any? do |comment|
197
+ comment.loc.line.between?(when_line, body_line - 1)
198
+ end
199
+ end
200
+
201
+ def max_line_length
202
+ config.for_cop("Layout/LineLength")["Max"] || 120
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end