cataract 0.1.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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/.clang-tidy +30 -0
  3. data/.github/workflows/ci-macos.yml +12 -0
  4. data/.github/workflows/ci.yml +77 -0
  5. data/.github/workflows/test.yml +76 -0
  6. data/.gitignore +45 -0
  7. data/.overcommit.yml +38 -0
  8. data/.rubocop.yml +83 -0
  9. data/BENCHMARKS.md +201 -0
  10. data/CHANGELOG.md +1 -0
  11. data/Gemfile +27 -0
  12. data/LICENSE +21 -0
  13. data/RAGEL_MIGRATION.md +60 -0
  14. data/README.md +292 -0
  15. data/Rakefile +209 -0
  16. data/benchmarks/benchmark_harness.rb +193 -0
  17. data/benchmarks/benchmark_merging.rb +121 -0
  18. data/benchmarks/benchmark_optimization_comparison.rb +168 -0
  19. data/benchmarks/benchmark_parsing.rb +153 -0
  20. data/benchmarks/benchmark_ragel_removal.rb +56 -0
  21. data/benchmarks/benchmark_runner.rb +70 -0
  22. data/benchmarks/benchmark_serialization.rb +180 -0
  23. data/benchmarks/benchmark_shorthand.rb +109 -0
  24. data/benchmarks/benchmark_shorthand_expansion.rb +176 -0
  25. data/benchmarks/benchmark_specificity.rb +124 -0
  26. data/benchmarks/benchmark_string_allocation.rb +151 -0
  27. data/benchmarks/benchmark_stylesheet_to_s.rb +62 -0
  28. data/benchmarks/benchmark_to_s_cached.rb +55 -0
  29. data/benchmarks/benchmark_value_splitter.rb +54 -0
  30. data/benchmarks/benchmark_yjit.rb +158 -0
  31. data/benchmarks/benchmark_yjit_workers.rb +61 -0
  32. data/benchmarks/profile_to_s.rb +23 -0
  33. data/benchmarks/speedup_calculator.rb +83 -0
  34. data/benchmarks/system_metadata.rb +81 -0
  35. data/benchmarks/templates/benchmarks.md.erb +221 -0
  36. data/benchmarks/yjit_tests.rb +141 -0
  37. data/cataract.gemspec +34 -0
  38. data/cliff.toml +92 -0
  39. data/examples/color_conversion_visual_test/color_conversion_test.html +3603 -0
  40. data/examples/color_conversion_visual_test/generate.rb +202 -0
  41. data/examples/color_conversion_visual_test/template.html.erb +259 -0
  42. data/examples/css_analyzer/analyzer.rb +164 -0
  43. data/examples/css_analyzer/analyzers/base.rb +33 -0
  44. data/examples/css_analyzer/analyzers/colors.rb +133 -0
  45. data/examples/css_analyzer/analyzers/important.rb +88 -0
  46. data/examples/css_analyzer/analyzers/properties.rb +61 -0
  47. data/examples/css_analyzer/analyzers/specificity.rb +68 -0
  48. data/examples/css_analyzer/templates/report.html.erb +575 -0
  49. data/examples/css_analyzer.rb +69 -0
  50. data/examples/github_analysis.html +5343 -0
  51. data/ext/cataract/cataract.c +1086 -0
  52. data/ext/cataract/cataract.h +174 -0
  53. data/ext/cataract/css_parser.c +1435 -0
  54. data/ext/cataract/extconf.rb +48 -0
  55. data/ext/cataract/import_scanner.c +174 -0
  56. data/ext/cataract/merge.c +973 -0
  57. data/ext/cataract/shorthand_expander.c +902 -0
  58. data/ext/cataract/specificity.c +213 -0
  59. data/ext/cataract/value_splitter.c +116 -0
  60. data/ext/cataract_color/cataract_color.c +16 -0
  61. data/ext/cataract_color/color_conversion.c +1687 -0
  62. data/ext/cataract_color/color_conversion.h +136 -0
  63. data/ext/cataract_color/color_conversion_lab.c +571 -0
  64. data/ext/cataract_color/color_conversion_named.c +259 -0
  65. data/ext/cataract_color/color_conversion_oklab.c +547 -0
  66. data/ext/cataract_color/extconf.rb +23 -0
  67. data/ext/cataract_old/cataract.c +393 -0
  68. data/ext/cataract_old/cataract.h +250 -0
  69. data/ext/cataract_old/css_parser.c +933 -0
  70. data/ext/cataract_old/extconf.rb +67 -0
  71. data/ext/cataract_old/import_scanner.c +174 -0
  72. data/ext/cataract_old/merge.c +776 -0
  73. data/ext/cataract_old/shorthand_expander.c +902 -0
  74. data/ext/cataract_old/specificity.c +213 -0
  75. data/ext/cataract_old/stylesheet.c +290 -0
  76. data/ext/cataract_old/value_splitter.c +116 -0
  77. data/lib/cataract/at_rule.rb +97 -0
  78. data/lib/cataract/color_conversion.rb +18 -0
  79. data/lib/cataract/declarations.rb +332 -0
  80. data/lib/cataract/import_resolver.rb +210 -0
  81. data/lib/cataract/rule.rb +131 -0
  82. data/lib/cataract/stylesheet.rb +716 -0
  83. data/lib/cataract/stylesheet_scope.rb +257 -0
  84. data/lib/cataract/version.rb +5 -0
  85. data/lib/cataract.rb +107 -0
  86. data/lib/tasks/gem.rake +158 -0
  87. data/scripts/fuzzer/run.rb +828 -0
  88. data/scripts/fuzzer/worker.rb +99 -0
  89. data/scripts/generate_benchmarks_md.rb +155 -0
  90. metadata +135 -0
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Visual test generator for Cataract color conversions
5
+ # Generates an HTML file showing color conversions across all supported formats
6
+
7
+ require 'erb'
8
+ require 'fileutils'
9
+
10
+ # Add lib to load path
11
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
12
+ require 'cataract'
13
+
14
+ # Sample of named colors to test (diverse hues, saturations, lightness)
15
+ SAMPLE_COLORS = %w[
16
+ red
17
+ green
18
+ blue
19
+ yellow
20
+ cyan
21
+ magenta
22
+ white
23
+ black
24
+ gray
25
+ silver
26
+ maroon
27
+ olive
28
+ lime
29
+ aqua
30
+ teal
31
+ navy
32
+ fuchsia
33
+ purple
34
+ orange
35
+ pink
36
+ coral
37
+ tomato
38
+ gold
39
+ indigo
40
+ violet
41
+ brown
42
+ tan
43
+ khaki
44
+ salmon
45
+ crimson
46
+ chocolate
47
+ peru
48
+ sienna
49
+ steelblue
50
+ skyblue
51
+ turquoise
52
+ orchid
53
+ plum
54
+ lavender
55
+ ].freeze
56
+
57
+ def convert_color(color_str, from_format, to_format)
58
+ css = ".test { color: #{color_str}; }"
59
+ sheet = Cataract.parse_css(css)
60
+ sheet.convert_colors!(from: from_format, to: to_format)
61
+ decls = Cataract::Declarations.new(sheet.declarations)
62
+ decls['color']
63
+ end
64
+
65
+ def auto_detect_convert(color_str, to_format)
66
+ css = ".test { color: #{color_str}; }"
67
+ sheet = Cataract.parse_css(css)
68
+ sheet.convert_colors!(to: to_format)
69
+ decls = Cataract::Declarations.new(sheet.declarations)
70
+ decls['color']
71
+ end
72
+
73
+ def hex_difference(hex1, hex2)
74
+ # Parse hex colors and calculate RGB distance
75
+ r1, g1, b1 = hex1.scan(/[0-9a-f]{2}/i).map { |h| h.to_i(16) }
76
+ r2, g2, b2 = hex2.scan(/[0-9a-f]{2}/i).map { |h| h.to_i(16) }
77
+
78
+ Math.sqrt(((r1 - r2)**2) + ((g1 - g2)**2) + ((b1 - b2)**2))
79
+ end
80
+
81
+ # Generate forward conversion tests (named → rgb → hwb → hsl → oklab → lab → lch)
82
+ def generate_forward_tests(colors)
83
+ colors.map do |color_name|
84
+ # Start with named color
85
+ named = color_name
86
+
87
+ # Convert through each format in sequence
88
+ rgb = convert_color(named, :named, :rgb)
89
+ hwb = convert_color(rgb, :rgb, :hwb)
90
+ hsl = convert_color(hwb, :hwb, :hsl)
91
+ oklab = convert_color(hsl, :hsl, :oklab)
92
+ lab = convert_color(oklab, :oklab, :lab)
93
+ lch = convert_color(lab, :lab, :lch)
94
+
95
+ {
96
+ name: color_name,
97
+ named: named,
98
+ rgb: rgb,
99
+ hwb: hwb,
100
+ hsl: hsl,
101
+ oklab: oklab,
102
+ lab: lab,
103
+ lch: lch
104
+ }
105
+ end
106
+ end
107
+
108
+ # Generate reverse conversion tests (lch → lab → oklab → hsl → hwb → rgb → hex)
109
+ def generate_reverse_tests(colors)
110
+ colors.map do |color_name|
111
+ # Get original hex value
112
+ original_hex = convert_color(color_name, :named, :hex)
113
+
114
+ # Convert to LCH first
115
+ lch = convert_color(color_name, :named, :lch)
116
+
117
+ # Convert back through the chain
118
+ lab = convert_color(lch, :lch, :lab)
119
+ oklab = convert_color(lab, :lab, :oklab)
120
+ hsl = convert_color(oklab, :oklab, :hsl)
121
+ hwb = convert_color(hsl, :hsl, :hwb)
122
+ rgb = convert_color(hwb, :hwb, :rgb)
123
+ hex = convert_color(rgb, :rgb, :hex)
124
+
125
+ # Calculate match quality
126
+ diff = hex_difference(original_hex, hex)
127
+ match_class = if diff.zero?
128
+ 'perfect'
129
+ elsif diff < 2
130
+ 'close'
131
+ else
132
+ 'off'
133
+ end
134
+
135
+ match_symbol = case match_class
136
+ when 'perfect' then '✓'
137
+ when 'close' then '≈'
138
+ else '✗'
139
+ end
140
+
141
+ {
142
+ name: color_name,
143
+ original_hex: original_hex,
144
+ lch: lch,
145
+ lab: lab,
146
+ oklab: oklab,
147
+ hsl: hsl,
148
+ hwb: hwb,
149
+ rgb: rgb,
150
+ hex: hex,
151
+ match_class: match_class,
152
+ match_symbol: match_symbol,
153
+ diff: diff
154
+ }
155
+ end
156
+ end
157
+
158
+ # Calculate statistics
159
+ def calculate_stats(forward_tests, reverse_tests)
160
+ perfect = reverse_tests.count { |t| t[:match_class] == 'perfect' }
161
+ close = reverse_tests.count { |t| t[:match_class] == 'close' }
162
+
163
+ {
164
+ total_tests: forward_tests.length,
165
+ perfect_matches: perfect,
166
+ close_matches: close,
167
+ formats_tested: 7 # hex, rgb, hwb, hsl, oklab, lab, lch
168
+ }
169
+ end
170
+
171
+ # Main execution
172
+ puts 'Generating color conversion visual test...'
173
+ puts "Testing #{SAMPLE_COLORS.length} colors..."
174
+
175
+ # Generate test data
176
+ forward_tests = generate_forward_tests(SAMPLE_COLORS)
177
+ puts '✓ Generated forward conversion tests'
178
+
179
+ reverse_tests = generate_reverse_tests(SAMPLE_COLORS)
180
+ puts '✓ Generated reverse conversion tests'
181
+
182
+ stats = calculate_stats(forward_tests, reverse_tests)
183
+ puts '✓ Calculated statistics'
184
+
185
+ # Load ERB template
186
+ template_path = File.join(__dir__, 'template.html.erb')
187
+ template = ERB.new(File.read(template_path), trim_mode: '-')
188
+
189
+ # Render HTML
190
+ html = template.result(binding)
191
+
192
+ # Write output
193
+ output_path = File.join(__dir__, 'color_conversion_test.html')
194
+ File.write(output_path, html)
195
+
196
+ puts "\n✓ Generated: #{output_path}"
197
+ puts "\nStatistics:"
198
+ puts " Total colors tested: #{stats[:total_tests]}"
199
+ puts " Perfect round-trips: #{stats[:perfect_matches]}"
200
+ puts " Close matches: #{stats[:close_matches]}"
201
+ puts " Formats: #{stats[:formats_tested]}"
202
+ puts "\nOpen #{output_path} in a browser to view the results."
@@ -0,0 +1,259 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Cataract Color Conversion Visual Test</title>
7
+
8
+ <!-- Bootstrap CSS -->
9
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
10
+
11
+ <style>
12
+ body {
13
+ background: #f8f9fa;
14
+ }
15
+ .color-cell {
16
+ width: 80px;
17
+ height: 40px;
18
+ border: 1px solid #dee2e6;
19
+ border-radius: 4px;
20
+ display: inline-block;
21
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
22
+ cursor: help;
23
+ }
24
+ .match-indicator {
25
+ font-size: 1.5em;
26
+ }
27
+ .match-indicator.perfect { color: #198754; }
28
+ .match-indicator.close { color: #ffc107; }
29
+ .match-indicator.off { color: #dc3545; }
30
+ .stat-value {
31
+ font-size: 2.5em;
32
+ font-weight: bold;
33
+ color: #0d6efd;
34
+ }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <div class="container-fluid py-5">
39
+ <h1 class="mb-4">🎨 Cataract Color Conversion Visual Test</h1>
40
+
41
+ <div class="card mb-4">
42
+ <div class="card-body">
43
+ <p><strong>Purpose:</strong> This page visually validates color conversions across different CSS color formats.</p>
44
+ <p>If conversions are correct, all color swatches in each row should appear identical.</p>
45
+ <p><strong>Tip:</strong> Hover over any color swatch to see the CSS color value.</p>
46
+ <p class="mb-0"><strong>Generated:</strong> <%= Time.now.strftime('%Y-%m-%d %H:%M:%S') %></p>
47
+ </div>
48
+ </div>
49
+
50
+ <!-- Tabs for Forward/Reverse Conversion -->
51
+ <ul class="nav nav-tabs mb-3" id="conversionTabs" role="tablist">
52
+ <li class="nav-item" role="presentation">
53
+ <button class="nav-link active" id="forward-tab" data-bs-toggle="tab" data-bs-target="#forward" type="button" role="tab" aria-controls="forward" aria-selected="true">
54
+ Forward: Named → RGB → HWB → HSL → Oklab → Lab → LCH
55
+ </button>
56
+ </li>
57
+ <li class="nav-item" role="presentation">
58
+ <button class="nav-link" id="reverse-tab" data-bs-toggle="tab" data-bs-target="#reverse" type="button" role="tab" aria-controls="reverse" aria-selected="false">
59
+ Reverse: LCH → Lab → Oklab → HSL → HWB → RGB → Hex
60
+ </button>
61
+ </li>
62
+ </ul>
63
+
64
+ <div class="tab-content mb-5" id="conversionTabContent">
65
+ <!-- Forward conversion tab -->
66
+ <div class="tab-pane fade show active" id="forward" role="tabpanel" aria-labelledby="forward-tab">
67
+ <p class="text-muted mb-3">Starting from named colors, converting through each format. All swatches should match.</p>
68
+
69
+ <div class="table-responsive">
70
+ <table class="table table-striped table-hover">
71
+ <thead class="table-light">
72
+ <tr>
73
+ <th>Color Name</th>
74
+ <th>Named</th>
75
+ <th>RGB</th>
76
+ <th>HWB</th>
77
+ <th>HSL</th>
78
+ <th>Oklab</th>
79
+ <th>Lab</th>
80
+ <th>LCH</th>
81
+ </tr>
82
+ </thead>
83
+ <tbody>
84
+ <% forward_tests.each do |test| %>
85
+ <tr>
86
+ <td class="fw-bold"><%= test[:name] %></td>
87
+ <td>
88
+ <div class="color-cell" style="background-color: <%= test[:named] %>;"
89
+ data-bs-toggle="tooltip" data-bs-placement="top"
90
+ title="<%= test[:named] %>"></div>
91
+ </td>
92
+ <td>
93
+ <div class="color-cell" style="background-color: <%= test[:rgb] %>;"
94
+ data-bs-toggle="tooltip" data-bs-placement="top"
95
+ title="<%= test[:rgb] %>"></div>
96
+ </td>
97
+ <td>
98
+ <div class="color-cell" style="background-color: <%= test[:hwb] %>;"
99
+ data-bs-toggle="tooltip" data-bs-placement="top"
100
+ title="<%= test[:hwb] %>"></div>
101
+ </td>
102
+ <td>
103
+ <div class="color-cell" style="background-color: <%= test[:hsl] %>;"
104
+ data-bs-toggle="tooltip" data-bs-placement="top"
105
+ title="<%= test[:hsl] %>"></div>
106
+ </td>
107
+ <td>
108
+ <div class="color-cell" style="background-color: <%= test[:oklab] %>;"
109
+ data-bs-toggle="tooltip" data-bs-placement="top"
110
+ title="<%= test[:oklab] %>"></div>
111
+ </td>
112
+ <td>
113
+ <div class="color-cell" style="background-color: <%= test[:lab] %>;"
114
+ data-bs-toggle="tooltip" data-bs-placement="top"
115
+ title="<%= test[:lab] %>"></div>
116
+ </td>
117
+ <td>
118
+ <div class="color-cell" style="background-color: <%= test[:lch] %>;"
119
+ data-bs-toggle="tooltip" data-bs-placement="top"
120
+ title="<%= test[:lch] %>"></div>
121
+ </td>
122
+ </tr>
123
+ <% end %>
124
+ </tbody>
125
+ </table>
126
+ </div>
127
+ </div>
128
+
129
+ <!-- Reverse conversion tab -->
130
+ <div class="tab-pane fade" id="reverse" role="tabpanel" aria-labelledby="reverse-tab">
131
+ <p class="text-muted mb-3">Starting from LCH, converting back through each format. Some precision loss expected but should be visually close.</p>
132
+
133
+ <div class="table-responsive">
134
+ <table class="table table-striped table-hover">
135
+ <thead class="table-light">
136
+ <tr>
137
+ <th>Original</th>
138
+ <th>LCH</th>
139
+ <th>Lab</th>
140
+ <th>Oklab</th>
141
+ <th>HSL</th>
142
+ <th>HWB</th>
143
+ <th>RGB</th>
144
+ <th>Hex</th>
145
+ <th>Match</th>
146
+ </tr>
147
+ </thead>
148
+ <tbody>
149
+ <% reverse_tests.each do |test| %>
150
+ <tr>
151
+ <td>
152
+ <div class="color-cell" style="background-color: <%= test[:original_hex] %>;"
153
+ data-bs-toggle="tooltip" data-bs-placement="top"
154
+ title="<%= test[:original_hex] %>"></div>
155
+ <div class="small fw-bold mt-1"><%= test[:name] %></div>
156
+ </td>
157
+ <td>
158
+ <div class="color-cell" style="background-color: <%= test[:lch] %>;"
159
+ data-bs-toggle="tooltip" data-bs-placement="top"
160
+ title="<%= test[:lch] %>"></div>
161
+ </td>
162
+ <td>
163
+ <div class="color-cell" style="background-color: <%= test[:lab] %>;"
164
+ data-bs-toggle="tooltip" data-bs-placement="top"
165
+ title="<%= test[:lab] %>"></div>
166
+ </td>
167
+ <td>
168
+ <div class="color-cell" style="background-color: <%= test[:oklab] %>;"
169
+ data-bs-toggle="tooltip" data-bs-placement="top"
170
+ title="<%= test[:oklab] %>"></div>
171
+ </td>
172
+ <td>
173
+ <div class="color-cell" style="background-color: <%= test[:hsl] %>;"
174
+ data-bs-toggle="tooltip" data-bs-placement="top"
175
+ title="<%= test[:hsl] %>"></div>
176
+ </td>
177
+ <td>
178
+ <div class="color-cell" style="background-color: <%= test[:hwb] %>;"
179
+ data-bs-toggle="tooltip" data-bs-placement="top"
180
+ title="<%= test[:hwb] %>"></div>
181
+ </td>
182
+ <td>
183
+ <div class="color-cell" style="background-color: <%= test[:rgb] %>;"
184
+ data-bs-toggle="tooltip" data-bs-placement="top"
185
+ title="<%= test[:rgb] %>"></div>
186
+ </td>
187
+ <td>
188
+ <div class="color-cell" style="background-color: <%= test[:hex] %>;"
189
+ data-bs-toggle="tooltip" data-bs-placement="top"
190
+ title="<%= test[:hex] %>"></div>
191
+ </td>
192
+ <td class="match-indicator <%= test[:match_class] %>"
193
+ data-bs-toggle="tooltip" data-bs-placement="top"
194
+ title="RGB distance: <%= '%.2f' % test[:diff] %>">
195
+ <%= test[:match_symbol] %>
196
+ </td>
197
+ </tr>
198
+ <% end %>
199
+ </tbody>
200
+ </table>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <!-- Statistics -->
206
+ <div class="mb-5">
207
+ <h2 class="mb-3">Conversion Statistics</h2>
208
+ <div class="row g-4">
209
+ <div class="col-md-3">
210
+ <div class="card text-center">
211
+ <div class="card-body">
212
+ <div class="stat-value"><%= stats[:total_tests] %></div>
213
+ <div class="text-muted">Total Tests</div>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ <div class="col-md-3">
218
+ <div class="card text-center">
219
+ <div class="card-body">
220
+ <div class="stat-value"><%= stats[:perfect_matches] %></div>
221
+ <div class="text-muted">Perfect Round-trips</div>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ <div class="col-md-3">
226
+ <div class="card text-center">
227
+ <div class="card-body">
228
+ <div class="stat-value"><%= stats[:close_matches] %></div>
229
+ <div class="text-muted">Close Matches</div>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ <div class="col-md-3">
234
+ <div class="card text-center">
235
+ <div class="card-body">
236
+ <div class="stat-value"><%= stats[:formats_tested] %></div>
237
+ <div class="text-muted">Formats Tested</div>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ </div>
243
+
244
+ <footer class="text-center text-muted mt-5">
245
+ Generated by Cataract Color Conversion Test Suite
246
+ </footer>
247
+ </div>
248
+
249
+ <!-- Bootstrap Bundle with Popper -->
250
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
251
+
252
+ <!-- Initialize tooltips -->
253
+ <script>
254
+ // Enable all tooltips
255
+ const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
256
+ const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
257
+ </script>
258
+ </body>
259
+ </html>
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'uri'
5
+ require_relative '../../lib/cataract'
6
+ require_relative 'analyzers/properties'
7
+ require_relative 'analyzers/colors'
8
+ require_relative 'analyzers/specificity'
9
+ require_relative 'analyzers/important'
10
+
11
+ module CSSAnalyzer
12
+ # Main analyzer orchestrator that coordinates all analysis modules
13
+ class Analyzer
14
+ attr_reader :stylesheet, :source, :options, :timings
15
+
16
+ def initialize(source, options = {})
17
+ @source = source
18
+ @options = {
19
+ top: 20,
20
+ use_shim: false
21
+ }.merge(options)
22
+ @timings = {}
23
+
24
+ # Load shim if requested
25
+ if @options[:use_shim]
26
+ require_relative '../../lib/cataract/css_parser_compat'
27
+ Cataract.mimic_CssParser!
28
+ end
29
+
30
+ # Load CSS based on source type
31
+ @stylesheet = load_css(source)
32
+ end
33
+
34
+ # Run all analyses and collect results
35
+ def analyze_all
36
+ {
37
+ summary: analyze_summary,
38
+ properties: Analyzers::Properties.new(stylesheet, options).analyze,
39
+ colors: Analyzers::Colors.new(stylesheet, options).analyze,
40
+ specificity: Analyzers::Specificity.new(stylesheet, options).analyze,
41
+ important: Analyzers::Important.new(stylesheet, options).analyze
42
+ }
43
+ end
44
+
45
+ # Generate summary statistics
46
+ def analyze_summary
47
+ {
48
+ total_rules: stylesheet.rules_count,
49
+ file_name: source_name,
50
+ file_path: source,
51
+ generated_at: Time.now
52
+ }
53
+ end
54
+
55
+ # Generate HTML report
56
+ def generate_report
57
+ analysis = analyze_all
58
+ template = ERB.new(template_content, trim_mode: '-')
59
+ template.result(binding)
60
+ end
61
+
62
+ # Save report to file or stdout
63
+ def save_report
64
+ report = generate_report
65
+
66
+ if options[:output]
67
+ File.write(options[:output], report)
68
+ puts "Report saved to #{options[:output]}"
69
+ else
70
+ puts report
71
+ end
72
+
73
+ # Also save the parsed CSS for debugging
74
+ save_parsed_css
75
+ end
76
+
77
+ # Save parsed CSS to a file for debugging/comparison
78
+ def save_parsed_css
79
+ # Generate a unique filename based on source and shim usage
80
+ source_slug = @source.gsub(%r{[:/]}, '_').gsub(/[^a-zA-Z0-9_.-]/, '')
81
+ shim_suffix = @options[:use_shim] ? '-shim' : '-direct'
82
+ filename = "parsed-css-#{source_slug}#{shim_suffix}.css"
83
+
84
+ # Serialize stylesheet to CSS
85
+ css_output = @stylesheet.to_s
86
+
87
+ File.write(filename, css_output)
88
+ warn "Parsed CSS saved to #{filename} (#{@stylesheet.size} rules, #{css_output.length} bytes)"
89
+ end
90
+
91
+ private
92
+
93
+ def load_css(source)
94
+ # Check if it's a URL
95
+ if /\A#{URI::DEFAULT_PARSER.make_regexp(%w[http https])}\z/.match?(source)
96
+ load_from_url(source)
97
+ elsif File.exist?(source)
98
+ # Local file
99
+ Cataract::Stylesheet.load_file(source)
100
+ else
101
+ raise ArgumentError, "Invalid source: #{source} (not a valid URL or file path)"
102
+ end
103
+ end
104
+
105
+ def load_from_url(url)
106
+ # Check if it's a direct CSS file
107
+ if url.end_with?('.css')
108
+ Cataract::Stylesheet.load_uri(url)
109
+ else
110
+ # It's a webpage - use Premailer to fetch and combine all CSS
111
+ load_from_webpage(url)
112
+ end
113
+ end
114
+
115
+ def load_from_webpage(url)
116
+ require 'premailer'
117
+
118
+ # Fetch webpage and extract CSS
119
+ fetch_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
120
+ premailer = Premailer.new(url, with_html_string: false)
121
+ @timings[:fetch] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - fetch_start
122
+
123
+ # Get CSS parser from Premailer
124
+ parser = premailer.instance_variable_get(:@css_parser)
125
+
126
+ # If using Cataract shim, parser is already a Cataract::Stylesheet - use it directly
127
+ if defined?(CssParser::CATARACT_SHIM) && CssParser::CATARACT_SHIM
128
+ @timings[:premailer_parse] = 0 # Already parsed by Premailer/Cataract
129
+ @timings[:cataract_parse] = 0 # No reparsing needed
130
+ parser # Return the Cataract::Stylesheet directly
131
+ else
132
+ # Not using shim - parser is real css_parser, get CSS string and reparse
133
+ parse_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
134
+ css_string = parser.to_s
135
+ @timings[:premailer_parse] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - parse_start
136
+
137
+ # Parse it with Cataract
138
+ cataract_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
+ stylesheet = Cataract.parse_css(css_string)
140
+ @timings[:cataract_parse] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - cataract_start
141
+
142
+ stylesheet
143
+ end
144
+ end
145
+
146
+ def source_name
147
+ if /\A#{URI::DEFAULT_PARSER.make_regexp(%w[http https])}\z/.match?(source)
148
+ uri = URI.parse(source)
149
+ if source.end_with?('.css')
150
+ File.basename(uri.path)
151
+ else
152
+ uri.host
153
+ end
154
+ else
155
+ File.basename(source)
156
+ end
157
+ end
158
+
159
+ def template_content
160
+ template_path = File.join(__dir__, 'templates', 'report.html.erb')
161
+ File.read(template_path)
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSSAnalyzer
4
+ module Analyzers
5
+ # Base class for all analyzers
6
+ # Each analyzer should implement #analyze method that returns a hash of results
7
+ class Base
8
+ attr_reader :stylesheet, :options
9
+
10
+ def initialize(stylesheet, options = {})
11
+ @stylesheet = stylesheet
12
+ @options = options
13
+ end
14
+
15
+ # Override in subclasses
16
+ def analyze
17
+ raise NotImplementedError, "#{self.class} must implement #analyze"
18
+ end
19
+
20
+ # Helper to get the tab name for this analyzer
21
+ def tab_name
22
+ self.class.name.split('::').last.downcase
23
+ end
24
+
25
+ # Helper to get media queries for a specific rule
26
+ # @param rule [Cataract::Rule] The rule to check
27
+ # @return [Array<Symbol>] Array of media query symbols this rule appears in
28
+ def media_queries_for_rule(rule)
29
+ stylesheet.instance_variable_get(:@_media_index).select { |_media, ids| ids.include?(rule.id) }.keys
30
+ end
31
+ end
32
+ end
33
+ end