rails-css_unused 0.2.0 → 0.2.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: 6be69a5f6aa27dedb1aef9797167bf8b8abed066632fa8fee01fe7fefa54554c
4
- data.tar.gz: c808dfa0ed3b70ad3a03ec158af1ef5d00a7bcf97ff899b6f4613853955c67fd
3
+ metadata.gz: 3e275861ec42e64ade413fc24f51105fcdfbc77691b3b8a421029523e96f8b59
4
+ data.tar.gz: 06da7233e0f634d4feeb02664b24f438537449a20ac2b28845866c60fb73514a
5
5
  SHA512:
6
- metadata.gz: 62d8dff321fb2dddf72d618747b2e22511a438864b0372067c9c222442f1a39f30513a8922d014a663c4dde0955ca44614d3b9506476df78d41ef7a517ed88cb
7
- data.tar.gz: 3e7e72d6cb22f7a6a1138162771cb3906e0b9a1399070131ab660b7a43286f4957faa15dd6d51b14a3981f36779264aa6da38a9ab04cbec1b8c889c189f30db8
6
+ metadata.gz: ec24c67fc3da46c54954d210c4ae1ffef696abba69a32b06c7e4a56d75c9f72c65f32ad0dfea9044248fd8ff31f9adb847381b2d527215668bac1ebcb66c5619
7
+ data.tar.gz: 0023736da3656117d62196f8509b04a1b27622726e10772ccad96690e8cd5210b21305ad2034720dffabba50fa3b9756df73c4abee628b8ce23f92dc46ac1be7
data/CHANGELOG.md CHANGED
@@ -1,30 +1,53 @@
1
1
  # Changelog
2
2
 
3
- ## [0.2.0] - 2026-06-02
3
+ ## [0.2.1] - 2026-06-02
4
4
 
5
- ### 🔧 Fixed
6
- - **Extension noise eliminated** — `.png`, `.css`, `.jpg`, `.js` etc. no longer appear as ghost classes
7
- - **At-rule noise eliminated** `@charset`, `@import`, `@keyframes` tokens no longer pollute the class list
8
- - **`:not()` false positives fixed** `.foo` inside `:not(.foo)` is no longer treated as a new definition
9
- - **Double-scanning removed** — `ViewScanner` and `StylesheetScanner` were each instantiated twice in `print_summary`, wasting time and producing wrong counts
5
+ ### Added
6
+ - **Smart dynamic class variable detection** — the scanner now automatically
7
+ detects CSS class strings assigned to variables whose name ends in
8
+ `_class`, `_classes`, `_style`, or `_css` (e.g. `status_class = "foo-bar"`).
9
+ - **Hyphenated string literal detection** — any quoted string containing a
10
+ hyphen is now treated as a CSS class value. Since Ruby variable names
11
+ cannot contain hyphens, hyphenated quoted strings are unambiguously string
12
+ values, never identifiers. This eliminates false positives from patterns like:
13
+ ```erb
14
+ <% status_class =
15
+ if exam.cancelled?
16
+ ["Cancelled", "status-cancelled"]
17
+ elsif exam.approved?
18
+ ["Approved", "status-approved"]
19
+ end %>
20
+ <span class="status-pill <%= status_class %>">
21
+ ```
22
+ Classes like `status-cancelled`, `status-approved`, `status-requested`
23
+ are now correctly detected as used without needing `ignore_patterns`.
24
+ - Dynamic class var detection also runs in HAML, Slim, and Ruby component files.
10
25
 
11
- ### ✨ Added
12
- - **Source file attribution** `show_source_files: true` shows which stylesheet each ghost comes from
13
- - **CI mode** `fail_on_unused: true` + `rake css_unused:ci` exits with code 1 on any ghost
14
- - **`rake css_unused:report_verbose`** — shows ghost classes with source file inline
15
- - **HAML & Slim support** — proper extraction of `.foo.bar` shorthand selectors
16
- - **Slim template support** — `div.foo` class shorthand
17
- - **Ruby component support** — scans ViewComponent `.rb` files for `class:` attributes
18
- - **Stimulus / JS support** — `classList.add("foo")` calls detected as used classes
19
- - **ERB dynamic class extraction** — `class="<%= cond ? 'a' : 'b' %>"` — static string parts extracted
20
- - **`ignore_patterns`** — regex-based ignore list (e.g. `/\Ajs-/`, `/\Ais-/`)
21
- - **`scan_javascript_for_classes`** — opt-in JS scanning for dynamically applied classes
22
- - **`scan_ruby_components`** — opt-in scanning of `.rb` component files
23
- - **Colour terminal output** — red/green/grey ANSI when outputting to a TTY
24
- - **BEM double-underscore selectors** — `block__element--modifier` handled correctly
26
+ ### Fixed
27
+ - False positives for dynamically assigned CSS classes used via ERB interpolation
28
+ (e.g. `<%= status_class %>`, `<%= button_css %>`).
25
29
 
26
- ### 🗑️ Removed
27
- - Confusing `stylesheet_scanner.rb` noise filter that used a hardcoded `%w[import media charset...]` list (replaced with proper context-aware skip logic)
30
+ ## [0.2.0] - 2026-05-31
28
31
 
29
- ## [0.1.0] - 2026-05-01
32
+ ### Added
33
+ - HAML and Slim template scanning
34
+ - ViewComponent and Phlex component support
35
+ - Stimulus controller JS class scanning (`classList.add`, `classList.toggle`)
36
+ - `scan_javascript_for_classes` config option
37
+ - `scan_ruby_components` config option
38
+ - `show_source_files` config option
39
+ - `fail_on_unused` config option for CI pipelines
40
+ - BEM class name support (`block__element--modifier`)
41
+ - ERB dynamic class attribute extraction (static parts from `class="<%= expr %>"`)
42
+ - `ignore_patterns` config for regex-based exclusions
43
+ - Spinner output during scanning
44
+
45
+ ## [0.1.0] - 2026-05-15
46
+
47
+ ### Added
30
48
  - Initial release
49
+ - CSS/SCSS/SASS stylesheet scanning
50
+ - ERB view scanning (`class="..."`, `class: "..."`, `class: [...]`)
51
+ - Ghost class report with counts
52
+ - `ignore_classes` configuration
53
+ - `css_unused:report` rake task
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 sghani001
3
+ Copyright (c) 2026 Syed Ghani
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rails
4
4
  module CssUnused
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
@@ -12,6 +12,7 @@ module Rails
12
12
  # Ruby: html_class: "foo", css_classes("foo bar"), "foo bar"
13
13
  # Stimulus: this.element.classList.add("foo"), "foo" string literals
14
14
  # Dynamic: class="<%= cond ? 'foo' : 'bar' %>" — literal parts extracted
15
+ # Dynamic vars: status_class = "foo-bar" — string assigned to *_class/*_classes var
15
16
  class ViewScanner
16
17
  # ── ERB / HTML patterns ──────────────────────────────────────────────
17
18
  # class="foo bar baz" or class='foo bar'
@@ -37,12 +38,33 @@ module Rails
37
38
  # class="<%= expr %>", class="prefix-<%= var %>" — extracts static parts
38
39
  ERB_DYNAMIC_CLASS = /class\s*=\s*["'][^"']*<%=[^%]+%>[^"']*["']/m
39
40
 
41
+ # ── Dynamic class variable detection (v0.2.1) ────────────────────────
42
+ # Detects string literals assigned to variables whose name ends with
43
+ # _class, _classes, _style, or _css — and the string contains hyphens
44
+ # (Ruby variable names cannot contain hyphens, so it must be a value).
45
+ #
46
+ # Matches patterns like:
47
+ # status_class = "foo-bar"
48
+ # button_classes = "btn btn-primary"
49
+ # ["Active", "status-active"] (array element with hyphenated string)
50
+ # ["Cancelled", "status-cancelled"]
51
+ #
52
+ # Rule: any double- or single-quoted string containing at least one
53
+ # hyphen is unambiguously a string value (not a Ruby identifier), so
54
+ # we can safely extract it as a potential class name.
55
+ #
56
+ # Pattern 1: variable ending in _class/_classes/_style/_css = "value"
57
+ DYNAMIC_CLASS_VAR = /\b\w+_(?:class(?:es)?|style|css)\s*=\s*["']([^"'\n]+)["']/
58
+ #
59
+ # Pattern 2: any quoted string with hyphens in array/tuple context
60
+ # e.g. ["Active", "status-active"] — the hyphenated strings are CSS classes
61
+ HYPHENATED_STRING = /["']([a-zA-Z][a-zA-Z0-9]*(?:-[a-zA-Z0-9]+)+)["']/
62
+
40
63
  # ── Ruby / Stimulus string literals ─────────────────────────────────
41
64
  # Any double-quoted string that looks like a space-separated class list
42
- # Used when scan_javascript_for_classes or scan_ruby_components is on.
43
65
  JS_ADD_CLASS = /(?:classList\.add|classList\.toggle|classList\.replace)\s*\(\s*["']([^"']+)["']/
44
- JS_REMOVE_CLASS = /(?:classList\.remove)\s*\(\s*["']([^"']+)["']/ # these ARE used
45
- RUBY_STRING_CLASSES = /["']([a-zA-Z][a-zA-Z0-9_-]*(?:\s+[a-zA-Z][a-zA-Z0-9_-]*)*)["']/
66
+ JS_REMOVE_CLASS = /(?:classList\.remove)\s*\(\s*["']([^"']+)["']/
67
+ RUBY_STRING_CLASSES = /["']([a-zA-Z][a-zA-Z0-9_-]*(?:\s+[a-zA-Z][a-zA-Z0-9_-]*)*)[\"']/
46
68
 
47
69
  def initialize(root:, config: CssUnused.configuration)
48
70
  @root = Pathname(root)
@@ -104,7 +126,6 @@ module Rails
104
126
  end
105
127
  end
106
128
 
107
- # Also scan Ruby component files if configured
108
129
  return unless @config.scan_ruby_components
109
130
 
110
131
  @config.component_paths.each do |rel|
@@ -132,22 +153,18 @@ module Rails
132
153
  ext = path.extname
133
154
  base = path.basename.to_s
134
155
 
135
- # ERB / HTML (also used for .html.erb compound names)
136
156
  if ext == ".erb" || base.end_with?(".html.erb")
137
157
  found.merge extract_erb(content)
138
158
  end
139
159
 
140
- # HAML
141
160
  if ext == ".haml" || base.end_with?(".html.haml")
142
161
  found.merge extract_haml(content)
143
162
  end
144
163
 
145
- # Slim
146
164
  if ext == ".slim" || base.end_with?(".html.slim")
147
165
  found.merge extract_slim(content)
148
166
  end
149
167
 
150
- # Ruby files (ViewComponent .rb, Phlex, helpers)
151
168
  if ext == ".rb"
152
169
  found.merge extract_ruby(content)
153
170
  end
@@ -171,11 +188,14 @@ module Rails
171
188
  # ERB dynamic class attributes — extract static string parts
172
189
  content.scan(ERB_DYNAMIC_CLASS) do
173
190
  chunk = Regexp.last_match(0)
174
- # Strip ERB tags, grab remaining quoted tokens
175
191
  stripped = chunk.gsub(/<%.*?%>/m, " ")
176
192
  stripped.scan(/["']([^"']+)["']/) { |m| found.merge tokenize(m[0]) }
177
193
  end
178
194
 
195
+ # ── v0.2.1: Dynamic class variable detection ──────────────────────
196
+ # Detects: status_class = "foo-bar" or button_classes = "btn btn-primary"
197
+ found.merge extract_dynamic_class_vars(content)
198
+
179
199
  found
180
200
  end
181
201
 
@@ -183,7 +203,6 @@ module Rails
183
203
  found = Set.new
184
204
 
185
205
  content.scan(HAML_IMPLICIT) do |m|
186
- # m[0] = ".foo.bar" — split on dots
187
206
  m[0].split(".").reject(&:empty?).each do |cls|
188
207
  found << cls if valid_class?(cls)
189
208
  end
@@ -192,6 +211,9 @@ module Rails
192
211
  content.scan(HAML_HASH_CLASS) { |m| found.merge tokenize(m[0]) }
193
212
  content.scan(RUBY_CLASS_KV) { |m| found.merge tokenize(m[0]) }
194
213
 
214
+ # v0.2.1: detect dynamic class vars in HAML Ruby blocks too
215
+ found.merge extract_dynamic_class_vars(content)
216
+
195
217
  found
196
218
  end
197
219
 
@@ -206,23 +228,26 @@ module Rails
206
228
 
207
229
  content.scan(RUBY_CLASS_KV) { |m| found.merge tokenize(m[0]) }
208
230
 
231
+ # v0.2.1: detect dynamic class vars in Slim Ruby blocks too
232
+ found.merge extract_dynamic_class_vars(content)
233
+
209
234
  found
210
235
  end
211
236
 
212
237
  def extract_ruby(content)
213
238
  found = Set.new
214
239
 
215
- # ViewComponent / Phlex: render with class: "foo"
216
240
  content.scan(RUBY_CLASS_KV) { |m| found.merge tokenize(m[0]) }
217
241
  content.scan(RUBY_CLASS_ARRAY) { |m| found.merge tokenize(m[0].gsub(/["',]/, " ")) }
218
242
 
219
- # Loose string literals that look like class name lists (safe: validated below)
220
243
  content.scan(RUBY_STRING_CLASSES) do |m|
221
244
  tokens = tokenize(m[0])
222
- # Only trust them if every token is a plausible CSS class
223
245
  found.merge(tokens) if tokens.all? { |t| valid_class?(t) }
224
246
  end
225
247
 
248
+ # v0.2.1: detect dynamic class vars in Ruby component files
249
+ found.merge extract_dynamic_class_vars(content)
250
+
226
251
  found
227
252
  end
228
253
 
@@ -233,16 +258,49 @@ module Rails
233
258
  found
234
259
  end
235
260
 
261
+ # ── v0.2.1: Smart dynamic class variable extraction ──────────────────
262
+ #
263
+ # Scans content for two patterns:
264
+ #
265
+ # 1. Variables named *_class, *_classes, *_style, *_css assigned a string:
266
+ # status_class = "foo-bar" => extracts "foo-bar"
267
+ # button_classes = "btn btn-sm" => extracts "btn", "btn-sm"
268
+ #
269
+ # 2. Any quoted string containing a hyphen (unambiguous: Ruby variable
270
+ # names cannot contain hyphens, so hyphenated quoted strings MUST be
271
+ # string values, never variable names):
272
+ # ["Active", "status-active"] => extracts "status-active"
273
+ # ["Cancelled", "status-cancelled"] => extracts "status-cancelled"
274
+ #
275
+ # This directly solves the false-positive problem where classes like
276
+ # status-approved, status-cancelled, status-requested were flagged as
277
+ # ghost classes because they were assigned to a variable (status_class)
278
+ # and used via <%= status_class %> interpolation.
279
+ def extract_dynamic_class_vars(content)
280
+ found = Set.new
281
+
282
+ # Pattern 1: *_class/*_classes variable assignments
283
+ content.scan(DYNAMIC_CLASS_VAR) do |m|
284
+ tokenize(m[0]).each { |cls| found << cls if valid_class?(cls) }
285
+ end
286
+
287
+ # Pattern 2: hyphenated string literals (unambiguously CSS class values)
288
+ content.scan(HYPHENATED_STRING) do |m|
289
+ cls = m[0].strip
290
+ found << cls if valid_class?(cls)
291
+ end
292
+
293
+ found
294
+ end
295
+
236
296
  # ── Helpers ───────────────────────────────────────────────────────────
237
297
 
238
- # Splits a raw class attribute value into individual class tokens.
239
- # Handles: spaces, commas, quotes, ERB fragments.
240
298
  def tokenize(raw)
241
299
  return Set.new if raw.nil?
242
300
 
243
301
  raw
244
- .gsub(/["']/, " ") # strip stray quotes
245
- .split(/[\s,]+/) # split on whitespace/commas
302
+ .gsub(/["']/, " ")
303
+ .split(/[\s,]+/)
246
304
  .map { |t| t.strip.delete_prefix(".") }
247
305
  .reject(&:empty?)
248
306
  .reject { |t| t.include?("<%") || t.include?('#{') }
@@ -250,9 +308,6 @@ module Rails
250
308
  .to_set
251
309
  end
252
310
 
253
- # A valid CSS class token: starts with a letter or underscore,
254
- # contains only alphanumeric, hyphens, underscores.
255
- # Allows BEM: block__element--modifier
256
311
  def valid_class?(token)
257
312
  token.match?(/\A-?[a-zA-Z_][a-zA-Z0-9_-]*\z/)
258
313
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-css_unused
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - sghani001
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-01 00:00:00.000000000 Z
11
+ date: 2026-06-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties