rubocop-tablecop 0.2.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51eef2c0d5feffe748c3522a755b445f04c3a4b3375ae0aea80fed3abac7e3c6
4
- data.tar.gz: d970c3c03b7cf9e4dc01c3184e276e79aaf7e88651d651f5f7d0b444ba7bad21
3
+ metadata.gz: 4b4eae624919db229c2973bf708c98fbb3493ae26c1442df19daded0b9f8b598
4
+ data.tar.gz: 1c0f7a64dc50fec124730b4d45c2a9b49044e05bad6984a91f45db43c5188e15
5
5
  SHA512:
6
- metadata.gz: 19bed6636d3be68d97d506d660dab28f2470eb2e6f3dc94312809a32ee7909a66d914e3a1995e65278f38e4c8b21c4365cde21839528be48748dad27661bde65
7
- data.tar.gz: e0c333e488389fee8c0cace9506b429e3eff5fed20b503ec2972ecbe5030c2b4791ba91cd5f8518e90338d48c93064a4c28b83e0f1507bf57bd7877d8be60b4d
6
+ metadata.gz: 4cd4fae66c17a7da85a859414f627475dc012902ceb4848a1ef84c9abd1c0236faad97ffdae718c3c9a635d1946d3b6c2a5c1e81734fd84f8916d32ad7d5e13e
7
+ data.tar.gz: 8cee4c0a8e542bebc8c7ea4e8a50fc4bb6c4cac8936f423cb45b7d08c650aced17c30d57484a0df8040834416d4a69b9e32d816e3274affa8ad9c0753a270907
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.1] - 2026-05-18
9
+
10
+ ### Fixed
11
+
12
+ - **`Tablecop/SafeEndlessMethod` no longer corrupts methods whose body
13
+ contains a multi-statement `begin … end` nested as a sub-expression**
14
+ (e.g. the memoization idiom `@memo ||= begin; a; b; end`). The body
15
+ is an `or_asgn`, so the existing direct-body `:begin` guard missed
16
+ it, and it is not a `{}`/`do-end` block so the block guard missed it
17
+ too; autocorrect collapsed `a\n b` into `a b` (no separator) →
18
+ syntactically invalid Ruby. Found when it broke a downstream project
19
+ at parse time. Single-statement `begin x end` still converts.
20
+
21
+ > Note: 0.3.0 was released without a changelog entry; entries resume
22
+ > here rather than reconstruct unrecorded history.
23
+
8
24
  ## [0.2.0] - 2025-12-13
9
25
 
10
26
  ### Changed
data/config/default.yml CHANGED
@@ -21,6 +21,11 @@ Tablecop/AlignMethods:
21
21
  Enabled: true
22
22
  VersionAdded: "0.1.0"
23
23
 
24
+ Tablecop/CondenseIn:
25
+ Description: "Condense multi-line `in` clauses (pattern matching) to single lines when possible."
26
+ Enabled: true
27
+ VersionAdded: "0.1.0"
28
+
24
29
  Tablecop/CondenseWhen:
25
30
  Description: "Condense multi-line `when` clauses to single lines when possible."
26
31
  Enabled: true
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Tablecop
6
+ # Checks for multi-line `in` clauses (pattern matching) that could be
7
+ # condensed to a single line using the `then` keyword, with alignment.
8
+ #
9
+ # This cop encourages a table-like, vertically-aligned style for pattern
10
+ # matching case statements where each in clause fits on one line.
11
+ #
12
+ # When guard clauses are present, three-column alignment is used:
13
+ # patterns align, then `if` keywords align, then `then` keywords align.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # case response
18
+ # in [:ok, body]
19
+ # handle_success(body)
20
+ # in [:error, code]
21
+ # handle_error(code)
22
+ # end
23
+ #
24
+ # # good (aligned)
25
+ # case response
26
+ # in [:ok, body] then handle_success(body)
27
+ # in [:error, code] then handle_error(code)
28
+ # end
29
+ #
30
+ # # good (with guards - three-column alignment)
31
+ # case response
32
+ # in [:ok, body] then handle_success(body)
33
+ # in [:error, code] if retry? then handle_retry(code)
34
+ # in [:error, code] then handle_error(code)
35
+ # end
36
+ #
37
+ # # also good (body too complex for single line)
38
+ # case response
39
+ # in [:ok, body]
40
+ # log_success
41
+ # handle_success(body)
42
+ # end
43
+ #
44
+ class CondenseIn < Base
45
+ extend AutoCorrector
46
+
47
+ MSG = "Condense `in` to single line with aligned `then`"
48
+
49
+ def on_case_match(node)
50
+ in_nodes = node.in_pattern_branches
51
+ return if in_nodes.empty?
52
+
53
+ # Analyze which in-patterns can be condensed
54
+ condensable = in_nodes.map { |n| [n, condensable?(n)] }
55
+
56
+ # If none can be condensed, nothing to do
57
+ return unless condensable.any? { |_, can| can }
58
+
59
+ # Calculate alignment widths from all condensable in-patterns
60
+ max_pattern_width = calculate_max_pattern_width(condensable)
61
+ max_guard_width = calculate_max_guard_width(condensable)
62
+
63
+ # Check if alignment would exceed line length for any condensable in-pattern
64
+ use_alignment = can_align_all?(condensable, max_pattern_width, max_guard_width, node)
65
+
66
+ # Register offenses and corrections for each condensable in-pattern
67
+ condensable.each do |in_node, can_condense|
68
+ next unless can_condense
69
+ next if in_node.single_line? # Already condensed
70
+
71
+ register_offense(in_node, max_pattern_width, max_guard_width, use_alignment, node)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def condensable?(node)
78
+ # Must have a body
79
+ return false unless node.body
80
+
81
+ # Body must be a simple single expression (not begin block with multiple statements)
82
+ return false if node.body.begin_type? && node.body.children.size > 1
83
+
84
+ # No heredocs
85
+ return false if contains_heredoc?(node.body)
86
+
87
+ # No multi-line strings
88
+ return false if contains_multiline_string?(node.body)
89
+
90
+ # No control flow that can't be safely condensed
91
+ return false if contains_complex_control_flow?(node.body)
92
+
93
+ # No comments between in and body
94
+ return false if comment_between?(node)
95
+
96
+ # Pattern must be on one line
97
+ return false unless pattern_single_line?(node)
98
+
99
+ # Guard (if present) must be on one line
100
+ return false unless guard_single_line?(node)
101
+
102
+ # Check if condensed form would exceed line length (without alignment)
103
+ single_line = build_single_line(node, 0, 0)
104
+ base_indent = node.loc.keyword.column
105
+ return false if (base_indent + single_line.length) > max_line_length
106
+
107
+ true
108
+ end
109
+
110
+ def calculate_max_pattern_width(condensable)
111
+ condensable
112
+ .select { |_, can| can }
113
+ .map { |n, _| pattern_width(n) }
114
+ .max || 0
115
+ end
116
+
117
+ def calculate_max_guard_width(condensable)
118
+ condensable
119
+ .select { |_, can| can }
120
+ .map { |n, _| guard_width(n) }
121
+ .max || 0
122
+ end
123
+
124
+ def pattern_width(in_node)
125
+ in_node.pattern.source.length
126
+ end
127
+
128
+ def guard_width(in_node)
129
+ guard = in_node.children[1]
130
+ return 0 unless guard
131
+
132
+ guard.source.length
133
+ end
134
+
135
+ def can_align_all?(condensable, max_pattern_width, max_guard_width, case_node)
136
+ base_indent = case_node.loc.keyword.column
137
+
138
+ condensable.all? do |in_node, can_condense|
139
+ next true unless can_condense
140
+ next true if in_node.single_line?
141
+
142
+ # Check if aligned version fits
143
+ single_line = build_single_line(in_node, max_pattern_width, max_guard_width)
144
+ (base_indent + single_line.length) <= max_line_length
145
+ end
146
+ end
147
+
148
+ def build_single_line(in_node, pad_pattern_to, pad_guard_to)
149
+ pattern_source = in_node.pattern.source
150
+ body_source = in_node.body.source.gsub(/\s*\n\s*/, " ").strip
151
+ guard = in_node.children[1]
152
+
153
+ if pad_pattern_to > 0
154
+ pattern_padding = " " * (pad_pattern_to - pattern_source.length)
155
+
156
+ if guard
157
+ guard_source = guard.source
158
+ guard_padding = " " * (pad_guard_to - guard_source.length)
159
+ "in #{pattern_source}#{pattern_padding} #{guard_source}#{guard_padding} then #{body_source}"
160
+ elsif pad_guard_to > 0
161
+ # No guard on this branch, but other branches have guards
162
+ # Pad to align with where 'then' would be after guards
163
+ total_padding = " " * (pad_pattern_to - pattern_source.length + pad_guard_to + 1)
164
+ "in #{pattern_source}#{total_padding} then #{body_source}"
165
+ else
166
+ "in #{pattern_source}#{pattern_padding} then #{body_source}"
167
+ end
168
+ else
169
+ if guard
170
+ "in #{pattern_source} #{guard.source} then #{body_source}"
171
+ else
172
+ "in #{pattern_source} then #{body_source}"
173
+ end
174
+ end
175
+ end
176
+
177
+ def register_offense(in_node, max_pattern_width, max_guard_width, use_alignment, _case_node)
178
+ add_offense(in_node) do |corrector|
179
+ pattern_width = use_alignment ? max_pattern_width : 0
180
+ guard_width = use_alignment ? max_guard_width : 0
181
+ single_line = build_single_line(in_node, pattern_width, guard_width)
182
+ corrector.replace(in_node, single_line)
183
+ end
184
+ end
185
+
186
+ def pattern_single_line?(node)
187
+ pattern = node.pattern
188
+ pattern.first_line == pattern.last_line
189
+ end
190
+
191
+ def guard_single_line?(node)
192
+ guard = node.children[1]
193
+ return true unless guard
194
+
195
+ guard.first_line == guard.last_line
196
+ end
197
+
198
+ def contains_heredoc?(node)
199
+ return false unless node
200
+
201
+ return true if node.respond_to?(:heredoc?) && node.heredoc?
202
+
203
+ node.each_descendant(:str, :dstr, :xstr).any?(&:heredoc?)
204
+ end
205
+
206
+ def contains_multiline_string?(node)
207
+ return false unless node
208
+
209
+ node.each_descendant(:str, :dstr).any? do |str_node|
210
+ next false if str_node.heredoc?
211
+
212
+ str_node.source.include?("\n")
213
+ end
214
+ end
215
+
216
+ def contains_complex_control_flow?(node)
217
+ return false unless node
218
+
219
+ # Multi-line if/unless/case can't be condensed safely
220
+ if node.if_type? || node.case_type? || node.case_match_type?
221
+ return true unless node.single_line?
222
+ end
223
+
224
+ # Check for multi-statement blocks
225
+ node.each_descendant(:block, :numblock) do |block_node|
226
+ return true if block_node.body&.begin_type?
227
+ end
228
+
229
+ # Check descendants for complex control flow
230
+ node.each_descendant(:if, :case, :case_match) do |control_node|
231
+ return true unless control_node.single_line?
232
+ end
233
+
234
+ false
235
+ end
236
+
237
+ def comment_between?(in_node)
238
+ return false unless in_node.body
239
+
240
+ comments = processed_source.comments
241
+ in_line = in_node.loc.keyword.line
242
+ body_line = in_node.body.first_line
243
+
244
+ comments.any? do |comment|
245
+ comment.loc.line.between?(in_line, body_line - 1)
246
+ end
247
+ end
248
+
249
+ def max_line_length
250
+ config.for_cop("Layout/LineLength")["Max"] || 120
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -66,6 +66,16 @@ module RuboCop
66
66
  # Body must be single expression (not begin block with multiple children)
67
67
  return false if node.body.begin_type?
68
68
 
69
+ # ...and must not CONTAIN a multi-statement sequence anywhere
70
+ # (e.g. `@memo ||= begin; a; b; end` — body is an or_asgn, so
71
+ # the check above misses it, and it is not a {}/do-end block so
72
+ # contains_multi_statement_block? misses it too). Collapsing its
73
+ # newlines to spaces would jam `a b` with no separator → invalid
74
+ # Ruby. This was a real corruption: rubocop-tablecop#<safe-
75
+ # endless> turned a valid memoized method into an unparseable
76
+ # one in a downstream project.
77
+ return false if contains_multi_statement_sequence?(node.body)
78
+
69
79
  # No heredocs
70
80
  return false if contains_heredoc?(node.body)
71
81
 
@@ -151,6 +161,19 @@ module RuboCop
151
161
  false
152
162
  end
153
163
 
164
+ # True if `body` is, or contains anywhere, a statement sequence
165
+ # with more than one statement. Two AST shapes (verified, not
166
+ # assumed): an implicit multi-statement body is a `:begin`
167
+ # sequence; an explicit `begin … end` is a `:kwbegin` whose
168
+ # children ARE the statements (no inner `:begin`). Either with
169
+ # >1 child is unsafe to space-join. Single-statement `begin x
170
+ # end` is a 1-child :kwbegin and stays convertible.
171
+ def contains_multi_statement_sequence?(body)
172
+ return false unless body
173
+
174
+ body.each_node(:begin, :kwbegin).any? { |seq| seq.children.size > 1 }
175
+ end
176
+
154
177
  def contains_complex_control_flow?(node)
155
178
  return false unless node
156
179
 
@@ -2,5 +2,6 @@
2
2
 
3
3
  require_relative "tablecop/align_assignments"
4
4
  require_relative "tablecop/align_methods"
5
+ require_relative "tablecop/condense_in"
5
6
  require_relative "tablecop/condense_when"
6
7
  require_relative "tablecop/safe_endless_method"
@@ -4,7 +4,7 @@ module RuboCop
4
4
  module Tablecop
5
5
  # Version information for rubocop-tablecop.
6
6
  module Version
7
- STRING = "0.2.0"
7
+ STRING = "0.3.1"
8
8
 
9
9
  def self.document_version
10
10
  STRING.match('\d+\.\d+').to_s
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-tablecop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joseph Wecker
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-12-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rubocop
@@ -53,6 +52,7 @@ files:
53
52
  - lib/rubocop-tablecop.rb
54
53
  - lib/rubocop/cop/tablecop/align_assignments.rb
55
54
  - lib/rubocop/cop/tablecop/align_methods.rb
55
+ - lib/rubocop/cop/tablecop/condense_in.rb
56
56
  - lib/rubocop/cop/tablecop/condense_when.rb
57
57
  - lib/rubocop/cop/tablecop/safe_endless_method.rb
58
58
  - lib/rubocop/cop/tablecop_cops.rb
@@ -67,7 +67,6 @@ metadata:
67
67
  changelog_uri: https://github.com/v2-io/rubocop-tablecop/blob/main/CHANGELOG.md
68
68
  rubygems_mfa_required: 'true'
69
69
  default_lint_roller_plugin: RuboCop::Tablecop::Plugin
70
- post_install_message:
71
70
  rdoc_options: []
72
71
  require_paths:
73
72
  - lib
@@ -82,8 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
82
81
  - !ruby/object:Gem::Version
83
82
  version: '0'
84
83
  requirements: []
85
- rubygems_version: 3.5.22
86
- signing_key:
84
+ rubygems_version: 4.0.3
87
85
  specification_version: 4
88
86
  summary: RuboCop extension for table-like, condensed Ruby formatting
89
87
  test_files: []