I built a gem that finds unused CSS classes in Rails apps — here's the interesting problem I had to solve

ruby dev.to

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .rb files
  • 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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]+)+)["']/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Links

Feedback and PRs welcome. Still learning — but shipping.

Source: dev.to

arrow_back Back to Tutorials