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,716 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cataract
4
+ # Represents a parsed CSS stylesheet with rule management and merging capabilities.
5
+ #
6
+ # The Stylesheet class stores parsed CSS rules in a flat array structure that preserves
7
+ # insertion order. Media queries are tracked via an index that maps media query symbols
8
+ # to rule IDs, allowing efficient filtering and serialization.
9
+ #
10
+ # @example Parse and query CSS
11
+ # sheet = Cataract::Stylesheet.parse("body { color: red; }")
12
+ # sheet.size #=> 1
13
+ # sheet.rules.first.selector #=> "body"
14
+ #
15
+ # @example Work with media queries
16
+ # sheet = Cataract.parse_css("@media print { .footer { color: blue; } }")
17
+ # sheet.media_queries #=> [:print]
18
+ # sheet.with_media(:print).first.selector #=> ".footer"
19
+ #
20
+ # @attr_reader [Array<Rule>] rules Array of parsed CSS rules
21
+ # @attr_reader [String, nil] charset The @charset declaration if present
22
+ class Stylesheet
23
+ include Enumerable
24
+
25
+ # @return [Array<Rule>] Array of parsed CSS rules
26
+ attr_reader :rules
27
+
28
+ # @return [String, nil] The @charset declaration if present
29
+ attr_reader :charset
30
+
31
+ # Create a new empty stylesheet.
32
+ #
33
+ # @param options [Hash] Configuration options
34
+ # @option options [Boolean, Hash] :import (false) Enable @import resolution.
35
+ # Pass true for defaults, or a hash with:
36
+ # - :allowed_schemes [Array<String>] URI schemes to allow (default: ['https'])
37
+ # - :extensions [Array<String>] File extensions to allow (default: ['css'])
38
+ # - :max_depth [Integer] Maximum import nesting (default: 5)
39
+ # - :base_path [String] Base directory for relative imports
40
+ # @option options [Boolean] :io_exceptions (true) Whether to raise exceptions
41
+ # on I/O errors (file not found, network errors, etc.)
42
+ def initialize(options = {})
43
+ @options = {
44
+ import: false,
45
+ io_exceptions: true
46
+ }.merge(options)
47
+
48
+ @rules = [] # Flat array of Rule structs
49
+ @_media_index = {} # Hash: Symbol => Array of rule IDs
50
+ @charset = nil
51
+ end
52
+
53
+ # Parse CSS and return a new Stylesheet
54
+ #
55
+ # @param css [String] CSS string to parse
56
+ # @param options [Hash] Options passed to Stylesheet.new
57
+ # @return [Stylesheet] Parsed stylesheet
58
+ def self.parse(css, **options)
59
+ sheet = new(options)
60
+ sheet.add_block(css)
61
+ sheet
62
+ end
63
+
64
+ # Load CSS from a file and return a new Stylesheet.
65
+ #
66
+ # @param filename [String] Path to the CSS file
67
+ # @param base_dir [String] Base directory for resolving the filename (default: '.')
68
+ # @param options [Hash] Options passed to Stylesheet.new
69
+ # @return [Stylesheet] A new Stylesheet containing the parsed CSS
70
+ def self.load_file(filename, base_dir = '.', **options)
71
+ sheet = new(options)
72
+ sheet.load_file(filename, base_dir)
73
+ sheet
74
+ end
75
+
76
+ # Load CSS from a URI and return a new Stylesheet.
77
+ #
78
+ # @param uri [String] URI to load CSS from (http://, https://, or file://)
79
+ # @param options [Hash] Options passed to Stylesheet.new
80
+ # @return [Stylesheet] A new Stylesheet containing the parsed CSS
81
+ def self.load_uri(uri, **options)
82
+ sheet = new(options)
83
+ sheet.load_uri(uri, options)
84
+ sheet
85
+ end
86
+
87
+ # Iterate over all rules (required by Enumerable).
88
+ #
89
+ # Yields both selector-based rules (Rule) and at-rules (AtRule).
90
+ # Use rule.selector? to filter for selector-based rules only.
91
+ #
92
+ # @yield [rule] Block to execute for each rule
93
+ # @yieldparam rule [Rule, AtRule] The rule object
94
+ # @return [Enumerator] Returns enumerator if no block given
95
+ #
96
+ # @example Iterate over all rules
97
+ # sheet.each { |rule| puts rule.selector }
98
+ #
99
+ # @example Filter to selector-based rules only
100
+ # sheet.select(&:selector?).each { |rule| puts rule.specificity }
101
+ def each(&)
102
+ return enum_for(:each) unless block_given?
103
+
104
+ @rules.each(&)
105
+ end
106
+
107
+ # Filter rules by media query symbol(s).
108
+ #
109
+ # Returns a chainable StylesheetScope that can be further filtered.
110
+ #
111
+ # @param media [Symbol, Array<Symbol>] Media query symbol(s) to filter by
112
+ # @return [StylesheetScope] Scope with media filter applied
113
+ #
114
+ # @example Get print media rules
115
+ # sheet.with_media(:print).each { |rule| puts rule.selector }
116
+ # sheet.with_media(:print).select(&:selector?).map(&:selector)
117
+ #
118
+ # @example Get rules from multiple media queries
119
+ # sheet.with_media([:screen, :print]).map(&:selector)
120
+ #
121
+ # @example Chain filters
122
+ # sheet.with_media(:print).with_specificity(10..).to_a
123
+ def with_media(media)
124
+ StylesheetScope.new(self, media: media)
125
+ end
126
+
127
+ # Filter rules by CSS specificity.
128
+ #
129
+ # Returns a chainable StylesheetScope that can be further filtered.
130
+ #
131
+ # @param specificity [Integer, Range] Specificity value or range
132
+ # @return [StylesheetScope] Scope with specificity filter applied
133
+ #
134
+ # @example Get high-specificity rules
135
+ # sheet.with_specificity(100..).each { |rule| puts rule.selector }
136
+ #
137
+ # @example Get exact specificity
138
+ # sheet.with_specificity(10).map(&:selector)
139
+ #
140
+ # @example Chain with media filter
141
+ # sheet.with_media(:print).with_specificity(10..50).to_a
142
+ def with_specificity(specificity)
143
+ StylesheetScope.new(self, specificity: specificity)
144
+ end
145
+
146
+ # Filter rules by CSS selector.
147
+ #
148
+ # Returns a chainable StylesheetScope that can be further filtered.
149
+ # Supports both exact string matching and regular expression patterns.
150
+ #
151
+ # @param selector [String, Regexp] CSS selector to match (exact or pattern)
152
+ # @return [StylesheetScope] Scope with selector filter applied
153
+ #
154
+ # @example Find body rules (exact match)
155
+ # sheet.with_selector('body').to_a
156
+ #
157
+ # @example Find all .btn-* classes (pattern match)
158
+ # sheet.with_selector(/\.btn-/).map(&:selector)
159
+ #
160
+ # @example Find body rules in print media
161
+ # sheet.with_media(:print).with_selector('body').each { |r| puts r }
162
+ #
163
+ # @example Chain multiple filters
164
+ # sheet.with_selector('.header').with_specificity(10..).to_a
165
+ def with_selector(selector)
166
+ StylesheetScope.new(self, selector: selector)
167
+ end
168
+
169
+ # Filter rules by CSS property name and optional value.
170
+ #
171
+ # Returns a chainable StylesheetScope that can be further filtered.
172
+ #
173
+ # @param property [String] CSS property name to match
174
+ # @param value [String, nil] Optional property value to match
175
+ # @return [StylesheetScope] Scope with property filter applied
176
+ #
177
+ # @example Find all rules with color property
178
+ # sheet.with_property('color').map(&:selector)
179
+ #
180
+ # @example Find rules with position: absolute
181
+ # sheet.with_property('position', 'absolute').to_a
182
+ #
183
+ # @example Chain with media filter
184
+ # sheet.with_media(:screen).with_property('z-index').to_a
185
+ def with_property(property, value = nil)
186
+ StylesheetScope.new(self, property: property, property_value: value)
187
+ end
188
+
189
+ # Filter to only base rules (rules not inside any @media query).
190
+ #
191
+ # Returns a chainable StylesheetScope that can be further filtered.
192
+ #
193
+ # @return [StylesheetScope] Scope with base_only filter applied
194
+ #
195
+ # @example Get base rules only
196
+ # sheet.base_only.map(&:selector)
197
+ #
198
+ # @example Chain with property filter
199
+ # sheet.base_only.with_property('color').to_a
200
+ def base_only
201
+ StylesheetScope.new(self, base_only: true)
202
+ end
203
+
204
+ # Filter by at-rule type.
205
+ #
206
+ # Returns a chainable StylesheetScope that can be further filtered.
207
+ #
208
+ # @param type [Symbol] At-rule type to match (:keyframes, :font_face, etc.)
209
+ # @return [StylesheetScope] Scope with at-rule type filter applied
210
+ #
211
+ # @example Find all @keyframes
212
+ # sheet.with_at_rule_type(:keyframes).map(&:selector)
213
+ #
214
+ # @example Find all @font-face
215
+ # sheet.with_at_rule_type(:font_face).to_a
216
+ #
217
+ # @example Chain with media filter
218
+ # sheet.with_media(:screen).with_at_rule_type(:keyframes).to_a
219
+ def with_at_rule_type(type)
220
+ StylesheetScope.new(self, at_rule_type: type)
221
+ end
222
+
223
+ # Filter to rules with !important declarations.
224
+ #
225
+ # Returns a chainable StylesheetScope that can be further filtered.
226
+ #
227
+ # @param property [String, nil] Optional property name to match
228
+ # @return [StylesheetScope] Scope with important filter applied
229
+ #
230
+ # @example Find all rules with any !important
231
+ # sheet.with_important.map(&:selector)
232
+ #
233
+ # @example Find rules with color !important
234
+ # sheet.with_important('color').to_a
235
+ #
236
+ # @example Chain with media filter
237
+ # sheet.with_media(:screen).with_important.to_a
238
+ def with_important(property = nil)
239
+ StylesheetScope.new(self, important: true, important_property: property)
240
+ end
241
+
242
+ # Get all rules without media query (rules that apply to all media)
243
+ #
244
+ # @return [Array<Rule>] Rules with no media query
245
+ def base_rules
246
+ # Rules not in any media_index entry
247
+ media_rule_ids = @_media_index.values.flatten.uniq
248
+ @rules.select.with_index { |_rule, idx| !media_rule_ids.include?(idx) }
249
+ end
250
+
251
+ # Get all unique media query symbols
252
+ #
253
+ # @return [Array<Symbol>] Array of unique media query symbols
254
+ def media_queries
255
+ @_media_index.keys
256
+ end
257
+
258
+ # Get all selectors
259
+ #
260
+ # @return [Array<String>] Array of all selectors
261
+ def selectors
262
+ @selectors ||= @rules.map(&:selector)
263
+ end
264
+
265
+ # Serialize to CSS string
266
+ #
267
+ # Converts the stylesheet to a CSS string. Optionally filters output
268
+ # to only include rules from specific media queries.
269
+ #
270
+ # @param media [Symbol, Array<Symbol>] Media type(s) to include (default: :all)
271
+ # - :all - Output all rules including base rules and all media queries
272
+ # - :screen, :print, etc. - Output only rules from specified media query
273
+ # - [:screen, :print] - Output rules from multiple media queries
274
+ #
275
+ # Important: When filtering to specific media types, base rules (rules not
276
+ # inside any @media block) are NOT included. Only rules explicitly inside
277
+ # the requested @media queries are output. Use :all to include base rules.
278
+ # @return [String] CSS string
279
+ #
280
+ # @example Get all CSS
281
+ # sheet.to_s # => "body { color: black; } @media print { .footer { color: red; } }"
282
+ # sheet.to_s(media: :all) # => "body { color: black; } @media print { .footer { color: red; } }"
283
+ #
284
+ # @example Filter to specific media type (excludes base rules)
285
+ # sheet.to_s(media: :print) # => "@media print { .footer { color: red; } }"
286
+ # # Note: base rules like "body { color: black; }" are NOT included
287
+ #
288
+ # @example Filter to multiple media types
289
+ # sheet.to_s(media: [:screen, :print]) # => "@media screen { ... } @media print { ... }"
290
+ def to_s(media: :all)
291
+ which_media = media
292
+ # Normalize to array for consistent filtering
293
+ which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
294
+
295
+ # If :all is present, return everything (no filtering)
296
+ if which_media_array.include?(:all)
297
+ Cataract._stylesheet_to_s(@rules, @_media_index, @charset, @_has_nesting || false)
298
+ else
299
+ # Collect all rule IDs that match the requested media types
300
+ matching_rule_ids = Set.new
301
+ which_media_array.each do |media_sym|
302
+ if @_media_index[media_sym]
303
+ matching_rule_ids.merge(@_media_index[media_sym])
304
+ end
305
+ end
306
+
307
+ # Build filtered rules array (keep original IDs, no recreation needed)
308
+ filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
309
+
310
+ # Build filtered media_index (keep original IDs, just filter to included rules)
311
+ filtered_media_index = {}
312
+ which_media_array.each do |media_sym|
313
+ if @_media_index[media_sym]
314
+ filtered_media_index[media_sym] = @_media_index[media_sym] & matching_rule_ids.to_a
315
+ end
316
+ end
317
+
318
+ # C serialization with filtered data
319
+ # Note: Filtered rules might still contain nesting, so pass the flag
320
+ Cataract._stylesheet_to_s(filtered_rules, filtered_media_index, @charset, @_has_nesting || false)
321
+ end
322
+ end
323
+ alias to_css to_s
324
+
325
+ # Serialize to formatted CSS string with indentation and newlines.
326
+ #
327
+ # Converts the stylesheet to a human-readable CSS string with proper indentation.
328
+ # Rules are formatted with each declaration on its own line, and media queries
329
+ # are properly indented. Optionally filters output to specific media queries.
330
+ #
331
+ # @param which_media [Symbol, Array<Symbol>] Optional media filter (default: :all)
332
+ # - :all - Output all rules including base rules and all media queries
333
+ # - :screen, :print, etc. - Output only rules from specified media query
334
+ # - [:screen, :print] - Output rules from multiple media queries
335
+ #
336
+ # @return [String] Formatted CSS string
337
+ #
338
+ # @example Get all CSS formatted
339
+ # sheet.to_formatted_s
340
+ # # => "body {\n color: black;\n}\n@media print {\n .footer {\n color: red;\n }\n}\n"
341
+ #
342
+ # @example Filter to specific media type
343
+ # sheet.to_formatted_s(:print)
344
+ #
345
+ # @see #to_s For compact single-line output
346
+ def to_formatted_s(media: :all)
347
+ which_media = media
348
+ # Normalize to array for consistent filtering
349
+ which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
350
+
351
+ # If :all is present, return everything (no filtering)
352
+ if which_media_array.include?(:all)
353
+ Cataract._stylesheet_to_formatted_s(@rules, @_media_index, @charset, @_has_nesting || false)
354
+ else
355
+ # Collect all rule IDs that match the requested media types
356
+ matching_rule_ids = Set.new
357
+
358
+ # Include rules not in any media query (they apply to all media)
359
+ media_rule_ids = @_media_index.values.flatten.uniq
360
+ all_rule_ids = (0...@rules.length).to_a
361
+ non_media_rule_ids = all_rule_ids - media_rule_ids
362
+ matching_rule_ids.merge(non_media_rule_ids)
363
+
364
+ # Include rules from requested media types
365
+ which_media_array.each do |media_sym|
366
+ if @_media_index[media_sym]
367
+ matching_rule_ids.merge(@_media_index[media_sym])
368
+ end
369
+ end
370
+
371
+ # Build filtered rules array (keep original IDs, no recreation needed)
372
+ filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
373
+
374
+ # Build filtered media_index (keep original IDs, just filter to included rules)
375
+ filtered_media_index = {}
376
+ which_media_array.each do |media_sym|
377
+ if @_media_index[media_sym]
378
+ filtered_media_index[media_sym] = @_media_index[media_sym] & matching_rule_ids.to_a
379
+ end
380
+ end
381
+
382
+ # C serialization with filtered data
383
+ # Note: Filtered rules might still contain nesting, so pass the flag
384
+ Cataract._stylesheet_to_formatted_s(filtered_rules, filtered_media_index, @charset, @_has_nesting || false)
385
+ end
386
+ end
387
+
388
+ # Get number of rules
389
+ #
390
+ # @return [Integer] Number of rules
391
+ def size
392
+ @rules.length
393
+ end
394
+ alias length size
395
+ alias rules_count size
396
+
397
+ # Check if stylesheet is empty
398
+ #
399
+ # @return [Boolean] true if no rules
400
+ def empty?
401
+ @rules.empty?
402
+ end
403
+
404
+ # Clear all rules
405
+ #
406
+ # @return [self] Returns self for method chaining
407
+ def clear!
408
+ @rules.clear
409
+ @_media_index.clear
410
+ @charset = nil
411
+ @selectors = nil # Clear memoized cache
412
+ self
413
+ end
414
+
415
+ # Load CSS from a local file and add to this stylesheet.
416
+ #
417
+ # @param filename [String] Path to the CSS file
418
+ # @param base_dir [String] Base directory for resolving the filename (default: '.')
419
+ # @return [self] Returns self for method chaining
420
+ def load_file(filename, base_dir = '.', _media_types = :all)
421
+ # Normalize file path and convert to file:// URI
422
+ file_path = File.expand_path(filename, base_dir)
423
+ file_uri = "file://#{file_path}"
424
+
425
+ # Delegate to load_uri which handles imports and base_path
426
+ load_uri(file_uri)
427
+ end
428
+
429
+ # Load CSS from a URI and add to this stylesheet.
430
+ #
431
+ # @param uri [String] URI to load CSS from (http://, https://, or file://)
432
+ # @param options [Hash] Additional options
433
+ # @return [self] Returns self for method chaining
434
+ def load_uri(uri, options = {})
435
+ require 'uri'
436
+ require 'net/http'
437
+
438
+ uri_obj = URI(uri)
439
+ css_content = nil
440
+ file_path = nil
441
+
442
+ case uri_obj.scheme
443
+ when 'http', 'https'
444
+ response = Net::HTTP.get_response(uri_obj)
445
+ unless response.is_a?(Net::HTTPSuccess)
446
+ raise IOError, "Failed to load URI: #{uri} (#{response.code} #{response.message})"
447
+ end
448
+
449
+ css_content = response.body
450
+ when 'file', nil
451
+ # file:// URI or relative path
452
+ path = uri_obj.scheme == 'file' ? uri_obj.path : uri
453
+ # Handle base_uri if provided
454
+ if options[:base_uri]
455
+ base = URI(options[:base_uri])
456
+ path = File.join(base.path, path) if base.scheme == 'file' || base.scheme.nil?
457
+ end
458
+ file_path = File.expand_path(path)
459
+
460
+ # If imports are enabled and base_path not already set, set it for resolving relative imports
461
+ if @options[:import].is_a?(Hash) && @options[:import][:base_path].nil?
462
+ file_dir = File.dirname(file_path)
463
+ @options[:import] = @options[:import].merge(base_path: file_dir)
464
+ end
465
+
466
+ css_content = File.read(file_path)
467
+ else
468
+ raise ArgumentError, "Unsupported URI scheme: #{uri_obj.scheme}"
469
+ end
470
+
471
+ add_block(css_content)
472
+ self
473
+ rescue Errno::ENOENT
474
+ raise IOError, "File not found: #{uri}" if @options[:io_exceptions]
475
+
476
+ self
477
+ rescue StandardError => e
478
+ raise IOError, "Error loading URI: #{uri} - #{e.message}" if @options[:io_exceptions]
479
+
480
+ self
481
+ end
482
+
483
+ # Remove rules matching criteria
484
+ #
485
+ # @param selector [String, nil] Selector to match (nil matches all)
486
+ # @param media_types [Symbol, Array<Symbol>, nil] Media types to filter by (nil matches all)
487
+ # @return [self] Returns self for method chaining
488
+ #
489
+ # @example Remove all rules with a specific selector
490
+ # sheet.remove_rules!(selector: '.header')
491
+ #
492
+ # @example Remove rules from specific media type
493
+ # sheet.remove_rules!(selector: '.header', media_types: :screen)
494
+ #
495
+ # @example Remove all rules from a media type
496
+ # sheet.remove_rules!(media_types: :print)
497
+ def remove_rules!(selector: nil, media_types: nil)
498
+ # Normalize media_types to array
499
+ filter_media = media_types ? Array(media_types).map(&:to_sym) : nil
500
+
501
+ # Find rules to remove
502
+ rules_to_remove = Set.new
503
+ @rules.each_with_index do |rule, rule_id|
504
+ # Check selector match
505
+ next if selector && rule.selector != selector
506
+
507
+ # Check media type match
508
+ if filter_media
509
+ rule_media_types = @_media_index.select { |_media, ids| ids.include?(rule_id) }.keys
510
+ # Extract individual media types from complex queries
511
+ individual_types = rule_media_types.flat_map { |key| Cataract.parse_media_types(key) }.uniq
512
+
513
+ # If rule is not in any media query (base rule), skip if filtering by media
514
+ if individual_types.empty?
515
+ next unless filter_media.include?(:all)
516
+ else
517
+ # Check if rule's media types intersect with filter
518
+ next unless individual_types.intersect?(filter_media)
519
+ end
520
+ end
521
+
522
+ rules_to_remove << rule_id
523
+ end
524
+
525
+ # Remove rules and update media_index
526
+ rules_to_remove.sort.reverse_each do |rule_id|
527
+ @rules.delete_at(rule_id)
528
+
529
+ # Remove from media_index and update IDs for rules after this one
530
+ @_media_index.each_value do |ids|
531
+ ids.delete(rule_id)
532
+ # Decrement IDs greater than removed ID
533
+ ids.map! { |id| id > rule_id ? id - 1 : id }
534
+ end
535
+ end
536
+
537
+ # Clean up empty media_index entries
538
+ @_media_index.delete_if { |_media, ids| ids.empty? }
539
+
540
+ # Update rule IDs in remaining rules
541
+ @rules.each_with_index { |rule, new_id| rule.id = new_id }
542
+
543
+ # Clear memoized cache
544
+ @selectors = nil
545
+
546
+ self
547
+ end
548
+
549
+ # Add CSS block to stylesheet
550
+ #
551
+ # @param css [String] CSS string to add
552
+ # @param fix_braces [Boolean] Automatically close missing braces
553
+ # @param media_types [Symbol, Array<Symbol>] Optional media query to wrap CSS in
554
+ # @return [self] Returns self for method chaining
555
+ # TODO: Move to C?
556
+ def add_block(css, fix_braces: false, media_types: nil)
557
+ css += ' }' if fix_braces && !css.strip.end_with?('}')
558
+
559
+ # Convenience wrapper: wrap in @media if media_types specified
560
+ if media_types
561
+ media_list = Array(media_types).join(', ')
562
+ css = "@media #{media_list} { #{css} }"
563
+ end
564
+
565
+ # Resolve @import statements if configured in constructor
566
+ css_to_parse = if @options[:import]
567
+ ImportResolver.resolve(css, @options[:import])
568
+ else
569
+ css
570
+ end
571
+
572
+ # Get current rule ID offset
573
+ offset = @_last_rule_id || 0
574
+
575
+ # Parse CSS with C function (returns hash)
576
+ result = Cataract._parse_css(css_to_parse)
577
+
578
+ # Merge rules with offsetted IDs
579
+ new_rules = result[:rules]
580
+ new_rules.each do |rule|
581
+ rule.id += offset
582
+ @rules << rule
583
+ end
584
+
585
+ # Merge media_index with offsetted IDs
586
+ result[:_media_index].each do |media_sym, rule_ids|
587
+ offsetted_ids = rule_ids.map { |id| id + offset }
588
+ if @_media_index[media_sym]
589
+ @_media_index[media_sym].concat(offsetted_ids)
590
+ else
591
+ @_media_index[media_sym] = offsetted_ids
592
+ end
593
+ end
594
+
595
+ # Update last rule ID
596
+ @_last_rule_id = offset + new_rules.length
597
+
598
+ # Set charset if not already set
599
+ @charset ||= result[:charset]
600
+
601
+ # Track if we have any nesting (for serialization optimization)
602
+ @_has_nesting = result[:_has_nesting]
603
+
604
+ self
605
+ end
606
+
607
+ # Add a single rule
608
+ #
609
+ # @param selector [String] CSS selector
610
+ # @param declarations [String] CSS declarations (property: value pairs)
611
+ # @param media_types [Symbol, Array<Symbol>] Optional media types to wrap rule in
612
+ # @return [self] Returns self for method chaining
613
+ def add_rule(selector:, declarations:, media_types: nil)
614
+ # Wrap in CSS syntax and add as block
615
+ css = "#{selector} { #{declarations} }"
616
+ add_block(css, media_types: media_types)
617
+ end
618
+
619
+ # Convert to hash
620
+ #
621
+ # @return [Hash] Hash representation
622
+ def to_h
623
+ {
624
+ rules: @rules,
625
+ charset: @charset
626
+ }
627
+ end
628
+
629
+ def inspect
630
+ total_rules = size
631
+ if total_rules.zero?
632
+ '#<Cataract::Stylesheet empty>'
633
+ else
634
+ preview = @rules.first(3).map(&:selector).join(', ')
635
+ more = total_rules > 3 ? ', ...' : ''
636
+ "#<Cataract::Stylesheet #{total_rules} rules: #{preview}#{more}>"
637
+ end
638
+ end
639
+
640
+ # Merge all rules in this stylesheet according to CSS cascade rules
641
+ #
642
+ # Applies specificity and !important precedence rules to compute the final
643
+ # set of declarations. Also recreates shorthand properties from longhand
644
+ # properties where possible.
645
+ #
646
+ # @return [Stylesheet] New stylesheet with a single merged rule
647
+ def merge
648
+ # C function handles everything - returns new Stylesheet
649
+ Cataract.merge(self)
650
+ end
651
+
652
+ # Merge rules in-place, mutating the receiver.
653
+ #
654
+ # This is a convenience method that updates the stylesheet's internal
655
+ # rules and media_index with the merged result. The Stylesheet object
656
+ # itself is mutated (same object_id), but note that the C merge function
657
+ # still allocates new arrays internally.
658
+ #
659
+ # @return [self] Returns self for method chaining
660
+ def merge!
661
+ merged = Cataract.merge(self)
662
+ @rules = merged.instance_variable_get(:@rules)
663
+ @_media_index = merged.instance_variable_get(:@_media_index)
664
+ @_has_nesting = merged.instance_variable_get(:@_has_nesting)
665
+ self
666
+ end
667
+
668
+ private
669
+
670
+ # @private
671
+ # Internal index mapping media query symbols to rule IDs for efficient filtering.
672
+ # This is an implementation detail and should not be relied upon by external code.
673
+ # @return [Hash<Symbol, Array<Integer>>]
674
+ attr_reader :_media_index
675
+
676
+ # Check if a rule matches any of the requested media queries
677
+ #
678
+ # @param rule_id [Integer] Rule ID to check
679
+ # @param query_media [Array<Symbol>] Media types to match
680
+ # @return [Boolean] true if rule appears in any of the requested media index entries
681
+ def rule_matches_media?(rule_id, query_media)
682
+ query_media.any? { |m| @_media_index[m]&.include?(rule_id) }
683
+ end
684
+
685
+ # Check if a rule matches the specificity filter
686
+ #
687
+ # @param rule [Rule] Rule to check
688
+ # @param specificity [Integer, Range] Specificity filter
689
+ # @return [Boolean] true if rule matches specificity
690
+ def rule_matches_specificity?(rule, specificity)
691
+ # Skip rules with nil specificity (e.g., AtRule)
692
+ return false if rule.specificity.nil?
693
+
694
+ case specificity
695
+ when Range
696
+ specificity.cover?(rule.specificity)
697
+ else
698
+ specificity == rule.specificity
699
+ end
700
+ end
701
+
702
+ # Check if a rule has a declaration matching property and/or value
703
+ #
704
+ # @param rule [Rule] Rule to check (AtRule filtered out by each_selector)
705
+ # @param property [String, nil] Property name to match
706
+ # @param property_value [String, nil] Property value to match
707
+ # @return [Boolean] true if rule has matching declaration
708
+ def rule_matches_property?(rule, property, property_value)
709
+ rule.declarations.any? do |decl|
710
+ property_matches = property.nil? || decl.property == property
711
+ value_matches = property_value.nil? || decl.value == property_value
712
+ property_matches && value_matches
713
+ end
714
+ end
715
+ end
716
+ end