gl_rubocop 0.2.19 → 0.2.20

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: bd4eb564bfaea4ae8ab15506ff2196070f55223989f1ac9504f281913a55dd6c
4
- data.tar.gz: 931bb25c4f11632bacfada61d5cf99306dbfddf1d4ba056f9ee773f86a876e6c
3
+ metadata.gz: e5cfeeb9c783874509854ab8f98e6c8f1d17284f81e16193666d99e49b2a7d0b
4
+ data.tar.gz: 295a40eae88db48e09a7a03026e89744f511d57748e0591565f0767e3ab80257
5
5
  SHA512:
6
- metadata.gz: 1db06adef9f18338c30d02177c993ef7c7978f4563683ac821c933f824170476f0088b90cc29ce87629d6b0d5bf76e335cacb8662d25a6583de074e5f92de18b
7
- data.tar.gz: d2631001ed66f5f91ede31f60387f07edf95bb297cd999e2b2528b29b79c215c75bfa0c79771804505b25b3ccfc457c54b8a42f35cc615f5af9d621e8769b86e
6
+ metadata.gz: 8344a4c809b323cdcc4c4a7d15441aa08346a8a6964237d4efc1e9fc9895293e098960d60268e74274720cc5f56c0472aea24b30c75abc8bbcad93b0b48e79f7
7
+ data.tar.gz: 496e86ca9704c2329455f7f001dbd8fe89405dda81b23a9b6a0067b6d0760f9b49d202e4db532d97626673508ab8814a53f0d0dce5b0a6dd2fe4ece5ca85d930
data/default.yml CHANGED
@@ -15,6 +15,7 @@ require:
15
15
  - ./lib/gl_rubocop/gl_cops/rails_cache.rb
16
16
  - ./lib/gl_rubocop/gl_cops/sidekiq_inherits_from_sidekiq_job.rb
17
17
  - ./lib/gl_rubocop/gl_cops/unique_identifier.rb
18
+ - ./lib/gl_rubocop/gl_cops/tailwind_no_contradicting_class_name.rb
18
19
 
19
20
  AllCops:
20
21
  SuggestExtensions: false
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/haml_content_helper'
4
+ require_relative '../helpers/erb_content_helper'
5
+
6
+ module GLRubocop
7
+ module GLCops
8
+ # Cop to detect contradicting Tailwind CSS class names
9
+ #
10
+ # @example
11
+ # # bad
12
+ # %div{ class: "tw:w-1 tw:w-2" }
13
+ # .tw:h-5.tw:h-6
14
+ # %button.tw:w-1.tw:w-2
15
+ #
16
+ #
17
+ # # good
18
+ # %div{ class: "tw:w-1 tw:h-2" }
19
+ # tw:h-5
20
+ # %button.tw:m-4.tw:p-8
21
+
22
+ # rubocop:disable Metrics/ClassLength
23
+ class TailwindNoContradictingClassName < RuboCop::Cop::Cop
24
+ include GLRubocop::HamlContentHelper
25
+ include GLRubocop::ErbContentHelper
26
+
27
+ MSG =
28
+ 'Contradicting Tailwind CSS classes found: %<classes>s both affect the same CSS property'
29
+ GIVELIVELY_TAILWIND_CLASS_PREFIX = 'tw:'
30
+
31
+ # Tailwind CSS property groups that should not contradict
32
+ CONTRADICTION_GROUPS = {
33
+ width: %w[w],
34
+ height: %w[h],
35
+ max_width: %w[max-w],
36
+ max_height: %w[max-h],
37
+ min_width: %w[min-w],
38
+ min_height: %w[min-h],
39
+ margin_top: %w[m my mt],
40
+ margin_right: %w[m mx mr],
41
+ margin_bottom: %w[m my mb],
42
+ margin_left: %w[m mx ml],
43
+ padding_top: %w[p py pt],
44
+ padding_right: %w[p px pr],
45
+ padding_bottom: %w[p py pb],
46
+ padding_left: %w[p px pl],
47
+ display: %w[block hidden flex inline inline-block inline-flex grid inline-grid table],
48
+ position: %w[static relative absolute fixed sticky],
49
+ text_align: %w[text-left text-center text-right text-justify],
50
+ flex_direction: %w[flex-row flex-row-reverse flex-col flex-col-reverse],
51
+ flex_wrap: %w[flex-nowrap flex-wrap flex-wrap-reverse],
52
+ justify_content: %w[
53
+ justify-start justify-end justify-center justify-between justify-around justify-evenly
54
+ ],
55
+ align_items: %w[items-start items-end items-center items-baseline items-stretch],
56
+ place_content: %w[
57
+ place-content-center place-content-start place-content-end place-content-between
58
+ place-content-around place-content-evenly
59
+ ],
60
+ place_items: %w[
61
+ place-items-start place-items-end place-items-center place-items-baseline
62
+ place-items-stretch
63
+ ],
64
+ place_self: %w[
65
+ place-self-auto place-self-start place-self-end place-self-center place-self-stretch
66
+ ],
67
+ align_content: %w[
68
+ content-center content-start content-end content-between content-around content-evenly
69
+ ],
70
+ align_self: %w[
71
+ self-auto self-start self-end self-center self-stretch self-baseline
72
+ ],
73
+ justify_items: %w[
74
+ justify-items-start justify-items-end justify-items-center justify-items-stretch
75
+ ],
76
+ justify_self: %w[
77
+ justify-self-auto justify-self-start justify-self-end justify-self-center
78
+ justify-self-stretch
79
+ ],
80
+ font_size: %w[
81
+ text-xs text-sm text-base text-lg text-xl text-2xl text-3xl text-4xl text-5xl text-6xl
82
+ ],
83
+ font_weight: %w[
84
+ font-thin font-extralight font-light font-normal font-medium font-semibold font-bold
85
+ font-extrabold font-black
86
+ ],
87
+ font_style: %w[italic not-italic],
88
+ letter_spacing: %w[
89
+ tracking-tighter tracking-tight tracking-normal tracking-wide tracking-wider
90
+ tracking-widest
91
+ ],
92
+ line_height: %w[
93
+ leading-none leading-tight leading-snug leading-normal leading-relaxed leading-loose
94
+ ],
95
+ text_decoration_line: %w[underline line-through no-underline],
96
+ text_transform: %w[uppercase lowercase capitalize normal-case],
97
+ text_decoration_style: %w[
98
+ decoration-solid decoration-dashed decoration-dotted decoration-double decoration-wavy
99
+ ],
100
+ text_wrap: %w[break-normal break-words break-all],
101
+ vertical_align: %w[
102
+ align-baseline align-top align-middle align-bottom align-text-top align-text-bottom
103
+ ],
104
+ text_overflow: %w[truncate overflow-ellipsis overflow-clip],
105
+ overflow: %w[
106
+ overflow-auto overflow-hidden overflow-visible overflow-scroll
107
+ ],
108
+ visibility: %w[visible invisible collapse],
109
+ border_style: %w[
110
+ border-solid border-dashed border-dotted border-double border-none
111
+ ],
112
+ box_shadow: %w[
113
+ shadow-sm shadow shadow-md shadow-lg shadow-xl shadow-2xl shadow-inner shadow-none
114
+ ]
115
+ # Add more property groups as needed, currently we have chosen to omit color-related properties and border-radius classes
116
+ # to reduce false positives in common use cases.
117
+ }.freeze
118
+
119
+ BREAKPOINT_ORDER = %w[sm md lg xl 2xl].freeze
120
+
121
+ def on_send(node)
122
+ return unless render_method?(node)
123
+
124
+ if haml_file?
125
+ haml_content = read_haml_file
126
+ return unless haml_content
127
+
128
+ check_haml_content(haml_content, node)
129
+ elsif erb_file?
130
+ erb_content = read_erb_file
131
+ return unless erb_content
132
+
133
+ check_erb_content(erb_content, node)
134
+ end
135
+ end
136
+
137
+ def on_str(node)
138
+ # Check string literals for Tailwind classes
139
+ check_string_for_tailwind_classes(node)
140
+ end
141
+
142
+ private
143
+
144
+ def render_method?(node)
145
+ node.method_name == :render && node.arguments.any?
146
+ end
147
+
148
+ def check_erb_content(content, node)
149
+ classes = extract_all_erb_classes(content)
150
+ contradicting_classes = find_contradicting_classes(classes)
151
+
152
+ return if contradicting_classes.empty?
153
+
154
+ contradicting_classes.each do |group|
155
+ add_offense(
156
+ node,
157
+ message: format(MSG, classes: group.join(', '))
158
+ )
159
+ end
160
+ end
161
+
162
+ def extract_all_erb_classes(content)
163
+ classes = []
164
+ classes.concat(extract_classes_from_html_attributes(content))
165
+ classes.concat(extract_classes_from_rails_hash(content))
166
+ classes.concat(extract_classes_from_rails_symbol_hash(content))
167
+ classes.select { |class_name| tailwind_class?(class_name) }
168
+ end
169
+
170
+ def extract_classes_from_html_attributes(content)
171
+ # Example: <div class="tw:w-1 tw:w-2"></div>
172
+ content.scan(/class\s*=\s*['"]([^'"]+)['"]/).flat_map { |match| match.first.split(/\s+/) }
173
+ end
174
+
175
+ def extract_classes_from_rails_hash(content)
176
+ # Example: <%= radio_button_tag { class: 'tw:w-1 tw:w-2' } %>
177
+ content.scan(/class:\s*['"]([^'"]+)['"]/).flat_map { |match| match.first.split(/\s+/) }
178
+ end
179
+
180
+ def extract_classes_from_rails_symbol_hash(content)
181
+ # Example: <%= text_field_tag( ..., :class => 'tw:w-1 tw:w-2' ) %>
182
+ content.scan(/:class\s*=>\s*['"]([^'"]+)['"]/).flat_map { |match| match.first.split(/\s+/) }
183
+ end
184
+
185
+ def check_haml_content(content, node)
186
+ classes = extract_all_haml_classes(content)
187
+ contradicting_classes = find_contradicting_classes(classes)
188
+
189
+ return if contradicting_classes.empty?
190
+
191
+ contradicting_classes.each do |group|
192
+ add_offense(
193
+ node,
194
+ message: format(MSG, classes: group.join(', '))
195
+ )
196
+ end
197
+ end
198
+
199
+ def check_string_for_tailwind_classes(node)
200
+ return unless node.str_type?
201
+
202
+ content = node.value
203
+ classes = extract_classes_from_string(content)
204
+ contradicting_classes = find_contradicting_classes(classes)
205
+
206
+ return if contradicting_classes.empty?
207
+
208
+ contradicting_classes.each do |group|
209
+ add_offense(
210
+ node,
211
+ message: format(MSG, classes: group.join(', '))
212
+ )
213
+ end
214
+ end
215
+
216
+ def extract_classes_from_string(content)
217
+ # Split by whitespace and filter for Tailwind classes
218
+ content.split(/\s+/).select { |cls| tailwind_class?(cls) }
219
+ end
220
+
221
+ def extract_all_haml_classes(content)
222
+ classes = []
223
+
224
+ # Extract from HAML class shortcuts (e.g., %div.tw:w-1.tw:w-2)
225
+ content.scan(/^[^#]*%\w+(?:\.[^{\s#]+)+/m) do |match|
226
+ class_shortcuts = match.scan(/\.([^.{\s#]+)/).flatten
227
+ classes.concat(class_shortcuts)
228
+ end
229
+
230
+ # Extract from HAML hash syntax (e.g., %div{ class: 'tw:m-4 tw:m-8' })
231
+ content.scan(/class:\s*['"]([^'"]+)['"]/) do |match|
232
+ class_list = match.first.split(/\s+/)
233
+ classes.concat(class_list)
234
+ end
235
+
236
+ classes.select { |class_name| tailwind_class?(class_name) }
237
+ end
238
+
239
+ def tailwind_class?(class_name)
240
+ class_name.start_with?(GIVELIVELY_TAILWIND_CLASS_PREFIX)
241
+ end
242
+
243
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
244
+ def find_contradicting_classes(classes)
245
+ # Remove the 'tw:' prefix for property matching
246
+ normalized_classes = classes.map { |class_name| class_name.sub(matcher, '') }
247
+
248
+ contradictions = []
249
+
250
+ normalized_classes.each_with_index do |first_class, index|
251
+ first_class_data = extract_class_data(first_class)
252
+ next unless valid_property?(first_class_data[:css_property])
253
+
254
+ classes_to_compare = normalized_classes[(index + 1)..]
255
+
256
+ classes_to_compare.each_with_index do |second_class, j|
257
+ second_class_data = extract_class_data(second_class)
258
+ next unless valid_property?(second_class_data[:css_property])
259
+
260
+ next unless contradiction_found?(first_class_data, second_class_data)
261
+
262
+ original_class = classes[index]
263
+ contradicting_class = classes[index + j + 1]
264
+ contradictions << [original_class, contradicting_class]
265
+ end
266
+ end
267
+
268
+ contradictions
269
+ end
270
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
271
+
272
+ def contradiction_found?(first_class_data, second_class_data)
273
+ breakpoint_ranges_overlap?(first_class_data[:breakpoint_range],
274
+ second_class_data[:breakpoint_range]) &&
275
+ container_queries_overlap?(first_class_data[:container_query],
276
+ second_class_data[:container_query]) &&
277
+ properties_contradict?(first_class_data[:css_property], second_class_data[:css_property])
278
+ end
279
+
280
+ def extract_class_data(class_name)
281
+ {
282
+ css_property: extract_css_property(class_name),
283
+ breakpoint_range: extract_breakpoint_range(class_name),
284
+ container_query: extract_container_query(class_name)
285
+ }
286
+ end
287
+
288
+ # Extracts container query (e.g., @md:) from class name
289
+ def extract_container_query(class_name)
290
+ match = class_name.match(/^@([a-zA-Z0-9_-]+):/)
291
+ match ? match[1] : nil
292
+ end
293
+
294
+ def container_queries_overlap?(first_container, second_container)
295
+ # If both have a container query, they overlap
296
+ return true if first_container && second_container
297
+
298
+ # If neither has a container query, they overlap (global)
299
+ return true if first_container.nil? && second_container.nil?
300
+
301
+ # If only one has a container query, treat as non-overlapping
302
+ false
303
+ end
304
+
305
+ def matcher
306
+ /^#{GIVELIVELY_TAILWIND_CLASS_PREFIX}/o
307
+ end
308
+
309
+ def valid_property?(property)
310
+ property && CONTRADICTION_GROUPS.any? { |_, group| group.include?(property) }
311
+ end
312
+
313
+ # rubocop:disable Metrics/MethodLength
314
+ def extract_css_property(class_name)
315
+ # Remove container query prefix (e.g., @md:)
316
+ class_without_container = class_name.sub(/^@([a-zA-Z0-9_-]+):/, '')
317
+ # Remove breakpoint prefixes (including v4 range syntax and max-only syntax)
318
+ class_without_breakpoint = class_without_container.sub(
319
+ /^(?:(?:sm|md|lg|xl|2xl)(?::max-(?:sm|md|lg|xl|2xl))?:|max-(?:sm|md|lg|xl|2xl):)+/,
320
+ ''
321
+ )
322
+
323
+ patterns = [
324
+ /^(w|h)-/,
325
+ /^(max-w|max-h)-/,
326
+ /^(min-w|min-h)-/,
327
+ /^(m[trblxy]?)-/,
328
+ /^(p[trblxy]?)-/,
329
+ /^(block|hidden|flex|inline|inline-block|inline-flex|grid|inline-grid|table)$/,
330
+ /^(static|relative|absolute|fixed|sticky)$/,
331
+ /^(text-(?:left|center|right|justify))$/,
332
+ /^(flex-(?:row|row-reverse|col|col-reverse))$/,
333
+ /^(justify-(?:start|end|center|between|around|evenly))$/,
334
+ /^(items-(?:start|end|center|baseline|stretch))$/,
335
+ /^(place-content-(?:center|start|end|between|around|evenly))$/,
336
+ /^(place-items-(?:start|end|center|baseline|stretch))$/,
337
+ /^(place-self-(?:auto|start|end|center|stretch))$/,
338
+ /^(content-(?:center|start|end|between|around|evenly))$/,
339
+ /^(self-(?:auto|start|end|center|stretch|baseline))$/,
340
+ /^(justify-items-(?:start|end|center|stretch))$/,
341
+ /^(justify-self-(?:auto|start|end|center|stretch))$/,
342
+ /^(text-(?:xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl))$/,
343
+ /^(font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black))$/,
344
+ /^(italic|not-italic)$/,
345
+ /^(tracking-(?:tighter|tight|normal|wide|wider|widest))$/,
346
+ /^(leading-(?:none|tight|snug|normal|relaxed|loose))$/,
347
+ /^(underline|line-through|no-underline)$/,
348
+ /^(uppercase|lowercase|capitalize|normal-case)$/,
349
+ /^(decoration-(?:solid|dashed|dotted|double|wavy))$/,
350
+ /^(break-(?:normal|words|all))$/,
351
+ /^(align-(?:baseline|top|middle|bottom|text-top|text-bottom))$/,
352
+ /^(truncate|overflow-(?:ellipsis|clip))$/,
353
+ /^(overflow-(?:auto|hidden|visible|scroll))$/,
354
+ /^(visible|invisible|collapse)$/,
355
+ /^(border-(?:solid|dashed|dotted|double|none))$/,
356
+ /^(shadow(?:-(?:sm|md|lg|xl|2xl|inner|none))?)$/
357
+ ]
358
+
359
+ patterns.each do |pattern|
360
+ match = class_without_breakpoint.match(pattern)
361
+ return match[1] if match
362
+ end
363
+
364
+ nil
365
+ end
366
+ # rubocop:enable Metrics/MethodLength
367
+
368
+ def extract_breakpoint_range(class_name)
369
+ # Extract breakpoint range (e.g., 'md', 'lg:max-xl', 'sm:max-md')
370
+ # Returns a hash with :min and :max keys, or nil if no breakpoint
371
+
372
+ # Match Tailwind v4 range syntax: breakpoint:max-breakpoint: or just breakpoint:
373
+ range_match = class_name.match(/^(sm|md|lg|xl|2xl)(?::max-(sm|md|lg|xl|2xl))?:/)
374
+ return nil unless range_match
375
+
376
+ min_breakpoint = range_match[1]
377
+ max_breakpoint = range_match[2]
378
+
379
+ {
380
+ min: min_breakpoint,
381
+ max: max_breakpoint
382
+ }
383
+ end
384
+
385
+ def breakpoint_ranges_overlap?(first_range, second_range)
386
+ # If either range is nil (no breakpoint), they're considered the same (base styles)
387
+ return true if first_range.nil? && second_range.nil?
388
+ return false if first_range.nil? || second_range.nil?
389
+
390
+ # Get numeric indices for comparison
391
+ first_min_index = BREAKPOINT_ORDER.index(first_range[:min])
392
+ first_max_index = max_index(first_range)
393
+
394
+ second_min_index = BREAKPOINT_ORDER.index(second_range[:min])
395
+ second_max_index = max_index(second_range)
396
+
397
+ # Check for overlap: ranges overlap if one starts before the other ends
398
+ !(first_max_index < second_min_index || second_max_index < first_min_index)
399
+ end
400
+
401
+ def max_index(range)
402
+ return BREAKPOINT_ORDER.index(range[:max]) if range[:max]
403
+
404
+ BREAKPOINT_ORDER.length - 1
405
+ end
406
+
407
+ def properties_contradict?(first_prop, second_prop)
408
+ first_prop_group = CONTRADICTION_GROUPS.select do |_, group|
409
+ group.include?(first_prop)
410
+ end.keys
411
+ second_prop_group = CONTRADICTION_GROUPS.select do |_, group|
412
+ group.include?(second_prop)
413
+ end.keys
414
+
415
+ return false unless first_prop_group && second_prop_group
416
+
417
+ # Check if both properties belong to the same contradicting group
418
+ first_prop_group.intersect?(second_prop_group)
419
+ end
420
+ end
421
+ # rubocop:enable Metrics/ClassLength
422
+ end
423
+ end
@@ -0,0 +1,15 @@
1
+ module GLRubocop
2
+ module ErbContentHelper
3
+ def erb_file?
4
+ processed_source.file_path&.end_with?('.erb', '.html.erb')
5
+ end
6
+
7
+ def read_erb_file
8
+ return unless processed_source.file_path
9
+
10
+ File.read(processed_source.file_path)
11
+ rescue StandardError
12
+ nil
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module GLRubocop
2
+ module HamlContentHelper
3
+ def haml_file?
4
+ file_path = processed_source.file_path
5
+ file_path&.end_with?('.html.haml') && File.exist?(file_path)
6
+ end
7
+
8
+ def read_haml_file
9
+ File.read(processed_source.file_path)
10
+ rescue StandardError
11
+ nil
12
+ end
13
+ end
14
+ end
@@ -1,3 +1,3 @@
1
1
  module GLRubocop
2
- VERSION = '0.2.19'.freeze
2
+ VERSION = '0.2.20'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gl_rubocop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.19
4
+ version: 0.2.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Give Lively
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-10-30 00:00:00.000000000 Z
11
+ date: 2025-10-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rubocop
@@ -155,7 +155,10 @@ files:
155
155
  - lib/gl_rubocop/gl_cops/prevent_erb_files.rb
156
156
  - lib/gl_rubocop/gl_cops/rails_cache.rb
157
157
  - lib/gl_rubocop/gl_cops/sidekiq_inherits_from_sidekiq_job.rb
158
+ - lib/gl_rubocop/gl_cops/tailwind_no_contradicting_class_name.rb
158
159
  - lib/gl_rubocop/gl_cops/unique_identifier.rb
160
+ - lib/gl_rubocop/helpers/erb_content_helper.rb
161
+ - lib/gl_rubocop/helpers/haml_content_helper.rb
159
162
  - lib/gl_rubocop/version.rb
160
163
  homepage: https://github.com/givelively/gl_rubocop
161
164
  licenses:
@@ -177,7 +180,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
177
180
  - !ruby/object:Gem::Version
178
181
  version: '0'
179
182
  requirements: []
180
- rubygems_version: 3.4.19
183
+ rubygems_version: 3.3.7
181
184
  signing_key:
182
185
  specification_version: 4
183
186
  summary: A shareable configuration of Give Lively's rubocop rules.