rails-css_unused 0.1.0 → 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.
@@ -1,117 +1,269 @@
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
+ class ViewScanner
16
+ # ── ERB / HTML patterns ──────────────────────────────────────────────
17
+ # class="foo bar baz" or class='foo bar'
18
+ HTML_CLASS_ATTR = /class\s*=\s*["']([^"'<>]+)["']/i
19
+ # class: "foo bar" or class: 'foo'
20
+ RUBY_CLASS_KV = /class:\s*["']([^"']+)["']/
21
+ # class: ["foo", "bar"] or class: %w[foo bar]
22
+ RUBY_CLASS_ARRAY = /class:\s*(?:\[|%w\[)\s*([^\]\n]+)/
23
+ # tag.div(class: "foo") content_tag(:div, class: "foo")
24
+ TAG_HELPER = /(?:content_tag|tag\.\w+)\s*[({][^)}\n]*class:\s*["']([^"']+)["']/
25
+
26
+ # ── HAML patterns ───────────────────────────────────────────────────
27
+ # .foo, %div.foo.bar, %span.foo#id
28
+ HAML_IMPLICIT = /^[ \t]*(?:%[\w:-]+)?(\.[a-zA-Z][a-zA-Z0-9_-]*(?:\.[a-zA-Z][a-zA-Z0-9_-]*)*)/
29
+ # Inline { class: "foo" }
30
+ HAML_HASH_CLASS = /class:\s*["']([^"']+)["']/
31
+
32
+ # ── Slim patterns ───────────────────────────────────────────────────
33
+ # div.foo.bar or .foo.bar on its own line
34
+ SLIM_CLASS = /^[ \t]*(?:[\w-]*)(\.[a-zA-Z][a-zA-Z0-9_-]*(?:\.[a-zA-Z][a-zA-Z0-9_-]*)*)/
35
+
36
+ # ── ERB dynamic interpolation ────────────────────────────────────────
37
+ # class="<%= expr %>", class="prefix-<%= var %>" — extracts static parts
38
+ ERB_DYNAMIC_CLASS = /class\s*=\s*["'][^"']*<%=[^%]+%>[^"']*["']/m
39
+
40
+ # ── Ruby / Stimulus string literals ─────────────────────────────────
41
+ # 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
+ 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_-]*)*)["']/
46
+
47
+ def initialize(root:, config: CssUnused.configuration)
48
+ @root = Pathname(root)
49
+ @config = config
50
+ end
51
+
52
+ # Returns a Set of class name strings referenced across all view files.
53
+ def used_classes
54
+ classes = Set.new
55
+ ignore_set = @config.ignore_classes.map(&:to_s).to_set
56
+ ignore_pats = Array(@config.ignore_patterns)
57
+
58
+ each_view_file do |path, content|
59
+ extract_from(content, path).each do |cls|
60
+ next if ignore_set.include?(cls)
61
+ next if ignore_pats.any? { |p| cls.match?(p) }
62
+ classes << cls
63
+ end
64
+ end
65
+
66
+ if @config.scan_javascript_for_classes
67
+ each_js_file do |path, content|
68
+ extract_js_classes(content).each { |cls| classes << cls }
69
+ end
70
+ end
71
+
72
+ classes
73
+ end
74
+
75
+ private
76
+
77
+ # ── File iteration ───────────────────────────────────────────────────
78
+
79
+ def each_view_file
80
+ paths = (@config.view_paths + @config.component_paths).uniq
81
+ paths.each do |rel|
82
+ dir = @root.join(rel)
83
+ next unless dir.directory?
84
+
85
+ Dir.glob(dir.join("**", "*")).each do |f|
86
+ path = Pathname(f)
87
+ next unless path.file? && view_file?(path)
88
+
89
+ content = safe_read(path)
90
+ yield path, content if content
91
+ end
92
+ end
93
+ end
94
+
95
+ def each_js_file
96
+ @config.javascript_paths.each do |rel|
97
+ dir = @root.join(rel)
98
+ next unless dir.directory?
99
+
100
+ Dir.glob(dir.join("**", "*.{js,ts,jsx,tsx}")).each do |f|
101
+ path = Pathname(f)
102
+ content = safe_read(path)
103
+ yield path, content if content
104
+ end
105
+ end
106
+
107
+ # Also scan Ruby component files if configured
108
+ return unless @config.scan_ruby_components
109
+
110
+ @config.component_paths.each do |rel|
111
+ dir = @root.join(rel)
112
+ next unless dir.directory?
113
+
114
+ Dir.glob(dir.join("**", "*.rb")).each do |f|
115
+ path = Pathname(f)
116
+ content = safe_read(path)
117
+ yield path, content if content
118
+ end
119
+ end
120
+ end
121
+
122
+ def view_file?(path)
123
+ name = path.basename.to_s
124
+ Configuration::VIEW_EXTENSIONS.any? { |ext| name.end_with?(ext) } ||
125
+ Configuration::COMPOUND_VIEW_ENDINGS.any? { |ext| name.end_with?(ext) }
126
+ end
127
+
128
+ # ── Class extraction ─────────────────────────────────────────────────
129
+
130
+ def extract_from(content, path)
131
+ found = Set.new
132
+ ext = path.extname
133
+ base = path.basename.to_s
134
+
135
+ # ERB / HTML (also used for .html.erb compound names)
136
+ if ext == ".erb" || base.end_with?(".html.erb")
137
+ found.merge extract_erb(content)
138
+ end
139
+
140
+ # HAML
141
+ if ext == ".haml" || base.end_with?(".html.haml")
142
+ found.merge extract_haml(content)
143
+ end
144
+
145
+ # Slim
146
+ if ext == ".slim" || base.end_with?(".html.slim")
147
+ found.merge extract_slim(content)
148
+ end
149
+
150
+ # Ruby files (ViewComponent .rb, Phlex, helpers)
151
+ if ext == ".rb"
152
+ found.merge extract_ruby(content)
153
+ end
154
+
155
+ found
156
+ end
157
+
158
+ def extract_erb(content)
159
+ found = Set.new
160
+
161
+ # Standard class attributes
162
+ [HTML_CLASS_ATTR, RUBY_CLASS_KV, TAG_HELPER].each do |pat|
163
+ content.scan(pat) { |m| found.merge tokenize(m[0]) }
164
+ end
165
+
166
+ # class: ["foo", "bar"] / class: %w[foo bar]
167
+ content.scan(RUBY_CLASS_ARRAY) do |m|
168
+ found.merge tokenize(m[0].gsub(/["',]/, " "))
169
+ end
170
+
171
+ # ERB dynamic class attributes — extract static string parts
172
+ content.scan(ERB_DYNAMIC_CLASS) do
173
+ chunk = Regexp.last_match(0)
174
+ # Strip ERB tags, grab remaining quoted tokens
175
+ stripped = chunk.gsub(/<%.*?%>/m, " ")
176
+ stripped.scan(/["']([^"']+)["']/) { |m| found.merge tokenize(m[0]) }
177
+ end
178
+
179
+ found
180
+ end
181
+
182
+ def extract_haml(content)
183
+ found = Set.new
184
+
185
+ content.scan(HAML_IMPLICIT) do |m|
186
+ # m[0] = ".foo.bar" — split on dots
187
+ m[0].split(".").reject(&:empty?).each do |cls|
188
+ found << cls if valid_class?(cls)
189
+ end
190
+ end
191
+
192
+ content.scan(HAML_HASH_CLASS) { |m| found.merge tokenize(m[0]) }
193
+ content.scan(RUBY_CLASS_KV) { |m| found.merge tokenize(m[0]) }
194
+
195
+ found
196
+ end
197
+
198
+ def extract_slim(content)
199
+ found = Set.new
200
+
201
+ content.scan(SLIM_CLASS) do |m|
202
+ m[0].split(".").reject(&:empty?).each do |cls|
203
+ found << cls if valid_class?(cls)
204
+ end
205
+ end
206
+
207
+ content.scan(RUBY_CLASS_KV) { |m| found.merge tokenize(m[0]) }
208
+
209
+ found
210
+ end
211
+
212
+ def extract_ruby(content)
213
+ found = Set.new
214
+
215
+ # ViewComponent / Phlex: render with class: "foo"
216
+ content.scan(RUBY_CLASS_KV) { |m| found.merge tokenize(m[0]) }
217
+ content.scan(RUBY_CLASS_ARRAY) { |m| found.merge tokenize(m[0].gsub(/["',]/, " ")) }
218
+
219
+ # Loose string literals that look like class name lists (safe: validated below)
220
+ content.scan(RUBY_STRING_CLASSES) do |m|
221
+ tokens = tokenize(m[0])
222
+ # Only trust them if every token is a plausible CSS class
223
+ found.merge(tokens) if tokens.all? { |t| valid_class?(t) }
224
+ end
225
+
226
+ found
227
+ end
228
+
229
+ def extract_js_classes(content)
230
+ found = Set.new
231
+ content.scan(JS_ADD_CLASS) { |m| found.merge tokenize(m[0]) }
232
+ content.scan(JS_REMOVE_CLASS) { |m| found.merge tokenize(m[0]) }
233
+ found
234
+ end
235
+
236
+ # ── Helpers ───────────────────────────────────────────────────────────
237
+
238
+ # Splits a raw class attribute value into individual class tokens.
239
+ # Handles: spaces, commas, quotes, ERB fragments.
240
+ def tokenize(raw)
241
+ return Set.new if raw.nil?
242
+
243
+ raw
244
+ .gsub(/["']/, " ") # strip stray quotes
245
+ .split(/[\s,]+/) # split on whitespace/commas
246
+ .map { |t| t.strip.delete_prefix(".") }
247
+ .reject(&:empty?)
248
+ .reject { |t| t.include?("<%") || t.include?('#{') }
249
+ .select { |t| valid_class?(t) }
250
+ .to_set
251
+ end
252
+
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
+ def valid_class?(token)
257
+ token.match?(/\A-?[a-zA-Z_][a-zA-Z0-9_-]*\z/)
258
+ end
259
+
260
+ def safe_read(path)
261
+ path.read(encoding: "UTF-8")
262
+ rescue ArgumentError, Encoding::UndefinedConversionError
263
+ path.read(encoding: "BINARY").encode("UTF-8", invalid: :replace, undef: :replace)
264
+ rescue Errno::ENOENT, Errno::EACCES
265
+ nil
266
+ end
267
+ end
268
+ end
269
+ 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,12 +1,12 @@
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - sghani001
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-06-01 00:00:00.000000000 Z
12
12
  dependencies:
@@ -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: []