Yesterday I published my first Ruby gem — rails-persona, a behavioral analytics library that lets you track user actions directly on ActiveRecord models.
It had bugs on day one. Wrong File.expand_path depths, frozen array errors in the Railtie, a migration using jsonb that broke on SQLite. I fixed them all, one GitHub issue at a time, and learned more about how Rails gems work internally than any tutorial ever gave me.
That experience gave me the confidence to build another one.
Introducing rails-css_unused
gem 'rails-css_unused', group: :development
bundle install
bundle exec rake css_unused:report
That's it. You get a full report of every CSS class that's defined in your stylesheets but never referenced anywhere in your views, components, or JS files.
rails-css_unused v0.2.1
✔ Scanning views & components
✔ Scanning stylesheets
✔ Comparing & computing ghost classes
Ghost Class Report
Ghost classes (unused): 2
• old-card-header
• legacy-sidebar-btn
No server needed. No browser. Pure static analysis.
What it scans
- ERB, HAML, Slim templates
- ViewComponent and Phlex component files
- Stimulus JS controllers (
classList.add,classList.toggle) - Ruby component
.rbfiles - BEM class names (
block__element--modifier)
The interesting problem I had to solve in v0.2.1
When I tested the gem on my own Online Exam System project, it flagged these as ghost classes:
• status-approved
• status-cancelled
• status-requested
But they were very much in use. Here's why the scanner missed them:
<% status_label, status_class =
if exam.cancelled?
["Cancelled", "status-cancelled"]
elsif exam.active? && exam.approved?
["Active", "status-active"]
elsif exam.approved?
["Approved", "status-approved"]
elsif exam.request_approval?
["Requested approval", "status-requested"]
else
["Draft", "status-draft"]
end %>
<span class="status-pill <%= status_class %>"><%= status_label %></span>
The classes are assigned to a variable (status_class) and rendered via <%= status_class %>. The scanner only picks up string literals in class="..." attributes — it can't see through variable interpolation.
The naive fix would be to add ignore_patterns << /\Astatus-/ to the config. But that feels wrong — you're telling the tool to ignore a whole prefix forever, which masks future real ghost classes.
The elegant fix
Here's the insight that made this work cleanly:
Ruby variable names cannot contain hyphens.
status-cancelled as a variable would be a syntax error — Ruby parses it as status - cancelled (subtraction). So any quoted string containing a hyphen is unambiguously a string value, never a variable name.
This means we can safely extract hyphenated quoted strings as CSS class names:
# Pattern 1: *_class / *_classes variable assignments
DYNAMIC_CLASS_VAR = /\b\w+_(?:class(?:es)?|style|css)\s*=\s*["']([^"'\n]+)["']/
# Pattern 2: any quoted string containing a hyphen
# (unambiguously a string value in Ruby — never a variable name)
HYPHENATED_STRING = /["']([a-zA-Z][a-zA-Z0-9]*(?:-[a-zA-Z0-9]+)+)["']/
With these two patterns, the scanner now finds:
["Cancelled", "status-cancelled"] # ✅ status-cancelled extracted
["Approved", "status-approved"] # ✅ status-approved extracted
status_class = "status-active" # ✅ status-active extracted
button_classes = "btn btn-primary" # ✅ btn-primary extracted
No ignore_patterns workarounds needed. No false positives.
Configuration
# config/initializers/css_unused.rb
Rails::CssUnused.configure do |config|
config.ignore_classes = %w[clearfix sr-only visually-hidden]
config.ignore_patterns = [/\Ajs-/, /\Ais-/, /\Ahas-/]
config.fail_on_unused = true # exit 1 in CI
config.show_source_files = true # show which stylesheet each ghost came from
end
Links
- GitHub: https://github.com/sghani001/rails-css_unused
- RubyGems: https://rubygems.org/gems/rails-css_unused
- rails-persona (my first gem): https://github.com/sghani001/rails-persona
Feedback and PRs welcome. Still learning — but shipping.
Top comments (0)