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,50 +1,111 @@
1
- # frozen_string_literal: true
2
-
3
- module Rails
4
- module CssUnused
5
- class Report
6
- Ghost = Struct.new(:class_name, keyword_init: true)
7
-
8
- def initialize(root:, output: $stdout, config: CssUnused.configuration)
9
- @root = Pathname(root)
10
- @output = output
11
- @config = config
12
- end
13
-
14
- def ghost_classes
15
- used = ViewScanner.new(root: @root, config: @config).used_classes
16
- defined = StylesheetScanner.new(root: @root, config: @config).defined_classes
17
- (defined - used).sort.map { |name| Ghost.new(class_name: name) }
18
- end
19
-
20
- def print_summary
21
- ghosts = ghost_classes
22
- used_count = ViewScanner.new(root: @root, config: @config).used_classes.size
23
- defined_count = StylesheetScanner.new(root: @root, config: @config).defined_classes.size
24
-
25
- @output.puts
26
- @output.puts "rails-css_unused Ghost Class Report"
27
- @output.puts "=" * 40
28
- @output.puts "Project root: #{@root}"
29
- @output.puts "Classes in stylesheets: #{defined_count}"
30
- @output.puts "Classes referenced in views: #{used_count}"
31
- @output.puts "Ghost classes (in CSS, not in views): #{ghosts.size}"
32
- @output.puts
33
-
34
- if ghosts.empty?
35
- @output.puts "No ghost classes found. Nice and tidy!"
36
- else
37
- @output.puts "Ghost classes:"
38
- ghosts.each { |g| @output.puts " #{g.class_name}" }
39
- end
40
-
41
- @output.puts
42
- @output.puts "Note: Dynamic class names and Tailwind utilities compiled at build time"
43
- @output.puts "may produce false positives. See README for configuration."
44
- @output.puts
45
-
46
- ghosts
47
- end
48
- end
49
- end
50
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spinner"
4
+
5
+ module Rails
6
+ module CssUnused
7
+ # Computes and renders the unused CSS class report.
8
+ class Report
9
+ Ghost = Struct.new(:class_name, :source_file, keyword_init: true)
10
+
11
+ RESET = "\e[0m"
12
+ BOLD = "\e[1m"
13
+ RED = "\e[31m"
14
+ GREEN = "\e[32m"
15
+ YELLOW = "\e[33m"
16
+ CYAN = "\e[36m"
17
+ GRAY = "\e[90m"
18
+
19
+ def initialize(root:, output: $stdout, config: CssUnused.configuration)
20
+ @root = Pathname(root)
21
+ @output = output
22
+ @config = config
23
+ @tty = output.respond_to?(:isatty) && output.isatty
24
+ end
25
+
26
+ # Returns Array<Ghost> of unused classes.
27
+ def ghost_classes
28
+ used = Spinner.run("Scanning views & components") { ViewScanner.new(root: @root, config: @config).used_classes }
29
+ defined = Spinner.run("Scanning stylesheets") { StylesheetScanner.new(root: @root, config: @config).defined_classes_with_sources }
30
+
31
+ unused_names = defined.map(&:name).to_set - used
32
+ defined
33
+ .select { |dc| unused_names.include?(dc.name) }
34
+ .sort_by(&:name)
35
+ .map { |dc| Ghost.new(class_name: dc.name, source_file: dc.source_file) }
36
+ end
37
+
38
+ # Prints the full report to @output.
39
+ # Returns the exit code (0 = clean, 1 = ghosts found and fail_on_unused set).
40
+ def print_summary
41
+ puts_color "", nil
42
+ puts_color "rails-css_unused v#{Rails::CssUnused::VERSION}", BOLD
43
+
44
+ # ── Scan with spinners ────────────────────────────────────────────
45
+ used = Spinner.run("Scanning views & components") do
46
+ ViewScanner.new(root: @root, config: @config).used_classes
47
+ end
48
+
49
+ defined = Spinner.run("Scanning stylesheets") do
50
+ StylesheetScanner.new(root: @root, config: @config).defined_classes_with_sources
51
+ end
52
+
53
+ ghosts = Spinner.run("Comparing & computing ghost classes") do
54
+ unused_names = defined.map(&:name).to_set - used
55
+ defined
56
+ .select { |dc| unused_names.include?(dc.name) }
57
+ .sort_by(&:name)
58
+ .map { |dc| Ghost.new(class_name: dc.name, source_file: dc.source_file) }
59
+ end
60
+
61
+ # ── Report ────────────────────────────────────────────────────────
62
+ puts_color "", nil
63
+ puts_color "Ghost Class Report", BOLD
64
+ puts_color "=" * 44, GRAY
65
+ puts_color "Project root : #{@root}", nil
66
+ puts_color "Stylesheet classes found : #{defined.size}", CYAN
67
+ puts_color "View classes referenced : #{used.size}", CYAN
68
+ puts_color "Ghost classes (unused) : #{ghosts.size}", ghosts.empty? ? GREEN : RED
69
+ puts_color "", nil
70
+
71
+ if ghosts.empty?
72
+ puts_color "✓ No ghost classes found — stylesheet is clean!", GREEN
73
+ else
74
+ puts_color "Ghost classes:", BOLD
75
+ puts_color "", nil
76
+
77
+ ghosts.each do |g|
78
+ if @config.show_source_files && g.source_file
79
+ relative = g.source_file.relative_path_from(@root) rescue g.source_file
80
+ puts_color " #{g.class_name}", RED
81
+ puts_color " → #{relative}", GRAY
82
+ else
83
+ puts_color " • #{g.class_name}", RED
84
+ end
85
+ end
86
+
87
+ puts_color "", nil
88
+ puts_color "Tip: Add classes to ignore_classes or ignore_patterns in your initializer", YELLOW
89
+ puts_color " if they are used dynamically (JS, server-side conditions, third-party).", YELLOW
90
+ end
91
+
92
+ puts_color "", nil
93
+ puts_color "Note: Dynamic class names (JS conditions, runtime interpolation) may still", GRAY
94
+ puts_color "produce false positives. Enable scan_javascript_for_classes to reduce them.", GRAY
95
+ puts_color "", nil
96
+
97
+ (ghosts.any? && @config.fail_on_unused) ? 1 : 0
98
+ end
99
+
100
+ private
101
+
102
+ def puts_color(msg, color)
103
+ if @tty && color
104
+ @output.puts "#{color}#{msg}#{RESET}"
105
+ else
106
+ @output.puts msg
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Rails
6
+ module CssUnused
7
+ # A lightweight TTY spinner shown while scanning is in progress.
8
+ # Automatically disabled when output is not a TTY (CI, pipes, file output).
9
+ class Spinner
10
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
11
+ BOLD = "\e[1m"
12
+ CYAN = "\e[36m"
13
+ GREEN = "\e[32m"
14
+ YELLOW = "\e[33m"
15
+ RESET = "\e[0m"
16
+ CLEAR = "\r\e[K" # move to column 0, erase line
17
+
18
+ INTERVAL = 0.08 # seconds between frames
19
+
20
+ def initialize(output: $stderr)
21
+ @output = output
22
+ @tty = output.respond_to?(:isatty) && output.isatty
23
+ @thread = nil
24
+ @frame = 0
25
+ @label = ""
26
+ end
27
+
28
+ # Run a block with a spinner, returning the block's return value.
29
+ # The spinner is suppressed when not on a TTY.
30
+ #
31
+ # result = Spinner.run("Scanning stylesheets") { expensive_work }
32
+ #
33
+ def self.run(label, output: $stderr, &block)
34
+ new(output: output).run(label, &block)
35
+ end
36
+
37
+ def run(label, &block)
38
+ @label = label
39
+ start!
40
+ result = block.call
41
+ stop!(success: true)
42
+ result
43
+ rescue => e
44
+ stop!(success: false)
45
+ raise e
46
+ end
47
+
48
+ private
49
+
50
+ def start!
51
+ return unless @tty
52
+
53
+ @running = true
54
+ @thread = Thread.new do
55
+ while @running
56
+ render_frame
57
+ sleep INTERVAL
58
+ end
59
+ end
60
+ end
61
+
62
+ def stop!(success:)
63
+ return unless @tty
64
+
65
+ @running = false
66
+ @thread&.join
67
+ @thread = nil
68
+
69
+ icon = success ? "#{GREEN}✔#{RESET}" : "#{YELLOW}✘#{RESET}"
70
+ @output.print "#{CLEAR}#{icon} #{BOLD}#{@label}#{RESET}\n"
71
+ end
72
+
73
+ def render_frame
74
+ frame = FRAMES[@frame % FRAMES.size]
75
+ @frame += 1
76
+ @output.print "#{CLEAR}#{CYAN}#{frame}#{RESET} #{BOLD}#{@label}…#{RESET}"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -1,82 +1,149 @@
1
- # frozen_string_literal: true
2
-
3
- module Rails
4
- module CssUnused
5
- # Extracts class selectors from CSS/SCSS/Sass files (static parse, no full AST).
6
- class StylesheetScanner
7
- # .class-name, .foo.bar, div.class — capture class tokens after dots
8
- CLASS_SELECTOR_PATTERN = /\.([a-zA-Z_][\w-]*(?:--[\w-]+)?)/
9
-
10
- # Strip comments before matching
11
- BLOCK_COMMENT = %r{/\*.*?\*/}m
12
- LINE_COMMENT = %r{//[^\n]*}
13
-
14
- def initialize(root:, config: CssUnused.configuration)
15
- @root = Pathname(root)
16
- @config = config
17
- end
18
-
19
- def defined_classes
20
- classes = Set.new
21
- each_stylesheet_file { |content| classes.merge(extract_from(strip_comments(content))) }
22
- classes.subtract(normalized_ignore_list)
23
- classes
24
- end
25
-
26
- private
27
-
28
- def each_stylesheet_file
29
- search_roots = @config.stylesheet_paths + @config.javascript_paths
30
- extensions = Configuration::CSS_EXTENSIONS
31
-
32
- search_roots.each do |relative|
33
- dir = @root.join(relative)
34
- next unless dir.directory?
35
-
36
- Dir.glob(dir.join("**", "*")).each do |file|
37
- path = Pathname(file)
38
- next unless path.file?
39
- next unless extensions.include?(path.extname)
40
-
41
- yield read_file(path)
42
- end
43
- end
44
- end
45
-
46
- def read_file(path)
47
- path.read(encoding: Encoding::UTF_8)
48
- rescue ArgumentError
49
- path.read
50
- end
51
-
52
- def strip_comments(css)
53
- css.gsub(BLOCK_COMMENT, "").gsub(LINE_COMMENT, "")
54
- end
55
-
56
- def extract_from(css)
57
- found = Set.new
58
- css.scan(CLASS_SELECTOR_PATTERN) do |match|
59
- class_name = match.is_a?(Array) ? match[0] : match
60
- next if skip_selector?(class_name, css)
61
-
62
- found << class_name
63
- end
64
- found
65
- end
66
-
67
- def skip_selector?(class_name, _css)
68
- return true if pseudo_or_utility_noise?(class_name)
69
-
70
- @config.ignore_selectors_matching.any? { |pattern| class_name.match?(pattern) }
71
- end
72
-
73
- def pseudo_or_utility_noise?(name)
74
- %w[import media charset namespace].include?(name)
75
- end
76
-
77
- def normalized_ignore_list
78
- @config.ignore_classes.map(&:to_s)
79
- end
80
- end
81
- end
82
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module CssUnused
5
+ # Scans CSS / SCSS / Sass stylesheets and extracts every defined class selector.
6
+ #
7
+ # Key improvements over v0.1:
8
+ # - Tracks which FILE each class was defined in (for --show-source-files).
9
+ # - Properly strips @charset, @import, @media, @keyframes, @font-face noise.
10
+ # - Ignores pseudo-class arguments like :not(.foo) — .foo is counted as used.
11
+ # - Handles BEM double-dash and double-underscore selectors.
12
+ # - Skips file-extension false positives (.png, .jpg, .css, .js etc.)
13
+ # - Skips @-rule word tokens that look like class selectors (.keyframe-name).
14
+ # - Applies ignore_classes AND ignore_patterns from configuration.
15
+ class StylesheetScanner
16
+ # Match .classname anywhere a class selector can appear.
17
+ # Deliberately broad — noise is filtered by the skip logic below.
18
+ CLASS_SELECTOR_PATTERN = /(?<![:\w])\.(-?[a-zA-Z_][a-zA-Z0-9_-]*)/
19
+
20
+ BLOCK_COMMENT = %r{/\*.*?\*/}m
21
+ LINE_COMMENT = %r{//[^\n]*}
22
+ STRING_LITERAL = /(['"])(?:(?!\1).)*\1/ # strip quoted strings first
23
+
24
+ # At-rule keywords whose following token looks like a class selector
25
+ # but is actually metadata: @charset, @import, @namespace, @keyframes, etc.
26
+ AT_RULE_NOISE = %w[
27
+ charset import namespace supports keyframes
28
+ font-face font-feature-values counter-style layer
29
+ page media document
30
+ ].freeze
31
+
32
+ # File extensions that produce false positives when found after a dot.
33
+ EXTENSION_NOISE = %w[
34
+ png jpg jpeg gif svg webp ico bmp tiff
35
+ css scss sass less
36
+ js ts jsx tsx mjs cjs
37
+ rb erb haml slim html htm xml json yaml yml
38
+ pdf zip gz tar woff woff2 ttf eot
39
+ map min
40
+ ].freeze
41
+
42
+ # Result struct — class name plus the file it was found in.
43
+ DefinedClass = Struct.new(:name, :source_file, keyword_init: true)
44
+
45
+ def initialize(root:, config: CssUnused.configuration)
46
+ @root = Pathname(root)
47
+ @config = config
48
+ end
49
+
50
+ # Returns a Set of plain class name strings (for diff calculations).
51
+ def defined_classes
52
+ scan_all.map(&:name).to_set
53
+ end
54
+
55
+ # Returns Array<DefinedClass> with source file info.
56
+ def defined_classes_with_sources
57
+ scan_all
58
+ end
59
+
60
+ private
61
+
62
+ def scan_all
63
+ results = []
64
+ ignore_set = @config.ignore_classes.map(&:to_s).to_set
65
+ ignore_patterns = Array(@config.ignore_patterns)
66
+
67
+ each_stylesheet_file do |path, content|
68
+ extract_from(clean(content), path).each do |name|
69
+ next if ignore_set.include?(name)
70
+ next if ignore_patterns.any? { |pat| name.match?(pat) }
71
+ results << DefinedClass.new(name: name, source_file: path)
72
+ end
73
+ end
74
+
75
+ # Deduplicate by name, keeping first occurrence.
76
+ seen = Set.new
77
+ results.select { |dc| seen.add?(dc.name) }
78
+ end
79
+
80
+ def each_stylesheet_file
81
+ dirs = (@config.stylesheet_paths + @config.javascript_paths)
82
+ .map { |p| @root.join(p) }
83
+ .select(&:directory?)
84
+
85
+ dirs.each do |dir|
86
+ Dir.glob(dir.join("**", "*")).each do |file|
87
+ path = Pathname(file)
88
+ next unless path.file?
89
+ next unless Configuration::CSS_EXTENSIONS.include?(path.extname)
90
+
91
+ content = safe_read(path)
92
+ yield path, content if content
93
+ end
94
+ end
95
+ end
96
+
97
+ # Remove comments and string literals so we don't extract class names
98
+ # from inside @import "file.css" or url("image.png").
99
+ def clean(css)
100
+ css
101
+ .gsub(BLOCK_COMMENT, " ")
102
+ .gsub(LINE_COMMENT, " ")
103
+ .gsub(STRING_LITERAL, " ")
104
+ end
105
+
106
+ def extract_from(css, _path)
107
+ found = Set.new
108
+
109
+ css.scan(CLASS_SELECTOR_PATTERN) do |match|
110
+ name = match[0]
111
+ next if skip?(name, Regexp.last_match.pre_match)
112
+ found << name
113
+ end
114
+
115
+ found
116
+ end
117
+
118
+ def skip?(name, pre_match)
119
+ # File extension false positives: .png .jpg .css etc.
120
+ return true if EXTENSION_NOISE.include?(name.downcase)
121
+
122
+ # Pure numbers or starting with a digit
123
+ return true if name.match?(/\A\d/)
124
+
125
+ # Single-character tokens are almost always noise
126
+ return true if name.length == 1
127
+
128
+ # At-rule keyword false positives: @keyframes slide-in → .slide-in is OK,
129
+ # but @charset, @import etc. followed by a dot-prefixed string.
130
+ preceding_word = pre_match.strip.split(/\s+/).last.to_s.gsub(/\A@/, "")
131
+ return true if AT_RULE_NOISE.include?(preceding_word.downcase)
132
+
133
+ # :not(.foo), :is(.foo), :where(.foo) — .foo is a USED class, not defined here.
134
+ # We skip it from the defined set; it will be found in views anyway.
135
+ return true if pre_match.match?(/:(not|is|where|has)\s*\(\s*\z/)
136
+
137
+ false
138
+ end
139
+
140
+ def safe_read(path)
141
+ path.read(encoding: "UTF-8")
142
+ rescue ArgumentError, Encoding::UndefinedConversionError
143
+ path.read(encoding: "BINARY").encode("UTF-8", invalid: :replace, undef: :replace)
144
+ rescue Errno::ENOENT, Errno::EACCES
145
+ nil
146
+ end
147
+ end
148
+ end
149
+ end
@@ -1,12 +1,32 @@
1
- # frozen_string_literal: true
2
-
3
- namespace :css_unused do
4
- desc "List CSS classes defined in stylesheets but not referenced in views/components"
5
- task report: :environment do
6
- require "rails/css_unused"
7
- Rails::CssUnused.report
8
- end
9
-
10
- desc "Same as report (alias)"
11
- task ghosts: :report
12
- end
1
+ # frozen_string_literal: true
2
+
3
+ namespace :css_unused do
4
+ desc "List CSS classes defined in stylesheets but never referenced in views/components"
5
+ task report: :environment do
6
+ require "rails/css_unused"
7
+ exit_code = Rails::CssUnused.report
8
+ exit(exit_code) if exit_code != 0
9
+ end
10
+
11
+ desc "Alias for css_unused:report"
12
+ task ghosts: :report
13
+
14
+ desc "Exit with code 1 if any ghost classes exist (CI-friendly)"
15
+ task ci: :environment do
16
+ require "rails/css_unused"
17
+ original = Rails::CssUnused.configuration.fail_on_unused
18
+ Rails::CssUnused.configuration.fail_on_unused = true
19
+ exit_code = Rails::CssUnused.report
20
+ Rails::CssUnused.configuration.fail_on_unused = original
21
+ exit(exit_code) if exit_code != 0
22
+ end
23
+
24
+ desc "Show ghost classes with their source stylesheet file"
25
+ task report_verbose: :environment do
26
+ require "rails/css_unused"
27
+ original = Rails::CssUnused.configuration.show_source_files
28
+ Rails::CssUnused.configuration.show_source_files = true
29
+ Rails::CssUnused.report
30
+ Rails::CssUnused.configuration.show_source_files = original
31
+ end
32
+ end
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
- module Rails
4
- module CssUnused
5
- VERSION = "0.1.0"
6
- end
7
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module CssUnused
5
+ VERSION = "0.2.1"
6
+ end
7
+ end