rails-css_unused 0.1.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.
@@ -1,117 +1,324 @@
1
- # frozen_string_literal: true
2
-
3
- module Rails
4
- module CssUnused
5
- # Extracts CSS class names referenced in Rails views and components via regex.
6
- # Dynamic classes (ERB interpolation) are only partially detected see README.
7
- class ViewScanner
8
- # class="foo bar", class='foo', class: "foo", class: 'foo', class: %w[foo bar]
9
- CLASS_ATTRIBUTE_PATTERN = /
10
- class\s*=\s*["']([^"']+)["']
11
- |
12
- class:\s*["']([^"']+)["']
13
- |
14
- class:\s*%w\[\s*([^\]]+)\]
15
- |
16
- class:\s*\[\s*([^\]]+)\]
17
- /ix
18
-
19
- # HAML: .foo.bar or %div.foo
20
- HAML_CLASS_PATTERN = /\.([a-zA-Z_][\w-]*)/
21
-
22
- # Tailwind-style @apply or data-class rarely; common helper: tag.div class: "x"
23
- TAG_HELPER_CLASS_PATTERN = /(?:^|\s)class:\s*["']([^"']+)["']/m
24
-
25
- def initialize(root:, config: CssUnused.configuration)
26
- @root = Pathname(root)
27
- @config = config
28
- end
29
-
30
- def used_classes
31
- classes = Set.new
32
- each_view_file { |path, content| classes.merge(extract_from(content, path)) }
33
- classes.subtract(normalized_ignore_list)
34
- classes
35
- end
36
-
37
- private
38
-
39
- def each_view_file
40
- paths = @config.view_paths + @config.component_paths
41
- extensions = (Configuration::VIEW_EXTENSIONS + Configuration::COMPONENT_EXTENSIONS).uniq
42
-
43
- paths.each do |relative|
44
- dir = @root.join(relative)
45
- next unless dir.directory?
46
-
47
- Dir.glob(dir.join("**", "*")).each do |file|
48
- path = Pathname(file)
49
- next unless path.file?
50
- next unless extensions.include?(path.extname) || compound_extension?(path)
51
-
52
- yield path, path.read(encoding: Encoding::UTF_8)
53
- rescue ArgumentError
54
- yield path, path.read
55
- end
56
- end
57
- end
58
-
59
- def compound_extension?(path)
60
- name = path.basename.to_s
61
- Configuration::VIEW_EXTENSIONS.any? { |ext| name.end_with?(ext) }
62
- end
63
-
64
- def extract_from(content, path)
65
- found = Set.new
66
- content.scan(CLASS_ATTRIBUTE_PATTERN).flatten.compact.each do |chunk|
67
- found.merge(tokenize_class_value(chunk))
68
- end
69
- content.scan(TAG_HELPER_CLASS_PATTERN) { |m| found.merge(tokenize_class_value(m[0])) }
70
- if haml_file?(path)
71
- content.scan(HAML_CLASS_PATTERN) { |m| found << m[0] unless haml_false_positive?(m[0]) }
72
- end
73
- found.merge(extract_erb_interpolated_classes(content))
74
- found
75
- end
76
-
77
- def haml_file?(path)
78
- path.extname == ".haml" || path.to_s.end_with?(".html.haml")
79
- end
80
-
81
- # Skip decimal numbers like .5 in HAML (rare in class position but possible in CSS snippets)
82
- def haml_false_positive?(token)
83
- token.match?(/\A\d/)
84
- end
85
-
86
- def tokenize_class_value(raw)
87
- raw
88
- .gsub(/['"]/, " ")
89
- .split(/[\s,]+/)
90
- .map { |t| t.strip.sub(/\A\./, "") }
91
- .reject(&:empty?)
92
- .reject { |t| t.include?("<%") || t.include?('#{') }
93
- .select { |t| valid_class_token?(t) }
94
- end
95
-
96
- def valid_class_token?(token)
97
- token.match?(/\A[a-zA-Z_][\w-]*\z/) || token.match?(/\A[a-zA-Z_][\w-]*--[\w-]+\z/)
98
- end
99
-
100
- # Picks up literal segments inside ERB-interpolated class attributes when present.
101
- def extract_erb_interpolated_classes(content)
102
- found = Set.new
103
- content.scan(/class\s*=\s*["'][^"']*<%=[^%]+%>[^"']*["']/m) do
104
- literal = Regexp.last_match(0)
105
- literal.gsub(/<%.*?%>/m, " ").scan(/["']([^"']+)["']/) do |part|
106
- found.merge(tokenize_class_value(part[0]))
107
- end
108
- end
109
- found
110
- end
111
-
112
- def normalized_ignore_list
113
- @config.ignore_classes.map(&:to_s)
114
- end
115
- end
116
- end
117
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module CssUnused
5
+ # Scans Rails view templates, ViewComponent files, Phlex components,
6
+ # Stimulus controllers, and Ruby files for referenced CSS class names.
7
+ #
8
+ # Handles:
9
+ # ERB: class="foo bar", class: "foo", class: ["foo", "bar"]
10
+ # HAML: .foo.bar, %div.foo
11
+ # Slim: div.foo, .foo
12
+ # Ruby: html_class: "foo", css_classes("foo bar"), "foo bar"
13
+ # Stimulus: this.element.classList.add("foo"), "foo" string literals
14
+ # Dynamic: class="<%= cond ? 'foo' : 'bar' %>" — literal parts extracted
15
+ # Dynamic vars: status_class = "foo-bar" — string assigned to *_class/*_classes var
16
+ class ViewScanner
17
+ # ── ERB / HTML patterns ──────────────────────────────────────────────
18
+ # class="foo bar baz" or class='foo bar'
19
+ HTML_CLASS_ATTR = /class\s*=\s*["']([^"'<>]+)["']/i
20
+ # class: "foo bar" or class: 'foo'
21
+ RUBY_CLASS_KV = /class:\s*["']([^"']+)["']/
22
+ # class: ["foo", "bar"] or class: %w[foo bar]
23
+ RUBY_CLASS_ARRAY = /class:\s*(?:\[|%w\[)\s*([^\]\n]+)/
24
+ # tag.div(class: "foo") content_tag(:div, class: "foo")
25
+ TAG_HELPER = /(?:content_tag|tag\.\w+)\s*[({][^)}\n]*class:\s*["']([^"']+)["']/
26
+
27
+ # ── HAML patterns ───────────────────────────────────────────────────
28
+ # .foo, %div.foo.bar, %span.foo#id
29
+ HAML_IMPLICIT = /^[ \t]*(?:%[\w:-]+)?(\.[a-zA-Z][a-zA-Z0-9_-]*(?:\.[a-zA-Z][a-zA-Z0-9_-]*)*)/
30
+ # Inline { class: "foo" }
31
+ HAML_HASH_CLASS = /class:\s*["']([^"']+)["']/
32
+
33
+ # ── Slim patterns ───────────────────────────────────────────────────
34
+ # div.foo.bar or .foo.bar on its own line
35
+ SLIM_CLASS = /^[ \t]*(?:[\w-]*)(\.[a-zA-Z][a-zA-Z0-9_-]*(?:\.[a-zA-Z][a-zA-Z0-9_-]*)*)/
36
+
37
+ # ── ERB dynamic interpolation ────────────────────────────────────────
38
+ # class="<%= expr %>", class="prefix-<%= var %>" — extracts static parts
39
+ ERB_DYNAMIC_CLASS = /class\s*=\s*["'][^"']*<%=[^%]+%>[^"']*["']/m
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
+
63
+ # ── Ruby / Stimulus string literals ─────────────────────────────────
64
+ # Any double-quoted string that looks like a space-separated class list
65
+ JS_ADD_CLASS = /(?:classList\.add|classList\.toggle|classList\.replace)\s*\(\s*["']([^"']+)["']/
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_-]*)*)[\"']/
68
+
69
+ def initialize(root:, config: CssUnused.configuration)
70
+ @root = Pathname(root)
71
+ @config = config
72
+ end
73
+
74
+ # Returns a Set of class name strings referenced across all view files.
75
+ def used_classes
76
+ classes = Set.new
77
+ ignore_set = @config.ignore_classes.map(&:to_s).to_set
78
+ ignore_pats = Array(@config.ignore_patterns)
79
+
80
+ each_view_file do |path, content|
81
+ extract_from(content, path).each do |cls|
82
+ next if ignore_set.include?(cls)
83
+ next if ignore_pats.any? { |p| cls.match?(p) }
84
+ classes << cls
85
+ end
86
+ end
87
+
88
+ if @config.scan_javascript_for_classes
89
+ each_js_file do |path, content|
90
+ extract_js_classes(content).each { |cls| classes << cls }
91
+ end
92
+ end
93
+
94
+ classes
95
+ end
96
+
97
+ private
98
+
99
+ # ── File iteration ───────────────────────────────────────────────────
100
+
101
+ def each_view_file
102
+ paths = (@config.view_paths + @config.component_paths).uniq
103
+ paths.each do |rel|
104
+ dir = @root.join(rel)
105
+ next unless dir.directory?
106
+
107
+ Dir.glob(dir.join("**", "*")).each do |f|
108
+ path = Pathname(f)
109
+ next unless path.file? && view_file?(path)
110
+
111
+ content = safe_read(path)
112
+ yield path, content if content
113
+ end
114
+ end
115
+ end
116
+
117
+ def each_js_file
118
+ @config.javascript_paths.each do |rel|
119
+ dir = @root.join(rel)
120
+ next unless dir.directory?
121
+
122
+ Dir.glob(dir.join("**", "*.{js,ts,jsx,tsx}")).each do |f|
123
+ path = Pathname(f)
124
+ content = safe_read(path)
125
+ yield path, content if content
126
+ end
127
+ end
128
+
129
+ return unless @config.scan_ruby_components
130
+
131
+ @config.component_paths.each do |rel|
132
+ dir = @root.join(rel)
133
+ next unless dir.directory?
134
+
135
+ Dir.glob(dir.join("**", "*.rb")).each do |f|
136
+ path = Pathname(f)
137
+ content = safe_read(path)
138
+ yield path, content if content
139
+ end
140
+ end
141
+ end
142
+
143
+ def view_file?(path)
144
+ name = path.basename.to_s
145
+ Configuration::VIEW_EXTENSIONS.any? { |ext| name.end_with?(ext) } ||
146
+ Configuration::COMPOUND_VIEW_ENDINGS.any? { |ext| name.end_with?(ext) }
147
+ end
148
+
149
+ # ── Class extraction ─────────────────────────────────────────────────
150
+
151
+ def extract_from(content, path)
152
+ found = Set.new
153
+ ext = path.extname
154
+ base = path.basename.to_s
155
+
156
+ if ext == ".erb" || base.end_with?(".html.erb")
157
+ found.merge extract_erb(content)
158
+ end
159
+
160
+ if ext == ".haml" || base.end_with?(".html.haml")
161
+ found.merge extract_haml(content)
162
+ end
163
+
164
+ if ext == ".slim" || base.end_with?(".html.slim")
165
+ found.merge extract_slim(content)
166
+ end
167
+
168
+ if ext == ".rb"
169
+ found.merge extract_ruby(content)
170
+ end
171
+
172
+ found
173
+ end
174
+
175
+ def extract_erb(content)
176
+ found = Set.new
177
+
178
+ # Standard class attributes
179
+ [HTML_CLASS_ATTR, RUBY_CLASS_KV, TAG_HELPER].each do |pat|
180
+ content.scan(pat) { |m| found.merge tokenize(m[0]) }
181
+ end
182
+
183
+ # class: ["foo", "bar"] / class: %w[foo bar]
184
+ content.scan(RUBY_CLASS_ARRAY) do |m|
185
+ found.merge tokenize(m[0].gsub(/["',]/, " "))
186
+ end
187
+
188
+ # ERB dynamic class attributes — extract static string parts
189
+ content.scan(ERB_DYNAMIC_CLASS) do
190
+ chunk = Regexp.last_match(0)
191
+ stripped = chunk.gsub(/<%.*?%>/m, " ")
192
+ stripped.scan(/["']([^"']+)["']/) { |m| found.merge tokenize(m[0]) }
193
+ end
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
+
199
+ found
200
+ end
201
+
202
+ def extract_haml(content)
203
+ found = Set.new
204
+
205
+ content.scan(HAML_IMPLICIT) do |m|
206
+ m[0].split(".").reject(&:empty?).each do |cls|
207
+ found << cls if valid_class?(cls)
208
+ end
209
+ end
210
+
211
+ content.scan(HAML_HASH_CLASS) { |m| found.merge tokenize(m[0]) }
212
+ content.scan(RUBY_CLASS_KV) { |m| found.merge tokenize(m[0]) }
213
+
214
+ # v0.2.1: detect dynamic class vars in HAML Ruby blocks too
215
+ found.merge extract_dynamic_class_vars(content)
216
+
217
+ found
218
+ end
219
+
220
+ def extract_slim(content)
221
+ found = Set.new
222
+
223
+ content.scan(SLIM_CLASS) do |m|
224
+ m[0].split(".").reject(&:empty?).each do |cls|
225
+ found << cls if valid_class?(cls)
226
+ end
227
+ end
228
+
229
+ content.scan(RUBY_CLASS_KV) { |m| found.merge tokenize(m[0]) }
230
+
231
+ # v0.2.1: detect dynamic class vars in Slim Ruby blocks too
232
+ found.merge extract_dynamic_class_vars(content)
233
+
234
+ found
235
+ end
236
+
237
+ def extract_ruby(content)
238
+ found = Set.new
239
+
240
+ content.scan(RUBY_CLASS_KV) { |m| found.merge tokenize(m[0]) }
241
+ content.scan(RUBY_CLASS_ARRAY) { |m| found.merge tokenize(m[0].gsub(/["',]/, " ")) }
242
+
243
+ content.scan(RUBY_STRING_CLASSES) do |m|
244
+ tokens = tokenize(m[0])
245
+ found.merge(tokens) if tokens.all? { |t| valid_class?(t) }
246
+ end
247
+
248
+ # v0.2.1: detect dynamic class vars in Ruby component files
249
+ found.merge extract_dynamic_class_vars(content)
250
+
251
+ found
252
+ end
253
+
254
+ def extract_js_classes(content)
255
+ found = Set.new
256
+ content.scan(JS_ADD_CLASS) { |m| found.merge tokenize(m[0]) }
257
+ content.scan(JS_REMOVE_CLASS) { |m| found.merge tokenize(m[0]) }
258
+ found
259
+ end
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
+
296
+ # ── Helpers ───────────────────────────────────────────────────────────
297
+
298
+ def tokenize(raw)
299
+ return Set.new if raw.nil?
300
+
301
+ raw
302
+ .gsub(/["']/, " ")
303
+ .split(/[\s,]+/)
304
+ .map { |t| t.strip.delete_prefix(".") }
305
+ .reject(&:empty?)
306
+ .reject { |t| t.include?("<%") || t.include?('#{') }
307
+ .select { |t| valid_class?(t) }
308
+ .to_set
309
+ end
310
+
311
+ def valid_class?(token)
312
+ token.match?(/\A-?[a-zA-Z_][a-zA-Z0-9_-]*\z/)
313
+ end
314
+
315
+ def safe_read(path)
316
+ path.read(encoding: "UTF-8")
317
+ rescue ArgumentError, Encoding::UndefinedConversionError
318
+ path.read(encoding: "BINARY").encode("UTF-8", invalid: :replace, undef: :replace)
319
+ rescue Errno::ENOENT, Errno::EACCES
320
+ nil
321
+ end
322
+ end
323
+ end
324
+ end
@@ -1,36 +1,40 @@
1
- # frozen_string_literal: true
2
-
3
- require "pathname"
4
- require "set"
5
-
6
- require_relative "css_unused/version"
7
- require_relative "css_unused/configuration"
8
- require_relative "css_unused/view_scanner"
9
- require_relative "css_unused/stylesheet_scanner"
10
- require_relative "css_unused/report"
11
-
12
- module Rails
13
- module CssUnused
14
- class Error < StandardError; end
15
-
16
- class << self
17
- def report(root: default_root, output: $stdout)
18
- Report.new(root: root, output: output).print_summary
19
- end
20
-
21
- def ghost_classes(root: default_root)
22
- Report.new(root: root).ghost_classes.map(&:class_name)
23
- end
24
-
25
- def default_root
26
- if defined?(Rails) && Rails.respond_to?(:root)
27
- Rails.root
28
- else
29
- Pathname.new(Dir.pwd)
30
- end
31
- end
32
- end
33
- end
34
- end
35
-
36
- require_relative "css_unused/railtie" if defined?(Rails::Railtie)
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "set"
5
+
6
+ require_relative "css_unused/version"
7
+ require_relative "css_unused/configuration"
8
+ require_relative "css_unused/stylesheet_scanner"
9
+ require_relative "css_unused/view_scanner"
10
+ require_relative "css_unused/report"
11
+
12
+ module Rails
13
+ module CssUnused
14
+ class Error < StandardError; end
15
+
16
+ class << self
17
+ # Prints a full report to output (default: $stdout).
18
+ # Returns the exit code (0 = clean, 1 = ghosts found + fail_on_unused).
19
+ def report(root: default_root, output: $stdout)
20
+ Report.new(root: root, output: output).print_summary
21
+ end
22
+
23
+ # Returns an array of unused class name strings.
24
+ def ghost_classes(root: default_root)
25
+ Report.new(root: root).ghost_classes.map(&:class_name)
26
+ end
27
+
28
+ # Returns the project root: Rails.root if inside Rails, else cwd.
29
+ def default_root
30
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
31
+ ::Rails.root
32
+ else
33
+ Pathname.new(Dir.pwd)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ require_relative "css_unused/railtie" if defined?(Rails::Railtie)
@@ -1,3 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "rails/css_unused"
1
+ # frozen_string_literal: true
2
+
3
+ # Compatibility shim — allows both:
4
+ # require "rails/css_unused"
5
+ # require "rails-css_unused"
6
+ require_relative "rails/css_unused"
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.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - sghani001
8
8
  autorequire:
9
- bindir: exe
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
@@ -59,9 +59,11 @@ dependencies:
59
59
  - !ruby/object:Gem::Version
60
60
  version: '3.12'
61
61
  description: |
62
- A lightweight Rake task that regex-scans views and components for CSS class
63
- references, compares them to selectors in your stylesheets, and reports
64
- ghost classes defined in CSS but never used in templates.
62
+ rails-css_unused scans your stylesheets (CSS/SCSS/Sass) and all view templates
63
+ (ERB, HAML, Slim), ViewComponents, Phlex components, and Stimulus JS controllers
64
+ to report CSS class selectors that are defined but never referenced. Supports
65
+ BEM naming, dynamic class detection, source-file attribution, CI exit codes,
66
+ and configurable ignore lists — zero runtime overhead.
65
67
  email:
66
68
  - sghani001@users.noreply.github.com
67
69
  executables: []
@@ -76,6 +78,7 @@ files:
76
78
  - lib/rails/css_unused/configuration.rb
77
79
  - lib/rails/css_unused/railtie.rb
78
80
  - lib/rails/css_unused/report.rb
81
+ - lib/rails/css_unused/spinner.rb
79
82
  - lib/rails/css_unused/stylesheet_scanner.rb
80
83
  - lib/rails/css_unused/tasks.rake
81
84
  - lib/rails/css_unused/version.rb
@@ -107,5 +110,5 @@ requirements: []
107
110
  rubygems_version: 3.5.3
108
111
  signing_key:
109
112
  specification_version: 4
110
- summary: Find unused CSS classes in Rails apps via static analysis
113
+ summary: Find unused CSS classes in Rails apps fast, accurate static analysis
111
114
  test_files: []